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