Files
sure/test/models/budget_test.rb
Guillem Arias Fauste 9d3879a859 feat(plan): unify Budgets and Goals under a single Plan tab (#2687)
* feat(plan): unify Budgets and Goals under a single Plan tab

Preview users get one "Plan" nav entry (compass icon) in place of the
separate Budgets and preview-gated Goals items. It fronts a new /plan
hub with two summary cards — this month's budget (spent vs budgeted,
days left, top categories) and active goals (total saved vs targets,
behind/pending counts, per-goal rows) — each drilling into the existing
/budgets and /goals pages, whose breadcrumbs now start Home > Plan.

The two features share a home, not a model: no schema changes, no URL
changes. Users without preview features keep exactly the pre-Plan nav
(Budgets entry, Goals hidden), and /plan falls through to /budgets for
them.

Supporting changes:
- Goal.active_prepared_for: index-style sorted active goals with the
  family-wide pooled-allocations + market-flows injection reused
- Goal::FUNDABLE_ACCOUNT_TYPES and Goal::ACTIVE_DISPLAY_STATUS_RANK
  extracted from GoalsController
- Budget#days_remaining (same day math as suggested_daily_spending)
- Breadcrumbable#plan_breadcrumb_prefix for the conditional Plan crumb
- New BudgetsController web tests (previously untested) + Plans tests

* fix(plan): address review feedback on the Plan hub

- Keep the "All goals" footer link rendered when the family has only
  completed/archived goals — the hub is a preview user's only route to
  the goals index now that the Goals nav entry is gone (Codex P2)
- Replace the two hand-rolled footer button-links with DS::Link
  (variant secondary, full_width, right icon) per DS Drift Patrol
- Fix DS::Link template comparing icon_position against the string
  "right" — the initializer symbolizes it, so right-positioned icons
  never rendered on links (DS::Button already compared symbols);
  existing callers passing icon_position now get the layout they asked for
- Clamp progress-bar percentages to 0..100 instead of capping only the
  upper bound (CodeRabbit)

* refactor(plan): single source of truth for goal loading, sorting, and counts

Addresses jjmata's draft review notes:

- GoalsController#index now builds on the shared Goal loaders instead
  of hand-rolling its own copy: Goal.prepared_for (preloads + family-
  wide backing-math injection, scope-able) and Goal.active_display_sort
  carry the algorithm once; active_prepared_for composes them for the
  hub. The controller-side constant alias is gone
- One definition of "behind pace": Goal#behind_pace? (excludes paused —
  pausing stops the pace clock on purpose). Both the Plan hub summary
  and GoalsController#kpi_payload's behind/needs-this-month figures use
  it, so adjacent pages can't disagree. While there, the kpi on-track
  numerator also excludes paused goals — it was counted against a
  paused-excluding denominator, so the "X of Y" fraction could exceed
  its own total
- BudgetCategory#suggested_daily_spending calls Budget#days_remaining
  instead of keeping an inline copy of the day math
- Per the fat-model convention, the hub's aggregation moved off the
  controller: Budget#top_spending_categories(limit:) and
  Goal.summary_for(goals, currency:)

* fix(plan): move Edit budget/New goal into their own cards

