How the Auto-Categorization and Rule System Works for Transactions in Maybe Finance

The auto-categorization and rule system in Maybe Finance links conditions to actions for transaction resources, using a registry-based architecture that supports AI-driven categorization through OpenAI integration when configured.

The maybe-finance/maybe repository implements a sophisticated rule engine that allows families to automate financial transaction management. This system combines traditional rule-based automation with optional artificial intelligence to categorize transactions dynamically. Understanding how the auto-categorization and rule system works for transactions reveals a well-architected solution that balances flexibility, security, and performance.

Core Architecture of the Transaction Rule Engine

The engine follows a registry pattern that separates resource-specific logic from generic rule orchestration. This design allows the system to support different resource types while maintaining consistent interfaces for conditions and actions.

The Rule Model and Orchestration

At the center of the system sits the Rule model defined in app/models/rule.rb. This class coordinates the entire lifecycle of automation:

class Rule < ApplicationRecord
  belongs_to :family
  has_many :conditions, dependent: :destroy
  has_many :actions,    dependent: :destroy

  accepts_nested_attributes_for :conditions, allow_destroy: true
  accepts_nested_attributes_for :actions,    allow_destroy: true

  validates :resource_type, presence: true
  validate  :min_actions, :no_duplicate_actions, :no_nested_compound_conditions
  # ...

end

The resource_type attribute determines which registry handles the rule, with "transaction" being the primary supported type. The apply method iterates through all actions, calling action.apply(scope) where scope represents the filtered set of matching resources. Meanwhile, matching_resources_scope constructs the database query by first preparing each condition (adding necessary table joins) and then applying their filter logic.

Resource Registry Pattern

The Rule::Registry::TransactionResource class in app/models/rule/registry/transaction_resource.rb defines transaction-specific behavior:

class Rule::Registry::TransactionResource < Rule::Registry
  def resource_scope
    family.transactions.visible.with_entry.where(entry: { date: rule.effective_date.. })
  end

  def condition_filters
    [
      Rule::ConditionFilter::TransactionName.new(rule),
      Rule::ConditionFilter::TransactionAmount.new(rule),
      Rule::ConditionFilter::TransactionMerchant.new(rule)
    ]
  end

  def action_executors
    execs = [
      Rule::ActionExecutor::SetTransactionCategory.new(rule),
      Rule::ActionExecutor::SetTransactionTags.new(rule),
      Rule::ActionExecutor::SetTransactionMerchant.new(rule),
      Rule::ActionExecutor::SetTransactionName.new(rule)
    ]

    execs << Rule::ActionExecutor::AutoCategorize.new(rule) if ai_enabled?
    execs << Rule::ActionExecutor::AutoDetectMerchants.new(rule) if ai_enabled?
    execs
  end

  private

  def ai_enabled?
    Provider::Registry.get_provider(:openai).present?
  end
end

This registry restricts the scope to visible transactions from the rule's effective date onward. It exposes three condition filters (name, amount, merchant) and conditionally includes AI-powered executors when an OpenAI provider is configured.

How Condition Filters Build Secure Queries

Condition filters translate user-defined criteria into sanitized SQL queries. Each filter implements a consistent interface with prepare (for table joins) and apply (for WHERE clauses).

Transaction Name, Amount, and Merchant Filters

The TransactionName filter in app/models/rule/condition_filter/transaction_name.rb demonstrates the pattern:

class Rule::ConditionFilter::TransactionName < Rule::ConditionFilter
  def prepare(scope) = scope.with_entry
  def apply(scope, operator, value)
    expression = build_sanitized_where_condition("entries.name", operator, value)
    scope.where(expression)
  end
end

The prepare method ensures the entries table is joined via with_entry. The apply method constructs a sanitized SQL condition using build_sanitized_where_condition, preventing SQL injection while supporting various operators (equals, contains, greater than, etc.). Similar implementations exist for TransactionAmount and TransactionMerchant in the same directory.

Action Executors and Auto-Categorization

Action executors contain the business logic for modifying transactions. The system distinguishes between standard updates and AI-enhanced categorization.

Standard Action Executors

The registry includes executors for setting categories, tags, merchants, and names directly. These operate synchronously on the scoped transactions, applying attribute updates with optional locking mechanisms to prevent conflicts.

AI-Powered Auto-Categorization

The AutoCategorize executor in app/models/rule/action_executor/auto_categorize.rb handles intelligent categorization:

class Rule::ActionExecutor::AutoCategorize < Rule::ActionExecutor
  def execute(transaction_scope, value: nil, ignore_attribute_locks: false)
    enrichable_transactions = transaction_scope.enrichable(:category_id)
    return if enrichable_transactions.empty?

    enrichable_transactions.in_batches(of: 20).each_with_index do |transactions, idx|
      Rails.logger.info("Scheduling auto-categorization batch #{idx + 1}")
      rule.family.auto_categorize_transactions_later(transactions)
    end
  end
