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

> Discover how Maybe Finance's auto-categorization and rule system links conditions to actions for transaction resources, leveraging AI-driven categorization via OpenAI.

- Repository: [Maybe/maybe](https://github.com/maybe-finance/maybe)
- Tags: internals
- Published: 2026-03-07

---

**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`](https://github.com/maybe-finance/maybe/blob/main/app/models/rule.rb). This class coordinates the entire lifecycle of automation:

```ruby
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`](https://github.com/maybe-finance/maybe/blob/main/app/models/rule/registry/transaction_resource.rb) defines transaction-specific behavior:

```ruby
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`](https://github.com/maybe-finance/maybe/blob/main/app/models/rule/condition_filter/transaction_name.rb) demonstrates the pattern:

```ruby
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`](https://github.com/maybe-finance/maybe/blob/main/app/models/rule/action_executor/auto_categorize.rb) handles intelligent categorization:

```ruby
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`](https://github.com/maybe-finance/maybe/blob/main/app/models/transaction/ruleable.rb), this module provides eligibility checking:

```ruby
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:

```ruby

# 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`](https://github.com/maybe-finance/maybe/blob/main/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`](https://github.com/maybe-finance/maybe/blob/main/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.