Ascenda Wiki
Ascenda · record

Merchant model refactor — summary

Source docs/merchant-model-refactor-summary.md · synced 2026-08-20

Short version. Full detail in merchant-model-refactor.md.

What changes

The merchant stops being an establishment and becomes the entity that owns the transaction. It’s the single unit of categorization for every transaction, however it arrived.

  • categorization_rules is deleted. Its regex coverage moves into merchant_aliases.
  • Merchants and categories are both global. No user-scoped rows in either.
  • A merchant maps to many categories, weighted by occurrences — not one fixed category.
  • Personalization lives in user_merchant_categories. That’s what makes a correction stick, and it’s the evidence essentiality reads.

Manual entries become merchants too: “aula de surf com o Pedro” resolves to a merchant Aula de surf, extracted by AI. The raw phrase stays on the transaction.

Tables

TableGrainPurpose
merchantsglobalidentity — brands and generic concepts alike
merchant_aliasesglobaldescriptor variants; match_mode = exact or prefix. This is the AI response cache
merchant_categoriesmerchant × categorycross-user prior, a rollup of the table below
user_merchant_categoriesuser × merchant × categorythe facts — occurrences, spend, dates
essentiality_merchant_scoresuser × merchant × category × cyclesnapshotted per budget cycle
categorization_rulesdeleted

The two *_categories tables look near-identical and can’t be merged: the global one is the sum of the per-user ones. Source rows and their rollup don’t belong in one table.

Category essentiality already exists as BudgetCategoryAllocation.categoryEssentiality — don’t add a second table for it.

How a transaction resolves

Stage A — which merchant?

  1. merchant name, exact
  2. alias, exact (longest key first)
  3. alias, prefix
  4. gateway token (IFD*… → iFood)
  5. AI extraction — creates the merchant

Stage B — which category?

  1. this user’s user_merchant_categories, highest occurrences
  2. the global merchant_categories prior
  3. AI

Then write back: user_merchant_categories +1 occurrence, +spend. A correction decrements the old pair and increments the new — and never touches anything global.

The three risks

  1. Merchant fragmentation from AI. Open-ended names produce Churrasco, Churrascaria, Churrasco com amigos as three merchants with split counts. Fixed by giving the AI a match-or-create tool schema with candidate ids as an enum, a write-time similarity guard, and a merge script. Never auto-merge.
  2. Regex coverage regression. The rules catch a long tail no exact alias will. Phase 0 exists to measure this rather than guess.
  3. Cold-start latency. More names reach AI once the rules are gone. Seed generic concepts hard.

Phases

#
0Eval harnessbaseline the current pipeline before deleting anything
1Schema & migrationnew tables, drop categorization_rules, enable pg_trgm
2Delete the rules tierkeyword-rules.ts, RULE_SEED, the rule branch
3Resolution servicetwo stages, thread userId through
4AI extraction & hygienematch-or-create schema, guards, merge script
5Transaction pathswrite-back, lazy creation on import
6Seed rewriterules → aliases, priors, gateway rows
7Backfillbuild user_merchant_categories from existing history
8Mobilecomments and one union type; wire field names unchanged
9Tests

Order matters: Phase 7 before 6 backfills against an incomplete dictionary.

Worth knowing before you start

  • Every merchant needs a self-alias, or it’s unreachable from any longer phrase.
  • Don’t put raw manual phrases in merchant_aliases — no other user types that sentence, and it fills a shared table with personal text.
  • Always write the alias on an AI resolution, even a low-confidence one. The alias table is the cache: a descriptor costs one AI call the first time anyone imports it and zero after. Skipping uncertain answers means re-paying forever; a wrong merchant is one merge-script run.
  • Dedupe descriptors by normalized key before the AI call — 200 statement rows is usually 50–70 unique descriptors.
  • createMany uses skipDuplicates — don’t increment occurrences for skipped rows.

Cost

One batched call per import, roughly 1.5K in / 1.5K out at ~60 unique descriptors: about $0.01 on Haiku 4.5, $0.03 on Sonnet 5, $0.05 on Opus 5. The cache is what actually bounds it — repeat imports of the same card are nearly all hits, and descriptors overlap heavily across Brazilian users. ai-categorizer.ts already reads the model from config, so this is a config dial, not a code change.