Files
sure/app/models/budget.rb
T
buzzromainandClaude Opus 5 1fddb4d97c feat(budgets): carry a category's unspent budget into the next month (#3143)
* feat(budgets): carry a category's unspent budget into the next month

A budget category resets to zero every month, so anything non-monthly
(annual insurance, a holiday fund, car servicing) has no place to
accumulate. Two columns on budget_categories turn a category into a real
envelope: `rollover_enabled`, opt-in per category and off by default, and
`rolled_over_amount`, the surplus carried in from the previous month.

  rolled_over(n) = rollover_enabled
                   ? max(0, budgeted(n-1) + rolled_over(n-1) - actual(n-1))
                   : 0

v1 floors at zero: only a surplus carries, never an overspend.

The amount is materialized, not derived. March depends on February which
depends on January, so computing it on read would walk the whole chain on
every budget render. Budget::RolloverCalculator recomputes it in a single
forward pass and writes once via upsert_all, from Budget.find_or_bootstrap
and from BudgetCategoriesController#update -- allocations and the toggle
being the only inputs. No Transaction hook: a past month's actuals can
change after the fact, and the page load is a fine moment to catch up.

Scope kept deliberately narrow. `Budget#budgeted_spending`,
`#allocated_spending` and `#available_to_allocate` are untouched -- the top
of the budget page still answers "I planned to spend X, I've allocated Y".
The carry is per-envelope information, surfaced as `Budget#total_rolled_over`
and never folded into those totals.

What the carry does change is consumption: `available_to_spend`,
`percent_of_budget_spent` and `budgeted?` all count it, or a category funded
entirely by rollover would read as unbudgeted and get an alert pill while it
still had money left. `display_budgeted_spending` stays the month's
allocation alone -- the card shows the two figures side by side.

Details worth knowing:

- A parent's carry is net of its ring-fenced subcategories'. A parent's
  allocation already contains theirs and its actuals already contain their
  spending; those subcategories carry their own surplus, so counting the
  parent's raw leftover would roll the same money over twice.
- Chains never mix: household with household, a member's personal budgets
  with their own. A missing month is a gap the carry crosses, not a month
  budgeted at zero.
- The carry stops at a currency change. sync_budget_categories stamps
  categories with family.currency at sync time while a budget freezes its
  own at creation, so the guard is on budget_category.currency -- the unit
  the amount is actually denominated in.
- upsert_all writes with `update_only`, so a concurrent request that moves
  an allocation between our read and our write doesn't get it clobbered by
  the stale value we loaded.
- copy_from! copies the toggle, never the amount.

Cost for families that never turn it on: one EXISTS query per budget page
load, measured, including on the reports page which also bootstraps a
budget. With rollover on, the walk starts at the first month that uses it
rather than at the two-year history bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z

* fix(budgets): pin the household rollover chain to a viewer-independent scope

Addresses review feedback on #3143.

The household budget (user_id NULL) has no owner to scope actuals by, and
`IncomeStatement` falls back to `Current.user` when nobody says otherwise.
The calculator therefore computed one shared `rolled_over_amount` through
whichever member happened to load the page, and each viewer overwrote the
other's number -- last one wins, and a member could infer spending in
accounts they cannot see. `Budget#income_statement_accounts` can now be
overridden, and the calculator pins the household chain to the whole
family so the shared row holds one number. Personal chains are untouched:
they already scope to their owner's accounts and were always deterministic.

`copy_from!` runs after `find_or_bootstrap` has already recomputed the
chain, so copying `rollover_enabled` left the target sitting on a zero carry
until the next page load. It now recomputes before its transaction commits.

The toggle tooltip described the wrong direction. `incoming_carry` checks
the flag of the month being computed, so the toggle governs what that month
*receives* from the previous one, not what it sends forward. Reworded in
English and French.

The concurrency regression test now drives its concurrent write through
`Budget#budget_category_actual_spending`, a public seam, instead of stubbing
a private method of the calculator from another class's test suite.

Each guard was confirmed load-bearing by reverting it and watching its test
fail. bin/rails test: 6939 runs, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z

* 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

* fix(budgets): make a rollover opt-out stop the money in both directions

`incoming_carry` gates what a month receives, but `leftover_for` computed
what it sends regardless of the toggle. So switching rollover off for one
month and back on the next handed the opted-out month's whole allocation to
the month after: the surplus the user meant to forfeit reappeared a month
later. Reproduced at 100, where 0 was expected.

The outgoing carry is now gated on the same flag, which also skips the
actuals lookup for opted-out rows. "Off" now means this envelope does not
roll over, in either direction -- the reading the standing toggle and the
tooltip both promise.

