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 <noreply@anthropic.com>
This commit is contained in:
Marcel Peterkau
2026-08-14 21:22:08 +02:00
co-authored by Claude Sonnet 5
parent 42fb4c4224
commit a5d9abd59a
3 changed files with 530 additions and 163 deletions
+135 -6
View File
@@ -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(
+300 -157
View File
@@ -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("<<TreeviewSelect>>", 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("<Escape>", 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("<Escape>", 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("<<TreeviewSelect>>", 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(),
)
+95
View File
@@ -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(