2025-04-14 11:40:34 -04:00
class Transaction :: Search
2024-12-20 11:37:26 -05:00
include ActiveModel :: Model
include ActiveModel :: Attributes
attribute :search , :string
attribute :amount , :string
attribute :amount_operator , :string
attribute :types , 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
2025-06-20 13:31:58 -04:00
attribute :active_accounts_only , :boolean , default : true
2024-12-20 11:37:26 -05:00
2025-06-20 13:31:58 -04:00
attr_reader :family
2024-12-20 11:37:26 -05:00
2025-06-20 13:31:58 -04:00
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_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 )
2025-03-11 15:38:45 -04:00
2025-06-20 13:31:58 -04:00
query
end
2025-03-11 15:38:45 -04:00
end
2025-06-20 13:31:58 -04:00
# Computes totals for the specific search
def totals
@totals || = begin
Rails . cache . fetch ( " transaction_search_totals/ #{ cache_key_base } " ) do
result = transactions_scope
. select (
2025-07-23 18:37:05 -04:00
" COALESCE(SUM(CASE 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') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total " ,
2025-06-20 13:31:58 -04:00
" 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 ,
2025-07-23 18:37:05 -04:00
income_money : Money . new ( result . income_total . round , family . currency ) ,
expense_money : Money . new ( result . expense_total . round , family . currency )
2025-03-11 15:38:45 -04:00
)
2025-06-20 13:31:58 -04:00
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
] . 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
2025-07-03 09:33:07 -04:00
query . where ( accounts : { status : [ " draft " , " active " ] } )
2025-06-20 13:31:58 -04:00
else
query
end
end
2025-01-07 09:41:24 -05:00
2025-03-11 15:38:45 -04:00
def apply_category_filter ( query , categories )
return query unless categories . present?
query = query . left_joins ( :category ) . where (
" categories.name IN (?) OR (
2025-06-20 13:31:58 -04:00
categories . id IS NULL AND ( transactions . kind NOT IN ( 'funds_movement' , 'cc_payment' ) )
2025-03-11 15:38:45 -04:00
) " ,
categories
)
2024-12-20 11:37:26 -05:00
if categories . exclude? ( " Uncategorized " )
2025-03-11 15:38:45 -04:00
query = query . where . not ( category_id : nil )
2024-12-20 11:37:26 -05:00
end
2025-03-11 15:38:45 -04:00
query
2024-12-20 11:37:26 -05:00
end
2025-03-11 15:38:45 -04:00
def apply_type_filter ( query , types )
return query unless types . present?
return query if types . sort == [ " expense " , " income " , " transfer " ]
2024-12-20 11:37:26 -05:00
2025-06-20 13:31:58 -04:00
transfer_condition = " transactions.kind IN ('funds_movement', 'cc_payment', 'loan_payment') "
2025-04-14 11:40:34 -04:00
expense_condition = " entries.amount >= 0 "
income_condition = " entries.amount <= 0 "
2024-12-20 11:37:26 -05:00
2025-03-11 15:38:45 -04:00
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
2024-12-20 11:37:26 -05:00
2025-03-11 15:38:45 -04:00
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
2024-12-20 11:37:26 -05:00
end