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
+11
View File
@@ -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.
+577
View File
@@ -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
+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)