From a5d9abd59a7a78da25837e2d174f74c4d538662a Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Fri, 14 Aug 2026 21:22:08 +0200 Subject: [PATCH] Allocate open claims and donations directly from the payment dialog Recording a payment previously meant saving it bare, then separately opening a claim to allocate money to it. Zahlung anlegen now lists a member's open claims and donations right in the same dialog with a select + amount field to assign parts of the payment on the spot, and a "Neue Spende anlegen" button to create a donation inline and allocate to it immediately -- covering members who pay more than the membership fee in one transfer. Shared the same allocation table in the existing payment-edit dialog so editing a payment shows and preserves donation allocations too; before this, saving an edited payment silently dropped any donation allocation because update_payment only round-tripped claim allocations. Co-Authored-By: Claude Sonnet 5 --- src/ccma/storage/repository.py | 141 +++++++++- src/ccma/ui/payment_dialog.py | 457 ++++++++++++++++++++++----------- tests/test_contributions.py | 95 +++++++ 3 files changed, 530 insertions(+), 163 deletions(-) diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index ceb1c4c..3bdc5cb 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -1106,13 +1106,16 @@ class MemberRepository: *, payment_date: str, amount: str, + claim_allocations: dict[str, str] | None = None, + donation_allocations: dict[str, str] | None = None, gnucash_transaction_id: str = "", reference: str = "", method: str = "bank_transfer", actor_name: str = "Vorstand", ) -> dict: - """Record an incoming payment without allocating it yet. Useful for logging - a bank transfer as soon as it arrives, to be assigned to claims or donations later.""" + """Record an incoming payment, optionally allocating parts of it immediately to + open claims and/or donations. Called with no allocations, this just logs a bank + transfer as soon as it arrives, to be assigned to claims or donations later.""" self.get_member(member_id) try: normalized_date = normalize_date_input(payment_date, "Zahlungsdatum") @@ -1126,6 +1129,56 @@ class MemberRepository: gnucash_id = gnucash_transaction_id.strip() if gnucash_id: self._assert_gnucash_id_available(gnucash_id) + + data = self.get_contributions(member_id) + claims_by_id = {str(claim.get("claim_id", "")): claim for claim in data.claims} + donations_by_id = {str(item.get("donation_id", "")): item for item in data.donations} + + selected_claim_allocations: dict[str, Decimal] = {} + for claim_id, raw_amount in (claim_allocations or {}).items(): + claim = claims_by_id.get(claim_id) + if claim is None: + raise RepositoryError(f"Forderung nicht gefunden: {claim_id}") + if str(claim.get("status", "")) == "cancelled": + raise RepositoryError("Eine stornierte Forderung kann nicht bezahlt werden.") + try: + selected = decimal_value(raw_amount, "Zuordnung") + except ValueError as exc: + raise RepositoryError(str(exc)) from exc + if selected <= 0: + continue + available = max(claim_balance(data, claim), Decimal("0")) + if selected > available: + raise RepositoryError( + f"{claim.get('title', 'Forderung')} hat nur {money_text(available)} EUR offen." + ) + selected_claim_allocations[claim_id] = selected + + selected_donation_allocations: dict[str, Decimal] = {} + for donation_id, raw_amount in (donation_allocations or {}).items(): + donation = donations_by_id.get(donation_id) + if donation is None: + raise RepositoryError(f"Spende nicht gefunden: {donation_id}") + try: + selected = decimal_value(raw_amount, "Zuordnung") + except ValueError as exc: + raise RepositoryError(str(exc)) from exc + if selected <= 0: + continue + available = max(donation_balance(data, donation), Decimal("0")) + if selected > available: + raise RepositoryError(f"Die Spende hat nur noch {money_text(available)} EUR offen.") + selected_donation_allocations[donation_id] = selected + + allocated_total_amount = sum(selected_claim_allocations.values(), Decimal("0")) + sum( + selected_donation_allocations.values(), Decimal("0") + ) + if allocated_total_amount > selected_amount: + raise RepositoryError( + f"Die Zuordnungen ({money_text(allocated_total_amount)} EUR) übersteigen den " + f"Zahlungsbetrag ({money_text(selected_amount)} EUR)." + ) + payment = { "payment_id": str(uuid4()), "date": normalized_date, @@ -1135,7 +1188,24 @@ class MemberRepository: "reference": reference.strip(), "created_at": datetime.now().astimezone().isoformat(timespec="seconds"), } - data = self.get_contributions(member_id) + for claim_id, claim_amount in selected_claim_allocations.items(): + data.allocations.append( + { + "allocation_id": str(uuid4()), + "payment_id": payment["payment_id"], + "claim_id": claim_id, + "amount": money_text(claim_amount), + } + ) + for donation_id, donation_amount in selected_donation_allocations.items(): + data.allocations.append( + { + "allocation_id": str(uuid4()), + "payment_id": payment["payment_id"], + "donation_id": donation_id, + "amount": money_text(donation_amount), + } + ) data.payments.append(payment) self.save_contributions(member_id, data) self.append_event( @@ -1145,7 +1215,11 @@ class MemberRepository: actor_type="user", actor_name=actor_name, references={"payment_id": str(payment["payment_id"])}, - data={"allocation_amount": "0.00"}, + data={ + "allocated_amount": money_text(allocated_total_amount), + "claim_ids": list(selected_claim_allocations), + "donation_ids": list(selected_donation_allocations), + }, ) return payment @@ -1193,9 +1267,13 @@ class MemberRepository: payment_date: str, amount: str, allocations: dict[str, str], + donation_allocations: dict[str, str] | None = None, gnucash_transaction_id: str = "", reference: str = "", ) -> dict: + """`allocations` fully replaces the claim allocations of this payment, and + `donation_allocations` (if given) fully replaces its donation allocations. Omit + `donation_allocations` to leave any existing donation allocations untouched.""" data = self.get_contributions(member_id) payment = next( (item for item in data.payments if str(item.get("payment_id", "")) == payment_id), @@ -1214,10 +1292,15 @@ class MemberRepository: raise RepositoryError("Der Zahlungsbetrag muss größer als null sein.") claims_by_id = {str(claim.get("claim_id", "")): claim for claim in data.claims} + donations_by_id = {str(item.get("donation_id", "")): item for item in data.donations} old_allocations = [item for item in data.allocations if str(item.get("payment_id", "")) == payment_id] old_by_claim: dict[str, list[dict]] = {} + old_by_donation: dict[str, list[dict]] = {} for allocation in old_allocations: - old_by_claim.setdefault(str(allocation.get("claim_id", "")), []).append(allocation) + if str(allocation.get("donation_id", "")): + old_by_donation.setdefault(str(allocation.get("donation_id", "")), []).append(allocation) + else: + old_by_claim.setdefault(str(allocation.get("claim_id", "")), []).append(allocation) selected_allocations: dict[str, Decimal] = {} for claim_id, raw_amount in allocations.items(): @@ -1247,7 +1330,43 @@ class MemberRepository: ) selected_allocations[claim_id] = allocation_amount - allocated_sum = sum(selected_allocations.values(), Decimal("0")) + if donation_allocations is None: + selected_donation_allocations = { + donation_id: sum( + (decimal_value(item.get("amount", "0")) for item in items), Decimal("0") + ) + for donation_id, items in old_by_donation.items() + } + else: + selected_donation_allocations = {} + for donation_id, raw_amount in donation_allocations.items(): + if donation_id not in donations_by_id: + raise RepositoryError(f"Spende nicht gefunden: {donation_id}") + try: + donation_amount = decimal_value(raw_amount, "Zuordnung") + except ValueError as exc: + raise RepositoryError(str(exc)) from exc + if donation_amount < 0: + raise RepositoryError("Zuordnungen dürfen nicht negativ sein.") + if donation_amount == 0: + continue + donation = donations_by_id[donation_id] + currently_allocated = sum( + (decimal_value(item.get("amount", "0")) for item in old_by_donation.get(donation_id, [])), + Decimal("0"), + ) + available_balance = max( + donation_balance(data, donation) + currently_allocated, Decimal("0") + ) + if donation_amount > available_balance: + raise RepositoryError( + f"Die Spende hat nur noch {money_text(available_balance)} EUR offen." + ) + selected_donation_allocations[donation_id] = donation_amount + + allocated_sum = sum(selected_allocations.values(), Decimal("0")) + sum( + selected_donation_allocations.values(), Decimal("0") + ) if allocated_sum > selected_amount: raise RepositoryError( f"Die Zuordnungen ({money_text(allocated_sum)} EUR) übersteigen den " @@ -1281,6 +1400,16 @@ class MemberRepository: "amount": money_text(allocation_amount), } ) + for donation_id, donation_amount in selected_donation_allocations.items(): + prior = old_by_donation.get(donation_id, []) + new_allocations.append( + { + "allocation_id": (str(prior[0].get("allocation_id", "")) if prior else str(uuid4())), + "payment_id": payment_id, + "donation_id": donation_id, + "amount": money_text(donation_amount), + } + ) data.allocations = retained_allocations + new_allocations self.save_contributions(member_id, data) self.append_event( diff --git a/src/ccma/ui/payment_dialog.py b/src/ccma/ui/payment_dialog.py index 9ce9437..fb78c8d 100644 --- a/src/ccma/ui/payment_dialog.py +++ b/src/ccma/ui/payment_dialog.py @@ -10,16 +10,216 @@ 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. + + Mutates the ``claim_allocations``/``donation_allocations`` dicts it is given in + place, so the owning dialog can read them back at save time. Lets the user create + a new donation on the fly and allocate against it immediately, which is the whole + point of surfacing this inside the payment dialog rather than as a separate step. + """ + + 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], + ): + self.repository = repository + self.member_id = member_id + self.claim_allocations = claim_allocations + self.donation_allocations = donation_allocations + self.on_change = on_change + self._load_targets() + self._build(parent) + self._refresh() + + def _load_targets(self) -> None: + self.data = self.repository.get_contributions(self.member_id) + 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.claim_allocations + ) + } + self.donations_by_id = {str(item.get("donation_id", "")): item for item in self.data.donations} + + def _capacities(self) -> None: + self.claim_capacity: dict[str, Decimal] = {} + for claim_id, claim in self.claims_by_id.items(): + current = decimal_value(self.claim_allocations.get(claim_id, "0")) + self.claim_capacity[claim_id] = max(claim_balance(self.data, claim) + current, Decimal("0")) + self.donation_capacity: dict[str, Decimal] = {} + for donation_id, donation in self.donations_by_id.items(): + current = decimal_value(self.donation_allocations.get(donation_id, "0")) + self.donation_capacity[donation_id] = max( + donation_balance(self.data, donation) + current, 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 + self.amount_var.set(allocations.get(target_id, "0.00")) + + 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 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.""" + """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, @@ -32,13 +232,33 @@ class PaymentCreateDialog(tk.Toplevel): 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.resizable(False, False) + self.geometry("880x560") + self.minsize(720, 460) + self.protocol("WM_DELETE_WINDOW", self.destroy) self.bind("", 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._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(), @@ -52,24 +272,44 @@ class PaymentCreateDialog(tk.Toplevel): ("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(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 ) - 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)) + 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, + ) + + 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.after_idle(self.grab_set) + ttk.Button( + buttons, text="Speichern", style="Accent.TButton", command=self._save + ).pack(side="left") + self._refresh_totals() + + def _refresh_totals(self) -> None: + allocated = self.allocation_table.total_allocated() if self.allocation_table else 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: @@ -77,6 +317,8 @@ class PaymentCreateDialog(tk.Toplevel): 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(), ) @@ -112,17 +354,8 @@ class PaymentEditDialog(tk.Toplevel): ) 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.claim_allocations, self.donation_allocations = self._current_allocations() + self.allocation_table: _AllocationTable | None = None self.title("Zahlung bearbeiten") self.transient(master.winfo_toplevel()) @@ -131,26 +364,26 @@ class PaymentEditDialog(tk.Toplevel): 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] = {} + 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", "")) - 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 + 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) @@ -178,51 +411,25 @@ class PaymentEditDialog(tk.Toplevel): 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" + + 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, + ) + + 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=2, column=0, sticky="e") + 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, @@ -230,6 +437,7 @@ class PaymentEditDialog(tk.Toplevel): style="Accent.TButton", command=self._save, ).pack(side="left") + self._refresh_totals() def _activate(self) -> None: try: @@ -240,74 +448,8 @@ class PaymentEditDialog(tk.Toplevel): 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") - ) + allocated = self.allocation_table.total_allocated() if self.allocation_table else Decimal("0") try: payment_amount = decimal_value(self.variables["amount"].get()) free = payment_amount - allocated @@ -324,7 +466,8 @@ class PaymentEditDialog(tk.Toplevel): self.payment_id, payment_date=self.variables["date"].get(), amount=self.variables["amount"].get(), - allocations=self.allocations, + allocations=self.claim_allocations, + donation_allocations=self.donation_allocations, gnucash_transaction_id=self.variables["gnucash"].get(), reference=self.variables["reference"].get(), ) diff --git a/tests/test_contributions.py b/tests/test_contributions.py index 793d5ba..d389e2d 100644 --- a/tests/test_contributions.py +++ b/tests/test_contributions.py @@ -441,6 +441,101 @@ def test_existing_free_payment_can_be_allocated_to_a_donation(tmp_path) -> None: ) +def test_payment_can_be_created_with_immediate_claim_and_donation_allocation(tmp_path) -> None: + repository, member = _repository_with_claim(tmp_path, amount="30.00") + donation = repository.record_donation( + member.member_id, donation_date="2026-06-21", amount="20.00", reference="Aufrundung" + ) + + payment = repository.create_payment( + member.member_id, + payment_date="2026-06-21", + amount="50.00", + claim_allocations={"claim-1": "30.00"}, + donation_allocations={donation["donation_id"]: "20.00"}, + reference="Mitgliedsbeitrag plus Spende", + ) + + data = repository.get_contributions(member.member_id) + _data, claim = repository.get_claim(member.member_id, "claim-1") + assert claim_balance(data, claim) == Decimal("0.00") + assert donation_balance(data, donation) == Decimal("0.00") + assert payment_allocated_total(data, payment["payment_id"]) == Decimal("50.00") + + +def test_payment_creation_rejects_allocations_exceeding_amount(tmp_path) -> None: + repository, member = _repository_with_claim(tmp_path, amount="30.00") + + with pytest.raises(RepositoryError, match="übersteigen den"): + repository.create_payment( + member.member_id, + payment_date="2026-06-21", + amount="10.00", + claim_allocations={"claim-1": "30.00"}, + ) + + +def test_updating_payment_without_donation_allocations_preserves_existing_ones(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Donation", last_name="Preserve") + donation = repository.record_donation( + member.member_id, donation_date="2026-06-21", amount="20.00", reference="Sommerfest" + ) + payment = repository.record_donation_payment( + member.member_id, + donation["donation_id"], + payment_date="2026-06-21", + amount="20.00", + allocation_amount="20.00", + ) + + repository.update_payment( + member.member_id, + payment["payment_id"], + payment_date="22.06.2026", + amount="20.00", + allocations={}, + reference="Korrigierte Referenz", + ) + + data = repository.get_contributions(member.member_id) + assert donation_allocated_total(data, donation["donation_id"]) == Decimal("20.00") + assert data.payments[0]["reference"] == "Korrigierte Referenz" + + +def test_updating_payment_with_donation_allocations_replaces_them(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Donation", last_name="Replace") + first_donation = repository.record_donation( + member.member_id, donation_date="2026-06-21", amount="20.00", reference="Erste Spende" + ) + second_donation = repository.record_donation( + member.member_id, donation_date="2026-06-21", amount="20.00", reference="Zweite Spende" + ) + payment = repository.record_donation_payment( + member.member_id, + first_donation["donation_id"], + payment_date="2026-06-21", + amount="20.00", + allocation_amount="20.00", + ) + + repository.update_payment( + member.member_id, + payment["payment_id"], + payment_date="2026-06-21", + amount="20.00", + allocations={}, + donation_allocations={second_donation["donation_id"]: "20.00"}, + ) + + data = repository.get_contributions(member.member_id) + assert donation_allocated_total(data, first_donation["donation_id"]) == Decimal("0.00") + assert donation_allocated_total(data, second_donation["donation_id"]) == Decimal("20.00") + + def test_gnucash_id_is_unique_across_member_store(tmp_path) -> None: repository, first_member = _repository_with_claim(tmp_path / "store") repository.record_payment(