diff --git a/CHANGELOG.md b/CHANGELOG.md index d0cf37e..f350ba9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,3 +24,14 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/). - Testinfrastruktur mit pytest, httpx und transaktionsisolierten Fixtures. - `.env.example`, `docker-compose.override.yml.example` und Makefile für die lokale Entwicklung. +- Recurrence-Engine (`app/services/recurrence.py`) mit vollständiger + RFC-5545-Expansion: `expand` liefert nominale und tatsächliche Zahltage, + `expand_by_due_date` filtert nach dem Zahltag für Kalender und Monatsberichte. +- Wochenend- und Feiertagsverschiebung über den NRW-Kalender; das nominale Datum + bleibt stabiler Schlüssel zur Zuordnung materialisierter Fälligkeiten. +- Auflösung der Preishistorie je Fälligkeitsdatum, Overlays aus `occurrence`, + Kappung der Serie durch `until`, Kündigung und Ratenzahl. +- `installments_remaining` (Restraten und Restschuld), `contract_term` und + `notice_deadline` (Mindestlaufzeit, automatische Verlängerung, Kündigungsfrist) + sowie `annual_burden` und `monthly_reserve` für die Rücklagenbildung. +- `validate_rrule` und `next_dates` als Grundlage für Eingabeprüfung und Vorschau. diff --git a/backend/app/services/recurrence.py b/backend/app/services/recurrence.py new file mode 100644 index 0000000..81462c4 --- /dev/null +++ b/backend/app/services/recurrence.py @@ -0,0 +1,577 @@ +"""Recurrence-Engine: virtuelle Expansion wiederkehrender Posten. + +Das Modul ist bewusst frei von Datenbankzugriffen. Alle Funktionen arbeiten auf +schlanken Protokollen, sodass sowohl ORM-Objekte als auch einfache Datenklassen +übergeben werden können. Preishistorie und materialisierte Occurrences werden als +Sequenzen hereingereicht – das Laden ist Aufgabe der aufrufenden Schicht. + +Zentrale Regel: Schlüssel einer Fälligkeit ist immer das **nominale** Datum, also +das von der RRULE gelieferte Datum vor einer Werktagsverschiebung. Nur so bleibt +die Zuordnung zu `occurrence`-Zeilen stabil, wenn sich Feiertage oder die +Verschiebungsregel ändern. +""" + +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from decimal import ROUND_HALF_UP, Decimal +from functools import lru_cache +from typing import Protocol, runtime_checkable +from zoneinfo import ZoneInfo + +import holidays +from dateutil.relativedelta import relativedelta +from dateutil.rrule import rrulestr + +from app.models.enums import BusinessDayShift, EntryKind, OccurrenceStatus + +ZERO = Decimal("0.00") +CENT = Decimal("0.01") + +# Obergrenze für die Werktagssuche – schützt vor Endlosschleifen bei absurden Kalendern. +MAX_SHIFT_DAYS = 30 + +# Puffer, um den das nominale Fenster erweitert wird, wenn nach Ist-Fälligkeit +# gefiltert wird. Eine Verschiebung überschreitet realistisch nie wenige Tage. +DUE_DATE_WINDOW_PADDING_DAYS = 21 + + +class RecurrenceError(ValueError): + """Fachlicher Fehler bei der Auswertung einer Wiederholungsregel.""" + + +class InvalidRRuleError(RecurrenceError): + """Die RRULE konnte nicht geparst werden.""" + + +# --- Protokolle --------------------------------------------------------------- + + +@runtime_checkable +class AmountVersionLike(Protocol): + """Eine Preisversion; gültig ab `valid_from` (einschließlich).""" + + amount: Decimal + valid_from: date + + +@runtime_checkable +class OccurrenceLike(Protocol): + """Eine materialisierte Fälligkeit, die das virtuelle Ergebnis überlagert.""" + + id: int | None + occurrence_date: date + status: OccurrenceStatus + actual_amount: Decimal | None + actual_date: date | None + account_id: int | None + note: str | None + + +@runtime_checkable +class RecurrenceLike(Protocol): + """Die von der Engine benötigten Felder einer Recurrence.""" + + id: int | None + kind: EntryKind + amount: Decimal + is_variable: bool + rrule: str + dtstart: date + until: date | None + business_day_shift: BusinessDayShift + holiday_region: str + installments_total: int | None + principal_amount: Decimal | None + contract_start: date | None + contract_min_term_months: int | None + contract_notice_period_days: int | None + contract_auto_renew_months: int | None + contract_cancelled_at: date | None + reserve_enabled: bool + account_id: int | None + + +# --- Ergebnistypen ------------------------------------------------------------ + + +@dataclass(frozen=True, slots=True) +class PlannedOccurrence: + """Eine einzelne Fälligkeit, virtuell berechnet und ggf. durch ein Overlay ergänzt.""" + + recurrence_id: int | None + kind: EntryKind + nominal_date: date + """Von der RRULE geliefertes Datum – stabiler Schlüssel, auch nach Verschiebung.""" + due_date: date + """Tatsächlicher Zahltag nach Wochenend-/Feiertagsverschiebung.""" + amount: Decimal + """Sollbetrag laut Preishistorie zum `nominal_date`.""" + status: OccurrenceStatus + is_variable: bool + occurrence_id: int | None = None + actual_amount: Decimal | None = None + actual_date: date | None = None + account_id: int | None = None + note: str | None = None + installment_number: int | None = None + installments_total: int | None = None + + @property + def is_materialised(self) -> bool: + """True, wenn zu dieser Fälligkeit bereits eine `occurrence`-Zeile existiert.""" + return self.occurrence_id is not None + + @property + def is_skipped(self) -> bool: + return self.status is OccurrenceStatus.SKIPPED + + @property + def effective_amount(self) -> Decimal: + """Ist-Betrag, sofern bestätigt, sonst der Sollbetrag. Ausgelassene zählen nicht.""" + if self.status is OccurrenceStatus.SKIPPED: + return ZERO + if self.actual_amount is not None: + return self.actual_amount + return self.amount + + @property + def effective_date(self) -> date: + """Ist-Datum, sofern erfasst, sonst der geplante Zahltag.""" + return self.actual_date or self.due_date + + @property + def signed_amount(self) -> Decimal: + """Betrag mit Vorzeichen: Ausgaben negativ, Einkünfte positiv.""" + amount = self.effective_amount + return -amount if self.kind is EntryKind.EXPENSE else amount + + +@dataclass(frozen=True, slots=True) +class InstallmentStatus: + """Stand einer Ratenzahlung zu einem Stichtag.""" + + total: int + paid: int + remaining: int + paid_amount: Decimal + remaining_amount: Decimal + """Restschuld: `principal_amount` abzüglich geleisteter Raten, sonst Summe der Restraten.""" + final_due_date: date | None + + +@dataclass(frozen=True, slots=True) +class ContractTerm: + """Aktuelle Vertragsperiode samt Kündigungstermin.""" + + term_start: date + term_end: date + """Letzter Tag der laufenden Periode.""" + notice_deadline: date | None + """Spätester Kündigungstermin; None, wenn keine Frist hinterlegt ist.""" + renews_on: date | None + """Beginn der Folgeperiode bei automatischer Verlängerung, sonst None.""" + is_cancelled: bool + + +# --- Feiertage und Werktagsverschiebung --------------------------------------- + + +@lru_cache(maxsize=16) +def _holiday_calendar(region: str) -> holidays.HolidayBase: + """Feiertagskalender für eine Region wie `DE-NW`. Jahre werden bei Bedarf nachgeladen.""" + country, _, subdiv = region.partition("-") + try: + return holidays.country_holidays(country.upper(), subdiv=subdiv.upper() or None) + except NotImplementedError as exc: # pragma: no cover - nur bei Fehlkonfiguration + raise RecurrenceError(f"Unbekannte Feiertagsregion: {region}") from exc + + +def is_business_day(day: date, region: str = "DE-NW") -> bool: + """Ein Werktag ist Montag bis Freitag und kein gesetzlicher Feiertag der Region.""" + if day.weekday() >= 5: + return False + return day not in _holiday_calendar(region) + + +def shift_to_business_day( + day: date, + shift: BusinessDayShift = BusinessDayShift.NEXT, + region: str = "DE-NW", +) -> date: + """Verschiebt einen Termin auf den nächsten bzw. vorherigen Werktag.""" + if shift is BusinessDayShift.NONE: + return day + + step = timedelta(days=1 if shift is BusinessDayShift.NEXT else -1) + candidate = day + for _ in range(MAX_SHIFT_DAYS): + if is_business_day(candidate, region): + return candidate + candidate += step + # Unerreichbar bei realen Kalendern; lieber das Originaldatum als eine Endlosschleife. + return day + + +# --- RRULE -------------------------------------------------------------------- + + +def build_rule(rrule: str, dtstart: date): + """Parst eine RRULE ohne DTSTART und bindet sie an den Startzeitpunkt.""" + text = (rrule or "").strip() + if not text: + raise InvalidRRuleError("Die Wiederholungsregel darf nicht leer sein.") + if "DTSTART" in text.upper(): + raise InvalidRRuleError("Die Wiederholungsregel darf kein DTSTART enthalten.") + try: + return rrulestr(text, dtstart=_as_datetime(dtstart)) + except Exception as exc: + raise InvalidRRuleError(f"Ungültige Wiederholungsregel: {exc}") from exc + + +def validate_rrule(rrule: str, dtstart: date) -> str: + """Prüft eine RRULE und liefert sie normalisiert zurück. Wirft `InvalidRRuleError`.""" + normalised = (rrule or "").strip() + rule = build_rule(normalised, dtstart) + # Eine Regel, die niemals feuert, ist mit Sicherheit ein Eingabefehler. + horizon = _as_datetime(dtstart + relativedelta(years=25)) + if not rule.between(_as_datetime(dtstart), horizon, inc=True): + raise InvalidRRuleError("Die Wiederholungsregel ergibt keine Termine.") + return normalised + + +def next_dates( + recurrence: RecurrenceLike, + *, + count: int = 5, + after: date | None = None, +) -> list[date]: + """Die nächsten `count` nominalen Termine nach `after` (einschließlich).""" + start = after or _today() + end = start + results: list[date] = [] + # Fenster schrittweise vergrößern, bis genug Termine gefunden sind. + for years in (1, 5, 25): + end = start + relativedelta(years=years) + results = [item.nominal_date for item in expand(recurrence, start, end)] + if len(results) >= count: + break + return results[:count] + + +# --- Kernfunktion ------------------------------------------------------------- + + +def expand( + recurrence: RecurrenceLike, + window_start: date, + window_end: date, + *, + amount_versions: Sequence[AmountVersionLike] | None = None, + occurrences: Sequence[OccurrenceLike] | None = None, +) -> list[PlannedOccurrence]: + """Expandiert eine Recurrence über ein Zeitfenster. + + Das Fenster bezieht sich auf das **nominale** Datum. Eine Verschiebung auf den + nächsten Werktag kann `due_date` daher über `window_end` hinausschieben; wer nach + dem tatsächlichen Zahltag filtern will, nutzt `expand_by_due_date`. + + Args: + recurrence: Die auszuwertende Regel. + window_start: Erster Tag des Fensters (einschließlich). + window_end: Letzter Tag des Fensters (einschließlich). + amount_versions: Preishistorie. `None` übernimmt `recurrence.amount_versions`, + falls vorhanden. + occurrences: Materialisierte Fälligkeiten, die das Ergebnis überlagern. + """ + if window_end < window_start: + return [] + + versions = _resolve_versions(recurrence, amount_versions) + overlays = _index_overlays(occurrences) + + rule = build_rule(recurrence.rrule, recurrence.dtstart) + + # Ratenzahlung: harte Obergrenze an Vorkommen, unabhängig von COUNT in der RRULE. + installment_numbers, installment_cutoff = _installment_plan(rule, recurrence) + + series_end = _series_end(recurrence, installment_cutoff) + effective_end = window_end if series_end is None else min(window_end, series_end) + if effective_end < window_start: + return [] + + nominal_dates = rule.between(_as_datetime(window_start), _as_datetime(effective_end), inc=True) + + results: list[PlannedOccurrence] = [] + for moment in nominal_dates: + nominal = moment.date() + overlay = overlays.get(nominal) + results.append( + PlannedOccurrence( + recurrence_id=getattr(recurrence, "id", None), + kind=recurrence.kind, + nominal_date=nominal, + due_date=shift_to_business_day( + nominal, recurrence.business_day_shift, recurrence.holiday_region + ), + amount=resolve_amount(recurrence, nominal, versions), + status=overlay.status if overlay else OccurrenceStatus.PLANNED, + is_variable=recurrence.is_variable, + occurrence_id=getattr(overlay, "id", None) if overlay else None, + actual_amount=overlay.actual_amount if overlay else None, + actual_date=overlay.actual_date if overlay else None, + account_id=( + overlay.account_id + if overlay and overlay.account_id is not None + else getattr(recurrence, "account_id", None) + ), + note=overlay.note if overlay else None, + installment_number=installment_numbers.get(nominal), + installments_total=recurrence.installments_total, + ) + ) + return results + + +def expand_by_due_date( + recurrence: RecurrenceLike, + window_start: date, + window_end: date, + *, + amount_versions: Sequence[AmountVersionLike] | None = None, + occurrences: Sequence[OccurrenceLike] | None = None, +) -> list[PlannedOccurrence]: + """Wie `expand`, filtert aber nach dem tatsächlichen Zahltag (`effective_date`). + + Für Kalender und Monatsauswertungen ist das die richtige Sicht: eine Rate vom + 31.05., die auf den 02.06. rutscht, gehört in den Juni. + """ + padding = timedelta(days=DUE_DATE_WINDOW_PADDING_DAYS) + candidates = expand( + recurrence, + window_start - padding, + window_end + padding, + amount_versions=amount_versions, + occurrences=occurrences, + ) + return [item for item in candidates if window_start <= item.effective_date <= window_end] + + +def resolve_amount( + recurrence: RecurrenceLike, + on: date, + amount_versions: Sequence[AmountVersionLike] | None = None, +) -> Decimal: + """Der zum Stichtag gültige Sollbetrag: größtes `valid_from <= on`.""" + versions = _resolve_versions(recurrence, amount_versions) + applicable = [version for version in versions if version.valid_from <= on] + if not applicable: + return recurrence.amount + return max(applicable, key=lambda version: version.valid_from).amount + + +# --- Raten -------------------------------------------------------------------- + + +def installments_remaining( + recurrence: RecurrenceLike, + as_of: date | None = None, + *, + amount_versions: Sequence[AmountVersionLike] | None = None, + occurrences: Sequence[OccurrenceLike] | None = None, +) -> InstallmentStatus | None: + """Restraten und Restschuld einer Ratenzahlung. `None`, wenn keine Raten definiert sind. + + Als geleistet gilt jede Rate, deren Zahltag am Stichtag bereits erreicht ist. + Die Restschuld folgt `principal_amount` abzüglich der geleisteten Beträge; ohne + hinterlegte Darlehenssumme wird die Summe der verbleibenden Raten gebildet – das + berücksichtigt Betragsänderungen mitten in der Serie korrekt. + """ + total = recurrence.installments_total + if not total: + return None + + reference = as_of or _today() + schedule = expand( + recurrence, + recurrence.dtstart, + # Weit genug in die Zukunft, um garantiert alle Raten zu erfassen. + recurrence.dtstart + relativedelta(years=100), + amount_versions=amount_versions, + occurrences=occurrences, + ) + + paid = [item for item in schedule if item.effective_date <= reference and not item.is_skipped] + outstanding = [ + item for item in schedule if item.effective_date > reference and not item.is_skipped + ] + + paid_amount = _sum(item.effective_amount for item in paid) + if recurrence.principal_amount is not None: + remaining_amount = recurrence.principal_amount - paid_amount + else: + remaining_amount = _sum(item.amount for item in outstanding) + + return InstallmentStatus( + total=total, + paid=len(paid), + remaining=len(outstanding), + paid_amount=paid_amount, + remaining_amount=max(remaining_amount, ZERO), + final_due_date=schedule[-1].due_date if schedule else None, + ) + + +# --- Verträge und Kündigungsfristen ------------------------------------------- + + +def contract_term(recurrence: RecurrenceLike, as_of: date | None = None) -> ContractTerm | None: + """Die zum Stichtag laufende Vertragsperiode. + + Die Erstlaufzeit beginnt mit `contract_start` (ersatzweise `dtstart`) und endet + am Tag vor Ablauf von `contract_min_term_months`. Ist eine automatische + Verlängerung hinterlegt, wird so lange verlängert, bis die Periode den Stichtag + einschließt. + """ + if not recurrence.contract_min_term_months: + return None + + start = recurrence.contract_start or recurrence.dtstart + reference = as_of or _today() + + term_start = start + term_end = start + relativedelta(months=recurrence.contract_min_term_months) - timedelta(days=1) + renew_months = recurrence.contract_auto_renew_months + + if renew_months: + # Höchstens 200 Verlängerungen – schützt vor Endlosschleifen bei Fehldaten. + for _ in range(200): + if term_end >= reference: + break + term_start = term_end + timedelta(days=1) + term_end = term_start + relativedelta(months=renew_months) - timedelta(days=1) + + deadline: date | None = None + if recurrence.contract_notice_period_days is not None: + deadline = term_end - timedelta(days=recurrence.contract_notice_period_days) + + cancelled = recurrence.contract_cancelled_at is not None + renews_on = None if cancelled or not renew_months else term_end + timedelta(days=1) + + return ContractTerm( + term_start=term_start, + term_end=term_end, + notice_deadline=deadline, + renews_on=renews_on, + is_cancelled=cancelled, + ) + + +def notice_deadline(recurrence: RecurrenceLike, as_of: date | None = None) -> date | None: + """Letzter Kündigungstermin: Vertragsende minus Kündigungsfrist. + + `None`, wenn kein Vertrag hinterlegt, keine Frist gesetzt oder bereits gekündigt ist. + """ + term = contract_term(recurrence, as_of) + if term is None or term.is_cancelled: + return None + return term.notice_deadline + + +# --- Rücklagen ---------------------------------------------------------------- + + +def annual_burden( + recurrence: RecurrenceLike, + as_of: date | None = None, + *, + amount_versions: Sequence[AmountVersionLike] | None = None, +) -> Decimal: + """Belastung der kommenden zwölf Monate aus der tatsächlichen Expansion. + + Bewusst kein fester Intervallfaktor: eine halbjährliche Zahlung, die im Fenster + nur einmal fällt, ergibt auch nur eine Belastung. + """ + start = as_of or _today() + end = start + relativedelta(years=1) - timedelta(days=1) + schedule = expand(recurrence, start, end, amount_versions=amount_versions) + return _sum(item.amount for item in schedule) + + +def monthly_reserve( + recurrence: RecurrenceLike, + as_of: date | None = None, + *, + amount_versions: Sequence[AmountVersionLike] | None = None, +) -> Decimal: + """Monatlich zurückzulegender Betrag für nicht-monatliche Posten. + + Jahresbelastung geteilt durch zwölf, kaufmännisch auf Cent gerundet. Ohne + aktivierte Rücklagenbildung ist das Ergebnis 0,00. + """ + if not recurrence.reserve_enabled: + return ZERO + burden = annual_burden(recurrence, as_of, amount_versions=amount_versions) + return (burden / 12).quantize(CENT, rounding=ROUND_HALF_UP) + + +# --- Interne Helfer ----------------------------------------------------------- + + +def _today() -> date: + """Heutiges Datum in der fachlichen Zeitzone.""" + return datetime.now(ZoneInfo("Europe/Berlin")).date() + + +def _as_datetime(day: date) -> datetime: + """dateutil rechnet intern mit datetime; die Uhrzeit ist fachlich bedeutungslos.""" + return datetime(day.year, day.month, day.day) + + +def _sum(values: Iterable[Decimal]) -> Decimal: + return sum(values, ZERO) + + +def _resolve_versions( + recurrence: RecurrenceLike, explicit: Sequence[AmountVersionLike] | None +) -> Sequence[AmountVersionLike]: + """Explizit übergebene Preisversionen haben Vorrang vor denen am Objekt.""" + if explicit is not None: + return explicit + return getattr(recurrence, "amount_versions", None) or () + + +def _index_overlays( + occurrences: Sequence[OccurrenceLike] | None, +) -> dict[date, OccurrenceLike]: + """Overlays nach nominalem Datum indizieren.""" + if not occurrences: + return {} + return {item.occurrence_date: item for item in occurrences} + + +def _series_end(recurrence: RecurrenceLike, installment_cutoff: date | None) -> date | None: + """Frühestes Serienende aus `until`, Kündigung und Ratenzahl.""" + candidates = [ + value + for value in (recurrence.until, recurrence.contract_cancelled_at, installment_cutoff) + if value is not None + ] + return min(candidates) if candidates else None + + +def _installment_plan(rule, recurrence: RecurrenceLike) -> tuple[dict[date, int], date | None]: + """Nummeriert die Raten ab `dtstart` und liefert das Datum der letzten Rate.""" + total = recurrence.installments_total + if not total: + return {}, None + + numbers: dict[date, int] = {} + last: date | None = None + for index, moment in enumerate(rule, start=1): + if index > total: + break + day = moment.date() + numbers[day] = index + last = day + return numbers, last diff --git a/backend/tests/factories.py b/backend/tests/factories.py new file mode 100644 index 0000000..72f8e18 --- /dev/null +++ b/backend/tests/factories.py @@ -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) diff --git a/backend/tests/test_recurrence_contracts.py b/backend/tests/test_recurrence_contracts.py new file mode 100644 index 0000000..aa5f15f --- /dev/null +++ b/backend/tests/test_recurrence_contracts.py @@ -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") diff --git a/backend/tests/test_recurrence_expansion.py b/backend/tests/test_recurrence_expansion.py new file mode 100644 index 0000000..01514ea --- /dev/null +++ b/backend/tests/test_recurrence_expansion.py @@ -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")] diff --git a/backend/tests/test_recurrence_holidays.py b/backend/tests/test_recurrence_holidays.py new file mode 100644 index 0000000..c506ae2 --- /dev/null +++ b/backend/tests/test_recurrence_holidays.py @@ -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") diff --git a/backend/tests/test_recurrence_models.py b/backend/tests/test_recurrence_models.py new file mode 100644 index 0000000..3ddc027 --- /dev/null +++ b/backend/tests/test_recurrence_models.py @@ -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)