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:
Marcel Peterkau
2026-08-14 23:02:37 +02:00
co-authored by Claude Sonnet 5
parent 42cde2a0c8
commit 4e4aa22589
4 changed files with 166 additions and 77 deletions
+18 -25
View File
@@ -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
+104 -46
View File
@@ -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:
+4 -5
View File
@@ -29,11 +29,10 @@ def test_housekeeper_reports_initial_payment_and_open_claims(tmp_path) -> None:
)
findings = Housekeeper(repository).run(today=date(2026, 2, 10))
assert {finding.code for finding in findings} == {
"initial_payment_overdue",
"claim_overdue",
"reminder_due",
}
# Both claims are more than the default 7-day grace period overdue, so the dunning
# escalation ("reminder_due") is the single active finding for each -- no separate,
# redundant "overdue" finding alongside it.
assert {finding.code for finding in findings} == {"reminder_due"}
def test_housekeeper_reports_birthdays_before_today_and_after(tmp_path) -> None:
+40 -1
View File
@@ -68,7 +68,11 @@ def test_dunning_hold_suppresses_and_then_restores_task(tmp_path) -> None:
reason="Betrag wird geklärt",
)
assert not any(item.code == "reminder_due" for item in housekeeper.run(today=date(2026, 2, 10)))
findings = housekeeper.run(today=date(2026, 2, 10))
assert not any(item.code == "reminder_due" for item in findings)
# The claim stays visible as a plain overdue notice instead of disappearing entirely
# while the hold suppresses the dunning escalation.
assert any(item.code == "claim_overdue" for item in findings)
with pytest.raises(RepositoryError, match="Mahnsperre aktiv"):
repository.create_reminder_draft(
member.member_id,
@@ -81,6 +85,41 @@ def test_dunning_hold_suppresses_and_then_restores_task(tmp_path) -> None:
assert any(item.code == "reminder_due" for item in housekeeper.run(today=date(2026, 2, 10)))
def test_overdue_claim_and_reminder_finding_are_never_shown_at_once(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
housekeeper = Housekeeper(repository)
# Still within the 7-day default grace period: only the plain overdue notice, no
# dunning escalation yet.
within_grace = housekeeper.run(today=date(2026, 2, 4))
codes = {item.code for item in within_grace if item.member_id == member.member_id}
assert codes == {"claim_overdue"}
# Past the grace period: the dunning escalation takes over as the single finding,
# the redundant plain overdue notice disappears.
past_grace = housekeeper.run(today=date(2026, 2, 10))
codes = {item.code for item in past_grace if item.member_id == member.member_id}
assert codes == {"reminder_due"}
def test_sepa_member_gets_debit_followup_instead_of_reminder(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
member.iban = "DE89370400440532013000"
member.mandate_reference = "MANDATE-1"
member.mandate_signed_at = "2026-01-01"
member.mandate_active = True
repository.save_member(member)
housekeeper = Housekeeper(repository)
findings = housekeeper.run(today=date(2026, 2, 10))
codes = {item.code for item in findings if item.member_id == member.member_id}
assert codes == {"sepa_debit_overdue"}
sepa_finding = next(item for item in findings if item.code == "sepa_debit_overdue")
assert "Lastschrift" in sepa_finding.title
assert "SEPA" in sepa_finding.detail
def test_draft_can_be_cancelled_but_sent_reminder_cannot(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
draft = repository.create_reminder_draft(