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