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