from dataclasses import dataclass from datetime import date from decimal import ROUND_HALF_UP, Decimal from app.engine.loans import Installment from app.engine.recurrence import occurrences CENT = Decimal("0.01") @dataclass(frozen=True) class PlainRecurring: id: int | None # None = szenario-eigene Zahlung (Posten-Einträge treffen sie nie) name: str amount: Decimal rhythm: str due_day: int start_date: date | None end_date: date | None category_id: int | None @dataclass(frozen=True) class PlainPlanned: name: str amount: Decimal due: date category_id: int | None @dataclass(frozen=True) class PlainModifier: target_type: str target_id: int kind: str value: Decimal 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], *, 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: 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 == "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: amount = min(Decimal("0"), amount + m.value) else: amount = max(Decimal("0"), amount - m.value) return amount def effective_end(r: PlainRecurring, modifiers: list[PlainModifier]) -> date | None: """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 or not _trifft(m, r.category_id, r.id): continue end = m.end_date if end is None else min(end, m.end_date) return end def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned], loan_schedules: list[list[Installment]], loan_payouts: list[tuple[date, Decimal]], modifiers: list[PlainModifier], window_start: date, window_end: date) -> list[tuple[date, Decimal]]: flows: list[tuple[date, Decimal]] = [] for r in recurring: for d in occurrences(r.rhythm, r.due_day, window_start, window_end, r.start_date, effective_end(r, modifiers)): 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: 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: flows.append((d, payout)) for sched in loan_schedules: for inst in sched: if window_start <= inst.due <= window_end: flows.append((inst.due, inst.payment)) return sorted(flows)