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