Files
moneyfy/backend/app/api/routes/reports.py
T
Jonas MenzelandClaude Opus 5 d63cc0af65
Images bauen / build (backend) (push) Failing after 58s
Images bauen / build (frontend) (push) Failing after 57s
CI / backend (push) Successful in 2m21s
CI / frontend (push) Canceled after 4m40s
feat: Politur – Demodaten, Kürzel, Fehlerseiten, Kontraste
- Demo-Seed mit realistischem deutschem Haushalt (make seed-demo): drei Konten,
  zehn Firmen samt Logos, zwölf Posten, bestätigte Historie, Buchungen, Budgets
  und Sparziele; alle Termine relativ zum heutigen Monat
- Tastaturkürzel n / r / / / ? samt Übersicht; greifen nur außerhalb von
  Eingabefeldern
- Fehlergrenze für Renderfehler und eigene 404-Seite
- Farbtoken beider Modi gegen WCAG geprüft und nachgezogen: Hilfstext lag hell
  bei 2,56:1, Weiß auf dem Primärknopf bei 3,30:1 – jetzt überall >= 4,5:1
- Abo-Übersicht heißt „Laufende Kosten“, weil sie auch Miete und Sparplan enthält
- build.yml fällt auf GITEA_TOKEN zurück, wenn REGISTRY_TOKEN fehlt, und erklärt
  im Fehlerfall, wie das Secret anzulegen ist
- README mit Screenshot-Abschnitt, Kürzeln und Barrierefreiheit

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
2026-09-09 21:55:01 +02:00

