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,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 &lt;Sverige&gt; AB</cbc:RegistrationName>')
expect(result.xml).toContain('<cbc:RegistrationName>Kund &amp; Partner AB</cbc:RegistrationName>')
expect(result.xml).toContain('<cbc:BuyerReference>REF &amp; 42</cbc:BuyerReference>')
expect(result.xml).toContain('<cbc:Note>Tack &lt;igen&gt;</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',
]))
})
})
+625
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
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`,
}
}