Allocate open claims and donations directly from the payment dialog

Recording a payment previously meant saving it bare, then separately
opening a claim to allocate money to it. Zahlung anlegen now lists a
member's open claims and donations right in the same dialog with a
select + amount field to assign parts of the payment on the spot, and a
"Neue Spende anlegen" button to create a donation inline and allocate to
it immediately -- covering members who pay more than the membership fee
in one transfer.

Shared the same allocation table in the existing payment-edit dialog so
editing a payment shows and preserves donation allocations too; before
this, saving an edited payment silently dropped any donation allocation
because update_payment only round-tripped claim allocations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marcel Peterkau
2026-08-14 21:22:08 +02:00
co-authored by Claude Sonnet 5
parent 42fb4c4224
commit a5d9abd59a
3 changed files with 530 additions and 163 deletions
+135 -6
View File
@@ -1106,13 +1106,16 @@ class MemberRepository:
*,
payment_date: str,
amount: str,
claim_allocations: dict[str, str] | None = None,
donation_allocations: dict[str, str] | None = None,
gnucash_transaction_id: str = "",
reference: str = "",
method: str = "bank_transfer",
actor_name: str = "Vorstand",
) -> dict:
"""Record an incoming payment without allocating it yet. Useful for logging
a bank transfer as soon as it arrives, to be assigned to claims or donations later."""
"""Record an incoming payment, optionally allocating parts of it immediately to
open claims and/or donations. Called with no allocations, this just logs a bank
transfer as soon as it arrives, to be assigned to claims or donations later."""
self.get_member(member_id)
try:
normalized_date = normalize_date_input(payment_date, "Zahlungsdatum")
@@ -1126,6 +1129,56 @@ class MemberRepository:
gnucash_id = gnucash_transaction_id.strip()
if gnucash_id:
self._assert_gnucash_id_available(gnucash_id)
data = self.get_contributions(member_id)
claims_by_id = {str(claim.get("claim_id", "")): claim for claim in data.claims}
donations_by_id = {str(item.get("donation_id", "")): item for item in data.donations}
selected_claim_allocations: dict[str, Decimal] = {}
for claim_id, raw_amount in (claim_allocations or {}).items():
claim = claims_by_id.get(claim_id)
if claim is None:
raise RepositoryError(f"Forderung nicht gefunden: {claim_id}")
if str(claim.get("status", "")) == "cancelled":
raise RepositoryError("Eine stornierte Forderung kann nicht bezahlt werden.")
try:
selected = decimal_value(raw_amount, "Zuordnung")
except ValueError as exc:
raise RepositoryError(str(exc)) from exc
if selected <= 0:
continue
available = max(claim_balance(data, claim), Decimal("0"))
if selected > available:
raise RepositoryError(
f"{claim.get('title', 'Forderung')} hat nur {money_text(available)} EUR offen."
)
selected_claim_allocations[claim_id] = selected
selected_donation_allocations: dict[str, Decimal] = {}
for donation_id, raw_amount in (donation_allocations or {}).items():
donation = donations_by_id.get(donation_id)
if donation is None:
raise RepositoryError(f"Spende nicht gefunden: {donation_id}")
try:
selected = decimal_value(raw_amount, "Zuordnung")
except ValueError as exc:
raise RepositoryError(str(exc)) from exc
if selected <= 0:
continue
available = max(donation_balance(data, donation), Decimal("0"))
if selected > available:
raise RepositoryError(f"Die Spende hat nur noch {money_text(available)} EUR offen.")
selected_donation_allocations[donation_id] = selected
allocated_total_amount = sum(selected_claim_allocations.values(), Decimal("0")) + sum(
selected_donation_allocations.values(), Decimal("0")
)
if allocated_total_amount > selected_amount:
raise RepositoryError(
f"Die Zuordnungen ({money_text(allocated_total_amount)} EUR) übersteigen den "
f"Zahlungsbetrag ({money_text(selected_amount)} EUR)."
)
payment = {
"payment_id": str(uuid4()),
"date": normalized_date,
@@ -1135,7 +1188,24 @@ class MemberRepository:
"reference": reference.strip(),
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
}
data = self.get_contributions(member_id)
for claim_id, claim_amount in selected_claim_allocations.items():
data.allocations.append(
{
"allocation_id": str(uuid4()),
"payment_id": payment["payment_id"],
"claim_id": claim_id,
"amount": money_text(claim_amount),
}
)
for donation_id, donation_amount in selected_donation_allocations.items():
data.allocations.append(
{
"allocation_id": str(uuid4()),
"payment_id": payment["payment_id"],
"donation_id": donation_id,
"amount": money_text(donation_amount),
}
)
data.payments.append(payment)
self.save_contributions(member_id, data)
self.append_event(
@@ -1145,7 +1215,11 @@ class MemberRepository:
actor_type="user",
actor_name=actor_name,
references={"payment_id": str(payment["payment_id"])},
data={"allocation_amount": "0.00"},
data={
"allocated_amount": money_text(allocated_total_amount),
"claim_ids": list(selected_claim_allocations),
"donation_ids": list(selected_donation_allocations),
},
)
return payment
@@ -1193,9 +1267,13 @@ class MemberRepository:
payment_date: str,
amount: str,
allocations: dict[str, str],
donation_allocations: dict[str, str] | None = None,
gnucash_transaction_id: str = "",
reference: str = "",
) -> dict:
"""`allocations` fully replaces the claim allocations of this payment, and
`donation_allocations` (if given) fully replaces its donation allocations. Omit
`donation_allocations` to leave any existing donation allocations untouched."""
data = self.get_contributions(member_id)
payment = next(
(item for item in data.payments if str(item.get("payment_id", "")) == payment_id),
@@ -1214,10 +1292,15 @@ class MemberRepository:
raise RepositoryError("Der Zahlungsbetrag muss größer als null sein.")
claims_by_id = {str(claim.get("claim_id", "")): claim for claim in data.claims}
donations_by_id = {str(item.get("donation_id", "")): item for item in data.donations}
old_allocations = [item for item in data.allocations if str(item.get("payment_id", "")) == payment_id]
old_by_claim: dict[str, list[dict]] = {}
old_by_donation: dict[str, list[dict]] = {}
for allocation in old_allocations:
old_by_claim.setdefault(str(allocation.get("claim_id", "")), []).append(allocation)
if str(allocation.get("donation_id", "")):
old_by_donation.setdefault(str(allocation.get("donation_id", "")), []).append(allocation)
else:
old_by_claim.setdefault(str(allocation.get("claim_id", "")), []).append(allocation)
selected_allocations: dict[str, Decimal] = {}
for claim_id, raw_amount in allocations.items():
@@ -1247,7 +1330,43 @@ class MemberRepository:
)
selected_allocations[claim_id] = allocation_amount
allocated_sum = sum(selected_allocations.values(), Decimal("0"))
if donation_allocations is None:
selected_donation_allocations = {
donation_id: sum(
(decimal_value(item.get("amount", "0")) for item in items), Decimal("0")
)
for donation_id, items in old_by_donation.items()
}
else:
selected_donation_allocations = {}
for donation_id, raw_amount in donation_allocations.items():
if donation_id not in donations_by_id:
raise RepositoryError(f"Spende nicht gefunden: {donation_id}")
try:
donation_amount = decimal_value(raw_amount, "Zuordnung")
except ValueError as exc:
raise RepositoryError(str(exc)) from exc
if donation_amount < 0:
raise RepositoryError("Zuordnungen dürfen nicht negativ sein.")
if donation_amount == 0:
continue
donation = donations_by_id[donation_id]
currently_allocated = sum(
(decimal_value(item.get("amount", "0")) for item in old_by_donation.get(donation_id, [])),
Decimal("0"),
)
available_balance = max(
donation_balance(data, donation) + currently_allocated, Decimal("0")
)
if donation_amount > available_balance:
raise RepositoryError(
f"Die Spende hat nur noch {money_text(available_balance)} EUR offen."
)
selected_donation_allocations[donation_id] = donation_amount
allocated_sum = sum(selected_allocations.values(), Decimal("0")) + sum(
selected_donation_allocations.values(), Decimal("0")
)
if allocated_sum > selected_amount:
raise RepositoryError(
f"Die Zuordnungen ({money_text(allocated_sum)} EUR) übersteigen den "
@@ -1281,6 +1400,16 @@ class MemberRepository:
"amount": money_text(allocation_amount),
}
)
for donation_id, donation_amount in selected_donation_allocations.items():
prior = old_by_donation.get(donation_id, [])
new_allocations.append(
{
"allocation_id": (str(prior[0].get("allocation_id", "")) if prior else str(uuid4())),
"payment_id": payment_id,
"donation_id": donation_id,
"amount": money_text(donation_amount),
}
)
data.allocations = retained_allocations + new_allocations
self.save_contributions(member_id, data)
self.append_event(