"""Auswertungen. Alle Berichte bauen auf `flows()` auf: einer einheitlichen Liste aus expandierten Fälligkeiten und einmaligen Buchungen. Dadurch rechnen Monatsübersicht, Forecast, Kalender und Jahresvergleich nachweislich mit denselben Zahlen. """ from collections.abc import Iterable, Sequence from dataclasses import dataclass, field from datetime import date, timedelta from decimal import ROUND_HALF_UP, Decimal from typing import Literal from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.clock import add_months, month_end, month_start, today from app.models import Budget, BudgetTemplate, Category, Merchant, Recurrence, Transaction from app.models.enums import EntryKind, OccurrenceStatus from app.services.balances import total_balance from app.services.occurrences import due_items, load_recurrences from app.services.recurrence import ( ContractTerm, annual_burden, contract_term, is_business_day, monthly_reserve, ) ZERO = Decimal("0.00") CENT = Decimal("0.01") # Rücklaufweite für die Budgetübertragung – begrenzt die Rekursion. ROLLOVER_LOOKBACK_MONTHS = 12 FlowSource = Literal["recurrence", "transaction"] @dataclass(frozen=True, slots=True) class FlowEntry: """Eine Geldbewegung, unabhängig davon, ob sie aus einer Serie oder einer einmaligen Buchung stammt.""" on: date """Tag, an dem das Geld fließt (Ist-Datum, sonst Zahltag).""" kind: EntryKind amount: Decimal """Bester bekannter Wert: Ist bei Bestätigung, sonst Soll. Immer positiv.""" planned_amount: Decimal """Sollbetrag laut Preishistorie. Bei einmaligen Buchungen gleich `amount`.""" title: str category_id: int account_id: int | None merchant_id: int | None source: FlowSource is_fixed_cost: bool recurrence_id: int | None = None occurrence_date: date | None = None status: OccurrenceStatus | None = None is_variable: bool = False installment_number: int | None = None installments_total: int | None = None @property def is_confirmed(self) -> bool: """Einmalige Buchungen sind immer Ist, Fälligkeiten nur nach Bestätigung.""" return self.source == "transaction" or self.status is OccurrenceStatus.CONFIRMED @property def signed(self) -> Decimal: return -self.amount if self.kind is EntryKind.EXPENSE else self.amount @dataclass(frozen=True, slots=True) class Totals: """Einnahmen, Ausgaben und Saldo einer Sicht.""" income: Decimal = ZERO expenses: Decimal = ZERO @property def balance(self) -> Decimal: return self.income - self.expenses def totals_of( entries: Iterable[FlowEntry], *, basis: Literal["planned", "effective"] = "planned", only_confirmed: bool = False, ) -> Totals: """Summiert Einnahmen und Ausgaben einer Auswahl. `basis="planned"` rechnet mit dem Soll, `basis="effective"` mit dem besten bekannten Wert. Beides zusammen ergibt den Plan-Ist-Vergleich. """ income = expenses = ZERO for entry in entries: if only_confirmed and not entry.is_confirmed: continue betrag = entry.planned_amount if basis == "planned" else entry.amount if entry.kind is EntryKind.INCOME: income += betrag else: expenses += betrag return Totals(income=income, expenses=expenses) async def fixed_cost_map(session: AsyncSession) -> dict[int, bool]: """Kategorie-ID -> gehört zu den Fixkosten.""" rows = await session.execute(select(Category.id, Category.is_fixed_cost)) return dict(rows.all()) async def flows( session: AsyncSession, date_from: date, date_to: date, *, fixed_costs: dict[int, bool] | None = None, ) -> list[FlowEntry]: """Alle Geldbewegungen eines Zeitraums, nach Datum sortiert. Ausgelassene Fälligkeiten sind bereits herausgefiltert – sie fließen nirgends mehr ein. """ fix = fixed_costs if fixed_costs is not None else await fixed_cost_map(session) ergebnis: list[FlowEntry] = [] for item in await due_items(session, date_from, date_to): planned = item.planned if planned.status is OccurrenceStatus.SKIPPED: continue ergebnis.append( FlowEntry( on=planned.effective_date, kind=planned.kind, amount=planned.effective_amount, planned_amount=planned.amount, title=item.recurrence.title, category_id=item.recurrence.category_id, account_id=planned.account_id, merchant_id=item.recurrence.merchant_id, source="recurrence", is_fixed_cost=fix.get(item.recurrence.category_id, False), recurrence_id=item.recurrence.id, occurrence_date=planned.nominal_date, status=planned.status, is_variable=planned.is_variable, installment_number=planned.installment_number, installments_total=planned.installments_total, ) ) stmt = select(Transaction).where( Transaction.booking_date >= date_from, Transaction.booking_date <= date_to ) for transaction in (await session.execute(stmt)).scalars(): ergebnis.append( FlowEntry( on=transaction.booking_date, kind=transaction.kind, amount=transaction.amount, planned_amount=transaction.amount, title=transaction.title, category_id=transaction.category_id, account_id=transaction.account_id, merchant_id=transaction.merchant_id, source="transaction", is_fixed_cost=fix.get(transaction.category_id, False), ) ) ergebnis.sort(key=lambda entry: (entry.on, entry.title)) return ergebnis # --- Monatsübersicht ----------------------------------------------------------- @dataclass(slots=True) class MonthReport: """Kennzahlen eines Monats.""" month: date planned: Totals actual: Totals previous_planned: Totals previous_actual: Totals fixed_costs: Decimal = ZERO variable_costs: Decimal = ZERO reserves: Decimal = ZERO confirmed_count: int = 0 open_count: int = 0 skipped_count: int = 0 @property def available_after_fixed(self) -> Decimal: """Einkünfte abzüglich Fixkosten und Rücklagen – die große Kennzahl im Dashboard.""" return self.planned.income - self.fixed_costs - self.reserves @property def income_delta(self) -> Decimal: return self.planned.income - self.previous_planned.income @property def expenses_delta(self) -> Decimal: return self.planned.expenses - self.previous_planned.expenses @property def balance_delta(self) -> Decimal: return self.planned.balance - self.previous_planned.balance async def _month_flows( session: AsyncSession, month: date, fixed_costs: dict[int, bool] ) -> list[FlowEntry]: return await flows(session, month_start(month), month_end(month), fixed_costs=fixed_costs) async def reserve_total(session: AsyncSession, month: date) -> Decimal: """Summe der monatlichen Rücklagen aller Posten mit aktivierter Rücklagenbildung.""" total = ZERO for recurrence in await load_recurrences(session): if recurrence.reserve_enabled: total += monthly_reserve( recurrence, month_start(month), amount_versions=recurrence.amount_versions ) return total async def month_report(session: AsyncSession, month: date) -> MonthReport: """Monatsübersicht inklusive Vergleich zum Vormonat.""" fix = await fixed_cost_map(session) aktuell = month_start(month) vormonat = add_months(aktuell, -1) bewegungen = await _month_flows(session, aktuell, fix) vorherige = await _month_flows(session, vormonat, fix) ausgaben = [entry for entry in bewegungen if entry.kind is EntryKind.EXPENSE] fixkosten = sum((entry.amount for entry in ausgaben if entry.is_fixed_cost), ZERO) variabel = sum((entry.amount for entry in ausgaben if not entry.is_fixed_cost), ZERO) # Ausgelassene Fälligkeiten fehlen in `flows` und werden separat gezählt. ausgelassen = sum( 1 for item in await due_items(session, month_start(month), month_end(month)) if item.planned.status is OccurrenceStatus.SKIPPED ) serien = [entry for entry in bewegungen if entry.source == "recurrence"] return MonthReport( month=aktuell, planned=totals_of(bewegungen), actual=totals_of(bewegungen, basis="effective", only_confirmed=True), previous_planned=totals_of(vorherige), previous_actual=totals_of(vorherige, basis="effective", only_confirmed=True), fixed_costs=fixkosten, variable_costs=variabel, reserves=await reserve_total(session, aktuell), confirmed_count=sum(1 for entry in serien if entry.is_confirmed), open_count=sum(1 for entry in serien if not entry.is_confirmed), skipped_count=ausgelassen, ) # --- Forecast ------------------------------------------------------------------ @dataclass(frozen=True, slots=True) class ForecastMonth: """Ein Monat der Vorschau.""" month: date income: Decimal expenses: Decimal cumulative_balance: Decimal """Prognostizierter Kontostand am Monatsende über alle Konten.""" @property def balance(self) -> Decimal: return self.income - self.expenses async def forecast( session: AsyncSession, months: int = 12, start: date | None = None ) -> list[ForecastMonth]: """Vorschau über mehrere Monate. Jährliche Posten erscheinen in ihrem echten Fälligkeitsmonat, weil die Reihe aus der tatsächlichen Expansion entsteht und nicht aus einem Durchschnitt. """ beginn = month_start(start or today()) fix = await fixed_cost_map(session) # Ausgangspunkt ist der bestätigte Kontostand am Tag vor dem ersten Monat. laufend = await total_balance(session, beginn - timedelta(days=1)) ergebnis: list[ForecastMonth] = [] for versatz in range(max(1, months)): monat = add_months(beginn, versatz) bewegungen = await flows(session, month_start(monat), month_end(monat), fixed_costs=fix) summen = totals_of(bewegungen, basis="effective") laufend += summen.balance ergebnis.append( ForecastMonth( month=monat, income=summen.income, expenses=summen.expenses, cumulative_balance=laufend, ) ) return ergebnis # --- Kategorien ---------------------------------------------------------------- @dataclass(slots=True) class CategorySlice: """Summe einer Kategorie, optional mit Unterkategorien.""" category_id: int name: str color: str icon: str amount: Decimal count: int children: list["CategorySlice"] = field(default_factory=list) async def category_breakdown( session: AsyncSession, date_from: date, date_to: date, kind: EntryKind = EntryKind.EXPENSE, ) -> list[CategorySlice]: """Summen je Oberkategorie mit Drilldown auf die Unterkategorien.""" kategorien = { kategorie.id: kategorie for kategorie in (await session.execute(select(Category))).scalars() } bewegungen = [entry for entry in await flows(session, date_from, date_to) if entry.kind is kind] summen: dict[int, Decimal] = {} anzahl: dict[int, int] = {} for entry in bewegungen: summen[entry.category_id] = summen.get(entry.category_id, ZERO) + entry.amount anzahl[entry.category_id] = anzahl.get(entry.category_id, 0) + 1 gruppen: dict[int, CategorySlice] = {} for kategorie_id, betrag in summen.items(): kategorie = kategorien.get(kategorie_id) if kategorie is None: continue wurzel_id = kategorie.parent_id or kategorie.id wurzel = kategorien.get(wurzel_id) if wurzel is None: continue gruppe = gruppen.get(wurzel_id) if gruppe is None: gruppe = CategorySlice( category_id=wurzel.id, name=wurzel.name, color=wurzel.color, icon=wurzel.icon, amount=ZERO, count=0, ) gruppen[wurzel_id] = gruppe gruppe.amount += betrag gruppe.count += anzahl[kategorie_id] gruppe.children.append( CategorySlice( category_id=kategorie.id, name=kategorie.name, color=kategorie.color, icon=kategorie.icon, amount=betrag, count=anzahl[kategorie_id], ) ) ergebnis = sorted(gruppen.values(), key=lambda gruppe: gruppe.amount, reverse=True) for gruppe in ergebnis: gruppe.children.sort(key=lambda kind_: kind_.amount, reverse=True) return ergebnis # --- Abo-Übersicht ------------------------------------------------------------- @dataclass(frozen=True, slots=True) class SubscriptionEntry: """Ein laufender Posten mit Jahreskosten und Vertragsdaten.""" recurrence_id: int title: str merchant_id: int | None merchant_name: str | None category_id: int amount: Decimal annual_cost: Decimal monthly_cost: Decimal rrule: str is_installment: bool term: ContractTerm | None days_until_notice: int | None is_cancelled: bool @dataclass(slots=True) class SubscriptionReport: entries: list[SubscriptionEntry] total_annual: Decimal total_monthly: Decimal """Summe der Jahreskosten geteilt durch zwölf.""" upcoming_deadlines: list[SubscriptionEntry] """Verträge, deren Kündigungsfrist in den nächsten 60 Tagen abläuft.""" NOTICE_WARNING_DAYS = 60 async def subscriptions(session: AsyncSession, as_of: date | None = None) -> SubscriptionReport: """Alle laufenden Ausgaben-Posten mit Jahreskosten. Als Abo gilt jeder aktive wiederkehrende Ausgabenposten. Ratenzahlungen sind enthalten, aber als solche gekennzeichnet und zählen nicht in die Summe „Abos gesamt p. a.“ – ein Kredit ist keine dauerhafte Belastung. """ stichtag = as_of or today() firmen = {firma.id: firma.name for firma in (await session.execute(select(Merchant))).scalars()} eintraege: list[SubscriptionEntry] = [] for recurrence in await load_recurrences(session, kind=EntryKind.EXPENSE): jahr = annual_burden(recurrence, stichtag, amount_versions=recurrence.amount_versions) term = contract_term(recurrence, stichtag) frist = term.notice_deadline if term and not term.is_cancelled else None eintraege.append( SubscriptionEntry( recurrence_id=recurrence.id, title=recurrence.title, merchant_id=recurrence.merchant_id, merchant_name=firmen.get(recurrence.merchant_id or -1), category_id=recurrence.category_id, amount=recurrence.amount, annual_cost=jahr, monthly_cost=(jahr / 12).quantize(CENT, rounding=ROUND_HALF_UP), rrule=recurrence.rrule, is_installment=recurrence.installments_total is not None, term=term, days_until_notice=(frist - stichtag).days if frist else None, is_cancelled=recurrence.contract_cancelled_at is not None, ) ) eintraege.sort(key=lambda eintrag: eintrag.annual_cost, reverse=True) laufend = [eintrag for eintrag in eintraege if not eintrag.is_installment] gesamt = sum((eintrag.annual_cost for eintrag in laufend), ZERO) return SubscriptionReport( entries=eintraege, total_annual=gesamt, total_monthly=(gesamt / 12).quantize(CENT, rounding=ROUND_HALF_UP), upcoming_deadlines=sorted( ( eintrag for eintrag in eintraege if eintrag.days_until_notice is not None and 0 <= eintrag.days_until_notice <= NOTICE_WARNING_DAYS ), key=lambda eintrag: eintrag.days_until_notice or 0, ), ) # --- Jahresvergleich ----------------------------------------------------------- @dataclass(frozen=True, slots=True) class YearComparisonRow: category_id: int name: str color: str current: Decimal previous: Decimal @property def delta(self) -> Decimal: return self.current - self.previous @dataclass(slots=True) class YearComparison: year: int rows: list[YearComparisonRow] current_total: Decimal previous_total: Decimal async def year_comparison( session: AsyncSession, year: int, kind: EntryKind = EntryKind.EXPENSE ) -> YearComparison: """Aktuelles Jahr gegen Vorjahr, aufgeschlüsselt nach Oberkategorie.""" kategorien = { kategorie.id: kategorie for kategorie in (await session.execute(select(Category))).scalars() } async def summen(jahr: int) -> dict[int, Decimal]: ergebnis: dict[int, Decimal] = {} for entry in await flows(session, date(jahr, 1, 1), date(jahr, 12, 31)): if entry.kind is not kind: continue kategorie = kategorien.get(entry.category_id) if kategorie is None: continue wurzel = kategorie.parent_id or kategorie.id ergebnis[wurzel] = ergebnis.get(wurzel, ZERO) + entry.amount return ergebnis aktuell = await summen(year) vorher = await summen(year - 1) zeilen: list[YearComparisonRow] = [] for kategorie_id in set(aktuell) | set(vorher): kategorie = kategorien.get(kategorie_id) if kategorie is None: continue zeilen.append( YearComparisonRow( category_id=kategorie_id, name=kategorie.name, color=kategorie.color, current=aktuell.get(kategorie_id, ZERO), previous=vorher.get(kategorie_id, ZERO), ) ) zeilen.sort(key=lambda zeile: zeile.current, reverse=True) return YearComparison( year=year, rows=zeilen, current_total=sum((zeile.current for zeile in zeilen), ZERO), previous_total=sum((zeile.previous for zeile in zeilen), ZERO), ) # --- Cashflow-Kalender --------------------------------------------------------- @dataclass(slots=True) class CalendarDay: """Ein Tag im Monatsraster.""" on: date entries: list[FlowEntry] net: Decimal running_balance: Decimal is_business_day: bool @dataclass(slots=True) class CalendarMonth: month: date days: list[CalendarDay] opening_balance: Decimal closing_balance: Decimal lowest_balance: Decimal lowest_balance_on: date | None async def calendar_month( session: AsyncSession, month: date, *, holiday_region: str = "DE-NW" ) -> CalendarMonth: """Monatsraster mit den Fälligkeiten je Tag und dem laufenden Kontostand.""" beginn = month_start(month) ende = month_end(month) eroeffnung = await total_balance(session, beginn - timedelta(days=1)) bewegungen = await flows(session, beginn, ende) nach_tag: dict[date, list[FlowEntry]] = {} for entry in bewegungen: nach_tag.setdefault(entry.on, []).append(entry) laufend = eroeffnung tiefstand = eroeffnung tiefstand_am: date | None = None tage: list[CalendarDay] = [] tag = beginn while tag <= ende: eintraege = nach_tag.get(tag, []) netto = sum((entry.signed for entry in eintraege), ZERO) laufend += netto if laufend < tiefstand: tiefstand = laufend tiefstand_am = tag tage.append( CalendarDay( on=tag, entries=eintraege, net=netto, running_balance=laufend, is_business_day=is_business_day(tag, holiday_region), ) ) tag += timedelta(days=1) return CalendarMonth( month=beginn, days=tage, opening_balance=eroeffnung, closing_balance=laufend, lowest_balance=tiefstand, lowest_balance_on=tiefstand_am, ) # --- Budget-Ampel -------------------------------------------------------------- BudgetState = Literal["ok", "warning", "exceeded"] # Schwellen der Ampel gemäß Fachspezifikation. WARNING_RATIO = Decimal("0.8") @dataclass(frozen=True, slots=True) class BudgetStatus: """Stand eines Budgets im Monat.""" category_id: int category_name: str color: str period_month: date limit_amount: Decimal carried_over: Decimal """Übertrag aus Vormonaten, falls `rollover` gesetzt ist.""" spent: Decimal rollover: bool is_template: bool """True, wenn der Wert aus einer Vorlage stammt und nicht aus einem Einzelbudget.""" @property def available(self) -> Decimal: return self.limit_amount + self.carried_over @property def remaining(self) -> Decimal: return self.available - self.spent @property def ratio(self) -> Decimal: if self.available <= ZERO: return Decimal("1") if self.spent > ZERO else ZERO return self.spent / self.available @property def state(self) -> BudgetState: """Grün unter 80 %, gelb unter 100 %, darüber rot.""" if self.ratio < WARNING_RATIO: return "ok" if self.ratio < Decimal("1"): return "warning" return "exceeded" async def _effective_limits( session: AsyncSession, month: date ) -> dict[int, tuple[Decimal, bool, bool]]: """Gültige Budgets eines Monats: Kategorie -> (Betrag, rollover, aus Vorlage). Ein ausdrücklich gepflegtes Budget schlägt immer die Vorlage. """ monat = month_start(month) ergebnis: dict[int, tuple[Decimal, bool, bool]] = {} stmt = select(BudgetTemplate).where(BudgetTemplate.valid_from <= monat) vorlagen = list((await session.execute(stmt)).scalars()) # Die jüngste passende Vorlage je Kategorie gewinnt. vorlagen.sort(key=lambda vorlage: vorlage.valid_from) for vorlage in vorlagen: if vorlage.valid_until is not None and vorlage.valid_until < monat: continue ergebnis[vorlage.category_id] = (vorlage.limit_amount, vorlage.rollover, True) stmt = select(Budget).where(Budget.period_month == monat) for budget in (await session.execute(stmt)).scalars(): ergebnis[budget.category_id] = (budget.limit_amount, budget.rollover, False) return ergebnis async def _spent_by_category(session: AsyncSession, month: date) -> dict[int, Decimal]: """Ausgaben eines Monats je Kategorie, Unterkategorien auf die Oberkategorie gerollt.""" kategorien = { kategorie.id: kategorie.parent_id for kategorie in (await session.execute(select(Category))).scalars() } ergebnis: dict[int, Decimal] = {} for entry in await flows(session, month_start(month), month_end(month)): if entry.kind is not EntryKind.EXPENSE: continue # Ein Budget auf der Oberkategorie umfasst auch deren Unterkategorien. ergebnis[entry.category_id] = ergebnis.get(entry.category_id, ZERO) + entry.amount eltern = kategorien.get(entry.category_id) if eltern is not None: ergebnis[eltern] = ergebnis.get(eltern, ZERO) + entry.amount return ergebnis async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus]: """Budgets eines Monats samt Verbrauch und Übertrag.""" monat = month_start(month) limits = await _effective_limits(session, monat) if not limits: return [] namen = { kategorie.id: kategorie for kategorie in (await session.execute(select(Category))).scalars() } ausgaben = await _spent_by_category(session, monat) ergebnis: list[BudgetStatus] = [] for kategorie_id, (limit, rollover, aus_vorlage) in limits.items(): kategorie = namen.get(kategorie_id) if kategorie is None: continue uebertrag = await _carry_over(session, kategorie_id, monat) if rollover else ZERO ergebnis.append( BudgetStatus( category_id=kategorie_id, category_name=kategorie.name, color=kategorie.color, period_month=monat, limit_amount=limit, carried_over=uebertrag, spent=ausgaben.get(kategorie_id, ZERO), rollover=rollover, is_template=aus_vorlage, ) ) ergebnis.sort(key=lambda eintrag: eintrag.ratio, reverse=True) return ergebnis async def _carry_over(session: AsyncSession, category_id: int, month: date) -> Decimal: """Nicht verbrauchtes Budget aus den Vormonaten. Es wird höchstens ein Jahr zurückgeschaut; ein Überschreiten setzt den Übertrag wieder auf null, statt eine Schuld weiterzureichen. """ uebertrag = ZERO for versatz in range(ROLLOVER_LOOKBACK_MONTHS, 0, -1): vormonat = add_months(month, -versatz) limits = await _effective_limits(session, vormonat) eintrag = limits.get(category_id) if eintrag is None: uebertrag = ZERO continue limit, rollover, _ = eintrag if not rollover: uebertrag = ZERO continue ausgaben = (await _spent_by_category(session, vormonat)).get(category_id, ZERO) uebertrag = max(limit + uebertrag - ausgaben, ZERO) return uebertrag # --- Sparziele ----------------------------------------------------------------- def months_between(start: date, end: date) -> int: """Volle Monate zwischen zwei Daten, mindestens null.""" return max((end.year - start.year) * 12 + end.month - start.month, 0) def required_monthly_rate( target_amount: Decimal, current_amount: Decimal, target_date: date | None, as_of: date ) -> Decimal | None: """Rate, die bis zum Zieldatum monatlich nötig ist. `None` ohne Zieldatum.""" if target_date is None: return None fehlend = target_amount - current_amount if fehlend <= ZERO: return ZERO monate = months_between(as_of, target_date) if monate <= 0: return fehlend return (fehlend / monate).quantize(CENT, rounding=ROUND_HALF_UP) # --- Gemeinsame Helfer für den Export ------------------------------------------ def flow_rows(entries: Sequence[FlowEntry], names: dict[int, str]) -> list[dict[str, object]]: """Bereitet Bewegungen als Zeilen für den Export auf.""" return [ { "Datum": entry.on.isoformat(), "Titel": entry.title, "Kategorie": names.get(entry.category_id, ""), "Richtung": "Einkunft" if entry.kind is EntryKind.INCOME else "Ausgabe", "Betrag": entry.amount, "Herkunft": "Serie" if entry.source == "recurrence" else "Einmalig", "Bestätigt": "ja" if entry.is_confirmed else "nein", } for entry in entries ] async def category_names(session: AsyncSession) -> dict[int, str]: """Kategorie-ID -> „Oberkategorie · Unterkategorie“.""" kategorien = list((await session.execute(select(Category))).scalars()) nach_id = {kategorie.id: kategorie for kategorie in kategorien} namen: dict[int, str] = {} for kategorie in kategorien: if kategorie.parent_id is None: namen[kategorie.id] = kategorie.name else: eltern = nach_id.get(kategorie.parent_id) namen[kategorie.id] = f"{eltern.name} · {kategorie.name}" if eltern else kategorie.name return namen async def recurrence_rows( session: AsyncSession, as_of: date | None = None ) -> list[dict[str, object]]: """Wiederkehrende Posten als Exportzeilen.""" stichtag = as_of or today() namen = await category_names(session) firmen = {firma.id: firma.name for firma in (await session.execute(select(Merchant))).scalars()} stmt = select(Recurrence).order_by(Recurrence.title) zeilen: list[dict[str, object]] = [] for recurrence in (await session.execute(stmt)).scalars(): zeilen.append( { "Titel": recurrence.title, "Richtung": "Einkunft" if recurrence.kind is EntryKind.INCOME else "Ausgabe", "Betrag": recurrence.amount, "Wiederholung": recurrence.rrule, "Beginn": recurrence.dtstart.isoformat(), "Ende": recurrence.until.isoformat() if recurrence.until else "", "Kategorie": namen.get(recurrence.category_id, ""), "Firma": firmen.get(recurrence.merchant_id or -1, ""), "Jahreskosten": annual_burden( recurrence, stichtag, amount_versions=recurrence.amount_versions ), "Raten": recurrence.installments_total or "", "Gekündigt zum": ( recurrence.contract_cancelled_at.isoformat() if recurrence.contract_cancelled_at else "" ), "Aktiv": "ja" if recurrence.is_active else "nein", } ) return zeilen