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
136
tests/test_actualbudget.py
Normal file
136
tests/test_actualbudget.py
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue