Files
sure/app/models/transaction/search.rb
charsel 33df3b781e fix: Handle uncategorized transactions filter correctly (#802)
* fix: Handle uncategorized transactions filter correctly

When filtering for 'Uncategorized' transactions, the filter was not working
because 'Uncategorized' is a virtual category (Category.uncategorized returns
a non-persisted Category object) and does not exist in the database.

The filter was attempting to match 'categories.name IN (Uncategorized)' which
returned zero results.

This fix removes 'Uncategorized' from the category names array before querying
the database, allowing the existing 'category_id IS NULL' condition to work
correctly.

Fixes filtering for uncategorized transactions while maintaining backward
compatibility with all other category filters.

* test: Add comprehensive tests for Uncategorized filter

- Test filtering for only uncategorized transactions
- Test combining uncategorized with real categories
- Test excluding uncategorized when not in filter
- Ensures fix prevents regression

* refactor: Use Category.uncategorized.name for i18n support

- Replace hard-coded 'Uncategorized' string with Category.uncategorized.name
- Conditionally build SQL query based on include_uncategorized flag
- Avoid adding category_id IS NULL clause when not needed
- Update tests to use Category.uncategorized.name for consistency
- Cleaner logic: only include uncategorized condition when requested

Addresses code review feedback on i18n support and query optimization.

* test: Fix travel category fixture error

Create travel category dynamically instead of using non-existent fixture

* style: Fix rubocop spacing in array brackets

---------

Co-authored-by: Charsel <charsel@charsel.com>
2026-01-27 12:28:33 +01:00

205 lines
7.8 KiB
Ruby

class Transaction::Search
include ActiveModel::Model
include ActiveModel::Attributes
attribute :search, :string
attribute :amount, :string
attribute :amount_operator, :string
attribute :types, array: true
attribute :status, array: true
attribute :accounts, array: true
attribute :account_ids, array: true
attribute :start_date, :string
attribute :end_date, :string
attribute :categories, array: true
attribute :merchants, array: true
attribute :tags, array: true
attribute :active_accounts_only, :boolean, default: true
attr_reader :family
def initialize(family, filters: {})
@family = family
super(filters)
end
def transactions_scope
@transactions_scope ||= begin
# This already joins entries + accounts. To avoid expensive double-joins, don't join them again (causes full table scan)
query = family.transactions
query = apply_active_accounts_filter(query, active_accounts_only)
query = apply_category_filter(query, categories)
query = apply_type_filter(query, types)
query = apply_status_filter(query, status)
query = apply_merchant_filter(query, merchants)
query = apply_tag_filter(query, tags)
query = EntrySearch.apply_search_filter(query, search)
query = EntrySearch.apply_date_filters(query, start_date, end_date)
query = EntrySearch.apply_amount_filter(query, amount, amount_operator)
query = EntrySearch.apply_accounts_filter(query, accounts, account_ids)
query
end
end
# Computes totals for the specific search
# Note: Excludes tax-advantaged accounts (401k, IRA, etc.) from totals calculation
# because those transactions are retirement savings, not daily income/expenses.
def totals
@totals ||= begin
Rails.cache.fetch("transaction_search_totals/#{cache_key_base}") do
scope = transactions_scope
# Exclude tax-advantaged accounts from totals calculation
tax_advantaged_ids = family.tax_advantaged_account_ids
scope = scope.where.not(accounts: { id: tax_advantaged_ids }) if tax_advantaged_ids.present?
result = scope
.select(
"COALESCE(SUM(CASE WHEN transactions.kind = 'investment_contribution' THEN ABS(entries.amount * COALESCE(er.rate, 1)) WHEN entries.amount >= 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
"COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
"COUNT(entries.id) as transactions_count"
)
.joins(
ActiveRecord::Base.sanitize_sql_array([
"LEFT JOIN exchange_rates er ON (er.date = entries.date AND er.from_currency = entries.currency AND er.to_currency = ?)",
family.currency
])
)
.take
Totals.new(
count: result.transactions_count.to_i,
income_money: Money.new(result.income_total, family.currency),
expense_money: Money.new(result.expense_total, family.currency)
)
end
end
end
def cache_key_base
[
family.id,
Digest::SHA256.hexdigest(attributes.sort.to_h.to_json), # cached by filters
family.entries_cache_version,
Digest::SHA256.hexdigest(family.tax_advantaged_account_ids.sort.to_json) # stable across processes
].join("/")
end
private
Totals = Data.define(:count, :income_money, :expense_money)
def apply_active_accounts_filter(query, active_accounts_only_filter)
if active_accounts_only_filter
query.where(accounts: { status: [ "draft", "active" ] })
else
query
end
end
def apply_category_filter(query, categories)
return query unless categories.present?
# Remove "Uncategorized" from category names to query the database
uncategorized_name = Category.uncategorized.name
include_uncategorized = categories.include?(uncategorized_name)
real_categories = categories - [ uncategorized_name ]
# Get parent category IDs for the given category names
parent_category_ids = family.categories.where(name: real_categories).pluck(:id)
uncategorized_condition = "(categories.id IS NULL AND transactions.kind NOT IN ('funds_movement', 'cc_payment'))"
# Build condition based on whether parent_category_ids is empty
if parent_category_ids.empty?
if include_uncategorized
query = query.left_joins(:category).where(
"categories.name IN (?) OR #{uncategorized_condition}",
real_categories.presence || []
)
else
query = query.left_joins(:category).where(categories: { name: real_categories })
end
else
if include_uncategorized
query = query.left_joins(:category).where(
"categories.name IN (?) OR categories.parent_id IN (?) OR #{uncategorized_condition}",
real_categories, parent_category_ids
)
else
query = query.left_joins(:category).where(
"categories.name IN (?) OR categories.parent_id IN (?)",
real_categories, parent_category_ids
)
end
end
query
end
def apply_type_filter(query, types)
return query unless types.present?
return query if types.sort == [ "expense", "income", "transfer" ]
transfer_condition = "transactions.kind IN ('funds_movement', 'cc_payment', 'loan_payment')"
# investment_contribution is always an expense regardless of amount sign
# (handles both manual outflows and provider-imported inflows like 401k contributions)
investment_contribution_condition = "transactions.kind = 'investment_contribution'"
expense_condition = "(entries.amount >= 0 OR #{investment_contribution_condition})"
income_condition = "(entries.amount <= 0 AND NOT #{investment_contribution_condition})"
condition = case types.sort
when [ "transfer" ]
transfer_condition
when [ "expense" ]
Arel.sql("#{expense_condition} AND NOT (#{transfer_condition})")
when [ "income" ]
Arel.sql("#{income_condition} AND NOT (#{transfer_condition})")
when [ "expense", "transfer" ]
Arel.sql("#{expense_condition} OR #{transfer_condition}")
when [ "income", "transfer" ]
Arel.sql("#{income_condition} OR #{transfer_condition}")
when [ "expense", "income" ]
Arel.sql("NOT (#{transfer_condition})")
end
query.where(condition)
end
def apply_merchant_filter(query, merchants)
return query unless merchants.present?
query.joins(:merchant).where(merchants: { name: merchants })
end
def apply_tag_filter(query, tags)
return query unless tags.present?
query.joins(:tags).where(tags: { name: tags })
end
def apply_status_filter(query, statuses)
return query unless statuses.present?
return query if statuses.uniq.sort == [ "confirmed", "pending" ] # Both selected = no filter
pending_condition = <<~SQL.squish
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
SQL
confirmed_condition = <<~SQL.squish
(transactions.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true
AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true
SQL
case statuses.sort
when [ "pending" ]
query.where(pending_condition)
when [ "confirmed" ]
query.where(confirmed_condition)
else
query
end
end
end