mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 00:24:15 +00:00
* 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>
93 lines
3.7 KiB
ERB
93 lines
3.7 KiB
ERB
<div class="pb-6 lg:pb-12">
|
|
<%= render "budgets/budget_header",
|
|
budget: @budget,
|
|
previous_budget: @previous_budget,
|
|
next_budget: @next_budget,
|
|
latest_budget: @latest_budget %>
|
|
|
|
<div class="space-y-4">
|
|
<%# Top Section: Donut and Summary side by side %>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<%# Budget Donut %>
|
|
<div class="min-h-[300px] bg-container rounded-xl shadow-border-xs p-8">
|
|
<% if !@budget.initialized? && @source_budget.present? && @editable %>
|
|
<%= render "budgets/copy_previous_prompt", budget: @budget, source_budget: @source_budget %>
|
|
<% elsif @budget.initialized? && @budget.available_to_allocate.negative? %>
|
|
<%= render "budgets/over_allocation_warning", budget: @budget, editable: @editable %>
|
|
<% else %>
|
|
<%= render "budgets/budget_donut", budget: @budget, editable: @editable %>
|
|
<% end %>
|
|
</div>
|
|
|
|
<%# Actuals Summary %>
|
|
<% if @budget.initialized? && @budget.available_to_allocate.positive? %>
|
|
<%= render DS::Tabs.new(active_tab: params[:tab].presence || "budgeted") do |tabs| %>
|
|
<% tabs.with_nav do |nav| %>
|
|
<% nav.with_btn(id: "budgeted", label: t("budgets.show.tabs.budgeted")) %>
|
|
<% nav.with_btn(id: "actuals", label: t("budgets.show.tabs.actual")) %>
|
|
<% end %>
|
|
|
|
<% tabs.with_panel(tab_id: "budgeted") do %>
|
|
<div class="bg-container rounded-xl shadow-border-xs">
|
|
<%= render "budgets/budgeted_summary", budget: @budget %>
|
|
</div>
|
|
<% end %>
|
|
|
|
<% tabs.with_panel(tab_id: "actuals") do %>
|
|
<div class="bg-container rounded-xl shadow-border-xs">
|
|
<%= render "budgets/actuals_summary", budget: @budget %>
|
|
</div>
|
|
<% end %>
|
|
<% end %>
|
|
<% else %>
|
|
<div class="bg-container rounded-xl shadow-border-xs">
|
|
<%= render "budgets/actuals_summary", budget: @budget %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
|
|
<%# Goals live behind the preview flag, so a panel that subtracts what they
|
|
claim — and links to them — must too, or it points at a page the reader
|
|
cannot reach and explains a subtraction they have no way to inspect. %>
|
|
<% if preview_features_enabled? %>
|
|
<%= render "budgets/available_cash", budget: @budget %>
|
|
<% end %>
|
|
|
|
<%# Bottom Section: Categories full width %>
|
|
<% has_over_budget = budget_has_over_budget?(@budget) %>
|
|
<%= content_tag :div,
|
|
class: "w-full bg-container rounded-xl shadow-border-xs p-4",
|
|
data: (has_over_budget ? { controller: "budget-filter" } : {}) do %>
|
|
<div class="flex items-center mb-4 flex-nowrap justify-between">
|
|
<h2 class="text-lg font-medium shrink-0">
|
|
<%= t("budgets.show.categories.title") %>
|
|
</h2>
|
|
|
|
<% if has_over_budget %>
|
|
<div class="hidden lg:flex flex-1 min-w-0 px-1">
|
|
<%= render "budgets/budget_tabs" %>
|
|
</div>
|
|
<% end %>
|
|
|
|
<div class="<%= has_over_budget ? "shrink-0 flex justify-end whitespace-nowrap" : "ml-auto" %>">
|
|
<% if @budget.initialized? && @editable %>
|
|
<%= render DS::Link.new(
|
|
text: t("budgets.show.categories.edit"),
|
|
variant: "secondary",
|
|
icon: "settings-2",
|
|
href: budget_budget_categories_path(@budget, **budget_owner_query)
|
|
) %>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<% if has_over_budget %>
|
|
<div class="flex lg:hidden flex-1 min-w-0 mb-4">
|
|
<%= render "budgets/budget_tabs" %>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= render "budgets/budget_categories", budget: @budget %>
|
|
<% end %>
|
|
</div>
|
|
</div>
|