mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 00:24:15 +00:00
* Align uncategorized filter with dashboard aggregate (fix #2592) The Transactions page's 'Uncategorized' category bucket excluded Transaction::TRANSFER_KINDS, but the dashboard cashflow widget computes its Uncategorized figure from IncomeStatement::Totals which excludes Transaction::BUDGET_EXCLUDED_KINDS. The two sets disagree on loan_payment and investment_contribution: those kinds were hidden from the list while their value was still counted in the widget, so the widget's figure could not be reproduced from the Transactions page. This changes the uncategorized exclusion in Transaction::Search# apply_category_filter to match BUDGET_EXCLUDED_KINDS exactly — the same set the dashboard aggregate excludes — so the widget and the list always agree. The type filter (apply_type_filter) is left using TRANSFER_KINDS, which is its correct semantic for the expense/ income/transfer UI switch. Regression tests: - search_test.rb: uncategorized filter lists loan_payment + investment_contribution (bites before the fix: asserts inclusion on two kinds that were being excluded). - search_test.rb: funds_movement (a member of BUDGET_EXCLUDED_KINDS) is still excluded from uncategorized, guarding against over-broadening. Fixes https://github.com/we-promise/sure/issues/2592 * docs: add docstrings to Transaction::Search methods (PR #3293) Add comprehensive docstrings to all public and private methods in the Transaction::Search class to meet 80%+ coverage requirement: - Add docstring to initialize method - Add docstring to transactions_scope - Add docstring to totals method - Add docstring to cache_key_base - Add docstring to apply_active_accounts_filter - Add docstring to apply_category_filter (method touched in PR) - Add docstring to apply_type_filter - Add docstring to apply_merchant_filter - Add docstring to apply_tag_filter - Add docstring to apply_status_filter Addresses CodeRabbit docstring coverage requirement. * fix: preserve one-time transactions in uncategorized searches (PR #3293) Address Codex feedback: one-time transactions should remain visible in uncategorized searches since users can categorize them. The previous approach using BUDGET_EXCLUDED_KINDS excluded one_time, making them undiscoverable. Solution: Use a minimal exclusion set for uncategorized that only excludes pure transfer-like kinds (funds_movement, cc_payment). This preserves: - one_time transactions (user-marked as one-time, still categorizable) - loan_payment transactions (legitimate uncategorized entries) - investment_contribution transactions (legitimate uncategorized entries) While still aligning with dashboard by excluding inter-account transfers. Fixes https://github.com/we-promise/sure/issues/2592 Addresses PR #3293 feedback * fix: bump totals cache key to avoid stale post-deploy mismatch (#2592) CodeRabbit flagged a moderate merge risk: Transaction::Search#totals is cached under a key that only reflects filter *parameters* and data changes (entries_cache_version), not application code changes. Since this PR changes which kinds count as "uncategorized," a totals entry cached before deploy would keep being served after deploy (same cache_key_base, same family, same filters, no new entries yet), disagreeing with the Transactions page list -- which reads transactions_scope directly and isn't cached -- until that family's entries_cache_version next changes. Bumping the cache key prefix from v2 to v3 invalidates every existing totals cache entry on deploy, so the first read after release recomputes under the new logic instead of serving stale figures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw * fix(transactions): align uncategorized filter with dashboard exclusions - Use Transaction::BUDGET_EXCLUDED_KINDS for consistency with dashboard - Exclude one_time from uncategorized filter to match dashboard behavior - Update comment to clarify alignment with dashboard uncategorized totals Fixes disagreement between code comment, test comment, and dashboard behavior. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A9494Pxh4LZKnNLTGfFXPw * fix(transactions): share one uncategorized-kind list across all three surfaces Resolves the two open review threads on #3293, which were the same finding from opposite sides: the filter excluded BUDGET_EXCLUDED_KINDS, which also drops one_time. one_time is documented as "a one-time expense/income, excluded from budget analytics" -- it is not a transfer, it is still categorizable, and excluding it made an uncategorized one-time transaction undiscoverable through its actual category state. Reviewing that also surfaced a defect neither thread caught. Aligning only Transaction::Search left Entry.uncategorized_transactions on TRANSFER_KINDS, so the Transactions filter and the badge count / Quick Categorize wizard disagreed on three of six kinds: kind filter wizard loan_payment included EXCLUDED one_time EXCLUDED included investment_contribution included EXCLUDED That leaves #2592's actual complaint standing: the dashboard counts uncategorized loan_payment / investment_contribution, but the wizard still would not offer them for categorization. Introduce Transaction::UNCATEGORIZED_EXCLUDED_KINDS (funds_movement, cc_payment) -- the kinds that have nothing to categorize because they are paired legs of a Transfer -- and read it from both surfaces. "Has no category" and "counts toward the budget" are different questions; only the former decides this list. Bump the uncategorized badge cache key to v4, since the count's meaning changes and the old key would otherwise survive deploy. The dashboard still excludes one_time from budget totals by design; that difference is intentional and now documented rather than papered over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: jaysbeekay <jaysbeekay@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
1043 lines
37 KiB
Ruby
1043 lines
37 KiB
Ruby
require "test_helper"
|
|
|
|
class Transaction::SearchTest < ActiveSupport::TestCase
|
|
include EntriesTestHelper
|
|
|
|
setup do
|
|
@family = families(:dylan_family)
|
|
@checking_account = accounts(:depository)
|
|
@credit_card_account = accounts(:credit_card)
|
|
@loan_account = accounts(:loan)
|
|
|
|
# Clean up existing entries/transactions from fixtures to ensure test isolation
|
|
@family.accounts.each { |account| account.entries.delete_all }
|
|
end
|
|
|
|
test "search filters by transaction types using kind enum" do
|
|
# Create different types of transactions using the helper method
|
|
standard_entry = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
category: categories(:food_and_drink),
|
|
kind: "standard"
|
|
)
|
|
|
|
transfer_entry = create_transaction(
|
|
account: @checking_account,
|
|
amount: 200,
|
|
kind: "funds_movement"
|
|
)
|
|
|
|
payment_entry = create_transaction(
|
|
account: @credit_card_account,
|
|
amount: -300,
|
|
kind: "cc_payment"
|
|
)
|
|
|
|
loan_payment_entry = create_transaction(
|
|
account: @loan_account,
|
|
amount: 400,
|
|
kind: "loan_payment"
|
|
)
|
|
|
|
one_time_entry = create_transaction(
|
|
account: @checking_account,
|
|
amount: 500,
|
|
kind: "one_time"
|
|
)
|
|
|
|
# Test transfer type filter (includes loan_payment)
|
|
transfer_results = Transaction::Search.new(@family, filters: { types: [ "transfer" ] }).transactions_scope
|
|
transfer_ids = transfer_results.pluck(:id)
|
|
|
|
assert_includes transfer_ids, transfer_entry.entryable.id
|
|
assert_includes transfer_ids, payment_entry.entryable.id
|
|
assert_includes transfer_ids, loan_payment_entry.entryable.id
|
|
assert_not_includes transfer_ids, one_time_entry.entryable.id
|
|
assert_not_includes transfer_ids, standard_entry.entryable.id
|
|
|
|
# Test expense type filter (excludes transfer kinds but includes one_time)
|
|
expense_results = Transaction::Search.new(@family, filters: { types: [ "expense" ] }).transactions_scope
|
|
expense_ids = expense_results.pluck(:id)
|
|
|
|
assert_includes expense_ids, standard_entry.entryable.id
|
|
assert_includes expense_ids, one_time_entry.entryable.id
|
|
assert_not_includes expense_ids, loan_payment_entry.entryable.id
|
|
assert_not_includes expense_ids, transfer_entry.entryable.id
|
|
assert_not_includes expense_ids, payment_entry.entryable.id
|
|
|
|
# Test income type filter
|
|
income_entry = create_transaction(
|
|
account: @checking_account,
|
|
amount: -600,
|
|
kind: "standard"
|
|
)
|
|
|
|
income_results = Transaction::Search.new(@family, filters: { types: [ "income" ] }).transactions_scope
|
|
income_ids = income_results.pluck(:id)
|
|
|
|
assert_includes income_ids, income_entry.entryable.id
|
|
assert_not_includes income_ids, standard_entry.entryable.id
|
|
assert_not_includes income_ids, loan_payment_entry.entryable.id
|
|
assert_not_includes income_ids, transfer_entry.entryable.id
|
|
|
|
# Test combined expense and income filter (excludes transfer kinds but includes one_time)
|
|
non_transfer_results = Transaction::Search.new(@family, filters: { types: [ "expense", "income" ] }).transactions_scope
|
|
non_transfer_ids = non_transfer_results.pluck(:id)
|
|
|
|
assert_includes non_transfer_ids, standard_entry.entryable.id
|
|
assert_includes non_transfer_ids, income_entry.entryable.id
|
|
assert_includes non_transfer_ids, one_time_entry.entryable.id
|
|
assert_not_includes non_transfer_ids, loan_payment_entry.entryable.id
|
|
assert_not_includes non_transfer_ids, transfer_entry.entryable.id
|
|
assert_not_includes non_transfer_ids, payment_entry.entryable.id
|
|
end
|
|
|
|
test "search category filter handles uncategorized transactions correctly with kind filtering" do
|
|
# Create uncategorized transactions of different kinds
|
|
uncategorized_standard = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
kind: "standard"
|
|
)
|
|
|
|
uncategorized_transfer = create_transaction(
|
|
account: @checking_account,
|
|
amount: 200,
|
|
kind: "funds_movement"
|
|
)
|
|
|
|
uncategorized_cc_payment = create_transaction(
|
|
account: @credit_card_account,
|
|
amount: 250,
|
|
kind: "cc_payment"
|
|
)
|
|
|
|
uncategorized_one_time = create_transaction(
|
|
account: @checking_account,
|
|
amount: 275,
|
|
kind: "one_time"
|
|
)
|
|
|
|
# Search for uncategorized transactions
|
|
uncategorized_results = Transaction::Search.new(@family, filters: { categories: [ Category.uncategorized.name ] }).transactions_scope
|
|
uncategorized_ids = uncategorized_results.pluck(:id)
|
|
|
|
# Should include standard uncategorized transactions
|
|
assert_includes uncategorized_ids, uncategorized_standard.entryable.id
|
|
|
|
# UNCATEGORIZED_EXCLUDED_KINDS (funds_movement, cc_payment) are paired legs
|
|
# of a Transfer between the user's own accounts, so there is nothing to
|
|
# categorize and they never land in the uncategorized bucket.
|
|
assert_not_includes uncategorized_ids, uncategorized_transfer.entryable.id
|
|
assert_not_includes uncategorized_ids, uncategorized_cc_payment.entryable.id
|
|
|
|
# one_time is NOT excluded: it's a real expense/income the user flagged so
|
|
# it doesn't skew budget medians, and it is still categorizable. Excluding
|
|
# it would make an uncategorized one-time transaction undiscoverable
|
|
# through its actual category state.
|
|
assert_includes uncategorized_ids, uncategorized_one_time.entryable.id,
|
|
"one_time is excluded from budget analytics, not from categorization"
|
|
end
|
|
|
|
test "uncategorized filter lists budget-tracked transfers (loan_payment, investment_contribution)" do
|
|
# Regression for https://github.com/we-promise/sure/issues/2592
|
|
# The dashboard Cashflow widget counts uncategorized loan_payment /
|
|
# investment_contribution transactions under Outflows → Uncategorized,
|
|
# but the Transactions page filter used TRANSFER_KINDS and hid them, so
|
|
# the widget's figure could not be reproduced from the list — and the
|
|
# transactions could not be found, let alone categorized. These two kinds
|
|
# are budget-tracked outflows (the intentional-tracking contract also
|
|
# asserted by the BudgetCategoriesController drilldown tests), so they
|
|
# stay listed.
|
|
loan_payment = create_transaction(account: @loan_account, amount: 300, kind: "loan_payment")
|
|
investment_contribution = create_transaction(account: @checking_account, amount: 400, kind: "investment_contribution")
|
|
|
|
uncategorized_ids = Transaction::Search.new(@family, filters: { categories: [ Category.uncategorized.name ] })
|
|
.transactions_scope.pluck(:id)
|
|
|
|
assert_includes uncategorized_ids, loan_payment.entryable.id,
|
|
"uncategorized loan_payment is visible on the dashboard; the list must include it (see #2592)"
|
|
assert_includes uncategorized_ids, investment_contribution.entryable.id,
|
|
"uncategorized investment_contribution is visible on the dashboard; the list must include it (see #2592)"
|
|
end
|
|
|
|
test "uncategorized filter and Entry.uncategorized_transactions agree on every kind" do
|
|
# The Transactions filter, the uncategorized badge count and the Quick
|
|
# Categorize wizard must describe the same set, or the badge promises rows
|
|
# the list won't show (and vice versa). Both read
|
|
# Transaction::UNCATEGORIZED_EXCLUDED_KINDS; this pins them together.
|
|
made = Transaction.kinds.keys.index_with do |kind|
|
|
account = kind == "loan_payment" ? @loan_account : @checking_account
|
|
create_transaction(account: account, amount: 100, kind: kind).entryable.id
|
|
end
|
|
|
|
filter_ids = Transaction::Search.new(@family, filters: { categories: [ Category.uncategorized.name ] })
|
|
.transactions_scope.pluck(:id).to_set
|
|
wizard_ids = @family.entries.uncategorized_transactions.pluck(:entryable_id).to_set
|
|
|
|
made.each do |kind, id|
|
|
assert_equal filter_ids.include?(id), wizard_ids.include?(id),
|
|
"#{kind}: Transactions filter and Entry.uncategorized_transactions disagree"
|
|
end
|
|
|
|
Transaction::UNCATEGORIZED_EXCLUDED_KINDS.each do |kind|
|
|
assert_not_includes filter_ids, made.fetch(kind), "#{kind} must not be uncategorizable"
|
|
end
|
|
(Transaction.kinds.keys - Transaction::UNCATEGORIZED_EXCLUDED_KINDS).each do |kind|
|
|
assert_includes filter_ids, made.fetch(kind), "#{kind} has no category and must be listed"
|
|
end
|
|
end
|
|
|
|
test "filtering for only Uncategorized returns only uncategorized transactions" do
|
|
# Create a mix of categorized and uncategorized transactions
|
|
categorized = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
category: categories(:food_and_drink)
|
|
)
|
|
|
|
uncategorized = create_transaction(
|
|
account: @checking_account,
|
|
amount: 200
|
|
)
|
|
|
|
# Filter for only uncategorized
|
|
results = Transaction::Search.new(@family, filters: { categories: [ Category.uncategorized.name ] }).transactions_scope
|
|
result_ids = results.pluck(:id)
|
|
|
|
# Should only include uncategorized transaction
|
|
assert_includes result_ids, uncategorized.entryable.id
|
|
assert_not_includes result_ids, categorized.entryable.id
|
|
assert_equal 1, result_ids.size
|
|
end
|
|
|
|
test "filtering for Uncategorized plus a real category returns both" do
|
|
# Create a travel category for testing
|
|
travel_category = @family.categories.create!(
|
|
name: "Travel",
|
|
color: "#3b82f6"
|
|
)
|
|
|
|
# Create transactions with different categories
|
|
food_transaction = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
category: categories(:food_and_drink)
|
|
)
|
|
|
|
travel_transaction = create_transaction(
|
|
account: @checking_account,
|
|
amount: 150,
|
|
category: travel_category
|
|
)
|
|
|
|
uncategorized = create_transaction(
|
|
account: @checking_account,
|
|
amount: 200
|
|
)
|
|
|
|
# Filter for food category + uncategorized
|
|
results = Transaction::Search.new(@family, filters: { categories: [ "Food & Drink", Category.uncategorized.name ] }).transactions_scope
|
|
result_ids = results.pluck(:id)
|
|
|
|
# Should include both food and uncategorized
|
|
assert_includes result_ids, food_transaction.entryable.id
|
|
assert_includes result_ids, uncategorized.entryable.id
|
|
# Should NOT include travel
|
|
assert_not_includes result_ids, travel_transaction.entryable.id
|
|
assert_equal 2, result_ids.size
|
|
end
|
|
|
|
test "filtering excludes uncategorized when not in filter" do
|
|
# Create a mix of transactions
|
|
categorized = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
category: categories(:food_and_drink)
|
|
)
|
|
|
|
uncategorized = create_transaction(
|
|
account: @checking_account,
|
|
amount: 200
|
|
)
|
|
|
|
# Filter for only food category (without Uncategorized)
|
|
results = Transaction::Search.new(@family, filters: { categories: [ "Food & Drink" ] }).transactions_scope
|
|
result_ids = results.pluck(:id)
|
|
|
|
# Should only include categorized transaction
|
|
assert_includes result_ids, categorized.entryable.id
|
|
assert_not_includes result_ids, uncategorized.entryable.id
|
|
assert_equal 1, result_ids.size
|
|
end
|
|
|
|
test "new family-based API works correctly" do
|
|
# Create transactions for testing
|
|
transaction1 = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
category: categories(:food_and_drink),
|
|
kind: "standard"
|
|
)
|
|
|
|
transaction2 = create_transaction(
|
|
account: @checking_account,
|
|
amount: 200,
|
|
kind: "funds_movement"
|
|
)
|
|
|
|
# Test new family-based API
|
|
search = Transaction::Search.new(@family, filters: { types: [ "expense" ] })
|
|
results = search.transactions_scope
|
|
result_ids = results.pluck(:id)
|
|
|
|
# Should include expense transactions
|
|
assert_includes result_ids, transaction1.entryable.id
|
|
# Should exclude transfer transactions
|
|
assert_not_includes result_ids, transaction2.entryable.id
|
|
|
|
# Test that the relation builds from family.transactions correctly
|
|
assert_equal @family.transactions.joins(entry: :account).where(
|
|
"entries.amount >= 0 AND NOT (transactions.kind IN (?))", Transaction::TRANSFER_KINDS
|
|
).count, results.count
|
|
end
|
|
|
|
test "transfer filter includes investment_contribution transactions" do
|
|
investment_contribution = create_transaction(
|
|
account: @checking_account,
|
|
amount: 500,
|
|
kind: "investment_contribution"
|
|
)
|
|
|
|
funds_movement = create_transaction(
|
|
account: @checking_account,
|
|
amount: 200,
|
|
kind: "funds_movement"
|
|
)
|
|
|
|
search = Transaction::Search.new(@family, filters: { types: [ "transfer" ] })
|
|
result_ids = search.transactions_scope.pluck(:id)
|
|
|
|
assert_includes result_ids, investment_contribution.entryable.id
|
|
assert_includes result_ids, funds_movement.entryable.id
|
|
end
|
|
|
|
test "expense filter excludes investment_contribution transactions" do
|
|
investment_contribution = create_transaction(
|
|
account: @checking_account,
|
|
amount: 500,
|
|
kind: "investment_contribution"
|
|
)
|
|
|
|
standard_expense = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
kind: "standard"
|
|
)
|
|
|
|
search = Transaction::Search.new(@family, filters: { types: [ "expense" ] })
|
|
result_ids = search.transactions_scope.pluck(:id)
|
|
|
|
assert_not_includes result_ids, investment_contribution.entryable.id
|
|
assert_includes result_ids, standard_expense.entryable.id
|
|
end
|
|
|
|
test "family-based API requires family parameter" do
|
|
assert_raises(NoMethodError) do
|
|
search = Transaction::Search.new({ types: [ "expense" ] })
|
|
search.transactions_scope # This will fail when trying to call .transactions on a Hash
|
|
end
|
|
end
|
|
|
|
# Totals method tests (lifted from Transaction::TotalsTest)
|
|
|
|
test "totals computes basic expense and income totals" do
|
|
# Create expense transaction
|
|
expense_entry = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
category: categories(:food_and_drink),
|
|
kind: "standard"
|
|
)
|
|
|
|
# Create income transaction
|
|
income_entry = create_transaction(
|
|
account: @checking_account,
|
|
amount: -200,
|
|
kind: "standard"
|
|
)
|
|
|
|
search = Transaction::Search.new(@family)
|
|
totals = search.totals
|
|
|
|
assert_equal 2, totals.count
|
|
assert_equal Money.new(100, "USD"), totals.expense_money # $100
|
|
assert_equal Money.new(200, "USD"), totals.income_money # $200
|
|
end
|
|
|
|
test "totals handles multi-currency transactions with exchange rates" do
|
|
# Create EUR transaction
|
|
eur_entry = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
currency: "EUR",
|
|
kind: "standard"
|
|
)
|
|
|
|
# Create exchange rate EUR -> USD
|
|
ExchangeRate.create!(
|
|
from_currency: "EUR",
|
|
to_currency: "USD",
|
|
rate: 1.1,
|
|
date: eur_entry.date
|
|
)
|
|
|
|
# Create USD transaction
|
|
usd_entry = create_transaction(
|
|
account: @checking_account,
|
|
amount: 50,
|
|
currency: "USD",
|
|
kind: "standard"
|
|
)
|
|
|
|
search = Transaction::Search.new(@family)
|
|
totals = search.totals
|
|
|
|
assert_equal 2, totals.count
|
|
# EUR 100 * 1.1 + USD 50 = 110 + 50 = 160
|
|
assert_equal Money.new(160, "USD"), totals.expense_money
|
|
assert_equal Money.new(0, "USD"), totals.income_money
|
|
end
|
|
|
|
test "totals handles missing exchange rates gracefully" do
|
|
# Create EUR transaction without exchange rate
|
|
eur_entry = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
currency: "EUR",
|
|
kind: "standard"
|
|
)
|
|
|
|
search = Transaction::Search.new(@family)
|
|
totals = search.totals
|
|
|
|
assert_equal 1, totals.count
|
|
# Should use rate of 1 when exchange rate is missing
|
|
assert_equal Money.new(100, "USD"), totals.expense_money # EUR 100 * 1
|
|
assert_equal Money.new(0, "USD"), totals.income_money
|
|
end
|
|
|
|
test "totals respects category filters" do
|
|
# Create transactions in different categories
|
|
food_entry = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
category: categories(:food_and_drink),
|
|
kind: "standard"
|
|
)
|
|
|
|
other_entry = create_transaction(
|
|
account: @checking_account,
|
|
amount: 50,
|
|
category: categories(:income),
|
|
kind: "standard"
|
|
)
|
|
|
|
# Filter by food category only
|
|
search = Transaction::Search.new(@family, filters: { categories: [ "Food & Drink" ] })
|
|
totals = search.totals
|
|
|
|
assert_equal 1, totals.count
|
|
assert_equal Money.new(100, "USD"), totals.expense_money # Only food transaction
|
|
assert_equal Money.new(0, "USD"), totals.income_money
|
|
end
|
|
|
|
test "category filter includes subcategories" do
|
|
# Create a transaction with the parent category
|
|
parent_entry = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
category: categories(:food_and_drink),
|
|
kind: "standard"
|
|
)
|
|
|
|
# Create a transaction with the subcategory (fixture :subcategory has name "Restaurants", parent "Food & Drink")
|
|
subcategory_entry = create_transaction(
|
|
account: @checking_account,
|
|
amount: 75,
|
|
category: categories(:subcategory),
|
|
kind: "standard"
|
|
)
|
|
|
|
# Create a transaction with a different category
|
|
other_entry = create_transaction(
|
|
account: @checking_account,
|
|
amount: 50,
|
|
category: categories(:income),
|
|
kind: "standard"
|
|
)
|
|
|
|
# Filter by parent category only - should include both parent and subcategory transactions
|
|
search = Transaction::Search.new(@family, filters: { categories: [ "Food & Drink" ] })
|
|
results = search.transactions_scope
|
|
result_ids = results.pluck(:id)
|
|
|
|
# Should include both parent and subcategory transactions
|
|
assert_includes result_ids, parent_entry.entryable.id
|
|
assert_includes result_ids, subcategory_entry.entryable.id
|
|
# Should not include transactions with different category
|
|
assert_not_includes result_ids, other_entry.entryable.id
|
|
|
|
# Verify totals also include subcategory transactions
|
|
totals = search.totals
|
|
assert_equal 2, totals.count
|
|
assert_equal Money.new(175, "USD"), totals.expense_money # 100 + 75
|
|
end
|
|
|
|
test "totals respects type filters" do
|
|
# Create expense and income transactions
|
|
expense_entry = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
kind: "standard"
|
|
)
|
|
|
|
income_entry = create_transaction(
|
|
account: @checking_account,
|
|
amount: -200,
|
|
kind: "standard"
|
|
)
|
|
|
|
# Filter by expense type only
|
|
search = Transaction::Search.new(@family, filters: { types: [ "expense" ] })
|
|
totals = search.totals
|
|
|
|
assert_equal 1, totals.count
|
|
assert_equal Money.new(100, "USD"), totals.expense_money
|
|
assert_equal Money.new(0, "USD"), totals.income_money
|
|
end
|
|
|
|
test "totals handles empty results" do
|
|
search = Transaction::Search.new(@family)
|
|
totals = search.totals
|
|
|
|
assert_equal 0, totals.count
|
|
assert_equal Money.new(0, "USD"), totals.expense_money
|
|
assert_equal Money.new(0, "USD"), totals.income_money
|
|
end
|
|
|
|
test "category filter handles non-existent category names without SQL error" do
|
|
# Create a transaction with an existing category
|
|
existing_entry = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
category: categories(:food_and_drink),
|
|
kind: "standard"
|
|
)
|
|
|
|
# Search for non-existent category names (parent_category_ids will be empty)
|
|
# This should not cause a SQL error with "IN ()"
|
|
search = Transaction::Search.new(@family, filters: { categories: [ "Non-Existent Category 1", "Non-Existent Category 2" ] })
|
|
results = search.transactions_scope
|
|
result_ids = results.pluck(:id)
|
|
|
|
# Should not include any transactions since categories don't exist
|
|
assert_not_includes result_ids, existing_entry.entryable.id
|
|
assert_equal 0, result_ids.length
|
|
|
|
# Verify totals also work without error
|
|
totals = search.totals
|
|
assert_equal 0, totals.count
|
|
assert_equal Money.new(0, "USD"), totals.expense_money
|
|
end
|
|
|
|
test "search matches entries name OR notes with ILIKE" do
|
|
# Transaction with matching text in name only
|
|
name_match = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
kind: "standard",
|
|
name: "Grocery Store"
|
|
)
|
|
|
|
# Transaction with matching text in notes only
|
|
notes_match = create_transaction(
|
|
account: @checking_account,
|
|
amount: 50,
|
|
kind: "standard",
|
|
name: "Credit Card Payment",
|
|
notes: "Payment of 50 USD at Grocery Mart on 2026-11-01"
|
|
)
|
|
|
|
# Transaction with no matching text
|
|
no_match = create_transaction(
|
|
account: @checking_account,
|
|
amount: 75,
|
|
kind: "standard",
|
|
name: "Gas station",
|
|
notes: "Fuel refill"
|
|
)
|
|
|
|
search = Transaction::Search.new(
|
|
@family,
|
|
filters: { search: "grocery" }
|
|
)
|
|
|
|
results = search.transactions_scope
|
|
result_ids = results.pluck(:id)
|
|
|
|
# Should match name
|
|
assert_includes result_ids, name_match.entryable.id
|
|
|
|
# Should match notes
|
|
assert_includes result_ids, notes_match.entryable.id
|
|
|
|
# Should not match unrelated transactions
|
|
assert_not_includes result_ids, no_match.entryable.id
|
|
end
|
|
|
|
test "uncategorized filter returns same results across all supported locales" do
|
|
# Create uncategorized transactions
|
|
uncategorized1 = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
kind: "standard"
|
|
)
|
|
|
|
uncategorized2 = create_transaction(
|
|
account: @checking_account,
|
|
amount: 200,
|
|
kind: "standard"
|
|
)
|
|
|
|
# Create a categorized transaction to ensure filter is working
|
|
categorized = create_transaction(
|
|
account: @checking_account,
|
|
amount: 300,
|
|
category: categories(:food_and_drink),
|
|
kind: "standard"
|
|
)
|
|
|
|
# Get the expected count using English locale (known working case)
|
|
I18n.with_locale(:en) do
|
|
english_uncategorized_name = Category.uncategorized.name
|
|
english_results = Transaction::Search.new(@family, filters: { categories: [ english_uncategorized_name ] }).transactions_scope
|
|
@expected_count = english_results.count
|
|
assert_equal 2, @expected_count, "English locale should return 2 uncategorized transactions"
|
|
end
|
|
|
|
# Test every supported locale returns the same count when filtering by that locale's uncategorized name
|
|
LanguagesHelper::SUPPORTED_LOCALES.each do |locale|
|
|
I18n.with_locale(locale) do
|
|
localized_uncategorized_name = Category.uncategorized.name
|
|
results = Transaction::Search.new(@family, filters: { categories: [ localized_uncategorized_name ] }).transactions_scope
|
|
result_count = results.count
|
|
|
|
assert_equal @expected_count, result_count,
|
|
"Locale '#{locale}' with uncategorized name '#{localized_uncategorized_name}' should return #{@expected_count} transactions but got #{result_count}"
|
|
end
|
|
end
|
|
end
|
|
|
|
test "uncategorized filter works with English parameter name regardless of current locale" do
|
|
# This tests the bug where URL contains English "Uncategorized" but user's locale is different
|
|
# Bug: /transactions/?q[categories][]=Uncategorized fails when locale is French
|
|
|
|
# Create uncategorized transactions
|
|
uncategorized1 = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
kind: "standard"
|
|
)
|
|
|
|
uncategorized2 = create_transaction(
|
|
account: @checking_account,
|
|
amount: 200,
|
|
kind: "standard"
|
|
)
|
|
|
|
# Create a categorized transaction to ensure filter is working
|
|
categorized = create_transaction(
|
|
account: @checking_account,
|
|
amount: 300,
|
|
category: categories(:food_and_drink),
|
|
kind: "standard"
|
|
)
|
|
|
|
# Get the English uncategorized name (this is what URLs typically contain)
|
|
english_uncategorized_name = I18n.t("models.category.uncategorized", locale: :en)
|
|
|
|
# Get the expected count using English locale (known working case)
|
|
expected_count = nil
|
|
I18n.with_locale(:en) do
|
|
results = Transaction::Search.new(@family, filters: { categories: [ english_uncategorized_name ] }).transactions_scope
|
|
expected_count = results.count
|
|
assert_equal 2, expected_count, "English locale should return 2 uncategorized transactions"
|
|
end
|
|
|
|
# Test that using the English parameter name works in every supported locale
|
|
# This catches the bug where French locale fails with English "Uncategorized" parameter
|
|
LanguagesHelper::SUPPORTED_LOCALES.each do |locale|
|
|
I18n.with_locale(locale) do
|
|
# Simulate URL parameter: q[categories][]=Uncategorized (English, regardless of user's locale)
|
|
results = Transaction::Search.new(@family, filters: { categories: [ english_uncategorized_name ] }).transactions_scope
|
|
result_count = results.count
|
|
|
|
assert_equal expected_count, result_count,
|
|
"Locale '#{locale}' should return #{expected_count} transactions when filtering with English 'Uncategorized' parameter, but got #{result_count}"
|
|
end
|
|
end
|
|
end
|
|
|
|
test "filtering for only No merchant returns only transactions without a merchant" do
|
|
with_merchant = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
merchant: merchants(:netflix),
|
|
kind: "standard"
|
|
)
|
|
|
|
without_merchant = create_transaction(
|
|
account: @checking_account,
|
|
amount: 200,
|
|
kind: "standard"
|
|
)
|
|
|
|
results = Transaction::Search.new(@family, filters: { merchants: [ Merchant::NO_MERCHANT_FILTER_VALUE ] }).transactions_scope
|
|
result_ids = results.pluck(:id)
|
|
|
|
assert_includes result_ids, without_merchant.entryable.id
|
|
assert_not_includes result_ids, with_merchant.entryable.id
|
|
end
|
|
|
|
test "filtering for No merchant plus a real merchant returns both" do
|
|
with_netflix = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
merchant: merchants(:netflix),
|
|
kind: "standard"
|
|
)
|
|
|
|
with_amazon = create_transaction(
|
|
account: @checking_account,
|
|
amount: 150,
|
|
merchant: merchants(:amazon),
|
|
kind: "standard"
|
|
)
|
|
|
|
without_merchant = create_transaction(
|
|
account: @checking_account,
|
|
amount: 200,
|
|
kind: "standard"
|
|
)
|
|
|
|
results = Transaction::Search.new(
|
|
@family,
|
|
filters: { merchants: [ merchants(:netflix).name, Merchant::NO_MERCHANT_FILTER_VALUE ] }
|
|
).transactions_scope
|
|
result_ids = results.pluck(:id)
|
|
|
|
assert_includes result_ids, with_netflix.entryable.id
|
|
assert_includes result_ids, without_merchant.entryable.id
|
|
assert_not_includes result_ids, with_amazon.entryable.id
|
|
end
|
|
|
|
test "filtering excludes transactions without a merchant when No merchant is not in the filter" do
|
|
with_merchant = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
merchant: merchants(:netflix),
|
|
kind: "standard"
|
|
)
|
|
|
|
without_merchant = create_transaction(
|
|
account: @checking_account,
|
|
amount: 200,
|
|
kind: "standard"
|
|
)
|
|
|
|
results = Transaction::Search.new(@family, filters: { merchants: [ merchants(:netflix).name ] }).transactions_scope
|
|
result_ids = results.pluck(:id)
|
|
|
|
assert_includes result_ids, with_merchant.entryable.id
|
|
assert_not_includes result_ids, without_merchant.entryable.id
|
|
end
|
|
|
|
test "filtering for only Untagged returns only transactions without tags" do
|
|
with_tag = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
tags: [ tags(:one) ],
|
|
kind: "standard"
|
|
)
|
|
|
|
without_tag = create_transaction(
|
|
account: @checking_account,
|
|
amount: 200,
|
|
kind: "standard"
|
|
)
|
|
|
|
results = Transaction::Search.new(@family, filters: { tags: [ Tag::UNTAGGED_FILTER_VALUE ] }).transactions_scope
|
|
result_ids = results.pluck(:id)
|
|
|
|
assert_includes result_ids, without_tag.entryable.id
|
|
assert_not_includes result_ids, with_tag.entryable.id
|
|
end
|
|
|
|
test "filtering for Untagged plus a real tag returns both" do
|
|
with_trips_tag = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
tags: [ tags(:one) ],
|
|
kind: "standard"
|
|
)
|
|
|
|
with_other_tag = create_transaction(
|
|
account: @checking_account,
|
|
amount: 150,
|
|
tags: [ tags(:two) ],
|
|
kind: "standard"
|
|
)
|
|
|
|
without_tag = create_transaction(
|
|
account: @checking_account,
|
|
amount: 200,
|
|
kind: "standard"
|
|
)
|
|
|
|
results = Transaction::Search.new(
|
|
@family,
|
|
filters: { tags: [ tags(:one).name, Tag::UNTAGGED_FILTER_VALUE ] }
|
|
).transactions_scope
|
|
result_ids = results.pluck(:id)
|
|
|
|
assert_includes result_ids, with_trips_tag.entryable.id
|
|
assert_includes result_ids, without_tag.entryable.id
|
|
assert_not_includes result_ids, with_other_tag.entryable.id
|
|
end
|
|
|
|
test "filtering excludes transactions without tags when Untagged is not in the filter" do
|
|
with_tag = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
tags: [ tags(:one) ],
|
|
kind: "standard"
|
|
)
|
|
|
|
without_tag = create_transaction(
|
|
account: @checking_account,
|
|
amount: 200,
|
|
kind: "standard"
|
|
)
|
|
|
|
results = Transaction::Search.new(@family, filters: { tags: [ tags(:one).name ] }).transactions_scope
|
|
result_ids = results.pluck(:id)
|
|
|
|
assert_includes result_ids, with_tag.entryable.id
|
|
assert_not_includes result_ids, without_tag.entryable.id
|
|
end
|
|
|
|
test "a real merchant literally named like the localized No merchant label remains selectable" do
|
|
# Regression test: filter values are opaque sentinels (Merchant::NO_MERCHANT_FILTER_VALUE), not the
|
|
# translated display name, so a merchant a user happens to name "No merchant" can't be misdetected
|
|
# as the synthetic filter option.
|
|
lookalike_merchant = @family.merchants.create!(
|
|
name: Merchant.no_merchant_name,
|
|
color: "#123456"
|
|
)
|
|
|
|
lookalike_transaction = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
merchant: lookalike_merchant,
|
|
kind: "standard"
|
|
)
|
|
|
|
without_merchant = create_transaction(
|
|
account: @checking_account,
|
|
amount: 200,
|
|
kind: "standard"
|
|
)
|
|
|
|
results = Transaction::Search.new(@family, filters: { merchants: [ lookalike_merchant.name ] }).transactions_scope
|
|
result_ids = results.pluck(:id)
|
|
|
|
assert_includes result_ids, lookalike_transaction.entryable.id
|
|
assert_not_includes result_ids, without_merchant.entryable.id
|
|
end
|
|
|
|
test "a real tag literally named like the localized Untagged label remains selectable" do
|
|
lookalike_tag = @family.tags.create!(name: Tag.untagged_name)
|
|
|
|
lookalike_transaction = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
tags: [ lookalike_tag ],
|
|
kind: "standard"
|
|
)
|
|
|
|
without_tag = create_transaction(
|
|
account: @checking_account,
|
|
amount: 200,
|
|
kind: "standard"
|
|
)
|
|
|
|
results = Transaction::Search.new(@family, filters: { tags: [ lookalike_tag.name ] }).transactions_scope
|
|
result_ids = results.pluck(:id)
|
|
|
|
assert_includes result_ids, lookalike_transaction.entryable.id
|
|
assert_not_includes result_ids, without_tag.entryable.id
|
|
end
|
|
|
|
test "Untagged filter works with controller-style reverse_chronological ordering" do
|
|
# Regression test for a PostgreSQL error: a top-level `.distinct` combined with `reverse_chronological`'s
|
|
# CASE-expression ORDER BY raises PG::InvalidColumnReference, since DISTINCT requires every ORDER BY
|
|
# expression to appear in the select list. apply_tag_filter must not add one.
|
|
with_tag = create_transaction(
|
|
account: @checking_account,
|
|
amount: 100,
|
|
tags: [ tags(:one), tags(:two) ],
|
|
kind: "standard"
|
|
)
|
|
|
|
without_tag = create_transaction(
|
|
account: @checking_account,
|
|
amount: 200,
|
|
kind: "standard"
|
|
)
|
|
|
|
results = Transaction::Search.new(@family, filters: { tags: [ Tag::UNTAGGED_FILTER_VALUE ] }).transactions_scope
|
|
|
|
assert_nothing_raised do
|
|
results.reverse_chronological.to_a
|
|
end
|
|
|
|
result_ids = results.pluck(:id)
|
|
assert_includes result_ids, without_tag.entryable.id
|
|
assert_not_includes result_ids, with_tag.entryable.id
|
|
end
|
|
|
|
test "empty accessible_account_ids yields no visible transactions" do
|
|
create_transaction(account: @checking_account, amount: 100)
|
|
|
|
search = Transaction::Search.new(@family, filters: {}, accessible_account_ids: [])
|
|
|
|
assert_empty search.transactions_scope
|
|
end
|
|
|
|
test "totals handles empty accessible_account_ids without raising" do
|
|
create_transaction(account: @checking_account, amount: 100)
|
|
|
|
search = Transaction::Search.new(@family, filters: {}, accessible_account_ids: [])
|
|
totals = search.totals
|
|
|
|
assert_equal 0, totals.count
|
|
assert_equal Money.new(0, @family.currency), totals.expense_money
|
|
assert_equal Money.new(0, @family.currency), totals.income_money
|
|
assert_equal Money.new(0, @family.currency), totals.transfer_inflow_money
|
|
assert_equal Money.new(0, @family.currency), totals.transfer_outflow_money
|
|
end
|
|
|
|
test "status filter matches pending transactions for every supported provider" do
|
|
confirmed = create_transaction(account: @checking_account, amount: 100, kind: "standard")
|
|
|
|
# One pending transaction per provider in PENDING_PROVIDERS. Regression for the
|
|
# status filter only checking simplefin/plaid/lunchflow and silently dropping
|
|
# enable_banking pending transactions.
|
|
pending_by_provider = Transaction::PENDING_PROVIDERS.index_with do |provider|
|
|
entry = create_transaction(account: @checking_account, amount: 100, kind: "standard")
|
|
entry.entryable.update!(extra: { provider => { "pending" => true } })
|
|
entry.entryable.id
|
|
end
|
|
|
|
pending_ids = Transaction::Search.new(@family, filters: { status: [ "pending" ] }).transactions_scope.pluck(:id)
|
|
confirmed_ids = Transaction::Search.new(@family, filters: { status: [ "confirmed" ] }).transactions_scope.pluck(:id)
|
|
|
|
pending_by_provider.each do |provider, txn_id|
|
|
assert_includes pending_ids, txn_id, "#{provider} pending txn should match the pending filter"
|
|
assert_not_includes confirmed_ids, txn_id, "#{provider} pending txn should be excluded from the confirmed filter"
|
|
end
|
|
|
|
assert_includes confirmed_ids, confirmed.entryable.id
|
|
assert_not_includes pending_ids, confirmed.entryable.id
|
|
end
|
|
|
|
# Regression for https://github.com/we-promise/sure/issues/3174
|
|
# The summary box (COUNT / SUM) on the Transactions page inflated when a
|
|
# transaction was tagged with multiple of the filtered tags, because the
|
|
# previous INNER JOIN produced one row per matching tag while the list
|
|
# deduplicated. These tests pin the invariant: totals.count must equal
|
|
# the deduplicated transaction count.
|
|
test "tag filter matches transactions with any of the filtered tags, once each" do
|
|
tag_a = @family.tags.create!(name: "TagA")
|
|
tag_b = @family.tags.create!(name: "TagB")
|
|
tag_c = @family.tags.create!(name: "TagC")
|
|
|
|
# One transaction carrying two of the filtered tags.
|
|
double_tagged = create_transaction(account: @checking_account, amount: 100, kind: "standard")
|
|
double_tagged.entryable.tags << tag_a
|
|
double_tagged.entryable.tags << tag_b
|
|
|
|
# One transaction carrying one of the filtered tags.
|
|
single_tagged = create_transaction(account: @checking_account, amount: 50, kind: "standard")
|
|
single_tagged.entryable.tags << tag_a
|
|
|
|
# One transaction carrying only a tag NOT in the filter set.
|
|
unrelated = create_transaction(account: @checking_account, amount: 75, kind: "standard")
|
|
unrelated.entryable.tags << tag_c
|
|
|
|
search = Transaction::Search.new(@family, filters: { tags: [ "TagA", "TagB" ] })
|
|
result_ids = search.transactions_scope.distinct.pluck(:id)
|
|
|
|
assert_includes result_ids, double_tagged.entryable.id
|
|
assert_includes result_ids, single_tagged.entryable.id
|
|
assert_not_includes result_ids, unrelated.entryable.id
|
|
end
|
|
|
|
# Pins the exact bug from #3174: the list count and the summary count must
|
|
# match even when a transaction is tagged with more than one filtered tag.
|
|
test "totals.count is not inflated when a transaction has multiple matching tags" do
|
|
tag_a = @family.tags.create!(name: "FanA")
|
|
tag_b = @family.tags.create!(name: "FanB")
|
|
|
|
double_tagged = create_transaction(account: @checking_account, amount: 100, kind: "standard")
|
|
double_tagged.entryable.tags << tag_a
|
|
double_tagged.entryable.tags << tag_b
|
|
|
|
single_tagged = create_transaction(account: @checking_account, amount: 50, kind: "standard")
|
|
single_tagged.entryable.tags << tag_a
|
|
|
|
# Sanity: the list deduplicates — exactly 2 rows.
|
|
list_count = Transaction::Search.new(@family, filters: { tags: [ "FanA", "FanB" ] })
|
|
.transactions_scope.count
|
|
|
|
# The summary box must return the same number — without the EXISTS-style
|
|
# subquery fix, `.joins(:tags)` produced 3 rows (2 for double_tagged + 1
|
|
# for single_tagged), so `tot.count` returned 3.
|
|
totals = Transaction::Search.new(@family, filters: { tags: [ "FanA", "FanB" ] }).totals
|
|
|
|
assert_equal 2, list_count
|
|
assert_equal 2, totals.count, "summary count must equal the deduplicated list count (see #3174)"
|
|
assert_equal Money.new(150, "USD"), totals.expense_money, "SUM must not double-count tagged rows"
|
|
end
|
|
|
|
# Extends the invariant to a three-way tag fan-out, so a future INNER JOIN
|
|
# regression can't hide behind the two-tag case above.
|
|
test "totals.count is stable when a transaction has three matching tags" do
|
|
t1 = @family.tags.create!(name: "Triple1")
|
|
t2 = @family.tags.create!(name: "Triple2")
|
|
t3 = @family.tags.create!(name: "Triple3")
|
|
|
|
triple_tagged = create_transaction(account: @checking_account, amount: 42, kind: "standard")
|
|
triple_tagged.entryable.tags << t1
|
|
triple_tagged.entryable.tags << t2
|
|
triple_tagged.entryable.tags << t3
|
|
|
|
totals = Transaction::Search.new(@family, filters: { tags: [ "Triple1", "Triple2", "Triple3" ] }).totals
|
|
|
|
assert_equal 1, totals.count, "a single transaction tagged with all three must be counted once"
|
|
assert_equal Money.new(42, "USD"), totals.expense_money
|
|
end
|
|
end
|