Files
CCMA/tests/test_contributions.py
T

348 lines
11 KiB
Python

from decimal import Decimal
import pytest
from ccma.domain.contributions import (
allocated_total,
claim_balance,
claim_items,
claim_settled_total,
claim_status,
claim_total,
payment_allocated_total,
)
from ccma.domain.models import ContributionData
from ccma.storage.repository import MemberRepository, RepositoryError
def _repository_with_claim(tmp_path, *, amount="100.00", claim_id="claim-1"):
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Claim", last_name="Test")
repository.save_contributions(
member.member_id,
ContributionData(
claims=[
{
"claim_id": claim_id,
"claim_key": "test-claim",
"title": "Testforderung",
"amount": amount,
"due_date": "2026-12-31",
"status": "open",
}
]
),
)
return repository, member
def test_legacy_claim_becomes_itemized_when_position_is_added(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
data, claim = repository.get_claim(member.member_id, "claim-1")
assert claim_total(claim) == Decimal("100.00")
assert claim_items(claim)[0]["item_id"] == "legacy-base"
repository.add_claim_item(
member.member_id,
"claim-1",
description="Gutschrift",
quantity="1",
unit_price="-10,00",
item_type="credit",
)
data, claim = repository.get_claim(member.member_id, "claim-1")
assert len(claim["items"]) == 2
assert claim_total(claim) == Decimal("90.00")
assert claim["amount"] == "90.00"
assert claim_balance(data, claim) == Decimal("90.00")
def test_claim_base_data_can_be_edited(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
repository.update_claim(
member.member_id,
"claim-1",
title="Korrigierter Beitrag",
due_date="30.06.2026",
base_amount="75,00",
description="Beitrag für sechs Monate",
)
_data, claim = repository.get_claim(member.member_id, "claim-1")
assert claim["title"] == "Korrigierter Beitrag"
assert claim["due_date"] == "2026-06-30"
assert claim["amount"] == "75.00"
assert claim["items"][0]["description"] == "Beitrag für sechs Monate"
assert claim["items"][0]["amount"] == "75.00"
assert claim["calculation"]["manual_override"]["actor"] == "Vorstand"
assert repository.get_events(member.member_id)[-1].event_type == "claim_changed"
def test_claim_cannot_be_reduced_below_allocated_payment(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-06-21",
amount="80.00",
allocation_amount="80.00",
)
with pytest.raises(RepositoryError, match="bereits zugeordneten Betrag"):
repository.update_claim(
member.member_id,
"claim-1",
title="Zu klein",
due_date="2026-06-30",
base_amount="75.00",
description="",
)
def test_payment_can_be_split_across_multiple_claims(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
data = repository.get_contributions(member.member_id)
data.claims.append(
{
"claim_id": "claim-2",
"claim_key": "second-claim",
"title": "Zweite Forderung",
"amount": "50.00",
"due_date": "2026-12-31",
"status": "open",
}
)
repository.save_contributions(member.member_id, data)
payment = repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-06-21",
amount="150.00",
allocation_amount="100.00",
gnucash_transaction_id="TX-42",
reference="Jahreszahlung",
)
repository.allocate_payment(
member.member_id,
"claim-2",
payment_id=payment["payment_id"],
amount="50.00",
)
data, first = repository.get_claim(member.member_id, "claim-1")
_data, second = repository.get_claim(member.member_id, "claim-2")
assert claim_status(data, first) == "paid"
assert claim_status(data, second) == "paid"
assert payment_allocated_total(data, payment["payment_id"]) == Decimal("150.00")
with pytest.raises(RepositoryError, match="nur 0.00 EUR"):
repository.allocate_payment(
member.member_id,
"claim-2",
payment_id=payment["payment_id"],
amount="1.00",
)
def test_combined_dummy_payment_settles_multiple_claims(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
data = repository.get_contributions(member.member_id)
data.claims.append(
{
"claim_id": "claim-2",
"title": "Zweite Forderung",
"amount": "50.00",
"due_date": "2019-12-31",
"status": "open",
}
)
repository.save_contributions(member.member_id, data)
payment = repository.record_combined_payment(
member.member_id,
payment_date="2019-12-31",
allocations={"claim-1": "100.00", "claim-2": "50.00"},
method="dummy",
reference="Altbestand",
)
changed = repository.get_contributions(member.member_id)
assert payment["amount"] == "150.00"
assert payment["method"] == "dummy"
assert payment_allocated_total(changed, payment["payment_id"]) == Decimal("150.00")
assert all(claim_balance(changed, claim) == 0 for claim in changed.claims)
assert repository.get_events(member.member_id)[-1].data["method"] == "dummy"
def test_payment_allocation_cannot_overpay_claim(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path, amount="12.50")
with pytest.raises(RepositoryError, match="nur noch 12.50 EUR offen"):
repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-06-21",
amount="22.50",
allocation_amount="22.50",
)
def test_payment_can_be_edited_and_reallocated_atomically(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path, amount="12.50")
data = repository.get_contributions(member.member_id)
data.claims.append(
{
"claim_id": "admission-fee",
"claim_key": "admission-fee",
"title": "Aufnahmegebühr",
"amount": "10.00",
"due_date": "2026-12-31",
"status": "open",
}
)
repository.save_contributions(member.member_id, data)
payment = repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-06-21",
amount="22.50",
allocation_amount="12.50",
reference="Vorher",
)
repository.update_payment(
member.member_id,
payment["payment_id"],
payment_date="22.06.2026",
amount="22.50",
allocations={"claim-1": "12.50", "admission-fee": "10.00"},
reference="Korrigiert",
)
changed = repository.get_contributions(member.member_id)
assert changed.payments[0]["date"] == "2026-06-22"
assert changed.payments[0]["reference"] == "Korrigiert"
assert payment_allocated_total(changed, payment["payment_id"]) == Decimal("22.50")
assert {item["claim_id"] for item in changed.allocations} == {"claim-1", "admission-fee"}
assert repository.get_events(member.member_id)[-1].event_type == "payment_changed"
def test_payment_can_be_deleted_with_its_allocations(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
payment = repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-06-21",
amount="10.00",
allocation_amount="10.00",
)
repository.delete_payment(member.member_id, payment["payment_id"])
data = repository.get_contributions(member.member_id)
assert data.payments == []
assert data.allocations == []
assert repository.get_events(member.member_id)[-1].event_type == "payment_deleted"
def test_credit_claim_settlement_is_displayed_as_positive_amount() -> None:
claim = {"claim_id": "claim-1", "title": "Kautionsrückzahlung", "amount": "-25.00"}
data = ContributionData(
claims=[claim],
credits=[{"credit_id": "credit-1", "amount": "25.00"}],
allocations=[
{
"allocation_id": "allocation-1",
"claim_id": "claim-1",
"credit_id": "credit-1",
"amount": "25.00",
}
],
)
assert allocated_total(data, "claim-1") == Decimal("-25.00")
assert claim_settled_total(data, claim) == Decimal("25.00")
def test_reminder_fee_increases_claim_and_is_audited(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
reminder = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
detail="Per E-Mail versandt",
fee="5.00",
)
data, claim = repository.get_claim(member.member_id, "claim-1")
assert claim_total(claim) == Decimal("100.00")
assert reminder["status"] == "draft"
assert reminder["fee_item_id"] is None
repository.mark_reminder_sent(member.member_id, "claim-1", reminder["reminder_id"])
data, claim = repository.get_claim(member.member_id, "claim-1")
sent = data.reminders[0]
assert claim_total(claim) == Decimal("105.00")
assert sent["status"] == "sent"
assert sent["payment_deadline"]
assert sent["fee_item_id"]
assert data.reminders[0]["detail"] == "Per E-Mail versandt"
assert repository.get_events(member.member_id)[-1].event_type == "reminder_sent"
def test_claim_with_payment_cannot_be_cancelled(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-06-21",
amount="10.00",
allocation_amount="10.00",
)
with pytest.raises(RepositoryError, match="Zahlungszuordnungen"):
repository.cancel_claim(member.member_id, "claim-1")
def test_gnucash_id_is_unique_across_member_store(tmp_path) -> None:
repository, first_member = _repository_with_claim(tmp_path / "store")
repository.record_payment(
first_member.member_id,
"claim-1",
payment_date="2026-06-21",
amount="100.00",
allocation_amount="100.00",
gnucash_transaction_id="UNIQUE-1",
)
second = repository.create_member(first_name="Second", last_name="Member")
repository.save_contributions(
second.member_id,
ContributionData(
claims=[
{
"claim_id": "other-claim",
"title": "Andere Forderung",
"amount": "10.00",
"due_date": "2026-12-31",
}
]
),
)
with pytest.raises(RepositoryError, match="GnuCash-ID bereits verwendet"):
repository.record_payment(
second.member_id,
"other-claim",
payment_date="2026-06-21",
amount="10.00",
allocation_amount="10.00",
gnucash_transaction_id="unique-1",
)