Files
CCMA/tests/test_reminders.py
T
Marcel PeterkauandClaude Opus 5 f80b17c443 Keep dunning a bounced debit out of the direct-debit track
Once the direct debit bounced and the board sent the Rücklastschrift reminder,
the claim is expected as a transfer by the deadline that letter states. Three
places still treated it as a claim the mandate covers, and the housekeeper's was
the one the board kept running into: after the reminder's deadline lapsed, the
finding went back to "Lastschrift überfällig -- Einzug prüfen, eine postalische
Mahnung ist hier nicht vorgesehen", for a claim that had just been dunned.

The rule now asks whether the claim was dunned before treating it as one for the
direct debit. If it was, it continues in the ordinary dunning sequence: the
running deadline shows as the usual "Frist läuft noch" note, and once that has
passed the next dunning level comes due. The SEPA-specific pending-reminder
detour that used to cover the deadline window is gone with it -- the ordinary
path reports the same thing.

The SEPA run now skips a dunned claim as well, instead of quietly collecting the
money the letter asked the member to transfer (which can bounce a second time,
with a second fee). The skip is reported like the incomplete mandates are, so
nothing disappears from the run without saying why; the dialog's wording is no
longer specific to mandates.

And a dunning mail asks for a transfer even from a member with an active
mandate. The shipped template spells the bank details out, but the ready-made
{{payment.instructions}} paragraph, offered by the template editor for exactly
this mail, told them "wir ziehen den Betrag ein, du musst nichts weiter tun" --
in the letter demanding payment.

Reverting the sent reminder is what puts the claim back into the direct-debit
run; the read of "dunned" is a sent reminder, not a draft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 00:16:26 +02:00

538 lines
20 KiB
Python