Found by CodeRabbit on #3143. It only became wrong with the standing-choice
inheritance in 2b1cff5a: while the flag was per-month, "off" plausibly meant
"do not accept", and the previous month's surplus reaching a re-armed month
was defensible. Once the flag reads as a property of the envelope, it isn't.

bin/rails test: 6943 runs, 0 failures. Confirmed load-bearing by removing
the guard and watching the new three-month test fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z

* fix(budgets): serialize rollover recomputes for a chain with an advisory lock

`recompute!` reads the whole chain into memory, walks it, then upserts.
Nothing made that atomic: two overlapping recomputes for the same
(family, owner) chain could both load it, and the one that started first
could land its now-stale `rolled_over_amount` on top of the other's.
`update_only` keeps an upsert off allocations, but the carry is the very
column this writes, so nothing protected it. The wrong value survived until
the next page load recomputed it.

The read-then-write now runs inside a transaction holding
`pg_advisory_xact_lock` keyed on the chain, and the walk was extracted so
the guard is legible. The cheap `first_relevant_budget_date` check still
runs first and unlocked, so families that never enabled rollover pay one
query and never contend; the date is re-read under the lock because the
chain may have moved while waiting. The key names the (family, owner) pair,
so a household recompute and a member's personal recompute don't queue
behind each other.

This reverses the spec's "no advisory lock" guidance, at the request of an
upstream maintainer reviewing #3143.

On the test: under transactional fixtures a second connection cannot see the
data, so a true two-connection interleaving test isn't practical here. The
regression test asserts what is observable in-process -- the lock is taken,
it is taken before the write, and two chains produce different keys.
Removing `lock_chain!` makes it fail.

bin/rails test: 6944 runs, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z

* feat(api): expose the rollover toggle and carried amount on budget categories

`available_to_spend` started counting the carry in this branch, so an API
client could receive a category budgeted at 500 with 700 available and
nothing in the payload to account for the difference. The two fields that
explain it are now serialized.

`rollover_enabled` ships with the stored fields, so the summary rendered by
the index action carries it. `rolled_over_amount` sits with the derived
amounts behind `include_derived_amounts`, next to the `available_to_spend`
it accounts for -- the index deliberately omits both, unchanged.

Schemas updated in spec/swagger_helper.rb (BudgetCategory and
BudgetCategorySummary), docs regenerated with rswag, and behavioural
coverage added to the Minitest controller test: the show action returns the
toggle and the carry, and the index returns the toggle without the derived
amount.

Note on docs/api/openapi.yaml: 64 of the 72 added lines are not from this
change. The committed file had drifted from what rswag generates -- specs
for the merchant CSV import and transfer source fees had been added without
regenerating -- and the mandated `rake rswag:specs:swaggerize` picks them up.
Verified by regenerating on a clean tree, where those 64 lines appear on
their own. Hand-trimming them back out would leave the generated file not
matching its generator, so they are included; happy to split them into their
own commit if a maintainer prefers.

bin/rails test: 6945 runs, 0 failures.
ruby test/support/verify_api_endpoint_consistency.rb: OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 06:29:15 +02:00

477 lines
16 KiB
Ruby

