Files
accounted/lib/suppliers/__tests__/match-supplier.test.ts
T
4300cb711b fix(inbox): match suppliers on VAT number, not just org-nr and name (#1630)
An extracted document auto-links to a supplier by org_number, then by an
exact (case-insensitive) name match. The extractor deliberately leaves
orgNumber null unless the document carries a real Swedish
organisationsnummer, so for every foreign supplier the name was the only
key left: "ADOBE SYSTEMS SOFTWARE IRELAND LTD" prints nothing but a
momsregistreringsnummer (IE6364992H), which the suppliers table already
stores in vat_number and which no code path looked at.

Adds vat_number as a match key between org_number and name, and collapses
the five inlined copies of the lookup into lib/suppliers/match-supplier.ts:

- invoice-inbox upload (sync and deferred worker)
- invoice-inbox PUT /items/:id/extracted-data
- MCP createDocumentInboxItem (org-nr only until now: gains VAT and name)
- MCP gnubok_set_inbox_extracted_data
- MCP gnubok_create_supplier_invoice_from_inbox, which additionally read
  supplierExt.organizationNumber, a key the extraction schema never
  writes, so its org-nr lookup could not fire at all

VAT numbers are compared on a canonical key (uppercased alphanumerics), so
formatting variants match and a prefix-less "556012579001" still matches
"SE556012579001"; two different country prefixes never do.

Also escapes LIKE metacharacters in the name lookup, so a supplier named
"100 % Solutions" is no longer a wildcard pattern.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
2026-08-17 14:52:50 +02:00

234 lines
7.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
matchSupplierByIdentity,
matchSupplierId,
supplierIdentityFrom,
vatNumberKey,
vatNumbersMatch,
} from '../match-supplier'
/**
* Minimal suppliers-table stub. The matcher issues three shapes of query and
* they are distinguishable by terminator: org_number and name end in
* maybeSingle(), the vat_number scan ends in range() and is awaited directly.
*/
function makeSupabase(rows: {
byOrgNumber?: { id: string } | null
byName?: { id: string } | null
withVatNumber?: { id: string; vat_number: string | null }[]
vatScanError?: { message: string }
}) {
const calls: { column: string; value: unknown }[] = []
const chain = (): Record<string, unknown> => {
const self: Record<string, unknown> = {}
self.select = () => self
self.eq = (column: string, value: unknown) => {
if (column !== 'company_id') calls.push({ column, value })
return self
}
self.ilike = (column: string, value: unknown) => {
calls.push({ column: `ilike:${column}`, value })
return self
}
self.not = () => self
self.order = () => self
self.limit = () => self
self.maybeSingle = () => {
const last = calls[calls.length - 1]
if (last?.column === 'org_number') {
return Promise.resolve({ data: rows.byOrgNumber ?? null, error: null })
}
return Promise.resolve({ data: rows.byName ?? null, error: null })
}
// The vat_number scan goes through fetchAllRows, which awaits .range().
self.range = () =>
Promise.resolve(
rows.vatScanError
? { data: null, error: rows.vatScanError }
: { data: rows.withVatNumber ?? [], error: null },
)
return self
}
return {
supabase: { from: () => chain() } as unknown as SupabaseClient,
calls,
}
}
describe('vatNumberKey', () => {
it('strips formatting and uppercases', () => {
expect(vatNumberKey('se 556012-5790 01')).toBe('SE556012579001')
expect(vatNumberKey('IE6364992H')).toBe('IE6364992H')
})
it('rejects values too short to identify anything', () => {
expect(vatNumberKey('SE')).toBeNull()
expect(vatNumberKey('-')).toBeNull()
expect(vatNumberKey('')).toBeNull()
expect(vatNumberKey(null)).toBeNull()
expect(vatNumberKey(undefined)).toBeNull()
})
})
describe('vatNumbersMatch', () => {
it('matches across formatting variants', () => {
expect(vatNumbersMatch('IE 6364992 H', 'ie6364992h')).toBe(true)
})
it('matches when only one side carries the country prefix', () => {
expect(vatNumbersMatch('SE556012579001', '556012579001')).toBe(true)
expect(vatNumbersMatch('556012579001', 'SE556012579001')).toBe(true)
})
it('keeps different countries distinct', () => {
expect(vatNumbersMatch('IE6364992H', 'SE6364992H')).toBe(false)
})
it('never matches on a missing value', () => {
expect(vatNumbersMatch(null, 'IE6364992H')).toBe(false)
expect(vatNumbersMatch('IE6364992H', null)).toBe(false)
expect(vatNumbersMatch(null, null)).toBe(false)
})
})
describe('supplierIdentityFrom', () => {
it('reads the extraction schema shape', () => {
expect(
supplierIdentityFrom({
name: 'Adobe Systems Software Ireland Ltd',
orgNumber: null,
vatNumber: 'IE6364992H',
}),
).toEqual({
name: 'Adobe Systems Software Ireland Ltd',
orgNumber: null,
vatNumber: 'IE6364992H',
})
})
it('accepts the legacy organizationNumber spelling', () => {
expect(supplierIdentityFrom({ organizationNumber: '5566778899' }).orgNumber).toBe('5566778899')
})
it('treats blank strings and non-strings as absent', () => {
expect(supplierIdentityFrom({ name: ' ', vatNumber: 42 })).toEqual({
name: null,
orgNumber: null,
vatNumber: null,
})
expect(supplierIdentityFrom(null)).toEqual({ name: null, orgNumber: null, vatNumber: null })
})
})
describe('matchSupplierByIdentity', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('prefers org_number over everything else', async () => {
const { supabase } = makeSupabase({
byOrgNumber: { id: 'by-org' },
withVatNumber: [{ id: 'by-vat', vat_number: 'SE556012579001' }],
byName: { id: 'by-name' },
})
const match = await matchSupplierByIdentity(supabase, 'company-1', {
orgNumber: '5566778899',
vatNumber: 'SE556012579001',
name: 'Acme AB',
})
expect(match).toEqual({ supplierId: 'by-org', matchedOn: 'org_number' })
})
it('falls back to vat_number when there is no org number: the Adobe case', async () => {
const { supabase } = makeSupabase({
byOrgNumber: null,
withVatNumber: [
{ id: 'other', vat_number: 'DE123456789' },
{ id: 'adobe', vat_number: 'IE6364992H' },
],
byName: null,
})
const match = await matchSupplierByIdentity(supabase, 'company-1', {
orgNumber: null,
vatNumber: 'IE6364992H',
name: 'Adobe Systems Software Ireland Ltd',
})
expect(match).toEqual({ supplierId: 'adobe', matchedOn: 'vat_number' })
})
it('falls through to name when no identifier matches', async () => {
const { supabase, calls } = makeSupabase({
byOrgNumber: null,
withVatNumber: [{ id: 'other', vat_number: 'DE123456789' }],
byName: { id: 'by-name' },
})
const match = await matchSupplierByIdentity(supabase, 'company-1', {
orgNumber: null,
vatNumber: 'IE6364992H',
name: 'Adobe Systems Software Ireland Ltd',
})
expect(match).toEqual({ supplierId: 'by-name', matchedOn: 'name' })
expect(calls.some((c) => c.column === 'ilike:name')).toBe(true)
})
it('escapes LIKE metacharacters in the name lookup', async () => {
const { supabase, calls } = makeSupabase({ byName: { id: 'by-name' } })
await matchSupplierByIdentity(supabase, 'company-1', { name: '100 % Solutions_AB' })
const nameCall = calls.find((c) => c.column === 'ilike:name')
expect(nameCall?.value).toBe('100 \\% Solutions\\_AB')
})
it('returns null when nothing matches', async () => {
const { supabase } = makeSupabase({ byOrgNumber: null, withVatNumber: [], byName: null })
const match = await matchSupplierByIdentity(supabase, 'company-1', {
orgNumber: null,
vatNumber: 'IE6364992H',
name: 'Unknown Ltd',
})
expect(match).toBeNull()
})
it('skips lookups entirely for an empty identity', async () => {
const { supabase, calls } = makeSupabase({})
const match = await matchSupplierByIdentity(supabase, 'company-1', {
orgNumber: null,
vatNumber: null,
name: null,
})
expect(match).toBeNull()
expect(calls).toEqual([])
})
it('falls through to name when the vat_number scan fails', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { supabase } = makeSupabase({
byOrgNumber: null,
vatScanError: { message: 'connection reset' },
byName: { id: 'by-name' },
})
const match = await matchSupplierByIdentity(supabase, 'company-1', {
vatNumber: 'IE6364992H',
name: 'Adobe Systems Software Ireland Ltd',
})
expect(match).toEqual({ supplierId: 'by-name', matchedOn: 'name' })
consoleSpy.mockRestore()
})
})
describe('matchSupplierId', () => {
it('returns just the id', async () => {
const { supabase } = makeSupabase({ byOrgNumber: { id: 'by-org' } })
await expect(
matchSupplierId(supabase, 'company-1', { orgNumber: '5566778899' }),
).resolves.toBe('by-org')
})
it('returns null when unmatched', async () => {
const { supabase } = makeSupabase({ byName: null })
await expect(matchSupplierId(supabase, 'company-1', { name: 'Nope' })).resolves.toBeNull()
})
})