From 25f5d51d77c5ee1d5690e07510ba57923a66a3ac Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 03:45:27 +0200 Subject: [PATCH] =?UTF-8?q?Stop=20flagging=20SEPA=20members=20with=20a=20r?= =?UTF-8?q?unning=20R=C3=BCcklastschrift-Mahnung=20as=20overdue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/ccma/rules/scripts/reminder_due.py | 50 +++++++++++++++++++++++++- tests/test_reminders.py | 41 +++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/ccma/rules/scripts/reminder_due.py b/src/ccma/rules/scripts/reminder_due.py index 15c0207..af3847d 100644 --- a/src/ccma/rules/scripts/reminder_due.py +++ b/src/ccma/rules/scripts/reminder_due.py @@ -49,7 +49,7 @@ def evaluate(context: RuleContext) -> list[RuleAction]: 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) + 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)) @@ -72,6 +72,13 @@ def _overdue_action(context: RuleContext, claim, claim_id: str, due: date, days_ ) +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( @@ -90,6 +97,47 @@ def _sepa_action(context: RuleContext, claim, claim_id: str, days_overdue: int) ) +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 diff --git a/tests/test_reminders.py b/tests/test_reminders.py index 8fe3eae..3698a46 100644 --- a/tests/test_reminders.py +++ b/tests/test_reminders.py @@ -152,6 +152,47 @@ def test_sepa_member_gets_debit_followup_instead_of_reminder(tmp_path) -> None: assert "SEPA" in sepa_finding.detail +def test_sepa_member_with_running_reminder_shows_awaiting_note_not_overdue(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) + + draft = repository.create_reminder_draft( + member.member_id, + "claim-1", + level=1, + name="Rücklastschrift", + payment_deadline_days=14, + items=[{"description": "Rücklastschriftgebühr", "amount": "5.00"}], + ) + sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"]) + deadline = date.fromisoformat(sent["payment_deadline"]) + + findings = [ + item + for item in housekeeper.run(today=deadline - timedelta(days=1)) + if item.member_id == member.member_id + ] + + assert not any(item.code == "sepa_debit_overdue" for item in findings) + awaiting = next(item for item in findings if item.code == "sepa_debit_awaiting_deadline") + assert awaiting.severity == "info" + assert "Rücklastschrift" in awaiting.title + + # Once the reminder's own deadline has passed without resolution, the plain SEPA + # follow-up notice returns so it doesn't just silently go quiet forever. + findings = [ + item for item in housekeeper.run(today=deadline + timedelta(days=1)) + if item.member_id == member.member_id + ] + assert any(item.code == "sepa_debit_overdue" for item in findings) + assert not any(item.code == "sepa_debit_awaiting_deadline" for item in findings) + + 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(