diff --git a/app/models/rule.rb b/app/models/rule.rb index a49cdca0..69c5f6ec 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -1,72 +1,30 @@ class Rule < ApplicationRecord + RESOURCE_TYPES = %w[transaction].freeze + belongs_to :family has_many :conditions, dependent: :destroy has_many :actions, dependent: :destroy validates :effective_date, presence: true - - def get_operator_symbol(operator) - case operator - when "gt" - ">" - when "lt" - "<" - when "eq" - "=" - end - end + validates :resource_type, inclusion: { in: RESOURCE_TYPES } def apply - case resource_type - when "transaction" - scope = family.transactions + scope = resource_scope - conditions.each do |condition| - case condition.condition_type - when "match_merchant" - scope = scope.left_joins(:merchant).where(merchant: { name: condition.value }) - when "compare_amount" - operator_symbol = get_operator_symbol(condition.operator) - scope = scope.joins(:entry) - .where("account_entries.amount #{Arel.sql(operator_symbol)} ?", condition.value) - when "compound" - subconditions = condition.conditions + conditions.each do |condition| + scope = condition.apply(scope) + end - subconditions.each do |subcondition| - case condition.operator - when "and" - case subcondition.condition_type - when "match_merchant" - scope = scope.left_joins(:merchant).where(merchant: { name: subcondition.value }) - when "compare_amount" - operator_symbol = get_operator_symbol(subcondition.operator) - scope = scope.joins(:entry) - .where("account_entries.amount #{Arel.sql(operator_symbol)} ?", subcondition.value.to_f) - end - when "or" - raise "not implemented yet" - else - raise "Invalid compound operator" - end - end - else - raise "Unsupported condition type: #{condition.condition_type}" - end - end - - scope.each do |transaction| - actions.each do |action| - case action.action_type - when "set_category" - category = family.categories.find_by(name: action.value) - transaction.update!(category: category) - else - raise "Unsupported action type: #{action.action_type}" - end - end - end - else - raise "Unsupported resource type: #{resource_type}" + actions.each do |action| + action.apply(scope) end end + + private + def resource_scope + case resource_type + when "transaction" + family.transactions + end + end end diff --git a/app/models/rule/action.rb b/app/models/rule/action.rb index ec2b56fb..4b941472 100644 --- a/app/models/rule/action.rb +++ b/app/models/rule/action.rb @@ -1,5 +1,26 @@ class Rule::Action < ApplicationRecord + UnsupportedActionError = Class.new(StandardError) + belongs_to :rule validates :action_type, presence: true + + 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) + when "set_transaction_tags" + # TODO + when "set_transaction_frequency" + # TODO + when "ai_enhance_transaction_name" + # TODO + when "ai_categorize_transaction" + # TODO + else + raise UnsupportedActionError, "Unsupported action type: #{action_type}" + end + end end diff --git a/app/models/rule/condition.rb b/app/models/rule/condition.rb index f3880dee..9b77f2a9 100644 --- a/app/models/rule/condition.rb +++ b/app/models/rule/condition.rb @@ -1,11 +1,40 @@ class Rule::Condition < ApplicationRecord - OPERATORS = [ "and", "or", "gt", "lt", "eq" ] - TYPES = [ "match_merchant", "compare_amount", "compound" ] + UnsupportedOperatorError = Class.new(StandardError) + UnsupportedConditionTypeError = Class.new(StandardError) - belongs_to :rule, optional: true + OPERATORS = [ "and", "or", "like", ">", ">=", "<", "<=", "=" ] + + belongs_to :rule, optional: -> { where.not(parent_id: nil) } belongs_to :parent, class_name: "Rule::Condition", optional: true - has_many :conditions, class_name: "Rule::Condition", foreign_key: :parent_id, dependent: :destroy + has_many :sub_conditions, class_name: "Rule::Condition", foreign_key: :parent_id, dependent: :destroy validates :operator, inclusion: { in: OPERATORS }, allow_nil: true - validates :condition_type, presence: true, inclusion: { in: TYPES } + validates :condition_type, presence: true + + def apply(resource_scope) + filtered_scope = resource_scope + + case condition_type + when "compound" + sub_conditions.each do |sub_condition| + filtered_scope = sub_condition.apply(filtered_scope) + end + when "transaction_name" + filtered_scope = filtered_scope.with_entry.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) + when "transaction_merchant" + filtered_scope = filtered_scope.left_joins(:merchant).where(merchant: { name: value }) + else + raise UnsupportedConditionTypeError, "Unsupported condition type: #{condition_type}" + end + + filtered_scope + end + + private + def sanitize_operator(operator) + raise UnsupportedOperatorError, "Unsupported operator: #{operator}" unless OPERATORS.include?(operator) + operator + end end diff --git a/db/schema.rb b/db/schema.rb index 5b82c43f..972fb1ca 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_03_19_212839) do +ActiveRecord::Schema[7.2].define(version: 2025_04_01_194500) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" diff --git a/test/models/rule_test.rb b/test/models/rule_test.rb index 6489a9eb..4a01892f 100644 --- a/test/models/rule_test.rb +++ b/test/models/rule_test.rb @@ -10,15 +10,15 @@ class RuleTest < ActiveSupport::TestCase @groceries_category = @family.categories.create!(name: "Groceries") end - test "can apply categories to transactions" do + test "basic rule" do transaction_entry = create_transaction(date: Date.current, account: @account, merchant: @whole_foods_merchant) rule = Rule.create!( family: @family, resource_type: "transaction", effective_date: 1.day.ago.to_date, - conditions: [ Rule::Condition.new(condition_type: "match_merchant", operator: "eq", value: "Whole Foods") ], - actions: [ Rule::Action.new(action_type: "set_category", value: "Groceries") ] + conditions: [ Rule::Condition.new(condition_type: "transaction_merchant", operator: "=", value: "Whole Foods") ], + actions: [ Rule::Action.new(action_type: "set_transaction_category", value: "Groceries") ] ) rule.apply @@ -28,7 +28,7 @@ class RuleTest < ActiveSupport::TestCase assert_equal @groceries_category, transaction_entry.account_transaction.category end - test "can create compound rules" do + test "compound rule" do transaction_entry1 = create_transaction(date: Date.current, amount: 50, account: @account, merchant: @whole_foods_merchant) transaction_entry2 = create_transaction(date: Date.current, amount: 100, account: @account, merchant: @whole_foods_merchant) @@ -38,12 +38,12 @@ class RuleTest < ActiveSupport::TestCase resource_type: "transaction", effective_date: 1.day.ago.to_date, conditions: [ - Rule::Condition.new(condition_type: "compound", operator: "and", conditions: [ - Rule::Condition.new(condition_type: "match_merchant", operator: "eq", value: "Whole Foods"), - Rule::Condition.new(condition_type: "compare_amount", operator: "gt", value: 60) + Rule::Condition.new(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: 60) ]) ], - actions: [ Rule::Action.new(action_type: "set_category", value: "Groceries") ] + actions: [ Rule::Action.new(action_type: "set_transaction_category", value: "Groceries") ] ) rule.apply