spending: prefill annual £ from actualbudget trailing 12mo
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:
Viktor Barzin 2026-05-10 11:11:51 +00:00
parent 2c51954790
commit 3bfa46ad4f
8 changed files with 617 additions and 8 deletions

136
tests/test_actualbudget.py Normal file
View file

@ -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")

116
tests/test_api_spending.py Normal file
View file

@ -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"]