class Budget < ApplicationRecord
include Monetizable
PARAM_DATE_FORMAT = "%b-%Y"
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
has_many :budget_categories, -> { includes(:category) }, dependent: :destroy
validates :start_date, :end_date, presence: true
validates :start_date, :end_date, uniqueness: { scope: [ :family_id, :user_id ] }
monetize :budgeted_spending, :expected_income, :allocated_spending,
:actual_spending, :available_to_spend, :available_to_allocate,
:estimated_spending, :estimated_income, :actual_income, :remaining_expected_income,
:total_rolled_over
class << self
def date_to_param(date)
date.strftime(PARAM_DATE_FORMAT).downcase
end
def param_to_date(param, family: nil)
base_date = Date.strptime(param, PARAM_DATE_FORMAT)
if family&.uses_custom_month_start?
Date.new(base_date.year, base_date.month, family.month_start_day)
else
base_date.beginning_of_month
end
end
def budget_date_valid?(date, family:)
budget_start, _ = period_for(date, family: family)
budget_start >= oldest_valid_budget_date(family) &&
budget_start <= latest_valid_budget_start_date(family)
end
def period_for(date, family:)
if family.uses_custom_month_start?
[ family.custom_month_start_for(date), family.custom_month_end_for(date) ]
else
[ date.beginning_of_month, date.end_of_month ]
end
end
# `household: true` explicitly requests the shared household budget
# (user_id NULL) regardless of `user:` — this is what lets a household
# budget and personal budgets coexist once `family.personal_budgets?` is
# on. Without it, `user:` resolves to that user's personal budget when
# personal_budgets is on, or the shared budget otherwise (unchanged
# behavior for families that never turned personal budgets on).
#
# Returns nil if the household budget was explicitly requested but the
# family opted out of it via `household_budget_enabled?`.
def find_or_bootstrap(family, start_date:, user: nil, household: false)
return nil unless budget_date_valid?(start_date, family: family)
return nil if household && family.personal_budgets? && !family.household_budget_enabled?
Budget.transaction do
budget_start, budget_end = period_for(start_date, family: family)
owner = (household || !family.personal_budgets?) ? nil : user
budget = Budget.find_or_create_by!(
family: family,
start_date: budget_start,
end_date: budget_end,
user: owner
) do |b|
b.currency = family.currency
end
budget.current_user = user
budget.sync_budget_categories
Budget::RolloverCalculator.new(family: family, user: owner).recompute!
budget
end
end
private
def oldest_valid_budget_date(family)
two_years_ago = 2.years.ago.beginning_of_month
oldest_entry_date = family.oldest_entry_date.beginning_of_month
[ two_years_ago, oldest_entry_date ].min
end
def latest_valid_budget_start_date(family)
if family.uses_custom_month_start?
family.current_custom_month_period.start_date + 2.years
else
Date.current.beginning_of_month + 2.years
end
end
end
def period
Period.custom(start_date: start_date, end_date: end_date)
end
def to_param
self.class.date_to_param(start_date)
end
def sync_budget_categories
# Category changes can leave the association memoized before this sync runs.
current_categories_by_id = family.categories.reload.index_by(&:id)
current_category_ids = current_categories_by_id.keys.to_set
existing_budget_category_ids = budget_categories.pluck(:category_id).to_set
categories_to_add = current_category_ids - existing_budget_category_ids
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,
rollover_enabled: inherited_rollover.fetch(category_id, false)
)
end
# Remove old categories
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
bc.currency = family.currency
end
end
# Personal budgets only ever reflect the owner's own accounts, regardless
# of who's viewing (a shared read-only/read-write viewer sees the owner's
# numbers, not their own accessible accounts). The household budget keeps
# the pre-personal-budgets behavior: whatever the requesting viewer can
# see, since it has no single owner to scope by.
def transactions
scope = family.transactions.visible.in_period(period)
if user_id.present?
scope = scope.joins(:entry).where(entries: { account_id: family.accounts.where(owner_id: user_id).included_in_reports.select(:id) })
elsif current_user
scope = scope.joins(:entry).where(entries: { account_id: family.accounts.accessible_by(current_user).included_in_reports.select(:id) })
end
scope
end
def name
if family.uses_custom_month_start?
I18n.t(
"budgets.name.custom_range",
start: I18n.l(start_date, format: :short),
end_date: I18n.l(end_date, format: :long)
)
else
I18n.t("budgets.name.month_year", month: I18n.l(start_date, format: :month_year))
end
end
def initialized?
budgeted_spending.present?
end
# The household budget (user_id nil) is visible/editable by every family
# member, matching pre-personal_budgets behavior. A personal budget is
# only visible/editable by its owner, or by someone the owner shared it
# with via BudgetShare.
def viewable_by?(user)
return true if user_id.nil?
return true if user_id == user.id
BudgetShare.exists?(owner_id: user_id, viewer_id: user.id)
end
def editable_by?(user)
return true if user_id.nil?
return true if user_id == user.id
BudgetShare.exists?(owner_id: user_id, viewer_id: user.id, permission: "read_write")
end
def most_recent_initialized_budget
family.budgets
.includes(:budget_categories)
.where("start_date < ?", start_date)
.where.not(budgeted_spending: nil)
.where(user_id: user_id)
.order(start_date: :desc)
.first
end
def copy_from!(source_budget)
raise ArgumentError, "source budget must belong to the same family" unless source_budget.family_id == family_id
raise ArgumentError, "source budget must belong to the same user" unless source_budget.user_id == user_id
raise ArgumentError, "source budget must precede target budget" unless source_budget.start_date < start_date
Budget.transaction do
update!(
budgeted_spending: source_budget.budgeted_spending,
expected_income: source_budget.expected_income
)
target_by_category = budget_categories.index_by(&:category_id)
source_budget.budget_categories.each do |source_bc|
target_bc = target_by_category[source_bc.category_id]
next unless target_bc
# 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
def income_category_totals
net_totals.net_income_categories.reject { |ct| ct.total.zero? }.sort_by(&:weight).reverse
end
def expense_category_totals
net_totals.net_expense_categories.reject { |ct| ct.total.zero? }.sort_by(&:weight).reverse
end
def current?
if family.uses_custom_month_start?
current_period = family.current_custom_month_period
start_date == current_period.start_date && end_date == current_period.end_date
else
start_date == Date.current.beginning_of_month && end_date == Date.current.end_of_month
end
end
# Whole days from today through the period's last day (today counts).
# 0 once the period is over. Also feeds
# BudgetCategory#suggested_daily_spending's per-day split.
def days_remaining
[ (end_date - Date.current).to_i + 1, 0 ].max
end
# Biggest parent categories by what's actually been spent this period,
# falling back to allocation size early in the month before spending
# lands. Categories with neither spend nor an allocation are noise in a
# summary. (The budget_categories association preloads :category.)
def top_spending_categories(limit: 4)
budget_categories
.reject(&:subcategory?)
.reject { |bc| bc.actual_spending.to_d.zero? && bc.budgeted_spending.to_d.zero? }
.sort_by { |bc| [ -bc.actual_spending.to_d, -bc.budgeted_spending.to_d ] }
.first(limit)
end
def previous_budget_param
previous_date = start_date - 1.month
return nil unless self.class.budget_date_valid?(previous_date, family: family)
self.class.date_to_param(previous_date)
end
def next_budget_param
next_date = start_date + 1.month
return nil unless self.class.budget_date_valid?(next_date, family: family)
self.class.date_to_param(next_date)
end
def to_donut_segments_json
unused_segment_id = "unused"
# Continuous gray segment for empty budgets
return [ { color: "var(--budget-unallocated-fill)", amount: 1, id: unused_segment_id } ] unless allocations_valid?
segments = donut_budget_categories.map do |bc|
{ color: bc.category.color, amount: budget_category_actual_spending(bc), id: bc.id }
end
if available_to_spend.positive?
segments.push({ color: "var(--budget-unallocated-fill)", amount: available_to_spend, id: unused_segment_id })
end
segments
end
def donut_budget_categories
categories = budget_categories.reject(&:subcategory?).to_a
uncategorized = uncategorized_budget_category
if budget_category_actual_spending(uncategorized).positive?
categories << uncategorized
end
categories
end
# =============================================================================
# Actuals: How much user has spent on each budget category
# =============================================================================
def estimated_spending
income_statement.median_expense(interval: "month")
end
def actual_spending
net_totals.total_net_expense
end
def budget_category_actual_spending(budget_category)
key = budget_category.category_id || stable_synthetic_key(budget_category.category)
expense = expense_totals_by_category[key]&.total || 0
refund = income_totals_by_category[key]&.total || 0
[ expense - refund, 0 ].max
end
def category_median_monthly_expense(category)
income_statement.median_expense(category: category)
end
def category_avg_monthly_expense(category)
income_statement.avg_expense(category: category)
end
def available_to_spend
(budgeted_spending || 0) - actual_spending
end
def percent_of_budget_spent
return 0 unless budgeted_spending > 0
(actual_spending / budgeted_spending.to_f) * 100
end
def overage_percent
return 0 unless available_to_spend.negative?
available_to_spend.abs / actual_spending.to_f * 100
end
# =============================================================================
# Budget allocations: How much user has budgeted for all parent categories combined
# =============================================================================
def allocated_spending
budget_categories.reject { |bc| bc.subcategory? }.sum(&:budgeted_spending)
end
def allocated_percent
return 0 unless budgeted_spending && budgeted_spending > 0
(allocated_spending / budgeted_spending.to_f) * 100
end
def available_to_allocate
(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
# =============================================================================
# Income: How much user earned relative to what they expected to earn
# =============================================================================
def estimated_income
income_statement.median_income(interval: "month")
end
def actual_income
income_statement.income_totals(period: self.period).total
end
def actual_income_percent
return 0 unless expected_income > 0
(actual_income / expected_income.to_f) * 100
end
def remaining_expected_income
expected_income - actual_income
end
def surplus_percent
return 0 unless remaining_expected_income.negative?
remaining_expected_income.abs / expected_income.to_f * 100
end
private
def income_statement
@income_statement ||= family.income_statement(user: current_user, accounts: income_statement_accounts)
end
# nil for the household budget (IncomeStatement falls back to whatever
# `current_user` can see, unchanged pre-personal-budgets behavior). For a
# personal budget, restrict to the owner's own accounts so a shared
# 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
def net_totals
@net_totals ||= income_statement.net_category_totals(period: period)
end
def expense_totals
@expense_totals ||= income_statement.expense_totals(period: period)
end
def income_totals
@income_totals ||= income_statement.income_totals(period: period)
end
def expense_totals_by_category
@expense_totals_by_category ||= expense_totals.category_totals.index_by { |ct| ct.category.id || stable_synthetic_key(ct.category) }
end
def income_totals_by_category
@income_totals_by_category ||= income_totals.category_totals.index_by { |ct| ct.category.id || stable_synthetic_key(ct.category) }
end
def stable_synthetic_key(category)
if category.uncategorized?
:uncategorized
elsif category.other_investments?
:other_investments
end
end
end