Add budget rollover: copy from previous month (#1100)

* Add budget rollover: copy from previous month

When navigating to an uninitialized budget month, show a prompt
offering to copy amounts from the most recent initialized budget.
Copies budgeted_spending, expected_income, and all matching category
allocations. Also fixes over-allocation warning showing on uninitialized
budgets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Redirect copy_previous to categories wizard for review

Matches the normal budget setup flow (edit → categories → show)
so users can review/tweak copied allocations before confirming.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Address code review: eager-load categories, guard against overwrite

- Add .includes(:budget_categories) to most_recent_initialized_budget
  to avoid N+1 when copy_from! iterates source categories
- Guard copy_previous action against overwriting already-initialized
  budgets (prevents crafted POST from clobbering existing data)
- Add i18n key for already_initialized flash message

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add invariant guards to copy_from! for defensive safety

Validate that source budget belongs to the same family and precedes
the target budget before copying. Protects against misuse from
other callers beyond the controller.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix button overflow on small screens in copy previous prompt

Stack buttons vertically on mobile, side-by-side on sm+ breakpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lolimmlost
2026-03-03 12:13:59 -08:00
committed by GitHub
parent 69f4e47d68
commit 158e18cd05
7 changed files with 182 additions and 2 deletions

View File

@@ -199,6 +199,101 @@ class BudgetTest < ActiveSupport::TestCase
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: "#aaa", classification: "expense")
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: "#bbb", classification: "expense")
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,