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
299 lines
9.9 KiB
Python
299 lines
9.9 KiB
Python
"""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")
|