diff --git a/app/models/rule.rb b/app/models/rule.rb index 69c5f6ec..6e600337 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -1,12 +1,11 @@ class Rule < ApplicationRecord - RESOURCE_TYPES = %w[transaction].freeze + UnsupportedResourceTypeError = Class.new(StandardError) belongs_to :family has_many :conditions, dependent: :destroy has_many :actions, dependent: :destroy - validates :effective_date, presence: true - validates :resource_type, inclusion: { in: RESOURCE_TYPES } + validates :resource_type, presence: true def apply scope = resource_scope @@ -20,11 +19,15 @@ class Rule < ApplicationRecord end end - private - def resource_scope - case resource_type - when "transaction" - family.transactions - end + def resource_scope + case resource_type + when "transaction" + family.transactions + .active + .with_entry + .where(account_entries: { date: effective_date..nil }) + else + raise UnsupportedResourceTypeError, "Unsupported resource type: #{resource_type}" end + end end diff --git a/app/models/rule/action.rb b/app/models/rule/action.rb index 4b941472..763447d6 100644 --- a/app/models/rule/action.rb +++ b/app/models/rule/action.rb @@ -8,9 +8,8 @@ class Rule::Action < ApplicationRecord def apply(resource_scope) case action_type when "set_transaction_category" - category = rule.family.categories.find_by(name: value) - raise "Category not found: #{value}" unless category - resource_scope.update_all(category_id: category.id) + category = rule.family.categories.find(value) + resource_scope.update_all(category_id: category.id, updated_at: Time.current) when "set_transaction_tags" # TODO when "set_transaction_frequency" diff --git a/app/models/rule/condition.rb b/app/models/rule/condition.rb index 9b77f2a9..7361fb12 100644 --- a/app/models/rule/condition.rb +++ b/app/models/rule/condition.rb @@ -16,13 +16,11 @@ class Rule::Condition < ApplicationRecord case condition_type when "compound" - sub_conditions.each do |sub_condition| - filtered_scope = sub_condition.apply(filtered_scope) - end + filtered_scope = build_compound_scope(filtered_scope) when "transaction_name" - filtered_scope = filtered_scope.with_entry.where("account_entries.name #{Arel.sql(sanitize_operator(operator))} ?", value) + filtered_scope = filtered_scope.where("account_entries.name #{Arel.sql(sanitize_operator(operator))} ?", value) when "transaction_amount" - filtered_scope = filtered_scope.with_entry.where("account_entries.amount #{Arel.sql(sanitize_operator(operator))} ?", value.to_d) + filtered_scope = filtered_scope.where("account_entries.amount #{Arel.sql(sanitize_operator(operator))} ?", value.to_d) when "transaction_merchant" filtered_scope = filtered_scope.left_joins(:merchant).where(merchant: { name: value }) else @@ -37,4 +35,28 @@ class Rule::Condition < ApplicationRecord raise UnsupportedOperatorError, "Unsupported operator: #{operator}" unless OPERATORS.include?(operator) operator end + + def build_compound_scope(filtered_scope) + if operator == "or" + combined_scope = nil + + sub_conditions.each do |sub_condition| + sub_scope = sub_condition.apply(filtered_scope) + + if combined_scope.nil? + combined_scope = sub_scope + else + combined_scope = combined_scope.or(sub_scope) + end + end + + filtered_scope = combined_scope || filtered_scope + else + sub_conditions.each do |sub_condition| + filtered_scope = sub_condition.apply(filtered_scope) + end + end + + filtered_scope + end end diff --git a/db/migrate/20250401194500_create_rules.rb b/db/migrate/20250401194500_create_rules.rb index 3fc18141..8f4ecf6e 100644 --- a/db/migrate/20250401194500_create_rules.rb +++ b/db/migrate/20250401194500_create_rules.rb @@ -4,7 +4,7 @@ class CreateRules < ActiveRecord::Migration[7.2] t.references :family, null: false, foreign_key: true, type: :uuid t.string :resource_type, null: false - t.date :effective_date, null: false + t.date :effective_date t.boolean :active, null: false, default: true t.timestamps end diff --git a/db/schema.rb b/db/schema.rb index 972fb1ca..85bc69b3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -492,7 +492,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_01_194500) do create_table "rules", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "family_id", null: false t.string "resource_type", null: false - t.date "effective_date", null: false + t.date "effective_date" t.boolean "active", default: true, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false diff --git a/test/fixtures/rules.yml b/test/fixtures/rules.yml new file mode 100644 index 00000000..db7a3566 --- /dev/null +++ b/test/fixtures/rules.yml @@ -0,0 +1,3 @@ +one: + family: dylan_family + resource_type: "transaction" \ No newline at end of file diff --git a/test/models/rule/action_test.rb b/test/models/rule/action_test.rb new file mode 100644 index 00000000..8ecc7a67 --- /dev/null +++ b/test/models/rule/action_test.rb @@ -0,0 +1,37 @@ +require "test_helper" + +class Rule::ActionTest < ActiveSupport::TestCase + include Account::EntriesTestHelper + + setup do + @family = families(:empty) + @transaction_rule = @family.rules.create!(resource_type: "transaction") + @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") + + # 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 "set_transaction_category" do + action = Rule::Action.new( + rule: @transaction_rule, + action_type: "set_transaction_category", + value: @grocery_category.id + ) + + action.apply(@rule_scope) + + @rule_scope.reload.each do |transaction| + assert_equal @grocery_category.id, transaction.category_id + end + end +end diff --git a/test/models/rule/condition_test.rb b/test/models/rule/condition_test.rb new file mode 100644 index 00000000..a1a2bf13 --- /dev/null +++ b/test/models/rule/condition_test.rb @@ -0,0 +1,108 @@ +require "test_helper" + +class Rule::ConditionTest < ActiveSupport::TestCase + include Account::EntriesTestHelper + + setup do + @family = families(:empty) + @transaction_rule = @family.rules.create!(resource_type: "transaction") + @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") + + # 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 + condition = Rule::Condition.new( + rule: @transaction_rule, + condition_type: "transaction_name", + operator: "=", + value: "Rule test transaction1" + ) + + assert_equal 5, @rule_scope.count + + filtered = condition.apply(@rule_scope) + + assert_equal 1, filtered.count + end + + test "applies transaction_amount condition" do + condition = Rule::Condition.new( + rule: @transaction_rule, + condition_type: "transaction_amount", + operator: ">", + value: "50" + ) + + filtered = condition.apply(@rule_scope) + assert_equal 2, filtered.count + end + + test "applies transaction_merchant condition" do + condition = Rule::Condition.new( + rule: @transaction_rule, + condition_type: "transaction_merchant", + operator: "=", + value: "Whole Foods" + ) + + filtered = condition.apply(@rule_scope) + assert_equal 2, filtered.count + end + + test "applies compound and condition" do + 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" + ), + Rule::Condition.new( + condition_type: "transaction_amount", + operator: "<", + value: "50" + ) + ] + ) + + filtered = parent_condition.apply(@rule_scope) + assert_equal 1, filtered.count + end + + test "applies compound or condition" do + 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" + ), + Rule::Condition.new( + condition_type: "transaction_amount", + operator: "<", + value: "50" + ) + ] + ) + + filtered = parent_condition.apply(@rule_scope) + assert_equal 3, filtered.count + end +end