mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 15:05:18 +02:00
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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
42cde2a0c8
commit
4e4aa22589
@@ -6,6 +6,11 @@ from ccma.rules.api import RuleContext, task
|
||||
RULE_ID = "claim-status"
|
||||
ORDER = 50
|
||||
|
||||
# Overdue claims (delta < 0) are handled entirely by reminder-due: it owns the whole
|
||||
# overdue lifecycle (grace period, dunning escalation, SEPA follow-up) so there is
|
||||
# exactly one finding per overdue claim instead of this rule and that one both firing
|
||||
# for the same problem.
|
||||
|
||||
|
||||
def evaluate(context: RuleContext):
|
||||
actions = []
|
||||
@@ -21,33 +26,21 @@ def evaluate(context: RuleContext):
|
||||
except ValueError:
|
||||
continue
|
||||
delta = (due - context.today).days
|
||||
if delta < 0 or delta > 14:
|
||||
continue
|
||||
claim_key = str(claim.get("claim_key") or claim.get("claim_id") or "unknown")
|
||||
title = str(claim.get("title") or "Beitragsforderung")
|
||||
claim_type = str(claim.get("type", ""))
|
||||
if delta < 0:
|
||||
actions.append(
|
||||
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 {-delta} Tagen.",
|
||||
due_date=due,
|
||||
code="initial_payment_overdue" if claim_type == "admission_fee" else "claim_overdue",
|
||||
)
|
||||
)
|
||||
elif delta <= 14:
|
||||
actions.append(
|
||||
task(
|
||||
rule_id=RULE_ID,
|
||||
member=context.member,
|
||||
key_suffix=f"due-soon:{claim_key}",
|
||||
severity="info",
|
||||
title=f"{context.member.display_name}: {title} bald fällig",
|
||||
detail=f"Fälligkeit in {delta} Tagen.",
|
||||
due_date=due,
|
||||
code="initial_payment_due_soon" if claim_type == "admission_fee" else "claim_due_soon",
|
||||
)
|
||||
actions.append(
|
||||
task(
|
||||
rule_id=RULE_ID,
|
||||
member=context.member,
|
||||
key_suffix=f"due-soon:{claim_key}",
|
||||
severity="info",
|
||||
title=f"{context.member.display_name}: {title} bald fällig",
|
||||
detail=f"Fälligkeit in {delta} Tagen.",
|
||||
due_date=due,
|
||||
code="initial_payment_due_soon" if claim_type == "admission_fee" else "claim_due_soon",
|
||||
)
|
||||
)
|
||||
return actions
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
from ccma.domain.contributions import claim_balance, claim_status, money_text
|
||||
from ccma.rules.api import RuleContext, task
|
||||
from ccma.rules.api import RuleAction, RuleContext, task
|
||||
|
||||
RULE_ID = "reminder-due"
|
||||
ORDER = 60
|
||||
@@ -16,10 +16,18 @@ DEFAULT_POLICY = {
|
||||
}
|
||||
|
||||
|
||||
def evaluate(context: RuleContext):
|
||||
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)))
|
||||
actions = []
|
||||
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 {
|
||||
@@ -28,54 +36,104 @@ def evaluate(context: RuleContext):
|
||||
"overdue",
|
||||
}:
|
||||
continue
|
||||
if claim_balance(context.contributions, claim) <= 0 or _hold_is_active(claim, context.today):
|
||||
if claim_balance(context.contributions, claim) <= 0:
|
||||
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:
|
||||
try:
|
||||
due = date.fromisoformat(str(claim.get("due_date", "")))
|
||||
except ValueError:
|
||||
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,
|
||||
)
|
||||
)
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user