408 lines
13 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."""
from datetime import date, timedelta
from decimal import Decimal
from fastapi import APIRouter, Query
from sqlalchemy import select
from app.api.deps import DbSession
from app.core.clock import month_end, month_start, today
from app.core.errors import ValidationError
from app.models import SavingsGoal
from app.models.enums import EntryKind
from app.schemas.recurrence import ContractTermOut
from app.schemas.report import (
BudgetStatusOut,
CalendarDayOut,
CalendarEntryOut,
CalendarMonthOut,
CategoryReportOut,
CategorySliceOut,
DashboardOut,
ForecastMonthOut,
ForecastOut,
MonthComparisonOut,
MonthReportOut,
SavingsGoalProgressOut,
SubscriptionOut,
SubscriptionReportOut,
TotalsOut,
YearComparisonOut,
YearComparisonRowOut,
)
from app.services.balances import total_balance
from app.services.reports import (
BudgetStatus,
CategorySlice,
FlowEntry,
ForecastMonth,
MonthReport,
SubscriptionEntry,
Totals,
budget_status,
calendar_month,
category_breakdown,
flows,
forecast,
month_report,
months_between,
required_monthly_rate,
subscriptions,
year_comparison,
)
router = APIRouter(prefix="/reports", tags=["reports"])
MAX_FORECAST_MONTHS = 60
# --- Umsetzung in Schemata -----------------------------------------------------
def _totals(value: Totals) -> TotalsOut:
return TotalsOut(income=value.income, expenses=value.expenses, balance=value.balance)
def _month(report: MonthReport) -> MonthReportOut:
return MonthReportOut(
month=report.month,
planned=_totals(report.planned),
actual=_totals(report.actual),
previous_planned=_totals(report.previous_planned),
previous_actual=_totals(report.previous_actual),
delta_to_previous=MonthComparisonOut(
income=report.income_delta,
expenses=report.expenses_delta,
balance=report.balance_delta,
),
fixed_costs=report.fixed_costs,
variable_costs=report.variable_costs,
reserves=report.reserves,
available_after_fixed=report.available_after_fixed,
confirmed_count=report.confirmed_count,
open_count=report.open_count,
skipped_count=report.skipped_count,
)
def _forecast_month(entry: ForecastMonth) -> ForecastMonthOut:
return ForecastMonthOut(
month=entry.month,
income=entry.income,
expenses=entry.expenses,
balance=entry.balance,
cumulative_balance=entry.cumulative_balance,
)
def _slice(value: CategorySlice) -> CategorySliceOut:
return CategorySliceOut(
category_id=value.category_id,
name=value.name,
color=value.color,
icon=value.icon,
amount=value.amount,
count=value.count,
children=[_slice(kind) for kind in value.children],
)
def _subscription(entry: SubscriptionEntry) -> SubscriptionOut:
return SubscriptionOut(
recurrence_id=entry.recurrence_id,
title=entry.title,
merchant_id=entry.merchant_id,
merchant_name=entry.merchant_name,
category_id=entry.category_id,
amount=entry.amount,
annual_cost=entry.annual_cost,
monthly_cost=entry.monthly_cost,
rrule=entry.rrule,
is_installment=entry.is_installment,
is_cancelled=entry.is_cancelled,
contract_term=ContractTermOut.model_validate(entry.term) if entry.term else None,
days_until_notice=entry.days_until_notice,
)
def _budget(entry: BudgetStatus) -> BudgetStatusOut:
return BudgetStatusOut(
category_id=entry.category_id,
category_name=entry.category_name,
color=entry.color,
period_month=entry.period_month,
limit_amount=entry.limit_amount,
carried_over=entry.carried_over,
available=entry.available,
spent=entry.spent,
remaining=entry.remaining,
ratio=float(entry.ratio),
state=entry.state,
rollover=entry.rollover,
is_template=entry.is_template,
)
def _entry(flow: FlowEntry) -> CalendarEntryOut:
return CalendarEntryOut(
title=flow.title,
kind=flow.kind,
amount=flow.amount,
category_id=flow.category_id,
merchant_id=flow.merchant_id,
account_id=flow.account_id,
source=flow.source,
recurrence_id=flow.recurrence_id,
occurrence_date=flow.occurrence_date,
status=flow.status,
is_variable=flow.is_variable,
)
async def _goals(session: DbSession, as_of: date) -> list[SavingsGoalProgressOut]:
"""Fortschritt aller nicht archivierten Sparziele."""
stmt = (
select(SavingsGoal)
.where(SavingsGoal.is_archived.is_(False))
.order_by(SavingsGoal.target_date.nulls_last(), SavingsGoal.name)
)
ergebnis: list[SavingsGoalProgressOut] = []
for ziel in (await session.execute(stmt)).scalars():
offen = max(ziel.target_amount - ziel.current_amount, 0)
noetig = required_monthly_rate(
ziel.target_amount, ziel.current_amount, ziel.target_date, as_of
)
monate = months_between(as_of, ziel.target_date) if ziel.target_date else None
ergebnis.append(
SavingsGoalProgressOut(
goal_id=ziel.id,
name=ziel.name,
color=ziel.color,
icon=ziel.icon,
target_amount=ziel.target_amount,
current_amount=ziel.current_amount,
remaining_amount=offen,
ratio=(
float(ziel.current_amount / ziel.target_amount)
if ziel.target_amount > 0
else 0.0
),
target_date=ziel.target_date,
months_left=monate,
required_monthly=noetig,
monthly_contribution=ziel.monthly_contribution,
is_on_track=(
None
if noetig is None or ziel.monthly_contribution is None
else ziel.monthly_contribution >= noetig
),
)
)
return ergebnis
# --- Endpunkte -----------------------------------------------------------------
@router.get(
"/month",
response_model=MonthReportOut,
summary="Monatsübersicht",
description="Einnahmen, Ausgaben, Saldo, Plan-Ist-Vergleich, Aufteilung in fixe "
"und variable Kosten sowie die Veränderung gegenüber dem Vormonat.",
)
async def read_month_report(
session: DbSession,
month: date | None = Query(
default=None, description="Beliebiger Tag im gewünschten Monat; Vorgabe ist heute."
),
) -> MonthReportOut:
return _month(await month_report(session, month or today()))
@router.get(
"/forecast",
response_model=ForecastOut,
summary="Vorschau über mehrere Monate",
description="Jährliche Posten erscheinen in ihrem echten Fälligkeitsmonat, weil "
"die Reihe aus der tatsächlichen Expansion entsteht.",
)
async def read_forecast(
session: DbSession,
months: int = Query(default=12, ge=1, le=MAX_FORECAST_MONTHS),
start: date | None = Query(default=None, description="Erster Monat; Vorgabe ist heute."),
) -> ForecastOut:
monate = await forecast(session, months, start)
return ForecastOut(
months=[_forecast_month(monat) for monat in monate],
total_income=sum((monat.income for monat in monate), Decimal("0.00")),
total_expenses=sum((monat.expenses for monat in monate), Decimal("0.00")),
)
@router.get(
"/categories",
response_model=CategoryReportOut,
summary="Aufteilung nach Kategorien",
description="Summen je Oberkategorie mit Drilldown auf die Unterkategorien.",
)
async def read_categories(
session: DbSession,
date_from: date | None = Query(default=None, alias="from"),
date_to: date | None = Query(default=None, alias="to"),
kind: EntryKind = Query(default=EntryKind.EXPENSE),
) -> CategoryReportOut:
heute = today()
start = date_from or month_start(heute)
ende = date_to or month_end(heute)
if ende < start:
raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range")
gruppen = await category_breakdown(session, start, ende, kind)
return CategoryReportOut(
date_from=start,
date_to=ende,
kind=kind,
total=sum((gruppe.amount for gruppe in gruppen), Decimal("0.00")),
categories=[_slice(gruppe) for gruppe in gruppen],
)
@router.get(
"/subscriptions",
response_model=SubscriptionReportOut,
summary="Laufende Kosten",
description="Jede aktive wiederkehrende Ausgabe mit ihren Jahreskosten von der "
"Miete bis zum Streamingdienst. Ratenzahlungen sind gekennzeichnet und zählen nicht "
"in `total_annual`, weil sie ein Ende haben.",
)
async def read_subscriptions(session: DbSession) -> SubscriptionReportOut:
bericht = await subscriptions(session)
return SubscriptionReportOut(
entries=[_subscription(eintrag) for eintrag in bericht.entries],
total_annual=bericht.total_annual,
total_monthly=bericht.total_monthly,
upcoming_deadlines=[_subscription(eintrag) for eintrag in bericht.upcoming_deadlines],
)
@router.get(
"/year-comparison",
response_model=YearComparisonOut,
summary="Jahresvergleich",
description="Aktuelles Jahr gegen Vorjahr, aufgeschlüsselt nach Oberkategorie.",
)
async def read_year_comparison(
session: DbSession,
year: int | None = Query(default=None, ge=1970, le=2200),
kind: EntryKind = Query(default=EntryKind.EXPENSE),
) -> YearComparisonOut:
vergleich = await year_comparison(session, year or today().year, kind)
return YearComparisonOut(
year=vergleich.year,
rows=[
YearComparisonRowOut(
category_id=zeile.category_id,
name=zeile.name,
color=zeile.color,
current=zeile.current,
previous=zeile.previous,
delta=zeile.delta,
)
for zeile in vergleich.rows
],
current_total=vergleich.current_total,
previous_total=vergleich.previous_total,
)
@router.get(
"/calendar",
response_model=CalendarMonthOut,
summary="Cashflow-Kalender",
description="Monatsraster mit den Fälligkeiten je Tag und dem laufenden Kontostand.",
)
async def read_calendar(
session: DbSession,
month: date | None = Query(default=None, description="Beliebiger Tag im Monat."),
) -> CalendarMonthOut:
raster = await calendar_month(session, month or today())
return CalendarMonthOut(
month=raster.month,
days=[
CalendarDayOut(
date=tag.on,
entries=[_entry(eintrag) for eintrag in tag.entries],
net=tag.net,
running_balance=tag.running_balance,
is_business_day=tag.is_business_day,
)
for tag in raster.days
],
opening_balance=raster.opening_balance,
closing_balance=raster.closing_balance,
lowest_balance=raster.lowest_balance,
lowest_balance_on=raster.lowest_balance_on,
)
@router.get(
"/budgets",
response_model=list[BudgetStatusOut],
summary="Budget-Ampel",
description="Verbrauch je Budget im Monat. Grün unter 80 %, gelb unter 100 %, "
"darüber rot. Budgets auf einer Oberkategorie umfassen deren Unterkategorien.",
)
async def read_budget_status(
session: DbSession,
month: date | None = Query(default=None),
) -> list[BudgetStatusOut]:
return [_budget(eintrag) for eintrag in await budget_status(session, month or today())]
@router.get(
"/savings-goals",
response_model=list[SavingsGoalProgressOut],
summary="Fortschritt der Sparziele",
description="Fortschritt und die bis zum Zieldatum nötige Monatsrate.",
)
async def read_goal_progress(session: DbSession) -> list[SavingsGoalProgressOut]:
return await _goals(session, today())
@router.get(
"/dashboard",
response_model=DashboardOut,
summary="Dashboard",
description="Bündelt Monatsübersicht, Vorschau, Kategorien, Budgets, Sparziele "
"und die nächsten Fälligkeiten in einem Aufruf.",
)
async def read_dashboard(
session: DbSession,
month: date | None = Query(default=None),
) -> DashboardOut:
heute = today()
monat = month_start(month or heute)
bericht = await month_report(session, monat)
vorschau = await forecast(session, 12, monat)
gruppen = await category_breakdown(session, monat, month_end(monat))
abos = await subscriptions(session)
# Die nächsten zwei Wochen ab heute, unabhängig vom betrachteten Monat.
naechste = await flows(session, heute, heute + timedelta(days=14))
return DashboardOut(
month=_month(bericht),
total_balance=await total_balance(session, heute),
forecast=[_forecast_month(eintrag) for eintrag in vorschau],
categories=[_slice(gruppe) for gruppe in gruppen],
budgets=[_budget(eintrag) for eintrag in await budget_status(session, monat)],
goals=await _goals(session, heute),
upcoming=[_entry(eintrag) for eintrag in naechste[:20]],
upcoming_deadlines=[_subscription(eintrag) for eintrag in abos.upcoming_deadlines],
)