mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
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>
164 lines
6.4 KiB
Python
164 lines
6.4 KiB
Python
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
|