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
+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.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,
+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 (
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("<Return>", 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("<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.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()
+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,