feat(invoices): add Peppol XML export foundation (#1585)
This commit is contained in:
@@ -920,3 +920,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-13] Tier 1 self-hosting packages the `document-extraction` extension in the stock image: a plain `ANTHROPIC_API_KEY` must cover documents uploaded in the app as well as emailed invoices and the assistant to satisfy issue #1406; this does not add provider abstraction or other Tier 2 work.
|
||||
[2026-08-13] document-extraction's manifest no longer lists AWS_REGION under requiredEnvVars: the extension accepts either AWS static keys or ANTHROPIC_API_KEY, which the manifest schema cannot express as alternatives, and requiredEnvVars only drives a build-time warning rather than gating execution.
|
||||
[2026-08-13] WhatsApp decline observability (#1552) reuses whatsapp_messages with content-free rows for unknown-sender declines instead of a new table or aggregate RPC: no migration (no orphan risk), the wamid unique index gives redelivery dedupe for free (a redelivered bad-code or greeted message no longer earns a second reply), and the existing 30-day unknown-sender retention pass already deletes the rows. Write amplification from an over-quota flood is bounded by a 20-rows-per-hash-per-day trace cap, not by dropping the trail entirely. The settings panel gets a closed event enum derived server-side (lib/last-event.ts), never raw error_message text, so internal errors cannot leak to the client.
|
||||
[2026-08-13] Issue #546 ships a provider-agnostic Peppol BIS Billing 3 XML export with strict Swedish preflight, not a fake send path: certified access-point delivery, SMP lookup, receipts, inbound handling, credentials, and commercial terms depend on Emil selecting and contracting a multitenant provider, and the existing email delivery state cannot truthfully model those guarantees.
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
# Peppol invoice foundation
|
||||
|
||||
Issue #546 requires two separable capabilities:
|
||||
|
||||
1. Produce a correctly structured Peppol BIS Billing 3 invoice from Accounted data.
|
||||
2. Deliver and receive documents through the Peppol network.
|
||||
|
||||
This change implements the first capability for a deliberately constrained Swedish invoice profile. It does not claim network delivery.
|
||||
|
||||
## Implemented scope
|
||||
|
||||
`GET /api/invoices/{id}/peppol` produces a UBL 2.1 invoice with the Peppol BIS Billing 3 CustomizationID and ProfileID. It is available to an authenticated member of the active company and applies the same explicit `company_id` isolation as other invoice routes.
|
||||
|
||||
The export supports:
|
||||
|
||||
- numbered standard sales invoices, not credit notes, self-billing, proformas, or delivery notes;
|
||||
- Swedish limited-company sellers and organization-number buyers identified with scheme `0007`;
|
||||
- SEK invoices with Swedish standard VAT categories at 6, 12, or 25 percent;
|
||||
- Bankgiro or Plusgiro credit transfers using payment means code `30` and an OCR reference;
|
||||
- mixed supported VAT rates, text-line omission, and UNECE unit mappings for Accounted's invoice units;
|
||||
- Accounted's SEK rounding as `PayableRoundingAmount`;
|
||||
- buyer reference, address, VAT, F-tax, payment, totals, and line reconciliation checks;
|
||||
- deterministic UTF-8 XML download from the invoice detail page.
|
||||
|
||||
Unsupported input is rejected with structured, field-addressable errors. The generator never emits partial XML after a failed preflight.
|
||||
|
||||
Sole-trader sellers and personnummer-derived `0007` identifiers are rejected. They require a separately configured `0088` GLN so the export does not publish personal identity data as a Peppol participant identifier.
|
||||
|
||||
The local preflight is not a replacement for the official validation stack. Before network delivery, every document must pass the UBL XSD, EN 16931 Schematron rules, and the Peppol BIS Billing rules for the active release. The selected access-point provider must perform that validation as part of submission. Accounted should also run the same release-pinned artifacts before calling the provider so failures can be explained before transport.
|
||||
|
||||
## Remaining architecture
|
||||
|
||||
### Standards validation
|
||||
|
||||
Pin and execute the official release artifacts from OpenPeppol and the EN 16931 validation artifact registry. The November 2025 release is the active production release at implementation time. The May 2026 release becomes mandatory on 17 August 2026, so provider onboarding and conformance testing must target the May 2026 validator before launch.
|
||||
|
||||
Validation must return stable rule identifiers, severity, source path, and localized remediation. Provider validation responses are useful evidence but must not become Accounted's only explanation layer.
|
||||
|
||||
### Access-point delivery
|
||||
|
||||
Accounted should integrate with a certified Peppol access-point provider. The provider owns AS4 transport, Peppol PKI, service metadata lookup, certificate rotation, and network conformance. Accounted should not implement an access point itself.
|
||||
|
||||
A provider adapter needs at least:
|
||||
|
||||
- recipient capability lookup by scheme and identifier;
|
||||
- invoice submission with an Accounted idempotency key;
|
||||
- a provider document identifier;
|
||||
- synchronous rejection details;
|
||||
- authenticated webhooks or polling for final transport status;
|
||||
- test and production environments with equivalent validation behavior.
|
||||
|
||||
The existing email delivery model cannot honestly represent Peppol receipts. Provider selection should precede the database design because provider status vocabularies and webhook guarantees determine the durable state machine.
|
||||
|
||||
### Status and audit handling
|
||||
|
||||
After provider selection, add a migration and pg-real tests for a Peppol-specific delivery record. It should preserve the exact submitted XML, its SHA-256 hash, recipient scheme and identifier, provider id, attempt timestamps, normalized status, raw receipt metadata, and immutable failure history. A successful API acceptance is not the same as network delivery.
|
||||
|
||||
No invoice status should change merely because XML was generated or accepted by a provider. The send workflow must define exactly which provider receipt constitutes delivery, how retries preserve idempotency, and how a permanent rejection is shown without mutating the posted invoice.
|
||||
|
||||
### Receiving
|
||||
|
||||
Inbound invoices are a separate acceptance slice. It requires provider webhook authentication, raw XML retention, duplicate detection, supplier matching, safe attachment handling, and mapping into the supplier-invoice inbox without treating received content as trusted. Nothing in this foundation claims inbound support.
|
||||
|
||||
### UI and API
|
||||
|
||||
The current UI downloads a locally checked XML file and states that it was not sent. Once delivery exists, sending should be a distinct confirmation flow that performs recipient lookup, shows the discovered recipient, and records the resulting delivery timeline. The download should remain available for diagnosis and interoperability testing.
|
||||
|
||||
### Credentials and commercial decision
|
||||
|
||||
Emil must choose and contract a certified access-point provider before full send or receive can be completed. The decision needs verified answers for:
|
||||
|
||||
- multitenant or reseller authorization for Accounted customer companies;
|
||||
- setup, monthly, per-document, inbound, lookup, and support pricing;
|
||||
- test and production API credentials and secret rotation;
|
||||
- sender-only versus searchable participant registration;
|
||||
- Swedish organization-number and optional GLN onboarding;
|
||||
- webhook signing, retention, service levels, and data-processing terms;
|
||||
- support for the mandatory May 2026 Peppol release.
|
||||
|
||||
Provider credentials and prices are external operational inputs. They are not invented, stored in source, or represented by a fake provider in this change.
|
||||
|
||||
## Primary specifications and authority guidance
|
||||
|
||||
- [OpenPeppol BIS Billing 3, November 2025](https://docs.peppol.eu/poacc/billing/3.0/2025-Q4/)
|
||||
- [OpenPeppol BIS Billing 3, May 2026](https://docs.peppol.eu/poacc/billing/3.0/upcoming/)
|
||||
- [OpenPeppol post-award release schedule](https://peppol.org/documentation/technical-documentation/post-award-documentation/)
|
||||
- [European Commission EN 16931 validation artefacts](https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/Registry+of+supporting+artefacts+to+implement+EN16931)
|
||||
- [SFTI Peppol BIS Billing 3 guidance](https://www.sfti.se/sfti/standarder/peppolbisochpeppolinfrastruktur/peppolbisbilling3.26609.html)
|
||||
- [Swedish authority guidance for connecting to Peppol](https://www.upphandlingsmyndigheten.se/inkopsprocessen/e-handel/peppol/anslut-till-peppol/)
|
||||
@@ -0,0 +1,248 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { makeCompanySettings, makeCustomer, makeInvoice } from '@/tests/helpers'
|
||||
import type { InvoiceItem } from '@/types'
|
||||
import {
|
||||
generatePeppolBisBillingInvoice,
|
||||
PEPPOL_BIS_BILLING_CUSTOMIZATION_ID,
|
||||
PEPPOL_BIS_BILLING_PROFILE_ID,
|
||||
} from '../peppol-bis-billing'
|
||||
|
||||
function makeItem(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
|
||||
return {
|
||||
id: 'item-1',
|
||||
invoice_id: 'invoice-1',
|
||||
sort_order: 0,
|
||||
line_type: 'product',
|
||||
description: 'Rådgivning',
|
||||
quantity: 2,
|
||||
unit: 'tim',
|
||||
unit_price: 100,
|
||||
line_total: 200,
|
||||
vat_rate: 25,
|
||||
vat_amount: 50,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeValidInput() {
|
||||
const customer = makeCustomer({
|
||||
name: 'Kund & Partner AB',
|
||||
org_number: '556677-8899',
|
||||
vat_number: 'SE556677889901',
|
||||
})
|
||||
const company = makeCompanySettings({
|
||||
company_name: 'Säljare <Sverige> AB',
|
||||
entity_type: 'aktiebolag',
|
||||
org_number: '556016-0680',
|
||||
vat_number: 'SE556016068001',
|
||||
bankgiro: '991-2346',
|
||||
ore_rounding: true,
|
||||
})
|
||||
const items = [
|
||||
makeItem(),
|
||||
makeItem({
|
||||
id: 'item-2',
|
||||
sort_order: 1,
|
||||
description: 'Lunch',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 100,
|
||||
line_total: 100,
|
||||
vat_rate: 12,
|
||||
vat_amount: 12,
|
||||
}),
|
||||
]
|
||||
const invoice = makeInvoice({
|
||||
id: 'invoice-1',
|
||||
invoice_number: 'F-2026-42',
|
||||
invoice_date: '2026-08-13',
|
||||
due_date: '2026-09-12',
|
||||
delivery_date: '2026-08-12',
|
||||
status: 'sent',
|
||||
subtotal: 300,
|
||||
vat_amount: 62,
|
||||
total: 362,
|
||||
remaining_amount: 362,
|
||||
vat_treatment: 'standard_25',
|
||||
your_reference: 'REF & 42',
|
||||
notes: 'Tack <igen>',
|
||||
})
|
||||
return { invoice, customer, company, items }
|
||||
}
|
||||
|
||||
describe('generatePeppolBisBillingInvoice', () => {
|
||||
it('generates a Swedish Peppol BIS Billing 3 invoice with reconciled VAT groups', () => {
|
||||
const result = generatePeppolBisBillingInvoice(makeValidInput())
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.filename).toBe('peppol-invoice-F-2026-42.xml')
|
||||
expect(result.xml).toContain(`<cbc:CustomizationID>${PEPPOL_BIS_BILLING_CUSTOMIZATION_ID}</cbc:CustomizationID>`)
|
||||
expect(result.xml).toContain(`<cbc:ProfileID>${PEPPOL_BIS_BILLING_PROFILE_ID}</cbc:ProfileID>`)
|
||||
expect(result.xml).toContain('<cbc:EndpointID schemeID="0007">5560160680</cbc:EndpointID>')
|
||||
expect(result.xml).toContain('<cbc:EndpointID schemeID="0007">5566778899</cbc:EndpointID>')
|
||||
expect(result.xml).toContain('<cbc:CompanyID>Godkänd för F-skatt</cbc:CompanyID>')
|
||||
expect(result.xml).toContain('<cbc:PaymentMeansCode>30</cbc:PaymentMeansCode>')
|
||||
expect(result.xml).toContain('<cbc:ID>SE:BANKGIRO</cbc:ID>')
|
||||
expect(result.xml).toContain('<cbc:TaxableAmount currencyID="SEK">100.00</cbc:TaxableAmount>')
|
||||
expect(result.xml).toContain('<cbc:TaxableAmount currencyID="SEK">200.00</cbc:TaxableAmount>')
|
||||
expect(result.xml).toContain('<cbc:TaxAmount currencyID="SEK">62.00</cbc:TaxAmount>')
|
||||
expect(result.xml).toContain('<cbc:PayableAmount currencyID="SEK">362.00</cbc:PayableAmount>')
|
||||
expect(result.xml).toContain('<cbc:InvoicedQuantity unitCode="HUR">2</cbc:InvoicedQuantity>')
|
||||
})
|
||||
|
||||
it('escapes user-controlled XML values without damaging Swedish text', () => {
|
||||
const result = generatePeppolBisBillingInvoice(makeValidInput())
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.xml).toContain('<cbc:RegistrationName>Säljare <Sverige> AB</cbc:RegistrationName>')
|
||||
expect(result.xml).toContain('<cbc:RegistrationName>Kund & Partner AB</cbc:RegistrationName>')
|
||||
expect(result.xml).toContain('<cbc:BuyerReference>REF & 42</cbc:BuyerReference>')
|
||||
expect(result.xml).toContain('<cbc:Note>Tack <igen></cbc:Note>')
|
||||
})
|
||||
|
||||
it('expresses öresavrundning as PayableRoundingAmount', () => {
|
||||
const input = makeValidInput()
|
||||
input.items = [makeItem({ quantity: 1, unit_price: 80.4, line_total: 80.4, vat_amount: 20.1 })]
|
||||
input.invoice = makeInvoice({
|
||||
...input.invoice,
|
||||
subtotal: 80.4,
|
||||
vat_amount: 20.1,
|
||||
total: 100.5,
|
||||
remaining_amount: 101,
|
||||
})
|
||||
|
||||
const result = generatePeppolBisBillingInvoice(input)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.xml).toContain('<cbc:TaxInclusiveAmount currencyID="SEK">100.50</cbc:TaxInclusiveAmount>')
|
||||
expect(result.xml).toContain('<cbc:PayableRoundingAmount currencyID="SEK">0.50</cbc:PayableRoundingAmount>')
|
||||
expect(result.xml).toContain('<cbc:PayableAmount currencyID="SEK">101.00</cbc:PayableAmount>')
|
||||
})
|
||||
|
||||
it('rejects invoice data outside the supported profile', () => {
|
||||
const input = makeValidInput()
|
||||
input.invoice = makeInvoice({
|
||||
...input.invoice,
|
||||
invoice_number: null,
|
||||
currency: 'EUR',
|
||||
your_reference: null,
|
||||
deduction_total: 10,
|
||||
})
|
||||
input.customer = makeCustomer({ ...input.customer, customer_type: 'individual' })
|
||||
input.company = makeCompanySettings({ ...input.company, bankgiro: null })
|
||||
input.items = [makeItem({ unit: 'paket', vat_rate: 0, vat_amount: 0 })]
|
||||
|
||||
const result = generatePeppolBisBillingInvoice(input)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.issues.map(({ code }) => code)).toEqual(expect.arrayContaining([
|
||||
'INVOICE_NUMBER_REQUIRED',
|
||||
'CURRENCY_UNSUPPORTED',
|
||||
'BUYER_REFERENCE_REQUIRED',
|
||||
'DEDUCTION_UNSUPPORTED',
|
||||
'BUYER_TYPE_UNSUPPORTED',
|
||||
'PAYMENT_ACCOUNT_REQUIRED',
|
||||
'UNIT_UNSUPPORTED',
|
||||
'VAT_RATE_UNSUPPORTED',
|
||||
]))
|
||||
})
|
||||
|
||||
it('rejects VAT rounding that conflicts with EN 16931 category rounding', () => {
|
||||
const input = makeValidInput()
|
||||
input.items = [
|
||||
makeItem({ id: 'item-1', quantity: 1, unit_price: 0.01, line_total: 0.01, vat_amount: 0 }),
|
||||
makeItem({ id: 'item-2', sort_order: 1, quantity: 1, unit_price: 0.01, line_total: 0.01, vat_amount: 0 }),
|
||||
]
|
||||
input.invoice = makeInvoice({
|
||||
...input.invoice,
|
||||
subtotal: 0.02,
|
||||
vat_amount: 0,
|
||||
total: 0.02,
|
||||
remaining_amount: 0.02,
|
||||
ore_rounding: false,
|
||||
})
|
||||
|
||||
const result = generatePeppolBisBillingInvoice(input)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.issues.map(({ code }) => code)).toContain('VAT_ROUNDING_MISMATCH')
|
||||
})
|
||||
|
||||
it('rejects invoice totals that do not reconcile to the emitted lines', () => {
|
||||
const input = makeValidInput()
|
||||
input.invoice = makeInvoice({ ...input.invoice, total: 999 })
|
||||
|
||||
const result = generatePeppolBisBillingInvoice(input)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.issues.map(({ code }) => code)).toContain('INVOICE_TOTALS_MISMATCH')
|
||||
})
|
||||
|
||||
it('requires the seller registered VAT identifier instead of deriving one', () => {
|
||||
const input = makeValidInput()
|
||||
input.company = makeCompanySettings({ ...input.company, vat_number: null })
|
||||
|
||||
const result = generatePeppolBisBillingInvoice(input)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.issues.map(({ code }) => code)).toContain('SUPPLIER_VAT_NUMBER_REQUIRED')
|
||||
})
|
||||
|
||||
it('validates a Swedish VAT identifier without assuming it is derived from the org number', () => {
|
||||
const input = makeValidInput()
|
||||
input.company = makeCompanySettings({ ...input.company, vat_number: 'SE123456789012' })
|
||||
|
||||
const result = generatePeppolBisBillingInvoice(input)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.xml).toContain('<cbc:CompanyID>SE123456789012</cbc:CompanyID>')
|
||||
})
|
||||
|
||||
it('rejects a malformed Swedish seller VAT identifier', () => {
|
||||
const input = makeValidInput()
|
||||
input.company = makeCompanySettings({ ...input.company, vat_number: 'SE5560160680' })
|
||||
|
||||
const result = generatePeppolBisBillingInvoice(input)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.issues.map(({ code }) => code)).toContain('SUPPLIER_VAT_NUMBER_INVALID')
|
||||
})
|
||||
|
||||
it('accepts the 12-digit organization-number form used by Swedish systems', () => {
|
||||
const input = makeValidInput()
|
||||
input.company = makeCompanySettings({ ...input.company, org_number: '165560160680' })
|
||||
|
||||
const result = generatePeppolBisBillingInvoice(input)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.xml).toContain('<cbc:EndpointID schemeID="0007">5560160680</cbc:EndpointID>')
|
||||
})
|
||||
|
||||
it('rejects a sole trader until a non-personal GLN participant ID can be configured', () => {
|
||||
const input = makeValidInput()
|
||||
input.company = makeCompanySettings({
|
||||
...input.company,
|
||||
entity_type: 'enskild_firma',
|
||||
org_number: '800101-1231',
|
||||
})
|
||||
|
||||
const result = generatePeppolBisBillingInvoice(input)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.issues.map(({ code }) => code)).toEqual(expect.arrayContaining([
|
||||
'SUPPLIER_ENTITY_TYPE_UNSUPPORTED',
|
||||
'SUPPLIER_PARTICIPANT_IDENTIFIER_UNSUPPORTED',
|
||||
]))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,625 @@
|
||||
import {
|
||||
generateOcrReference,
|
||||
validateBankgiroNumber,
|
||||
validatePlusgiroNumber,
|
||||
} from '@/lib/bankgiro/luhn'
|
||||
import { isSaneDateString, normalizeOrgNumber } from '@/lib/invariants'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import { equalOre, roundOre } from '@/lib/money'
|
||||
import type { CompanySettings, Customer, Invoice, InvoiceItem } from '@/types'
|
||||
|
||||
export const PEPPOL_BIS_BILLING_CUSTOMIZATION_ID =
|
||||
'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0'
|
||||
export const PEPPOL_BIS_BILLING_PROFILE_ID =
|
||||
'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0'
|
||||
|
||||
const SUPPORTED_VAT_RATES = new Set([6, 12, 25])
|
||||
const UNIT_CODES: Record<string, string> = {
|
||||
st: 'EA',
|
||||
tim: 'HUR',
|
||||
dag: 'DAY',
|
||||
'månad': 'MON',
|
||||
km: 'KMT',
|
||||
kg: 'KGM',
|
||||
}
|
||||
|
||||
export interface PeppolValidationIssue {
|
||||
code: string
|
||||
field: string
|
||||
messageSv: string
|
||||
messageEn: string
|
||||
}
|
||||
|
||||
export interface PeppolInvoiceInput {
|
||||
invoice: Invoice
|
||||
customer: Customer
|
||||
items: InvoiceItem[]
|
||||
company: CompanySettings
|
||||
}
|
||||
|
||||
export type PeppolInvoiceResult =
|
||||
| { ok: true; xml: string; filename: string }
|
||||
| { ok: false; issues: PeppolValidationIssue[] }
|
||||
|
||||
interface PreparedParty {
|
||||
name: string
|
||||
orgNumber: string
|
||||
vatNumber: string | null
|
||||
addressLine1: string
|
||||
addressLine2: string | null
|
||||
postalCode: string
|
||||
city: string
|
||||
email: string | null
|
||||
phone: string | null
|
||||
}
|
||||
|
||||
interface PreparedInvoice {
|
||||
supplier: PreparedParty
|
||||
buyer: PreparedParty
|
||||
payment: {
|
||||
accountId: string
|
||||
branchId: 'SE:BANKGIRO' | 'SE:PLUSGIRO'
|
||||
paymentId: string
|
||||
}
|
||||
productItems: InvoiceItem[]
|
||||
taxBreakdown: Array<{ rate: number; taxableAmount: number; taxAmount: number }>
|
||||
payableAmount: number
|
||||
roundingAmount: number
|
||||
}
|
||||
|
||||
function validationIssue(
|
||||
code: string,
|
||||
field: string,
|
||||
messageSv: string,
|
||||
messageEn: string,
|
||||
): PeppolValidationIssue {
|
||||
return { code, field, messageSv, messageEn }
|
||||
}
|
||||
|
||||
function roundMoney(value: number): number {
|
||||
return roundOre(value)
|
||||
}
|
||||
|
||||
function equalMoney(left: number, right: number): boolean {
|
||||
return equalOre(left, right)
|
||||
}
|
||||
|
||||
function formatMoney(value: number): string {
|
||||
const cents = Math.round(roundOre(value) * 100)
|
||||
const sign = cents < 0 ? '-' : ''
|
||||
const absolute = Math.abs(cents)
|
||||
return `${sign}${Math.floor(absolute / 100)}.${String(absolute % 100).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatDecimal(value: number): string {
|
||||
const precision = 1_000_000
|
||||
const scaled = Math.round(value * precision)
|
||||
const sign = scaled < 0 ? '-' : ''
|
||||
const absolute = Math.abs(scaled)
|
||||
const whole = Math.floor(absolute / precision)
|
||||
const fraction = String(absolute % precision).padStart(6, '0').replace(/0+$/, '')
|
||||
return fraction ? `${sign}${whole}.${fraction}` : `${sign}${whole}`
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function hasText(value: string | null | undefined): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
}
|
||||
|
||||
function normalizeOrganizationNumber(value: string | null): string | null {
|
||||
return normalizeOrgNumber(value)
|
||||
}
|
||||
|
||||
function normalizeVatNumber(value: string | null): string | null {
|
||||
return value?.replace(/[\s-]/g, '').toUpperCase() || null
|
||||
}
|
||||
|
||||
function isIsoDate(value: string | null): value is string {
|
||||
return Boolean(value && isSaneDateString(value))
|
||||
}
|
||||
|
||||
function prepareParty(
|
||||
role: 'supplier' | 'buyer',
|
||||
source: {
|
||||
name: string | null
|
||||
orgNumber: string | null
|
||||
vatNumber: string | null
|
||||
addressLine1: string | null
|
||||
addressLine2: string | null
|
||||
postalCode: string | null
|
||||
city: string | null
|
||||
country: string | null
|
||||
email: string | null
|
||||
phone: string | null
|
||||
},
|
||||
vatRequired: boolean,
|
||||
issues: PeppolValidationIssue[],
|
||||
): PreparedParty | null {
|
||||
const prefixSv = role === 'supplier' ? 'Säljaren' : 'Köparen'
|
||||
const prefixEn = role === 'supplier' ? 'The seller' : 'The buyer'
|
||||
const fieldPrefix = role === 'supplier' ? 'company' : 'customer'
|
||||
const orgNumber = normalizeOrganizationNumber(source.orgNumber)
|
||||
|
||||
if (!hasText(source.name)) {
|
||||
issues.push(validationIssue(
|
||||
`${role.toUpperCase()}_NAME_REQUIRED`, `${fieldPrefix}.name`,
|
||||
`${prefixSv} måste ha ett namn.`, `${prefixEn} must have a name.`,
|
||||
))
|
||||
}
|
||||
if (!orgNumber) {
|
||||
issues.push(validationIssue(
|
||||
`${role.toUpperCase()}_ORG_NUMBER_INVALID`, `${fieldPrefix}.org_number`,
|
||||
`${prefixSv} måste ha ett giltigt svenskt organisationsnummer.`,
|
||||
`${prefixEn} must have a valid Swedish organization number.`,
|
||||
))
|
||||
}
|
||||
if (orgNumber && Number(orgNumber[2]) < 2) {
|
||||
issues.push(validationIssue(
|
||||
`${role.toUpperCase()}_PARTICIPANT_IDENTIFIER_UNSUPPORTED`, `${fieldPrefix}.org_number`,
|
||||
`${prefixSv} måste använda ett organisationsnummer för 0007. Personnummerbaserade identifierare kräver GLN-stöd.`,
|
||||
`${prefixEn} must use an organization number for scheme 0007. Personal-identity-based identifiers require GLN support.`,
|
||||
))
|
||||
}
|
||||
if (!hasText(source.addressLine1) || !hasText(source.postalCode) || !hasText(source.city)) {
|
||||
issues.push(validationIssue(
|
||||
`${role.toUpperCase()}_ADDRESS_REQUIRED`, `${fieldPrefix}.address`,
|
||||
`${prefixSv} måste ha gatuadress, postnummer och ort.`,
|
||||
`${prefixEn} must have a street address, postal code, and city.`,
|
||||
))
|
||||
}
|
||||
if (source.country?.toUpperCase() !== 'SE') {
|
||||
issues.push(validationIssue(
|
||||
`${role.toUpperCase()}_COUNTRY_UNSUPPORTED`, `${fieldPrefix}.country`,
|
||||
`${prefixSv} måste ha Sverige som land för denna Peppol-export.`,
|
||||
`${prefixEn} must be located in Sweden for this Peppol export.`,
|
||||
))
|
||||
}
|
||||
|
||||
if (!orgNumber || Number(orgNumber[2]) < 2 || !hasText(source.name) || !hasText(source.addressLine1) ||
|
||||
!hasText(source.postalCode) || !hasText(source.city) || source.country?.toUpperCase() !== 'SE') {
|
||||
return null
|
||||
}
|
||||
|
||||
const vatNumber = normalizeVatNumber(source.vatNumber)
|
||||
if (vatRequired && !vatNumber) {
|
||||
issues.push(validationIssue(
|
||||
`${role.toUpperCase()}_VAT_NUMBER_REQUIRED`, `${fieldPrefix}.vat_number`,
|
||||
`${prefixSv} måste ha sitt registrerade momsregistreringsnummer.`,
|
||||
`${prefixEn} must provide its registered VAT identifier.`,
|
||||
))
|
||||
return null
|
||||
}
|
||||
if (vatNumber && !/^SE\d{12}$/.test(vatNumber)) {
|
||||
issues.push(validationIssue(
|
||||
`${role.toUpperCase()}_VAT_NUMBER_INVALID`, `${fieldPrefix}.vat_number`,
|
||||
`${prefixSv} måste ha ett svenskt momsregistreringsnummer med SE följt av 12 siffror.`,
|
||||
`${prefixEn} must have a Swedish VAT identifier with SE followed by 12 digits.`,
|
||||
))
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
name: source.name.trim(),
|
||||
orgNumber,
|
||||
vatNumber,
|
||||
addressLine1: source.addressLine1.trim(),
|
||||
addressLine2: source.addressLine2?.trim() || null,
|
||||
postalCode: source.postalCode.trim(),
|
||||
city: source.city.trim(),
|
||||
email: source.email?.trim() || null,
|
||||
phone: source.phone?.trim() || null,
|
||||
}
|
||||
}
|
||||
|
||||
function prepareInvoice(input: PeppolInvoiceInput):
|
||||
| { prepared: PreparedInvoice; issues: [] }
|
||||
| { prepared: null; issues: PeppolValidationIssue[] } {
|
||||
const { invoice, customer, company } = input
|
||||
const issues: PeppolValidationIssue[] = []
|
||||
|
||||
if (invoice.document_type !== 'invoice' || invoice.credited_invoice_id || invoice.is_self_billed) {
|
||||
issues.push(validationIssue(
|
||||
'DOCUMENT_TYPE_UNSUPPORTED', 'invoice.document_type',
|
||||
'Endast vanliga kundfakturor kan exporteras i detta Peppol-format.',
|
||||
'Only standard customer invoices can be exported in this Peppol format.',
|
||||
))
|
||||
}
|
||||
if (!hasText(invoice.invoice_number)) {
|
||||
issues.push(validationIssue(
|
||||
'INVOICE_NUMBER_REQUIRED', 'invoice.invoice_number',
|
||||
'Fakturan måste vara slutförd och ha ett fakturanummer före export.',
|
||||
'The invoice must be finalized and have an invoice number before export.',
|
||||
))
|
||||
}
|
||||
if (invoice.status === 'cancelled') {
|
||||
issues.push(validationIssue(
|
||||
'CANCELLED_INVOICE_UNSUPPORTED', 'invoice.status',
|
||||
'En makulerad faktura kan inte exporteras till Peppol.',
|
||||
'A cancelled invoice cannot be exported to Peppol.',
|
||||
))
|
||||
}
|
||||
if (invoice.currency !== 'SEK') {
|
||||
issues.push(validationIssue(
|
||||
'CURRENCY_UNSUPPORTED', 'invoice.currency',
|
||||
'Denna Peppol-export stöder endast svenska fakturor i SEK.',
|
||||
'This Peppol export supports Swedish invoices in SEK only.',
|
||||
))
|
||||
}
|
||||
if (!isIsoDate(invoice.invoice_date) || !isIsoDate(invoice.due_date) ||
|
||||
(invoice.delivery_date !== null && !isIsoDate(invoice.delivery_date))) {
|
||||
issues.push(validationIssue(
|
||||
'DATE_INVALID', 'invoice.dates',
|
||||
'Fakturadatum, förfallodatum och leveransdatum måste vara giltiga datum.',
|
||||
'Invoice, due, and delivery dates must be valid dates.',
|
||||
))
|
||||
}
|
||||
if (!hasText(invoice.your_reference)) {
|
||||
issues.push(validationIssue(
|
||||
'BUYER_REFERENCE_REQUIRED', 'invoice.your_reference',
|
||||
'Er referens krävs för Peppol när inköpsordernummer saknas.',
|
||||
'Buyer reference is required for Peppol when no purchase order reference is available.',
|
||||
))
|
||||
}
|
||||
if ((invoice.deduction_total ?? 0) !== 0) {
|
||||
issues.push(validationIssue(
|
||||
'DEDUCTION_UNSUPPORTED', 'invoice.deduction_total',
|
||||
'ROT- och RUT-avdrag stöds ännu inte av Peppol-exporten.',
|
||||
'ROT and RUT deductions are not yet supported by the Peppol export.',
|
||||
))
|
||||
}
|
||||
if (!company.vat_registered || !['standard_25', 'reduced_12', 'reduced_6'].includes(invoice.vat_treatment)) {
|
||||
issues.push(validationIssue(
|
||||
'VAT_TREATMENT_UNSUPPORTED', 'invoice.vat_treatment',
|
||||
'Exporten stöder endast momspliktig svensk försäljning med 6, 12 eller 25 procent moms.',
|
||||
'The export supports taxable Swedish sales with 6, 12, or 25 percent VAT only.',
|
||||
))
|
||||
}
|
||||
if (customer.customer_type !== 'swedish_business') {
|
||||
issues.push(validationIssue(
|
||||
'BUYER_TYPE_UNSUPPORTED', 'customer.customer_type',
|
||||
'Kunden måste vara ett svenskt företag eller en svensk organisation.',
|
||||
'The customer must be a Swedish business or organization.',
|
||||
))
|
||||
}
|
||||
if (company.entity_type !== 'aktiebolag') {
|
||||
issues.push(validationIssue(
|
||||
'SUPPLIER_ENTITY_TYPE_UNSUPPORTED', 'company.entity_type',
|
||||
'Enskild firma kräver ett separat GLN som Peppol-identifierare. Exporten stöder därför endast aktiebolag tills GLN kan konfigureras.',
|
||||
'A sole trader requires a separate GLN as its Peppol identifier. This export therefore supports limited companies only until GLN can be configured.',
|
||||
))
|
||||
}
|
||||
|
||||
const supplier = prepareParty('supplier', {
|
||||
name: company.company_name,
|
||||
orgNumber: company.org_number,
|
||||
vatNumber: company.vat_number,
|
||||
addressLine1: company.address_line1,
|
||||
addressLine2: company.address_line2,
|
||||
postalCode: company.postal_code,
|
||||
city: company.city,
|
||||
country: company.country,
|
||||
email: company.email,
|
||||
phone: company.phone,
|
||||
}, true, issues)
|
||||
const buyer = prepareParty('buyer', {
|
||||
name: customer.name,
|
||||
orgNumber: customer.org_number,
|
||||
vatNumber: customer.vat_number,
|
||||
addressLine1: customer.address_line1,
|
||||
addressLine2: customer.address_line2,
|
||||
postalCode: customer.postal_code,
|
||||
city: customer.city,
|
||||
country: customer.country,
|
||||
email: customer.email,
|
||||
phone: customer.phone,
|
||||
}, false, issues)
|
||||
|
||||
let payment: PreparedInvoice['payment'] | null = null
|
||||
if (hasText(company.bankgiro) && validateBankgiroNumber(company.bankgiro)) {
|
||||
payment = {
|
||||
accountId: company.bankgiro.replace(/\D/g, ''),
|
||||
branchId: 'SE:BANKGIRO',
|
||||
paymentId: generateOcrReference(invoice.invoice_number ?? ''),
|
||||
}
|
||||
} else if (hasText(company.plusgiro) && validatePlusgiroNumber(company.plusgiro)) {
|
||||
payment = {
|
||||
accountId: company.plusgiro.replace(/\D/g, ''),
|
||||
branchId: 'SE:PLUSGIRO',
|
||||
paymentId: generateOcrReference(invoice.invoice_number ?? ''),
|
||||
}
|
||||
} else {
|
||||
issues.push(validationIssue(
|
||||
'PAYMENT_ACCOUNT_REQUIRED', 'company.bankgiro',
|
||||
'Ett giltigt Bankgiro eller Plusgiro krävs för svensk Peppol-export.',
|
||||
'A valid Bankgiro or Plusgiro account is required for Swedish Peppol export.',
|
||||
))
|
||||
}
|
||||
if (payment && !/^\d{2,25}$/.test(payment.paymentId)) {
|
||||
issues.push(validationIssue(
|
||||
'PAYMENT_REFERENCE_INVALID', 'invoice.invoice_number',
|
||||
'Fakturanumret kan inte omvandlas till en giltig OCR-referens.',
|
||||
'The invoice number cannot be converted to a valid OCR payment reference.',
|
||||
))
|
||||
payment = null
|
||||
}
|
||||
|
||||
const productItems = input.items
|
||||
.filter((item) => item.line_type !== 'text')
|
||||
.sort((left, right) => left.sort_order - right.sort_order)
|
||||
if (productItems.length === 0) {
|
||||
issues.push(validationIssue(
|
||||
'INVOICE_LINES_REQUIRED', 'invoice.items',
|
||||
'Minst en fakturarad med belopp krävs.',
|
||||
'At least one invoice line with an amount is required.',
|
||||
))
|
||||
}
|
||||
|
||||
const taxGroups = new Map<number, { taxableAmount: number; lineTaxAmount: number }>()
|
||||
productItems.forEach((item, index) => {
|
||||
const lineField = `invoice.items.${index}`
|
||||
if (!hasText(item.description) || !Number.isFinite(item.quantity) || item.quantity <= 0 ||
|
||||
!Number.isFinite(item.unit_price) || item.unit_price < 0 || !Number.isFinite(item.line_total)) {
|
||||
issues.push(validationIssue(
|
||||
'INVOICE_LINE_INVALID', lineField,
|
||||
`Fakturarad ${index + 1} måste ha beskrivning, positivt antal och giltiga belopp.`,
|
||||
`Invoice line ${index + 1} must have a description, positive quantity, and valid amounts.`,
|
||||
))
|
||||
}
|
||||
if (!UNIT_CODES[item.unit]) {
|
||||
issues.push(validationIssue(
|
||||
'UNIT_UNSUPPORTED', `${lineField}.unit`,
|
||||
`Enheten på fakturarad ${index + 1} stöds inte av Peppol-exporten.`,
|
||||
`The unit on invoice line ${index + 1} is not supported by the Peppol export.`,
|
||||
))
|
||||
}
|
||||
if (!SUPPORTED_VAT_RATES.has(item.vat_rate)) {
|
||||
issues.push(validationIssue(
|
||||
'VAT_RATE_UNSUPPORTED', `${lineField}.vat_rate`,
|
||||
`Momssatsen på fakturarad ${index + 1} måste vara 6, 12 eller 25 procent.`,
|
||||
`The VAT rate on invoice line ${index + 1} must be 6, 12, or 25 percent.`,
|
||||
))
|
||||
}
|
||||
if (!equalMoney(item.line_total, roundMoney(item.quantity * item.unit_price))) {
|
||||
issues.push(validationIssue(
|
||||
'LINE_TOTAL_MISMATCH', `${lineField}.line_total`,
|
||||
`Beloppet på fakturarad ${index + 1} stämmer inte med antal gånger pris.`,
|
||||
`The amount on invoice line ${index + 1} does not equal quantity times price.`,
|
||||
))
|
||||
}
|
||||
if (!equalMoney(item.vat_amount, roundMoney(item.line_total * item.vat_rate / 100))) {
|
||||
issues.push(validationIssue(
|
||||
'LINE_VAT_MISMATCH', `${lineField}.vat_amount`,
|
||||
`Momsbeloppet på fakturarad ${index + 1} stämmer inte.`,
|
||||
`The VAT amount on invoice line ${index + 1} is inconsistent.`,
|
||||
))
|
||||
}
|
||||
const group = taxGroups.get(item.vat_rate) ?? { taxableAmount: 0, lineTaxAmount: 0 }
|
||||
group.taxableAmount = roundMoney(group.taxableAmount + item.line_total)
|
||||
group.lineTaxAmount = roundMoney(group.lineTaxAmount + item.vat_amount)
|
||||
taxGroups.set(item.vat_rate, group)
|
||||
})
|
||||
|
||||
const taxBreakdown = [...taxGroups.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([rate, group]) => ({
|
||||
rate,
|
||||
taxableAmount: group.taxableAmount,
|
||||
taxAmount: roundMoney(group.taxableAmount * rate / 100),
|
||||
}))
|
||||
taxBreakdown.forEach((group) => {
|
||||
if (!equalMoney(group.taxAmount, taxGroups.get(group.rate)?.lineTaxAmount ?? 0)) {
|
||||
issues.push(validationIssue(
|
||||
'VAT_ROUNDING_MISMATCH', 'invoice.items',
|
||||
`Momsavrundningen för ${group.rate} procent kan inte uttryckas enligt EN 16931. Justera fakturaraderna.`,
|
||||
`VAT rounding for the ${group.rate} percent category cannot be represented under EN 16931. Adjust the invoice lines.`,
|
||||
))
|
||||
}
|
||||
})
|
||||
|
||||
const calculatedSubtotal = roundMoney(productItems.reduce((sum, item) => sum + item.line_total, 0))
|
||||
const calculatedVat = roundMoney(taxBreakdown.reduce((sum, group) => sum + group.taxAmount, 0))
|
||||
const calculatedTotal = roundMoney(calculatedSubtotal + calculatedVat)
|
||||
if (!equalMoney(invoice.subtotal, calculatedSubtotal) ||
|
||||
!equalMoney(invoice.vat_amount, calculatedVat) || !equalMoney(invoice.total, calculatedTotal)) {
|
||||
issues.push(validationIssue(
|
||||
'INVOICE_TOTALS_MISMATCH', 'invoice.total',
|
||||
'Fakturans delsumma, moms eller total stämmer inte med fakturaraderna.',
|
||||
'The invoice subtotal, VAT, or total does not reconcile with its lines.',
|
||||
))
|
||||
}
|
||||
|
||||
const rounding = getDisplayTotal(invoice, company)
|
||||
if (rounding.displayed <= 0) {
|
||||
issues.push(validationIssue(
|
||||
'PAYABLE_AMOUNT_INVALID', 'invoice.total',
|
||||
'Beloppet att betala måste vara större än noll.',
|
||||
'The payable amount must be greater than zero.',
|
||||
))
|
||||
}
|
||||
|
||||
if (issues.length > 0 || !supplier || !buyer || !payment) return { prepared: null, issues }
|
||||
return {
|
||||
prepared: {
|
||||
supplier,
|
||||
buyer,
|
||||
payment,
|
||||
productItems,
|
||||
taxBreakdown,
|
||||
payableAmount: rounding.displayed,
|
||||
roundingAmount: rounding.roundingDelta,
|
||||
},
|
||||
issues: [],
|
||||
}
|
||||
}
|
||||
|
||||
function renderAddress(party: PreparedParty): string {
|
||||
return [
|
||||
` <cbc:StreetName>${escapeXml(party.addressLine1)}</cbc:StreetName>`,
|
||||
party.addressLine2
|
||||
? ` <cbc:AdditionalStreetName>${escapeXml(party.addressLine2)}</cbc:AdditionalStreetName>`
|
||||
: null,
|
||||
` <cbc:CityName>${escapeXml(party.city)}</cbc:CityName>`,
|
||||
` <cbc:PostalZone>${escapeXml(party.postalCode)}</cbc:PostalZone>`,
|
||||
' <cac:Country>',
|
||||
' <cbc:IdentificationCode>SE</cbc:IdentificationCode>',
|
||||
' </cac:Country>',
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function renderContact(party: PreparedParty): string | null {
|
||||
if (!party.email && !party.phone) return null
|
||||
return [
|
||||
' <cac:Contact>',
|
||||
party.phone ? ` <cbc:Telephone>${escapeXml(party.phone)}</cbc:Telephone>` : null,
|
||||
party.email ? ` <cbc:ElectronicMail>${escapeXml(party.email)}</cbc:ElectronicMail>` : null,
|
||||
' </cac:Contact>',
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function renderParty(
|
||||
wrapper: 'AccountingSupplierParty' | 'AccountingCustomerParty',
|
||||
party: PreparedParty,
|
||||
fSkatt: boolean,
|
||||
): string {
|
||||
const taxBlocks = [
|
||||
party.vatNumber
|
||||
? [
|
||||
' <cac:PartyTaxScheme>',
|
||||
` <cbc:CompanyID>${party.vatNumber}</cbc:CompanyID>`,
|
||||
' <cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>',
|
||||
' </cac:PartyTaxScheme>',
|
||||
].join('\n')
|
||||
: null,
|
||||
wrapper === 'AccountingSupplierParty' && fSkatt
|
||||
? [
|
||||
' <cac:PartyTaxScheme>',
|
||||
' <cbc:CompanyID>Godkänd för F-skatt</cbc:CompanyID>',
|
||||
' <cac:TaxScheme><cbc:ID>TAX</cbc:ID></cac:TaxScheme>',
|
||||
' </cac:PartyTaxScheme>',
|
||||
].join('\n')
|
||||
: null,
|
||||
].filter(Boolean)
|
||||
|
||||
return [
|
||||
` <cac:${wrapper}>`,
|
||||
' <cac:Party>',
|
||||
` <cbc:EndpointID schemeID="0007">${party.orgNumber}</cbc:EndpointID>`,
|
||||
' <cac:PartyIdentification>',
|
||||
` <cbc:ID schemeID="0007">${party.orgNumber}</cbc:ID>`,
|
||||
' </cac:PartyIdentification>',
|
||||
' <cac:PostalAddress>',
|
||||
renderAddress(party),
|
||||
' </cac:PostalAddress>',
|
||||
...taxBlocks,
|
||||
' <cac:PartyLegalEntity>',
|
||||
` <cbc:RegistrationName>${escapeXml(party.name)}</cbc:RegistrationName>`,
|
||||
` <cbc:CompanyID schemeID="0007">${party.orgNumber}</cbc:CompanyID>`,
|
||||
' </cac:PartyLegalEntity>',
|
||||
renderContact(party),
|
||||
' </cac:Party>',
|
||||
` </cac:${wrapper}>`,
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function renderInvoiceXml(input: PeppolInvoiceInput, prepared: PreparedInvoice): string {
|
||||
const { invoice, company } = input
|
||||
const taxTotal = roundMoney(prepared.taxBreakdown.reduce((sum, group) => sum + group.taxAmount, 0))
|
||||
const taxSubtotals = prepared.taxBreakdown.flatMap((group) => [
|
||||
' <cac:TaxSubtotal>',
|
||||
` <cbc:TaxableAmount currencyID="SEK">${formatMoney(group.taxableAmount)}</cbc:TaxableAmount>`,
|
||||
` <cbc:TaxAmount currencyID="SEK">${formatMoney(group.taxAmount)}</cbc:TaxAmount>`,
|
||||
' <cac:TaxCategory>',
|
||||
' <cbc:ID>S</cbc:ID>',
|
||||
` <cbc:Percent>${formatDecimal(group.rate)}</cbc:Percent>`,
|
||||
' <cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>',
|
||||
' </cac:TaxCategory>',
|
||||
' </cac:TaxSubtotal>',
|
||||
])
|
||||
const invoiceLines = prepared.productItems.flatMap((item, index) => [
|
||||
' <cac:InvoiceLine>',
|
||||
` <cbc:ID>${index + 1}</cbc:ID>`,
|
||||
` <cbc:InvoicedQuantity unitCode="${UNIT_CODES[item.unit]}">${formatDecimal(item.quantity)}</cbc:InvoicedQuantity>`,
|
||||
` <cbc:LineExtensionAmount currencyID="SEK">${formatMoney(item.line_total)}</cbc:LineExtensionAmount>`,
|
||||
' <cac:Item>',
|
||||
` <cbc:Name>${escapeXml(item.description.trim())}</cbc:Name>`,
|
||||
' <cac:ClassifiedTaxCategory>',
|
||||
' <cbc:ID>S</cbc:ID>',
|
||||
` <cbc:Percent>${formatDecimal(item.vat_rate)}</cbc:Percent>`,
|
||||
' <cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>',
|
||||
' </cac:ClassifiedTaxCategory>',
|
||||
' </cac:Item>',
|
||||
' <cac:Price>',
|
||||
` <cbc:PriceAmount currencyID="SEK">${formatDecimal(item.unit_price)}</cbc:PriceAmount>`,
|
||||
' </cac:Price>',
|
||||
' </cac:InvoiceLine>',
|
||||
])
|
||||
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"',
|
||||
' xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"',
|
||||
' xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">',
|
||||
` <cbc:CustomizationID>${PEPPOL_BIS_BILLING_CUSTOMIZATION_ID}</cbc:CustomizationID>`,
|
||||
` <cbc:ProfileID>${PEPPOL_BIS_BILLING_PROFILE_ID}</cbc:ProfileID>`,
|
||||
` <cbc:ID>${escapeXml(invoice.invoice_number ?? '')}</cbc:ID>`,
|
||||
` <cbc:IssueDate>${invoice.invoice_date}</cbc:IssueDate>`,
|
||||
` <cbc:DueDate>${invoice.due_date}</cbc:DueDate>`,
|
||||
' <cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>',
|
||||
invoice.notes ? ` <cbc:Note>${escapeXml(invoice.notes)}</cbc:Note>` : null,
|
||||
' <cbc:DocumentCurrencyCode>SEK</cbc:DocumentCurrencyCode>',
|
||||
` <cbc:BuyerReference>${escapeXml(invoice.your_reference?.trim() ?? '')}</cbc:BuyerReference>`,
|
||||
renderParty('AccountingSupplierParty', prepared.supplier, company.f_skatt),
|
||||
renderParty('AccountingCustomerParty', prepared.buyer, false),
|
||||
invoice.delivery_date
|
||||
? ` <cac:Delivery><cbc:ActualDeliveryDate>${invoice.delivery_date}</cbc:ActualDeliveryDate></cac:Delivery>`
|
||||
: null,
|
||||
' <cac:PaymentMeans>',
|
||||
' <cbc:PaymentMeansCode>30</cbc:PaymentMeansCode>',
|
||||
` <cbc:PaymentID>${prepared.payment.paymentId}</cbc:PaymentID>`,
|
||||
' <cac:PayeeFinancialAccount>',
|
||||
` <cbc:ID>${prepared.payment.accountId}</cbc:ID>`,
|
||||
' <cac:FinancialInstitutionBranch>',
|
||||
` <cbc:ID>${prepared.payment.branchId}</cbc:ID>`,
|
||||
' </cac:FinancialInstitutionBranch>',
|
||||
' </cac:PayeeFinancialAccount>',
|
||||
' </cac:PaymentMeans>',
|
||||
' <cac:TaxTotal>',
|
||||
` <cbc:TaxAmount currencyID="SEK">${formatMoney(taxTotal)}</cbc:TaxAmount>`,
|
||||
...taxSubtotals,
|
||||
' </cac:TaxTotal>',
|
||||
' <cac:LegalMonetaryTotal>',
|
||||
` <cbc:LineExtensionAmount currencyID="SEK">${formatMoney(invoice.subtotal)}</cbc:LineExtensionAmount>`,
|
||||
` <cbc:TaxExclusiveAmount currencyID="SEK">${formatMoney(invoice.subtotal)}</cbc:TaxExclusiveAmount>`,
|
||||
` <cbc:TaxInclusiveAmount currencyID="SEK">${formatMoney(invoice.total)}</cbc:TaxInclusiveAmount>`,
|
||||
prepared.roundingAmount !== 0
|
||||
? ` <cbc:PayableRoundingAmount currencyID="SEK">${formatMoney(prepared.roundingAmount)}</cbc:PayableRoundingAmount>`
|
||||
: null,
|
||||
` <cbc:PayableAmount currencyID="SEK">${formatMoney(prepared.payableAmount)}</cbc:PayableAmount>`,
|
||||
' </cac:LegalMonetaryTotal>',
|
||||
...invoiceLines,
|
||||
'</Invoice>',
|
||||
'',
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
export function generatePeppolBisBillingInvoice(input: PeppolInvoiceInput): PeppolInvoiceResult {
|
||||
const validation = prepareInvoice(input)
|
||||
if (validation.prepared === null) return { ok: false, issues: validation.issues }
|
||||
|
||||
const filenameNumber = (input.invoice.invoice_number ?? input.invoice.id)
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || input.invoice.id
|
||||
return {
|
||||
ok: true,
|
||||
xml: renderInvoiceXml(input, validation.prepared),
|
||||
filename: `peppol-invoice-${filenameNumber}.xml`,
|
||||
}
|
||||
}
|
||||
@@ -3665,6 +3665,7 @@
|
||||
"mark_as_paid": "Mark as paid",
|
||||
"copy_invoice": "Copy invoice",
|
||||
"download_pdf": "Download PDF",
|
||||
"download_peppol_xml": "Download Peppol XML",
|
||||
"preview_pdf": "Preview",
|
||||
"viewer_disabled_tooltip": "You only have read-only access to this company",
|
||||
"customer_card_title": "Customer",
|
||||
@@ -3784,6 +3785,10 @@
|
||||
"pdf_downloaded_with_number": "Invoice {number} has been downloaded",
|
||||
"pdf_downloaded_draft": "The draft has been downloaded",
|
||||
"pdf_download_failed_title": "Could not download PDF",
|
||||
"peppol_downloaded_title": "Peppol XML downloaded",
|
||||
"peppol_downloaded_description": "The XML file passed Accounted's local checks. It has not been sent to Peppol.",
|
||||
"peppol_download_failed_title": "Could not create Peppol XML",
|
||||
"peppol_download_failed_description": "The invoice does not satisfy the Peppol profile supported by this export.",
|
||||
"pdf_rerender_downloaded_title": "Freshly generated PDF downloaded",
|
||||
"pdf_rerender_preview_title": "Showing a freshly generated PDF",
|
||||
"pdf_preview_blocked_title": "Could not open the preview",
|
||||
|
||||
@@ -3665,6 +3665,7 @@
|
||||
"mark_as_paid": "Markera som betald",
|
||||
"copy_invoice": "Kopiera faktura",
|
||||
"download_pdf": "Ladda ner PDF",
|
||||
"download_peppol_xml": "Ladda ner Peppol XML",
|
||||
"preview_pdf": "Förhandsgranska",
|
||||
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
|
||||
"customer_card_title": "Kund",
|
||||
@@ -3784,6 +3785,10 @@
|
||||
"pdf_downloaded_with_number": "Faktura {number} har laddats ner",
|
||||
"pdf_downloaded_draft": "Utkastet har laddats ner",
|
||||
"pdf_download_failed_title": "Kunde inte ladda ner PDF",
|
||||
"peppol_downloaded_title": "Peppol XML nedladdad",
|
||||
"peppol_downloaded_description": "XML-filen har klarat Accounteds lokala kontroll. Den har inte skickats till Peppol.",
|
||||
"peppol_download_failed_title": "Kunde inte skapa Peppol XML",
|
||||
"peppol_download_failed_description": "Fakturan uppfyller inte den Peppol-profil som exporten stöder.",
|
||||
"pdf_rerender_downloaded_title": "Nyskapad PDF nedladdad",
|
||||
"pdf_rerender_preview_title": "Nyskapad PDF visas",
|
||||
"pdf_preview_blocked_title": "Kunde inte öppna förhandsgranskningen",
|
||||
|
||||
Reference in New Issue
Block a user