mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 11:14:52 +02:00
feat: add itemized claims and payments
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
@@ -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 []),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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", "")))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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("<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.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"
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user