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