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
+172 -19
View File
@@ -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: