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:
moneyfyandClaude Opus 5 committed 2026-09-09 13:21:51 +02:00
1 parent 0b69067d7c
commit 70d73cf8d3
7 files changed
+1599

No files matched your search

+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)