diff --git a/backend/db/migrations/0056_auth_refresh_tokens.sql b/backend/db/migrations/0056_auth_refresh_tokens.sql new file mode 100644 index 0000000..e3786d8 --- /dev/null +++ b/backend/db/migrations/0056_auth_refresh_tokens.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS "auth_refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "tenant_id" bigint, + "token_hash" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "revoked_at" timestamp with time zone, + CONSTRAINT "auth_refresh_tokens_token_hash_unique" UNIQUE("token_hash"), + CONSTRAINT "auth_refresh_tokens_user_id_auth_users_id_fk" + FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") + ON DELETE cascade ON UPDATE cascade, + CONSTRAINT "auth_refresh_tokens_tenant_id_tenants_id_fk" + FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") + ON DELETE set null ON UPDATE cascade +); + +CREATE INDEX IF NOT EXISTS "auth_refresh_tokens_user_id_idx" + ON "auth_refresh_tokens" ("user_id"); +CREATE INDEX IF NOT EXISTS "auth_refresh_tokens_expires_at_idx" + ON "auth_refresh_tokens" ("expires_at"); 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 2b5a13b..f69d735 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -372,6 +372,20 @@ "when": 1784021669805, "tag": "0055_tenant_export_maintenance_lock", "breakpoints": true + }, + { + "idx": 53, + "version": "7", + "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/auth_refresh_tokens.ts b/backend/db/schema/auth_refresh_tokens.ts new file mode 100644 index 0000000..dd2c84d --- /dev/null +++ b/backend/db/schema/auth_refresh_tokens.ts @@ -0,0 +1,20 @@ +import { pgTable, text, timestamp, uuid, bigint } from "drizzle-orm/pg-core" + +import { authUsers } from "./auth_users" +import { tenants } from "./tenants" + +export const authRefreshTokens = pgTable("auth_refresh_tokens", { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => authUsers.id, { onDelete: "cascade", onUpdate: "cascade" }), + tenantId: bigint("tenant_id", { mode: "number" }) + .references(() => tenants.id, { onDelete: "set null", onUpdate: "cascade" }), + tokenHash: text("token_hash").notNull().unique(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), +}) + +export type AuthRefreshToken = typeof authRefreshTokens.$inferSelect +export type NewAuthRefreshToken = typeof authRefreshTokens.$inferInsert 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/db/schema/index.ts b/backend/db/schema/index.ts index 90a53dd..8f766d6 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -7,6 +7,7 @@ export * from "./auth_roles" export * from "./auth_tenant_users" export * from "./auth_user_roles" export * from "./auth_users" +export * from "./auth_refresh_tokens" export * from "./bankaccounts" export * from "./bankrequisitions" export * from "./bankstatements" 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 4582d2a..bb1ef67 100644 --- a/backend/src/modules/cron/prepareIncomingInvoices.ts +++ b/backend/src/modules/cron/prepareIncomingInvoices.ts @@ -1,6 +1,13 @@ import { FastifyInstance } from "fastify" 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 { @@ -24,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") @@ -85,75 +147,59 @@ 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}`) - const data = await getInvoiceDataFromGPT(server,file, tenantId) + let fileData: Buffer - if (!data) { - server.log.warn(`GPT returned no data for file ${file.id}`) + try { + fileData = await loadFileBuffer(file.path!) + } catch (error) { + server.log.error(error, `Datei ${file.id} konnte nicht aus S3 geladen werden.`) continue } - // --------------------------------------------------------- - // 3.1 IncomingInvoice-Objekt vorbereiten - // --------------------------------------------------------- - let itemInfo: any = { - tenant: tenantId, - state: "Vorbereitet" - } + const electronicInvoice = await detectElectronicInvoice(fileData, file) - 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() + let itemInfo: any = null - // 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 + if (electronicInvoice) { + server.log.info({ + fileId: file.id, + container: electronicInvoice.container, + syntax: electronicInvoice.syntax, + attachmentName: electronicInvoice.attachmentName, + }, "Strukturierte E-Rechnung erkannt.") - // 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 + 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.`) - 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` + if (electronicInvoice.container === "xml") continue } } - itemInfo.description = description.trim() + + 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) + } // --------------------------------------------------------- // 4️⃣ IncomingInvoice erstellen diff --git a/backend/src/modules/einvoice/detectElectronicInvoice.ts b/backend/src/modules/einvoice/detectElectronicInvoice.ts new file mode 100644 index 0000000..689182e --- /dev/null +++ b/backend/src/modules/einvoice/detectElectronicInvoice.ts @@ -0,0 +1,141 @@ +import { + decodePDFRawStream, + PDFDict, + PDFDocument, + PDFHexString, + PDFName, + PDFRawStream, + PDFString, +} from "pdf-lib" + +export type ElectronicInvoiceSyntax = "cii" | "ubl-invoice" | "ubl-credit-note" + +export type ElectronicInvoiceDetection = { + container: "pdf" | "xml" + syntax: ElectronicInvoiceSyntax + xml: Buffer + attachmentName: string | null +} + +type FileLike = { + name?: string | null + path?: string | null + mimeType?: string | null +} + +const decodePdfText = (value: unknown): string | null => { + if (value instanceof PDFString || value instanceof PDFHexString) { + return value.decodeText() + } + + return null +} + +const detectXmlSyntax = (xml: Buffer): ElectronicInvoiceSyntax | null => { + const sample = xml.subarray(0, Math.min(xml.length, 64 * 1024)).toString("utf8") + + if (/<(?:[A-Za-z_][\w.-]*:)?CrossIndustryInvoice(?:\s|>)/i.test(sample)) { + return "cii" + } + + if ( + /<(?:[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 ( + /<(?:[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" + } + + return null +} + +const extractXmlFromFileSpec = (fileSpec: PDFDict): { name: string | null, xml: Buffer } | null => { + const context = fileSpec.context + const embeddedFiles = context.lookupMaybe(fileSpec.get(PDFName.of("EF")), PDFDict) + + if (!embeddedFiles) return null + + const stream = context.lookup( + embeddedFiles.get(PDFName.of("UF")) || embeddedFiles.get(PDFName.of("F")), + ) + + if (!(stream instanceof PDFRawStream)) return null + + const name = decodePdfText( + context.lookup(fileSpec.get(PDFName.of("UF")) || fileSpec.get(PDFName.of("F"))), + ) + const contents = Buffer.from(decodePDFRawStream(stream).decode()) + + return { name, xml: contents } +} + +const detectEmbeddedInvoice = async (pdf: Buffer): Promise => { + let document: PDFDocument + + try { + document = await PDFDocument.load(pdf, { + ignoreEncryption: true, + updateMetadata: false, + }) + } catch { + return null + } + + for (const [, object] of document.context.enumerateIndirectObjects()) { + if (!(object instanceof PDFDict)) continue + + const type = object.get(PDFName.of("Type")) + if (!(type instanceof PDFName) || type.asString() !== "/Filespec") continue + + const embedded = extractXmlFromFileSpec(object) + if (!embedded) continue + + const syntax = detectXmlSyntax(embedded.xml) + if (!syntax) continue + + return { + container: "pdf", + syntax, + xml: embedded.xml, + attachmentName: embedded.name, + } + } + + return null +} + +const hasPdfFileType = (file: FileLike, data: Buffer) => { + const filename = String(file.name || file.path || "").toLowerCase() + const mimeType = String(file.mimeType || "").toLowerCase().split(";")[0].trim() + + return filename.endsWith(".pdf") || mimeType === "application/pdf" || data.subarray(0, 5).toString("ascii") === "%PDF-" +} + +export const detectElectronicInvoice = async ( + data: Buffer, + file: FileLike, +): Promise => { + if (hasPdfFileType(file, data)) { + return detectEmbeddedInvoice(data) + } + + const beginsLikeXml = data.subarray(0, 1024).toString("utf8").replace(/^\uFEFF/, "").trimStart().startsWith("<") + const syntax = beginsLikeXml ? detectXmlSyntax(data) : null + + if (syntax) { + return { + container: "xml", + syntax, + xml: data, + attachmentName: file.name || file.path?.split("/").pop() || null, + } + } + + return null +} 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/src/routes/auth/auth.ts b/backend/src/routes/auth/auth.ts index 7d7881e..2537d6c 100644 --- a/backend/src/routes/auth/auth.ts +++ b/backend/src/routes/auth/auth.ts @@ -1,14 +1,18 @@ import { FastifyInstance } from "fastify"; import bcrypt from "bcrypt"; -import jwt from "jsonwebtoken"; import { generateRandomPassword, hashPassword } from "../../utils/password"; import { sendMail } from "../../utils/mailer"; -import { secrets } from "../../utils/secrets"; import { authUsers } from "../../../db/schema"; import { authTenantUsers } from "../../../db/schema"; import { tenants } from "../../../db/schema"; -import { eq, and } from "drizzle-orm"; +import { eq } from "drizzle-orm"; +import { + createAccessToken, + issueRefreshToken, + revokeRefreshToken, + rotateRefreshToken, +} from "../../utils/authTokens"; export default async function authRoutes(server: FastifyInstance) { @@ -61,11 +65,12 @@ export default async function authRoutes(server: FastifyInstance) { properties: { email: { type: "string", format: "email" }, password: { type: "string" }, + rememberMe: { type: "boolean" }, }, }, }, }, async (req, reply) => { - const body = req.body as { email: string; password: string }; + const body = req.body as { email: string; password: string; rememberMe?: boolean }; let user: any = null; @@ -122,27 +127,43 @@ export default async function authRoutes(server: FastifyInstance) { return reply.code(401).send({ error: "Invalid credentials" }); } - const token = jwt.sign( - { - user_id: user.id, - email: user.email, - tenant_id: req.tenant?.id ?? null, - }, - secrets.JWT_SECRET!, - { expiresIn: "6h" } - ); + const tenantId = req.tenant?.id ? Number(req.tenant.id) : null; + const token = createAccessToken(user, tenantId); + const refreshToken = await issueRefreshToken(server, user.id, tenantId); reply.setCookie("token", token, { path: "/", httpOnly: true, sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", secure: process.env.NODE_ENV === "production", - maxAge: 60 * 60 * 6, + ...(body.rememberMe ? { maxAge: 60 * 60 * 6 } : {}), }); - return { token }; + return { token, refreshToken }; }); + server.post("/auth/refresh", { + schema: { + tags: ["Auth"], + summary: "Refresh a persistent session", + body: { + type: "object", + required: ["refreshToken"], + properties: { + refreshToken: { type: "string" }, + }, + }, + }, + }, async (req, reply) => { + const { refreshToken } = req.body as { refreshToken: string }; + const refreshed = await rotateRefreshToken(server, refreshToken); + + if (!refreshed) { + return reply.code(401).send({ error: "Invalid or expired refresh token" }); + } + + return refreshed; + }); // ----------------------------------------------------- // LOGOUT @@ -153,6 +174,10 @@ export default async function authRoutes(server: FastifyInstance) { summary: "Logout User" } }, async (req, reply) => { + const refreshToken = (req.body as { refreshToken?: string } | undefined)?.refreshToken; + if (refreshToken) { + await revokeRefreshToken(server, refreshToken); + } reply.clearCookie("token", { path: "/", httpOnly: true, diff --git a/backend/src/routes/tenant.ts b/backend/src/routes/tenant.ts index 0c85424..b67fb3a 100644 --- a/backend/src/routes/tenant.ts +++ b/backend/src/routes/tenant.ts @@ -14,6 +14,7 @@ import { import {and, desc, eq, inArray} from "drizzle-orm" import { enrichProfilesWithBranches } from "../utils/profileBranches" import { enrichProfilesWithTeams } from "../utils/profileTeams" +import { rotateRefreshToken } from "../utils/authTokens" export default async function tenantRoutes(server: FastifyInstance) { @@ -65,7 +66,7 @@ export default async function tenantRoutes(server: FastifyInstance) { return reply.code(401).send({ error: "Unauthorized" }) } - const { tenant_id } = req.body as { tenant_id: string } + const { tenant_id, refreshToken } = req.body as { tenant_id: string; refreshToken?: string } if (!tenant_id) return reply.code(400).send({ error: "tenant_id required" }) // prüfen ob der User zu diesem Tenant gehört @@ -81,7 +82,22 @@ export default async function tenantRoutes(server: FastifyInstance) { return reply.code(403).send({ error: "Not a member of this tenant" }) } - // JWT neu erzeugen + if (refreshToken) { + const session = await rotateRefreshToken( + server, + refreshToken, + Number(tenant_id), + req.user.user_id + ) + + if (!session) { + return reply.code(401).send({ error: "Invalid or expired refresh token" }) + } + + return session + } + + // Abwärtskompatibilität für Clients ohne persistente Session const token = jwt.sign( { user_id: req.user.user_id, diff --git a/backend/src/utils/authTokens.ts b/backend/src/utils/authTokens.ts new file mode 100644 index 0000000..16ad5eb --- /dev/null +++ b/backend/src/utils/authTokens.ts @@ -0,0 +1,93 @@ +import { createHash, randomBytes } from "node:crypto" +import jwt from "jsonwebtoken" +import { FastifyInstance } from "fastify" +import { and, eq, gt, isNull } from "drizzle-orm" + +import { authRefreshTokens, authUsers } from "../../db/schema" +import { secrets } from "./secrets" + +const ACCESS_TOKEN_TTL_SECONDS = 60 * 60 * 6 +const REFRESH_TOKEN_TTL_DAYS = 90 + +export function createAccessToken(user: { id: string; email: string }, tenantId: number | null) { + return jwt.sign( + { + user_id: user.id, + email: user.email, + tenant_id: tenantId, + }, + secrets.JWT_SECRET!, + { expiresIn: ACCESS_TOKEN_TTL_SECONDS } + ) +} + +export function hashRefreshToken(token: string) { + return createHash("sha256").update(token, "utf8").digest("hex") +} + +export async function issueRefreshToken( + server: FastifyInstance, + userId: string, + tenantId: number | null +) { + const token = randomBytes(48).toString("base64url") + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000) + + await server.db.insert(authRefreshTokens).values({ + userId, + tenantId, + tokenHash: hashRefreshToken(token), + expiresAt, + }) + + return token +} + +export async function rotateRefreshToken( + server: FastifyInstance, + token: string, + tenantOverride?: number | null, + expectedUserId?: string +) { + const tokenHash = hashRefreshToken(token) + const [session] = await server.db + .update(authRefreshTokens) + .set({ revokedAt: new Date() }) + .where(and( + eq(authRefreshTokens.tokenHash, tokenHash), + isNull(authRefreshTokens.revokedAt), + gt(authRefreshTokens.expiresAt, new Date()) + )) + .returning({ + id: authRefreshTokens.id, + userId: authRefreshTokens.userId, + tenantId: authRefreshTokens.tenantId, + }) + + if (!session) return null + if (expectedUserId && session.userId !== expectedUserId) return null + + const [user] = await server.db + .select({ id: authUsers.id, email: authUsers.email }) + .from(authUsers) + .where(eq(authUsers.id, session.userId)) + .limit(1) + + if (!user) return null + + const tenantId = tenantOverride === undefined ? session.tenantId : tenantOverride + const refreshToken = await issueRefreshToken(server, session.userId, tenantId) + const accessToken = createAccessToken(user, tenantId) + + return { token: accessToken, refreshToken } +} + +export async function revokeRefreshToken(server: FastifyInstance, token: string) { + await server.db + .update(authRefreshTokens) + .set({ revokedAt: new Date() }) + .where(and( + eq(authRefreshTokens.tokenHash, hashRefreshToken(token)), + isNull(authRefreshTokens.revokedAt) + )) +} diff --git a/backend/src/utils/fileBuffer.ts b/backend/src/utils/fileBuffer.ts new file mode 100644 index 0000000..a6d9b4e --- /dev/null +++ b/backend/src/utils/fileBuffer.ts @@ -0,0 +1,26 @@ +import { GetObjectCommand } from "@aws-sdk/client-s3" +import { s3 } from "./s3" +import { secrets } from "./secrets" + +export const streamToBuffer = async (stream: any): Promise => { + const chunks: Buffer[] = [] + + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + + return Buffer.concat(chunks) +} + +export const loadFileBuffer = async (path: string): Promise => { + const response = await s3.send(new GetObjectCommand({ + Bucket: secrets.S3_BUCKET, + Key: path, + })) + + if (!response.Body) { + throw new Error(`S3-Datei '${path}' enthält keinen lesbaren Inhalt.`) + } + + return streamToBuffer(response.Body) +} diff --git a/backend/src/utils/gpt.ts b/backend/src/utils/gpt.ts index 95b9cc7..3577faa 100644 --- a/backend/src/utils/gpt.ts +++ b/backend/src/utils/gpt.ts @@ -2,12 +2,11 @@ import dayjs from "dayjs"; import OpenAI from "openai"; import { z } from "zod"; import { zodResponseFormat } from "openai/helpers/zod"; -import { GetObjectCommand } from "@aws-sdk/client-s3"; import { FastifyInstance } from "fastify"; -import { s3 } from "./s3"; -import { secrets } from "./secrets"; import { storeExtractedTextForFile } from "./documentText"; +import { loadFileBuffer } from "./fileBuffer"; +import { secrets } from "./secrets"; // Drizzle schema import { vendors, accounts, tenants } from "../../db/schema"; @@ -27,18 +26,6 @@ export const initOpenAi = async () => { }); }; -// --------------------------------------------------------- -// STREAM → BUFFER -// --------------------------------------------------------- -async function streamToBuffer(stream: any): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - stream.on("data", (chunk: Buffer) => chunks.push(chunk)); - stream.on("error", reject); - stream.on("end", () => resolve(Buffer.concat(chunks))); - }); -} - // --------------------------------------------------------- // GPT RESPONSE FORMAT (Zod Schema) // --------------------------------------------------------- @@ -93,7 +80,8 @@ const InstructionFormat = z.object({ export const getInvoiceDataFromGPT = async function ( server: FastifyInstance, file: any, - tenantId: number + tenantId: number, + suppliedFileData?: Buffer, ) { await initOpenAi(); @@ -109,13 +97,7 @@ export const getInvoiceDataFromGPT = async function ( let fileData: Buffer; try { - const command = new GetObjectCommand({ - Bucket: secrets.S3_BUCKET, - Key: file.path, - }); - - const response: any = await s3.send(command); - fileData = await streamToBuffer(response.Body); + fileData = suppliedFileData || await loadFileBuffer(file.path); } catch (err) { console.log(`❌ S3 Download failed for file ${file.id}`, err); return null; 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/createDocument/edit/[[id]].vue b/frontend/pages/createDocument/edit/[[id]].vue index 40e1862..95e1d49 100644 --- a/frontend/pages/createDocument/edit/[[id]].vue +++ b/frontend/pages/createDocument/edit/[[id]].vue @@ -91,6 +91,7 @@ const selectedProductcategorie = ref(null) const services = ref([]) const servicecategories = ref([]) const selectedServicecategorie = ref(null) +const collapsedTitleRowIds = ref(new Set()) const customers = ref([]) const contacts = ref([]) const contracts = ref([]) @@ -935,6 +936,45 @@ const getPositionAddMenuItems = (row) => [ })) ] +const toggleTitleSection = (rowId) => { + const nextCollapsedTitleRowIds = new Set(collapsedTitleRowIds.value) + + if (nextCollapsedTitleRowIds.has(rowId)) { + nextCollapsedTitleRowIds.delete(rowId) + } else { + nextCollapsedTitleRowIds.add(rowId) + } + + collapsedTitleRowIds.value = nextCollapsedTitleRowIds +} + +const hiddenDocumentRowIds = computed(() => { + const hiddenRowIds = new Set() + const collapsedTitleLevels = [] + + itemInfo.value.rows.forEach((row) => { + if (row.mode === 'title') { + const titleLevel = Math.min(Math.max(Number(row.titleLevel) || 1, 1), 3) + + while ( + collapsedTitleLevels.length > 0 + && collapsedTitleLevels[collapsedTitleLevels.length - 1] >= titleLevel + ) { + collapsedTitleLevels.pop() + } + + if (collapsedTitleLevels.length > 0) hiddenRowIds.add(row.id) + if (collapsedTitleRowIds.value.has(row.id)) collapsedTitleLevels.push(titleLevel) + } else if (collapsedTitleLevels.length > 0) { + hiddenRowIds.add(row.id) + } + }) + + return hiddenRowIds +}) + +const isDocumentRowHidden = (rowId) => hiddenDocumentRowIds.value.has(rowId) + const getFullWidthPositionColspan = () => deliveryNoteLikeDocumentTypes.includes(itemInfo.value.type) ? 5 : 8 const getTitlePositionColspan = () => deliveryNoteLikeDocumentTypes.includes(itemInfo.value.type) ? 4 : 7 @@ -2897,13 +2937,28 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = { @end="setPosNumbers" > +