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:
co-authored by
Claude Fable 5
parent
3920c893f4
commit
4a0b524fbb
@@ -46,6 +46,40 @@ describe('buildMerchantHistory / merchantHistoryFor', () => {
|
||||
expect(merchantHistoryFor(map, 'Unknown Vendor')).toEqual({})
|
||||
expect(merchantHistoryFor(map, null)).toEqual({})
|
||||
})
|
||||
|
||||
it('falls back to the description when merchant_name is null (card purchases)', () => {
|
||||
// Bank feeds only carry counterparty names for transfers; card purchases
|
||||
// arrive with merchant_name null and the merchant buried in a descriptor
|
||||
// whose tail (product, city) changes between charges. All of these are
|
||||
// one counterparty: the reported Anthropic no-signal bug.
|
||||
const map = buildMerchantHistory([
|
||||
{ merchant_name: null, description: 'ANTHROPIC* CLAUDE SUB SAN FRANCISCO', category: 'expense_software' },
|
||||
{ merchant_name: null, description: 'ANTHROPIC*CLAUDE SUB +14155551234', category: 'expense_software' },
|
||||
{ merchant_name: 'Anthropic', description: 'irrelevant when merchant_name set', category: 'expense_software' },
|
||||
])
|
||||
expect(merchantHistoryFor(map, null, 'ANTHROPIC* CLAUDE SUB LONDON')).toEqual({
|
||||
expense_software: 3,
|
||||
})
|
||||
expect(merchantHistoryFor(map, 'Anthropic')).toEqual({ expense_software: 3 })
|
||||
})
|
||||
|
||||
it('anchors on original_description so user renames do not sever history', () => {
|
||||
// description is a mutable working title; a user renaming the row to
|
||||
// "Software" must not detach it from the raw bank descriptor identity.
|
||||
const map = buildMerchantHistory([
|
||||
{
|
||||
merchant_name: null,
|
||||
description: 'Software',
|
||||
original_description: 'ANTHROPIC* CLAUDE SUB SAN FRANCISCO',
|
||||
category: 'expense_software',
|
||||
},
|
||||
])
|
||||
expect(merchantHistoryFor(map, null, 'ANTHROPIC*CLAUDE SUB +14155551234')).toEqual({
|
||||
expense_software: 1,
|
||||
})
|
||||
// Renamed title itself is NOT a key when the raw descriptor exists.
|
||||
expect(merchantHistoryFor(map, null, 'Software')).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSuggestedCategories: counterparty history', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { suggestCategory } from '@/lib/tax/expense-warnings'
|
||||
import { getExpenseAccountForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import { normalizeCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { findMatchingTemplates, getTemplateById, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
|
||||
import type { Transaction, TransactionCategory, EntityType, MappingRule, LinePatternEntry } from '@/types'
|
||||
|
||||
@@ -39,16 +40,39 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
*/
|
||||
export type MerchantHistoryMap = Map<string, Record<string, number>>
|
||||
|
||||
function normalizeMerchantKey(name: string | null | undefined): string {
|
||||
return (name ?? '').toLowerCase().trim()
|
||||
/**
|
||||
* History keys share the counterparty-template normalization so card
|
||||
* descriptors ("ANTHROPIC* CLAUDE SUB SAN FRANCISCO") and clean merchant
|
||||
* names ("Anthropic") aggregate under one key. merchant_name is null on card
|
||||
* purchases (bank feeds only carry counterparty names for transfers), so the
|
||||
* descriptor is the fallback identity: without it, card merchants have no
|
||||
* history at all and every recurring foreign SaaS line reads as no-signal.
|
||||
* Callers pass `original_description ?? description`: the raw bank descriptor
|
||||
* is the stable anchor, `description` is a user-editable working title that
|
||||
* would sever the link on rename.
|
||||
*/
|
||||
function normalizeMerchantKey(
|
||||
merchantName: string | null | undefined,
|
||||
descriptor?: string | null,
|
||||
): string {
|
||||
const raw = (merchantName ?? '').trim() || (descriptor ?? '').trim()
|
||||
return raw ? normalizeCounterpartyName(raw) : ''
|
||||
}
|
||||
|
||||
export function buildMerchantHistory(
|
||||
rows: Array<{ merchant_name: string | null; category: string | null }>,
|
||||
rows: Array<{
|
||||
merchant_name: string | null
|
||||
description?: string | null
|
||||
original_description?: string | null
|
||||
category: string | null
|
||||
}>,
|
||||
): MerchantHistoryMap {
|
||||
const map: MerchantHistoryMap = new Map()
|
||||
for (const row of rows) {
|
||||
const key = normalizeMerchantKey(row.merchant_name)
|
||||
const key = normalizeMerchantKey(
|
||||
row.merchant_name,
|
||||
row.original_description ?? row.description,
|
||||
)
|
||||
if (!key || !row.category) continue
|
||||
const bucket = map.get(key) ?? {}
|
||||
bucket[row.category] = (bucket[row.category] || 0) + 1
|
||||
@@ -60,8 +84,9 @@ export function buildMerchantHistory(
|
||||
export function merchantHistoryFor(
|
||||
map: MerchantHistoryMap,
|
||||
merchantName: string | null | undefined,
|
||||
descriptor?: string | null,
|
||||
): Record<string, number> {
|
||||
const key = normalizeMerchantKey(merchantName)
|
||||
const key = normalizeMerchantKey(merchantName, descriptor)
|
||||
return key ? (map.get(key) ?? {}) : {}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user