mirror of
https://github.com/we-promise/sure.git
synced 2026-09-06 15:14:19 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3bdfe9521e
commit
1fddb4d97c
@@ -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)
|
||||
|
||||
+53
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -64,6 +64,11 @@
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-gray-tint-5 text-secondary"><%= t("reports.budget_performance.shared") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if budget_category.rolled_over? %>
|
||||
<div class="whitespace-nowrap w-full sm:w-auto text-xs text-secondary privacy-sensitive">
|
||||
<%= t("budget_categories.budget_category.rolled_over", amount: format_money(budget_category.rolled_over_amount_money)) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if budget_category.suggested_daily_spending.present? %>
|
||||
<% daily_info = budget_category.suggested_daily_spending %>
|
||||
<div class="w-full sm:w-auto text-xs text-subdued">
|
||||
|
||||
@@ -13,17 +13,31 @@
|
||||
|
||||
<div class="ml-auto">
|
||||
<%= 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| %>
|
||||
<div class="form-field w-[120px]">
|
||||
<div class="flex items-center privacy-sensitive privacy-sensitive-interactive">
|
||||
<span class="text-secondary text-sm mr-2"><%= currency.symbol %></span>
|
||||
<%= 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 %>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1.5" title="<%= t("budget_categories.budget_category_form.rollover_title") %>">
|
||||
<%= 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" }
|
||||
) %>
|
||||
</div>
|
||||
|
||||
<div class="form-field w-[120px]">
|
||||
<div class="flex items-center privacy-sensitive privacy-sensitive-interactive">
|
||||
<span class="text-secondary text-sm mr-2"><%= currency.symbol %></span>
|
||||
<%= 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 %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -68,6 +68,15 @@
|
||||
<%= format_money @budget_category.budgeted_spending_money %>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<% if @budget_category.rolled_over? %>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<dt class="text-secondary"><%= t(".rolled_over") %></dt>
|
||||
<dd class="text-primary font-medium privacy-sensitive">
|
||||
<%= format_money @budget_category.rolled_over_amount_money %>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Generated
+3
-1
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user