Backend: - Gemeinsame Bewegungsschicht flows(), auf der alle Berichte aufbauen; Plan und Ist bleiben dabei getrennt - Forecast, Kategorien mit Drilldown, Abo-Übersicht, Jahresvergleich, Cashflow-Kalender, Budget-Ampel, Sparziel-Fortschritt, gebündeltes Dashboard - Budgetübertrag über Monatsgrenzen, Budgets auf Oberkategorien schließen Unterkategorien ein - Export als CSV (BOM, Semikolon, deutsches Dezimaltrennzeichen) und XLSX mit typisierten Beträgen Frontend: - Dashboard, Cashflow-Kalender mit Bestätigen direkt am Tag, Budget-, Sparziel- und Auswertungsseite - Diagrammpalette gegen beide Flächen auf Kontrast und Farbfehlsichtigkeit geprüft; Grün/Rot als Serienpaar verworfen - Einnahmen/Ausgaben und kumulierter Saldo in getrennten Diagrammen statt auf zwei Größenachsen - Recharts in einen eigenen Chunk ausgelagert 27 neue Backend-Tests (235 gesamt), 16 neue Frontend-Tests (65 gesamt) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
871 lines
29 KiB
Python
871 lines
29 KiB
Python
"""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
|