224 lines
10 KiB
TypeScript
224 lines
10 KiB
TypeScript
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 = <T>(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<ParsedElectronicInvoice, "validation"> => {
|
|
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<ParsedElectronicInvoice, "validation"> => {
|
|
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<ParsedElectronicInvoice, "validation">) => {
|
|
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) }
|
|
}
|