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

View file

@ -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;

View file

@ -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,