Files
moneyfy/backend/app/services/reports.py
T
Jonas MenzelandClaude Opus 5 0a6261fc55
CI / backend (push) Successful in 2m33s
Images bauen / build (backend) (push) Successful in 3m26s
Images bauen / build (frontend) (push) Successful in 4m4s
CI / frontend (push) Successful in 6m32s
feat: einstellbarer Monatsbeginn zum Gehaltstag
Wer nach dem Gehaltseingang plant, stellt unter Einstellungen den Tag
ein, ab dem ein neuer Monat zählt. Der Zeitraum läuft dann vom
Gehaltstag bis zum Vortag des nächsten und trägt den Namen des Monats,
in dem er beginnt: Mit dem 25. umfasst „September 2026“ den 25.09. bis
zum 24.10. Ein Starttag jenseits der Monatslänge rutscht auf den
Monatsletzten, sodass 31 verlässlich den letzten Tag des Monats meint.

Dashboard, Cashflow-Kalender, Budgets, Zwölf-Monats-Vorschau, die
Kategorienauswertung, der Monatsexport und die Benachrichtigung über
überschrittene Budgets rechnen mit diesem Zeitraum. Budgets bleiben je
Monat gepflegt; der Bezeichner ist weiterhin der Monatserste, nur der
Schnitt verschiebt sich. Bestandsinstallationen bleiben beim Ersten.

Die Einstellung liegt in einer einzeiligen Tabelle hinter
GET/PUT /api/settings; die Monatsauswertungen liefern zusätzlich
period_start und period_end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fj7mB1PGA1aDHyfdSGHgzD
2026-09-10 09:52:51 +02:00

936 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 (
DEFAULT_MONTH_START_DAY,
add_months,
month_start,
period_bounds,
period_key,
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
@dataclass(frozen=True, slots=True)
class Period:
"""Ein Abrechnungsmonat.
Er läuft vom eingestellten Monatsbeginn dem Gehaltstag bis zum Vortag des
nächsten und trägt den Namen des Monats, in dem er beginnt. Bei Monatsbeginn 1
ist er deckungsgleich mit dem Kalendermonat.
"""
key: date
"""Bezeichner: der Erste des Monats, in dem der Zeitraum beginnt."""
start: date
end: date
def period_of(month: date, start_day: int = DEFAULT_MONTH_START_DAY) -> Period:
"""Der Abrechnungsmonat mit dem Bezeichner `month`."""
schluessel = month_start(month)
beginn, ende = period_bounds(schluessel, start_day)
return Period(key=schluessel, start=beginn, end=ende)
def current_period(start_day: int = DEFAULT_MONTH_START_DAY, as_of: date | None = None) -> Period:
"""Der Abrechnungsmonat, in dem `as_of` liegt Vorgabe ist heute."""
return period_of(period_key(as_of or today(), start_day), start_day)
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 Abrechnungsmonats."""
period: Period
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 month(self) -> date:
"""Bezeichner des Zeitraums immer ein Monatserster."""
return self.period.key
@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, period: Period, fixed_costs: dict[int, bool]
) -> list[FlowEntry]:
return await flows(session, period.start, period.end, fixed_costs=fixed_costs)
async def reserve_total(session: AsyncSession, period: Period) -> 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, period.start, amount_versions=recurrence.amount_versions
)
return total
async def month_report(
session: AsyncSession, month: date, start_day: int = DEFAULT_MONTH_START_DAY
) -> MonthReport:
"""Monatsübersicht inklusive Vergleich zum Vormonat."""
fix = await fixed_cost_map(session)
aktuell = period_of(month, start_day)
vormonat = period_of(add_months(aktuell.key, -1), start_day)
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, aktuell.start, aktuell.end)
if item.planned.status is OccurrenceStatus.SKIPPED
)
serien = [entry for entry in bewegungen if entry.source == "recurrence"]
return MonthReport(
period=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 Abrechnungsmonat der Vorschau."""
month: date
period_start: date
period_end: date
income: Decimal
expenses: Decimal
cumulative_balance: Decimal
"""Prognostizierter Kontostand am Ende des Zeitraums ü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,
start_day: int = DEFAULT_MONTH_START_DAY,
) -> 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.
"""
erster = period_of(start, start_day) if start is not None else current_period(start_day)
fix = await fixed_cost_map(session)
# Ausgangspunkt ist der bestätigte Kontostand am Tag vor dem ersten Zeitraum.
laufend = await total_balance(session, erster.start - timedelta(days=1))
ergebnis: list[ForecastMonth] = []
for versatz in range(max(1, months)):
zeitraum = period_of(add_months(erster.key, versatz), start_day)
bewegungen = await flows(session, zeitraum.start, zeitraum.end, fixed_costs=fix)
summen = totals_of(bewegungen, basis="effective")
laufend += summen.balance
ergebnis.append(
ForecastMonth(
month=zeitraum.key,
period_start=zeitraum.start,
period_end=zeitraum.end,
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
"""Bezeichner des Abrechnungsmonats immer ein Monatserster."""
period_start: date
period_end: 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,
*,
start_day: int = DEFAULT_MONTH_START_DAY,
holiday_region: str = "DE-NW",
) -> CalendarMonth:
"""Tagesraster des Abrechnungsmonats mit den Fälligkeiten und dem laufenden Kontostand."""
zeitraum = period_of(month, start_day)
beginn = zeitraum.start
ende = zeitraum.end
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=zeitraum.key,
period_start=beginn,
period_end=ende,
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, start_day: int
) -> dict[int, Decimal]:
"""Ausgaben eines Zeitraums je Kategorie, Unterkategorien auf die Oberkategorie gerollt."""
kategorien = {
kategorie.id: kategorie.parent_id
for kategorie in (await session.execute(select(Category))).scalars()
}
zeitraum = period_of(month, start_day)
ergebnis: dict[int, Decimal] = {}
for entry in await flows(session, zeitraum.start, zeitraum.end):
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, start_day: int = DEFAULT_MONTH_START_DAY
) -> list[BudgetStatus]:
"""Budgets eines Abrechnungsmonats 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, start_day)
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, start_day) 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, start_day: int
) -> 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, start_day)).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