Compare commits

...

43 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
37d08ee984 Prevent duplicate tenant memberships on import
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 37s
Build and Push Docker Images / build-frontend (push) Successful in 1m18s
Build and Push Docker Images / build-website (push) Successful in 17s
Build and Push Docker Images / build-docs (push) Successful in 16s
2026-07-20 19:06:01 +02:00
720c70845e Rechnungsbeträge deutsch formatieren 2026-07-20 10:17:00 +02:00
ae379b0f22 Aggregate nested document title sums 2026-07-15 13:21:31 +02:00
6a14b0c1ca Extend document title levels 2026-07-15 13:11:57 +02:00
d8273e794d Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m0s
Build and Push Docker Images / build-frontend (push) Successful in 1m13s
Build and Push Docker Images / build-website (push) Successful in 20s
Build and Push Docker Images / build-docs (push) Successful in 16s
2026-07-15 12:49:25 +02:00
1711f975bb KI-AGENT: Dropdown zum Einfügen von Dokumentpositionen ergänzt 2026-07-15 10:15:11 +02:00
4e3f1e0c15 Include letterhead files in tenant archive
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 27s
Build and Push Docker Images / build-frontend (push) Successful in 1m10s
Build and Push Docker Images / build-website (push) Successful in 17s
Build and Push Docker Images / build-docs (push) Successful in 16s
2026-07-14 17:33:05 +02:00
a9b063aa89 Add tenant import progress polling 2026-07-14 17:27:59 +02:00
473c92d2e4 docs: describe selfhost install and update flow
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 26s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Build and Push Docker Images / build-website (push) Successful in 16s
Build and Push Docker Images / build-docs (push) Successful in 16s
2026-07-14 17:04:24 +02:00
3153a7a669 Allow terminating idle migration blockers 2026-07-14 16:58:01 +02:00
d0f5a72350 Diagnose tenant lock migration blockers 2026-07-14 16:50:27 +02:00
4c03a96cbe Add logging to tenant lock migration script 2026-07-14 16:48:37 +02:00
bf9e397e51 Add tenant lock migration repair script 2026-07-14 16:40:32 +02:00
31ef408b66 Merge remote-tracking branch 'origin/dev' into dev 2026-07-14 16:33:07 +02:00
a945aaffe3 Extend tenant maintenance lock for imports
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 27s
Build and Push Docker Images / build-frontend (push) Successful in 1m11s
Build and Push Docker Images / build-website (push) Successful in 18s
Build and Push Docker Images / build-docs (push) Successful in 16s
2026-07-14 11:40:48 +02:00
5f66f81ade Merge remote-tracking branch 'origin/dev' into dev 2026-07-13 21:22:36 +02:00
ee7d3d3afc Fix letterhead remapping in tenant import
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 33s
Build and Push Docker Images / build-frontend (push) Successful in 18s
Build and Push Docker Images / build-website (push) Successful in 17s
Build and Push Docker Images / build-docs (push) Successful in 17s
2026-07-13 18:14:27 +02:00
b3067e0e00 KI-AGENT: Erhöht iOS-Buildnummer nach Icon-Korrektur 2026-07-09 08:13:25 +02:00
e30722995e KI-AGENT: Ergänzt native Expo-Dateisystem-Abhängigkeit 2026-07-09 07:59:28 +02:00
bbef553e6b KI-AGENT: Erhöht iOS-Buildnummer für TestFlight 2026-07-09 07:57:48 +02:00
53632d25ac KI-AGENT: Aktualisiert Expo-Patch-Versionen 2026-07-09 07:46:23 +02:00
2eaa49bf05 KI-AGENT: Aktualisiert Mobile-App-Icons 2026-07-09 07:41:52 +02:00
14026181c2 KI-AGENT: Stabilisiert mobilen Session-Start 2026-07-09 07:34:44 +02:00
d67da7e02a KI-AGENT: Skript zum Löschen von Rechnungsbereichen ergänzen 2026-07-03 20:56:15 +02:00
3b3a879b02 KI-AGENT: Individuellen Aufschlag bei importierten Abschlagsrechnungen verhindern 2026-06-15 10:41:49 +02:00
365cec206d KI-AGENT: Selfhost-Netzwerke beim Setup bereinigen 2026-06-03 15:15:33 +02:00
63 changed files with 4037 additions and 906 deletions

View File

@@ -146,7 +146,7 @@ Auf einem frischen Server kannst du die Betriebsdateien und die Konfiguration di
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash
```
Der schnelle One-Liner mit direktem Stack-Start:
Der One-Liner von der Webseite nutzt den schnellen Standardpfad und startet den Stack direkt:
```bash
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash -s -- --simple --start
@@ -491,17 +491,41 @@ Wenn der Bootstrap aktiviert ist, kannst du dich danach mit `FEDEO_BOOTSTRAP_ADM
## Updates
Bei neuen Versionen:
Script-installierte Instanzen liegen standardmassig unter `/opt/fedeo`. Ein Update besteht daraus, die Selfhost-Dateien zu erneuern, die vorgebauten Images zu ziehen und den Compose-Stack neu zu starten.
Vor kritischen Updates sollte ein Backup der persistenten Daten erstellt werden:
```bash
cd /opt/fedeo
tar -czf "backup-$(date +%Y%m%d-%H%M%S).tar.gz" .env postgres minio
```
Danach die aktuellen Selfhost-Dateien laden und den Stack aktualisieren:
```bash
cd /opt/fedeo
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/docker-compose.selfhost.yml -o /opt/fedeo/docker-compose.yml
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/.env.example -o /opt/fedeo/.env.example
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-setup.sh -o /opt/fedeo/scripts/selfhost-setup.sh
chmod +x /opt/fedeo/scripts/selfhost-setup.sh
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml pull
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
```
Der Backend-Container wendet Datenbankmigrationen beim Start automatisch an. Bei kritischen Updates sollte vorher ein Backup von `./postgres` und `./minio` erstellt werden.
Der Backend-Container wendet Datenbankmigrationen beim Start automatisch an, solange `FEDEO_RUN_MIGRATIONS` nicht deaktiviert wurde. Falls Migrationen manuell erzwungen werden sollen:
```bash
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml run --rm backend npm run migrate
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
```
Anschliessend den Zustand prüfen:
```bash
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml ps
curl -I https://app.example.com
curl https://app.example.com/backend/health
```
Die Selfhost-Compose-Datei nutzt vorgebaute Images. Dadurch braucht der Server keinen Repository-Checkout und keine lokalen Build-Kontexte.

View File

@@ -0,0 +1,22 @@
ALTER TABLE "tenant_export_jobs"
ADD COLUMN IF NOT EXISTS "operation" text DEFAULT 'export' NOT NULL,
ADD COLUMN IF NOT EXISTS "previous_tenant_locked" "locked_tenant";
ALTER TABLE "tenants"
ADD COLUMN IF NOT EXISTS "locked_by_export_job_id" uuid;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE constraint_schema = 'public'
AND constraint_name = 'tenants_locked_by_export_job_id_tenant_export_jobs_id_fk'
) THEN
ALTER TABLE "tenants"
ADD CONSTRAINT "tenants_locked_by_export_job_id_tenant_export_jobs_id_fk"
FOREIGN KEY ("locked_by_export_job_id")
REFERENCES "tenant_export_jobs"("id")
ON DELETE SET NULL;
END IF;
END $$;

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

@@ -365,6 +365,27 @@
"when": 1783247083934,
"tag": "0054_tenant_export_jobs",
"breakpoints": true
},
{
"idx": 52,
"version": "7",
"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

@@ -9,6 +9,7 @@ import {
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { lockedTenantEnum } from "./enums"
export const tenantExportJobs = pgTable("tenant_export_jobs", {
id: uuid("id").primaryKey().defaultRandom(),
@@ -26,6 +27,8 @@ export const tenantExportJobs = pgTable("tenant_export_jobs", {
createdBy: uuid("created_by").references(() => authUsers.id),
operation: text("operation").notNull().default("export"),
previousTenantLocked: lockedTenantEnum("previous_tenant_locked"),
status: text("status").notNull().default("queued"),
filename: text("filename").notNull(),
storagePath: text("storage_path"),

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({
@@ -189,6 +198,7 @@ export const tenants = pgTable(
updatedBy: uuid("updated_by").references(() => authUsers.id),
locked: lockedTenantEnum("locked"),
lockedByExportJobId: uuid("locked_by_export_job_id"),
}
)

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,9 +9,11 @@
"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",
"invoices:delete-range": "tsx scripts/delete-createddocument-range.ts",
"members:import:csv": "tsx scripts/import-members-csv.ts",
"profiles:import:mitarbeiterliste": "tsx scripts/import-mitarbeiterliste.ts",
"accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts"
@@ -42,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

@@ -0,0 +1,234 @@
import "dotenv/config"
import { drizzle } from "drizzle-orm/node-postgres"
import { sql } from "drizzle-orm"
import { Pool } from "pg"
import { loadSecrets, secrets } from "../src/utils/secrets"
const maskConnectionString = (value: string) =>
value.replace(/:\/\/([^:@]+):([^@]+)@/, "://$1:***@")
const logStep = (message: string) => {
console.log(`[tenant-lock-migration] ${message}`)
}
const terminateIdleBlockers = process.argv.includes("--terminate-idle-blockers")
const relationLocksQuery = (relationName: string) => sql`
SELECT
activity.pid,
activity.state,
activity.wait_event_type,
activity.wait_event,
now() - activity.query_start AS query_age,
locks.mode,
locks.granted,
left(activity.query, 500) AS query
FROM pg_locks locks
JOIN pg_class relation ON relation.oid = locks.relation
LEFT JOIN pg_stat_activity activity ON activity.pid = locks.pid
WHERE relation.relname = ${relationName}
ORDER BY locks.granted ASC, activity.query_start ASC NULLS LAST;
`
const getRelationLocks = async (db: ReturnType<typeof drizzle>, relationName: string) => {
const result = await db.execute(relationLocksQuery(relationName))
return result.rows as Record<string, any>[]
}
const printRelationLocks = async (db: ReturnType<typeof drizzle>, relationName: string) => {
const rows = await getRelationLocks(db, relationName)
if (!rows.length) {
logStep(`Keine aktiven Locks fuer ${relationName} gefunden`)
return
}
logStep(`Aktive Locks fuer ${relationName}:`)
for (const row of rows) {
console.log(JSON.stringify({
pid: row.pid,
state: row.state,
waitEventType: row.wait_event_type,
waitEvent: row.wait_event,
queryAge: row.query_age,
mode: row.mode,
granted: row.granted,
query: row.query,
}, null, 2))
}
}
const terminateIdleRelationBlockers = async (db: ReturnType<typeof drizzle>, relationName: string) => {
const rows = await getRelationLocks(db, relationName)
const blockerPids = rows
.filter((row) =>
row.granted === true &&
row.state === "idle in transaction" &&
Number(row.pid) !== process.pid
)
.map((row) => Number(row.pid))
.filter((pid) => Number.isFinite(pid))
if (!blockerPids.length) {
logStep(`Keine idle-in-transaction Blocker fuer ${relationName} gefunden`)
return false
}
for (const pid of blockerPids) {
logStep(`Beende idle-in-transaction DB-Session ${pid}`)
await db.execute(sql`SELECT pg_terminate_backend(${pid})`)
}
return true
}
const executeStatement = async (
db: ReturnType<typeof drizzle>,
statement: ReturnType<typeof sql>,
) => {
await db.execute(statement)
}
const handleLockError = async (
db: ReturnType<typeof drizzle>,
relationName: string,
) => {
logStep(`Konnte Lock fuer ${relationName} nicht rechtzeitig bekommen`)
await printRelationLocks(db, relationName)
if (!terminateIdleBlockers) return false
const terminated = await terminateIdleRelationBlockers(db, relationName)
if (terminated) {
logStep(`Retry nach beendeten idle-in-transaction Sessions fuer ${relationName}`)
}
return terminated
}
const executeStep = async (
db: ReturnType<typeof drizzle>,
message: string,
statement: ReturnType<typeof sql>,
lockRelationName?: string,
) => {
logStep(message)
try {
await executeStatement(db, statement)
} catch (err: any) {
const code = err?.cause?.code || err?.code
if (!lockRelationName || !["55P03", "57014"].includes(code)) {
throw err
}
const shouldRetry = await handleLockError(db, lockRelationName)
if (!shouldRetry) throw err
await executeStatement(db, statement)
}
}
async function run() {
if (terminateIdleBlockers) {
logStep("Option --terminate-idle-blockers aktiv")
}
let connectionString = process.env.DATABASE_URL
if (!connectionString) {
logStep("DATABASE_URL nicht in process.env gefunden, lade Secrets")
await loadSecrets()
connectionString = secrets.DATABASE_URL
} else {
logStep("DATABASE_URL aus process.env gefunden")
}
if (!connectionString) {
throw new Error("DATABASE_URL not configured")
}
logStep(`Verbinde mit ${maskConnectionString(connectionString)}`)
const pool = new Pool({
connectionString,
max: 1,
connectionTimeoutMillis: 10000,
idleTimeoutMillis: 10000,
})
try {
const db = drizzle(pool)
logStep("Teste DB-Verbindung")
await db.execute(sql`SELECT 1`)
logStep("Setze Lock- und Statement-Timeout")
await db.execute(sql`SET lock_timeout = '10s'`)
await db.execute(sql`SET statement_timeout = '2min'`)
await executeStep(db, "Erweitere tenant_export_jobs", sql`
ALTER TABLE "tenant_export_jobs"
ADD COLUMN IF NOT EXISTS "operation" text DEFAULT 'export' NOT NULL,
ADD COLUMN IF NOT EXISTS "previous_tenant_locked" "locked_tenant";
`, "tenant_export_jobs")
await executeStep(db, "Erweitere tenants", sql`
ALTER TABLE "tenants"
ADD COLUMN IF NOT EXISTS "locked_by_export_job_id" uuid;
`, "tenants")
await executeStep(db, "Pruefe Foreign Key", sql`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE constraint_schema = 'public'
AND constraint_name = 'tenants_locked_by_export_job_id_tenant_export_jobs_id_fk'
) THEN
ALTER TABLE "tenants"
ADD CONSTRAINT "tenants_locked_by_export_job_id_tenant_export_jobs_id_fk"
FOREIGN KEY ("locked_by_export_job_id")
REFERENCES "tenant_export_jobs"("id")
ON DELETE SET NULL;
END IF;
END $$;
`, "tenants")
await executeStep(db, "Pruefe Drizzle-Schema", sql`
CREATE SCHEMA IF NOT EXISTS drizzle;
`)
await executeStep(db, "Pruefe Drizzle-Migrationstabelle", sql`
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
id SERIAL PRIMARY KEY,
hash text NOT NULL,
created_at bigint
);
`)
await executeStep(db, "Setze Drizzle-Journal auf 0055", sql`
INSERT INTO drizzle.__drizzle_migrations (hash, created_at)
SELECT 'manual-0055_tenant_export_maintenance_lock', 1784021669805
WHERE NOT EXISTS (
SELECT 1
FROM drizzle.__drizzle_migrations
WHERE created_at >= 1784021669805
);
`)
logStep("Fertig")
console.log("✅ Tenant export lock migration applied")
} finally {
logStep("Schließe DB-Verbindung")
await pool.end()
}
}
run().catch((err) => {
console.error("❌ Tenant export lock migration failed")
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1,67 @@
import "dotenv/config"
import { drizzle } from "drizzle-orm/node-postgres"
import { sql } from "drizzle-orm"
import { Pool } from "pg"
import { loadSecrets, secrets } from "../src/utils/secrets"
async function run() {
let connectionString = process.env.DATABASE_URL
if (!connectionString) {
await loadSecrets()
connectionString = secrets.DATABASE_URL
}
if (!connectionString) {
throw new Error("DATABASE_URL not configured")
}
const pool = new Pool({
connectionString,
max: 1,
connectionTimeoutMillis: 10000,
})
try {
const db = drizzle(pool)
const duplicates = await db.execute(sql`
SELECT tenant_id, user_id, count(*)::int AS count
FROM auth_tenant_users
GROUP BY tenant_id, user_id
HAVING count(*) > 1
ORDER BY count(*) DESC;
`)
console.log(`Gefundene doppelte Tenant-Zuordnungen: ${duplicates.rows.length}`)
for (const row of duplicates.rows as Record<string, any>[]) {
console.log(JSON.stringify(row))
}
const deleted = await db.execute(sql`
DELETE FROM auth_tenant_users duplicate
USING auth_tenant_users keep
WHERE duplicate.tenant_id = keep.tenant_id
AND duplicate.user_id = keep.user_id
AND duplicate.ctid > keep.ctid;
`)
console.log(`Entfernte doppelte Zeilen: ${deleted.rowCount || 0}`)
await db.execute(sql`
CREATE UNIQUE INDEX IF NOT EXISTS auth_tenant_users_tenant_user_unique
ON auth_tenant_users (tenant_id, user_id);
`)
console.log("Unique Index auth_tenant_users_tenant_user_unique ist vorhanden")
} finally {
await pool.end()
}
}
run().catch((err) => {
console.error("Dedupe auth_tenant_users failed")
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1,359 @@
import "dotenv/config"
import { Pool, type PoolClient } from "pg"
import { loadSecrets, secrets } from "../src/utils/secrets"
type Args = {
tenantId?: number
fromNumber?: string
toNumber?: string
fromId?: number
toId?: number
types: string[]
execute: boolean
unlinkRelated: boolean
}
type TargetDocument = {
id: number
documentNumber: string | null
type: string
title: string | null
state: string
}
const DEFAULT_TYPES = ["invoices"]
function printUsage() {
console.log(`
Löscht einen Bereich von Ausgangsbelegen und deren direkt verknüpfte DB-Dokumente.
Standardmäßig läuft das Skript als Dry-Run. Erst --execute löscht wirklich.
Beispiele:
npm run invoices:delete-range -- --tenant 41 --from RE-2026-001 --to RE-2026-010
npm run invoices:delete-range -- --tenant 41 --from-id 1200 --to-id 1210 --execute
npm run invoices:delete-range -- --tenant 41 --from RE-2026-001 --to RE-2026-010 --type invoices --type advanceInvoices --execute
Optionen:
--tenant <id> Pflicht: Tenant-ID
--from <nummer> Erste Rechnungsnummer im Bereich
--to <nummer> Letzte Rechnungsnummer im Bereich
--from-id <id> Erste Beleg-ID im Bereich
--to-id <id> Letzte Beleg-ID im Bereich
--type <type> Belegtyp, mehrfach möglich. Standard: invoices
--execute Wirklich löschen. Ohne diese Option nur Vorschau.
--unlink-related Externe Belege, die auf Zielbelege zeigen, werden entkoppelt.
--help Hilfe anzeigen
`)
}
function readValue(args: string[], index: number, flag: string) {
const value = args[index + 1]
if (!value || value.startsWith("--")) {
throw new Error(`Für ${flag} fehlt ein Wert.`)
}
return value
}
function readNumber(args: string[], index: number, flag: string) {
const value = Number(readValue(args, index, flag))
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${flag} muss eine positive Ganzzahl sein.`)
}
return value
}
function parseArgs(argv: string[]): Args {
const parsed: Args = {
types: [],
execute: false,
unlinkRelated: false,
}
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]
if (arg === "--help" || arg === "-h") {
printUsage()
process.exit(0)
}
if (arg === "--tenant") {
parsed.tenantId = readNumber(argv, i, arg)
i += 1
continue
}
if (arg === "--from") {
parsed.fromNumber = readValue(argv, i, arg)
i += 1
continue
}
if (arg === "--to") {
parsed.toNumber = readValue(argv, i, arg)
i += 1
continue
}
if (arg === "--from-id") {
parsed.fromId = readNumber(argv, i, arg)
i += 1
continue
}
if (arg === "--to-id") {
parsed.toId = readNumber(argv, i, arg)
i += 1
continue
}
if (arg === "--type") {
parsed.types.push(readValue(argv, i, arg))
i += 1
continue
}
if (arg === "--execute") {
parsed.execute = true
continue
}
if (arg === "--unlink-related") {
parsed.unlinkRelated = true
continue
}
throw new Error(`Unbekannte Option: ${arg}`)
}
if (!parsed.tenantId) throw new Error("--tenant ist Pflicht.")
const hasNumberRange = parsed.fromNumber !== undefined || parsed.toNumber !== undefined
const hasIdRange = parsed.fromId !== undefined || parsed.toId !== undefined
if (hasNumberRange && hasIdRange) {
throw new Error("Bitte entweder Rechnungsnummernbereich oder ID-Bereich verwenden, nicht beides.")
}
if (hasNumberRange && (!parsed.fromNumber || !parsed.toNumber)) {
throw new Error("Für einen Rechnungsnummernbereich sind --from und --to Pflicht.")
}
if (hasIdRange && (!parsed.fromId || !parsed.toId)) {
throw new Error("Für einen ID-Bereich sind --from-id und --to-id Pflicht.")
}
if (!hasNumberRange && !hasIdRange) {
throw new Error("Bitte einen Bereich mit --from/--to oder --from-id/--to-id angeben.")
}
if (parsed.fromNumber && parsed.toNumber && parsed.fromNumber > parsed.toNumber) {
throw new Error("--from darf bei Textvergleich nicht größer als --to sein.")
}
if (parsed.fromId && parsed.toId && parsed.fromId > parsed.toId) {
throw new Error("--from-id darf nicht größer als --to-id sein.")
}
if (parsed.types.length === 0) parsed.types = DEFAULT_TYPES
return parsed
}
async function loadConnectionString() {
if (process.env.DATABASE_URL) return process.env.DATABASE_URL
await loadSecrets()
if (secrets.DATABASE_URL) return secrets.DATABASE_URL
throw new Error("DATABASE_URL ist nicht konfiguriert.")
}
function buildTargetWhere(args: Args, startParam = 1) {
const values: unknown[] = [args.tenantId, args.types]
const clauses = [
`"tenant" = $${startParam}`,
`"type" = ANY($${startParam + 1}::text[])`,
]
if (args.fromNumber && args.toNumber) {
values.push(args.fromNumber, args.toNumber)
clauses.push(`"documentNumber" >= $${startParam + 2}`)
clauses.push(`"documentNumber" <= $${startParam + 3}`)
} else {
values.push(args.fromId, args.toId)
clauses.push(`"id" >= $${startParam + 2}`)
clauses.push(`"id" <= $${startParam + 3}`)
}
return {
values,
whereSql: clauses.join(" AND "),
}
}
async function selectTargetDocuments(client: PoolClient, args: Args) {
const target = buildTargetWhere(args)
const result = await client.query<TargetDocument>(
`
SELECT "id", "documentNumber", "type", "title", "state"
FROM "createddocuments"
WHERE ${target.whereSql}
ORDER BY "id"
`,
target.values
)
return result.rows
}
async function countByTarget(client: PoolClient, table: string, column: string, targetIds: number[]) {
const result = await client.query<{ count: string }>(
`SELECT COUNT(*)::int AS "count" FROM "${table}" WHERE "${column}" = ANY($1::bigint[])`,
[targetIds]
)
return Number(result.rows[0]?.count || 0)
}
async function findExternalLinkedDocuments(client: PoolClient, targetIds: number[]) {
const result = await client.query<TargetDocument>(
`
SELECT "id", "documentNumber", "type", "title", "state"
FROM "createddocuments"
WHERE "linkedDocument" = ANY($1::bigint[])
AND NOT ("id" = ANY($1::bigint[]))
ORDER BY "id"
`,
[targetIds]
)
return result.rows
}
async function deleteByTarget(client: PoolClient, table: string, column: string, targetIds: number[]) {
const result = await client.query(
`DELETE FROM "${table}" WHERE "${column}" = ANY($1::bigint[])`,
[targetIds]
)
return result.rowCount || 0
}
function printTargets(targets: TargetDocument[]) {
console.table(
targets.map((item) => ({
id: item.id,
nummer: item.documentNumber,
typ: item.type,
status: item.state,
titel: item.title,
}))
)
}
async function run() {
const args = parseArgs(process.argv.slice(2))
const connectionString = await loadConnectionString()
const pool = new Pool({ connectionString, max: 1 })
const client = await pool.connect()
try {
await client.query("BEGIN")
const targets = await selectTargetDocuments(client, args)
if (targets.length === 0) {
console.log("Keine passenden Rechnungen gefunden.")
await client.query("ROLLBACK")
return
}
const targetIds = targets.map((item) => item.id)
const [fileCount, historyCount, allocationCount, externalLinks] = await Promise.all([
countByTarget(client, "files", "createddocument", targetIds),
countByTarget(client, "historyitems", "createddocument", targetIds),
countByTarget(client, "statementallocations", "cd_id", targetIds),
findExternalLinkedDocuments(client, targetIds),
])
console.log(`Tenant: ${args.tenantId}`)
console.log(`Belegtypen: ${args.types.join(", ")}`)
console.log(`Modus: ${args.execute ? "Löschen" : "Dry-Run"}`)
console.log("")
printTargets(targets)
console.log("")
console.log("Betroffene verknüpfte DB-Zeilen:")
console.log(`- Dateien: ${fileCount}`)
console.log(`- Historie: ${historyCount}`)
console.log(`- Bank-Zuordnungen: ${allocationCount}`)
if (externalLinks.length > 0 && !args.unlinkRelated) {
console.log("")
console.log("Abbruch: Andere Belege verweisen auf Zielbelege.")
console.log("Nutze --unlink-related, wenn diese Verknüpfungen vor dem Löschen entfernt werden sollen.")
printTargets(externalLinks)
await client.query("ROLLBACK")
process.exitCode = 1
return
}
if (!args.execute) {
console.log("")
console.log("Dry-Run abgeschlossen. Es wurde nichts gelöscht. Zum Löschen --execute ergänzen.")
await client.query("ROLLBACK")
return
}
if (externalLinks.length > 0) {
await client.query(
`
UPDATE "createddocuments"
SET "linkedDocument" = NULL
WHERE "linkedDocument" = ANY($1::bigint[])
AND NOT ("id" = ANY($1::bigint[]))
`,
[targetIds]
)
}
await client.query(
`UPDATE "createddocuments" SET "linkedDocument" = NULL WHERE "id" = ANY($1::bigint[])`,
[targetIds]
)
const deletedFiles = await deleteByTarget(client, "files", "createddocument", targetIds)
const deletedHistory = await deleteByTarget(client, "historyitems", "createddocument", targetIds)
const deletedAllocations = await deleteByTarget(client, "statementallocations", "cd_id", targetIds)
const deletedDocuments = await client.query(
`DELETE FROM "createddocuments" WHERE "id" = ANY($1::bigint[])`,
[targetIds]
)
await client.query("COMMIT")
console.log("")
console.log("Löschung abgeschlossen:")
console.log(`- Rechnungen: ${deletedDocuments.rowCount || 0}`)
console.log(`- Dateien: ${deletedFiles}`)
console.log(`- Historie: ${deletedHistory}`)
console.log(`- Bank-Zuordnungen: ${deletedAllocations}`)
if (externalLinks.length > 0) {
console.log(`- Entkoppelte externe Belege: ${externalLinks.length}`)
}
} catch (error) {
await client.query("ROLLBACK")
throw error
} finally {
client.release()
await pool.end()
}
}
run().catch((error) => {
console.error("Fehler beim Löschen der Rechnungen:")
console.error(error)
process.exit(1)
})

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
);
@@ -447,7 +451,10 @@ async function getCloseData(server:FastifyInstance,item: any, tenant: any, units
// Formatiert Zahlen zu deutscher Währung
function renderCurrency(value: any, currency = "€") {
if (value === undefined || value === null) return "0,00 " + currency;
return Number(value).toFixed(2).replace(".", ",") + " " + currency;
return Number(value).toLocaleString("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}) + " " + currency;
}
// Berechnet den Zeilenpreis (Menge * Preis * Rabatt)
@@ -525,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
@@ -721,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

@@ -9,6 +9,7 @@ import {
authRolePermissions,
authUsers,
m2mApiKeys,
tenants,
} from "../../db/schema"
import { eq, and, inArray } from "drizzle-orm"
@@ -23,6 +24,19 @@ export default fp(async (server: FastifyInstance) => {
url === "/api/mcp" ||
url.startsWith("/api/mcp/")
const isWriteMethod = (method: string) =>
["POST", "PUT", "PATCH", "DELETE"].includes(method)
const isTenantLockAllowedRoute = (urlPath: string) =>
urlPath === "/api/auth/me" ||
urlPath === "/auth/me" ||
urlPath === "/api/tenant/switch" ||
urlPath === "/tenant/switch" ||
urlPath.startsWith("/api/admin/") ||
urlPath.startsWith("/admin/") ||
urlPath.startsWith("/api/auth/") ||
urlPath.startsWith("/auth/")
const authenticateMcpApiKey = async (apiKey: string) => {
if (!apiKey.startsWith("fedeo_mcp_")) return false
@@ -150,6 +164,25 @@ export default fp(async (server: FastifyInstance) => {
const tenantId = req.user.tenant_id
const userId = req.user.user_id
if (isWriteMethod(req.method) && !isTenantLockAllowedRoute(urlPath)) {
const [tenant] = await server.db
.select({
locked: tenants.locked,
lockedByExportJobId: tenants.lockedByExportJobId,
})
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1)
if (tenant?.locked === "maintenance_tenant") {
return reply.code(423).send({
error: "Tenant is locked for maintenance",
locked: tenant.locked,
lockedByExportJobId: tenant.lockedByExportJobId,
})
}
}
// --------------------------------------------------------
// 3⃣ Rollen des Nutzers im Tenant holen
// --------------------------------------------------------

View File

@@ -267,8 +267,204 @@ export default async function adminRoutes(server: FastifyInstance) {
return `fedeo-tenant-${safeTenantName || tenantId}-${new Date().toISOString().slice(0, 10)}.fedeo-export.zip`;
};
const lockTenantForJob = async (tenantId: number, jobId: string) => {
const [tenant] = await server.db
.select({
locked: tenants.locked,
lockedByExportJobId: tenants.lockedByExportJobId,
})
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1);
if (!tenant) throw new Error("Tenant not found");
if (tenant.lockedByExportJobId && tenant.lockedByExportJobId !== jobId) {
throw new Error("Tenant ist bereits durch einen anderen Export oder Import gesperrt");
}
await server.db
.update(tenantExportJobs)
.set({
previousTenantLocked: tenant.locked,
updatedAt: new Date(),
})
.where(eq(tenantExportJobs.id, jobId));
await server.db
.update(tenants)
.set({
locked: "maintenance_tenant",
lockedByExportJobId: jobId,
updatedAt: new Date(),
})
.where(eq(tenants.id, tenantId));
};
const unlockTenantForJob = async (tenantId: number, jobId: string) => {
const [job] = await server.db
.select({
previousTenantLocked: tenantExportJobs.previousTenantLocked,
})
.from(tenantExportJobs)
.where(eq(tenantExportJobs.id, jobId))
.limit(1);
await server.db
.update(tenants)
.set({
locked: job?.previousTenantLocked || null,
lockedByExportJobId: null,
updatedAt: new Date(),
})
.where(and(
eq(tenants.id, tenantId),
eq(tenants.lockedByExportJobId, jobId)
));
};
const createTenantImportJob = async (tenantId: number, currentUserId: string, filename: string) => {
const [job] = await server.db
.insert(tenantExportJobs)
.values({
tenantId,
createdBy: currentUserId,
operation: "import",
status: "running",
filename,
updatedAt: new Date(),
})
.returning();
await lockTenantForJob(tenantId, job.id);
return job;
};
const completeImportedTenantAccess = async (
currentUser: { id: string; email: string },
result: { tenantId: number }
) => {
const fallbackName = deriveNameFromEmail(currentUser.email);
await server.db
.insert(authTenantUsers)
.values({
tenant_id: result.tenantId,
user_id: currentUser.id,
created_by: currentUser.id,
})
.onConflictDoNothing();
const [existingAdminProfile] = await server.db
.select({ id: authProfiles.id })
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, result.tenantId),
eq(authProfiles.user_id, currentUser.id)
))
.limit(1);
if (!existingAdminProfile) {
await server.db
.insert(authProfiles)
.values({
tenant_id: result.tenantId,
user_id: currentUser.id,
first_name: fallbackName.first_name,
last_name: fallbackName.last_name,
email: currentUser.email,
active: true,
});
}
let matrixProvisioned = false;
let matrixProvisioningError: string | null = null;
if (process.env.MATRIX_REGISTRATION_SHARED_SECRET) {
try {
const matrix = matrixService(server);
await matrix.provisionTenantRoom(currentUser.id, result.tenantId, {
key: "allgemein",
name: "Allgemeiner Chat",
type: "general",
});
matrixProvisioned = true;
} catch (err: any) {
matrixProvisioningError = err?.message || String(err);
server.log.warn({ err }, "Matrix-Räume konnten nach Tenant-Import nicht neu provisioniert werden");
}
}
return {
matrixProvisioned,
matrixProvisioningError,
};
};
const startTenantImportJob = async (
jobId: string,
targetTenantId: number,
currentUser: { id: string; email: string },
importData: { type: "archive"; archiveBuffer: Buffer } | { type: "json"; exportData: TenantFullExport }
) => {
try {
await server.db
.update(tenantExportJobs)
.set({
status: "running",
filesDone: 0,
filesTotal: 1,
updatedAt: new Date(),
})
.where(eq(tenantExportJobs.id, jobId));
const onProgress = async ({ done, total }: { done: number; total: number }) => {
await server.db
.update(tenantExportJobs)
.set({
filesDone: done,
filesTotal: total,
updatedAt: new Date(),
})
.where(eq(tenantExportJobs.id, jobId));
};
const result = importData.type === "archive"
? await importTenantFullExportArchive(server, importData.archiveBuffer, { targetTenantId, onProgress })
: await importTenantFullExport(server, importData.exportData, { targetTenantId, onProgress });
await completeImportedTenantAccess(currentUser, result);
await server.db
.update(tenantExportJobs)
.set({
status: "ready",
filesDone: 1,
filesTotal: 1,
completedAt: new Date(),
updatedAt: new Date(),
error: null,
})
.where(eq(tenantExportJobs.id, jobId));
} catch (err: any) {
console.error("ERROR tenant import job:", err);
await server.db
.update(tenantExportJobs)
.set({
status: "failed",
error: err?.message || String(err),
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(tenantExportJobs.id, jobId));
} finally {
await unlockTenantForJob(targetTenantId, jobId);
}
};
const startTenantExportJob = async (jobId: string, tenantId: number, filename: string) => {
try {
await lockTenantForJob(tenantId, jobId);
await server.db
.update(tenantExportJobs)
.set({
@@ -317,6 +513,8 @@ export default async function adminRoutes(server: FastifyInstance) {
updatedAt: new Date(),
})
.where(eq(tenantExportJobs.id, jobId));
} finally {
await unlockTenantForJob(tenantId, jobId);
}
};
@@ -339,6 +537,7 @@ export default async function adminRoutes(server: FastifyInstance) {
.values({
tenantId,
createdBy: currentUserId,
operation: "export",
status: "queued",
filename,
updatedAt: new Date(),
@@ -417,6 +616,7 @@ export default async function adminRoutes(server: FastifyInstance) {
short: tenants.short,
createdAt: tenants.createdAt,
locked: tenants.locked,
lockedByExportJobId: tenants.lockedByExportJobId,
})
.from(tenants),
server.db
@@ -460,10 +660,16 @@ export default async function adminRoutes(server: FastifyInstance) {
})
.from(authUserRoles),
]);
const uniqueMembershipRows = Array.from(
new Map(membershipRows.map((membership) => [
`${membership.tenant_id}:${membership.user_id}`,
membership,
])).values()
);
const users = userRows.map((user) => {
const profiles = profileRows.filter((profile) => profile.user_id === user.id);
const memberships = membershipRows.filter((membership) => membership.user_id === user.id);
const memberships = uniqueMembershipRows.filter((membership) => membership.user_id === user.id);
const roleAssignments = roleAssignmentRows.filter((assignment) => assignment.user_id === user.id);
const preferredProfile = profiles.find((profile) => profile.active) || profiles[0];
const fallbackName = deriveNameFromEmail(user.email);
@@ -483,7 +689,7 @@ export default async function adminRoutes(server: FastifyInstance) {
const tenantsWithCounts = tenantRows.map((tenant) => ({
...tenant,
user_count: membershipRows.filter((membership) => membership.tenant_id === tenant.id).length,
user_count: uniqueMembershipRows.filter((membership) => membership.tenant_id === tenant.id).length,
}));
return {
@@ -491,7 +697,7 @@ export default async function adminRoutes(server: FastifyInstance) {
tenants: tenantsWithCounts,
roles: roleRows,
unassignedProfiles: profileRows.filter((profile) => !profile.user_id),
memberships: membershipRows,
memberships: uniqueMembershipRows,
roleAssignments: roleAssignmentRows,
};
} catch (err) {
@@ -931,6 +1137,7 @@ export default async function adminRoutes(server: FastifyInstance) {
const body = req.body as {
name?: string;
short?: string;
locked?: string | null;
};
const name = body.name?.trim();
@@ -954,6 +1161,7 @@ export default async function adminRoutes(server: FastifyInstance) {
short: tenants.short,
createdAt: tenants.createdAt,
locked: tenants.locked,
lockedByExportJobId: tenants.lockedByExportJobId,
});
await createTenantSeeds(createdTenant.id, currentUser.id);
@@ -1027,6 +1235,7 @@ export default async function adminRoutes(server: FastifyInstance) {
const body = req.body as {
name?: string;
short?: string;
locked?: string | null;
};
const updateData: Record<string, any> = {
@@ -1036,6 +1245,16 @@ export default async function adminRoutes(server: FastifyInstance) {
if (typeof body.name === "string") updateData.name = body.name.trim();
if (typeof body.short === "string") updateData.short = body.short.trim();
if (Object.prototype.hasOwnProperty.call(body, "locked")) {
const allowedLockedValues = new Set([null, "maintenance_tenant", "maintenance", "general", "no_subscription"]);
const lockedValue = body.locked || null;
if (!allowedLockedValues.has(lockedValue)) {
return reply.code(400).send({ error: "Invalid locked value" });
}
updateData.locked = lockedValue;
updateData.lockedByExportJobId = null;
}
const [updatedTenant] = await server.db
.update(tenants)
@@ -1047,6 +1266,7 @@ export default async function adminRoutes(server: FastifyInstance) {
short: tenants.short,
createdAt: tenants.createdAt,
locked: tenants.locked,
lockedByExportJobId: tenants.lockedByExportJobId,
});
if (!updatedTenant) {
@@ -1144,6 +1364,7 @@ export default async function adminRoutes(server: FastifyInstance) {
return {
exportId: job.id,
tenantId: job.tenantId,
operation: job.operation,
status: job.status,
filename: job.filename,
fileSize: job.fileSize,
@@ -1153,7 +1374,7 @@ export default async function adminRoutes(server: FastifyInstance) {
createdAt: job.createdAt,
updatedAt: job.updatedAt,
completedAt: job.completedAt,
downloadUrl: job.status === "ready" ? `/api/admin/tenant-exports/${job.id}/download` : null,
downloadUrl: job.operation === "export" && job.status === "ready" ? `/api/admin/tenant-exports/${job.id}/download` : null,
};
} catch (err) {
console.error("ERROR /admin/tenant-exports/:export_id:", err);
@@ -1206,74 +1427,63 @@ export default async function adminRoutes(server: FastifyInstance) {
const isMultipart = req.headers["content-type"]?.includes("multipart/form-data");
let result;
let targetTenantId: number | null = null;
if (isMultipart) {
const data: any = await req.file();
if (!data?.file) return reply.code(400).send({ error: "export file required" });
const archiveBuffer = await data.toBuffer();
const targetTenantId = Number(data.fields?.targetTenantId?.value || 0) || null;
targetTenantId = Number(data.fields?.targetTenantId?.value || 0) || null;
if (targetTenantId) {
const importJob = await createTenantImportJob(targetTenantId, currentUser.id, data.filename || "tenant-import.zip");
void startTenantImportJob(importJob.id, targetTenantId, currentUser, {
type: "archive",
archiveBuffer,
});
return reply.code(202).send({
importId: importJob.id,
exportId: importJob.id,
tenantId: targetTenantId,
status: importJob.status,
filename: importJob.filename,
statusUrl: `/api/admin/tenant-exports/${importJob.id}`,
});
}
result = await importTenantFullExportArchive(server, archiveBuffer, { targetTenantId });
} else {
const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number };
const exportData = "format" in body ? body : body.exportData;
const targetTenantId = "format" in body ? null : Number(body.targetTenantId || 0) || null;
targetTenantId = "format" in body ? null : Number(body.targetTenantId || 0) || null;
if (!exportData) {
return reply.code(400).send({ error: "exportData required" });
}
if (targetTenantId) {
const importJob = await createTenantImportJob(targetTenantId, currentUser.id, "tenant-import.json");
void startTenantImportJob(importJob.id, targetTenantId, currentUser, {
type: "json",
exportData,
});
return reply.code(202).send({
importId: importJob.id,
exportId: importJob.id,
tenantId: targetTenantId,
status: importJob.status,
filename: importJob.filename,
statusUrl: `/api/admin/tenant-exports/${importJob.id}`,
});
}
result = await importTenantFullExport(server, exportData, { targetTenantId });
}
const fallbackName = deriveNameFromEmail(currentUser.email);
await server.db
.insert(authTenantUsers)
.values({
tenant_id: result.tenantId,
user_id: currentUser.id,
created_by: currentUser.id,
})
.onConflictDoNothing();
const [existingAdminProfile] = await server.db
.select({ id: authProfiles.id })
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, result.tenantId),
eq(authProfiles.user_id, currentUser.id)
))
.limit(1);
if (!existingAdminProfile) {
await server.db
.insert(authProfiles)
.values({
tenant_id: result.tenantId,
user_id: currentUser.id,
first_name: fallbackName.first_name,
last_name: fallbackName.last_name,
email: currentUser.email,
active: true,
});
}
let matrixProvisioned = false;
let matrixProvisioningError: string | null = null;
if (process.env.MATRIX_REGISTRATION_SHARED_SECRET) {
try {
const matrix = matrixService(server);
await matrix.provisionTenantRoom(currentUser.id, result.tenantId, {
key: "allgemein",
name: "Allgemeiner Chat",
type: "general",
});
matrixProvisioned = true;
} catch (err: any) {
matrixProvisioningError = err?.message || String(err);
req.log.warn({ err }, "Matrix-Räume konnten nach Tenant-Import nicht neu provisioniert werden");
}
}
const { matrixProvisioned, matrixProvisioningError } = await completeImportedTenantAccess(currentUser, result);
return {
success: true,

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

@@ -69,7 +69,9 @@ export default async function meRoutes(server: FastifyInstance) {
.innerJoin(tenants, eq(authTenantUsers.tenant_id, tenants.id))
.where(eq(authTenantUsers.user_id, userId))
const tenantList = tenantRows ?? []
const tenantList = Array.from(
new Map((tenantRows ?? []).map((tenant) => [tenant.id, tenant])).values()
)
// ----------------------------------------------------
// 3) ACTIVE TENANT

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) => {
/*
@@ -25,6 +26,65 @@ const getCoordinatesForPDFLib = (x:number ,y:number, page:any) => {
}
}
const getRightAlignedX = (rightEdgeMm: number, text: string, size: number, font: any, page: any) => {
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) => {
@@ -66,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
@@ -77,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()
@@ -372,8 +457,10 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
font: fontBold
})
pages[pageCounter - 1].drawText("Einheitspreis", {
...getCoordinatesForPDFLib(150, 137, page1),
const unitPriceHeader = "Einheitspreis"
pages[pageCounter - 1].drawText(unitPriceHeader, {
y: getCoordinatesForPDFLib(150, 137, page1).y,
x: getRightAlignedX(177, unitPriceHeader, 12, fontBold, page1),
size: 12,
color: rgb(0, 0, 0),
lineHeight: 12,
@@ -399,6 +486,12 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
let pageIndex = 0
const getTitleKey = (row) => `${row.pos} - ${row.text}`
const getTransferSumText = (row) => {
const transferSum = invoiceData.total.titleSumsTransfer[getTitleKey(row)]
return transferSum ? `Übertrag: ${transferSum}` : null
}
invoiceData.rows.forEach((row, index) => {
@@ -502,7 +595,8 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
maxWidth: 240
})
pages[pageCounter - 1].drawText(row.price, {
...getCoordinatesForPDFLib(150, rowHeight, page1),
y: getCoordinatesForPDFLib(150, rowHeight, page1).y,
x: getRightAlignedX(177, row.price, 10, font, page1),
size: 10,
color: rgb(0, 0, 0),
lineHeight: 10,
@@ -579,18 +673,20 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
console.log(invoiceData.rows[index + 1])
if (invoiceData.rows[index + 1].mode === 'title') {
let transferSumText = `Übertrag: ${invoiceData.total.titleSumsTransfer[Object.keys(invoiceData.total.titleSums)[invoiceData.rows[index + 1].pos - 2]]}`
pages[pageCounter - 1].drawText(transferSumText, {
y: getCoordinatesForPDFLib(21, rowHeight - 2, page1).y,
x: getCoordinatesForPDFLib(21, rowHeight - 2, page1).x + 500 - fontBold.widthOfTextAtSize(transferSumText, 10),
size: 10,
color: rgb(0, 0, 0),
lineHeight: 10,
opacity: 1,
maxWidth: 240,
font: fontBold
})
if (invoiceData.rows[index + 1]?.mode === 'title') {
const transferSumText = getTransferSumText(invoiceData.rows[index + 1])
if (transferSumText) {
pages[pageCounter - 1].drawText(transferSumText, {
y: getCoordinatesForPDFLib(21, rowHeight - 2, page1).y,
x: getCoordinatesForPDFLib(21, rowHeight - 2, page1).x + 500 - fontBold.widthOfTextAtSize(transferSumText, 10),
size: 10,
color: rgb(0, 0, 0),
lineHeight: 10,
opacity: 1,
maxWidth: 240,
font: fontBold
})
}
}
@@ -681,8 +777,10 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
font: fontBold
})
page.drawText("Einheitspreis", {
...getCoordinatesForPDFLib(150, 22, page1),
const unitPriceHeader = "Einheitspreis"
page.drawText(unitPriceHeader, {
y: getCoordinatesForPDFLib(150, 22, page1).y,
x: getRightAlignedX(177, unitPriceHeader, 12, fontBold, page1),
size: 12,
color: rgb(0, 0, 0),
lineHeight: 12,
@@ -713,17 +811,19 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
if (index === 0 || pageIndex === 0) {
rowHeight += 3
} else {
let transferSumText = `Übertrag: ${invoiceData.total.titleSumsTransfer[Object.keys(invoiceData.total.titleSums)[row.pos - 2]]}`
pages[pageCounter - 1].drawText(transferSumText, {
y: getCoordinatesForPDFLib(21, rowHeight - 2, page1).y,
x: getCoordinatesForPDFLib(21, rowHeight - 2, page1).x + 500 - fontBold.widthOfTextAtSize(transferSumText, 10),
size: 10,
color: rgb(0, 0, 0),
lineHeight: 10,
opacity: 1,
maxWidth: 240,
font: fontBold
})
const transferSumText = getTransferSumText(row)
if (transferSumText) {
pages[pageCounter - 1].drawText(transferSumText, {
y: getCoordinatesForPDFLib(21, rowHeight - 2, page1).y,
x: getCoordinatesForPDFLib(21, rowHeight - 2, page1).x + 500 - fontBold.widthOfTextAtSize(transferSumText, 10),
size: 10,
color: rgb(0, 0, 0),
lineHeight: 10,
opacity: 1,
maxWidth: 240,
font: fontBold
})
}
pages[pageCounter - 1].drawLine({
start: getCoordinatesForPDFLib(20, rowHeight, page1),
@@ -735,6 +835,10 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
rowHeight += 5
}
const titleLevel = Math.min(Math.max(Number(row.titleLevel) || 1, 1), 3)
const titleTextSize = titleLevel === 1 ? 12 : titleLevel === 2 ? 11 : 10
const titleTextX = 35 + ((titleLevel - 1) * 7)
pages[pageCounter - 1].drawText(String(row.pos), {
...getCoordinatesForPDFLib(21, rowHeight, page1),
size: 10,
@@ -746,16 +850,16 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
})
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, 60).join("\n"), {
...getCoordinatesForPDFLib(35, rowHeight, page1),
size: 12,
...getCoordinatesForPDFLib(titleTextX, rowHeight, page1),
size: titleTextSize,
color: rgb(0, 0, 0),
lineHeight: 12,
lineHeight: titleTextSize,
opacity: 1,
maxWidth: 500,
font: fontBold
})
rowHeight += splitStringBySpace(row.text, 60).length * 4.5
rowHeight += splitStringBySpace(row.text, 60).length * (titleLevel === 1 ? 4.5 : 4)
} else if (row.mode === 'text') {
if (index === 0 || pageIndex === 0) {
rowHeight += 3
@@ -893,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

@@ -1,6 +1,11 @@
export const renderAsCurrency = (value: string | number,currencyString = "€") => {
return `${Number(value).toFixed(2).replace(".",",")} ${currencyString}`
const formattedValue = Number(value).toLocaleString("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
return `${formattedValue} ${currencyString}`
}
export const splitStringBySpace = (input:string,maxSplitLength:number,removeLinebreaks = false) => {
@@ -48,4 +53,4 @@ export const splitStringBySpace = (input:string,maxSplitLength:number,removeLine
})
return returnSplitStrings
}
}

View File

@@ -45,6 +45,7 @@ type ImportResult = {
type ImportOptions = {
targetTenantId?: number | null
onProgress?: (progress: { done: number; total: number; message?: string }) => Promise<void> | void
}
type BuildTenantFullExportOptions = {
@@ -264,6 +265,22 @@ const archiveEntryPathForFile = (path: string) => {
return `files/${normalizedPath || "unnamed-file"}`
}
const filenameFromPath = (path: string) => {
const filename = path
.replace(/\\/g, "/")
.split("/")
.filter(Boolean)
.pop()
if (!filename) return null
try {
return decodeURIComponent(filename)
} catch {
return filename
}
}
const jsonBuffer = (value: unknown) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8")
export const buildTenantFullExport = async (
@@ -328,11 +345,17 @@ export const buildTenantFullExport = async (
tables.entitybankaccounts = decryptEntityBankAccountsForExport(tables.entitybankaccounts)
}
const fileRows = tables.files || []
const files = []
for (const file of fileRows) {
if (!file.path) continue
const filePaths = new Set<string>()
const addExportFile = async (file: {
id: string
path: string
name?: string | null
mimeType?: string | null
size?: number | null
}) => {
if (!file.path || filePaths.has(file.path)) return
filePaths.add(file.path)
const object = includeFileContents
? await loadObjectAsBase64(file.path)
@@ -349,7 +372,7 @@ export const buildTenantFullExport = async (
files.push({
id: file.id,
path: file.path,
name: file.name || null,
name: file.name || filenameFromPath(file.path),
mimeType: file.mimeType || null,
size: file.size || null,
contentBase64: object.contentBase64,
@@ -358,6 +381,30 @@ export const buildTenantFullExport = async (
})
}
for (const file of tables.files || []) {
if (!file.path) continue
await addExportFile({
id: file.id,
path: file.path,
name: file.name || null,
mimeType: file.mimeType || null,
size: file.size || null,
})
}
for (const letterhead of tables.letterheads || []) {
if (!letterhead.path) continue
await addExportFile({
id: `letterhead:${letterhead.id}`,
path: letterhead.path,
name: filenameFromPath(letterhead.path),
mimeType: "application/pdf",
size: null,
})
}
return {
format: "fedeo.tenant-full-export",
version: 1,
@@ -383,7 +430,7 @@ export const createTenantFullExportArchive = async (
.toLowerCase()
const filename = options.filename || `fedeo-tenant-${safeTenantName || tenantId}-${new Date().toISOString().slice(0, 10)}.fedeo-export.zip`
const storagePath = options.storagePath || `tenant-exports/${tenantId}/${Date.now()}-${filename}`
const fileRows = exportData.tables.files || []
const fileRows = exportData.files || []
const archive = archiver("zip", { zlib: { level: 6 } })
const tempPath = path.join(os.tmpdir(), `fedeo-tenant-export-${tenantId}-${Date.now()}-${filename}`)
const output = createWriteStream(tempPath)
@@ -522,7 +569,7 @@ const restoreArchiveFiles = async (
let skipped = 0
const filesByPath = new Map((manifest.files || []).map((file) => [file.path, file]))
for (const fileRow of exportData.tables.files || []) {
for (const fileRow of exportData.files || []) {
const originalPath = fileRow.path
if (!originalPath) {
skipped += 1
@@ -587,6 +634,10 @@ const remapTenantScopedExport = (
nextRow.path = `${targetPathPrefix}${nextRow.path.slice(sourcePathPrefix.length)}`
}
if (table === "letterheads" && typeof nextRow.path === "string" && nextRow.path.startsWith(sourcePathPrefix)) {
nextRow.path = `${targetPathPrefix}${nextRow.path.slice(sourcePathPrefix.length)}`
}
return nextRow
})
}
@@ -721,6 +772,37 @@ const insertRows = async (client: any, table: string, rows: Record<string, any>[
return inserted
}
const insertAuthTenantUserRows = async (client: any, rows: Record<string, any>[], metadata: TableMetadata) => {
if (!rows.length) return 0
let inserted = 0
const availableColumns = new Set(metadata.columns.filter((column) => !metadata.generatedColumns.has(column)))
for (const row of rows) {
if (!row.tenant_id || !row.user_id) continue
const existing = await client.query(
`select 1 from ${quoteIdent("auth_tenant_users")} where ${quoteIdent("tenant_id")} = $1 and ${quoteIdent("user_id")} = $2 limit 1`,
[row.tenant_id, row.user_id]
)
if (existing.rows.length) continue
const rowColumns = Object.keys(row).filter((column) => availableColumns.has(column))
if (!rowColumns.length) continue
const placeholders = rowColumns.map((_, index) => `$${index + 1}`).join(", ")
const values = rowColumns.map((column) => prepareColumnValue(row[column], metadata.jsonColumns.has(column)))
await client.query(
`insert into ${quoteIdent("auth_tenant_users")} (${rowColumns.map(quoteIdent).join(", ")}) values (${placeholders})`,
values
)
inserted += 1
}
return inserted
}
const insertIdentityRowsWithRemap = async (
client: any,
table: string,
@@ -830,17 +912,46 @@ export const importTenantFullExport = async (
...importOrder,
...Object.keys(exportData.tables).filter((table) => !importOrder.includes(table)).sort(),
].filter((table, index, all) => all.indexOf(table) === index)
const specialTables = [
columnsByTable.has("auth_tenant_users") ? "auth_tenant_users" : null,
columnsByTable.has("bankaccounts") ? "bankaccounts" : null,
columnsByTable.has("bankstatements") ? "bankstatements" : null,
columnsByTable.has("letterheads") ? "letterheads" : null,
].filter(Boolean) as string[]
const regularTableNames = tableNames.filter((table) => !specialTables.includes(table))
const progressTotal = Math.max(1, specialTables.length + regularTableNames.length + 3)
let progressDone = 0
const reportProgress = async (message: string) => {
await options.onProgress?.({
done: Math.min(progressDone, progressTotal),
total: progressTotal,
message,
})
}
await client.query("begin")
await client.query("set local session_replication_role = replica")
await reportProgress("Dateien werden vorbereitet")
const files = await restoreFiles(exportData)
progressDone += 1
await reportProgress("Dateien vorbereitet")
const importedTables: { table: string; rows: number }[] = []
const authTenantUsersMetadata = columnsByTable.get("auth_tenant_users")
if (authTenantUsersMetadata) {
const count = await insertAuthTenantUserRows(client, exportData.tables.auth_tenant_users || [], authTenantUsersMetadata)
importedTables.push({ table: "auth_tenant_users", rows: count })
progressDone += 1
await reportProgress("Tenant-Zuordnungen importiert")
}
const bankaccountMetadata = columnsByTable.get("bankaccounts")
if (bankaccountMetadata) {
const result = await insertIdentityRowsWithRemap(client, "bankaccounts", exportData.tables.bankaccounts || [], bankaccountMetadata)
remapTableColumn(exportData.tables.bankstatements, "account", result.idMap)
importedTables.push({ table: "bankaccounts", rows: result.count })
progressDone += 1
await reportProgress("Bankkonten importiert")
}
const bankstatementMetadata = columnsByTable.get("bankstatements")
@@ -849,25 +960,40 @@ export const importTenantFullExport = async (
remapTableColumn(exportData.tables.statementallocations, "bs_id", result.idMap)
remapTableColumn(exportData.tables.historyitems, "bankstatement", result.idMap)
importedTables.push({ table: "bankstatements", rows: result.count })
progressDone += 1
await reportProgress("Bankauszüge importiert")
}
for (const table of tableNames) {
if (["bankaccounts", "bankstatements"].includes(table)) continue
const letterheadMetadata = columnsByTable.get("letterheads")
if (letterheadMetadata) {
const result = await insertIdentityRowsWithRemap(client, "letterheads", exportData.tables.letterheads || [], letterheadMetadata)
remapTableColumn(exportData.tables.createddocuments, "letterhead", result.idMap)
importedTables.push({ table: "letterheads", rows: result.count })
progressDone += 1
await reportProgress("Briefpapiere importiert")
}
for (const table of regularTableNames) {
const rows = exportData.tables[table] || []
const metadata = columnsByTable.get(table)
if (!metadata) continue
const count = await insertRows(client, table, rows, metadata)
importedTables.push({ table, rows: count })
progressDone += 1
await reportProgress(`${table} importiert`)
}
const cleanedCommunicationRooms = await cleanupImportedCommunicationRooms(client, exportData)
if (cleanedCommunicationRooms) {
importedTables.push({ table: "communication_rooms_matrix_reset", rows: cleanedCommunicationRooms })
}
progressDone += 1
await reportProgress("Kommunikationsräume bereinigt")
await refreshSequences(client, columnsByTable)
progressDone = progressTotal
await reportProgress("Import abgeschlossen")
await client.query("commit")
return {

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

@@ -11,6 +11,7 @@ export type AdminTenant = {
short: string
user_count: number
locked?: string | null
lockedByExportJobId?: string | null
}
export type AdminUserProfile = {
@@ -53,11 +54,20 @@ export type TenantImportResult = {
tenantId: number
tables: { table: string; rows: number }[]
files: { restored: number; skipped: number }
importId?: string
exportId?: string
status?: string
filename?: string
filesDone?: number
filesTotal?: number
error?: string | null
}
export type TenantExportJob = {
exportId: string
importId?: string
tenantId?: number
operation?: "export" | "import" | string
status: "queued" | "running" | "ready" | "failed" | string
filename: string
fileSize?: number | null

View File

@@ -18,6 +18,12 @@ const tenantExportProgress = ref<null | {
filesTotal: number
filename: string
}>(null)
const tenantImportProgress = ref<null | {
status: string
filesDone: number
filesTotal: number
filename: string
}>(null)
const creatingUser = ref(false)
const createUserModalOpen = ref(false)
const createdUserPassword = ref("")
@@ -29,6 +35,13 @@ const lastImportResult = ref<null | {
restoredFiles: number
skippedFiles: number
}>(null)
const lockedOptions = [
{ label: "Aktiv", value: null },
{ label: "Tenant-Wartung", value: "maintenance_tenant" },
{ label: "Globale Wartung", value: "maintenance" },
{ label: "Allgemeine Sperre", value: "general" },
{ label: "Kein Abonnement", value: "no_subscription" },
]
const tenantForm = ref<AdminTenant | null>(null)
const assignedUsers = ref<AdminUser[]>([])
@@ -77,6 +90,7 @@ const saveTenant = async () => {
await admin.updateTenant(tenantForm.value.id, {
name: tenantForm.value.name,
short: tenantForm.value.short,
locked: tenantForm.value.locked || null,
})
await fetchTenant()
@@ -161,6 +175,7 @@ const importTenantExport = async (event: Event) => {
if (!file || importingTenant.value) return
importingTenant.value = true
tenantImportProgress.value = null
lastImportResult.value = null
try {
@@ -180,6 +195,44 @@ const importTenantExport = async (event: Event) => {
})
}
const importJobId = result.importId || result.exportId
if (importJobId) {
let job = result
tenantImportProgress.value = {
status: job.status,
filesDone: job.filesDone || 0,
filesTotal: job.filesTotal || 0,
filename: job.filename || file.name,
}
while (!["ready", "failed"].includes(job.status)) {
await new Promise((resolve) => setTimeout(resolve, 1500))
job = await admin.getTenantExport(importJobId)
tenantImportProgress.value = {
status: job.status,
filesDone: job.filesDone || 0,
filesTotal: job.filesTotal || 0,
filename: job.filename || file.name,
}
}
if (job.status === "failed") {
throw new Error(job.error || "Import konnte nicht abgeschlossen werden.")
}
await fetchTenant()
await auth.fetchMe()
await auth.switchTenant(String(job.tenantId || targetTenantId))
toast.add({
title: "Mandantenimport abgeschlossen",
description: job.filename || file.name,
color: "green",
})
return
}
const rowCount = (result.tables || []).reduce((sum, table) => sum + table.rows, 0)
lastImportResult.value = {
@@ -316,7 +369,25 @@ onMounted(async () => {
<UFormField label="Kürzel">
<UInput v-model="tenantForm.short" />
</UFormField>
<UFormField label="Locked Mode">
<USelectMenu
v-model="tenantForm.locked"
:items="lockedOptions"
value-key="value"
label-key="label"
/>
</UFormField>
</UForm>
<UAlert
v-if="tenantForm.lockedByExportJobId"
class="mt-4"
title="Automatische Sperre aktiv"
:description="`Dieser Tenant wird gerade durch Export/Import ${tenantForm.lockedByExportJobId} pausiert.`"
color="warning"
variant="soft"
/>
</UCard>
<UCard v-if="!loading && tenantForm" class="mt-3">
@@ -370,6 +441,17 @@ onMounted(async () => {
>
Export importieren
</UButton>
<div v-if="tenantImportProgress" class="mt-4 space-y-2">
<UProgress
:model-value="tenantImportProgress.filesTotal ? Math.round((tenantImportProgress.filesDone / tenantImportProgress.filesTotal) * 100) : undefined"
/>
<p class="text-xs text-gray-500">
{{ tenantImportProgress.status === 'ready' ? 'Import abgeschlossen' : 'Import wird verarbeitet' }}
<span v-if="tenantImportProgress.filesTotal">
· {{ tenantImportProgress.filesDone }} / {{ tenantImportProgress.filesTotal }} Schritte
</span>
</p>
</div>
</div>
</div>

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([])
@@ -108,6 +110,11 @@ const taxTypeItems = [
{ key: '19 UStG', label: '19 UStG Kleinunternehmer' },
{ key: '12.3 UStG', label: '12.3 UStG' }
]
const titleLevelItems = [
{ label: 'Titel Ebene 1', value: 1 },
{ label: 'Untertitel Ebene 2', value: 2 },
{ label: 'Untertitel Ebene 3', value: 3 }
]
const deliveryDateTypeItems = ['Lieferdatum', 'Lieferzeitraum', 'Leistungsdatum', 'Leistungszeitraum', 'Kein Lieferdatum anzeigen']
const paymentTypeItems = [
{ key: 'transfer', label: 'Überweisung' },
@@ -231,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`, {
@@ -351,6 +364,10 @@ const normalizeCreatedDocumentRow = (row) => {
const normalizeCreatedDocumentRows = (rows) => Array.isArray(rows)
? rows.map((row) => normalizeCreatedDocumentRow(row))
: []
const isImportedAdvanceInvoiceRow = (row) => {
return Array.isArray(row.linkedEntitys)
&& row.linkedEntitys.some(i => i.type === "createddocuments" && i.subtype === "advanceInvoices")
}
const setupPage = async () => {
await setupData()
@@ -507,6 +524,8 @@ const setupPage = async () => {
})
for await (const doc of linkedDocuments.filter(i => i.type === "advanceInvoices")) {
const advanceInvoiceNet = useSum().getCreatedDocumentSumDetailed(doc).totalNet * -1
itemInfo.value.rows.push({
mode: "free",
text: `Abschlagsrechnung ${doc.documentNumber}`,
@@ -514,7 +533,8 @@ const setupPage = async () => {
taxPercent: 19, // TODO TAX PERCENTAGE
discountPercent: 0,
unit: 10,
inputPrice: useSum().getCreatedDocumentSumDetailed(doc).totalNet * -1,
inputPrice: advanceInvoiceNet,
price: advanceInvoiceNet,
linkedEntitys: [
{
type: "createddocuments",
@@ -819,21 +839,19 @@ const getRowAmountUndiscounted = (row) => {
return String(Number(Number(row.quantity) * Number(row.price)).toFixed(2)).replace('.', ',')
}
const addPosition = (mode) => {
let lastId = 0
itemInfo.value.rows.forEach(row => {
if (row.id > lastId) lastId = row.id
})
let taxPercentage = 19
if (['13b UStG', '19 UStG', '12.3 UStG'].includes(itemInfo.value.taxType)) {
taxPercentage = 0
}
const positionAddOptions = [
{ mode: 'service', label: 'Leistung', icon: 'i-heroicons-wrench-screwdriver' },
{ mode: 'normal', label: 'Artikel', icon: 'i-heroicons-cube' },
{ mode: 'free', label: 'Freie Position', icon: 'i-heroicons-pencil-square' },
{ mode: 'pagebreak', label: 'Seitenumbruch', icon: 'i-heroicons-document-minus' },
{ mode: 'title', label: 'Titel', icon: 'i-heroicons-bars-3-bottom-left' },
{ mode: 'subtitle', label: 'Untertitel', icon: 'i-heroicons-list-bullet' },
{ mode: 'text', label: 'Text', icon: 'i-heroicons-document-text' }
]
const createPositionRow = (mode, taxPercentage) => {
if (mode === 'free') {
let rowData = {
const rowData = {
id: uuidv4(),
mode: "free",
text: "",
@@ -848,10 +866,11 @@ const addPosition = (mode) => {
linkedEntitys: []
}
itemInfo.value.rows.push({...rowData, ...auth.activeTenantData.extraModules.includes("agriculture") ? {agriculture: {}} : {}})
return {...rowData, ...auth.activeTenantData.extraModules.includes("agriculture") ? {agriculture: {}} : {}}
}
} else if (mode === 'normal') {
itemInfo.value.rows.push({
if (mode === 'normal') {
return {
id: uuidv4(),
mode: "normal",
quantity: 1,
@@ -862,9 +881,11 @@ const addPosition = (mode) => {
unit: 1,
costCentre: itemInfo.value.costcentre,
linkedEntitys: []
})
} else if (mode === 'service') {
let rowData = {
}
}
if (mode === 'service') {
const rowData = {
id: uuidv4(),
mode: "service",
quantity: 1,
@@ -877,26 +898,103 @@ const addPosition = (mode) => {
linkedEntitys: []
}
//Push Agriculture Holder only if Module is activated
itemInfo.value.rows.push({...rowData, ...auth.activeTenantData.extraModules.includes("agriculture") ? {agriculture: {}} : {}})
} else if (mode === "pagebreak") {
itemInfo.value.rows.push({
return {...rowData, ...auth.activeTenantData.extraModules.includes("agriculture") ? {agriculture: {}} : {}}
}
if (mode === "pagebreak") {
return {
id: uuidv4(),
mode: "pagebreak",
linkedEntitys: []
})
} else if (mode === "title") {
itemInfo.value.rows.push({
}
}
if (mode === "title" || mode === "subtitle") {
return {
id: uuidv4(),
mode: "title",
titleLevel: mode === "subtitle" ? 2 : 1,
linkedEntitys: []
})
} else if (mode === "text") {
itemInfo.value.rows.push({
}
}
if (mode === "text") {
return {
id: uuidv4(),
mode: "text",
linkedEntitys: []
})
}
}
return null
}
const getPositionAddMenuItems = (row) => [
positionAddOptions.map(option => ({
...option,
onSelect: () => addPosition(option.mode, row?.id)
}))
]
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
const addPosition = (mode, afterRowId = null) => {
let taxPercentage = 19
if (['13b UStG', '19 UStG', '12.3 UStG'].includes(itemInfo.value.taxType)) {
taxPercentage = 0
}
const newRow = createPositionRow(mode, taxPercentage)
if (!newRow) return
const insertAfterIndex = afterRowId ? itemInfo.value.rows.findIndex(row => row.id === afterRowId) : -1
if (insertAfterIndex >= 0) {
itemInfo.value.rows.splice(insertAfterIndex + 1, 0, newRow)
} else {
itemInfo.value.rows.push(newRow)
}
setPosNumbers()
@@ -1101,22 +1199,27 @@ const documentTotal = computed(() => {
let titleSums = {}
let titleSumsTransfer = {}
let lastTitle = ""
let activeTitleKeys = []
let transferCounter = 0
itemInfo.value.rows.forEach(row => {
if (row.mode === 'title') {
let title = `${row.pos} - ${row.text}`
const titleLevel = Math.min(Math.max(Number(row.titleLevel) || 1, 1), 3)
titleSums[title] = 0
lastTitle = title
if (activeTitleKeys.some(Boolean)) {
titleSumsTransfer[title] = transferCounter
}
activeTitleKeys = activeTitleKeys.slice(0, titleLevel - 1)
activeTitleKeys[titleLevel - 1] = title
activeTitleKeys = activeTitleKeys.slice(0, titleLevel)
} else if (!['pagebreak', 'text'].includes(row.mode) && activeTitleKeys.some(Boolean) && !row.optional && !row.alternative) {
const rowSum = Number(Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) / 100))
//Übertrag berechnen
titleSumsTransfer[Object.keys(titleSums)[row.pos - 2]] = transferCounter
} else if (!['pagebreak', 'text'].includes(row.mode) && lastTitle !== "" && !row.optional && !row.alternative) {
titleSums[lastTitle] = Number(titleSums[lastTitle]) + Number(Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) / 100))
transferCounter += Number(Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) / 100))
activeTitleKeys.forEach(titleKey => {
titleSums[titleKey] = Number(titleSums[titleKey]) + rowSum
})
transferCounter += rowSum
console.log(transferCounter)
}
})
@@ -1308,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 => {
@@ -1353,11 +1460,11 @@ const getDocumentData = async () => {
return {
...row,
rowAmount: `${getRowAmount(row)}`,
rowAmount: renderCurrency(Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) / 100)),
quantity: String(row.quantity).replace(".", ","),
unit: unit.short,
pos: String(row.pos),
price: `${String(row.price.toFixed(2)).replace(".", ",")}`,
price: renderCurrency(row.price),
discountText: `(Rabatt: ${row.discountPercent} %)`
}
} else {
@@ -1524,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)
@@ -1566,16 +1680,33 @@ const onChangeTab = (index) => {
}
const setPosNumbers = () => {
let mainIndex = 1
let subIndex = 1
let titleCounters = [0, 0, 0]
let positionIndex = 1
let currentTitlePath = []
const hasTitles = itemInfo.value.rows.some(row => row.mode === "title")
let rows = itemInfo.value.rows.map(row => {
if (row.mode === 'title') {
row.pos = mainIndex
mainIndex += 1
subIndex = 1
const titleLevel = Math.min(Math.max(Number(row.titleLevel) || 1, 1), titleCounters.length)
row.titleLevel = titleLevel
for (let index = 0; index < titleLevel - 1; index += 1) {
if (titleCounters[index] === 0) {
titleCounters[index] = 1
}
}
titleCounters[titleLevel - 1] += 1
for (let index = titleLevel; index < titleCounters.length; index += 1) {
titleCounters[index] = 0
}
currentTitlePath = titleCounters.slice(0, titleLevel)
row.pos = currentTitlePath.join(".")
positionIndex = 1
} else if (!['pagebreak', 'title', 'text'].includes(row.mode)) {
row.pos = itemInfo.value.rows.filter(i => i.mode === "title").length === 0 ? `${subIndex}` : `${mainIndex - 1}.${subIndex}`
subIndex += 1
row.pos = hasTitles && currentTitlePath.length > 0 ? `${currentTitlePath.join(".")}.${positionIndex}` : `${positionIndex}`
positionIndex += 1
}
if (!row.id) {
@@ -1628,6 +1759,7 @@ const saveSerialInvoice = async () => {
const saveDocument = async (state, resetup = false) => {
itemInfo.value.state = state
normalizeCustomSurchargePercentage()
if (state !== "Entwurf") {
console.log("???")
@@ -1797,17 +1929,24 @@ 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) /*&& !row.linkedEntitys.find(i => i.type === "createddocuments" && i.subtype === "advanceInvoices")*/) {
if (!["pagebreak", "title", "text"].includes(row.mode) && isImportedAdvanceInvoiceRow(row)) {
row.price = Number(row.inputPrice || 0)
} else if (!["pagebreak", "title", "text"].includes(row.mode)) {
//setRowData(row)
row.price = Number((row.inputPrice * (1 + itemInfo.value.customSurchargePercentage / 100)).toFixed(2))
}/* else if(!["pagebreak","title","text"].includes(row.mode) /!*&& row.linkedEntitys.find(i => i.type === "createddocuments" && i.subtype === "advanceInvoices")*!/) {
row.price = row.inputPrice
}*/
}
})
}
@@ -2790,6 +2929,8 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
<th class="pl-2" v-if="!deliveryNoteLikeDocumentTypes.includes(itemInfo.type)">Rabatt</th>-->
<th class="pl-2"></th>
<th class="pl-2" v-if="!deliveryNoteLikeDocumentTypes.includes(itemInfo.type)">Gesamt</th>
<th class="pl-2"></th>
<th class="pl-2"></th>
</tr>
</thead>
<draggable
@@ -2800,23 +2941,38 @@ 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'"
colspan="8"
:colspan="getFullWidthPositionColspan()"
>
<USeparator/>
</td>
<td
v-if="row.mode === 'text'"
colspan="8"
:colspan="getFullWidthPositionColspan()"
>
<!-- <UInput
v-model="row.text"
@@ -3289,7 +3445,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
</InputGroup>
<UAlert
v-if="row.linkedEntitys && row.linkedEntitys.find(i => i.type === 'createddocuments' && i.subtype === 'advanceInvoices')"
v-if="isImportedAdvanceInvoiceRow(row)"
title="Berechnung des Individuellen Aufschlags für die Zeile gesperrt"
color="orange"
variant="subtle"
@@ -3423,14 +3579,37 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
</td>
<td
v-if="row.mode === 'title'"
colspan="7"
:colspan="getTitlePositionColspan()"
>
<UInput
:disabled="itemInfo.type === 'cancellationInvoices'"
v-model="row.text"
placeholder="Titel"
class="w-full"
/>
<InputGroup class="w-full">
<USelect
:disabled="itemInfo.type === 'cancellationInvoices'"
v-model="row.titleLevel"
:items="titleLevelItems"
value-key="value"
class="w-44 shrink-0"
@update:model-value="setPosNumbers"
/>
<UInput
:disabled="itemInfo.type === 'cancellationInvoices'"
v-model="row.text"
placeholder="Titel"
class="w-full min-w-0"
/>
</InputGroup>
</td>
<td>
<UDropdownMenu
:items="getPositionAddMenuItems(row)"
:content="{ align: 'end' }"
>
<UButton
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
variant="ghost"
color="primary"
icon="i-heroicons-plus"
/>
</UDropdownMenu>
</td>
<td>
<UButton
@@ -3455,46 +3634,13 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
<InputGroup>
<UButton
@click="addPosition('service')"
v-for="option in positionAddOptions"
:key="option.mode"
@click="addPosition(option.mode)"
class="mt-3"
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
>
+ Leistung
</UButton>
<UButton
@click="addPosition('normal')"
class="mt-3"
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
>
+ Artikel
</UButton>
<UButton
@click="addPosition('free')"
class="mt-3"
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
>
+ Freie Position
</UButton>
<UButton
@click="addPosition('pagebreak')"
class="mt-3"
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
>
+ Seitenumbruch
</UButton>
<UButton
@click="addPosition('title')"
class="mt-3"
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
>
+ Titel
</UButton>
<UButton
@click="addPosition('text')"
class="mt-3"
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
>
+ Text
+ {{ option.label }}
</UButton>
</InputGroup>

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

@@ -11,7 +11,7 @@
"ios": {
"supportsTablet": true,
"bundleIdentifier": "software.federspiel.fedeo",
"buildNumber": "5",
"buildNumber": "7",
"infoPlist": {
"NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.",
"NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.",
@@ -28,9 +28,8 @@
"android.permission.ACCESS_FINE_LOCATION"
],
"adaptiveIcon": {
"backgroundColor": "#69c350",
"backgroundColor": "#ffffff",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
@@ -50,7 +49,8 @@
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#0f1f0d"
"image": "./assets/images/splash-icon-dark.png",
"backgroundColor": "#000000"
}
}
],

View File

@@ -1,10 +1,10 @@
import { Redirect } from 'expo-router';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
import { useAuth } from '@/src/providers/auth-provider';
export default function IndexScreen() {
const { isBootstrapping, token, requiresTenantSelection } = useAuth();
const { isBootstrapping, bootstrapError, token, requiresTenantSelection, retryBootstrap, resetLocalSession } = useAuth();
if (isBootstrapping) {
return (
@@ -15,6 +15,23 @@ export default function IndexScreen() {
);
}
if (bootstrapError) {
return (
<View style={styles.centered}>
<Text style={styles.title}>Session konnte nicht gestartet werden</Text>
<Text style={styles.copy}>{bootstrapError}</Text>
<View style={styles.actions}>
<Pressable style={styles.primaryButton} onPress={retryBootstrap}>
<Text style={styles.primaryButtonText}>Erneut versuchen</Text>
</Pressable>
<Pressable style={styles.secondaryButton} onPress={resetLocalSession}>
<Text style={styles.secondaryButtonText}>Lokale Session löschen</Text>
</Pressable>
</View>
</View>
);
}
if (!token) {
return <Redirect href="/login" />;
}
@@ -37,5 +54,43 @@ const styles = StyleSheet.create({
copy: {
fontSize: 14,
color: '#4b5563',
textAlign: 'center',
},
title: {
fontSize: 20,
fontWeight: '700',
color: '#111827',
textAlign: 'center',
},
actions: {
width: '100%',
gap: 10,
marginTop: 4,
},
primaryButton: {
minHeight: 44,
borderRadius: 10,
backgroundColor: '#69c350',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 16,
},
primaryButtonText: {
color: '#ffffff',
fontWeight: '700',
},
secondaryButton: {
minHeight: 44,
borderRadius: 10,
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#ffffff',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 16,
},
secondaryButtonText: {
color: '#374151',
fontWeight: '600',
},
});

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,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 39 KiB

980
mobile/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,17 +25,18 @@
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"expo": "~54.0.34",
"expo": "~54.0.35",
"expo-camera": "~17.0.10",
"expo-constants": "~18.0.13",
"expo-document-picker": "^14.0.8",
"expo-font": "~14.0.11",
"expo-file-system": "~19.0.23",
"expo-font": "~14.0.12",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-image-picker": "~17.0.11",
"expo-linking": "~8.0.12",
"expo-notifications": "~0.32.17",
"expo-router": "~6.0.23",
"expo-router": "~6.0.24",
"expo-secure-store": "^15.0.8",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",

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;
@@ -196,8 +201,23 @@ type RequestOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
token?: string | null;
body?: unknown;
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;
@@ -314,17 +334,46 @@ async function parseJson(response: Response): Promise<unknown> {
}
}
const DEFAULT_REQUEST_TIMEOUT_MS = 15000;
function createTimeoutSignal(timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS): { signal: AbortSignal; cleanup: () => void } {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
return {
signal: controller.signal,
cleanup: () => clearTimeout(timeout),
};
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
}
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const hasJsonBody = options.body !== undefined;
const response = await fetch(buildUrl(path), {
method: options.method || 'GET',
headers: {
Accept: 'application/json',
...(hasJsonBody ? { 'Content-Type': 'application/json' } : {}),
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
},
body: hasJsonBody ? JSON.stringify(options.body) : undefined,
});
const { signal, cleanup } = createTimeoutSignal(options.timeoutMs);
let response: Response;
try {
response = await fetch(buildUrl(path), {
method: options.method || 'GET',
headers: {
Accept: 'application/json',
...(hasJsonBody ? { 'Content-Type': 'application/json' } : {}),
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
},
body: hasJsonBody ? JSON.stringify(options.body) : undefined,
signal,
});
} catch (error) {
if (isAbortError(error)) {
throw new Error(`Zeitüberschreitung beim Verbinden mit dem FEDEO-Server (${path}).`);
}
throw error;
} finally {
cleanup();
}
const payload = await parseJson(response);
@@ -333,21 +382,34 @@ 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;
}
async function apiFormRequest<T>(path: string, token: string, formData: FormData): Promise<T> {
const response = await fetch(buildUrl(path), {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
},
body: formData,
});
const { signal, cleanup } = createTimeoutSignal();
let response: Response;
try {
response = await fetch(buildUrl(path), {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
},
body: formData,
signal,
});
} catch (error) {
if (isAbortError(error)) {
throw new Error(`Zeitüberschreitung beim Hochladen zum FEDEO-Server (${path}).`);
}
throw error;
} finally {
cleanup();
}
const payload = await parseJson(response);
@@ -356,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;
@@ -586,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> {
@@ -623,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,13 +1,28 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { fetchMe, loginWithEmailPassword, MeResponse, switchTenantRequest, Tenant } from '@/src/lib/api';
import {
fetchMe,
loginWithEmailPassword,
logoutSession,
MeResponse,
isAuthenticationError,
refreshAuthSession,
switchTenantRequest,
Tenant,
} from '@/src/lib/api';
import { hydrateApiBaseUrl } from '@/src/lib/server-config';
import { clearStoredToken, getStoredToken, setStoredToken, tokenStorageInfo } from '@/src/lib/token-storage';
import {
clearStoredToken,
getStoredSession,
setStoredSession,
tokenStorageInfo,
} from '@/src/lib/token-storage';
export type AuthUser = MeResponse['user'];
type AuthContextValue = {
isBootstrapping: boolean;
bootstrapError: string | null;
token: string | null;
user: AuthUser | null;
tenants: Tenant[];
@@ -20,9 +35,13 @@ type AuthContextValue = {
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
switchTenant: (tenantId: number) => Promise<void>;
retryBootstrap: () => Promise<void>;
resetLocalSession: () => Promise<void>;
};
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 === '') {
@@ -33,14 +52,28 @@ function normalizeTenantId(value: number | string | null | undefined): number |
return Number.isNaN(parsed) ? null : parsed;
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error(message)), timeoutMs);
promise
.then(resolve)
.catch(reject)
.finally(() => clearTimeout(timeout));
});
}
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);
const [profile, setProfile] = useState<Record<string, unknown> | null>(null);
const [permissions, setPermissions] = useState<string[]>([]);
const bootstrapRunRef = useRef(0);
const resetSession = useCallback(() => {
setUser(null);
@@ -63,10 +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) {
@@ -81,55 +119,116 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
}, [hydrateSession, logout, resetSession, token]);
useEffect(() => {
async function bootstrap() {
await hydrateApiBaseUrl();
const storedToken = await getStoredToken();
const resetLocalSession = useCallback(async () => {
setToken(null);
setRefreshToken(null);
setBootstrapError(null);
setIsBootstrapping(false);
resetSession();
void clearStoredToken().catch(() => undefined);
}, [resetSession]);
if (!storedToken) {
setIsBootstrapping(false);
return;
}
const bootstrap = useCallback(async () => {
const runId = bootstrapRunRef.current + 1;
bootstrapRunRef.current = runId;
setIsBootstrapping(true);
setBootstrapError(null);
try {
await hydrateSession(storedToken);
setToken(storedToken);
} catch {
await clearStoredToken();
try {
await withTimeout(
(async () => {
await hydrateApiBaseUrl();
const storedSession = await getStoredSession();
if (!storedSession) {
if (bootstrapRunRef.current !== runId) return;
setToken(null);
resetSession();
return;
}
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(activeSession.token);
setRefreshToken(activeSession.refreshToken);
})(),
BOOTSTRAP_TIMEOUT_MS,
'Die mobile Session konnte nicht rechtzeitig initialisiert werden.'
);
} catch (err) {
if (bootstrapRunRef.current === runId) {
setToken(null);
setRefreshToken(null);
resetSession();
} finally {
setBootstrapError(err instanceof Error ? err.message : 'Die mobile Session konnte nicht initialisiert werden.');
if (isAuthenticationError(err)) {
void clearStoredToken().catch(() => undefined);
}
}
} finally {
if (bootstrapRunRef.current === runId) {
setIsBootstrapping(false);
}
}
void bootstrap();
}, [hydrateSession, resetSession]);
useEffect(() => {
void bootstrap();
}, [bootstrap]);
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]
@@ -140,6 +239,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const value = useMemo(
() => ({
isBootstrapping,
bootstrapError,
token,
user,
tenants,
@@ -152,10 +252,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
logout,
refreshUser,
switchTenant,
retryBootstrap: bootstrap,
resetLocalSession,
}),
[
activeTenant,
activeTenantId,
bootstrap,
bootstrapError,
isBootstrapping,
login,
logout,
@@ -163,6 +267,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
profile,
refreshUser,
requiresTenantSelection,
resetLocalSession,
switchTenant,
tenants,
token,

View File

@@ -30,6 +30,14 @@ read_interactive() {
fi
}
docker_engine() {
if [[ "${FEDEO_USE_SUDO_DOCKER:-false}" == "true" ]]; then
sudo docker "$@"
else
docker "$@"
fi
}
compose() {
if [[ "${FEDEO_USE_SUDO_DOCKER:-false}" == "true" ]]; then
sudo docker compose "$@"
@@ -44,12 +52,27 @@ compose_stack() {
compose_start_command() {
if [[ "${FEDEO_USE_SUDO_DOCKER:-false}" == "true" ]]; then
echo "sudo docker compose --env-file $ENV_FILE -f $COMPOSE_FILE up -d"
echo "sudo docker compose --env-file $ENV_FILE -f $COMPOSE_FILE up -d --remove-orphans"
else
echo "docker compose --env-file $ENV_FILE -f $COMPOSE_FILE up -d"
echo "docker compose --env-file $ENV_FILE -f $COMPOSE_FILE up -d --remove-orphans"
fi
}
remove_selfhost_networks() {
local network
for network in fedeo_web fedeo_internal; do
if docker_engine network inspect "$network" >/dev/null 2>&1; then
if docker_engine network rm "$network" >/dev/null 2>&1; then
echo "Docker-Netzwerk entfernt: $network"
else
echo "Docker-Netzwerk konnte nicht entfernt werden: $network"
echo "Prüfe laufende Container mit: docker network inspect $network"
fi
fi
done
}
usage() {
cat <<'USAGE'
FEDEO Selfhost Setup
@@ -461,6 +484,8 @@ uninstall_stack() {
"$ROOT_DIR/traefik/letsencrypt" \
"$ROOT_DIR/traefik/logs"
remove_selfhost_networks
echo
echo "Uninstall inklusive lokaler Daten abgeschlossen."
}
@@ -597,7 +622,7 @@ main() {
fi
if [[ "$START_STACK" == "yes" ]]; then
compose_stack up -d
compose_stack up -d --remove-orphans
else
echo
echo "Start später mit:"