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
166
fire_planner/actualbudget.py
Normal file
166
fire_planner/actualbudget.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue