mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-24 14:35:19 +02:00
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:
co-authored by
Claude Sonnet 5
parent
42fb4c4224
commit
a5d9abd59a
@@ -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
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user