From 42fb4c4224281d22c88973151bad20a07e6afa85 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Fri, 14 Aug 2026 21:02:51 +0200 Subject: [PATCH] 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 --- src/ccma/domain/contributions.py | 39 ++++ src/ccma/domain/models.py | 3 + src/ccma/storage/repository.py | 295 +++++++++++++++++++++++++++++++ src/ccma/ui/claim_tab.py | 22 ++- src/ccma/ui/donation_dialog.py | 223 +++++++++++++++++++++++ src/ccma/ui/member_tab.py | 178 ++++++++++++++++++- src/ccma/ui/payment_dialog.py | 72 ++++++++ tests/test_contributions.py | 130 ++++++++++++++ 8 files changed, 960 insertions(+), 2 deletions(-) create mode 100644 src/ccma/ui/donation_dialog.py diff --git a/src/ccma/domain/contributions.py b/src/ccma/domain/contributions.py index 4aab558..1034dec 100644 --- a/src/ccma/domain/contributions.py +++ b/src/ccma/domain/contributions.py @@ -18,6 +18,13 @@ CLAIM_STATUS_LABELS = { "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: 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) +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: if str(claim.get("status", "")) == "cancelled": return "cancelled" diff --git a/src/ccma/domain/models.py b/src/ccma/domain/models.py index 3d34405..977b1a7 100644 --- a/src/ccma/domain/models.py +++ b/src/ccma/domain/models.py @@ -323,6 +323,7 @@ class ContributionData: credits: 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) + donations: list[dict[str, Any]] = field(default_factory=list) schema_version: int = 1 def to_dict(self) -> dict[str, Any]: @@ -333,6 +334,7 @@ class ContributionData: "credits": self.credits, "allocations": self.allocations, "reminders": self.reminders, + "donations": self.donations, } @classmethod @@ -344,6 +346,7 @@ class ContributionData: credits=list(data.get("credits") or []), allocations=list(data.get("allocations") or []), reminders=list(data.get("reminders") or []), + donations=list(data.get("donations") or []), ) diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index 9ac247b..ceb1c4c 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -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: diff --git a/src/ccma/ui/claim_tab.py b/src/ccma/ui/claim_tab.py index ef71cc5..4ef00e7 100644 --- a/src/ccma/ui/claim_tab.py +++ b/src/ccma/ui/claim_tab.py @@ -92,7 +92,9 @@ class ClaimTab(ttk.Frame): 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.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: ledger = ttk.Frame(self, padding=12) @@ -503,6 +505,24 @@ class ClaimTab(ttk.Frame): return 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: ClaimEditDialog( self, diff --git a/src/ccma/ui/donation_dialog.py b/src/ccma/ui/donation_dialog.py new file mode 100644 index 0000000..f6c7c9b --- /dev/null +++ b/src/ccma/ui/donation_dialog.py @@ -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("", 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("<>", 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() diff --git a/src/ccma/ui/member_tab.py b/src/ccma/ui/member_tab.py index 41b3658..128207f 100644 --- a/src/ccma/ui/member_tab.py +++ b/src/ccma/ui/member_tab.py @@ -9,8 +9,12 @@ from tkinter import messagebox, ttk from ccma.domain.contributions import ( CLAIM_STATUS_LABELS, + DONATION_STATUS_LABELS, claim_status, claim_total, + donation_allocated_total, + donation_balance, + donation_status, money_text, 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.ui.dialogs import IntegrityWarningDialog 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.labels import display_label, storage_key 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 CLAIM_TABLE_COLUMNS = ( @@ -154,10 +163,12 @@ class MemberTab(ttk.Frame): ).grid(row=0, column=0, sticky="e") contribution_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) documents_tab = ttk.Frame(notebook, padding=16) notebook.add(contribution_tab, text="Forderungen") notebook.add(payments_tab, text="Zahlungen") + notebook.add(donations_tab, text="Spenden") notebook.add(assets_tab, text="Assets") notebook.add(documents_tab, text="Dokumente") @@ -305,6 +316,9 @@ class MemberTab(ttk.Frame): self.payments.bind("", lambda _event: self._edit_selected_payment()) payment_actions = ttk.Frame(payments_tab) 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( side="left", padx=(0, 8) ) @@ -312,6 +326,50 @@ class MemberTab(ttk.Frame): 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("", lambda _event: self._edit_selected_donation()) + self.donations.bind("", 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.rowconfigure(1, weight=1) self.assets_summary = tk.StringVar() @@ -499,6 +557,7 @@ class MemberTab(ttk.Frame): self._clear_dirty() self._refresh_events() self._refresh_contributions() + self._refresh_donations() self._refresh_assets() self._refresh_documents() @@ -573,6 +632,44 @@ class MemberTab(ttk.Frame): 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: if self.claim_sort_column == column: self.claim_sort_descending = not self.claim_sort_descending @@ -633,10 +730,89 @@ class MemberTab(ttk.Frame): return self._payment_changed() + def _create_payment(self) -> None: + PaymentCreateDialog(self, self.repository, self.member_id, self._payment_changed) + def _payment_changed(self) -> None: self.refresh() 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: self.documents.delete(*self.documents.get_children()) self.document_paths.clear() diff --git a/src/ccma/ui/payment_dialog.py b/src/ccma/ui/payment_dialog.py index 0066089..9ce9437 100644 --- a/src/ccma/ui/payment_dialog.py +++ b/src/ccma/ui/payment_dialog.py @@ -2,6 +2,7 @@ from __future__ import annotations import tkinter as tk from collections.abc import Callable +from datetime import date from decimal import Decimal, InvalidOperation 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 +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("", 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): def __init__( self, diff --git a/tests/test_contributions.py b/tests/test_contributions.py index 5f454ea..793d5ba 100644 --- a/tests/test_contributions.py +++ b/tests/test_contributions.py @@ -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(