diff --git a/app/models/rule.rb b/app/models/rule.rb index 6e600337..4b6d31bd 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -7,6 +7,24 @@ class Rule < ApplicationRecord validates :resource_type, presence: true + def conditions_registry + case resource_type + when "transaction" + Rule::Condition::TransactionRegistry.new(family) + else + raise UnsupportedResourceTypeError, "Unsupported resource type: #{resource_type}" + end + end + + def actions_registry + case resource_type + when "transaction" + Rule::Action::TransactionRegistry.new(family) + else + raise UnsupportedResourceTypeError, "Unsupported resource type: #{resource_type}" + end + end + def apply scope = resource_scope @@ -19,15 +37,29 @@ class Rule < ApplicationRecord end 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}" + private + def resource_scope + scope = base_resource_scope + + conditions.each do |condition| + if condition.compound? + condition.sub_conditions.each do |sub_condition| + scope = sub_condition.prepare(scope) + end + else + scope = condition.prepare(scope) + end + end + + scope + end + + def base_resource_scope + case resource_type + when "transaction" + family.transactions.active + else + raise UnsupportedResourceTypeError, "Unsupported resource type: #{resource_type}" + end end - end end diff --git a/app/models/rule/action.rb b/app/models/rule/action.rb index 763447d6..09a9cb06 100644 --- a/app/models/rule/action.rb +++ b/app/models/rule/action.rb @@ -6,20 +6,12 @@ class Rule::Action < ApplicationRecord validates :action_type, presence: true def apply(resource_scope) - case action_type - when "set_transaction_category" - 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" - # TODO - when "ai_enhance_transaction_name" - # TODO - when "ai_categorize_transaction" - # TODO - else - raise UnsupportedActionError, "Unsupported action type: #{action_type}" - end + config = registry.get_config(action_type) + raise UnsupportedActionError, "Unsupported action type: #{action_type}" unless config + config.builder.call(resource_scope, value) + end + + def registry + @registry ||= rule.actions_registry end end diff --git a/app/models/rule/action/transaction_registry.rb b/app/models/rule/action/transaction_registry.rb new file mode 100644 index 00000000..735fb1d1 --- /dev/null +++ b/app/models/rule/action/transaction_registry.rb @@ -0,0 +1,65 @@ +class Rule::Action::TransactionRegistry + attr_reader :family + + def initialize(family) + @family = family + end + + def get_config(action_type) + ActionConfig.new(**definitions[action_type.to_sym]) + end + + def as_json + definitions.map do |action_type, data| + { + label: data[:label], + action_type: action_type + } + end + end + + private + ActionConfig = Data.define(:label, :options, :builder) + + def definitions + { + set_transaction_category: { + label: "Set category", + options: family.categories.pluck(:name, :id), + builder: ->(transaction_scope, value) { + category = family.categories.find(value) + transaction_scope.update_all(category_id: category.id, updated_at: Time.current) + } + }, + set_transaction_tags: { + label: "Set tags", + options: family.tags.pluck(:name, :id), + builder: ->(transaction_scope, value) { + # TODO + } + }, + set_transaction_frequency: { + label: "Set frequency", + options: [ + [ "One-time", "one_time" ], + [ "Recurring", "recurring" ] + ], + builder: ->(transaction_scope, value) { + # TODO + } + }, + ai_enhance_transaction_name: { + label: "AI enhance name", + builder: ->(transaction_scope, value) { + # TODO + } + }, + ai_categorize_transaction: { + label: "AI categorize", + builder: ->(transaction_scope, value) { + # TODO + } + } + } + end +end diff --git a/app/models/rule/condition.rb b/app/models/rule/condition.rb index 2172cfa7..ec7ac2ea 100644 --- a/app/models/rule/condition.rb +++ b/app/models/rule/condition.rb @@ -1,66 +1,31 @@ class Rule::Condition < ApplicationRecord - UnsupportedOperatorError = Class.new(StandardError) + include Compoundable + UnsupportedConditionTypeError = Class.new(StandardError) OPERATORS = [ "and", "or", "like", ">", ">=", "<", "<=", "=" ] belongs_to :rule, optional: -> { where.not(parent_id: nil) } - belongs_to :parent, class_name: "Rule::Condition", optional: true - 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 - def apply(resource_scope) - filtered_scope = resource_scope - - case condition_type - when "compound" - filtered_scope = build_compound_scope(filtered_scope) - when "transaction_name" - filtered_scope = filtered_scope.where(build_sanitized_comparison_sql("account_entries.name", operator), value) - when "transaction_amount" - filtered_scope = filtered_scope.where(build_sanitized_comparison_sql("account_entries.amount", operator), value.to_d) - when "transaction_merchant" - filtered_scope = filtered_scope.left_joins(:merchant).where(merchant: { name: value }) + def apply(scope) + if compound? + build_compound_scope(scope) else - raise UnsupportedConditionTypeError, "Unsupported condition type: #{condition_type}" + config.builder.call(scope, operator, value) end + end - filtered_scope + def prepare(scope) + config.preparer.call(scope) end private - def build_sanitized_comparison_sql(field, operator) - "#{field} #{sanitize_operator(operator)} ?" - end - - def sanitize_operator(operator) - 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 + def config + config ||= rule.conditions_registry.get_config(condition_type) + raise UnsupportedConditionTypeError, "Unsupported condition type: #{condition_type}" unless config + config end end diff --git a/app/models/rule/condition/compoundable.rb b/app/models/rule/condition/compoundable.rb new file mode 100644 index 00000000..3389be13 --- /dev/null +++ b/app/models/rule/condition/compoundable.rb @@ -0,0 +1,30 @@ +module Rule::Condition::Compoundable + extend ActiveSupport::Concern + + included do + belongs_to :parent, class_name: "Rule::Condition", optional: true, inverse_of: :sub_conditions + has_many :sub_conditions, class_name: "Rule::Condition", foreign_key: :parent_id, dependent: :destroy, inverse_of: :parent + end + + # We don't store rule_id on sub_conditions, so "walk up" to the parent rule + def rule + parent&.rule || super + end + + def compound? + condition_type == "compound" + end + + private + def build_compound_scope(scope) + if operator == "or" + combined_scope = sub_conditions + .map { |sub| sub.apply(scope) } + .reduce { |acc, s| acc.or(s) } + + combined_scope || scope + else + sub_conditions.reduce(scope) { |s, sub| sub.apply(s) } + end + end +end diff --git a/app/models/rule/condition/sanitizable.rb b/app/models/rule/condition/sanitizable.rb new file mode 100644 index 00000000..43d0160d --- /dev/null +++ b/app/models/rule/condition/sanitizable.rb @@ -0,0 +1,17 @@ +module Rule::Condition::Sanitizable + extend ActiveSupport::Concern + + def build_sanitized_where_condition(field, operator, value) + sanitized_value = operator == "like" ? ActiveRecord::Base.sanitize_sql_like(value) : value + + ActiveRecord::Base.sanitize_sql_for_conditions([ + "#{field} #{sanitize_operator(operator)} ?", + sanitized_value + ]) + end + + def sanitize_operator(operator) + raise UnsupportedOperatorError, "Unsupported operator: #{operator}" unless Rule::Condition::OPERATORS.include?(operator) + operator + end +end diff --git a/app/models/rule/condition/transaction_registry.rb b/app/models/rule/condition/transaction_registry.rb new file mode 100644 index 00000000..fa768706 --- /dev/null +++ b/app/models/rule/condition/transaction_registry.rb @@ -0,0 +1,63 @@ +class Rule::Condition::TransactionRegistry + include Rule::Condition::Sanitizable + + attr_reader :family + + def initialize(family) + @family = family + end + + def get_config(condition_type) + config = definitions[condition_type.to_sym] + ConditionConfig.new(**config) + end + + def as_json + definitions.map do |condition_type, data| + { + label: data[:label], + condition_type: condition_type, + operators: data[:operators], + options: data[:options] + } + end + end + + private + ConditionConfig = Data.define(:label, :operators, :options, :preparer, :builder) + + def definitions + { + transaction_name: { + label: "Name", + operators: [ "like", "=" ], + options: nil, + preparer: ->(scope) { scope.with_entry }, + builder: ->(transaction_scope, operator, value) { + expression = build_sanitized_where_condition("account_entries.name", operator, value) + transaction_scope.where(expression) + } + }, + transaction_amount: { + label: "Amount", + operators: [ ">", ">=", "<", "<=", "=" ], + options: nil, + preparer: ->(scope) { scope.with_entry }, + builder: ->(transaction_scope, operator, value) { + expression = build_sanitized_where_condition("account_entries.amount", operator, value.to_d) + transaction_scope.where(expression) + } + }, + transaction_merchant: { + label: "Merchant", + operators: [ "=" ], + options: family.assigned_merchants.pluck(:name, :id), + preparer: ->(scope) { scope.left_joins(:merchant) }, + builder: ->(transaction_scope, operator, value) { + expression = build_sanitized_where_condition("merchants.id", operator, value) + transaction_scope.where(expression) + } + } + } + end +end diff --git a/test/models/rule/condition_test.rb b/test/models/rule/condition_test.rb index 2eb020dc..f2591bc2 100644 --- a/test/models/rule/condition_test.rb +++ b/test/models/rule/condition_test.rb @@ -18,7 +18,7 @@ class Rule::ConditionTest < ActiveSupport::TestCase 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 + @rule_scope = @account.transactions.left_joins(:merchant).with_entry end test "applies transaction_name condition" do @@ -53,7 +53,7 @@ class Rule::ConditionTest < ActiveSupport::TestCase rule: @transaction_rule, condition_type: "transaction_merchant", operator: "=", - value: "Whole Foods" + value: @whole_foods_merchant.id ) filtered = condition.apply(@rule_scope) @@ -69,7 +69,7 @@ class Rule::ConditionTest < ActiveSupport::TestCase Rule::Condition.new( condition_type: "transaction_merchant", operator: "=", - value: "Whole Foods" + value: @whole_foods_merchant.id ), Rule::Condition.new( condition_type: "transaction_amount", @@ -92,7 +92,7 @@ class Rule::ConditionTest < ActiveSupport::TestCase Rule::Condition.new( condition_type: "transaction_merchant", operator: "=", - value: "Whole Foods" + value: @whole_foods_merchant.id ), Rule::Condition.new( condition_type: "transaction_amount", diff --git a/test/models/rule_test.rb b/test/models/rule_test.rb index 0eba0ee7..982a68f4 100644 --- a/test/models/rule_test.rb +++ b/test/models/rule_test.rb @@ -17,7 +17,7 @@ class RuleTest < ActiveSupport::TestCase family: @family, resource_type: "transaction", effective_date: 1.day.ago.to_date, - conditions: [ Rule::Condition.new(condition_type: "transaction_merchant", operator: "=", value: "Whole Foods") ], + conditions: [ Rule::Condition.new(condition_type: "transaction_merchant", operator: "=", value: @whole_foods_merchant.id) ], actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ] ) @@ -39,7 +39,7 @@ class RuleTest < ActiveSupport::TestCase effective_date: 1.day.ago.to_date, conditions: [ 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_merchant", operator: "=", value: @whole_foods_merchant.id), Rule::Condition.new(condition_type: "transaction_amount", operator: ">", value: 60) ]) ],