end

This executor first filters for enrichable transactions—those without an existing category_id. It then processes these in batches of 20 to prevent memory bloat and schedules background jobs via Family#auto_categorize_transactions_later. The actual AI classification happens asynchronously, using the OpenAI provider configured in the family's settings.

Preventing Duplicate Category Rules

To maintain data integrity, the system prevents redundant categorization attempts through the Transaction::Ruleable concern.

Transaction::Ruleable Concern

Located in app/models/transaction/ruleable.rb, this module provides eligibility checking:

module Transaction::Ruleable
  extend ActiveSupport::Concern

  def eligible_for_category_rule?
    rules.joins(:actions).where(
      actions: { action_type: "set_transaction_category", value: category_id }
    ).empty?
  end

  private

  def rules
    entry.account.family.rules
  end
end

The eligible_for_category_rule? method queries existing rules to detect if the transaction's current category_id already results from a set_transaction_category action. If a match exists, the method returns false, preventing the rule engine from applying duplicate categorization. This ensures each transaction maintains a clear audit trail of which automation (or manual action) assigned its category.

Putting It All Together

Consider a family wanting to automatically categorize large grocery purchases using AI. They would create a rule through the following Ruby implementation:


# Create the rule definition

rule = family.rules.create!(
  resource_type: "transaction",
  effective_date: Date.current,
  name: "Big grocery auto-categorization"
)

# Add compound conditions: merchant = Whole Foods AND amount > 50

merchant_cond = rule.conditions.create!(
  condition_type: "transaction_merchant",
  operator: "=",
  value: whole_foods_merchant.id
)

amount_cond = rule.conditions.create!(
  condition_type: "transaction_amount",
  operator: ">",
  value: 50,
  parent: merchant_cond # Creates AND relationship

)

# Add the AI-powered action

rule.actions.create!(action_type: "auto_categorize")

When rule.apply executes (typically via RuleJob), the engine:

  1. Queries visible transactions from the effective date using Rule::Registry::TransactionResource#resource_scope
  2. Joins the entries table and applies sanitized WHERE clauses for the merchant and amount conditions
  3. Identifies transactions lacking categories via enrichable(:category_id)
  4. Batches eligible transactions into groups of 20 and schedules Family#auto_categorize_transactions_later for AI processing

Summary

  • Rule Engine Architecture: The system uses a registry pattern where Rule models coordinate conditions and actions through resource-specific registries like Rule::Registry::TransactionResource.
  • Secure Query Building: Condition filters in app/models/rule/condition_filter/ sanitize user input and build proper ActiveRecord scopes with table joins.
  • AI Integration: The AutoCategorize executor batches transactions and schedules background jobs when an OpenAI provider is configured, processing only enrichable transactions without existing categories.
  • Duplicate Prevention: The Transaction::Ruleable concern provides eligible_for_category_rule? to stop redundant categorization actions on already-categorized transactions.
  • Performance: Batching (20 records per job), scoped queries, and background processing ensure the system handles large transaction volumes efficiently.

Frequently Asked Questions

How does Maybe Finance prevent duplicate categorization when multiple rules target the same transaction?

The system uses the Transaction::Ruleable concern located in app/models/transaction/ruleable.rb to check existing rule applications. The eligible_for_category_rule? method queries the transaction's family rules to detect if any existing action already set the current category_id. If a match exists, the method returns false, preventing the rule engine from applying redundant categorization actions and maintaining clear audit trails.

What happens when auto-categorization is enabled but no OpenAI provider is configured?

The AutoCategorize executor only becomes available when Rule::Registry::TransactionResource detects an OpenAI provider via Provider::Registry.get_provider(:openai). Without this configuration, the executor is excluded from the action_executors array, and users cannot create rules with the auto_categorize action type. The system gracefully degrades to manual categorization and standard rule-based actions like set_transaction_category.

How does the rule engine handle large volumes of transactions without blocking the application?

The AutoCategorize executor in app/models/rule/action_executor/auto_categorize.rb implements batching to manage scale. It filters for enrichable transactions and processes them in batches of 20 using in_batches(of: 20). Each batch schedules a background job via Family#auto_categorize_transactions_later, allowing the rule application to complete immediately while AI processing happens asynchronously. This prevents memory bloat and keeps the UI responsive.

Can rules target specific merchants or transaction amounts?

Yes, the Rule::Registry::TransactionResource provides specific condition filters for these attributes. The registry exposes Rule::ConditionFilter::TransactionName, Rule::ConditionFilter::TransactionAmount, and Rule::ConditionFilter::TransactionMerchant. Each filter implements prepare to join necessary tables (like entries) and apply to generate sanitized SQL conditions. Users can combine these with compound conditions (AND/OR groups) to create precise targeting rules.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →