Files
sure/app/views/budgets
buzzromainandClaude Opus 5 03b783b139 feat(budgets): show what is actually free, beside the plan (#3179)
* feat(budgets): show what is actually free, beside the plan

The budget has never consulted an account balance. `budgeted_spending` and
`expected_income` are numbers the user typed, and every figure on the page
derives from them — a forecast, checked against reality after the fact. It
answers "what did I plan to spend" and cannot answer "what do I actually have".

Three methods answer the second question: `available_cash`,
`earmarked_for_goals`, and `free_cash`. They appear in their own panel, below
the plan and outside it.

That separation is the whole design, not a layout choice. Folding cash into the
allocation arithmetic turns the budget into a different product — YNAB's, where
you distribute money you hold rather than money you expect — and a page showing
"expected income 3,000" beside "really free 1,600" leaves the reader unsure
which number drives the split. `allocated_spending` and `available_to_allocate`
keep their exact meaning; a test asserts none of them moves.

**The subtraction has to be over the same accounts as the sum.**
`Goal::FUNDABLE_ACCOUNT_TYPES` includes Investment, so a goal can be backed by
a brokerage account that `available_cash` never counted. Subtracting that
earmark would show a "really free" figure too low, or negative, with nothing on
the page to explain it. `earmarked_for_goals` is therefore restricted to
`cash_accounts`, and `Goal#backing_within` exists to ask that question.

It reads through the shared pool rather than summing `allocated_amount`,
because a whole-account link reserves no fixed slice: summed naively it counts
as zero while actually claiming the remainder.

Scoped like `#transactions` — a personal budget sees its owner's accounts, the
household one what the viewer can see. A figure labelled "available" has to
mean available to the person reading it.

Behind the preview flag, because goals are: a panel that subtracts what they
claim, and links to them, would otherwise point at a page the reader cannot
open and explain a subtraction they cannot inspect.

bin/rails test: 7083 runs, 28500 assertions, 0 failures. RuboCop, erb_lint and
Brakeman clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(budgets): make the cash panel work in more than one currency

Review on #3179. Three defects, all of them mine, all in the same seam.

`ExchangeRate.find_rate` does not exist. Every multi-currency family
opening the budget page hit a NoMethodError before the panel rendered.
`find_or_fetch_rate` is the lookup the rest of the app uses.

`earmarked_for_goals` summed each goal's backing in the goal's own
currency and subtracted it from an `available_cash` that had been
converted. A fully earmarked EUR 1,000 account in a USD budget read as
1,200 available, 1,000 earmarked and 200 free — when none of it is free.

The French keys landed under `budget_categories` instead of `budgets`, so
the partial's `t(".heading")` found nothing and French readers got the
English fallback.

A missing rate leaves the amount as it stands rather than raising: a panel
wrong by the spread beats the whole budget page failing to render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 21:17:51 +02:00
..