Reference

Complete syntax for merchants.rules and views.rules

Contents

#Merchants Rules

File: config/merchants.rules

Categorize transactions by matching descriptions to patterns.

Rule Structure

[Rule Name]              # Display name for matched transactions
match: <expression>      # Required: when to apply this rule
category: <Category>     # Required: primary grouping
subcategory: <Sub>       # Optional: secondary grouping
tags: tag1, tag2         # Optional: labels for filtering

#Transforms

Transforms are defined by top-level assignments whose left side starts with field.. The parser does not require a block marker; an @transforms line is just a human-readable separator you can include in the file if you like.

Built-ins field.amount, field.description, and field.date mutate the actual transaction fields; any other field.<name> writes to the custom field map.

@transforms
# Add fee column to amount (handles $ and commas)
field.amount = field.amount + regex_replace(regex_replace(field.fee, "\\$", ""), ",", "")

# Clean descriptions
field.description = regex_replace(field.description, "^APLPAY\\s+", "")

Example

[Netflix]
match: contains("NETFLIX")
category: Subscriptions
subcategory: Streaming
tags: entertainment, recurring

[Costco Grocery]
match: contains("COSTCO") and amount <= 200
category: Food
subcategory: Grocery

[Costco Bulk]
match: contains("COSTCO") and amount > 200
category: Shopping
subcategory: Wholesale

#Match Functions

All match functions search description by default. Add an optional first argument to search a custom field.

Function Description
contains("text") Case-insensitive substring match
regex("pattern") Perl-compatible regex
normalized("text") Ignores spaces, hyphens, punctuation
anyof("a", "b", ...) Match any of multiple patterns
startswith("text") Match only at beginning
fuzzy("text") Approximate matching (80% similar)
fuzzy("text", 0.90) Fuzzy with custom threshold

Examples

# Case-insensitive substring
match: contains("NETFLIX")
# Matches: NETFLIX.COM, netflix, Netflix Inc

# Regex with negative lookahead
match: regex("UBER\\s(?!EATS)")
# Matches: UBER TRIP, but not UBER EATS

# Ignore formatting differences
match: normalized("WHOLEFOODS")
# Matches: WHOLE FOODS, WHOLE-FOODS, WHOLEFDS

# Match multiple patterns
match: anyof("NETFLIX", "HULU", "HBO")

# Search custom field
match: contains(field.memo, "REF")

#Amount & Date Conditions

Condition Description
amount > 100 Transactions over $100
amount <= 50 Transactions $50 or less
amount < 0 Credits/refunds (negative amounts)
month == 12 December transactions only
month >= 11 November and December
year == 2024 Specific year
day == 1 First of the month
weekday == 0 Mondays only (0=Mon, 1=Tue, ... 6=Sun)
weekday >= 5 Weekends (Saturday=5, Sunday=6)
date >= "2024-01-01" On or after a specific date

#Combining Conditions

Operator Description Example
and Both must be true contains("COSTCO") and amount > 200
or Either can be true contains("SHELL") or contains("CHEVRON")
not Negates condition contains("UBER") and not contains("EATS")
( ) Group conditions (contains("AMAZON") or contains("AMZN")) and amount > 100

#Rule Mode (Experimental)

Control how rules are matched when multiple patterns could match a transaction.

Experimental

This feature is experimental and may change based on feedback.

# In settings.yaml:
rule_mode: first_match    # Default - first matching rule wins
rule_mode: most_specific  # Most specific pattern wins

first_match (Default)

The first rule that matches wins. Order your rules from most specific to least specific:

[Uber Eats]                    # Check first (more specific)
match: contains("UBER EATS")
category: Food

[Uber]                         # Check second (less specific)
match: contains("UBER")
category: Transportation

most_specific

The rule with the most specific match wins, regardless of order. Specificity is determined by pattern length and type:

# Order doesn't matter with most_specific mode
[Uber]
match: contains("UBER")        # Less specific (4 chars)
category: Transportation

[Uber Eats]
match: contains("UBER EATS")   # More specific (9 chars) - wins
category: Food

#Custom CSV Fields

Access fields captured from CSV format strings using field.<name>:

# In settings.yaml:
format: "{date},{txn_type},{memo},{vendor},{amount}"
columns:
  description: "{vendor}"

# In merchants.rules:
[Wire Transfer]
match: field.txn_type == "WIRE"
category: Transfers

[Invoice Payment]
match: contains(field.memo, "Invoice")
category: Bills

Use exists(field.name) to safely check if a field exists:

match: exists(field.memo) and contains(field.memo, "REF")

#Extraction Functions

Function Description
extract("pattern") Extract first regex capture group
split("-", 0) Split by delimiter, get element at index
substring(0, 4) Extract substring by position
trim() Remove leading/trailing whitespace
exists(field.x) Check if field exists and is non-empty

#Variables

Define reusable conditions at the top of your file:

# Define variables
is_large = amount > 500
is_holiday = month >= 11 and month <= 12
is_coffee = anyof("STARBUCKS", "PEETS", "PHILZ")

# Use in rules
[Holiday Splurge]
match: is_large and is_holiday
category: Shopping

#Field Transforms

Mutate field values before matching. Place at the top of your file:

# Strip common prefixes
field.description = regex_replace(field.description, "^APLPAY\\s+", "")
field.description = regex_replace(field.description, "^SQ\\*\\s*", "")
field.memo = trim(field.memo)
Function Description
regex_replace(text, pattern, repl) Regex substitution (replaces all matches)
uppercase(text) Convert to uppercase
lowercase(text) Convert to lowercase
strip_prefix(text, prefix) Remove prefix (case-insensitive)
strip_suffix(text, suffix) Remove suffix (case-insensitive)
trim(text) Remove leading/trailing whitespace
Note

