Merge pull request 'Feature/housekeeper overdue anniversary fixes' (#20) from feature/housekeeper-overdue-anniversary-fixes into dev

Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/20
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
This commit is contained in:
Marcel Peterkau
2026-08-15 02:04:34 +00:00
6 changed files with 189 additions and 3 deletions
+4 -1
View File
@@ -32,7 +32,10 @@
"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.",
"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."
]
},
{
+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:
+78 -2
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
@@ -105,8 +153,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 +188,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:
+70
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)
@@ -123,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(
+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()