"""Tests zu Ratenzahlungen, Vertragsfristen und Rücklagenbildung.""" from decimal import Decimal from app.models.enums import BusinessDayShift, OccurrenceStatus from app.services.recurrence import ( annual_burden, contract_term, expand, installments_remaining, monthly_reserve, notice_deadline, ) from tests.factories import FakeAmountVersion, FakeOccurrence, FakeRecurrence, d # --- Ratenzahlung -------------------------------------------------------------- def kredit(**overrides) -> FakeRecurrence: """Kredit über 36 Monatsraten à 250,00 EUR ab Januar 2026.""" defaults = { "rrule": "FREQ=MONTHLY;BYMONTHDAY=1", "dtstart": d("2026-01-01"), "amount": Decimal("250.00"), "installments_total": 36, "principal_amount": Decimal("9000.00"), } return FakeRecurrence(**{**defaults, **overrides}) def test_ratenende_kappt_die_serie_ohne_count() -> None: """Die RRULE ist unbegrenzt – die Ratenzahl beendet die Serie trotzdem.""" result = expand(kredit(), d("2026-01-01"), d("2032-12-31")) assert len(result) == 36 assert result[-1].nominal_date == d("2028-12-01") def test_raten_werden_durchnummeriert() -> None: result = expand(kredit(), d("2026-01-01"), d("2026-12-31")) assert result[0].installment_number == 1 assert result[0].installments_total == 36 assert result[-1].installment_number == 12 def test_restraten_und_restschuld_aus_darlehenssumme() -> None: status = installments_remaining(kredit(), as_of=d("2026-06-15")) assert status is not None assert status.total == 36 assert status.paid == 6 # Januar bis Juni assert status.remaining == 30 assert status.paid_amount == Decimal("1500.00") assert status.remaining_amount == Decimal("7500.00") assert status.final_due_date == d("2028-12-01") def test_restschuld_beruecksichtigt_abweichende_istbetraege() -> None: """Eine Sondertilgung im Februar senkt die Restschuld entsprechend.""" sondertilgung = FakeOccurrence( occurrence_date=d("2026-02-01"), status=OccurrenceStatus.CONFIRMED, actual_amount=Decimal("750.00"), ) status = installments_remaining(kredit(), as_of=d("2026-03-15"), occurrences=[sondertilgung]) assert status is not None assert status.paid_amount == Decimal("1250.00") # 250 + 750 + 250 assert status.remaining_amount == Decimal("7750.00") def test_restschuld_ohne_darlehenssumme_summiert_die_restraten() -> None: status = installments_remaining(kredit(principal_amount=None), as_of=d("2028-10-15")) assert status is not None assert status.remaining == 2 # November und Dezember 2028 assert status.remaining_amount == Decimal("500.00") def test_restschuld_beachtet_ratenaenderung() -> None: """Ohne Darlehenssumme wird die Preishistorie der Restraten berücksichtigt.""" recurrence = kredit( principal_amount=None, amount_versions=[ FakeAmountVersion(Decimal("250.00"), d("2026-01-01")), FakeAmountVersion(Decimal("300.00"), d("2028-11-01")), ], ) status = installments_remaining(recurrence, as_of=d("2028-10-15")) assert status is not None assert status.remaining_amount == Decimal("600.00") def test_restschuld_wird_nicht_negativ() -> None: status = installments_remaining( kredit(principal_amount=Decimal("500.00")), as_of=d("2027-01-15") ) assert status is not None assert status.remaining_amount == Decimal("0.00") def test_nach_der_letzten_rate_ist_nichts_offen() -> None: status = installments_remaining(kredit(), as_of=d("2029-06-01")) assert status is not None assert status.paid == 36 assert status.remaining == 0 assert status.remaining_amount == Decimal("0.00") def test_ohne_raten_gibt_es_keinen_ratenstand() -> None: laufend = FakeRecurrence(rrule="FREQ=MONTHLY", dtstart=d("2026-01-01")) assert installments_remaining(laufend, as_of=d("2026-06-01")) is None def test_ratenende_und_verschiebung_greifen_zusammen() -> None: """Die Ratenzahl begrenzt die nominalen Termine, der Zahltag darf danach liegen.""" recurrence = kredit(installments_total=3, business_day_shift=BusinessDayShift.NEXT) result = expand(recurrence, d("2026-01-01"), d("2026-12-31")) assert [item.nominal_date for item in result] == [ d("2026-01-01"), d("2026-02-01"), d("2026-03-01"), ] # 01.01. Feiertag, 01.02. Sonntag, 01.03. Sonntag assert [item.due_date for item in result] == [ d("2026-01-02"), d("2026-02-02"), d("2026-03-02"), ] # --- Verträge und Kündigungsfristen ------------------------------------------- def handyvertrag(**overrides) -> FakeRecurrence: """Mobilfunkvertrag: 24 Monate Mindestlaufzeit, 3 Monate Frist, jährliche Verlängerung.""" defaults = { "rrule": "FREQ=MONTHLY;BYMONTHDAY=1", "dtstart": d("2026-03-01"), "amount": Decimal("29.99"), "contract_start": d("2026-03-01"), "contract_min_term_months": 24, "contract_notice_period_days": 90, "contract_auto_renew_months": 12, } return FakeRecurrence(**{**defaults, **overrides}) def test_kuendigungstermin_in_der_erstlaufzeit() -> None: term = contract_term(handyvertrag(), as_of=d("2026-09-01")) assert term is not None assert term.term_start == d("2026-03-01") assert term.term_end == d("2028-02-29") # 24 Monate, letzter Tag der Laufzeit assert term.notice_deadline == d("2027-12-01") assert term.renews_on == d("2028-03-01") assert notice_deadline(handyvertrag(), as_of=d("2026-09-01")) == d("2027-12-01") def test_nach_ablauf_gilt_die_verlaengerte_periode() -> None: term = contract_term(handyvertrag(), as_of=d("2028-06-01")) assert term is not None assert term.term_start == d("2028-03-01") assert term.term_end == d("2029-02-28") assert term.notice_deadline == d("2028-11-30") def test_ohne_automatische_verlaengerung_bleibt_die_erstlaufzeit_stehen() -> None: term = contract_term(handyvertrag(contract_auto_renew_months=None), as_of=d("2029-01-01")) assert term is not None assert term.term_end == d("2028-02-29") assert term.renews_on is None def test_gekuendigter_vertrag_hat_keinen_termin_mehr() -> None: vertrag = handyvertrag(contract_cancelled_at=d("2028-02-29")) assert notice_deadline(vertrag, as_of=d("2027-06-01")) is None term = contract_term(vertrag, as_of=d("2027-06-01")) assert term is not None assert term.is_cancelled is True assert term.renews_on is None def test_ohne_mindestlaufzeit_gibt_es_keine_vertragsperiode() -> None: laufend = FakeRecurrence(rrule="FREQ=MONTHLY", dtstart=d("2026-01-01")) assert contract_term(laufend, as_of=d("2026-06-01")) is None assert notice_deadline(laufend, as_of=d("2026-06-01")) is None def test_ohne_frist_bleibt_der_termin_offen() -> None: vertrag = handyvertrag(contract_notice_period_days=None) term = contract_term(vertrag, as_of=d("2026-09-01")) assert term is not None assert term.term_end == d("2028-02-29") assert term.notice_deadline is None def test_vertragsstart_faellt_notfalls_auf_dtstart_zurueck() -> None: vertrag = handyvertrag(contract_start=None, dtstart=d("2026-01-01")) term = contract_term(vertrag, as_of=d("2026-06-01")) assert term is not None assert term.term_start == d("2026-01-01") assert term.term_end == d("2027-12-31") # --- Rücklagen ----------------------------------------------------------------- def test_jahresbelastung_eines_jaehrlichen_postens() -> None: kfz = FakeRecurrence( rrule="FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=15", dtstart=d("2026-01-15"), amount=Decimal("612.00"), reserve_enabled=True, ) assert annual_burden(kfz, as_of=d("2026-01-01")) == Decimal("612.00") assert monthly_reserve(kfz, as_of=d("2026-01-01")) == Decimal("51.00") def test_jahresbelastung_wird_nicht_hochgerechnet() -> None: """Ein halbjährlicher Posten schlägt im Jahresfenster genau zweimal zu Buche.""" halbjaehrlich = FakeRecurrence( rrule="FREQ=MONTHLY;INTERVAL=6;BYMONTHDAY=1", dtstart=d("2026-01-01"), amount=Decimal("120.00"), reserve_enabled=True, ) assert annual_burden(halbjaehrlich, as_of=d("2026-01-01")) == Decimal("240.00") assert monthly_reserve(halbjaehrlich, as_of=d("2026-01-01")) == Decimal("20.00") def test_ruecklage_wird_kaufmaennisch_gerundet() -> None: posten = FakeRecurrence( rrule="FREQ=YEARLY", dtstart=d("2026-04-01"), amount=Decimal("1000.00"), reserve_enabled=True, ) # 1000,00 / 12 = 83,333… -> 83,33 assert monthly_reserve(posten, as_of=d("2026-01-01")) == Decimal("83.33") def test_ruecklage_beruecksichtigt_preisaenderung_im_jahresfenster() -> None: posten = FakeRecurrence( rrule="FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1", dtstart=d("2026-01-01"), amount=Decimal("30.00"), reserve_enabled=True, amount_versions=[ FakeAmountVersion(Decimal("30.00"), d("2026-01-01")), FakeAmountVersion(Decimal("60.00"), d("2026-07-01")), ], ) # Januar 30 + April 30 + Juli 60 + Oktober 60 = 180 assert annual_burden(posten, as_of=d("2026-01-01")) == Decimal("180.00") assert monthly_reserve(posten, as_of=d("2026-01-01")) == Decimal("15.00") def test_ohne_aktivierte_ruecklage_ist_der_betrag_null() -> None: posten = FakeRecurrence( rrule="FREQ=YEARLY", dtstart=d("2026-01-15"), amount=Decimal("600.00"), reserve_enabled=False, ) assert monthly_reserve(posten, as_of=d("2026-01-01")) == Decimal("0.00") def test_ausgelaufener_vertrag_erzeugt_keine_ruecklage_mehr() -> None: posten = FakeRecurrence( rrule="FREQ=YEARLY", dtstart=d("2020-01-15"), amount=Decimal("600.00"), reserve_enabled=True, until=d("2024-01-15"), ) assert monthly_reserve(posten, as_of=d("2026-01-01")) == Decimal("0.00")