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>
200 lines
7.2 KiB
Python
200 lines
7.2 KiB
Python
"""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
|