Original values are preserved in _raw_<field> (e.g., _raw_description).

#Special Tags

These tags have special meaning in the spending report:

Tag Effect
income Excluded from spending totals (salary, deposits)
transfer Excluded from spending totals (CC payments, transfers)
refund Shown in "Credits Applied" section, nets against spending

#Dynamic Tags

Use {expression} to create tags from field values:

[Bank Transaction]
match: contains("BANK")
category: Transfers
tags: banking, {field.txn_type}     # → "banking", "wire" or "ach"

[All Purchases]
match: *
tags: {source}                      # → "alice-amex", "bob-chase", etc.

#Tag-Only Rules

Rules without category: add tags without affecting categorization:

[Large Purchase]
match: amount > 500
tags: large, review              # No category - just adds tags

[Holiday Season]
match: month >= 11 and month <= 12
tags: holiday
Two-pass matching

First rule with category: sets the category. Tags are collected from tag-only rules plus the winning categorization rule.

#Supplemental Data Sources (Experimental)

Enrich transactions with data from other CSV files. Match Amazon transactions to order details, PayPal payments to merchant names, or any cross-reference scenario.

Experimental

This feature is experimental and will evolve based on feedback.

Defining Supplemental Sources

# In settings.yaml:
data_sources:
  - name: Chase                    # Primary source (generates transactions)
    file: data/chase.csv
    format: "{date},{description},{amount}"

  - name: amazon_orders            # Supplemental source (query-only)
    file: data/amazon_orders.csv
    format: "{date},{item},{amount}"
    supplemental: true              # Won't generate transactions

Querying with List Comprehensions

Use Python list comprehensions to query supplemental data. Access the current transaction via txn.:

[Amazon - Verified]
match: contains("AMAZON") and any(r for r in amazon_orders if r.amount == txn.amount)
category: Shopping
subcategory: Online

The let: Directive

Cache expensive queries in named variables for reuse:

[Amazon - Verified]
let: orders = [r for r in amazon_orders if r.amount == txn.amount]
let: has_order = len(orders) > 0
match: contains("AMAZON") and has_order
category: Shopping
tags: verified

The field: Directive

Add computed fields to transactions (visible in HTML report):

[Amazon - Verified]
let: orders = [r for r in amazon_orders if r.amount == txn.amount]
match: contains("AMAZON") and len(orders) > 0
category: Shopping
field: items = [r.item for r in orders]
field: order_count = len(orders)

Supported Functions

Function Description
any(expr for r in source if cond) True if any record matches
len([...]) Count of matching records
sum(r.amount for r in source) Sum values from records
next((r for r in source if cond), None) First matching record or None

Transaction Context (txn.)

Access current transaction fields with the txn. prefix:

Field Description
txn.amount Transaction amount
txn.date Transaction date
txn.description Transaction description
txn.<field> Any custom captured field

#Rule Priority

First categorization rule wins. Put specific patterns before general ones:

[Uber Eats]                    # More specific, checked first
match: contains("UBER EATS")
category: Food

[Uber Rides]                   # Less specific, checked second
match: contains("UBER")
category: Transportation

#Practical Examples

Weekday vs Weekend Tagging

Tag the same merchant differently based on day of week (e.g., work lunches Monday-Friday):

[Starbucks - Workdays]
match: contains("Starbucks") and weekday < 5  # Monday-Friday (0-4)
category: Food
subcategory: Coffee
tags: work

[Starbucks]
match: contains("Starbucks") and weekday >= 5  # Saturday-Sunday (5-6)
category: Food
subcategory: Coffee
# No work tag for weekends

#Views Rules

File: config/views.rules

Create custom sections in the spending report.

View Structure

[View Name]                # Section header in report
description: <text>        # Optional: subtitle under header
filter: <expression>       # Required: which merchants to include

Filter Primitives

Name Type Description
months int Number of months with transactions
payments int Total number of transactions
total float Total spending for this merchant
cv float Coefficient of variation (0 = consistent)
category str Merchant category
subcategory str Merchant subcategory
tags set Merchant tags (use has to check)

#View Functions

Function Description
sum(), avg(), count() Aggregation functions
min(), max(), stddev() Statistical functions
by("month") Group transactions by month
by("year") Group transactions by year

Grouping with by()

# Monthly grouping
by("month")              # [[100, 200], [150], [175, 125]] - grouped payments
sum(by("month"))         # [300, 150, 300] - monthly totals
avg(sum(by("month")))    # 250 - average monthly spend
max(sum(by("month")))    # 300 - highest spending month

View Examples

[Every Month]
description: Consistent recurring (rent, utilities, subscriptions)
filter: months >= 6 and cv < 0.3

[Variable Recurring]
description: Frequent but inconsistent (groceries, shopping)
filter: months >= 6 and cv >= 0.3

[Large Purchases]
description: Big one-time expenses
filter: total > 1000 and months <= 2

[Business]
description: Expenses to submit for reimbursement
filter: tags has "business"

[Streaming]
filter: category == "Subscriptions" and subcategory == "Streaming"
Views vs Categories

Categories (in merchants.rules) define WHAT a transaction is. Each transaction has exactly one category.
Views (in views.rules) define HOW to group for reporting. Same merchant can appear in multiple views.