Fix housekeeper false-positive overdue errors and ended-member birthday/anniversary noise

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 <noreply@anthropic.com>
This commit is contained in:
Marcel Peterkau
2026-08-15 03:20:18 +02:00
co-authored by Claude Sonnet 5
parent 4ecdf87d05
commit 56a3f5f037
5 changed files with 95 additions and 1 deletions
+2
View File
@@ -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:
@@ -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:
+29 -1
View File
@@ -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:
+29
View File
@@ -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)
+33
View File
@@ -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()