diff --git a/backend/db/migrations/0057_incoming_invoice_einvoice_metadata.sql b/backend/db/migrations/0057_incoming_invoice_einvoice_metadata.sql new file mode 100644 index 0000000..47080c1 --- /dev/null +++ b/backend/db/migrations/0057_incoming_invoice_einvoice_metadata.sql @@ -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; diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 6e6b436..f69d735 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -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 } ] } diff --git a/backend/db/schema/incominginvoices.ts b/backend/db/schema/incominginvoices.ts index 07ac711..caa1ffb 100644 --- a/backend/db/schema/incominginvoices.ts +++ b/backend/db/schema/incominginvoices.ts @@ -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, diff --git a/backend/package-lock.json b/backend/package-lock.json index 36e4ac0..ae09c0e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index fce10d1..6302391 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/modules/cron/prepareIncomingInvoices.ts b/backend/src/modules/cron/prepareIncomingInvoices.ts index 98cbf8d..bb1ef67 100644 --- a/backend/src/modules/cron/prepareIncomingInvoices.ts +++ b/backend/src/modules/cron/prepareIncomingInvoices.ts @@ -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 = { + "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) { } // ------------------------------------------------------------- - // 3️⃣ Jede Datei einzeln durch GPT jagen & IncomingInvoice erzeugen + // 3️⃣ Strukturierte 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,77 +173,34 @@ 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.") + 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) { + server.log.warn(`GPT returned no data for file ${file.id}`) continue } + + itemInfo = mapGptDataToIncomingInvoice(data, tenantId) } - const data = await getInvoiceDataFromGPT(server, file, tenantId, fileData) - - if (!data) { - server.log.warn(`GPT returned no data for file ${file.id}`) - continue - } - - // --------------------------------------------------------- - // 3.1 IncomingInvoice-Objekt vorbereiten - // --------------------------------------------------------- - let itemInfo: any = { - tenant: tenantId, - state: "Vorbereitet" - } - - 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 // --------------------------------------------------------- diff --git a/backend/src/modules/einvoice/detectElectronicInvoice.ts b/backend/src/modules/einvoice/detectElectronicInvoice.ts index 5b174fe..689182e 100644 --- a/backend/src/modules/einvoice/detectElectronicInvoice.ts +++ b/backend/src/modules/einvoice/detectElectronicInvoice.ts @@ -39,14 +39,14 @@ const detectXmlSyntax = (xml: Buffer): ElectronicInvoiceSyntax | null => { } if ( - /)/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 ( - /)/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" diff --git a/backend/src/modules/einvoice/mapElectronicInvoice.ts b/backend/src/modules/einvoice/mapElectronicInvoice.ts new file mode 100644 index 0000000..e8a2531 --- /dev/null +++ b/backend/src/modules/einvoice/mapElectronicInvoice.ts @@ -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 = { + "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, + }, + } +} diff --git a/backend/src/modules/einvoice/parseElectronicInvoice.ts b/backend/src/modules/einvoice/parseElectronicInvoice.ts new file mode 100644 index 0000000..cea5477 --- /dev/null +++ b/backend/src/modules/einvoice/parseElectronicInvoice.ts @@ -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 = (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) } +} diff --git a/backend/src/modules/einvoice/types.ts b/backend/src/modules/einvoice/types.ts new file mode 100644 index 0000000..607a44e --- /dev/null +++ b/backend/src/modules/einvoice/types.ts @@ -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[] + } +} diff --git a/backend/tests/einvoice.test.ts b/backend/tests/einvoice.test.ts new file mode 100644 index 0000000..e58f9e9 --- /dev/null +++ b/backend/tests/einvoice.test.ts @@ -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(` + + urn:factur-x.eu:1p0:en16931 + RE-2026-10038020260721 + + + Wartung + 2 + S19100.00 + + Leitweg-1Beispiel GmbHDE123456789 + EUR5820260820100.0019.00119.00119.00 + +`) + +const ublInvoice = Buffer.from(` + + urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0XR-422026-07-212026-08-20EUR + UBL LieferantDE987654321VAT + 30 + 7.00 + 100.00107.00107.00 + 11100.00MaterialS7 +`) + +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("100.00", "90.00")) + const parsed = parseElectronicInvoice(invalid, "cii") + + assert.match(parsed.validation.errors.join(" "), /Positionssumme/) +}) diff --git a/frontend/pages/incomingInvoices/[mode]/[id].vue b/frontend/pages/incomingInvoices/[mode]/[id].vue index d30f8ef..fdc8ff3 100644 --- a/frontend/pages/incomingInvoices/[mode]/[id].vue +++ b/frontend/pages/incomingInvoices/[mode]/[id].vue @@ -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 + + + + { {{row.original.state}} {{row.original.state}} {{row.original.state}} + {{row.original.state}} + {{row.original.state}} + +