feat(invoices): add Peppol delivery foundation (#1595)

* feat(invoices): add Peppol delivery foundation

* fix(invoices): harden Peppol compliance guards

* fix(api): narrow Peppol document loading

* test(pg): hash Peppol fixture payload

* fix(invoices): address Peppol review findings

* test(pg): isolate Peppol provider events

* test(pg): isolate Peppol submission fixtures
This commit is contained in:
Mattsson
2026-08-13 19:44:32 +02:00
committed by GitHub
parent 05380ddf54
commit 07e89d9b52
25 changed files with 2376 additions and 84 deletions
@@ -22,7 +22,11 @@ vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { GET } from '../route'
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { GET, POST } from '../route'
const INVOICE_ID = '11111111-1111-4111-8111-111111111111'
const user = { id: 'user-1', email: 'owner@example.test' }
@@ -153,3 +157,118 @@ describe('GET /api/invoices/[id]/peppol', () => {
expect(xml).toContain('<cbc:PayableAmount currencyID="SEK">125.00</cbc:PayableAmount>')
})
})
describe('POST /api/invoices/[id]/peppol', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
delete process.env.PEPPOL_TRANSPORT_PROVIDER
requireAuthMock.mockResolvedValue({ user, supabase: mockSupabase, error: null })
})
it('returns 401 when the caller is not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await POST(
createMockRequest(`/api/invoices/${INVOICE_ID}/peppol`, { method: 'POST' }),
createMockRouteParams({ id: INVOICE_ID }),
)
expect(response.status).toBe(401)
})
it('returns 400 for an invalid invoice id', async () => {
const response = await POST(
createMockRequest('/api/invoices/not-a-uuid/peppol', { method: 'POST' }),
createMockRouteParams({ id: 'not-a-uuid' }),
)
expect(response.status).toBe(400)
expect((await response.json()).error.code).toBe('VALIDATION_ERROR')
})
it('returns 404 when the invoice does not exist in the active company', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const response = await POST(
createMockRequest(`/api/invoices/${INVOICE_ID}/peppol`, { method: 'POST' }),
createMockRouteParams({ id: INVOICE_ID }),
)
expect(response.status).toBe(404)
expect((await response.json()).error.code).toBe('INVOICE_NOT_FOUND')
})
it('stages the exact XML but truthfully reports that nothing was sent', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
enqueue({
data: {
id: '22222222-2222-4222-8222-222222222222',
invoice_id: INVOICE_ID,
idempotency_key: '33333333-3333-4333-8333-333333333333',
xml_sha256: 'a'.repeat(64),
status: 'staged',
filename: 'peppol-invoice-F-2026-42.xml',
created_at: '2026-08-13T16:00:00.000Z',
},
error: null,
})
const response = await POST(
createMockRequest(`/api/invoices/${INVOICE_ID}/peppol`, { method: 'POST' }),
createMockRouteParams({ id: INVOICE_ID }),
)
const body = await response.json()
expect(response.status).toBe(201)
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(body.data).toMatchObject({
status: 'staged',
network_submitted: false,
transport: {
available: false,
provider: null,
reason: 'provider_selection_required',
},
})
expect(mockSupabase.rpc).toHaveBeenCalledWith(
'stage_peppol_delivery',
expect.objectContaining({
p_company_id: 'company-1',
p_invoice_id: INVOICE_ID,
p_recipient_scheme: '0007',
p_recipient_identifier: '5566778899',
p_xml_payload: expect.stringContaining('<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>'),
p_xml_sha256: expect.stringMatching(/^[0-9a-f]{64}$/),
}),
)
})
it.each([
['42501', 403, 'FORBIDDEN'],
['P0002', 404, 'NOT_FOUND'],
])('preserves staging SQLSTATE %s as an expected API response', async (
code,
expectedStatus,
expectedCode,
) => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
enqueue({ data: null, error: { code, message: 'staging rejected' } })
const response = await POST(
createMockRequest(`/api/invoices/${INVOICE_ID}/peppol`, { method: 'POST' }),
createMockRouteParams({ id: INVOICE_ID }),
)
const body = await response.json()
expect(response.status).toBe(expectedStatus)
expect(body.error.code).toBe(expectedCode)
expect(body.error.details).toMatchObject({ pgCode: code })
})
})
@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
createMockRouteParams,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { GET } from '../route'
const INVOICE_ID = '11111111-1111-4111-8111-111111111111'
const user = { id: 'user-1', email: 'owner@example.test' }
describe('GET /api/invoices/[id]/peppol/deliveries', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
delete process.env.PEPPOL_TRANSPORT_PROVIDER
requireAuthMock.mockResolvedValue({ user, supabase: mockSupabase, error: null })
})
it('returns 401 when the caller is not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await GET(
createMockRequest(`/api/invoices/${INVOICE_ID}/peppol/deliveries`),
createMockRouteParams({ id: INVOICE_ID }),
)
expect(response.status).toBe(401)
})
it('returns 400 for an invalid invoice id', async () => {
const response = await GET(
createMockRequest('/api/invoices/not-a-uuid/peppol/deliveries'),
createMockRouteParams({ id: 'not-a-uuid' }),
)
expect(response.status).toBe(400)
expect((await response.json()).error.code).toBe('VALIDATION_ERROR')
})
it('returns 404 for an invoice outside the active company', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const response = await GET(
createMockRequest(`/api/invoices/${INVOICE_ID}/peppol/deliveries`),
createMockRouteParams({ id: INVOICE_ID }),
)
expect(response.status).toBe(404)
expect((await response.json()).error.code).toBe('INVOICE_NOT_FOUND')
})
it('returns minimized delivery status and a truthful transport gate', async () => {
enqueue({ data: { id: INVOICE_ID }, error: null })
enqueue({
data: [{
id: '22222222-2222-4222-8222-222222222222',
status: 'transport_succeeded',
provider: 'storecove',
xml_sha256: 'a'.repeat(64),
}],
error: null,
})
const response = await GET(
createMockRequest(`/api/invoices/${INVOICE_ID}/peppol/deliveries`),
createMockRouteParams({ id: INVOICE_ID }),
)
const body = await response.json()
expect(response.status).toBe(200)
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(body.data).toEqual([
expect.objectContaining({ status: 'transport_succeeded', provider: 'storecove' }),
])
expect(body.transport).toEqual({
available: false,
provider: null,
reason: 'provider_selection_required',
})
})
})
@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { privateNoStore } from '@/lib/api/private-no-store'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { listPeppolDeliverySummaries } from '@/lib/invoices/peppol-delivery'
import { getPeppolTransportAvailability } from '@/lib/invoices/peppol-transport'
const paramsSchema = z.object({ id: z.uuid() })
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.peppol.deliveries.list',
async (_request, { supabase, companyId, log, requestId }, { params }) => {
const parsedParams = paramsSchema.safeParse(await params)
if (!parsedParams.success) {
return privateNoStore(errorResponseFromCode('VALIDATION_ERROR', log, {
requestId,
details: { fields: parsedParams.error.flatten().fieldErrors },
}))
}
const invoiceId = parsedParams.data.id
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('id')
.eq('id', invoiceId)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) {
return privateNoStore(errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId }))
}
try {
const deliveries = await listPeppolDeliverySummaries({
supabase,
companyId,
invoiceId,
})
return privateNoStore(NextResponse.json({
data: deliveries,
transport: getPeppolTransportAvailability(),
}))
} catch (err) {
return privateNoStore(errorResponse(err, log, { requestId }))
}
},
)
+147 -65
View File
@@ -1,16 +1,110 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import type { SupabaseClient } from '@supabase/supabase-js'
import { contentDisposition } from '@/lib/api/content-disposition'
import { privateNoStore } from '@/lib/api/private-no-store'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { generatePeppolBisBillingInvoice } from '@/lib/invoices/peppol-bis-billing'
import { stagePeppolDelivery } from '@/lib/invoices/peppol-delivery'
import { getPeppolTransportAvailability } from '@/lib/invoices/peppol-transport'
import type { CompanySettings, Customer, Invoice, InvoiceItem } from '@/types'
const paramsSchema = z.object({ id: z.uuid() })
type GeneratedPeppolInvoice = Extract<
ReturnType<typeof generatePeppolBisBillingInvoice>,
{ ok: true }
>
type LoadPeppolDocumentResult =
| { ok: true; document: GeneratedPeppolInvoice }
| { ok: false; response: NextResponse }
function privateNoStore(response: NextResponse): NextResponse {
response.headers.set('Cache-Control', 'private, no-store')
return response
async function loadPeppolDocument(args: {
supabase: SupabaseClient
companyId: string
invoiceId: string
log: Parameters<typeof errorResponseFromCode>[1]
requestId: string
}): Promise<LoadPeppolDocumentResult> {
const { data: invoice, error: invoiceError } = await args.supabase
.from('invoices')
.select(`
*,
customer:customers(*),
items:invoice_items(*)
`)
.eq('id', args.invoiceId)
.eq('company_id', args.companyId)
.single()
if (invoiceError || !invoice) {
return {
ok: false,
response: privateNoStore(errorResponseFromCode(
'INVOICE_NOT_FOUND',
args.log,
{ requestId: args.requestId },
)),
}
}
const { data: company, error: companyError } = await args.supabase
.from('company_settings')
.select('*')
.eq('company_id', args.companyId)
.single()
if (companyError || !company) {
return {
ok: false,
response: privateNoStore(errorResponseFromCode(
'INVOICE_SEND_COMPANY_SETTINGS_MISSING',
args.log,
{ requestId: args.requestId },
)),
}
}
const typedInvoice = invoice as Invoice & { customer?: Customer; items?: InvoiceItem[] }
if (!typedInvoice.customer) {
return {
ok: false,
response: privateNoStore(errorResponseFromCode('VALIDATION_ERROR', args.log, {
requestId: args.requestId,
messageSv: 'Fakturan saknar en kund som kan användas för Peppol-export.',
messageEn: 'The invoice has no customer available for Peppol export.',
details: { field: 'invoice.customer' },
})),
}
}
const document = generatePeppolBisBillingInvoice({
invoice: typedInvoice,
customer: typedInvoice.customer,
items: typedInvoice.items ?? [],
company: company as CompanySettings,
})
if (!document.ok) {
const first = document.issues[0]
return {
ok: false,
response: privateNoStore(errorResponseFromCode('VALIDATION_ERROR', args.log, {
requestId: args.requestId,
messageSv: first?.messageSv,
messageEn: first?.messageEn,
details: {
issues: document.issues.map((item) => ({
code: item.code,
field: item.field,
message_sv: item.messageSv,
message_en: item.messageEn,
})),
},
})),
}
}
return { ok: true, document }
}
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
@@ -25,67 +119,9 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
}
const { id } = parsedParams.data
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select(`
*,
customer:customers(*),
items:invoice_items(*)
`)
.eq('id', id)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) {
return privateNoStore(errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId }))
}
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('company_id', companyId)
.single()
if (companyError || !company) {
return privateNoStore(errorResponseFromCode(
'INVOICE_SEND_COMPANY_SETTINGS_MISSING',
log,
{ requestId },
))
}
const typedInvoice = invoice as Invoice & { customer?: Customer; items?: InvoiceItem[] }
if (!typedInvoice.customer) {
return privateNoStore(errorResponseFromCode('VALIDATION_ERROR', log, {
requestId,
messageSv: 'Fakturan saknar en kund som kan användas för Peppol-export.',
messageEn: 'The invoice has no customer available for Peppol export.',
details: { field: 'invoice.customer' },
}))
}
const result = generatePeppolBisBillingInvoice({
invoice: typedInvoice,
customer: typedInvoice.customer,
items: typedInvoice.items ?? [],
company: company as CompanySettings,
})
if (!result.ok) {
const first = result.issues[0]
return privateNoStore(errorResponseFromCode('VALIDATION_ERROR', log, {
requestId,
messageSv: first?.messageSv,
messageEn: first?.messageEn,
details: {
issues: result.issues.map((item) => ({
code: item.code,
field: item.field,
message_sv: item.messageSv,
message_en: item.messageEn,
})),
},
}))
}
const loaded = await loadPeppolDocument({ supabase, companyId, invoiceId: id, log, requestId })
if (!loaded.ok) return loaded.response
const result = loaded.document
return new NextResponse(result.xml, {
status: 200,
@@ -98,3 +134,49 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
})
},
)
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.peppol.stage',
async (_request, { supabase, companyId, log, requestId }, { params }) => {
const parsedParams = paramsSchema.safeParse(await params)
if (!parsedParams.success) {
return privateNoStore(errorResponseFromCode('VALIDATION_ERROR', log, {
requestId,
details: { fields: parsedParams.error.flatten().fieldErrors },
}))
}
const invoiceId = parsedParams.data.id
const loaded = await loadPeppolDocument({
supabase,
companyId,
invoiceId,
log,
requestId,
})
if (!loaded.ok) return loaded.response
try {
const delivery = await stagePeppolDelivery({
supabase,
companyId,
invoiceId,
document: loaded.document,
})
return privateNoStore(NextResponse.json({
data: {
id: delivery.id,
idempotency_key: delivery.idempotency_key,
xml_sha256: delivery.xml_sha256,
status: delivery.status,
created_at: delivery.created_at,
network_submitted: false,
transport: getPeppolTransportAvailability(),
},
}, { status: 201 }))
} catch (err) {
return privateNoStore(errorResponse(err, log, { requestId }))
}
},
{ requireWrite: true },
)