mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 06:55:17 +02:00
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:
co-authored by
Claude Sonnet 5
parent
42fb4c4224
commit
a5d9abd59a
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user