+ <%# Outside the allocation form on purpose: a button inside it would
+ submit the amount field on click. Hidden for an envelope with nothing
+ to give — an inheriting subcategory, or one left at zero. %>
+ <% if budget_category[:budgeted_spending].to_d.positive? %>
+
+ <% end %>
+
<%= 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| %>
">
diff --git a/app/views/budget_categories/_move_dialog.html.erb b/app/views/budget_categories/_move_dialog.html.erb
new file mode 100644
index 000000000..71c4891fa
--- /dev/null
+++ b/app/views/budget_categories/_move_dialog.html.erb
@@ -0,0 +1,80 @@
+<%# locals: (budget:, budget_categories:) %>
+
+<%# One dialog for the whole page — every row's button fills it in. The list can
+ hold dozens of categories and they would all be identical but for the
+ source, so a per-row dialog would be dozens of copies of the same markup.
+
+ Rendered with DS::Dialog rather than a bare
<% else %>
-
+
<%= render "budget_categories/allocation_progress", budget: @budget %>
@@ -46,6 +46,8 @@
<%= render "budget_categories/confirm_button", budget: @budget %>
+
+ <%= render "budget_categories/move_dialog", budget: @budget, budget_categories: @budget_categories %>
<% end %>
diff --git a/app/views/budget_categories/move.turbo_stream.erb b/app/views/budget_categories/move.turbo_stream.erb
new file mode 100644
index 000000000..4622e3669
--- /dev/null
+++ b/app/views/budget_categories/move.turbo_stream.erb
@@ -0,0 +1,29 @@
+<%= flash_notification_stream_items %>
+
+<%= turbo_stream.replace dom_id(@budget, :allocation_progress), partial: "budget_categories/allocation_progress", locals: { budget: @budget } %>
+
+<%= turbo_stream.replace dom_id(@budget, :uncategorized_budget_category_form), partial: "budget_categories/uncategorized_budget_category_form", locals: { budget: @budget } %>
+
+<%= turbo_stream.replace dom_id(@budget, :confirm_button), partial: "budget_categories/confirm_button", locals: { budget: @budget } %>
+
+<%# Both ends of the move, plus whatever their allocation drags along: a
+ subcategory pulls its parent and siblings, a parent pushes down to its
+ children. Re-rendering the same row twice (from and to can be siblings)
+ is harmless — the last replace wins with identical markup. %>
+<% [ @from, @to ].each do |budget_category| %>
+ <%= turbo_stream.replace dom_id(budget_category, :form), partial: "budget_categories/budget_category_form", locals: { budget_category: budget_category } %>
+
+ <% if budget_category.subcategory? %>
+ <% if (parent_budget_category = budget_category.parent_budget_category) %>
+ <%= turbo_stream.replace dom_id(parent_budget_category, :form), partial: "budget_categories/budget_category_form", locals: { budget_category: parent_budget_category } %>
+ <% end %>
+
+ <% budget_category.siblings.each do |sibling| %>
+ <%= turbo_stream.replace dom_id(sibling, :form), partial: "budget_categories/budget_category_form", locals: { budget_category: sibling } %>
+ <% end %>
+ <% else %>
+ <% budget_category.subcategories.each do |subcategory| %>
+ <%= turbo_stream.replace dom_id(subcategory, :form), partial: "budget_categories/budget_category_form", locals: { budget_category: subcategory } %>
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/config/locales/views/budgets/en.yml b/config/locales/views/budgets/en.yml
index da77e6f9a..63d1a0589 100644
--- a/config/locales/views/budgets/en.yml
+++ b/config/locales/views/budgets/en.yml
@@ -82,6 +82,24 @@ en:
shared_title: Leave empty to share parent's budget
confirm_button:
confirm: "Confirm"
+ move:
+ button_title: Move money from this category
+ cancel: Cancel
+ dialog_title: Move money
+ no_destination: This is the only envelope money can move between right now — a category cannot send to its own parent or child.
+ submit: Move
+ amount_label: Amount
+ available: "%{amount} available"
+ from_label: From
+ to_label: To
+ success: Money moved.
+ errors:
+ different_budgets: Both categories must belong to the same budget.
+ insufficient_funds: That is more than this category has allocated.
+ non_positive_amount: Enter an amount greater than zero.
+ parent_child: Money cannot move between a category and its own subcategory — adjust the subcategory directly.
+ same_category: Pick a different category to move the money to.
+ uncategorized: Uncategorized is not a real envelope, so money cannot move in or out of it.
no_categories:
oops: "Oops!"
no_categories_message: "You have not created or assigned any expense categories to your transactions yet."
diff --git a/config/locales/views/budgets/fr.yml b/config/locales/views/budgets/fr.yml
index fc921a9c4..594e7782b 100644
--- a/config/locales/views/budgets/fr.yml
+++ b/config/locales/views/budgets/fr.yml
@@ -19,6 +19,24 @@ fr:
index:
description: Ajustez les budgets des catégories pour fixer des limites de dépenses. Les fonds non alloués seront automatiquement attribués comme non classés.
title: Modifiez vos budgets de catégorie
+ move:
+ amount_label: Montant
+ available: "%{amount} disponible"
+ button_title: Déplacer de l'argent depuis cette catégorie
+ cancel: Annuler
+ dialog_title: Déplacer de l'argent
+ no_destination: C'est la seule enveloppe disponible pour l'instant — une catégorie ne peut pas envoyer vers son propre parent ni vers sa sous-catégorie.
+ errors:
+ different_budgets: Les deux catégories doivent appartenir au même budget.
+ insufficient_funds: C'est plus que ce que cette catégorie a alloué.
+ non_positive_amount: Indiquez un montant supérieur à zéro.
+ parent_child: L'argent ne peut pas circuler entre une catégorie et sa propre sous-catégorie — ajustez directement la sous-catégorie.
+ same_category: Choisissez une autre catégorie de destination.
+ uncategorized: "« Non classé » n'est pas une vraie enveloppe : l'argent ne peut ni y entrer ni en sortir."
+ from_label: Depuis
+ submit: Déplacer
+ success: Argent déplacé.
+ to_label: Vers
no_categories:
new_category: Nouvelle catégorie
no_categories_message: Vous n'avez pas encore créé ou attribué de catégories de dépenses à vos transactions.
diff --git a/config/routes.rb b/config/routes.rb
index d4bec8082..44352608b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -415,7 +415,9 @@ Rails.application.routes.draw do
post :copy_previous, on: :member
get :picker, on: :collection
- resources :budget_categories, only: %i[index show update]
+ resources :budget_categories, only: %i[index show update] do
+ post :move, on: :collection
+ end
end
resources :goals do
diff --git a/db/migrate/20260823000000_add_rollover_to_budget_categories.rb b/db/migrate/20260823000000_add_rollover_to_budget_categories.rb
index 3d61afe96..015f3ca61 100644
--- a/db/migrate/20260823000000_add_rollover_to_budget_categories.rb
+++ b/db/migrate/20260823000000_add_rollover_to_budget_categories.rb
@@ -2,5 +2,13 @@ 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
+
+ # The calculator floors the carry at zero, but it writes through
+ # `upsert_all` and nothing else stops a direct write. A negative carry
+ # would quietly SUBTRACT from `available_to_spend` — an envelope that
+ # shrinks for no visible reason. Enforced in the database because that is
+ # the one door every writer goes through.
+ add_check_constraint :budget_categories, "rolled_over_amount >= 0",
+ name: "chk_budget_categories_rolled_over_amount_non_negative"
end
end
diff --git a/db/schema.rb b/db/schema.rb
index 6eb5d2ccc..f20402a76 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -372,6 +372,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_23_000000) do
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"
+ t.check_constraint "rolled_over_amount >= 0::numeric", name: "chk_budget_categories_rolled_over_amount_non_negative"
end
create_table "budget_shares", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 18de97bec..913c65ed0 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -666,6 +666,7 @@ components:
- currency
- subcategory
- inherits_parent_budget
+ - rollover_enabled
- category
- created_at
- updated_at
@@ -727,6 +728,7 @@ components:
- currency
- subcategory
- inherits_parent_budget
+ - rollover_enabled
- category
- created_at
- updated_at
diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb
index b43f9ffd2..f1d5e7ed9 100644
--- a/spec/swagger_helper.rb
+++ b/spec/swagger_helper.rb
@@ -391,7 +391,7 @@ RSpec.configure do |config|
},
BudgetCategorySummary: {
type: :object,
- required: %w[id budget_id currency subcategory inherits_parent_budget category created_at updated_at],
+ required: %w[id budget_id currency subcategory inherits_parent_budget rollover_enabled category created_at updated_at],
properties: {
id: { type: :string, format: :uuid },
budget_id: { type: :string, format: :uuid },
@@ -420,7 +420,7 @@ RSpec.configure do |config|
},
BudgetCategory: {
type: :object,
- required: %w[id budget_id currency subcategory inherits_parent_budget category created_at updated_at],
+ required: %w[id budget_id currency subcategory inherits_parent_budget rollover_enabled category created_at updated_at],
properties: {
id: { type: :string, format: :uuid },
budget_id: { type: :string, format: :uuid },
diff --git a/test/controllers/api/v1/budget_categories_controller_test.rb b/test/controllers/api/v1/budget_categories_controller_test.rb
index 9b230c4f2..67a1190b4 100644
--- a/test/controllers/api/v1/budget_categories_controller_test.rb
+++ b/test/controllers/api/v1/budget_categories_controller_test.rb
@@ -213,4 +213,29 @@ class Api::V1::BudgetCategoriesControllerTest < ActionDispatch::IntegrationTest
ensure
api_key_without_read&.destroy
end
+
+ # Every web surface that shows the carry recomputes on the way in, through
+ # Budget.find_or_bootstrap. This endpoint reads the materialized column
+ # straight, so it was the one place a carry left stale by a sync or a
+ # recategorisation could still be served.
+ test "index refreshes a stale carry rather than serving it" do
+ earlier = Budget.find_or_bootstrap(@family, start_date: 3.months.ago.to_date, user: nil)
+ earlier.update!(budgeted_spending: 3_000, expected_income: 5_000)
+ earlier.budget_categories.find_by!(category: @category)
+ .update!(budgeted_spending: 500, rollover_enabled: true)
+
+ later = Budget.find_or_bootstrap(@family, start_date: 2.months.ago.to_date, user: nil)
+ later.update!(budgeted_spending: 3_000, expected_income: 5_000)
+ later.budget_categories.find_by!(category: @category)
+ .update!(budgeted_spending: 500, rollover_enabled: true)
+
+ # Simulate what a sync does: rewrite the stored carry behind the app's
+ # back, the way a changed past month would leave it.
+ later.budget_categories.find_by!(category: @category).update_column(:rolled_over_amount, 0)
+
+ get api_v1_budget_categories_url, headers: api_headers(@api_key)
+
+ assert_response :success
+ assert_equal 500, later.budget_categories.find_by!(category: @category).reload[:rolled_over_amount]
+ end
end
diff --git a/test/controllers/budget_categories_controller_test.rb b/test/controllers/budget_categories_controller_test.rb
index cb4b55d1c..77812c5d8 100644
--- a/test/controllers/budget_categories_controller_test.rb
+++ b/test/controllers/budget_categories_controller_test.rb
@@ -220,6 +220,95 @@ class BudgetCategoriesControllerTest < ActionDispatch::IntegrationTest
assert_includes @response.body, "MORTGAGE_REPRO_OUTFLOW",
"loan_payment outflow remains visible (kind is not BUDGET_EXCLUDED)"
end
+
+ # --- move (Lot A2) ---
+
+ test "move shifts allocation between two envelopes and leaves the total alone" do
+ source = @budget.budget_categories.find_by(category: @parent_category)
+ other = Category.create!(name: "Transport controller test", family: @family, color: "#e99537")
+ destination = BudgetCategory.create!(budget: @budget, category: other, budgeted_spending: 100, currency: @budget.currency)
+ source.update_budgeted_spending!(400)
+ before = @budget.reload.allocated_spending
+
+ post move_budget_budget_categories_path(@budget),
+ params: { from_id: source.id, to_id: destination.id, budget_category_move: { amount: "150" } },
+ as: :turbo_stream
+
+ assert_response :success
+ assert_equal 250, source.reload.budgeted_spending.to_i
+ assert_equal 250, destination.reload.budgeted_spending.to_i
+ assert_equal before, @budget.reload.allocated_spending
+ end
+
+ test "move refuses an amount the source does not have and says why" do
+ source = @budget.budget_categories.find_by(category: @parent_category)
+ other = Category.create!(name: "Transport controller test", family: @family, color: "#e99537")
+ destination = BudgetCategory.create!(budget: @budget, category: other, budgeted_spending: 0, currency: @budget.currency)
+ source.update_budgeted_spending!(100)
+
+ post move_budget_budget_categories_path(@budget),
+ params: { from_id: source.id, to_id: destination.id, budget_category_move: { amount: "500" } },
+ as: :turbo_stream
+
+ assert_response :unprocessable_entity
+ assert_equal 100, source.reload.budgeted_spending.to_i
+ assert_equal 0, destination.reload.budgeted_spending.to_i
+ end
+
+ test "move refuses a parent to subcategory transfer" do
+ parent = @budget.budget_categories.find_by(category: @parent_category)
+ child = @budget.budget_categories.find_by(category: @electric_category)
+ child.update_budgeted_spending!(50)
+ before_parent = parent.reload.budgeted_spending
+
+ post move_budget_budget_categories_path(@budget),
+ params: { from_id: parent.id, to_id: child.id, budget_category_move: { amount: "10" } },
+ as: :turbo_stream
+
+ assert_response :unprocessable_entity
+ assert_equal before_parent, parent.reload.budgeted_spending
+ end
+
+ # A move changes what each envelope has left, so it changes what the next
+ # month inherits — the chain has to be rebuilt, exactly as an allocation
+ # edit does.
+ #
+ # Twice, not once, and that is worth pinning: `set_budget` resolves the
+ # budget through `Budget.find_or_bootstrap`, which already recomputes on
+ # every request to this controller, and the action then recomputes after
+ # the move commits. `#update` has carried the same double cost since the
+ # rollover lot landed. The action itself must call it exactly once — the
+ # count moving to three would mean a second call crept into #move.
+ test "move recomputes the rollover chain, once of its own accord" do
+ source = @budget.budget_categories.find_by(category: @parent_category)
+ other = Category.create!(name: "Transport controller test", family: @family, color: "#e99537")
+ destination = BudgetCategory.create!(budget: @budget, category: other, budgeted_spending: 0, currency: @budget.currency)
+ source.update_budgeted_spending!(300)
+
+ Budget::RolloverCalculator.any_instance.expects(:recompute!).twice
+
+ post move_budget_budget_categories_path(@budget),
+ params: { from_id: source.id, to_id: destination.id, budget_category_move: { amount: "100" } },
+ as: :turbo_stream
+
+ assert_response :success
+ end
+
+ test "a category from another budget cannot be reached through move" do
+ source = @budget.budget_categories.find_by(category: @parent_category)
+ source.update_budgeted_spending!(300)
+ other_family = families(:empty)
+ other_budget = Budget.find_or_bootstrap(other_family, start_date: Date.current.beginning_of_month)
+ foreign_category = other_family.categories.create!(name: "Foreign", color: "#e99537")
+ foreign = BudgetCategory.create!(budget: other_budget, category: foreign_category, budgeted_spending: 0, currency: other_budget.currency)
+
+ post move_budget_budget_categories_path(@budget),
+ params: { from_id: source.id, to_id: foreign.id, budget_category_move: { amount: "10" } },
+ as: :turbo_stream
+
+ assert_response :not_found
+ assert_equal 0, foreign.reload.budgeted_spending.to_i
+ end
end
class BudgetCategoriesControllerSharingTest < ActionDispatch::IntegrationTest
@@ -254,4 +343,21 @@ class BudgetCategoriesControllerSharingTest < ActionDispatch::IntegrationTest
assert_response :success
assert_equal 250.0, budget_category.reload.budgeted_spending.to_f
end
+
+ test "a read_only viewer cannot move money on the owner's budget" do
+ BudgetShare.create!(owner: @owner, viewer: @viewer, permission: "read_only")
+ categories = @owner_budget.budget_categories.to_a
+ source = categories.first
+ source.update_budgeted_spending!(200)
+ destination = @family.categories.create!(name: "Transport", color: "#e99537")
+ target = BudgetCategory.create!(budget: @owner_budget, category: destination, budgeted_spending: 0, currency: @owner_budget.currency)
+ sign_in @viewer
+
+ post move_budget_budget_categories_path(@owner_budget, owner: @owner.id),
+ params: { from_id: source.id, to_id: target.id, budget_category_move: { amount: "50" } },
+ as: :turbo_stream
+
+ assert_response :not_found
+ assert_equal 200, source.reload.budgeted_spending.to_i
+ end
end
diff --git a/test/models/budget_category_test.rb b/test/models/budget_category_test.rb
index a0eed36f4..3031f0586 100644
--- a/test/models/budget_category_test.rb
+++ b/test/models/budget_category_test.rb
@@ -271,6 +271,187 @@ class BudgetCategoryTest < ActiveSupport::TestCase
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
@@ -688,6 +869,35 @@ class BudgetCategoryRolloverTest < ActiveSupport::TestCase
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 = []