Allow deleting claims, creating standalone payments, and add a donations tab

Claims could previously only be cancelled (stornieren), which blocks once a
payment is allocated. Add a hard delete that releases any linked
payments/credits back to being unallocated instead of destroying them.

Payments could only be created from within a claim, forcing immediate
allocation. Add a bare payment creation flow in the Zahlungen tab so
incoming transfers can be logged first and allocated later.

Add a per-member Spenden tab (donations, backed by a new donations list on
ContributionData) so amounts paid beyond the membership fee can be tracked
and existing free payments allocated to them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marcel Peterkau
2026-08-14 21:02:51 +02:00
co-authored by Claude Sonnet 5
parent a61ea3cb57
commit 42fb4c4224
8 changed files with 960 additions and 2 deletions
+130
View File
@@ -9,6 +9,9 @@ from ccma.domain.contributions import (
claim_settled_total,
claim_status,
claim_total,
donation_allocated_total,
donation_balance,
donation_status,
payment_allocated_total,
)
from ccma.domain.models import ContributionData
@@ -311,6 +314,133 @@ def test_claim_with_payment_cannot_be_cancelled(tmp_path) -> None:
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_gnucash_id_is_unique_across_member_store(tmp_path) -> None:
repository, first_member = _repository_with_claim(tmp_path / "store")
repository.record_payment(