Files
sure/test/models/rule/condition_test.rb
T
super 1a5d04d527 feat(rules): add a Transaction tag condition filter (#2558)
* feat(rules): add a Transaction tag condition filter

Rules could already set tags via the set_transaction_tags action but had
no way to match transactions by an existing tag. Add a select-type
transaction_tag condition filter (mirroring transaction_category) with the
standard "Equal to" and "Is empty" operators, registered on the
transaction rule resource so it surfaces in the rule builder automatically.

Also remap the tag UUID<->name in family data export/import for the new
condition, matching how the transaction_category/transaction_merchant
conditions and the set_transaction_tags action are already handled, so
tag-based rules survive an export/import round-trip.

Closes #2557

* fix(rules): match transaction_tag via EXISTS so compound tag conditions work

Addresses review feedback on #2558: the tag filter joined transactions.tags
and predicated on tags.id, which broke two compound cases:
- two ANDed tag conditions collapsed to `tags.id = a AND tags.id = b` on the
  same joined alias and could never match, even when the transaction had both
  tags;
- OR / multi-tag matches returned a transaction once per tagging row, inflating
  counts and making rule actions iterate duplicate transactions.

Use a correlated EXISTS subquery per condition instead. Each condition is
independent (fixes the AND case) and no join is added, so rows are never
multiplied (fixes the OR duplication) and prepare adds nothing, keeping
branches structurally compatible inside a compound OR. Add tests for both.
2026-08-22 07:38:09 +02:00

633 lines
18 KiB
Ruby

require "test_helper"
class Rule::ConditionTest < ActiveSupport::TestCase
include EntriesTestHelper
setup do
@family = families(:empty)
@transaction_rule = rules(:one)
@account = @family.accounts.create!(name: "Rule test", balance: 1000, currency: "USD", accountable: Depository.new)
@grocery_category = @family.categories.create!(name: "Grocery")
@whole_foods_merchant = @family.merchants.create!(name: "Whole Foods", type: "FamilyMerchant")
# Some sample transactions to work with
create_transaction(date: Date.current, account: @account, amount: 100, name: "Rule test transaction1", merchant: @whole_foods_merchant)
create_transaction(date: Date.current, account: @account, amount: -200, name: "Rule test transaction2")
create_transaction(date: 1.day.ago.to_date, account: @account, amount: 50, name: "Rule test transaction3")
create_transaction(date: 1.year.ago.to_date, account: @account, amount: 10, name: "Rule test transaction4", merchant: @whole_foods_merchant)
create_transaction(date: 1.year.ago.to_date, account: @account, amount: 1000, name: "Rule test transaction5")
@rule_scope = @account.transactions
end
test "applies transaction_name condition" do
scope = @rule_scope
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_name",
operator: "=",
value: "Rule test transaction1"
)
scope = condition.prepare(scope)
assert_equal 5, scope.count
filtered = condition.apply(scope)
assert_equal 1, filtered.count
end
test "applies transaction_amount condition using absolute values" do
scope = @rule_scope
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_amount",
operator: ">",
value: "50"
)
scope = condition.prepare(scope)
filtered = condition.apply(scope)
assert_equal 3, filtered.count
end
test "applies transaction_amount not equal operator using absolute values" do
create_transaction(date: Date.current, account: @account, amount: -100, name: "Rule test transaction negative 100")
scope = @account.transactions
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_amount",
operator: "!=",
value: "100"
)
scope = condition.prepare(scope)
filtered = condition.apply(scope)
# Absolute amounts: 100, -100, 200, 50, 10, 1000 — exclude both ±100
assert_equal 4, filtered.count
assert_not filtered.any? { |txn| txn.entry.amount.abs == 100 }
end
test "applies transaction_merchant condition" do
scope = @rule_scope
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_merchant",
operator: "=",
value: @whole_foods_merchant.id
)
scope = condition.prepare(scope)
filtered = condition.apply(scope)
assert_equal 2, filtered.count
end
test "applies compound and condition" do
scope = @rule_scope
parent_condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "compound",
operator: "and",
sub_conditions: [
Rule::Condition.new(
condition_type: "transaction_merchant",
operator: "=",
value: @whole_foods_merchant.id
),
Rule::Condition.new(
condition_type: "transaction_amount",
operator: "<",
value: "50"
)
]
)
scope = parent_condition.prepare(scope)
filtered = parent_condition.apply(scope)
assert_equal 1, filtered.count
end
test "applies compound or condition" do
scope = @rule_scope
parent_condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "compound",
operator: "or",
sub_conditions: [
Rule::Condition.new(
condition_type: "transaction_merchant",
operator: "=",
value: @whole_foods_merchant.id
),
Rule::Condition.new(
condition_type: "transaction_amount",
operator: "<",
value: "50"
)
]
)
scope = parent_condition.prepare(scope)
filtered = parent_condition.apply(scope)
assert_equal 2, filtered.count
end
test "applies transaction_category condition" do
scope = @rule_scope
# Set category for one transaction
@account.transactions.first.update!(category: @grocery_category)
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_category",
operator: "=",
value: @grocery_category.id
)
scope = condition.prepare(scope)
filtered = condition.apply(scope)
assert_equal 1, filtered.count
assert_equal @grocery_category.id, filtered.first.category_id
end
test "applies is_null condition for transaction_category" do
scope = @rule_scope
# Set category for one transaction
@account.transactions.first.update!(category: @grocery_category)
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_category",
operator: "is_null",
value: nil
)
scope = condition.prepare(scope)
filtered = condition.apply(scope)
assert_equal 4, filtered.count
assert filtered.all? { |t| t.category_id.nil? }
end
test "applies transaction_tag condition" do
scope = @rule_scope
tag = @family.tags.create!(name: "Reimbursable")
tagged = @account.transactions.first
tagged.tags << tag
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_tag",
operator: "=",
value: tag.id
)
scope = condition.prepare(scope)
filtered = condition.apply(scope)
assert_equal 1, filtered.count
assert_equal tagged.id, filtered.first.id
end
test "applies is_null condition for transaction_tag" do
scope = @rule_scope
tag = @family.tags.create!(name: "Reimbursable")
@account.transactions.first.tags << tag
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_tag",
operator: "is_null",
value: nil
)
scope = condition.prepare(scope)
filtered = condition.apply(scope)
# The 4 transactions without any tag
assert_equal 4, filtered.count
assert filtered.none? { |t| t.tags.include?(tag) }
end
test "compound AND of two transaction_tag conditions matches transactions having both tags" do
scope = @rule_scope
tag_a = @family.tags.create!(name: "Reimbursable")
tag_b = @family.tags.create!(name: "Business")
both = @account.transactions.first
both.tags << [ tag_a, tag_b ]
only_a = @account.transactions.second
only_a.tags << tag_a
parent_condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "compound",
operator: "and",
sub_conditions: [
Rule::Condition.new(condition_type: "transaction_tag", operator: "=", value: tag_a.id),
Rule::Condition.new(condition_type: "transaction_tag", operator: "=", value: tag_b.id)
]
)
scope = parent_condition.prepare(scope)
filtered = parent_condition.apply(scope)
# Only the transaction carrying BOTH tags matches (a single joined alias could not)
assert_equal 1, filtered.count
assert_equal both.id, filtered.first.id
end
test "compound OR of transaction_tag conditions does not duplicate multi-tagged transactions" do
scope = @rule_scope
tag_a = @family.tags.create!(name: "Reimbursable")
tag_b = @family.tags.create!(name: "Business")
multi = @account.transactions.first
multi.tags << [ tag_a, tag_b ]
parent_condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "compound",
operator: "or",
sub_conditions: [
Rule::Condition.new(condition_type: "transaction_tag", operator: "=", value: tag_a.id),
Rule::Condition.new(condition_type: "transaction_tag", operator: "=", value: tag_b.id)
]
)
scope = parent_condition.prepare(scope)
filtered = parent_condition.apply(scope)
# Matches both OR branches but must be returned exactly once (no join fan-out)
assert_equal 1, filtered.count
assert_equal [ multi.id ], filtered.map(&:id)
end
test "applies is_null condition for transaction_merchant" do
scope = @rule_scope
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_merchant",
operator: "is_null",
value: nil
)
scope = condition.prepare(scope)
filtered = condition.apply(scope)
assert_equal 3, filtered.count
assert filtered.all? { |t| t.merchant_id.nil? }
end
test "applies transaction_details condition with like operator" do
scope = @rule_scope
# Create a transaction with extra details (simulating PayPal with underlying merchant)
paypal_entry = create_transaction(
date: Date.current,
account: @account,
amount: 75,
name: "PayPal"
)
paypal_entry.transaction.update!(
extra: {
"simplefin" => {
"payee" => "Amazon via PayPal",
"description" => "Purchase from Amazon",
"memo" => "Order #12345"
}
}
)
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_details",
operator: "like",
value: "Amazon"
)
scope = condition.prepare(scope)
filtered = condition.apply(scope)
assert_equal 1, filtered.count
assert_equal paypal_entry.transaction.id, filtered.first.id
end
test "applies transaction_details condition with equal operator case-sensitive" do
scope = @rule_scope
# Create transaction with specific details
transaction_entry = create_transaction(
date: Date.current,
account: @account,
amount: 100,
name: "PayPal"
)
transaction_entry.transaction.update!(
extra: {
"simplefin" => {
"payee" => "Netflix"
}
}
)
# Test case-sensitive match (should match)
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_details",
operator: "=",
value: "Netflix"
)
scope = condition.prepare(scope)
filtered = condition.apply(scope)
assert_equal 1, filtered.count
# Test case-sensitive match (should NOT match due to case difference)
condition_lowercase = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_details",
operator: "=",
value: "netflix"
)
scope = condition_lowercase.prepare(scope)
filtered = condition_lowercase.apply(scope)
assert_equal 0, filtered.count
end
test "applies transaction_details condition with is_null operator" do
scope = @rule_scope
# Create transaction with extra details
transaction_with_details = create_transaction(
date: Date.current,
account: @account,
amount: 50,
name: "Transaction with details"
)
transaction_with_details.transaction.update!(
extra: { "simplefin" => { "payee" => "Test Merchant" } }
)
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_details",
operator: "is_null",
value: nil
)
scope = condition.prepare(scope)
filtered = condition.apply(scope)
# Should return all original transactions (which have no extra details) but not the new one
assert_equal 5, filtered.count
assert_not filtered.map(&:id).include?(transaction_with_details.transaction.id)
end
test "applies transaction_notes condition" do
scope = @rule_scope
# Add notes to one transaction
transaction_entry = @account.entries.first
transaction_entry.update!(notes: "Important: This is a business expense")
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_notes",
operator: "like",
value: "business expense"
)
scope = condition.prepare(scope)
filtered = condition.apply(scope)
assert_equal 1, filtered.count
assert_equal transaction_entry.transaction.id, filtered.first.id
end
test "applies transaction_notes condition with is_null operator" do
scope = @rule_scope
# Add notes to one transaction
transaction_entry = @account.entries.first
transaction_entry.update!(notes: "Some notes")
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_notes",
operator: "is_null",
value: nil
)
scope = condition.prepare(scope)
filtered = condition.apply(scope)
# Should return all transactions without notes
assert_equal 4, filtered.count
assert_not filtered.map(&:id).include?(transaction_entry.transaction.id)
end
test "applies transaction_type condition for income" do
scope = @rule_scope
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_type",
operator: "=",
value: "income"
)
scope = condition.prepare(scope)
filtered = condition.apply(scope)
# transaction2 has amount -200 (income)
assert_equal 1, filtered.count
assert filtered.all? { |t| t.entry.amount.negative? }
end
test "applies transaction_type condition for expense" do
scope = @rule_scope
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_type",
operator: "=",
value: "expense"
)
scope = condition.prepare(scope)
filtered = condition.apply(scope)
# transaction1, 3, 4, 5 have positive amounts (expenses)
assert_equal 4, filtered.count
assert filtered.all? { |t| t.entry.amount.positive? && !t.transfer? }
end
test "applies transaction_type condition for transfer" do
scope = @rule_scope
# Create a transfer transaction
transfer_entry = create_transaction(
date: Date.current,
account: @account,
amount: 500,
name: "Transfer to savings"
)
transfer_entry.transaction.update!(kind: "funds_movement")
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_type",
operator: "=",
value: "transfer"
)
scope = condition.prepare(scope)
filtered = condition.apply(scope)
assert_equal 1, filtered.count
assert_equal transfer_entry.transaction.id, filtered.first.id
assert filtered.first.transfer?
end
test "transaction_type expense excludes transfers" do
scope = @rule_scope
# Create a transfer with positive amount (would look like expense)
transfer_entry = create_transaction(
date: Date.current,
account: @account,
amount: 500,
name: "Transfer to savings"
)
transfer_entry.transaction.update!(kind: "funds_movement")
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_type",
operator: "=",
value: "expense"
)
scope = condition.prepare(scope)
filtered = condition.apply(scope)
# Should NOT include the transfer even though it has positive amount
assert_not filtered.map(&:id).include?(transfer_entry.transaction.id)
end
test "transaction_type income excludes transfers" do
scope = @rule_scope
# Create a transfer inflow (negative amount)
transfer_entry = create_transaction(
date: Date.current,
account: @account,
amount: -500,
name: "Transfer from savings"
)
transfer_entry.transaction.update!(kind: "funds_movement")
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_type",
operator: "=",
value: "income"
)
scope = condition.prepare(scope)
filtered = condition.apply(scope)
# Should NOT include the transfer even though it has negative amount
assert_not filtered.map(&:id).include?(transfer_entry.transaction.id)
end
test "transaction_type transfer includes investment_contribution" do
scope = @rule_scope
# Create investment contribution with negative amount (inflow from provider)
contribution_entry = create_transaction(
date: Date.current,
account: @account,
amount: -1000,
name: "401k contribution"
)
contribution_entry.transaction.update!(kind: "investment_contribution")
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_type",
operator: "=",
value: "transfer"
)
scope = condition.prepare(scope)
filtered = condition.apply(scope)
# investment_contribution is a transfer kind
assert filtered.map(&:id).include?(contribution_entry.transaction.id)
# Should NOT match expense filter
expense_condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_type",
operator: "=",
value: "expense"
)
expense_scope = expense_condition.prepare(@rule_scope)
expense_filtered = expense_condition.apply(expense_scope)
assert_not expense_filtered.map(&:id).include?(contribution_entry.transaction.id)
end
test "transaction_type income excludes investment_contribution" do
scope = @rule_scope
# Create investment contribution with negative amount
contribution_entry = create_transaction(
date: Date.current,
account: @account,
amount: -1000,
name: "401k contribution"
)
contribution_entry.transaction.update!(kind: "investment_contribution")
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_type",
operator: "=",
value: "income"
)
scope = condition.prepare(scope)
filtered = condition.apply(scope)
# Should NOT include investment_contribution even with negative amount
assert_not filtered.map(&:id).include?(contribution_entry.transaction.id)
end
end