feat: add staged reminder workflow

This commit is contained in:
Marcel Peterkau
2026-06-21 18:40:54 +02:00
parent 288b5f6247
commit e6d2f77d1e
9 changed files with 630 additions and 43 deletions
+6
View File
@@ -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 they explicitly add a fee line item. Every change is also appended to the
member's `events.jsonl` audit trail. 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. Do not place a real member store inside the source repository.
+1
View File
@@ -25,6 +25,7 @@
"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.", "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.", "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.", "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.",
+105
View File
@@ -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
+172 -19
View File
@@ -4,7 +4,7 @@ import json
import os import os
import unicodedata import unicodedata
from collections.abc import Iterable from collections.abc import Iterable
from datetime import date, datetime from datetime import date, datetime, timedelta
from pathlib import Path from pathlib import Path
from string import Formatter from string import Formatter
from uuid import uuid4 from uuid import uuid4
@@ -37,6 +37,29 @@ DEFAULT_CONFIGURATION = {
"pattern": DEFAULT_MEMBER_NUMBER_PATTERN, "pattern": DEFAULT_MEMBER_NUMBER_PATTERN,
}, },
"member_number_sequences": {}, "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": [ "contribution_rules": [
{ {
"rule_id": "standard-2022", "rule_id": "standard-2022",
@@ -392,14 +415,17 @@ class MemberRepository:
) )
return allocation return allocation
def add_reminder( def create_reminder_draft(
self, self,
member_id: str, member_id: str,
claim_id: str, claim_id: str,
*, *,
level: int, level: int,
name: str,
payment_deadline_days: int,
detail: str = "", detail: str = "",
fee: str = "0", fee: str = "0",
channel: str = "email",
) -> dict: ) -> dict:
if level < 1: if level < 1:
raise RepositoryError("Die Mahnstufe muss mindestens 1 sein.") raise RepositoryError("Die Mahnstufe muss mindestens 1 sein.")
@@ -409,41 +435,156 @@ class MemberRepository:
raise RepositoryError(str(exc)) from exc raise RepositoryError(str(exc)) from exc
if selected_fee < 0: if selected_fee < 0:
raise RepositoryError("Die Mahngebühr darf nicht negativ sein.") 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) data, claim = self.get_claim(member_id, claim_id)
if str(claim.get("status", "")) == "cancelled": if str(claim.get("status", "")) == "cancelled":
raise RepositoryError("Eine stornierte Forderung kann nicht gemahnt werden.") 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 = {
"reminder_id": str(uuid4()), "reminder_id": str(uuid4()),
"reminder_key": f"{claim_id}:level-{level}",
"claim_id": claim_id, "claim_id": claim_id,
"level": level, "level": level,
"name": name.strip() or f"Mahnung Stufe {level}",
"status": "draft",
"detail": detail.strip(), "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, "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) data.reminders.append(reminder)
self.save_contributions(member_id, data) self.save_contributions(member_id, data)
self.append_event( self.append_event(
member_id, member_id,
event_type="reminder_created", event_type="reminder_draft_created",
summary=f"Mahnung Stufe {level} erfasst", summary=f"Mahnungsentwurf erstellt: {reminder['name']}",
references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])}, 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 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: def cancel_claim(self, member_id: str, claim_id: str) -> None:
data, claim = self.get_claim(member_id, claim_id) data, claim = self.get_claim(member_id, claim_id)
if claim_balance(data, claim) != claim_total(claim): if claim_balance(data, claim) != claim_total(claim):
@@ -677,6 +818,18 @@ def _german_date(value: str) -> str:
return "" 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: def validate_member_number_pattern(pattern: str) -> None:
selected = pattern.strip() selected = pattern.strip()
if not selected: if not selected:
+177 -20
View File
@@ -19,7 +19,13 @@ from ccma.domain.contributions import (
) )
from ccma.domain.dates import date_input_hint, format_date_for_display from ccma.domain.dates import date_input_hint, format_date_for_display
from ccma.storage.repository import MemberRepository, RepositoryError 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): class ClaimTab(ttk.Frame):
@@ -86,6 +92,8 @@ class ClaimTab(ttk.Frame):
footer = ttk.Frame(self) footer = ttk.Frame(self)
footer.grid(row=3, column=0, sticky="ew", pady=(10, 0)) footer.grid(row=3, column=0, sticky="ew", pady=(10, 0))
footer.columnconfigure(0, weight=1) 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 = ttk.Button(footer, text="Forderung stornieren", command=self._cancel_claim)
self.cancel_button.grid(row=0, column=1, sticky="e") 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: def _build_reminders(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1) parent.columnconfigure(0, weight=1)
parent.rowconfigure(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 ( for key, title, width in (
("created", "Erstellt", 150), ("created", "Erstellt", 150),
("level", "Stufe", 80), ("level", "Stufe", 60),
("detail", "Details", 430), ("name", "Mahnung", 230),
("fee", "Gebühr", 100), ("status", "Status", 130),
("deadline", "Zahlungsfrist", 120),
("fee", "Gebühr", 90),
): ):
self.reminders.heading(key, text=title) self.reminders.heading(key, text=title)
self.reminders.column(key, width=width, anchor="w") self.reminders.column(key, width=width, anchor="w")
self.reminders.grid(row=0, column=0, sticky="nsew") self.reminders.grid(row=0, column=0, sticky="nsew")
ttk.Button(parent, text="Mahnung erfassen", command=self._add_reminder).grid( self.reminders.bind("<<TreeviewSelect>>", lambda _event: self._update_reminder_buttons())
row=1, column=0, sticky="e", pady=(10, 0) 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: def refresh(self) -> None:
try: try:
@@ -165,11 +188,17 @@ class ClaimTab(ttk.Frame):
self.subtitle_var.set( self.subtitle_var.set(
f"{member.member_number} · {member.display_name} · Fällig: {due or ''} · {self.claim_id}" 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["total"].set(f"{money_text(total)} EUR")
self.summary_vars["paid"].set(f"{money_text(paid)} EUR") self.summary_vars["paid"].set(f"{money_text(paid)} EUR")
self.summary_vars["balance"].set(f"{money_text(balance)} EUR") self.summary_vars["balance"].set(f"{money_text(balance)} EUR")
self.summary_vars["status"].set(CLAIM_STATUS_LABELS.get(status, status.upper())) self.summary_vars["status"].set(CLAIM_STATUS_LABELS.get(status, status.upper()))
self.cancel_button.configure(state="disabled" if status == "cancelled" else "normal") 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_positions()
self._render_payments() self._render_payments()
self._render_reminders() self._render_reminders()
@@ -217,6 +246,7 @@ class ClaimTab(ttk.Frame):
if str(reminder.get("claim_id", "")) != self.claim_id: if str(reminder.get("claim_id", "")) != self.claim_id:
continue continue
fee_item = items.get(str(reminder.get("fee_item_id", "")), {}) fee_item = items.get(str(reminder.get("fee_item_id", "")), {})
status = str(reminder.get("status", "draft"))
self.reminders.insert( self.reminders.insert(
"", "",
"end", "end",
@@ -224,10 +254,13 @@ class ClaimTab(ttk.Frame):
values=( values=(
str(reminder.get("created_at", ""))[:16], str(reminder.get("created_at", ""))[:16],
reminder.get("level", ""), reminder.get("level", ""),
reminder.get("detail", ""), reminder.get("name", f"Mahnung Stufe {reminder.get('level', '')}"),
fee_item.get("amount", "0.00"), 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: def _add_item(self) -> None:
ItemDialog(self, self.repository, self.member_id, self.claim_id, self._changed) 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: def _add_reminder(self) -> None:
ReminderDialog(self, self.repository, self.member_id, self.claim_id, self._changed) 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: def _cancel_claim(self) -> None:
if not messagebox.askyesno( if not messagebox.askyesno(
"Forderung stornieren", "Diese Forderung wirklich stornieren?", parent=self "Forderung stornieren", "Diese Forderung wirklich stornieren?", parent=self
@@ -278,11 +369,11 @@ class _Dialog(tk.Toplevel):
self.on_saved = on_saved self.on_saved = on_saved
self.title(title) self.title(title)
self.transient(master.winfo_toplevel()) self.transient(master.winfo_toplevel())
self.grab_set()
self.resizable(False, False) self.resizable(False, False)
self.frame = ttk.Frame(self, padding=18) self.frame = ttk.Frame(self, padding=18)
self.frame.pack(fill="both", expand=True) self.frame.pack(fill="both", expand=True)
self.bind("<Escape>", lambda _event: self.destroy()) self.bind("<Escape>", lambda _event: self.destroy())
self.after_idle(self.grab_set)
def _buttons(self, row: int, command: Callable[[], None]) -> None: def _buttons(self, row: int, command: Callable[[], None]) -> None:
buttons = ttk.Frame(self.frame) buttons = ttk.Frame(self.frame)
@@ -432,32 +523,98 @@ class AllocatePaymentDialog(_Dialog):
class ReminderDialog(_Dialog): class ReminderDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, on_saved): 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.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.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.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( level_combo = ttk.Combobox(
row=0, column=1, sticky="w", pady=5 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("<<ComboboxSelected>>", lambda _event: self._load_definition())
ttk.Label(self.frame, text="Details").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12)) 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.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.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) 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): def _save(self):
definition = self.definition_by_label[self.level_var.get()]
try: try:
self.repository.add_reminder( self.repository.create_reminder_draft(
self.member_id, self.member_id,
self.claim_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(), detail=self.detail_var.get(),
fee=self.fee_var.get(), fee=self.fee_var.get(),
channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()),
) )
except (tk.TclError, RepositoryError) as exc: except (ValueError, RepositoryError) as exc:
messagebox.showerror("Mahnung konnte nicht gespeichert werden", str(exc), parent=self) 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 return
self.destroy() self.destroy()
self.on_saved() self.on_saved()
+13
View File
@@ -17,6 +17,19 @@ CLAIM_ITEM_TYPE_LABELS = {
"correction": "Korrektur", "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: def display_label(labels: Mapping[str, str], key: str) -> str:
return labels.get(key, key) return labels.get(key, key)
+15 -3
View File
@@ -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: def test_reminder_fee_increases_claim_and_is_audited(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path) repository, member = _repository_with_claim(tmp_path)
reminder = repository.add_reminder( reminder = repository.create_reminder_draft(
member.member_id, member.member_id,
"claim-1", "claim-1",
level=1, level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
detail="Per E-Mail versandt", detail="Per E-Mail versandt",
fee="5.00", fee="5.00",
) )
data, claim = repository.get_claim(member.member_id, "claim-1") 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 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 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: def test_claim_with_payment_cannot_be_cancelled(tmp_path) -> None:
+5 -1
View File
@@ -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)) 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: def test_housekeeper_reports_birthdays_before_today_and_after(tmp_path) -> None:
+136
View File
@@ -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)