whatif: live data refresh, inflation-adjusted spending, legend fix
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Three follow-ups to the actualbudget integration: **Always-fresh autofill.** Drop the one-shot `*AutoFilled` boolean gates; replace with `nwUserEdited` / `spendingUserEdited` flags. Until the user types into either field, every refetch (mount, window focus) updates the form value. Once they edit, we leave it alone. A small ↻ button next to each anchor input flips the edited flag back off so the user can re-snap to live data on demand. React Query configured with staleTime=0 + refetchOnMount='always' + refetchOnWindowFocus=true so the cache never serves stale numbers. NW provenance shows the snapshot date. **Inflation-adjusted spending.** Backend now revalues each trailing month's nominal pence forward to today's £ using monthly compounding of `inflation_pct` (default 0.03 ≈ UK CPI 2024-26). Headline `total_gbp` is the real-£ figure — matches the simulator's real-GBP convention. Response also includes `nominal_total_gbp` and `inflation_pct` for transparency. New /spending/annual?inflation_pct= override param. 10/10 actualbudget tests pass. **FanChart legend.** The bottom-anchored legend was overlapping the x-axis label. Moved to top: 8 with itemGap=18 + type=scroll for narrow viewports; bumped grid top→48 / bottom→56 + xAxis nameGap→28 so nothing collides. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
3bfa46ad4f
commit
e12e8f9290
8 changed files with 263 additions and 68 deletions
|
|
@ -127,25 +127,49 @@ def _today() -> date:
|
|||
return date.today()
|
||||
|
||||
|
||||
def _months_between(older: str, newer: str) -> int:
|
||||
"""Calendar months from `older` to `newer` (both YYYY-MM)."""
|
||||
yo, mo = (int(p) for p in older.split("-"))
|
||||
yn, mn = (int(p) for p in newer.split("-"))
|
||||
return (yn - yo) * 12 + (mn - mo)
|
||||
|
||||
|
||||
def _inflation_factor(months_age: int, annual_inflation_pct: float) -> float:
|
||||
"""Multiplier to convert nominal £ at a point `months_age` months
|
||||
in the past into today's real £. Compounds monthly so the curve
|
||||
is smooth, not stair-stepped."""
|
||||
if months_age <= 0:
|
||||
return 1.0
|
||||
monthly = (1.0 + annual_inflation_pct) ** (1.0 / 12.0)
|
||||
return float(monthly ** months_age)
|
||||
|
||||
|
||||
async def fetch_trailing_spending(
|
||||
months: int,
|
||||
exclude_groups: frozenset[str],
|
||||
annual_inflation_pct: float = 0.03,
|
||||
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 £).
|
||||
) -> tuple[list[MonthSpend], Decimal, Decimal]:
|
||||
"""Fetch trailing-N-months spending and return (per-month
|
||||
breakdown, nominal-£ total, today's-£ inflation-adjusted total).
|
||||
|
||||
`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`.
|
||||
Months older than the current one are revalued to today's £ using
|
||||
`annual_inflation_pct` compounded monthly. The fire-planner
|
||||
simulator works entirely in REAL GBP, so the real-£ total is the
|
||||
one to feed back as the "Annual spending" default.
|
||||
|
||||
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)
|
||||
today_d = today or _today()
|
||||
target_months = _trailing_months(today_d, months)
|
||||
available_set = None # only fetch if the API surfaces fewer months than asked
|
||||
|
||||
async with httpx.AsyncClient() as http:
|
||||
|
|
@ -159,8 +183,18 @@ async def fetch_trailing_spending(
|
|||
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
|
||||
today_month = f"{today_d.year:04d}-{today_d.month:02d}"
|
||||
nominal_pence = 0
|
||||
real_pence_float = 0.0
|
||||
for ms in spends:
|
||||
age = _months_between(ms.month, today_month)
|
||||
factor = _inflation_factor(age, annual_inflation_pct)
|
||||
for name, amt in ms.by_group.items():
|
||||
if name in exclude_groups:
|
||||
continue
|
||||
nominal_pence += amt
|
||||
real_pence_float += amt * factor
|
||||
|
||||
nominal_gbp = (Decimal(nominal_pence) / Decimal(100)).quantize(Decimal("0.01"))
|
||||
real_gbp = (Decimal(str(round(real_pence_float / 100, 2)))).quantize(Decimal("0.01"))
|
||||
return spends, nominal_gbp, real_gbp
|
||||
|
|
|
|||
|
|
@ -147,18 +147,28 @@ class SpendingMonth(BaseModel):
|
|||
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).
|
||||
`total_gbp` is the headline figure used as the "Annual spending"
|
||||
default in the WhatIf form. It is **inflation-adjusted to today's
|
||||
£** (each month's nominal pence revalued forward by
|
||||
`inflation_pct` compounded monthly), matching the simulator's
|
||||
real-£ convention.
|
||||
|
||||
`nominal_total_gbp` is the same window without inflation
|
||||
adjustment — for transparency / comparison.
|
||||
|
||||
`raw_total_gbp` is the nominal sum *including* groups that were
|
||||
excluded (e.g. investment transfers) — useful when you want to
|
||||
see your full cash outflow.
|
||||
"""
|
||||
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)
|
||||
inflation_pct: Decimal # annual rate applied
|
||||
total_gbp: Decimal # inflation-adjusted, after exclusions
|
||||
nominal_total_gbp: Decimal # not adjusted, after exclusions
|
||||
raw_total_gbp: Decimal # nominal, before exclusions
|
||||
by_group_total_gbp: dict[str, Decimal] # nominal 12-mo group sums (incl. excluded)
|
||||
monthly: list[SpendingMonth]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,15 @@ async def annual_spending(
|
|||
f"from the headline total. Defaults to: {','.join(DEFAULT_EXCLUDE_GROUPS)}."
|
||||
),
|
||||
),
|
||||
inflation_pct: float = Query(
|
||||
default=0.03, ge=0.0, le=0.30,
|
||||
description=(
|
||||
"Annual inflation rate used to revalue past months' spending "
|
||||
"into today's £. The simulator runs in real GBP, so the headline "
|
||||
"`total_gbp` is the inflation-adjusted (real) figure. Default ≈ "
|
||||
"UK CPI 2024-26."
|
||||
),
|
||||
),
|
||||
) -> AnnualSpending:
|
||||
excluded = (
|
||||
[g.strip() for g in exclude.split(",") if g.strip()]
|
||||
|
|
@ -48,9 +57,10 @@ async def annual_spending(
|
|||
else list(DEFAULT_EXCLUDE_GROUPS)
|
||||
)
|
||||
try:
|
||||
spends, total_gbp = await fetch_trailing_spending(
|
||||
spends, nominal_gbp, real_gbp = await fetch_trailing_spending(
|
||||
months=months,
|
||||
exclude_groups=frozenset(excluded),
|
||||
annual_inflation_pct=inflation_pct,
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception("actualbudget unreachable")
|
||||
|
|
@ -93,8 +103,10 @@ async def annual_spending(
|
|||
window_start=monthly[0].month,
|
||||
window_end=monthly[-1].month,
|
||||
excluded_groups=excluded,
|
||||
total_gbp=total_gbp,
|
||||
total_gbp=real_gbp,
|
||||
nominal_total_gbp=nominal_gbp,
|
||||
raw_total_gbp=raw_total_gbp,
|
||||
inflation_pct=Decimal(str(inflation_pct)),
|
||||
by_group_total_gbp=by_group_gbp,
|
||||
monthly=monthly,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -94,8 +94,10 @@ export interface AnnualSpending {
|
|||
window_start: string;
|
||||
window_end: string;
|
||||
excluded_groups: string[];
|
||||
total_gbp: string;
|
||||
raw_total_gbp: string;
|
||||
inflation_pct: string;
|
||||
total_gbp: string; // real, after exclusions (the autofill default)
|
||||
nominal_total_gbp: string; // not adjusted, after exclusions
|
||||
raw_total_gbp: string; // nominal, before exclusions
|
||||
by_group_total_gbp: Record<string, string>;
|
||||
monthly: Array<{
|
||||
month: string;
|
||||
|
|
|
|||
|
|
@ -135,7 +135,9 @@ function buildFan(yearly: ProjectionPoint[], showWithdrawal: boolean): EChartsOp
|
|||
}
|
||||
|
||||
return {
|
||||
grid: { left: 60, right: showWithdrawal ? 70 : 24, top: 30, bottom: 40 },
|
||||
// Grid: leave room above for the legend (top:48) and below for the
|
||||
// x-axis name label so neither collides with the chart area.
|
||||
grid: { left: 60, right: showWithdrawal ? 70 : 24, top: 48, bottom: 56 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params) => {
|
||||
|
|
@ -157,7 +159,16 @@ function buildFan(yearly: ProjectionPoint[], showWithdrawal: boolean): EChartsOp
|
|||
},
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
// Above the chart, centred — out of the way of the x-axis label.
|
||||
// `type: 'scroll'` lets ECharts paginate items on narrow viewports
|
||||
// instead of wrapping them into the chart area.
|
||||
top: 8,
|
||||
left: 'center',
|
||||
type: 'scroll',
|
||||
itemGap: 18,
|
||||
itemWidth: 14,
|
||||
itemHeight: 10,
|
||||
textStyle: { fontSize: 11, color: '#475569' },
|
||||
data: [
|
||||
'median',
|
||||
'p10',
|
||||
|
|
@ -171,7 +182,8 @@ function buildFan(yearly: ProjectionPoint[], showWithdrawal: boolean): EChartsOp
|
|||
data: years,
|
||||
name: 'years from now',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 24,
|
||||
nameGap: 28,
|
||||
nameTextStyle: { color: '#64748b' },
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -98,38 +98,57 @@ const DEFAULTS: SimulateRequest = {
|
|||
|
||||
export function WhatIf() {
|
||||
const [form, setForm] = useState<SimulateRequest>(DEFAULTS);
|
||||
const [nwAutoFilled, setNwAutoFilled] = useState(false);
|
||||
const [spendingAutoFilled, setSpendingAutoFilled] = useState(false);
|
||||
// Track which anchor numbers the user has *manually* edited. Until
|
||||
// they touch a field, we keep mirroring whatever the backend says is
|
||||
// current — so opening /what-if a week later picks up fresh net
|
||||
// worth + spending without forcing a full refresh. Once the user
|
||||
// types, we stop clobbering. The ↻ button next to each input flips
|
||||
// the flag back off so the user can re-snap to live data on demand.
|
||||
const [nwUserEdited, setNwUserEdited] = useState(false);
|
||||
const [spendingUserEdited, setSpendingUserEdited] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Pre-fill NW seed from the latest Wealthfolio snapshot the first
|
||||
// time it loads, so opening /what-if always starts from real numbers.
|
||||
// The user can still edit; we won't clobber their input on later refetches.
|
||||
const nw = useQuery({ queryKey: ['networth', 'current'], queryFn: api.networth.current });
|
||||
// Always-fresh queries: refetchOnMount + refetchOnWindowFocus mean
|
||||
// the page reflects the latest snapshot whenever the tab regains
|
||||
// focus. staleTime=0 ensures every mount triggers a network fetch.
|
||||
const nw = useQuery({
|
||||
queryKey: ['networth', 'current'],
|
||||
queryFn: api.networth.current,
|
||||
staleTime: 0,
|
||||
refetchOnMount: 'always',
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (nwAutoFilled || !nw.data || nw.data.accounts.length === 0) return;
|
||||
if (nwUserEdited || !nw.data || nw.data.accounts.length === 0) return;
|
||||
const rounded = String(Math.round(Number(nw.data.total_gbp)));
|
||||
setForm((f) => ({ ...f, nw_seed_gbp: rounded }));
|
||||
setNwAutoFilled(true);
|
||||
}, [nw.data, nwAutoFilled]);
|
||||
setForm((f) => (f.nw_seed_gbp === rounded ? f : { ...f, nw_seed_gbp: rounded }));
|
||||
}, [nw.data, nwUserEdited]);
|
||||
|
||||
// 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,
|
||||
staleTime: 0,
|
||||
refetchOnMount: 'always',
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (spendingAutoFilled || !spending.data) return;
|
||||
if (spendingUserEdited || !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 rounded = String(Math.round(total));
|
||||
setForm((f) => (f.spending_gbp === rounded ? f : { ...f, spending_gbp: rounded }));
|
||||
}, [spending.data, spendingUserEdited]);
|
||||
|
||||
const resetNw = () => {
|
||||
setNwUserEdited(false);
|
||||
void nw.refetch();
|
||||
};
|
||||
const resetSpending = () => {
|
||||
setSpendingUserEdited(false);
|
||||
void spending.refetch();
|
||||
};
|
||||
|
||||
const sim = useMutation({
|
||||
mutationFn: (req: SimulateRequest) => api.simulate(req),
|
||||
|
|
@ -192,7 +211,24 @@ 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} spending={spending.data} />
|
||||
<AnchorNumbers
|
||||
form={form}
|
||||
update={update}
|
||||
spending={spending.data}
|
||||
nwSnapshotDate={nw.data?.snapshot_date}
|
||||
onNwChange={(v) => {
|
||||
setNwUserEdited(true);
|
||||
update('nw_seed_gbp', v);
|
||||
}}
|
||||
onSpendingChange={(v) => {
|
||||
setSpendingUserEdited(true);
|
||||
update('spending_gbp', v);
|
||||
}}
|
||||
nwUserEdited={nwUserEdited}
|
||||
spendingUserEdited={spendingUserEdited}
|
||||
onResetNw={resetNw}
|
||||
onResetSpending={resetSpending}
|
||||
/>
|
||||
|
||||
<PlanCard form={form} update={update} strategy={strategy} />
|
||||
|
||||
|
|
@ -259,28 +295,58 @@ function AnchorNumbers({
|
|||
form,
|
||||
update,
|
||||
spending,
|
||||
nwSnapshotDate,
|
||||
onNwChange,
|
||||
onSpendingChange,
|
||||
nwUserEdited,
|
||||
spendingUserEdited,
|
||||
onResetNw,
|
||||
onResetSpending,
|
||||
}: {
|
||||
form: SimulateRequest;
|
||||
update: <K extends keyof SimulateRequest>(k: K, v: SimulateRequest[K]) => void;
|
||||
spending: AnnualSpending | undefined;
|
||||
nwSnapshotDate: string | undefined;
|
||||
onNwChange: (v: string) => void;
|
||||
onSpendingChange: (v: string) => void;
|
||||
nwUserEdited: boolean;
|
||||
spendingUserEdited: boolean;
|
||||
onResetNw: () => void;
|
||||
onResetSpending: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div className="grid grid-cols-[1.4fr_1.2fr_0.7fr] gap-3">
|
||||
<BigNumber
|
||||
label="NW seed"
|
||||
prefix="£"
|
||||
value={form.nw_seed_gbp}
|
||||
onChange={(v) => update('nw_seed_gbp', v)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<BigNumber
|
||||
label="NW seed"
|
||||
prefix="£"
|
||||
value={form.nw_seed_gbp}
|
||||
onChange={onNwChange}
|
||||
trailing={nwUserEdited ? (
|
||||
<ResetButton title="Snap back to live Wealthfolio NW" onClick={onResetNw} />
|
||||
) : undefined}
|
||||
/>
|
||||
{nwSnapshotDate && (
|
||||
<p className="mt-1 text-[10px] text-slate-500 leading-tight">
|
||||
wealthfolio · as of {nwSnapshotDate}
|
||||
{nwUserEdited && <span className="text-amber-700"> · edited</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<BigNumber
|
||||
label="Annual spending"
|
||||
prefix="£"
|
||||
value={form.spending_gbp}
|
||||
onChange={(v) => update('spending_gbp', v)}
|
||||
onChange={onSpendingChange}
|
||||
trailing={spendingUserEdited ? (
|
||||
<ResetButton title="Snap back to live budget total" onClick={onResetSpending} />
|
||||
) : undefined}
|
||||
/>
|
||||
{spending && <SpendingProvenance data={spending} />}
|
||||
{spending && (
|
||||
<SpendingProvenance data={spending} edited={spendingUserEdited} />
|
||||
)}
|
||||
</div>
|
||||
<BigNumber
|
||||
label="Horizon"
|
||||
|
|
@ -294,15 +360,35 @@ function AnchorNumbers({
|
|||
);
|
||||
}
|
||||
|
||||
function SpendingProvenance({ data }: { data: AnnualSpending }) {
|
||||
function ResetButton({ title, onClick }: { title: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={onClick}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-slate-400 hover:text-slate-900 hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||
>
|
||||
<span className="text-xs leading-none">↻</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SpendingProvenance({
|
||||
data,
|
||||
edited,
|
||||
}: {
|
||||
data: AnnualSpending;
|
||||
edited: boolean;
|
||||
}) {
|
||||
const inflPct = (Number(data.inflation_pct) * 100).toFixed(1);
|
||||
return (
|
||||
<p className="mt-1 text-[10px] text-slate-500 leading-tight">
|
||||
from budget · {data.window_start}→{data.window_end}
|
||||
budget · {data.window_start}→{data.window_end} · real £ (+{inflPct}%/yr)
|
||||
{data.excluded_groups.length > 0 && (
|
||||
<>
|
||||
{' '}· excl. {data.excluded_groups.join(', ')}
|
||||
</>
|
||||
<> · excl. {data.excluded_groups.join(', ')}</>
|
||||
)}
|
||||
{edited && <span className="text-amber-700"> · edited</span>}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
|
@ -545,6 +631,7 @@ function BigNumber({
|
|||
prefix,
|
||||
suffix,
|
||||
step = 1,
|
||||
trailing,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
|
|
@ -552,12 +639,16 @@ function BigNumber({
|
|||
prefix?: string;
|
||||
suffix?: string;
|
||||
step?: number;
|
||||
trailing?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="block min-w-0">
|
||||
<span className="text-[11px] uppercase tracking-wide text-slate-500 font-medium">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[11px] uppercase tracking-wide text-slate-500 font-medium">
|
||||
{label}
|
||||
</span>
|
||||
{trailing}
|
||||
</div>
|
||||
<div className="mt-1 relative">
|
||||
{prefix && (
|
||||
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 text-base pointer-events-none">
|
||||
|
|
|
|||
|
|
@ -100,15 +100,44 @@ async def test_fetch_trailing_spending_excludes_groups(respx_mock: respx.MockRou
|
|||
}
|
||||
}))
|
||||
|
||||
spends, total_gbp = await fetch_trailing_spending(
|
||||
spends, nominal_gbp, real_gbp = await fetch_trailing_spending(
|
||||
months=2,
|
||||
exclude_groups=frozenset(["Investments and Savings"]),
|
||||
annual_inflation_pct=0.0, # no adjustment for the simple assertion
|
||||
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")
|
||||
assert nominal_gbp == Decimal("30.00")
|
||||
assert real_gbp == Decimal("30.00") # 0% inflation → same number
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_trailing_spending_inflation_adjusts(respx_mock: respx.MockRouter) -> None:
|
||||
"""At 12% annual inflation, a £10 expense from 12 months ago is
|
||||
worth £11.20 in today's £. Sanity-check the compounding via the
|
||||
oldest month of a 12-month window (today=2025-04 → 2024-04 is 12mo
|
||||
old)."""
|
||||
# Only one month available in the window.
|
||||
respx_mock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months").mock(
|
||||
return_value=httpx.Response(200, json={"data": ["2024-04"]}))
|
||||
respx_mock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months/2024-04").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"data": {"categoryGroups": [
|
||||
{"name": "Usual Expenses", "is_income": False,
|
||||
"categories": [{"spent": -1000}]}, # £10
|
||||
]}
|
||||
}))
|
||||
_, nominal_gbp, real_gbp = await fetch_trailing_spending(
|
||||
months=12,
|
||||
exclude_groups=frozenset(),
|
||||
annual_inflation_pct=0.12,
|
||||
client_factory=_client(),
|
||||
today=date(2025, 4, 15),
|
||||
)
|
||||
assert nominal_gbp == Decimal("10.00")
|
||||
assert real_gbp == Decimal("11.20")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -126,11 +155,12 @@ async def test_fetch_trailing_spending_drops_unavailable_months(
|
|||
"categories": [{"spent": -5000}]},
|
||||
]}
|
||||
}))
|
||||
spends, total_gbp = await fetch_trailing_spending(
|
||||
spends, nominal_gbp, _real_gbp = await fetch_trailing_spending(
|
||||
months=3,
|
||||
exclude_groups=frozenset(),
|
||||
annual_inflation_pct=0.0,
|
||||
client_factory=_client(),
|
||||
today=date(2025, 3, 15),
|
||||
)
|
||||
assert [ms.month for ms in spends] == ["2025-02"]
|
||||
assert total_gbp == Decimal("50.00")
|
||||
assert nominal_gbp == Decimal("50.00")
|
||||
|
|
|
|||
|
|
@ -69,18 +69,20 @@ async def test_spending_annual_excludes_investments_by_default(client: AsyncClie
|
|||
_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")
|
||||
resp = await client.get("/spending/annual?months=2&inflation_pct=0")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
# Usual: 3000 + 4000 = 7000 pence = £70
|
||||
# 0% inflation → real == nominal. Usual: 3000 + 4000 = 7000p = £70
|
||||
assert Decimal(body["total_gbp"]) == Decimal("70.00")
|
||||
# Raw includes investments: 7000 + 60000 = 67000 pence = £670
|
||||
assert Decimal(body["nominal_total_gbp"]) == Decimal("70.00")
|
||||
# Raw includes investments: 7000 + 60000 = 67000p = £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"
|
||||
assert Decimal(body["inflation_pct"]) == Decimal("0")
|
||||
|
||||
|
||||
async def test_spending_annual_custom_exclude(client: AsyncClient) -> None:
|
||||
|
|
@ -93,7 +95,9 @@ async def test_spending_annual_custom_exclude(client: AsyncClient) -> None:
|
|||
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")
|
||||
resp = await client.get(
|
||||
"/spending/annual?months=2&exclude=Usual%20Expenses&inflation_pct=0"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
# Excluding "Usual Expenses" leaves only Investments: £600
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue