Compare commits

...

17 Commits

Author SHA1 Message Date
d3ad53bcf0 Improve unsupported PDF character errors
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 30s
Build and Push Docker Images / build-frontend (push) Successful in 1m12s
Build and Push Docker Images / build-website (push) Successful in 18s
Build and Push Docker Images / build-docs (push) Successful in 17s
2026-07-24 10:25:10 +02:00
4ab4c24b1a Avoid unnecessary invoice QR page break 2026-07-22 20:51:13 +02:00
e7107f151a Reserve invoice QR code space 2026-07-22 20:46:52 +02:00
c222b793b0 Raise invoice payment QR code 2026-07-22 19:48:30 +02:00
5dcdc29a18 Place invoice payment QR code bottom right 2026-07-22 19:45:01 +02:00
fbea66b754 Use tenant business payment info for invoice QR codes 2026-07-22 19:40:01 +02:00
621243ba7b Merge remote-tracking branch 'origin/dev' into dev 2026-07-22 19:10:11 +02:00
dd2a9926dc Add payment QR code to invoice PDFs 2026-07-22 18:51:13 +02:00
3230fc6cf0 KI-AGENT: Spaltenbreiten eingeklappter Titelbereiche erhalten
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m12s
Build and Push Docker Images / build-frontend (push) Successful in 1m18s
Build and Push Docker Images / build-website (push) Successful in 19s
Build and Push Docker Images / build-docs (push) Successful in 17s
2026-07-21 21:48:19 +02:00
b8220685d7 KI-AGENT: Position des Titelbereich-Schalters stabilisieren 2026-07-21 21:45:58 +02:00
b7b8a2077a KI-AGENT: Einklappbare Titelbereiche im Dokumenteneditor ergänzen 2026-07-21 21:40:07 +02:00
5e7a1f9aee KI-AGENT: E-Rechnungen in der Belegvorbereitung verarbeiten 2026-07-21 20:20:34 +02:00
6c613857fa KI-AGENT: ZUGFeRD- und E-Rechnungserkennung ergänzen 2026-07-21 19:59:44 +02:00
430ab74522 Merge remote-tracking branch 'origin/dev' into dev 2026-07-21 19:55:51 +02:00
5585bd7012 KI-AGENT: Refresh-Token für Web-Sitzungen ergänzen 2026-07-20 22:12:09 +02:00
657a4f9e85 KI-AGENT: Persistente App-Sessions mit Refresh-Tokens ergänzen 2026-07-20 22:01:08 +02:00
769185cd82 Leeren individuellen Aufschlag normalisieren 2026-07-20 19:35:07 +02:00
38 changed files with 1823 additions and 201 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,
"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
}
]
}

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"),
preparationSource: text("preparation_source"),
eInvoiceSyntax: text("e_invoice_syntax"),
eInvoiceProfile: text("e_invoice_profile"),
eInvoiceValidation: jsonb("e_invoice_validation"),
accounts: jsonb("accounts").notNull().default([
{
account: null,

View File

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

View File

@@ -60,6 +60,15 @@ export const tenants = pgTable(
city: "",
name: "",
street: "",
email: "",
phone: "",
website: "",
taxNumber: "",
vatId: "",
bankName: "",
bankAccountOwner: "",
iban: "",
bic: "",
}),
features: jsonb("features").default({

View File

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

View File

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

View File

@@ -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<string, string> = {
"Direct Debit": "Einzug",
"Transfer": "Überweisung",
"Credit Card": "Kreditkarte",
"Other": "Sonstiges",
}
if (data.terms) itemInfo.paymentType = mapPayment[data.terms] ?? data.terms
if (data.invoice_items?.length > 0) {
itemInfo.accounts = data.invoice_items
.filter((item: any) => item.description || item.total !== null || item.total_without_tax !== null)
.map((item: any) => {
const total = typeof item.total === "number" ? item.total : null
const totalWithoutTax = typeof item.total_without_tax === "number" ? item.total_without_tax : null
const amountTax = total !== null && totalWithoutTax !== null
? Number((total - totalWithoutTax).toFixed(2))
: null
return {
account: item.account_id,
description: item.description,
amountNet: totalWithoutTax,
amountTax,
taxType: item.tax_rate !== null ? String(item.tax_rate) : null,
amountGross: total,
costCentre: null,
quantity: item.quantity,
}
})
}
let description = ""
if (data.delivery_note_number) description += `Lieferschein: ${data.delivery_note_number}\n`
if (data.reference) description += `Referenz: ${data.reference}\n`
for (const item of data.invoice_items || []) {
const line = formatInvoiceItemDescription(item)
if (line) description += `${line}\n`
}
itemInfo.description = description.trim()
return itemInfo
}
export function prepareIncomingInvoices(server: FastifyInstance) {
const processInvoices = async (tenantId:number) => {
console.log("▶ Starting Incoming Invoice Preparation")
@@ -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) {
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

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

@@ -405,12 +405,15 @@ async function getCloseData(server:FastifyInstance,item: any, tenant: any, units
console.log(item);
const [contact, customer, project, contract] = await Promise.all([
const [contact, customer, project, contract, bankAccounts] = await Promise.all([
fetchById(server, schema.contacts, item.contact),
fetchById(server, schema.customers, item.customer),
fetchById(server, schema.projects, item.project),
fetchById(server, schema.contracts, item.contract),
item.letterhead ? fetchById(server, schema.letterheads, item.letterhead) : null
server.db
.select()
.from(schema.bankaccounts)
.where(and(eq(schema.bankaccounts.tenant, tenant.id), eq(schema.bankaccounts.archived, false), eq(schema.bankaccounts.expired, false)))
]);
@@ -433,7 +436,8 @@ async function getCloseData(server:FastifyInstance,item: any, tenant: any, units
contract, // Contract Object
units, // Units Array
products, // Products Array
services // Services Array
services, // Services Array
bankAccounts // Tenant Bank Accounts
);
@@ -528,9 +532,14 @@ export function getDocumentDataBackend(
contractData: any, // Vertrag
units: any[], // Array aller Einheiten
products: any[], // Array aller Produkte
services: any[] // Array aller Services
services: any[], // Array aller Services
bankAccounts: any[] = [] // Array aller Bankkonten
) {
const businessInfo = tenant.businessInfo || {}; // Fallback falls leer
const paymentQrBankAccount = bankAccounts.find((account: any) => !account.archived && !account.expired && account.iban) || null
const paymentQrIban = businessInfo.iban || paymentQrBankAccount?.iban
const paymentQrBic = businessInfo.bic || paymentQrBankAccount?.bic
const paymentQrRecipient = businessInfo.bankAccountOwner || paymentQrBankAccount?.ownerName || businessInfo.name || tenant.name
// --- 1. Agriculture Logic ---
// Prüfen ob 'extraModules' existiert, sonst leeres Array annehmen
@@ -724,6 +733,13 @@ export function getDocumentDataBackend(
},
agriculture: itemInfo.agriculture,
// Falls du AdvanceInvoices brauchst, musst du die Objekte hier übergeben oder leer lassen
usedAdvanceInvoices: []
usedAdvanceInvoices: [],
paymentQr: paymentQrIban && itemInfo.payment_type === "transfer" ? {
iban: paymentQrIban,
bic: paymentQrBic,
recipientName: paymentQrRecipient,
amount: totals.totalGross,
remittance: itemInfo.documentNumber || itemInfo.title,
} : null
};
}

View File

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

View File

@@ -25,6 +25,7 @@ import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets";
import { storeExtractedTextForFile } from "../utils/documentText";
import { generateLiquidityForecast } from "../utils/liquidityForecast";
import { getUnsupportedPdfCharacterMessage } from "../utils/pdfCharacterError";
dayjs.extend(customParseFormat)
dayjs.extend(isoWeek)
dayjs.extend(isBetween)
@@ -134,7 +135,15 @@ export default async function functionRoutes(server: FastifyInstance) {
return pdf // Fastify wandelt automatisch in JSON
} catch (err) {
console.log(err)
reply.code(500).send({ error: "Failed to create PDF" })
const unsupportedCharacterMessage = getUnsupportedPdfCharacterMessage(err, body.data)
if (unsupportedCharacterMessage) {
return reply.code(422).send({
error: unsupportedCharacterMessage,
code: "UNSUPPORTED_PDF_CHARACTER",
})
}
return reply.code(500).send({ error: "Failed to create PDF" })
}
})

View File

@@ -239,6 +239,13 @@ function isDateValue(value: any) {
}
function normalizeCreatedDocumentPayload(payload: Record<string, any>) {
if (Object.prototype.hasOwnProperty.call(payload, "customSurchargePercentage")) {
const customSurchargePercentage = Number(payload.customSurchargePercentage)
payload.customSurchargePercentage = Number.isFinite(customSurchargePercentage)
? customSurchargePercentage
: 0
}
const numberRelationFields = [
"customer",
"contact",

View File

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

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 { 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<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)
// ---------------------------------------------------------
@@ -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;

View File

@@ -5,6 +5,7 @@ import {FastifyInstance} from "fastify";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { s3 } from "./s3";
import { secrets } from "./secrets";
import bwipjs from "bwip-js"
const getCoordinatesForPDFLib = (x:number ,y:number, page:any) => {
/*
@@ -29,6 +30,61 @@ const getRightAlignedX = (rightEdgeMm: number, text: string, size: number, font:
return getCoordinatesForPDFLib(rightEdgeMm, 0, page).x - font.widthOfTextAtSize(text, size)
}
const normalizePaymentQrText = (value: any, maxLength: number) => {
return String(value || "")
.replace(/\r?\n/g, " ")
.replace(/[^\x20-\x7EÄÖÜäöüß]/g, "")
.trim()
.slice(0, maxLength)
}
const normalizeIban = (value: any) => String(value || "").replace(/\s+/g, "").toUpperCase()
const getPaymentQrAmount = (value: any) => {
if (typeof value === "number" && Number.isFinite(value)) return value
const normalized = String(value || "")
.replace(/[^\d,.-]/g, "")
.replace(/\./g, "")
.replace(",", ".")
const amount = Number(normalized)
return Number.isFinite(amount) ? amount : 0
}
const getPaymentQrCodeBuffer = async (invoiceData: any) => {
if (invoiceData?.payment_type && invoiceData.payment_type !== "transfer") return null
const paymentQr = invoiceData?.paymentQr || {}
const iban = normalizeIban(paymentQr.iban)
const recipientName = normalizePaymentQrText(paymentQr.recipientName || paymentQr.name, 70)
const amount = getPaymentQrAmount(paymentQr.amount ?? invoiceData?.total?.totalSumToPay)
if (!iban || !recipientName || amount <= 0) return null
const epcPayload = [
"BCD",
"002",
"1",
"SCT",
normalizePaymentQrText(paymentQr.bic, 11),
recipientName,
iban,
`EUR${amount.toFixed(2)}`,
"",
normalizePaymentQrText(paymentQr.remittance || invoiceData?.documentNumber || invoiceData?.title, 140),
"",
].join("\n")
return bwipjs.toBuffer({
bcid: "qrcode",
text: epcPayload,
scale: 4,
includetext: false,
})
}
const getBackgroundSourceBuffer = async (server:FastifyInstance, path:string) => {
@@ -70,6 +126,16 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
const font = await pdfDoc.embedFont(StandardFonts.Helvetica)
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold)
const paymentQrCodeBuffer = await getPaymentQrCodeBuffer(invoiceData)
const paymentQrCode = paymentQrCodeBuffer ? await pdfDoc.embedPng(paymentQrCodeBuffer) : null
const mm = 2.83
const paymentQrLayout = {
sizeMm: 32,
rightMarginMm: 20,
bottomMarginMm: 40,
labelGapMm: 5,
safeGapMm: 5,
}
let pages = []
let pageCounter = 1
@@ -81,6 +147,21 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
const firstPageBackground = await pdfDoc.embedPage(backgroudPdf.getPages()[0])
const secondPageBackground = await pdfDoc.embedPage(backgroudPdf.getPages()[backgroudPdf.getPages().length > 1 ? 1 : 0])
const getPaymentQrX = (page: any) => {
return page.getWidth()
- paymentQrLayout.rightMarginMm * mm
- paymentQrLayout.sizeMm * mm
}
const getTextWidthBeforePaymentQr = (page: any, startX: number) => {
if (!paymentQrCode) return 500
return Math.max(
240,
getPaymentQrX(page) - startX - paymentQrLayout.safeGapMm * mm
)
}
//
const page1 = pdfDoc.addPage()
@@ -916,14 +997,42 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
}
const endTextPosition = getCoordinatesForPDFLib(21, rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8, page1)
pages[pageCounter - 1].drawText(invoiceData.endText, {
...getCoordinatesForPDFLib(21, rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8, page1),
...endTextPosition,
size: 10,
color: rgb(0, 0, 0),
lineHeight: 10,
opacity: 1,
maxWidth: 500
maxWidth: getTextWidthBeforePaymentQr(pages[pageCounter - 1], endTextPosition.x)
})
}
if (paymentQrCode) {
const lastPage = pages[pages.length - 1]
const qrSize = paymentQrLayout.sizeMm * mm
const bottomMargin = paymentQrLayout.bottomMarginMm * mm
const labelGap = paymentQrLayout.labelGapMm * mm
const qrX = getPaymentQrX(lastPage)
const qrY = bottomMargin
lastPage.drawText("Überweisung per QR-Code", {
x: qrX,
y: qrY + qrSize + labelGap,
size: 8,
color: rgb(0, 0, 0),
lineHeight: 8,
opacity: 1,
maxWidth: qrSize,
font: fontBold,
})
lastPage.drawImage(paymentQrCode, {
x: qrX,
y: qrY,
width: qrSize,
height: qrSize,
})
}

View File

@@ -0,0 +1,79 @@
const PDF_CHARACTER_ERROR_PATTERN = /WinAnsi cannot encode "[\s\S]+?" \(0x([0-9a-f]+)\)/i
const DOCUMENT_FIELD_LABELS: Record<string, string> = {
title: "Dokumenttitel",
description: "Dokumentbeschreibung",
startText: "Einleitungstext",
endText: "Schlusstext",
text: "Bezeichnung",
descriptionText: "Beschreibung",
unit: "Einheit",
}
type CharacterLocation = {
label: string
characterPosition: number
}
const labelForPath = (path: Array<string | number>, root: any): string => {
if (path[0] === "rows" && typeof path[1] === "number") {
const row = root?.rows?.[path[1]]
const position = row?.pos ? `Position ${row.pos}` : `Dokumentzeile ${path[1] + 1}`
const field = DOCUMENT_FIELD_LABELS[String(path[path.length - 1])] || String(path[path.length - 1])
return `${position}, ${field}`
}
const field = String(path[path.length - 1])
return DOCUMENT_FIELD_LABELS[field] || field
}
const findCharacterLocations = (
value: unknown,
unsupportedCharacter: string,
root: any,
path: Array<string | number> = [],
): CharacterLocation[] => {
if (typeof value === "string") {
const characters = Array.from(value)
return characters.flatMap((character, index) =>
character === unsupportedCharacter
? [{ label: labelForPath(path, root), characterPosition: index + 1 }]
: []
)
}
if (Array.isArray(value)) {
return value.flatMap((entry, index) =>
findCharacterLocations(entry, unsupportedCharacter, root, [...path, index])
)
}
if (value && typeof value === "object") {
return Object.entries(value).flatMap(([key, entry]) =>
findCharacterLocations(entry, unsupportedCharacter, root, [...path, key])
)
}
return []
}
export const getUnsupportedPdfCharacterMessage = (error: unknown, documentData: any): string | null => {
const errorMessage = error instanceof Error ? error.message : String(error)
const match = errorMessage.match(PDF_CHARACTER_ERROR_PATTERN)
if (!match) return null
const numericCodePoint = Number.parseInt(match[1], 16)
if (!Number.isSafeInteger(numericCodePoint) || numericCodePoint > 0x10FFFF) return null
const unsupportedCharacter = String.fromCodePoint(numericCodePoint)
const [location] = findCharacterLocations(documentData, unsupportedCharacter, documentData)
const codePoint = numericCodePoint
.toString(16)
.toUpperCase()
.padStart(4, "0")
const locationText = location
? ` Fundstelle: ${location.label}, Zeichen ${location.characterPosition}.`
: ""
return `Das Zeichen „${unsupportedCharacter}“ (Unicode U+${codePoint}) wird in PDF-Dokumenten nicht unterstützt.${locationText} Bitte ersetze oder entferne das Zeichen.`
}

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

@@ -0,0 +1,27 @@
import assert from "node:assert/strict"
import test from "node:test"
import { getUnsupportedPdfCharacterMessage } from "../src/utils/pdfCharacterError"
test("reports unsupported characters with the document position and character offset", () => {
// pdf-lib displays supplementary Unicode characters as a private-use glyph,
// while the hexadecimal value still contains the original code point.
const error = new Error('WinAnsi cannot encode "" (0x1f600)')
const documentData = {
rows: [
{ pos: "1", text: "Montage" },
{ pos: "2.3", descriptionText: "Gerät 😀 montieren" },
],
}
assert.equal(
getUnsupportedPdfCharacterMessage(error, documentData),
"Das Zeichen „😀“ (Unicode U+1F600) wird in PDF-Dokumenten nicht unterstützt. Fundstelle: Position 2.3, Beschreibung, Zeichen 7. Bitte ersetze oder entferne das Zeichen."
)
})
test("returns null for unrelated PDF errors", () => {
assert.equal(
getUnsupportedPdfCharacterMessage(new Error("Background PDF is invalid"), {}),
null
)
})

View File

@@ -91,10 +91,12 @@ 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([])
const outgoingsepamandates = ref([])
const bankaccounts = ref([])
const texttemplates = ref([])
const units = ref([])
const tenantUsers = ref([])
@@ -236,6 +238,12 @@ const setupData = async () => {
contacts.value = await useEntities("contacts").select("*")
contracts.value = await useEntities("contracts").select("*")
outgoingsepamandates.value = await useEntities("outgoingsepamandates").select("*")
try {
bankaccounts.value = await useEntities("bankaccounts").select("*")
} catch (error) {
console.warn("Bankkonten konnten nicht für den Zahlungs-QR-Code geladen werden", error)
bankaccounts.value = []
}
texttemplates.value = await useEntities("texttemplates").select("*")
units.value = await useEntities("units").selectSpecial("*")
tenantUsers.value = (await useNuxtApp().$api(`/api/tenant/users`, {
@@ -928,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
@@ -1364,7 +1411,11 @@ const getDocumentData = async () => {
let customerData = customers.value.find(i => i.id === itemInfo.value.customer)
let contactData = contacts.value.find(i => i.id === itemInfo.value.contact)
let businessInfo = auth.activeTenantData.businessInfo
let businessInfo = auth.activeTenantData.businessInfo || {}
const paymentQrBankAccount = bankaccounts.value.find(account => !account.archived && !account.expired && account.iban)
const paymentQrIban = businessInfo.iban || paymentQrBankAccount?.iban
const paymentQrBic = businessInfo.bic || paymentQrBankAccount?.bic
const paymentQrRecipient = businessInfo.bankAccountOwner || paymentQrBankAccount?.ownerName || businessInfo.name || auth.activeTenantData.name
if (auth.activeTenantData.extraModules.includes("agriculture")) {
itemInfo.value.rows.forEach(row => {
@@ -1580,7 +1631,14 @@ const getDocumentData = async () => {
agriculture: itemInfo.value.agriculture,
usedAdvanceInvoices: itemInfo.value.usedAdvanceInvoices.map(i => {
return createddocuments.value.find(x => x.id === i)
})
}),
paymentQr: paymentQrIban && itemInfo.value.payment_type === "transfer" ? {
iban: paymentQrIban,
bic: paymentQrBic,
recipientName: paymentQrRecipient,
amount: documentTotal.value.totalSumToPay,
remittance: itemInfo.value.documentNumber || itemInfo.value.title,
} : null
}
console.log(returnData)
@@ -1701,6 +1759,7 @@ const saveSerialInvoice = async () => {
const saveDocument = async (state, resetup = false) => {
itemInfo.value.state = state
normalizeCustomSurchargePercentage()
if (state !== "Entwurf") {
console.log("???")
@@ -1870,7 +1929,14 @@ const checkCompatibilityWithInputPrice = () => {
})
}
const normalizeCustomSurchargePercentage = () => {
const percentage = Number(itemInfo.value.customSurchargePercentage)
itemInfo.value.customSurchargePercentage = Number.isFinite(percentage) ? percentage : 0
}
const updateCustomSurcharge = () => {
normalizeCustomSurchargePercentage()
itemInfo.value.rows.forEach(row => {
if (!["pagebreak", "title", "text"].includes(row.mode) && isImportedAdvanceInvoiceRow(row)) {
row.price = Number(row.inputPrice || 0)
@@ -2875,13 +2941,28 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
@end="setPosNumbers"
>
<template #item="{element: row}">
<tr>
<tr :class="{ collapse: isDocumentRowHidden(row.id) }">
<td>
<UIcon
class="handle"
name="i-mdi-menu"
v-if="itemInfo.type !== 'cancellationInvoices'"
/>
<div class="flex items-center gap-1 whitespace-nowrap">
<UIcon
class="handle shrink-0"
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
v-if="row.mode === 'pagebreak'"

View File

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

View File

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

View File

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

View File

@@ -214,17 +214,34 @@ const activeTelephonyTargetOptions = computed(() => {
if (telephonyExtensionForm.targetType === "branch") return telephonyExtensionOptions.value.branches || []
return telephonyExtensionOptions.value.users || []
})
const defaultBusinessInfo = {
name: "",
street: "",
zip: "",
city: "",
email: "",
phone: "",
website: "",
taxNumber: "",
vatId: "",
bankName: "",
bankAccountOwner: "",
iban: "",
bic: "",
}
const normalizeBusinessInfo = (value) => ({ ...defaultBusinessInfo, ...(value || {}) })
const setupPage = async () => {
itemInfo.value = auth.activeTenantData
console.log(itemInfo.value)
businessInfo.value = normalizeBusinessInfo(auth.activeTenantData?.businessInfo)
planningBoardConfig.startTime = auth.activeTenantData?.calendarConfig?.planningBoard?.startTime || "06:00"
planningBoardConfig.endTime = auth.activeTenantData?.calendarConfig?.planningBoard?.endTime || "21:00"
planningBoardConfig.slotMinutes = auth.activeTenantData?.calendarConfig?.planningBoard?.slotMinutes || 180
}
const features = ref({ ...defaultFeatures, ...(auth.activeTenantData?.features || {}) })
const businessInfo = ref(auth.activeTenantData.businessInfo)
const businessInfo = ref(normalizeBusinessInfo(auth.activeTenantData?.businessInfo))
const accountChart = ref(auth.activeTenantData.accountChart || "skr03")
const taxEvaluationPeriod = ref(normalizeTaxEvaluationPeriod(auth.activeTenantData.taxEvaluationPeriod))
const accountChartOptions = [
@@ -245,6 +262,7 @@ const updateTenant = async (newData) => {
itemInfo.value = res
auth.activeTenantData = res
features.value = { ...defaultFeatures, ...(res?.features || {}) }
businessInfo.value = normalizeBusinessInfo(res?.businessInfo)
taxEvaluationPeriod.value = normalizeTaxEvaluationPeriod(res?.taxEvaluationPeriod)
}
}
@@ -628,6 +646,51 @@ onMounted(() => {
<UInput v-model="businessInfo.city" class="flex-auto"/>
</InputGroup>
</UFormField>
<UFormField
label="E-Mail:"
>
<UInput v-model="businessInfo.email"/>
</UFormField>
<UFormField
label="Telefon:"
>
<UInput v-model="businessInfo.phone"/>
</UFormField>
<UFormField
label="Website:"
>
<UInput v-model="businessInfo.website"/>
</UFormField>
<UFormField
label="Steuernummer:"
>
<UInput v-model="businessInfo.taxNumber"/>
</UFormField>
<UFormField
label="USt-IdNr.:"
>
<UInput v-model="businessInfo.vatId"/>
</UFormField>
<UFormField
label="Bank:"
>
<UInput v-model="businessInfo.bankName"/>
</UFormField>
<UFormField
label="Kontoinhaber:"
>
<UInput v-model="businessInfo.bankAccountOwner"/>
</UFormField>
<UFormField
label="IBAN:"
>
<UInput v-model="businessInfo.iban"/>
</UFormField>
<UFormField
label="BIC:"
>
<UInput v-model="businessInfo.bic"/>
</UFormField>
<UButton
class="mt-3"
@click="updateTenant({businessInfo: businessInfo})"

View File

@@ -45,6 +45,11 @@ export default defineNuxtPlugin(() => {
let title = "Fehler"
let description = "Ein unerwarteter Fehler ist aufgetreten."
if (status === 422 && response._data?.code === "UNSUPPORTED_PDF_CHARACTER") {
title = "Nicht unterstütztes Zeichen"
description = response._data.error
}
switch (status) {
case 400:
title = "Anfrage fehlerhaft"

View File

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

View File

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

View File

@@ -33,6 +33,7 @@ export default function LoginScreen() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [apiBaseUrl, setApiBaseUrl] = useState(getApiBaseUrlSync());
const [serverInput, setServerInput] = useState(getApiBaseUrlSync());
const [showServerModal, setShowServerModal] = useState(false);
@@ -141,14 +142,23 @@ export default function LoginScreen() {
/>
<Text style={styles.label}>Passwort</Text>
<TextInput
secureTextEntry
placeholder="••••••••"
placeholderTextColor="#9ca3af"
style={styles.input}
value={password}
onChangeText={setPassword}
/>
<View style={styles.passwordRow}>
<TextInput
secureTextEntry={!isPasswordVisible}
placeholder="••••••••"
placeholderTextColor="#9ca3af"
style={styles.passwordInput}
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}
@@ -251,6 +261,30 @@ const styles = StyleSheet.create({
fontSize: 16,
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: {
marginTop: 6,
backgroundColor: PRIMARY,

View File

@@ -131,6 +131,11 @@ export type MeResponse = {
permissions: string[];
};
export type AuthSession = {
token: string;
refreshToken: string;
};
export type EncodedLabelRow = {
dataType: 'pixels' | 'void' | 'check';
rowNumber: number;
@@ -199,6 +204,20 @@ type RequestOptions = {
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 = {
enabled?: 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)?.error ||
`Request failed (${response.status}) for ${path}`;
throw new Error(message);
throw new ApiError(message, response.status);
}
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)?.error ||
`Request failed (${response.status}) for ${path}`;
throw new Error(message);
throw new ApiError(message, response.status);
}
return payload as T;
@@ -629,17 +648,37 @@ export async function renderPrintLabel(
});
}
export async function loginWithEmailPassword(email: string, password: string): Promise<string> {
const payload = await apiRequest<{ token?: string }>('/auth/login', {
function requireAuthSession(payload: Partial<AuthSession>, action: string): AuthSession {
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',
body: { email, password },
});
if (!payload?.token) {
throw new Error('Login did not return a token.');
}
return requireAuthSession(payload, 'Login');
}
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> {
@@ -666,18 +705,18 @@ export async function sendMobileTestPush(token: string): Promise<{ accepted: num
});
}
export async function switchTenantRequest(tenantId: number, token: string): Promise<string> {
const payload = await apiRequest<{ token?: string }>('/api/tenant/switch', {
export async function switchTenantRequest(
tenantId: number,
token: string,
refreshToken: string
): Promise<AuthSession> {
const payload = await apiRequest<Partial<AuthSession>>('/api/tenant/switch', {
method: 'POST',
token,
body: { tenant_id: String(tenantId) },
body: { tenant_id: String(tenantId), refreshToken },
});
if (!payload?.token) {
throw new Error('Tenant switch did not return a token.');
}
return payload.token;
return requireAuthSession(payload, 'Tenant switch');
}
export async function fetchTasks(token: string): Promise<Task[]> {

View File

@@ -1,8 +1,15 @@
import * as SecureStore from 'expo-secure-store';
const TOKEN_KEY = 'fedeo.mobile.auth.token';
const REFRESH_TOKEN_KEY = 'fedeo.mobile.auth.refresh-token';
let memoryToken: string | null = null;
let memoryRefreshToken: string | null = null;
export type StoredSession = {
token: string;
refreshToken: string;
};
async function hasSecureStore(): Promise<boolean> {
try {
@@ -30,11 +37,41 @@ export async function setStoredToken(token: string): Promise<void> {
}
}
export async function clearStoredToken(): Promise<void> {
memoryToken = null;
export async function getStoredSession(): Promise<StoredSession | 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()) {
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 { 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 { clearStoredToken, getStoredToken, setStoredToken, tokenStorageInfo } from '@/src/lib/token-storage';
import {
clearStoredToken,
getStoredSession,
setStoredSession,
tokenStorageInfo,
} from '@/src/lib/token-storage';
export type AuthUser = MeResponse['user'];
@@ -27,6 +41,7 @@ type AuthContextValue = {
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
const BOOTSTRAP_TIMEOUT_MS = 20000;
const SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 60 * 1000;
function normalizeTenantId(value: number | string | null | undefined): number | null {
if (value === null || value === undefined || value === '') {
@@ -52,6 +67,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isBootstrapping, setIsBootstrapping] = useState(true);
const [bootstrapError, setBootstrapError] = 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 [tenants, setTenants] = useState<Tenant[]>([]);
const [activeTenantId, setActiveTenantId] = useState<number | null>(null);
@@ -80,11 +96,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
);
const logout = useCallback(async () => {
if (refreshToken) {
void logoutSession(refreshToken).catch(() => undefined);
}
await clearStoredToken();
setToken(null);
setRefreshToken(null);
setBootstrapError(null);
resetSession();
}, [resetSession]);
}, [refreshToken, resetSession]);
const refreshUser = useCallback(async () => {
if (!token) {
@@ -101,6 +121,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const resetLocalSession = useCallback(async () => {
setToken(null);
setRefreshToken(null);
setBootstrapError(null);
setIsBootstrapping(false);
resetSession();
@@ -117,19 +138,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await withTimeout(
(async () => {
await hydrateApiBaseUrl();
const storedToken = await getStoredToken();
const storedSession = await getStoredSession();
if (!storedToken) {
if (!storedSession) {
if (bootstrapRunRef.current !== runId) return;
setToken(null);
resetSession();
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;
setToken(storedToken);
setToken(activeSession.token);
setRefreshToken(activeSession.refreshToken);
})(),
BOOTSTRAP_TIMEOUT_MS,
'Die mobile Session konnte nicht rechtzeitig initialisiert werden.'
@@ -137,9 +166,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} catch (err) {
if (bootstrapRunRef.current === runId) {
setToken(null);
setRefreshToken(null);
resetSession();
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 {
if (bootstrapRunRef.current === runId) {
@@ -154,28 +186,49 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const login = useCallback(
async (email: string, password: string) => {
const nextToken = await loginWithEmailPassword(email, password);
await setStoredToken(nextToken);
await hydrateSession(nextToken);
setToken(nextToken);
const session = await loginWithEmailPassword(email, password);
await setStoredSession(session);
await hydrateSession(session.token);
setToken(session.token);
setRefreshToken(session.refreshToken);
},
[hydrateSession]
);
const switchTenant = useCallback(
async (tenantId: number) => {
if (!token) {
if (!token || !refreshToken) {
throw new Error('No active session found.');
}
const nextToken = await switchTenantRequest(tenantId, token);
await setStoredToken(nextToken);
await hydrateSession(nextToken);
setToken(nextToken);
const session = await switchTenantRequest(tenantId, token, refreshToken);
await setStoredSession(session);
await hydrateSession(session.token);
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(
() => tenants.find((tenant) => Number(tenant.id) === activeTenantId) || null,
[activeTenantId, tenants]