From 82800ea7d0594835326bfd6d05dd2fe5c5f350aef0e20a3d68ff60f3ae22d962 Mon Sep 17 00:00:00 2001 From: wlfb Date: Fri, 17 Jul 2026 17:16:36 +0200 Subject: [PATCH] feat: Recurrence-Terminberechnung Co-Authored-By: Claude Fable 5 --- finance/app/engine/recurrence.py | 25 +++++++++++++++++++++++++ finance/tests/test_recurrence.py | 19 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 finance/app/engine/recurrence.py create mode 100644 finance/tests/test_recurrence.py diff --git a/finance/app/engine/recurrence.py b/finance/app/engine/recurrence.py new file mode 100644 index 0000000..256b20f --- /dev/null +++ b/finance/app/engine/recurrence.py @@ -0,0 +1,25 @@ +import calendar +from datetime import date + +STEP = {"monthly": 1, "quarterly": 3, "yearly": 12} + + +def _clamp(y: int, m: int, day: int) -> date: + return date(y, m, min(day, calendar.monthrange(y, m)[1])) + + +def occurrences(rhythm: str, due_day: int, window_start: date, window_end: date, + item_start: date | None = None, item_end: date | None = None) -> list[date]: + step = STEP[rhythm] + lo = max(window_start, item_start) if item_start else window_start + hi = min(window_end, item_end) if item_end else window_end + anchor = item_start or window_start + d = _clamp(anchor.year, anchor.month, due_day) + out: list[date] = [] + while d <= hi: + if d >= lo: + out.append(d) + total = d.year * 12 + d.month - 1 + step + y, m0 = divmod(total, 12) + d = _clamp(y, m0 + 1, due_day) + return out diff --git a/finance/tests/test_recurrence.py b/finance/tests/test_recurrence.py new file mode 100644 index 0000000..0ed711c --- /dev/null +++ b/finance/tests/test_recurrence.py @@ -0,0 +1,19 @@ +from datetime import date + +from app.engine.recurrence import occurrences + + +def test_monthly_clamps_short_months(): + got = occurrences("monthly", 31, date(2026, 1, 1), date(2026, 3, 31)) + assert got == [date(2026, 1, 31), date(2026, 2, 28), date(2026, 3, 31)] + + +def test_quarterly_respects_item_bounds(): + got = occurrences("quarterly", 15, date(2026, 1, 1), date(2026, 12, 31), + item_start=date(2026, 4, 1), item_end=date(2026, 10, 31)) + assert got == [date(2026, 4, 15), date(2026, 7, 15), date(2026, 10, 15)] + + +def test_yearly(): + got = occurrences("yearly", 1, date(2026, 1, 1), date(2028, 12, 31)) + assert got == [date(2026, 1, 1), date(2027, 1, 1), date(2028, 1, 1)]