diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index 4addfb06e..e2661d0ba 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -408,9 +408,13 @@ class ReportsController < ApplicationController end def apply_transaction_filters(transactions) - # Filter by category + # Filter by category (including subcategories) if params[:filter_category_id].present? - transactions = transactions.where(category_id: params[:filter_category_id]) + category_id = params[:filter_category_id] + # Scope to family's categories to prevent cross-family data access + subcategory_ids = Current.family.categories.where(parent_id: category_id).pluck(:id) + all_category_ids = [ category_id ] + subcategory_ids + transactions = transactions.where(category_id: all_category_ids) end # Filter by account diff --git a/app/models/transaction/search.rb b/app/models/transaction/search.rb index 1242d8052..8dc3efcb5 100644 --- a/app/models/transaction/search.rb +++ b/app/models/transaction/search.rb @@ -91,12 +91,25 @@ class Transaction::Search def apply_category_filter(query, categories) return query unless categories.present? - query = query.left_joins(:category).where( - "categories.name IN (?) OR ( - categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment')) - )", - categories - ) + # Get parent category IDs for the given category names + parent_category_ids = family.categories.where(name: categories).pluck(:id) + + # Build condition based on whether parent_category_ids is empty + if parent_category_ids.empty? + query = query.left_joins(:category).where( + "categories.name IN (?) OR ( + categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment')) + )", + categories + ) + else + query = query.left_joins(:category).where( + "categories.name IN (?) OR categories.parent_id IN (?) OR ( + categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment')) + )", + categories, parent_category_ids + ) + end if categories.exclude?("Uncategorized") query = query.where.not(category_id: nil) diff --git a/test/models/transaction/search_test.rb b/test/models/transaction/search_test.rb index c9c660b2e..c4cf8f53c 100644 --- a/test/models/transaction/search_test.rb +++ b/test/models/transaction/search_test.rb @@ -267,6 +267,48 @@ class Transaction::SearchTest < ActiveSupport::TestCase assert_equal Money.new(0, "USD"), totals.income_money end + test "category filter includes subcategories" do + # Create a transaction with the parent category + parent_entry = create_transaction( + account: @checking_account, + amount: 100, + category: categories(:food_and_drink), + kind: "standard" + ) + + # Create a transaction with the subcategory (fixture :subcategory has name "Restaurants", parent "Food & Drink") + subcategory_entry = create_transaction( + account: @checking_account, + amount: 75, + category: categories(:subcategory), + kind: "standard" + ) + + # Create a transaction with a different category + other_entry = create_transaction( + account: @checking_account, + amount: 50, + category: categories(:income), + kind: "standard" + ) + + # Filter by parent category only - should include both parent and subcategory transactions + search = Transaction::Search.new(@family, filters: { categories: [ "Food & Drink" ] }) + results = search.transactions_scope + result_ids = results.pluck(:id) + + # Should include both parent and subcategory transactions + assert_includes result_ids, parent_entry.entryable.id + assert_includes result_ids, subcategory_entry.entryable.id + # Should not include transactions with different category + assert_not_includes result_ids, other_entry.entryable.id + + # Verify totals also include subcategory transactions + totals = search.totals + assert_equal 2, totals.count + assert_equal Money.new(175, "USD"), totals.expense_money # 100 + 75 + end + test "totals respects type filters" do # Create expense and income transactions expense_entry = create_transaction( @@ -298,4 +340,29 @@ class Transaction::SearchTest < ActiveSupport::TestCase assert_equal Money.new(0, "USD"), totals.expense_money assert_equal Money.new(0, "USD"), totals.income_money end + + test "category filter handles non-existent category names without SQL error" do + # Create a transaction with an existing category + existing_entry = create_transaction( + account: @checking_account, + amount: 100, + category: categories(:food_and_drink), + kind: "standard" + ) + + # Search for non-existent category names (parent_category_ids will be empty) + # This should not cause a SQL error with "IN ()" + search = Transaction::Search.new(@family, filters: { categories: [ "Non-Existent Category 1", "Non-Existent Category 2" ] }) + results = search.transactions_scope + result_ids = results.pluck(:id) + + # Should not include any transactions since categories don't exist + assert_not_includes result_ids, existing_entry.entryable.id + assert_equal 0, result_ids.length + + # Verify totals also work without error + totals = search.totals + assert_equal 0, totals.count + assert_equal Money.new(0, "USD"), totals.expense_money + end end