1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-24 15:49:39 +02:00

Basic implementation + tests

This commit is contained in:
Zach Gollwitzer 2025-04-02 12:47:07 -04:00
parent 016b48a71c
commit f07940bf45
8 changed files with 191 additions and 19 deletions

View file

@ -1,12 +1,11 @@
class Rule < ApplicationRecord
RESOURCE_TYPES = %w[transaction].freeze
UnsupportedResourceTypeError = Class.new(StandardError)
belongs_to :family
has_many :conditions, dependent: :destroy
has_many :actions, dependent: :destroy
validates :effective_date, presence: true
validates :resource_type, inclusion: { in: RESOURCE_TYPES }
validates :resource_type, presence: true
def apply
scope = resource_scope
@ -20,11 +19,15 @@ class Rule < ApplicationRecord
end
end
private
def resource_scope
case resource_type
when "transaction"
family.transactions
end
def resource_scope
case resource_type
when "transaction"
family.transactions
.active
.with_entry
.where(account_entries: { date: effective_date..nil })
else
raise UnsupportedResourceTypeError, "Unsupported resource type: #{resource_type}"
end
end
end

View file

@ -8,9 +8,8 @@ class Rule::Action < ApplicationRecord
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)
category = rule.family.categories.find(value)
resource_scope.update_all(category_id: category.id, updated_at: Time.current)
when "set_transaction_tags"
# TODO
when "set_transaction_frequency"

View file

@ -16,13 +16,11 @@ class Rule::Condition < ApplicationRecord
case condition_type
when "compound"
sub_conditions.each do |sub_condition|
filtered_scope = sub_condition.apply(filtered_scope)
end
filtered_scope = build_compound_scope(filtered_scope)
when "transaction_name"
filtered_scope = filtered_scope.with_entry.where("account_entries.name #{Arel.sql(sanitize_operator(operator))} ?", value)
filtered_scope = filtered_scope.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)
filtered_scope = filtered_scope.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
@ -37,4 +35,28 @@ class Rule::Condition < ApplicationRecord
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

View file

@ -4,7 +4,7 @@ class CreateRules < ActiveRecord::Migration[7.2]
t.references :family, null: false, foreign_key: true, type: :uuid
t.string :resource_type, null: false
t.date :effective_date, null: false
t.date :effective_date
t.boolean :active, null: false, default: true
t.timestamps
end

2
db/schema.rb generated
View file

@ -492,7 +492,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_01_194500) do
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.date "effective_date"
t.boolean "active", default: true, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false

3
test/fixtures/rules.yml vendored Normal file
View file

@ -0,0 +1,3 @@
one:
family: dylan_family
resource_type: "transaction"

View file

@ -0,0 +1,37 @@
require "test_helper"
class Rule::ActionTest < ActiveSupport::TestCase
include Account::EntriesTestHelper
setup do
@family = families(:empty)
@transaction_rule = @family.rules.create!(resource_type: "transaction")
@account = @family.accounts.create!(name: "Rule test", balance: 1000, currency: "USD", accountable: Depository.new)
@grocery_category = @family.categories.create!(name: "Grocery")
@whole_foods_merchant = @family.merchants.create!(name: "Whole Foods")
# Some sample transactions to work with
create_transaction(date: Date.current, account: @account, amount: 100, name: "Rule test transaction1", merchant: @whole_foods_merchant)
create_transaction(date: Date.current, account: @account, amount: -200, name: "Rule test transaction2")
create_transaction(date: 1.day.ago.to_date, account: @account, amount: 50, name: "Rule test transaction3")
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")
@rule_scope = @account.transactions
end
test "set_transaction_category" do
action = Rule::Action.new(
rule: @transaction_rule,
action_type: "set_transaction_category",
value: @grocery_category.id
)
action.apply(@rule_scope)
@rule_scope.reload.each do |transaction|
assert_equal @grocery_category.id, transaction.category_id
end
end
end

View file

@ -0,0 +1,108 @@
require "test_helper"
class Rule::ConditionTest < ActiveSupport::TestCase
include Account::EntriesTestHelper
setup do
@family = families(:empty)
@transaction_rule = @family.rules.create!(resource_type: "transaction")
@account = @family.accounts.create!(name: "Rule test", balance: 1000, currency: "USD", accountable: Depository.new)
@grocery_category = @family.categories.create!(name: "Grocery")
@whole_foods_merchant = @family.merchants.create!(name: "Whole Foods")
# Some sample transactions to work with
create_transaction(date: Date.current, account: @account, amount: 100, name: "Rule test transaction1", merchant: @whole_foods_merchant)
create_transaction(date: Date.current, account: @account, amount: -200, name: "Rule test transaction2")
create_transaction(date: 1.day.ago.to_date, account: @account, amount: 50, name: "Rule test transaction3")
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")
@rule_scope = @account.transactions
end
test "applies transaction_name condition" do
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_name",
operator: "=",
value: "Rule test transaction1"
)
assert_equal 5, @rule_scope.count
filtered = condition.apply(@rule_scope)
assert_equal 1, filtered.count
end
test "applies transaction_amount condition" do
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_amount",
operator: ">",
value: "50"
)
filtered = condition.apply(@rule_scope)
assert_equal 2, filtered.count
end
test "applies transaction_merchant condition" do
condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "transaction_merchant",
operator: "=",
value: "Whole Foods"
)
filtered = condition.apply(@rule_scope)
assert_equal 2, filtered.count
end
test "applies compound and condition" do
parent_condition = Rule::Condition.new(
rule: @transaction_rule,
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: "50"
)
]
)
filtered = parent_condition.apply(@rule_scope)
assert_equal 1, filtered.count
end
test "applies compound or condition" do
parent_condition = Rule::Condition.new(
rule: @transaction_rule,
condition_type: "compound",
operator: "or",
sub_conditions: [
Rule::Condition.new(
condition_type: "transaction_merchant",
operator: "=",
value: "Whole Foods"
),
Rule::Condition.new(
condition_type: "transaction_amount",
operator: "<",
value: "50"
)
]
)
filtered = parent_condition.apply(@rule_scope)
assert_equal 3, filtered.count
end
end