* 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>
127 lines
4.8 KiB
TypeScript
127 lines
4.8 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
buildMerchantHistory,
|
|
getSuggestedCategories,
|
|
merchantHistoryFor,
|
|
} from '../category-suggestions'
|
|
import type { Transaction } from '@/types'
|
|
|
|
/**
|
|
* P2-1 (mcp_optimization_plan): suggestions must carry signal tied to THIS
|
|
* transaction. The old company-wide frequency fallback emitted an identical
|
|
* ~0.5 four-way spread on every transaction: noise agents correctly
|
|
* distrusted. History is now counterparty-keyed with provenance; when no
|
|
* source matches, the honest answer is an empty list.
|
|
*/
|
|
|
|
const tx = (overrides: Partial<Transaction> = {}): Transaction =>
|
|
({
|
|
id: 'tx-1',
|
|
company_id: 'company-1',
|
|
date: '2026-06-01',
|
|
description: 'KORTKÖP POLARN O PYRET',
|
|
amount: -500,
|
|
currency: 'SEK',
|
|
merchant_name: 'Polarn O. Pyret',
|
|
...overrides,
|
|
}) as Transaction
|
|
|
|
describe('buildMerchantHistory / merchantHistoryFor', () => {
|
|
const rows = [
|
|
{ merchant_name: 'Polarn O. Pyret', category: 'expense_office' },
|
|
{ merchant_name: 'polarn o. pyret', category: 'expense_office' },
|
|
{ merchant_name: 'Polarn O. Pyret', category: 'expense_consumables' },
|
|
{ merchant_name: 'DNB Bank', category: 'expense_bank_fees' },
|
|
{ merchant_name: null, category: 'expense_other' },
|
|
{ merchant_name: 'Ghost AB', category: null },
|
|
]
|
|
|
|
it('groups case-insensitively by merchant and ignores null merchants/categories', () => {
|
|
const map = buildMerchantHistory(rows)
|
|
expect(merchantHistoryFor(map, 'POLARN O. PYRET')).toEqual({
|
|
expense_office: 2,
|
|
expense_consumables: 1,
|
|
})
|
|
expect(merchantHistoryFor(map, 'DNB Bank')).toEqual({ expense_bank_fees: 1 })
|
|
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', () => {
|
|
it('returns an empty list (not a fabricated spread) when nothing matches', () => {
|
|
const result = getSuggestedCategories(
|
|
tx({ merchant_name: 'Helt Okänd Motpart', description: 'XYZ 123' }),
|
|
[],
|
|
{},
|
|
)
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it('surfaces merchant history with provenance and occurrence-scaled confidence', () => {
|
|
const result = getSuggestedCategories(tx({ description: 'XYZ 123' }), [], {
|
|
expense_office: 3,
|
|
expense_consumables: 1,
|
|
})
|
|
expect(result.length).toBe(2)
|
|
expect(result[0]).toMatchObject({
|
|
category: 'expense_office',
|
|
source: 'history',
|
|
confidence: Math.min(0.85, 0.5 + 3 * 0.06),
|
|
})
|
|
expect(result[0].match_reason).toMatch(/3 gånger tidigare för denna motpart/)
|
|
expect(result[1].category).toBe('expense_consumables')
|
|
expect(result[1].match_reason).toMatch(/1 gång tidigare/)
|
|
})
|
|
|
|
it('caps history confidence at 0.85', () => {
|
|
const result = getSuggestedCategories(tx({ description: 'XYZ 123' }), [], {
|
|
expense_office: 50,
|
|
})
|
|
expect(result[0].confidence).toBe(0.85)
|
|
})
|
|
|
|
it('filters history to the transaction direction', () => {
|
|
const result = getSuggestedCategories(
|
|
tx({ amount: 1000, description: 'XYZ 123' }), // income direction
|
|
[],
|
|
{ expense_office: 5 },
|
|
)
|
|
expect(result).toEqual([])
|
|
})
|
|
})
|