Files
CCMA/src/ccma/ui/claim_tab.py
T
Marcel PeterkauandClaude Sonnet 5 42fb4c4224 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>
2026-08-14 21:02:51 +02:00

1057 lines
44 KiB
Python

from __future__ import annotations
import tkinter as tk
from collections.abc import Callable
from datetime import date
from decimal import Decimal
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from ccma.domain.contributions import (
CLAIM_STATUS_LABELS,
allocated_total,
claim_balance,
claim_items,
claim_settled_total,
claim_status,
claim_total,
credit_allocated_total,
decimal_value,
money_text,
payment_allocated_total,
)
from ccma.domain.dates import date_input_hint, format_date_for_display
from ccma.services.reminder_mail import generate_and_send_reminder_mail
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.document_dialog import DocumentTemplateDialog
from ccma.ui.labels import (
CLAIM_ITEM_TYPE_LABELS,
REMINDER_CHANNEL_LABELS,
REMINDER_STATUS_LABELS,
display_label,
storage_key,
)
class ClaimTab(ttk.Frame):
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
member_id: str,
claim_id: str,
on_close: Callable[[], None],
on_changed: Callable[[], None],
):
super().__init__(master, padding=12)
self.repository = repository
self.member_id = member_id
self.claim_id = claim_id
self.on_close = on_close
self.on_changed = on_changed
self._build_ui()
self.refresh()
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(2, weight=1)
header = ttk.Frame(self)
header.grid(row=0, column=0, sticky="ew", pady=(0, 12))
header.columnconfigure(0, weight=1)
self.title_var = tk.StringVar()
self.subtitle_var = tk.StringVar()
ttk.Label(header, textvariable=self.title_var, style="TabTitle.TLabel").grid(
row=0, column=0, sticky="w"
)
ttk.Label(header, textvariable=self.subtitle_var, style="Mono.TLabel").grid(
row=1, column=0, sticky="w", pady=(3, 0)
)
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=1, rowspan=2)
summary = ttk.Frame(self)
summary.grid(row=1, column=0, sticky="ew", pady=(0, 12))
self.summary_vars: dict[str, tk.StringVar] = {}
for column, (key, title) in enumerate(
(("total", "GESAMT"), ("paid", "BEZAHLT"), ("balance", "OFFEN"), ("status", "STATUS"))
):
card = ttk.Frame(summary, style="Card.TFrame", padding=12)
card.grid(row=0, column=column, sticky="nsew", padx=(0, 8))
summary.columnconfigure(column, weight=1)
ttk.Label(card, text=title, style="CardTitle.TLabel").pack(anchor="w")
variable = tk.StringVar()
self.summary_vars[key] = variable
ttk.Label(card, textvariable=variable, style="CardValue.TLabel").pack(anchor="w", pady=(5, 0))
self._build_ledger()
footer = ttk.Frame(self)
footer.grid(row=3, column=0, sticky="ew", pady=(10, 0))
footer.columnconfigure(0, weight=1)
self.hold_button = ttk.Button(footer, text="Mahnsperre setzen", command=self._toggle_hold)
self.hold_button.grid(row=0, column=0, sticky="w")
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", 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)
ledger.grid(row=2, column=0, sticky="nsew")
ledger.columnconfigure(0, weight=1)
ledger.rowconfigure(0, weight=1)
self.ledger = ttk.Treeview(
ledger,
columns=("date", "description", "quantity", "unit", "amount", "status", "reference"),
show="tree headings",
)
self.ledger.heading("#0", text="Bereich / Element")
self.ledger.column("#0", width=190, minwidth=140, anchor="w")
for key, title, width in (
("date", "Datum", 120),
("description", "Beschreibung", 300),
("quantity", "Menge", 75),
("unit", "Einzelpreis", 100),
("amount", "Betrag", 105),
("status", "Status / Details", 180),
("reference", "Referenz", 210),
):
self.ledger.heading(key, text=title)
self.ledger.column(key, width=width, minwidth=60, anchor="w")
self.ledger.grid(row=0, column=0, sticky="nsew")
vertical_scroll = ttk.Scrollbar(ledger, orient="vertical", command=self.ledger.yview)
vertical_scroll.grid(row=0, column=1, sticky="ns")
horizontal_scroll = ttk.Scrollbar(ledger, orient="horizontal", command=self.ledger.xview)
horizontal_scroll.grid(row=1, column=0, sticky="ew")
self.ledger.configure(
yscrollcommand=vertical_scroll.set,
xscrollcommand=horizontal_scroll.set,
)
self.ledger.tag_configure("position-group", background="#1261a0", foreground="#ffffff")
self.ledger.tag_configure("position", background="#234d70", foreground="#ffffff")
self.ledger.tag_configure("payment-group", background="#237a3b", foreground="#ffffff")
self.ledger.tag_configure("payment", background="#285b3b", foreground="#ffffff")
self.ledger.tag_configure("credit-group", background="#8b3d88", foreground="#ffffff")
self.ledger.tag_configure("credit", background="#5e2f5b", foreground="#ffffff")
self.ledger.tag_configure("reminder-group", background="#b85f00", foreground="#ffffff")
self.ledger.tag_configure("reminder", background="#70451f", foreground="#ffffff")
self.ledger.bind("<<TreeviewSelect>>", lambda _event: self._update_reminder_buttons())
buttons = ttk.Frame(ledger)
buttons.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(10, 0))
ttk.Button(buttons, text="Position hinzufügen", command=self._add_item).pack(side="left")
ttk.Separator(buttons, orient="vertical").pack(side="left", fill="y", padx=10)
ttk.Button(buttons, text="Vorhandene Zahlung zuordnen", command=self._allocate_payment).pack(
side="left", padx=(0, 8)
)
ttk.Button(buttons, text="Zahlung erfassen", command=self._record_payment).pack(side="left")
ttk.Button(buttons, text="Vorhandene Gutschrift zuordnen", command=self._allocate_credit).pack(
side="left", padx=(8, 8)
)
ttk.Button(buttons, text="Gutschrift erfassen", command=self._record_credit).pack(side="left")
ttk.Separator(buttons, orient="vertical").pack(side="left", fill="y", padx=10)
self.discard_reminder_button = ttk.Button(
buttons, text="Entwurf verwerfen", command=self._discard_reminder, state="disabled"
)
self.discard_reminder_button.pack(side="right", padx=(8, 0))
self.send_reminder_button = ttk.Button(
buttons, text="Als versandt markieren", command=self._send_reminder, state="disabled"
)
self.send_reminder_button.pack(side="right", padx=(8, 0))
self.email_reminder_button = ttk.Button(
buttons,
text="E-Mail erzeugen & versandt",
command=self._email_reminder,
state="disabled",
)
self.email_reminder_button.pack(side="right", padx=(8, 0))
ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="right")
ttk.Button(buttons, text="Dokument erzeugen", command=self._create_document).pack(
side="right", padx=(0, 8)
)
def refresh(self) -> None:
try:
self.data, self.claim = self.repository.get_claim(self.member_id, self.claim_id)
member = self.repository.get_member(self.member_id)
except RepositoryError as exc:
messagebox.showerror("Forderung konnte nicht geladen werden", str(exc), parent=self)
return
total = claim_total(self.claim)
paid = claim_settled_total(self.data, self.claim)
balance = claim_balance(self.data, self.claim)
status = claim_status(self.data, self.claim)
self.title_var.set(str(self.claim.get("title") or "Forderung"))
due = format_date_for_display(str(self.claim.get("due_date", "")))
self.subtitle_var.set(
f"{member.member_number} · {member.display_name} · Fällig: {due or '—'} · {self.claim_id}"
)
hold = self.claim.get("dunning_hold") or {}
if hold.get("active"):
until = format_date_for_display(str(hold.get("until") or "")) or "unbefristet"
reason = str(hold.get("reason") or "ohne Begründung")
self.subtitle_var.set(f"{self.subtitle_var.get()} · MAHNSPERRE bis {until}: {reason}")
self.summary_vars["total"].set(f"{money_text(total)} EUR")
self.summary_vars["paid"].set(f"{money_text(paid)} EUR")
self.summary_vars["balance"].set(f"{money_text(balance)} EUR")
self.summary_vars["status"].set(CLAIM_STATUS_LABELS.get(status, status.upper()))
self.cancel_button.configure(state="disabled" if status == "cancelled" else "normal")
self.edit_button.configure(state="disabled" if status == "cancelled" else "normal")
self.hold_button.configure(text="Mahnsperre aufheben" if hold.get("active") else "Mahnsperre setzen")
self._render_ledger()
def _render_ledger(self) -> None:
self.ledger.delete(*self.ledger.get_children())
items = claim_items(self.claim)
position_group = self.ledger.insert(
"",
"end",
iid="group:positions",
text=f"Positionen ({len(items)})",
values=("", "", "", "", f"{money_text(claim_total(self.claim))} EUR", "", ""),
tags=("position-group",),
open=True,
)
for item in items:
self.ledger.insert(
position_group,
"end",
iid=f"item:{item.get('item_id', '')}",
text=display_label(CLAIM_ITEM_TYPE_LABELS, str(item.get("type", ""))),
values=(
str(item.get("created_at", ""))[:10],
item.get("description", ""),
item.get("quantity", "1"),
item.get("unit_price", item.get("amount", "")),
f"{item.get('amount', '')} EUR",
"",
"",
),
tags=("position",),
)
payment_by_id = {str(item.get("payment_id")): item for item in self.data.payments}
allocations = [
allocation
for allocation in self.data.allocations
if str(allocation.get("claim_id", "")) == self.claim_id
]
payment_group = self.ledger.insert(
"",
"end",
iid="group:payments",
text=f"Zahlungen ({len(allocations)})",
values=("", "", "", "", f"-{money_text(allocated_total(self.data, self.claim_id))} EUR", "", ""),
tags=("payment-group",),
open=True,
)
for allocation in allocations:
payment = payment_by_id.get(str(allocation.get("payment_id", "")), {})
if not payment:
continue
payment_total = str(payment.get("amount", ""))
gnucash_id = str(payment.get("gnucash_transaction_id", ""))
self.ledger.insert(
payment_group,
"end",
iid=f"allocation:{allocation.get('allocation_id', '')}",
text="Zahlung",
values=(
format_date_for_display(str(payment.get("date", ""))),
payment.get("reference", ""),
"",
"",
f"-{allocation.get('amount', '')} EUR",
f"Zahlung gesamt: {payment_total} EUR",
f"GnuCash: {gnucash_id}" if gnucash_id else "",
),
tags=("payment",),
)
credits_by_id = {str(item.get("credit_id")): item for item in self.data.credits}
credit_allocations = [
allocation
for allocation in self.data.allocations
if str(allocation.get("claim_id", "")) == self.claim_id and str(allocation.get("credit_id", ""))
]
allocated_credit_total = money_text(
sum(
(decimal_value(item.get("amount", "0")) for item in credit_allocations),
Decimal("0"),
)
)
credit_group = self.ledger.insert(
"",
"end",
iid="group:credits",
text=f"Gutschriften ({len(credit_allocations)})",
values=(
"",
"",
"",
"",
f"{allocated_credit_total} EUR",
"",
"",
),
tags=("credit-group",),
open=True,
)
for allocation in credit_allocations:
credit = credits_by_id.get(str(allocation.get("credit_id", "")), {})
if not credit:
continue
credit_total = str(credit.get("amount", ""))
self.ledger.insert(
credit_group,
"end",
iid=f"credit-allocation:{allocation.get('allocation_id', '')}",
text="Gutschrift",
values=(
format_date_for_display(str(credit.get("date", ""))),
credit.get("reference", ""),
"",
"",
f"{allocation.get('amount', '')} EUR",
f"Gutschrift gesamt: {credit_total} EUR",
"",
),
tags=("credit",),
)
reminders = [
reminder
for reminder in self.data.reminders
if str(reminder.get("claim_id", "")) == self.claim_id
]
reminder_group = self.ledger.insert(
"",
"end",
iid="group:reminders",
text=f"Mahnungen ({len(reminders)})",
tags=("reminder-group",),
open=True,
)
for reminder in reminders:
status = str(reminder.get("status", "draft"))
deadline = format_date_for_display(str(reminder.get("payment_deadline") or ""))
channel = display_label(REMINDER_CHANNEL_LABELS, str(reminder.get("channel", "")))
detail = str(reminder.get("detail", ""))
self.ledger.insert(
reminder_group,
"end",
iid=f"reminder:{reminder.get('reminder_id', '')}",
text=f"Mahnstufe {reminder.get('level', '')}",
values=(
str(reminder.get("created_at", ""))[:16],
reminder.get("name", f"Mahnung Stufe {reminder.get('level', '')}"),
"",
"",
f"{reminder.get('fee', '0.00')} EUR",
display_label(REMINDER_STATUS_LABELS, status),
" · ".join(
part
for part in (channel, f"Frist: {deadline}" if deadline else "", detail)
if part
),
),
tags=("reminder",),
)
self._update_reminder_buttons()
def _add_item(self) -> None:
ItemDialog(self, self.repository, self.member_id, self.claim_id, self._changed)
def _record_payment(self) -> None:
PaymentDialog(
self,
self.repository,
self.member_id,
self.claim_id,
claim_balance(self.data, self.claim),
self._changed,
)
def _allocate_payment(self) -> None:
AllocatePaymentDialog(
self,
self.repository,
self.member_id,
self.claim_id,
claim_balance(self.data, self.claim),
self._changed,
)
def _record_credit(self) -> None:
CreditDialog(
self,
self.repository,
self.member_id,
self.claim_id,
claim_balance(self.data, self.claim),
self._changed,
)
def _allocate_credit(self) -> None:
AllocateCreditDialog(
self,
self.repository,
self.member_id,
self.claim_id,
claim_balance(self.data, self.claim),
self._changed,
)
def _add_reminder(self) -> None:
ReminderDialog(self, self.repository, self.member_id, self.claim_id, self._changed)
def _create_document(self) -> None:
reminder = self._selected_reminder()
DocumentTemplateDialog(
self,
self.repository,
self.member_id,
self._document_generated,
claim_id=self.claim_id,
reminder_id=str(reminder["reminder_id"]) if reminder else None,
)
def _document_generated(self, _path: Path) -> None:
self._changed()
def _selected_reminder(self) -> dict | None:
selected = self.ledger.selection()
if not selected or not selected[0].startswith("reminder:"):
return None
reminder_id = selected[0].removeprefix("reminder:")
return next(
(item for item in self.data.reminders if str(item.get("reminder_id", "")) == reminder_id),
None,
)
def _update_reminder_buttons(self) -> None:
reminder = self._selected_reminder()
editable = bool(reminder and str(reminder.get("status", "draft")) in {"draft", "generated"})
state = "normal" if editable else "disabled"
self.send_reminder_button.configure(state=state)
self.email_reminder_button.configure(state=state)
self.discard_reminder_button.configure(state=state)
def _email_reminder(self) -> None:
reminder = self._selected_reminder()
if reminder:
ReminderMailDialog(
self,
self.repository,
self.member_id,
self.claim_id,
str(reminder["reminder_id"]),
str(reminder.get("name", "Mahnung")),
self._changed,
)
def _send_reminder(self) -> None:
reminder = self._selected_reminder()
if not reminder or not messagebox.askyesno(
"Mahnung versandt",
"Wurde diese Mahnung tatsächlich versandt? Erst jetzt werden Frist und Gebühr gebucht.",
parent=self,
):
return
try:
self.repository.mark_reminder_sent(self.member_id, self.claim_id, str(reminder["reminder_id"]))
except RepositoryError as exc:
messagebox.showerror("Versand konnte nicht gespeichert werden", str(exc), parent=self)
return
self._changed()
def _discard_reminder(self) -> None:
reminder = self._selected_reminder()
if not reminder or not messagebox.askyesno(
"Mahnungsentwurf verwerfen", "Diesen Mahnungsentwurf wirklich verwerfen?", parent=self
):
return
try:
self.repository.cancel_reminder(self.member_id, self.claim_id, str(reminder["reminder_id"]))
except RepositoryError as exc:
messagebox.showerror("Entwurf konnte nicht verworfen werden", str(exc), parent=self)
return
self._changed()
def _toggle_hold(self) -> None:
hold = self.claim.get("dunning_hold") or {}
if hold.get("active"):
if not messagebox.askyesno("Mahnsperre aufheben", "Mahnsperre wirklich aufheben?", parent=self):
return
try:
self.repository.set_dunning_hold(self.member_id, self.claim_id, active=False)
except RepositoryError as exc:
messagebox.showerror("Mahnsperre", str(exc), parent=self)
return
self._changed()
return
DunningHoldDialog(self, self.repository, self.member_id, self.claim_id, self._changed)
def _cancel_claim(self) -> None:
if not messagebox.askyesno(
"Forderung stornieren", "Diese Forderung wirklich stornieren?", parent=self
):
return
try:
self.repository.cancel_claim(self.member_id, self.claim_id)
except RepositoryError as exc:
messagebox.showerror("Storno fehlgeschlagen", str(exc), parent=self)
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,
self.repository,
self.member_id,
self.claim_id,
self.claim,
self._changed,
)
def _changed(self) -> None:
self.refresh()
self.on_changed()
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 ClaimEditDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, claim, on_saved):
super().__init__(master, "Forderung bearbeiten", on_saved)
self.repository = repository
self.member_id = member_id
self.claim_id = claim_id
items = claim_items(claim)
base_item = next(
(item for item in items if str(item.get("type", "")) == "base"),
items[0] if items else {},
)
self.variables = {
"title": tk.StringVar(value=str(claim.get("title", ""))),
"due_date": tk.StringVar(
value=format_date_for_display(str(claim.get("due_date", "")))
),
"base_amount": tk.StringVar(
value=str(base_item.get("amount", claim.get("amount", "")))
),
"description": tk.StringVar(value=str(base_item.get("description", ""))),
}
fields = (
("Titel", "title"),
(f"Fällig am ({date_input_hint()})", "due_date"),
("Grundbetrag", "base_amount"),
("Beschreibung der Grundposition", "description"),
)
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=52).grid(
row=row, column=1, sticky="ew", pady=5
)
ttk.Label(
self.frame,
text=(
"Der Grundbetrag ändert nur die Grundposition. Weitere Positionen, "
"zum Beispiel Mahngebühren, bleiben erhalten."
),
style="Mono.TLabel",
wraplength=520,
).grid(row=len(fields), column=0, columnspan=2, sticky="w", pady=(8, 0))
self._buttons(len(fields) + 1, self._save)
def _save(self) -> None:
try:
self.repository.update_claim(
self.member_id,
self.claim_id,
title=self.variables["title"].get(),
due_date=self.variables["due_date"].get(),
base_amount=self.variables["base_amount"].get(),
description=self.variables["description"].get(),
)
except RepositoryError as exc:
messagebox.showerror("Forderung konnte nicht gespeichert werden", str(exc), parent=self)
return
self.destroy()
self.on_saved()
class ItemDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, on_saved):
super().__init__(master, "Forderungsposition hinzufügen", on_saved)
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
self.type_var = tk.StringVar(value=CLAIM_ITEM_TYPE_LABELS["correction"])
self.description_var = tk.StringVar()
self.quantity_var = tk.StringVar(value="1")
self.unit_var = tk.StringVar()
fields = (
("Typ", self.type_var),
("Beschreibung", self.description_var),
("Menge", self.quantity_var),
("Einzelpreis", self.unit_var),
)
for row, (label, variable) in enumerate(fields):
ttk.Label(self.frame, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
if row == 0:
ttk.Combobox(
self.frame,
textvariable=variable,
values=list(CLAIM_ITEM_TYPE_LABELS.values()),
state="readonly",
width=32,
).grid(row=row, column=1, sticky="ew", pady=5)
else:
ttk.Entry(self.frame, textvariable=variable, width=35).grid(row=row, column=1, pady=5)
self._buttons(len(fields), self._save)
def _save(self):
try:
self.repository.add_claim_item(
self.member_id,
self.claim_id,
description=self.description_var.get(),
quantity=self.quantity_var.get(),
unit_price=self.unit_var.get(),
item_type=storage_key(CLAIM_ITEM_TYPE_LABELS, self.type_var.get()),
)
except RepositoryError as exc:
messagebox.showerror("Position konnte nicht gespeichert werden", str(exc), parent=self)
return
self.destroy()
self.on_saved()
class PaymentDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, balance: Decimal, on_saved):
super().__init__(master, "Zahlung erfassen", on_saved)
self.repository, self.member_id, self.claim_id = repository, member_id, claim_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 Forderung zuordnen", "allocation"),
("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", 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):
try:
self.repository.record_payment(
self.member_id,
self.claim_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 CreditDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, balance: Decimal, on_saved):
super().__init__(master, "Gutschrift erfassen", on_saved)
self.repository, self.member_id, self.claim_id = repository, member_id, claim_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),
"reference": tk.StringVar(),
}
fields = (
(f"Gutschriftsdatum ({date_input_hint()})", "date"),
("Gutschriftsbetrag", "amount"),
("Dieser Gutschrift zuordnen", "allocation"),
("Referenz", "reference"),
)
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):
try:
self.repository.record_credit(
self.member_id,
self.claim_id,
credit_date=self.variables["date"].get(),
amount=self.variables["amount"].get(),
allocation_amount=self.variables["allocation"].get(),
reference=self.variables["reference"].get(),
)
except RepositoryError as exc:
messagebox.showerror("Gutschrift konnte nicht gespeichert werden", str(exc), parent=self)
return
self.destroy()
self.on_saved()
class AllocatePaymentDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, balance: Decimal, on_saved):
super().__init__(master, "Vorhandene Zahlung zuordnen", on_saved)
self.repository, self.member_id, self.claim_id = repository, member_id, claim_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"))))
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):
_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):
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(
self.member_id, self.claim_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()
class AllocateCreditDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, balance: Decimal, on_saved):
super().__init__(master, "Vorhandene Gutschrift zuordnen", on_saved)
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
data = repository.get_contributions(member_id)
self.credit_by_label = {}
for credit in data.credits:
credit_id = str(credit.get("credit_id", ""))
available = decimal_value(credit.get("amount", "0")) - credit_allocated_total(data, credit_id)
if available <= 0:
continue
label = (
f"{credit.get('date', '')} · {money_text(available)} EUR frei · "
f"{credit.get('reference', '')}"
)
self.credit_by_label[label] = (credit_id, available)
self.credit_var = tk.StringVar()
self.amount_var = tk.StringVar(value=money_text(max(-balance, Decimal("0"))))
ttk.Label(self.frame, text="Gutschrift").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
combo = ttk.Combobox(
self.frame,
textvariable=self.credit_var,
values=list(self.credit_by_label),
state="readonly",
width=60,
)
combo.grid(row=0, column=1, pady=5)
ttk.Label(self.frame, text="Betrag").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.amount_var).grid(row=1, column=1, sticky="ew", pady=5)
combo.bind("<<ComboboxSelected>>", lambda _event: self._select(balance))
self._buttons(2, self._save)
def _select(self, balance):
_credit_id, available = self.credit_by_label[self.credit_var.get()]
self.amount_var.set(money_text(min(available, max(-balance, Decimal("0")))))
def _save(self):
selected = self.credit_by_label.get(self.credit_var.get())
if not selected:
messagebox.showerror("Gutschrift auswählen", "Bitte eine Gutschrift auswählen.", parent=self)
return
try:
self.repository.allocate_credit(
self.member_id, self.claim_id, credit_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()
class ReminderDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, on_saved):
super().__init__(master, "Mahnung vorbereiten", on_saved)
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
policy = repository.get_configuration().get("reminder_policy") or {}
levels = policy.get("levels") or [
{"level": 1, "name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 14}
]
self.definition_by_label = {
f"Stufe {item.get('level', '')}: {item.get('name', '')}": item for item in levels
}
self.level_var = tk.StringVar(value=next(iter(self.definition_by_label)))
self.detail_var = tk.StringVar()
self.fee_var = tk.StringVar()
self.deadline_var = tk.StringVar()
self.channel_var = tk.StringVar(value=REMINDER_CHANNEL_LABELS["email"])
ttk.Label(self.frame, text="Mahnstufe").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
level_combo = ttk.Combobox(
self.frame,
textvariable=self.level_var,
values=list(self.definition_by_label),
state="readonly",
width=38,
)
level_combo.grid(row=0, column=1, sticky="ew", pady=5)
level_combo.bind("<<ComboboxSelected>>", lambda _event: self._load_definition())
ttk.Label(self.frame, text="Details").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.detail_var, width=45).grid(row=1, column=1, pady=5)
ttk.Label(self.frame, text="Mahngebühr").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.fee_var).grid(row=2, column=1, sticky="ew", pady=5)
ttk.Label(self.frame, text="Zahlungsfrist in Tagen").grid(
row=3, column=0, sticky="w", pady=5, padx=(0, 12)
)
ttk.Entry(self.frame, textvariable=self.deadline_var).grid(row=3, column=1, sticky="ew", pady=5)
ttk.Label(self.frame, text="Versandweg").grid(row=4, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Combobox(
self.frame,
textvariable=self.channel_var,
values=list(REMINDER_CHANNEL_LABELS.values()),
state="readonly",
).grid(row=4, column=1, sticky="ew", pady=5)
self._load_definition()
self._buttons(5, self._save)
def _load_definition(self) -> None:
definition = self.definition_by_label[self.level_var.get()]
self.fee_var.set(str(definition.get("fee", "0.00")))
self.deadline_var.set(str(definition.get("payment_deadline_days", 14)))
def _save(self):
definition = self.definition_by_label[self.level_var.get()]
try:
self.repository.create_reminder_draft(
self.member_id,
self.claim_id,
level=int(definition.get("level", 1)),
name=str(definition.get("name", "Mahnung")),
payment_deadline_days=int(self.deadline_var.get()),
detail=self.detail_var.get(),
fee=self.fee_var.get(),
channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()),
)
except (ValueError, RepositoryError) as exc:
messagebox.showerror("Mahnungsentwurf konnte nicht gespeichert werden", str(exc), parent=self)
return
self.destroy()
self.on_saved()
class ReminderMailDialog(tk.Toplevel):
def __init__(
self,
master,
repository,
member_id,
claim_id,
reminder_id,
reminder_name,
on_saved,
):
super().__init__(master)
self.repository = repository
self.member_id = member_id
self.claim_id = claim_id
self.reminder_id = reminder_id
self.reminder_name = reminder_name
self.on_saved = on_saved
organization = repository.get_configuration().get("organization") or {}
self.sender_name_var = tk.StringVar(
value=str(organization.get("name") or "Verwaltung C3MA")
)
self.sender_email_var = tk.StringVar(value=str(organization.get("email") or ""))
self.title("Mahnung als E-Mail erzeugen")
self.transient(master)
self.resizable(True, False)
self.columnconfigure(1, weight=1)
ttk.Label(
self,
text=(
"Die erzeugte E-Mail wird in der Mitgliederakte archiviert und die Mahnung "
"sofort als versandt gebucht. Zahlungsfrist und Mahngebühr werden dabei wirksam."
),
wraplength=620,
).grid(row=0, column=0, columnspan=2, sticky="w", padx=14, pady=(14, 10))
ttk.Label(self, text="Absendername").grid(row=1, column=0, sticky="w", padx=14, pady=5)
ttk.Entry(self, textvariable=self.sender_name_var, width=55).grid(
row=1, column=1, sticky="ew", padx=(0, 14), pady=5
)
ttk.Label(self, text="Absenderadresse").grid(row=2, column=0, sticky="w", padx=14, pady=5)
ttk.Entry(self, textvariable=self.sender_email_var, width=55).grid(
row=2, column=1, sticky="ew", padx=(0, 14), pady=5
)
ttk.Label(self, text="Signatur").grid(row=3, column=0, sticky="nw", padx=14, pady=5)
self.signature = tk.Text(self, width=55, height=5, wrap="word")
self.signature.grid(row=3, column=1, sticky="ew", padx=(0, 14), pady=5)
self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.")
buttons = ttk.Frame(self, padding=14)
buttons.grid(row=4, column=0, columnspan=2, sticky="e")
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
ttk.Button(
buttons,
text="EML speichern und als versandt buchen",
style="Accent.TButton",
command=self._generate,
).pack(side="left", padx=(8, 0))
self.grab_set()
def _generate(self) -> None:
selected = filedialog.asksaveasfilename(
parent=self,
title="Mahnung als Thunderbird-Entwurf speichern",
defaultextension=".eml",
initialfile=f"{self.reminder_name}.eml",
filetypes=[("E-Mail-Datei", "*.eml"), ("Alle Dateien", "*.*")],
)
if not selected:
return
try:
result = generate_and_send_reminder_mail(
self.repository,
self.member_id,
self.claim_id,
self.reminder_id,
output_path=selected,
sender_name=self.sender_name_var.get(),
sender_email=self.sender_email_var.get(),
signature=self.signature.get("1.0", "end-1c"),
)
except (OSError, RepositoryError) as exc:
messagebox.showerror("Mahnung konnte nicht erzeugt werden", str(exc), parent=self)
return
messagebox.showinfo(
"Mahnung erzeugt",
f"Die E-Mail wurde gespeichert und als versandt gebucht:\n{result.export_path}",
parent=self,
)
self.destroy()
self.on_saved()
class DunningHoldDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, on_saved):
super().__init__(master, "Mahnsperre setzen", on_saved)
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
self.reason_var = tk.StringVar()
self.until_var = tk.StringVar()
ttk.Label(self.frame, text="Grund").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.reason_var, width=45).grid(row=0, column=1, pady=5)
ttk.Label(self.frame, text=f"Bis ({date_input_hint()}, optional)").grid(
row=1, column=0, sticky="w", pady=5, padx=(0, 12)
)
ttk.Entry(self.frame, textvariable=self.until_var).grid(row=1, column=1, sticky="ew", pady=5)
self._buttons(2, self._save)
def _save(self) -> None:
try:
self.repository.set_dunning_hold(
self.member_id,
self.claim_id,
active=True,
reason=self.reason_var.get(),
until=self.until_var.get(),
)
except RepositoryError as exc:
messagebox.showerror("Mahnsperre konnte nicht gespeichert werden", str(exc), parent=self)
return
self.destroy()
self.on_saved()