From adee36d731dc42a32b040a3b4f24556aa2f88457 Mon Sep 17 00:00:00 2001 From: Graham Barber Date: Mon, 13 Jul 2026 15:02:56 -0700 Subject: [PATCH] match rules against memo, show memo in ledger tooltip --- .../specs/categorization/spec.md | 6 ++-- src/lib/server/services/ledger.ts | 4 ++- src/lib/server/services/rules.test.ts | 10 ++++-- src/lib/server/services/rules.ts | 36 ++++++++++++------- src/routes/(app)/ledger/+page.svelte | 4 ++- 5 files changed, 39 insertions(+), 21 deletions(-) diff --git a/openspec/changes/bootstrap-finance-app/specs/categorization/spec.md b/openspec/changes/bootstrap-finance-app/specs/categorization/spec.md index e4ecb21..2cd58dc 100644 --- a/openspec/changes/bootstrap-finance-app/specs/categorization/spec.md +++ b/openspec/changes/bootstrap-finance-app/specs/categorization/spec.md @@ -27,7 +27,7 @@ The system SHALL record every category assignment as an immutable event: transac - **THEN** a new event is appended and prior events remain queryable ### Requirement: Rule-based auto-categorization -The system SHALL support categorization rules with match types `exact` and `contains`, matched case-insensitively against the raw transaction description and, when the provider supplies one, the payee field (a rule fires if either matches). Rules record their creator's DID and creation time. When multiple rules match one transaction, precedence SHALL be deterministic: `exact` beats `contains`, then longer pattern beats shorter, then newer rule beats older. Rule application SHALL append a `rule` event recording the winning rule's id. +The system SHALL support categorization rules with match types `exact` and `contains`, matched case-insensitively against the raw transaction description and, when the provider supplies them, the payee and memo fields (a rule fires if any of these matches). Rules record their creator's DID and creation time. When multiple rules match one transaction, precedence SHALL be deterministic: `exact` beats `contains`, then longer pattern beats shorter, then newer rule beats older. Rule application SHALL append a `rule` event recording the winning rule's id. #### Scenario: Rule fires on new transaction at sync - **WHEN** a sync ingests an uncategorized transaction whose description matches an active rule @@ -37,8 +37,8 @@ The system SHALL support categorization rules with match types `exact` and `cont - **WHEN** a description matches both `contains "AMAZON"` and `contains "AMAZON PRIME"` - **THEN** the longer pattern's rule wins and the fired rule id is recorded on the event -#### Scenario: Rule matches the payee field -- **WHEN** a transaction's description is a terse bank label but its provider-supplied payee matches an active rule +#### Scenario: Rule matches the payee or memo field +- **WHEN** a transaction's description is a terse bank label but its provider-supplied payee or memo matches an active rule - **THEN** the rule fires exactly as if the description had matched ### Requirement: Manual decisions outrank rules diff --git a/src/lib/server/services/ledger.ts b/src/lib/server/services/ledger.ts index 9167555..447e81b 100644 --- a/src/lib/server/services/ledger.ts +++ b/src/lib/server/services/ledger.ts @@ -22,6 +22,7 @@ export interface LedgerRow { description: string; /** Provider-supplied clean merchant name; preferred for display when present. */ payee: string | null; + memo: string | null; pending: boolean; categoryId: number | null; categoryName: string | null; @@ -70,7 +71,7 @@ export function listLedger(db: DatabaseSync, filters: LedgerFilters = {}): Ledge .prepare( `SELECT t.id, t.account_id, COALESCE(a.display_name, a.name) AS account_label, ${EFFECTIVE_TS} AS effective_at, - t.amount_cents, a.currency, t.description, t.payee, t.pending, + t.amount_cents, a.currency, t.description, t.payee, t.memo, t.pending, t.category_id, c.name AS category_name, e.source AS prov_source, r.pattern AS prov_pattern, u.handle AS prov_handle FROM transactions t @@ -96,6 +97,7 @@ export function listLedger(db: DatabaseSync, filters: LedgerFilters = {}): Ledge currency: r.currency as string, description: r.description as string, payee: r.payee as string | null, + memo: r.memo as string | null, pending: r.pending === 1, categoryId: r.category_id as number | null, categoryName: r.category_name as string | null, diff --git a/src/lib/server/services/rules.test.ts b/src/lib/server/services/rules.test.ts index e0a900d..e294282 100644 --- a/src/lib/server/services/rules.test.ts +++ b/src/lib/server/services/rules.test.ts @@ -38,15 +38,19 @@ Deno.test('precedence: exact beats contains, longer beats shorter, newer beats o throw new Error('non-matching description should return null'); }); -Deno.test('rules match the provider payee when the description is terse', () => { +Deno.test('rules match the provider payee or memo when the description is terse', () => { const kroger = rule({ id: 1, pattern: 'KROGER' }); if (findWinningRule([kroger], 'Card Purchase', 'Kroger Columbus')?.id !== 1) throw new Error('payee match should fire the rule'); - if (findWinningRule([kroger], 'Card Purchase', null) !== null) - throw new Error('no payee, terse description: rule must not fire'); + if (findWinningRule([kroger], 'Card Purchase', null, 'KROGER #123 weekly shop')?.id !== 1) + throw new Error('memo match should fire the rule'); + if (findWinningRule([kroger], 'Card Purchase', null, null) !== null) + throw new Error('no payee/memo, terse description: rule must not fire'); const exactPayee = rule({ id: 2, matchType: 'exact', pattern: 'kroger' }); if (findWinningRule([exactPayee], 'Card Purchase', 'Kroger')?.id !== 2) throw new Error('exact match should apply to payee too'); + if (findWinningRule([exactPayee], 'Card Purchase', null, 'kroger') === null) + throw new Error('exact match should apply to memo too'); }); function testDb(): DatabaseSync { diff --git a/src/lib/server/services/rules.ts b/src/lib/server/services/rules.ts index db60b76..32f031e 100644 --- a/src/lib/server/services/rules.ts +++ b/src/lib/server/services/rules.ts @@ -57,12 +57,21 @@ export function setRuleActive(db: DatabaseSync, ruleId: number, active: boolean) db.prepare('UPDATE rules SET active = ? WHERE id = ?').run(active ? 1 : 0, ruleId); } -/** A rule fires if the raw description OR the provider-supplied payee matches. */ -function matches(rule: Rule, description: string, payee: string | null): boolean { +/** + * A rule fires if the raw description OR any provider-supplied field + * (payee, memo) matches — banks scatter the identifying text differently. + */ +function matches( + rule: Rule, + description: string, + payee: string | null, + memo: string | null +): boolean { const pattern = rule.pattern.toLowerCase(); - const hit = (text: string) => - rule.matchType === 'exact' ? text === pattern : text.includes(pattern); - return hit(description.toLowerCase()) || (payee != null && hit(payee.toLowerCase())); + const hit = (text: string | null) => + text != null && + (rule.matchType === 'exact' ? text.toLowerCase() === pattern : text.toLowerCase().includes(pattern)); + return hit(description) || hit(payee) || hit(memo); } /** @@ -73,11 +82,12 @@ function matches(rule: Rule, description: string, payee: string | null): boolean export function findWinningRule( rules: Rule[], description: string, - payee: string | null = null + payee: string | null = null, + memo: string | null = null ): Rule | null { let winner: Rule | null = null; for (const rule of rules) { - if (!matches(rule, description, payee)) continue; + if (!matches(rule, description, payee, memo)) continue; if (!winner) { winner = rule; continue; @@ -111,7 +121,7 @@ export function applyRulesToUncategorized( // category is currently NULL (a human explicitly uncategorized it). const candidates = db .prepare( - `SELECT t.id, t.description, t.payee FROM transactions t + `SELECT t.id, t.description, t.payee, t.memo FROM transactions t WHERE t.category_id IS NULL AND t.removed_at IS NULL AND NOT EXISTS ( SELECT 1 FROM categorization_events e @@ -119,11 +129,11 @@ export function applyRulesToUncategorized( AND e.id = (SELECT MAX(id) FROM categorization_events WHERE transaction_id = t.id) )` ) - .all() as { id: number; description: string; payee: string | null }[]; + .all() as { id: number; description: string; payee: string | null; memo: string | null }[]; let count = 0; for (const txn of candidates) { - const winner = findWinningRule(rules, txn.description, txn.payee); + const winner = findWinningRule(rules, txn.description, txn.payee, txn.memo); if (!winner) continue; appendCategorizationEvent(db, { transactionId: txn.id, @@ -153,8 +163,8 @@ export function countRuleMatches( }; const rows = db .prepare( - 'SELECT description, payee FROM transactions WHERE category_id IS NULL AND removed_at IS NULL' + 'SELECT description, payee, memo FROM transactions WHERE category_id IS NULL AND removed_at IS NULL' ) - .all() as { description: string; payee: string | null }[]; - return rows.filter((r) => matches(probe, r.description, r.payee)).length; + .all() as { description: string; payee: string | null; memo: string | null }[]; + return rows.filter((r) => matches(probe, r.description, r.payee, r.memo)).length; } diff --git a/src/routes/(app)/ledger/+page.svelte b/src/routes/(app)/ledger/+page.svelte index 67133d8..880fc0b 100644 --- a/src/routes/(app)/ledger/+page.svelte +++ b/src/routes/(app)/ledger/+page.svelte @@ -108,7 +108,9 @@ {formatDay(row.effectiveAt)}{#if row.pending}pending{/if} - {row.payee ?? row.description} + + {row.payee ?? row.description} + {row.accountLabel} {formatCents(row.amountCents, row.currency)} -- 2.51.2