"""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() def _months_between(older: str, newer: str) -> int: """Calendar months from `older` to `newer` (both YYYY-MM).""" yo, mo = (int(p) for p in older.split("-")) yn, mn = (int(p) for p in newer.split("-")) return (yn - yo) * 12 + (mn - mo) def _inflation_factor(months_age: int, annual_inflation_pct: float) -> float: """Multiplier to convert nominal £ at a point `months_age` months in the past into today's real £. Compounds monthly so the curve is smooth, not stair-stepped.""" if months_age <= 0: return 1.0 monthly = (1.0 + annual_inflation_pct) ** (1.0 / 12.0) return float(monthly ** months_age) async def fetch_trailing_spending( months: int, exclude_groups: frozenset[str], annual_inflation_pct: float = 0.03, client_factory: ActualBudgetClient | None = None, today: date | None = None, ) -> tuple[list[MonthSpend], Decimal, Decimal]: """Fetch trailing-N-months spending and return (per-month breakdown, nominal-£ total, today's-£ inflation-adjusted total). `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 older than the current one are revalued to today's £ using `annual_inflation_pct` compounded monthly. The fire-planner simulator works entirely in REAL GBP, so the real-£ total is the one to feed back as the "Annual spending" default. Missing months (e.g. user joined actualbudget mid-year) come back with empty `by_group`. """ cli = client_factory or ActualBudgetClient() today_d = today or _today() target_months = _trailing_months(today_d, 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)) today_month = f"{today_d.year:04d}-{today_d.month:02d}" nominal_pence = 0 real_pence_float = 0.0 for ms in spends: age = _months_between(ms.month, today_month) factor = _inflation_factor(age, annual_inflation_pct) for name, amt in ms.by_group.items(): if name in exclude_groups: continue nominal_pence += amt real_pence_float += amt * factor nominal_gbp = (Decimal(nominal_pence) / Decimal(100)).quantize(Decimal("0.01")) real_gbp = (Decimal(str(round(real_pence_float / 100, 2)))).quantize(Decimal("0.01")) return spends, nominal_gbp, real_gbp