Files
CCMA/src/ccma/rules/scripts/reminder_due.py
T
Marcel PeterkauandClaude Sonnet 5 4e4aa22589 Stop double-reporting overdue claims and add SEPA-specific followup
The claim-status and reminder-due housekeeper rules used to run
independently, so once a claim was both overdue and past its dunning
grace period, both a generic "überfällig" task and a "Mahnung fällig"
task showed up for the same problem. Rules can't see each other's
output, so the fix is to give reminder-due sole ownership of the whole
overdue lifecycle: a plain overdue notice during the grace period, then
either the dunning escalation or a fallback overdue notice (dunning
hold active, or waiting between reminder levels) -- never both at once.
claim-status now only handles claims that aren't overdue yet ("bald
fällig").

Members with an active SEPA mandate get a new sepa_debit_overdue finding
instead of the postal dunning escalation once a claim is past its grace
period, since mailing a Mahnung makes no sense for a member who pays by
direct debit -- the board needs to check/retrigger the debit instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 23:02:37 +02:00

164 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import date, timedelta
from ccma.domain.contributions import claim_balance, claim_status, money_text
from ccma.rules.api import RuleAction, 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) -> list[RuleAction]:
"""Owns the whole lifecycle of an overdue claim: a plain overdue notice during the
grace period, then either the postal dunning escalation or -- for members paying by
SEPA direct debit, where sending a dunning letter makes no sense -- a distinct
"check the direct debit" notice. Falls back to the plain overdue notice whenever no
escalation applies (dunning hold active, or between reminder levels), so there is
always exactly one finding for an overdue claim, never both an overdue notice and a
reminder notice at once."""
policy = context.repository_config.get("reminder_policy") or DEFAULT_POLICY
levels = sorted(policy.get("levels") or [], key=lambda value: int(value.get("level", 0)))
grace_days = int(policy.get("grace_days_after_due", 7))
actions: list[RuleAction] = []
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:
continue
try:
due = date.fromisoformat(str(claim.get("due_date", "")))
except ValueError:
continue
days_overdue = (context.today - due).days
if days_overdue < 0:
continue # not yet due; claim-status handles the "due soon" notice
escalation = None
if days_overdue >= grace_days and not _hold_is_active(claim, context.today):
if context.member.mandate_active:
escalation = _sepa_action(context, claim, claim_id, days_overdue)
else:
escalation = _reminder_action(context, claim, claim_id, levels, policy)
actions.append(escalation or _overdue_action(context, claim, claim_id, due, days_overdue))
return actions
def _overdue_action(context: RuleContext, claim, claim_id: str, due: date, days_overdue: int) -> RuleAction:
title = str(claim.get("title") or "Beitragsforderung")
claim_type = str(claim.get("type", ""))
claim_key = str(claim.get("claim_key") or claim_id)
return task(
rule_id=RULE_ID,
member=context.member,
key_suffix=f"overdue:{claim_key}",
severity="error",
title=f"{context.member.display_name}: {title} überfällig",
detail=f"Fälligkeit war vor {days_overdue} Tagen.",
due_date=due,
code="initial_payment_overdue" if claim_type == "admission_fee" else "claim_overdue",
)
def _sepa_action(context: RuleContext, claim, claim_id: str, days_overdue: int) -> RuleAction:
balance = money_text(claim_balance(context.contributions, claim))
return task(
rule_id=RULE_ID,
member=context.member,
key_suffix=f"{claim_id}:sepa",
severity="warning",
code="sepa_debit_overdue",
title=f"{context.member.display_name}: Lastschrift überfällig Einzug prüfen",
detail=(
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR, seit "
f"{days_overdue} Tagen überfällig. Das Mitglied zahlt per SEPA-Lastschriftmandat, eine "
"postalische Mahnung ist hier nicht vorgesehen -- bitte den Lastschrifteinzug prüfen "
"bzw. erneut anstoßen."
),
)
def _reminder_action(context: RuleContext, claim, claim_id: str, levels, policy) -> RuleAction | None:
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:
return None
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:
return None
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."
)
return 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,
)
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