/** * Tier 0 — embedded e-invoice XML (ZUGFeRD / Factur-X / XRechnung). * * The inverse of server/utils/zugferd/buildInvoiceXml.ts: detect & parse the * machine-readable invoice XML that an increasing share of German B2B suppliers * embed (Factur-X) or send standalone (XRechnung). When present this yields * perfect structured data with NO OCR — so it is always tried first. * * Handles CII (Cross-Industry-Invoice, the ZUGFeRD/Factur-X format) fully and * UBL (Invoice) best-effort. Fail-soft: returns null on anything unexpected so * the pipeline falls through to the text/scan tiers. */ import { PDFDocument, PDFName, PDFDict, PDFRawStream, PDFArray } from 'pdf-lib' import { XMLParser } from 'fast-xml-parser' import zlib from 'zlib' import type { ZugferdInvoiceData, ZugferdLine, ZugferdTaxBreakdown } from '../zugferd/buildInvoiceXml' const XML_INVOICE_HINTS = ['CrossIndustryInvoice', ':Invoice', ' !!s && XML_INVOICE_HINTS.some(h => s.includes(h)) /** Inflate a stream if it is FlateDecode-encoded; otherwise return as-is. */ const maybeInflate = (raw: Uint8Array, filter: string | null): Buffer => { const buf = Buffer.from(raw) if (filter && /Flate/i.test(filter)) { try { return zlib.inflateSync(buf) } catch {} try { return zlib.inflateRawSync(buf) } catch {} } return buf } /** * Walk the PDF catalog's Names → EmbeddedFiles name tree and return the first * embedded file whose bytes look like an invoice XML. */ const extractViaPdfLib = async (pdfBuffer: Buffer): Promise => { const pdfDoc = await PDFDocument.load(pdfBuffer, { throwOnInvalidObject: false, updateMetadata: false }) const catalog: any = pdfDoc.catalog const names = catalog.lookup(PDFName.of('Names'), PDFDict) if (!names) return null const embedded = names.lookup(PDFName.of('EmbeddedFiles'), PDFDict) if (!embedded) return null // Collect filespec dicts from a (possibly nested) name tree. const filespecs: PDFDict[] = [] const visit = (node: PDFDict) => { const namesArr = node.lookup(PDFName.of('Names'), PDFArray) if (namesArr) { for (let i = 1; i < namesArr.size(); i += 2) { const spec = namesArr.lookup(i, PDFDict) if (spec) filespecs.push(spec) } } const kids = node.lookup(PDFName.of('Kids'), PDFArray) if (kids) { for (let i = 0; i < kids.size(); i++) { const kid = kids.lookup(i, PDFDict) if (kid) visit(kid) } } } visit(embedded) for (const spec of filespecs) { const ef = spec.lookup(PDFName.of('EF'), PDFDict) if (!ef) continue const stream = (ef.lookup(PDFName.of('F'), PDFRawStream) || ef.lookup(PDFName.of('UF'), PDFRawStream)) as PDFRawStream | undefined if (!stream) continue const filterObj = stream.dict.lookup(PDFName.of('Filter')) const filter = filterObj ? filterObj.toString() : null const bytes = maybeInflate(stream.contents, filter) const text = bytes.toString('utf8') if (looksLikeInvoiceXml(text)) return text } return null } /** Brute-force fallback: find an uncompressed XML invoice region in the raw bytes. */ const extractViaRawScan = (pdfBuffer: Buffer): string | null => { const s = pdfBuffer.toString('latin1') const closers = ['', '', '', ''] for (const closer of closers) { const end = s.indexOf(closer) if (end === -1) continue const startTag = s.lastIndexOf(' => { try { const viaLib = await extractViaPdfLib(pdfBuffer) if (viaLib) return viaLib } catch {} try { return extractViaRawScan(pdfBuffer) } catch { return null } } /* ---------------- CII / UBL → ZugferdInvoiceData ---------------- */ const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', removeNSPrefix: true, parseTagValue: false, trimValues: true }) const arr = (x: any): any[] => (x == null ? [] : Array.isArray(x) ? x : [x]) const txt = (x: any): string => { if (x == null) return '' if (typeof x === 'object') return String(x['#text'] ?? '') return String(x) } const num = (x: any): number => { const n = Number(String(txt(x)).replace(',', '.')) return Number.isFinite(n) ? n : 0 } /** CII format-102 date YYYYMMDD → YYYY-MM-DD. */ const ymdToIso = (x: any): string => { const s = String(txt(x)).replace(/[^0-9]/g, '') if (s.length >= 8) return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` return '' } const ciiParty = (p: any) => { if (!p) return { name: '' } const addr = p.PostalTradeAddress || {} const taxReg = arr(p.SpecifiedTaxRegistration).map((r: any) => txt(r?.ID)).find(Boolean) return { name: txt(p.Name), vatId: taxReg || '', street: txt(addr.LineOne), city: txt(addr.CityName), postcode: txt(addr.PostcodeCode), countryCode: txt(addr.CountryID) || 'DE' } } const parseCii = (root: any): ZugferdInvoiceData | null => { const doc = root.ExchangedDocument const tx = root.SupplyChainTradeTransaction if (!doc || !tx) return null const agreement = tx.ApplicableHeaderTradeAgreement || {} const settlement = tx.ApplicableHeaderTradeSettlement || {} const delivery = tx.ApplicableHeaderTradeDelivery || {} const summation = settlement.SpecifiedTradeSettlementHeaderMonetarySummation || {} const lines: ZugferdLine[] = arr(tx.IncludedSupplyChainTradeLineItem).map((li: any, idx: number) => { const prod = li.SpecifiedTradeProduct || {} const agr = li.SpecifiedLineTradeAgreement || {} const del = li.SpecifiedLineTradeDelivery || {} const set = li.SpecifiedLineTradeSettlement || {} const tax = set.ApplicableTradeTax || {} const qtyNode = del.BilledQuantity return { lineId: Number(txt(li.AssociatedDocumentLineDocument?.LineID)) || idx + 1, name: txt(prod.Name) || '-', qty: num(qtyNode), unitCode: (qtyNode && qtyNode['@_unitCode']) || 'C62', netPrice: num(agr.NetPriceProductTradePrice?.ChargeAmount), lineTotal: num(set.SpecifiedTradeSettlementLineMonetarySummation?.LineTotalAmount), taxRate: num(tax.RateApplicablePercent), taxCategory: txt(tax.CategoryCode) || 'S' } }) const taxes: ZugferdTaxBreakdown[] = arr(settlement.ApplicableTradeTax).map((t: any) => ({ rate: num(t.RateApplicablePercent), basis: num(t.BasisAmount), amount: num(t.CalculatedAmount), category: txt(t.CategoryCode) || 'S' })) const typeCode = txt(doc.TypeCode) === '381' ? '381' : '380' return { documentNo: txt(doc.ID), issueDate: ymdToIso(doc.IssueDateTime?.DateTimeString), dueDate: ymdToIso(settlement.SpecifiedTradePaymentTerms?.DueDateDateTime?.DateTimeString) || undefined, paymentTerms: txt(settlement.SpecifiedTradePaymentTerms?.Description) || undefined, deliveryDate: ymdToIso(delivery.ActualDeliverySupplyChainEvent?.OccurrenceDateTime?.DateTimeString) || undefined, typeCode, currency: txt(settlement.InvoiceCurrencyCode) || 'EUR', orderRef: txt(agreement.BuyerOrderReferencedDocument?.IssuerAssignedID) || undefined, seller: ciiParty(agreement.SellerTradeParty), buyer: ciiParty(agreement.BuyerTradeParty), lines, taxes, lineTotal: num(summation.LineTotalAmount), taxBasisTotal: num(summation.TaxBasisTotalAmount), taxTotal: num(summation.TaxTotalAmount), grandTotal: num(summation.GrandTotalAmount), duePayable: num(summation.DuePayableAmount), paymentIban: txt(settlement.SpecifiedTradeSettlementPaymentMeans?.PayeePartyCreditorFinancialAccount?.IBANID) || undefined } } /** Minimal UBL (Invoice) mapping — enough to prefill header + totals. */ const parseUbl = (root: any): ZugferdInvoiceData | null => { const inv = root.Invoice || root if (!inv || !inv.ID) return null const seller = inv.AccountingSupplierParty?.Party || {} const buyer = inv.AccountingCustomerParty?.Party || {} const monetary = inv.LegalMonetaryTotal || {} const ublParty = (p: any) => ({ name: txt(p?.PartyName?.Name) || txt(p?.PartyLegalEntity?.RegistrationName), vatId: txt(p?.PartyTaxScheme?.CompanyID), street: txt(p?.PostalAddress?.StreetName), city: txt(p?.PostalAddress?.CityName), postcode: txt(p?.PostalAddress?.PostalZone), countryCode: txt(p?.PostalAddress?.Country?.IdentificationCode) || 'DE' }) return { documentNo: txt(inv.ID), issueDate: txt(inv.IssueDate), typeCode: txt(inv.InvoiceTypeCode) === '381' ? '381' : '380', currency: txt(inv.DocumentCurrencyCode) || 'EUR', seller: ublParty(seller), buyer: ublParty(buyer), lines: [], taxes: arr(inv.TaxTotal?.TaxSubtotal).map((t: any) => ({ rate: num(t?.TaxCategory?.Percent), basis: num(t?.TaxableAmount), amount: num(t?.TaxAmount), category: txt(t?.TaxCategory?.ID) || 'S' })), lineTotal: num(monetary.LineExtensionAmount), taxBasisTotal: num(monetary.TaxExclusiveAmount), taxTotal: num(inv.TaxTotal?.TaxAmount), grandTotal: num(monetary.TaxInclusiveAmount), duePayable: num(monetary.PayableAmount) } } export const parseInvoiceXml = (xml: string): ZugferdInvoiceData | null => { try { const root = parser.parse(xml) if (root.CrossIndustryInvoice) return parseCii(root.CrossIndustryInvoice) if (root.Invoice) return parseUbl(root) return null } catch { return null } } /** Orchestrates Tier 0 from a PDF buffer and/or a standalone XML string. */ export const parseEinvoice = async (input: { pdf?: Buffer; xml?: string }): Promise => { let xml = input.xml || null if (!xml && input.pdf) xml = await extractEmbeddedInvoiceXml(input.pdf) if (!xml) return null return parseInvoiceXml(xml) } export default parseEinvoice