From 3bfa46ad4f7cca7866bc114954e5bcfc7c679149 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 10 May 2026 11:11:51 +0000 Subject: [PATCH] =?UTF-8?q?spending:=20prefill=20annual=20=C2=A3=20from=20?= =?UTF-8?q?actualbudget=20trailing=2012mo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- fire_planner/actualbudget.py | 166 ++++++++++++++++++++++++++++++++++ fire_planner/api/schemas.py | 29 ++++++ fire_planner/api/spending.py | 100 ++++++++++++++++++++ fire_planner/app.py | 2 + frontend/src/api/client.ts | 19 ++++ frontend/src/pages/WhatIf.tsx | 57 ++++++++++-- tests/test_actualbudget.py | 136 ++++++++++++++++++++++++++++ tests/test_api_spending.py | 116 ++++++++++++++++++++++++ 8 files changed, 617 insertions(+), 8 deletions(-) create mode 100644 fire_planner/actualbudget.py create mode 100644 fire_planner/api/spending.py create mode 100644 tests/test_actualbudget.py create mode 100644 tests/test_api_spending.py diff --git a/fire_planner/actualbudget.py b/fire_planner/actualbudget.py new file mode 100644 index 0000000..2a741d6 --- /dev/null +++ b/fire_planner/actualbudget.py @@ -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 diff --git a/fire_planner/api/schemas.py b/fire_planner/api/schemas.py index 0d89998..8a09940 100644 --- a/fire_planner/api/schemas.py +++ b/fire_planner/api/schemas.py @@ -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 ────────────────────────────────────────────────────── diff --git a/fire_planner/api/spending.py b/fire_planner/api/spending.py new file mode 100644 index 0000000..09005c6 --- /dev/null +++ b/fire_planner/api/spending.py @@ -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, + ) diff --git a/fire_planner/app.py b/fire_planner/app.py index 6e352f9..dd25edf 100644 --- a/fire_planner/app.py +++ b/fire_planner/app.py @@ -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( diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 896945d..25b96a3 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -70,6 +70,10 @@ export const api = { }>; }>(`/networth/history?days=${days}`), }, + spending: { + annual: (months = 12) => + request(`/spending/annual?months=${months}`), + }, scenarios: { list: (kind?: 'cartesian' | 'user') => request(`/scenarios${kind ? `?kind=${kind}` : ''}`), @@ -85,6 +89,21 @@ export const api = { request('/simulate', { method: 'POST', body: JSON.stringify(req) }), }; +export interface AnnualSpending { + months: number; + window_start: string; + window_end: string; + excluded_groups: string[]; + total_gbp: string; + raw_total_gbp: string; + by_group_total_gbp: Record; + monthly: Array<{ + month: string; + by_group: Record; + total_gbp: string; + }>; +} + export interface ScenarioCreateBody { name: string; description?: string | null; diff --git a/frontend/src/pages/WhatIf.tsx b/frontend/src/pages/WhatIf.tsx index 4b0ef80..21ac8a6 100644 --- a/frontend/src/pages/WhatIf.tsx +++ b/frontend/src/pages/WhatIf.tsx @@ -16,7 +16,12 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { api, type SimulateRequest, type SimulateResult } from '@/api/client'; +import { + api, + type AnnualSpending, + type SimulateRequest, + type SimulateResult, +} from '@/api/client'; import { FanChart } from '@/components/FanChart'; import { InfoTip } from '@/components/InfoTip'; import { SegmentedControl, type SegmentedOption } from '@/components/SegmentedControl'; @@ -94,6 +99,7 @@ const DEFAULTS: SimulateRequest = { export function WhatIf() { const [form, setForm] = useState(DEFAULTS); const [nwAutoFilled, setNwAutoFilled] = useState(false); + const [spendingAutoFilled, setSpendingAutoFilled] = useState(false); const navigate = useNavigate(); const qc = useQueryClient(); @@ -108,6 +114,23 @@ export function WhatIf() { setNwAutoFilled(true); }, [nw.data, nwAutoFilled]); + // Pre-fill annual spending from actualbudget trailing 12 months, + // excluding investment/savings transfers (the default exclusion on + // the backend). Fails silently if the upstream is down — we keep + // the hardcoded DEFAULTS value in that case. + const spending = useQuery({ + queryKey: ['spending', 'annual', 12], + queryFn: () => api.spending.annual(12), + retry: false, + }); + useEffect(() => { + if (spendingAutoFilled || !spending.data) return; + const total = Number(spending.data.total_gbp); + if (!Number.isFinite(total) || total <= 0) return; + setForm((f) => ({ ...f, spending_gbp: String(Math.round(total)) })); + setSpendingAutoFilled(true); + }, [spending.data, spendingAutoFilled]); + const sim = useMutation({ mutationFn: (req: SimulateRequest) => api.simulate(req), }); @@ -169,7 +192,7 @@ export function WhatIf() {
- + @@ -235,9 +258,11 @@ export function WhatIf() { function AnchorNumbers({ form, update, + spending, }: { form: SimulateRequest; update: (k: K, v: SimulateRequest[K]) => void; + spending: AnnualSpending | undefined; }) { return (
@@ -248,12 +273,15 @@ function AnchorNumbers({ value={form.nw_seed_gbp} onChange={(v) => update('nw_seed_gbp', v)} /> - update('spending_gbp', v)} - /> +
+ update('spending_gbp', v)} + /> + {spending && } +
+ from budget · {data.window_start}→{data.window_end} + {data.excluded_groups.length > 0 && ( + <> + {' '}· excl. {data.excluded_groups.join(', ')} + + )} +

+ ); +} + function PlanCard({ form, update, diff --git a/tests/test_actualbudget.py b/tests/test_actualbudget.py new file mode 100644 index 0000000..a6a2c9d --- /dev/null +++ b/tests/test_actualbudget.py @@ -0,0 +1,136 @@ +"""Unit tests for the actualbudget client + trailing-spending helper. + +We mock the upstream HTTP API with respx so the tests don't need a +live cluster connection. The interesting axes are: + +- _trailing_months walks back the right complete-month window +- fetch_month flips sign and skips income groups +- fetch_trailing_spending applies exclusions correctly +""" +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +import httpx +import pytest +import respx + +from fire_planner.actualbudget import ( + ActualBudgetClient, + _trailing_months, + fetch_trailing_spending, +) + +API_URL = "http://budget-http-api.test" +SYNC_ID = "sync-1" + + +def _client() -> ActualBudgetClient: + return ActualBudgetClient(api_url=API_URL, api_key="key", sync_id=SYNC_ID) + + +def test_trailing_months_skips_current_and_walks_back() -> None: + # 2025-04 mid-month → trailing 3 months = 2025-01, 2025-02, 2025-03 + result = _trailing_months(date(2025, 4, 15), 3) + assert result == ["2025-01", "2025-02", "2025-03"] + + +def test_trailing_months_crosses_year_boundary() -> None: + result = _trailing_months(date(2025, 2, 1), 4) + assert result == ["2024-10", "2024-11", "2024-12", "2025-01"] + + +def test_trailing_months_zero_returns_empty() -> None: + assert _trailing_months(date(2025, 5, 1), 0) == [] + + +@pytest.mark.asyncio +async def test_fetch_month_flips_sign_and_drops_income(respx_mock: respx.MockRouter) -> None: + respx_mock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months/2025-03").mock( + return_value=httpx.Response(200, json={ + "data": { + "categoryGroups": [ + { + "name": "Usual Expenses", + "is_income": False, + "categories": [ + {"spent": -1500}, # £15.00 spent + {"spent": -2000}, # £20.00 spent + ], + }, + { + "name": "Salary", + "is_income": True, + "categories": [{"spent": 0}], + }, + { + "name": "Empty Group", + "is_income": False, + "categories": [], + }, + ], + } + })) + cli = _client() + async with httpx.AsyncClient() as h: + result = await cli.fetch_month(h, "2025-03") + # Income skipped; outflows positive + assert result.by_group == {"Usual Expenses": 3500, "Empty Group": 0} + assert result.total_pence == 3500 + + +@pytest.mark.asyncio +async def test_fetch_trailing_spending_excludes_groups(respx_mock: respx.MockRouter) -> None: + # Two months, both with Usual + Investments; we exclude Investments. + respx_mock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months").mock( + return_value=httpx.Response(200, json={ + "data": ["2025-01", "2025-02"] + })) + for m, usual, invest in [("2025-01", -1000, -50000), ("2025-02", -2000, -10000)]: + respx_mock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months/{m}").mock( + return_value=httpx.Response(200, json={ + "data": { + "categoryGroups": [ + {"name": "Usual Expenses", "is_income": False, + "categories": [{"spent": usual}]}, + {"name": "Investments and Savings", "is_income": False, + "categories": [{"spent": invest}]}, + ], + } + })) + + spends, total_gbp = await fetch_trailing_spending( + months=2, + exclude_groups=frozenset(["Investments and Savings"]), + client_factory=_client(), + today=date(2025, 3, 15), + ) + assert [ms.month for ms in spends] == ["2025-01", "2025-02"] + # Usual: 1000 + 2000 = 3000 pence = £30 + assert total_gbp == Decimal("30.00") + + +@pytest.mark.asyncio +async def test_fetch_trailing_spending_drops_unavailable_months( + respx_mock: respx.MockRouter, +) -> None: + """If the API surfaces fewer months than asked, missing months are + skipped instead of erroring.""" + respx_mock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months").mock( + return_value=httpx.Response(200, json={"data": ["2025-02"]})) + respx_mock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months/2025-02").mock( + return_value=httpx.Response(200, json={ + "data": {"categoryGroups": [ + {"name": "Usual Expenses", "is_income": False, + "categories": [{"spent": -5000}]}, + ]} + })) + spends, total_gbp = await fetch_trailing_spending( + months=3, + exclude_groups=frozenset(), + client_factory=_client(), + today=date(2025, 3, 15), + ) + assert [ms.month for ms in spends] == ["2025-02"] + assert total_gbp == Decimal("50.00") diff --git a/tests/test_api_spending.py b/tests/test_api_spending.py new file mode 100644 index 0000000..db1ce6d --- /dev/null +++ b/tests/test_api_spending.py @@ -0,0 +1,116 @@ +"""Endpoint test for /spending/annual. + +Mocks the actualbudget upstream HTTP API with respx so the endpoint +runs end-to-end without a live cluster connection. +""" +from __future__ import annotations + +from collections.abc import AsyncIterator +from datetime import date +from decimal import Decimal +from unittest.mock import patch + +import httpx +import pytest_asyncio +import respx +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from fire_planner.api.dependencies import get_session +from fire_planner.app import app + +API_URL = "http://budget-http-api.test" +SYNC_ID = "sync-1" + + +@pytest_asyncio.fixture +async def client(engine: AsyncEngine, + session: AsyncSession) -> AsyncIterator[AsyncClient]: + factory = async_sessionmaker(engine, expire_on_commit=False) + + async def _override() -> AsyncIterator[AsyncSession]: + async with factory() as s: + yield s + + app.dependency_overrides[get_session] = _override + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test", timeout=30) as ac: + yield ac + app.dependency_overrides.clear() + + +def _mock_two_months(rmock: respx.MockRouter) -> None: + """Mock 2 months of spending data — Usual + Investments groups.""" + rmock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months").mock( + return_value=httpx.Response(200, json={"data": ["2025-01", "2025-02"]})) + for m, usual, invest in [("2025-01", -3000, -50000), ("2025-02", -4000, -10000)]: + rmock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months/{m}").mock( + return_value=httpx.Response(200, json={ + "data": { + "categoryGroups": [ + {"name": "Usual Expenses", "is_income": False, + "categories": [{"spent": usual}]}, + {"name": "Investments and Savings", "is_income": False, + "categories": [{"spent": invest}]}, + ] + } + })) + + +async def test_spending_annual_excludes_investments_by_default(client: AsyncClient) -> None: + env = { + "ACTUALBUDGET_API_URL": API_URL, + "ACTUALBUDGET_API_KEY": "key", + "ACTUALBUDGET_SYNC_ID": SYNC_ID, + } + with patch.dict("os.environ", env, clear=False), \ + patch("fire_planner.actualbudget._today", lambda: date(2025, 3, 15)), \ + respx.mock(assert_all_called=False) as rmock: + _mock_two_months(rmock) + # The endpoint asks for 12 months by default but we mocked only 2; + # the remaining 10 are silently dropped (list_months only returns 2). + resp = await client.get("/spending/annual?months=2") + assert resp.status_code == 200, resp.text + body = resp.json() + # Usual: 3000 + 4000 = 7000 pence = £70 + assert Decimal(body["total_gbp"]) == Decimal("70.00") + # Raw includes investments: 7000 + 60000 = 67000 pence = £670 + assert Decimal(body["raw_total_gbp"]) == Decimal("670.00") + assert body["window_start"] == "2025-01" + assert body["window_end"] == "2025-02" + assert body["excluded_groups"] == ["Investments and Savings", "Budget Reset"] + assert body["by_group_total_gbp"]["Usual Expenses"] == "70.00" + assert body["by_group_total_gbp"]["Investments and Savings"] == "600.00" + + +async def test_spending_annual_custom_exclude(client: AsyncClient) -> None: + env = { + "ACTUALBUDGET_API_URL": API_URL, + "ACTUALBUDGET_API_KEY": "key", + "ACTUALBUDGET_SYNC_ID": SYNC_ID, + } + with patch.dict("os.environ", env, clear=False), \ + patch("fire_planner.actualbudget._today", lambda: date(2025, 3, 15)), \ + respx.mock(assert_all_called=False) as rmock: + _mock_two_months(rmock) + resp = await client.get("/spending/annual?months=2&exclude=Usual%20Expenses") + assert resp.status_code == 200 + body = resp.json() + # Excluding "Usual Expenses" leaves only Investments: £600 + assert Decimal(body["total_gbp"]) == Decimal("600.00") + assert body["excluded_groups"] == ["Usual Expenses"] + + +async def test_spending_annual_502_on_upstream_error(client: AsyncClient) -> None: + env = { + "ACTUALBUDGET_API_URL": API_URL, + "ACTUALBUDGET_API_KEY": "key", + "ACTUALBUDGET_SYNC_ID": SYNC_ID, + } + with patch.dict("os.environ", env, clear=False), \ + respx.mock(assert_all_called=False) as rmock: + rmock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months").mock( + return_value=httpx.Response(503)) + resp = await client.get("/spending/annual?months=2") + assert resp.status_code == 502 + assert "actualbudget" in resp.json()["detail"]