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
166
fire_planner/actualbudget.py
Normal file
166
fire_planner/actualbudget.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
100
fire_planner/api/spending.py
Normal file
100
fire_planner/api/spending.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ export const api = {
|
|||
}>;
|
||||
}>(`/networth/history?days=${days}`),
|
||||
},
|
||||
spending: {
|
||||
annual: (months = 12) =>
|
||||
request<AnnualSpending>(`/spending/annual?months=${months}`),
|
||||
},
|
||||
scenarios: {
|
||||
list: (kind?: 'cartesian' | 'user') =>
|
||||
request<Scenario[]>(`/scenarios${kind ? `?kind=${kind}` : ''}`),
|
||||
|
|
@ -85,6 +89,21 @@ export const api = {
|
|||
request<SimulateResult>('/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<string, string>;
|
||||
monthly: Array<{
|
||||
month: string;
|
||||
by_group: Record<string, string>;
|
||||
total_gbp: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ScenarioCreateBody {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
|
|
|
|||
|
|
@ -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<SimulateRequest>(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() {
|
|||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[480px_1fr] gap-6 items-start">
|
||||
<form onSubmit={onSubmit} className="space-y-4 sticky top-4">
|
||||
<AnchorNumbers form={form} update={update} />
|
||||
<AnchorNumbers form={form} update={update} spending={spending.data} />
|
||||
|
||||
<PlanCard form={form} update={update} strategy={strategy} />
|
||||
|
||||
|
|
@ -235,9 +258,11 @@ export function WhatIf() {
|
|||
function AnchorNumbers({
|
||||
form,
|
||||
update,
|
||||
spending,
|
||||
}: {
|
||||
form: SimulateRequest;
|
||||
update: <K extends keyof SimulateRequest>(k: K, v: SimulateRequest[K]) => void;
|
||||
spending: AnnualSpending | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||
|
|
@ -248,12 +273,15 @@ function AnchorNumbers({
|
|||
value={form.nw_seed_gbp}
|
||||
onChange={(v) => update('nw_seed_gbp', v)}
|
||||
/>
|
||||
<BigNumber
|
||||
label="Annual spending"
|
||||
prefix="£"
|
||||
value={form.spending_gbp}
|
||||
onChange={(v) => update('spending_gbp', v)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<BigNumber
|
||||
label="Annual spending"
|
||||
prefix="£"
|
||||
value={form.spending_gbp}
|
||||
onChange={(v) => update('spending_gbp', v)}
|
||||
/>
|
||||
{spending && <SpendingProvenance data={spending} />}
|
||||
</div>
|
||||
<BigNumber
|
||||
label="Horizon"
|
||||
suffix="yrs"
|
||||
|
|
@ -266,6 +294,19 @@ function AnchorNumbers({
|
|||
);
|
||||
}
|
||||
|
||||
function SpendingProvenance({ data }: { data: AnnualSpending }) {
|
||||
return (
|
||||
<p className="mt-1 text-[10px] text-slate-500 leading-tight">
|
||||
from budget · {data.window_start}→{data.window_end}
|
||||
{data.excluded_groups.length > 0 && (
|
||||
<>
|
||||
{' '}· excl. {data.excluded_groups.join(', ')}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanCard({
|
||||
form,
|
||||
update,
|
||||
|
|
|
|||
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")
|
||||
116
tests/test_api_spending.py
Normal file
116
tests/test_api_spending.py
Normal 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"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue