1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-10 07:55:21 +02:00

Add Rule action and condition registries

This commit is contained in:
Zach Gollwitzer 2025-04-03 17:48:30 -04:00
parent 82944a98c8
commit af1fa49974
9 changed files with 243 additions and 79 deletions

View file

@ -7,6 +7,24 @@ class Rule < ApplicationRecord
validates :resource_type, presence: true 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 def apply
scope = resource_scope scope = resource_scope
@ -19,15 +37,29 @@ class Rule < ApplicationRecord
end end
end end
def resource_scope private
case resource_type def resource_scope
when "transaction" scope = base_resource_scope
family.transactions
.active conditions.each do |condition|
.with_entry if condition.compound?
.where(account_entries: { date: effective_date..nil }) condition.sub_conditions.each do |sub_condition|
else scope = sub_condition.prepare(scope)
raise UnsupportedResourceTypeError, "Unsupported resource type: #{resource_type}" 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
end end

View file

@ -6,20 +6,12 @@ class Rule::Action < ApplicationRecord
validates :action_type, presence: true validates :action_type, presence: true
def apply(resource_scope) def apply(resource_scope)
case action_type config = registry.get_config(action_type)
when "set_transaction_category" raise UnsupportedActionError, "Unsupported action type: #{action_type}" unless config
category = rule.family.categories.find(value) config.builder.call(resource_scope, value)
resource_scope.update_all(category_id: category.id, updated_at: Time.current) end
when "set_transaction_tags"
# TODO def registry
when "set_transaction_frequency" @registry ||= rule.actions_registry
# TODO
when "ai_enhance_transaction_name"
# TODO
when "ai_categorize_transaction"
# TODO
else
raise UnsupportedActionError, "Unsupported action type: #{action_type}"
end
end end
end end

View file

@ -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

View file

@ -1,66 +1,31 @@
class Rule::Condition < ApplicationRecord class Rule::Condition < ApplicationRecord
UnsupportedOperatorError = Class.new(StandardError) include Compoundable
UnsupportedConditionTypeError = Class.new(StandardError) UnsupportedConditionTypeError = Class.new(StandardError)
OPERATORS = [ "and", "or", "like", ">", ">=", "<", "<=", "=" ] OPERATORS = [ "and", "or", "like", ">", ">=", "<", "<=", "=" ]
belongs_to :rule, optional: -> { where.not(parent_id: nil) } 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 :operator, inclusion: { in: OPERATORS }, allow_nil: true
validates :condition_type, presence: true validates :condition_type, presence: true
def apply(resource_scope) def apply(scope)
filtered_scope = resource_scope if compound?
build_compound_scope(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 })
else else
raise UnsupportedConditionTypeError, "Unsupported condition type: #{condition_type}" config.builder.call(scope, operator, value)
end end
end
filtered_scope def prepare(scope)
config.preparer.call(scope)
end end
private private
def build_sanitized_comparison_sql(field, operator) def config
"#{field} #{sanitize_operator(operator)} ?" config ||= rule.conditions_registry.get_config(condition_type)
end raise UnsupportedConditionTypeError, "Unsupported condition type: #{condition_type}" unless config
config
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
end end
end end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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: 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") 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 end
test "applies transaction_name condition" do test "applies transaction_name condition" do
@ -53,7 +53,7 @@ class Rule::ConditionTest < ActiveSupport::TestCase
rule: @transaction_rule, rule: @transaction_rule,
condition_type: "transaction_merchant", condition_type: "transaction_merchant",
operator: "=", operator: "=",
value: "Whole Foods" value: @whole_foods_merchant.id
) )
filtered = condition.apply(@rule_scope) filtered = condition.apply(@rule_scope)
@ -69,7 +69,7 @@ class Rule::ConditionTest < ActiveSupport::TestCase
Rule::Condition.new( Rule::Condition.new(
condition_type: "transaction_merchant", condition_type: "transaction_merchant",
operator: "=", operator: "=",
value: "Whole Foods" value: @whole_foods_merchant.id
), ),
Rule::Condition.new( Rule::Condition.new(
condition_type: "transaction_amount", condition_type: "transaction_amount",
@ -92,7 +92,7 @@ class Rule::ConditionTest < ActiveSupport::TestCase
Rule::Condition.new( Rule::Condition.new(
condition_type: "transaction_merchant", condition_type: "transaction_merchant",
operator: "=", operator: "=",
value: "Whole Foods" value: @whole_foods_merchant.id
), ),
Rule::Condition.new( Rule::Condition.new(
condition_type: "transaction_amount", condition_type: "transaction_amount",

View file

@ -17,7 +17,7 @@ class RuleTest < ActiveSupport::TestCase
family: @family, family: @family,
resource_type: "transaction", resource_type: "transaction",
effective_date: 1.day.ago.to_date, 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) ] 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, effective_date: 1.day.ago.to_date,
conditions: [ conditions: [
Rule::Condition.new(condition_type: "compound", operator: "and", sub_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) Rule::Condition.new(condition_type: "transaction_amount", operator: ">", value: 60)
]) ])
], ],