Files
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

432 lines
14 KiB
Python
Raw Permalink 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, MonthStartDay
from app.core.clock import 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,
current_period,
flows,
forecast,
month_report,
months_between,
period_of,
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,
period_start=report.period.start,
period_end=report.period.end,
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,
period_start=entry.period_start,
period_end=entry.period_end,
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,
start_day: MonthStartDay,
month: date | None = Query(
default=None,
description="Beliebiger Tag im gewünschten Monat; ausschlaggebend ist dessen "
"Monatserster. Vorgabe ist der laufende Abrechnungsmonat.",
),
) -> MonthReportOut:
monat = month or current_period(start_day).key
return _month(await month_report(session, monat, start_day))
@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,
start_day: MonthStartDay,
months: int = Query(default=12, ge=1, le=MAX_FORECAST_MONTHS),
start: date | None = Query(
default=None, description="Erster Monat; Vorgabe ist der laufende Abrechnungsmonat."
),
) -> ForecastOut:
monate = await forecast(session, months, start, start_day)
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,
start_day: MonthStartDay,
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:
laufend = current_period(start_day)
start = date_from or laufend.start
ende = date_to or laufend.end
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,
start_day: MonthStartDay,
month: date | None = Query(default=None, description="Beliebiger Tag im Monat."),
) -> CalendarMonthOut:
monat = month or current_period(start_day).key
raster = await calendar_month(session, monat, start_day=start_day)
return CalendarMonthOut(
month=raster.month,
period_start=raster.period_start,
period_end=raster.period_end,
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,
start_day: MonthStartDay,
month: date | None = Query(default=None),
) -> list[BudgetStatusOut]:
monat = month or current_period(start_day).key
return [_budget(eintrag) for eintrag in await budget_status(session, monat, start_day)]
@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,
start_day: MonthStartDay,
month: date | None = Query(default=None),
) -> DashboardOut:
heute = today()
zeitraum = period_of(month, start_day) if month else current_period(start_day, heute)
bericht = await month_report(session, zeitraum.key, start_day)
vorschau = await forecast(session, 12, zeitraum.key, start_day)
gruppen = await category_breakdown(session, zeitraum.start, zeitraum.end)
abos = await subscriptions(session)
# Die nächsten zwei Wochen ab heute, unabhängig vom betrachteten Zeitraum.
naechste = await flows(session, heute, heute + timedelta(days=14))
return DashboardOut(
month_start_day=start_day,
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, zeitraum.key, start_day)
],
goals=await _goals(session, heute),
upcoming=[_entry(eintrag) for eintrag in naechste[:20]],
upcoming_deadlines=[_subscription(eintrag) for eintrag in abos.upcoming_deadlines],
)