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 from ccma.domain.contributions import ( claim_balance, claim_status, decimal_value, donation_balance, money_text, ) from ccma.domain.dates import date_input_hint, format_date_for_display from ccma.storage.repository import MemberRepository, RepositoryError from ccma.ui.donation_dialog import DonationEditDialog class _AllocationTable: """Embeds a combined claims + donations allocation tree into a payment dialog. Only claims/donations that are still open (or already linked to this payment) are listed, so fully-settled ones don't clutter the picture. A row's visibility is decided once, from a snapshot taken when the dialog opens (or when a new donation is created inline) -- it never disappears again for the rest of the editing session just because the user unassigned it. That way "Zuordnung lösen" followed by "Zuordnung setzen" always works on the same row instead of the target vanishing. Mutates the ``claim_allocations``/``donation_allocations`` dicts it is given in place, so the owning dialog can read them back at save time. Nothing is written to the repository until the dialog's own Save button does so. """ def __init__( self, parent: tk.Misc, repository: MemberRepository, member_id: str, *, claim_allocations: dict[str, str], donation_allocations: dict[str, str], on_change: Callable[[], None], get_payment_amount: Callable[[], Decimal], ): self.repository = repository self.member_id = member_id self.claim_allocations = claim_allocations self.donation_allocations = donation_allocations self.on_change = on_change self.get_payment_amount = get_payment_amount # Fixed snapshot of what this payment already covered when the dialog opened. # Capacities are computed against this baseline rather than the live, editable # dicts above -- otherwise "Maximal zuordenbar" would inflate every time the # user changes an amount, since claim_balance() reflects only persisted state. self._baseline_claim_allocations = dict(claim_allocations) self._baseline_donation_allocations = dict(donation_allocations) self._visible_claim_ids: set[str] = set(claim_allocations) self._visible_donation_ids: set[str] = set(donation_allocations) self._load_targets() self._build(parent) self._refresh() def _load_targets(self) -> None: self.data = self.repository.get_contributions(self.member_id) self._visible_claim_ids |= { str(claim.get("claim_id", "")) for claim in self.data.claims if str(claim.get("claim_id", "")) and claim_status(self.data, claim) != "cancelled" and claim_balance(self.data, claim) > 0 } self._visible_donation_ids |= { str(item.get("donation_id", "")) for item in self.data.donations if str(item.get("donation_id", "")) and donation_balance(self.data, item) > 0 } self.claims_by_id = { str(claim.get("claim_id", "")): claim for claim in self.data.claims if str(claim.get("claim_id", "")) in self._visible_claim_ids } self.donations_by_id = { str(item.get("donation_id", "")): item for item in self.data.donations if str(item.get("donation_id", "")) in self._visible_donation_ids } def _capacities(self) -> None: self.claim_capacity: dict[str, Decimal] = {} for claim_id, claim in self.claims_by_id.items(): baseline = decimal_value(self._baseline_claim_allocations.get(claim_id, "0")) self.claim_capacity[claim_id] = max(claim_balance(self.data, claim) + baseline, Decimal("0")) self.donation_capacity: dict[str, Decimal] = {} for donation_id, donation in self.donations_by_id.items(): baseline = decimal_value(self._baseline_donation_allocations.get(donation_id, "0")) self.donation_capacity[donation_id] = max( donation_balance(self.data, donation) + baseline, Decimal("0") ) def _build(self, parent: tk.Misc) -> None: parent.columnconfigure(0, weight=1) parent.rowconfigure(0, weight=1) self.tree = ttk.Treeview( parent, columns=("kind", "title", "date", "capacity", "allocated"), show="headings", selectmode="browse", ) for key, title, width in ( ("kind", "Typ", 90), ("title", "Forderung / Spende", 260), ("date", "Fällig / Datum", 110), ("capacity", "Maximal zuordenbar", 140), ("allocated", "Aktuell zugeordnet", 140), ): self.tree.heading(key, text=title) self.tree.column(key, width=width, anchor="w") self.tree.grid(row=0, column=0, sticky="nsew") scrollbar = ttk.Scrollbar(parent, orient="vertical", command=self.tree.yview) scrollbar.grid(row=0, column=1, sticky="ns") self.tree.configure(yscrollcommand=scrollbar.set) self.tree.bind("<>", self._select) actions = ttk.Frame(parent) actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(10, 0)) ttk.Label(actions, text="Betrag für Auswahl").pack(side="left") self.amount_var = tk.StringVar() ttk.Entry(actions, textvariable=self.amount_var, width=14).pack(side="left", padx=(8, 8)) ttk.Button(actions, text="Zuordnung setzen", command=self._set).pack(side="left") ttk.Button(actions, text="Zuordnung lösen", command=self._remove).pack(side="left", padx=(8, 0)) ttk.Separator(actions, orient="vertical").pack(side="left", fill="y", padx=10) ttk.Button(actions, text="Neue Spende anlegen", command=self._create_donation).pack(side="left") def _refresh(self) -> None: selected = self.tree.selection() self.tree.delete(*self.tree.get_children()) self._capacities() for claim_id, claim in sorted( self.claims_by_id.items(), key=lambda item: (str(item[1].get("due_date", "")), str(item[1].get("title", "")).casefold()), ): self.tree.insert( "", "end", iid=f"claim:{claim_id}", values=( "Forderung", claim.get("title", "Forderung"), format_date_for_display(str(claim.get("due_date", ""))), f"{money_text(self.claim_capacity[claim_id])} EUR", f"{self.claim_allocations.get(claim_id, '0.00')} EUR", ), ) for donation_id, donation in sorted( self.donations_by_id.items(), key=lambda item: str(item[1].get("date", "")), reverse=True ): label = donation.get("reference") or donation.get("purpose") or "Spende" self.tree.insert( "", "end", iid=f"donation:{donation_id}", values=( "Spende", label, format_date_for_display(str(donation.get("date", ""))), f"{money_text(self.donation_capacity[donation_id])} EUR", f"{self.donation_allocations.get(donation_id, '0.00')} EUR", ), ) if selected and self.tree.exists(selected[0]): self.tree.selection_set(selected[0]) self.on_change() @staticmethod def _target(iid: str) -> tuple[str, str]: kind, target_id = iid.split(":", 1) return kind, target_id def _select(self, _event: tk.Event | None = None) -> None: selected = self.tree.selection() if not selected: return kind, target_id = self._target(selected[0]) allocations = self.claim_allocations if kind == "claim" else self.donation_allocations current = allocations.get(target_id) if current: # Already allocated: show the existing amount so it can be reviewed/edited. self.amount_var.set(current) return # Not yet allocated: suggest whichever is smaller -- what this claim/donation # still needs, or what's left of the payment -- so the common case is just # "click the row, then Zuordnung setzen". capacity = (self.claim_capacity if kind == "claim" else self.donation_capacity)[target_id] free = max(self._payment_amount() - self.total_allocated(), Decimal("0")) self.amount_var.set(money_text(min(capacity, free))) def _payment_amount(self) -> Decimal: try: return self.get_payment_amount() except ValueError: return Decimal("0") def _set(self) -> None: selected = self.tree.selection() if not selected: messagebox.showerror( "Auswahl fehlt", "Bitte eine Forderung oder Spende auswählen.", parent=self.tree ) return kind, target_id = self._target(selected[0]) try: value = decimal_value(self.amount_var.get(), "Zuordnung") except ValueError as exc: messagebox.showerror("Ungültige Zuordnung", str(exc), parent=self.tree) return capacity = (self.claim_capacity if kind == "claim" else self.donation_capacity)[target_id] if value < 0 or value > capacity: messagebox.showerror( "Ungültige Zuordnung", f"Es können höchstens {money_text(capacity)} EUR zugeordnet werden.", parent=self.tree, ) return allocations = self.claim_allocations if kind == "claim" else self.donation_allocations if value: allocations[target_id] = money_text(value) else: allocations.pop(target_id, None) self._refresh() def _remove(self) -> None: selected = self.tree.selection() if not selected: messagebox.showerror( "Auswahl fehlt", "Bitte eine Forderung oder Spende auswählen.", parent=self.tree ) return kind, target_id = self._target(selected[0]) allocations = self.claim_allocations if kind == "claim" else self.donation_allocations allocations.pop(target_id, None) self.amount_var.set("0.00") self._refresh() def _create_donation(self) -> None: DonationEditDialog(self.tree, self.repository, self.member_id, self._donation_created) def _donation_created(self) -> None: self._load_targets() self._refresh() def total_allocated(self) -> Decimal: return sum( ( decimal_value(value) for value in (*self.claim_allocations.values(), *self.donation_allocations.values()) ), Decimal("0"), ) class PaymentCreateDialog(tk.Toplevel): """Records a payment and lets the board allocate parts of it to open claims and/or donations right away — including creating a new donation on the fly — instead of having to save the bare payment first and assign it in a separate step.""" 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.claim_allocations: dict[str, str] = {} self.donation_allocations: dict[str, str] = {} self.allocation_table: _AllocationTable | None = None self.title("Zahlung anlegen") self.transient(master.winfo_toplevel()) self.geometry("880x560") self.minsize(720, 460) self.protocol("WM_DELETE_WINDOW", self.destroy) self.bind("", lambda _event: self.destroy()) self._build_ui() self.after_idle(self._activate) def _activate(self) -> None: try: self.deiconify() self.lift() self.focus_force() self.grab_set() except tk.TclError: return def _build_ui(self) -> None: self.columnconfigure(0, weight=1) self.rowconfigure(1, weight=1) form = ttk.Frame(self, padding=16) form.grid(row=0, column=0, sticky="ew") form.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(form, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=4) ttk.Entry(form, textvariable=self.variables[key], width=70).grid( row=row, column=1, sticky="ew", pady=4 ) self.variables["amount"].trace_add("write", lambda *_args: self._refresh_totals()) self.total_var = tk.StringVar() allocation_frame = ttk.LabelFrame(self, text="Sofort zuordnen (optional)", padding=12) allocation_frame.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12)) self.allocation_table = _AllocationTable( allocation_frame, self.repository, self.member_id, claim_allocations=self.claim_allocations, donation_allocations=self.donation_allocations, on_change=self._refresh_totals, get_payment_amount=self._current_payment_amount, ) ttk.Label(self, textvariable=self.total_var, style="Mono.TLabel").grid( row=2, column=0, sticky="w", padx=16, pady=(0, 8) ) buttons = ttk.Frame(self, padding=(16, 0, 16, 16)) buttons.grid(row=3, column=0, sticky="e") 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._refresh_totals() def _current_payment_amount(self) -> Decimal: return decimal_value(self.variables["amount"].get()) def _refresh_totals(self) -> None: allocated = self.allocation_table.total_allocated() if self.allocation_table else Decimal("0") try: payment_amount = self._current_payment_amount() free = payment_amount - allocated self.total_var.set(f"Zugeordnet: {money_text(allocated)} EUR · Frei: {money_text(free)} EUR") except (ValueError, InvalidOperation): self.total_var.set(f"Zugeordnet: {money_text(allocated)} EUR · Betrag ungültig") def _save(self) -> None: try: self.repository.create_payment( self.member_id, payment_date=self.variables["date"].get(), amount=self.variables["amount"].get(), claim_allocations=self.claim_allocations, donation_allocations=self.donation_allocations, 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, master: tk.Misc, repository: MemberRepository, member_id: str, payment_id: str, on_saved: Callable[[], None], ): super().__init__(master) self.repository = repository self.member_id = member_id self.payment_id = payment_id self.on_saved = on_saved self.data = repository.get_contributions(member_id) self.payment = next( ( payment for payment in self.data.payments if str(payment.get("payment_id", "")) == payment_id ), None, ) if self.payment is None: raise RepositoryError("Zahlung nicht gefunden.") self.claim_allocations, self.donation_allocations = self._current_allocations() self.allocation_table: _AllocationTable | None = None self.title("Zahlung bearbeiten") self.transient(master.winfo_toplevel()) self.geometry("880x560") self.minsize(720, 460) self.protocol("WM_DELETE_WINDOW", self.destroy) self.bind("", lambda _event: self.destroy()) self._build_ui() self.after_idle(self._activate) def _current_allocations(self) -> tuple[dict[str, str], dict[str, str]]: claim_totals: dict[str, Decimal] = {} donation_totals: dict[str, Decimal] = {} for allocation in self.data.allocations: if str(allocation.get("payment_id", "")) != self.payment_id: continue amount = decimal_value(allocation.get("amount", "0")) donation_id = str(allocation.get("donation_id", "")) if donation_id: donation_totals[donation_id] = donation_totals.get(donation_id, Decimal("0")) + amount continue claim_id = str(allocation.get("claim_id", "")) if claim_id: claim_totals[claim_id] = claim_totals.get(claim_id, Decimal("0")) + amount return ( {claim_id: money_text(value) for claim_id, value in claim_totals.items()}, {donation_id: money_text(value) for donation_id, value in donation_totals.items()}, ) def _build_ui(self) -> None: self.columnconfigure(0, weight=1) self.rowconfigure(1, weight=1) form = ttk.Frame(self, padding=16) form.grid(row=0, column=0, sticky="ew") form.columnconfigure(1, weight=1) self.variables = { "date": tk.StringVar( value=format_date_for_display(str(self.payment.get("date", ""))) ), "amount": tk.StringVar(value=str(self.payment.get("amount", ""))), "gnucash": tk.StringVar(value=str(self.payment.get("gnucash_transaction_id", ""))), "reference": tk.StringVar(value=str(self.payment.get("reference", ""))), } fields = ( (f"Zahlungsdatum ({date_input_hint()})", "date"), ("Zahlungsbetrag", "amount"), ("GnuCash-ID", "gnucash"), ("Referenz", "reference"), ) for row, (label, key) in enumerate(fields): ttk.Label(form, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=4) ttk.Entry(form, textvariable=self.variables[key], width=70).grid( row=row, column=1, sticky="ew", pady=4 ) self.variables["amount"].trace_add("write", lambda *_args: self._refresh_totals()) self.total_var = tk.StringVar() allocation_frame = ttk.LabelFrame(self, text="Aufteilung auf Forderungen und Spenden", padding=12) allocation_frame.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12)) self.allocation_table = _AllocationTable( allocation_frame, self.repository, self.member_id, claim_allocations=self.claim_allocations, donation_allocations=self.donation_allocations, on_change=self._refresh_totals, get_payment_amount=self._current_payment_amount, ) ttk.Label(self, textvariable=self.total_var, style="Mono.TLabel").grid( row=2, column=0, sticky="w", padx=16, pady=(0, 8) ) buttons = ttk.Frame(self, padding=(16, 0, 16, 16)) buttons.grid(row=3, column=0, sticky="e") ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8)) ttk.Button( buttons, text="Zahlung speichern", style="Accent.TButton", command=self._save, ).pack(side="left") self._refresh_totals() def _activate(self) -> None: try: self.deiconify() self.lift() self.focus_force() self.grab_set() except tk.TclError: return def _current_payment_amount(self) -> Decimal: return decimal_value(self.variables["amount"].get()) def _refresh_totals(self) -> None: allocated = self.allocation_table.total_allocated() if self.allocation_table else Decimal("0") try: payment_amount = self._current_payment_amount() free = payment_amount - allocated self.total_var.set( f"Zugeordnet: {money_text(allocated)} EUR · Frei: {money_text(free)} EUR" ) except (ValueError, InvalidOperation): self.total_var.set(f"Zugeordnet: {money_text(allocated)} EUR · Betrag ungültig") def _save(self) -> None: try: self.repository.update_payment( self.member_id, self.payment_id, payment_date=self.variables["date"].get(), amount=self.variables["amount"].get(), allocations=self.claim_allocations, donation_allocations=self.donation_allocations, 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()