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)