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 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) 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_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( 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"] == []