feat(invoices): add Peppol XML export foundation (#1585)
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, use } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -118,6 +118,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
const t = useTranslations('invoice_detail')
|
||||
const locale = useLocale()
|
||||
|
||||
const [invoice, setInvoice] = useState<InvoiceWithRelations | null>(null)
|
||||
const [reminders, setReminders] = useState<InvoiceReminder[]>([])
|
||||
@@ -158,6 +159,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isUpdating, setIsUpdating] = useState(false)
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const [isDownloadingPeppol, setIsDownloadingPeppol] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [showFinalizeDialog, setShowFinalizeDialog] = useState(false)
|
||||
@@ -582,6 +584,50 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
)
|
||||
}
|
||||
|
||||
async function downloadPeppolXml() {
|
||||
if (!invoice) return
|
||||
setIsDownloadingPeppol(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/invoices/${invoice.id}/peppol`)
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null) as {
|
||||
error?: { code?: string; message?: string; message_en?: string }
|
||||
} | null
|
||||
throw body?.error ?? new Error(t('peppol_download_failed_description'))
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = contentDispositionFilename(response.headers.get('Content-Disposition'))
|
||||
?? `peppol-invoice-${invoice.invoice_number ?? invoice.id}.xml`
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(anchor)
|
||||
|
||||
toast({
|
||||
title: t('peppol_downloaded_title'),
|
||||
description: t('peppol_downloaded_description'),
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('peppol_download_failed_title'),
|
||||
description: error instanceof Error
|
||||
? getUserErrorMessage(error, { locale: locale.startsWith('sv') ? 'sv' : 'en' })
|
||||
: getUserErrorMessage(error, {
|
||||
context: 'invoice',
|
||||
locale: locale.startsWith('sv') ? 'sv' : 'en',
|
||||
}),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsDownloadingPeppol(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show one specific document in the browser instead of saving it (#1190):
|
||||
* granskning should not require leaving the app for the Downloads folder.
|
||||
@@ -1024,6 +1070,20 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!isSelfBilled && isRealInvoice && !isCreditNote && invoice.invoice_number && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={downloadPeppolXml}
|
||||
disabled={isDownloadingPeppol}
|
||||
>
|
||||
{isDownloadingPeppol ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('download_peppol_xml')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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