diff --git a/app/controllers/budget_categories_controller.rb b/app/controllers/budget_categories_controller.rb index bd3ef1da4..d4e3cb9ad 100644 --- a/app/controllers/budget_categories_controller.rb +++ b/app/controllers/budget_categories_controller.rb @@ -34,8 +34,16 @@ class BudgetCategoriesController < ApplicationController def update @budget_category = @budget.budget_categories.find(params[:id]) + @budget_category.update!(rollover_enabled: rollover_enabled_param) unless rollover_enabled_param.nil? @budget_category.update_budgeted_spending!(budgeted_spending_param) + # Allocations and the rollover toggle both feed the chain, so recompute + # it here rather than on every transaction change: the budget page is + # the only place the amount is read, and it always comes through here or + # through Budget.find_or_bootstrap. + Budget::RolloverCalculator.new(family: @budget.family, user: @budget.user).recompute! + @budget_category.reload + respond_to do |format| format.turbo_stream format.html { redirect_to budget_budget_categories_path(@budget, **budget_owner_query) } @@ -45,6 +53,13 @@ class BudgetCategoriesController < ApplicationController end private + def rollover_enabled_param + permitted = params.require(:budget_category).permit(:rollover_enabled) + return nil unless permitted.key?(:rollover_enabled) + + ActiveModel::Type::Boolean.new.cast(permitted[:rollover_enabled]) + end + def budgeted_spending_param params.require(:budget_category) .permit(:budgeted_spending) diff --git a/app/models/budget.rb b/app/models/budget.rb index 8622a427d..057c97479 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -5,6 +5,12 @@ class Budget < ApplicationRecord attr_accessor :current_user + # Overrides the account scope `income_statement` would otherwise infer. + # Budget::RolloverCalculator sets it on the household chain: the carry it + # stores is one shared row, so it must not be computed through whichever + # viewer's account access happened to trigger the recompute. + attr_writer :income_statement_accounts + belongs_to :family belongs_to :user, optional: true @@ -15,7 +21,8 @@ class Budget < ApplicationRecord monetize :budgeted_spending, :expected_income, :allocated_spending, :actual_spending, :available_to_spend, :available_to_allocate, - :estimated_spending, :estimated_income, :actual_income, :remaining_expected_income + :estimated_spending, :estimated_income, :actual_income, :remaining_expected_income, + :total_rolled_over class << self def date_to_param(date) @@ -75,6 +82,8 @@ class Budget < ApplicationRecord budget.current_user = user budget.sync_budget_categories + Budget::RolloverCalculator.new(family: family, user: owner).recompute! + budget end end @@ -112,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 @@ -124,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 @@ -209,8 +238,18 @@ class Budget < ApplicationRecord target_bc = target_by_category[source_bc.category_id] next unless target_bc - target_bc.update!(budgeted_spending: source_bc.budgeted_spending) + # The toggle is a preference and travels with the copy; the amount + # is derived state that only Budget::RolloverCalculator may write. + target_bc.update!( + budgeted_spending: source_bc.budgeted_spending, + rollover_enabled: source_bc.rollover_enabled + ) end + + # Copying the toggle changes what the chain should hold, and this runs + # after find_or_bootstrap already recomputed it. Recompute again so the + # target doesn't sit on a zero carry until the next page load. + Budget::RolloverCalculator.new(family: family, user: user).recompute! end end @@ -351,6 +390,15 @@ class Budget < ApplicationRecord (budgeted_spending || 0) - allocated_spending end + # Informational aggregate only -- deliberately kept out of + # `allocated_spending` and `available_to_allocate`, which stay a pure + # "what did I plan to spend this month" pair. Ring-fenced subcategories + # carry their own surplus and their parent's is net of theirs, so summing + # every non-inheriting category counts each amount once. + def total_rolled_over + budget_categories.reject(&:inherits_parent_budget?).sum(&:rolled_over_amount) + end + def allocations_valid? initialized? && available_to_allocate >= 0 && allocated_spending > 0 end @@ -393,6 +441,8 @@ class Budget < ApplicationRecord # viewer sees the owner's numbers, and household vs. personal actually # differ instead of both reflecting the viewer's full accessible set. def income_statement_accounts + return @income_statement_accounts if @income_statement_accounts + family.accounts.where(owner_id: user_id).included_in_reports if user_id.present? end diff --git a/app/models/budget/rollover_calculator.rb b/app/models/budget/rollover_calculator.rb new file mode 100644 index 000000000..b8e9aab96 --- /dev/null +++ b/app/models/budget/rollover_calculator.rb @@ -0,0 +1,194 @@ +# Materializes BudgetCategory#rolled_over_amount for a single budget chain: +# either a family's household budgets (user nil) or one member's personal +# budgets. Chains never mix — a personal budget only ever inherits from the +# same user's earlier personal budgets. +# +# The amount rolled into month n depends on month n-1, which depends on +# n-2. Computed lazily it would walk the whole chain on every budget render, +# so it is stored instead and recomputed in one forward pass whenever a +# budget is bootstrapped or an allocation changes. +class Budget::RolloverCalculator + # Arbitrary namespace so the advisory lock below cannot collide with any + # other pg_advisory_lock user in the application. + LOCK_NAMESPACE = 1_920_231_276 + + def initialize(family:, user:) + @family = family + @user = user + end + + # The read-then-write below is not atomic on its own: two overlapping + # recomputes for the same chain can both load it, and the one that started + # first can land its now-stale carry on top of the other's. `update_only` + # keeps that from touching allocations, but rolled_over_amount is the very + # column this writes, so nothing else protects it. A transaction-scoped + # advisory lock keyed on the chain serializes them. + # + # Taken after the cheap guard, so families that never enabled rollover pay + # one query and never contend, and re-read under the lock because the chain + # may have changed while we waited for it. + def recompute! + return if first_relevant_budget_date.nil? + + BudgetCategory.transaction do + lock_chain! + from = first_relevant_budget_date + recompute_chain!(from) if from + end + end + + private + attr_reader :family, :user + + def lock_chain! + BudgetCategory.connection.execute( + BudgetCategory.sanitize_sql_array( + [ "SELECT pg_advisory_xact_lock(?, hashtext(?))", LOCK_NAMESPACE, chain_key ] + ) + ) + end + + def chain_key + "budget_rollover:#{family.id}:#{user&.id}" + end + + def recompute_chain!(from) + updates = [] + now = Time.current + + # category_id => [amount, currency]. A budget freezes its currency when + # it is created, so a family that switches currency leaves a break in the + # chain: the amounts on either side are not the same unit. Carrying the + # raw number across would silently reinterpret it, so the carry stops + # there and the next month starts from zero. + # + # A category deleted mid-chain takes its budget_categories rows with it + # (Category has_many :budget_categories, dependent: :destroy), so it + # simply drops out of `carry` — its history is gone, which is what + # deleting a category means. + carry = {} + + chain(from).each do |budget| + budget.income_statement_accounts = household_account_scope if user.nil? + + next_carry = {} + ring_fenced_children = ring_fenced_children_by_parent(budget) + + budget.budget_categories.each do |budget_category| + # Subcategories that share their parent's budget have no allocation + # of their own, so there is nothing for them to carry forward. + next if budget_category.inherits_parent_budget? + + incoming = incoming_carry(carry, budget_category) + + if budget_category[:rolled_over_amount] != incoming + updates << budget_category.attributes.merge( + "rolled_over_amount" => incoming, + "updated_at" => now + ) + end + + # Switching the toggle off has to stop the money in both directions. + # Gating only what a month receives would let an opted-out month hand + # its whole allocation to the next one that opts back in, so the + # surplus a user meant to forfeit would reappear a month later. + outgoing = if budget_category.rollover_enabled? + children = ring_fenced_children[budget_category.category_id] || [] + leftover_for(budget, budget_category, incoming, children) + else + 0 + end + + next_carry[budget_category.category_id] = [ outgoing, budget_category.currency ] + end + + carry = next_carry + end + + # The full attribute set is what makes the INSERT branch legal + # (budget_id, category_id and currency are NOT NULL), but only the two + # rollover columns may be written on conflict: a concurrent request that + # changed an allocation between our read and this write must not have it + # clobbered by the stale value we loaded. + if updates.any? + BudgetCategory.upsert_all(updates, unique_by: :id, update_only: %w[rolled_over_amount updated_at]) + end + end + + + # Earliest month this chain has anything to say about: rollover switched + # on, or an amount left behind by a toggle that was switched off and + # still needs clearing. nil -- the common case, families that never + # turned rollover on -- costs one query and does nothing. + def first_relevant_budget_date + initialized_budgets + .joins("INNER JOIN budget_categories ON budget_categories.budget_id = budgets.id") + .where("budget_categories.rollover_enabled OR budget_categories.rolled_over_amount <> 0") + .minimum(:start_date) + end + + # Walking back to `oldest_valid_budget_date` every time would read an + # income statement per month for nothing: months before the first one + # that uses rollover cannot change any stored amount. Start one + # initialized month earlier than `from`, which is where its carry comes + # from. + def chain(from) + seed = initialized_budgets.where("start_date < ?", from).maximum(:start_date) + + initialized_budgets + .where("start_date >= ?", seed || from) + .order(:start_date) + .includes(budget_categories: :category) + end + + # Only initialized budgets take part: a month the user never set up is a + # gap in the chain, not a month budgeted at zero, so the carry crosses it + # untouched (same semantics as Budget#most_recent_initialized_budget). + def initialized_budgets + family.budgets + .where(user_id: user&.id) + .where.not(budgeted_spending: nil) + end + + def incoming_carry(carry, budget_category) + return 0 unless budget_category.rollover_enabled? + + amount, currency = carry[budget_category.category_id] + return 0 if amount.nil? || currency != budget_category.currency + + amount + end + + # The household chain has no owner to scope actuals by, and IncomeStatement + # falls back to Current.user when nobody says otherwise -- which would make + # the stored carry depend on whichever member loaded the page, each + # overwriting the other. Pin it to the whole family so the shared row holds + # one number. Personal chains already scope to their owner's accounts. + def household_account_scope + @household_account_scope ||= family.accounts.included_in_reports + end + + def ring_fenced_children_by_parent(budget) + budget.budget_categories + .select { |bc| bc.subcategory? && !bc.inherits_parent_budget? } + .group_by { |bc| bc.category.parent_id } + end + + # max(0, budgeted + rolled_over − actual): v1 only carries a surplus, a + # negative balance stops at the month it happened in. + def leftover_for(budget, budget_category, incoming, ring_fenced_children) + budgeted = (budget_category[:budgeted_spending] || 0) + incoming + actual = budget.budget_category_actual_spending(budget_category) + + # A parent's allocation already contains its ring-fenced subcategories' + # allocations, and its actual spending already contains their spending. + # Those subcategories carry their own surplus forward, so take them out + # here rather than rolling the same money over twice. + ring_fenced_children.each do |child| + budgeted -= (child[:budgeted_spending] || 0) + actual -= budget.budget_category_actual_spending(child) + end + + [ budgeted - actual, 0 ].max + end +end diff --git a/app/models/budget_category.rb b/app/models/budget_category.rb index ddf069214..ff0dd87e1 100644 --- a/app/models/budget_category.rb +++ b/app/models/budget_category.rb @@ -6,7 +6,8 @@ class BudgetCategory < ApplicationRecord validates :budget_id, uniqueness: { scope: :category_id } - monetize :budgeted_spending, :available_to_spend, :avg_monthly_expense, :median_monthly_expense, :actual_spending + monetize :budgeted_spending, :available_to_spend, :avg_monthly_expense, :median_monthly_expense, :actual_spending, + :rolled_over_amount class Group attr_reader :budget_category, :budget_subcategories @@ -76,6 +77,21 @@ class BudgetCategory < ApplicationRecord category.parent_id.present? end + # Materialized by Budget::RolloverCalculator, never derived on read. Going + # through the toggle here (and skipping subcategories that share their + # parent's budget) keeps every consumer consistent even if a stale amount + # outlives the toggle that produced it. + def rolled_over_amount + return 0 unless rollover_enabled? + return 0 if inherits_parent_budget? + + super || 0 + end + + def rolled_over? + rolled_over_amount.positive? + end + # Returns true if this subcategory has no individual budget limit and should use parent's budget def inherits_parent_budget? subcategory? && (self[:budgeted_spending].nil? || self[:budgeted_spending] == 0) @@ -107,10 +123,10 @@ class BudgetCategory < ApplicationRecord parent.available_to_spend elsif subcategory? # Subcategory with individual limit - (self[:budgeted_spending] || 0) - actual_spending + (self[:budgeted_spending] || 0) + rolled_over_amount - actual_spending else # Parent category - parent_budget = self[:budgeted_spending] || 0 + parent_budget = (self[:budgeted_spending] || 0) + rolled_over_amount # Get subcategories with and without individual limits subcategories_with_limits = subcategories.reject(&:inherits_parent_budget?) @@ -135,18 +151,24 @@ class BudgetCategory < ApplicationRecord end end + # Consumption, so it measures against everything there is to spend -- + # rollover included, which is what `available_to_spend` reports. (The + # allocation figures, `allocated_spending` and `available_to_allocate`, + # deliberately stay a pure "what did I plan for this month".) The + # zero-budget guards below apply to that effective amount, not to the + # allocation alone: a category funded only by rollover has money to spend. def percent_of_budget_spent if inherits_parent_budget? # For subcategories using parent budget, show their spending as percentage of parent's budget parent = parent_budget_category return 0 unless parent - parent_budget = parent[:budgeted_spending] || 0 + parent_budget = (parent[:budgeted_spending] || 0) + parent.rolled_over_amount return 0 if parent_budget == 0 && actual_spending == 0 return 100 if parent_budget == 0 && actual_spending > 0 (actual_spending.to_f / parent_budget) * 100 else - budget_amount = self[:budgeted_spending] || 0 + budget_amount = (self[:budgeted_spending] || 0) + rolled_over_amount return 0 if budget_amount == 0 && actual_spending == 0 return 0 if budget_amount > 0 && actual_spending == 0 return 100 if budget_amount == 0 && actual_spending > 0 @@ -162,8 +184,22 @@ class BudgetCategory < ApplicationRecord available_to_spend.negative? end + # "Is there money in this envelope", which drives the over-budget / + # on-track classification -- so it counts the carry too. Without it a + # category funded entirely by rollover reads as unbudgeted, lands in + # `unbudgeted_with_spending?` and gets an alert pill while it still has + # money left. `display_budgeted_spending` stays the month's allocation + # alone: the card shows the two figures side by side. def budgeted? - display_budgeted_spending.to_d.positive? + (display_budgeted_spending.to_d + display_rolled_over_amount.to_d).positive? + end + + # Sibling of `display_budgeted_spending`: a subcategory sharing its + # parent's budget shares its parent's carry as well. + def display_rolled_over_amount + return rolled_over_amount unless inherits_parent_budget? + + parent_budget_category&.rolled_over_amount || 0 end def unbudgeted_with_spending? diff --git a/app/views/api/v1/budget_categories/_budget_category.json.jbuilder b/app/views/api/v1/budget_categories/_budget_category.json.jbuilder index a0418f78a..280094597 100644 --- a/app/views/api/v1/budget_categories/_budget_category.json.jbuilder +++ b/app/views/api/v1/budget_categories/_budget_category.json.jbuilder @@ -11,6 +11,8 @@ json.currency budget_category.currency json.subcategory budget_category.subcategory? json.inherits_parent_budget budget_category.inherits_parent_budget? +json.rollover_enabled budget_category.rollover_enabled? + json.budgeted_spending budget_category.budgeted_spending_money.format json.budgeted_spending_cents money_to_minor_units.call(budget_category.budgeted_spending_money) json.display_budgeted_spending Money.new(budget_category.display_budgeted_spending, budget_category.currency).format @@ -18,6 +20,11 @@ json.display_budgeted_spending_cents money_to_minor_units.call(Money.new(budget_ if include_derived_amounts json.actual_spending budget_category.actual_spending_money.format json.actual_spending_cents money_to_minor_units.call(budget_category.actual_spending_money) + # Sits with the derived amounts because it is what makes available_to_spend + # exceed budgeted_spending: without it a client sees the larger number with + # nothing to account for the difference. + json.rolled_over_amount budget_category.rolled_over_amount_money.format + json.rolled_over_amount_cents money_to_minor_units.call(budget_category.rolled_over_amount_money) json.available_to_spend budget_category.available_to_spend_money.format json.available_to_spend_cents money_to_minor_units.call(budget_category.available_to_spend_money) end diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb index 830ce817b..8dae19f45 100644 --- a/app/views/budget_categories/_budget_category.html.erb +++ b/app/views/budget_categories/_budget_category.html.erb @@ -64,6 +64,11 @@ <%= t("reports.budget_performance.shared") %> <% end %> + <% if budget_category.rolled_over? %> +
+ <%= t("budget_categories.budget_category.rolled_over", amount: format_money(budget_category.rolled_over_amount_money)) %> +
+ <% end %> <% if budget_category.suggested_daily_spending.present? %> <% daily_info = budget_category.suggested_daily_spending %>
diff --git a/app/views/budget_categories/_budget_category_form.html.erb b/app/views/budget_categories/_budget_category_form.html.erb index bb82311ff..80e243d6d 100644 --- a/app/views/budget_categories/_budget_category_form.html.erb +++ b/app/views/budget_categories/_budget_category_form.html.erb @@ -13,17 +13,31 @@
<%= form_with model: [budget_category.budget, budget_category], url: budget_budget_category_path(budget_category.budget, budget_category, **budget_owner_query), data: { controller: "auto-submit-form preserve-focus" } do |f| %> -
-
- <%= currency.symbol %> - <%= f.number_field :budgeted_spending, - class: "form-field__input text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none", - placeholder: budget_category.subcategory? ? t("budget_categories.budget_category_form.shared_placeholder") : "0", - step: currency.step, - id: dom_id(budget_category, :budgeted_spending), - min: 0, - data: { auto_submit_form_target: "auto" }, - title: budget_category.subcategory? ? t("budget_categories.budget_category_form.shared_title") : nil %> +
+
"> + <%= label_tag dom_id(budget_category, :rollover_enabled), + t("budget_categories.budget_category_form.rollover_label"), + class: "text-xs text-secondary cursor-pointer" %> + <%= render DS::Toggle.new( + id: dom_id(budget_category, :rollover_enabled), + name: "budget_category[rollover_enabled]", + checked: budget_category.rollover_enabled?, + data: { auto_submit_form_target: "auto" } + ) %> +
+ +
+
+ <%= currency.symbol %> + <%= f.number_field :budgeted_spending, + class: "form-field__input text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none", + placeholder: budget_category.subcategory? ? t("budget_categories.budget_category_form.shared_placeholder") : "0", + step: currency.step, + id: dom_id(budget_category, :budgeted_spending), + min: 0, + data: { auto_submit_form_target: "auto" }, + title: budget_category.subcategory? ? t("budget_categories.budget_category_form.shared_title") : nil %> +
<% end %> diff --git a/app/views/budget_categories/show.html.erb b/app/views/budget_categories/show.html.erb index ecf4a2e7e..96e04997c 100644 --- a/app/views/budget_categories/show.html.erb +++ b/app/views/budget_categories/show.html.erb @@ -68,6 +68,15 @@ <%= format_money @budget_category.budgeted_spending_money %>
+ + <% if @budget_category.rolled_over? %> +
+
<%= t(".rolled_over") %>
+
+ <%= format_money @budget_category.rolled_over_amount_money %> +
+
+ <% end %> <% end %>
diff --git a/config/locales/views/budgets/en.yml b/config/locales/views/budgets/en.yml index 519ede9dc..da77e6f9a 100644 --- a/config/locales/views/budgets/en.yml +++ b/config/locales/views/budgets/en.yml @@ -72,8 +72,12 @@ en: left_to_allocate: left to allocate over_set: "> 100% set" percent_set: "%{percent} set" + budget_category: + rolled_over: "+%{amount} rolled over" budget_category_form: monthly_average: "%{amount}/m avg" + 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: @@ -94,6 +98,7 @@ en: overspent: "overspent" left: "left" budgeted: "Budgeted" + rolled_over: "Rolled over" monthly_average_spending: "Monthly average spending" monthly_median_spending: "Monthly median spending" recent_transactions: "Recent Transactions" diff --git a/config/locales/views/budgets/fr.yml b/config/locales/views/budgets/fr.yml index bdc73ee06..fc921a9c4 100644 --- a/config/locales/views/budgets/fr.yml +++ b/config/locales/views/budgets/fr.yml @@ -6,8 +6,12 @@ fr: left_to_allocate: reste à attribuer over_set: "> 100 % réglé" percent_set: "%{percent} défini" + budget_category: + rolled_over: "+%{amount} de report" budget_category_form: monthly_average: "%{amount}/mois en moyenne" + rollover_label: Report + 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: @@ -30,6 +34,7 @@ fr: overspent: trop dépensé overview: Aperçu recent_transactions: Transactions récentes + rolled_over: Reporté spending: "%{date} dépenses" status: Statut view_all_transactions: Voir toutes les transactions de catégorie diff --git a/db/migrate/20260823000000_add_rollover_to_budget_categories.rb b/db/migrate/20260823000000_add_rollover_to_budget_categories.rb new file mode 100644 index 000000000..3d61afe96 --- /dev/null +++ b/db/migrate/20260823000000_add_rollover_to_budget_categories.rb @@ -0,0 +1,6 @@ +class AddRolloverToBudgetCategories < ActiveRecord::Migration[7.2] + def change + add_column :budget_categories, :rollover_enabled, :boolean, null: false, default: false + add_column :budget_categories, :rolled_over_amount, :decimal, precision: 19, scale: 4, null: false, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index 4375379ba..6eb5d2ccc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_08_22_130000) do +ActiveRecord::Schema[7.2].define(version: 2026_08_23_000000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -367,6 +367,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_22_130000) do t.string "currency", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "rollover_enabled", default: false, null: false + t.decimal "rolled_over_amount", precision: 19, scale: 4, default: "0.0", null: false t.index ["budget_id", "category_id"], name: "index_budget_categories_on_budget_id_and_category_id", unique: true t.index ["budget_id"], name: "index_budget_categories_on_budget_id" t.index ["category_id"], name: "index_budget_categories_on_category_id" diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e7184ccae..18de97bec 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -682,6 +682,8 @@ components: type: boolean inherits_parent_budget: type: boolean + rollover_enabled: + type: boolean budgeted_spending: type: string budgeted_spending_cents: @@ -741,6 +743,8 @@ components: type: boolean inherits_parent_budget: type: boolean + rollover_enabled: + type: boolean budgeted_spending: type: string budgeted_spending_cents: @@ -749,6 +753,10 @@ components: type: string display_budgeted_spending_cents: type: integer + rolled_over_amount: + type: string + rolled_over_amount_cents: + type: integer actual_spending: type: string actual_spending_cents: diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index 604936069..b43f9ffd2 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -398,6 +398,7 @@ RSpec.configure do |config| currency: { type: :string }, subcategory: { type: :boolean }, inherits_parent_budget: { type: :boolean }, + rollover_enabled: { type: :boolean }, budgeted_spending: { type: :string }, budgeted_spending_cents: { type: :integer }, display_budgeted_spending: { type: :string }, @@ -426,12 +427,15 @@ RSpec.configure do |config| currency: { type: :string }, subcategory: { type: :boolean }, inherits_parent_budget: { type: :boolean }, + rollover_enabled: { type: :boolean }, budgeted_spending: { type: :string }, budgeted_spending_cents: { type: :integer }, display_budgeted_spending: { type: :string }, display_budgeted_spending_cents: { type: :integer }, actual_spending: { type: :string }, actual_spending_cents: { type: :integer }, + rolled_over_amount: { type: :string }, + rolled_over_amount_cents: { type: :integer }, available_to_spend: { type: :string }, available_to_spend_cents: { type: :integer }, category: { diff --git a/test/controllers/api/v1/budget_categories_controller_test.rb b/test/controllers/api/v1/budget_categories_controller_test.rb index c698af53e..9b230c4f2 100644 --- a/test/controllers/api/v1/budget_categories_controller_test.rb +++ b/test/controllers/api/v1/budget_categories_controller_test.rb @@ -62,6 +62,12 @@ class Api::V1::BudgetCategoriesControllerTest < ActionDispatch::IntegrationTest assert_not budget_category.key?("actual_spending_cents") assert_not budget_category.key?("available_to_spend") assert_not budget_category.key?("available_to_spend_cents") + + # The toggle is stored state, so it travels with the summary; the carry + # is a derived amount and stays with the others. + assert_equal false, budget_category["rollover_enabled"] + assert_not budget_category.key?("rolled_over_amount") + assert_not budget_category.key?("rolled_over_amount_cents") end test "shows a budget category" do @@ -77,6 +83,37 @@ class Api::V1::BudgetCategoriesControllerTest < ActionDispatch::IntegrationTest assert_kind_of Integer, response_data["available_to_spend_cents"] end + test "show exposes the carry that makes available_to_spend exceed the allocation" do + previous_budget = @family.budgets.create!( + start_date: 6.months.ago.beginning_of_month.to_date, + end_date: 6.months.ago.end_of_month.to_date, + budgeted_spending: 3000, + expected_income: 5000, + currency: "USD" + ) + previous_budget.budget_categories.create!( + category: @category, + budgeted_spending: 200, + currency: "USD", + rollover_enabled: true + ) + @budget_category.update!(rollover_enabled: true) + Budget::RolloverCalculator.new(family: @family, user: nil).recompute! + + get api_v1_budget_category_url(@budget_category), headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + + assert_equal true, response_data["rollover_enabled"] + assert_equal 20_000, response_data["rolled_over_amount_cents"] + + # Without the field a client sees 700 available against a 500 allocation + # and has nothing to account for the difference. + assert_equal 50_000, response_data["budgeted_spending_cents"] + assert_equal 70_000, response_data["available_to_spend_cents"] + end + test "returns not found for another family's budget category" do get api_v1_budget_category_url(@other_budget_category), headers: api_headers(@api_key) diff --git a/test/controllers/budget_categories_controller_test.rb b/test/controllers/budget_categories_controller_test.rb index 5ed729ddd..cb4b55d1c 100644 --- a/test/controllers/budget_categories_controller_test.rb +++ b/test/controllers/budget_categories_controller_test.rb @@ -109,6 +109,46 @@ class BudgetCategoriesControllerTest < ActionDispatch::IntegrationTest assert_equal 0.0, @electric_budget_category.reload.budgeted_spending.to_f end + test "toggling rollover persists and recomputes the chain" do + previous_budget = Budget.find_or_bootstrap(@family, start_date: 1.month.ago) + previous_budget.update!(budgeted_spending: 5000, expected_income: 7000) + previous_budget_category = previous_budget.budget_categories.find_by!(category: @parent_category) + previous_budget_category.update!(budgeted_spending: 400, rollover_enabled: true) + + create_transaction( + date: previous_budget.start_date, + account: accounts(:depository), + amount: 150, + category: @parent_category + ) + + patch budget_budget_category_path(@budget, @parent_budget_category), + params: { budget_category: { budgeted_spending: 500, rollover_enabled: "1" } }, + as: :turbo_stream + + assert_response :success + assert @parent_budget_category.reload.rollover_enabled? + assert_equal 250.0, @parent_budget_category[:rolled_over_amount].to_f + + patch budget_budget_category_path(@budget, @parent_budget_category), + params: { budget_category: { budgeted_spending: 500, rollover_enabled: "0" } }, + as: :turbo_stream + + assert_response :success + assert_not @parent_budget_category.reload.rollover_enabled? + assert_equal 0.0, @parent_budget_category[:rolled_over_amount].to_f + end + + test "updating an allocation without the toggle leaves rollover off" do + patch budget_budget_category_path(@budget, @parent_budget_category), + params: { budget_category: { budgeted_spending: 600 } }, + as: :turbo_stream + + assert_response :success + assert_not @parent_budget_category.reload.rollover_enabled? + assert_equal 600.0, @parent_budget_category.budgeted_spending.to_f + end + test "show drilldown excludes BUDGET_EXCLUDED_KINDS transfers from recent transactions" do # Issue #1059: a matched depository <-> CC pair becomes # (cc_payment outflow + funds_movement inflow). Both kinds are in diff --git a/test/models/budget_category_test.rb b/test/models/budget_category_test.rb index 2486f9a75..a0eed36f4 100644 --- a/test/models/budget_category_test.rb +++ b/test/models/budget_category_test.rb @@ -272,3 +272,471 @@ class BudgetCategoryTest < ActiveSupport::TestCase end end end + +class BudgetCategoryRolloverTest < ActiveSupport::TestCase + include EntriesTestHelper + + setup do + @family = families(:empty) + @category = @family.categories.create!(name: "Vacations", color: "#6172F3") + @account = create_account(owner: nil) + end + + test "carries a surplus into the next month" do + first = initialized_budget(2.months.ago) + allocate(first, 100) + spend(30, budget: first) + + second = initialized_budget(1.month.ago) + allocate(second, 100) + + recompute! + + assert_equal 0, stored_rollover(first) + assert_equal 70, stored_rollover(second) + assert_equal 170, budget_category_for(second).available_to_spend + end + + test "an overspent month rolls over nothing rather than a negative" do + first = initialized_budget(2.months.ago) + allocate(first, 100) + spend(150, budget: first) + + second = initialized_budget(1.month.ago) + allocate(second, 100) + + recompute! + + assert_equal 0, stored_rollover(second) + assert_equal 100, budget_category_for(second).available_to_spend + end + + test "a category without the toggle never accumulates a rollover" 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) + assert_equal 100, budget_category_for(second).available_to_spend + end + + test "a subcategory inheriting its parent's budget is excluded" do + subcategory = @family.categories.create!(name: "Flights", parent: @category, color: "#6172F3") + + first = initialized_budget(2.months.ago) + allocate(first, 100) + first.budget_categories.find_by!(category: subcategory).update!(budgeted_spending: 0, rollover_enabled: true) + + second = initialized_budget(1.month.ago) + allocate(second, 100) + second_subcategory = second.budget_categories.find_by!(category: subcategory) + second_subcategory.update!(budgeted_spending: 0, rollover_enabled: true) + + recompute! + + assert_equal 0, second_subcategory.reload[:rolled_over_amount] + assert_equal 0, second_subcategory.rolled_over_amount + end + + test "a parent's rollover excludes what its ring-fenced subcategories carry themselves" do + subcategory = @family.categories.create!(name: "Hotels", parent: @category, color: "#6172F3") + + first = initialized_budget(2.months.ago) + allocate(first, 300) + first.budget_categories.find_by!(category: subcategory).update!(budgeted_spending: 100, rollover_enabled: true) + spend(20, budget: first, category: subcategory) + + second = initialized_budget(1.month.ago) + allocate(second, 300) + second_subcategory = second.budget_categories.find_by!(category: subcategory) + second_subcategory.update!(budgeted_spending: 100, rollover_enabled: true) + + recompute! + + # Subcategory keeps its own 100 - 20 = 80; the parent only carries the + # 300 - 100 = 200 shared pool it never spent from. + assert_equal 80, second_subcategory.reload[:rolled_over_amount] + assert_equal 200, stored_rollover(second) + end + + test "accumulates across three consecutive months" do + first = initialized_budget(3.months.ago) + allocate(first, 50) + + second = initialized_budget(2.months.ago) + allocate(second, 50) + + third = initialized_budget(1.month.ago) + allocate(third, 50) + + recompute! + + assert_equal 0, stored_rollover(first) + assert_equal 50, stored_rollover(second) + assert_equal 100, stored_rollover(third) + assert_equal 150, budget_category_for(third).available_to_spend + end + + test "a gap month does not reset the chain" do + first = initialized_budget(3.months.ago) + allocate(first, 100) + spend(20, budget: first) + + # Bootstrapped but never initialized: a month the user skipped, not a + # month budgeted at zero. + Budget.find_or_bootstrap(@family, start_date: 2.months.ago) + + third = initialized_budget(1.month.ago) + allocate(third, 100) + + recompute! + + assert_equal 80, stored_rollover(third) + end + + test "one member's personal chain does not contaminate another's" do + @family.update!(personal_budgets: true) + josh = users(:josh) + ann = users(:ann) + josh_account = create_account(owner: josh, name: "Josh Checking") + + josh_first = initialized_budget(2.months.ago, user: josh) + allocate(josh_first, 100) + create_transaction(account: josh_account, date: josh_first.start_date, amount: 30, category: @category) + + ann_first = initialized_budget(2.months.ago, user: ann) + allocate(ann_first, 40) + + josh_second = initialized_budget(1.month.ago, user: josh) + allocate(josh_second, 100) + + ann_second = initialized_budget(1.month.ago, user: ann) + allocate(ann_second, 40) + + recompute!(user: josh) + recompute!(user: ann) + + assert_equal 70, stored_rollover(josh_second) + assert_equal 40, stored_rollover(ann_second) + end + + test "flipping the toggle off clears the stored amount" do + first = initialized_budget(2.months.ago) + allocate(first, 100) + spend(30, budget: first) + + second = initialized_budget(1.month.ago) + second_category = allocate(second, 100) + + recompute! + assert_equal 70, stored_rollover(second) + + second_category.update!(rollover_enabled: false) + recompute! + + assert_equal 0, stored_rollover(second) + end + + test "recompute does not clobber an allocation changed underneath it" do + first = initialized_budget(2.months.ago) + allocate(first, 100) + spend(30, budget: first) + + second = initialized_budget(1.month.ago) + allocate(second, 100) + + # Stand in for a concurrent request: the calculator has already loaded the + # chain when someone else moves the allocation, so its in-memory copy is + # stale by the time it writes. `budget_category_actual_spending` is a + # public seam it goes through on every row, just before the upsert. + Budget.any_instance.stubs(:budget_category_actual_spending).with do |_budget_category| + budget_category_for(second).update_column(:budgeted_spending, 250) + true + end.returns(30) + + Budget::RolloverCalculator.new(family: @family, user: nil).recompute! + + assert_equal 70, stored_rollover(second) + assert_equal 250, budget_category_for(second).budgeted_spending + end + + test "the household carry is the same whoever triggers the recompute" do + # Owned by josh, so ann's finance accounts don't include it. Before the + # account scope was pinned, IncomeStatement fell back to Current.user and + # each member's page view overwrote the shared row with their own number. + josh_account = create_account(owner: users(:josh), name: "Josh only") + + first = initialized_budget(2.months.ago) + allocate(first, 100) + create_transaction(account: josh_account, date: first.start_date, amount: 30, category: @category) + + second = initialized_budget(1.month.ago) + allocate(second, 100) + + Current.stubs(:user).returns(users(:ann)) + recompute! + assert_equal 70, stored_rollover(second) + + Current.stubs(:user).returns(users(:josh)) + recompute! + assert_equal 70, stored_rollover(second) + end + + test "the carry stops at a currency change rather than crossing it" do + first = initialized_budget(2.months.ago) + allocate(first, 100) + + @family.update!(currency: "EUR") + second = initialized_budget(1.month.ago) + assert_equal "EUR", second.currency + allocate(second, 100) + + recompute! + + assert_equal 0, stored_rollover(second) + end + + test "deleting a category removes it from every month of the chain" do + first = initialized_budget(2.months.ago) + allocate(first, 100) + spend(30, budget: first) + + second = initialized_budget(1.month.ago) + allocate(second, 100) + + recompute! + assert_equal 70, stored_rollover(second) + + assert_difference "BudgetCategory.count", -2 do + @category.destroy! + end + + # Nothing to chain any more, and recomputing must not resurrect it. + assert_nothing_raised { recompute! } + assert_empty BudgetCategory.where(category_id: @category.id) + end + + test "consumption is measured against the allocation plus the carry" do + first = initialized_budget(2.months.ago) + allocate(first, 100) + spend(50, budget: first) + + second = initialized_budget(1.month.ago) + allocate(second, 100) + spend(30, budget: second) + + recompute! + + # 30 spent out of 100 allocated + 50 carried. + assert_equal 50, stored_rollover(second) + assert_in_delta 20.0, budget_category_for(second).percent_of_budget_spent, 0.01 + end + + test "a category funded only by the carry is not flagged as unbudgeted" do + first = initialized_budget(2.months.ago) + allocate(first, 100) + spend(50, budget: first) + + # Nothing allocated this month -- the envelope lives entirely off what + # it carried. The zero-budget guards must look at 0 + 50, not at 0. + second = initialized_budget(1.month.ago) + allocate(second, 0) + spend(20, budget: second) + + recompute! + budget_category = budget_category_for(second) + + assert_equal 50, stored_rollover(second) + assert_in_delta 40.0, budget_category.percent_of_budget_spent, 0.01 + assert_equal 30, budget_category.available_to_spend + + assert budget_category.budgeted? + assert_not budget_category.over_budget? + assert_not budget_category.unbudgeted_with_spending? + assert_not budget_category.any_over_budget? + assert budget_category.on_track? + end + + test "an inheriting subcategory measures itself against its parent's carry" do + subcategory = @family.categories.create!(name: "Flights", parent: @category, color: "#6172F3") + + first = initialized_budget(2.months.ago) + allocate(first, 200) + spend(50, budget: first) + + second = initialized_budget(1.month.ago) + allocate(second, 200) + second_subcategory = second.budget_categories.find_by!(category: subcategory) + second_subcategory.update!(budgeted_spending: 0) + spend(40, budget: second, category: subcategory) + + recompute! + + assert_equal 150, stored_rollover(second) + assert second_subcategory.reload.inherits_parent_budget? + + # 40 spent against the parent's 200 allocated + 150 carried. + assert_in_delta 11.43, second_subcategory.percent_of_budget_spent, 0.01 + 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 "opting out of a month forfeits its surplus even if a later month opts back in" do + first = initialized_budget(3.months.ago) + allocate(first, 100) + spend(30, budget: first) + + # The user deliberately switches the envelope off for this month. + second = initialized_budget(2.months.ago) + allocate(second, 100, rollover: false) + + # ...and switches it back on the month after. The 100 they gave up must + # not come back: an opted-out month neither receives nor sends. + third = initialized_budget(1.month.ago) + allocate(third, 100) + + recompute! + + assert_equal 0, stored_rollover(second) + assert_equal 0, stored_rollover(third) + assert_equal 100, budget_category_for(third).available_to_spend + end + + test "the chain is locked before anything is written, and per chain" do + @family.update!(personal_budgets: true) + josh = users(:josh) + + first = initialized_budget(2.months.ago) + allocate(first, 100) + spend(30, budget: first) + second = initialized_budget(1.month.ago) + allocate(second, 100) + + household_sql = capture_sql { recompute! } + + lock = household_sql.index { |sql| sql.include?("pg_advisory_xact_lock") } + write = household_sql.index { |sql| sql.include?("INSERT INTO \"budget_categories\"") } + + assert lock, "the chain must be locked before its derived carry is written" + assert write, "expected this recompute to write" + assert lock < write, "the lock has to be taken before the read-then-write, not after" + + # A different chain must not queue behind this one: the key names the + # (family, owner) pair, so household and personal recomputes are free to + # run at the same time. + josh_first = initialized_budget(2.months.ago, user: josh) + josh_first.budget_categories.find_by!(category: @category).update!(budgeted_spending: 100, rollover_enabled: true) + josh_sql = capture_sql { recompute!(user: josh) } + + assert_not_equal household_sql[lock], + josh_sql.find { |sql| sql.include?("pg_advisory_xact_lock") }, + "each chain gets its own lock key" + 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 capture_sql + statements = [] + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args| + payload = args.last + statements << payload[:sql] unless payload[:name].to_s =~ /SCHEMA|TRANSACTION/ + end + yield + statements + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) + end + + def create_account(owner:, name: "Rollover Checking") + @family.accounts.create!( + accountable: Depository.new, + name: name, + currency: "USD", + balance: 10_000, + status: "active", + owner: owner + ) + end + + def initialized_budget(date, user: nil) + Budget.find_or_bootstrap(@family, start_date: date, user: user).tap do |budget| + budget.update!(budgeted_spending: 5_000, expected_income: 7_000) + end + end + + def allocate(budget, amount, rollover: true) + budget.budget_categories.find_by!(category: @category).tap do |budget_category| + budget_category.update!(budgeted_spending: amount, rollover_enabled: rollover) + end + end + + def spend(amount, budget:, category: @category) + create_transaction(account: @account, date: budget.start_date, amount: amount, category: category) + end + + def recompute!(user: nil) + Budget::RolloverCalculator.new(family: @family, user: user).recompute! + end + + def budget_category_for(budget) + BudgetCategory.find_by!(budget_id: budget.id, category: @category) + end + + def stored_rollover(budget) + budget_category_for(budget)[:rolled_over_amount] + end +end diff --git a/test/models/budget_test.rb b/test/models/budget_test.rb index 214f48e54..cd8fc98ec 100644 --- a/test/models/budget_test.rb +++ b/test/models/budget_test.rb @@ -436,6 +436,50 @@ class BudgetTest < ActiveSupport::TestCase assert_equal 500, target_bc.budgeted_spending end + test "copy_from copies the rollover toggle but not the rolled over amount" 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) + source_bc = source_budget.budget_categories.find_by(category: categories(:food_and_drink)) + source_bc.update!(budgeted_spending: 500, rollover_enabled: true) + source_bc.update_column(:rolled_over_amount, 250) + + target_budget = Budget.find_or_bootstrap(family, start_date: 1.month.ago) + target_budget.copy_from!(source_budget) + + target_bc = target_budget.budget_categories.find_by(category: categories(:food_and_drink)).reload + + assert target_bc.rollover_enabled? + assert_not_equal 250, target_bc[:rolled_over_amount], + "the carry is derived from the chain, never copied from the source" + + # copy_from! changes what the chain should hold, so it must leave it + # recomputed: running the calculator again has nothing left to do. + derived = target_bc[:rolled_over_amount] + Budget::RolloverCalculator.new(family: family, user: nil).recompute! + assert_equal derived, target_bc.reload[:rolled_over_amount] + end + + test "rollover leaves allocated_spending and available_to_allocate alone" do + family = families(:dylan_family) + + budget = Budget.find_or_bootstrap(family, start_date: 1.month.ago) + budget.update!(budgeted_spending: 4000, expected_income: 6000) + budget_category = budget.budget_categories.find_by(category: categories(:food_and_drink)) + budget_category.update!(budgeted_spending: 500, rollover_enabled: true) + + allocated_before = budget.reload.allocated_spending + available_before = budget.available_to_allocate + + budget_category.update_column(:rolled_over_amount, 120) + budget.reload + + assert_equal allocated_before, budget.allocated_spending + assert_equal available_before, budget.available_to_allocate + assert_equal 120, budget.total_rolled_over + end + test "copy_from skips categories that dont exist in target" do family = families(:dylan_family)