diff --git a/DECISIONS.md b/DECISIONS.md index 5e3a8742..9a085fb4 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1029,6 +1029,7 @@ One line per decision: `[YYYY-MM-DD] : `. 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 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. diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts index d1b38822..28e84296 100644 --- a/extensions/general/invoice-inbox/index.ts +++ b/extensions/general/invoice-inbox/index.ts @@ -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') diff --git a/extensions/general/invoice-inbox/lib/upload-and-extract.ts b/extensions/general/invoice-inbox/lib/upload-and-extract.ts index 1e07f382..70ca20a6 100644 --- a/extensions/general/invoice-inbox/lib/upload-and-extract.ts +++ b/extensions/general/invoice-inbox/lib/upload-and-extract.ts @@ -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. diff --git a/extensions/general/mcp-server/__tests__/create-supplier-invoice-from-inbox.test.ts b/extensions/general/mcp-server/__tests__/create-supplier-invoice-from-inbox.test.ts index 4a0c8c73..c3a34a4d 100644 --- a/extensions/general/mcp-server/__tests__/create-supplier-invoice-from-inbox.test.ts +++ b/extensions/general/mcp-server/__tests__/create-supplier-invoice-from-inbox.test.ts @@ -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') diff --git a/extensions/general/mcp-server/__tests__/document-upload-tools.test.ts b/extensions/general/mcp-server/__tests__/document-upload-tools.test.ts index a07ae107..c59060ba 100644 --- a/extensions/general/mcp-server/__tests__/document-upload-tools.test.ts +++ b/extensions/general/mcp-server/__tests__/document-upload-tools.test.ts @@ -39,11 +39,14 @@ function findTool(name: string) { function makeQueryBuilder(result: { data: unknown; error: unknown }) { const builder: Record = {} - 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 } diff --git a/extensions/general/mcp-server/__tests__/supplier-candidates.test.ts b/extensions/general/mcp-server/__tests__/supplier-candidates.test.ts index 0cfffbab..d85bb5df 100644 --- a/extensions/general/mcp-server/__tests__/supplier-candidates.test.ts +++ b/extensions/general/mcp-server/__tests__/supplier-candidates.test.ts @@ -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([]) }) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 25d9e9f8..8b6363ef 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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 | undefined const lineItemsExt = (extracted.lineItems as Array> | 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') diff --git a/extensions/general/mcp-server/supplier-candidates.ts b/extensions/general/mcp-server/supplier-candidates.ts index 5046a97d..27204f75 100644 --- a/extensions/general/mcp-server/supplier-candidates.ts +++ b/extensions/general/mcp-server/supplier-candidates.ts @@ -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) { diff --git a/lib/suppliers/__tests__/match-supplier.test.ts b/lib/suppliers/__tests__/match-supplier.test.ts new file mode 100644 index 00000000..7923f9e3 --- /dev/null +++ b/lib/suppliers/__tests__/match-supplier.test.ts @@ -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 => { + const self: Record = {} + 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() + }) +}) diff --git a/lib/suppliers/match-supplier.ts b/lib/suppliers/match-supplier.ts new file mode 100644 index 00000000..16400516 --- /dev/null +++ b/lib/suppliers/match-supplier.ts @@ -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 { + 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, 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 + 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 { + const match = await matchSupplierByIdentity(supabase, companyId, identity) + return match?.supplierId ?? null +}