from datetime import date import pytest from ccma.services.housekeeper import Housekeeper from ccma.storage.repository import MemberRepository, RepositoryError def _member(tmp_path): repository = MemberRepository(tmp_path) repository.initialize() member = repository.create_member(first_name="Override", last_name="Test", birth_date="2010-01-01") member.status = "active" member.accepted_at = "2026-01-01" member.membership_started_at = "2026-01-01" member.payment_frequency = "semiannual" repository.save_member(member) return repository, member def test_contribution_override_can_be_recorded_with_amount(tmp_path) -> None: repository, member = _member(tmp_path) override = repository.record_contribution_override( member.member_id, valid_from="03.2026", valid_until="", kind="amount", value="90.00", reason="Schüler laut Nachweis", ) data = repository.get_contributions(member.member_id) assert data.contribution_overrides == [override] assert override["valid_from"] == "2026-03" assert override["valid_until"] == "" assert override["kind"] == "amount" assert override["value"] == "90.00" assert override["reason"] == "Schüler laut Nachweis" assert repository.get_events(member.member_id)[-1].event_type == "contribution_override_recorded" def test_contribution_override_accepts_percent_kind(tmp_path) -> None: repository, member = _member(tmp_path) override = repository.record_contribution_override( member.member_id, valid_from="2026-03", valid_until="2026-08", kind="percent", value="50", reason="Schüler laut Nachweis", ) assert override["kind"] == "percent" assert override["value"] == "50.00" assert override["valid_until"] == "2026-08" def test_contribution_override_requires_reason(tmp_path) -> None: repository, member = _member(tmp_path) with pytest.raises(RepositoryError, match="Begründung"): repository.record_contribution_override( member.member_id, valid_from="03.2026", kind="amount", value="90.00", reason=" " ) def test_contribution_override_rejects_invalid_percent(tmp_path) -> None: repository, member = _member(tmp_path) with pytest.raises(RepositoryError, match="zwischen 0 und 100"): repository.record_contribution_override( member.member_id, valid_from="03.2026", kind="percent", value="150", reason="Test" ) with pytest.raises(RepositoryError, match="zwischen 0 und 100"): repository.record_contribution_override( member.member_id, valid_from="03.2026", kind="percent", value="0", reason="Test" ) def test_contribution_override_rejects_negative_amount(tmp_path) -> None: repository, member = _member(tmp_path) with pytest.raises(RepositoryError, match="nicht negativ"): repository.record_contribution_override( member.member_id, valid_from="03.2026", kind="amount", value="-10", reason="Test" ) def test_contribution_override_rejects_until_before_from(tmp_path) -> None: repository, member = _member(tmp_path) with pytest.raises(RepositoryError, match="darf nicht vor"): repository.record_contribution_override( member.member_id, valid_from="08.2026", valid_until="03.2026", kind="amount", value="90.00", reason="Test", ) def test_overlapping_contribution_overrides_are_rejected(tmp_path) -> None: repository, member = _member(tmp_path) repository.record_contribution_override( member.member_id, valid_from="2026-01", valid_until="2026-06", kind="amount", value="90.00", reason="Erstes", ) with pytest.raises(RepositoryError, match="überschneidet sich"): repository.record_contribution_override( member.member_id, valid_from="2026-04", valid_until="2026-12", kind="amount", value="100.00", reason="Zweites", ) # Adjacent, non-overlapping range is fine. second = repository.record_contribution_override( member.member_id, valid_from="2026-07", valid_until="", kind="amount", value="100.00", reason="Zweites, ab Juli", ) assert second["valid_from"] == "2026-07" def test_open_ended_override_overlaps_with_any_later_range(tmp_path) -> None: repository, member = _member(tmp_path) repository.record_contribution_override( member.member_id, valid_from="2026-01", kind="amount", value="90.00", reason="Unbefristet" ) with pytest.raises(RepositoryError, match="überschneidet sich"): repository.record_contribution_override( member.member_id, valid_from="2030-01", valid_until="2030-06", kind="amount", value="100.00", reason="Später", ) def test_contribution_override_can_be_updated_and_deleted(tmp_path) -> None: repository, member = _member(tmp_path) override = repository.record_contribution_override( member.member_id, valid_from="2026-01", kind="amount", value="90.00", reason="Erstversion" ) updated = repository.update_contribution_override( member.member_id, override["override_id"], valid_from="2026-02", valid_until="2026-12", kind="percent", value="40", reason="Korrigiert", ) assert updated["valid_from"] == "2026-02" assert updated["kind"] == "percent" assert updated["reason"] == "Korrigiert" assert repository.get_events(member.member_id)[-1].event_type == "contribution_override_changed" repository.delete_contribution_override(member.member_id, override["override_id"]) data = repository.get_contributions(member.member_id) assert data.contribution_overrides == [] assert repository.get_events(member.member_id)[-1].event_type == "contribution_override_deleted" def test_updating_override_can_keep_its_own_range(tmp_path) -> None: repository, member = _member(tmp_path) override = repository.record_contribution_override( member.member_id, valid_from="2026-01", valid_until="2026-06", kind="amount", value="90.00", reason="Erstversion", ) # Saving again with the same range must not trip the overlap check against itself. updated = repository.update_contribution_override( member.member_id, override["override_id"], valid_from="2026-01", valid_until="2026-06", kind="amount", value="95.00", reason="Betrag korrigiert", ) assert updated["value"] == "95.00" def _housekeeper_member(tmp_path, *, payment_frequency="annual", started_at="2025-01-01"): repository = MemberRepository(tmp_path) repository.initialize() member = repository.create_member(first_name="Override", last_name="Claims", birth_date="1990-01-01") member.status = "active" member.accepted_at = started_at member.membership_started_at = started_at member.payment_frequency = payment_frequency repository.save_member(member) return repository, member def test_full_year_amount_override_replaces_the_annual_claim_amount(tmp_path) -> None: repository, member = _housekeeper_member(tmp_path, payment_frequency="annual") repository.record_contribution_override( member.member_id, valid_from="2026-01", valid_until="2026-12", kind="amount", value="60.00", reason="Schüler laut Nachweis", ) Housekeeper(repository).run(today=date(2026, 6, 21)) claims_by_key = { claim["claim_key"]: claim for claim in repository.get_contributions(member.member_id).claims } claim = claims_by_key["membership-fee:2026:annual"] assert claim["amount"] == "60.00" assert claim["calculation"]["contribution_override_ids"] != [] def test_full_year_percent_override_reduces_the_base_rate(tmp_path) -> None: repository, member = _housekeeper_member(tmp_path, payment_frequency="annual") repository.record_contribution_override( member.member_id, valid_from="2026-01", valid_until="2026-12", kind="percent", value="50", reason="Schüler laut Nachweis", ) Housekeeper(repository).run(today=date(2026, 6, 21)) claim = next( claim for claim in repository.get_contributions(member.member_id).claims if claim["claim_key"] == "membership-fee:2026:annual" ) assert claim["amount"] == "75.00" def test_override_starting_mid_period_blends_month_by_month(tmp_path) -> None: repository, member = _housekeeper_member(tmp_path, payment_frequency="semiannual") repository.record_contribution_override( member.member_id, valid_from="2026-03", valid_until="2026-08", kind="amount", value="60.00", reason="Schüler ab März", ) Housekeeper(repository).run(today=date(2026, 12, 31)) claims_by_key = { claim["claim_key"]: claim for claim in repository.get_contributions(member.member_id).claims } # Jan+Feb at 12.50/month (full rate) + Mar-Jun at 5.00/month (override) = 45.00 assert claims_by_key["membership-fee:2026:first-half"]["amount"] == "45.00" # Jul+Aug at 5.00/month (override) + Sep-Dec at 12.50/month (full rate) = 60.00 assert claims_by_key["membership-fee:2026:second-half"]["amount"] == "60.00" def test_claim_without_applicable_override_is_unaffected(tmp_path) -> None: repository, member = _housekeeper_member(tmp_path, payment_frequency="annual") repository.record_contribution_override( member.member_id, valid_from="2020-01", valid_until="2020-12", kind="amount", value="60.00", reason="Frueherer Zeitraum", ) Housekeeper(repository).run(today=date(2026, 6, 21)) claim = next( claim for claim in repository.get_contributions(member.member_id).claims if claim["claim_key"] == "membership-fee:2026:annual" ) assert claim["amount"] == "150.00" assert claim["calculation"]["contribution_override_ids"] == []