Files
accounted/lib/invoices/peppol-bis-billing.ts
T
MattssonandClaude Fable 5.1 7a30f623ba fix(invoices): invoice PDF draft stamp, page breaks, wrapping, units, multi-line descriptions (#2369)
* fix(invoices): draft stamp out of flow, page-break control, whole-word wrapping, English units, multi-line descriptions

A user reported five things about the invoice PDF after the English
translation shipped:

- The yellow draft banner pushed the whole document down, so a draft
  previewed differently from the final invoice. It is now a small stamp in
  the page's top margin (absolute, fixed), repeated on every page, out of
  the flow.
- Table rows, totals, the payment box and the notice boxes could split
  across a page break, and a section heading could be left alone at the
  bottom of a page. Those blocks now carry wrap={false}; headings and the
  table header carry minPresenceAhead.
- react-pdf hyphenated Swedish words with English patterns ("Septem-ber").
  Descriptions, notes, notices and the footer now wrap whole words.
- "st" printed verbatim on an English invoice. The editor's known units map
  to English labels at render time; user-typed units print as stored.
- Descriptions were single-line inputs, so a user could never choose where
  a line breaks. The editor field is now an auto-growing textarea; the PDF
  and the on-screen views keep the line breaks; the Peppol item name
  collapses them (single-line field).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* fix(invoices): keep long tokens printable and oversize text splittable in the invoice PDF

Skeptic findings on the first commit:

- A word wider than its column was dropped from the page or overprinted the
  quantity column, because the no-hyphenation callback gave react-pdf no
  break point inside it. Words up to 16 characters still wrap whole; longer
  tokens (URLs, e-mail addresses, references) break after separators and
  every 16 characters.
- wrap={false} on a text row or notes taller than a page clipped everything
  past the page edge. Line descriptions and notes are now kept together only
  while a line estimate says they fit; past that they split.
- A multi-line description reached the periodisering voucher text and broke
  the SIE export (one record per line). The accrual builder and the Peppol
  item name share toSingleLine(); the SIE writer collapses line breaks in
  quoted text as a format guard.
- The English no-number draft stamp ended 1.3pt below the top margin.

Tests lay the document out with @react-pdf/layout and assert that no text
node ends past the page edge and that every line's ink stays inside its
column, for 80-line descriptions and notes, a 3000-character description,
and four long tokens.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* fix(invoices): count rendered lines and cap the kept-together budget so no font can clip a row

Skeptic cycle 2: with the bundled Source Serif 4 font (13.7pt per line)
and a description of 20 source lines of wide glyphs, the estimate said
"fits" while the chunked token wrapping produced 58 rendered lines; the
non-splittable row then ran off the page. The estimate now counts the
chunks a long token is broken into, and the cap is 12 lines: at 20pt per
line that is under a third of the page for any font a company can pick.

The page-edge test now measures absolute positions (box.top is
parent-relative) and covers the bundled serif case through
prepareInvoiceFont().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* fix(invoices): keep words whole by estimated ink width, not a character cap

Skeptic cycle 2 (regression): the flat 16-character cap gave react-pdf a
break point inside ordinary Swedish compounds (Fastighetsskötse-l,
Företagsförsäkri-ng), which the breaker used whenever it filled the line
better. Words are now kept whole whenever a rough per-glyph width estimate
says they fit the column; only a token wider than the column gets parts,
after separators and where the column is full. The page-fit estimate uses
the same widths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* fix(invoices): estimate word width from real Helvetica metrics

Skeptic cycle 3: the hand-rolled per-glyph estimate over-counted ordinary
lowercase (6.5pt for a 5.56pt glyph), so 24 to 28 character compounds
(Momskompensationsansökan, Mervärdesskattedeklarationen) were still handed
a break point while they fit the column, and under-counted rare glyphs
(æ, œ, Cyrillic) so a token of those could overflow. The estimate now uses
Helvetica advances measured through react-pdf's own metrics
(lib/invoices/pdf-glyph-widths.ts) with a 10% margin for the bundled
fonts, and counts any glyph outside Helvetica at the widest Latin advance.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* docs(invoices): state the bundled-font width margin accurately

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* fix(invoices): review round: SIE backslash escaping, text-row budget, ROT/RUT box keep-together

- CodeQL: the SIE quoted-text escaper left backslashes alone while backslash
  is the escape character. It now writes a literal backslash as two, and the
  parser unescapes both that and the escaped quote, so text round-trips.
- CodeRabbit: a free-text row renders at full table width but was budgeted
  at the description column, so it could split unnecessarily. It uses the
  full-width budget now.
- CodeRabbit: the ROT/RUT box was unconditionally kept on one page although
  its per-line breakdown carries the (possibly multi-line) descriptions. It
  uses the same keep-together estimate as rows and notes, and its line texts
  wrap whole words.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 14:45:38 +02:00

669 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { escapeXml } from '@/lib/xml/escape'
import {
generateOcrReference,
validateBankgiroNumber,
validatePlusgiroNumber,
} from '@/lib/bankgiro/luhn'
import { isSaneDateString, normalizeOrgNumber } from '@/lib/invariants'
import { resolveInvoicePaymentAccount } from '@/lib/invoices/payment-accounts'
import { computeLineAmounts, hasLineDiscount } from '@/lib/invoices/line-amounts'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { toSingleLine } from '@/lib/invoices/display'
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'
/** Peppol document type identifier for a BIS Billing 3 UBL 2.1 invoice (SMP capability key). */
export const PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID =
'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1'
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
sender: { scheme: '0007'; identifier: string }
recipient: { scheme: '0007'; identifier: 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 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.',
))
}
// BT-10 BuyerReference: fakturamärkning wins when set (SFTI convention:
// the buyer's routing/marking string), else Er referens.
if (!hasText(invoice.invoice_marking) && !hasText(invoice.your_reference)) {
issues.push(validationIssue(
'BUYER_REFERENCE_REQUIRED', 'invoice.your_reference',
'Märkning eller Er referens krävs för Peppol när inköpsordernummer saknas.',
'A marking or 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)
// Same payee the PDF and the email print: the resolver, not the raw legacy
// columns. Peppol is SEK-only (validated above), so resolve for SEK.
const payee = resolveInvoicePaymentAccount(company, 'SEK', invoice.payment_details ?? null)
let payment: PreparedInvoice['payment'] | null = null
if (hasText(payee?.bankgiro) && validateBankgiroNumber(payee.bankgiro)) {
payment = {
accountId: payee.bankgiro.replace(/\D/g, ''),
branchId: 'SE:BANKGIRO',
paymentId: generateOcrReference(invoice.invoice_number ?? ''),
}
} else if (hasText(payee?.plusgiro) && validatePlusgiroNumber(payee.plusgiro)) {
payment = {
accountId: payee.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.`,
))
}
// Net of any line discount: line_total must equal (qty × price) − rabatt,
// the same exact öre arithmetic the write path stores
// (lib/invoices/line-amounts.ts) and the BG-27 allowance below renders.
const expectedAmounts = computeLineAmounts(item.quantity, item.unit_price, item.discount_percent)
if (!equalMoney(item.line_total, roundMoney(expectedAmounts.net))) {
issues.push(validationIssue(
'LINE_TOTAL_MISMATCH', `${lineField}.line_total`,
`Beloppet på fakturarad ${index + 1} stämmer inte med antal gånger pris minus rabatt.`,
`The amount on invoice line ${index + 1} does not equal quantity times price less discount.`,
))
}
if (item.discount_percent !== undefined && (item.discount_percent < 0 || item.discount_percent > 100)) {
issues.push(validationIssue(
'LINE_DISCOUNT_INVALID', `${lineField}.discount_percent`,
`Rabatten på fakturarad ${index + 1} måste vara mellan 0 och 100 procent.`,
`The discount on invoice line ${index + 1} must be between 0 and 100 percent.`,
))
}
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) => {
// Line discount as a BG-27 allowance: LineExtensionAmount stays the net
// line_total and the allowance documents base − amount = net exactly
// (the amounts come from the same öre arithmetic as the stored total).
const amounts = computeLineAmounts(item.quantity, item.unit_price, item.discount_percent)
const allowance = hasLineDiscount(item.discount_percent)
? [
' <cac:AllowanceCharge>',
' <cbc:ChargeIndicator>false</cbc:ChargeIndicator>',
' <cbc:AllowanceChargeReasonCode>95</cbc:AllowanceChargeReasonCode>',
' <cbc:AllowanceChargeReason>Rabatt</cbc:AllowanceChargeReason>',
` <cbc:MultiplierFactorNumeric>${formatDecimal(item.discount_percent as number)}</cbc:MultiplierFactorNumeric>`,
` <cbc:Amount currencyID="SEK">${formatMoney(amounts.discount)}</cbc:Amount>`,
` <cbc:BaseAmount currencyID="SEK">${formatMoney(amounts.gross)}</cbc:BaseAmount>`,
' </cac:AllowanceCharge>',
]
: []
return [
' <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>`,
...allowance,
' <cac:Item>',
// The UBL item name is a single-line field; descriptions may carry
// line breaks.
` <cbc:Name>${escapeXml(toSingleLine(item.description))}</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.invoice_marking?.trim() || 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`,
sender: { scheme: '0007', identifier: validation.prepared.supplier.orgNumber },
recipient: { scheme: '0007', identifier: validation.prepared.buyer.orgNumber },
}
}