from decimal import Decimal import pytest from ccma.domain.contributions import ( allocated_total, claim_balance, claim_items, claim_settled_total, claim_status, claim_total, donation_allocated_total, donation_balance, donation_status, 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_claim_can_be_deleted_and_releases_allocated_payment(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="60.00", allocation_amount="60.00", ) repository.delete_claim(member.member_id, "claim-1") data = repository.get_contributions(member.member_id) assert data.claims == [] assert data.allocations == [] assert data.payments[0]["payment_id"] == payment["payment_id"] assert payment_allocated_total(data, payment["payment_id"]) == Decimal("0.00") assert repository.get_events(member.member_id)[-1].event_type == "claim_deleted" with pytest.raises(RepositoryError, match="nicht gefunden"): repository.get_claim(member.member_id, "claim-1") def test_claim_with_payment_can_be_deleted_even_though_it_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") repository.delete_claim(member.member_id, "claim-1") assert repository.get_contributions(member.member_id).claims == [] def test_bare_payment_can_be_created_without_allocation(tmp_path) -> None: repository = MemberRepository(tmp_path) repository.initialize() member = repository.create_member(first_name="Payment", last_name="Test") payment = repository.create_payment( member.member_id, payment_date="2026-06-21", amount="42.00", reference="Überweisung ohne Zuordnung", ) data = repository.get_contributions(member.member_id) assert data.payments == [payment] assert data.allocations == [] assert payment_allocated_total(data, payment["payment_id"]) == Decimal("0.00") assert repository.get_events(member.member_id)[-1].event_type == "payment_recorded" def test_donation_can_be_recorded_paid_and_deleted_releases_payment(tmp_path) -> None: repository = MemberRepository(tmp_path) repository.initialize() member = repository.create_member(first_name="Donation", last_name="Test") donation = repository.record_donation( member.member_id, donation_date="2026-06-21", amount="30.00", reference="Sommerfest", purpose="Freiwillige Zusatzspende", ) data = repository.get_contributions(member.member_id) assert donation_status(data, donation) == "open" assert donation_balance(data, donation) == Decimal("30.00") payment = repository.record_donation_payment( member.member_id, donation["donation_id"], payment_date="2026-06-22", amount="30.00", allocation_amount="30.00", ) data = repository.get_contributions(member.member_id) assert donation_allocated_total(data, donation["donation_id"]) == Decimal("30.00") assert donation_status(data, donation) == "allocated" repository.delete_donation(member.member_id, donation["donation_id"]) data = repository.get_contributions(member.member_id) assert data.donations == [] assert data.allocations == [] assert data.payments[0]["payment_id"] == payment["payment_id"] assert payment_allocated_total(data, payment["payment_id"]) == Decimal("0.00") assert repository.get_events(member.member_id)[-1].event_type == "donation_deleted" def test_existing_free_payment_can_be_allocated_to_a_donation(tmp_path) -> None: repository = MemberRepository(tmp_path) repository.initialize() member = repository.create_member(first_name="Donation", last_name="Allocate") payment = repository.create_payment( member.member_id, payment_date="2026-06-21", amount="100.00", reference="Mitgliedsbeitrag plus Spende", ) donation = repository.record_donation( member.member_id, donation_date="2026-06-21", amount="20.00", reference="Aufrundung", ) repository.allocate_payment_to_donation( member.member_id, donation["donation_id"], payment_id=payment["payment_id"], amount="20.00" ) data = repository.get_contributions(member.member_id) assert donation_balance(data, donation) == Decimal("0.00") assert payment_allocated_total(data, payment["payment_id"]) == Decimal("20.00") with pytest.raises(RepositoryError, match="nur noch 0.00 EUR"): repository.allocate_payment_to_donation( member.member_id, donation["donation_id"], payment_id=payment["payment_id"], amount="1.00" ) def test_payment_can_be_created_with_immediate_claim_and_donation_allocation(tmp_path) -> None: repository, member = _repository_with_claim(tmp_path, amount="30.00") donation = repository.record_donation( member.member_id, donation_date="2026-06-21", amount="20.00", reference="Aufrundung" ) payment = repository.create_payment( member.member_id, payment_date="2026-06-21", amount="50.00", claim_allocations={"claim-1": "30.00"}, donation_allocations={donation["donation_id"]: "20.00"}, reference="Mitgliedsbeitrag plus Spende", ) data = repository.get_contributions(member.member_id) _data, claim = repository.get_claim(member.member_id, "claim-1") assert claim_balance(data, claim) == Decimal("0.00") assert donation_balance(data, donation) == Decimal("0.00") assert payment_allocated_total(data, payment["payment_id"]) == Decimal("50.00") def test_payment_creation_rejects_allocations_exceeding_amount(tmp_path) -> None: repository, member = _repository_with_claim(tmp_path, amount="30.00") with pytest.raises(RepositoryError, match="übersteigen den"): repository.create_payment( member.member_id, payment_date="2026-06-21", amount="10.00", claim_allocations={"claim-1": "30.00"}, ) def test_updating_payment_without_donation_allocations_preserves_existing_ones(tmp_path) -> None: repository = MemberRepository(tmp_path) repository.initialize() member = repository.create_member(first_name="Donation", last_name="Preserve") donation = repository.record_donation( member.member_id, donation_date="2026-06-21", amount="20.00", reference="Sommerfest" ) payment = repository.record_donation_payment( member.member_id, donation["donation_id"], payment_date="2026-06-21", amount="20.00", allocation_amount="20.00", ) repository.update_payment( member.member_id, payment["payment_id"], payment_date="22.06.2026", amount="20.00", allocations={}, reference="Korrigierte Referenz", ) data = repository.get_contributions(member.member_id) assert donation_allocated_total(data, donation["donation_id"]) == Decimal("20.00") assert data.payments[0]["reference"] == "Korrigierte Referenz" def test_updating_payment_with_donation_allocations_replaces_them(tmp_path) -> None: repository = MemberRepository(tmp_path) repository.initialize() member = repository.create_member(first_name="Donation", last_name="Replace") first_donation = repository.record_donation( member.member_id, donation_date="2026-06-21", amount="20.00", reference="Erste Spende" ) second_donation = repository.record_donation( member.member_id, donation_date="2026-06-21", amount="20.00", reference="Zweite Spende" ) payment = repository.record_donation_payment( member.member_id, first_donation["donation_id"], payment_date="2026-06-21", amount="20.00", allocation_amount="20.00", ) repository.update_payment( member.member_id, payment["payment_id"], payment_date="2026-06-21", amount="20.00", allocations={}, donation_allocations={second_donation["donation_id"]: "20.00"}, ) data = repository.get_contributions(member.member_id) assert donation_allocated_total(data, first_donation["donation_id"]) == Decimal("0.00") assert donation_allocated_total(data, second_donation["donation_id"]) == Decimal("20.00") 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", )