"""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], )