diff --git a/app/models/budget.rb b/app/models/budget.rb index 7e673b3fa..dafa0a700 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -90,7 +90,9 @@ class Budget < ApplicationRecord end def sync_budget_categories - current_category_ids = family.categories.pluck(:id).to_set + # Category changes can leave the association memoized before this sync runs. + current_categories_by_id = family.categories.reload.index_by(&:id) + current_category_ids = current_categories_by_id.keys.to_set existing_budget_category_ids = budget_categories.pluck(:category_id).to_set categories_to_add = current_category_ids - existing_budget_category_ids categories_to_remove = existing_budget_category_ids - current_category_ids @@ -98,7 +100,7 @@ class Budget < ApplicationRecord # Create missing categories categories_to_add.each do |category_id| budget_categories.create!( - category_id: category_id, + category: current_categories_by_id.fetch(category_id), budgeted_spending: 0, currency: family.currency ) diff --git a/app/models/income_statement.rb b/app/models/income_statement.rb index ff3f3db56..156e1ac38 100644 --- a/app/models/income_statement.rb +++ b/app/models/income_statement.rb @@ -134,7 +134,8 @@ class IncomeStatement NetCategoryTotals = Data.define(:net_expense_categories, :net_income_categories, :total_net_expense, :total_net_income, :currency) def categories - @categories ||= family.categories.all.to_a + # Keep Category#subcategory?'s parent-based orphan semantics without lazy loads. + @categories ||= family.categories.includes(:parent).to_a end def period_cache_key(period) diff --git a/test/controllers/reports_controller_test.rb b/test/controllers/reports_controller_test.rb index 87a6c2bca..4c75de2c3 100644 --- a/test/controllers/reports_controller_test.rb +++ b/test/controllers/reports_controller_test.rb @@ -193,6 +193,40 @@ class ReportsControllerTest < ActionDispatch::IntegrationTest assert_select ".text-center.py-8.text-subdued", { text: /No spending data/, count: 0 }, "Should not show 'No spending data' message when transactions exist" end + test "index avoids residual category lazy loads" do + account = accounts(:depository) + + 4.times do |idx| + parent = @family.categories.create!( + name: "Reports Parent #{idx}", + color: "#000000", + lucide_icon: "folder" + ) + child = @family.categories.create!( + name: "Reports Child #{idx}", + color: "#111111", + lucide_icon: "folder", + parent: parent + ) + entry = account.entries.create!( + name: "Reports transaction #{idx}", + date: Date.current, + amount: 10 + idx, + currency: "USD", + entryable: Transaction.new( + category: child, + kind: "standard" + ) + ) + assert entry.persisted? + end + + queries = capture_sql_queries { get reports_path(period_type: :monthly) } + + assert_response :ok + assert_empty queries.grep(/SELECT "categories"\.\* FROM "categories" WHERE "categories"\."id" = \$1 LIMIT \$2/) + end + test "export transactions with API key authentication" do # Use an active API key with read permissions api_key = api_keys(:active_key) diff --git a/test/models/income_statement_test.rb b/test/models/income_statement_test.rb index 0f95fcff0..ec7c5829d 100644 --- a/test/models/income_statement_test.rb +++ b/test/models/income_statement_test.rb @@ -39,6 +39,17 @@ class IncomeStatementTest < ActiveSupport::TestCase assert_equal expected_total_expense, expense_totals.category_totals.find { |ct| ct.category.id == @food_category.id }.total end + test "orphaned categories still count as root category totals" do + orphan_category = @family.categories.create! name: "Orphaned Category" + orphan_category.update_column(:parent_id, SecureRandom.uuid) + create_transaction(account: @checking_account, amount: 123, category: orphan_category) + + expense_totals = IncomeStatement.new(@family).expense_totals(period: Period.last_30_days) + + assert_equal 200 + 300 + 400 + 123, expense_totals.total + assert_equal 123, expense_totals.category_totals.find { |ct| ct.category.id == orphan_category.id }.total + end + test "memoizes expense and income period totals across repeated calculations" do income_statement = IncomeStatement.new(@family) period = Period.last_30_days