spending: prefill annual £ from actualbudget trailing 12mo
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
2c51954790
commit
3bfa46ad4f
8 changed files with 617 additions and 8 deletions
|
|
@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
100
fire_planner/api/spending.py
Normal file
100
fire_planner/api/spending.py
Normal 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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue