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:
parent
82944a98c8
commit
af1fa49974
9 changed files with 243 additions and 79 deletions
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
65
app/models/rule/action/transaction_registry.rb
Normal file
65
app/models/rule/action/transaction_registry.rb
Normal 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
|
|
@ -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
|
||||||
|
|
30
app/models/rule/condition/compoundable.rb
Normal file
30
app/models/rule/condition/compoundable.rb
Normal 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
|
17
app/models/rule/condition/sanitizable.rb
Normal file
17
app/models/rule/condition/sanitizable.rb
Normal 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
|
63
app/models/rule/condition/transaction_registry.rb
Normal file
63
app/models/rule/condition/transaction_registry.rb
Normal 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
|
|
@ -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",
|
||||||
|
|
|
@ -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)
|
||||||
])
|
])
|
||||||
],
|
],
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue