import { XMLParser } from "fast-xml-parser" import type { ElectronicInvoiceSyntax } from "./detectElectronicInvoice" import type { ElectronicInvoiceItem, ParsedElectronicInvoice } from "./types" const parser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, parseTagValue: false, trimValues: true, }) const array = (value: T | T[] | null | undefined): T[] => value == null ? [] : Array.isArray(value) ? value : [value] const text = (value: any): string | null => { if (value == null) return null if (typeof value === "string" || typeof value === "number") return String(value).trim() || null if (typeof value === "object" && value["#text"] != null) return text(value["#text"]) return null } const number = (value: any): number | null => { const raw = text(value) if (raw == null) return null const parsed = Number(raw) return Number.isFinite(parsed) ? parsed : null } const roundMoney = (value: number) => Number(value.toFixed(2)) const date = (value: any): string | null => { const raw = text(value?.DateTimeString ?? value) if (!raw) return null if (/^\d{8}$/.test(raw)) { return `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}` } const match = raw.match(/^\d{4}-\d{2}-\d{2}/) return match?.[0] || null } const findTaxRegistration = (party: any, schemeId: string) => { return text(array(party?.SpecifiedTaxRegistration).find((entry: any) => { return String(entry?.ID?.["@_schemeID"] || "").toUpperCase() === schemeId })?.ID) } const ciiDescription = (line: any) => { const product = line?.SpecifiedTradeProduct || {} return [text(product.Name), text(product.Description)].filter(Boolean).join(" - ") || "Position" } const parseCii = (root: any): Omit => { const transaction = root?.SupplyChainTradeTransaction || {} const agreement = transaction?.ApplicableHeaderTradeAgreement || {} const settlement = transaction?.ApplicableHeaderTradeSettlement || {} const monetary = settlement?.SpecifiedTradeSettlementHeaderMonetarySummation || {} const seller = agreement?.SellerTradeParty || {} const typeCode = text(root?.ExchangedDocument?.TypeCode) const sign = typeCode === "381" ? -1 : 1 const items: ElectronicInvoiceItem[] = array(transaction?.IncludedSupplyChainTradeLineItem).map((line: any) => { const delivery = line?.SpecifiedLineTradeDelivery || {} const lineSettlement = line?.SpecifiedLineTradeSettlement || {} const tradeTax = array(lineSettlement?.ApplicableTradeTax)[0] || {} const quantity = number(delivery?.BilledQuantity) const net = number(lineSettlement?.SpecifiedTradeSettlementLineMonetarySummation?.LineTotalAmount) || 0 const taxRate = number(tradeTax?.RateApplicablePercent) || 0 const signedNet = roundMoney(sign * net) const taxAmount = roundMoney(signedNet * taxRate / 100) return { description: ciiDescription(line), quantity, unitCode: text(delivery?.BilledQuantity?.["@_unitCode"]), netAmount: signedNet, taxRate, taxCategory: text(tradeTax?.CategoryCode), taxAmount, grossAmount: roundMoney(signedNet + taxAmount), } }) const paymentMeans = array(settlement?.SpecifiedTradeSettlementPaymentMeans)[0] || {} const paymentTerms = array(settlement?.SpecifiedTradePaymentTerms)[0] || {} const guideline = root?.ExchangedDocumentContext?.GuidelineSpecifiedDocumentContextParameter?.ID return { syntax: "cii", profileId: text(guideline), invoiceNumber: text(root?.ExchangedDocument?.ID), invoiceDate: date(root?.ExchangedDocument?.IssueDateTime), dueDate: date(paymentTerms?.DueDateDateTime), currency: text(settlement?.InvoiceCurrencyCode), invoiceType: typeCode === "381" ? "credit-note" : "invoice", seller: { name: text(seller?.Name), vatId: findTaxRegistration(seller, "VA"), taxNumber: findTaxRegistration(seller, "FC"), iban: text(paymentMeans?.PayeePartyCreditorFinancialAccount?.IBANID), bic: text(paymentMeans?.PayeeSpecifiedCreditorFinancialInstitution?.BICID), }, buyerReference: text(agreement?.BuyerReference), paymentMeansCode: text(paymentMeans?.TypeCode), items, totals: { lineNet: number(monetary?.LineTotalAmount) != null ? roundMoney(sign * number(monetary.LineTotalAmount)!) : null, tax: number(monetary?.TaxTotalAmount) != null ? roundMoney(sign * number(monetary.TaxTotalAmount)!) : null, gross: number(monetary?.GrandTotalAmount) != null ? roundMoney(sign * number(monetary.GrandTotalAmount)!) : null, payable: number(monetary?.DuePayableAmount) != null ? roundMoney(sign * number(monetary.DuePayableAmount)!) : null, }, } } const ublPartyName = (party: any) => text(party?.PartyLegalEntity?.RegistrationName) || text(party?.PartyName?.Name) const parseUbl = (root: any, syntax: "ubl-invoice" | "ubl-credit-note"): Omit => { const isCreditNote = syntax === "ubl-credit-note" const sign = isCreditNote ? -1 : 1 const supplier = root?.AccountingSupplierParty?.Party || {} const monetary = root?.LegalMonetaryTotal || {} const paymentMeans = array(root?.PaymentMeans)[0] || {} const sourceLines = array(isCreditNote ? root?.CreditNoteLine : root?.InvoiceLine) const items: ElectronicInvoiceItem[] = sourceLines.map((line: any) => { const quantityValue = isCreditNote ? line?.CreditedQuantity : line?.InvoicedQuantity const taxCategory = line?.Item?.ClassifiedTaxCategory || {} const net = number(line?.LineExtensionAmount) || 0 const taxRate = number(taxCategory?.Percent) || 0 const signedNet = roundMoney(sign * net) const taxAmount = roundMoney(signedNet * taxRate / 100) return { description: [text(line?.Item?.Name), text(line?.Item?.Description)].filter(Boolean).join(" - ") || "Position", quantity: number(quantityValue), unitCode: text(quantityValue?.["@_unitCode"]), netAmount: signedNet, taxRate, taxCategory: text(taxCategory?.ID), taxAmount, grossAmount: roundMoney(signedNet + taxAmount), } }) const taxSchemeIds = array(supplier?.PartyTaxScheme) const vatId = text(taxSchemeIds.find((entry: any) => text(entry?.TaxScheme?.ID)?.toUpperCase() === "VAT")?.CompanyID) return { syntax, profileId: text(root?.CustomizationID) || text(root?.ProfileID), invoiceNumber: text(root?.ID), invoiceDate: date(root?.IssueDate), dueDate: date(root?.DueDate) || date(paymentMeans?.PaymentDueDate), currency: text(root?.DocumentCurrencyCode), invoiceType: isCreditNote ? "credit-note" : "invoice", seller: { name: ublPartyName(supplier), vatId, taxNumber: text(supplier?.PartyTaxScheme?.CompanyID), iban: text(paymentMeans?.PayeeFinancialAccount?.ID), bic: text(paymentMeans?.PayeeFinancialAccount?.FinancialInstitutionBranch?.ID), }, buyerReference: text(root?.BuyerReference), paymentMeansCode: text(paymentMeans?.PaymentMeansCode), items, totals: { lineNet: number(monetary?.LineExtensionAmount) != null ? roundMoney(sign * number(monetary.LineExtensionAmount)!) : null, tax: number(root?.TaxTotal?.TaxAmount) != null ? roundMoney(sign * number(root.TaxTotal.TaxAmount)!) : null, gross: number(monetary?.TaxInclusiveAmount) != null ? roundMoney(sign * number(monetary.TaxInclusiveAmount)!) : null, payable: number(monetary?.PayableAmount) != null ? roundMoney(sign * number(monetary.PayableAmount)!) : null, }, } } const validate = (invoice: Omit) => { const errors: string[] = [] const warnings: string[] = [] const tolerance = 0.02 if (!invoice.invoiceNumber) errors.push("Rechnungsnummer fehlt.") if (!invoice.invoiceDate) errors.push("Rechnungsdatum fehlt oder ist ungültig.") if (!invoice.currency) errors.push("Rechnungswährung fehlt.") if (!invoice.seller.name) errors.push("Name des Rechnungsstellers fehlt.") if (invoice.items.length === 0) errors.push("Die E-Rechnung enthält keine Rechnungspositionen.") const calculatedLineNet = roundMoney(invoice.items.reduce((sum, item) => sum + item.netAmount, 0)) const calculatedTax = roundMoney(invoice.items.reduce((sum, item) => sum + item.taxAmount, 0)) if (invoice.totals.lineNet != null && Math.abs(invoice.totals.lineNet - calculatedLineNet) > tolerance) { errors.push(`Positionssumme ${calculatedLineNet.toFixed(2)} stimmt nicht mit der Nettosumme ${invoice.totals.lineNet.toFixed(2)} überein.`) } if (invoice.totals.tax != null && Math.abs(invoice.totals.tax - calculatedTax) > tolerance) { warnings.push(`Berechnete Steuer ${calculatedTax.toFixed(2)} weicht von der Steuer-Gesamtsumme ${invoice.totals.tax.toFixed(2)} ab.`) } if (invoice.totals.gross != null && invoice.totals.lineNet != null && invoice.totals.tax != null) { const expectedGross = roundMoney(invoice.totals.lineNet + invoice.totals.tax) if (Math.abs(expectedGross - invoice.totals.gross) > tolerance) { errors.push(`Bruttosumme ${invoice.totals.gross.toFixed(2)} stimmt nicht mit Netto plus Steuer ${expectedGross.toFixed(2)} überein.`) } } if (!invoice.seller.vatId) warnings.push("Keine USt-ID des Rechnungsstellers enthalten.") if (!invoice.dueDate) warnings.push("Kein Fälligkeitsdatum enthalten.") for (const taxRate of new Set(invoice.items.map((item) => item.taxRate))) { if (![0, 7, 19].includes(taxRate)) warnings.push(`Steuersatz ${taxRate}% ist in FEDEO noch keinem Standard-Steuerschlüssel zugeordnet.`) } return { errors, warnings } } export const parseElectronicInvoice = (xml: Buffer, syntax: ElectronicInvoiceSyntax): ParsedElectronicInvoice => { const parsed = parser.parse(xml.toString("utf8")) const root = syntax === "cii" ? parsed.CrossIndustryInvoice : syntax === "ubl-credit-note" ? parsed.CreditNote : parsed.Invoice if (!root) throw new Error(`XML-Wurzelelement für ${syntax} wurde nicht gefunden.`) const invoice = syntax === "cii" ? parseCii(root) : parseUbl(root, syntax) return { ...invoice, validation: validate(invoice) } }