KI-AGENT: E-Rechnungen in der Belegvorbereitung verarbeiten

This commit is contained in:
2026-07-21 20:20:34 +02:00
parent 6c613857fa
commit 5e7a1f9aee
14 changed files with 708 additions and 79 deletions

View File

@@ -0,0 +1,5 @@
ALTER TABLE "incominginvoices"
ADD COLUMN IF NOT EXISTS "preparation_source" text,
ADD COLUMN IF NOT EXISTS "e_invoice_syntax" text,
ADD COLUMN IF NOT EXISTS "e_invoice_profile" text,
ADD COLUMN IF NOT EXISTS "e_invoice_validation" jsonb;

View File

@@ -379,6 +379,13 @@
"when": 1784548800000,
"tag": "0056_auth_refresh_tokens",
"breakpoints": true
},
{
"idx": 54,
"version": "7",
"when": 1784635200000,
"tag": "0057_incoming_invoice_einvoice_metadata",
"breakpoints": true
}
]
}

View File

@@ -40,6 +40,11 @@ export const incominginvoices = pgTable("incominginvoices", {
paymentType: text("paymentType"),
preparationSource: text("preparation_source"),
eInvoiceSyntax: text("e_invoice_syntax"),
eInvoiceProfile: text("e_invoice_profile"),
eInvoiceValidation: jsonb("e_invoice_validation"),
accounts: jsonb("accounts").notNull().default([
{
account: null,

View File

@@ -27,6 +27,7 @@
"crypto": "^1.0.1",
"dayjs": "^1.11.18",
"drizzle-orm": "^0.45.0",
"fast-xml-parser": "^5.10.1",
"fastify": "^5.5.0",
"fastify-plugin": "^5.0.1",
"handlebars": "^4.7.8",
@@ -5290,6 +5291,24 @@
"node": ">=18.0.0"
}
},
"node_modules/@aws-sdk/xml-builder/node_modules/fast-xml-parser": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^2.1.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz",
@@ -5850,6 +5869,18 @@
"eventemitter3": "^5.0.1"
}
},
"node_modules/@nodable/entities": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
"integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
@@ -6881,6 +6912,18 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/anynum": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/archiver": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
@@ -8004,10 +8047,10 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-parser": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
"node_modules/fast-xml-builder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz",
"integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==",
"funding": [
{
"type": "github",
@@ -8016,7 +8059,28 @@
],
"license": "MIT",
"dependencies": {
"strnum": "^2.1.0"
"path-expression-matcher": "^1.6.2",
"xml-naming": "^0.3.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz",
"integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"@nodable/entities": "^3.0.0",
"fast-xml-builder": "^1.2.0",
"is-unsafe": "^2.0.0",
"path-expression-matcher": "^1.6.2",
"strnum": "^2.4.1",
"xml-naming": "^0.3.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -8517,6 +8581,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-unsafe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz",
"integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@@ -9098,6 +9174,21 @@
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/path-expression-matcher": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -9940,16 +10031,19 @@
}
},
"node_modules/strnum": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz",
"integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
"license": "MIT",
"dependencies": {
"anynum": "^1.0.1"
}
},
"node_modules/tar-stream": {
"version": "3.1.7",
@@ -10351,6 +10445,21 @@
"node": ">= 4"
}
},
"node_modules/xml-naming": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",

View File

@@ -9,6 +9,7 @@
"fill": "ts-node src/webdav/fill-file-sizes.ts",
"dev:dav": "tsx watch src/webdav/server.ts",
"build": "tsc",
"test:einvoice": "tsx --test tests/einvoice.test.ts",
"start": "node dist/src/index.js",
"schema:index": "ts-node scripts/generate-schema-index.ts",
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
@@ -43,6 +44,7 @@
"crypto": "^1.0.1",
"dayjs": "^1.11.18",
"drizzle-orm": "^0.45.0",
"fast-xml-parser": "^5.10.1",
"fastify": "^5.5.0",
"fastify-plugin": "^5.0.1",
"handlebars": "^4.7.8",

