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
936 lines
31 KiB
Python
936 lines
31 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 (
|
||
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
|