feat(invoices): add Peppol XML export foundation (#1585)
This commit is contained in:
@@ -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>')
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user