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