from __future__ import annotations import tkinter as tk from collections.abc import Callable from decimal import Decimal, InvalidOperation from tkinter import messagebox, ttk from ccma.domain.contributions import ( claim_balance, claim_status, decimal_value, money_text, ) from ccma.domain.dates import date_input_hint, format_date_for_display from ccma.storage.repository import MemberRepository, RepositoryError 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.allocations = self._current_allocations() self.claims_by_id = { str(claim.get("claim_id", "")): claim for claim in self.data.claims if str(claim.get("claim_id", "")) and ( claim_status(self.data, claim) != "cancelled" or str(claim.get("claim_id", "")) in self.allocations ) } self.capacities = self._claim_capacities() 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._refresh_claims() self.after_idle(self._activate) def _current_allocations(self) -> dict[str, str]: totals: dict[str, Decimal] = {} for allocation in self.data.allocations: if str(allocation.get("payment_id", "")) != self.payment_id: continue claim_id = str(allocation.get("claim_id", "")) totals[claim_id] = totals.get(claim_id, Decimal("0")) + decimal_value( allocation.get("amount", "0") ) return {claim_id: money_text(amount) for claim_id, amount in totals.items() if claim_id} def _claim_capacities(self) -> dict[str, Decimal]: capacities = {} for claim_id, claim in self.claims_by_id.items(): current = decimal_value(self.allocations.get(claim_id, "0")) capacities[claim_id] = max(claim_balance(self.data, claim) + current, Decimal("0")) return capacities 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()) allocation_frame = ttk.LabelFrame(self, text="Aufteilung auf Forderungen", padding=12) allocation_frame.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12)) allocation_frame.columnconfigure(0, weight=1) allocation_frame.rowconfigure(0, weight=1) self.claims = ttk.Treeview( allocation_frame, columns=("title", "due", "available", "allocated"), show="headings", selectmode="browse", ) for key, title, width in ( ("title", "Forderung", 300), ("due", "Fällig", 110), ("available", "Maximal zuordenbar", 150), ("allocated", "Aktuell zugeordnet", 150), ): self.claims.heading(key, text=title) self.claims.column(key, width=width, anchor="w") self.claims.grid(row=0, column=0, sticky="nsew") scrollbar = ttk.Scrollbar(allocation_frame, orient="vertical", command=self.claims.yview) scrollbar.grid(row=0, column=1, sticky="ns") self.claims.configure(yscrollcommand=scrollbar.set) self.claims.bind("<>", self._select_claim) allocation_actions = ttk.Frame(allocation_frame) allocation_actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(10, 0)) ttk.Label(allocation_actions, text="Betrag für ausgewählte Forderung").pack(side="left") self.allocation_var = tk.StringVar() ttk.Entry(allocation_actions, textvariable=self.allocation_var, width=14).pack( side="left", padx=(8, 8) ) ttk.Button(allocation_actions, text="Zuordnung setzen", command=self._set_allocation).pack( side="left" ) ttk.Button(allocation_actions, text="Zuordnung lösen", command=self._remove_allocation).pack( side="left", padx=(8, 0) ) self.total_var = tk.StringVar() ttk.Label(allocation_actions, textvariable=self.total_var, style="Mono.TLabel").pack( side="right" ) buttons = ttk.Frame(self, padding=(16, 0, 16, 16)) buttons.grid(row=2, 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") def _activate(self) -> None: try: self.deiconify() self.lift() self.focus_force() self.grab_set() except tk.TclError: return def _refresh_claims(self) -> None: selected = self.claims.selection() self.claims.delete(*self.claims.get_children()) ordered = sorted( self.claims_by_id.items(), key=lambda item: ( str(item[1].get("due_date", "")), str(item[1].get("title", "")).casefold(), ), ) for claim_id, claim in ordered: self.claims.insert( "", "end", iid=claim_id, values=( claim.get("title", "Forderung"), format_date_for_display(str(claim.get("due_date", ""))), f"{money_text(self.capacities[claim_id])} EUR", f"{self.allocations.get(claim_id, '0.00')} EUR", ), ) if selected and self.claims.exists(selected[0]): self.claims.selection_set(selected[0]) self._refresh_totals() def _select_claim(self, _event=None) -> None: selected = self.claims.selection() if selected: self.allocation_var.set(self.allocations.get(selected[0], "0.00")) def _set_allocation(self) -> None: selected = self.claims.selection() if not selected: messagebox.showerror("Forderung auswählen", "Bitte eine Forderung auswählen.", parent=self) return try: amount = decimal_value(self.allocation_var.get(), "Zuordnung") except ValueError as exc: messagebox.showerror("Ungültige Zuordnung", str(exc), parent=self) return capacity = self.capacities[selected[0]] if amount < 0 or amount > capacity: messagebox.showerror( "Ungültige Zuordnung", f"Für diese Forderung können höchstens {money_text(capacity)} EUR zugeordnet werden.", parent=self, ) return if amount: self.allocations[selected[0]] = money_text(amount) else: self.allocations.pop(selected[0], None) self._refresh_claims() def _remove_allocation(self) -> None: selected = self.claims.selection() if not selected: messagebox.showerror("Forderung auswählen", "Bitte eine Forderung auswählen.", parent=self) return self.allocations.pop(selected[0], None) self.allocation_var.set("0.00") self._refresh_claims() def _refresh_totals(self) -> None: allocated = sum( (decimal_value(value) for value in self.allocations.values()), Decimal("0") ) try: payment_amount = decimal_value(self.variables["amount"].get()) 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.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()