Datenbankfreie Kernberechnung auf Basis schlanker Protokolle, sodass sowohl ORM-Objekte als auch Testdatenklassen verarbeitet werden. - expand() mit voller RFC-5545-Unterstützung, Fensterbegrenzung und Overlays - expand_by_due_date() für Sichten, die nach dem tatsächlichen Zahltag gruppieren - Werktagsverschiebung über den NRW-Feiertagskalender, nominales Datum als Schlüssel - Preishistorie, Ratenzahlungen, Vertragsfristen und Rücklagenberechnung - validate_rrule() und next_dates() für Eingabeprüfung und Terminvorschau - 81 Unit-Tests, davon alle in Abschnitt 2 geforderten Fälle Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
465 lines
15 KiB
Python
465 lines
15 KiB
Python
"""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")]
|