mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 06:55:17 +02:00
sepa_debit_overdue fired unconditionally for every overdue SEPA claim past the grace period, regardless of whether a reminder had already been sent for it -- so a Rücklastschrift-Mahnung created via the reminder mechanism made no difference to the housekeeper output. Check for an already-sent reminder whose own payment deadline hasn't expired yet first; if there is one, show a low-priority "Rücklastschriftklärung läuft" note instead, mirroring the non-SEPA "reminder_awaiting_deadline" fix. Once that deadline passes without resolution, the plain sepa_debit_overdue notice returns. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
240 lines
9.7 KiB
Python
240 lines
9.7 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_escalation(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_escalation(context: RuleContext, claim, claim_id: str, days_overdue: int) -> RuleAction:
|
||
pending = _latest_pending_reminder(context.contributions.reminders, claim_id, context.today)
|
||
if pending:
|
||
return _sepa_awaiting_action(context, claim, claim_id, *pending)
|
||
return _sepa_action(context, claim, claim_id, days_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 _sepa_awaiting_action(
|
||
context: RuleContext, claim, claim_id: str, reminder: dict, trigger_date: date
|
||
) -> RuleAction:
|
||
"""A Rücklastschrift-Mahnung (or any reminder) was already sent for this SEPA
|
||
claim and its own deadline hasn't expired yet -- staff already engaged, so this
|
||
must not keep nagging "check the direct debit" as if nothing had happened."""
|
||
name = str(reminder.get("name") or "Rücklastschrift")
|
||
balance = money_text(claim_balance(context.contributions, claim))
|
||
return task(
|
||
rule_id=RULE_ID,
|
||
member=context.member,
|
||
key_suffix=f"{claim_id}:sepa-awaiting",
|
||
severity="info",
|
||
code="sepa_debit_awaiting_deadline",
|
||
title=f"{context.member.display_name}: {name} versandt, Rücklastschriftklärung läuft",
|
||
detail=(
|
||
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
|
||
f"Zahlungsfrist bis {trigger_date.isoformat()} noch nicht abgelaufen."
|
||
),
|
||
due_date=trigger_date,
|
||
)
|
||
|
||
|
||
def _latest_pending_reminder(
|
||
reminders: list[dict], claim_id: str, today: date
|
||
) -> tuple[dict, date] | None:
|
||
best: tuple[dict, date] | None = None
|
||
for item in reminders:
|
||
if str(item.get("claim_id", "")) != claim_id or str(item.get("status", "")) != "sent":
|
||
continue
|
||
try:
|
||
deadline = date.fromisoformat(str(item.get("payment_deadline", "")))
|
||
except ValueError:
|
||
continue
|
||
if today > deadline:
|
||
continue
|
||
if best is None or deadline > best[1]:
|
||
best = (item, deadline)
|
||
return best
|
||
|
||
|
||
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:
|
||
return None
|
||
if context.today < trigger_date:
|
||
# A reminder for the previous level was already sent and its own payment
|
||
# deadline hasn't expired yet -- this is being handled, not neglected, so it
|
||
# must not show up as the same "error" severity as a plain unaddressed overdue
|
||
# claim (that fallback is what the caller uses when this returns None).
|
||
return _awaiting_deadline_action(context, claim, claim_id, level, sent_levels, trigger_date)
|
||
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 _awaiting_deadline_action(
|
||
context: RuleContext, claim, claim_id: str, level: int, sent_levels: dict, trigger_date: date
|
||
) -> RuleAction:
|
||
previous_level = level - 1
|
||
previous = sent_levels.get(previous_level) or {}
|
||
name = str(previous.get("name") or f"Mahnstufe {previous_level}")
|
||
balance = money_text(claim_balance(context.contributions, claim))
|
||
return task(
|
||
rule_id=RULE_ID,
|
||
member=context.member,
|
||
key_suffix=f"{claim_id}:level-{previous_level}:awaiting",
|
||
severity="info",
|
||
code="reminder_awaiting_deadline",
|
||
title=f"{context.member.display_name}: {name} versandt, Frist läuft noch",
|
||
detail=(
|
||
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
|
||
f"Zahlungsfrist bis {trigger_date.isoformat()} noch nicht abgelaufen."
|
||
),
|
||
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
|