feat(invoices): add Peppol XML export foundation (#1585)

This commit is contained in:
Mattsson
2026-08-13 16:17:00 +02:00
committed by GitHub
parent 6c64dd2312
commit 22eaab82e5
9 changed files with 1289 additions and 1 deletions
@@ -0,0 +1,155 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import { contentDispositionFilename } from '@/lib/api/content-disposition'
import {
createMockRequest,
createMockRouteParams,
createQueuedMockSupabase,
makeCompanySettings,
makeCustomer,
makeInvoice,
} from '@/tests/helpers'
import type { InvoiceItem } from '@/types'
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' }
const customer = makeCustomer({
name: 'Kund AB',
org_number: '556677-8899',
vat_number: 'SE556677889901',
})
const company = makeCompanySettings({
company_name: 'Säljare AB',
entity_type: 'aktiebolag',
org_number: '556016-0680',
vat_number: 'SE556016068001',
bankgiro: '991-2346',
})
const item: InvoiceItem = {
id: 'item-1',
invoice_id: INVOICE_ID,
sort_order: 0,
line_type: 'product',
description: 'Rådgivning',
quantity: 1,
unit: 'tim',
unit_price: 100,
line_total: 100,
vat_rate: 25,
vat_amount: 25,
}
const invoice = makeInvoice({
id: INVOICE_ID,
invoice_number: 'F-2026-42',
invoice_date: '2026-08-13',
due_date: '2026-09-12',
status: 'sent',
subtotal: 100,
vat_amount: 25,
total: 125,
remaining_amount: 125,
vat_treatment: 'standard_25',
your_reference: 'KST-100',
customer,
items: [item],
})
describe('GET /api/invoices/[id]/peppol', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
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`),
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'),
createMockRouteParams({ id: 'not-a-uuid' }),
)
const body = await response.json()
expect(response.status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
})
it('returns 404 when the invoice does not exist in the active company', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const response = await GET(
createMockRequest(`/api/invoices/${INVOICE_ID}/peppol`),
createMockRouteParams({ id: INVOICE_ID }),
)
const body = await response.json()
expect(response.status).toBe(404)
expect(body.error.code).toBe('INVOICE_NOT_FOUND')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
})
it('returns a standards preflight error without producing partial XML', async () => {
enqueue({ data: { ...invoice, your_reference: null }, error: null })
enqueue({ data: company, error: null })
const response = await GET(
createMockRequest(`/api/invoices/${INVOICE_ID}/peppol`),
createMockRouteParams({ id: INVOICE_ID }),
)
const body = await response.json()
expect(response.status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details.issues).toEqual(expect.arrayContaining([
expect.objectContaining({ code: 'BUYER_REFERENCE_REQUIRED' }),
]))
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
})
it('downloads a valid Peppol BIS Billing XML document', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
const response = await GET(
createMockRequest(`/api/invoices/${INVOICE_ID}/peppol`),
createMockRouteParams({ id: INVOICE_ID }),
)
const xml = await response.text()
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('application/xml; charset=utf-8')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
expect(contentDispositionFilename(response.headers.get('Content-Disposition')))
.toBe('peppol-invoice-F-2026-42.xml')
expect(xml).toContain('<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>')
expect(xml).toContain('<cbc:PayableAmount currencyID="SEK">125.00</cbc:PayableAmount>')
})
})
+100
View File
@@ -0,0 +1,100 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { contentDisposition } from '@/lib/api/content-disposition'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { generatePeppolBisBillingInvoice } from '@/lib/invoices/peppol-bis-billing'
import type { CompanySettings, Customer, Invoice, InvoiceItem } from '@/types'
const paramsSchema = z.object({ id: z.uuid() })
function privateNoStore(response: NextResponse): NextResponse {
response.headers.set('Cache-Control', 'private, no-store')
return response
}
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.peppol',
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 { 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,
})),
},
}))
}
return new NextResponse(result.xml, {
status: 200,
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Content-Disposition': contentDisposition('attachment', result.filename),
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff',
},
})
},
)