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
|
|
|
|
attribute :excluded_transactions, :boolean, default: false
|
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_excluded_transactions_filter(query, excluded_transactions)
|
|
|
|
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(
|
|
|
|
"COALESCE(SUM(CASE WHEN entries.amount >= 0 THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
|
|
|
|
"COALESCE(SUM(CASE WHEN entries.amount < 0 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.to_i, family.currency),
|
|
|
|
expense_money: Money.new(result.expense_total.to_i, 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
|
|
|
|
query.where(accounts: { is_active: true })
|
|
|
|
else
|
|
|
|
query
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def apply_excluded_transactions_filter(query, excluded_transactions_filter)
|
|
|
|
unless excluded_transactions_filter
|
|
|
|
query.where(entries: { excluded: false })
|
|
|
|
else
|
|
|
|
query
|
|
|
|
end
|
2025-01-07 09:41:24 -05:00
|
|
|
end
|
|
|
|
|
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
|