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:
co-authored by
Claude Opus 5
Jakob Wennberg
parent
0a3a2e1ad6
commit
4300cb711b
@@ -1029,6 +1029,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-16] /transactions FyPicker double-fetch fixed by gating the initial fetch on FyPicker's existing onReady (fires after its restore onChange) instead of the analysis doc's literal "read the persisted period synchronously in initial state": localStorage only holds the period ID, not the FiscalPeriod bounds, so a synchronous read would suppress FyPicker's restore (value !== null) and leave the fetch permanently unscoped while the chip claimed a year. Same outcome (one scoped fetch per mount, background refetch on period change) without a stale-bounds cache or new FyPicker API.
|
||||
[2026-08-16] Row exit animation for dry-table <tr> rows collapses via td padding/line-height/font-size transitions plus a numeric max-height (.row-collapsible) on the fixed-height cell spans, not grid-template-rows 0fr (the AttGoraSection pattern): table cells cannot host the grid wrapper without restructuring every td, and max-height needs a numeric rest value because auto/none does not interpolate. prefers-reduced-motion hides the exiting row instantly (display: none) while the 350ms timer does the state cleanup.
|
||||
[2026-08-16] Restyled QuickReviewDialog's inbox-picker trigger to the same full-width dropzone-footer row as TransactionBookingDialog even though it did not share the orphan-button layout: both surfaces come from #1620 and should present the same underlag affordance; the alternative (leaving a small outline button in one dialog and a footer row in the other) would split the visual language of one control. Presentation only, disabled-while-booking kept (PR #1628).
|
||||
[2026-08-17] Supplier auto-match now keys on vat_number between org_number and name, in one shared lib/suppliers/match-supplier.ts instead of five inlined copies: extraction deliberately leaves orgNumber null for non-Swedish entities, so momsregistreringsnumret is the only exact key a foreign supplier has and an exact name match was the sole remaining fallback. Normalisation is done in JS over the suppliers that carry a vat_number at all, because PostgREST cannot normalise in SQL; a prefix-vs-no-prefix pair (SE556012579001 vs 556012579001) matches, two different country prefixes never do.
|
||||
[2026-08-17] Batch "Bokför valda" ships ONE aggregate toast with an "Ångra alla" action instead of dropping undo from batch toasts: per-row undo is feasible today (each booked row storno-reverses via POST /uncategorize, the same endpoint as the per-row Ångra), so the aggregate action just pools it over every booked row. Silent mode suppresses the per-row success toast and the generic failure toast only; interactive escalations (SI/CI match suggestions, duplicate warning, activate-account) keep their dialogs because they are the only way forward for those rows.
|
||||
[2026-08-17] /pending: a FAILED fetch for a tab whose rows are not on screen HOLDS the loading state (spinner + error toast) rather than clearing rows to the empty state: "Inget att granska" after a failed load would be indistinguishable from a genuinely empty list, and BFL-relevant pending work must not look done when it is unknown.
|
||||
[2026-08-17] Full-archive direct download resurfaced as an ImportRow + small centered dialog on /import's Exportera tab (row "Komplett arkiv", hash #full-archive), not by re-mounting the orphaned components/settings/BackupDownloadForm.tsx: the form was pre-frame card styling with a duplicate cloud-backup section, while the export tab's existing SIE dialog sets the house pattern (ImportRow -> sm:max-w-md dialog). Its logic (estimate, 413 handling, last-download stamp) ported into components/import/FullArchiveDialog.tsx; the orphan and its dead settings_backup_download i18n namespace deleted. The dialog reuses FiscalYearSelector despite design.md's "legacy, no new uses" line: FyPicker is a toolbar context chip, and the SIE dialog in the same file already uses FiscalYearSelector for the identical dialog-form slot, so matching it beats introducing a third pattern. Row gated to owner/admin because GET /api/reports/full-archive enforces that role server-side; showing members a download that can only 403 helps nobody.
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user