Stop flagging SEPA members with a running Rücklastschrift-Mahnung as overdue

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>
This commit is contained in:
Marcel Peterkau
2026-08-15 03:45:27 +02:00
co-authored by Claude Sonnet 5
parent 0641285681
commit 25f5d51d77
2 changed files with 90 additions and 1 deletions
+49 -1
View File
@@ -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
+41
View File
@@ -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(