Both actions lived in the hub's shared page header, unlinked to either
card and, on mobile, wrapping above all content before any real data
appeared. Each now lives in its own card's header instead: Edit budget
as a compact icon-only control next to the status pill (only when a
budget exists — the uninitialized state already has its own "Set up"
CTA), New goal as a small outline button next to the goals count (only
once there's a goal to sit beside; the empty state keeps its own CTA).

Also swaps the edit icon from "pencil" to "square-pen" — at the sizes
these header controls render, lucide's plain pencil is a thin diagonal
stroke that reads noticeably smaller than a neighboring bold glyph like
"plus", even in the same size box. square-pen carries more visual mass
and reads clearly at the same footprint.

* fix(plan): match established DS precedent for the card header actions

Edit budget was a bare icon-only button; verified against the app's
own precedent for this exact action (app/views/budgets/_budget_donut.html.erb,
the budget card already shipped on /budgets) and it's a labeled
secondary link with a trailing pencil, not icon-only and not a
three-dot menu. Matched that: DS::Link, variant secondary, size sm,
icon right. New goal gets the same treatment for consistency between
the two cards' header actions, rather than the full-page-scoped
"primary" weight goals/index.html.erb uses for its own create button —
that's calibrated for a whole page's sole CTA, not a compact card.

Adding a labeled button (wider than the bare icon this replaces)
crowded the header row on mobile enough to wrap "This month" onto two
lines and truncate the "· July 2026" meta away entirely. Header rows
now wrap as a whole (flex-wrap) with the title pinned (shrink-0) so
the action cluster drops to its own line instead of squeezing the
title and meta text.

Also drops the hub's footer note ("Budgets cap your spend; goals track
what you're saving toward...") — redundant with the subtitle right
above the cards.

* fix(plan): lead the budget card header with status, not the edit action

On Track/Over/Warning is what a glance at the card wants first; Edit
budget is the secondary action. Swapped their order so status leads
and the edit control trails, gap-2 unchanged.

* fix(plan): put the status pill on the left, next to the title

Meant the left side of the card, not just left of the edit button. On
Track/Over/Warning now sits beside "This month · July 2026" in normal
flow; ml-auto carries only the Edit budget link, alone on the right —
matching the goals card's own left-meta/right-action split ("· 7
active" left, "New goal" right).

* fix(plan): lead Edit budget with its icon, matching same-shape precedent

Wrong axis on the earlier match: _budget_donut's trailing pencil labels
the VALUE itself ("$12,850 ✎"), not a static action. Our button's label
is a static "Edit budget", and that shape takes a leading icon
everywhere else it appears — the categories "Edit" on budgets/show.html.erb
(icon: settings-2) and "Edit split" in transactions/show.html.erb both
lead with their icon. Drops icon_position: :right so it defaults to
left, matching New goal's shape in the sibling card.

* fix(plan): use the divider token for row separators, not border-primary

Traced against the dashboard outflows list (pages/dashboard/_outflows_donut.html.erb),
which renders its row separators via shared/_ruler → border-divider
(border-tertiary: black/8%, white/10%). Our category and goal rows used
border-b border-primary instead (black/15%, white/30%) — 2-3x heavier
than the established row-separator weight elsewhere in the app. Swapped
both to border-divider.

* fix(plan): lift the duplicated card shell into DS::Card

Codex P1: _budget_card.html.erb and _goals_card.html.erb hand-rolled
the identical "bg-container rounded-xl shadow-border-xs p-5 flex
flex-col" shell twice, with no DS:: card primitive to reach for
instead. Extracted a minimal wrapper — content-only, no header/footer
slots — matching what both cards actually need right now; the roadmap
cards (envelopes #2153, retirement #2044) can adopt it too instead of
copying the class string a third time.

Verified pixel-identical in a browser: same classes, same DOM shape,
just rendered through the component.

* fix(plan): batch pace queries before sorting goals

Codex P2: active_display_sort calls goal.status per goal to build the
sort key; Goal#status reaches Goal#pace for any goal with a
target_date, which fired its own Entry.sum(:amount) query per goal.
The /plan hub renders only the first 5 of active_prepared_for's list,
but paid the full O(N) query cost sorting all of them.

Adds Goal.pace_for(family) (account_id => 90-day net inflow), grouped
in one query and injected via inject_backing_math! alongside the
existing pooled_allocations/market_flows pattern. #pace now sums from
that shared map instead of firing its own query — same math, same
90-day window, same exclusions, just computed once per family instead
of once per goal.
2026-08-01 08:51:08 +02:00

527 lines
17 KiB
Ruby

require "test_helper"
class BudgetTest < ActiveSupport::TestCase
setup do
@family = families(:empty)
end
test "budget_date_valid? allows going back 2 years even without entries" do
two_years_ago = 2.years.ago.beginning_of_month
assert Budget.budget_date_valid?(two_years_ago, family: @family)
end
test "budget_date_valid? allows going back to earliest entry date if more than 2 years ago" do
# Create an entry 3 years ago
old_account = Account.create!(
family: @family,
accountable: Depository.new,
name: "Old Account",
status: "active",
currency: "USD",
balance: 1000
)
old_entry = Entry.create!(
account: old_account,
entryable: Transaction.new(category: categories(:income)),
date: 3.years.ago,
name: "Old Transaction",
amount: 100,
currency: "USD"
)
# Should allow going back to the old entry date
assert Budget.budget_date_valid?(3.years.ago.beginning_of_month, family: @family)
end
test "budget_date_valid? does not allow dates before earliest entry or 2 years ago" do
# Create an entry 1 year ago
account = Account.create!(
family: @family,
accountable: Depository.new,
name: "Test Account",
status: "active",
currency: "USD",
balance: 500
)
Entry.create!(
account: account,
entryable: Transaction.new(category: categories(:income)),
date: 1.year.ago,
name: "Recent Transaction",
amount: 100,
currency: "USD"
)
# Should not allow going back more than 2 years
refute Budget.budget_date_valid?(3.years.ago.beginning_of_month, family: @family)
end
test "budget_date_valid? allows future dates up to 2 years ahead" do
travel_to Date.current.beginning_of_month do
assert Budget.budget_date_valid?(Date.current.beginning_of_month + 1.month, family: @family)
assert Budget.budget_date_valid?(Date.current.beginning_of_month + 2.years, family: @family)
end
end
test "budget_date_valid? does not allow future dates beyond 2 years ahead" do
travel_to Date.current.beginning_of_month do
refute Budget.budget_date_valid?(Date.current.beginning_of_month + 2.years + 1.month, family: @family)
end
end
test "budget_date_valid? for custom month start allows dates up to 2 years ahead" do
@family.update!(month_start_day: 15)
travel_to Date.current.beginning_of_month do
cap_start = @family.current_custom_month_period.start_date + 2.years
assert Budget.budget_date_valid?(cap_start, family: @family)
end
end
test "budget_date_valid? for custom month start does not allow dates beyond 2 years ahead" do
@family.update!(month_start_day: 15)
travel_to Date.current.beginning_of_month do
beyond_cap = @family.current_custom_month_period.start_date + 2.years + 1.month
refute Budget.budget_date_valid?(beyond_cap, family: @family)
end
end
test "previous_budget_param returns nil when date is too old" do
# Create a budget at the oldest allowed date
two_years_ago = 2.years.ago.beginning_of_month
budget = Budget.create!(
family: @family,
start_date: two_years_ago,
end_date: two_years_ago.end_of_month,
currency: "USD"
)
assert_nil budget.previous_budget_param
end
test "next_budget_param returns next month when current month budget is selected" do
travel_to Date.current.beginning_of_month do
budget = Budget.create!(
family: @family,
start_date: Date.current.beginning_of_month,
end_date: Date.current.end_of_month,
currency: "USD"
)
assert_equal Budget.date_to_param(Date.current.beginning_of_month + 1.month), budget.next_budget_param
end
end
test "next_budget_param returns nil at future cap" do
travel_to Date.current.beginning_of_month do
cap_start = Date.current.beginning_of_month + 2.years
budget = Budget.create!(
family: @family,
start_date: cap_start,
end_date: cap_start.end_of_month,
currency: "USD"
)
assert_nil budget.next_budget_param
end
end
test "next_budget_param returns nil at future cap for custom month start" do
@family.update!(month_start_day: 15)
travel_to Date.current.beginning_of_month do
cap_start = @family.current_custom_month_period.start_date + 2.years
budget = Budget.create!(
family: @family,
start_date: cap_start,
end_date: cap_start + 1.month - 1.day,
currency: "USD"
)
assert_nil budget.next_budget_param
end
end
test "actual_spending nets refunds against expenses in same category" do
family = families(:dylan_family)
budget = Budget.find_or_bootstrap(family, start_date: Date.current.beginning_of_month)
healthcare = Category.create!(
name: "Healthcare #{Time.now.to_f}",
family: family,
color: "#e74c3c"
)
budget.sync_budget_categories
budget_category = budget.budget_categories.find_by(category: healthcare)
budget_category.update!(budgeted_spending: 200)
account = accounts(:depository)
# Create a $500 expense
Entry.create!(
account: account,
entryable: Transaction.create!(category: healthcare),
date: Date.current,
name: "Doctor visit",
amount: 500,
currency: "USD"
)
# Create a $200 refund (negative amount = income classification in the SQL)
Entry.create!(
account: account,
entryable: Transaction.create!(category: healthcare),
date: Date.current,
name: "Insurance reimbursement",
amount: -200,
currency: "USD"
)
# Clear memoized values
budget = Budget.find(budget.id)
budget.sync_budget_categories
# Budget category should show net spending: $500 - $200 = $300
assert_equal 300, budget.budget_category_actual_spending(
budget.budget_categories.find_by(category: healthcare)
)
end
test "budget_category_actual_spending does not go below zero" do
family = families(:dylan_family)
budget = Budget.find_or_bootstrap(family, start_date: Date.current.beginning_of_month)
category = Category.create!(
name: "Returns Only #{Time.now.to_f}",
family: family,
color: "#3498db"
)
budget.sync_budget_categories
budget_category = budget.budget_categories.find_by(category: category)
budget_category.update!(budgeted_spending: 100)
account = accounts(:depository)
# Only a refund, no expense
Entry.create!(
account: account,
entryable: Transaction.create!(category: category),
date: Date.current,
name: "Full refund",
amount: -50,
currency: "USD"
)
budget = Budget.find(budget.id)
budget.sync_budget_categories
assert_equal 0, budget.budget_category_actual_spending(
budget.budget_categories.find_by(category: category)
)
end
test "to_donut_segments_json only includes top-level budget categories" do
family = @family
budget = Budget.find_or_bootstrap(family, start_date: Date.current.beginning_of_month)
budget.update!(budgeted_spending: 500, currency: family.currency)
parent_category = Category.create!(
name: "Transport #{Time.now.to_f}",
family: family,
color: "#6471eb"
)
child_category = Category.create!(
name: "Petrol #{Time.now.to_f}",
family: family,
parent: parent_category,
color: "#61c9ea"
)
standalone_category = Category.create!(
name: "Shopping #{Time.now.to_f}",
family: family,
color: "#df4e92"
)
budget.sync_budget_categories
parent_budget_category = budget.budget_categories.find_by!(category: parent_category)
child_budget_category = budget.budget_categories.find_by!(category: child_category)
standalone_budget_category = budget.budget_categories.find_by!(category: standalone_category)
parent_budget_category.update!(budgeted_spending: 150, currency: family.currency)
child_budget_category.update!(budgeted_spending: 50, currency: family.currency)
standalone_budget_category.update!(budgeted_spending: 100, currency: family.currency)
budget.stubs(:allocations_valid?).returns(true)
budget.stubs(:available_to_spend).returns(200)
budget.stubs(:budget_category_actual_spending).with(parent_budget_category).returns(63.11)
budget.stubs(:budget_category_actual_spending).with(standalone_budget_category).returns(25)
budget.stubs(:budget_category_actual_spending).with(budget.uncategorized_budget_category).returns(0)
segments = budget.to_donut_segments_json
segment_ids = segments.pluck(:id)
segments_by_id = segments.index_by { |segment| segment[:id] }
assert_equal 3, segments.size
assert_includes segment_ids, parent_budget_category.id
assert_includes segment_ids, standalone_budget_category.id
assert_includes segment_ids, "unused"
refute_includes segment_ids, child_budget_category.id
assert_equal 63.11, segments_by_id[parent_budget_category.id][:amount]
assert_equal 25, segments_by_id[standalone_budget_category.id][:amount]
assert_equal 200, segments_by_id["unused"][:amount]
end
test "to_donut_segments_json includes uncategorized spending" do
family = @family
account = Account.create!(
family: family,
accountable: Depository.new,
name: "Checking",
status: "active",
currency: "USD",
balance: 0
)
category = Category.create!(
name: "Groceries #{Time.now.to_f}",
family: family,
color: "#407706",
lucide_icon: "shopping-bag"
)
budget = Budget.create!(
family: family,
start_date: Date.current.beginning_of_month,
end_date: Date.current.end_of_month,
currency: "USD",
budgeted_spending: 100
)
BudgetCategory.create!(
budget: budget,
category: category,
budgeted_spending: 100,
currency: "USD"
)
Entry.create!(
account: account,
entryable: Transaction.create!(category: nil),
date: Date.current,
name: "Uncategorized donut spending",
amount: 125,
currency: "USD"
)
budget = Budget.find(budget.id)
uncategorized = budget.uncategorized_budget_category
segments = budget.to_donut_segments_json
uncategorized_segment = segments.find { |segment| segment[:id] == uncategorized.id }
assert_equal 125, budget.actual_spending
assert_equal 125, uncategorized.actual_spending
assert_not_nil uncategorized_segment
assert_equal 125, uncategorized_segment[:amount]
end
test "actual_spending subtracts uncategorized refunds" do
family = families(:dylan_family)
budget = Budget.find_or_bootstrap(family, start_date: Date.current.beginning_of_month)
account = accounts(:depository)
# Create an uncategorized expense
Entry.create!(
account: account,
entryable: Transaction.create!(category: nil),
date: Date.current,
name: "Uncategorized purchase",
amount: 400,
currency: "USD"
)
# Create an uncategorized refund
Entry.create!(
account: account,
entryable: Transaction.create!(category: nil),
date: Date.current,
name: "Uncategorized refund",
amount: -150,
currency: "USD"
)
budget = Budget.find(budget.id)
budget.sync_budget_categories
# The uncategorized refund should reduce overall actual_spending
# Other fixtures may contribute spending, so check that the net
# uncategorized amount (400 - 150 = 250) is reflected by comparing
# with and without the refund rather than asserting an exact total.
spending_with_refund = budget.actual_spending
# Remove the refund and check spending increases
Entry.find_by(name: "Uncategorized refund").destroy!
budget = Budget.find(budget.id)
spending_without_refund = budget.actual_spending
assert_equal 150, spending_without_refund - spending_with_refund
end
test "most_recent_initialized_budget returns latest initialized budget before this one" do
family = families(:dylan_family)
# Create an older initialized budget (2 months ago)
older_budget = Budget.create!(
family: family,
start_date: 2.months.ago.beginning_of_month,
end_date: 2.months.ago.end_of_month,
budgeted_spending: 3000,
expected_income: 5000,
currency: "USD"
)
# Create a middle uninitialized budget (1 month ago)
Budget.create!(
family: family,
start_date: 1.month.ago.beginning_of_month,
end_date: 1.month.ago.end_of_month,
currency: "USD"
)
current_budget = Budget.find_or_bootstrap(family, start_date: Date.current)
assert_equal older_budget, current_budget.most_recent_initialized_budget
end
test "most_recent_initialized_budget returns nil when none exist" do
family = families(:empty)
budget = Budget.create!(
family: family,
start_date: Date.current.beginning_of_month,
end_date: Date.current.end_of_month,
currency: "USD"
)
assert_nil budget.most_recent_initialized_budget
end
test "copy_from copies budgeted_spending expected_income and matching category budgets" do
family = families(:dylan_family)
# Use past months to avoid fixture conflict (fixture :one is at Date.current for dylan_family)
source_budget = Budget.find_or_bootstrap(family, start_date: 2.months.ago)
source_budget.update!(budgeted_spending: 4000, expected_income: 6000)
source_bc = source_budget.budget_categories.find_by(category: categories(:food_and_drink))
source_bc.update!(budgeted_spending: 500)
target_budget = Budget.find_or_bootstrap(family, start_date: 1.month.ago)
assert_nil target_budget.budgeted_spending
target_budget.copy_from!(source_budget)
target_budget.reload
assert_equal 4000, target_budget.budgeted_spending
assert_equal 6000, target_budget.expected_income
target_bc = target_budget.budget_categories.find_by(category: categories(:food_and_drink))
assert_equal 500, target_bc.budgeted_spending
end
test "copy_from skips categories that dont exist in target" do
family = families(:dylan_family)
source_budget = Budget.find_or_bootstrap(family, start_date: 2.months.ago)
source_budget.update!(budgeted_spending: 4000, expected_income: 6000)
# Create a category only in the source budget
temp_category = Category.create!(name: "Temp #{Time.now.to_f}", family: family, color: "#aaaaaa")
source_budget.budget_categories.create!(category: temp_category, budgeted_spending: 100, currency: "USD")
target_budget = Budget.find_or_bootstrap(family, start_date: 1.month.ago)
# Should not raise even though target doesn't have the temp category
assert_nothing_raised { target_budget.copy_from!(source_budget) }
assert_equal 4000, target_budget.reload.budgeted_spending
end
test "copy_from leaves new categories at zero" do
family = families(:dylan_family)
source_budget = Budget.find_or_bootstrap(family, start_date: 2.months.ago)
source_budget.update!(budgeted_spending: 4000, expected_income: 6000)
target_budget = Budget.find_or_bootstrap(family, start_date: 1.month.ago)
# Add a new category only to the target
new_category = Category.create!(name: "New #{Time.now.to_f}", family: family, color: "#bbbbbb")
target_budget.budget_categories.create!(category: new_category, budgeted_spending: 0, currency: "USD")
target_budget.copy_from!(source_budget)
new_bc = target_budget.budget_categories.find_by(category: new_category)
assert_equal 0, new_bc.budgeted_spending
end
test "previous_budget_param returns param when date is valid" do
budget = Budget.create!(
family: @family,
start_date: Date.current.beginning_of_month,
end_date: Date.current.end_of_month,
currency: "USD"
)
assert_not_nil budget.previous_budget_param
end
test "uncategorized budget category actual spending reflects uncategorized transactions" do
family = families(:dylan_family)
budget = Budget.find_or_bootstrap(family, start_date: Date.current.beginning_of_month)
account = accounts(:depository)
# Create an uncategorized expense
Entry.create!(
account: account,
entryable: Transaction.create!(category: nil),
date: Date.current,
name: "Uncategorized lunch",
amount: 75,
currency: "USD"
)
budget = Budget.find(budget.id)
budget.sync_budget_categories
uncategorized_bc = budget.uncategorized_budget_category
spending = budget.budget_category_actual_spending(uncategorized_bc)
# Must be > 0 — the nil-key collision between Uncategorized and
# Other Investments synthetic categories previously caused this to return 0
assert spending >= 75, "Uncategorized actual spending should include the $75 transaction, got #{spending}"
end
test "days_remaining counts today through the end of the period" do
budget = budgets(:one)
travel_to budget.start_date do
assert_equal (budget.end_date - budget.start_date).to_i + 1, budget.days_remaining
end
travel_to budget.end_date do
assert_equal 1, budget.days_remaining
end
travel_to budget.end_date + 1.day do
assert_equal 0, budget.days_remaining
end
end
end