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:
@@ -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 }))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user