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!(