diff --git a/app/jobs/rule_processor_job.rb b/app/jobs/rule_processor_job.rb new file mode 100644 index 00000000..e5b088dc --- /dev/null +++ b/app/jobs/rule_processor_job.rb @@ -0,0 +1,9 @@ +class RuleProcessorJob < ApplicationJob + queue_as :default + + def perform(family) + family.rules.each do |rule| + rule.apply + end + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 28cd4e0c..cca6bfc8 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -78,6 +78,8 @@ class Account < ApplicationRecord Rails.logger.info("Processing balances (#{linked? ? 'reverse' : 'forward'})") sync_balances + + RuleProcessorJob.perform_later(family) end def post_sync diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index a6418c03..f9f66185 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -40,7 +40,7 @@ class Demo::Generator create_tags!(family) create_categories!(family) create_merchants!(family) - + create_rules!(family) puts "tags, categories, merchants created for #{family_name}" create_credit_card_account!(family) @@ -184,6 +184,19 @@ class Demo::Generator onboarded_at: Time.current end + def create_rules!(family) + family.rules.create!( + effective_date: 1.year.ago.to_date, + active: true, + conditions: [ + Rule::Condition.new(condition_type: "match_merchant", value: "Whole Foods") + ], + actions: [ + Rule::Action.new(action_type: "set_category", value: "Groceries") + ] + ) + end + def create_tags!(family) [ "Trips", "Emergency Fund", "Demo Tag" ].each do |tag| family.tags.create!(name: tag) diff --git a/app/models/family.rb b/app/models/family.rb index 0b4405e8..3a8d95f0 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -22,6 +22,7 @@ class Family < ApplicationRecord has_many :entries, through: :accounts has_many :transactions, through: :accounts + has_many :rules, dependent: :destroy has_many :trades, through: :accounts has_many :holdings, through: :accounts diff --git a/app/models/rule.rb b/app/models/rule.rb index 47d4611e..a49cdca0 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -1,7 +1,72 @@ class Rule < ApplicationRecord belongs_to :family - has_many :triggers, dependent: :destroy + 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 + + def apply + case resource_type + when "transaction" + scope = family.transactions + + 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 + + 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}" + end + end end diff --git a/app/models/rule/condition.rb b/app/models/rule/condition.rb new file mode 100644 index 00000000..f3880dee --- /dev/null +++ b/app/models/rule/condition.rb @@ -0,0 +1,11 @@ +class Rule::Condition < ApplicationRecord + OPERATORS = [ "and", "or", "gt", "lt", "eq" ] + TYPES = [ "match_merchant", "compare_amount", "compound" ] + + belongs_to :rule, optional: true + belongs_to :parent, class_name: "Rule::Condition", optional: true + has_many :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 } +end diff --git a/app/models/rule/trigger.rb b/app/models/rule/trigger.rb deleted file mode 100644 index 4beae4d2..00000000 --- a/app/models/rule/trigger.rb +++ /dev/null @@ -1,7 +0,0 @@ -class Rule::Trigger < ApplicationRecord - self.table_name = "rule_triggers" - - belongs_to :rule - - validates :trigger_type, presence: true -end diff --git a/db/migrate/20250225135843_add_rules.rb b/db/migrate/20250225135843_add_rules.rb deleted file mode 100644 index 28cf8e01..00000000 --- a/db/migrate/20250225135843_add_rules.rb +++ /dev/null @@ -1,25 +0,0 @@ -class AddRules < ActiveRecord::Migration[7.2] - def change - create_table :rules, id: :uuid do |t| - t.references :family, null: false, foreign_key: true, type: :uuid - - t.date :effective_date, null: false - t.boolean :active, null: false, default: true - t.timestamps - end - - create_table :rule_triggers do |t| - t.references :rule, null: false, foreign_key: true, type: :uuid - - t.string :trigger_type, null: false - t.timestamps - end - - create_table :rule_actions do |t| - t.references :rule, null: false, foreign_key: true, type: :uuid - - t.string :action_type, null: false - t.timestamps - end - end -end diff --git a/db/migrate/20250401194500_create_rules.rb b/db/migrate/20250401194500_create_rules.rb new file mode 100644 index 00000000..3fc18141 --- /dev/null +++ b/db/migrate/20250401194500_create_rules.rb @@ -0,0 +1,30 @@ +class CreateRules < ActiveRecord::Migration[7.2] + def change + create_table :rules, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + + t.string :resource_type, null: false + t.date :effective_date, null: false + t.boolean :active, null: false, default: true + t.timestamps + end + + create_table :rule_conditions, id: :uuid do |t| + t.references :rule, foreign_key: true, type: :uuid + t.references :parent, foreign_key: { to_table: :rule_conditions }, type: :uuid + + t.string :condition_type, null: false + t.string :operator, null: false + t.string :value + t.timestamps + end + + create_table :rule_actions, id: :uuid do |t| + t.references :rule, null: false, foreign_key: true, type: :uuid + + t.string :action_type, null: false + t.string :value, null: false + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index aedba1e9..5b82c43f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -468,24 +468,30 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_212839) do t.index ["outflow_transaction_id"], name: "index_rejected_transfers_on_outflow_transaction_id" end - create_table "rule_actions", force: :cascade do |t| + create_table "rule_actions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "rule_id", null: false t.string "action_type", null: false + t.string "value", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["rule_id"], name: "index_rule_actions_on_rule_id" end - create_table "rule_triggers", force: :cascade do |t| - t.uuid "rule_id", null: false - t.string "trigger_type", null: false + create_table "rule_conditions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "rule_id" + t.uuid "parent_id" + t.string "condition_type", null: false + t.string "operator", null: false + t.string "value" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["rule_id"], name: "index_rule_triggers_on_rule_id" + t.index ["parent_id"], name: "index_rule_conditions_on_parent_id" + t.index ["rule_id"], name: "index_rule_conditions_on_rule_id" end create_table "rules", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "family_id", null: false + t.string "resource_type", null: false t.date "effective_date", null: false t.boolean "active", default: true, null: false t.datetime "created_at", null: false @@ -687,7 +693,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_212839) do add_foreign_key "rejected_transfers", "account_transactions", column: "inflow_transaction_id" add_foreign_key "rejected_transfers", "account_transactions", column: "outflow_transaction_id" add_foreign_key "rule_actions", "rules" - add_foreign_key "rule_triggers", "rules" + add_foreign_key "rule_conditions", "rule_conditions", column: "parent_id" + add_foreign_key "rule_conditions", "rules" add_foreign_key "rules", "families" add_foreign_key "security_prices", "securities" add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id" diff --git a/test/models/rule_test.rb b/test/models/rule_test.rb new file mode 100644 index 00000000..6489a9eb --- /dev/null +++ b/test/models/rule_test.rb @@ -0,0 +1,57 @@ +require "test_helper" + +class RuleTest < ActiveSupport::TestCase + include Account::EntriesTestHelper + + setup do + @family = families(:empty) + @account = @family.accounts.create!(name: "Rule test", balance: 1000, currency: "USD", accountable: Depository.new) + @whole_foods_merchant = @family.merchants.create!(name: "Whole Foods") + @groceries_category = @family.categories.create!(name: "Groceries") + end + + test "can apply categories to transactions" 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") ] + ) + + rule.apply + + transaction_entry.reload + + assert_equal @groceries_category, transaction_entry.account_transaction.category + end + + test "can create compound rules" 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) + + # Assign "Groceries" to transactions with a merchant of "Whole Foods" and an amount greater than $60 + rule = Rule.create!( + family: @family, + 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) + ]) + ], + actions: [ Rule::Action.new(action_type: "set_category", value: "Groceries") ] + ) + + rule.apply + + transaction_entry1.reload + transaction_entry2.reload + + assert_nil transaction_entry1.account_transaction.category + assert_equal @groceries_category, transaction_entry2.account_transaction.category + end +end