Transaction Rule Engine#
The rule engine matches transactions against user-defined conditions and applies automated actions (e.g., set category, tag, merchant, exclude). The core models live in app/models/rule/ and the primary entry point is Rule.
Each Rule belongs to a family, has many conditions and actions, and targets a resource_type (currently only "transaction") . The resource-type–specific logic is delegated to a Registry class looked up at runtime .
Architecture Overview#
Rule
├── conditions: [Rule::Condition, ...]
│ ├── Simple condition (condition_type = "transaction_name", operator, value)
│ └── Compound condition (condition_type = "compound", operator = "or"|"and")
│ ├── sub_condition A
│ └── sub_condition B
└── actions: [Rule::Action, ...]
Rules apply in two sequential passes over the active scope :
- Prepare — each condition calls
condition.prepare(scope)to add necessary joins without applying filters yet. - Apply — each condition calls
condition.apply(scope)to append WHERE clauses.
This two-phase pattern lets filters add joins (e.g., left_joins(:merchant)) once, before any filtering, avoiding duplicate joins or ordering conflicts.
Condition & Compound Conditions#
Rule::Condition is a self-referential model with an optional parent_id . The rule_conditions table stores condition_type, operator, value, and parent_id .
- A simple condition delegates directly to its filter:
filter.apply(scope, operator, value). - A compound condition (
condition_type = "compound") groups sub-conditions withORorANDlogic viabuild_compound_scope:operator = "or": maps each sub-condition to a separate scope, then merges with.or().operator = "and"(default): chains sub-conditions by reducing through the scope.
Nesting is intentionally one level deep only — a validation in Rule prevents nested compound conditions . Sub-conditions do not store a rule_id; they walk up via parent&.rule .
Registry & Resource Scope#
Rule::Registry::TransactionResource is the only concrete registry. It provides:
resource_scope: the base transaction query —family.transactions.visible, filtered toentry.date >= rule.effective_dateand excluding split parents .condition_filters: the full list of available filters .action_executors: the list of available actions, with AI-powered executors (AutoCategorize,AutoDetectMerchants) conditionally added when OpenAI is configured .
The base Rule::Registry provides get_filter!(key) / get_executor!(key) lookup with typed errors on missing entries.
Condition Filters#
All filters inherit from Rule::ConditionFilter, which defines:
type—"text","number", or"select".OPERATORS_MAP— operators per type: text supportslike,=,is_null; number supports>,>=,<,<=,=,!=; select supports=,is_null.prepare(scope)— no-op by default; override to add joins .apply(scope, operator, value)— abstract; must be implemented .build_sanitized_where_condition— shared SQL-injection-safe helper that normalizes whitespace for text fields and wrapslikevalues in%…%withILIKE.
| Filter | Type | Prepare join | Filters on |
|---|---|---|---|
TransactionName | text | with_entry | entries.name |
TransactionMerchant | select | left_joins(:merchant) | merchants.id |
TransactionAmount | number | with_entry | ABS(entries.amount) |
TransactionCategory | select | left join category | categories.id |
TransactionDetails | text | — | JSONB extra field |
Additional filters registered: TransactionType, TransactionNotes, TransactionAccount .
Condition Type Validation#
Rule::Condition#condition_type is validated against a registry of supported types derived from Rule::Registry::TransactionResource.condition_filter_keys plus "compound". Unsupported condition types are rejected at save time with an inclusion validation error.
Supported types for transaction rules: transaction_name, transaction_amount, transaction_type, transaction_merchant, transaction_category, transaction_tag, transaction_details, transaction_notes, transaction_account, and compound.
Legacy Value Normalization#
A before_validation callback automatically normalizes legacy condition_type values. For example, "name" is normalized to "transaction_name". A one-time migration has updated existing rows in the database.
Graceful Degradation for Unsupported Types#
If a persisted row has an unsupported condition_type (e.g., from direct DB manipulation or data migration), the system handles it gracefully:
Rule::ConditionFilter::Unsupportedis used as a fallback filter.- It displays as
"Unsupported (<type>)"in the UI. - The filter returns
scope.none(matches zero records) when applied. - A warning is logged when an unsupported filter is applied.
Adding a New Condition Filter#
- Create
app/models/rule/condition_filter/your_filter.rbsubclassingRule::ConditionFilter. - Override
type, and optionallyoptions(for select) orprepare(for joins). - Implement
apply(scope, operator, value)usingbuild_sanitized_where_condition. - Register the class in
Rule::Registry::TransactionResource::CONDITION_FILTER_CLASSES.
Note: New filter classes must be added to CONDITION_FILTER_CLASSES for the filter key to become part of the supported condition_type values used in validation.
Key Files#
| File | Purpose |
|---|---|
app/models/rule.rb | Root model; matching loop, validation, apply |
app/models/rule/condition.rb | Condition model; compound logic |
app/models/rule/condition_filter.rb | Base filter class; operator map, SQL helpers |
app/models/rule/registry/transaction_resource.rb | Transaction-specific registry: scope, filters, actions |
app/models/rule/condition_filter/ | Individual filter implementations |