import json
from datetime import date, timedelta
from decimal import Decimal
import pytest
from ccma.domain.contributions import claim_total
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 _sepa_member(repository, member):
member.iban = "DE89370400440532013000"
member.mandate_reference = "MANDATE-1"
member.mandate_signed_at = "2026-01-01"
member.mandate_active = True
repository.save_member(member)
return 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,
items=[],
)
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_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)
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)
_sepa_member(repository, 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_a_dunned_sepa_claim_continues_in_the_normal_dunning_process(tmp_path) -> None:
"""A bounced direct debit is dunned like any other claim: the money is expected as
a transfer from then on, so the "check the direct debit" notice must not come back
once the Rücklastschrift deadline has passed."""
repository, member = _overdue_claim_repository(tmp_path)
_sepa_member(repository, 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 == "reminder_awaiting_deadline")
assert awaiting.severity == "info"
assert "Rücklastschrift" in awaiting.title
# Deadline gone by without payment: the next dunning level is due, not another
# "der Einzug muss geprüft werden".
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)
reminder_task = next(item for item in findings if item.code == "reminder_due")
assert "Erste Mahnung" in reminder_task.title
assert "Mahnstufe 2" in reminder_task.detail
def test_an_undunned_sepa_claim_still_asks_to_check_the_debit(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
_sepa_member(repository, member)
housekeeper = Housekeeper(repository)
findings = [
item for item in housekeeper.run(today=date(2026, 2, 10)) if item.member_id == member.member_id
]
# A draft alone changes nothing -- only a reminder that actually went out takes the
# claim out of the direct-debit run.
repository.create_reminder_draft(
member.member_id, "claim-1", level=1, name="Rücklastschrift", payment_deadline_days=14
)
still_sepa = [
item for item in housekeeper.run(today=date(2026, 2, 10)) if item.member_id == member.member_id
]
assert {item.code for item in findings} == {"sepa_debit_overdue"}
assert {item.code for item in still_sepa} == {"sepa_debit_overdue"}
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,
items=[{"description": "Mahngebühr", "amount": "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)
def test_reminder_can_carry_multiple_fee_items(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
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"},
{"description": "Bankgebühr", "amount": "3.50"},
],
)
assert draft["fee_item_ids"] == []
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
assert len(sent["fee_item_ids"]) == 2
data, claim = repository.get_claim(member.member_id, "claim-1")
descriptions = {item["description"] for item in claim["items"] if item["type"] == "fee"}
assert descriptions == {"Rücklastschriftgebühr", "Bankgebühr"}
assert claim_total(claim) == Decimal("108.50")
def test_reminder_items_require_a_description(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
with pytest.raises(RepositoryError, match="Beschreibung"):
repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Rücklastschrift",
payment_deadline_days=14,
items=[{"description": " ", "amount": "5.00"}],
)
def test_reminder_with_zero_amount_items_creates_no_claim_item(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,
items=[{"description": "Mahngebühr", "amount": "0.00"}],
)
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
assert sent["fee_item_ids"] == []
_data, claim = repository.get_claim(member.member_id, "claim-1")
assert claim_total(claim) == Decimal("100.00")
def test_reminder_policy_round_trips_through_save_and_get(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
repository.save_reminder_policy(
grace_days_after_due=10,
levels=[
{"name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 10},
{"name": "Mahnung", "fee": "7.50", "payment_deadline_days": 14},
],
standard_fee_items=[
{"description": "Rücklastschriftgebühr", "default_amount": "6.00"},
],
)
policy = repository.get_reminder_policy()
assert policy["grace_days_after_due"] == 10
assert [level["level"] for level in policy["levels"]] == [1, 2]
assert policy["levels"][1]["name"] == "Mahnung"
assert policy["levels"][1]["fee"] == "7.50"
assert policy["standard_fee_items"] == [
{"description": "Rücklastschriftgebühr", "default_amount": "6.00"}
]
def test_reminder_policy_rejects_invalid_input(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
with pytest.raises(RepositoryError, match="Mindestens eine Mahnstufe"):
repository.save_reminder_policy(grace_days_after_due=7, levels=[], standard_fee_items=[])
with pytest.raises(RepositoryError, match="benötigt einen Namen"):
repository.save_reminder_policy(
grace_days_after_due=7,
levels=[{"name": " ", "fee": "0.00", "payment_deadline_days": 14}],
standard_fee_items=[],
)
with pytest.raises(RepositoryError, match="nicht negativ"):
repository.save_reminder_policy(
grace_days_after_due=7,
levels=[{"name": "Mahnung", "fee": "-1.00", "payment_deadline_days": 14}],
standard_fee_items=[],
)
def test_edit_reminder_draft_updates_fields_but_not_level(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,
items=[],
)
edited = repository.edit_reminder_draft(
member.member_id,
"claim-1",
draft["reminder_id"],
name="Erste Mahnung",
payment_deadline_days=10,
detail="Bitte um Ausgleich.",
items=[{"description": "Mahngebühr", "amount": "5.00"}],
)
assert edited["level"] == 1
assert edited["name"] == "Erste Mahnung"
assert edited["payment_deadline_days"] == 10
assert edited["detail"] == "Bitte um Ausgleich."
assert edited["items"] == [{"description": "Mahngebühr", "amount": "5.00"}]
def test_edit_reminder_draft_rejects_already_sent_reminder(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,
items=[],
)
repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
with pytest.raises(RepositoryError, match="Nur ein Entwurf"):
repository.edit_reminder_draft(
member.member_id,
"claim-1",
draft["reminder_id"],
name="Andere Bezeichnung",
payment_deadline_days=14,
)
def test_revert_reminder_sent_removes_booked_fee_and_reopens_draft(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
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"])
assert len(sent["fee_item_ids"]) == 1
_data, claim = repository.get_claim(member.member_id, "claim-1")
assert claim_total(claim) == Decimal("105.00")
reverted = repository.revert_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
assert reverted["status"] == "draft"
assert reverted["sent_at"] is None
assert reverted["payment_deadline"] is None
assert reverted["fee_item_ids"] == []
_data, claim = repository.get_claim(member.member_id, "claim-1")
assert claim_total(claim) == Decimal("100.00")
descriptions = {item["description"] for item in claim["items"]}
assert "Rücklastschriftgebühr" not in descriptions
# Now editable and re-sendable, e.g. after fixing the wording.
repository.edit_reminder_draft(
member.member_id,
"claim-1",
draft["reminder_id"],
name="Rücklastschrift",
payment_deadline_days=14,
items=[{"description": "Rücklastschriftgebühr", "amount": "7.50"}],
)
repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
_data, claim = repository.get_claim(member.member_id, "claim-1")
assert claim_total(claim) == Decimal("107.50")
def test_revert_reminder_sent_rejects_when_higher_level_exists(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
first = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
items=[],
)
repository.mark_reminder_sent(member.member_id, "claim-1", first["reminder_id"])
repository.create_reminder_draft(
member.member_id,
"claim-1",
level=2,
name="Erste Mahnung",
payment_deadline_days=14,
items=[{"description": "Mahngebühr", "amount": "5.00"}],
)
with pytest.raises(RepositoryError, match="höhere Mahnstufe"):
repository.revert_reminder_sent(member.member_id, "claim-1", first["reminder_id"])
def test_revert_reminder_sent_rejects_when_payment_already_covers_fee(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
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"}],
)
repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-02-10",
amount="105.00",
allocation_amount="105.00",
)
with pytest.raises(RepositoryError, match="bereits Zahlungen verbucht"):
repository.revert_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
def test_reminder_policy_backfills_standard_fee_items_for_old_repositories(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
config_path = tmp_path / "repository.json"
raw = json.loads(config_path.read_text(encoding="utf-8"))
# Simulate a repository created before "standard_fee_items" existed at all.
del raw["reminder_policy"]["standard_fee_items"]
config_path.write_text(json.dumps(raw), encoding="utf-8")
policy = repository.get_reminder_policy()
assert policy["standard_fee_items"], "should fall back to built-in defaults"
assert any(item["description"] == "Rücklastschriftgebühr" for item in policy["standard_fee_items"])
# But once the board explicitly saves an empty list, that choice is respected.
repository.save_reminder_policy(
grace_days_after_due=policy["grace_days_after_due"],
levels=policy["levels"],
standard_fee_items=[],
)
assert repository.get_reminder_policy()["standard_fee_items"] == []