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
This commit is contained in:
moneyfy
2026-09-09 13:21:51 +02:00
co-authored by Claude Opus 5
parent 0b69067d7c
commit 70d73cf8d3
7 changed files with 1599 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
"""Leichtgewichtige Testobjekte für die Recurrence-Engine bewusst ohne Datenbank."""
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from app.models.enums import BusinessDayShift, EntryKind, OccurrenceStatus
@dataclass
class FakeAmountVersion:
"""Preisversion, gültig ab `valid_from`."""
amount: Decimal
valid_from: date
note: str | None = None
@dataclass
class FakeOccurrence:
"""Materialisierte Fälligkeit, die das virtuelle Ergebnis überlagert."""
occurrence_date: date
status: OccurrenceStatus = OccurrenceStatus.PLANNED
actual_amount: Decimal | None = None
actual_date: date | None = None
account_id: int | None = None
note: str | None = None
id: int | None = 1
@dataclass
class FakeRecurrence:
"""Erfüllt `RecurrenceLike` mit denselben Vorgabewerten wie das ORM-Modell."""
rrule: str
dtstart: date
amount: Decimal = Decimal("100.00")
kind: EntryKind = EntryKind.EXPENSE
id: int | None = 1
is_variable: bool = False
until: date | None = None
business_day_shift: BusinessDayShift = BusinessDayShift.NONE
holiday_region: str = "DE-NW"
installments_total: int | None = None
principal_amount: Decimal | None = None
contract_start: date | None = None
contract_min_term_months: int | None = None
contract_notice_period_days: int | None = None
contract_auto_renew_months: int | None = None
contract_cancelled_at: date | None = None
reserve_enabled: bool = False
account_id: int | None = 1
amount_versions: list[FakeAmountVersion] = field(default_factory=list)
def d(value: str) -> date:
"""Kurzschreibweise für ISO-Datumsangaben in Tests."""
return date.fromisoformat(value)
def euro(value: str) -> Decimal:
return Decimal(value)
+298
View File
@@ -0,0 +1,298 @@
"""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")
+464
View File
@@ -0,0 +1,464 @@
"""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")]
+102
View File
@@ -0,0 +1,102 @@
"""Tests der Wochenend- und Feiertagsverschiebung (Region NRW)."""
import pytest
from app.models.enums import BusinessDayShift
from app.services.recurrence import (
RecurrenceError,
expand,
is_business_day,
shift_to_business_day,
)
from tests.factories import FakeRecurrence, d
def test_fronleichnam_ist_in_nrw_kein_werktag() -> None:
"""Fronleichnam fällt 2026 auf Donnerstag, den 04.06. in NRW ein Feiertag."""
assert is_business_day(d("2026-06-04"), "DE-NW") is False
assert is_business_day(d("2026-06-03"), "DE-NW") is True
assert is_business_day(d("2026-06-05"), "DE-NW") is True
def test_fronleichnam_ist_in_niedersachsen_ein_werktag() -> None:
"""Gegenprobe: derselbe Tag ist in einem anderen Bundesland ganz normal."""
assert is_business_day(d("2026-06-04"), "DE-NI") is True
def test_verschiebung_ueber_fronleichnam_nach_hinten() -> None:
recurrence = FakeRecurrence(
rrule="FREQ=MONTHLY;BYMONTHDAY=4",
dtstart=d("2026-06-04"),
business_day_shift=BusinessDayShift.NEXT,
)
result = expand(recurrence, d("2026-06-01"), d("2026-06-30"))
assert result[0].nominal_date == d("2026-06-04")
assert result[0].due_date == d("2026-06-05")
def test_verschiebung_ueber_fronleichnam_nach_vorne() -> None:
recurrence = FakeRecurrence(
rrule="FREQ=MONTHLY;BYMONTHDAY=4",
dtstart=d("2026-06-04"),
business_day_shift=BusinessDayShift.PREVIOUS,
)
result = expand(recurrence, d("2026-06-01"), d("2026-06-30"))
assert result[0].due_date == d("2026-06-03")
def test_ohne_verschiebung_bleibt_das_datum_stehen() -> None:
recurrence = FakeRecurrence(
rrule="FREQ=MONTHLY;BYMONTHDAY=4",
dtstart=d("2026-06-04"),
business_day_shift=BusinessDayShift.NONE,
)
result = expand(recurrence, d("2026-06-01"), d("2026-06-30"))
assert result[0].due_date == d("2026-06-04")
def test_neujahr_und_wochenende_werden_zusammen_uebersprungen() -> None:
"""01.01.2026 ist Donnerstag und Feiertag, der 02.01. ein normaler Freitag."""
assert shift_to_business_day(d("2026-01-01"), BusinessDayShift.NEXT) == d("2026-01-02")
def test_verschiebung_ueberspringt_mehrere_tage_am_stueck() -> None:
"""01.05.2026 ist ein Freitag und Feiertag der nächste Werktag ist Montag."""
assert shift_to_business_day(d("2026-05-01"), BusinessDayShift.NEXT) == d("2026-05-04")
def test_weihnachten_verschiebt_rueckwaerts_ueber_mehrere_tage() -> None:
"""25.12.2026 Fr und 26.12. Sa sind Feiertage; rückwärts landet man am 24.12. (Do)."""
assert shift_to_business_day(d("2026-12-25"), BusinessDayShift.PREVIOUS) == d("2026-12-24")
@pytest.mark.parametrize(
("tag", "erwartet"),
[
(d("2026-01-03"), False), # Samstag
(d("2026-01-04"), False), # Sonntag
(d("2026-01-05"), True), # Montag
(d("2026-04-03"), False), # Karfreitag
(d("2026-04-06"), False), # Ostermontag
(d("2026-05-14"), False), # Christi Himmelfahrt
(d("2026-05-25"), False), # Pfingstmontag
(d("2026-10-03"), False), # Tag der Deutschen Einheit (Samstag)
(d("2026-11-01"), False), # Allerheiligen (Sonntag, NRW)
(d("2026-10-31"), False), # Reformationstag in NRW kein Feiertag, aber Samstag
(d("2026-08-15"), False), # Mariä Himmelfahrt in NRW kein Feiertag, aber Samstag
(d("2026-08-17"), True), # Montag danach
],
)
def test_werktagserkennung_nrw(tag, erwartet: bool) -> None:
assert is_business_day(tag, "DE-NW") is erwartet
def test_unbekannte_region_meldet_fehler() -> None:
with pytest.raises(RecurrenceError):
is_business_day(d("2026-01-05"), "XX-YY")
+84
View File
@@ -0,0 +1,84 @@
"""Nachweis, dass die Engine unmittelbar auf den ORM-Objekten arbeitet.
Die Objekte werden nur im Speicher gebaut es wird keine Session benötigt.
"""
from datetime import date
from decimal import Decimal
from app.models import AmountVersion, Occurrence, Recurrence
from app.models.enums import BusinessDayShift, EntryKind, OccurrenceStatus
from app.services.recurrence import expand, monthly_reserve, notice_deadline
def test_engine_arbeitet_direkt_auf_dem_ormmodell() -> None:
recurrence = Recurrence(
id=7,
kind=EntryKind.EXPENSE,
title="Netflix",
category_id=1,
account_id=2,
amount=Decimal("13.99"),
is_variable=False,
currency="EUR",
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart=date(2026, 1, 1),
business_day_shift=BusinessDayShift.NEXT,
holiday_region="DE-NW",
reserve_enabled=False,
is_active=True,
)
recurrence.amount_versions = [
AmountVersion(amount=Decimal("9.99"), valid_from=date(2026, 1, 1)),
AmountVersion(amount=Decimal("13.99"), valid_from=date(2026, 4, 1)),
]
overlay = Occurrence(
id=99,
recurrence_id=7,
occurrence_date=date(2026, 2, 1),
status=OccurrenceStatus.CONFIRMED,
planned_amount=Decimal("9.99"),
actual_amount=Decimal("9.99"),
actual_date=date(2026, 2, 2),
)
result = expand(recurrence, date(2026, 1, 1), date(2026, 5, 31), occurrences=[overlay])
assert [item.nominal_date.month for item in result] == [1, 2, 3, 4, 5]
assert result[0].recurrence_id == 7
assert result[0].account_id == 2
assert result[0].due_date == date(2026, 1, 2) # 01.01. ist Feiertag
assert [item.amount for item in result] == [
Decimal("9.99"),
Decimal("9.99"),
Decimal("9.99"),
Decimal("13.99"),
Decimal("13.99"),
]
assert result[1].status is OccurrenceStatus.CONFIRMED
assert result[1].occurrence_id == 99
def test_vertrags_und_ruecklagenlogik_auf_dem_ormmodell() -> None:
recurrence = Recurrence(
kind=EntryKind.EXPENSE,
title="Kfz-Versicherung",
category_id=1,
account_id=1,
amount=Decimal("612.00"),
is_variable=False,
currency="EUR",
rrule="FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=15",
dtstart=date(2026, 1, 15),
business_day_shift=BusinessDayShift.NEXT,
holiday_region="DE-NW",
contract_start=date(2026, 1, 1),
contract_min_term_months=12,
contract_notice_period_days=30,
contract_auto_renew_months=12,
reserve_enabled=True,
is_active=True,
)
assert monthly_reserve(recurrence, as_of=date(2026, 1, 1)) == Decimal("51.00")
assert notice_deadline(recurrence, as_of=date(2026, 6, 1)) == date(2026, 12, 1)