diff --git a/app/models/rule/action_executor/exclude_transaction.rb b/app/models/rule/action_executor/exclude_transaction.rb new file mode 100644 index 000000000..e56edadc5 --- /dev/null +++ b/app/models/rule/action_executor/exclude_transaction.rb @@ -0,0 +1,26 @@ +class Rule::ActionExecutor::ExcludeTransaction < Rule::ActionExecutor + def label + "Exclude from budgeting and reports" + end + + def execute(transaction_scope, value: nil, ignore_attribute_locks: false, rule_run: nil) + scope = transaction_scope.with_entry + + unless ignore_attribute_locks + # Filter by entry's locked_attributes, not transaction's + # Since excluded is on Entry, not Transaction, we need to check entries.locked_attributes + scope = scope.where.not( + Arel.sql("entries.locked_attributes ? 'excluded'") + ) + end + + count_modified_resources(scope) do |txn| + # enrich_attribute returns true if the entry was actually modified, false otherwise + txn.entry.enrich_attribute( + :excluded, + true, + source: "rule" + ) + end + end +end diff --git a/app/models/rule/registry/transaction_resource.rb b/app/models/rule/registry/transaction_resource.rb index 115a747fd..02de3d8c8 100644 --- a/app/models/rule/registry/transaction_resource.rb +++ b/app/models/rule/registry/transaction_resource.rb @@ -17,7 +17,8 @@ class Rule::Registry::TransactionResource < Rule::Registry Rule::ActionExecutor::SetTransactionCategory.new(rule), Rule::ActionExecutor::SetTransactionTags.new(rule), Rule::ActionExecutor::SetTransactionMerchant.new(rule), - Rule::ActionExecutor::SetTransactionName.new(rule) + Rule::ActionExecutor::SetTransactionName.new(rule), + Rule::ActionExecutor::ExcludeTransaction.new(rule) ] if ai_enabled? diff --git a/test/models/rule_test.rb b/test/models/rule_test.rb index 5cddc644a..bf5622035 100644 --- a/test/models/rule_test.rb +++ b/test/models/rule_test.rb @@ -55,6 +55,45 @@ class RuleTest < ActiveSupport::TestCase assert_equal @groceries_category, transaction_entry2.transaction.category end + test "exclude transaction rule" do + transaction_entry = create_transaction(date: Date.current, account: @account, merchant: @whole_foods_merchant) + + assert_not transaction_entry.excluded, "Transaction should not be excluded initially" + + rule = Rule.create!( + family: @family, + resource_type: "transaction", + effective_date: 1.day.ago.to_date, + conditions: [ Rule::Condition.new(condition_type: "transaction_merchant", operator: "=", value: @whole_foods_merchant.id) ], + actions: [ Rule::Action.new(action_type: "exclude_transaction") ] + ) + + rule.apply + + transaction_entry.reload + + assert transaction_entry.excluded, "Transaction should be excluded after rule applies" + end + + test "exclude transaction rule respects attribute locks" do + transaction_entry = create_transaction(date: Date.current, account: @account, merchant: @whole_foods_merchant) + transaction_entry.lock_attr!(:excluded) + + rule = Rule.create!( + family: @family, + resource_type: "transaction", + effective_date: 1.day.ago.to_date, + conditions: [ Rule::Condition.new(condition_type: "transaction_merchant", operator: "=", value: @whole_foods_merchant.id) ], + actions: [ Rule::Action.new(action_type: "exclude_transaction") ] + ) + + rule.apply + + transaction_entry.reload + + assert_not transaction_entry.excluded, "Transaction should not be excluded when attribute is locked" + end + # Artificial limitation put in place to prevent users from creating overly complex rules # Rules should be shallow and wide test "no nested compound conditions" do