Files
moneyfy/backend/tests/test_recurrence_expansion.py
moneyfyandClaude Opus 5 70d73cf8d3 feat(recurrence): Expansions-Engine für wiederkehrende Posten
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
2026-09-09 13:21:51 +02:00

465 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")]