fix(budgets): let the rollover choice stand instead of resetting each month

`rollover_enabled` lives on budget_categories, one row per (budget,
category), so a month created by `find_or_bootstrap` was born with the flag
off. Switching rollover on for Vacations in January and simply opening
February dropped January's surplus on the floor -- the user had to re-arm
the toggle every month, or go through "copy from previous budget". The
feature's headline case, a category funded 50/month accumulating over a
year, did not work as shipped.

New rows now inherit the flag from the last initialized budget of the same
owner, the same chain the carry itself walks. Turning the toggle off on a
given month still overrides it from there on, so the per-month escape hatch
survives.

The flag stays on budget_categories rather than moving to Category, which is
where comparable products (Monarch, Copilot, Lunch Money) put it. Categories
here are family-wide while budgets are per owner, so a category-level flag
would force one member's rollover choice onto everyone's personal budget and
onto the household budget. budget_categories is the only table carrying both
the category and the owner. A regression test covers that isolation.

Naming follows the same products: the toggle reads "Rollover", the noun, not
"Roll over", the verb -- which also matches `rollover_enabled` and the
calculator. Both tooltips now describe the property rather than a direction
("keep this category's unspent money from one month to the next"). The
previous wording named the direction the flag actually gates, incoming,
which is accurate but the opposite of the mental model every comparable
product installs; describing the property is true under either reading. The
French card string switched to "+%{amount} de report" so it no longer has to
agree in number with a currency noun it cannot see.

bin/rails test: 6942 runs, 0 failures. The inheritance was confirmed
load-bearing by removing it and watching its tests fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z
This commit is contained in:
buzzromain
2026-08-23 14:32:39 +00:00
co-authored by Claude Opus 5
parent 1f12e51f0a
commit 2b1cff5af8
4 changed files with 76 additions and 5 deletions
+21 -1
View File
@@ -121,11 +121,14 @@ class Budget < ApplicationRecord
categories_to_remove = existing_budget_category_ids - current_category_ids
# Create missing categories
inherited_rollover = inherited_rollover_flags(categories_to_add)
categories_to_add.each do |category_id|
budget_categories.create!(
category: current_categories_by_id.fetch(category_id),
budgeted_spending: 0,
currency: family.currency
currency: family.currency,
rollover_enabled: inherited_rollover.fetch(category_id, false)
)
end
@@ -133,6 +136,23 @@ class Budget < ApplicationRecord
budget_categories.where(category_id: categories_to_remove).destroy_all if categories_to_remove.any?
end
# Rollover is a standing choice about an envelope, not about one month: a
# user who switches it on for Vacations expects it to keep going, and a
# month bootstrapped with the flag off would silently break the chain. New
# rows therefore inherit it from the last initialized budget of the same
# owner -- the same chain the carry itself walks. Turning it off on a given
# month still overrides it from there on.
def inherited_rollover_flags(category_ids)
return {} if category_ids.empty?
source = most_recent_initialized_budget
return {} unless source
source.budget_categories
.where(category_id: category_ids, rollover_enabled: true)
.each_with_object({}) { |bc, flags| flags[bc.category_id] = true }
end
def uncategorized_budget_category
budget_categories.uncategorized.tap do |bc|
bc.budgeted_spending = [ available_to_allocate, 0 ].max
+2 -2
View File
@@ -76,8 +76,8 @@ en:
rolled_over: "+%{amount} rolled over"
budget_category_form:
monthly_average: "%{amount}/m avg"
rollover_label: Roll over
rollover_title: Bring this category's unspent amount from last month into this one
rollover_label: Rollover
rollover_title: Keep this category's unspent money from one month to the next
shared_placeholder: Shared
shared_title: Leave empty to share parent's budget
confirm_button:
+2 -2
View File
@@ -7,11 +7,11 @@ fr:
over_set: "> 100 % réglé"
percent_set: "%{percent} défini"
budget_category:
rolled_over: "+%{amount} reportés"
rolled_over: "+%{amount} de report"
budget_category_form:
monthly_average: "%{amount}/mois en moyenne"
rollover_label: Report
rollover_title: Reprendre sur ce mois le solde non dépensé de cette catégorie le mois dernier
rollover_title: Conserver d'un mois sur l'autre ce que cette catégorie n'a pas dépensé
shared_placeholder: Partagé
shared_title: Laisser vide pour partager le budget des parents
confirm_button:
+51
View File
@@ -585,6 +585,57 @@ class BudgetCategoryRolloverTest < ActiveSupport::TestCase
assert second_subcategory.budgeted?
end
test "the carry survives a month the user merely opens" do
first = initialized_budget(2.months.ago)
allocate(first, 100)
spend(30, budget: first)
# The user does nothing but open the next month. Bootstrapping created
# its rows; without inheritance they'd default the toggle off and drop
# the carry on the floor.
second = initialized_budget(1.month.ago)
second_category = budget_category_for(second)
assert second_category.rollover_enabled?, "a new month inherits the standing rollover choice"
second_category.update!(budgeted_spending: 100)
recompute!
assert_equal 70, stored_rollover(second)
end
test "turning the toggle off overrides the inherited choice from there on" do
first = initialized_budget(2.months.ago)
allocate(first, 100)
spend(30, budget: first)
second = initialized_budget(1.month.ago)
allocate(second, 100, rollover: false)
recompute!
assert_equal 0, stored_rollover(second)
third = initialized_budget(0.months.ago)
assert_not budget_category_for(third).rollover_enabled?,
"the later month inherits the off state, not the older on state"
end
test "one member's rollover choice does not leak into another's budget" do
@family.update!(personal_budgets: true)
josh = users(:josh)
ann = users(:ann)
josh_first = initialized_budget(2.months.ago, user: josh)
josh_first.budget_categories.find_by!(category: @category).update!(budgeted_spending: 100, rollover_enabled: true)
# Categories are family-wide, budgets are not: Ann's new month must not
# pick up Josh's choice.
ann_second = initialized_budget(1.month.ago, user: ann)
josh_second = initialized_budget(1.month.ago, user: josh)
assert josh_second.budget_categories.find_by!(category: @category).rollover_enabled?
assert_not ann_second.budget_categories.find_by!(category: @category).rollover_enabled?
end
private
def create_account(owner:, name: "Rollover Checking")
@family.accounts.create!(