import { XMLParser } from 'fast-xml-parser'; export type ERechnungSyntax = 'CII' | 'UBL'; export interface ERechnungPartei { name: string | null; /** USt-IdNr. (schemeID "VA" bzw. TaxScheme "VAT") */ ustId: string | null; /** Kennung der Partei (BT-46 beim Käufer, BT-29 beim Verkäufer) */ kennung: string | null; /** Elektronische Adresse (BT-49 bzw. BT-34) */ email: string | null; } export interface ERechnungDaten { syntax: ERechnungSyntax; /** Rechnungsnummer (BT-1) */ rechnungsnummer: string | null; /** Rechnungsdatum (BT-2) als ISO-Datum */ rechnungsdatum: string | null; /** Rechnungstyp (BT-3), z. B. "380" = Rechnung, "381" = Gutschrift */ typeCode: string | null; /** Währung (BT-5) */ waehrung: string | null; /** Bruttogesamtbetrag (BT-112) */ gesamtbetrag: number | null; /** Käuferreferenz (BT-10) – im deutschen B2G die Leitweg-ID */ leitwegId: string | null; kaeufer: ERechnungPartei; verkaeufer: ERechnungPartei; } // removeNSPrefix schneidet die frei wählbaren Namespace-Präfixe (ram:, rsm:, // cac:, cbc:, …) ab – ohne das wären die Pfade je nach Erzeuger andere. const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', removeNSPrefix: true, parseTagValue: false, parseAttributeValue: false, trimValues: true, // Ohne htmlEntities bleiben numerische Referenzen (ö) als Text stehen – // ein so verstümmelter Käufername würde den Namensvergleich zerstören. htmlEntities: true, }); /** * Parst ein E-Rechnungs-XML in der Syntax CII (ZUGFeRD/Factur-X/XRechnung-CII) * oder UBL (XRechnung-UBL). Gibt `null` zurück, wenn das XML keine der beiden * Wurzeln trägt oder gar nicht lesbar ist. */ export function parseERechnung(xml: Buffer | string): ERechnungDaten | null { let wurzel: unknown; try { wurzel = parser.parse(Buffer.isBuffer(xml) ? xml.toString('utf8') : xml); } catch { return null; } const cii = kind(wurzel, 'CrossIndustryInvoice'); if (cii) return parseCii(cii); const ubl = kind(wurzel, 'Invoice'); if (ubl) return parseUbl(ubl, 'Invoice'); const gutschrift = kind(wurzel, 'CreditNote'); if (gutschrift) return parseUbl(gutschrift, 'CreditNote'); return null; } // === CII (ZUGFeRD / Factur-X / XRechnung-CII) === function parseCii(inv: unknown): ERechnungDaten { const kopf = kind(inv, 'ExchangedDocument'); const transaktion = kind(inv, 'SupplyChainTradeTransaction'); const vereinbarung = kind(transaktion, 'ApplicableHeaderTradeAgreement'); const abrechnung = kind(transaktion, 'ApplicableHeaderTradeSettlement'); const summen = kind( abrechnung, 'SpecifiedTradeSettlementHeaderMonetarySummation', ); const datum = kind(kopf, 'IssueDateTime'); return { syntax: 'CII', rechnungsnummer: text(kind(kopf, 'ID')), rechnungsdatum: normalisiereDatum( text(kind(datum, 'DateTimeString')) ?? text(datum), ), typeCode: text(kind(kopf, 'TypeCode')), waehrung: text(kind(abrechnung, 'InvoiceCurrencyCode')), gesamtbetrag: zuBetrag( text(kind(summen, 'GrandTotalAmount')) ?? text(kind(summen, 'DuePayableAmount')), ), leitwegId: text(kind(vereinbarung, 'BuyerReference')), kaeufer: parseCiiPartei(kind(vereinbarung, 'BuyerTradeParty')), verkaeufer: parseCiiPartei(kind(vereinbarung, 'SellerTradeParty')), }; } function parseCiiPartei(partei: unknown): ERechnungPartei { if (!partei) return leerePartei(); const ustId = liste(kind(partei, 'SpecifiedTaxRegistration')) .map((r) => kind(r, 'ID')) .find((id) => attribut(id, 'schemeID') === 'VA'); const kontaktMail = liste(kind(partei, 'DefinedTradeContact')) .map((k) => text(kind(kind(k, 'EmailURIUniversalCommunication'), 'URIID'))) .find((m): m is string => !!m); return { name: text(kind(partei, 'Name')), ustId: text(ustId), kennung: text(kind(partei, 'ID')) ?? text(kind(partei, 'GlobalID')), email: text(kind(kind(partei, 'URIUniversalCommunication'), 'URIID')) ?? kontaktMail ?? null, }; } // === UBL (XRechnung-UBL) === function parseUbl( inv: unknown, wurzelName: 'Invoice' | 'CreditNote', ): ERechnungDaten { const summen = kind(inv, 'LegalMonetaryTotal'); return { syntax: 'UBL', rechnungsnummer: text(kind(inv, 'ID')), rechnungsdatum: normalisiereDatum(text(kind(inv, 'IssueDate'))), typeCode: text( kind( inv, wurzelName === 'CreditNote' ? 'CreditNoteTypeCode' : 'InvoiceTypeCode', ), ), waehrung: text(kind(inv, 'DocumentCurrencyCode')), gesamtbetrag: zuBetrag( text(kind(summen, 'TaxInclusiveAmount')) ?? text(kind(summen, 'PayableAmount')), ), leitwegId: text(kind(inv, 'BuyerReference')), kaeufer: parseUblPartei( kind(kind(inv, 'AccountingCustomerParty'), 'Party'), ), verkaeufer: parseUblPartei( kind(kind(inv, 'AccountingSupplierParty'), 'Party'), ), }; } function parseUblPartei(partei: unknown): ERechnungPartei { if (!partei) return leerePartei(); const ustId = liste(kind(partei, 'PartyTaxScheme')) .filter( (s) => (text(kind(kind(s, 'TaxScheme'), 'ID')) ?? '').toUpperCase() === 'VAT', ) .map((s) => kind(s, 'CompanyID'))[0]; const rechtseinheit = kind(partei, 'PartyLegalEntity'); return { // BT-44/BT-27 ist der eingetragene Name, BT-45/BT-28 nur der Handelsname. name: text(kind(rechtseinheit, 'RegistrationName')) ?? text(kind(kind(partei, 'PartyName'), 'Name')), ustId: text(ustId), kennung: text(kind(kind(partei, 'PartyIdentification'), 'ID')) ?? text(kind(rechtseinheit, 'CompanyID')), email: text(kind(partei, 'EndpointID')) ?? text(kind(kind(partei, 'Contact'), 'ElectronicMail')), }; } // === Hilfsfunktionen === function leerePartei(): ERechnungPartei { return { name: null, ustId: null, kennung: null, email: null }; } /** * Steigt einen Schritt in den Parse-Baum ab. Wiederholte Elemente liefert der * Parser als Array – hier zählt dann das erste Vorkommen. */ function kind(knoten: unknown, name: string): unknown { const erstes: unknown = Array.isArray(knoten) ? (knoten as unknown[])[0] : knoten; if (!erstes || typeof erstes !== 'object') return undefined; return (erstes as Record)[name]; } /** Liest den Textinhalt eines Knotens – auch wenn er Attribute trägt. */ function text(knoten: unknown): string | null { if (knoten === null || knoten === undefined) return null; if (typeof knoten === 'string') return knoten.trim() || null; if (typeof knoten === 'number' || typeof knoten === 'boolean') { return String(knoten); } if (Array.isArray(knoten)) return text((knoten as unknown[])[0]); if (typeof knoten === 'object') { return text((knoten as Record)['#text']); } return null; } function attribut(knoten: unknown, name: string): string | null { const wert = kind(knoten, `@_${name}`); return typeof wert === 'string' ? wert : null; } function liste(knoten: unknown): unknown[] { if (knoten === null || knoten === undefined) return []; return Array.isArray(knoten) ? (knoten as unknown[]) : [knoten]; } function zuBetrag(wert: string | null): number | null { if (!wert) return null; const zahl = Number(wert); return Number.isFinite(zahl) ? zahl : null; } /** * CII liefert das Datum als "20240315" (format="102"), UBL bereits als * ISO-Datum. Alles andere wird unverändert durchgereicht. */ function normalisiereDatum(wert: string | null): string | null { if (!wert) return null; if (/^\d{8}$/.test(wert)) { return `${wert.slice(0, 4)}-${wert.slice(4, 6)}-${wert.slice(6, 8)}`; } if (/^\d{4}-\d{2}-\d{2}/.test(wert)) return wert.slice(0, 10); return wert; }