Files
CCMA/tests/test_reminders.py
T
Marcel PeterkauandClaude Sonnet 5 4e4aa22589 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>
2026-08-14 23:02:37 +02:00

176 lines
6.6 KiB
Python

from datetime import date, timedelta
import pytest
from ccma.domain.models import ContributionData
from ccma.services.housekeeper import Housekeeper
from ccma.storage.repository import MemberRepository, RepositoryError
def _overdue_claim_repository(tmp_path):
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Reminder", last_name="Test", birth_date="1990-01-01")
repository.save_contributions(
member.member_id,
ContributionData(
claims=[
{
"claim_id": "claim-1",
"claim_key": "overdue-test",
"title": "Offene Forderung",
"amount": "100.00",
"due_date": "2026-01-31",
"status": "open",
}
]
),
)
return repository, member
def test_reminder_rule_progresses_only_after_sent_deadline(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
housekeeper = Housekeeper(repository)
findings = housekeeper.run(today=date(2026, 2, 10))
reminder_task = next(item for item in findings if item.code == "reminder_due")
assert "Zahlungserinnerung" in reminder_task.title
draft = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
fee="0.00",
)
findings = housekeeper.run(today=date(2026, 2, 10))
reminder_task = next(item for item in findings if item.code == "reminder_due")
assert "wartet auf Versand" in reminder_task.title
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
deadline = date.fromisoformat(sent["payment_deadline"])
assert not any(
item.code == "reminder_due" for item in housekeeper.run(today=deadline - timedelta(days=1))
)
findings = housekeeper.run(today=deadline)
next_task = next(item for item in findings if item.code == "reminder_due")
assert "Erste Mahnung" in next_task.title
def test_dunning_hold_suppresses_and_then_restores_task(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
housekeeper = Housekeeper(repository)
repository.set_dunning_hold(
member.member_id,
"claim-1",
active=True,
reason="Betrag wird geklärt",
)
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"):
repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
)
repository.set_dunning_hold(member.member_id, "claim-1", active=False)
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:
repository, member = _overdue_claim_repository(tmp_path)
draft = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
)
repository.cancel_reminder(member.member_id, "claim-1", draft["reminder_id"])
data = repository.get_contributions(member.member_id)
assert data.reminders[0]["status"] == "cancelled"
second = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
)
repository.mark_reminder_sent(member.member_id, "claim-1", second["reminder_id"])
with pytest.raises(RepositoryError, match="bereits versandte"):
repository.cancel_reminder(member.member_id, "claim-1", second["reminder_id"])
def test_reminder_levels_cannot_be_skipped(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
with pytest.raises(RepositoryError, match="Mahnstufe 1 wurde noch nicht versandt"):
repository.create_reminder_draft(
member.member_id,
"claim-1",
level=2,
name="Erste Mahnung",
payment_deadline_days=14,
fee="5.00",
)
def test_payment_resolves_open_reminder_task(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
housekeeper = Housekeeper(repository)
assert any(item.code == "reminder_due" for item in housekeeper.run(today=date(2026, 2, 10)))
repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-02-10",
amount="100.00",
allocation_amount="100.00",
)
findings = housekeeper.run(today=date(2026, 2, 10))
assert not any(item.code == "reminder_due" for item in findings)