/**
* ZUGFeRD / Factur-X CII XML builder (EN16931 / "COMFORT" profile).
*
* Pure, side-effect-free: takes a normalized invoice object and returns the
* Cross-Industry-Invoice (CII) XML string that gets embedded into the iDempiere
* PDF as `factur-x.xml`. No iDempiere/HTTP calls here — the orchestrator
* (`maybeZugferdPdf.ts`) gathers the data and maps it into `ZugferdInvoiceData`.
*
* Profile URN: urn:cen.eu:en16931:2017 (full EN16931 — the standard B2B level).
* Pragmatic v1 scope (see CLAUDE.md plan): tax-exclusive invoices, VAT category
* derived from the rate (S for >0, Z for 0). Harden later if a validator demands.
*/
export interface ZugferdParty {
name: string
/** BT-29 / BT-46 party identifier (any string; satisfies BR-CO-26 for the seller). */
id?: string
/** BT-30 legal registration id, e.g. German Handelsregisternummer (HRB …). */
legalRegId?: string
vatId?: string
street?: string
city?: string
postcode?: string
/** ISO 3166-1 alpha-2, e.g. "DE" */
countryCode?: string
}
export interface ZugferdLine {
lineId: number
name: string
qty: number
/** UN/ECE Rec 20 unit code; "C62" = one/piece (pragmatic default) */
unitCode: string
netPrice: number
lineTotal: number
taxRate: number
/** VAT category code: S (standard), Z (zero-rated) */
taxCategory: string
}
export interface ZugferdTaxBreakdown {
rate: number
basis: number
amount: number
category: string
}
export interface ZugferdInvoiceData {
documentNo: string
/** YYYY-MM-DD or ISO date string */
issueDate: string
/** YYYY-MM-DD or ISO date string (optional) — BT-9 payment due date */
dueDate?: string
/** BT-20 free-text payment terms (satisfies BR-CO-25 even without a due date) */
paymentTerms?: string
/** BT-72 actual delivery / service date (YYYY-MM-DD); defaults to issueDate */
deliveryDate?: string
/** 380 = commercial invoice, 381 = credit note */
typeCode: '380' | '381'
/** ISO 4217, e.g. "EUR" */
currency: string
/** BT-10 buyer reference (optional; required for XRechnung, optional for EN16931) */
buyerReference?: string
/** BT-13 purchase order number (optional) */
orderRef?: string
seller: ZugferdParty
buyer: ZugferdParty
lines: ZugferdLine[]
taxes: ZugferdTaxBreakdown[]
/** BT-106 sum of line net amounts */
lineTotal: number
/** BT-109 tax basis total */
taxBasisTotal: number
/** BT-110 tax total */
taxTotal: number
/** BT-112 grand total incl. tax */
grandTotal: number
/** BT-115 amount due for payment */
duePayable: number
/** optional SEPA credit-transfer payee account */
paymentIban?: string
}
const escapeXml = (value: any): string =>
String(value ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
/** Money / percent: fixed 2 decimals, "." separator, no thousands. */
const amt = (n: any): string => (Number(n) || 0).toFixed(2)
/** Unit net price (BT-146): up to 4 decimals so price × quantity reconciles to the
* 2-decimal line amount (EN16931 permits >2 decimals on the item net price). */
const priceAmt = (n: any): string => (Number(n) || 0).toFixed(4)
/** Quantity: integer stays integer, else up to 4 decimals. */
const qtyFmt = (n: any): string => {
const x = Number(n) || 0
return Number.isInteger(x) ? String(x) : x.toFixed(4)
}
/** Any date-ish string -> YYYYMMDD (CII format code 102). */
const toYmd = (value: any): string => {
const s = String(value ?? '')
const m = s.match(/(\d{4})-(\d{2})-(\d{2})/)
if (m) return `${m[1]}${m[2]}${m[3]}`
const digits = s.replace(/[^0-9]/g, '')
return digits.slice(0, 8)
}
const partyXml = (tag: string, p: ZugferdParty): string => {
const lines: string[] = []
lines.push(` `)
if (p.id) lines.push(` ${escapeXml(p.id)}`)
lines.push(` ${escapeXml(p.name)}`)
// BT-30 legal registration id (e.g. Handelsregisternummer) — optional, only if present.
if (p.legalRegId) {
lines.push(` `)
lines.push(` ${escapeXml(p.legalRegId)}`)
lines.push(` `)
}
lines.push(` `)
if (p.postcode) lines.push(` ${escapeXml(p.postcode)}`)
if (p.street) lines.push(` ${escapeXml(p.street)}`)
if (p.city) lines.push(` ${escapeXml(p.city)}`)
lines.push(` ${escapeXml(p.countryCode || 'DE')}`)
lines.push(` `)
if (p.vatId) {
lines.push(` `)
lines.push(` ${escapeXml(p.vatId)}`)
lines.push(` `)
}
lines.push(` `)
return lines.join('\n')
}
const lineItemXml = (line: ZugferdLine): string => `
${escapeXml(line.lineId)}
${escapeXml(line.name || '-')}
${priceAmt(line.netPrice)}
${qtyFmt(line.qty)}
VAT
${escapeXml(line.taxCategory || 'S')}
${amt(line.taxRate)}
${amt(line.lineTotal)}
`
const tradeTaxXml = (t: ZugferdTaxBreakdown): string => `
${amt(t.amount)}
VAT
${amt(t.basis)}
${escapeXml(t.category || 'S')}
${amt(t.rate)}
`
export const buildInvoiceXml = (data: ZugferdInvoiceData): string => {
const lineItems = (data.lines || []).map(lineItemXml).join('\n')
const tradeTaxes = (data.taxes || []).map(tradeTaxXml).join('\n')
const orderRefXml = data.orderRef
? `
${escapeXml(data.orderRef)}
\n`
: ''
const buyerReferenceXml = data.buyerReference
? ` ${escapeXml(data.buyerReference)}\n`
: ''
const paymentMeansXml = data.paymentIban
? `
58
${escapeXml(data.paymentIban.replace(/\s+/g, ''))}
\n`
: ''
// SpecifiedTradePaymentTerms (CII order: Description, then DueDateDateTime).
// Emitting BT-20 (Description) and/or BT-9 (DueDate) satisfies BR-CO-25.
const ptInner: string[] = []
if (data.paymentTerms) ptInner.push(` ${escapeXml(data.paymentTerms)}`)
if (data.dueDate) ptInner.push(`
${toYmd(data.dueDate)}
`)
const paymentTermsXml = ptInner.length
? ` \n${ptInner.join('\n')}\n \n`
: ''
return `
urn:cen.eu:en16931:2017
${escapeXml(data.documentNo)}
${data.typeCode}
${toYmd(data.issueDate)}
${lineItems}
${buyerReferenceXml}${partyXml('SellerTradeParty', data.seller)}
${partyXml('BuyerTradeParty', data.buyer)}
${orderRefXml}
${toYmd(data.deliveryDate || data.issueDate)}
${escapeXml(data.currency || 'EUR')}
${paymentMeansXml}${tradeTaxes}
${paymentTermsXml}
${amt(data.lineTotal)}
${amt(data.taxBasisTotal)}
${amt(data.taxTotal)}
${amt(data.grandTotal)}
${amt(data.duePayable)}
`
}
export default buildInvoiceXml