mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 14:51: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>
130 lines
4.6 KiB
Ruby
130 lines
4.6 KiB
Ruby
require "test_helper"
|
|
|
|
class BudgetsControllerTest < ActionDispatch::IntegrationTest
|
|
setup do
|
|
@user = users(:family_admin)
|
|
sign_in @user
|
|
ensure_tailwind_build
|
|
end
|
|
|
|
test "index redirects to the current month budget" do
|
|
get budgets_url
|
|
|
|
assert_redirected_to budget_path(Budget.date_to_param(Date.current))
|
|
end
|
|
|
|
test "show renders the budget page" do
|
|
get budget_url(Budget.date_to_param(Date.current))
|
|
|
|
assert_response :success
|
|
end
|
|
|
|
test "breadcrumbs include the Plan hub for preview users" do
|
|
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
|
|
|
|
get budget_url(Budget.date_to_param(Date.current))
|
|
|
|
assert_response :success
|
|
assert_select "a[href=?]", plan_path, minimum: 1
|
|
end
|
|
|
|
test "renders no Plan links without preview features" do
|
|
get budget_url(Budget.date_to_param(Date.current))
|
|
|
|
assert_response :success
|
|
assert_select "a[href=?]", plan_path, count: 0
|
|
assert_select "a[href=?]", budgets_path, minimum: 1
|
|
end
|
|
# --- Lot A3: cash on hand ---
|
|
|
|
test "the cash panel is hidden without preview access" do
|
|
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false))
|
|
|
|
get budget_url(Budget.date_to_param(Date.current.beginning_of_month))
|
|
|
|
assert_response :success
|
|
assert_no_match I18n.t("budgets.available_cash.heading"), response.body
|
|
end
|
|
|
|
test "the cash panel shows what goals have already claimed" do
|
|
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
|
|
|
|
get budget_url(Budget.date_to_param(Date.current.beginning_of_month))
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("budgets.available_cash.heading"), response.body
|
|
assert_match I18n.t("budgets.available_cash.free"), response.body
|
|
end
|
|
end
|
|
|
|
class BudgetsControllerSharingTest < ActionDispatch::IntegrationTest
|
|
setup do
|
|
@family = families(:empty)
|
|
@family.update!(personal_budgets: true)
|
|
@owner = users(:josh)
|
|
@viewer = users(:ann)
|
|
@date = Date.current.beginning_of_month
|
|
end
|
|
|
|
test "household budget is viewable and editable by any family member" do
|
|
Budget.find_or_bootstrap(@family, start_date: @date, user: @owner, household: true)
|
|
sign_in @viewer
|
|
|
|
get budget_url(Budget.date_to_param(@date), params: { owner: "household" })
|
|
assert_response :success
|
|
|
|
patch budget_url(Budget.date_to_param(@date), params: { owner: "household" }),
|
|
params: { budget: { budgeted_spending: 1000, expected_income: 2000 } }
|
|
assert_response :redirect
|
|
end
|
|
|
|
test "household tab is unreachable once household_budget_enabled is off, falling back to the viewer's own budget" do
|
|
@family.update!(household_budget_enabled: false)
|
|
sign_in @viewer
|
|
|
|
get budget_url(Budget.date_to_param(@date), params: { owner: "household" })
|
|
|
|
assert_response :success
|
|
assert_equal @viewer.id, Budget.find_by(family: @family, start_date: @date).user_id
|
|
end
|
|
|
|
test "a member without a BudgetShare cannot view another member's personal budget" do
|
|
Budget.find_or_bootstrap(@family, start_date: @date, user: @owner)
|
|
sign_in @viewer
|
|
|
|
get budget_url(Budget.date_to_param(@date), params: { owner: @owner.id })
|
|
|
|
# Falls back to the viewer's own budget rather than the owner's.
|
|
assert_response :success
|
|
assert Budget.exists?(family: @family, start_date: @date, user_id: @viewer.id)
|
|
end
|
|
|
|
test "a read_only BudgetShare lets the viewer see but not edit the owner's budget" do
|
|
Budget.find_or_bootstrap(@family, start_date: @date, user: @owner)
|
|
BudgetShare.create!(owner: @owner, viewer: @viewer, permission: "read_only")
|
|
sign_in @viewer
|
|
|
|
get budget_url(Budget.date_to_param(@date), params: { owner: @owner.id })
|
|
assert_response :success
|
|
|
|
get edit_budget_url(Budget.date_to_param(@date), params: { owner: @owner.id })
|
|
assert_response :not_found
|
|
|
|
patch budget_url(Budget.date_to_param(@date), params: { owner: @owner.id }),
|
|
params: { budget: { budgeted_spending: 1000, expected_income: 2000 } }
|
|
assert_response :not_found
|
|
end
|
|
|
|
test "a read_write BudgetShare lets the viewer edit the owner's budget" do
|
|
Budget.find_or_bootstrap(@family, start_date: @date, user: @owner)
|
|
BudgetShare.create!(owner: @owner, viewer: @viewer, permission: "read_write")
|
|
sign_in @viewer
|
|
|
|
patch budget_url(Budget.date_to_param(@date), params: { owner: @owner.id }),
|
|
params: { budget: { budgeted_spending: 1000, expected_income: 2000 } }
|
|
|
|
assert_redirected_to budget_budget_categories_url(Budget.date_to_param(@date), owner: @owner.id)
|
|
assert_equal 1000, Budget.find_by(family: @family, user: @owner).budgeted_spending.to_i
|
|
end
|
|
end
|