From ba835c74eee853ee3e0a2cd4e043dbf571695b28 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 00:55:55 +0100 Subject: [PATCH] Add transaction details and notes filters to rules engine (#439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * Add transaction details and notes filters to rules engine Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com> * Refine transaction details filter to use ILIKE for both operators Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com> * Add type methods and fix operator semantics for transaction filters Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com> * Refactor to use parent class sanitize_operator and add clear documentation Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com> * Linter noise --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com> Co-authored-by: Juan José Mata --- .../condition_filter/transaction_details.rb | 31 ++++ .../condition_filter/transaction_notes.rb | 14 ++ .../rule/registry/transaction_resource.rb | 4 +- test/models/rule/condition_test.rb | 149 ++++++++++++++++++ test/models/rule_test.rb | 88 +++++++++++ 5 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 app/models/rule/condition_filter/transaction_details.rb create mode 100644 app/models/rule/condition_filter/transaction_notes.rb diff --git a/app/models/rule/condition_filter/transaction_details.rb b/app/models/rule/condition_filter/transaction_details.rb new file mode 100644 index 000000000..b93e769c1 --- /dev/null +++ b/app/models/rule/condition_filter/transaction_details.rb @@ -0,0 +1,31 @@ +class Rule::ConditionFilter::TransactionDetails < Rule::ConditionFilter + def type + "text" + end + + def prepare(scope) + scope + end + + def apply(scope, operator, value) + # Search within the transaction's extra JSONB field + # This allows matching on provider-specific details like SimpleFin payee, description, memo + + # Validate operator using parent class method + sanitize_operator(operator) + + if operator == "is_null" + # Check if extra field is empty or null + scope.where("transactions.extra IS NULL OR transactions.extra = '{}'::jsonb") + else + # For both "like" and "=" operators, perform contains search + # "like" is case-insensitive (ILIKE), "=" is case-sensitive (LIKE) + # Note: For JSONB fields, both operators use contains semantics rather than exact match + # because searching within structured JSON data makes contains more useful than exact equality + sanitized_value = "%#{ActiveRecord::Base.sanitize_sql_like(value)}%" + sql_operator = operator == "like" ? "ILIKE" : "LIKE" + + scope.where("transactions.extra::text #{sql_operator} ?", sanitized_value) + end + end +end diff --git a/app/models/rule/condition_filter/transaction_notes.rb b/app/models/rule/condition_filter/transaction_notes.rb new file mode 100644 index 000000000..826214259 --- /dev/null +++ b/app/models/rule/condition_filter/transaction_notes.rb @@ -0,0 +1,14 @@ +class Rule::ConditionFilter::TransactionNotes < Rule::ConditionFilter + def type + "text" + end + + def prepare(scope) + scope.with_entry + end + + def apply(scope, operator, value) + expression = build_sanitized_where_condition("entries.notes", operator, value) + scope.where(expression) + end +end diff --git a/app/models/rule/registry/transaction_resource.rb b/app/models/rule/registry/transaction_resource.rb index 02de3d8c8..d051b5837 100644 --- a/app/models/rule/registry/transaction_resource.rb +++ b/app/models/rule/registry/transaction_resource.rb @@ -8,7 +8,9 @@ class Rule::Registry::TransactionResource < Rule::Registry Rule::ConditionFilter::TransactionName.new(rule), Rule::ConditionFilter::TransactionAmount.new(rule), Rule::ConditionFilter::TransactionMerchant.new(rule), - Rule::ConditionFilter::TransactionCategory.new(rule) + Rule::ConditionFilter::TransactionCategory.new(rule), + Rule::ConditionFilter::TransactionDetails.new(rule), + Rule::ConditionFilter::TransactionNotes.new(rule) ] end diff --git a/test/models/rule/condition_test.rb b/test/models/rule/condition_test.rb index 2fbfb375d..353963629 100644 --- a/test/models/rule/condition_test.rb +++ b/test/models/rule/condition_test.rb @@ -182,4 +182,153 @@ class Rule::ConditionTest < ActiveSupport::TestCase 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 end diff --git a/test/models/rule_test.rb b/test/models/rule_test.rb index bf5622035..bf082ec73 100644 --- a/test/models/rule_test.rb +++ b/test/models/rule_test.rb @@ -113,4 +113,92 @@ class RuleTest < ActiveSupport::TestCase assert_not rule.valid? assert_equal [ "Compound conditions cannot be nested" ], rule.errors.full_messages end + + test "rule matching on transaction details" do + # Create PayPal transaction with underlying merchant in details + paypal_entry = create_transaction( + date: Date.current, + account: @account, + name: "PayPal", + amount: 50 + ) + paypal_entry.transaction.update!( + extra: { + "simplefin" => { + "payee" => "Whole Foods via PayPal", + "description" => "Grocery shopping" + } + } + ) + + # Create another PayPal transaction with different underlying merchant + paypal_entry2 = create_transaction( + date: Date.current, + account: @account, + name: "PayPal", + amount: 100 + ) + paypal_entry2.transaction.update!( + extra: { + "simplefin" => { + "payee" => "Amazon via PayPal" + } + } + ) + + # Rule to categorize PayPal transactions containing "Whole Foods" in details + rule = Rule.create!( + family: @family, + resource_type: "transaction", + effective_date: 1.day.ago.to_date, + conditions: [ Rule::Condition.new(condition_type: "transaction_details", operator: "like", value: "Whole Foods") ], + actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ] + ) + + rule.apply + + paypal_entry.reload + paypal_entry2.reload + + assert_equal @groceries_category, paypal_entry.transaction.category, "PayPal transaction with 'Whole Foods' in details should be categorized" + assert_nil paypal_entry2.transaction.category, "PayPal transaction without 'Whole Foods' in details should not be categorized" + end + + test "rule matching on transaction notes" do + # Create transaction with notes + transaction_entry = create_transaction( + date: Date.current, + account: @account, + name: "Expense", + amount: 50 + ) + transaction_entry.update!(notes: "Business lunch with client") + + # Create another transaction without relevant notes + transaction_entry2 = create_transaction( + date: Date.current, + account: @account, + name: "Expense", + amount: 100 + ) + transaction_entry2.update!(notes: "Personal expense") + + # Rule to categorize transactions with "business" in notes + business_category = @family.categories.create!(name: "Business") + rule = Rule.create!( + family: @family, + resource_type: "transaction", + effective_date: 1.day.ago.to_date, + conditions: [ Rule::Condition.new(condition_type: "transaction_notes", operator: "like", value: "business") ], + actions: [ Rule::Action.new(action_type: "set_transaction_category", value: business_category.id) ] + ) + + rule.apply + + transaction_entry.reload + transaction_entry2.reload + + assert_equal business_category, transaction_entry.transaction.category, "Transaction with 'business' in notes should be categorized" + assert_nil transaction_entry2.transaction.category, "Transaction without 'business' in notes should not be categorized" + end end