"""Spending endpoints — pulled from the actualbudget HTTP API. `GET /spending/annual` returns trailing-N-month outflows aggregated across category groups, with selectable exclusions. The frontend uses the headline `total_gbp` as the default "Annual spending" in the WhatIf form, falling back to a hardcoded number if the upstream API is unreachable. Live-fetched on every call — no caching, no DB write. ~12 upstream HTTP requests per call (~1.5s typical). """ from __future__ import annotations import logging from collections import defaultdict from decimal import Decimal from fastapi import APIRouter, HTTPException, Query from fire_planner.actualbudget import fetch_trailing_spending from fire_planner.api.schemas import AnnualSpending, SpendingMonth router = APIRouter(prefix="/spending", tags=["spending"]) log = logging.getLogger(__name__) # Default exclusions: groups that represent wealth transfers, not # consumption. Investments+savings flows out of cash-flow but flows # back into NW; "Budget Reset" is Viktor's name for periodic balance # corrections that shouldn't count as real spending. DEFAULT_EXCLUDE_GROUPS = ("Investments and Savings", "Budget Reset") @router.get("/annual", response_model=AnnualSpending) async def annual_spending( months: int = Query(default=12, ge=1, le=60, description="Trailing window length in months."), exclude: str | None = Query( default=None, description=( "Comma-separated list of category-group names to exclude " f"from the headline total. Defaults to: {','.join(DEFAULT_EXCLUDE_GROUPS)}." ), ), inflation_pct: float = Query( default=0.03, ge=0.0, le=0.30, description=( "Annual inflation rate used to revalue past months' spending " "into today's £. The simulator runs in real GBP, so the headline " "`total_gbp` is the inflation-adjusted (real) figure. Default ≈ " "UK CPI 2024-26." ), ), ) -> AnnualSpending: excluded = ( [g.strip() for g in exclude.split(",") if g.strip()] if exclude is not None else list(DEFAULT_EXCLUDE_GROUPS) ) try: spends, nominal_gbp, real_gbp = await fetch_trailing_spending( months=months, exclude_groups=frozenset(excluded), annual_inflation_pct=inflation_pct, ) except Exception as e: log.exception("actualbudget unreachable") raise HTTPException( status_code=502, detail=f"actualbudget upstream error: {e}" ) from e if not spends: raise HTTPException( status_code=404, detail="No spending months returned from actualbudget; " "check ACTUALBUDGET_SYNC_ID and that the budget is loaded.", ) by_group_total: dict[str, Decimal] = defaultdict(lambda: Decimal("0")) raw_total_pence = 0 monthly: list[SpendingMonth] = [] for ms in spends: month_total_pence = 0 by_group_decimal: dict[str, Decimal] = {} for name, pence in ms.by_group.items(): by_group_decimal[name] = (Decimal(pence) / Decimal(100)).quantize(Decimal("0.01")) by_group_total[name] += Decimal(pence) month_total_pence += pence raw_total_pence += month_total_pence monthly.append(SpendingMonth( month=ms.month, by_group=by_group_decimal, total_gbp=(Decimal(month_total_pence) / Decimal(100)).quantize(Decimal("0.01")), )) by_group_gbp = { name: (pence / Decimal(100)).quantize(Decimal("0.01")) for name, pence in by_group_total.items() } raw_total_gbp = (Decimal(raw_total_pence) / Decimal(100)).quantize(Decimal("0.01")) return AnnualSpending( months=len(monthly), window_start=monthly[0].month, window_end=monthly[-1].month, excluded_groups=excluded, total_gbp=real_gbp, nominal_total_gbp=nominal_gbp, raw_total_gbp=raw_total_gbp, inflation_pct=Decimal(str(inflation_pct)), by_group_total_gbp=by_group_gbp, monthly=monthly, )