feat: add itemized claims and payments

This commit is contained in:
Marcel Peterkau
2026-06-21 18:20:55 +02:00
parent c717d6806b
commit 80d4d5ef90
12 changed files with 1049 additions and 6 deletions
+13
View File
@@ -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 the pending run ID. A failed run therefore cannot advance the stored counter or
silently resolve existing tasks. 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. Do not place a real member store inside the source repository.
+1
View File
@@ -23,6 +23,7 @@
"Ein Akten-Preflight sperrt bei beschädigten Mitglieder-, Beitrags- oder Eventdateien alle Regeln für die betroffene Akte.", "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.", "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.", "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.", "Hausmeister um konfigurierbare Geburtstags- und Mitgliedsjubiläumsmeldungen erweitert.",
"Statusänderungen werden mit altem und neuem Klartextwert in der Mitgliederchronik protokolliert.", "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.", "Fensterposition, normaler Fensterzustand und Maximierung werden gespeichert; der Splash startet auf dem zuletzt verwendeten Monitor.",
+104
View File
@@ -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"
+3
View File
@@ -138,6 +138,7 @@ class ContributionData:
claims: list[dict[str, Any]] = field(default_factory=list) claims: list[dict[str, Any]] = field(default_factory=list)
payments: 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) allocations: list[dict[str, Any]] = field(default_factory=list)
reminders: list[dict[str, Any]] = field(default_factory=list)
schema_version: int = 1 schema_version: int = 1
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
@@ -146,6 +147,7 @@ class ContributionData:
"claims": self.claims, "claims": self.claims,
"payments": self.payments, "payments": self.payments,
"allocations": self.allocations, "allocations": self.allocations,
"reminders": self.reminders,
} }
@classmethod @classmethod
@@ -155,6 +157,7 @@ class ContributionData:
claims=list(data.get("claims") or []), claims=list(data.get("claims") or []),
payments=list(data.get("payments") or []), payments=list(data.get("payments") or []),
allocations=list(data.get("allocations") or []), allocations=list(data.get("allocations") or []),
reminders=list(data.get("reminders") or []),
) )
+6 -1
View File
@@ -1,5 +1,6 @@
from datetime import date from datetime import date
from ccma.domain.contributions import claim_status
from ccma.rules.api import RuleContext, task from ccma.rules.api import RuleContext, task
RULE_ID = "claim-status" RULE_ID = "claim-status"
@@ -9,7 +10,11 @@ ORDER = 50
def evaluate(context: RuleContext): def evaluate(context: RuleContext):
actions = [] actions = []
for claim in context.contributions.claims: 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 continue
try: try:
due = date.fromisoformat(str(claim.get("due_date", ""))) due = date.fromisoformat(str(claim.get("due_date", "")))
+12
View File
@@ -229,6 +229,18 @@ class Housekeeper:
"script_hash": rule.script_hash, "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) data.claims.append(claim)
self.repository.save_contributions(action.member_id, data) self.repository.save_contributions(action.member_id, data)
self.repository.append_event( self.repository.append_event(
+227 -1
View File
@@ -9,6 +9,14 @@ from pathlib import Path
from string import Formatter from string import Formatter
from uuid import uuid4 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.dates import DateValidationError, normalize_date_input, validate_member_dates
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, ContributionData, Event, Member from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, ContributionData, Event, Member
from ccma.storage.atomic import read_json, write_json_atomic from ccma.storage.atomic import read_json, write_json_atomic
@@ -229,7 +237,7 @@ class MemberRepository:
raw = read_json(path) raw = read_json(path)
if not isinstance(raw, dict): if not isinstance(raw, dict):
raise TypeError("Wurzelelement muss ein JSON-Objekt sein") 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): if field_name in raw and not isinstance(raw[field_name], list):
raise TypeError(f"{field_name} muss eine JSON-Liste sein") 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]): 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) self.get_member(member_id)
write_json_atomic(self._member_path(member_id) / "contributions.json", data.to_dict()) 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( def append_event(
self, self,
member_id: str, member_id: str,
+462
View File
@@ -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("<Escape>", 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("<<ComboboxSelected>>", 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()
+33
View File
@@ -8,6 +8,7 @@ from ccma.config import AppConfig
from ccma.domain.models import HousekeeperFinding, Member from ccma.domain.models import HousekeeperFinding, Member
from ccma.services.housekeeper import Housekeeper from ccma.services.housekeeper import Housekeeper
from ccma.storage.repository import MemberRepository, RepositoryError from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.claim_tab import ClaimTab
from ccma.ui.dialogs import NewMemberDialog from ccma.ui.dialogs import NewMemberDialog
from ccma.ui.icons import IconStore from ccma.ui.icons import IconStore
from ccma.ui.member_tab import MemberTab from ccma.ui.member_tab import MemberTab
@@ -276,6 +277,7 @@ class MainWindow(ttk.Frame):
member_id, member_id,
on_close=lambda: self.tabs.close(key), on_close=lambda: self.tabs.close(key),
on_changed=self.refresh_overview, on_changed=self.refresh_overview,
on_open_claim=self.open_claim,
) )
self.tabs.add( self.tabs.add(
key, key,
@@ -285,6 +287,37 @@ class MainWindow(ttk.Frame):
icon_name="account", 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: def open_members(self) -> None:
key = "members" key = "members"
if self.tabs.focus(key): if self.tabs.focus(key):
+17 -4
View File
@@ -7,6 +7,7 @@ from collections.abc import Callable
from datetime import datetime from datetime import datetime
from tkinter import messagebox, ttk 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.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 MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
from ccma.domain.models import Event from ccma.domain.models import Event
@@ -21,12 +22,14 @@ class MemberTab(ttk.Frame):
member_id: str, member_id: str,
on_close: Callable[[], None], on_close: Callable[[], None],
on_changed: Callable[[], None], on_changed: Callable[[], None],
on_open_claim: Callable[[str, str], None],
): ):
super().__init__(master, padding=12) super().__init__(master, padding=12)
self.repository = repository self.repository = repository
self.member_id = member_id self.member_id = member_id
self.on_close = on_close self.on_close = on_close
self.on_changed = on_changed self.on_changed = on_changed
self.on_open_claim = on_open_claim
self.member = repository.get_member(member_id) self.member = repository.get_member(member_id)
self.variables: dict[str, tk.Variable] = {} self.variables: dict[str, tk.Variable] = {}
self._build_ui() self._build_ui()
@@ -152,6 +155,8 @@ class MemberTab(ttk.Frame):
self.claims.heading(key, text=title) self.claims.heading(key, text=title)
self.claims.column(key, width=width, anchor="w") self.claims.column(key, width=width, anchor="w")
self.claims.grid(row=1, column=0, sticky="nsew") self.claims.grid(row=1, column=0, sticky="nsew")
self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
self.claims.bind("<Return>", lambda _event: self._open_selected_claim())
documents_tab.columnconfigure(0, weight=1) documents_tab.columnconfigure(0, weight=1)
documents_tab.rowconfigure(1, weight=1) documents_tab.rowconfigure(1, weight=1)
@@ -213,19 +218,27 @@ class MemberTab(ttk.Frame):
except RepositoryError as exc: except RepositoryError as exc:
self.contribution_summary.set(f"FEHLER: {exc}") self.contribution_summary.set(f"FEHLER: {exc}")
return 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( self.claims.insert(
"", "",
"end", "end",
iid=claim_id,
values=( values=(
claim.get("title", "Beitrag"), claim.get("title", "Beitrag"),
claim.get("due_date", ""), format_date_for_display(str(claim.get("due_date", ""))),
claim.get("amount", ""), money_text(claim_total(claim)),
claim.get("status", "open"), CLAIM_STATUS_LABELS.get(status, status.upper()),
), ),
) )
self.contribution_summary.set(f"{len(data.claims)} Forderungen · {len(data.payments)} Zahlungen") 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: def _refresh_documents(self) -> None:
self.documents.delete(0, "end") self.documents.delete(0, "end")
root = self.repository.members_root / self.member_id / "files" root = self.repository.members_root / self.member_id / "files"
+170
View File
@@ -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",
)
+1
View File
@@ -1,5 +1,6 @@
def test_ui_modules_import_without_creating_root_window() -> None: def test_ui_modules_import_without_creating_root_window() -> None:
import ccma.app # noqa: F401 import ccma.app # noqa: F401
import ccma.ui.claim_tab # noqa: F401
import ccma.ui.main_window # noqa: F401 import ccma.ui.main_window # noqa: F401
import ccma.ui.member_tab # noqa: F401 import ccma.ui.member_tab # noqa: F401
import ccma.ui.splash # noqa: F401 import ccma.ui.splash # noqa: F401