diff --git a/finance/app/engine/breakdown.py b/finance/app/engine/breakdown.py index a301b74..6a4659a 100644 --- a/finance/app/engine/breakdown.py +++ b/finance/app/engine/breakdown.py @@ -3,7 +3,8 @@ Rein (kein DB-Zugriff). Fixposten werden auf den Monat normalisiert (quartalsweise /3, jährlich /12), Einmalposten und Kreditraten zählen voll im Monat ihrer Fälligkeit; die Kredit-Auszahlung zählt nie. Modifikatoren -wirken exakt wie in build_cashflows (gleiche Helfer). +wirken wie in build_cashflows (gleiche Helfer); Stichtag je Fixposten ist sein +Fälligkeitstag im Monat, je Einmalposten das Fälligkeitsdatum. """ import calendar from collections import defaultdict @@ -12,7 +13,7 @@ from datetime import date from decimal import ROUND_HALF_UP, Decimal from app.engine.loans import Installment -from app.engine.recurrence import STEP +from app.engine.recurrence import STEP, due_in_month from app.engine.scenario import (CENT, PlainModifier, PlainPlanned, PlainRecurring, effective_end, modified_amount) @@ -77,7 +78,8 @@ def breakdown_rows(month: date, recurring: list[PlainRecurring], end = effective_end(r, modifiers) if end is not None and end < first: continue - amount = modified_amount(r.amount, r.category_id, r.id, modifiers) + amount = modified_amount(r.amount, r.category_id, r.id, modifiers, + on=due_in_month(first, r.due_day)) if amount is None: continue monthly = (amount / STEP[r.rhythm]).quantize(CENT, ROUND_HALF_UP) @@ -87,7 +89,7 @@ def breakdown_rows(month: date, recurring: list[PlainRecurring], for p in planned: if not first <= p.due <= last: continue - amount = modified_amount(p.amount, p.category_id, None, modifiers) + amount = modified_amount(p.amount, p.category_id, None, modifiers, on=p.due) if amount is None: continue out.append(BreakdownRow(p.name, cat(p.category_id), "planned", None, diff --git a/finance/app/engine/recurrence.py b/finance/app/engine/recurrence.py index 256b20f..60250b2 100644 --- a/finance/app/engine/recurrence.py +++ b/finance/app/engine/recurrence.py @@ -8,6 +8,12 @@ def _clamp(y: int, m: int, day: int) -> date: return date(y, m, min(day, calendar.monthrange(y, m)[1])) +def due_in_month(month: date, due_day: int) -> date: + """Fälligkeitstag `due_day` im Monat von `month`, auf die Monatslänge + geklemmt (gleiche Regel wie occurrences).""" + return _clamp(month.year, month.month, due_day) + + 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] diff --git a/finance/app/engine/scenario.py b/finance/app/engine/scenario.py index d4a2321..254933a 100644 --- a/finance/app/engine/scenario.py +++ b/finance/app/engine/scenario.py @@ -10,7 +10,7 @@ CENT = Decimal("0.01") @dataclass(frozen=True) class PlainRecurring: - id: int + id: int | None # None = szenario-eigene Zahlung (Posten-Einträge treffen sie nie) name: str amount: Decimal rhythm: str @@ -34,19 +34,36 @@ class PlainModifier: target_id: int kind: str value: Decimal - end_date: date | None = None + end_date: date | None = None # ende: Enddatum; sonst „Gültig bis" (inklusiv) + start_date: date | None = None # „Gültig ab" (inklusiv); bei ende immer None + + +def _trifft(m: PlainModifier, category_id: int | None, recurring_id: int | None) -> bool: + return ((m.target_type == "category" and category_id is not None + and category_id == m.target_id) + or (m.target_type == "recurring" and recurring_id is not None + and recurring_id == m.target_id)) + + +def gilt_am(m: PlainModifier, on: date) -> bool: + """Liegt der Stichtag im Zeitraum des Eintrags (Grenzen inklusiv)?""" + return ((m.start_date is None or on >= m.start_date) + and (m.end_date is None or on <= m.end_date)) def modified_amount(amount: Decimal, category_id: int | None, recurring_id: int | None, - modifiers: list[PlainModifier]) -> Decimal | None: + modifiers: list[PlainModifier], *, on: date) -> Decimal | None: + """Betrag einer Fälligkeit am Stichtag `on` nach allen treffenden, dort + gültigen Einträgen in Anlage-Reihenfolge. None = entfällt. Art "ende" + wirkt nur über effective_end.""" for m in modifiers: - hit = ((m.target_type == "category" and category_id == m.target_id) - or (m.target_type == "recurring" and recurring_id == m.target_id)) - if not hit: + if m.kind == "ende" or not _trifft(m, category_id, recurring_id) or not gilt_am(m, on): continue if m.kind == "remove": return None - if m.kind == "percent": + if m.kind == "set": + amount = m.value + elif m.kind == "percent": amount = (amount * (Decimal(100) - m.value) / Decimal(100)).quantize(CENT, ROUND_HALF_UP) elif m.kind == "absolute": if amount < 0: @@ -57,15 +74,12 @@ def modified_amount(amount: Decimal, category_id: int | None, recurring_id: int def effective_end(r: PlainRecurring, modifiers: list[PlainModifier]) -> date | None: - """Fruehestes Ende aus eigenem end_date und allen treffenden ende-Modifikatoren.""" + """Frühestes Ende aus eigenem end_date und allen treffenden ende-Modifikatoren.""" end = r.end_date for m in modifiers: - if m.kind != "ende" or m.end_date is None: + if m.kind != "ende" or m.end_date is None or not _trifft(m, r.category_id, r.id): continue - hit = ((m.target_type == "category" and r.category_id == m.target_id) - or (m.target_type == "recurring" and r.id == m.target_id)) - if hit: - end = m.end_date if end is None else min(end, m.end_date) + end = m.end_date if end is None else min(end, m.end_date) return end @@ -76,15 +90,16 @@ def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned] window_start: date, window_end: date) -> list[tuple[date, Decimal]]: flows: list[tuple[date, Decimal]] = [] for r in recurring: - amount = modified_amount(r.amount, r.category_id, r.id, modifiers) - if amount is None: - continue for d in occurrences(r.rhythm, r.due_day, window_start, window_end, r.start_date, effective_end(r, modifiers)): - flows.append((d, amount)) + amount = modified_amount(r.amount, r.category_id, r.id, modifiers, on=d) + if amount is not None: + flows.append((d, amount)) for p in planned: - amount = modified_amount(p.amount, p.category_id, None, modifiers) - if amount is not None and window_start <= p.due <= window_end: + if not window_start <= p.due <= window_end: + continue + amount = modified_amount(p.amount, p.category_id, None, modifiers, on=p.due) + if amount is not None: flows.append((p.due, amount)) for d, payout in loan_payouts: if window_start <= d <= window_end: diff --git a/finance/tests/test_breakdown.py b/finance/tests/test_breakdown.py index a0411a0..72734b0 100644 --- a/finance/tests/test_breakdown.py +++ b/finance/tests/test_breakdown.py @@ -172,3 +172,25 @@ def test_category_rows_nur_kosten_der_kategorien(): assert [r.name for r in category_rows(rs, {"Wohnen", "Versicherung"})] == ["Miete", "Police", "Strom"] assert category_rows(rs, set()) == [] assert category_rows(rs, {FREI}) == [] + + +def test_stichtag_ist_faelligkeitstag_im_monat(): + r = PlainRecurring(1, "R1", D("-200.00"), "monthly", 14, None, None, None) + mods = [PlainModifier("recurring", 1, "percent", D("50"), start_date=date(2030, 10, 16))] + assert by_name(rows(month=date(2030, 10, 1), recurring=[r], modifiers=mods)) == {"R1": D("-200.00")} + assert by_name(rows(month=date(2030, 11, 1), recurring=[r], modifiers=mods)) == {"R1": D("-100.00")} + + +def test_stichtag_monatsende_geklemmt(): + # Fällig am 31.: im Februar ist der Stichtag der 28.02. (liegt im Zeitraum). + r = PlainRecurring(1, "R1", D("-100.00"), "monthly", 31, None, None, None) + mods = [PlainModifier("recurring", 1, "remove", D("0"), end_date=date(2030, 2, 28))] + assert rows(month=date(2030, 2, 1), recurring=[r], modifiers=mods) == [] + assert by_name(rows(month=date(2030, 3, 1), recurring=[r], modifiers=mods)) == {"R1": D("-100.00")} + + +def test_szenario_zahlung_ohne_recurring_id(): + r = PlainRecurring(None, "S", D("-30.00"), "monthly", 1, None, None, 1) + rs = rows(recurring=[r]) + assert by_name(rs) == {"S": D("-30.00")} + assert (rs[0].recurring_id, rs[0].category) == (None, "Wohnen") diff --git a/finance/tests/test_recurrence.py b/finance/tests/test_recurrence.py index f306dcd..56f6046 100644 --- a/finance/tests/test_recurrence.py +++ b/finance/tests/test_recurrence.py @@ -27,3 +27,10 @@ def test_monthly_leap_year_clamping(): def test_quarterly_clamping(): got = occurrences("quarterly", 31, date(2026, 1, 1), date(2026, 12, 31)) assert got == [date(2026, 1, 31), date(2026, 4, 30), date(2026, 7, 31), date(2026, 10, 31)] + + +def test_due_in_month_klemmt_auf_monatsende(): + from app.engine.recurrence import due_in_month + assert due_in_month(date(2030, 2, 1), 31) == date(2030, 2, 28) + assert due_in_month(date(2032, 2, 10), 30) == date(2032, 2, 29) + assert due_in_month(date(2030, 10, 1), 14) == date(2030, 10, 14) diff --git a/finance/tests/test_scenario_engine.py b/finance/tests/test_scenario_engine.py index 748b609..d5c836b 100644 --- a/finance/tests/test_scenario_engine.py +++ b/finance/tests/test_scenario_engine.py @@ -105,3 +105,70 @@ def test_ende_modifier_aendert_betraege_nicht(): m = PlainModifier("recurring", 1, "ende", Decimal("50"), date(2026, 12, 31)) flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 8, 31)) assert all(a == Decimal("-2000.00") for _, a in flows) + + +D = Decimal + + +def _rec(amount="-100.00", due_day=14, id=1, cat=None): + return PlainRecurring(id, "R", D(amount), "monthly", due_day, None, None, cat) + + +def test_zeitraum_grenzen_inklusiv(): + mods = [PlainModifier("recurring", 1, "percent", D("50"), + end_date=date(2030, 3, 14), start_date=date(2030, 2, 14))] + flows = build_cashflows([_rec()], [], [], [], mods, date(2030, 1, 1), date(2030, 4, 30)) + assert flows == [(date(2030, 1, 14), D("-100.00")), + (date(2030, 2, 14), D("-50.00")), + (date(2030, 3, 14), D("-50.00")), + (date(2030, 4, 14), D("-100.00"))] + + +def test_ab_stichtag_erst_folgemonat(): + # Fällig am 14., gültig ab 16.10. -> Oktober voll, ab November halbiert. + mods = [PlainModifier("recurring", 1, "percent", D("50"), start_date=date(2030, 10, 16))] + flows = build_cashflows([_rec("-200.00")], [], [], [], mods, + date(2030, 10, 1), date(2030, 12, 31)) + assert flows == [(date(2030, 10, 14), D("-200.00")), + (date(2030, 11, 14), D("-100.00")), + (date(2030, 12, 14), D("-100.00"))] + + +def test_neuer_betrag_dann_prozent(): + mods = [PlainModifier("recurring", 1, "set", D("-80.00"), start_date=date(2030, 2, 1)), + PlainModifier("recurring", 1, "percent", D("25"))] + flows = build_cashflows([_rec(due_day=1)], [], [], [], mods, + date(2030, 1, 1), date(2030, 2, 28)) + assert flows == [(date(2030, 1, 1), D("-75.00")), (date(2030, 2, 1), D("-60.00"))] + + +def test_entfaellt_nur_im_zeitraum(): + mods = [PlainModifier("recurring", 1, "remove", D("0"), + end_date=date(2030, 2, 28), start_date=date(2030, 2, 1))] + flows = build_cashflows([_rec(due_day=1)], [], [], [], mods, + date(2030, 1, 1), date(2030, 3, 31)) + assert [d for d, _ in flows] == [date(2030, 1, 1), date(2030, 3, 1)] + + +def test_szenario_zahlung_ohne_id_nur_von_kategorie_getroffen(): + rec = [PlainRecurring(None, "S", D("-100.00"), "monthly", 1, None, None, 7)] + mods = [PlainModifier("recurring", 1, "remove", D("0")), + PlainModifier("category", 7, "percent", D("10"))] + flows = build_cashflows(rec, [], [], [], mods, date(2030, 1, 1), date(2030, 1, 31)) + assert flows == [(date(2030, 1, 1), D("-90.00"))] + + +def test_einmalposten_stichtag_ist_faelligkeit(): + planned = [PlainPlanned("E", D("-100.00"), date(2030, 1, 31), 7), + PlainPlanned("F", D("-100.00"), date(2030, 2, 1), 7)] + mods = [PlainModifier("category", 7, "percent", D("50"), start_date=date(2030, 2, 1))] + flows = build_cashflows([], planned, [], [], mods, date(2030, 1, 1), date(2030, 2, 28)) + assert flows == [(date(2030, 1, 31), D("-100.00")), (date(2030, 2, 1), D("-50.00"))] + + +def test_ende_unveraendert_mit_zeitraum_feldern(): + # "ende" wirkt weiter nur über effective_end, nie als Betragsänderung. + mods = [PlainModifier("recurring", 1, "ende", D("0"), date(2030, 2, 15))] + flows = build_cashflows([_rec(due_day=1)], [], [], [], mods, + date(2030, 1, 1), date(2030, 4, 30)) + assert flows == [(date(2030, 1, 1), D("-100.00")), (date(2030, 2, 1), D("-100.00"))] diff --git a/finance/tests/test_scenario_inputs.py b/finance/tests/test_scenario_inputs.py index 6bfd345..0d3aaa5 100644 --- a/finance/tests/test_scenario_inputs.py +++ b/finance/tests/test_scenario_inputs.py @@ -12,8 +12,8 @@ from app.services.projection_service import load_scenario_inputs def test_modifikator_helfer_oeffentlich(): mods = [PlainModifier("recurring", 1, "percent", Decimal("10")), PlainModifier("recurring", 1, "ende", Decimal("0"), date(2030, 6, 30))] - assert modified_amount(Decimal("-100.00"), None, 1, mods) == Decimal("-90.00") - assert modified_amount(Decimal("-100.00"), None, 2, mods) == Decimal("-100.00") + assert modified_amount(Decimal("-100.00"), None, 1, mods, on=date(2030, 1, 1)) == Decimal("-90.00") + assert modified_amount(Decimal("-100.00"), None, 2, mods, on=date(2030, 1, 1)) == Decimal("-100.00") r = PlainRecurring(1, "R", Decimal("-100.00"), "monthly", 1, None, date(2030, 12, 31), None) assert effective_end(r, mods) == date(2030, 6, 30)