From 388f249e4ead98b992cd11d73df5ecb24a1e9a1a Mon Sep 17 00:00:00 2001 From: Juan Manuel Reyes Date: Fri, 6 Mar 2026 14:24:33 -0800 Subject: [PATCH] Fix nil-key collision in budget category hash lookups (#1136) Both Uncategorized and Other Investments are synthetic categories with id=nil. When expense_totals_by_category indexes by category.id, Other Investments overwrites Uncategorized at the nil key, causing uncategorized actual spending to always return 0. Use category.name as fallback key (id || name) to differentiate the two synthetic categories in all hash builders and lookup sites. Co-authored-by: Claude Opus 4.6 --- app/models/budget.rb | 10 +++++----- test/models/budget_test.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/app/models/budget.rb b/app/models/budget.rb index aa8f900b8..0396fc30e 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -218,9 +218,9 @@ class Budget < ApplicationRecord end def budget_category_actual_spending(budget_category) - cat_id = budget_category.category_id - expense = expense_totals_by_category[cat_id]&.total || 0 - refund = income_totals_by_category[cat_id]&.total || 0 + key = budget_category.category_id || budget_category.category.name + expense = expense_totals_by_category[key]&.total || 0 + refund = income_totals_by_category[key]&.total || 0 [ expense - refund, 0 ].max end @@ -318,10 +318,10 @@ class Budget < ApplicationRecord end def expense_totals_by_category - @expense_totals_by_category ||= expense_totals.category_totals.index_by { |ct| ct.category.id } + @expense_totals_by_category ||= expense_totals.category_totals.index_by { |ct| ct.category.id || ct.category.name } end def income_totals_by_category - @income_totals_by_category ||= income_totals.category_totals.index_by { |ct| ct.category.id } + @income_totals_by_category ||= income_totals.category_totals.index_by { |ct| ct.category.id || ct.category.name } end end diff --git a/test/models/budget_test.rb b/test/models/budget_test.rb index 1896e887a..e12b51d49 100644 --- a/test/models/budget_test.rb +++ b/test/models/budget_test.rb @@ -304,4 +304,30 @@ class BudgetTest < ActiveSupport::TestCase assert_not_nil budget.previous_budget_param end + + test "uncategorized budget category actual spending reflects uncategorized transactions" do + family = families(:dylan_family) + budget = Budget.find_or_bootstrap(family, start_date: Date.current.beginning_of_month) + account = accounts(:depository) + + # Create an uncategorized expense + Entry.create!( + account: account, + entryable: Transaction.create!(category: nil), + date: Date.current, + name: "Uncategorized lunch", + amount: 75, + currency: "USD" + ) + + budget = Budget.find(budget.id) + budget.sync_budget_categories + + uncategorized_bc = budget.uncategorized_budget_category + spending = budget.budget_category_actual_spending(uncategorized_bc) + + # Must be > 0 — the nil-key collision between Uncategorized and + # Other Investments synthetic categories previously caused this to return 0 + assert spending >= 75, "Uncategorized actual spending should include the $75 transaction, got #{spending}" + end end