"""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="Abo-Übersicht", description="Alle laufenden Ausgabenposten mit Jahreskosten. Ratenzahlungen sind " "gekennzeichnet und zählen nicht in die Gesamtsumme.", ) 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], )