/** * 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