Rules
Categorize transactions and automate journal entry creation with Axiomatic's two-stage rule pipeline.
Overview
Axiomatic uses a two-stage rule pipeline to process imported transactions into journal entries:
Transaction → [Categorization] → Event → [Posting Rules] → Journal Entry| Stage | Purpose | Who uses it |
|---|---|---|
| Categorization | Determines what type of transaction this is (e.g. "JPMorgan Chase" → loan_payment) | Everyone |
| Posting | Determines how to record the event as debits and credits | Power users / accountants |
These two stages work together but are managed independently. The Rules page in Axiomatic has two tabs — one for each stage.
Stage 1: Categorization
Categorization rules automatically classify imported transactions based on counterparty name patterns. When a bank transaction is imported, the system checks if its counterparty matches any saved pattern before falling back to the default classification heuristics.
How It Works
- A transaction is imported (via Plaid, CSV, or API)
- The system checks the counterparty name against saved categorization patterns
- If a pattern matches, the transaction is classified with the pattern's event type
- If no pattern matches, the system uses built-in heuristics (account type, Plaid category, etc.)
- If the pattern specifies a target account, that account overrides the default role-based mapping
Creating Patterns
Patterns can be created in two ways:
- From reclassification — after you reclassify a transaction in the account ledger drill-down, the system prompts you to save the counterparty as a categorization rule. You can optionally specify a target account. If other past transactions match the same counterparty, you can batch-reclassify them in one action.
- Manually — from the Rules > Categorization tab, click "Add Rule" and specify the counterparty pattern, event type, and optional target account.
Pattern Matching
Patterns use case-insensitive substring matching. A pattern of JPMORGAN CHASE matches any counterparty containing that string (e.g. "JPMORGAN CHASE DES:Ext Trnsfr", "JPMorgan Chase Bank").
Patterns are evaluated in order of usage frequency — the most frequently matched pattern is checked first.
Account Override
Each pattern can optionally specify a target account. When set, the posting engine uses this specific account instead of the default role-based account mapping. This is useful for cases like:
- "JPMORGAN CHASE" →
loan_payment→ Auto Loan account (instead of generic "Liability") - "ANYTIME FITNESS" →
vendor_payment→ Health & Fitness expense account
API Reference
GET /api/match-patterns?entityId=...
POST /api/match-patterns { entityId, counterpartyPattern, mappedEventType, mappedAccountId? }
PATCH /api/match-patterns { id, counterpartyPattern?, mappedEventType?, mappedAccountId? }
DELETE /api/match-patterns?id=...Retroactive Reclassification
After creating a pattern, you can batch-apply it to past transactions:
GET /api/match-patterns/retroactive?entityId=...&counterparty=...&newEventType=...This returns all existing events whose imported counterparty matches the pattern but have a different event type. Use the batch reclassify endpoint to apply:
POST /api/events/batch-reclassify
{ entityId, eventIds: [...], newEventType, accountOverride?, reason? }Stage 2: Posting Rules (DSL)
The posting engine uses a purpose-built DSL (domain-specific language) to define how events become journal entries. Instead of manually creating journal entries for every transaction, you write rules that automatically match events and produce the correct debits and credits.
Rules are organized into rule packs — versioned containers that group related rules. Each rule matches an event type, evaluates optional conditions, and produces posting lines.
How Posting Rules Work
- An event enters the system (e.g.
invoice_issued) - The posting engine searches for a matching rule by event type
- Rules are evaluated in priority order — the first match wins
- The matched rule's
wherecondition is checked against the event payload - If the condition passes, the
postblock generates debit/credit lines - Account roles are resolved to actual account IDs via role mappings (or the categorization pattern's account override)
- A journal entry is created
Rule Packs
Rule packs group related rules and control when they're active.
Statuses
- Draft — editable, not used by the posting engine
- Published — active and evaluated during posting
- Superseded — replaced by a newer version, kept for audit
- Deprecated — no longer in use
Layers
Packs are classified by scope:
- Kernel — foundational rules shipped with the system
- Standard — general-purpose rules applicable across entities
- Industry — sector-specific rule sets (e.g. fund accounting)
- Entity — rules created for a specific entity
Activating Packs
Rule packs must be activated for a book before their rules are evaluated during posting. Activation creates a link between a book and a pack via the book_rule_packs table.
- Single-book entities — clicking Activate applies the pack to the entity's only book automatically.
- Multi-book entities — the UI presents a book picker so you can activate a pack for specific books (e.g. activate a tax-basis pack only for the TAX book).
You can activate multiple packs for the same book. Rules are resolved across all activated packs by priority — the first matching rule wins.
To deactivate a pack, click Deactivate on an active pack. For multi-book entities, you can deactivate from individual books.
Custom entity packs (layer = ENTITY) are always shown in the Active section regardless of explicit activation.
PUT /api/rule-packs
{ "action": "activate_pack", "entityId": "...", "packId": "...", "bookId": "..." }PUT /api/rule-packs
{ "action": "deactivate_pack", "entityId": "...", "packId": "...", "bookId": "..." }Omit bookId to activate/deactivate for all books in the entity.
DSL Syntax
Basic Structure
rule "Rule Name" {
on "event_type"
priority 10
where <condition>
let variable = <expression>
post {
debit {
account: <expression>
amount: <expression>
currency: <expression>
memo: <expression>
}
credit {
account: <expression>
amount: <expression>
currency: <expression>
memo: <expression>
}
}
}Rule Clauses
| Clause | Required | Description |
|---|---|---|
on "event_type" | Yes | The event type this rule matches |
priority N | No | Higher values are tried first (default: 0) |
where <expr> | No | Boolean condition that must be true for the rule to match |
let name = <expr> | No | Variable bindings for use in subsequent expressions |
post { ... } | Yes | Posting block with debit/credit lines |
Accessing Event Data
Event payload fields are accessed via dot notation:
payload.total_amount
payload.currency
payload.counterparty
payload.line_items.amountCondition Expressions
The where clause accepts boolean expressions:
where payload.currency == "USD"
where contains(payload.description, "advisory") and payload.total_amount > 1000
where payload.method == "crypto" or payload.currency == "USDC"Operators
| Category | Operators |
|---|---|
| Arithmetic | +, -, *, /, % |
| Comparison | ==, !=, <, >, <=, >= |
| Boolean | and, or, not |
| String | ++ (concatenation) |
Built-in Functions
| Function | Description |
|---|---|
coalesce(a, b) | Returns the first non-null value |
contains(str, substr) | Case-insensitive substring check |
starts_with(str, prefix) | Prefix check |
upper(str) / lower(str) | Case conversion |
abs(n) / min(a, b) / max(a, b) | Numeric operations |
round(n, scale, mode) | Decimal rounding (HALF_UP, HALF_EVEN, FLOOR, CEILING, TRUNCATE) |
account(role) | Resolves a logical role to an account |
is_null(val) | Null check |
between(val, lo, hi) | Range check |
concat(a, b, ...) | Multi-argument string concatenation |
to_string(val) | Convert any value to string |
Account Resolution
Use the account() function to reference accounts by their logical role:
account: account("accounts.receivable")
account: account("cash.operating")
account: account("accounts.revenue.advisory")The role is resolved to a concrete account ID at posting time via your entity's role mappings.
Full Example
rule "Revenue Recognition — Advisory Invoice" {
on "invoice_issued"
priority 10
where contains(payload.description, "advisory")
let amt = payload.total_amount
let ccy = coalesce(payload.currency, "USD")
post {
debit {
account: account("accounts.receivable")
amount: amt
currency: ccy
memo: "Invoice " ++ coalesce(payload.invoice_number, "") ++ " — " ++ coalesce(payload.counterparty, "")
}
credit {
account: account("accounts.revenue.advisory")
amount: amt
currency: ccy
memo: coalesce(payload.description, "")
}
}
}This rule:
- Matches
invoice_issuedevents where the description contains "advisory" - Debits Accounts Receivable and credits Advisory Revenue
- Uses the invoice amount and currency from the event payload
- Builds a memo from the invoice number and counterparty name
Conditional Lines
Lines with a zero or empty amount are automatically excluded. Use if/then/else for more complex logic:
amount: if payload.tax_amount > 0 then payload.tax_amount else 0If fewer than 2 lines survive filtering, no journal entry is created.
Priority and Matching
Rules are evaluated in descending priority order. The first rule whose conditions match wins. Use priority to layer specific rules above general-purpose ones:
rule "Crypto Invoice" {
on "invoice_issued"
priority 20
where payload.currency == "USDC"
...
}
rule "Standard Invoice" {
on "invoice_issued"
priority 10
...
}The crypto-specific rule at priority 20 is tried first. If the currency isn't USDC, it falls through to the standard rule at priority 10.
Common Event Types
These event types are typically covered by the default rule packs:
| Event Type | Typical Posting |
|---|---|
invoice_issued | DR Accounts Receivable, CR Revenue |
payment_received | DR Cash, CR Accounts Receivable |
payroll_processed | DR Expense, CR Cash |
vendor_bill | DR Expense, CR Accounts Payable |
vendor_payment | DR Expense, CR Cash |
debt_payment | DR Liability, CR Cash |
income_received | DR Cash, CR Income |
crypto_received | DR Crypto Cash, CR Revenue |
capital_contribution | DR Cash, CR Equity |
investment_made | DR Investment Asset, CR Cash |
In-App Rule Editor
ENTITY-layer rules can be edited directly in the Axiomatic UI using the built-in DSL editor. The editor is powered by CodeMirror 6 and provides:
- Syntax highlighting — keywords (
rule,on,post,where,let,debit,credit), built-in functions, operators, strings, and numbers are color-coded. - Inline validation — as you type, the DSL source is validated against the Rust WASM engine. Errors are displayed below the editor in real time.
- Test / Preview panel — paste a sample event payload (JSON) and dry-run the rule to see the posting lines it would produce, without creating actual journal entries. This uses the
/api/dsl/evaluateendpoint. - Read-only mode — KERNEL and STANDARD pack rules are displayed in the editor but cannot be modified, preserving system integrity.
To edit a rule, navigate to Rules, expand a pack, and click on a rule. For ENTITY-layer packs, the editor is fully interactive. Changes are saved via the update_rule API action:
PUT /api/rule-packs
{ "action": "update_rule", "entityId": "...", "ruleId": "...", "dslSource": "..." }The DSL source is re-parsed on save — the rule's name, eventType, and priority are automatically extracted from the updated source.
Creating a Rule Pack
POST /api/rule-packs
{
"name": "My Custom Rules",
"layer": "ENTITY",
"entityId": "your-entity-id",
"status": "DRAFT",
"effectiveDate": "2025-01-01",
"rules": [
{
"name": "Custom Revenue Rule",
"eventType": "invoice_issued",
"priority": 15,
"dslSource": "rule \"Custom Revenue\" {\n on \"invoice_issued\"\n priority 15\n post {\n debit { account: account(\"accounts.receivable\") amount: payload.total_amount currency: coalesce(payload.currency, \"USD\") memo: \"Invoice\" }\n credit { account: account(\"accounts.revenue.saas\") amount: payload.total_amount currency: coalesce(payload.currency, \"USD\") memo: \"Revenue\" }\n }\n}"
}
]
}Set the status to PUBLISHED when the pack is ready for use.