diff --git a/README.md b/README.md index e386c6c..8bf1b4c 100644 --- a/README.md +++ b/README.md @@ -61,4 +61,17 @@ CCMA performs all file writes, duplicate checks, audit events, and atomic update the pending run ID. A failed run therefore cannot advance the stored counter or silently resolve existing tasks. +## Claims and payments + +Claims are stored in the member's `contributions.json`. A claim consists of +signed line items; fees increase and credits reduce its total. Payments remain +separate records and allocations connect one payment to one or more claims. +This supports partial payments, shared annual payments, unallocated credit, and +optional GnuCash transaction IDs without duplicating a bank transaction. + +Claim status and outstanding balance are derived from line items and payment +allocations. Reminders are separate processes and only change the amount when +they explicitly add a fee line item. Every change is also appended to the +member's `events.jsonl` audit trail. + Do not place a real member store inside the source repository. diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index 6eebccb..a61485e 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -23,6 +23,7 @@ "Ein Akten-Preflight sperrt bei beschädigten Mitglieder-, Beitrags- oder Eventdateien alle Regeln für die betroffene Akte.", "Der Hausmeister-Tab zeigt den vollständigen Inhalt eines markierten Vorgangs in einem mehrzeiligen Detailbereich.", "Hausmeister-Tasks lassen sich manuell löschen; Einträge entfernter Mitgliederakten werden beim nächsten Lauf bereinigt.", + "Forderungen besitzen eigene Tabs mit Positionen, Teilzahlungen, GnuCash-Referenzen, Zahlungszuordnungen, Mahnungen und Gebühren.", "Hausmeister um konfigurierbare Geburtstags- und Mitgliedsjubiläumsmeldungen erweitert.", "Statusänderungen werden mit altem und neuem Klartextwert in der Mitgliederchronik protokolliert.", "Fensterposition, normaler Fensterzustand und Maximierung werden gespeichert; der Splash startet auf dem zuletzt verwendeten Monitor.", diff --git a/src/ccma/domain/contributions.py b/src/ccma/domain/contributions.py new file mode 100644 index 0000000..140df2a --- /dev/null +++ b/src/ccma/domain/contributions.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from datetime import date +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation +from typing import Any + +from ccma.domain.models import ContributionData + +CENT = Decimal("0.01") + +CLAIM_STATUS_LABELS = { + "open": "OFFEN", + "partially_paid": "TEILBEZAHLT", + "paid": "BEZAHLT", + "overpaid": "ÜBERZAHLT", + "overdue": "ÜBERFÄLLIG", + "cancelled": "STORNIERT", +} + + +def decimal_value(value: Any, field_name: str = "Betrag") -> Decimal: + text = str(value).strip().replace(",", ".") + try: + return Decimal(text).quantize(CENT, rounding=ROUND_HALF_UP) + except (InvalidOperation, ValueError) as exc: + raise ValueError(f"{field_name} ist kein gültiger Geldbetrag.") from exc + + +def money_text(value: Decimal | str) -> str: + return f"{decimal_value(value):.2f}" + + +def claim_items(claim: dict[str, Any]) -> list[dict[str, Any]]: + items = claim.get("items") + if isinstance(items, list) and items: + return items + amount = decimal_value(claim.get("amount", "0")) + return [ + { + "item_id": "legacy-base", + "type": "base", + "description": str(claim.get("title") or "Forderung"), + "quantity": "1.00", + "unit_price": money_text(amount), + "amount": money_text(amount), + } + ] + + +def materialize_claim_items(claim: dict[str, Any]) -> list[dict[str, Any]]: + if not isinstance(claim.get("items"), list) or not claim["items"]: + claim["items"] = claim_items(claim) + return claim["items"] + + +def claim_total(claim: dict[str, Any]) -> Decimal: + return sum((decimal_value(item.get("amount", "0")) for item in claim_items(claim)), Decimal("0")) + + +def allocated_total(data: ContributionData, claim_id: str) -> Decimal: + return sum( + ( + decimal_value(allocation.get("amount", "0")) + for allocation in data.allocations + if str(allocation.get("claim_id", "")) == claim_id + ), + Decimal("0"), + ) + + +def payment_allocated_total(data: ContributionData, payment_id: str) -> Decimal: + return sum( + ( + decimal_value(allocation.get("amount", "0")) + for allocation in data.allocations + if str(allocation.get("payment_id", "")) == payment_id + ), + Decimal("0"), + ) + + +def claim_balance(data: ContributionData, claim: dict[str, Any]) -> Decimal: + return (claim_total(claim) - allocated_total(data, str(claim.get("claim_id", "")))).quantize(CENT) + + +def claim_status(data: ContributionData, claim: dict[str, Any], *, today: date | None = None) -> str: + if str(claim.get("status", "")) == "cancelled": + return "cancelled" + total = claim_total(claim) + paid = allocated_total(data, str(claim.get("claim_id", ""))) + balance = total - paid + if balance < 0: + return "overpaid" + if balance == 0: + return "paid" + if paid > 0: + return "partially_paid" + try: + due = date.fromisoformat(str(claim.get("due_date", ""))) + except ValueError: + due = None + if due and due < (today or date.today()): + return "overdue" + return "open" diff --git a/src/ccma/domain/models.py b/src/ccma/domain/models.py index bb34742..9cd2c84 100644 --- a/src/ccma/domain/models.py +++ b/src/ccma/domain/models.py @@ -138,6 +138,7 @@ class ContributionData: claims: list[dict[str, Any]] = field(default_factory=list) payments: list[dict[str, Any]] = field(default_factory=list) allocations: list[dict[str, Any]] = field(default_factory=list) + reminders: list[dict[str, Any]] = field(default_factory=list) schema_version: int = 1 def to_dict(self) -> dict[str, Any]: @@ -146,6 +147,7 @@ class ContributionData: "claims": self.claims, "payments": self.payments, "allocations": self.allocations, + "reminders": self.reminders, } @classmethod @@ -155,6 +157,7 @@ class ContributionData: claims=list(data.get("claims") or []), payments=list(data.get("payments") or []), allocations=list(data.get("allocations") or []), + reminders=list(data.get("reminders") or []), ) diff --git a/src/ccma/rules/scripts/claim_status.py b/src/ccma/rules/scripts/claim_status.py index 346b474..b0a025b 100644 --- a/src/ccma/rules/scripts/claim_status.py +++ b/src/ccma/rules/scripts/claim_status.py @@ -1,5 +1,6 @@ from datetime import date +from ccma.domain.contributions import claim_status from ccma.rules.api import RuleContext, task RULE_ID = "claim-status" @@ -9,7 +10,11 @@ ORDER = 50 def evaluate(context: RuleContext): actions = [] for claim in context.contributions.claims: - if str(claim.get("status", "open")) not in {"open", "partially_paid"}: + if claim_status(context.contributions, claim, today=context.today) not in { + "open", + "partially_paid", + "overdue", + }: continue try: due = date.fromisoformat(str(claim.get("due_date", ""))) diff --git a/src/ccma/services/housekeeper.py b/src/ccma/services/housekeeper.py index 79b0935..4df270a 100644 --- a/src/ccma/services/housekeeper.py +++ b/src/ccma/services/housekeeper.py @@ -229,6 +229,18 @@ class Housekeeper: "script_hash": rule.script_hash, }, } + if not claim.get("items"): + claim["items"] = [ + { + "item_id": str(uuid4()), + "type": "base", + "description": str(claim.get("title", claim_key)), + "quantity": "1.00", + "unit_price": str(claim.get("amount", "0.00")), + "amount": str(claim.get("amount", "0.00")), + "created_at": now, + } + ] data.claims.append(claim) self.repository.save_contributions(action.member_id, data) self.repository.append_event( diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index 2e433a0..bb255dc 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -9,6 +9,14 @@ from pathlib import Path from string import Formatter from uuid import uuid4 +from ccma.domain.contributions import ( + claim_balance, + claim_total, + decimal_value, + materialize_claim_items, + money_text, + payment_allocated_total, +) from ccma.domain.dates import DateValidationError, normalize_date_input, validate_member_dates from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, ContributionData, Event, Member from ccma.storage.atomic import read_json, write_json_atomic @@ -229,7 +237,7 @@ class MemberRepository: raw = read_json(path) if not isinstance(raw, dict): raise TypeError("Wurzelelement muss ein JSON-Objekt sein") - for field_name in ("claims", "payments", "allocations"): + for field_name in ("claims", "payments", "allocations", "reminders"): if field_name in raw and not isinstance(raw[field_name], list): raise TypeError(f"{field_name} muss eine JSON-Liste sein") if field_name in raw and any(not isinstance(item, dict) for item in raw[field_name]): @@ -244,6 +252,224 @@ class MemberRepository: self.get_member(member_id) write_json_atomic(self._member_path(member_id) / "contributions.json", data.to_dict()) + def get_claim(self, member_id: str, claim_id: str) -> tuple[ContributionData, dict]: + data = self.get_contributions(member_id) + claim = next( + (item for item in data.claims if str(item.get("claim_id", "")) == claim_id), + None, + ) + if claim is None: + raise RepositoryError(f"Forderung nicht gefunden: {claim_id}") + return data, claim + + def add_claim_item( + self, + member_id: str, + claim_id: str, + *, + description: str, + quantity: str, + unit_price: str, + item_type: str = "correction", + ) -> dict: + if not description.strip(): + raise RepositoryError("Eine Beschreibung ist erforderlich.") + try: + selected_quantity = decimal_value(quantity, "Menge") + selected_unit_price = decimal_value(unit_price, "Einzelpreis") + except ValueError as exc: + raise RepositoryError(str(exc)) from exc + if selected_quantity == 0: + raise RepositoryError("Die Menge darf nicht null sein.") + data, claim = self.get_claim(member_id, claim_id) + if str(claim.get("status", "")) == "cancelled": + raise RepositoryError("Eine stornierte Forderung kann nicht verändert werden.") + amount = selected_quantity * selected_unit_price + item = { + "item_id": str(uuid4()), + "type": item_type.strip() or "correction", + "description": description.strip(), + "quantity": money_text(selected_quantity), + "unit_price": money_text(selected_unit_price), + "amount": money_text(amount), + "created_at": datetime.now().astimezone().isoformat(timespec="seconds"), + } + materialize_claim_items(claim).append(item) + claim["amount"] = money_text(claim_total(claim)) + self.save_contributions(member_id, data) + self.append_event( + member_id, + event_type="claim_item_added", + summary=f"Forderungsposition ergänzt: {item['description']} ({item['amount']} EUR)", + references={"claim_id": claim_id, "item_id": str(item["item_id"])}, + ) + return item + + def record_payment( + self, + member_id: str, + claim_id: str, + *, + payment_date: str, + amount: str, + allocation_amount: str, + gnucash_transaction_id: str = "", + reference: str = "", + ) -> dict: + try: + normalized_date = normalize_date_input(payment_date, "Zahlungsdatum") + selected_amount = decimal_value(amount) + selected_allocation = decimal_value(allocation_amount, "Zuordnung") + except (DateValidationError, ValueError) as exc: + raise RepositoryError(str(exc)) from exc + if not normalized_date: + raise RepositoryError("Zahlungsdatum ist erforderlich.") + if selected_amount <= 0: + raise RepositoryError("Der Zahlungsbetrag muss größer als null sein.") + if selected_allocation <= 0 or selected_allocation > selected_amount: + raise RepositoryError( + "Die Zuordnung muss größer als null und höchstens so hoch wie die Zahlung sein." + ) + gnucash_id = gnucash_transaction_id.strip() + if gnucash_id: + self._assert_gnucash_id_available(gnucash_id) + data, claim = self.get_claim(member_id, claim_id) + payment = { + "payment_id": str(uuid4()), + "date": normalized_date, + "amount": money_text(selected_amount), + "method": "bank_transfer", + "gnucash_transaction_id": gnucash_id, + "reference": reference.strip(), + "created_at": datetime.now().astimezone().isoformat(timespec="seconds"), + } + allocation = { + "allocation_id": str(uuid4()), + "payment_id": payment["payment_id"], + "claim_id": claim_id, + "amount": money_text(selected_allocation), + } + data.payments.append(payment) + data.allocations.append(allocation) + self.save_contributions(member_id, data) + self.append_event( + member_id, + event_type="payment_recorded", + summary=f"Zahlung eingegangen: {payment['amount']} EUR", + references={"claim_id": claim_id, "payment_id": str(payment["payment_id"])}, + data={"allocation_amount": allocation["amount"]}, + ) + return payment + + def allocate_payment(self, member_id: str, claim_id: str, *, payment_id: str, amount: str) -> dict: + data, _claim = self.get_claim(member_id, claim_id) + payment = next( + (item for item in data.payments if str(item.get("payment_id", "")) == payment_id), + None, + ) + if payment is None: + raise RepositoryError("Zahlung nicht gefunden.") + try: + selected_amount = decimal_value(amount, "Zuordnung") + available = decimal_value(payment.get("amount", "0")) - payment_allocated_total(data, payment_id) + except ValueError as exc: + raise RepositoryError(str(exc)) from exc + if selected_amount <= 0 or selected_amount > available: + raise RepositoryError(f"Es sind nur {money_text(available)} EUR dieser Zahlung verfügbar.") + allocation = { + "allocation_id": str(uuid4()), + "payment_id": payment_id, + "claim_id": claim_id, + "amount": money_text(selected_amount), + } + data.allocations.append(allocation) + self.save_contributions(member_id, data) + self.append_event( + member_id, + event_type="payment_allocated", + summary=f"Zahlung zugeordnet: {allocation['amount']} EUR", + references={"claim_id": claim_id, "payment_id": payment_id}, + ) + return allocation + + def add_reminder( + self, + member_id: str, + claim_id: str, + *, + level: int, + detail: str = "", + fee: str = "0", + ) -> dict: + if level < 1: + raise RepositoryError("Die Mahnstufe muss mindestens 1 sein.") + try: + selected_fee = decimal_value(fee, "Mahngebühr") + except ValueError as exc: + raise RepositoryError(str(exc)) from exc + if selected_fee < 0: + raise RepositoryError("Die Mahngebühr darf nicht negativ sein.") + data, claim = self.get_claim(member_id, claim_id) + if str(claim.get("status", "")) == "cancelled": + raise RepositoryError("Eine stornierte Forderung kann nicht gemahnt werden.") + reminder = { + "reminder_id": str(uuid4()), + "claim_id": claim_id, + "level": level, + "detail": detail.strip(), + "created_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "fee_item_id": None, + } + if selected_fee > 0: + item = { + "item_id": str(uuid4()), + "type": "fee", + "description": f"Mahngebühr Stufe {level}", + "quantity": "1.00", + "unit_price": money_text(selected_fee), + "amount": money_text(selected_fee), + "created_at": reminder["created_at"], + } + materialize_claim_items(claim).append(item) + claim["amount"] = money_text(claim_total(claim)) + reminder["fee_item_id"] = item["item_id"] + data.reminders.append(reminder) + self.save_contributions(member_id, data) + self.append_event( + member_id, + event_type="reminder_created", + summary=f"Mahnung Stufe {level} erfasst", + references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])}, + data={"fee": money_text(selected_fee)}, + ) + return reminder + + def cancel_claim(self, member_id: str, claim_id: str) -> None: + data, claim = self.get_claim(member_id, claim_id) + if claim_balance(data, claim) != claim_total(claim): + raise RepositoryError("Eine Forderung mit Zahlungszuordnungen kann nicht storniert werden.") + claim["status"] = "cancelled" + claim["cancelled_at"] = datetime.now().astimezone().isoformat(timespec="seconds") + self.save_contributions(member_id, data) + self.append_event( + member_id, + event_type="claim_cancelled", + summary=f"Forderung storniert: {claim.get('title', claim_id)}", + references={"claim_id": claim_id}, + ) + + def _assert_gnucash_id_available(self, transaction_id: str) -> None: + selected = transaction_id.casefold() + for member in self.list_members(): + try: + payments = self.get_contributions(member.member_id).payments + except RepositoryError: + continue + if any( + str(payment.get("gnucash_transaction_id", "")).casefold() == selected for payment in payments + ): + raise RepositoryError(f"GnuCash-ID bereits verwendet: {transaction_id}") + def append_event( self, member_id: str, diff --git a/src/ccma/ui/claim_tab.py b/src/ccma/ui/claim_tab.py new file mode 100644 index 0000000..7bd1dc3 --- /dev/null +++ b/src/ccma/ui/claim_tab.py @@ -0,0 +1,462 @@ +from __future__ import annotations + +import tkinter as tk +from collections.abc import Callable +from datetime import date +from decimal import Decimal +from tkinter import messagebox, ttk + +from ccma.domain.contributions import ( + CLAIM_STATUS_LABELS, + allocated_total, + claim_balance, + claim_items, + claim_status, + claim_total, + decimal_value, + money_text, + payment_allocated_total, +) +from ccma.domain.dates import date_input_hint, format_date_for_display +from ccma.storage.repository import MemberRepository, RepositoryError + + +class ClaimTab(ttk.Frame): + def __init__( + self, + master: tk.Misc, + repository: MemberRepository, + member_id: str, + claim_id: str, + on_close: Callable[[], None], + on_changed: Callable[[], None], + ): + super().__init__(master, padding=12) + self.repository = repository + self.member_id = member_id + self.claim_id = claim_id + self.on_close = on_close + self.on_changed = on_changed + self._build_ui() + self.refresh() + + def _build_ui(self) -> None: + self.columnconfigure(0, weight=1) + self.rowconfigure(2, weight=1) + header = ttk.Frame(self) + header.grid(row=0, column=0, sticky="ew", pady=(0, 12)) + header.columnconfigure(0, weight=1) + self.title_var = tk.StringVar() + self.subtitle_var = tk.StringVar() + ttk.Label(header, textvariable=self.title_var, style="TabTitle.TLabel").grid( + row=0, column=0, sticky="w" + ) + ttk.Label(header, textvariable=self.subtitle_var, style="Mono.TLabel").grid( + row=1, column=0, sticky="w", pady=(3, 0) + ) + ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=1, rowspan=2) + + summary = ttk.Frame(self) + summary.grid(row=1, column=0, sticky="ew", pady=(0, 12)) + self.summary_vars: dict[str, tk.StringVar] = {} + for column, (key, title) in enumerate( + (("total", "GESAMT"), ("paid", "BEZAHLT"), ("balance", "OFFEN"), ("status", "STATUS")) + ): + card = ttk.Frame(summary, style="Card.TFrame", padding=12) + card.grid(row=0, column=column, sticky="nsew", padx=(0, 8)) + summary.columnconfigure(column, weight=1) + ttk.Label(card, text=title, style="CardTitle.TLabel").pack(anchor="w") + variable = tk.StringVar() + self.summary_vars[key] = variable + ttk.Label(card, textvariable=variable, style="CardValue.TLabel").pack(anchor="w", pady=(5, 0)) + + notebook = ttk.Notebook(self) + notebook.grid(row=2, column=0, sticky="nsew") + positions = ttk.Frame(notebook, padding=12) + payments = ttk.Frame(notebook, padding=12) + reminders = ttk.Frame(notebook, padding=12) + notebook.add(positions, text="Positionen") + notebook.add(payments, text="Zahlungen") + notebook.add(reminders, text="Mahnungen") + self._build_positions(positions) + self._build_payments(payments) + self._build_reminders(reminders) + + footer = ttk.Frame(self) + footer.grid(row=3, column=0, sticky="ew", pady=(10, 0)) + footer.columnconfigure(0, weight=1) + self.cancel_button = ttk.Button(footer, text="Forderung stornieren", command=self._cancel_claim) + self.cancel_button.grid(row=0, column=1, sticky="e") + + def _build_positions(self, parent: ttk.Frame) -> None: + parent.columnconfigure(0, weight=1) + parent.rowconfigure(0, weight=1) + self.positions = ttk.Treeview( + parent, columns=("type", "description", "quantity", "unit", "amount"), show="headings" + ) + for key, title, width in ( + ("type", "Typ", 100), + ("description", "Beschreibung", 360), + ("quantity", "Menge", 80), + ("unit", "Einzelpreis", 100), + ("amount", "Betrag", 100), + ): + self.positions.heading(key, text=title) + self.positions.column(key, width=width, anchor="w") + self.positions.grid(row=0, column=0, sticky="nsew") + ttk.Button(parent, text="Position hinzufügen", command=self._add_item).grid( + row=1, column=0, sticky="e", pady=(10, 0) + ) + + def _build_payments(self, parent: ttk.Frame) -> None: + parent.columnconfigure(0, weight=1) + parent.rowconfigure(0, weight=1) + self.payments = ttk.Treeview( + parent, columns=("date", "allocated", "payment", "gnucash", "reference"), show="headings" + ) + for key, title, width in ( + ("date", "Datum", 110), + ("allocated", "Zugeordnet", 100), + ("payment", "Zahlung gesamt", 110), + ("gnucash", "GnuCash-ID", 180), + ("reference", "Referenz", 280), + ): + self.payments.heading(key, text=title) + self.payments.column(key, width=width, anchor="w") + self.payments.grid(row=0, column=0, sticky="nsew") + buttons = ttk.Frame(parent) + buttons.grid(row=1, column=0, sticky="e", pady=(10, 0)) + ttk.Button(buttons, text="Vorhandene Zahlung zuordnen", command=self._allocate_payment).pack( + side="left", padx=(0, 8) + ) + ttk.Button(buttons, text="Zahlung erfassen", command=self._record_payment).pack(side="left") + + def _build_reminders(self, parent: ttk.Frame) -> None: + parent.columnconfigure(0, weight=1) + parent.rowconfigure(0, weight=1) + self.reminders = ttk.Treeview(parent, columns=("created", "level", "detail", "fee"), show="headings") + for key, title, width in ( + ("created", "Erstellt", 150), + ("level", "Stufe", 80), + ("detail", "Details", 430), + ("fee", "Gebühr", 100), + ): + self.reminders.heading(key, text=title) + self.reminders.column(key, width=width, anchor="w") + self.reminders.grid(row=0, column=0, sticky="nsew") + ttk.Button(parent, text="Mahnung erfassen", command=self._add_reminder).grid( + row=1, column=0, sticky="e", pady=(10, 0) + ) + + def refresh(self) -> None: + try: + self.data, self.claim = self.repository.get_claim(self.member_id, self.claim_id) + member = self.repository.get_member(self.member_id) + except RepositoryError as exc: + messagebox.showerror("Forderung konnte nicht geladen werden", str(exc), parent=self) + return + total = claim_total(self.claim) + paid = allocated_total(self.data, self.claim_id) + balance = claim_balance(self.data, self.claim) + status = claim_status(self.data, self.claim) + self.title_var.set(str(self.claim.get("title") or "Forderung")) + due = format_date_for_display(str(self.claim.get("due_date", ""))) + self.subtitle_var.set( + f"{member.member_number} · {member.display_name} · Fällig: {due or '—'} · {self.claim_id}" + ) + self.summary_vars["total"].set(f"{money_text(total)} EUR") + self.summary_vars["paid"].set(f"{money_text(paid)} EUR") + self.summary_vars["balance"].set(f"{money_text(balance)} EUR") + self.summary_vars["status"].set(CLAIM_STATUS_LABELS.get(status, status.upper())) + self.cancel_button.configure(state="disabled" if status == "cancelled" else "normal") + self._render_positions() + self._render_payments() + self._render_reminders() + + def _render_positions(self) -> None: + self.positions.delete(*self.positions.get_children()) + for item in claim_items(self.claim): + self.positions.insert( + "", + "end", + iid=str(item.get("item_id", "")), + values=( + item.get("type", ""), + item.get("description", ""), + item.get("quantity", "1"), + item.get("unit_price", item.get("amount", "")), + item.get("amount", ""), + ), + ) + + def _render_payments(self) -> None: + self.payments.delete(*self.payments.get_children()) + payment_by_id = {str(item.get("payment_id")): item for item in self.data.payments} + for allocation in self.data.allocations: + if str(allocation.get("claim_id", "")) != self.claim_id: + continue + payment = payment_by_id.get(str(allocation.get("payment_id", "")), {}) + self.payments.insert( + "", + "end", + iid=str(allocation.get("allocation_id", "")), + values=( + format_date_for_display(str(payment.get("date", ""))), + allocation.get("amount", ""), + payment.get("amount", ""), + payment.get("gnucash_transaction_id", ""), + payment.get("reference", ""), + ), + ) + + def _render_reminders(self) -> None: + self.reminders.delete(*self.reminders.get_children()) + items = {str(item.get("item_id")): item for item in claim_items(self.claim)} + for reminder in self.data.reminders: + if str(reminder.get("claim_id", "")) != self.claim_id: + continue + fee_item = items.get(str(reminder.get("fee_item_id", "")), {}) + self.reminders.insert( + "", + "end", + iid=str(reminder.get("reminder_id", "")), + values=( + str(reminder.get("created_at", ""))[:16], + reminder.get("level", ""), + reminder.get("detail", ""), + fee_item.get("amount", "0.00"), + ), + ) + + def _add_item(self) -> None: + ItemDialog(self, self.repository, self.member_id, self.claim_id, self._changed) + + def _record_payment(self) -> None: + PaymentDialog( + self, + self.repository, + self.member_id, + self.claim_id, + claim_balance(self.data, self.claim), + self._changed, + ) + + def _allocate_payment(self) -> None: + AllocatePaymentDialog( + self, + self.repository, + self.member_id, + self.claim_id, + claim_balance(self.data, self.claim), + self._changed, + ) + + def _add_reminder(self) -> None: + ReminderDialog(self, self.repository, self.member_id, self.claim_id, self._changed) + + def _cancel_claim(self) -> None: + if not messagebox.askyesno( + "Forderung stornieren", "Diese Forderung wirklich stornieren?", parent=self + ): + return + try: + self.repository.cancel_claim(self.member_id, self.claim_id) + except RepositoryError as exc: + messagebox.showerror("Storno fehlgeschlagen", str(exc), parent=self) + return + self._changed() + + def _changed(self) -> None: + self.refresh() + self.on_changed() + + +class _Dialog(tk.Toplevel): + def __init__(self, master: tk.Misc, title: str, on_saved: Callable[[], None]): + super().__init__(master) + self.on_saved = on_saved + self.title(title) + self.transient(master.winfo_toplevel()) + self.grab_set() + self.resizable(False, False) + self.frame = ttk.Frame(self, padding=18) + self.frame.pack(fill="both", expand=True) + self.bind("", lambda _event: self.destroy()) + + def _buttons(self, row: int, command: Callable[[], None]) -> None: + buttons = ttk.Frame(self.frame) + buttons.grid(row=row, column=0, columnspan=2, sticky="e", pady=(16, 0)) + ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8)) + ttk.Button(buttons, text="Speichern", style="Accent.TButton", command=command).pack(side="left") + + +class ItemDialog(_Dialog): + def __init__(self, master, repository, member_id, claim_id, on_saved): + super().__init__(master, "Forderungsposition hinzufügen", on_saved) + self.repository, self.member_id, self.claim_id = repository, member_id, claim_id + self.type_var = tk.StringVar(value="correction") + self.description_var = tk.StringVar() + self.quantity_var = tk.StringVar(value="1") + self.unit_var = tk.StringVar() + fields = ( + ("Typ", self.type_var), + ("Beschreibung", self.description_var), + ("Menge", self.quantity_var), + ("Einzelpreis", self.unit_var), + ) + for row, (label, variable) in enumerate(fields): + ttk.Label(self.frame, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12)) + if row == 0: + ttk.Combobox( + self.frame, + textvariable=variable, + values=("base", "product", "service", "fee", "discount", "credit", "correction"), + state="readonly", + width=32, + ).grid(row=row, column=1, sticky="ew", pady=5) + else: + ttk.Entry(self.frame, textvariable=variable, width=35).grid(row=row, column=1, pady=5) + self._buttons(len(fields), self._save) + + def _save(self): + try: + self.repository.add_claim_item( + self.member_id, + self.claim_id, + description=self.description_var.get(), + quantity=self.quantity_var.get(), + unit_price=self.unit_var.get(), + item_type=self.type_var.get(), + ) + except RepositoryError as exc: + messagebox.showerror("Position konnte nicht gespeichert werden", str(exc), parent=self) + return + self.destroy() + self.on_saved() + + +class PaymentDialog(_Dialog): + def __init__(self, master, repository, member_id, claim_id, balance: Decimal, on_saved): + super().__init__(master, "Zahlung erfassen", on_saved) + self.repository, self.member_id, self.claim_id = repository, member_id, claim_id + initial = money_text(max(balance, Decimal("0"))) + self.variables = { + "date": tk.StringVar(value=format_date_for_display(date.today().isoformat())), + "amount": tk.StringVar(value=initial), + "allocation": tk.StringVar(value=initial), + "gnucash": tk.StringVar(), + "reference": tk.StringVar(), + } + fields = ( + (f"Zahlungsdatum ({date_input_hint()})", "date"), + ("Zahlungsbetrag", "amount"), + ("Dieser Forderung zuordnen", "allocation"), + ("GnuCash-ID (optional)", "gnucash"), + ("Referenz", "reference"), + ) + for row, (label, key) in enumerate(fields): + ttk.Label(self.frame, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12)) + ttk.Entry(self.frame, textvariable=self.variables[key], width=38).grid(row=row, column=1, pady=5) + self._buttons(len(fields), self._save) + + def _save(self): + try: + self.repository.record_payment( + self.member_id, + self.claim_id, + payment_date=self.variables["date"].get(), + amount=self.variables["amount"].get(), + allocation_amount=self.variables["allocation"].get(), + gnucash_transaction_id=self.variables["gnucash"].get(), + reference=self.variables["reference"].get(), + ) + except RepositoryError as exc: + messagebox.showerror("Zahlung konnte nicht gespeichert werden", str(exc), parent=self) + return + self.destroy() + self.on_saved() + + +class AllocatePaymentDialog(_Dialog): + def __init__(self, master, repository, member_id, claim_id, balance: Decimal, on_saved): + super().__init__(master, "Vorhandene Zahlung zuordnen", on_saved) + self.repository, self.member_id, self.claim_id = repository, member_id, claim_id + data = repository.get_contributions(member_id) + self.payment_by_label = {} + for payment in data.payments: + payment_id = str(payment.get("payment_id", "")) + available = decimal_value(payment.get("amount", "0")) - payment_allocated_total(data, payment_id) + if available <= 0: + continue + label = ( + f"{payment.get('date', '')} · {money_text(available)} EUR frei · " + f"{payment.get('reference', '')}" + ) + self.payment_by_label[label] = (payment_id, available) + self.payment_var = tk.StringVar() + self.amount_var = tk.StringVar(value=money_text(max(balance, Decimal("0")))) + ttk.Label(self.frame, text="Zahlung").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12)) + combo = ttk.Combobox( + self.frame, + textvariable=self.payment_var, + values=list(self.payment_by_label), + state="readonly", + width=60, + ) + combo.grid(row=0, column=1, pady=5) + ttk.Label(self.frame, text="Betrag").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12)) + ttk.Entry(self.frame, textvariable=self.amount_var).grid(row=1, column=1, sticky="ew", pady=5) + combo.bind("<>", lambda _event: self._select(balance)) + self._buttons(2, self._save) + + def _select(self, balance): + _payment_id, available = self.payment_by_label[self.payment_var.get()] + self.amount_var.set(money_text(min(available, max(balance, Decimal("0"))))) + + def _save(self): + selected = self.payment_by_label.get(self.payment_var.get()) + if not selected: + messagebox.showerror("Zahlung auswählen", "Bitte eine Zahlung auswählen.", parent=self) + return + try: + self.repository.allocate_payment( + self.member_id, self.claim_id, payment_id=selected[0], amount=self.amount_var.get() + ) + except RepositoryError as exc: + messagebox.showerror("Zuordnung fehlgeschlagen", str(exc), parent=self) + return + self.destroy() + self.on_saved() + + +class ReminderDialog(_Dialog): + def __init__(self, master, repository, member_id, claim_id, on_saved): + super().__init__(master, "Mahnung erfassen", on_saved) + self.repository, self.member_id, self.claim_id = repository, member_id, claim_id + self.level_var = tk.IntVar(value=1) + self.detail_var = tk.StringVar() + self.fee_var = tk.StringVar(value="0.00") + ttk.Label(self.frame, text="Mahnstufe").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12)) + ttk.Spinbox(self.frame, from_=1, to=99, textvariable=self.level_var, width=8).grid( + row=0, column=1, sticky="w", pady=5 + ) + ttk.Label(self.frame, text="Details").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12)) + ttk.Entry(self.frame, textvariable=self.detail_var, width=45).grid(row=1, column=1, pady=5) + ttk.Label(self.frame, text="Mahngebühr").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12)) + ttk.Entry(self.frame, textvariable=self.fee_var).grid(row=2, column=1, sticky="ew", pady=5) + self._buttons(3, self._save) + + def _save(self): + try: + self.repository.add_reminder( + self.member_id, + self.claim_id, + level=self.level_var.get(), + detail=self.detail_var.get(), + fee=self.fee_var.get(), + ) + except (tk.TclError, RepositoryError) as exc: + messagebox.showerror("Mahnung konnte nicht gespeichert werden", str(exc), parent=self) + return + self.destroy() + self.on_saved() diff --git a/src/ccma/ui/main_window.py b/src/ccma/ui/main_window.py index 79eac90..a3cfd5a 100644 --- a/src/ccma/ui/main_window.py +++ b/src/ccma/ui/main_window.py @@ -8,6 +8,7 @@ from ccma.config import AppConfig from ccma.domain.models import HousekeeperFinding, Member from ccma.services.housekeeper import Housekeeper from ccma.storage.repository import MemberRepository, RepositoryError +from ccma.ui.claim_tab import ClaimTab from ccma.ui.dialogs import NewMemberDialog from ccma.ui.icons import IconStore from ccma.ui.member_tab import MemberTab @@ -276,6 +277,7 @@ class MainWindow(ttk.Frame): member_id, on_close=lambda: self.tabs.close(key), on_changed=self.refresh_overview, + on_open_claim=self.open_claim, ) self.tabs.add( key, @@ -285,6 +287,37 @@ class MainWindow(ttk.Frame): icon_name="account", ) + def open_claim(self, member_id: str, claim_id: str) -> None: + key = f"claim:{member_id}:{claim_id}" + if self.tabs.focus(key): + return + try: + _data, claim = self.repository.get_claim(member_id, claim_id) + except RepositoryError as exc: + messagebox.showerror("Forderung konnte nicht geöffnet werden", str(exc), parent=self) + return + tab = ClaimTab( + self.notebook, + self.repository, + member_id, + claim_id, + on_close=lambda: self.tabs.close(key), + on_changed=lambda: self._claim_changed(member_id), + ) + self.tabs.add( + key, + tab, + str(claim.get("title") or "Forderung"), + image=self.icons.get("receipt", 16) or self.icons.get("file-document", 16), + icon_name="receipt", + ) + + def _claim_changed(self, member_id: str) -> None: + member_tab = self.tabs.tabs.get(f"member:{member_id}") + if isinstance(member_tab, MemberTab) and member_tab.winfo_exists(): + member_tab.refresh() + self.refresh_overview() + def open_members(self) -> None: key = "members" if self.tabs.focus(key): diff --git a/src/ccma/ui/member_tab.py b/src/ccma/ui/member_tab.py index a618e17..8d1272e 100644 --- a/src/ccma/ui/member_tab.py +++ b/src/ccma/ui/member_tab.py @@ -7,6 +7,7 @@ from collections.abc import Callable from datetime import datetime from tkinter import messagebox, ttk +from ccma.domain.contributions import CLAIM_STATUS_LABELS, claim_status, claim_total, money_text from ccma.domain.dates import age_label, date_input_hint, format_date_for_display from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS from ccma.domain.models import Event @@ -21,12 +22,14 @@ class MemberTab(ttk.Frame): member_id: str, on_close: Callable[[], None], on_changed: Callable[[], None], + on_open_claim: Callable[[str, str], None], ): super().__init__(master, padding=12) self.repository = repository self.member_id = member_id self.on_close = on_close self.on_changed = on_changed + self.on_open_claim = on_open_claim self.member = repository.get_member(member_id) self.variables: dict[str, tk.Variable] = {} self._build_ui() @@ -152,6 +155,8 @@ class MemberTab(ttk.Frame): self.claims.heading(key, text=title) self.claims.column(key, width=width, anchor="w") self.claims.grid(row=1, column=0, sticky="nsew") + self.claims.bind("", lambda _event: self._open_selected_claim()) + self.claims.bind("", lambda _event: self._open_selected_claim()) documents_tab.columnconfigure(0, weight=1) documents_tab.rowconfigure(1, weight=1) @@ -213,19 +218,27 @@ class MemberTab(ttk.Frame): except RepositoryError as exc: self.contribution_summary.set(f"FEHLER: {exc}") return - for claim in data.claims: + for index, claim in enumerate(data.claims): + claim_id = str(claim.get("claim_id") or f"missing-id-{index}") + status = claim_status(data, claim) self.claims.insert( "", "end", + iid=claim_id, values=( claim.get("title", "Beitrag"), - claim.get("due_date", ""), - claim.get("amount", ""), - claim.get("status", "open"), + format_date_for_display(str(claim.get("due_date", ""))), + money_text(claim_total(claim)), + CLAIM_STATUS_LABELS.get(status, status.upper()), ), ) self.contribution_summary.set(f"{len(data.claims)} Forderungen · {len(data.payments)} Zahlungen") + def _open_selected_claim(self) -> None: + selected = self.claims.selection() + if selected and not selected[0].startswith("missing-id-"): + self.on_open_claim(self.member_id, selected[0]) + def _refresh_documents(self) -> None: self.documents.delete(0, "end") root = self.repository.members_root / self.member_id / "files" diff --git a/tests/test_contributions.py b/tests/test_contributions.py new file mode 100644 index 0000000..b0cbc4d --- /dev/null +++ b/tests/test_contributions.py @@ -0,0 +1,170 @@ +from decimal import Decimal + +import pytest + +from ccma.domain.contributions import ( + claim_balance, + claim_items, + claim_status, + claim_total, + payment_allocated_total, +) +from ccma.domain.models import ContributionData +from ccma.storage.repository import MemberRepository, RepositoryError + + +def _repository_with_claim(tmp_path, *, amount="100.00", claim_id="claim-1"): + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Claim", last_name="Test") + repository.save_contributions( + member.member_id, + ContributionData( + claims=[ + { + "claim_id": claim_id, + "claim_key": "test-claim", + "title": "Testforderung", + "amount": amount, + "due_date": "2026-12-31", + "status": "open", + } + ] + ), + ) + return repository, member + + +def test_legacy_claim_becomes_itemized_when_position_is_added(tmp_path) -> None: + repository, member = _repository_with_claim(tmp_path) + data, claim = repository.get_claim(member.member_id, "claim-1") + assert claim_total(claim) == Decimal("100.00") + assert claim_items(claim)[0]["item_id"] == "legacy-base" + + repository.add_claim_item( + member.member_id, + "claim-1", + description="Gutschrift", + quantity="1", + unit_price="-10,00", + item_type="credit", + ) + data, claim = repository.get_claim(member.member_id, "claim-1") + + assert len(claim["items"]) == 2 + assert claim_total(claim) == Decimal("90.00") + assert claim["amount"] == "90.00" + assert claim_balance(data, claim) == Decimal("90.00") + + +def test_payment_can_be_split_across_multiple_claims(tmp_path) -> None: + repository, member = _repository_with_claim(tmp_path) + data = repository.get_contributions(member.member_id) + data.claims.append( + { + "claim_id": "claim-2", + "claim_key": "second-claim", + "title": "Zweite Forderung", + "amount": "50.00", + "due_date": "2026-12-31", + "status": "open", + } + ) + repository.save_contributions(member.member_id, data) + + payment = repository.record_payment( + member.member_id, + "claim-1", + payment_date="2026-06-21", + amount="150.00", + allocation_amount="100.00", + gnucash_transaction_id="TX-42", + reference="Jahreszahlung", + ) + repository.allocate_payment( + member.member_id, + "claim-2", + payment_id=payment["payment_id"], + amount="50.00", + ) + data, first = repository.get_claim(member.member_id, "claim-1") + _data, second = repository.get_claim(member.member_id, "claim-2") + + assert claim_status(data, first) == "paid" + assert claim_status(data, second) == "paid" + assert payment_allocated_total(data, payment["payment_id"]) == Decimal("150.00") + with pytest.raises(RepositoryError, match="nur 0.00 EUR"): + repository.allocate_payment( + member.member_id, + "claim-2", + payment_id=payment["payment_id"], + amount="1.00", + ) + + +def test_reminder_fee_increases_claim_and_is_audited(tmp_path) -> None: + repository, member = _repository_with_claim(tmp_path) + + reminder = repository.add_reminder( + member.member_id, + "claim-1", + level=1, + detail="Per E-Mail versandt", + fee="5.00", + ) + data, claim = repository.get_claim(member.member_id, "claim-1") + + assert claim_total(claim) == Decimal("105.00") + assert reminder["fee_item_id"] + assert data.reminders[0]["detail"] == "Per E-Mail versandt" + assert repository.get_events(member.member_id)[-1].event_type == "reminder_created" + + +def test_claim_with_payment_cannot_be_cancelled(tmp_path) -> None: + repository, member = _repository_with_claim(tmp_path) + repository.record_payment( + member.member_id, + "claim-1", + payment_date="2026-06-21", + amount="10.00", + allocation_amount="10.00", + ) + + with pytest.raises(RepositoryError, match="Zahlungszuordnungen"): + repository.cancel_claim(member.member_id, "claim-1") + + +def test_gnucash_id_is_unique_across_member_store(tmp_path) -> None: + repository, first_member = _repository_with_claim(tmp_path / "store") + repository.record_payment( + first_member.member_id, + "claim-1", + payment_date="2026-06-21", + amount="100.00", + allocation_amount="100.00", + gnucash_transaction_id="UNIQUE-1", + ) + second = repository.create_member(first_name="Second", last_name="Member") + repository.save_contributions( + second.member_id, + ContributionData( + claims=[ + { + "claim_id": "other-claim", + "title": "Andere Forderung", + "amount": "10.00", + "due_date": "2026-12-31", + } + ] + ), + ) + + with pytest.raises(RepositoryError, match="GnuCash-ID bereits verwendet"): + repository.record_payment( + second.member_id, + "other-claim", + payment_date="2026-06-21", + amount="10.00", + allocation_amount="10.00", + gnucash_transaction_id="unique-1", + ) diff --git a/tests/test_ui_imports.py b/tests/test_ui_imports.py index e522e5e..57a7f4d 100644 --- a/tests/test_ui_imports.py +++ b/tests/test_ui_imports.py @@ -1,5 +1,6 @@ def test_ui_modules_import_without_creating_root_window() -> None: import ccma.app # noqa: F401 + import ccma.ui.claim_tab # noqa: F401 import ccma.ui.main_window # noqa: F401 import ccma.ui.member_tab # noqa: F401 import ccma.ui.splash # noqa: F401