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>
This commit is contained in:
bjornbergenheim
2026-08-17 14:52:50 +02:00
committed by GitHub
co-authored by Claude Opus 5 Jakob Wennberg
parent 0a3a2e1ad6
commit 4300cb711b
10 changed files with 560 additions and 135 deletions
@@ -0,0 +1,233 @@
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()
})
})
+183
View File
@@ -0,0 +1,183 @@
/**
* Supplier auto-matching for extracted documents (inbox items, uploads, MCP).
*
* Every extraction path used to inline the same two lookups: exact org_number,
* then case-insensitive full name. That works for Swedish suppliers and fails
* for every foreign one, because the extractor deliberately leaves orgNumber
* null unless the document carries a real Swedish organisationsnummer (see
* extensions/general/invoice-inbox/lib/extract-invoice-fields.ts). A supplier
* like "ADOBE SYSTEMS SOFTWARE IRELAND LTD" only ever prints a
* momsregistreringsnummer (IE6364992H), which the suppliers table stores in
* vat_number and which nothing looked at, so an exact name match was the sole
* remaining key and any rename or OCR variant broke the link.
*
* This module is the single implementation: org_number, then vat_number, then
* name. Matching is best-effort and never throws: a failed lookup leaves the
* item unmatched for the user to link by hand, it does not fail the upload.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
export type SupplierIdentity = {
orgNumber?: string | null
vatNumber?: string | null
name?: string | null
}
/** Same shape with every key present: what supplierIdentityFrom() guarantees. */
export type ResolvedSupplierIdentity = Required<{
[K in keyof SupplierIdentity]: string | null
}>
/** How a match was found. Callers log this; it is not persisted. */
export type SupplierMatchKey = 'org_number' | 'vat_number' | 'name'
export type SupplierMatch = {
supplierId: string
matchedOn: SupplierMatchKey
}
/**
* Canonical key for a VAT registration number. Registration numbers are
* printed with spaces, dots and hyphens in every combination ("SE 556012-5790
* 01", "IE6364992H"), none of which carry identity, so the key is the
* uppercased alphanumerics. Returns null for values too short to be an
* identifier at all ("SE", "VAT", "-"), which keeps junk in the column from
* matching other junk.
*/
export function vatNumberKey(raw: string | null | undefined): string | null {
if (!raw) return null
const key = raw.toUpperCase().replace(/[^A-Z0-9]/g, '')
return key.length >= 6 ? key : null
}
/** The ISO country code an EU VAT number leads with, when present. */
const COUNTRY_PREFIX = /^[A-Z]{2}(?=[0-9])/
function withoutCountryPrefix(key: string): string | null {
return COUNTRY_PREFIX.test(key) ? key.slice(2) : null
}
/**
* True when two VAT numbers denote the same registration.
*
* Beyond canonical equality this accepts the prefix-vs-no-prefix pair
* ("SE556012579001" vs "556012579001"): suppliers are often typed in from a
* Swedish invoice without the country code while the extractor is instructed
* to always include it. The relaxation only fires when exactly ONE side
* carries a prefix, so IE6364992H and SE6364992H stay distinct.
*/
export function vatNumbersMatch(
a: string | null | undefined,
b: string | null | undefined,
): boolean {
const keyA = vatNumberKey(a)
const keyB = vatNumberKey(b)
if (!keyA || !keyB) return false
if (keyA === keyB) return true
const bareA = withoutCountryPrefix(keyA)
const bareB = withoutCountryPrefix(keyB)
if (bareA && !bareB) return bareA === keyB
if (bareB && !bareA) return bareB === keyA
return false
}
/**
* Escape the LIKE metacharacters before a name goes into .ilike(). A supplier
* named "100 % Solutions" or "Foo_Bar" would otherwise be a wildcard pattern
* and match the wrong row.
*/
function escapeLikePattern(value: string): string {
return value.replace(/[\\%_]/g, (char) => `\\${char}`)
}
/**
* Resolve an extracted supplier identity to a supplier in this company.
*
* Order is strongest-key-first: org_number is exact and unique, vat_number is
* exact once normalised, name is a heuristic that legal-form suffixes and OCR
* casing routinely break. Returns null when nothing matches.
*/
export async function matchSupplierByIdentity(
supabase: SupabaseClient,
companyId: string,
identity: SupplierIdentity,
): Promise<SupplierMatch | null> {
if (identity.orgNumber) {
const { data } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.eq('org_number', identity.orgNumber)
.limit(1)
.maybeSingle()
if (data) return { supplierId: data.id as string, matchedOn: 'org_number' }
}
// Normalising in SQL is not possible through PostgREST, so the comparison
// happens here over the suppliers that have a vat_number at all: a small
// set even for companies with thousands of suppliers.
if (vatNumberKey(identity.vatNumber)) {
try {
const rows = await fetchAllRows<{ id: string; vat_number: string | null }>(
({ from, to }) =>
supabase
.from('suppliers')
.select('id, vat_number')
.eq('company_id', companyId)
.not('vat_number', 'is', null)
.order('id', { ascending: true })
.range(from, to),
)
const hit = rows.find((row) => vatNumbersMatch(row.vat_number, identity.vatNumber))
if (hit) return { supplierId: hit.id, matchedOn: 'vat_number' }
} catch (error) {
// Best-effort: fall through to the name lookup rather than fail the
// extraction that called us.
console.error('[match-supplier] vat_number lookup failed:', error)
}
}
if (identity.name) {
const { data } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.ilike('name', escapeLikePattern(identity.name))
.limit(1)
.maybeSingle()
if (data) return { supplierId: data.id as string, matchedOn: 'name' }
}
return null
}
/**
* Read a supplier identity out of a loosely-typed `extracted_data.supplier`
* blob (MCP callers hold it as Record<string, unknown>, not the Zod type).
*
* `organizationNumber` is accepted alongside the schema's `orgNumber` because
* the MCP inbox resolver has always read that spelling, and agent-supplied
* extracted_data may still use it.
*/
export function supplierIdentityFrom(raw: unknown): ResolvedSupplierIdentity {
const supplier = (raw ?? {}) as Record<string, unknown>
const str = (value: unknown): string | null =>
typeof value === 'string' && value.trim() !== '' ? value : null
return {
orgNumber: str(supplier.orgNumber) ?? str(supplier.organizationNumber),
vatNumber: str(supplier.vatNumber),
name: str(supplier.name),
}
}
/** Convenience wrapper for the call sites that only persist the id. */
export async function matchSupplierId(
supabase: SupabaseClient,
companyId: string,
identity: SupplierIdentity,
): Promise<string | null> {
const match = await matchSupplierByIdentity(supabase, companyId, identity)
return match?.supplierId ?? null
}