All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Three follow-ups to the actualbudget integration: **Always-fresh autofill.** Drop the one-shot `*AutoFilled` boolean gates; replace with `nwUserEdited` / `spendingUserEdited` flags. Until the user types into either field, every refetch (mount, window focus) updates the form value. Once they edit, we leave it alone. A small ↻ button next to each anchor input flips the edited flag back off so the user can re-snap to live data on demand. React Query configured with staleTime=0 + refetchOnMount='always' + refetchOnWindowFocus=true so the cache never serves stale numbers. NW provenance shows the snapshot date. **Inflation-adjusted spending.** Backend now revalues each trailing month's nominal pence forward to today's £ using monthly compounding of `inflation_pct` (default 0.03 ≈ UK CPI 2024-26). Headline `total_gbp` is the real-£ figure — matches the simulator's real-GBP convention. Response also includes `nominal_total_gbp` and `inflation_pct` for transparency. New /spending/annual?inflation_pct= override param. 10/10 actualbudget tests pass. **FanChart legend.** The bottom-anchored legend was overlapping the x-axis label. Moved to top: 8 with itemGap=18 + type=scroll for narrow viewports; bumped grid top→48 / bottom→56 + xAxis nameGap→28 so nothing collides. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
112 lines
4.1 KiB
Python
112 lines
4.1 KiB
Python
"""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,
|
|
)
|