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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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")]
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user