mirror of
https://github.com/we-promise/sure.git
synced 2026-04-18 19:44:09 +00:00
* 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>
205 lines
7.8 KiB
Ruby
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
|