mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 03:54:08 +00:00
Ensure accessible_account_ids filtering is applied whenever account scope is provided, including empty arrays, so users with no shared accounts cannot see family-wide transactions. Also make totals robust when scoped queries return no rows and add regression tests for both visibility and totals behavior with empty accessible account lists.
221 lines
9.0 KiB
Ruby
221 lines
9.0 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, :accessible_account_ids
|
|
|
|
def initialize(family, filters: {}, accessible_account_ids: nil)
|
|
@family = family
|
|
@accessible_account_ids = accessible_account_ids
|
|
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.merge(Entry.excluding_split_parents)
|
|
|
|
# Scope to accessible accounts when provided (including an empty array, which should yield no results)
|
|
query = query.where(entries: { account_id: accessible_account_ids }) unless accessible_account_ids.nil?
|
|
|
|
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/v2/#{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(
|
|
ActiveRecord::Base.sanitize_sql_array([
|
|
"COALESCE(SUM(CASE WHEN entries.amount >= 0 AND transactions.kind NOT IN (?) THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
|
|
Transaction::TRANSFER_KINDS
|
|
]),
|
|
ActiveRecord::Base.sanitize_sql_array([
|
|
"COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN (?) THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
|
|
Transaction::TRANSFER_KINDS
|
|
]),
|
|
ActiveRecord::Base.sanitize_sql_array([
|
|
"COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind IN (?) THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as transfer_inflow_total",
|
|
Transaction::TRANSFER_KINDS
|
|
]),
|
|
ActiveRecord::Base.sanitize_sql_array([
|
|
"COALESCE(SUM(CASE WHEN entries.amount >= 0 AND transactions.kind IN (?) THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as transfer_outflow_total",
|
|
Transaction::TRANSFER_KINDS
|
|
]),
|
|
"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 || 0), family.currency),
|
|
expense_money: Money.new((result&.expense_total || 0), family.currency),
|
|
transfer_inflow_money: Money.new((result&.transfer_inflow_total || 0), family.currency),
|
|
transfer_outflow_money: Money.new((result&.transfer_outflow_total || 0), 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
|
|
accessible_account_ids ? Digest::SHA256.hexdigest(accessible_account_ids.sort.to_json) : "all"
|
|
].join("/")
|
|
end
|
|
|
|
private
|
|
Totals = Data.define(:count, :income_money, :expense_money, :transfer_inflow_money, :transfer_outflow_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?
|
|
|
|
# Check for "Uncategorized" in any supported locale (handles URL params in different languages)
|
|
all_uncategorized_names = Category.all_uncategorized_names
|
|
include_uncategorized = (categories & all_uncategorized_names).any?
|
|
real_categories = categories - all_uncategorized_names
|
|
|
|
# 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 (?)"
|
|
|
|
# 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 || [], Transaction::TRANSFER_KINDS
|
|
)
|
|
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, Transaction::TRANSFER_KINDS
|
|
)
|
|
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" ]
|
|
|
|
case types.sort
|
|
when [ "transfer" ]
|
|
query.where(kind: Transaction::TRANSFER_KINDS)
|
|
when [ "expense" ]
|
|
query.where("entries.amount >= 0").where.not(kind: Transaction::TRANSFER_KINDS)
|
|
when [ "income" ]
|
|
query.where("entries.amount < 0").where.not(kind: Transaction::TRANSFER_KINDS)
|
|
when [ "expense", "transfer" ]
|
|
query.where("entries.amount >= 0 OR transactions.kind IN (?)", Transaction::TRANSFER_KINDS)
|
|
when [ "income", "transfer" ]
|
|
query.where("entries.amount < 0 OR transactions.kind IN (?)", Transaction::TRANSFER_KINDS)
|
|
when [ "expense", "income" ]
|
|
query.where.not(kind: Transaction::TRANSFER_KINDS)
|
|
else
|
|
query
|
|
end
|
|
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
|
|
OR (transactions.extra -> 'lunchflow' ->> '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
|
|
AND (transactions.extra -> 'lunchflow' ->> '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
|