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
+7 -22
View File
@@ -4,6 +4,7 @@ import { createServiceRoleClient } from '@/lib/supabase/service-client'
import { z } from 'zod'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { createServiceClient } from '@/lib/supabase/server'
import { matchSupplierId } from '@/lib/suppliers/match-supplier'
import { extractInvoiceFields, ExtractionSchema, emptyResult } from './lib/extract-invoice-fields'
import {
uploadAndExtract,
@@ -595,28 +596,12 @@ export const invoiceInboxExtension: Extension = {
}
// Re-run supplier match so the agent's parsed fields trigger the
// same auto-link the AI path uses. Skipped if neither key is present.
let matchedSupplierId: string | null = null
if (extracted.supplier.orgNumber) {
const { data: s } = await ctx.supabase
.from('suppliers')
.select('id')
.eq('company_id', ctx.companyId)
.eq('org_number', extracted.supplier.orgNumber)
.limit(1)
.maybeSingle()
if (s) matchedSupplierId = s.id
}
if (!matchedSupplierId && extracted.supplier.name) {
const { data: s } = await ctx.supabase
.from('suppliers')
.select('id')
.eq('company_id', ctx.companyId)
.ilike('name', extracted.supplier.name)
.limit(1)
.maybeSingle()
if (s) matchedSupplierId = s.id
}
// same auto-link the AI path uses. Skipped if no key is present.
const matchedSupplierId = await matchSupplierId(
ctx.supabase,
ctx.companyId,
extracted.supplier,
)
const { data: updated, error: updateError } = await ctx.supabase
.from('invoice_inbox_items')
@@ -5,6 +5,7 @@ import { hasCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { matchSupplierId } from '@/lib/suppliers/match-supplier'
import type { InvoiceExtractionResult } from '@/types'
import { PDFDocument } from 'pdf-lib'
import path from 'node:path'
@@ -444,28 +445,9 @@ export async function uploadAndExtract(
extracted.pages = { total: pageCount, analyzed: MAX_PAGES_FOR_AUTO_EXTRACT }
}
// Supplier match by org-nr, then case-insensitive name (no AI fuzz).
let matchedSupplierId: string | null = null
if (extracted.supplier.orgNumber) {
const { data: s } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.eq('org_number', extracted.supplier.orgNumber)
.limit(1)
.maybeSingle()
if (s) matchedSupplierId = s.id
}
if (!matchedSupplierId && extracted.supplier.name) {
const { data: s } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.ilike('name', extracted.supplier.name)
.limit(1)
.maybeSingle()
if (s) matchedSupplierId = s.id
}
// Supplier match by org-nr, then VAT number, then case-insensitive name
// (no AI fuzz).
const matchedSupplierId = await matchSupplierId(supabase, companyId, extracted.supplier)
const { data: inbox, error: inboxError } = await supabase
.from('invoice_inbox_items')
@@ -598,28 +580,13 @@ function scheduleDeferredExtraction(job: DeferredExtractionJob): void {
console.error('[invoice-inbox] Deferred extraction failed:', err)
}
// Supplier match by org-nr, then case-insensitive name (no AI fuzz).
let matchedSupplierId: string | null = null
if (extracted.supplier.orgNumber) {
const { data: s } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', job.companyId)
.eq('org_number', extracted.supplier.orgNumber)
.limit(1)
.maybeSingle()
if (s) matchedSupplierId = s.id
}
if (!matchedSupplierId && extracted.supplier.name) {
const { data: s } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', job.companyId)
.ilike('name', extracted.supplier.name)
.limit(1)
.maybeSingle()
if (s) matchedSupplierId = s.id
}
// Supplier match by org-nr, then VAT number, then case-insensitive name
// (no AI fuzz).
const matchedSupplierId = await matchSupplierId(
supabase,
job.companyId,
extracted.supplier,
)
// CAS: only the row still waiting on THIS worker flips. A user retry
// or the sweep cron may have claimed it first; their result wins.
@@ -238,6 +238,49 @@ describe('gnubok_create_supplier_invoice_from_inbox: execute', () => {
expect(result.preview.vat_amount).toBe(250)
})
it('resolves a foreign supplier by VAT number when the document carries no org number', async () => {
// The extractor leaves orgNumber null for non-Swedish entities by design,
// so momsregistreringsnumret is the only exact key an EU supplier has.
const supabase = makeMock({
inbox: {
id: 'inbox-vat',
status: 'received',
extracted_data: {
...baseExtracted,
supplier: {
name: 'Adobe Systems Software Ireland Ltd',
orgNumber: null,
vatNumber: 'IE6364992H',
},
},
matched_supplier_id: null,
created_supplier_invoice_id: null,
document_id: 'doc-vat',
},
supplierByOrg: null,
supplierByName: null,
supplierList: [
{ id: 'other-supplier', name: 'Some GmbH', org_number: null, vat_number: 'DE123456789' },
{
id: 'adobe-supplier',
name: 'ADOBE SYSTEMS SOFTWARE IRELAND LTD',
org_number: null,
vat_number: 'IE6364992H',
},
],
supplierRecord: { id: 'adobe-supplier', default_expense_account: null },
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{ inbox_item_id: 'inbox-vat', dry_run: true },
'company-1', 'user-1', supabase,
)) as { preview: { supplier_id: string; supplier_resolution: string; extracted_vat_number: string | null } }
expect(result.preview.supplier_id).toBe('adobe-supplier')
expect(result.preview.supplier_resolution).toBe('lookup_vat_number')
expect(result.preview.extracted_vat_number).toBe('IE6364992H')
})
it('falls through to org_number lookup when no matched supplier', async () => {
const supabase = makeMock({
inbox: {
@@ -331,6 +374,7 @@ describe('gnubok_create_supplier_invoice_from_inbox: execute', () => {
expect(result.preview.unresolved_supplier).toEqual({
extracted_name: 'Acme AB',
extracted_org_number: '5566778899',
extracted_vat_number: null,
})
// Next hint prefills gnubok_create_supplier from the extraction.
expect(result.next.tool).toBe('gnubok_create_supplier')
@@ -39,11 +39,14 @@ function findTool(name: string) {
function makeQueryBuilder(result: { data: unknown; error: unknown }) {
const builder: Record<string, unknown> = {}
for (const method of ['select', 'eq', 'limit', 'insert']) {
// ilike/not/order/range serve the shared supplier matcher
// (lib/suppliers/match-supplier.ts): name lookup and the vat_number scan.
for (const method of ['select', 'eq', 'limit', 'insert', 'ilike', 'not', 'order']) {
builder[method] = vi.fn().mockReturnValue(builder)
}
builder.maybeSingle = vi.fn().mockResolvedValue(result)
builder.single = vi.fn().mockResolvedValue(result)
builder.range = vi.fn().mockResolvedValue({ data: [], error: null })
return builder
}
@@ -97,6 +97,28 @@ describe('findSupplierCandidates', () => {
expect(c.every((x, i, arr) => i === 0 || arr[i - 1].score >= x.score)).toBe(true)
})
it('matches a foreign supplier on VAT number when no org number exists', () => {
const withAdobe = [
...suppliers,
{
id: 'sup-adobe',
name: 'ADOBE SYSTEMS SOFTWARE IRELAND LTD',
org_number: null,
vat_number: 'IE6364992H',
},
]
const c = findSupplierCandidates(withAdobe, 'Adobe Ireland', null, 'IE 6364992 H')
expect(c[0]).toMatchObject({ supplier_id: 'sup-adobe', score: 1, matched_on: 'vat_number' })
})
it('does not match a VAT number against a different country', () => {
const withDe = [
...suppliers,
{ id: 'sup-de', name: 'Some GmbH', org_number: null, vat_number: 'DE6364992H' },
]
expect(findSupplierCandidates(withDe, null, null, 'IE6364992H')).toEqual([])
})
it('returns empty for no extracted signal', () => {
expect(findSupplierCandidates(suppliers, null, null)).toEqual([])
})
+37 -67
View File
@@ -135,7 +135,12 @@ import {
projectToolInputSchema,
resolveMcpCompanyContext,
} from './company-routing'
import { findSupplierCandidates } from './supplier-candidates'
import { findSupplierCandidates, type SupplierRow } from './supplier-candidates'
import {
matchSupplierByIdentity,
matchSupplierId,
supplierIdentityFrom,
} from '@/lib/suppliers/match-supplier'
import { assertNoPlaintextPersonnummer } from './staging-pii-guard'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { generateGeneralLedger } from '@/lib/reports/general-ledger'
@@ -487,17 +492,7 @@ async function createDocumentInboxItem(
const { data: extracted } = await extractInvoiceFields({ buffer, mimeType, fileName })
let matchedSupplierId: string | null = null
if (extracted.supplier.orgNumber) {
const { data: supplier } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.eq('org_number', extracted.supplier.orgNumber)
.limit(1)
.maybeSingle()
if (supplier) matchedSupplierId = supplier.id
}
const matchedSupplierId = await matchSupplierId(supabase, companyId, extracted.supplier)
const { data: inbox, error: inboxError } = await supabase
.from('invoice_inbox_items')
@@ -10008,38 +10003,30 @@ export const tools: McpTool[] = [
const totalsExt = extracted.totals as Record<string, unknown> | undefined
const lineItemsExt = (extracted.lineItems as Array<Record<string, unknown>> | undefined) ?? []
// Resolve supplier: explicit override > matched > org_number lookup > name lookup
// Resolve supplier: explicit override > matched > org_number > VAT number > name
const supplierIdOverride = args.supplier_id_override as string | undefined
let supplierId: string | null = supplierIdOverride ?? (inbox.matched_supplier_id as string | null) ?? null
let supplierResolution: 'override' | 'matched' | 'lookup_org_number' | 'lookup_name' | 'unresolved' =
let supplierResolution:
| 'override'
| 'matched'
| 'lookup_org_number'
| 'lookup_vat_number'
| 'lookup_name'
| 'unresolved' =
supplierIdOverride ? 'override' : inbox.matched_supplier_id ? 'matched' : 'unresolved'
const supplierIdentity = supplierIdentityFrom(supplierExt)
if (!supplierId) {
const orgNumber = supplierExt?.organizationNumber as string | undefined
const supplierName = supplierExt?.name as string | undefined
if (orgNumber) {
const { data } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.eq('org_number', orgNumber)
.maybeSingle()
if (data) {
supplierId = data.id
supplierResolution = 'lookup_org_number'
}
}
if (!supplierId && supplierName) {
const { data } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.ilike('name', supplierName)
.maybeSingle()
if (data) {
supplierId = data.id
supplierResolution = 'lookup_name'
}
const match = await matchSupplierByIdentity(supabase, companyId, supplierIdentity)
if (match) {
supplierId = match.supplierId
supplierResolution =
match.matchedOn === 'org_number'
? 'lookup_org_number'
: match.matchedOn === 'vat_number'
? 'lookup_vat_number'
: 'lookup_name'
}
}
@@ -10050,20 +10037,21 @@ export const tools: McpTool[] = [
// with near-miss candidates the agent can pass as supplier_id_override,
// or a create-supplier next hint when nothing is close. Fuzzy scores
// never auto-resolve: the agent/human confirms against the underlag.
const extractedName = (supplierExt?.name as string | undefined) ?? null
const extractedOrg = (supplierExt?.organizationNumber as string | undefined) ?? null
const extractedName = supplierIdentity.name
const extractedOrg = supplierIdentity.orgNumber
const CANDIDATE_POOL_CAP = 500
const { data: companySuppliers } = await supabase
.from('suppliers')
.select('id, name, org_number')
.select('id, name, org_number, vat_number')
.eq('company_id', companyId)
.limit(CANDIDATE_POOL_CAP)
const candidates = findSupplierCandidates(
(companySuppliers ?? []) as { id: string; name: string; org_number: string | null }[],
(companySuppliers ?? []) as SupplierRow[],
extractedName,
extractedOrg,
supplierIdentity.vatNumber,
)
const best = candidates[0]
// No silent caps: past the pool cap the right supplier may exist yet
@@ -10083,6 +10071,7 @@ export const tools: McpTool[] = [
unresolved_supplier: {
extracted_name: extractedName,
extracted_org_number: extractedOrg,
extracted_vat_number: supplierIdentity.vatNumber,
},
candidates,
candidate_pool_truncated: poolTruncated,
@@ -10270,8 +10259,9 @@ export const tools: McpTool[] = [
inbox_item_id: inboxItemId,
supplier_id: supplierId,
supplier_resolution: supplierResolution,
extracted_supplier_name: supplierExt?.name ?? null,
extracted_org_number: supplierExt?.organizationNumber ?? null,
extracted_supplier_name: supplierIdentity.name,
extracted_org_number: supplierIdentity.orgNumber,
extracted_vat_number: supplierIdentity.vatNumber,
supplier_invoice_number: supplierInvoiceNumber,
invoice_date: invoiceDate,
due_date: dueDate,
@@ -16019,28 +16009,8 @@ export const tools: McpTool[] = [
}
// Re-run supplier match so agent-supplied fields trigger the same
// auto-link the AI path does (org-nr → name, ILIKE).
let matchedSupplierId: string | null = null
if (extracted.supplier.orgNumber) {
const { data: s } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.eq('org_number', extracted.supplier.orgNumber)
.limit(1)
.maybeSingle()
if (s) matchedSupplierId = s.id
}
if (!matchedSupplierId && extracted.supplier.name) {
const { data: s } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.ilike('name', extracted.supplier.name)
.limit(1)
.maybeSingle()
if (s) matchedSupplierId = s.id
}
// auto-link the AI path does (org-nr → VAT number → name, ILIKE).
const matchedSupplierId = await matchSupplierId(supabase, companyId, extracted.supplier)
const { error: updateError } = await supabase
.from('invoice_inbox_items')
@@ -10,10 +10,13 @@
* fuzzy scores never auto-resolve.
*/
import { vatNumbersMatch } from '@/lib/suppliers/match-supplier'
export type SupplierRow = {
id: string
name: string
org_number: string | null
vat_number?: string | null
}
export type SupplierCandidate = {
@@ -21,7 +24,7 @@ export type SupplierCandidate = {
name: string
org_number: string | null
score: number
matched_on: 'org_number' | 'name'
matched_on: 'org_number' | 'vat_number' | 'name'
}
// Legal-form suffixes carry no identity signal and OCR/extraction includes
@@ -96,6 +99,7 @@ export function findSupplierCandidates(
suppliers: SupplierRow[],
extractedName: string | null,
extractedOrgNumber: string | null,
extractedVatNumber: string | null = null,
options: { limit?: number; minScore?: number } = {},
): SupplierCandidate[] {
const limit = options.limit ?? 5
@@ -117,6 +121,19 @@ export function findSupplierCandidates(
})
continue
}
// The only identifier a foreign supplier prints: extraction leaves
// orgNumber null for non-Swedish entities by design, so without this a
// renamed EU supplier has no exact key left at all.
if (extractedVatNumber && vatNumbersMatch(s.vat_number, extractedVatNumber)) {
scored.push({
supplier_id: s.id,
name: s.name,
org_number: s.org_number,
score: 1,
matched_on: 'vat_number',
})
continue
}
if (extractedName) {
const score = scoreSupplierName(extractedName, s.name)
if (score >= minScore) {