mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 00:24:15 +00:00
* fix(transactions): dedupe tag filter for correct summary totals (#3174) The Transactions page summary box (COUNT/SUM) was using `.joins(:tags).where(tags: { name: tags })`, an INNER JOIN that fans out to one row per matching tag. A transaction tagged with two of the filtered tags produced two rows; the list rendered it once (deduped by Postgres), but the totals query summed and counted both rows. Switch the filter to the same `IN (subquery)` idiom already used in `Api::V1::TransactionsController#index` (line 280-284): the subquery selects the deduplicated transaction ids, and the outer query keeps them via `WHERE id IN (...)`. COUNT and SUM now see exactly one row per matching transaction. Closes #3174. * docs: add docstrings to satisfy CodeRabbit coverage check on PR #3290 CodeRabbit's pre-merge check flagged 0% docstring coverage (threshold 80%) on the two functions touched by the tag-filter dedupe fix. Adds a one-line docstring to Transaction::Search#apply_tag_filter and explanatory comments above the two new regression tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw * fix: correct tag association call in regression tests (CI failure) CI's test_unit job failed on all three new regression tests with NoMethodError: undefined method '<<' for an instance of Transaction. `entryable << tag` doesn't exist -- `entryable` is the Transaction record itself; tags attach through its `has_many :tags, through: :taggings` association, so it needs to be `entryable.tags << tag`. I hadn't actually run this suite before -- the environment I authored it in had no working Ruby/Bundler, so this shipped based on tracing rather than execution. Now verified for real: 26 runs, 206 assertions, 0 failures, 0 errors for this file; rubocop clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw --------- Co-authored-by: jaysbeekay <jaysbeekay@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
221 lines
9.2 KiB
Ruby
221 lines
9.2 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
|
|
|
|
# Filter transactions by tag name, matching any transaction that carries
|
|
# at least one of the given tags.
|
|
def apply_tag_filter(query, tags)
|
|
return query unless tags.present?
|
|
|
|
# Use a subquery instead of an INNER JOIN: `.joins(:tags)` fans out to
|
|
# one row per matching tag, so a transaction tagged with two of the
|
|
# filtered tags produces two rows and double-counts in the summary
|
|
# box (COUNT / SUM) even though the list renders it once.
|
|
# See https://github.com/we-promise/sure/issues/3174
|
|
matching_ids = query.joins(:tags).where(tags: { name: tags }).distinct.select(:id)
|
|
query.where(id: matching_ids)
|
|
end
|
|
|
|
def apply_status_filter(query, statuses)
|
|
return query unless statuses.present?
|
|
return query if statuses.uniq.sort == [ "confirmed", "pending" ] # Both selected = no filter
|
|
|
|
# Delegate to the model scopes so the provider list stays sourced from
|
|
# Transaction::PENDING_PROVIDERS. Previously this method hardcoded only
|
|
# simplefin/plaid/lunchflow, silently dropping enable_banking transactions.
|
|
case statuses.sort
|
|
when [ "pending" ]
|
|
query.pending
|
|
when [ "confirmed" ]
|
|
query.excluding_pending
|
|
else
|
|
query
|
|
end
|
|
end
|
|
end
|