From 56a3f5f0377b014ac3575637c115235a032baf0e Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 03:20:18 +0200 Subject: [PATCH 1/4] Fix housekeeper false-positive overdue errors and ended-member birthday/anniversary noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An overdue claim whose reminder had already been sent, with that reminder's own payment deadline still running, fell through to the plain "überfällig" error finding whenever reminder_due's escalation check returned None to signal "not yet time for the next Mahnstufe" -- that None was indistinguishable from "never handled at all" to the caller, so it looked neglected even though a reminder was already out. Give that case its own low-priority "reminder_awaiting_deadline" finding instead of silently falling back to the overdue-error path. Birthday and membership-anniversary findings also kept firing for members whose status is "ended", which doesn't make sense once the membership itself is over. Co-Authored-By: Claude Sonnet 5 --- src/ccma/rules/scripts/birthday.py | 2 ++ .../rules/scripts/membership_anniversary.py | 2 ++ src/ccma/rules/scripts/reminder_due.py | 30 ++++++++++++++++- tests/test_reminders.py | 29 ++++++++++++++++ tests/test_rules.py | 33 +++++++++++++++++++ 5 files changed, 95 insertions(+), 1 deletion(-) diff --git a/src/ccma/rules/scripts/birthday.py b/src/ccma/rules/scripts/birthday.py index a3d7a06..555df31 100644 --- a/src/ccma/rules/scripts/birthday.py +++ b/src/ccma/rules/scripts/birthday.py @@ -8,6 +8,8 @@ ORDER = 20 def evaluate(context: RuleContext): member = context.member + if member.status == "ended": + return [] try: birth_date = validate_birth_date(member.birth_date, today=context.today) except DateValidationError: diff --git a/src/ccma/rules/scripts/membership_anniversary.py b/src/ccma/rules/scripts/membership_anniversary.py index abded48..b7647c1 100644 --- a/src/ccma/rules/scripts/membership_anniversary.py +++ b/src/ccma/rules/scripts/membership_anniversary.py @@ -8,6 +8,8 @@ ORDER = 30 def evaluate(context: RuleContext): member = context.member + if member.status == "ended": + return [] try: started_at = parse_iso_date(member.membership_started_at, "Mitglied seit") except DateValidationError: diff --git a/src/ccma/rules/scripts/reminder_due.py b/src/ccma/rules/scripts/reminder_due.py index 360e3be..15c0207 100644 --- a/src/ccma/rules/scripts/reminder_due.py +++ b/src/ccma/rules/scripts/reminder_due.py @@ -105,8 +105,14 @@ def _reminder_action(context: RuleContext, claim, claim_id: str, levels, policy) 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: + if not trigger_date: return None + if context.today < trigger_date: + # A reminder for the previous level was already sent and its own payment + # deadline hasn't expired yet -- this is being handled, not neglected, so it + # must not show up as the same "error" severity as a plain unaddressed overdue + # claim (that fallback is what the caller uses when this returns None). + return _awaiting_deadline_action(context, claim, claim_id, level, sent_levels, trigger_date) draft_exists = any( int(item.get("level", 0)) == level and str(item.get("status", "draft")) in {"draft", "generated"} for item in reminders @@ -134,6 +140,28 @@ def _reminder_action(context: RuleContext, claim, claim_id: str, levels, policy) ) +def _awaiting_deadline_action( + context: RuleContext, claim, claim_id: str, level: int, sent_levels: dict, trigger_date: date +) -> RuleAction: + previous_level = level - 1 + previous = sent_levels.get(previous_level) or {} + name = str(previous.get("name") or f"Mahnstufe {previous_level}") + balance = money_text(claim_balance(context.contributions, claim)) + return task( + rule_id=RULE_ID, + member=context.member, + key_suffix=f"{claim_id}:level-{previous_level}:awaiting", + severity="info", + code="reminder_awaiting_deadline", + title=f"{context.member.display_name}: {name} versandt, Frist läuft noch", + 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 _trigger_date(claim, sent_levels, level: int, policy) -> date | None: if level == 1: try: diff --git a/tests/test_reminders.py b/tests/test_reminders.py index 9e2aa35..8fe3eae 100644 --- a/tests/test_reminders.py +++ b/tests/test_reminders.py @@ -61,6 +61,35 @@ def test_reminder_rule_progresses_only_after_sent_deadline(tmp_path) -> None: assert "Erste Mahnung" in next_task.title +def test_overdue_claim_awaiting_reminder_deadline_is_not_reported_as_error(tmp_path) -> None: + repository, member = _overdue_claim_repository(tmp_path) + housekeeper = Housekeeper(repository) + draft = repository.create_reminder_draft( + member.member_id, + "claim-1", + level=1, + name="Zahlungserinnerung", + payment_deadline_days=14, + items=[], + ) + 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 + ] + + # A reminder was already sent and its own deadline hasn't expired yet -- this is + # being handled, so it must not appear as an unaddressed "error"-severity overdue + # claim, only as a low-priority note that the existing deadline is still running. + assert not any(item.code == "claim_overdue" for item in findings) + awaiting = next(item for item in findings if item.code == "reminder_awaiting_deadline") + assert awaiting.severity == "info" + assert "Zahlungserinnerung" in awaiting.title + + def test_dunning_hold_suppresses_and_then_restores_task(tmp_path) -> None: repository, member = _overdue_claim_repository(tmp_path) housekeeper = Housekeeper(repository) diff --git a/tests/test_rules.py b/tests/test_rules.py index bd8746d..cfd1afa 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -236,6 +236,39 @@ def test_housekeeper_does_not_create_claims_due_after_membership_end(tmp_path) - assert "membership-fee:2027:annual" not in claim_keys +def test_housekeeper_skips_birthday_and_anniversary_for_ended_members(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + active = repository.create_member(first_name="Active", last_name="Member", birth_date="1990-06-21") + active.status = "active" + active.accepted_at = "2025-06-21" + active.membership_started_at = "2025-06-21" + repository.save_member(active) + ended = repository.create_member(first_name="Ended", last_name="Member", birth_date="1990-06-21") + ended.status = "ended" + ended.accepted_at = "2025-06-21" + ended.membership_started_at = "2025-06-21" + ended.membership_ended_at = "2025-12-31" + repository.save_member(ended) + + settings = housekeeper_module.HousekeeperSettings.from_values( + birthday_days_before=0, + birthday_days_after=0, + anniversary_days_before=0, + anniversary_days_after=0, + anniversary_intervals="1Y", + ) + + findings = Housekeeper(repository, settings).run(today=date(2026, 6, 21)) + codes_by_member = {} + for item in findings: + codes_by_member.setdefault(item.member_id, set()).add(item.code) + + assert "birthday" in codes_by_member.get(active.member_id, set()) + assert "membership_anniversary" in codes_by_member.get(active.member_id, set()) + assert codes_by_member.get(ended.member_id, set()).isdisjoint({"birthday", "membership_anniversary"}) + + def test_housekeeper_resolves_tasks_not_seen_in_current_run(tmp_path) -> None: repository = MemberRepository(tmp_path) repository.initialize() From 0641285681574f5da1c9aded20e0929aedabe821 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 03:32:07 +0200 Subject: [PATCH 2/4] Document the housekeeper overdue-severity and ended-member reminder fixes Co-Authored-By: Claude Sonnet 5 --- src/ccma/assets/CHANGELOG.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index 9fd70a5..f1ba325 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -32,7 +32,9 @@ "Der Hausmeister kann per Knopfdruck direkt aus der Mitgliederakte gezielt für ein einzelnes Mitglied ausgeführt werden, optional mit rückwirkender Erstellung von Beiträgen/Forderungen nur für diesen einen Lauf; alle anderen Mitglieder bleiben davon unberührt.", "Eine Mahnung kann statt einer einzelnen Gebühr mehrere frei bearbeitbare Positionen (Beschreibung & Betrag) enthalten; für eine nicht eingelöste Lastschrift steht eine eigene Rücklastschrift-Vorlage bereit, die dieselbe Mahnstufe wie eine reguläre Mahnung belegt und einen vorausgefüllten Hinweistext für die Mahnungs-E-Mail mitbringt.", "Mahnstufen, Zahlungsfristen und Standardpositionen (Bezeichnung & Betrag) für Mahnungen lassen sich jetzt unter Optionen → Mahnungen zentral konfigurieren.", - "Mahnungsentwürfe können nachträglich bearbeitet werden; auch eine bereits als versandt markierte Mahnung lässt sich dafür sicher zurücksetzen (die gebuchte Mahngebühr wird wieder entfernt), sofern noch keine höhere Mahnstufe existiert oder Zahlungen bereits darauf verbucht wurden." + "Mahnungsentwürfe können nachträglich bearbeitet werden; auch eine bereits als versandt markierte Mahnung lässt sich dafür sicher zurücksetzen (die gebuchte Mahngebühr wird wieder entfernt), sofern noch keine höhere Mahnstufe existiert oder Zahlungen bereits darauf verbucht wurden.", + "Eine überfällige Forderung, für die bereits eine Mahnung versandt wurde und deren Zahlungsfrist noch läuft, wird vom Hausmeister nicht mehr fälschlich als unbearbeiteter Fehler gemeldet, sondern als niedrig priorisierter Hinweis, dass die laufende Frist noch nicht abgelaufen ist.", + "Geburtstags- und Mitgliedsjubiläums-Hinweise des Hausmeisters werden für Mitglieder mit beendeter Mitgliedschaft nicht mehr angezeigt." ] }, { From 25f5d51d77c5ee1d5690e07510ba57923a66a3ac Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 03:45:27 +0200 Subject: [PATCH 3/4] =?UTF-8?q?Stop=20flagging=20SEPA=20members=20with=20a?= =?UTF-8?q?=20running=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( From d87a57775e5fab41556ca4160ed992d1394569be Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 03:45:49 +0200 Subject: [PATCH 4/4] Document the SEPA overdue-during-active-Mahnung fix Co-Authored-By: Claude Sonnet 5 --- src/ccma/assets/CHANGELOG.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index f1ba325..5ddae9e 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -34,7 +34,8 @@ "Mahnstufen, Zahlungsfristen und Standardpositionen (Bezeichnung & Betrag) für Mahnungen lassen sich jetzt unter Optionen → Mahnungen zentral konfigurieren.", "Mahnungsentwürfe können nachträglich bearbeitet werden; auch eine bereits als versandt markierte Mahnung lässt sich dafür sicher zurücksetzen (die gebuchte Mahngebühr wird wieder entfernt), sofern noch keine höhere Mahnstufe existiert oder Zahlungen bereits darauf verbucht wurden.", "Eine überfällige Forderung, für die bereits eine Mahnung versandt wurde und deren Zahlungsfrist noch läuft, wird vom Hausmeister nicht mehr fälschlich als unbearbeiteter Fehler gemeldet, sondern als niedrig priorisierter Hinweis, dass die laufende Frist noch nicht abgelaufen ist.", - "Geburtstags- und Mitgliedsjubiläums-Hinweise des Hausmeisters werden für Mitglieder mit beendeter Mitgliedschaft nicht mehr angezeigt." + "Geburtstags- und Mitgliedsjubiläums-Hinweise des Hausmeisters werden für Mitglieder mit beendeter Mitgliedschaft nicht mehr angezeigt.", + "Eine überfällige Lastschrift, für die bereits eine Rücklastschrift-Mahnung versandt wurde und deren Zahlungsfrist noch läuft, wird vom Hausmeister nicht mehr weiterhin als überfällig gemeldet, sondern als niedrig priorisierter Hinweis, dass die Klärung noch läuft." ] }, {