spending: prefill annual £ from actualbudget trailing 12mo
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Adds a thin read-only client for the actualbudget HTTP API
(`fire_planner/actualbudget.py`) and a `GET /spending/annual` endpoint
that returns trailing-N-month spending broken out by category group.

Default exclusions ("Investments and Savings", "Budget Reset") strip
out wealth transfers so the headline number reflects actual
consumption — for Viktor's data, ~£41k/yr instead of the raw £210k
total. Caller can pass `?exclude=...` to override.

Frontend uses the headline `total_gbp` to autofill the Annual spending
input (same pattern as nw_seed from networth), with a small
provenance line below the input showing the window + which groups
were excluded.

Auth: 3 new env vars (ACTUALBUDGET_API_URL/KEY/SYNC_ID) sourced from
Vault `secret/fire-planner` via the existing ExternalSecret —
infra/stacks/fire-planner applied separately. Backend silently keeps
the hardcoded default if the upstream is unreachable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-05-10 11:11:51 +00:00
parent 2c51954790
commit 3bfa46ad4f
8 changed files with 617 additions and 8 deletions

View file

@ -0,0 +1,166 @@
"""Read-only client for the actualbudget HTTP API (per-user).
The API is `jhonderson/actual-http-api`, an HTTP wrapper over the
Actual Budget node API. Endpoints used:
- `GET /v1/budgets/{syncId}/months` list of "YYYY-MM" with data
- `GET /v1/budgets/{syncId}/months/{month}` per-category-group
spending; the `totalSpent` field is in pence (negative = outflow)
The wrapper is cluster-internal `ACTUALBUDGET_API_URL` points at
`http://budget-http-api-viktor.actualbudget.svc.cluster.local`. Auth
is a static API key set on the http-api Deployment (header
`x-api-key`); fire-planner reads both via Vault ESO.
This module deliberately stays thin: no caching, no retries, no
background sync. The /spending endpoint pulls live each call (~12
upstream HTTP requests for one year, <2s).
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
import httpx
@dataclass(frozen=True)
class MonthSpend:
"""One calendar month's spending broken out by category group.
All amounts are in pence and represent OUTFLOWS as positive numbers
(the upstream API returns negative for spend; we flip the sign at
parse time so callers don't have to remember the convention).
Income groups (`is_income=True` upstream) are excluded we only
want consumption-style buckets.
"""
month: str # "YYYY-MM"
by_group: dict[str, int] # group name → pence outflow
@property
def total_pence(self) -> int:
return sum(self.by_group.values())
def _required_env(name: str) -> str:
v = os.environ.get(name)
if not v:
raise RuntimeError(f"{name} not set; cannot reach actualbudget")
return v
class ActualBudgetClient:
"""Per-user actualbudget HTTP API client.
Constructed once per request (cheap; httpx.AsyncClient internally
reuses connections within the call) so we don't have to thread a
long-lived client through FastAPI.
"""
def __init__(self,
api_url: str | None = None,
api_key: str | None = None,
sync_id: str | None = None,
timeout: float = 10.0) -> None:
self.api_url = (api_url or _required_env("ACTUALBUDGET_API_URL")).rstrip("/")
self.api_key = api_key or _required_env("ACTUALBUDGET_API_KEY")
self.sync_id = sync_id or _required_env("ACTUALBUDGET_SYNC_ID")
self.timeout = timeout
async def list_months(self, client: httpx.AsyncClient) -> list[str]:
r = await client.get(
f"{self.api_url}/v1/budgets/{self.sync_id}/months",
headers={"x-api-key": self.api_key},
timeout=self.timeout,
)
r.raise_for_status()
return list(r.json()["data"])
async def fetch_month(self, client: httpx.AsyncClient, month: str) -> MonthSpend:
r = await client.get(
f"{self.api_url}/v1/budgets/{self.sync_id}/months/{month}",
headers={"x-api-key": self.api_key},
timeout=self.timeout,
)
r.raise_for_status()
groups = r.json()["data"]["categoryGroups"]
by_group: dict[str, int] = {}
for g in groups:
if g.get("is_income"):
continue
spent = sum(int(c.get("spent", 0)) for c in g.get("categories", []))
# API uses negative for outflows; flip so callers see positive £.
by_group[g["name"]] = -spent
return MonthSpend(month=month, by_group=by_group)
def _trailing_months(today: date, count: int) -> list[str]:
"""Return the last `count` complete calendar months ending the
month before `today`. The current month is excluded because it's
incomplete and would skew the trailing average.
"""
if count <= 0:
return []
months: list[str] = []
y, m = today.year, today.month
# Walk back: skip current month, then take `count` complete months.
m -= 1
if m == 0:
y -= 1
m = 12
for _ in range(count):
months.append(f"{y:04d}-{m:02d}")
m -= 1
if m == 0:
y -= 1
m = 12
months.reverse()
return months
def _today() -> date:
"""Indirection so tests can patch the clock without subclassing date."""
return date.today()
async def fetch_trailing_spending(
months: int,
exclude_groups: frozenset[str],
client_factory: ActualBudgetClient | None = None,
today: date | None = None,
) -> tuple[list[MonthSpend], Decimal]:
"""Fetch trailing-N-months spending and return (per-month breakdown,
total in £).
`exclude_groups` is a set of category-group names whose spending
should NOT count towards the consumption total typical example
is "Investments and Savings", which is a wealth transfer rather
than consumption.
Months are fetched concurrently. Missing months (e.g. user joined
actualbudget mid-year) come back with empty `by_group`.
"""
cli = client_factory or ActualBudgetClient()
target_months = _trailing_months(today or _today(), months)
available_set = None # only fetch if the API surfaces fewer months than asked
async with httpx.AsyncClient() as http:
if len(target_months) > 0:
available = await cli.list_months(http)
available_set = set(available)
kept = [m for m in target_months if available_set is None or m in available_set]
# Sequential — actualbudget single-user instance can't parallelise the
# SQLite-backed API anyway, and 12 calls @ ~80ms each is well under 2s.
spends: list[MonthSpend] = []
for m in kept:
spends.append(await cli.fetch_month(http, m))
total_pence = sum(
amt for ms in spends for name, amt in ms.by_group.items() if name not in exclude_groups
)
total_gbp = (Decimal(total_pence) / Decimal(100)).quantize(Decimal("0.01"))
return spends, total_gbp

View file

@ -133,6 +133,35 @@ class NetWorthHistory(BaseModel):
points: list[NetWorthHistoryPoint]
# ── annual spending (from actualbudget) ──────────────────────────────
class SpendingMonth(BaseModel):
"""One month's outflows (positive £) by category group, after
income groups have been dropped upstream."""
month: str # "YYYY-MM"
by_group: dict[str, Decimal]
total_gbp: Decimal
class AnnualSpending(BaseModel):
"""Aggregated trailing-N-month spending pulled from actualbudget.
`total_gbp` is what we suggest as the "Annual spending" default in
the WhatIf form: the sum of all category groups across the trailing
window, *minus* whichever groups the caller asked to exclude
(default: investments/savings transfers, which aren't consumption).
"""
months: int
window_start: str # "YYYY-MM" (oldest month included)
window_end: str # "YYYY-MM" (newest)
excluded_groups: list[str]
total_gbp: Decimal # the headline number
raw_total_gbp: Decimal # before exclusions, for transparency
by_group_total_gbp: dict[str, Decimal] # 12-mo group sums (incl. excluded)
monthly: list[SpendingMonth]
# ── life events ──────────────────────────────────────────────────────

View file

@ -0,0 +1,100 @@
"""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)}."
),
),
) -> AnnualSpending:
excluded = (
[g.strip() for g in exclude.split(",") if g.strip()]
if exclude is not None
else list(DEFAULT_EXCLUDE_GROUPS)
)
try:
spends, total_gbp = await fetch_trailing_spending(
months=months,
exclude_groups=frozenset(excluded),
)
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=total_gbp,
raw_total_gbp=raw_total_gbp,
by_group_total_gbp=by_group_gbp,
monthly=monthly,
)

View file

@ -46,6 +46,7 @@ from fire_planner.api.life_events import router as life_events_router
from fire_planner.api.networth import router as networth_router
from fire_planner.api.scenarios import router as scenarios_router
from fire_planner.api.simulate import router as simulate_router
from fire_planner.api.spending import router as spending_router
from fire_planner.db import create_engine_from_env, make_session_factory
log = logging.getLogger(__name__)
@ -126,6 +127,7 @@ app.include_router(scenarios_router, prefix=_API_PREFIX)
app.include_router(life_events_router, prefix=_API_PREFIX)
app.include_router(goals_router, prefix=_API_PREFIX)
app.include_router(simulate_router, prefix=_API_PREFIX)
app.include_router(spending_router, prefix=_API_PREFIX)
@app.post(