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