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
+39
View File
@@ -18,6 +18,13 @@ CLAIM_STATUS_LABELS = {
"cancelled": "STORNIERT", "cancelled": "STORNIERT",
} }
DONATION_STATUS_LABELS = {
"open": "OFFEN",
"partially_allocated": "TEILWEISE ZUGEORDNET",
"allocated": "ZUGEORDNET",
"overallocated": "ÜBERZUGEORDNET",
}
def decimal_value(value: Any, field_name: str = "Betrag") -> Decimal: def decimal_value(value: Any, field_name: str = "Betrag") -> Decimal:
text = str(value).strip().replace(",", ".") text = str(value).strip().replace(",", ".")
@@ -109,6 +116,38 @@ def claim_balance(data: ContributionData, claim: dict[str, Any]) -> Decimal:
return (claim_total(claim) - allocated_total(data, str(claim.get("claim_id", "")))).quantize(CENT) return (claim_total(claim) - allocated_total(data, str(claim.get("claim_id", "")))).quantize(CENT)
def donation_allocated_total(data: ContributionData, donation_id: str) -> Decimal:
return sum(
(
decimal_value(allocation.get("amount", "0"))
for allocation in data.allocations
if str(allocation.get("donation_id", "")) == donation_id
),
Decimal("0"),
)
def donation_amount(donation: dict[str, Any]) -> Decimal:
return decimal_value(donation.get("amount", "0"))
def donation_balance(data: ContributionData, donation: dict[str, Any]) -> Decimal:
donation_id = str(donation.get("donation_id", ""))
return (donation_amount(donation) - donation_allocated_total(data, donation_id)).quantize(CENT)
def donation_status(data: ContributionData, donation: dict[str, Any]) -> str:
balance = donation_balance(data, donation)
allocated = donation_allocated_total(data, str(donation.get("donation_id", "")))
if balance < 0:
return "overallocated"
if balance == 0:
return "allocated"
if allocated > 0:
return "partially_allocated"
return "open"
def claim_status(data: ContributionData, claim: dict[str, Any], *, today: date | None = None) -> str: def claim_status(data: ContributionData, claim: dict[str, Any], *, today: date | None = None) -> str:
if str(claim.get("status", "")) == "cancelled": if str(claim.get("status", "")) == "cancelled":
return "cancelled" return "cancelled"
+3
View File
@@ -323,6 +323,7 @@ class ContributionData:
credits: list[dict[str, Any]] = field(default_factory=list) credits: list[dict[str, Any]] = field(default_factory=list)
allocations: list[dict[str, Any]] = field(default_factory=list) allocations: list[dict[str, Any]] = field(default_factory=list)
reminders: list[dict[str, Any]] = field(default_factory=list) reminders: list[dict[str, Any]] = field(default_factory=list)
donations: list[dict[str, Any]] = field(default_factory=list)
schema_version: int = 1 schema_version: int = 1
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
@@ -333,6 +334,7 @@ class ContributionData:
"credits": self.credits, "credits": self.credits,
"allocations": self.allocations, "allocations": self.allocations,
"reminders": self.reminders, "reminders": self.reminders,
"donations": self.donations,
} }
@classmethod @classmethod
@@ -344,6 +346,7 @@ class ContributionData:
credits=list(data.get("credits") or []), credits=list(data.get("credits") or []),
allocations=list(data.get("allocations") or []), allocations=list(data.get("allocations") or []),
reminders=list(data.get("reminders") or []), reminders=list(data.get("reminders") or []),
donations=list(data.get("donations") or []),
) )
+295
View File
@@ -19,6 +19,7 @@ from ccma.domain.contributions import (
claim_total, claim_total,
credit_allocated_total, credit_allocated_total,
decimal_value, decimal_value,
donation_balance,
materialize_claim_items, materialize_claim_items,
money_text, money_text,
payment_allocated_total, payment_allocated_total,
@@ -1099,6 +1100,55 @@ class MemberRepository:
) )
return payment 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: 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) data, claim = self.get_claim(member_id, claim_id)
payment = next( payment = next(
@@ -1355,6 +1405,220 @@ class MemberRepository:
) )
return allocation 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( def create_reminder_draft(
self, self,
member_id: str, member_id: str,
@@ -1578,6 +1842,37 @@ class MemberRepository:
references={"claim_id": claim_id}, 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( def _assert_gnucash_id_available(
self, transaction_id: str, *, exclude_payment_id: str | None = None self, transaction_id: str, *, exclude_payment_id: str | None = None
) -> None: ) -> None:
+21 -1
View File
@@ -92,7 +92,9 @@ class ClaimTab(ttk.Frame):
self.edit_button = ttk.Button(footer, text="Forderung bearbeiten", command=self._edit_claim) self.edit_button = ttk.Button(footer, text="Forderung bearbeiten", command=self._edit_claim)
self.edit_button.grid(row=0, column=1, sticky="e", padx=(0, 8)) self.edit_button.grid(row=0, column=1, sticky="e", padx=(0, 8))
self.cancel_button = ttk.Button(footer, text="Forderung stornieren", command=self._cancel_claim) self.cancel_button = ttk.Button(footer, text="Forderung stornieren", command=self._cancel_claim)
self.cancel_button.grid(row=0, column=2, sticky="e") self.cancel_button.grid(row=0, column=2, sticky="e", padx=(0, 8))
self.delete_button = ttk.Button(footer, text="Forderung löschen", command=self._delete_claim)
self.delete_button.grid(row=0, column=3, sticky="e")
def _build_ledger(self) -> None: def _build_ledger(self) -> None:
ledger = ttk.Frame(self, padding=12) ledger = ttk.Frame(self, padding=12)
@@ -503,6 +505,24 @@ class ClaimTab(ttk.Frame):
return return
self._changed() self._changed()
def _delete_claim(self) -> None:
allocated = allocated_total(self.data, self.claim_id)
detail = "Diese Forderung wirklich endgültig löschen? Das kann nicht rückgängig gemacht werden."
if allocated:
detail += (
f"\n\nZugeordnete Zahlungen/Gutschriften in Höhe von {money_text(allocated)} EUR "
"werden dabei gelöst und stehen danach wieder frei zur Verfügung."
)
if not messagebox.askyesno("Forderung löschen", detail, parent=self):
return
try:
self.repository.delete_claim(self.member_id, self.claim_id)
except RepositoryError as exc:
messagebox.showerror("Löschen fehlgeschlagen", str(exc), parent=self)
return
self.on_changed()
self.on_close()
def _edit_claim(self) -> None: def _edit_claim(self) -> None:
ClaimEditDialog( ClaimEditDialog(
self, self,
+223
View File
@@ -0,0 +1,223 @@
from __future__ import annotations
import tkinter as tk
from collections.abc import Callable
from datetime import date
from decimal import Decimal
from tkinter import messagebox, ttk
from ccma.domain.contributions import (
decimal_value,
money_text,
payment_allocated_total,
)
from ccma.domain.dates import date_input_hint, format_date_for_display
from ccma.storage.repository import MemberRepository, RepositoryError
class _Dialog(tk.Toplevel):
def __init__(self, master: tk.Misc, title: str, on_saved: Callable[[], None]):
super().__init__(master)
self.on_saved = on_saved
self.title(title)
self.transient(master.winfo_toplevel())
self.resizable(False, False)
self.frame = ttk.Frame(self, padding=18)
self.frame.pack(fill="both", expand=True)
self.bind("<Escape>", lambda _event: self.destroy())
self.after_idle(self.grab_set)
def _buttons(self, row: int, command: Callable[[], None]) -> None:
buttons = ttk.Frame(self.frame)
buttons.grid(row=row, column=0, columnspan=2, sticky="e", pady=(16, 0))
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
ttk.Button(buttons, text="Speichern", style="Accent.TButton", command=command).pack(side="left")
class DonationEditDialog(_Dialog):
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
member_id: str,
on_saved: Callable[[], None],
donation: dict | None = None,
):
super().__init__(
master, "Spende bearbeiten" if donation else "Spende anlegen", on_saved
)
self.repository = repository
self.member_id = member_id
self.donation_id = str(donation.get("donation_id")) if donation else None
initial_date = str(donation.get("date", "")) if donation else date.today().isoformat()
self.variables = {
"date": tk.StringVar(value=format_date_for_display(initial_date)),
"amount": tk.StringVar(value=str(donation.get("amount", "")) if donation else ""),
"reference": tk.StringVar(value=str(donation.get("reference", "")) if donation else ""),
"purpose": tk.StringVar(value=str(donation.get("purpose", "")) if donation else ""),
}
fields = (
(f"Spendendatum ({date_input_hint()})", "date"),
("Betrag", "amount"),
("Referenz", "reference"),
("Verwendungszweck", "purpose"),
)
self.frame.columnconfigure(1, weight=1)
for row, (label, key) in enumerate(fields):
ttk.Label(self.frame, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.variables[key], width=42).grid(
row=row, column=1, sticky="ew", pady=5
)
self._buttons(len(fields), self._save)
def _save(self) -> None:
try:
if self.donation_id:
self.repository.update_donation(
self.member_id,
self.donation_id,
donation_date=self.variables["date"].get(),
amount=self.variables["amount"].get(),
reference=self.variables["reference"].get(),
purpose=self.variables["purpose"].get(),
)
else:
self.repository.record_donation(
self.member_id,
donation_date=self.variables["date"].get(),
amount=self.variables["amount"].get(),
reference=self.variables["reference"].get(),
purpose=self.variables["purpose"].get(),
)
except RepositoryError as exc:
messagebox.showerror("Spende konnte nicht gespeichert werden", str(exc), parent=self)
return
self.destroy()
self.on_saved()
class DonationPaymentDialog(_Dialog):
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
member_id: str,
donation_id: str,
balance: Decimal,
on_saved: Callable[[], None],
):
super().__init__(master, "Zahlung für Spende erfassen", on_saved)
self.repository, self.member_id, self.donation_id = repository, member_id, donation_id
initial = money_text(max(balance, Decimal("0")))
self.variables = {
"date": tk.StringVar(value=format_date_for_display(date.today().isoformat())),
"amount": tk.StringVar(value=initial),
"allocation": tk.StringVar(value=initial),
"gnucash": tk.StringVar(),
"reference": tk.StringVar(),
}
fields = (
(f"Zahlungsdatum ({date_input_hint()})", "date"),
("Zahlungsbetrag", "amount"),
("Dieser Spende zuordnen", "allocation"),
("GnuCash-ID (optional)", "gnucash"),
("Referenz", "reference"),
)
self.frame.columnconfigure(1, weight=1)
for row, (label, key) in enumerate(fields):
ttk.Label(self.frame, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.variables[key], width=38).grid(row=row, column=1, pady=5)
self._buttons(len(fields), self._save)
def _save(self) -> None:
try:
self.repository.record_donation_payment(
self.member_id,
self.donation_id,
payment_date=self.variables["date"].get(),
amount=self.variables["amount"].get(),
allocation_amount=self.variables["allocation"].get(),
gnucash_transaction_id=self.variables["gnucash"].get(),
reference=self.variables["reference"].get(),
)
except RepositoryError as exc:
messagebox.showerror("Zahlung konnte nicht gespeichert werden", str(exc), parent=self)
return
self.destroy()
self.on_saved()
class AllocateDonationPaymentDialog(_Dialog):
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
member_id: str,
donation_id: str,
balance: Decimal,
on_saved: Callable[[], None],
):
super().__init__(master, "Vorhandene Zahlung zuordnen", on_saved)
self.repository, self.member_id, self.donation_id = repository, member_id, donation_id
data = repository.get_contributions(member_id)
self.payment_by_label = {}
for payment in sorted(
data.payments,
key=lambda item: (str(item.get("date", "")), str(item.get("created_at", ""))),
reverse=True,
):
payment_id = str(payment.get("payment_id", ""))
available = decimal_value(payment.get("amount", "0")) - payment_allocated_total(data, payment_id)
if available <= 0:
continue
label = (
f"{payment.get('date', '')} · {money_text(available)} EUR frei · "
f"{payment.get('reference', '')}"
)
self.payment_by_label[label] = (payment_id, available)
self.payment_var = tk.StringVar()
self.amount_var = tk.StringVar(value=money_text(max(balance, Decimal("0"))))
self.frame.columnconfigure(1, weight=1)
ttk.Label(self.frame, text="Zahlung").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
combo = ttk.Combobox(
self.frame,
textvariable=self.payment_var,
values=list(self.payment_by_label),
state="readonly",
width=60,
)
combo.grid(row=0, column=1, pady=5)
if not self.payment_by_label:
ttk.Label(
self.frame,
text="Für dieses Mitglied gibt es keine Zahlung mit freiem Restbetrag.",
style="Mono.TLabel",
).grid(row=1, column=0, columnspan=2, sticky="w", pady=(3, 5))
amount_row = 2
else:
amount_row = 1
ttk.Label(self.frame, text="Betrag").grid(row=amount_row, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.amount_var).grid(
row=amount_row, column=1, sticky="ew", pady=5
)
combo.bind("<<ComboboxSelected>>", lambda _event: self._select(balance))
self._buttons(amount_row + 1, self._save)
def _select(self, balance: Decimal) -> None:
_payment_id, available = self.payment_by_label[self.payment_var.get()]
self.amount_var.set(money_text(min(available, max(balance, Decimal("0")))))
def _save(self) -> None:
selected = self.payment_by_label.get(self.payment_var.get())
if not selected:
messagebox.showerror("Zahlung auswählen", "Bitte eine Zahlung auswählen.", parent=self)
return
try:
self.repository.allocate_payment_to_donation(
self.member_id, self.donation_id, payment_id=selected[0], amount=self.amount_var.get()
)
except RepositoryError as exc:
messagebox.showerror("Zuordnung fehlgeschlagen", str(exc), parent=self)
return
self.destroy()
self.on_saved()
+177 -1
View File
@@ -9,8 +9,12 @@ from tkinter import messagebox, ttk
from ccma.domain.contributions import ( from ccma.domain.contributions import (
CLAIM_STATUS_LABELS, CLAIM_STATUS_LABELS,
DONATION_STATUS_LABELS,
claim_status, claim_status,
claim_total, claim_total,
donation_allocated_total,
donation_balance,
donation_status,
money_text, money_text,
payment_allocated_total, payment_allocated_total,
) )
@@ -20,10 +24,15 @@ from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
from ccma.storage.repository import MemberRepository, RepositoryError from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.dialogs import IntegrityWarningDialog from ccma.ui.dialogs import IntegrityWarningDialog
from ccma.ui.document_dialog import DocumentTemplateDialog from ccma.ui.document_dialog import DocumentTemplateDialog
from ccma.ui.donation_dialog import (
AllocateDonationPaymentDialog,
DonationEditDialog,
DonationPaymentDialog,
)
from ccma.ui.file_open import open_path from ccma.ui.file_open import open_path
from ccma.ui.labels import display_label, storage_key from ccma.ui.labels import display_label, storage_key
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
from ccma.ui.payment_dialog import PaymentEditDialog from ccma.ui.payment_dialog import PaymentCreateDialog, PaymentEditDialog
from ccma.ui.scrolling import ScrollableFrame from ccma.ui.scrolling import ScrollableFrame
CLAIM_TABLE_COLUMNS = ( CLAIM_TABLE_COLUMNS = (
@@ -154,10 +163,12 @@ class MemberTab(ttk.Frame):
).grid(row=0, column=0, sticky="e") ).grid(row=0, column=0, sticky="e")
contribution_tab = ttk.Frame(notebook, padding=16) contribution_tab = ttk.Frame(notebook, padding=16)
payments_tab = ttk.Frame(notebook, padding=16) payments_tab = ttk.Frame(notebook, padding=16)
donations_tab = ttk.Frame(notebook, padding=16)
assets_tab = ttk.Frame(notebook, padding=16) assets_tab = ttk.Frame(notebook, padding=16)
documents_tab = ttk.Frame(notebook, padding=16) documents_tab = ttk.Frame(notebook, padding=16)
notebook.add(contribution_tab, text="Forderungen") notebook.add(contribution_tab, text="Forderungen")
notebook.add(payments_tab, text="Zahlungen") notebook.add(payments_tab, text="Zahlungen")
notebook.add(donations_tab, text="Spenden")
notebook.add(assets_tab, text="Assets") notebook.add(assets_tab, text="Assets")
notebook.add(documents_tab, text="Dokumente") notebook.add(documents_tab, text="Dokumente")
@@ -305,6 +316,9 @@ class MemberTab(ttk.Frame):
self.payments.bind("<Return>", lambda _event: self._edit_selected_payment()) self.payments.bind("<Return>", lambda _event: self._edit_selected_payment())
payment_actions = ttk.Frame(payments_tab) payment_actions = ttk.Frame(payments_tab)
payment_actions.grid(row=2, column=0, sticky="e", pady=(8, 0)) payment_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
ttk.Button(payment_actions, text="Zahlung anlegen", command=self._create_payment).pack(
side="left", padx=(0, 8)
)
ttk.Button(payment_actions, text="Zahlung bearbeiten", command=self._edit_selected_payment).pack( ttk.Button(payment_actions, text="Zahlung bearbeiten", command=self._edit_selected_payment).pack(
side="left", padx=(0, 8) side="left", padx=(0, 8)
) )
@@ -312,6 +326,50 @@ class MemberTab(ttk.Frame):
side="left" side="left"
) )
donations_tab.columnconfigure(0, weight=1)
donations_tab.rowconfigure(1, weight=1)
self.donation_summary = tk.StringVar()
ttk.Label(donations_tab, textvariable=self.donation_summary, style="Mono.TLabel").grid(
row=0, column=0, sticky="w", pady=(0, 10)
)
self.donations = ttk.Treeview(
donations_tab,
columns=("date", "amount", "allocated", "balance", "status", "reference"),
show="headings",
selectmode="browse",
)
for key, title, width in (
("date", "Datum", 100),
("amount", "Betrag", 90),
("allocated", "Zugeordnet", 90),
("balance", "Offen", 90),
("status", "Status", 150),
("reference", "Referenz / Zweck", 260),
):
self.donations.heading(key, text=title)
self.donations.column(key, width=width, anchor="w")
self.donations.grid(row=1, column=0, sticky="nsew")
self.donations.bind("<Double-1>", lambda _event: self._edit_selected_donation())
self.donations.bind("<Return>", lambda _event: self._edit_selected_donation())
donation_actions = ttk.Frame(donations_tab)
donation_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
ttk.Button(donation_actions, text="Spende anlegen", command=self._create_donation).pack(
side="left", padx=(0, 8)
)
ttk.Button(donation_actions, text="Spende bearbeiten", command=self._edit_selected_donation).pack(
side="left", padx=(0, 8)
)
ttk.Button(donation_actions, text="Spende löschen", command=self._delete_selected_donation).pack(
side="left", padx=(0, 8)
)
ttk.Separator(donation_actions, orient="vertical").pack(side="left", fill="y", padx=(0, 8))
ttk.Button(
donation_actions, text="Vorhandene Zahlung zuordnen", command=self._allocate_donation_payment
).pack(side="left", padx=(0, 8))
ttk.Button(donation_actions, text="Zahlung erfassen", command=self._record_donation_payment).pack(
side="left"
)
assets_tab.columnconfigure(0, weight=1) assets_tab.columnconfigure(0, weight=1)
assets_tab.rowconfigure(1, weight=1) assets_tab.rowconfigure(1, weight=1)
self.assets_summary = tk.StringVar() self.assets_summary = tk.StringVar()
@@ -499,6 +557,7 @@ class MemberTab(ttk.Frame):
self._clear_dirty() self._clear_dirty()
self._refresh_events() self._refresh_events()
self._refresh_contributions() self._refresh_contributions()
self._refresh_donations()
self._refresh_assets() self._refresh_assets()
self._refresh_documents() self._refresh_documents()
@@ -573,6 +632,44 @@ class MemberTab(ttk.Frame):
f"Frei {money_text(max(total_amount - total_allocated, Decimal('0')))} EUR" f"Frei {money_text(max(total_amount - total_allocated, Decimal('0')))} EUR"
) )
def _refresh_donations(self) -> None:
self.donations.delete(*self.donations.get_children())
try:
data = self.repository.get_contributions(self.member_id)
except RepositoryError as exc:
self.donation_summary.set(f"FEHLER: {exc}")
return
for donation in sorted(
data.donations,
key=lambda item: (str(item.get("date", "")), str(item.get("created_at", ""))),
reverse=True,
):
donation_id = str(donation.get("donation_id", ""))
amount = donation.get("amount", "0")
allocated = donation_allocated_total(data, donation_id)
balance = donation_balance(data, donation)
status = donation_status(data, donation)
reference = " · ".join(
part for part in (donation.get("reference", ""), donation.get("purpose", "")) if part
)
self.donations.insert(
"",
"end",
iid=donation_id,
values=(
format_date_for_display(str(donation.get("date", ""))),
f"{money_text(amount)} EUR",
f"{money_text(allocated)} EUR",
f"{money_text(balance)} EUR",
DONATION_STATUS_LABELS.get(status, status.upper()),
reference,
),
)
total_amount = sum(
(Decimal(str(item.get("amount", "0"))) for item in data.donations), Decimal("0")
)
self.donation_summary.set(f"{len(data.donations)} Spenden · Gesamt {money_text(total_amount)} EUR")
def _toggle_claim_sort(self, column: str) -> None: def _toggle_claim_sort(self, column: str) -> None:
if self.claim_sort_column == column: if self.claim_sort_column == column:
self.claim_sort_descending = not self.claim_sort_descending self.claim_sort_descending = not self.claim_sort_descending
@@ -633,10 +730,89 @@ class MemberTab(ttk.Frame):
return return
self._payment_changed() self._payment_changed()
def _create_payment(self) -> None:
PaymentCreateDialog(self, self.repository, self.member_id, self._payment_changed)
def _payment_changed(self) -> None: def _payment_changed(self) -> None:
self.refresh() self.refresh()
self.on_changed() self.on_changed()
def _selected_donation(self) -> dict | None:
selected = self.donations.selection()
if not selected:
return None
try:
_data, donation = self.repository.get_donation(self.member_id, selected[0])
except RepositoryError:
return None
return donation
def _create_donation(self) -> None:
DonationEditDialog(self, self.repository, self.member_id, self._donation_changed)
def _edit_selected_donation(self) -> None:
donation = self._selected_donation()
if not donation:
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
return
DonationEditDialog(self, self.repository, self.member_id, self._donation_changed, donation)
def _delete_selected_donation(self) -> None:
donation = self._selected_donation()
if not donation:
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
return
data = self.repository.get_contributions(self.member_id)
allocated = donation_allocated_total(data, str(donation.get("donation_id", "")))
detail = "Diese Spende wirklich endgültig löschen? Das kann nicht rückgängig gemacht werden."
if allocated:
detail += (
f"\n\nZugeordnete Zahlungen in Höhe von {money_text(allocated)} EUR werden dabei "
"gelöst und stehen danach wieder frei zur Verfügung."
)
if not messagebox.askyesno("Spende löschen", detail, parent=self):
return
try:
self.repository.delete_donation(self.member_id, str(donation.get("donation_id", "")))
except RepositoryError as exc:
messagebox.showerror("Löschen fehlgeschlagen", str(exc), parent=self)
return
self._donation_changed()
def _record_donation_payment(self) -> None:
donation = self._selected_donation()
if not donation:
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
return
data = self.repository.get_contributions(self.member_id)
DonationPaymentDialog(
self,
self.repository,
self.member_id,
str(donation.get("donation_id", "")),
donation_balance(data, donation),
self._donation_changed,
)
def _allocate_donation_payment(self) -> None:
donation = self._selected_donation()
if not donation:
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
return
data = self.repository.get_contributions(self.member_id)
AllocateDonationPaymentDialog(
self,
self.repository,
self.member_id,
str(donation.get("donation_id", "")),
donation_balance(data, donation),
self._donation_changed,
)
def _donation_changed(self) -> None:
self.refresh()
self.on_changed()
def _refresh_documents(self) -> None: def _refresh_documents(self) -> None:
self.documents.delete(*self.documents.get_children()) self.documents.delete(*self.documents.get_children())
self.document_paths.clear() self.document_paths.clear()
+72
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import tkinter as tk import tkinter as tk
from collections.abc import Callable from collections.abc import Callable
from datetime import date
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from tkinter import messagebox, ttk from tkinter import messagebox, ttk
@@ -15,6 +16,77 @@ from ccma.domain.dates import date_input_hint, format_date_for_display
from ccma.storage.repository import MemberRepository, RepositoryError from ccma.storage.repository import MemberRepository, RepositoryError
class PaymentCreateDialog(tk.Toplevel):
"""Records a payment without requiring it to be tied to a claim right away.
Useful for logging an incoming bank transfer as soon as it arrives; it can be
allocated to claims or donations afterwards."""
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
member_id: str,
on_saved: Callable[[], None],
):
super().__init__(master)
self.repository = repository
self.member_id = member_id
self.on_saved = on_saved
self.title("Zahlung anlegen")
self.transient(master.winfo_toplevel())
self.resizable(False, False)
self.bind("<Escape>", lambda _event: self.destroy())
self.frame = ttk.Frame(self, padding=18)
self.frame.pack(fill="both", expand=True)
self.frame.columnconfigure(1, weight=1)
self.variables = {
"date": tk.StringVar(value=format_date_for_display(date.today().isoformat())),
"amount": tk.StringVar(),
"gnucash": tk.StringVar(),
"reference": tk.StringVar(),
}
fields = (
(f"Zahlungsdatum ({date_input_hint()})", "date"),
("Zahlungsbetrag", "amount"),
("GnuCash-ID (optional)", "gnucash"),
("Referenz", "reference"),
)
for row, (label, key) in enumerate(fields):
ttk.Label(self.frame, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5)
ttk.Entry(self.frame, textvariable=self.variables[key], width=42).grid(
row=row, column=1, sticky="ew", pady=5
)
ttk.Label(
self.frame,
text=(
"Die Zahlung wird zunächst ohne Zuordnung gespeichert. Sie kann anschließend "
"einer Forderung oder Spende zugeordnet werden."
),
style="Mono.TLabel",
wraplength=380,
).grid(row=len(fields), column=0, columnspan=2, sticky="w", pady=(8, 0))
buttons = ttk.Frame(self.frame)
buttons.grid(row=len(fields) + 1, column=0, columnspan=2, sticky="e", pady=(16, 0))
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
ttk.Button(buttons, text="Speichern", style="Accent.TButton", command=self._save).pack(side="left")
self.after_idle(self.grab_set)
def _save(self) -> None:
try:
self.repository.create_payment(
self.member_id,
payment_date=self.variables["date"].get(),
amount=self.variables["amount"].get(),
gnucash_transaction_id=self.variables["gnucash"].get(),
reference=self.variables["reference"].get(),
)
except RepositoryError as exc:
messagebox.showerror("Zahlung konnte nicht gespeichert werden", str(exc), parent=self)
return
self.destroy()
self.on_saved()
class PaymentEditDialog(tk.Toplevel): class PaymentEditDialog(tk.Toplevel):
def __init__( def __init__(
self, self,
+130
View File
@@ -9,6 +9,9 @@ from ccma.domain.contributions import (
claim_settled_total, claim_settled_total,
claim_status, claim_status,
claim_total, claim_total,
donation_allocated_total,
donation_balance,
donation_status,
payment_allocated_total, payment_allocated_total,
) )
from ccma.domain.models import ContributionData 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") 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: def test_gnucash_id_is_unique_across_member_store(tmp_path) -> None:
repository, first_member = _repository_with_claim(tmp_path / "store") repository, first_member = _repository_with_claim(tmp_path / "store")
repository.record_payment( repository.record_payment(