From 2b1cff5af8b41ea1cca3a57c97d02492549881cd Mon Sep 17 00:00:00 2001 From: buzzromain <18685603+buzzromain@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:32:39 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z --- app/models/budget.rb | 22 ++++++++++++- config/locales/views/budgets/en.yml | 4 +-- config/locales/views/budgets/fr.yml | 4 +-- test/models/budget_category_test.rb | 51 +++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/app/models/budget.rb b/app/models/budget.rb index 6d6a8f02f..057c97479 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -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 diff --git a/config/locales/views/budgets/en.yml b/config/locales/views/budgets/en.yml index 2200e08ae..da77e6f9a 100644 --- a/config/locales/views/budgets/en.yml +++ b/config/locales/views/budgets/en.yml @@ -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: diff --git a/config/locales/views/budgets/fr.yml b/config/locales/views/budgets/fr.yml index 427dbe796..fc921a9c4 100644 --- a/config/locales/views/budgets/fr.yml +++ b/config/locales/views/budgets/fr.yml @@ -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: diff --git a/test/models/budget_category_test.rb b/test/models/budget_category_test.rb index 9de9906b1..c6bd8f4c7 100644 --- a/test/models/budget_category_test.rb +++ b/test/models/budget_category_test.rb @@ -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!(