mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-04 13:05:19 +02:00
Sketch out business logic and basic tests
This commit is contained in:
parent
8effdcb2d3
commit
3bc0c18da0
11 changed files with 203 additions and 40 deletions
9
app/jobs/rule_processor_job.rb
Normal file
9
app/jobs/rule_processor_job.rb
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
class RuleProcessorJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform(family)
|
||||||
|
family.rules.each do |rule|
|
||||||
|
rule.apply
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -78,6 +78,8 @@ class Account < ApplicationRecord
|
||||||
|
|
||||||
Rails.logger.info("Processing balances (#{linked? ? 'reverse' : 'forward'})")
|
Rails.logger.info("Processing balances (#{linked? ? 'reverse' : 'forward'})")
|
||||||
sync_balances
|
sync_balances
|
||||||
|
|
||||||
|
RuleProcessorJob.perform_later(family)
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_sync
|
def post_sync
|
||||||
|
|
|
@ -40,7 +40,7 @@ class Demo::Generator
|
||||||
create_tags!(family)
|
create_tags!(family)
|
||||||
create_categories!(family)
|
create_categories!(family)
|
||||||
create_merchants!(family)
|
create_merchants!(family)
|
||||||
|
create_rules!(family)
|
||||||
puts "tags, categories, merchants created for #{family_name}"
|
puts "tags, categories, merchants created for #{family_name}"
|
||||||
|
|
||||||
create_credit_card_account!(family)
|
create_credit_card_account!(family)
|
||||||
|
@ -184,6 +184,19 @@ class Demo::Generator
|
||||||
onboarded_at: Time.current
|
onboarded_at: Time.current
|
||||||
end
|
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)
|
def create_tags!(family)
|
||||||
[ "Trips", "Emergency Fund", "Demo Tag" ].each do |tag|
|
[ "Trips", "Emergency Fund", "Demo Tag" ].each do |tag|
|
||||||
family.tags.create!(name: tag)
|
family.tags.create!(name: tag)
|
||||||
|
|
|
@ -22,6 +22,7 @@ class Family < ApplicationRecord
|
||||||
|
|
||||||
has_many :entries, through: :accounts
|
has_many :entries, through: :accounts
|
||||||
has_many :transactions, through: :accounts
|
has_many :transactions, through: :accounts
|
||||||
|
has_many :rules, dependent: :destroy
|
||||||
has_many :trades, through: :accounts
|
has_many :trades, through: :accounts
|
||||||
has_many :holdings, through: :accounts
|
has_many :holdings, through: :accounts
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,72 @@
|
||||||
class Rule < ApplicationRecord
|
class Rule < ApplicationRecord
|
||||||
belongs_to :family
|
belongs_to :family
|
||||||
has_many :triggers, dependent: :destroy
|
has_many :conditions, dependent: :destroy
|
||||||
has_many :actions, dependent: :destroy
|
has_many :actions, dependent: :destroy
|
||||||
|
|
||||||
validates :effective_date, presence: true
|
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
|
end
|
||||||
|
|
11
app/models/rule/condition.rb
Normal file
11
app/models/rule/condition.rb
Normal file
|
@ -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
|
|
@ -1,7 +0,0 @@
|
||||||
class Rule::Trigger < ApplicationRecord
|
|
||||||
self.table_name = "rule_triggers"
|
|
||||||
|
|
||||||
belongs_to :rule
|
|
||||||
|
|
||||||
validates :trigger_type, presence: true
|
|
||||||
end
|
|
|
@ -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
|
|
30
db/migrate/20250401194500_create_rules.rb
Normal file
30
db/migrate/20250401194500_create_rules.rb
Normal file
|
@ -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
|
19
db/schema.rb
generated
19
db/schema.rb
generated
|
@ -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"
|
t.index ["outflow_transaction_id"], name: "index_rejected_transfers_on_outflow_transaction_id"
|
||||||
end
|
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.uuid "rule_id", null: false
|
||||||
t.string "action_type", null: false
|
t.string "action_type", null: false
|
||||||
|
t.string "value", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.index ["rule_id"], name: "index_rule_actions_on_rule_id"
|
t.index ["rule_id"], name: "index_rule_actions_on_rule_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "rule_triggers", force: :cascade do |t|
|
create_table "rule_conditions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.uuid "rule_id", null: false
|
t.uuid "rule_id"
|
||||||
t.string "trigger_type", null: false
|
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 "created_at", null: false
|
||||||
t.datetime "updated_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
|
end
|
||||||
|
|
||||||
create_table "rules", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "rules", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.uuid "family_id", null: false
|
t.uuid "family_id", null: false
|
||||||
|
t.string "resource_type", null: false
|
||||||
t.date "effective_date", null: false
|
t.date "effective_date", null: false
|
||||||
t.boolean "active", default: true, null: false
|
t.boolean "active", default: true, null: false
|
||||||
t.datetime "created_at", 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: "inflow_transaction_id"
|
||||||
add_foreign_key "rejected_transfers", "account_transactions", column: "outflow_transaction_id"
|
add_foreign_key "rejected_transfers", "account_transactions", column: "outflow_transaction_id"
|
||||||
add_foreign_key "rule_actions", "rules"
|
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 "rules", "families"
|
||||||
add_foreign_key "security_prices", "securities"
|
add_foreign_key "security_prices", "securities"
|
||||||
add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id"
|
add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id"
|
||||||
|
|
57
test/models/rule_test.rb
Normal file
57
test/models/rule_test.rb
Normal file
|
@ -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
|
Loading…
Add table
Add a link
Reference in a new issue