* fix(inbox): match suppliers on VAT number, not just org-nr and name 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> * fix(inbox): re-run the supplier match when the extraction is re-run "Tolka om" (POST /items/:id/retry-extraction) rewrote extracted_data and left matched_supplier_id untouched. It is the one affordance a user reaches for precisely when auto-linking failed, and it could not produce a link no matter how many times they pressed it: the match ran once, at first extraction, and never again. An item that arrived before its supplier existed stayed unlinked forever. The retry now runs the same shared matcher the other extraction paths use. Only a positive match is written, unlike those paths which also write null: a supplier the user picked by hand must survive a retry that finds nothing, which is the likelier case on a document that already failed to match once. Stacked on fix/supplier-match-vat-number for matchSupplierId(); merge that one first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(inbox): write the retry-extraction update as two literal payloads The conditional spread for matched_supplier_id was one more dynamic payload than the phantom-column guard's ceiling allows (380 > 379), and a spread is exactly the shape that guard cannot check. Two inline literal update calls keep every written column checkable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
284 lines
11 KiB
TypeScript
284 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
|
import {
|
|
createQueuedMockSupabase,
|
|
createMockRequest,
|
|
parseJsonResponse,
|
|
} from '@/tests/helpers'
|
|
import type { ExtensionContext } from '@/lib/extensions/types'
|
|
|
|
vi.mock('@/extensions/general/invoice-inbox/lib/extract-invoice-fields', () => ({
|
|
extractInvoiceFields: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/rate-limits/inbox', () => ({
|
|
checkInboxUploadRateLimit: vi.fn().mockResolvedValue({ ok: true }),
|
|
}))
|
|
|
|
// Paid AI OCR gate. Retry is an explicit "run AI now" action, so a company
|
|
// without CAPABILITY.ai is hard-blocked (403). Default to entitled here;
|
|
// capabilityBlockedResponse stays real so the 403 envelope is exercised.
|
|
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
|
|
return { ...actual, hasCapability: vi.fn().mockResolvedValue(true) }
|
|
})
|
|
|
|
// The attachment download runs on the service-role client (the storage
|
|
// SELECT policy is per-uploader-folder, and inbox documents are attributed
|
|
// to the company creator, not the caller).
|
|
const serviceDownloadMock = vi.fn()
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createServiceClient: () => ({
|
|
storage: { from: vi.fn(() => ({ download: serviceDownloadMock })) },
|
|
}),
|
|
}))
|
|
|
|
import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
|
|
import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox'
|
|
import { hasCapability } from '@/lib/entitlements/has-capability'
|
|
|
|
function findRoute(method: string, path: string) {
|
|
return invoiceInboxExtension.apiRoutes!.find(
|
|
(r) => r.method === method && r.path === path,
|
|
)!
|
|
}
|
|
|
|
const retryRoute = findRoute('POST', '/items/:id/retry-extraction')
|
|
|
|
function buildCtx(supabase: unknown): ExtensionContext {
|
|
return {
|
|
userId: 'user-1',
|
|
companyId: 'company-1',
|
|
extensionId: 'invoice-inbox',
|
|
supabase: supabase as ExtensionContext['supabase'],
|
|
emit: vi.fn(),
|
|
settings: { get: vi.fn(), set: vi.fn() },
|
|
storage: { from: vi.fn() } as unknown as ExtensionContext['storage'],
|
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as ExtensionContext['log'],
|
|
services: {},
|
|
} as ExtensionContext
|
|
}
|
|
|
|
function makeReq() {
|
|
return createMockRequest('/items/item-1/retry-extraction', {
|
|
method: 'POST',
|
|
searchParams: { _id: 'item-1' },
|
|
})
|
|
}
|
|
|
|
const EXTRACTION_SUCCESS = {
|
|
data: {
|
|
supplier: { name: 'Acme AB', orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
|
|
invoice: { invoiceNumber: 'F-1', invoiceDate: '2026-05-01', dueDate: null, paymentReference: null, currency: 'SEK' },
|
|
lineItems: [],
|
|
totals: { subtotal: 100, vatAmount: 25, total: 125 },
|
|
vatBreakdown: [],
|
|
confidence: 1,
|
|
},
|
|
rawText: 'mock',
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
vi.mocked(checkInboxUploadRateLimit).mockResolvedValue({ ok: true })
|
|
vi.mocked(hasCapability).mockResolvedValue(true)
|
|
})
|
|
|
|
describe('POST /items/:id/retry-extraction', () => {
|
|
it('returns 401 when no context', async () => {
|
|
const res = await retryRoute.handler(makeReq(), undefined)
|
|
const { status } = await parseJsonResponse(res)
|
|
expect(status).toBe(401)
|
|
})
|
|
|
|
it('returns 429 when the per-company rate limit is exceeded', async () => {
|
|
vi.mocked(checkInboxUploadRateLimit).mockResolvedValueOnce({
|
|
ok: false,
|
|
scope: 'minute',
|
|
retryAfterSec: 30,
|
|
})
|
|
const { supabase } = createQueuedMockSupabase()
|
|
const res = await retryRoute.handler(makeReq(), buildCtx(supabase))
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
|
expect(status).toBe(429)
|
|
expect(body.error).toMatch(/för många/i)
|
|
expect(res.headers.get('Retry-After')).toBe('30')
|
|
})
|
|
|
|
it('returns 404 when the inbox item is missing', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: null, error: null }) // item lookup
|
|
const res = await retryRoute.handler(makeReq(), buildCtx(supabase))
|
|
const { status } = await parseJsonResponse(res)
|
|
expect(status).toBe(404)
|
|
})
|
|
|
|
it('returns 409 when already booked as supplier invoice', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: { id: 'item-1', document_id: 'doc-1', correlation_id: null, created_supplier_invoice_id: 'si-1' },
|
|
error: null,
|
|
})
|
|
const res = await retryRoute.handler(makeReq(), buildCtx(supabase))
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
|
expect(status).toBe(409)
|
|
expect(body.error).toMatch(/redan bokfört/i)
|
|
})
|
|
|
|
it('hard-blocks with 403 capability_blocked when the company lacks the ai capability', async () => {
|
|
vi.mocked(hasCapability).mockResolvedValueOnce(false)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: { id: 'item-1', document_id: 'doc-1', correlation_id: null, created_supplier_invoice_id: null },
|
|
error: null,
|
|
}) // item lookup: the ai gate fires immediately after
|
|
const res = await retryRoute.handler(makeReq(), buildCtx(supabase))
|
|
const { status, body } = await parseJsonResponse<{ capability_blocked: boolean; capability: string }>(res)
|
|
expect(status).toBe(403)
|
|
expect(body.capability_blocked).toBe(true)
|
|
expect(body.capability).toBe('ai')
|
|
expect(extractInvoiceFields).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns 400 when the item has no attached document', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: { id: 'item-1', document_id: null, correlation_id: null, created_supplier_invoice_id: null },
|
|
error: null,
|
|
})
|
|
const res = await retryRoute.handler(makeReq(), buildCtx(supabase))
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
|
expect(status).toBe(400)
|
|
expect(body.error).toMatch(/Ingen bilaga/i)
|
|
})
|
|
|
|
it('returns 200 with re-extracted data on the happy path', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: { id: 'item-1', document_id: 'doc-1', correlation_id: null, created_supplier_invoice_id: null },
|
|
error: null,
|
|
})
|
|
enqueue({ data: { is_sandbox: false }, error: null }) // sandbox check
|
|
enqueue({
|
|
data: { storage_path: 'path/to.pdf', mime_type: 'application/pdf', file_name: 'invoice.pdf' },
|
|
error: null,
|
|
})
|
|
enqueue({ data: null, error: null }) // supplier name lookup: no match
|
|
enqueue({ data: null, error: null }) // inbox update on success
|
|
|
|
serviceDownloadMock.mockResolvedValue({
|
|
data: new Blob([new Uint8Array([1, 2, 3])], { type: 'application/pdf' }),
|
|
error: null,
|
|
})
|
|
|
|
vi.mocked(extractInvoiceFields).mockResolvedValueOnce(EXTRACTION_SUCCESS as never)
|
|
|
|
const res = await retryRoute.handler(makeReq(), buildCtx(supabase))
|
|
const { status, body } = await parseJsonResponse<{ data: { extracted_data: { totals: { total: number } } } }>(res)
|
|
expect(status).toBe(200)
|
|
expect(body.data.extracted_data.totals.total).toBe(125)
|
|
})
|
|
|
|
it('re-links the supplier on retry, matching a foreign supplier by VAT number', async () => {
|
|
// Re-running the extraction used to rewrite extracted_data and leave
|
|
// matched_supplier_id untouched, so "Tolka om" could never repair a link
|
|
// that failed the first time (e.g. because the supplier was created after
|
|
// the document arrived).
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: { id: 'item-1', document_id: 'doc-1', correlation_id: null, created_supplier_invoice_id: null },
|
|
error: null,
|
|
})
|
|
enqueue({ data: { is_sandbox: false }, error: null }) // sandbox check
|
|
enqueue({
|
|
data: { storage_path: 'path/to.pdf', mime_type: 'application/pdf', file_name: 'invoice.pdf' },
|
|
error: null,
|
|
})
|
|
enqueue({
|
|
data: [{ id: 'adobe-supplier', vat_number: 'IE6364992H' }],
|
|
error: null,
|
|
}) // vat_number scan
|
|
enqueue({ data: null, error: null }) // inbox update on success
|
|
|
|
serviceDownloadMock.mockResolvedValue({
|
|
data: new Blob([new Uint8Array([1, 2, 3])], { type: 'application/pdf' }),
|
|
error: null,
|
|
})
|
|
|
|
vi.mocked(extractInvoiceFields).mockResolvedValueOnce({
|
|
...EXTRACTION_SUCCESS,
|
|
data: {
|
|
...EXTRACTION_SUCCESS.data,
|
|
supplier: {
|
|
...EXTRACTION_SUCCESS.data.supplier,
|
|
name: 'Adobe Systems Software Ireland Ltd',
|
|
vatNumber: 'IE6364992H',
|
|
},
|
|
},
|
|
} as never)
|
|
|
|
const res = await retryRoute.handler(makeReq(), buildCtx(supabase))
|
|
const { status } = await parseJsonResponse(res)
|
|
expect(status).toBe(200)
|
|
|
|
const update = findCall('invoice_inbox_items', 'update')?.[0] as Record<string, unknown>
|
|
expect(update.matched_supplier_id).toBe('adobe-supplier')
|
|
})
|
|
|
|
it('leaves a hand-picked supplier alone when the retry finds no match', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: { id: 'item-1', document_id: 'doc-1', correlation_id: null, created_supplier_invoice_id: null },
|
|
error: null,
|
|
})
|
|
enqueue({ data: { is_sandbox: false }, error: null }) // sandbox check
|
|
enqueue({
|
|
data: { storage_path: 'path/to.pdf', mime_type: 'application/pdf', file_name: 'invoice.pdf' },
|
|
error: null,
|
|
})
|
|
enqueue({ data: null, error: null }) // supplier name lookup: no match
|
|
enqueue({ data: null, error: null }) // inbox update on success
|
|
|
|
serviceDownloadMock.mockResolvedValue({
|
|
data: new Blob([new Uint8Array([1, 2, 3])], { type: 'application/pdf' }),
|
|
error: null,
|
|
})
|
|
vi.mocked(extractInvoiceFields).mockResolvedValueOnce(EXTRACTION_SUCCESS as never)
|
|
|
|
const res = await retryRoute.handler(makeReq(), buildCtx(supabase))
|
|
const { status } = await parseJsonResponse(res)
|
|
expect(status).toBe(200)
|
|
|
|
const update = findCall('invoice_inbox_items', 'update')?.[0] as Record<string, unknown>
|
|
expect(update).not.toHaveProperty('matched_supplier_id')
|
|
})
|
|
|
|
it('marks the item as error and returns 500 when extraction throws', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: { id: 'item-1', document_id: 'doc-1', correlation_id: null, created_supplier_invoice_id: null },
|
|
error: null,
|
|
})
|
|
enqueue({ data: { is_sandbox: false }, error: null }) // sandbox check
|
|
enqueue({
|
|
data: { storage_path: 'path/to.pdf', mime_type: 'application/pdf', file_name: 'invoice.pdf' },
|
|
error: null,
|
|
})
|
|
enqueue({ data: null, error: null }) // error-state update
|
|
|
|
serviceDownloadMock.mockResolvedValue({
|
|
data: new Blob([new Uint8Array([1])], { type: 'application/pdf' }),
|
|
error: null,
|
|
})
|
|
|
|
vi.mocked(extractInvoiceFields).mockRejectedValueOnce(new Error('pdfjs blew up'))
|
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
const res = await retryRoute.handler(makeReq(), buildCtx(supabase))
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
|
expect(status).toBe(500)
|
|
expect(body.error).toBe('pdfjs blew up')
|
|
spy.mockRestore()
|
|
})
|
|
})
|