From 4e4aa22589565036304ebb6ce1c98e3948186070 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Fri, 14 Aug 2026 22:41:37 +0200 Subject: [PATCH] Stop double-reporting overdue claims and add SEPA-specific followup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/ccma/rules/scripts/claim_status.py | 43 +++---- src/ccma/rules/scripts/reminder_due.py | 150 +++++++++++++++++-------- tests/test_housekeeper.py | 9 +- tests/test_reminders.py | 41 ++++++- 4 files changed, 166 insertions(+), 77 deletions(-) diff --git a/src/ccma/rules/scripts/claim_status.py b/src/ccma/rules/scripts/claim_status.py index b0a025b..2821fef 100644 --- a/src/ccma/rules/scripts/claim_status.py +++ b/src/ccma/rules/scripts/claim_status.py @@ -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 diff --git a/src/ccma/rules/scripts/reminder_due.py b/src/ccma/rules/scripts/reminder_due.py index d1ebea1..360e3be 100644 --- a/src/ccma/rules/scripts/reminder_due.py +++ b/src/ccma/rules/scripts/reminder_due.py @@ -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: diff --git a/tests/test_housekeeper.py b/tests/test_housekeeper.py index 56fe8b2..14ab59f 100644 --- a/tests/test_housekeeper.py +++ b/tests/test_housekeeper.py @@ -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: diff --git a/tests/test_reminders.py b/tests/test_reminders.py index c9f7b9a..f1e5091 100644 --- a/tests/test_reminders.py +++ b/tests/test_reminders.py @@ -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(