Files
accounted/tests/pg/ledger-usage-stats-rpc.pg.test.ts
T
Jakob Wennberg 4a0b524fbb 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>
2026-07-22 15:58:45 +02:00

502 lines
20 KiB
TypeScript

/**
* pg-real test for get_ledger_usage_stats + normalize_counterparty_key.
*
* The RPC backs the Accounted://ledger/context MCP resource: one jsonb
* document with windowed account-usage, counterparty-pattern, and
* supplier-pattern aggregates. Verifies: posted-only filtering, the date
* window, dominant category/account derivation (19xx contra exclusion),
* splinter-merging merchant normalization (payment-rail prefixes, dates,
* legal suffixes), storno exclusion from account_usage, supplier-side
* aggregation (credit notes and reversed invoices excluded), and two-company
* isolation (a foreign company id yields empty sections).
*/
import { describe, it, expect, beforeAll } from 'vitest'
import { randomUUID } from 'node:crypto'
import { getPool } from './setup'
import {
seedCompany,
insertDraftJournalEntry,
} from './fixtures'
async function insertLines(
journalEntryId: string,
lines: Array<{ account: string; debit: number; credit: number }>,
): Promise<void> {
for (const line of lines) {
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, $2, $3, $4)`,
[journalEntryId, line.account, line.debit, line.credit],
)
}
}
async function insertBookedTransaction(params: {
companyId: string
userId: string
journalEntryId: string
merchantName: string
category: string
date: string
amount?: number
}): Promise<void> {
await getPool().query(
`INSERT INTO public.transactions
(id, company_id, user_id, currency, amount, date, description,
journal_entry_id, merchant_name, category)
VALUES ($1, $2, $3, 'SEK', $4, $5, $6, $7, $8, $9)`,
[
randomUUID(),
params.companyId,
params.userId,
params.amount ?? -500,
params.date,
`Payment ${params.merchantName}`,
params.journalEntryId,
params.merchantName,
params.category,
],
)
}
/** ISO date + n days, as a UTC timestamptz string (for committed_at). */
function plusDays(isoDate: string, n: number): string {
const d = new Date(`${isoDate}T00:00:00Z`)
d.setUTCDate(d.getUTCDate() + n)
return d.toISOString()
}
// Posted entry + lines + a booked transaction pointing at it, in one call.
async function bookMerchant(params: {
userId: string
companyId: string
fiscalPeriodId: string
merchantName: string
category: string
date: string
expenseAccount: string
voucherNumber: number
sourceType?: string
}): Promise<string> {
const entryId = await insertDraftJournalEntry({
userId: params.userId,
companyId: params.companyId,
fiscalPeriodId: params.fiscalPeriodId,
entryDate: params.date,
status: 'posted',
voucherNumber: params.voucherNumber,
sourceType: params.sourceType ?? 'bank_transaction',
// Booked 3 days after the transaction: exercises the committed_at-based
// lag (entry_date == transaction date would give 0).
committedAt: plusDays(params.date, 3),
})
await insertLines(entryId, [
{ account: params.expenseAccount, debit: 500, credit: 0 },
{ account: '1930', debit: 0, credit: 500 },
])
await insertBookedTransaction({
companyId: params.companyId,
userId: params.userId,
journalEntryId: entryId,
merchantName: params.merchantName,
category: params.category,
date: params.date,
})
return entryId
}
async function insertSupplierWithInvoices(params: {
userId: string
companyId: string
name: string
invoices: Array<{
invoiceDate: string
account: string
vatTreatment?: string
status?: string
isCreditNote?: boolean
extraItemAccounts?: string[]
}>
arrivalStart: number
}): Promise<void> {
const supplierId = randomUUID()
await getPool().query(
`INSERT INTO public.suppliers (id, user_id, company_id, name)
VALUES ($1, $2, $3, $4)`,
[supplierId, params.userId, params.companyId, params.name],
)
let arrival = params.arrivalStart
for (const inv of params.invoices) {
const invoiceId = randomUUID()
await getPool().query(
`INSERT INTO public.supplier_invoices
(id, user_id, company_id, supplier_id, arrival_number,
supplier_invoice_number, invoice_date, due_date, status,
vat_treatment, is_credit_note, subtotal, vat_amount, total)
VALUES ($1, $2, $3, $4, $5, $6, $7, $7, $8, $9, $10, 1000, 250, 1250)`,
[
invoiceId,
params.userId,
params.companyId,
supplierId,
arrival++,
`SI-${arrival}`,
inv.invoiceDate,
inv.status ?? 'registered',
inv.vatTreatment ?? 'standard_25',
inv.isCreditNote ?? false,
],
)
for (const account of [inv.account, ...(inv.extraItemAccounts ?? [])]) {
await getPool().query(
`INSERT INTO public.supplier_invoice_items
(supplier_invoice_id, description, quantity, unit_price, line_total,
account_number, vat_rate, vat_amount)
VALUES ($1, 'Line', 1, 1000, 1000, $2, 0.25, 250)`,
[invoiceId, account],
)
}
}
}
type LedgerStats = {
account_usage: Array<{
account_number: string
account_name: string | null
postings: number
last_used: string
}>
counterparty_patterns: Array<{
counterparty: string
counterparty_key: string
occurrences: number
last_booked: string
dominant_category: string | null
dominant_category_count: number
dominant_account_number: string | null
}>
supplier_patterns: Array<{
supplier: string
invoices: number
last_invoice: string
vat_treatment: string | null
dominant_account_number: string | null
dominant_account_count: number
}>
vat_treatments_used: string[]
median_booking_lag_days: number | null
}
async function callRpc(companyId: string, fromDate: string): Promise<LedgerStats> {
const res = await getPool().query(
`SELECT public.get_ledger_usage_stats($1, $2) AS stats`,
[companyId, fromDate],
)
return res.rows[0].stats as LedgerStats
}
describe('normalize_counterparty_key', () => {
async function normalize(raw: string): Promise<string> {
const res = await getPool().query(
`SELECT public.normalize_counterparty_key($1) AS key`,
[raw],
)
return res.rows[0].key as string
}
it('mirrors normalizeCounterpartyName for the splinter cases', async () => {
// Payment-rail prefix + trailing date.
expect(await normalize('KLARNA AB 2026-07-01')).toBe('klarna')
expect(await normalize('KLARNA AB')).toBe('klarna')
expect(await normalize('SWISH KLARNA AB')).toBe('klarna')
expect(await normalize('KORTKÖP KLARNA AB')).toBe('klarna')
// Faithful-mirror check: bare KORT is NOT a stripped prefix in
// normalizeCounterpartyName() either (only KORTKÖP). Hardening the prefix
// list is Layer B of bank_transaction_ai_normalization.md and must change
// the TS + SQL pair together, or the categorization_templates join drifts.
expect(await normalize('KORT KLARNA AB')).toBe('kort klarna')
// Legal suffix + casing.
expect(await normalize('Telia Sverige AB')).toBe('telia sverige')
// Trailing initials and month tokens (the ngrok bug).
expect(await normalize('ngrok JW')).toBe('ngrok')
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.
const res = await getPool().query(
`SELECT public.normalize_counterparty_key(NULL) AS key`,
)
expect(res.rows[0].key).toBe('')
})
})
describe('get_ledger_usage_stats', () => {
let userId: string
let companyId: string
let fiscalPeriodId: string
beforeAll(async () => {
const seeded = await seedCompany()
userId = seeded.userId
companyId = seeded.companyId
fiscalPeriodId = seeded.fiscalPeriodId
// 3x Klarna to 6570 under splintered labels (prefix/date/casing variants
// that must merge), 1x Klarna miscategorized, 2x SL to 5810, plus a draft
// that must not count and an old entry outside the window.
await bookMerchant({ userId, companyId, fiscalPeriodId, merchantName: 'KLARNA AB', category: 'expense_bank_fees', date: '2026-05-01', expenseAccount: '6570', voucherNumber: 1 })
await bookMerchant({ userId, companyId, fiscalPeriodId, merchantName: 'KORTKÖP KLARNA AB 2026-05-15', category: 'expense_bank_fees', date: '2026-05-15', expenseAccount: '6570', voucherNumber: 2 })
await bookMerchant({ userId, companyId, fiscalPeriodId, merchantName: 'Klarna AB', category: 'expense_bank_fees', date: '2026-06-01', expenseAccount: '6570', voucherNumber: 3 })
await bookMerchant({ userId, companyId, fiscalPeriodId, merchantName: 'SWISH KLARNA AB', category: 'expense_other', date: '2026-06-10', expenseAccount: '6570', voucherNumber: 4 })
await bookMerchant({ userId, companyId, fiscalPeriodId, merchantName: 'SL', category: 'expense_travel', date: '2026-06-05', expenseAccount: '5810', voucherNumber: 5 })
await bookMerchant({ userId, companyId, fiscalPeriodId, merchantName: 'SL', category: 'expense_travel', date: '2026-06-20', expenseAccount: '5810', voucherNumber: 6 })
// A storno pair: original already excluded via status='reversed'; the
// storno entry itself is posted and must be excluded from account_usage
// by the source_type filter. 4010 must NOT gain postings from either.
const stornoOriginalId = await insertDraftJournalEntry({
userId, companyId, fiscalPeriodId,
entryDate: '2026-06-15', status: 'reversed', voucherNumber: 8,
sourceType: 'bank_transaction',
})
await insertLines(stornoOriginalId, [
{ account: '4010', debit: 300, credit: 0 },
{ account: '1930', debit: 0, credit: 300 },
])
const stornoId = await insertDraftJournalEntry({
userId, companyId, fiscalPeriodId,
entryDate: '2026-06-15', status: 'posted', voucherNumber: 9,
sourceType: 'storno',
})
await insertLines(stornoId, [
{ account: '1930', debit: 300, credit: 0 },
{ account: '4010', debit: 0, credit: 300 },
])
// Legacy shape: a transaction still linked to the storno entry (predates
// reverseEntry() unlinking). Must not create a counterparty pattern.
await insertBookedTransaction({
companyId, userId,
journalEntryId: stornoId,
merchantName: 'STORNO VENDOR',
category: 'expense_other',
date: '2026-06-15',
})
// Draft entry: must not appear in account_usage.
const draftId = await insertDraftJournalEntry({
userId, companyId, fiscalPeriodId,
entryDate: '2026-06-25', status: 'draft', voucherNumber: 0,
})
await insertLines(draftId, [
{ account: '9999', debit: 100, credit: 0 },
{ account: '1930', debit: 0, credit: 100 },
])
// Outside the window: must not count.
await bookMerchant({ userId, companyId, fiscalPeriodId, merchantName: 'OLD VENDOR', category: 'expense_other', date: '2026-01-05', expenseAccount: '4010', voucherNumber: 7 })
// Reverse-charge EU purchase (foreign SaaS): lines are expense (5420) +
// calc input VAT (2645) + calc output VAT (2614) + bank (1930), tying at
// equal counts. The dominant contra must be the expense 5420, NOT the low
// VAT number 2614 that the account_number tiebreak would otherwise pick
// (regression for 20260708110000; observed on prod as 2614).
let rcVoucher = 20
for (const d of ['2026-05-20', '2026-06-18']) {
const rcEntry = await insertDraftJournalEntry({
userId, companyId, fiscalPeriodId,
entryDate: d, status: 'posted',
voucherNumber: rcVoucher++, sourceType: 'bank_transaction',
committedAt: plusDays(d, 3),
})
await insertLines(rcEntry, [
{ account: '5420', debit: 500, credit: 0 },
{ account: '2645', debit: 125, credit: 0 },
{ account: '2614', debit: 0, credit: 125 },
{ account: '1930', debit: 0, credit: 500 },
])
await insertBookedTransaction({
companyId, userId, journalEntryId: rcEntry,
merchantName: 'GOOGLE WO', category: 'expense_software', date: d,
})
}
// Suppliers: Telia with 3 consistent invoices (one of them multi-line,
// which must not outvote), one credit note and one reversed invoice that
// must both be excluded; Blandat with a 1/2 split staying below any
// dominance and one invoice outside the window.
await insertSupplierWithInvoices({
userId, companyId, name: 'Telia Sverige AB', arrivalStart: 1,
invoices: [
{ invoiceDate: '2026-05-05', account: '6212' },
{ invoiceDate: '2026-06-05', account: '6212', extraItemAccounts: ['6212', '6212'] },
{ invoiceDate: '2026-06-25', account: '6212' },
{ invoiceDate: '2026-06-26', account: '6212', isCreditNote: true },
{ invoiceDate: '2026-06-27', account: '6212', status: 'reversed' },
],
})
await insertSupplierWithInvoices({
userId, companyId, name: 'Blandat AB', arrivalStart: 10,
invoices: [
{ invoiceDate: '2026-06-01', account: '4010' },
{ invoiceDate: '2026-06-02', account: '5460' },
{ invoiceDate: '2026-01-02', account: '4010' },
],
})
// Invoices carrying VAT treatments: one in-window, one before the window.
await getPool().query(
`INSERT INTO public.invoices
(company_id, user_id, invoice_number, invoice_date, due_date, vat_treatment)
VALUES ($1, $2, 'INV-1', '2026-06-01', '2026-06-30', 'standard_25'),
($1, $2, 'INV-2', '2026-01-02', '2026-01-31', 'reverse_charge_eu')`,
[companyId, userId],
)
})
it('aggregates posted account usage within the window, excluding stornos', async () => {
const stats = await callRpc(companyId, '2026-04-01')
const byAccount = Object.fromEntries(
stats.account_usage.map((a) => [a.account_number, a]),
)
// 8 posted in-window bank entries each carry a 1930 line (6 simple + 2
// reverse-charge); the storno's 1930 line is excluded by source_type.
expect(byAccount['1930'].postings).toBe(8)
expect(byAccount['6570'].postings).toBe(4)
expect(byAccount['5810'].postings).toBe(2)
expect(byAccount['5810'].last_used).toBe('2026-06-20')
// The reverse-charge fixture's expense and VAT lines all appear in
// account_usage (which does not exclude 26xx: it answers "what accounts
// are used", not "what characterizes a counterparty").
expect(byAccount['5420'].postings).toBe(2)
expect(byAccount['2614'].postings).toBe(2)
// Neither the reversed original nor its storno may credit 4010 postings,
// and the draft line and out-of-window account are absent.
expect(byAccount['4010']).toBeUndefined()
expect(byAccount['9999']).toBeUndefined()
})
it('merges splintered merchant labels into one normalized counterparty', async () => {
const stats = await callRpc(companyId, '2026-04-01')
const klarna = stats.counterparty_patterns.find(
(p) => p.counterparty_key === 'klarna',
)
expect(klarna).toBeDefined()
// KLARNA AB / KORTKÖP ... 2026-05-15 / Klarna AB / SWISH KLARNA AB: one key.
expect(klarna!.occurrences).toBe(4)
expect(klarna!.dominant_category).toBe('expense_bank_fees')
expect(klarna!.dominant_category_count).toBe(3)
// 1930 excluded, so the expense side wins.
expect(klarna!.dominant_account_number).toBe('6570')
expect(klarna!.last_booked).toBe('2026-06-10')
// No second Klarna-ish row survives the merge.
expect(
stats.counterparty_patterns.filter((p) => p.counterparty_key.includes('klarna')),
).toHaveLength(1)
const sl = stats.counterparty_patterns.find((p) => p.counterparty_key === 'sl')
expect(sl!.occurrences).toBe(2)
expect(sl!.dominant_account_number).toBe('5810')
// Out-of-window merchant absent.
expect(
stats.counterparty_patterns.find((p) => p.counterparty === 'OLD VENDOR'),
).toBeUndefined()
// A transaction still linked to a storno entry (legacy rows predating the
// reverseEntry unlink) must not surface as a pattern.
expect(
stats.counterparty_patterns.find((p) => p.counterparty === 'STORNO VENDOR'),
).toBeUndefined()
})
it('picks the expense over a VAT contra for reverse-charge bookings', async () => {
const stats = await callRpc(companyId, '2026-04-01')
const google = stats.counterparty_patterns.find((p) => p.counterparty_key === 'google')
expect(google).toBeDefined()
expect(google!.occurrences).toBe(2)
// 5420/2645/2614 tie at equal counts; excluding 26xx leaves the expense.
// Without the fix the account_number tiebreak would return 2614.
expect(google!.dominant_account_number).toBe('5420')
})
it('orders counterparties by occurrences descending', async () => {
const stats = await callRpc(companyId, '2026-04-01')
const occurrences = stats.counterparty_patterns.map((p) => p.occurrences)
expect(occurrences).toEqual([...occurrences].sort((a, b) => b - a))
})
it('aggregates supplier patterns excluding credit notes and reversed invoices', async () => {
const stats = await callRpc(companyId, '2026-04-01')
const telia = stats.supplier_patterns.find((s) => s.supplier === 'Telia Sverige AB')
expect(telia).toBeDefined()
// 3 live invoices; the credit note and the reversed one are excluded.
expect(telia!.invoices).toBe(3)
expect(telia!.last_invoice).toBe('2026-06-25')
expect(telia!.vat_treatment).toBe('standard_25')
expect(telia!.dominant_account_number).toBe('6212')
// Counted per invoice, not per line: the multi-line invoice adds 1.
expect(telia!.dominant_account_count).toBe(3)
const blandat = stats.supplier_patterns.find((s) => s.supplier === 'Blandat AB')
// Only the two in-window invoices; 1/2 agree on the dominant account.
expect(blandat!.invoices).toBe(2)
expect(blandat!.dominant_account_count).toBe(1)
})
it('reports window-scoped VAT treatments and median booking lag', async () => {
const stats = await callRpc(companyId, '2026-04-01')
expect(stats.vat_treatments_used).toContain('standard_25')
expect(stats.vat_treatments_used).not.toContain('reverse_charge_eu')
// Every booked fixture sets committed_at = transaction date + 3 days, so
// the lag is measured from committed_at (not entry_date, which == the
// transaction date and would give a misleading 0).
expect(stats.median_booking_lag_days).toBe(3)
})
it('returns empty sections for a company with no data (isolation)', async () => {
const other = await seedCompany()
const stats = await callRpc(other.companyId, '2026-04-01')
expect(stats.account_usage).toEqual([])
expect(stats.counterparty_patterns).toEqual([])
expect(stats.supplier_patterns).toEqual([])
expect(stats.vat_treatments_used).toEqual([])
expect(stats.median_booking_lag_days).toBeNull()
})
it('does not leak data across companies with identical merchants', async () => {
const other = await seedCompany()
await bookMerchant({
userId: other.userId,
companyId: other.companyId,
fiscalPeriodId: other.fiscalPeriodId,
merchantName: 'KLARNA AB',
category: 'expense_card_fees',
date: '2026-06-01',
expenseAccount: '6580',
voucherNumber: 1,
})
const stats = await callRpc(other.companyId, '2026-04-01')
const klarna = stats.counterparty_patterns.find(
(p) => p.counterparty_key === 'klarna',
)
// Only its own single booking; the first company's 4 do not bleed in.
expect(klarna!.occurrences).toBe(1)
expect(klarna!.dominant_category).toBe('expense_card_fees')
expect(klarna!.dominant_account_number).toBe('6580')
})
})