Files
sure/test/models/budget_category_test.rb
T
3d6a8d8b6e feat(budgets): move money between envelopes in one gesture (#3164)
* 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

* feat(budgets): move money between envelopes in one gesture

Overspending one category and covering it from another meant editing two
allocations by hand, with no atomicity: the budget could sit
over-allocated between the two saves, and a failure left it there.

`BudgetCategory.move_allocation!` does both sides in one transaction.
Deliberately no new table — v1 stores the resulting allocations and keeps
no history of the move itself.

Refused, each with its own localized message: an amount at or below zero,
more than the source has, two categories from different budgets, a
category and itself, "Uncategorized" (synthesized on read, it has no row),
and — the one that is not obvious — a category and its own direct parent
or child. `sync_parent_budgeted_spending!` rebuilds a parent from the sum
of its children plus its reserve, so money moved across that boundary
would be re-derived away and the total would not be conserved.

Lock order is the delicate part. `update_budgeted_spending!` locks its own
row and, for a subcategory, its parent, so two simultaneous moves in
opposite directions could each hold what the other needs. Every row the
operation will touch — both ends and their parents — is locked up front by
ascending id.

The rollover chain is recomputed by the caller AFTER the move commits,
never inside it. `Budget::RolloverCalculator` takes a transaction-scoped
advisory lock, and taking it while these row locks are held would invert
the order `#update` already established: one request holding rows and
waiting for the advisory lock, another holding the advisory lock and
waiting for those rows. A model test pins that `move_allocation!` never
recomputes on its own.

The recompute is not optional. A move is neutral for
`Budget#allocated_spending`, but not for the carry: `leftover_for` is
budgeted + rolled_over − actual, so moving money changes what both
envelopes hand to the next month.

UI is one native `<dialog>` shared by the page rather than one per row,
opened from a discreet button on each envelope that has something to give.
The Stimulus controller has 6 targets and disables the options the server
would refuse anyway, so an impossible move is never offered.

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

* fix(budgets): use the design-system dialog, and stop a parent lending its children's money

Addresses review feedback on #3164.

**The move dialog was hand-rolled.** `DS::Dialog` already exists and already
carries focus trapping, Escape, click-outside, focus restore and the
design-system chrome; rewriting those by hand is how they end up subtly wrong,
and the guidelines say to reach for the primitive first. It keeps the
one-dialog-for-the-page shape — the list holds dozens of rows and a per-row
dialog would be dozens of copies of the same markup — via `auto_open: false`
and `disable_frame: true`.

**It also stayed open after a successful move,** still showing the previous
source and amount. It now closes on `turbo:submit-end`, and only when Turbo
reports success: closing on submit alone would hide the reason a move was
refused.

**Submit was enabled with nowhere to send.** A lone envelope, or one whose only
peers are its own parent and children, offered a button whose only outcome was
a server error. The form now says so and disables itself.

**A parent could send away its children's money.** `budgeted_spending` on a
parent already contains its individually funded subcategories' allocations, so
comparing against the gross figure let a move spend what a child had
ring-fenced. The parent dropped below the sum of its children, and the next
edit to any child rebuilt it — the money appeared to teleport back. The
movable amount for a parent is now its own reserve.

`test "moving the whole allocation is allowed, moving one cent more is not"`
moved a parent's gross amount and passed: it encoded that bug. It now uses a
leaf as its source, where "the whole allocation" is the whole of it, and the
parent boundary gets its own pair of tests.

**A negative carry could be written.** The calculator floors it at zero but
writes through `upsert_all`, and a negative `rolled_over_amount` would quietly
subtract from `available_to_spend`. Now a CHECK constraint, verified by
replaying the migration on a throwaway database.

bin/rails test: 6967 runs, 28020 assertions, 0 failures. RuboCop, erb_lint,
Brakeman and biome clean.

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

* fix(budgets): carry the rollover choice into months already open, and mend the API schema

Addresses the remaining review feedback on #3164.

**Enabling rollover skipped months that already existed.** Inheritance runs
when `sync_budget_categories` creates a missing row, so it only ever reaches
months that do not exist yet. A user who opened March, then went back to
January and switched rollover on, left March sitting at `false` — created
before the choice was made, so it had nothing to inherit — and the chain died
there.

The toggle is a standing choice about the envelope, which is what
`inherited_rollover_flags` already says: "turning it off on a given month still
overrides it from there on." Applying the choice forward closes the hole
without a tri-state column. Later months take the most recent decision, which
is the one the user just made; earlier months keep theirs.

**`rollover_enabled` was emitted but not required.** The shared partial always
sends it in both list and detail responses. Added to the `required` list of
`BudgetCategorySummary` and `BudgetCategory` in `spec/swagger_helper.rb`, then
regenerated.

**`type: file` is not valid OpenAPI 3.0.3.** A Swagger 2.0 leftover in the
merchants import spec, which generated clients that send nothing the controller
can read. It surfaced now because this branch is the first to regenerate
`openapi.yaml` since it was written — `origin/main` has no occurrence of it.
Spelled as a string with `format: binary` instead.

Regeneration produced a four-line diff, so the checked-in document was already
in sync otherwise.

bin/rails test: 6969 runs, 28023 assertions, 0 failures. RuboCop and Brakeman
clean; 324 rswag examples pass.

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

* fix(budgets): stop the API serving a carry the web pages would have refreshed

Addresses the remaining P1 on #3164, and its duplicate on #3143.

The objection was that nothing recomputes when a sync, an edit or a
recategorisation changes spending in an earlier month. On the web that is by
design and measured: every surface showing the carry goes through
`Budget.find_or_bootstrap`, so it recomputes on the way in, and the alternative
— recomputing on every transaction write — buys nothing a page load does not
already give.

The API is the case that argument does not cover, and the review was right
about it. `Api::V1::BudgetCategoriesController` reads `rolled_over_amount`
straight off the column, so it was the one surface that could serve a stale
carry indefinitely, until somebody happened to open the budget page.

It now recomputes the chains it is about to read. A read that writes is a
smell, but it is the same bargain the budget page already makes, applied to the
surface that was missed: the walk is per family, and the calculator's leading
EXISTS makes it a single query that writes nothing for a family that never
turned rollover on.

Also from review: the move dialog's amount field allowed `min: 0` while
`move_allocation!` rejects zero as non-positive. Browser validation now matches
the server contract, at the currency step.

bin/rails test: 6970 runs, 28025 assertions, 0 failures. Confirmed load-bearing
by removing the callback and watching the new API test fail. RuboCop, erb_lint
and Brakeman clean.

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

* Fix budget rollover schema delta

* Remove duplicate rollover test class

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
2026-08-26 07:01:12 +02:00

953 lines
37 KiB
Ruby

require "test_helper"
class BudgetCategoryTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@budget = budgets(:one)
# Create parent category with unique name
@parent_category = Category.create!(
name: "Test Food & Groceries #{Time.now.to_f}",
family: @family,
color: "#4da568",
lucide_icon: "utensils"
)
# Create subcategories with unique names
@subcategory_with_limit = Category.create!(
name: "Test Restaurants #{Time.now.to_f}",
parent: @parent_category,
family: @family
)
@subcategory_inheriting = Category.create!(
name: "Test Groceries #{Time.now.to_f}",
parent: @parent_category,
family: @family
)
# Create budget categories
@parent_budget_category = BudgetCategory.create!(
budget: @budget,
category: @parent_category,
budgeted_spending: 1000,
currency: "USD"
)
@subcategory_with_limit_bc = BudgetCategory.create!(
budget: @budget,
category: @subcategory_with_limit,
budgeted_spending: 300,
currency: "USD"
)
@subcategory_inheriting_bc = BudgetCategory.create!(
budget: @budget,
category: @subcategory_inheriting,
budgeted_spending: 0, # Inherits from parent
currency: "USD"
)
end
test "subcategory with zero budget inherits from parent" do
assert @subcategory_inheriting_bc.inherits_parent_budget?
refute @subcategory_with_limit_bc.inherits_parent_budget?
refute @parent_budget_category.inherits_parent_budget?
end
test "parent_budget_category returns parent for subcategories" do
assert_equal @parent_budget_category, @subcategory_inheriting_bc.parent_budget_category
assert_equal @parent_budget_category, @subcategory_with_limit_bc.parent_budget_category
assert_nil @parent_budget_category.parent_budget_category
end
test "display_budgeted_spending shows parent budget for inheriting subcategories" do
assert_equal 1000, @subcategory_inheriting_bc.display_budgeted_spending
assert_equal 300, @subcategory_with_limit_bc.display_budgeted_spending
assert_equal 1000, @parent_budget_category.display_budgeted_spending
end
test "inheriting subcategory shares parent available_to_spend" do
# Mock the actual spending values
# Parent's actual_spending from income_statement includes all children
@budget.stubs(:budget_category_actual_spending).with(@parent_budget_category).returns(150)
@budget.stubs(:budget_category_actual_spending).with(@subcategory_with_limit_bc).returns(100)
@budget.stubs(:budget_category_actual_spending).with(@subcategory_inheriting_bc).returns(50)
# Parent available calculation:
# shared_pool = 1000 (parent budget) - 300 (subcategory with limit budget) = 700
# shared_pool_spending = 150 (total) - 100 (subcategory with limit spending) = 50
# available = 700 - 50 = 650
assert_equal 650, @parent_budget_category.available_to_spend
# Inheriting subcategory shares parent's available (650)
assert_equal 650, @subcategory_inheriting_bc.available_to_spend
# Subcategory with limit: 300 (its budget) - 100 (its spending) = 200
assert_equal 200, @subcategory_with_limit_bc.available_to_spend
end
test "percent_of_budget_spent for inheriting subcategory uses parent budget" do
# Mock spending
@budget.stubs(:budget_category_actual_spending).with(@subcategory_inheriting_bc).returns(100)
# 100 / 1000 (parent budget) = 10%
assert_equal 10.0, @subcategory_inheriting_bc.percent_of_budget_spent
end
test "parent with no subcategories works as before" do
# Create a standalone parent category without subcategories
standalone_category = Category.create!(
name: "Test Entertainment #{Time.now.to_f}",
family: @family,
color: "#a855f7",
lucide_icon: "drama"
)
standalone_bc = BudgetCategory.create!(
budget: @budget,
category: standalone_category,
budgeted_spending: 500,
currency: "USD"
)
# Mock spending
@budget.stubs(:budget_category_actual_spending).with(standalone_bc).returns(200)
# Should work exactly as before: 500 - 200 = 300
assert_equal 300, standalone_bc.available_to_spend
assert_equal 40.0, standalone_bc.percent_of_budget_spent
end
test "uncategorized budget category returns no subcategories" do
uncategorized_bc = BudgetCategory.uncategorized
uncategorized_bc.budget = @budget
# Before the fix, this would return all top-level categories because
# category.id is nil, causing WHERE parent_id IS NULL to match all roots
assert_empty uncategorized_bc.subcategories
end
test "parent with only inheriting subcategories shares entire budget" do
# Set subcategory_with_limit to also inherit
@subcategory_with_limit_bc.update!(budgeted_spending: 0)
# Mock spending
@budget.stubs(:budget_category_actual_spending).with(@parent_budget_category).returns(200)
@budget.stubs(:budget_category_actual_spending).with(@subcategory_with_limit_bc).returns(100)
@budget.stubs(:budget_category_actual_spending).with(@subcategory_inheriting_bc).returns(100)
# All should show same available: 1000 - 200 = 800
assert_equal 800, @parent_budget_category.available_to_spend
assert_equal 800, @subcategory_with_limit_bc.available_to_spend
assert_equal 800, @subcategory_inheriting_bc.available_to_spend
end
test "update_budgeted_spending! preserves positive parent reserve when subcategory becomes individual" do
@subcategory_inheriting_bc.update_budgeted_spending!(200)
assert_equal 1200, @parent_budget_category.reload.budgeted_spending
assert_equal 200, @subcategory_inheriting_bc.reload.budgeted_spending
refute @subcategory_inheriting_bc.reload.inherits_parent_budget?
end
test "update_budgeted_spending! lowers parent when subcategory returns to shared" do
@subcategory_with_limit_bc.update_budgeted_spending!(0)
assert_equal 700, @parent_budget_category.reload.budgeted_spending
assert @subcategory_with_limit_bc.reload.inherits_parent_budget?
end
test "update_budgeted_spending! does not preserve a negative parent reserve" do
# Create an artificial inconsistent parent total to verify recovery behavior.
@parent_budget_category.update!(budgeted_spending: 50)
@subcategory_inheriting_bc.update!(budgeted_spending: 50)
@subcategory_with_limit_bc.update_budgeted_spending!(20)
assert_equal 70, @parent_budget_category.reload.budgeted_spending
assert_equal 20, @subcategory_with_limit_bc.reload.budgeted_spending
assert_equal 50, @subcategory_inheriting_bc.reload.budgeted_spending
end
test "budgeted? returns true only when display_budgeted_spending > 0" do
@subcategory_with_limit_bc.stubs(:display_budgeted_spending).returns(100)
assert @subcategory_with_limit_bc.budgeted?
@subcategory_with_limit_bc.stubs(:display_budgeted_spending).returns(0)
refute @subcategory_with_limit_bc.budgeted?
@subcategory_with_limit_bc.stubs(:display_budgeted_spending).returns(nil)
refute @subcategory_with_limit_bc.budgeted?
end
test "unbudgeted_with_spending? is true only when not budgeted and has spending" do
@subcategory_with_limit_bc.stubs(:budgeted?).returns(false)
@subcategory_with_limit_bc.stubs(:actual_spending).returns(10)
assert @subcategory_with_limit_bc.unbudgeted_with_spending?
@subcategory_with_limit_bc.stubs(:budgeted?).returns(true)
assert_not @subcategory_with_limit_bc.unbudgeted_with_spending?
@subcategory_with_limit_bc.stubs(:budgeted?).returns(false)
@subcategory_with_limit_bc.stubs(:actual_spending).returns(0)
assert_not @subcategory_with_limit_bc.unbudgeted_with_spending?
@subcategory_with_limit_bc.stubs(:actual_spending).returns(nil)
assert_not @subcategory_with_limit_bc.unbudgeted_with_spending?
end
test "over_budget_with_budget? requires both budgeted and over_budget" do
@subcategory_with_limit_bc.stubs(:budgeted?).returns(true)
@subcategory_with_limit_bc.stubs(:over_budget?).returns(true)
assert @subcategory_with_limit_bc.over_budget_with_budget?
@subcategory_with_limit_bc.stubs(:over_budget?).returns(false)
assert_not @subcategory_with_limit_bc.over_budget_with_budget?
@subcategory_with_limit_bc.stubs(:budgeted?).returns(false)
@subcategory_with_limit_bc.stubs(:over_budget?).returns(true)
assert_not @subcategory_with_limit_bc.over_budget_with_budget?
end
test "on_track? is true only when budgeted and not over_budget" do
@subcategory_with_limit_bc.stubs(:budgeted?).returns(true)
@subcategory_with_limit_bc.stubs(:over_budget?).returns(false)
assert @subcategory_with_limit_bc.on_track?
@subcategory_with_limit_bc.stubs(:over_budget?).returns(true)
assert_not @subcategory_with_limit_bc.on_track?
@subcategory_with_limit_bc.stubs(:budgeted?).returns(false)
@subcategory_with_limit_bc.stubs(:over_budget?).returns(false)
assert_not @subcategory_with_limit_bc.on_track?
end
test "any_over_budget? is true if either condition is true" do
@subcategory_with_limit_bc.stubs(:unbudgeted_with_spending?).returns(true)
@subcategory_with_limit_bc.stubs(:over_budget_with_budget?).returns(false)
assert @subcategory_with_limit_bc.any_over_budget?
@subcategory_with_limit_bc.stubs(:unbudgeted_with_spending?).returns(false)
@subcategory_with_limit_bc.stubs(:over_budget_with_budget?).returns(true)
assert @subcategory_with_limit_bc.any_over_budget?
@subcategory_with_limit_bc.stubs(:unbudgeted_with_spending?).returns(false)
@subcategory_with_limit_bc.stubs(:over_budget_with_budget?).returns(false)
assert_not @subcategory_with_limit_bc.any_over_budget?
end
test "visible_on_track? behavior for different category types" do
# 1. not on_track => always false
@subcategory_with_limit_bc.stubs(:on_track?).returns(false)
assert_not @subcategory_with_limit_bc.visible_on_track?
# 2. normal category (not subcategory) => true if on_track
@parent_budget_category.stubs(:on_track?).returns(true)
assert @parent_budget_category.visible_on_track?
# 3. subcategory inheriting, no spending => hidden
@subcategory_inheriting_bc.stubs(:on_track?).returns(true)
@subcategory_inheriting_bc.stubs(:actual_spending).returns(0)
assert_not @subcategory_inheriting_bc.visible_on_track?
# 4. subcategory inheriting, has spending => visible
@subcategory_inheriting_bc.stubs(:actual_spending).returns(10)
assert @subcategory_inheriting_bc.visible_on_track?
end
test "suggested_daily_spending uses budget.end_date for custom month periods" do
@family.update!(month_start_day: 15)
# Today (Jun 1) is in the calendar month after the budget period start (May 15).
# The pre-fix helper compared start_date.month to Date.current.month and returned nil here.
travel_to Date.new(2026, 6, 1) do
@budget.update!(start_date: Date.new(2026, 5, 15), end_date: Date.new(2026, 6, 14))
@parent_budget_category.stubs(:actual_spending).returns(0)
suggestion = @parent_budget_category.suggested_daily_spending
assert suggestion, "expected suggested_daily_spending when current period spans calendar months"
assert_equal 14, suggestion[:days_remaining]
end
end
# --- move_allocation! (Lot A2) ---
test "moving money between two top-level envelopes conserves the total" do
other_parent = Category.create!(name: "Test Transport #{Time.now.to_f}", family: @family, color: "#e99537")
destination = BudgetCategory.create!(budget: @budget, category: other_parent, budgeted_spending: 200, currency: "USD")
before = @budget.reload.allocated_spending
BudgetCategory.move_allocation!(from: @parent_budget_category, to: destination, amount: 50)
assert_equal 950, @parent_budget_category.reload.budgeted_spending
assert_equal 250, destination.reload.budgeted_spending
assert_equal before, @budget.reload.allocated_spending, "allocated_spending must be invariant"
end
# Deliberately a leaf as the source: only there does "the whole allocation"
# mean the whole of it. A parent's figure already contains its individually
# funded children's, so its boundary is its own reserve — covered separately
# below. This test used to move a parent's gross amount and pass, which is
# exactly the money-teleports-back bug.
test "moving the whole allocation is allowed, moving one cent more is not" do
other_parent = Category.create!(name: "Test Transport #{Time.now.to_f}", family: @family, color: "#e99537")
destination = BudgetCategory.create!(budget: @budget, category: other_parent, budgeted_spending: 0, currency: "USD")
source = @subcategory_with_limit_bc.reload
whole = source.budgeted_spending
assert_raises(BudgetCategory::InvalidMove) do
BudgetCategory.move_allocation!(from: source, to: destination, amount: whole + 0.01)
end
BudgetCategory.move_allocation!(from: source, to: destination, amount: whole)
assert_equal 0, source.reload.budgeted_spending
assert_equal whole, destination.reload.budgeted_spending
end
test "an amount larger than the source allocation is refused" do
other_parent = Category.create!(name: "Test Transport #{Time.now.to_f}", family: @family, color: "#e99537")
destination = BudgetCategory.create!(budget: @budget, category: other_parent, budgeted_spending: 0, currency: "USD")
error = assert_raises(BudgetCategory::InvalidMove) do
BudgetCategory.move_allocation!(from: @parent_budget_category, to: destination, amount: 1001)
end
assert_equal :insufficient_funds, error.reason
assert_equal 1000, @parent_budget_category.reload.budgeted_spending
end
test "a zero or negative amount is refused" do
other_parent = Category.create!(name: "Test Transport #{Time.now.to_f}", family: @family, color: "#e99537")
destination = BudgetCategory.create!(budget: @budget, category: other_parent, budgeted_spending: 0, currency: "USD")
[ 0, -50 ].each do |amount|
error = assert_raises(BudgetCategory::InvalidMove) do
BudgetCategory.move_allocation!(from: @parent_budget_category, to: destination, amount: amount)
end
assert_equal :non_positive_amount, error.reason
end
end
test "categories from two different budgets cannot exchange money" do
other_budget = Budget.create!(
family: @family,
start_date: @budget.start_date - 1.month,
end_date: @budget.start_date - 1.day,
currency: @budget.currency
)
foreign_category = Category.create!(name: "Test Foreign #{Time.now.to_f}", family: @family, color: "#e99537")
foreign = BudgetCategory.create!(budget: other_budget, category: foreign_category, budgeted_spending: 100, currency: other_budget.currency)
error = assert_raises(BudgetCategory::InvalidMove) do
BudgetCategory.move_allocation!(from: @parent_budget_category, to: foreign, amount: 10)
end
assert_equal :different_budgets, error.reason
end
test "a category cannot move money to itself" do
error = assert_raises(BudgetCategory::InvalidMove) do
BudgetCategory.move_allocation!(from: @parent_budget_category, to: @parent_budget_category, amount: 10)
end
assert_equal :same_category, error.reason
end
# sync_parent_budgeted_spending! rebuilds a parent from its children, so a
# parent <-> child move would be re-derived away.
test "money cannot move between a parent and its own subcategory, in either direction" do
[ [ @parent_budget_category, @subcategory_with_limit_bc ],
[ @subcategory_with_limit_bc, @parent_budget_category ] ].each do |from, to|
error = assert_raises(BudgetCategory::InvalidMove) do
BudgetCategory.move_allocation!(from: from, to: to, amount: 50)
end
assert_equal :parent_child, error.reason
end
end
test "Uncategorized can neither give nor receive" do
[ [ BudgetCategory.uncategorized, @parent_budget_category ],
[ @parent_budget_category, BudgetCategory.uncategorized ] ].each do |from, to|
error = assert_raises(BudgetCategory::InvalidMove) do
BudgetCategory.move_allocation!(from: from, to: to, amount: 10)
end
assert_equal :uncategorized, error.reason
end
end
# A subcategory's allocation is folded into its parent's, so moving money
# out of one must pull the parent down by the same amount and leave the
# budget total untouched.
test "a move out of a subcategory keeps its parent consistent" do
other_parent = Category.create!(name: "Test Transport #{Time.now.to_f}", family: @family, color: "#e99537")
destination = BudgetCategory.create!(budget: @budget, category: other_parent, budgeted_spending: 0, currency: "USD")
before = @budget.reload.allocated_spending
BudgetCategory.move_allocation!(from: @subcategory_with_limit_bc, to: destination, amount: 100)
assert_equal 200, @subcategory_with_limit_bc.reload.budgeted_spending
assert_equal 100, destination.reload.budgeted_spending
assert_equal 900, @parent_budget_category.reload.budgeted_spending,
"the parent must absorb its subcategory's decrease"
assert_equal before, @budget.reload.allocated_spending, "allocated_spending must be invariant"
end
# A parent's budgeted_spending already contains its individually funded
# subcategories', so treating the gross figure as movable let a move spend
# money a child had ring-fenced. The parent dropped below the sum of its
# children, and the next edit to any child rebuilt it — the money appeared
# to teleport back.
test "a parent can only send away its own reserve, not its children's money" do
other_parent = Category.create!(name: "Test Transport #{Time.now.to_f}", family: @family, color: "#e99537")
destination = BudgetCategory.create!(budget: @budget, category: other_parent, budgeted_spending: 0, currency: "USD")
parent_gross = @parent_budget_category.reload.budgeted_spending
ring_fenced = @subcategory_with_limit_bc.reload.budgeted_spending
reserve = parent_gross - ring_fenced
assert_operator ring_fenced, :>, 0, "fixture should ring-fence part of the parent"
error = assert_raises(BudgetCategory::InvalidMove) do
BudgetCategory.move_allocation!(from: @parent_budget_category, to: destination, amount: reserve + 1)
end
assert_equal :insufficient_funds, error.reason
assert_equal parent_gross, @parent_budget_category.reload.budgeted_spending
end
test "a parent may still send away every penny of its own reserve" do
other_parent = Category.create!(name: "Test Transport #{Time.now.to_f}", family: @family, color: "#e99537")
destination = BudgetCategory.create!(budget: @budget, category: other_parent, budgeted_spending: 0, currency: "USD")
reserve = @parent_budget_category.reload.budgeted_spending - @subcategory_with_limit_bc.reload.budgeted_spending
before_total = @budget.reload.allocated_spending
BudgetCategory.move_allocation!(from: @parent_budget_category, to: destination, amount: reserve)
assert_equal reserve, destination.reload.budgeted_spending
assert_equal before_total, @budget.reload.allocated_spending, "allocated_spending must be invariant"
end
test "a move between two subcategories of the same parent leaves the parent alone" do
before_parent = @parent_budget_category.reload.budgeted_spending
before_total = @budget.reload.allocated_spending
@subcategory_inheriting_bc.update_budgeted_spending!(100)
BudgetCategory.move_allocation!(from: @subcategory_with_limit_bc, to: @subcategory_inheriting_bc, amount: 50)
assert_equal 250, @subcategory_with_limit_bc.reload.budgeted_spending
assert_equal 150, @subcategory_inheriting_bc.reload.budgeted_spending
assert_equal before_parent + 100, @parent_budget_category.reload.budgeted_spending
assert_equal before_total + 100, @budget.reload.allocated_spending
end
# The rollover chain is the caller's job, never the move's: taking the
# calculator's advisory lock while these row locks are held would invert
# the lock order and deadlock two concurrent moves.
test "move_allocation! does not recompute the rollover chain itself" do
other_parent = Category.create!(name: "Test Transport #{Time.now.to_f}", family: @family, color: "#e99537")
destination = BudgetCategory.create!(budget: @budget, category: other_parent, budgeted_spending: 0, currency: "USD")
Budget::RolloverCalculator.any_instance.expects(:recompute!).never
BudgetCategory.move_allocation!(from: @parent_budget_category, to: destination, amount: 10)
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
# Inheritance at row creation only covers months that do not exist yet. A
# month opened BEFORE the user made the choice was created with the flag off
# and had nothing to inherit, so the chain died there.
test "switching rollover on reaches months that were already open" do
first = initialized_budget(2.months.ago)
later = initialized_budget(1.month.ago)
allocate(first, 100, rollover: false)
allocate(later, 100, rollover: false)
budget_category_for(first).update!(rollover_enabled: true)
budget_category_for(first).propagate_rollover_choice_forward!
assert budget_category_for(later).reload.rollover_enabled?
end
test "switching it off reaches them too, and never runs backwards" do
first = initialized_budget(2.months.ago)
middle = initialized_budget(1.month.ago)
allocate(first, 100)
allocate(middle, 100)
budget_category_for(middle).update!(rollover_enabled: false)
budget_category_for(middle).propagate_rollover_choice_forward!
assert_not budget_category_for(middle).reload.rollover_enabled?
assert budget_category_for(first).reload.rollover_enabled?,
"an earlier month keeps the choice it was given"
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