"""Tests der virtuellen Expansion: Frequenzen, Fenster, Serienende, Overlays.""" from decimal import Decimal import pytest from app.models.enums import BusinessDayShift, EntryKind, OccurrenceStatus from app.services.recurrence import ( InvalidRRuleError, expand, expand_by_due_date, next_dates, validate_rrule, ) from tests.factories import FakeAmountVersion, FakeOccurrence, FakeRecurrence, d def nominal(items) -> list: return [item.nominal_date for item in items] def due(items) -> list: return [item.due_date for item in items] # --- Monatliche Regeln --------------------------------------------------------- def test_monatlich_am_31_ueberspringt_kurze_monate() -> None: """BYMONTHDAY=31 feuert laut RFC 5545 nur in Monaten mit 31 Tagen.""" recurrence = FakeRecurrence(rrule="FREQ=MONTHLY;BYMONTHDAY=31", dtstart=d("2026-01-31")) result = expand(recurrence, d("2026-01-01"), d("2026-12-31")) assert nominal(result) == [ d("2026-01-31"), d("2026-03-31"), d("2026-05-31"), d("2026-07-31"), d("2026-08-31"), d("2026-10-31"), d("2026-12-31"), ] def test_monatsletzter_trifft_auch_kurze_monate() -> None: """BYMONTHDAY=-1 ist die richtige Regel für 'am Monatsletzten'.""" recurrence = FakeRecurrence(rrule="FREQ=MONTHLY;BYMONTHDAY=-1", dtstart=d("2026-01-31")) result = expand(recurrence, d("2026-01-01"), d("2026-04-30")) assert nominal(result) == [ d("2026-01-31"), d("2026-02-28"), d("2026-03-31"), d("2026-04-30"), ] def test_schaltjahr_am_monatsletzten() -> None: recurrence = FakeRecurrence(rrule="FREQ=MONTHLY;BYMONTHDAY=-1", dtstart=d("2028-01-31")) result = expand(recurrence, d("2028-02-01"), d("2028-02-29")) assert nominal(result) == [d("2028-02-29")] def test_letzter_werktag_des_monats_mit_rueckverschiebung() -> None: """Klassiker 'letzter Werktag': BYSETPOS=-1 plus Verschiebung nach vorne.""" recurrence = FakeRecurrence( rrule="FREQ=MONTHLY;BYMONTHDAY=28,29,30,31;BYSETPOS=-1", dtstart=d("2026-01-31"), business_day_shift=BusinessDayShift.PREVIOUS, ) result = expand(recurrence, d("2026-01-01"), d("2026-12-31")) # Nominal immer der Monatsletzte … assert nominal(result)[:3] == [d("2026-01-31"), d("2026-02-28"), d("2026-03-31")] # … der Zahltag rutscht bei Wochenenden auf den Freitag davor. assert due(result) == [ d("2026-01-30"), # 31.01. Samstag d("2026-02-27"), # 28.02. Samstag d("2026-03-31"), # Dienstag d("2026-04-30"), # Donnerstag d("2026-05-29"), # 31.05. Sonntag d("2026-06-30"), # Dienstag d("2026-07-31"), # Freitag d("2026-08-31"), # Montag d("2026-09-30"), # Mittwoch d("2026-10-30"), # 31.10. Samstag d("2026-11-30"), # Montag d("2026-12-31"), # Donnerstag, Silvester ist kein Feiertag ] def test_alle_zwei_monate() -> None: recurrence = FakeRecurrence(rrule="FREQ=MONTHLY;INTERVAL=2", dtstart=d("2026-01-10")) result = expand(recurrence, d("2026-01-01"), d("2026-12-31")) assert nominal(result) == [ d("2026-01-10"), d("2026-03-10"), d("2026-05-10"), d("2026-07-10"), d("2026-09-10"), d("2026-11-10"), ] def test_vierteljaehrlich() -> None: recurrence = FakeRecurrence(rrule="FREQ=MONTHLY;INTERVAL=3", dtstart=d("2026-02-15")) result = expand(recurrence, d("2026-01-01"), d("2027-02-28")) assert nominal(result) == [ d("2026-02-15"), d("2026-05-15"), d("2026-08-15"), d("2026-11-15"), d("2027-02-15"), ] def test_jaehrlich() -> None: recurrence = FakeRecurrence(rrule="FREQ=YEARLY", dtstart=d("2026-03-01")) result = expand(recurrence, d("2026-01-01"), d("2029-12-31")) assert nominal(result) == [ d("2026-03-01"), d("2027-03-01"), d("2028-03-01"), d("2029-03-01"), ] def test_woechentlich_an_festen_wochentagen() -> None: recurrence = FakeRecurrence(rrule="FREQ=WEEKLY;BYDAY=MO,TH", dtstart=d("2026-01-05")) result = expand(recurrence, d("2026-01-01"), d("2026-01-18")) assert nominal(result) == [ d("2026-01-05"), d("2026-01-08"), d("2026-01-12"), d("2026-01-15"), ] # --- Fenstergrenzen ------------------------------------------------------------ def test_fenster_grenzen_sind_einschliesslich() -> None: recurrence = FakeRecurrence(rrule="FREQ=MONTHLY;BYMONTHDAY=1", dtstart=d("2026-01-01")) result = expand(recurrence, d("2026-02-01"), d("2026-04-01")) assert nominal(result) == [d("2026-02-01"), d("2026-03-01"), d("2026-04-01")] def test_fenster_vor_dem_start_ist_leer() -> None: recurrence = FakeRecurrence(rrule="FREQ=MONTHLY", dtstart=d("2026-06-01")) assert expand(recurrence, d("2026-01-01"), d("2026-05-31")) == [] def test_verdrehtes_fenster_liefert_nichts() -> None: recurrence = FakeRecurrence(rrule="FREQ=MONTHLY", dtstart=d("2026-01-01")) assert expand(recurrence, d("2026-06-30"), d("2026-06-01")) == [] # --- Serienende ---------------------------------------------------------------- def test_until_kappt_die_serie() -> None: recurrence = FakeRecurrence( rrule="FREQ=MONTHLY;BYMONTHDAY=1", dtstart=d("2026-01-01"), until=d("2026-03-01"), ) result = expand(recurrence, d("2026-01-01"), d("2026-12-31")) assert nominal(result) == [d("2026-01-01"), d("2026-02-01"), d("2026-03-01")] def test_kuendigung_kappt_die_serie() -> None: recurrence = FakeRecurrence( rrule="FREQ=MONTHLY;BYMONTHDAY=1", dtstart=d("2026-01-01"), contract_cancelled_at=d("2026-04-15"), ) result = expand(recurrence, d("2026-01-01"), d("2026-12-31")) # Die Zahlung am Kündigungstag selbst zählt noch, danach ist Schluss. assert nominal(result) == [ d("2026-01-01"), d("2026-02-01"), d("2026-03-01"), d("2026-04-01"), ] def test_count_in_der_rrule_wird_beachtet() -> None: recurrence = FakeRecurrence(rrule="FREQ=MONTHLY;COUNT=3", dtstart=d("2026-01-20")) result = expand(recurrence, d("2026-01-01"), d("2026-12-31")) assert len(result) == 3 def test_until_in_der_rrule_und_im_feld_wirken_zusammen() -> None: recurrence = FakeRecurrence( rrule="FREQ=MONTHLY;BYMONTHDAY=1;UNTIL=20260601", dtstart=d("2026-01-01"), until=d("2026-04-01"), ) result = expand(recurrence, d("2026-01-01"), d("2026-12-31")) # Das frühere der beiden Enden gewinnt. assert nominal(result)[-1] == d("2026-04-01") # --- Overlays ------------------------------------------------------------------ def test_overlay_setzt_status_und_istbetrag() -> None: recurrence = FakeRecurrence( rrule="FREQ=MONTHLY;BYMONTHDAY=1", dtstart=d("2026-01-01"), amount=Decimal("50.00"), ) overlay = FakeOccurrence( occurrence_date=d("2026-02-01"), status=OccurrenceStatus.CONFIRMED, actual_amount=Decimal("57.30"), actual_date=d("2026-02-03"), note="Nachzahlung", id=42, ) result = expand(recurrence, d("2026-01-01"), d("2026-03-31"), occurrences=[overlay]) februar = result[1] assert februar.status is OccurrenceStatus.CONFIRMED assert februar.amount == Decimal("50.00") # Soll bleibt unangetastet assert februar.actual_amount == Decimal("57.30") assert februar.effective_amount == Decimal("57.30") assert februar.effective_date == d("2026-02-03") assert februar.occurrence_id == 42 assert februar.is_materialised assert result[0].effective_amount == Decimal("50.00") assert result[0].occurrence_id is None def test_overlay_wird_ueber_das_nominale_datum_zugeordnet() -> None: """Auch bei Werktagsverschiebung bleibt das nominale Datum der Schlüssel.""" recurrence = FakeRecurrence( rrule="FREQ=MONTHLY;BYMONTHDAY=1", dtstart=d("2026-02-01"), business_day_shift=BusinessDayShift.NEXT, ) overlay = FakeOccurrence( occurrence_date=d("2026-02-01"), # Sonntag, Zahltag ist der 02.02. status=OccurrenceStatus.CONFIRMED, ) result = expand(recurrence, d("2026-02-01"), d("2026-02-28"), occurrences=[overlay]) assert result[0].nominal_date == d("2026-02-01") assert result[0].due_date == d("2026-02-02") assert result[0].status is OccurrenceStatus.CONFIRMED def test_ausgelassene_faelligkeit_zaehlt_nicht() -> None: recurrence = FakeRecurrence(rrule="FREQ=MONTHLY;BYMONTHDAY=1", dtstart=d("2026-01-01")) overlay = FakeOccurrence(occurrence_date=d("2026-02-01"), status=OccurrenceStatus.SKIPPED) result = expand(recurrence, d("2026-01-01"), d("2026-03-31"), occurrences=[overlay]) assert result[1].is_skipped assert result[1].effective_amount == Decimal("0.00") def test_overlay_uebersteuert_das_konto() -> None: recurrence = FakeRecurrence( rrule="FREQ=MONTHLY;BYMONTHDAY=1", dtstart=d("2026-01-01"), account_id=1 ) overlay = FakeOccurrence(occurrence_date=d("2026-01-01"), account_id=7) result = expand(recurrence, d("2026-01-01"), d("2026-02-28"), occurrences=[overlay]) assert result[0].account_id == 7 assert result[1].account_id == 1 def test_vorzeichen_folgt_der_richtung() -> None: ausgabe = FakeRecurrence(rrule="FREQ=MONTHLY", dtstart=d("2026-01-01"), amount=Decimal("10.00")) einkunft = FakeRecurrence( rrule="FREQ=MONTHLY", dtstart=d("2026-01-01"), amount=Decimal("10.00"), kind=EntryKind.INCOME, ) assert expand(ausgabe, d("2026-01-01"), d("2026-01-31"))[0].signed_amount == Decimal("-10.00") assert expand(einkunft, d("2026-01-01"), d("2026-01-31"))[0].signed_amount == Decimal("10.00") # --- Fenster nach Zahltag ------------------------------------------------------ def test_expand_by_due_date_holt_verschobene_posten_in_den_folgemonat() -> None: """Eine Zahlung vom 31.05., die auf den 01.06. rutscht, gehört in den Juni.""" recurrence = FakeRecurrence( rrule="FREQ=MONTHLY;BYMONTHDAY=-1", dtstart=d("2026-01-31"), business_day_shift=BusinessDayShift.NEXT, ) mai = expand_by_due_date(recurrence, d("2026-05-01"), d("2026-05-31")) juni = expand_by_due_date(recurrence, d("2026-06-01"), d("2026-06-30")) # 31.05.2026 ist ein Sonntag – der Zahltag ist der 01.06. assert nominal(mai) == [] assert nominal(juni) == [d("2026-05-31"), d("2026-06-30")] assert due(juni) == [d("2026-06-01"), d("2026-06-30")] def test_expand_by_due_date_beruecksichtigt_das_istdatum() -> None: recurrence = FakeRecurrence(rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart=d("2026-01-15")) overlay = FakeOccurrence( occurrence_date=d("2026-01-15"), status=OccurrenceStatus.CONFIRMED, actual_date=d("2026-02-02"), ) januar = expand_by_due_date(recurrence, d("2026-01-01"), d("2026-01-31"), occurrences=[overlay]) februar = expand_by_due_date( recurrence, d("2026-02-01"), d("2026-02-28"), occurrences=[overlay] ) assert nominal(januar) == [] assert nominal(februar) == [d("2026-01-15"), d("2026-02-15")] # --- Preishistorie ------------------------------------------------------------- def test_preiswechsel_mitten_in_der_serie() -> None: """Vergangene Monate bleiben betragstreu, wenn ein Abo teurer wird.""" recurrence = FakeRecurrence( rrule="FREQ=MONTHLY;BYMONTHDAY=1", dtstart=d("2026-01-01"), amount=Decimal("12.99"), amount_versions=[ FakeAmountVersion(Decimal("9.99"), d("2026-01-01")), FakeAmountVersion(Decimal("12.99"), d("2026-07-01")), ], ) result = expand(recurrence, d("2026-01-01"), d("2026-12-31")) assert [item.amount for item in result[:6]] == [Decimal("9.99")] * 6 assert [item.amount for item in result[6:]] == [Decimal("12.99")] * 6 def test_preiswechsel_mitten_im_monat_gilt_ab_der_naechsten_faelligkeit() -> None: recurrence = FakeRecurrence( rrule="FREQ=MONTHLY;BYMONTHDAY=10", dtstart=d("2026-01-10"), amount_versions=[ FakeAmountVersion(Decimal("20.00"), d("2026-01-01")), FakeAmountVersion(Decimal("25.00"), d("2026-03-15")), ], ) result = expand(recurrence, d("2026-03-01"), d("2026-04-30")) assert result[0].nominal_date == d("2026-03-10") assert result[0].amount == Decimal("20.00") # vor dem Stichtag assert result[1].amount == Decimal("25.00") def test_ohne_passende_version_gilt_der_basisbetrag() -> None: recurrence = FakeRecurrence( rrule="FREQ=MONTHLY;BYMONTHDAY=1", dtstart=d("2026-01-01"), amount=Decimal("30.00"), amount_versions=[FakeAmountVersion(Decimal("35.00"), d("2026-06-01"))], ) result = expand(recurrence, d("2026-01-01"), d("2026-07-31")) assert result[0].amount == Decimal("30.00") assert result[-1].amount == Decimal("35.00") def test_explizite_versionen_haben_vorrang() -> None: recurrence = FakeRecurrence( rrule="FREQ=MONTHLY;BYMONTHDAY=1", dtstart=d("2026-01-01"), amount_versions=[FakeAmountVersion(Decimal("1.00"), d("2026-01-01"))], ) result = expand( recurrence, d("2026-01-01"), d("2026-01-31"), amount_versions=[FakeAmountVersion(Decimal("2.00"), d("2026-01-01"))], ) assert result[0].amount == Decimal("2.00") # --- RRULE-Validierung --------------------------------------------------------- def test_validate_rrule_akzeptiert_gueltige_regel() -> None: regel = "FREQ=MONTHLY;BYMONTHDAY=1" assert validate_rrule(regel, d("2026-01-01")) == regel @pytest.mark.parametrize( "rrule", ["", " ", "FREQ=QUARTERLY", "DTSTART=20260101\nFREQ=MONTHLY", "kein rrule"], ) def test_validate_rrule_weist_unbrauchbare_regeln_ab(rrule: str) -> None: with pytest.raises(InvalidRRuleError): validate_rrule(rrule, d("2026-01-01")) def test_regel_ohne_termine_wird_abgelehnt() -> None: with pytest.raises(InvalidRRuleError): validate_rrule("FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=30", d("2026-01-01")) def test_next_dates_liefert_die_naechsten_termine() -> None: recurrence = FakeRecurrence(rrule="FREQ=MONTHLY;BYMONTHDAY=1", dtstart=d("2026-01-01")) result = next_dates(recurrence, count=5, after=d("2026-03-15")) assert result == [ d("2026-04-01"), d("2026-05-01"), d("2026-06-01"), d("2026-07-01"), d("2026-08-01"), ] def test_next_dates_findet_auch_seltene_termine() -> None: """Ein alle fünf Jahre fälliger Posten erfordert ein größeres Suchfenster.""" recurrence = FakeRecurrence(rrule="FREQ=YEARLY;INTERVAL=5", dtstart=d("2026-01-01")) result = next_dates(recurrence, count=3, after=d("2026-01-01")) assert result == [d("2026-01-01"), d("2031-01-01"), d("2036-01-01")]