fix(categorization): connect card descriptors to counterparty history (#1095)

* fix(categorization): connect card descriptors to counterparty history

suggest_categories returned no signal for recurring card merchants
(reported: Anthropic booked to 5420 fourteen times, zero suggestions).
Three compounding causes, all fixed:

- normalizeCounterpartyName() now reduces card-network descriptors to
  their merchant segment ("ANTHROPIC* CLAUDE SUB SAN FRANCISCO" ->
  "anthropic"; "PAYPAL *SPOTIFY" -> "spotify"), so monthly per-charge
  tails stop splintering one merchant into unmatchable variants. SQL
  mirror normalize_counterparty_key() updated in lockstep (migration
  20260721140000), keeping the ledger-context template join exact.
- New token_subset match tier bridges templates learned from manual
  bookings ("Claude Dec" -> "claude") to bank descriptors containing
  the token, and card-core descriptors to legacy splintered templates.
  Guarded by a distinctive-token filter so generic/geo words never
  match on their own.
- Merchant history falls back to description when merchant_name is
  null: card purchases never carry merchant_name, so the history path
  was structurally blind to exactly the transactions that need it.
  History keys now share the counterparty-template normalization and
  the 200-row window is ordered by recency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(categorization): guard single-token matches, anchor history on original_description

Review follow-ups (CodeRabbit on #1095):

- token_subset tier: a single shared distinctive token now also requires
  occurrence_count >= 3 on the template, so a template named after a
  common word or first name (one prior booking) cannot vacuum up
  unrelated transfers ("SWISH ANDERS JOHANSSON"). Multi-token agreement
  stays unrestricted; the Claude/Anthropic case (14 bookings) is
  unaffected.
- merchant history keys on original_description ?? description: the raw
  bank descriptor is immutable while description is a user-editable
  working title, so renaming a transaction no longer severs its history
  link for future recurring charges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): re-timestamp card-descriptor migration after prod moved past it

Prod applied 20260721144311 (#1101) through 20260721201747 (#1104) while
this PR was open; 20260721140000 would sort before them and risk being
skipped by out-of-order auto-apply at merge. Not yet applied to prod, so
renaming is safe; the preview branch re-applies idempotently
(CREATE OR REPLACE).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-22 15:58:45 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 3920c893f4
commit 4a0b524fbb
9 changed files with 463 additions and 22 deletions
@@ -223,6 +223,14 @@ describe('normalize_counterparty_key', () => {
expect(await normalize('Ngrok Mars')).toBe('ngrok')
// Invoice references.
expect(await normalize('Acme INV-123')).toBe('acme')
// Card-network descriptors: merchant segment before the star, per-charge
// product/ref/city tail dropped (the Anthropic splinter bug). Processor
// prefixes keep the merchant AFTER the star instead.
expect(await normalize('ANTHROPIC* CLAUDE SUB SAN FRANCISCO')).toBe('anthropic')
expect(await normalize('ANTHROPIC*CLAUDE SUB +14155551234')).toBe('anthropic')
expect(await normalize('PAYPAL *SPOTIFY')).toBe('spotify')
expect(await normalize('SQ *BLUE BOTTLE COFFEE')).toBe('blue bottle coffee')
expect(await normalize('AMZN MKTP SE*A12B34CD5')).toBe('amzn mktp')
// Never strips to empty: keeps the last token.
expect(await normalize('SEB')).toBe('seb')
// NULL-safe.