View File

@@ -3,6 +3,11 @@ import dayjs from "dayjs"
import { getInvoiceDataFromGPT } from "../../utils/gpt"
import { loadFileBuffer } from "../../utils/fileBuffer"
import { detectElectronicInvoice } from "../einvoice/detectElectronicInvoice"
import { parseElectronicInvoice } from "../einvoice/parseElectronicInvoice"
import {
findElectronicInvoiceVendor,
mapElectronicInvoiceToIncomingInvoice,
} from "../einvoice/mapElectronicInvoice"
// Drizzle schema
import {
@@ -26,6 +31,61 @@ const formatInvoiceItemDescription = (item: any) => {
return parts.join(" - ")
}
const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
const itemInfo: any = {
tenant: tenantId,
state: "Vorbereitet",
preparationSource: "gpt",
}
if (data.invoice_number) itemInfo.reference = data.invoice_number
if (data.invoice_date && dayjs(data.invoice_date).isValid()) itemInfo.date = dayjs(data.invoice_date).toISOString()
if (data.issuer?.id) itemInfo.vendor = data.issuer.id
if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
const mapPayment: Record<string, string> = {
"Direct Debit": "Einzug",
"Transfer": "Überweisung",
"Credit Card": "Kreditkarte",
"Other": "Sonstiges",
}
if (data.terms) itemInfo.paymentType = mapPayment[data.terms] ?? data.terms
if (data.invoice_items?.length > 0) {
itemInfo.accounts = data.invoice_items
.filter((item: any) => item.description || item.total !== null || item.total_without_tax !== null)
.map((item: any) => {
const total = typeof item.total === "number" ? item.total : null
const totalWithoutTax = typeof item.total_without_tax === "number" ? item.total_without_tax : null
const amountTax = total !== null && totalWithoutTax !== null
? Number((total - totalWithoutTax).toFixed(2))
: null
return {
account: item.account_id,
description: item.description,
amountNet: totalWithoutTax,
amountTax,
taxType: item.tax_rate !== null ? String(item.tax_rate) : null,
amountGross: total,
costCentre: null,
quantity: item.quantity,
}
})
}
let description = ""
if (data.delivery_note_number) description += `Lieferschein: ${data.delivery_note_number}\n`
if (data.reference) description += `Referenz: ${data.reference}\n`
for (const item of data.invoice_items || []) {
const line = formatInvoiceItemDescription(item)
if (line) description += `${line}\n`
}
itemInfo.description = description.trim()
return itemInfo
}
export function prepareIncomingInvoices(server: FastifyInstance) {
const processInvoices = async (tenantId:number) => {
console.log("▶ Starting Incoming Invoice Preparation")
@@ -87,7 +147,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
}
// -------------------------------------------------------------
// 3Jede Datei einzeln durch GPT jagen & IncomingInvoice erzeugen
// 3Strukturierte E-Rechnung bevorzugen, GPT als PDF-Fallback verwenden
// -------------------------------------------------------------
for (const file of filesRes) {
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
@@ -103,6 +163,8 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
const electronicInvoice = await detectElectronicInvoice(fileData, file)
let itemInfo: any = null
if (electronicInvoice) {
server.log.info({
fileId: file.id,
@@ -111,12 +173,24 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
attachmentName: electronicInvoice.attachmentName,
}, "Strukturierte E-Rechnung erkannt.")
if (electronicInvoice.container === "xml") {
server.log.warn({ fileId: file.id }, "Die Auswertung eigenständiger E-Rechnungs-XML folgt in Etappe 2.")
continue
try {
const parsedInvoice = parseElectronicInvoice(electronicInvoice.xml, electronicInvoice.syntax)
const vendor = await findElectronicInvoiceVendor(server, tenantId, parsedInvoice)
itemInfo = mapElectronicInvoiceToIncomingInvoice(
parsedInvoice,
tenantId,
vendor?.id || null,
electronicInvoice.container,
electronicInvoice.attachmentName,
)
} catch (error) {
server.log.error(error, `E-Rechnung aus Datei ${file.id} konnte nicht verarbeitet werden.`)
if (electronicInvoice.container === "xml") continue
}
}
if (!itemInfo) {
const data = await getInvoiceDataFromGPT(server, file, tenantId, fileData)
if (!data) {
@@ -124,64 +198,9 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
continue
}
// ---------------------------------------------------------
// 3.1 IncomingInvoice-Objekt vorbereiten
// ---------------------------------------------------------
let itemInfo: any = {
tenant: tenantId,
state: "Vorbereitet"
itemInfo = mapGptDataToIncomingInvoice(data, tenantId)
}
if (data.invoice_number) itemInfo.reference = data.invoice_number
if (data.invoice_date && dayjs(data.invoice_date).isValid()) itemInfo.date = dayjs(data.invoice_date).toISOString()
if (data.issuer?.id) itemInfo.vendor = data.issuer.id
if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
// Payment terms mapping
const mapPayment: any = {
"Direct Debit": "Einzug",
"Transfer": "Überweisung",
"Credit Card": "Kreditkarte",
"Other": "Sonstiges",
}
if (data.terms) itemInfo.paymentType = mapPayment[data.terms] ?? data.terms
// 3.2 Positionszeilen konvertieren
if (data.invoice_items?.length > 0) {
itemInfo.accounts = data.invoice_items
.filter(item => item.description || item.total !== null || item.total_without_tax !== null)
.map(item => {
const total = typeof item.total === "number" ? item.total : null
const totalWithoutTax = typeof item.total_without_tax === "number" ? item.total_without_tax : null
const amountTax = total !== null && totalWithoutTax !== null
? Number((total - totalWithoutTax).toFixed(2))
: null
return {
account: item.account_id,
description: item.description,
amountNet: totalWithoutTax,
amountTax,
taxType: item.tax_rate !== null ? String(item.tax_rate) : null,
amountGross: total,
costCentre: null,
quantity: item.quantity,
}
})
}
// 3.3 Beschreibung generieren
let description = ""
if (data.delivery_note_number) description += `Lieferschein: ${data.delivery_note_number}\n`
if (data.reference) description += `Referenz: ${data.reference}\n`
if (data.invoice_items) {
for (const item of data.invoice_items) {
const line = formatInvoiceItemDescription(item)
if (line) description += `${line}\n`
}
}
itemInfo.description = description.trim()
// ---------------------------------------------------------
// 4⃣ IncomingInvoice erstellen
// ---------------------------------------------------------

View File

@@ -39,14 +39,14 @@ const detectXmlSyntax = (xml: Buffer): ElectronicInvoiceSyntax | null => {
}
if (
/<Invoice(?:\s|>)/i.test(sample)
/<(?:[A-Za-z_][\w.-]*:)?Invoice(?:\s|>)/i.test(sample)
&& /urn:oasis:names:specification:ubl:schema:xsd:Invoice-2/i.test(sample)
) {
return "ubl-invoice"
}
if (
/<CreditNote(?:\s|>)/i.test(sample)
/<(?:[A-Za-z_][\w.-]*:)?CreditNote(?:\s|>)/i.test(sample)
&& /urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2/i.test(sample)
) {
return "ubl-credit-note"

View File

@@ -0,0 +1,93 @@
import { and, eq } from "drizzle-orm"
import type { FastifyInstance } from "fastify"
import { vendors } from "../../../db/schema"
import type { ParsedElectronicInvoice } from "./types"
const normalizeIdentifier = (value: unknown) => String(value || "").replace(/[^A-Za-z0-9]/g, "").toUpperCase()
const normalizeName = (value: unknown) => String(value || "").normalize("NFKD").replace(/[^A-Za-z0-9]/g, "").toUpperCase()
const paymentTypeByCode: Record<string, string> = {
"10": "Bar",
"30": "Überweisung",
"48": "Kreditkarte",
"49": "Lastschrift",
"57": "Sonstiges",
"58": "Überweisung",
"59": "Lastschrift",
}
export const findElectronicInvoiceVendor = async (
server: FastifyInstance,
tenantId: number,
invoice: ParsedElectronicInvoice,
) => {
const candidates = await server.db.select().from(vendors).where(and(
eq(vendors.tenant, tenantId),
eq(vendors.archived, false),
))
const vatId = normalizeIdentifier(invoice.seller.vatId)
if (vatId) {
const vatMatches = candidates.filter((vendor: any) => normalizeIdentifier(vendor.infoData?.ustid) === vatId)
if (vatMatches.length === 1) return vatMatches[0]
}
const sellerName = normalizeName(invoice.seller.name)
if (sellerName) {
const nameMatches = candidates.filter((vendor) => normalizeName(vendor.name) === sellerName)
if (nameMatches.length === 1) return nameMatches[0]
}
return null
}
export const mapElectronicInvoiceToIncomingInvoice = (
invoice: ParsedElectronicInvoice,
tenantId: number,
vendorId: number | null,
container: "pdf" | "xml",
attachmentName: string | null,
) => {
const warnings = [...invoice.validation.warnings]
if (!vendorId) warnings.push("Rechnungssteller konnte keinem Lieferanten eindeutig zugeordnet werden.")
const state = invoice.validation.errors.length > 0 || !vendorId ? "Prüfung erforderlich" : "Vorbereitet"
const descriptionParts = [
invoice.buyerReference ? `Käuferreferenz: ${invoice.buyerReference}` : null,
...invoice.items.map((item) => item.description),
].filter(Boolean)
return {
tenant: tenantId,
state,
vendor: vendorId,
reference: invoice.invoiceNumber,
date: invoice.invoiceDate,
dueDate: invoice.dueDate,
paymentType: paymentTypeByCode[invoice.paymentMeansCode || ""] || "Sonstiges",
description: descriptionParts.join("\n"),
accounts: invoice.items.map((item) => ({
account: null,
description: item.description,
amountNet: item.netAmount,
amountTax: item.taxAmount,
taxType: item.taxCategory === "AE" ? "13B" : item.taxRate === 0 ? "null" : String(item.taxRate),
amountGross: item.grossAmount,
costCentre: null,
quantity: item.quantity,
unitCode: item.unitCode,
})),
preparationSource: "e-invoice",
eInvoiceSyntax: invoice.syntax,
eInvoiceProfile: invoice.profileId,
eInvoiceValidation: {
errors: invoice.validation.errors,
warnings,
totals: invoice.totals,
seller: invoice.seller,
invoiceType: invoice.invoiceType,
container,
attachmentName,
},
}
}

View File

@@ -0,0 +1,223 @@
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) }
}

View File

@@ -0,0 +1,42 @@
import type { ElectronicInvoiceSyntax } from "./detectElectronicInvoice"
export type ElectronicInvoiceItem = {
description: string
quantity: number | null
unitCode: string | null
netAmount: number
taxRate: number
taxCategory: string | null
taxAmount: number
grossAmount: number
}
export type ParsedElectronicInvoice = {
syntax: ElectronicInvoiceSyntax
profileId: string | null
invoiceNumber: string | null
invoiceDate: string | null
dueDate: string | null
currency: string | null
invoiceType: "invoice" | "credit-note"
seller: {
name: string | null
vatId: string | null
taxNumber: string | null
iban: string | null
bic: string | null
}
buyerReference: string | null
paymentMeansCode: string | null
items: ElectronicInvoiceItem[]
totals: {
lineNet: number | null
tax: number | null
gross: number | null
payable: number | null
}
validation: {
errors: string[]
warnings: string[]
}
}

View File

@@ -0,0 +1,68 @@
import assert from "node:assert/strict"
import test from "node:test"
import { PDFDocument } from "pdf-lib"
import { detectElectronicInvoice } from "../src/modules/einvoice/detectElectronicInvoice"
import { parseElectronicInvoice } from "../src/modules/einvoice/parseElectronicInvoice"
import { mapElectronicInvoiceToIncomingInvoice } from "../src/modules/einvoice/mapElectronicInvoice"
const ciiInvoice = Buffer.from(`<?xml version="1.0" encoding="UTF-8"?>
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100" xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
<rsm:ExchangedDocumentContext><ram:GuidelineSpecifiedDocumentContextParameter><ram:ID>urn:factur-x.eu:1p0:en16931</ram:ID></ram:GuidelineSpecifiedDocumentContextParameter></rsm:ExchangedDocumentContext>
<rsm:ExchangedDocument><ram:ID>RE-2026-100</ram:ID><ram:TypeCode>380</ram:TypeCode><ram:IssueDateTime><udt:DateTimeString format="102">20260721</udt:DateTimeString></ram:IssueDateTime></rsm:ExchangedDocument>
<rsm:SupplyChainTradeTransaction>
<ram:IncludedSupplyChainTradeLineItem>
<ram:SpecifiedTradeProduct><ram:Name>Wartung</ram:Name></ram:SpecifiedTradeProduct>
<ram:SpecifiedLineTradeDelivery><ram:BilledQuantity unitCode="HUR">2</ram:BilledQuantity></ram:SpecifiedLineTradeDelivery>
<ram:SpecifiedLineTradeSettlement><ram:ApplicableTradeTax><ram:CategoryCode>S</ram:CategoryCode><ram:RateApplicablePercent>19</ram:RateApplicablePercent></ram:ApplicableTradeTax><ram:SpecifiedTradeSettlementLineMonetarySummation><ram:LineTotalAmount>100.00</ram:LineTotalAmount></ram:SpecifiedTradeSettlementLineMonetarySummation></ram:SpecifiedLineTradeSettlement>
</ram:IncludedSupplyChainTradeLineItem>
<ram:ApplicableHeaderTradeAgreement><ram:BuyerReference>Leitweg-1</ram:BuyerReference><ram:SellerTradeParty><ram:Name>Beispiel GmbH</ram:Name><ram:SpecifiedTaxRegistration><ram:ID schemeID="VA">DE123456789</ram:ID></ram:SpecifiedTaxRegistration></ram:SellerTradeParty></ram:ApplicableHeaderTradeAgreement>
<ram:ApplicableHeaderTradeSettlement><ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode><ram:SpecifiedTradeSettlementPaymentMeans><ram:TypeCode>58</ram:TypeCode></ram:SpecifiedTradeSettlementPaymentMeans><ram:SpecifiedTradePaymentTerms><ram:DueDateDateTime><udt:DateTimeString format="102">20260820</udt:DateTimeString></ram:DueDateDateTime></ram:SpecifiedTradePaymentTerms><ram:SpecifiedTradeSettlementHeaderMonetarySummation><ram:LineTotalAmount>100.00</ram:LineTotalAmount><ram:TaxTotalAmount>19.00</ram:TaxTotalAmount><ram:GrandTotalAmount>119.00</ram:GrandTotalAmount><ram:DuePayableAmount>119.00</ram:DuePayableAmount></ram:SpecifiedTradeSettlementHeaderMonetarySummation></ram:ApplicableHeaderTradeSettlement>
</rsm:SupplyChainTradeTransaction>
</rsm:CrossIndustryInvoice>`)
const ublInvoice = Buffer.from(`<?xml version="1.0" encoding="UTF-8"?>
<ubl:Invoice xmlns:ubl="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0</cbc:CustomizationID><cbc:ID>XR-42</cbc:ID><cbc:IssueDate>2026-07-21</cbc:IssueDate><cbc:DueDate>2026-08-20</cbc:DueDate><cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
<cac:AccountingSupplierParty><cac:Party><cac:PartyName><cbc:Name>UBL Lieferant</cbc:Name></cac:PartyName><cac:PartyTaxScheme><cbc:CompanyID>DE987654321</cbc:CompanyID><cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme></cac:PartyTaxScheme></cac:Party></cac:AccountingSupplierParty>
<cac:PaymentMeans><cbc:PaymentMeansCode>30</cbc:PaymentMeansCode></cac:PaymentMeans>
<cac:TaxTotal><cbc:TaxAmount currencyID="EUR">7.00</cbc:TaxAmount></cac:TaxTotal>
<cac:LegalMonetaryTotal><cbc:LineExtensionAmount currencyID="EUR">100.00</cbc:LineExtensionAmount><cbc:TaxInclusiveAmount currencyID="EUR">107.00</cbc:TaxInclusiveAmount><cbc:PayableAmount currencyID="EUR">107.00</cbc:PayableAmount></cac:LegalMonetaryTotal>
<cac:InvoiceLine><cbc:ID>1</cbc:ID><cbc:InvoicedQuantity unitCode="C62">1</cbc:InvoicedQuantity><cbc:LineExtensionAmount currencyID="EUR">100.00</cbc:LineExtensionAmount><cac:Item><cbc:Name>Material</cbc:Name><cac:ClassifiedTaxCategory><cbc:ID>S</cbc:ID><cbc:Percent>7</cbc:Percent></cac:ClassifiedTaxCategory></cac:Item></cac:InvoiceLine>
</ubl:Invoice>`)
test("erkennt und liest eine eingebettete Factur-X-Rechnung", async () => {
const document = await PDFDocument.create()
document.addPage()
await document.attach(ciiInvoice, "factur-x.xml", { mimeType: "application/xml", afRelationship: "Data" })
const detection = await detectElectronicInvoice(Buffer.from(await document.save()), { name: "rechnung.pdf" })
assert.equal(detection?.container, "pdf")
assert.equal(detection?.syntax, "cii")
const parsed = parseElectronicInvoice(detection!.xml, detection!.syntax)
assert.equal(parsed.invoiceNumber, "RE-2026-100")
assert.equal(parsed.seller.vatId, "DE123456789")
assert.equal(parsed.items[0].grossAmount, 119)
assert.deepEqual(parsed.validation.errors, [])
})
test("liest eine eigenständige UBL-XRechnung und mappt sie nach FEDEO", async () => {
const detection = await detectElectronicInvoice(ublInvoice, { name: "xrechnung.xml" })
assert.equal(detection?.syntax, "ubl-invoice")
const parsed = parseElectronicInvoice(detection!.xml, detection!.syntax)
const mapped = mapElectronicInvoiceToIncomingInvoice(parsed, 5, 12, "xml", "xrechnung.xml")
assert.equal(mapped.reference, "XR-42")
assert.equal(mapped.vendor, 12)
assert.equal(mapped.paymentType, "Überweisung")
assert.equal(mapped.state, "Vorbereitet")
assert.equal(mapped.accounts[0].amountTax, 7)
})
test("markiert Summenabweichungen als Prüfungsfehler", () => {
const invalid = Buffer.from(ciiInvoice.toString("utf8").replace("<ram:LineTotalAmount>100.00</ram:LineTotalAmount><ram:TaxTotalAmount>", "<ram:LineTotalAmount>90.00</ram:LineTotalAmount><ram:TaxTotalAmount>"))
const parsed = parseElectronicInvoice(invalid, "cii")
assert.match(parsed.validation.errors.join(" "), /Positionssumme/)
})

View File

@@ -149,6 +149,18 @@ const bankBookingDateLabel = computed(() => {
return bankBookingDates.value.map(formatDate).join(", ")
})
const vendorName = computed(() => vendors.value.find((vendor) => vendor.id === itemInfo.value.vendor)?.name || "-")
const eInvoiceValidation = computed(() => itemInfo.value.eInvoiceValidation || null)
const eInvoiceSourceLabel = computed(() => {
if (itemInfo.value.preparationSource !== "e-invoice") return null
if (itemInfo.value.eInvoiceSyntax === "cii") {
return String(itemInfo.value.eInvoiceProfile || "").toLowerCase().includes("xrechnung")
? "XRechnung (CII)"
: "ZUGFeRD / Factur-X (CII)"
}
if (itemInfo.value.eInvoiceSyntax === "ubl-credit-note") return "XRechnung / UBL-Gutschrift"
return "XRechnung / UBL"
})
const eInvoiceProfileLabel = computed(() => itemInfo.value.eInvoiceProfile || "Profil nicht angegeben")
const getAccountLabel = (item) => {
const account = accounts.value.find((entry) => entry.id === item.account)
@@ -356,6 +368,35 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
Dokument andocken
</UButton>
<UAlert
v-if="eInvoiceSourceLabel"
:title="eInvoiceSourceLabel"
color="primary"
variant="soft"
icon="i-heroicons-document-check"
>
<template #description>
<div class="mt-1 space-y-2 text-sm">
<p class="break-all">{{ eInvoiceProfileLabel }}</p>
<div v-if="eInvoiceValidation?.errors?.length">
<p class="font-semibold">Validierungsfehler</p>
<ul class="list-inside list-disc">
<li v-for="error in eInvoiceValidation.errors" :key="error">{{ error }}</li>
</ul>
</div>
<div v-if="eInvoiceValidation?.warnings?.length">
<p class="font-semibold">Hinweise</p>
<ul class="list-inside list-disc">
<li v-for="warning in eInvoiceValidation.warnings" :key="warning">{{ warning }}</li>
</ul>
</div>
<p v-if="!eInvoiceValidation?.errors?.length && !eInvoiceValidation?.warnings?.length">
Pflichtfelder und Rechnungssummen wurden erfolgreich geprüft.
</p>
</div>
</template>
</UAlert>
<UAlert
v-if="mode !== 'show' && findIncomingInvoiceErrors.length > 0"
title="Prüfung erforderlich"

View File

@@ -287,6 +287,18 @@ const selectIncomingInvoice = (invoiceLike) => {
<span v-if="row.original.state === 'Vorbereitet'" class="text-cyan-500">{{row.original.state}}</span>
<span v-else-if="row.original.state === 'Entwurf'" class="text-red-500">{{row.original.state}}</span>
<span v-else-if="row.original.state === 'Gebucht'" class="text-primary-500">{{row.original.state}}</span>
<span v-else-if="row.original.state === 'Prüfung erforderlich'" class="text-orange-500">{{row.original.state}}</span>
<span v-else>{{row.original.state}}</span>
</template>
<template #preparationSource-cell="{row}">
<UBadge v-if="row.original.preparationSource === 'e-invoice'" color="primary" variant="soft">
{{ row.original.eInvoiceSyntax === 'cii'
? (String(row.original.eInvoiceProfile || '').toLowerCase().includes('xrechnung') ? 'XRechnung / CII' : 'ZUGFeRD / CII')
: 'XRechnung / UBL' }}
</UBadge>
<UBadge v-else-if="row.original.preparationSource === 'gpt'" color="neutral" variant="soft">
PDF / KI
</UBadge>
</template>
<template #date-cell="{row}">
{{dayjs(row.original.date).format("DD.MM.YYYY")}}

View File

@@ -2460,6 +2460,9 @@ export const useDataStore = defineStore('data', () => {
key: 'reference',
label: "Referenz:",
sortable: true,
}, {
key: 'preparationSource',
label: "Quelle",
}, {
key: 'state',
label: "Status:"