From 56a3f5f0377b014ac3575637c115235a032baf0e Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 03:20:18 +0200 Subject: [PATCH] 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()