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? %> +