feat(invoice): handle duplicate supplier invoice number conflict with… (#685)

* feat(invoice): handle duplicate supplier invoice number conflict with detailed error response

* fix(invoice): enhance error response for duplicate invoice number to ensure data minimization
This commit is contained in:
Mattsson
2026-06-06 15:27:09 +02:00
committed by GitHub
parent 3c85082f3a
commit 32af88f9c4
3 changed files with 163 additions and 26 deletions
@@ -131,6 +131,45 @@ describe('POST /items/:id/convert', () => {
expect(status).toBe(404)
})
it('returns 409 (not 500) when the supplier invoice number already exists', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeInvoiceInboxItem({ status: 'received' }) })
enqueue({ data: makeSupplier({ id: SUPPLIER_UUID }) })
enqueue({ data: 42 })
// Insert collides with idx_supplier_invoices_company_supplier_number.
enqueue({
data: null,
error: {
code: '23505',
message:
'duplicate key value violates unique constraint "idx_supplier_invoices_company_supplier_number"',
},
})
// Lookup of the existing (non-credited) invoice for the conflict payload.
enqueue({
data: { id: 'existing-1', supplier_invoice_number: 'F-2024-001', status: 'approved' },
})
const ctx = buildCtx(supabase)
const request = createMockRequest('/items/item-1/convert', {
method: 'POST',
body: VALID_CONVERT_BODY,
searchParams: { _id: 'item-1' },
})
const res = await route.handler(request, ctx)
const { status, body } = await parseJsonResponse<{
error: { code: string; details?: Record<string, unknown> & { existing?: { id: string } } }
}>(res)
expect(status).toBe(409)
expect(body.error.code).toBe('SI_CREATE_DUPLICATE_INVOICE_NUMBER')
expect(body.error.details?.existing?.id).toBe('existing-1')
// Data minimisation: the raw request body must NOT be echoed back into the
// error envelope — only the server-authoritative `existing` row.
expect(body.error.details).not.toHaveProperty('supplierId')
expect(body.error.details).not.toHaveProperty('supplierInvoiceNumber')
})
it('successfully converts inbox item to supplier invoice', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const inboxItem = makeInvoiceInboxItem({ status: 'received', document_id: 'doc-1' })
+62
View File
@@ -1718,6 +1718,68 @@ export const invoiceInboxExtension: Extension = {
.single()
if (invoiceError || !invoice) {
// A unique-index hit on (company_id, supplier_id,
// supplier_invoice_number) is a recoverable conflict — the user
// already registered this invoice (often manually, then tried to
// convert the same inbox document). Mirror the main
// /api/supplier-invoices route and return a friendly 409 with the
// existing invoice, instead of letting the raw Postgres message
// surface as a generic 500 ("Ett oväntat serverfel uppstod").
const pgErr = invoiceError as { code?: string; message?: string } | null
const isDuplicateNumber =
pgErr?.code === '23505' &&
(pgErr.message || '').includes('idx_supplier_invoices_company_supplier_number')
if (isDuplicateNumber) {
// Tenancy: ctx.supabase is the cookie-scoped RLS client and the
// supplier_invoices SELECT policy is
// `company_id IN (SELECT user_company_ids())`. Combined with the
// explicit company_id filter below, this lookup can only ever
// resolve an invoice the caller's own company owns — the returned
// details are never cross-tenant (OWASP ASVS V8.2.1; ISO 27001
// A.8.3; GDPR art.25(2)).
const { data: existing } = await ctx.supabase
.from('supplier_invoices')
.select('id, supplier_invoice_number, status')
.eq('company_id', ctx.companyId)
.eq('supplier_id', body.supplier_id)
.eq('supplier_invoice_number', body.supplier_invoice_number)
.maybeSingle()
let creditNoteId: string | null = null
if (existing?.status === 'credited') {
const { data: creditNote } = await ctx.supabase
.from('supplier_invoices')
.select('id')
.eq('company_id', ctx.companyId)
.eq('credited_invoice_id', existing.id)
.eq('is_credit_note', true)
.maybeSingle()
creditNoteId = creditNote?.id ?? null
}
// Return ONLY server-authoritative fields the recovery dialog needs
// (the existing row, read under RLS). The raw request body
// (supplier_id / supplier_invoice_number) is deliberately not
// echoed back: the client already holds it from its own form state,
// and reflecting user-supplied values widens the response surface
// for no benefit (GDPR art.5(1)(c) data minimisation; OWASP ASVS
// V4.5). The Postgres constraint name is used only to classify the
// error above and is never placed in the response.
return errorResponseFromCode('SI_CREATE_DUPLICATE_INVOICE_NUMBER', ctx.log, {
details: {
existing: existing
? {
id: existing.id,
supplier_invoice_number: existing.supplier_invoice_number,
status: existing.status,
credit_note_id: creditNoteId,
}
: null,
},
})
}
return NextResponse.json({ error: invoiceError?.message || 'Failed to create invoice' }, { status: 500 })
}