mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-24 15:49:39 +02:00
Simplify rule scope building and action executions
This commit is contained in:
parent
3bc0c18da0
commit
d64e1fc575
5 changed files with 81 additions and 73 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
2
db/schema.rb
generated
2
db/schema.rb
generated
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue