feat: add itemized claims and payments

This commit is contained in:
Marcel Peterkau
2026-06-21 18:20:55 +02:00
parent c717d6806b
commit 80d4d5ef90
12 changed files with 1049 additions and 6 deletions
+462
View File
@@ -0,0 +1,462 @@
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 (
CLAIM_STATUS_LABELS,
allocated_total,
claim_balance,
claim_items,
claim_status,
claim_total,
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 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))
notebook = ttk.Notebook(self)
notebook.grid(row=2, column=0, sticky="nsew")
positions = ttk.Frame(notebook, padding=12)
payments = ttk.Frame(notebook, padding=12)
reminders = ttk.Frame(notebook, padding=12)
notebook.add(positions, text="Positionen")
notebook.add(payments, text="Zahlungen")
notebook.add(reminders, text="Mahnungen")
self._build_positions(positions)
self._build_payments(payments)
self._build_reminders(reminders)
footer = ttk.Frame(self)
footer.grid(row=3, column=0, sticky="ew", pady=(10, 0))
footer.columnconfigure(0, weight=1)
self.cancel_button = ttk.Button(footer, text="Forderung stornieren", command=self._cancel_claim)
self.cancel_button.grid(row=0, column=1, sticky="e")
def _build_positions(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(0, weight=1)
self.positions = ttk.Treeview(
parent, columns=("type", "description", "quantity", "unit", "amount"), show="headings"
)
for key, title, width in (
("type", "Typ", 100),
("description", "Beschreibung", 360),
("quantity", "Menge", 80),
("unit", "Einzelpreis", 100),
("amount", "Betrag", 100),
):
self.positions.heading(key, text=title)
self.positions.column(key, width=width, anchor="w")
self.positions.grid(row=0, column=0, sticky="nsew")
ttk.Button(parent, text="Position hinzufügen", command=self._add_item).grid(
row=1, column=0, sticky="e", pady=(10, 0)
)
def _build_payments(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(0, weight=1)
self.payments = ttk.Treeview(
parent, columns=("date", "allocated", "payment", "gnucash", "reference"), show="headings"
)
for key, title, width in (
("date", "Datum", 110),
("allocated", "Zugeordnet", 100),
("payment", "Zahlung gesamt", 110),
("gnucash", "GnuCash-ID", 180),
("reference", "Referenz", 280),
):
self.payments.heading(key, text=title)
self.payments.column(key, width=width, anchor="w")
self.payments.grid(row=0, column=0, sticky="nsew")
buttons = ttk.Frame(parent)
buttons.grid(row=1, column=0, sticky="e", pady=(10, 0))
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")
def _build_reminders(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(0, weight=1)
self.reminders = ttk.Treeview(parent, columns=("created", "level", "detail", "fee"), show="headings")
for key, title, width in (
("created", "Erstellt", 150),
("level", "Stufe", 80),
("detail", "Details", 430),
("fee", "Gebühr", 100),
):
self.reminders.heading(key, text=title)
self.reminders.column(key, width=width, anchor="w")
self.reminders.grid(row=0, column=0, sticky="nsew")
ttk.Button(parent, text="Mahnung erfassen", command=self._add_reminder).grid(
row=1, column=0, sticky="e", pady=(10, 0)
)
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 = allocated_total(self.data, self.claim_id)
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}"
)
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._render_positions()
self._render_payments()
self._render_reminders()
def _render_positions(self) -> None:
self.positions.delete(*self.positions.get_children())
for item in claim_items(self.claim):
self.positions.insert(
"",
"end",
iid=str(item.get("item_id", "")),
values=(
item.get("type", ""),
item.get("description", ""),
item.get("quantity", "1"),
item.get("unit_price", item.get("amount", "")),
item.get("amount", ""),
),
)
def _render_payments(self) -> None:
self.payments.delete(*self.payments.get_children())
payment_by_id = {str(item.get("payment_id")): item for item in self.data.payments}
for allocation in self.data.allocations:
if str(allocation.get("claim_id", "")) != self.claim_id:
continue
payment = payment_by_id.get(str(allocation.get("payment_id", "")), {})
self.payments.insert(
"",
"end",
iid=str(allocation.get("allocation_id", "")),
values=(
format_date_for_display(str(payment.get("date", ""))),
allocation.get("amount", ""),
payment.get("amount", ""),
payment.get("gnucash_transaction_id", ""),
payment.get("reference", ""),
),
)
def _render_reminders(self) -> None:
self.reminders.delete(*self.reminders.get_children())
items = {str(item.get("item_id")): item for item in claim_items(self.claim)}
for reminder in self.data.reminders:
if str(reminder.get("claim_id", "")) != self.claim_id:
continue
fee_item = items.get(str(reminder.get("fee_item_id", "")), {})
self.reminders.insert(
"",
"end",
iid=str(reminder.get("reminder_id", "")),
values=(
str(reminder.get("created_at", ""))[:16],
reminder.get("level", ""),
reminder.get("detail", ""),
fee_item.get("amount", "0.00"),
),
)
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 _add_reminder(self) -> None:
ReminderDialog(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 _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.grab_set()
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())
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 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="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=("base", "product", "service", "fee", "discount", "credit", "correction"),
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=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 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 data.payments:
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)
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):
_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 ReminderDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, on_saved):
super().__init__(master, "Mahnung erfassen", on_saved)
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
self.level_var = tk.IntVar(value=1)
self.detail_var = tk.StringVar()
self.fee_var = tk.StringVar(value="0.00")
ttk.Label(self.frame, text="Mahnstufe").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Spinbox(self.frame, from_=1, to=99, textvariable=self.level_var, width=8).grid(
row=0, column=1, sticky="w", pady=5
)
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)
self._buttons(3, self._save)
def _save(self):
try:
self.repository.add_reminder(
self.member_id,
self.claim_id,
level=self.level_var.get(),
detail=self.detail_var.get(),
fee=self.fee_var.get(),
)
except (tk.TclError, RepositoryError) as exc:
messagebox.showerror("Mahnung konnte nicht gespeichert werden", str(exc), parent=self)
return
self.destroy()
self.on_saved()