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
+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