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
+295
View File
@@ -19,6 +19,7 @@ from ccma.domain.contributions import (
claim_total,
credit_allocated_total,
decimal_value,
donation_balance,
materialize_claim_items,
money_text,
payment_allocated_total,
@@ -1099,6 +1100,55 @@ class MemberRepository:
)
return payment
def create_payment(
self,
member_id: str,
*,
payment_date: str,
amount: str,
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."""
self.get_member(member_id)
try:
normalized_date = normalize_date_input(payment_date, "Zahlungsdatum")
selected_amount = decimal_value(amount)
except (DateValidationError, ValueError) as exc:
raise RepositoryError(str(exc)) from exc
if not normalized_date:
raise RepositoryError("Zahlungsdatum ist erforderlich.")
if selected_amount <= 0:
raise RepositoryError("Der Zahlungsbetrag muss größer als null sein.")
gnucash_id = gnucash_transaction_id.strip()
if gnucash_id:
self._assert_gnucash_id_available(gnucash_id)
payment = {
"payment_id": str(uuid4()),
"date": normalized_date,
"amount": money_text(selected_amount),
"method": method.strip() or "bank_transfer",
"gnucash_transaction_id": gnucash_id,
"reference": reference.strip(),
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
}
data = self.get_contributions(member_id)
data.payments.append(payment)
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="payment_recorded",
summary=f"Zahlung erfasst: {payment['amount']} EUR",
actor_type="user",
actor_name=actor_name,
references={"payment_id": str(payment["payment_id"])},
data={"allocation_amount": "0.00"},
)
return payment
def allocate_payment(self, member_id: str, claim_id: str, *, payment_id: str, amount: str) -> dict:
data, claim = self.get_claim(member_id, claim_id)
payment = next(
@@ -1355,6 +1405,220 @@ class MemberRepository:
)
return allocation
def record_donation(
self,
member_id: str,
*,
donation_date: str,
amount: str,
reference: str = "",
purpose: str = "",
actor_name: str = "Vorstand",
) -> dict:
self.get_member(member_id)
try:
normalized_date = normalize_date_input(donation_date, "Spendendatum")
selected_amount = decimal_value(amount, "Spendenbetrag")
except (DateValidationError, ValueError) as exc:
raise RepositoryError(str(exc)) from exc
if not normalized_date:
raise RepositoryError("Ein Spendendatum ist erforderlich.")
if selected_amount <= 0:
raise RepositoryError("Der Spendenbetrag muss größer als null sein.")
donation = {
"donation_id": str(uuid4()),
"date": normalized_date,
"amount": money_text(selected_amount),
"reference": reference.strip(),
"purpose": purpose.strip(),
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
}
data = self.get_contributions(member_id)
data.donations.append(donation)
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="donation_recorded",
summary=f"Spende erfasst: {donation['amount']} EUR",
actor_type="user",
actor_name=actor_name,
references={"donation_id": donation["donation_id"]},
)
return donation
def get_donation(self, member_id: str, donation_id: str) -> tuple[ContributionData, dict]:
data = self.get_contributions(member_id)
donation = next(
(item for item in data.donations if str(item.get("donation_id", "")) == donation_id),
None,
)
if donation is None:
raise RepositoryError(f"Spende nicht gefunden: {donation_id}")
return data, donation
def update_donation(
self,
member_id: str,
donation_id: str,
*,
donation_date: str,
amount: str,
reference: str = "",
purpose: str = "",
actor_name: str = "Vorstand",
) -> dict:
try:
normalized_date = normalize_date_input(donation_date, "Spendendatum")
selected_amount = decimal_value(amount, "Spendenbetrag")
except (DateValidationError, ValueError) as exc:
raise RepositoryError(str(exc)) from exc
if not normalized_date:
raise RepositoryError("Ein Spendendatum ist erforderlich.")
if selected_amount <= 0:
raise RepositoryError("Der Spendenbetrag muss größer als null sein.")
data, donation = self.get_donation(member_id, donation_id)
allocated = sum(
(
decimal_value(item.get("amount", "0"))
for item in data.allocations
if str(item.get("donation_id", "")) == donation_id
),
Decimal("0"),
)
if selected_amount < allocated:
raise RepositoryError(
f"Der Betrag darf nicht unter den bereits zugeordneten "
f"{money_text(allocated)} EUR liegen."
)
donation["date"] = normalized_date
donation["amount"] = money_text(selected_amount)
donation["reference"] = reference.strip()
donation["purpose"] = purpose.strip()
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="donation_changed",
summary=f"Spende geändert: {donation['amount']} EUR",
actor_type="user",
actor_name=actor_name,
references={"donation_id": donation_id},
)
return donation
def delete_donation(self, member_id: str, donation_id: str, *, actor_name: str = "Vorstand") -> None:
"""Permanently remove a donation. Payments allocated to it are released, not deleted."""
data, donation = self.get_donation(member_id, donation_id)
data.donations = [
item for item in data.donations if str(item.get("donation_id", "")) != donation_id
]
data.allocations = [
item for item in data.allocations if str(item.get("donation_id", "")) != donation_id
]
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="donation_deleted",
summary=f"Spende gelöscht: {donation.get('amount', '')} EUR",
actor_type="user",
actor_name=actor_name,
references={"donation_id": donation_id},
)
def record_donation_payment(
self,
member_id: str,
donation_id: str,
*,
payment_date: str,
amount: str,
allocation_amount: str,
gnucash_transaction_id: str = "",
reference: str = "",
) -> dict:
try:
normalized_date = normalize_date_input(payment_date, "Zahlungsdatum")
selected_amount = decimal_value(amount)
selected_allocation = decimal_value(allocation_amount, "Zuordnung")
except (DateValidationError, ValueError) as exc:
raise RepositoryError(str(exc)) from exc
if not normalized_date:
raise RepositoryError("Zahlungsdatum ist erforderlich.")
if selected_amount <= 0:
raise RepositoryError("Der Zahlungsbetrag muss größer als null sein.")
if selected_allocation <= 0 or selected_allocation > selected_amount:
raise RepositoryError(
"Die Zuordnung muss größer als null und höchstens so hoch wie die Zahlung sein."
)
gnucash_id = gnucash_transaction_id.strip()
if gnucash_id:
self._assert_gnucash_id_available(gnucash_id)
data, donation = self.get_donation(member_id, donation_id)
available_balance = max(donation_balance(data, donation), Decimal("0"))
if selected_allocation > available_balance:
raise RepositoryError(f"Die Spende hat nur noch {money_text(available_balance)} EUR offen.")
payment = {
"payment_id": str(uuid4()),
"date": normalized_date,
"amount": money_text(selected_amount),
"method": "bank_transfer",
"gnucash_transaction_id": gnucash_id,
"reference": reference.strip(),
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
}
allocation = {
"allocation_id": str(uuid4()),
"payment_id": payment["payment_id"],
"donation_id": donation_id,
"amount": money_text(selected_allocation),
}
data.payments.append(payment)
data.allocations.append(allocation)
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="payment_recorded",
summary=f"Zahlung für Spende eingegangen: {payment['amount']} EUR",
references={"donation_id": donation_id, "payment_id": str(payment["payment_id"])},
data={"allocation_amount": allocation["amount"]},
)
return payment
def allocate_payment_to_donation(
self, member_id: str, donation_id: str, *, payment_id: str, amount: str
) -> dict:
data, donation = self.get_donation(member_id, donation_id)
payment = next(
(item for item in data.payments if str(item.get("payment_id", "")) == payment_id),
None,
)
if payment is None:
raise RepositoryError("Zahlung nicht gefunden.")
try:
selected_amount = decimal_value(amount, "Zuordnung")
available = decimal_value(payment.get("amount", "0")) - payment_allocated_total(data, payment_id)
except ValueError as exc:
raise RepositoryError(str(exc)) from exc
if selected_amount <= 0 or selected_amount > available:
raise RepositoryError(f"Es sind nur {money_text(available)} EUR dieser Zahlung verfügbar.")
available_balance = max(donation_balance(data, donation), Decimal("0"))
if selected_amount > available_balance:
raise RepositoryError(f"Die Spende hat nur noch {money_text(available_balance)} EUR offen.")
allocation = {
"allocation_id": str(uuid4()),
"payment_id": payment_id,
"donation_id": donation_id,
"amount": money_text(selected_amount),
}
data.allocations.append(allocation)
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="payment_allocated",
summary=f"Zahlung Spende zugeordnet: {allocation['amount']} EUR",
references={"donation_id": donation_id, "payment_id": payment_id},
)
return allocation
def create_reminder_draft(
self,
member_id: str,
@@ -1578,6 +1842,37 @@ class MemberRepository:
references={"claim_id": claim_id},
)
def delete_claim(self, member_id: str, claim_id: str, *, actor_name: str = "Vorstand") -> None:
"""Permanently remove a claim. Any payments/credits allocated to it are released
(kept intact, just unlinked) rather than deleted, so they can be reallocated."""
data, claim = self.get_claim(member_id, claim_id)
released_allocations = [
allocation for allocation in data.allocations if str(allocation.get("claim_id", "")) == claim_id
]
released_total = sum(
(decimal_value(item.get("amount", "0")) for item in released_allocations), Decimal("0")
)
data.claims = [item for item in data.claims if str(item.get("claim_id", "")) != claim_id]
data.allocations = [
item for item in data.allocations if str(item.get("claim_id", "")) != claim_id
]
data.reminders = [
item for item in data.reminders if str(item.get("claim_id", "")) != claim_id
]
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="claim_deleted",
summary=f"Forderung gelöscht: {claim.get('title', claim_id)}",
actor_type="user",
actor_name=actor_name,
references={"claim_id": claim_id},
data={
"amount": money_text(claim_total(claim)),
"released_allocations": money_text(released_total),
},
)
def _assert_gnucash_id_available(
self, transaction_id: str, *, exclude_payment_id: str | None = None
) -> None: