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
+72
View File
@@ -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("<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):
def __init__(
self,