Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
2026-07-22 19:10:11 +02:00
29 changed files with 1462 additions and 190 deletions

View File

@@ -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");

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

@@ -372,6 +372,20 @@
"when": 1784021669805, "when": 1784021669805,
"tag": "0055_tenant_export_maintenance_lock", "tag": "0055_tenant_export_maintenance_lock",
"breakpoints": true "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
} }
] ]
} }

View File

@@ -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

View File

@@ -40,6 +40,11 @@ export const incominginvoices = pgTable("incominginvoices", {
paymentType: text("paymentType"), 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([ accounts: jsonb("accounts").notNull().default([
{ {
account: null, account: null,

View File

@@ -7,6 +7,7 @@ export * from "./auth_roles"
export * from "./auth_tenant_users" export * from "./auth_tenant_users"
export * from "./auth_user_roles" export * from "./auth_user_roles"
export * from "./auth_users" export * from "./auth_users"
export * from "./auth_refresh_tokens"
export * from "./bankaccounts" export * from "./bankaccounts"
export * from "./bankrequisitions" export * from "./bankrequisitions"
export * from "./bankstatements" export * from "./bankstatements"

View File

@@ -27,6 +27,7 @@
"crypto": "^1.0.1", "crypto": "^1.0.1",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"drizzle-orm": "^0.45.0", "drizzle-orm": "^0.45.0",
"fast-xml-parser": "^5.10.1",
"fastify": "^5.5.0", "fastify": "^5.5.0",
"fastify-plugin": "^5.0.1", "fastify-plugin": "^5.0.1",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
@@ -5290,6 +5291,24 @@
"node": ">=18.0.0" "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": { "node_modules/@aws/lambda-invoke-store": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz",
@@ -5850,6 +5869,18 @@
"eventemitter3": "^5.0.1" "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": { "node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", "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" "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": { "node_modules/archiver": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
@@ -8004,10 +8047,10 @@
], ],
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/fast-xml-parser": { "node_modules/fast-xml-builder": {
"version": "5.2.5", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz",
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -8016,7 +8059,28 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "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": { "bin": {
"fxparser": "src/cli/cli.js" "fxparser": "src/cli/cli.js"
@@ -8517,6 +8581,18 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/isarray": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@@ -9098,6 +9174,21 @@
"url": "https://ko-fi.com/killymxi" "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": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -9940,16 +10031,19 @@
} }
}, },
"node_modules/strnum": { "node_modules/strnum": {
"version": "2.1.2", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz",
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence" "url": "https://github.com/sponsors/NaturalIntelligence"
} }
], ],
"license": "MIT" "license": "MIT",
"dependencies": {
"anynum": "^1.0.1"
}
}, },
"node_modules/tar-stream": { "node_modules/tar-stream": {
"version": "3.1.7", "version": "3.1.7",
@@ -10351,6 +10445,21 @@
"node": ">= 4" "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": { "node_modules/xmlbuilder": {
"version": "15.1.1", "version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", "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", "fill": "ts-node src/webdav/fill-file-sizes.ts",
"dev:dav": "tsx watch src/webdav/server.ts", "dev:dav": "tsx watch src/webdav/server.ts",
"build": "tsc", "build": "tsc",
"test:einvoice": "tsx --test tests/einvoice.test.ts",
"start": "node dist/src/index.js", "start": "node dist/src/index.js",
"schema:index": "ts-node scripts/generate-schema-index.ts", "schema:index": "ts-node scripts/generate-schema-index.ts",
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts", "bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
@@ -43,6 +44,7 @@
"crypto": "^1.0.1", "crypto": "^1.0.1",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"drizzle-orm": "^0.45.0", "drizzle-orm": "^0.45.0",
"fast-xml-parser": "^5.10.1",
"fastify": "^5.5.0", "fastify": "^5.5.0",
"fastify-plugin": "^5.0.1", "fastify-plugin": "^5.0.1",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",

View File

@@ -1,6 +1,13 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import dayjs from "dayjs" import dayjs from "dayjs"
import { getInvoiceDataFromGPT } from "../../utils/gpt" 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 // Drizzle schema
import { import {
@@ -24,6 +31,61 @@ const formatInvoiceItemDescription = (item: any) => {
return parts.join(" - ") 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) { export function prepareIncomingInvoices(server: FastifyInstance) {
const processInvoices = async (tenantId:number) => { const processInvoices = async (tenantId:number) => {
console.log("▶ Starting Incoming Invoice Preparation") console.log("▶ Starting Incoming Invoice Preparation")
@@ -85,75 +147,59 @@ 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) { for (const file of filesRes) {
console.log(`Processing file ${file.id} for tenant ${tenantId}`) console.log(`Processing file ${file.id} for tenant ${tenantId}`)
const data = await getInvoiceDataFromGPT(server,file, tenantId) let fileData: Buffer
if (!data) { try {
server.log.warn(`GPT returned no data for file ${file.id}`) fileData = await loadFileBuffer(file.path!)
} catch (error) {
server.log.error(error, `Datei ${file.id} konnte nicht aus S3 geladen werden.`)
continue continue
} }
// --------------------------------------------------------- const electronicInvoice = await detectElectronicInvoice(fileData, file)
// 3.1 IncomingInvoice-Objekt vorbereiten
// ---------------------------------------------------------
let itemInfo: any = {
tenant: tenantId,
state: "Vorbereitet"
}
if (data.invoice_number) itemInfo.reference = data.invoice_number let itemInfo: any = null
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 if (electronicInvoice) {
const mapPayment: any = { server.log.info({
"Direct Debit": "Einzug", fileId: file.id,
"Transfer": "Überweisung", container: electronicInvoice.container,
"Credit Card": "Kreditkarte", syntax: electronicInvoice.syntax,
"Other": "Sonstiges", attachmentName: electronicInvoice.attachmentName,
} }, "Strukturierte E-Rechnung erkannt.")
if (data.terms) itemInfo.paymentType = mapPayment[data.terms] ?? data.terms
// 3.2 Positionszeilen konvertieren try {
if (data.invoice_items?.length > 0) { const parsedInvoice = parseElectronicInvoice(electronicInvoice.xml, electronicInvoice.syntax)
itemInfo.accounts = data.invoice_items const vendor = await findElectronicInvoiceVendor(server, tenantId, parsedInvoice)
.filter(item => item.description || item.total !== null || item.total_without_tax !== null) itemInfo = mapElectronicInvoiceToIncomingInvoice(
.map(item => { parsedInvoice,
const total = typeof item.total === "number" ? item.total : null tenantId,
const totalWithoutTax = typeof item.total_without_tax === "number" ? item.total_without_tax : null vendor?.id || null,
const amountTax = total !== null && totalWithoutTax !== null electronicInvoice.container,
? Number((total - totalWithoutTax).toFixed(2)) electronicInvoice.attachmentName,
: null )
} catch (error) {
server.log.error(error, `E-Rechnung aus Datei ${file.id} konnte nicht verarbeitet werden.`)
return { if (electronicInvoice.container === "xml") continue
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()
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 // 4⃣ IncomingInvoice erstellen

View File

@@ -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<ElectronicInvoiceDetection | null> => {
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<ElectronicInvoiceDetection | null> => {
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
}

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

@@ -1,14 +1,18 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { generateRandomPassword, hashPassword } from "../../utils/password"; import { generateRandomPassword, hashPassword } from "../../utils/password";
import { sendMail } from "../../utils/mailer"; import { sendMail } from "../../utils/mailer";
import { secrets } from "../../utils/secrets";
import { authUsers } from "../../../db/schema"; import { authUsers } from "../../../db/schema";
import { authTenantUsers } from "../../../db/schema"; import { authTenantUsers } from "../../../db/schema";
import { tenants } 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) { export default async function authRoutes(server: FastifyInstance) {
@@ -61,11 +65,12 @@ export default async function authRoutes(server: FastifyInstance) {
properties: { properties: {
email: { type: "string", format: "email" }, email: { type: "string", format: "email" },
password: { type: "string" }, password: { type: "string" },
rememberMe: { type: "boolean" },
}, },
}, },
}, },
}, async (req, reply) => { }, 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; let user: any = null;
@@ -122,27 +127,43 @@ export default async function authRoutes(server: FastifyInstance) {
return reply.code(401).send({ error: "Invalid credentials" }); return reply.code(401).send({ error: "Invalid credentials" });
} }
const token = jwt.sign( const tenantId = req.tenant?.id ? Number(req.tenant.id) : null;
{ const token = createAccessToken(user, tenantId);
user_id: user.id, const refreshToken = await issueRefreshToken(server, user.id, tenantId);
email: user.email,
tenant_id: req.tenant?.id ?? null,
},
secrets.JWT_SECRET!,
{ expiresIn: "6h" }
);
reply.setCookie("token", token, { reply.setCookie("token", token, {
path: "/", path: "/",
httpOnly: true, httpOnly: true,
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
secure: process.env.NODE_ENV === "production", 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 // LOGOUT
@@ -153,6 +174,10 @@ export default async function authRoutes(server: FastifyInstance) {
summary: "Logout User" summary: "Logout User"
} }
}, async (req, reply) => { }, async (req, reply) => {
const refreshToken = (req.body as { refreshToken?: string } | undefined)?.refreshToken;
if (refreshToken) {
await revokeRefreshToken(server, refreshToken);
}
reply.clearCookie("token", { reply.clearCookie("token", {
path: "/", path: "/",
httpOnly: true, httpOnly: true,

View File

@@ -14,6 +14,7 @@ import {
import {and, desc, eq, inArray} from "drizzle-orm" import {and, desc, eq, inArray} from "drizzle-orm"
import { enrichProfilesWithBranches } from "../utils/profileBranches" import { enrichProfilesWithBranches } from "../utils/profileBranches"
import { enrichProfilesWithTeams } from "../utils/profileTeams" import { enrichProfilesWithTeams } from "../utils/profileTeams"
import { rotateRefreshToken } from "../utils/authTokens"
export default async function tenantRoutes(server: FastifyInstance) { 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" }) 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" }) if (!tenant_id) return reply.code(400).send({ error: "tenant_id required" })
// prüfen ob der User zu diesem Tenant gehört // 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" }) 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( const token = jwt.sign(
{ {
user_id: req.user.user_id, user_id: req.user.user_id,

View File

@@ -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)
))
}

View File

@@ -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<Buffer> => {
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<Buffer> => {
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)
}

View File

@@ -2,12 +2,11 @@ import dayjs from "dayjs";
import OpenAI from "openai"; import OpenAI from "openai";
import { z } from "zod"; import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod"; import { zodResponseFormat } from "openai/helpers/zod";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { s3 } from "./s3";
import { secrets } from "./secrets";
import { storeExtractedTextForFile } from "./documentText"; import { storeExtractedTextForFile } from "./documentText";
import { loadFileBuffer } from "./fileBuffer";
import { secrets } from "./secrets";
// Drizzle schema // Drizzle schema
import { vendors, accounts, tenants } from "../../db/schema"; import { vendors, accounts, tenants } from "../../db/schema";
@@ -27,18 +26,6 @@ export const initOpenAi = async () => {
}); });
}; };
// ---------------------------------------------------------
// STREAM → BUFFER
// ---------------------------------------------------------
async function streamToBuffer(stream: any): Promise<Buffer> {
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) // GPT RESPONSE FORMAT (Zod Schema)
// --------------------------------------------------------- // ---------------------------------------------------------
@@ -93,7 +80,8 @@ const InstructionFormat = z.object({
export const getInvoiceDataFromGPT = async function ( export const getInvoiceDataFromGPT = async function (
server: FastifyInstance, server: FastifyInstance,
file: any, file: any,
tenantId: number tenantId: number,
suppliedFileData?: Buffer,
) { ) {
await initOpenAi(); await initOpenAi();
@@ -109,13 +97,7 @@ export const getInvoiceDataFromGPT = async function (
let fileData: Buffer; let fileData: Buffer;
try { try {
const command = new GetObjectCommand({ fileData = suppliedFileData || await loadFileBuffer(file.path);
Bucket: secrets.S3_BUCKET,
Key: file.path,
});
const response: any = await s3.send(command);
fileData = await streamToBuffer(response.Body);
} catch (err) { } catch (err) {
console.log(`❌ S3 Download failed for file ${file.id}`, err); console.log(`❌ S3 Download failed for file ${file.id}`, err);
return null; return null;

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

@@ -91,6 +91,7 @@ const selectedProductcategorie = ref(null)
const services = ref([]) const services = ref([])
const servicecategories = ref([]) const servicecategories = ref([])
const selectedServicecategorie = ref(null) const selectedServicecategorie = ref(null)
const collapsedTitleRowIds = ref(new Set())
const customers = ref([]) const customers = ref([])
const contacts = ref([]) const contacts = ref([])
const contracts = 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 getFullWidthPositionColspan = () => deliveryNoteLikeDocumentTypes.includes(itemInfo.value.type) ? 5 : 8
const getTitlePositionColspan = () => deliveryNoteLikeDocumentTypes.includes(itemInfo.value.type) ? 4 : 7 const getTitlePositionColspan = () => deliveryNoteLikeDocumentTypes.includes(itemInfo.value.type) ? 4 : 7
@@ -2897,13 +2937,28 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
@end="setPosNumbers" @end="setPosNumbers"
> >
<template #item="{element: row}"> <template #item="{element: row}">
<tr> <tr :class="{ collapse: isDocumentRowHidden(row.id) }">
<td> <td>
<UIcon <div class="flex items-center gap-1 whitespace-nowrap">
class="handle" <UIcon
name="i-mdi-menu" class="handle shrink-0"
v-if="itemInfo.type !== 'cancellationInvoices'" name="i-mdi-menu"
/> v-if="itemInfo.type !== 'cancellationInvoices'"
/>
<UTooltip
v-if="row.mode === 'title'"
:text="collapsedTitleRowIds.has(row.id) ? 'Titelbereich ausklappen' : 'Titelbereich einklappen'"
>
<UButton
class="shrink-0"
variant="ghost"
color="neutral"
:icon="collapsedTitleRowIds.has(row.id) ? 'i-heroicons-chevron-right' : 'i-heroicons-chevron-down'"
:aria-label="collapsedTitleRowIds.has(row.id) ? 'Titelbereich ausklappen' : 'Titelbereich einklappen'"
@click="toggleTitleSection(row.id)"
/>
</UTooltip>
</div>
</td> </td>
<td <td
v-if="row.mode === 'pagebreak'" v-if="row.mode === 'pagebreak'"

View File

@@ -149,6 +149,18 @@ const bankBookingDateLabel = computed(() => {
return bankBookingDates.value.map(formatDate).join(", ") return bankBookingDates.value.map(formatDate).join(", ")
}) })
const vendorName = computed(() => vendors.value.find((vendor) => vendor.id === itemInfo.value.vendor)?.name || "-") 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 getAccountLabel = (item) => {
const account = accounts.value.find((entry) => entry.id === item.account) const account = accounts.value.find((entry) => entry.id === item.account)
@@ -356,6 +368,35 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
Dokument andocken Dokument andocken
</UButton> </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 <UAlert
v-if="mode !== 'show' && findIncomingInvoiceErrors.length > 0" v-if="mode !== 'show' && findIncomingInvoiceErrors.length > 0"
title="Prüfung erforderlich" 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-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 === '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 === '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>
<template #date-cell="{row}"> <template #date-cell="{row}">
{{dayjs(row.original.date).format("DD.MM.YYYY")}} {{dayjs(row.original.date).format("DD.MM.YYYY")}}

View File

@@ -11,7 +11,8 @@ const router = useRouter()
const state = reactive({ const state = reactive({
email: '', email: '',
password: '' password: '',
rememberMe: true
}) })
const loading = ref(false) const loading = ref(false)
@@ -19,7 +20,7 @@ const loading = ref(false)
const doLogin = async (event: FormSubmitEvent<typeof state>) => { const doLogin = async (event: FormSubmitEvent<typeof state>) => {
loading.value = true loading.value = true
try { try {
await auth.login(event.data.email, event.data.password) await auth.login(event.data.email, event.data.password, event.data.rememberMe)
toast.add({title:"Einloggen erfolgreich"}) toast.add({title:"Einloggen erfolgreich"})
await router.push("/") await router.push("/")
} catch (err: any) { } catch (err: any) {
@@ -79,6 +80,12 @@ const doLogin = async (event: FormSubmitEvent<typeof state>) => {
/> />
</UFormField> </UFormField>
<UCheckbox
v-model="state.rememberMe"
name="rememberMe"
label="Angemeldet bleiben"
/>
<UButton type="submit" block class="w-full" :loading="loading"> <UButton type="submit" block class="w-full" :loading="loading">
Weiter Weiter
</UButton> </UButton>

View File

@@ -27,6 +27,7 @@ export const useAuthStore = defineStore("auth", {
sessionWarningTimer: null as ReturnType<typeof setTimeout> | null, sessionWarningTimer: null as ReturnType<typeof setTimeout> | null,
sessionLogoutTimer: null as ReturnType<typeof setTimeout> | null, sessionLogoutTimer: null as ReturnType<typeof setTimeout> | null,
sessionCountdownTimer: null as ReturnType<typeof setInterval> | null, sessionCountdownTimer: null as ReturnType<typeof setInterval> | null,
sessionPersistent: false,
}), }),
actions: { actions: {
@@ -36,18 +37,18 @@ export const useAuthStore = defineStore("auth", {
getStoredToken() { getStoredToken() {
const rootToken = this.tokenCookie().value const rootToken = this.tokenCookie().value
if (rootToken || !process.client) return rootToken if (!process.client) return rootToken
const tokenCookie = document.cookie return sessionStorage.getItem("token")
.split(";") || localStorage.getItem("token")
.map((part) => part.trim()) || rootToken
.find((part) => part.startsWith("token=")) },
if (tokenCookie) { getStoredRefreshToken() {
return decodeURIComponent(tokenCookie.slice("token=".length)) if (!process.client) return null
}
return localStorage.getItem("token") return sessionStorage.getItem("refreshToken")
|| localStorage.getItem("refreshToken")
}, },
clearScopedTokenCookies() { clearScopedTokenCookies() {
@@ -155,24 +156,34 @@ export const useAuthStore = defineStore("auth", {
const msUntilWarning = msUntilLogout - this.sessionWarningLeadMs const msUntilWarning = msUntilLogout - this.sessionWarningLeadMs
if (msUntilWarning <= 0) { if (msUntilWarning <= 0) {
if (this.getStoredRefreshToken()) {
void this.refreshSession()
return
}
this.openSessionWarning(expiresAtMs) this.openSessionWarning(expiresAtMs)
return return
} }
this.sessionWarningTimer = setTimeout(() => { this.sessionWarningTimer = setTimeout(() => {
if (this.getStoredRefreshToken()) {
void this.refreshSession()
return
}
this.openSessionWarning(expiresAtMs) this.openSessionWarning(expiresAtMs)
}, msUntilWarning) }, msUntilWarning)
}, },
setToken(token: string | null) { setToken(token: string | null, persistent = this.sessionPersistent) {
this.clearScopedTokenCookies() this.clearScopedTokenCookies()
this.tokenCookie().value = token this.tokenCookie().value = token
if (process.client) { if (process.client) {
localStorage.removeItem("token")
sessionStorage.removeItem("token")
if (token) { if (token) {
localStorage.setItem("token", token) const storage = persistent ? localStorage : sessionStorage
} else { storage.setItem("token", token)
localStorage.removeItem("token")
} }
} }
@@ -186,6 +197,30 @@ export const useAuthStore = defineStore("auth", {
this.scheduleSessionTimers(token) this.scheduleSessionTimers(token)
}, },
setRefreshToken(refreshToken: string | null, persistent = this.sessionPersistent) {
if (!process.client) return
localStorage.removeItem("refreshToken")
sessionStorage.removeItem("refreshToken")
if (refreshToken) {
const storage = persistent ? localStorage : sessionStorage
storage.setItem("refreshToken", refreshToken)
}
},
setSession(token: string, refreshToken: string, persistent: boolean) {
this.sessionPersistent = persistent
this.setRefreshToken(refreshToken, persistent)
this.setToken(token, persistent)
},
clearStoredSession() {
this.setRefreshToken(null)
this.setToken(null)
this.sessionPersistent = false
},
async persist(token: string | null) { async persist(token: string | null) {
console.log("On Web") console.log("On Web")
@@ -195,6 +230,7 @@ export const useAuthStore = defineStore("auth", {
async initStore() { async initStore() {
console.log("Auth initStore") console.log("Auth initStore")
this.sessionPersistent = process.client && Boolean(localStorage.getItem("refreshToken"))
// 1. Check: Haben wir überhaupt ein Token? // 1. Check: Haben wir überhaupt ein Token?
const token = this.getStoredToken() const token = this.getStoredToken()
@@ -207,7 +243,7 @@ export const useAuthStore = defineStore("auth", {
} }
// 2. Token existiert -> Versuche User zu laden // 2. Token existiert -> Versuche User zu laden
await this.fetchMe(token) await this.fetchMe(token, true)
// Wenn fetchMe fertig ist (egal ob Erfolg oder Fehler), ladebalken weg // Wenn fetchMe fertig ist (egal ob Erfolg oder Fehler), ladebalken weg
@@ -236,18 +272,18 @@ export const useAuthStore = defineStore("auth", {
} }
}, },
async login(email: string, password: string) { async login(email: string, password: string, rememberMe = false) {
try { try {
console.log("Auth login") console.log("Auth login")
const { token } = await useNuxtApp().$api("/auth/login", { const { token, refreshToken } = await useNuxtApp().$api("/auth/login", {
method: "POST", method: "POST",
body: { email, password } body: { email, password, rememberMe }
}) })
console.log("Token: " + token) console.log("Token: " + token)
// 1. WICHTIG: Token sofort ins Cookie schreiben, damit es persistiert wird // Tokens abhängig von „Angemeldet bleiben dauerhaft oder nur für diese Sitzung speichern
this.setToken(token) this.setSession(token, refreshToken, rememberMe)
this.sessionExpired = false this.sessionExpired = false
// 2. User Daten laden // 2. User Daten laden
@@ -269,14 +305,18 @@ export const useAuthStore = defineStore("auth", {
} catch (e) { } catch (e) {
console.log("login error:" + e) console.log("login error:" + e)
// Hier könnte man noch eine Fehlermeldung im UI anzeigen throw e
} }
}, },
async logout() { async logout() {
console.log("Auth logout") console.log("Auth logout")
const refreshToken = this.getStoredRefreshToken()
try { try {
await useNuxtApp().$api("/auth/logout", { method: "POST" }) await useNuxtApp().$api("/auth/logout", {
method: "POST",
body: { refreshToken }
})
} catch (e) { } catch (e) {
console.error("Logout API fehlgeschlagen (egal):", e) console.error("Logout API fehlgeschlagen (egal):", e)
} }
@@ -285,7 +325,7 @@ export const useAuthStore = defineStore("auth", {
this.resetState() this.resetState()
// Token löschen // Token löschen
this.setToken(null) this.clearStoredSession()
// Nur beim expliziten Logout navigieren wir // Nur beim expliziten Logout navigieren wir
navigateTo("/login") navigateTo("/login")
@@ -295,7 +335,7 @@ export const useAuthStore = defineStore("auth", {
console.log("Auth session expired") console.log("Auth session expired")
this.resetState() this.resetState()
this.sessionExpired = true this.sessionExpired = true
this.setToken(null) this.clearStoredSession()
this.loading = false this.loading = false
if (process.client) { if (process.client) {
@@ -317,7 +357,7 @@ export const useAuthStore = defineStore("auth", {
this.loading = false this.loading = false
}, },
async fetchMe(jwt= null) { async fetchMe(jwt= null, allowRefresh = true) {
console.log("Auth fetchMe") console.log("Auth fetchMe")
const tempStore = useTempStore() const tempStore = useTempStore()
@@ -384,6 +424,10 @@ export const useAuthStore = defineStore("auth", {
console.log("fetchMe failed (Invalid Token or Network)", err) console.log("fetchMe failed (Invalid Token or Network)", err)
if (err?.response?.status === 401 || err?.status === 401 || err?.statusCode === 401) { if (err?.response?.status === 401 || err?.status === 401 || err?.statusCode === 401) {
if (allowRefresh && this.getStoredRefreshToken()) {
await this.refreshSession()
return
}
this.expireSession() this.expireSession()
return return
} }
@@ -395,17 +439,31 @@ export const useAuthStore = defineStore("auth", {
}, },
async refreshSession() { async refreshSession() {
const refreshToken = this.getStoredRefreshToken()
if (!refreshToken) {
this.expireSession()
return
}
try { try {
const { token } = await useNuxtApp().$api("/api/auth/refresh", { const session = await useNuxtApp().$api("/auth/refresh", {
method: "POST", method: "POST",
body: { refreshToken }
}) })
this.setToken(token) this.setSession(session.token, session.refreshToken, this.sessionPersistent)
await this.fetchMe(token) await this.fetchMe(session.token, false)
this.sessionWarningVisible = false this.sessionWarningVisible = false
} catch (err) { } catch (err) {
console.error("JWT refresh failed", err) console.error("JWT refresh failed", err)
await this.logout() const status = err?.response?.status || err?.status || err?.statusCode
if (status === 401) {
this.expireSession()
} else {
const token = this.getStoredToken()
const expiresAtMs = token ? this.decodeTokenExpiryMs(token) : null
if (expiresAtMs) this.openSessionWarning(expiresAtMs)
}
} }
}, },
@@ -414,14 +472,15 @@ export const useAuthStore = defineStore("auth", {
this.loading = true this.loading = true
const res = await useNuxtApp().$api("/api/tenant/switch", { const res = await useNuxtApp().$api("/api/tenant/switch", {
method: "POST", method: "POST",
body: { tenant_id } body: {
tenant_id,
refreshToken: this.getStoredRefreshToken()
}
}) })
console.log(res) console.log(res)
const {token} = res const {token, refreshToken} = res
this.setSession(token, refreshToken, this.sessionPersistent)
this.setToken(token)
await this.init(token) await this.init(token)

View File

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

View File

@@ -33,6 +33,7 @@ export default function LoginScreen() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [apiBaseUrl, setApiBaseUrl] = useState(getApiBaseUrlSync()); const [apiBaseUrl, setApiBaseUrl] = useState(getApiBaseUrlSync());
const [serverInput, setServerInput] = useState(getApiBaseUrlSync()); const [serverInput, setServerInput] = useState(getApiBaseUrlSync());
const [showServerModal, setShowServerModal] = useState(false); const [showServerModal, setShowServerModal] = useState(false);
@@ -141,14 +142,23 @@ export default function LoginScreen() {
/> />
<Text style={styles.label}>Passwort</Text> <Text style={styles.label}>Passwort</Text>
<TextInput <View style={styles.passwordRow}>
secureTextEntry <TextInput
placeholder="••••••••" secureTextEntry={!isPasswordVisible}
placeholderTextColor="#9ca3af" placeholder="••••••••"
style={styles.input} placeholderTextColor="#9ca3af"
value={password} style={styles.passwordInput}
onChangeText={setPassword} value={password}
/> onChangeText={setPassword}
/>
<Pressable
accessibilityRole="button"
accessibilityLabel={isPasswordVisible ? 'Passwort ausblenden' : 'Passwort einblenden'}
style={styles.passwordToggle}
onPress={() => setIsPasswordVisible((visible) => !visible)}>
<Text style={styles.passwordToggleText}>{isPasswordVisible ? 'Ausblenden' : 'Anzeigen'}</Text>
</Pressable>
</View>
{error ? <Text style={styles.error}>{error}</Text> : null} {error ? <Text style={styles.error}>{error}</Text> : null}
@@ -251,6 +261,30 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
color: '#111827', color: '#111827',
}, },
passwordRow: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
},
passwordInput: {
flex: 1,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
color: '#111827',
},
passwordToggle: {
alignSelf: 'stretch',
justifyContent: 'center',
paddingHorizontal: 12,
},
passwordToggleText: {
color: PRIMARY,
fontSize: 13,
fontWeight: '600',
},
button: { button: {
marginTop: 6, marginTop: 6,
backgroundColor: PRIMARY, backgroundColor: PRIMARY,

View File

@@ -131,6 +131,11 @@ export type MeResponse = {
permissions: string[]; permissions: string[];
}; };
export type AuthSession = {
token: string;
refreshToken: string;
};
export type EncodedLabelRow = { export type EncodedLabelRow = {
dataType: 'pixels' | 'void' | 'check'; dataType: 'pixels' | 'void' | 'check';
rowNumber: number; rowNumber: number;
@@ -199,6 +204,20 @@ type RequestOptions = {
timeoutMs?: number; timeoutMs?: number;
}; };
export class ApiError extends Error {
constructor(
message: string,
public readonly status: number
) {
super(message);
this.name = 'ApiError';
}
}
export function isAuthenticationError(error: unknown): boolean {
return error instanceof ApiError && error.status === 401;
}
export type MatrixStatus = { export type MatrixStatus = {
enabled?: boolean; enabled?: boolean;
ready?: boolean; ready?: boolean;
@@ -363,7 +382,7 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
(payload as { message?: string; error?: string } | null)?.message || (payload as { message?: string; error?: string } | null)?.message ||
(payload as { message?: string; error?: string } | null)?.error || (payload as { message?: string; error?: string } | null)?.error ||
`Request failed (${response.status}) for ${path}`; `Request failed (${response.status}) for ${path}`;
throw new Error(message); throw new ApiError(message, response.status);
} }
return payload as T; return payload as T;
@@ -399,7 +418,7 @@ async function apiFormRequest<T>(path: string, token: string, formData: FormData
(payload as { message?: string; error?: string } | null)?.message || (payload as { message?: string; error?: string } | null)?.message ||
(payload as { message?: string; error?: string } | null)?.error || (payload as { message?: string; error?: string } | null)?.error ||
`Request failed (${response.status}) for ${path}`; `Request failed (${response.status}) for ${path}`;
throw new Error(message); throw new ApiError(message, response.status);
} }
return payload as T; return payload as T;
@@ -629,17 +648,37 @@ export async function renderPrintLabel(
}); });
} }
export async function loginWithEmailPassword(email: string, password: string): Promise<string> { function requireAuthSession(payload: Partial<AuthSession>, action: string): AuthSession {
const payload = await apiRequest<{ token?: string }>('/auth/login', { if (!payload?.token || !payload?.refreshToken) {
throw new Error(`${action} did not return a complete session.`);
}
return { token: payload.token, refreshToken: payload.refreshToken };
}
export async function loginWithEmailPassword(email: string, password: string): Promise<AuthSession> {
const payload = await apiRequest<Partial<AuthSession>>('/auth/login', {
method: 'POST', method: 'POST',
body: { email, password }, body: { email, password },
}); });
if (!payload?.token) { return requireAuthSession(payload, 'Login');
throw new Error('Login did not return a token.'); }
}
return payload.token; export async function refreshAuthSession(refreshToken: string): Promise<AuthSession> {
const payload = await apiRequest<Partial<AuthSession>>('/auth/refresh', {
method: 'POST',
body: { refreshToken },
});
return requireAuthSession(payload, 'Session refresh');
}
export async function logoutSession(refreshToken: string): Promise<void> {
await apiRequest('/auth/logout', {
method: 'POST',
body: { refreshToken },
});
} }
export async function fetchMe(token: string): Promise<MeResponse> { export async function fetchMe(token: string): Promise<MeResponse> {
@@ -666,18 +705,18 @@ export async function sendMobileTestPush(token: string): Promise<{ accepted: num
}); });
} }
export async function switchTenantRequest(tenantId: number, token: string): Promise<string> { export async function switchTenantRequest(
const payload = await apiRequest<{ token?: string }>('/api/tenant/switch', { tenantId: number,
token: string,
refreshToken: string
): Promise<AuthSession> {
const payload = await apiRequest<Partial<AuthSession>>('/api/tenant/switch', {
method: 'POST', method: 'POST',
token, token,
body: { tenant_id: String(tenantId) }, body: { tenant_id: String(tenantId), refreshToken },
}); });
if (!payload?.token) { return requireAuthSession(payload, 'Tenant switch');
throw new Error('Tenant switch did not return a token.');
}
return payload.token;
} }
export async function fetchTasks(token: string): Promise<Task[]> { export async function fetchTasks(token: string): Promise<Task[]> {

View File

@@ -1,8 +1,15 @@
import * as SecureStore from 'expo-secure-store'; import * as SecureStore from 'expo-secure-store';
const TOKEN_KEY = 'fedeo.mobile.auth.token'; const TOKEN_KEY = 'fedeo.mobile.auth.token';
const REFRESH_TOKEN_KEY = 'fedeo.mobile.auth.refresh-token';
let memoryToken: string | null = null; let memoryToken: string | null = null;
let memoryRefreshToken: string | null = null;
export type StoredSession = {
token: string;
refreshToken: string;
};
async function hasSecureStore(): Promise<boolean> { async function hasSecureStore(): Promise<boolean> {
try { try {
@@ -30,11 +37,41 @@ export async function setStoredToken(token: string): Promise<void> {
} }
} }
export async function clearStoredToken(): Promise<void> { export async function getStoredSession(): Promise<StoredSession | null> {
memoryToken = null; if (await hasSecureStore()) {
const [token, refreshToken] = await Promise.all([
SecureStore.getItemAsync(TOKEN_KEY),
SecureStore.getItemAsync(REFRESH_TOKEN_KEY),
]);
memoryToken = token;
memoryRefreshToken = refreshToken;
}
if (!memoryToken || !memoryRefreshToken) return null;
return { token: memoryToken, refreshToken: memoryRefreshToken };
}
export async function setStoredSession(session: StoredSession): Promise<void> {
memoryToken = session.token;
memoryRefreshToken = session.refreshToken;
if (await hasSecureStore()) { if (await hasSecureStore()) {
await SecureStore.deleteItemAsync(TOKEN_KEY); await Promise.all([
SecureStore.setItemAsync(TOKEN_KEY, session.token),
SecureStore.setItemAsync(REFRESH_TOKEN_KEY, session.refreshToken),
]);
}
}
export async function clearStoredToken(): Promise<void> {
memoryToken = null;
memoryRefreshToken = null;
if (await hasSecureStore()) {
await Promise.all([
SecureStore.deleteItemAsync(TOKEN_KEY),
SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY),
]);
} }
} }

View File

@@ -1,8 +1,22 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { fetchMe, loginWithEmailPassword, MeResponse, switchTenantRequest, Tenant } from '@/src/lib/api'; import {
fetchMe,
loginWithEmailPassword,
logoutSession,
MeResponse,
isAuthenticationError,
refreshAuthSession,
switchTenantRequest,
Tenant,
} from '@/src/lib/api';
import { hydrateApiBaseUrl } from '@/src/lib/server-config'; import { hydrateApiBaseUrl } from '@/src/lib/server-config';
import { clearStoredToken, getStoredToken, setStoredToken, tokenStorageInfo } from '@/src/lib/token-storage'; import {
clearStoredToken,
getStoredSession,
setStoredSession,
tokenStorageInfo,
} from '@/src/lib/token-storage';
export type AuthUser = MeResponse['user']; export type AuthUser = MeResponse['user'];
@@ -27,6 +41,7 @@ type AuthContextValue = {
const AuthContext = createContext<AuthContextValue | undefined>(undefined); const AuthContext = createContext<AuthContextValue | undefined>(undefined);
const BOOTSTRAP_TIMEOUT_MS = 20000; const BOOTSTRAP_TIMEOUT_MS = 20000;
const SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 60 * 1000;
function normalizeTenantId(value: number | string | null | undefined): number | null { function normalizeTenantId(value: number | string | null | undefined): number | null {
if (value === null || value === undefined || value === '') { if (value === null || value === undefined || value === '') {
@@ -52,6 +67,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isBootstrapping, setIsBootstrapping] = useState(true); const [isBootstrapping, setIsBootstrapping] = useState(true);
const [bootstrapError, setBootstrapError] = useState<string | null>(null); const [bootstrapError, setBootstrapError] = useState<string | null>(null);
const [token, setToken] = useState<string | null>(null); const [token, setToken] = useState<string | null>(null);
const [refreshToken, setRefreshToken] = useState<string | null>(null);
const [user, setUser] = useState<AuthUser | null>(null); const [user, setUser] = useState<AuthUser | null>(null);
const [tenants, setTenants] = useState<Tenant[]>([]); const [tenants, setTenants] = useState<Tenant[]>([]);
const [activeTenantId, setActiveTenantId] = useState<number | null>(null); const [activeTenantId, setActiveTenantId] = useState<number | null>(null);
@@ -80,11 +96,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
); );
const logout = useCallback(async () => { const logout = useCallback(async () => {
if (refreshToken) {
void logoutSession(refreshToken).catch(() => undefined);
}
await clearStoredToken(); await clearStoredToken();
setToken(null); setToken(null);
setRefreshToken(null);
setBootstrapError(null); setBootstrapError(null);
resetSession(); resetSession();
}, [resetSession]); }, [refreshToken, resetSession]);
const refreshUser = useCallback(async () => { const refreshUser = useCallback(async () => {
if (!token) { if (!token) {
@@ -101,6 +121,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const resetLocalSession = useCallback(async () => { const resetLocalSession = useCallback(async () => {
setToken(null); setToken(null);
setRefreshToken(null);
setBootstrapError(null); setBootstrapError(null);
setIsBootstrapping(false); setIsBootstrapping(false);
resetSession(); resetSession();
@@ -117,19 +138,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await withTimeout( await withTimeout(
(async () => { (async () => {
await hydrateApiBaseUrl(); await hydrateApiBaseUrl();
const storedToken = await getStoredToken(); const storedSession = await getStoredSession();
if (!storedToken) { if (!storedSession) {
if (bootstrapRunRef.current !== runId) return; if (bootstrapRunRef.current !== runId) return;
setToken(null); setToken(null);
resetSession(); resetSession();
return; return;
} }
await hydrateSession(storedToken); let activeSession = storedSession;
try {
await hydrateSession(activeSession.token);
} catch {
activeSession = await refreshAuthSession(activeSession.refreshToken);
await setStoredSession(activeSession);
await hydrateSession(activeSession.token);
}
if (bootstrapRunRef.current !== runId) return; if (bootstrapRunRef.current !== runId) return;
setToken(storedToken); setToken(activeSession.token);
setRefreshToken(activeSession.refreshToken);
})(), })(),
BOOTSTRAP_TIMEOUT_MS, BOOTSTRAP_TIMEOUT_MS,
'Die mobile Session konnte nicht rechtzeitig initialisiert werden.' 'Die mobile Session konnte nicht rechtzeitig initialisiert werden.'
@@ -137,9 +166,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} catch (err) { } catch (err) {
if (bootstrapRunRef.current === runId) { if (bootstrapRunRef.current === runId) {
setToken(null); setToken(null);
setRefreshToken(null);
resetSession(); resetSession();
setBootstrapError(err instanceof Error ? err.message : 'Die mobile Session konnte nicht initialisiert werden.'); setBootstrapError(err instanceof Error ? err.message : 'Die mobile Session konnte nicht initialisiert werden.');
void clearStoredToken().catch(() => undefined); if (isAuthenticationError(err)) {
void clearStoredToken().catch(() => undefined);
}
} }
} finally { } finally {
if (bootstrapRunRef.current === runId) { if (bootstrapRunRef.current === runId) {
@@ -154,28 +186,49 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const login = useCallback( const login = useCallback(
async (email: string, password: string) => { async (email: string, password: string) => {
const nextToken = await loginWithEmailPassword(email, password); const session = await loginWithEmailPassword(email, password);
await setStoredToken(nextToken); await setStoredSession(session);
await hydrateSession(nextToken); await hydrateSession(session.token);
setToken(nextToken); setToken(session.token);
setRefreshToken(session.refreshToken);
}, },
[hydrateSession] [hydrateSession]
); );
const switchTenant = useCallback( const switchTenant = useCallback(
async (tenantId: number) => { async (tenantId: number) => {
if (!token) { if (!token || !refreshToken) {
throw new Error('No active session found.'); throw new Error('No active session found.');
} }
const nextToken = await switchTenantRequest(tenantId, token); const session = await switchTenantRequest(tenantId, token, refreshToken);
await setStoredToken(nextToken); await setStoredSession(session);
await hydrateSession(nextToken); await hydrateSession(session.token);
setToken(nextToken); setToken(session.token);
setRefreshToken(session.refreshToken);
}, },
[token, hydrateSession] [token, refreshToken, hydrateSession]
); );
useEffect(() => {
if (!refreshToken) return;
const interval = setInterval(() => {
void (async () => {
try {
const session = await refreshAuthSession(refreshToken);
await setStoredSession(session);
setToken(session.token);
setRefreshToken(session.refreshToken);
} catch {
// Bei einem temporären Netzfehler bleibt die gespeicherte Session erhalten.
}
})();
}, SESSION_REFRESH_INTERVAL_MS);
return () => clearInterval(interval);
}, [refreshToken]);
const activeTenant = useMemo( const activeTenant = useMemo(
() => tenants.find((tenant) => Number(tenant.id) === activeTenantId) || null, () => tenants.find((tenant) => Number(tenant.id) === activeTenantId) || null,
[activeTenantId, tenants] [activeTenantId, tenants]