diff --git a/README.md b/README.md index ab6e84e..2b70654 100644 --- a/README.md +++ b/README.md @@ -74,4 +74,10 @@ 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. +Overdue claims are evaluated by the reminder rule. It creates housekeeper tasks +for the next configured reminder level. Reminder drafts do not change a claim; +only confirming actual dispatch starts the new payment deadline and adds an +optional fee line item. A claim-level dunning hold suppresses automatic and +manual reminder preparation until it is removed or expires. + 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 98f99ce..9d6faac 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -25,6 +25,7 @@ "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.", "Dropdowns zeigen deutsche Begriffe bei weiterhin englischen Speicher-Keys; der Hausmeisterstatus liegt einheitlich in housekeeper.json.", + "Mehrstufiger Mahnworkflow mit Hausmeister-Regel, Entwurf, Versandbestätigung, Zahlungsfrist, optionaler Gebühr und Mahnsperre ergänzt.", "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/rules/scripts/reminder_due.py b/src/ccma/rules/scripts/reminder_due.py new file mode 100644 index 0000000..d1ebea1 --- /dev/null +++ b/src/ccma/rules/scripts/reminder_due.py @@ -0,0 +1,105 @@ +from datetime import date, timedelta + +from ccma.domain.contributions import claim_balance, claim_status, money_text +from ccma.rules.api import RuleContext, task + +RULE_ID = "reminder-due" +ORDER = 60 + +DEFAULT_POLICY = { + "grace_days_after_due": 7, + "levels": [ + {"level": 1, "name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 14}, + {"level": 2, "name": "Erste Mahnung", "fee": "5.00", "payment_deadline_days": 14}, + {"level": 3, "name": "Zweite Mahnung", "fee": "5.00", "payment_deadline_days": 14}, + ], +} + + +def evaluate(context: RuleContext): + policy = context.repository_config.get("reminder_policy") or DEFAULT_POLICY + levels = sorted(policy.get("levels") or [], key=lambda value: int(value.get("level", 0))) + actions = [] + for claim in context.contributions.claims: + claim_id = str(claim.get("claim_id", "")) + if not claim_id or claim_status(context.contributions, claim, today=context.today) not in { + "open", + "partially_paid", + "overdue", + }: + continue + if claim_balance(context.contributions, claim) <= 0 or _hold_is_active(claim, context.today): + continue + reminders = [ + item for item in context.contributions.reminders if str(item.get("claim_id", "")) == claim_id + ] + sent_levels = { + int(item.get("level", 0)): item for item in reminders if str(item.get("status", "")) == "sent" + } + next_level = next( + (definition for definition in levels if int(definition.get("level", 0)) not in sent_levels), + None, + ) + if not next_level: + continue + level = int(next_level.get("level", 0)) + trigger_date = _trigger_date(claim, sent_levels, level, policy) + if not trigger_date or context.today < trigger_date: + continue + draft_exists = any( + int(item.get("level", 0)) == level and str(item.get("status", "draft")) in {"draft", "generated"} + for item in reminders + ) + name = str(next_level.get("name") or f"Mahnung Stufe {level}") + balance = money_text(claim_balance(context.contributions, claim)) + title = ( + f"{context.member.display_name}: Mahnungsentwurf wartet auf Versand" + if draft_exists + else f"{context.member.display_name}: {name} fällig" + ) + detail = ( + f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. " + f"Mahnstufe {level}, vorgesehen Gebühr: {next_level.get('fee', '0.00')} EUR." + ) + actions.append( + task( + rule_id=RULE_ID, + member=context.member, + key_suffix=f"{claim_id}:level-{level}", + severity="warning", + code="reminder_due", + title=title, + detail=detail, + due_date=trigger_date, + ) + ) + return actions + + +def _trigger_date(claim, sent_levels, level: int, policy) -> date | None: + if level == 1: + try: + due_date = date.fromisoformat(str(claim.get("due_date", ""))) + except ValueError: + return None + return due_date + timedelta(days=int(policy.get("grace_days_after_due", 7))) + previous = sent_levels.get(level - 1) + if not previous: + return None + try: + return date.fromisoformat(str(previous.get("payment_deadline", ""))) + except ValueError: + return None + + +def _hold_is_active(claim, today: date) -> bool: + hold = claim.get("dunning_hold") or {} + if not hold.get("active"): + return False + until = hold.get("until") + if not until: + return True + try: + return date.fromisoformat(str(until)) >= today + except ValueError: + return True diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index bb255dc..226512c 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -4,7 +4,7 @@ import json import os import unicodedata from collections.abc import Iterable -from datetime import date, datetime +from datetime import date, datetime, timedelta from pathlib import Path from string import Formatter from uuid import uuid4 @@ -37,6 +37,29 @@ DEFAULT_CONFIGURATION = { "pattern": DEFAULT_MEMBER_NUMBER_PATTERN, }, "member_number_sequences": {}, + "reminder_policy": { + "grace_days_after_due": 7, + "levels": [ + { + "level": 1, + "name": "Zahlungserinnerung", + "fee": "0.00", + "payment_deadline_days": 14, + }, + { + "level": 2, + "name": "Erste Mahnung", + "fee": "5.00", + "payment_deadline_days": 14, + }, + { + "level": 3, + "name": "Zweite Mahnung", + "fee": "5.00", + "payment_deadline_days": 14, + }, + ], + }, "contribution_rules": [ { "rule_id": "standard-2022", @@ -392,14 +415,17 @@ class MemberRepository: ) return allocation - def add_reminder( + def create_reminder_draft( self, member_id: str, claim_id: str, *, level: int, + name: str, + payment_deadline_days: int, detail: str = "", fee: str = "0", + channel: str = "email", ) -> dict: if level < 1: raise RepositoryError("Die Mahnstufe muss mindestens 1 sein.") @@ -409,41 +435,156 @@ class MemberRepository: raise RepositoryError(str(exc)) from exc if selected_fee < 0: raise RepositoryError("Die Mahngebühr darf nicht negativ sein.") + if payment_deadline_days < 1 or payment_deadline_days > 365: + raise RepositoryError("Die Zahlungsfrist muss zwischen 1 und 365 Tagen liegen.") data, claim = self.get_claim(member_id, claim_id) if str(claim.get("status", "")) == "cancelled": raise RepositoryError("Eine stornierte Forderung kann nicht gemahnt werden.") + if _dunning_hold_is_active(claim): + raise RepositoryError("Für diese Forderung ist eine Mahnsperre aktiv.") + if claim_balance(data, claim) <= 0: + raise RepositoryError("Die Forderung hat keinen offenen Betrag.") + if any( + str(item.get("claim_id", "")) == claim_id + and int(item.get("level", 0)) == level + and str(item.get("status", "draft")) in {"draft", "generated", "sent"} + for item in data.reminders + ): + raise RepositoryError(f"Mahnstufe {level} existiert bereits.") + if level > 1 and not any( + str(item.get("claim_id", "")) == claim_id + and int(item.get("level", 0)) == level - 1 + and str(item.get("status", "")) == "sent" + for item in data.reminders + ): + raise RepositoryError(f"Mahnstufe {level - 1} wurde noch nicht versandt.") + created_at = datetime.now().astimezone().isoformat(timespec="seconds") reminder = { "reminder_id": str(uuid4()), + "reminder_key": f"{claim_id}:level-{level}", "claim_id": claim_id, "level": level, + "name": name.strip() or f"Mahnung Stufe {level}", + "status": "draft", "detail": detail.strip(), - "created_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "channel": channel.strip() or "email", + "created_at": created_at, + "generated_at": None, + "sent_at": None, + "payment_deadline_days": payment_deadline_days, + "payment_deadline": None, + "balance_snapshot": money_text(claim_balance(data, claim)), + "fee": money_text(selected_fee), "fee_item_id": None, + "document": {"path": "", "sha256": ""}, } - 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", + event_type="reminder_draft_created", + summary=f"Mahnungsentwurf erstellt: {reminder['name']}", references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])}, - data={"fee": money_text(selected_fee)}, + data={"fee": reminder["fee"], "balance_snapshot": reminder["balance_snapshot"]}, ) return reminder + def mark_reminder_sent(self, member_id: str, claim_id: str, reminder_id: str) -> dict: + data, claim = self.get_claim(member_id, claim_id) + reminder = self._find_reminder(data, claim_id, reminder_id) + if str(reminder.get("status", "")) not in {"draft", "generated"}: + raise RepositoryError("Nur ein Entwurf kann als versandt markiert werden.") + if claim_balance(data, claim) <= 0: + raise RepositoryError("Die Forderung hat keinen offenen Betrag mehr.") + now = datetime.now().astimezone() + selected_fee = decimal_value(reminder.get("fee", "0"), "Mahngebühr") + if selected_fee > 0 and not reminder.get("fee_item_id"): + item = { + "item_id": str(uuid4()), + "type": "fee", + "description": f"Mahngebühr Stufe {reminder.get('level', '')}", + "quantity": "1.00", + "unit_price": money_text(selected_fee), + "amount": money_text(selected_fee), + "created_at": now.isoformat(timespec="seconds"), + } + materialize_claim_items(claim).append(item) + claim["amount"] = money_text(claim_total(claim)) + reminder["fee_item_id"] = item["item_id"] + reminder["status"] = "sent" + reminder["sent_at"] = now.isoformat(timespec="seconds") + reminder["payment_deadline"] = ( + now.date() + timedelta(days=int(reminder.get("payment_deadline_days", 14))) + ).isoformat() + self.save_contributions(member_id, data) + self.append_event( + member_id, + event_type="reminder_sent", + summary=f"Mahnung als versandt markiert: {reminder.get('name', '')}", + references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])}, + data={"fee": money_text(selected_fee), "payment_deadline": reminder["payment_deadline"]}, + ) + return reminder + + def cancel_reminder(self, member_id: str, claim_id: str, reminder_id: str) -> None: + data, _claim = self.get_claim(member_id, claim_id) + reminder = self._find_reminder(data, claim_id, reminder_id) + if str(reminder.get("status", "")) == "sent": + raise RepositoryError("Eine bereits versandte Mahnung kann nicht verworfen werden.") + reminder["status"] = "cancelled" + reminder["cancelled_at"] = datetime.now().astimezone().isoformat(timespec="seconds") + self.save_contributions(member_id, data) + self.append_event( + member_id, + event_type="reminder_cancelled", + summary=f"Mahnungsentwurf verworfen: {reminder.get('name', '')}", + references={"claim_id": claim_id, "reminder_id": reminder_id}, + ) + + def set_dunning_hold( + self, + member_id: str, + claim_id: str, + *, + active: bool, + reason: str = "", + until: str = "", + ) -> None: + data, claim = self.get_claim(member_id, claim_id) + try: + normalized_until = normalize_date_input(until, "Mahnsperre bis") + except DateValidationError as exc: + raise RepositoryError(str(exc)) from exc + claim["dunning_hold"] = { + "active": active, + "reason": reason.strip(), + "until": normalized_until or None, + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + } + self.save_contributions(member_id, data) + self.append_event( + member_id, + event_type="dunning_hold_changed", + summary="Mahnsperre gesetzt" if active else "Mahnsperre aufgehoben", + references={"claim_id": claim_id}, + data={"reason": reason.strip(), "until": normalized_until}, + ) + + @staticmethod + def _find_reminder(data: ContributionData, claim_id: str, reminder_id: str) -> dict: + reminder = next( + ( + item + for item in data.reminders + if str(item.get("claim_id", "")) == claim_id + and str(item.get("reminder_id", "")) == reminder_id + ), + None, + ) + if reminder is None: + raise RepositoryError("Mahnung nicht gefunden.") + 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): @@ -677,6 +818,18 @@ def _german_date(value: str) -> str: return "" +def _dunning_hold_is_active(claim: dict) -> bool: + hold = claim.get("dunning_hold") or {} + if not hold.get("active"): + return False + if not hold.get("until"): + return True + try: + return date.fromisoformat(str(hold["until"])) >= date.today() + except ValueError: + return True + + def validate_member_number_pattern(pattern: str) -> None: selected = pattern.strip() if not selected: diff --git a/src/ccma/ui/claim_tab.py b/src/ccma/ui/claim_tab.py index 71a0574..ec8f439 100644 --- a/src/ccma/ui/claim_tab.py +++ b/src/ccma/ui/claim_tab.py @@ -19,7 +19,13 @@ from ccma.domain.contributions import ( ) from ccma.domain.dates import date_input_hint, format_date_for_display from ccma.storage.repository import MemberRepository, RepositoryError -from ccma.ui.labels import CLAIM_ITEM_TYPE_LABELS, display_label, storage_key +from ccma.ui.labels import ( + CLAIM_ITEM_TYPE_LABELS, + REMINDER_CHANNEL_LABELS, + REMINDER_STATUS_LABELS, + display_label, + storage_key, +) class ClaimTab(ttk.Frame): @@ -86,6 +92,8 @@ class ClaimTab(ttk.Frame): footer = ttk.Frame(self) footer.grid(row=3, column=0, sticky="ew", pady=(10, 0)) footer.columnconfigure(0, weight=1) + self.hold_button = ttk.Button(footer, text="Mahnsperre setzen", command=self._toggle_hold) + self.hold_button.grid(row=0, column=0, sticky="w") self.cancel_button = ttk.Button(footer, text="Forderung stornieren", command=self._cancel_claim) self.cancel_button.grid(row=0, column=1, sticky="e") @@ -135,19 +143,34 @@ class ClaimTab(ttk.Frame): 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") + self.reminders = ttk.Treeview( + parent, + columns=("created", "level", "name", "status", "deadline", "fee"), + show="headings", + ) for key, title, width in ( ("created", "Erstellt", 150), - ("level", "Stufe", 80), - ("detail", "Details", 430), - ("fee", "Gebühr", 100), + ("level", "Stufe", 60), + ("name", "Mahnung", 230), + ("status", "Status", 130), + ("deadline", "Zahlungsfrist", 120), + ("fee", "Gebühr", 90), ): 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) + self.reminders.bind("<>", lambda _event: self._update_reminder_buttons()) + buttons = ttk.Frame(parent) + buttons.grid(row=1, column=0, sticky="e", pady=(10, 0)) + self.discard_reminder_button = ttk.Button( + buttons, text="Entwurf verwerfen", command=self._discard_reminder, state="disabled" ) + self.discard_reminder_button.pack(side="left", padx=(0, 8)) + self.send_reminder_button = ttk.Button( + buttons, text="Als versandt markieren", command=self._send_reminder, state="disabled" + ) + self.send_reminder_button.pack(side="left", padx=(0, 8)) + ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="left") def refresh(self) -> None: try: @@ -165,11 +188,17 @@ class ClaimTab(ttk.Frame): self.subtitle_var.set( f"{member.member_number} · {member.display_name} · Fällig: {due or '—'} · {self.claim_id}" ) + hold = self.claim.get("dunning_hold") or {} + if hold.get("active"): + until = format_date_for_display(str(hold.get("until") or "")) or "unbefristet" + reason = str(hold.get("reason") or "ohne Begründung") + self.subtitle_var.set(f"{self.subtitle_var.get()} · MAHNSPERRE bis {until}: {reason}") 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.hold_button.configure(text="Mahnsperre aufheben" if hold.get("active") else "Mahnsperre setzen") self._render_positions() self._render_payments() self._render_reminders() @@ -217,6 +246,7 @@ class ClaimTab(ttk.Frame): if str(reminder.get("claim_id", "")) != self.claim_id: continue fee_item = items.get(str(reminder.get("fee_item_id", "")), {}) + status = str(reminder.get("status", "draft")) self.reminders.insert( "", "end", @@ -224,10 +254,13 @@ class ClaimTab(ttk.Frame): values=( str(reminder.get("created_at", ""))[:16], reminder.get("level", ""), - reminder.get("detail", ""), - fee_item.get("amount", "0.00"), + reminder.get("name", f"Mahnung Stufe {reminder.get('level', '')}"), + display_label(REMINDER_STATUS_LABELS, status), + format_date_for_display(str(reminder.get("payment_deadline") or "")), + fee_item.get("amount", reminder.get("fee", "0.00")), ), ) + self._update_reminder_buttons() def _add_item(self) -> None: ItemDialog(self, self.repository, self.member_id, self.claim_id, self._changed) @@ -255,6 +288,64 @@ class ClaimTab(ttk.Frame): def _add_reminder(self) -> None: ReminderDialog(self, self.repository, self.member_id, self.claim_id, self._changed) + def _selected_reminder(self) -> dict | None: + selected = self.reminders.selection() + if not selected: + return None + return next( + (item for item in self.data.reminders if str(item.get("reminder_id", "")) == selected[0]), + None, + ) + + def _update_reminder_buttons(self) -> None: + reminder = self._selected_reminder() + editable = bool(reminder and str(reminder.get("status", "draft")) in {"draft", "generated"}) + state = "normal" if editable else "disabled" + self.send_reminder_button.configure(state=state) + self.discard_reminder_button.configure(state=state) + + def _send_reminder(self) -> None: + reminder = self._selected_reminder() + if not reminder or not messagebox.askyesno( + "Mahnung versandt", + "Wurde diese Mahnung tatsächlich versandt? Erst jetzt werden Frist und Gebühr gebucht.", + parent=self, + ): + return + try: + self.repository.mark_reminder_sent(self.member_id, self.claim_id, str(reminder["reminder_id"])) + except RepositoryError as exc: + messagebox.showerror("Versand konnte nicht gespeichert werden", str(exc), parent=self) + return + self._changed() + + def _discard_reminder(self) -> None: + reminder = self._selected_reminder() + if not reminder or not messagebox.askyesno( + "Mahnungsentwurf verwerfen", "Diesen Mahnungsentwurf wirklich verwerfen?", parent=self + ): + return + try: + self.repository.cancel_reminder(self.member_id, self.claim_id, str(reminder["reminder_id"])) + except RepositoryError as exc: + messagebox.showerror("Entwurf konnte nicht verworfen werden", str(exc), parent=self) + return + self._changed() + + def _toggle_hold(self) -> None: + hold = self.claim.get("dunning_hold") or {} + if hold.get("active"): + if not messagebox.askyesno("Mahnsperre aufheben", "Mahnsperre wirklich aufheben?", parent=self): + return + try: + self.repository.set_dunning_hold(self.member_id, self.claim_id, active=False) + except RepositoryError as exc: + messagebox.showerror("Mahnsperre", str(exc), parent=self) + return + self._changed() + return + DunningHoldDialog(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 @@ -278,11 +369,11 @@ class _Dialog(tk.Toplevel): 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()) + self.after_idle(self.grab_set) def _buttons(self, row: int, command: Callable[[], None]) -> None: buttons = ttk.Frame(self.frame) @@ -432,32 +523,98 @@ class AllocatePaymentDialog(_Dialog): class ReminderDialog(_Dialog): def __init__(self, master, repository, member_id, claim_id, on_saved): - super().__init__(master, "Mahnung erfassen", on_saved) + super().__init__(master, "Mahnung vorbereiten", on_saved) self.repository, self.member_id, self.claim_id = repository, member_id, claim_id - self.level_var = tk.IntVar(value=1) + policy = repository.get_configuration().get("reminder_policy") or {} + levels = policy.get("levels") or [ + {"level": 1, "name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 14} + ] + self.definition_by_label = { + f"Stufe {item.get('level', '')}: {item.get('name', '')}": item for item in levels + } + self.level_var = tk.StringVar(value=next(iter(self.definition_by_label))) self.detail_var = tk.StringVar() - self.fee_var = tk.StringVar(value="0.00") + self.fee_var = tk.StringVar() + self.deadline_var = tk.StringVar() + self.channel_var = tk.StringVar(value=REMINDER_CHANNEL_LABELS["email"]) 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 + level_combo = ttk.Combobox( + self.frame, + textvariable=self.level_var, + values=list(self.definition_by_label), + state="readonly", + width=38, ) + level_combo.grid(row=0, column=1, sticky="ew", pady=5) + level_combo.bind("<>", lambda _event: self._load_definition()) 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) + ttk.Label(self.frame, text="Zahlungsfrist in Tagen").grid( + row=3, column=0, sticky="w", pady=5, padx=(0, 12) + ) + ttk.Entry(self.frame, textvariable=self.deadline_var).grid(row=3, column=1, sticky="ew", pady=5) + ttk.Label(self.frame, text="Versandweg").grid(row=4, column=0, sticky="w", pady=5, padx=(0, 12)) + ttk.Combobox( + self.frame, + textvariable=self.channel_var, + values=list(REMINDER_CHANNEL_LABELS.values()), + state="readonly", + ).grid(row=4, column=1, sticky="ew", pady=5) + self._load_definition() + self._buttons(5, self._save) + + def _load_definition(self) -> None: + definition = self.definition_by_label[self.level_var.get()] + self.fee_var.set(str(definition.get("fee", "0.00"))) + self.deadline_var.set(str(definition.get("payment_deadline_days", 14))) def _save(self): + definition = self.definition_by_label[self.level_var.get()] try: - self.repository.add_reminder( + self.repository.create_reminder_draft( self.member_id, self.claim_id, - level=self.level_var.get(), + level=int(definition.get("level", 1)), + name=str(definition.get("name", "Mahnung")), + payment_deadline_days=int(self.deadline_var.get()), detail=self.detail_var.get(), fee=self.fee_var.get(), + channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()), ) - except (tk.TclError, RepositoryError) as exc: - messagebox.showerror("Mahnung konnte nicht gespeichert werden", str(exc), parent=self) + except (ValueError, RepositoryError) as exc: + messagebox.showerror("Mahnungsentwurf konnte nicht gespeichert werden", str(exc), parent=self) + return + self.destroy() + self.on_saved() + + +class DunningHoldDialog(_Dialog): + def __init__(self, master, repository, member_id, claim_id, on_saved): + super().__init__(master, "Mahnsperre setzen", on_saved) + self.repository, self.member_id, self.claim_id = repository, member_id, claim_id + self.reason_var = tk.StringVar() + self.until_var = tk.StringVar() + ttk.Label(self.frame, text="Grund").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12)) + ttk.Entry(self.frame, textvariable=self.reason_var, width=45).grid(row=0, column=1, pady=5) + ttk.Label(self.frame, text=f"Bis ({date_input_hint()}, optional)").grid( + row=1, column=0, sticky="w", pady=5, padx=(0, 12) + ) + ttk.Entry(self.frame, textvariable=self.until_var).grid(row=1, column=1, sticky="ew", pady=5) + self._buttons(2, self._save) + + def _save(self) -> None: + try: + self.repository.set_dunning_hold( + self.member_id, + self.claim_id, + active=True, + reason=self.reason_var.get(), + until=self.until_var.get(), + ) + except RepositoryError as exc: + messagebox.showerror("Mahnsperre konnte nicht gespeichert werden", str(exc), parent=self) return self.destroy() self.on_saved() diff --git a/src/ccma/ui/labels.py b/src/ccma/ui/labels.py index 644e3b3..57bde48 100644 --- a/src/ccma/ui/labels.py +++ b/src/ccma/ui/labels.py @@ -17,6 +17,19 @@ CLAIM_ITEM_TYPE_LABELS = { "correction": "Korrektur", } +REMINDER_STATUS_LABELS = { + "draft": "Entwurf", + "generated": "Dokument erzeugt", + "sent": "Versandt", + "cancelled": "Verworfen", +} + +REMINDER_CHANNEL_LABELS = { + "email": "E-Mail", + "letter": "Brief", + "personal": "Persönlich", +} + def display_label(labels: Mapping[str, str], key: str) -> str: return labels.get(key, key) diff --git a/tests/test_contributions.py b/tests/test_contributions.py index b0cbc4d..0905700 100644 --- a/tests/test_contributions.py +++ b/tests/test_contributions.py @@ -105,19 +105,31 @@ def test_payment_can_be_split_across_multiple_claims(tmp_path) -> None: def test_reminder_fee_increases_claim_and_is_audited(tmp_path) -> None: repository, member = _repository_with_claim(tmp_path) - reminder = repository.add_reminder( + reminder = repository.create_reminder_draft( member.member_id, "claim-1", level=1, + name="Zahlungserinnerung", + payment_deadline_days=14, detail="Per E-Mail versandt", fee="5.00", ) data, claim = repository.get_claim(member.member_id, "claim-1") + assert claim_total(claim) == Decimal("100.00") + assert reminder["status"] == "draft" + assert reminder["fee_item_id"] is None + + repository.mark_reminder_sent(member.member_id, "claim-1", reminder["reminder_id"]) + data, claim = repository.get_claim(member.member_id, "claim-1") + sent = data.reminders[0] + assert claim_total(claim) == Decimal("105.00") - assert reminder["fee_item_id"] + assert sent["status"] == "sent" + assert sent["payment_deadline"] + assert sent["fee_item_id"] assert data.reminders[0]["detail"] == "Per E-Mail versandt" - assert repository.get_events(member.member_id)[-1].event_type == "reminder_created" + assert repository.get_events(member.member_id)[-1].event_type == "reminder_sent" def test_claim_with_payment_cannot_be_cancelled(tmp_path) -> None: diff --git a/tests/test_housekeeper.py b/tests/test_housekeeper.py index 5dd6af8..c3600d0 100644 --- a/tests/test_housekeeper.py +++ b/tests/test_housekeeper.py @@ -29,7 +29,11 @@ def test_housekeeper_reports_initial_payment_and_open_claims(tmp_path) -> None: ) findings = Housekeeper(repository).run(today=date(2026, 2, 10)) - assert {finding.code for finding in findings} == {"initial_payment_overdue", "claim_overdue"} + assert {finding.code for finding in findings} == { + "initial_payment_overdue", + "claim_overdue", + "reminder_due", + } def test_housekeeper_reports_birthdays_before_today_and_after(tmp_path) -> None: diff --git a/tests/test_reminders.py b/tests/test_reminders.py new file mode 100644 index 0000000..c9f7b9a --- /dev/null +++ b/tests/test_reminders.py @@ -0,0 +1,136 @@ +from datetime import date, timedelta + +import pytest + +from ccma.domain.models import ContributionData +from ccma.services.housekeeper import Housekeeper +from ccma.storage.repository import MemberRepository, RepositoryError + + +def _overdue_claim_repository(tmp_path): + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Reminder", last_name="Test", birth_date="1990-01-01") + repository.save_contributions( + member.member_id, + ContributionData( + claims=[ + { + "claim_id": "claim-1", + "claim_key": "overdue-test", + "title": "Offene Forderung", + "amount": "100.00", + "due_date": "2026-01-31", + "status": "open", + } + ] + ), + ) + return repository, member + + +def test_reminder_rule_progresses_only_after_sent_deadline(tmp_path) -> None: + repository, member = _overdue_claim_repository(tmp_path) + housekeeper = Housekeeper(repository) + findings = housekeeper.run(today=date(2026, 2, 10)) + reminder_task = next(item for item in findings if item.code == "reminder_due") + assert "Zahlungserinnerung" in reminder_task.title + + draft = repository.create_reminder_draft( + member.member_id, + "claim-1", + level=1, + name="Zahlungserinnerung", + payment_deadline_days=14, + fee="0.00", + ) + findings = housekeeper.run(today=date(2026, 2, 10)) + reminder_task = next(item for item in findings if item.code == "reminder_due") + assert "wartet auf Versand" in reminder_task.title + + sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"]) + deadline = date.fromisoformat(sent["payment_deadline"]) + assert not any( + item.code == "reminder_due" for item in housekeeper.run(today=deadline - timedelta(days=1)) + ) + findings = housekeeper.run(today=deadline) + next_task = next(item for item in findings if item.code == "reminder_due") + assert "Erste Mahnung" in next_task.title + + +def test_dunning_hold_suppresses_and_then_restores_task(tmp_path) -> None: + repository, member = _overdue_claim_repository(tmp_path) + housekeeper = Housekeeper(repository) + repository.set_dunning_hold( + member.member_id, + "claim-1", + active=True, + reason="Betrag wird geklärt", + ) + + assert not any(item.code == "reminder_due" for item in housekeeper.run(today=date(2026, 2, 10))) + with pytest.raises(RepositoryError, match="Mahnsperre aktiv"): + repository.create_reminder_draft( + member.member_id, + "claim-1", + level=1, + name="Zahlungserinnerung", + payment_deadline_days=14, + ) + repository.set_dunning_hold(member.member_id, "claim-1", active=False) + assert any(item.code == "reminder_due" for item in housekeeper.run(today=date(2026, 2, 10))) + + +def test_draft_can_be_cancelled_but_sent_reminder_cannot(tmp_path) -> None: + repository, member = _overdue_claim_repository(tmp_path) + draft = repository.create_reminder_draft( + member.member_id, + "claim-1", + level=1, + name="Zahlungserinnerung", + payment_deadline_days=14, + ) + repository.cancel_reminder(member.member_id, "claim-1", draft["reminder_id"]) + data = repository.get_contributions(member.member_id) + assert data.reminders[0]["status"] == "cancelled" + + second = repository.create_reminder_draft( + member.member_id, + "claim-1", + level=1, + name="Zahlungserinnerung", + payment_deadline_days=14, + ) + repository.mark_reminder_sent(member.member_id, "claim-1", second["reminder_id"]) + with pytest.raises(RepositoryError, match="bereits versandte"): + repository.cancel_reminder(member.member_id, "claim-1", second["reminder_id"]) + + +def test_reminder_levels_cannot_be_skipped(tmp_path) -> None: + repository, member = _overdue_claim_repository(tmp_path) + + with pytest.raises(RepositoryError, match="Mahnstufe 1 wurde noch nicht versandt"): + repository.create_reminder_draft( + member.member_id, + "claim-1", + level=2, + name="Erste Mahnung", + payment_deadline_days=14, + fee="5.00", + ) + + +def test_payment_resolves_open_reminder_task(tmp_path) -> None: + repository, member = _overdue_claim_repository(tmp_path) + housekeeper = Housekeeper(repository) + assert any(item.code == "reminder_due" for item in housekeeper.run(today=date(2026, 2, 10))) + repository.record_payment( + member.member_id, + "claim-1", + payment_date="2026-02-10", + amount="100.00", + allocation_amount="100.00", + ) + + findings = housekeeper.run(today=date(2026, 2, 10)) + assert not any(item.code == "reminder_due" for item in findings)