mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-24 22:45:18 +02:00
Stop double-reporting overdue claims and add SEPA-specific followup
The claim-status and reminder-due housekeeper rules used to run
independently, so once a claim was both overdue and past its dunning
grace period, both a generic "überfällig" task and a "Mahnung fällig"
task showed up for the same problem. Rules can't see each other's
output, so the fix is to give reminder-due sole ownership of the whole
overdue lifecycle: a plain overdue notice during the grace period, then
either the dunning escalation or a fallback overdue notice (dunning
hold active, or waiting between reminder levels) -- never both at once.
claim-status now only handles claims that aren't overdue yet ("bald
fällig").
Members with an active SEPA mandate get a new sepa_debit_overdue finding
instead of the postal dunning escalation once a claim is past its grace
period, since mailing a Mahnung makes no sense for a member who pays by
direct debit -- the board needs to check/retrigger the debit instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
42cde2a0c8
commit
4e4aa22589
@@ -6,6 +6,11 @@ from ccma.rules.api import RuleContext, task
|
|||||||
RULE_ID = "claim-status"
|
RULE_ID = "claim-status"
|
||||||
ORDER = 50
|
ORDER = 50
|
||||||
|
|
||||||
|
# Overdue claims (delta < 0) are handled entirely by reminder-due: it owns the whole
|
||||||
|
# overdue lifecycle (grace period, dunning escalation, SEPA follow-up) so there is
|
||||||
|
# exactly one finding per overdue claim instead of this rule and that one both firing
|
||||||
|
# for the same problem.
|
||||||
|
|
||||||
|
|
||||||
def evaluate(context: RuleContext):
|
def evaluate(context: RuleContext):
|
||||||
actions = []
|
actions = []
|
||||||
@@ -21,33 +26,21 @@ def evaluate(context: RuleContext):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
delta = (due - context.today).days
|
delta = (due - context.today).days
|
||||||
|
if delta < 0 or delta > 14:
|
||||||
|
continue
|
||||||
claim_key = str(claim.get("claim_key") or claim.get("claim_id") or "unknown")
|
claim_key = str(claim.get("claim_key") or claim.get("claim_id") or "unknown")
|
||||||
title = str(claim.get("title") or "Beitragsforderung")
|
title = str(claim.get("title") or "Beitragsforderung")
|
||||||
claim_type = str(claim.get("type", ""))
|
claim_type = str(claim.get("type", ""))
|
||||||
if delta < 0:
|
actions.append(
|
||||||
actions.append(
|
task(
|
||||||
task(
|
rule_id=RULE_ID,
|
||||||
rule_id=RULE_ID,
|
member=context.member,
|
||||||
member=context.member,
|
key_suffix=f"due-soon:{claim_key}",
|
||||||
key_suffix=f"overdue:{claim_key}",
|
severity="info",
|
||||||
severity="error",
|
title=f"{context.member.display_name}: {title} bald fällig",
|
||||||
title=f"{context.member.display_name}: {title} überfällig",
|
detail=f"Fälligkeit in {delta} Tagen.",
|
||||||
detail=f"Fälligkeit war vor {-delta} Tagen.",
|
due_date=due,
|
||||||
due_date=due,
|
code="initial_payment_due_soon" if claim_type == "admission_fee" else "claim_due_soon",
|
||||||
code="initial_payment_overdue" if claim_type == "admission_fee" else "claim_overdue",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif delta <= 14:
|
|
||||||
actions.append(
|
|
||||||
task(
|
|
||||||
rule_id=RULE_ID,
|
|
||||||
member=context.member,
|
|
||||||
key_suffix=f"due-soon:{claim_key}",
|
|
||||||
severity="info",
|
|
||||||
title=f"{context.member.display_name}: {title} bald fällig",
|
|
||||||
detail=f"Fälligkeit in {delta} Tagen.",
|
|
||||||
due_date=due,
|
|
||||||
code="initial_payment_due_soon" if claim_type == "admission_fee" else "claim_due_soon",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
return actions
|
return actions
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from ccma.domain.contributions import claim_balance, claim_status, money_text
|
from ccma.domain.contributions import claim_balance, claim_status, money_text
|
||||||
from ccma.rules.api import RuleContext, task
|
from ccma.rules.api import RuleAction, RuleContext, task
|
||||||
|
|
||||||
RULE_ID = "reminder-due"
|
RULE_ID = "reminder-due"
|
||||||
ORDER = 60
|
ORDER = 60
|
||||||
@@ -16,10 +16,18 @@ DEFAULT_POLICY = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def evaluate(context: RuleContext):
|
def evaluate(context: RuleContext) -> list[RuleAction]:
|
||||||
|
"""Owns the whole lifecycle of an overdue claim: a plain overdue notice during the
|
||||||
|
grace period, then either the postal dunning escalation or -- for members paying by
|
||||||
|
SEPA direct debit, where sending a dunning letter makes no sense -- a distinct
|
||||||
|
"check the direct debit" notice. Falls back to the plain overdue notice whenever no
|
||||||
|
escalation applies (dunning hold active, or between reminder levels), so there is
|
||||||
|
always exactly one finding for an overdue claim, never both an overdue notice and a
|
||||||
|
reminder notice at once."""
|
||||||
policy = context.repository_config.get("reminder_policy") or DEFAULT_POLICY
|
policy = context.repository_config.get("reminder_policy") or DEFAULT_POLICY
|
||||||
levels = sorted(policy.get("levels") or [], key=lambda value: int(value.get("level", 0)))
|
levels = sorted(policy.get("levels") or [], key=lambda value: int(value.get("level", 0)))
|
||||||
actions = []
|
grace_days = int(policy.get("grace_days_after_due", 7))
|
||||||
|
actions: list[RuleAction] = []
|
||||||
for claim in context.contributions.claims:
|
for claim in context.contributions.claims:
|
||||||
claim_id = str(claim.get("claim_id", ""))
|
claim_id = str(claim.get("claim_id", ""))
|
||||||
if not claim_id or claim_status(context.contributions, claim, today=context.today) not in {
|
if not claim_id or claim_status(context.contributions, claim, today=context.today) not in {
|
||||||
@@ -28,54 +36,104 @@ def evaluate(context: RuleContext):
|
|||||||
"overdue",
|
"overdue",
|
||||||
}:
|
}:
|
||||||
continue
|
continue
|
||||||
if claim_balance(context.contributions, claim) <= 0 or _hold_is_active(claim, context.today):
|
if claim_balance(context.contributions, claim) <= 0:
|
||||||
continue
|
continue
|
||||||
reminders = [
|
try:
|
||||||
item for item in context.contributions.reminders if str(item.get("claim_id", "")) == claim_id
|
due = date.fromisoformat(str(claim.get("due_date", "")))
|
||||||
]
|
except ValueError:
|
||||||
sent_levels = {
|
|
||||||
int(item.get("level", 0)): item for item in reminders if str(item.get("status", "")) == "sent"
|
|
||||||
}
|
|
||||||
next_level = next(
|
|
||||||
(definition for definition in levels if int(definition.get("level", 0)) not in sent_levels),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not next_level:
|
|
||||||
continue
|
continue
|
||||||
level = int(next_level.get("level", 0))
|
days_overdue = (context.today - due).days
|
||||||
trigger_date = _trigger_date(claim, sent_levels, level, policy)
|
if days_overdue < 0:
|
||||||
if not trigger_date or context.today < trigger_date:
|
continue # not yet due; claim-status handles the "due soon" notice
|
||||||
continue
|
|
||||||
draft_exists = any(
|
escalation = None
|
||||||
int(item.get("level", 0)) == level and str(item.get("status", "draft")) in {"draft", "generated"}
|
if days_overdue >= grace_days and not _hold_is_active(claim, context.today):
|
||||||
for item in reminders
|
if context.member.mandate_active:
|
||||||
)
|
escalation = _sepa_action(context, claim, claim_id, days_overdue)
|
||||||
name = str(next_level.get("name") or f"Mahnung Stufe {level}")
|
else:
|
||||||
balance = money_text(claim_balance(context.contributions, claim))
|
escalation = _reminder_action(context, claim, claim_id, levels, policy)
|
||||||
title = (
|
actions.append(escalation or _overdue_action(context, claim, claim_id, due, days_overdue))
|
||||||
f"{context.member.display_name}: Mahnungsentwurf wartet auf Versand"
|
|
||||||
if draft_exists
|
|
||||||
else f"{context.member.display_name}: {name} fällig"
|
|
||||||
)
|
|
||||||
detail = (
|
|
||||||
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
|
|
||||||
f"Mahnstufe {level}, vorgesehen Gebühr: {next_level.get('fee', '0.00')} EUR."
|
|
||||||
)
|
|
||||||
actions.append(
|
|
||||||
task(
|
|
||||||
rule_id=RULE_ID,
|
|
||||||
member=context.member,
|
|
||||||
key_suffix=f"{claim_id}:level-{level}",
|
|
||||||
severity="warning",
|
|
||||||
code="reminder_due",
|
|
||||||
title=title,
|
|
||||||
detail=detail,
|
|
||||||
due_date=trigger_date,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return actions
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
def _overdue_action(context: RuleContext, claim, claim_id: str, due: date, days_overdue: int) -> RuleAction:
|
||||||
|
title = str(claim.get("title") or "Beitragsforderung")
|
||||||
|
claim_type = str(claim.get("type", ""))
|
||||||
|
claim_key = str(claim.get("claim_key") or claim_id)
|
||||||
|
return task(
|
||||||
|
rule_id=RULE_ID,
|
||||||
|
member=context.member,
|
||||||
|
key_suffix=f"overdue:{claim_key}",
|
||||||
|
severity="error",
|
||||||
|
title=f"{context.member.display_name}: {title} überfällig",
|
||||||
|
detail=f"Fälligkeit war vor {days_overdue} Tagen.",
|
||||||
|
due_date=due,
|
||||||
|
code="initial_payment_overdue" if claim_type == "admission_fee" else "claim_overdue",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sepa_action(context: RuleContext, claim, claim_id: str, days_overdue: int) -> RuleAction:
|
||||||
|
balance = money_text(claim_balance(context.contributions, claim))
|
||||||
|
return task(
|
||||||
|
rule_id=RULE_ID,
|
||||||
|
member=context.member,
|
||||||
|
key_suffix=f"{claim_id}:sepa",
|
||||||
|
severity="warning",
|
||||||
|
code="sepa_debit_overdue",
|
||||||
|
title=f"{context.member.display_name}: Lastschrift überfällig – Einzug prüfen",
|
||||||
|
detail=(
|
||||||
|
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR, seit "
|
||||||
|
f"{days_overdue} Tagen überfällig. Das Mitglied zahlt per SEPA-Lastschriftmandat, eine "
|
||||||
|
"postalische Mahnung ist hier nicht vorgesehen -- bitte den Lastschrifteinzug prüfen "
|
||||||
|
"bzw. erneut anstoßen."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
]
|
||||||
|
sent_levels = {
|
||||||
|
int(item.get("level", 0)): item for item in reminders if str(item.get("status", "")) == "sent"
|
||||||
|
}
|
||||||
|
next_level = next(
|
||||||
|
(definition for definition in levels if int(definition.get("level", 0)) not in sent_levels),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not next_level:
|
||||||
|
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:
|
||||||
|
return None
|
||||||
|
draft_exists = any(
|
||||||
|
int(item.get("level", 0)) == level and str(item.get("status", "draft")) in {"draft", "generated"}
|
||||||
|
for item in reminders
|
||||||
|
)
|
||||||
|
name = str(next_level.get("name") or f"Mahnung Stufe {level}")
|
||||||
|
balance = money_text(claim_balance(context.contributions, claim))
|
||||||
|
title = (
|
||||||
|
f"{context.member.display_name}: Mahnungsentwurf wartet auf Versand"
|
||||||
|
if draft_exists
|
||||||
|
else f"{context.member.display_name}: {name} fällig"
|
||||||
|
)
|
||||||
|
detail = (
|
||||||
|
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
|
||||||
|
f"Mahnstufe {level}, vorgesehen Gebühr: {next_level.get('fee', '0.00')} EUR."
|
||||||
|
)
|
||||||
|
return task(
|
||||||
|
rule_id=RULE_ID,
|
||||||
|
member=context.member,
|
||||||
|
key_suffix=f"{claim_id}:level-{level}",
|
||||||
|
severity="warning",
|
||||||
|
code="reminder_due",
|
||||||
|
title=title,
|
||||||
|
detail=detail,
|
||||||
|
due_date=trigger_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _trigger_date(claim, sent_levels, level: int, policy) -> date | None:
|
def _trigger_date(claim, sent_levels, level: int, policy) -> date | None:
|
||||||
if level == 1:
|
if level == 1:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -29,11 +29,10 @@ def test_housekeeper_reports_initial_payment_and_open_claims(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
findings = Housekeeper(repository).run(today=date(2026, 2, 10))
|
findings = Housekeeper(repository).run(today=date(2026, 2, 10))
|
||||||
assert {finding.code for finding in findings} == {
|
# Both claims are more than the default 7-day grace period overdue, so the dunning
|
||||||
"initial_payment_overdue",
|
# escalation ("reminder_due") is the single active finding for each -- no separate,
|
||||||
"claim_overdue",
|
# redundant "overdue" finding alongside it.
|
||||||
"reminder_due",
|
assert {finding.code for finding in findings} == {"reminder_due"}
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_housekeeper_reports_birthdays_before_today_and_after(tmp_path) -> None:
|
def test_housekeeper_reports_birthdays_before_today_and_after(tmp_path) -> None:
|
||||||
|
|||||||
+40
-1
@@ -68,7 +68,11 @@ def test_dunning_hold_suppresses_and_then_restores_task(tmp_path) -> None:
|
|||||||
reason="Betrag wird geklärt",
|
reason="Betrag wird geklärt",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert not any(item.code == "reminder_due" for item in housekeeper.run(today=date(2026, 2, 10)))
|
findings = housekeeper.run(today=date(2026, 2, 10))
|
||||||
|
assert not any(item.code == "reminder_due" for item in findings)
|
||||||
|
# The claim stays visible as a plain overdue notice instead of disappearing entirely
|
||||||
|
# while the hold suppresses the dunning escalation.
|
||||||
|
assert any(item.code == "claim_overdue" for item in findings)
|
||||||
with pytest.raises(RepositoryError, match="Mahnsperre aktiv"):
|
with pytest.raises(RepositoryError, match="Mahnsperre aktiv"):
|
||||||
repository.create_reminder_draft(
|
repository.create_reminder_draft(
|
||||||
member.member_id,
|
member.member_id,
|
||||||
@@ -81,6 +85,41 @@ def test_dunning_hold_suppresses_and_then_restores_task(tmp_path) -> None:
|
|||||||
assert any(item.code == "reminder_due" for item in housekeeper.run(today=date(2026, 2, 10)))
|
assert any(item.code == "reminder_due" for item in housekeeper.run(today=date(2026, 2, 10)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_overdue_claim_and_reminder_finding_are_never_shown_at_once(tmp_path) -> None:
|
||||||
|
repository, member = _overdue_claim_repository(tmp_path)
|
||||||
|
housekeeper = Housekeeper(repository)
|
||||||
|
|
||||||
|
# Still within the 7-day default grace period: only the plain overdue notice, no
|
||||||
|
# dunning escalation yet.
|
||||||
|
within_grace = housekeeper.run(today=date(2026, 2, 4))
|
||||||
|
codes = {item.code for item in within_grace if item.member_id == member.member_id}
|
||||||
|
assert codes == {"claim_overdue"}
|
||||||
|
|
||||||
|
# Past the grace period: the dunning escalation takes over as the single finding,
|
||||||
|
# the redundant plain overdue notice disappears.
|
||||||
|
past_grace = housekeeper.run(today=date(2026, 2, 10))
|
||||||
|
codes = {item.code for item in past_grace if item.member_id == member.member_id}
|
||||||
|
assert codes == {"reminder_due"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sepa_member_gets_debit_followup_instead_of_reminder(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)
|
||||||
|
|
||||||
|
findings = housekeeper.run(today=date(2026, 2, 10))
|
||||||
|
codes = {item.code for item in findings if item.member_id == member.member_id}
|
||||||
|
|
||||||
|
assert codes == {"sepa_debit_overdue"}
|
||||||
|
sepa_finding = next(item for item in findings if item.code == "sepa_debit_overdue")
|
||||||
|
assert "Lastschrift" in sepa_finding.title
|
||||||
|
assert "SEPA" in sepa_finding.detail
|
||||||
|
|
||||||
|
|
||||||
def test_draft_can_be_cancelled_but_sent_reminder_cannot(tmp_path) -> None:
|
def test_draft_can_be_cancelled_but_sent_reminder_cannot(tmp_path) -> None:
|
||||||
repository, member = _overdue_claim_repository(tmp_path)
|
repository, member = _overdue_claim_repository(tmp_path)
|
||||||
draft = repository.create_reminder_draft(
|
draft = repository.create_reminder_draft(
|
||||||
|
|||||||
Reference in New Issue
Block a user