Compare commits
8 Commits
37d08ee984
...
3230fc6cf0
| Author | SHA1 | Date | |
|---|---|---|---|
| 3230fc6cf0 | |||
| b8220685d7 | |||
| b7b8a2077a | |||
| 5e7a1f9aee | |||
| 6c613857fa | |||
| 430ab74522 | |||
| 5585bd7012 | |||
| 657a4f9e85 |
21
backend/db/migrations/0056_auth_refresh_tokens.sql
Normal file
21
backend/db/migrations/0056_auth_refresh_tokens.sql
Normal 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");
|
||||||
@@ -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;
|
||||||
@@ -372,6 +372,20 @@
|
|||||||
"when": 1784021669805,
|
"when": 1784021669805,
|
||||||
"tag": "0055_tenant_export_maintenance_lock",
|
"tag": "0055_tenant_export_maintenance_lock",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 53,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784548800000,
|
||||||
|
"tag": "0056_auth_refresh_tokens",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 54,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784635200000,
|
||||||
|
"tag": "0057_incoming_invoice_einvoice_metadata",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
20
backend/db/schema/auth_refresh_tokens.ts
Normal file
20
backend/db/schema/auth_refresh_tokens.ts
Normal 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
|
||||||
@@ -40,6 +40,11 @@ export const incominginvoices = pgTable("incominginvoices", {
|
|||||||
|
|
||||||
paymentType: text("paymentType"),
|
paymentType: text("paymentType"),
|
||||||
|
|
||||||
|
preparationSource: text("preparation_source"),
|
||||||
|
eInvoiceSyntax: text("e_invoice_syntax"),
|
||||||
|
eInvoiceProfile: text("e_invoice_profile"),
|
||||||
|
eInvoiceValidation: jsonb("e_invoice_validation"),
|
||||||
|
|
||||||
accounts: jsonb("accounts").notNull().default([
|
accounts: jsonb("accounts").notNull().default([
|
||||||
{
|
{
|
||||||
account: null,
|
account: null,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export * from "./auth_roles"
|
|||||||
export * from "./auth_tenant_users"
|
export * from "./auth_tenant_users"
|
||||||
export * from "./auth_user_roles"
|
export * from "./auth_user_roles"
|
||||||
export * from "./auth_users"
|
export * from "./auth_users"
|
||||||
|
export * from "./auth_refresh_tokens"
|
||||||
export * from "./bankaccounts"
|
export * from "./bankaccounts"
|
||||||
export * from "./bankrequisitions"
|
export * from "./bankrequisitions"
|
||||||
export * from "./bankstatements"
|
export * from "./bankstatements"
|
||||||
|
|||||||
127
backend/package-lock.json
generated
127
backend/package-lock.json
generated
@@ -27,6 +27,7 @@
|
|||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.18",
|
||||||
"drizzle-orm": "^0.45.0",
|
"drizzle-orm": "^0.45.0",
|
||||||
|
"fast-xml-parser": "^5.10.1",
|
||||||
"fastify": "^5.5.0",
|
"fastify": "^5.5.0",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
@@ -5290,6 +5291,24 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@aws-sdk/xml-builder/node_modules/fast-xml-parser": {
|
||||||
|
"version": "5.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
|
||||||
|
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"strnum": "^2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"fxparser": "src/cli/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@aws/lambda-invoke-store": {
|
"node_modules/@aws/lambda-invoke-store": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz",
|
||||||
@@ -5850,6 +5869,18 @@
|
|||||||
"eventemitter3": "^5.0.1"
|
"eventemitter3": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nodable/entities": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@pdf-lib/standard-fonts": {
|
"node_modules/@pdf-lib/standard-fonts": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||||
@@ -6881,6 +6912,18 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/anynum": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/archiver": {
|
"node_modules/archiver": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
|
||||||
@@ -8004,10 +8047,10 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-parser": {
|
"node_modules/fast-xml-builder": {
|
||||||
"version": "5.2.5",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz",
|
||||||
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
|
"integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -8016,7 +8059,28 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"strnum": "^2.1.0"
|
"path-expression-matcher": "^1.6.2",
|
||||||
|
"xml-naming": "^0.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-xml-parser": {
|
||||||
|
"version": "5.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz",
|
||||||
|
"integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@nodable/entities": "^3.0.0",
|
||||||
|
"fast-xml-builder": "^1.2.0",
|
||||||
|
"is-unsafe": "^2.0.0",
|
||||||
|
"path-expression-matcher": "^1.6.2",
|
||||||
|
"strnum": "^2.4.1",
|
||||||
|
"xml-naming": "^0.3.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"fxparser": "src/cli/cli.js"
|
"fxparser": "src/cli/cli.js"
|
||||||
@@ -8517,6 +8581,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-unsafe": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/isarray": {
|
"node_modules/isarray": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
@@ -9098,6 +9174,21 @@
|
|||||||
"url": "https://ko-fi.com/killymxi"
|
"url": "https://ko-fi.com/killymxi"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-expression-matcher": {
|
||||||
|
"version": "1.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
|
||||||
|
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-key": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
@@ -9940,16 +10031,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/strnum": {
|
"node_modules/strnum": {
|
||||||
"version": "2.1.2",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz",
|
||||||
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
|
"integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"anynum": "^1.0.1"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tar-stream": {
|
"node_modules/tar-stream": {
|
||||||
"version": "3.1.7",
|
"version": "3.1.7",
|
||||||
@@ -10351,6 +10445,21 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xml-naming": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xmlbuilder": {
|
"node_modules/xmlbuilder": {
|
||||||
"version": "15.1.1",
|
"version": "15.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"fill": "ts-node src/webdav/fill-file-sizes.ts",
|
"fill": "ts-node src/webdav/fill-file-sizes.ts",
|
||||||
"dev:dav": "tsx watch src/webdav/server.ts",
|
"dev:dav": "tsx watch src/webdav/server.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
"test:einvoice": "tsx --test tests/einvoice.test.ts",
|
||||||
"start": "node dist/src/index.js",
|
"start": "node dist/src/index.js",
|
||||||
"schema:index": "ts-node scripts/generate-schema-index.ts",
|
"schema:index": "ts-node scripts/generate-schema-index.ts",
|
||||||
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
|
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.18",
|
||||||
"drizzle-orm": "^0.45.0",
|
"drizzle-orm": "^0.45.0",
|
||||||
|
"fast-xml-parser": "^5.10.1",
|
||||||
"fastify": "^5.5.0",
|
"fastify": "^5.5.0",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import { getInvoiceDataFromGPT } from "../../utils/gpt"
|
import { getInvoiceDataFromGPT } from "../../utils/gpt"
|
||||||
|
import { loadFileBuffer } from "../../utils/fileBuffer"
|
||||||
|
import { detectElectronicInvoice } from "../einvoice/detectElectronicInvoice"
|
||||||
|
import { parseElectronicInvoice } from "../einvoice/parseElectronicInvoice"
|
||||||
|
import {
|
||||||
|
findElectronicInvoiceVendor,
|
||||||
|
mapElectronicInvoiceToIncomingInvoice,
|
||||||
|
} from "../einvoice/mapElectronicInvoice"
|
||||||
|
|
||||||
// Drizzle schema
|
// Drizzle schema
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +31,61 @@ const formatInvoiceItemDescription = (item: any) => {
|
|||||||
return parts.join(" - ")
|
return parts.join(" - ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
|
||||||
|
const itemInfo: any = {
|
||||||
|
tenant: tenantId,
|
||||||
|
state: "Vorbereitet",
|
||||||
|
preparationSource: "gpt",
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.invoice_number) itemInfo.reference = data.invoice_number
|
||||||
|
if (data.invoice_date && dayjs(data.invoice_date).isValid()) itemInfo.date = dayjs(data.invoice_date).toISOString()
|
||||||
|
if (data.issuer?.id) itemInfo.vendor = data.issuer.id
|
||||||
|
if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
|
||||||
|
|
||||||
|
const mapPayment: Record<string, string> = {
|
||||||
|
"Direct Debit": "Einzug",
|
||||||
|
"Transfer": "Überweisung",
|
||||||
|
"Credit Card": "Kreditkarte",
|
||||||
|
"Other": "Sonstiges",
|
||||||
|
}
|
||||||
|
if (data.terms) itemInfo.paymentType = mapPayment[data.terms] ?? data.terms
|
||||||
|
|
||||||
|
if (data.invoice_items?.length > 0) {
|
||||||
|
itemInfo.accounts = data.invoice_items
|
||||||
|
.filter((item: any) => item.description || item.total !== null || item.total_without_tax !== null)
|
||||||
|
.map((item: any) => {
|
||||||
|
const total = typeof item.total === "number" ? item.total : null
|
||||||
|
const totalWithoutTax = typeof item.total_without_tax === "number" ? item.total_without_tax : null
|
||||||
|
const amountTax = total !== null && totalWithoutTax !== null
|
||||||
|
? Number((total - totalWithoutTax).toFixed(2))
|
||||||
|
: null
|
||||||
|
|
||||||
|
return {
|
||||||
|
account: item.account_id,
|
||||||
|
description: item.description,
|
||||||
|
amountNet: totalWithoutTax,
|
||||||
|
amountTax,
|
||||||
|
taxType: item.tax_rate !== null ? String(item.tax_rate) : null,
|
||||||
|
amountGross: total,
|
||||||
|
costCentre: null,
|
||||||
|
quantity: item.quantity,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let description = ""
|
||||||
|
if (data.delivery_note_number) description += `Lieferschein: ${data.delivery_note_number}\n`
|
||||||
|
if (data.reference) description += `Referenz: ${data.reference}\n`
|
||||||
|
for (const item of data.invoice_items || []) {
|
||||||
|
const line = formatInvoiceItemDescription(item)
|
||||||
|
if (line) description += `${line}\n`
|
||||||
|
}
|
||||||
|
itemInfo.description = description.trim()
|
||||||
|
|
||||||
|
return itemInfo
|
||||||
|
}
|
||||||
|
|
||||||
export function prepareIncomingInvoices(server: FastifyInstance) {
|
export function prepareIncomingInvoices(server: FastifyInstance) {
|
||||||
const processInvoices = async (tenantId:number) => {
|
const processInvoices = async (tenantId:number) => {
|
||||||
console.log("▶ Starting Incoming Invoice Preparation")
|
console.log("▶ Starting Incoming Invoice Preparation")
|
||||||
@@ -85,75 +147,59 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// 3️⃣ Jede Datei einzeln durch GPT jagen & IncomingInvoice erzeugen
|
// 3️⃣ Strukturierte E-Rechnung bevorzugen, GPT als PDF-Fallback verwenden
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
for (const file of filesRes) {
|
for (const file of filesRes) {
|
||||||
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
|
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
|
||||||
|
|
||||||
const data = await getInvoiceDataFromGPT(server,file, tenantId)
|
let fileData: Buffer
|
||||||
|
|
||||||
if (!data) {
|
try {
|
||||||
server.log.warn(`GPT returned no data for file ${file.id}`)
|
fileData = await loadFileBuffer(file.path!)
|
||||||
|
} catch (error) {
|
||||||
|
server.log.error(error, `Datei ${file.id} konnte nicht aus S3 geladen werden.`)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
const electronicInvoice = await detectElectronicInvoice(fileData, file)
|
||||||
// 3.1 IncomingInvoice-Objekt vorbereiten
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
let itemInfo: any = {
|
|
||||||
tenant: tenantId,
|
|
||||||
state: "Vorbereitet"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.invoice_number) itemInfo.reference = data.invoice_number
|
let itemInfo: any = null
|
||||||
if (data.invoice_date && dayjs(data.invoice_date).isValid()) itemInfo.date = dayjs(data.invoice_date).toISOString()
|
|
||||||
if (data.issuer?.id) itemInfo.vendor = data.issuer.id
|
|
||||||
if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
|
|
||||||
|
|
||||||
// Payment terms mapping
|
if (electronicInvoice) {
|
||||||
const mapPayment: any = {
|
server.log.info({
|
||||||
"Direct Debit": "Einzug",
|
fileId: file.id,
|
||||||
"Transfer": "Überweisung",
|
container: electronicInvoice.container,
|
||||||
"Credit Card": "Kreditkarte",
|
syntax: electronicInvoice.syntax,
|
||||||
"Other": "Sonstiges",
|
attachmentName: electronicInvoice.attachmentName,
|
||||||
}
|
}, "Strukturierte E-Rechnung erkannt.")
|
||||||
if (data.terms) itemInfo.paymentType = mapPayment[data.terms] ?? data.terms
|
|
||||||
|
|
||||||
// 3.2 Positionszeilen konvertieren
|
try {
|
||||||
if (data.invoice_items?.length > 0) {
|
const parsedInvoice = parseElectronicInvoice(electronicInvoice.xml, electronicInvoice.syntax)
|
||||||
itemInfo.accounts = data.invoice_items
|
const vendor = await findElectronicInvoiceVendor(server, tenantId, parsedInvoice)
|
||||||
.filter(item => item.description || item.total !== null || item.total_without_tax !== null)
|
itemInfo = mapElectronicInvoiceToIncomingInvoice(
|
||||||
.map(item => {
|
parsedInvoice,
|
||||||
const total = typeof item.total === "number" ? item.total : null
|
tenantId,
|
||||||
const totalWithoutTax = typeof item.total_without_tax === "number" ? item.total_without_tax : null
|
vendor?.id || null,
|
||||||
const amountTax = total !== null && totalWithoutTax !== null
|
electronicInvoice.container,
|
||||||
? Number((total - totalWithoutTax).toFixed(2))
|
electronicInvoice.attachmentName,
|
||||||
: null
|
)
|
||||||
|
} catch (error) {
|
||||||
|
server.log.error(error, `E-Rechnung aus Datei ${file.id} konnte nicht verarbeitet werden.`)
|
||||||
|
|
||||||
return {
|
if (electronicInvoice.container === "xml") continue
|
||||||
account: item.account_id,
|
|
||||||
description: item.description,
|
|
||||||
amountNet: totalWithoutTax,
|
|
||||||
amountTax,
|
|
||||||
taxType: item.tax_rate !== null ? String(item.tax_rate) : null,
|
|
||||||
amountGross: total,
|
|
||||||
costCentre: null,
|
|
||||||
quantity: item.quantity,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3.3 Beschreibung generieren
|
|
||||||
let description = ""
|
|
||||||
if (data.delivery_note_number) description += `Lieferschein: ${data.delivery_note_number}\n`
|
|
||||||
if (data.reference) description += `Referenz: ${data.reference}\n`
|
|
||||||
if (data.invoice_items) {
|
|
||||||
for (const item of data.invoice_items) {
|
|
||||||
const line = formatInvoiceItemDescription(item)
|
|
||||||
if (line) description += `${line}\n`
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
itemInfo.description = description.trim()
|
|
||||||
|
if (!itemInfo) {
|
||||||
|
const data = await getInvoiceDataFromGPT(server, file, tenantId, fileData)
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
server.log.warn(`GPT returned no data for file ${file.id}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
itemInfo = mapGptDataToIncomingInvoice(data, tenantId)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
// 4️⃣ IncomingInvoice erstellen
|
// 4️⃣ IncomingInvoice erstellen
|
||||||
|
|||||||
141
backend/src/modules/einvoice/detectElectronicInvoice.ts
Normal file
141
backend/src/modules/einvoice/detectElectronicInvoice.ts
Normal 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
|
||||||
|
}
|
||||||
93
backend/src/modules/einvoice/mapElectronicInvoice.ts
Normal file
93
backend/src/modules/einvoice/mapElectronicInvoice.ts
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
223
backend/src/modules/einvoice/parseElectronicInvoice.ts
Normal file
223
backend/src/modules/einvoice/parseElectronicInvoice.ts
Normal 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) }
|
||||||
|
}
|
||||||
42
backend/src/modules/einvoice/types.ts
Normal file
42
backend/src/modules/einvoice/types.ts
Normal 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[]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
import jwt from "jsonwebtoken";
|
|
||||||
import { generateRandomPassword, hashPassword } from "../../utils/password";
|
import { generateRandomPassword, hashPassword } from "../../utils/password";
|
||||||
import { sendMail } from "../../utils/mailer";
|
import { sendMail } from "../../utils/mailer";
|
||||||
import { secrets } from "../../utils/secrets";
|
|
||||||
|
|
||||||
import { authUsers } from "../../../db/schema";
|
import { authUsers } from "../../../db/schema";
|
||||||
import { authTenantUsers } from "../../../db/schema";
|
import { authTenantUsers } from "../../../db/schema";
|
||||||
import { tenants } from "../../../db/schema";
|
import { tenants } from "../../../db/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
createAccessToken,
|
||||||
|
issueRefreshToken,
|
||||||
|
revokeRefreshToken,
|
||||||
|
rotateRefreshToken,
|
||||||
|
} from "../../utils/authTokens";
|
||||||
|
|
||||||
export default async function authRoutes(server: FastifyInstance) {
|
export default async function authRoutes(server: FastifyInstance) {
|
||||||
|
|
||||||
@@ -61,11 +65,12 @@ export default async function authRoutes(server: FastifyInstance) {
|
|||||||
properties: {
|
properties: {
|
||||||
email: { type: "string", format: "email" },
|
email: { type: "string", format: "email" },
|
||||||
password: { type: "string" },
|
password: { type: "string" },
|
||||||
|
rememberMe: { type: "boolean" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, async (req, reply) => {
|
}, async (req, reply) => {
|
||||||
const body = req.body as { email: string; password: string };
|
const body = req.body as { email: string; password: string; rememberMe?: boolean };
|
||||||
|
|
||||||
let user: any = null;
|
let user: any = null;
|
||||||
|
|
||||||
@@ -122,27 +127,43 @@ export default async function authRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(401).send({ error: "Invalid credentials" });
|
return reply.code(401).send({ error: "Invalid credentials" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = jwt.sign(
|
const tenantId = req.tenant?.id ? Number(req.tenant.id) : null;
|
||||||
{
|
const token = createAccessToken(user, tenantId);
|
||||||
user_id: user.id,
|
const refreshToken = await issueRefreshToken(server, user.id, tenantId);
|
||||||
email: user.email,
|
|
||||||
tenant_id: req.tenant?.id ?? null,
|
|
||||||
},
|
|
||||||
secrets.JWT_SECRET!,
|
|
||||||
{ expiresIn: "6h" }
|
|
||||||
);
|
|
||||||
|
|
||||||
reply.setCookie("token", token, {
|
reply.setCookie("token", token, {
|
||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
|
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
maxAge: 60 * 60 * 6,
|
...(body.rememberMe ? { maxAge: 60 * 60 * 6 } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return { token };
|
return { token, refreshToken };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.post("/auth/refresh", {
|
||||||
|
schema: {
|
||||||
|
tags: ["Auth"],
|
||||||
|
summary: "Refresh a persistent session",
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
required: ["refreshToken"],
|
||||||
|
properties: {
|
||||||
|
refreshToken: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, async (req, reply) => {
|
||||||
|
const { refreshToken } = req.body as { refreshToken: string };
|
||||||
|
const refreshed = await rotateRefreshToken(server, refreshToken);
|
||||||
|
|
||||||
|
if (!refreshed) {
|
||||||
|
return reply.code(401).send({ error: "Invalid or expired refresh token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshed;
|
||||||
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------
|
// -----------------------------------------------------
|
||||||
// LOGOUT
|
// LOGOUT
|
||||||
@@ -153,6 +174,10 @@ export default async function authRoutes(server: FastifyInstance) {
|
|||||||
summary: "Logout User"
|
summary: "Logout User"
|
||||||
}
|
}
|
||||||
}, async (req, reply) => {
|
}, async (req, reply) => {
|
||||||
|
const refreshToken = (req.body as { refreshToken?: string } | undefined)?.refreshToken;
|
||||||
|
if (refreshToken) {
|
||||||
|
await revokeRefreshToken(server, refreshToken);
|
||||||
|
}
|
||||||
reply.clearCookie("token", {
|
reply.clearCookie("token", {
|
||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import {and, desc, eq, inArray} from "drizzle-orm"
|
import {and, desc, eq, inArray} from "drizzle-orm"
|
||||||
import { enrichProfilesWithBranches } from "../utils/profileBranches"
|
import { enrichProfilesWithBranches } from "../utils/profileBranches"
|
||||||
import { enrichProfilesWithTeams } from "../utils/profileTeams"
|
import { enrichProfilesWithTeams } from "../utils/profileTeams"
|
||||||
|
import { rotateRefreshToken } from "../utils/authTokens"
|
||||||
|
|
||||||
|
|
||||||
export default async function tenantRoutes(server: FastifyInstance) {
|
export default async function tenantRoutes(server: FastifyInstance) {
|
||||||
@@ -65,7 +66,7 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(401).send({ error: "Unauthorized" })
|
return reply.code(401).send({ error: "Unauthorized" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tenant_id } = req.body as { tenant_id: string }
|
const { tenant_id, refreshToken } = req.body as { tenant_id: string; refreshToken?: string }
|
||||||
if (!tenant_id) return reply.code(400).send({ error: "tenant_id required" })
|
if (!tenant_id) return reply.code(400).send({ error: "tenant_id required" })
|
||||||
|
|
||||||
// prüfen ob der User zu diesem Tenant gehört
|
// prüfen ob der User zu diesem Tenant gehört
|
||||||
@@ -81,7 +82,22 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(403).send({ error: "Not a member of this tenant" })
|
return reply.code(403).send({ error: "Not a member of this tenant" })
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWT neu erzeugen
|
if (refreshToken) {
|
||||||
|
const session = await rotateRefreshToken(
|
||||||
|
server,
|
||||||
|
refreshToken,
|
||||||
|
Number(tenant_id),
|
||||||
|
req.user.user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return reply.code(401).send({ error: "Invalid or expired refresh token" })
|
||||||
|
}
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abwärtskompatibilität für Clients ohne persistente Session
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{
|
{
|
||||||
user_id: req.user.user_id,
|
user_id: req.user.user_id,
|
||||||
|
|||||||
93
backend/src/utils/authTokens.ts
Normal file
93
backend/src/utils/authTokens.ts
Normal 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)
|
||||||
|
))
|
||||||
|
}
|
||||||
26
backend/src/utils/fileBuffer.ts
Normal file
26
backend/src/utils/fileBuffer.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -2,12 +2,11 @@ import dayjs from "dayjs";
|
|||||||
import OpenAI from "openai";
|
import OpenAI from "openai";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodResponseFormat } from "openai/helpers/zod";
|
import { zodResponseFormat } from "openai/helpers/zod";
|
||||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
|
||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
import { s3 } from "./s3";
|
|
||||||
import { secrets } from "./secrets";
|
|
||||||
import { storeExtractedTextForFile } from "./documentText";
|
import { storeExtractedTextForFile } from "./documentText";
|
||||||
|
import { loadFileBuffer } from "./fileBuffer";
|
||||||
|
import { secrets } from "./secrets";
|
||||||
|
|
||||||
// Drizzle schema
|
// Drizzle schema
|
||||||
import { vendors, accounts, tenants } from "../../db/schema";
|
import { vendors, accounts, tenants } from "../../db/schema";
|
||||||
@@ -27,18 +26,6 @@ export const initOpenAi = async () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
// STREAM → BUFFER
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
async function streamToBuffer(stream: any): Promise<Buffer> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
||||||
stream.on("error", reject);
|
|
||||||
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
// GPT RESPONSE FORMAT (Zod Schema)
|
// GPT RESPONSE FORMAT (Zod Schema)
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
@@ -93,7 +80,8 @@ const InstructionFormat = z.object({
|
|||||||
export const getInvoiceDataFromGPT = async function (
|
export const getInvoiceDataFromGPT = async function (
|
||||||
server: FastifyInstance,
|
server: FastifyInstance,
|
||||||
file: any,
|
file: any,
|
||||||
tenantId: number
|
tenantId: number,
|
||||||
|
suppliedFileData?: Buffer,
|
||||||
) {
|
) {
|
||||||
await initOpenAi();
|
await initOpenAi();
|
||||||
|
|
||||||
@@ -109,13 +97,7 @@ export const getInvoiceDataFromGPT = async function (
|
|||||||
let fileData: Buffer;
|
let fileData: Buffer;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const command = new GetObjectCommand({
|
fileData = suppliedFileData || await loadFileBuffer(file.path);
|
||||||
Bucket: secrets.S3_BUCKET,
|
|
||||||
Key: file.path,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response: any = await s3.send(command);
|
|
||||||
fileData = await streamToBuffer(response.Body);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(`❌ S3 Download failed for file ${file.id}`, err);
|
console.log(`❌ S3 Download failed for file ${file.id}`, err);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
68
backend/tests/einvoice.test.ts
Normal file
68
backend/tests/einvoice.test.ts
Normal 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/)
|
||||||
|
})
|
||||||
@@ -91,6 +91,7 @@ const selectedProductcategorie = ref(null)
|
|||||||
const services = ref([])
|
const services = ref([])
|
||||||
const servicecategories = ref([])
|
const servicecategories = ref([])
|
||||||
const selectedServicecategorie = ref(null)
|
const selectedServicecategorie = ref(null)
|
||||||
|
const collapsedTitleRowIds = ref(new Set())
|
||||||
const customers = ref([])
|
const customers = ref([])
|
||||||
const contacts = ref([])
|
const contacts = ref([])
|
||||||
const contracts = ref([])
|
const contracts = ref([])
|
||||||
@@ -928,6 +929,45 @@ const getPositionAddMenuItems = (row) => [
|
|||||||
}))
|
}))
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const toggleTitleSection = (rowId) => {
|
||||||
|
const nextCollapsedTitleRowIds = new Set(collapsedTitleRowIds.value)
|
||||||
|
|
||||||
|
if (nextCollapsedTitleRowIds.has(rowId)) {
|
||||||
|
nextCollapsedTitleRowIds.delete(rowId)
|
||||||
|
} else {
|
||||||
|
nextCollapsedTitleRowIds.add(rowId)
|
||||||
|
}
|
||||||
|
|
||||||
|
collapsedTitleRowIds.value = nextCollapsedTitleRowIds
|
||||||
|
}
|
||||||
|
|
||||||
|
const hiddenDocumentRowIds = computed(() => {
|
||||||
|
const hiddenRowIds = new Set()
|
||||||
|
const collapsedTitleLevels = []
|
||||||
|
|
||||||
|
itemInfo.value.rows.forEach((row) => {
|
||||||
|
if (row.mode === 'title') {
|
||||||
|
const titleLevel = Math.min(Math.max(Number(row.titleLevel) || 1, 1), 3)
|
||||||
|
|
||||||
|
while (
|
||||||
|
collapsedTitleLevels.length > 0
|
||||||
|
&& collapsedTitleLevels[collapsedTitleLevels.length - 1] >= titleLevel
|
||||||
|
) {
|
||||||
|
collapsedTitleLevels.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collapsedTitleLevels.length > 0) hiddenRowIds.add(row.id)
|
||||||
|
if (collapsedTitleRowIds.value.has(row.id)) collapsedTitleLevels.push(titleLevel)
|
||||||
|
} else if (collapsedTitleLevels.length > 0) {
|
||||||
|
hiddenRowIds.add(row.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return hiddenRowIds
|
||||||
|
})
|
||||||
|
|
||||||
|
const isDocumentRowHidden = (rowId) => hiddenDocumentRowIds.value.has(rowId)
|
||||||
|
|
||||||
const getFullWidthPositionColspan = () => deliveryNoteLikeDocumentTypes.includes(itemInfo.value.type) ? 5 : 8
|
const getFullWidthPositionColspan = () => deliveryNoteLikeDocumentTypes.includes(itemInfo.value.type) ? 5 : 8
|
||||||
|
|
||||||
const getTitlePositionColspan = () => deliveryNoteLikeDocumentTypes.includes(itemInfo.value.type) ? 4 : 7
|
const getTitlePositionColspan = () => deliveryNoteLikeDocumentTypes.includes(itemInfo.value.type) ? 4 : 7
|
||||||
@@ -2875,13 +2915,28 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
@end="setPosNumbers"
|
@end="setPosNumbers"
|
||||||
>
|
>
|
||||||
<template #item="{element: row}">
|
<template #item="{element: row}">
|
||||||
<tr>
|
<tr :class="{ collapse: isDocumentRowHidden(row.id) }">
|
||||||
<td>
|
<td>
|
||||||
<UIcon
|
<div class="flex items-center gap-1 whitespace-nowrap">
|
||||||
class="handle"
|
<UIcon
|
||||||
name="i-mdi-menu"
|
class="handle shrink-0"
|
||||||
v-if="itemInfo.type !== 'cancellationInvoices'"
|
name="i-mdi-menu"
|
||||||
/>
|
v-if="itemInfo.type !== 'cancellationInvoices'"
|
||||||
|
/>
|
||||||
|
<UTooltip
|
||||||
|
v-if="row.mode === 'title'"
|
||||||
|
:text="collapsedTitleRowIds.has(row.id) ? 'Titelbereich ausklappen' : 'Titelbereich einklappen'"
|
||||||
|
>
|
||||||
|
<UButton
|
||||||
|
class="shrink-0"
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
:icon="collapsedTitleRowIds.has(row.id) ? 'i-heroicons-chevron-right' : 'i-heroicons-chevron-down'"
|
||||||
|
:aria-label="collapsedTitleRowIds.has(row.id) ? 'Titelbereich ausklappen' : 'Titelbereich einklappen'"
|
||||||
|
@click="toggleTitleSection(row.id)"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
v-if="row.mode === 'pagebreak'"
|
v-if="row.mode === 'pagebreak'"
|
||||||
|
|||||||
@@ -149,6 +149,18 @@ const bankBookingDateLabel = computed(() => {
|
|||||||
return bankBookingDates.value.map(formatDate).join(", ")
|
return bankBookingDates.value.map(formatDate).join(", ")
|
||||||
})
|
})
|
||||||
const vendorName = computed(() => vendors.value.find((vendor) => vendor.id === itemInfo.value.vendor)?.name || "-")
|
const vendorName = computed(() => vendors.value.find((vendor) => vendor.id === itemInfo.value.vendor)?.name || "-")
|
||||||
|
const eInvoiceValidation = computed(() => itemInfo.value.eInvoiceValidation || null)
|
||||||
|
const eInvoiceSourceLabel = computed(() => {
|
||||||
|
if (itemInfo.value.preparationSource !== "e-invoice") return null
|
||||||
|
if (itemInfo.value.eInvoiceSyntax === "cii") {
|
||||||
|
return String(itemInfo.value.eInvoiceProfile || "").toLowerCase().includes("xrechnung")
|
||||||
|
? "XRechnung (CII)"
|
||||||
|
: "ZUGFeRD / Factur-X (CII)"
|
||||||
|
}
|
||||||
|
if (itemInfo.value.eInvoiceSyntax === "ubl-credit-note") return "XRechnung / UBL-Gutschrift"
|
||||||
|
return "XRechnung / UBL"
|
||||||
|
})
|
||||||
|
const eInvoiceProfileLabel = computed(() => itemInfo.value.eInvoiceProfile || "Profil nicht angegeben")
|
||||||
const getAccountLabel = (item) => {
|
const getAccountLabel = (item) => {
|
||||||
const account = accounts.value.find((entry) => entry.id === item.account)
|
const account = accounts.value.find((entry) => entry.id === item.account)
|
||||||
|
|
||||||
@@ -356,6 +368,35 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
|
|||||||
Dokument andocken
|
Dokument andocken
|
||||||
</UButton>
|
</UButton>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="eInvoiceSourceLabel"
|
||||||
|
:title="eInvoiceSourceLabel"
|
||||||
|
color="primary"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-heroicons-document-check"
|
||||||
|
>
|
||||||
|
<template #description>
|
||||||
|
<div class="mt-1 space-y-2 text-sm">
|
||||||
|
<p class="break-all">{{ eInvoiceProfileLabel }}</p>
|
||||||
|
<div v-if="eInvoiceValidation?.errors?.length">
|
||||||
|
<p class="font-semibold">Validierungsfehler</p>
|
||||||
|
<ul class="list-inside list-disc">
|
||||||
|
<li v-for="error in eInvoiceValidation.errors" :key="error">{{ error }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div v-if="eInvoiceValidation?.warnings?.length">
|
||||||
|
<p class="font-semibold">Hinweise</p>
|
||||||
|
<ul class="list-inside list-disc">
|
||||||
|
<li v-for="warning in eInvoiceValidation.warnings" :key="warning">{{ warning }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p v-if="!eInvoiceValidation?.errors?.length && !eInvoiceValidation?.warnings?.length">
|
||||||
|
Pflichtfelder und Rechnungssummen wurden erfolgreich geprüft.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UAlert>
|
||||||
|
|
||||||
<UAlert
|
<UAlert
|
||||||
v-if="mode !== 'show' && findIncomingInvoiceErrors.length > 0"
|
v-if="mode !== 'show' && findIncomingInvoiceErrors.length > 0"
|
||||||
title="Prüfung erforderlich"
|
title="Prüfung erforderlich"
|
||||||
|
|||||||
@@ -287,6 +287,18 @@ const selectIncomingInvoice = (invoiceLike) => {
|
|||||||
<span v-if="row.original.state === 'Vorbereitet'" class="text-cyan-500">{{row.original.state}}</span>
|
<span v-if="row.original.state === 'Vorbereitet'" class="text-cyan-500">{{row.original.state}}</span>
|
||||||
<span v-else-if="row.original.state === 'Entwurf'" class="text-red-500">{{row.original.state}}</span>
|
<span v-else-if="row.original.state === 'Entwurf'" class="text-red-500">{{row.original.state}}</span>
|
||||||
<span v-else-if="row.original.state === 'Gebucht'" class="text-primary-500">{{row.original.state}}</span>
|
<span v-else-if="row.original.state === 'Gebucht'" class="text-primary-500">{{row.original.state}}</span>
|
||||||
|
<span v-else-if="row.original.state === 'Prüfung erforderlich'" class="text-orange-500">{{row.original.state}}</span>
|
||||||
|
<span v-else>{{row.original.state}}</span>
|
||||||
|
</template>
|
||||||
|
<template #preparationSource-cell="{row}">
|
||||||
|
<UBadge v-if="row.original.preparationSource === 'e-invoice'" color="primary" variant="soft">
|
||||||
|
{{ row.original.eInvoiceSyntax === 'cii'
|
||||||
|
? (String(row.original.eInvoiceProfile || '').toLowerCase().includes('xrechnung') ? 'XRechnung / CII' : 'ZUGFeRD / CII')
|
||||||
|
: 'XRechnung / UBL' }}
|
||||||
|
</UBadge>
|
||||||
|
<UBadge v-else-if="row.original.preparationSource === 'gpt'" color="neutral" variant="soft">
|
||||||
|
PDF / KI
|
||||||
|
</UBadge>
|
||||||
</template>
|
</template>
|
||||||
<template #date-cell="{row}">
|
<template #date-cell="{row}">
|
||||||
{{dayjs(row.original.date).format("DD.MM.YYYY")}}
|
{{dayjs(row.original.date).format("DD.MM.YYYY")}}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ const router = useRouter()
|
|||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
email: '',
|
email: '',
|
||||||
password: ''
|
password: '',
|
||||||
|
rememberMe: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -19,7 +20,7 @@ const loading = ref(false)
|
|||||||
const doLogin = async (event: FormSubmitEvent<typeof state>) => {
|
const doLogin = async (event: FormSubmitEvent<typeof state>) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await auth.login(event.data.email, event.data.password)
|
await auth.login(event.data.email, event.data.password, event.data.rememberMe)
|
||||||
toast.add({title:"Einloggen erfolgreich"})
|
toast.add({title:"Einloggen erfolgreich"})
|
||||||
await router.push("/")
|
await router.push("/")
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -79,6 +80,12 @@ const doLogin = async (event: FormSubmitEvent<typeof state>) => {
|
|||||||
/>
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
|
<UCheckbox
|
||||||
|
v-model="state.rememberMe"
|
||||||
|
name="rememberMe"
|
||||||
|
label="Angemeldet bleiben"
|
||||||
|
/>
|
||||||
|
|
||||||
<UButton type="submit" block class="w-full" :loading="loading">
|
<UButton type="submit" block class="w-full" :loading="loading">
|
||||||
Weiter
|
Weiter
|
||||||
</UButton>
|
</UButton>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
sessionWarningTimer: null as ReturnType<typeof setTimeout> | null,
|
sessionWarningTimer: null as ReturnType<typeof setTimeout> | null,
|
||||||
sessionLogoutTimer: null as ReturnType<typeof setTimeout> | null,
|
sessionLogoutTimer: null as ReturnType<typeof setTimeout> | null,
|
||||||
sessionCountdownTimer: null as ReturnType<typeof setInterval> | null,
|
sessionCountdownTimer: null as ReturnType<typeof setInterval> | null,
|
||||||
|
sessionPersistent: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
@@ -36,18 +37,18 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
|
|
||||||
getStoredToken() {
|
getStoredToken() {
|
||||||
const rootToken = this.tokenCookie().value
|
const rootToken = this.tokenCookie().value
|
||||||
if (rootToken || !process.client) return rootToken
|
if (!process.client) return rootToken
|
||||||
|
|
||||||
const tokenCookie = document.cookie
|
return sessionStorage.getItem("token")
|
||||||
.split(";")
|
|| localStorage.getItem("token")
|
||||||
.map((part) => part.trim())
|
|| rootToken
|
||||||
.find((part) => part.startsWith("token="))
|
},
|
||||||
|
|
||||||
if (tokenCookie) {
|
getStoredRefreshToken() {
|
||||||
return decodeURIComponent(tokenCookie.slice("token=".length))
|
if (!process.client) return null
|
||||||
}
|
|
||||||
|
|
||||||
return localStorage.getItem("token")
|
return sessionStorage.getItem("refreshToken")
|
||||||
|
|| localStorage.getItem("refreshToken")
|
||||||
},
|
},
|
||||||
|
|
||||||
clearScopedTokenCookies() {
|
clearScopedTokenCookies() {
|
||||||
@@ -155,24 +156,34 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
const msUntilWarning = msUntilLogout - this.sessionWarningLeadMs
|
const msUntilWarning = msUntilLogout - this.sessionWarningLeadMs
|
||||||
|
|
||||||
if (msUntilWarning <= 0) {
|
if (msUntilWarning <= 0) {
|
||||||
|
if (this.getStoredRefreshToken()) {
|
||||||
|
void this.refreshSession()
|
||||||
|
return
|
||||||
|
}
|
||||||
this.openSessionWarning(expiresAtMs)
|
this.openSessionWarning(expiresAtMs)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sessionWarningTimer = setTimeout(() => {
|
this.sessionWarningTimer = setTimeout(() => {
|
||||||
|
if (this.getStoredRefreshToken()) {
|
||||||
|
void this.refreshSession()
|
||||||
|
return
|
||||||
|
}
|
||||||
this.openSessionWarning(expiresAtMs)
|
this.openSessionWarning(expiresAtMs)
|
||||||
}, msUntilWarning)
|
}, msUntilWarning)
|
||||||
},
|
},
|
||||||
|
|
||||||
setToken(token: string | null) {
|
setToken(token: string | null, persistent = this.sessionPersistent) {
|
||||||
this.clearScopedTokenCookies()
|
this.clearScopedTokenCookies()
|
||||||
this.tokenCookie().value = token
|
this.tokenCookie().value = token
|
||||||
|
|
||||||
if (process.client) {
|
if (process.client) {
|
||||||
|
localStorage.removeItem("token")
|
||||||
|
sessionStorage.removeItem("token")
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
localStorage.setItem("token", token)
|
const storage = persistent ? localStorage : sessionStorage
|
||||||
} else {
|
storage.setItem("token", token)
|
||||||
localStorage.removeItem("token")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +197,30 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
this.scheduleSessionTimers(token)
|
this.scheduleSessionTimers(token)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setRefreshToken(refreshToken: string | null, persistent = this.sessionPersistent) {
|
||||||
|
if (!process.client) return
|
||||||
|
|
||||||
|
localStorage.removeItem("refreshToken")
|
||||||
|
sessionStorage.removeItem("refreshToken")
|
||||||
|
|
||||||
|
if (refreshToken) {
|
||||||
|
const storage = persistent ? localStorage : sessionStorage
|
||||||
|
storage.setItem("refreshToken", refreshToken)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setSession(token: string, refreshToken: string, persistent: boolean) {
|
||||||
|
this.sessionPersistent = persistent
|
||||||
|
this.setRefreshToken(refreshToken, persistent)
|
||||||
|
this.setToken(token, persistent)
|
||||||
|
},
|
||||||
|
|
||||||
|
clearStoredSession() {
|
||||||
|
this.setRefreshToken(null)
|
||||||
|
this.setToken(null)
|
||||||
|
this.sessionPersistent = false
|
||||||
|
},
|
||||||
|
|
||||||
async persist(token: string | null) {
|
async persist(token: string | null) {
|
||||||
|
|
||||||
console.log("On Web")
|
console.log("On Web")
|
||||||
@@ -195,6 +230,7 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
|
|
||||||
async initStore() {
|
async initStore() {
|
||||||
console.log("Auth initStore")
|
console.log("Auth initStore")
|
||||||
|
this.sessionPersistent = process.client && Boolean(localStorage.getItem("refreshToken"))
|
||||||
|
|
||||||
// 1. Check: Haben wir überhaupt ein Token?
|
// 1. Check: Haben wir überhaupt ein Token?
|
||||||
const token = this.getStoredToken()
|
const token = this.getStoredToken()
|
||||||
@@ -207,7 +243,7 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Token existiert -> Versuche User zu laden
|
// 2. Token existiert -> Versuche User zu laden
|
||||||
await this.fetchMe(token)
|
await this.fetchMe(token, true)
|
||||||
|
|
||||||
// Wenn fetchMe fertig ist (egal ob Erfolg oder Fehler), ladebalken weg
|
// Wenn fetchMe fertig ist (egal ob Erfolg oder Fehler), ladebalken weg
|
||||||
|
|
||||||
@@ -236,18 +272,18 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async login(email: string, password: string) {
|
async login(email: string, password: string, rememberMe = false) {
|
||||||
try {
|
try {
|
||||||
console.log("Auth login")
|
console.log("Auth login")
|
||||||
const { token } = await useNuxtApp().$api("/auth/login", {
|
const { token, refreshToken } = await useNuxtApp().$api("/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { email, password }
|
body: { email, password, rememberMe }
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log("Token: " + token)
|
console.log("Token: " + token)
|
||||||
|
|
||||||
// 1. WICHTIG: Token sofort ins Cookie schreiben, damit es persistiert wird
|
// Tokens abhängig von „Angemeldet bleiben“ dauerhaft oder nur für diese Sitzung speichern
|
||||||
this.setToken(token)
|
this.setSession(token, refreshToken, rememberMe)
|
||||||
this.sessionExpired = false
|
this.sessionExpired = false
|
||||||
|
|
||||||
// 2. User Daten laden
|
// 2. User Daten laden
|
||||||
@@ -269,14 +305,18 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("login error:" + e)
|
console.log("login error:" + e)
|
||||||
// Hier könnte man noch eine Fehlermeldung im UI anzeigen
|
throw e
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async logout() {
|
async logout() {
|
||||||
console.log("Auth logout")
|
console.log("Auth logout")
|
||||||
|
const refreshToken = this.getStoredRefreshToken()
|
||||||
try {
|
try {
|
||||||
await useNuxtApp().$api("/auth/logout", { method: "POST" })
|
await useNuxtApp().$api("/auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
body: { refreshToken }
|
||||||
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Logout API fehlgeschlagen (egal):", e)
|
console.error("Logout API fehlgeschlagen (egal):", e)
|
||||||
}
|
}
|
||||||
@@ -285,7 +325,7 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
this.resetState()
|
this.resetState()
|
||||||
|
|
||||||
// Token löschen
|
// Token löschen
|
||||||
this.setToken(null)
|
this.clearStoredSession()
|
||||||
|
|
||||||
// Nur beim expliziten Logout navigieren wir
|
// Nur beim expliziten Logout navigieren wir
|
||||||
navigateTo("/login")
|
navigateTo("/login")
|
||||||
@@ -295,7 +335,7 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
console.log("Auth session expired")
|
console.log("Auth session expired")
|
||||||
this.resetState()
|
this.resetState()
|
||||||
this.sessionExpired = true
|
this.sessionExpired = true
|
||||||
this.setToken(null)
|
this.clearStoredSession()
|
||||||
this.loading = false
|
this.loading = false
|
||||||
|
|
||||||
if (process.client) {
|
if (process.client) {
|
||||||
@@ -317,7 +357,7 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
this.loading = false
|
this.loading = false
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchMe(jwt= null) {
|
async fetchMe(jwt= null, allowRefresh = true) {
|
||||||
console.log("Auth fetchMe")
|
console.log("Auth fetchMe")
|
||||||
const tempStore = useTempStore()
|
const tempStore = useTempStore()
|
||||||
|
|
||||||
@@ -384,6 +424,10 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
console.log("fetchMe failed (Invalid Token or Network)", err)
|
console.log("fetchMe failed (Invalid Token or Network)", err)
|
||||||
|
|
||||||
if (err?.response?.status === 401 || err?.status === 401 || err?.statusCode === 401) {
|
if (err?.response?.status === 401 || err?.status === 401 || err?.statusCode === 401) {
|
||||||
|
if (allowRefresh && this.getStoredRefreshToken()) {
|
||||||
|
await this.refreshSession()
|
||||||
|
return
|
||||||
|
}
|
||||||
this.expireSession()
|
this.expireSession()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -395,17 +439,31 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async refreshSession() {
|
async refreshSession() {
|
||||||
|
const refreshToken = this.getStoredRefreshToken()
|
||||||
|
if (!refreshToken) {
|
||||||
|
this.expireSession()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { token } = await useNuxtApp().$api("/api/auth/refresh", {
|
const session = await useNuxtApp().$api("/auth/refresh", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
body: { refreshToken }
|
||||||
})
|
})
|
||||||
|
|
||||||
this.setToken(token)
|
this.setSession(session.token, session.refreshToken, this.sessionPersistent)
|
||||||
await this.fetchMe(token)
|
await this.fetchMe(session.token, false)
|
||||||
this.sessionWarningVisible = false
|
this.sessionWarningVisible = false
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("JWT refresh failed", err)
|
console.error("JWT refresh failed", err)
|
||||||
await this.logout()
|
const status = err?.response?.status || err?.status || err?.statusCode
|
||||||
|
if (status === 401) {
|
||||||
|
this.expireSession()
|
||||||
|
} else {
|
||||||
|
const token = this.getStoredToken()
|
||||||
|
const expiresAtMs = token ? this.decodeTokenExpiryMs(token) : null
|
||||||
|
if (expiresAtMs) this.openSessionWarning(expiresAtMs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -414,14 +472,15 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
this.loading = true
|
this.loading = true
|
||||||
const res = await useNuxtApp().$api("/api/tenant/switch", {
|
const res = await useNuxtApp().$api("/api/tenant/switch", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { tenant_id }
|
body: {
|
||||||
|
tenant_id,
|
||||||
|
refreshToken: this.getStoredRefreshToken()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
console.log(res)
|
console.log(res)
|
||||||
|
|
||||||
const {token} = res
|
const {token, refreshToken} = res
|
||||||
|
this.setSession(token, refreshToken, this.sessionPersistent)
|
||||||
|
|
||||||
this.setToken(token)
|
|
||||||
|
|
||||||
|
|
||||||
await this.init(token)
|
await this.init(token)
|
||||||
|
|||||||
@@ -2460,6 +2460,9 @@ export const useDataStore = defineStore('data', () => {
|
|||||||
key: 'reference',
|
key: 'reference',
|
||||||
label: "Referenz:",
|
label: "Referenz:",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
}, {
|
||||||
|
key: 'preparationSource',
|
||||||
|
label: "Quelle",
|
||||||
}, {
|
}, {
|
||||||
key: 'state',
|
key: 'state',
|
||||||
label: "Status:"
|
label: "Status:"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export default function LoginScreen() {
|
|||||||
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||||
const [apiBaseUrl, setApiBaseUrl] = useState(getApiBaseUrlSync());
|
const [apiBaseUrl, setApiBaseUrl] = useState(getApiBaseUrlSync());
|
||||||
const [serverInput, setServerInput] = useState(getApiBaseUrlSync());
|
const [serverInput, setServerInput] = useState(getApiBaseUrlSync());
|
||||||
const [showServerModal, setShowServerModal] = useState(false);
|
const [showServerModal, setShowServerModal] = useState(false);
|
||||||
@@ -141,14 +142,23 @@ export default function LoginScreen() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Text style={styles.label}>Passwort</Text>
|
<Text style={styles.label}>Passwort</Text>
|
||||||
<TextInput
|
<View style={styles.passwordRow}>
|
||||||
secureTextEntry
|
<TextInput
|
||||||
placeholder="••••••••"
|
secureTextEntry={!isPasswordVisible}
|
||||||
placeholderTextColor="#9ca3af"
|
placeholder="••••••••"
|
||||||
style={styles.input}
|
placeholderTextColor="#9ca3af"
|
||||||
value={password}
|
style={styles.passwordInput}
|
||||||
onChangeText={setPassword}
|
value={password}
|
||||||
/>
|
onChangeText={setPassword}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={isPasswordVisible ? 'Passwort ausblenden' : 'Passwort einblenden'}
|
||||||
|
style={styles.passwordToggle}
|
||||||
|
onPress={() => setIsPasswordVisible((visible) => !visible)}>
|
||||||
|
<Text style={styles.passwordToggleText}>{isPasswordVisible ? 'Ausblenden' : 'Anzeigen'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
|
||||||
@@ -251,6 +261,30 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: '#111827',
|
color: '#111827',
|
||||||
},
|
},
|
||||||
|
passwordRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
passwordInput: {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#111827',
|
||||||
|
},
|
||||||
|
passwordToggle: {
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
},
|
||||||
|
passwordToggleText: {
|
||||||
|
color: PRIMARY,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
button: {
|
button: {
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
backgroundColor: PRIMARY,
|
backgroundColor: PRIMARY,
|
||||||
|
|||||||
@@ -131,6 +131,11 @@ export type MeResponse = {
|
|||||||
permissions: string[];
|
permissions: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AuthSession = {
|
||||||
|
token: string;
|
||||||
|
refreshToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type EncodedLabelRow = {
|
export type EncodedLabelRow = {
|
||||||
dataType: 'pixels' | 'void' | 'check';
|
dataType: 'pixels' | 'void' | 'check';
|
||||||
rowNumber: number;
|
rowNumber: number;
|
||||||
@@ -199,6 +204,20 @@ type RequestOptions = {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status: number
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthenticationError(error: unknown): boolean {
|
||||||
|
return error instanceof ApiError && error.status === 401;
|
||||||
|
}
|
||||||
|
|
||||||
export type MatrixStatus = {
|
export type MatrixStatus = {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
ready?: boolean;
|
ready?: boolean;
|
||||||
@@ -363,7 +382,7 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
|||||||
(payload as { message?: string; error?: string } | null)?.message ||
|
(payload as { message?: string; error?: string } | null)?.message ||
|
||||||
(payload as { message?: string; error?: string } | null)?.error ||
|
(payload as { message?: string; error?: string } | null)?.error ||
|
||||||
`Request failed (${response.status}) for ${path}`;
|
`Request failed (${response.status}) for ${path}`;
|
||||||
throw new Error(message);
|
throw new ApiError(message, response.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload as T;
|
return payload as T;
|
||||||
@@ -399,7 +418,7 @@ async function apiFormRequest<T>(path: string, token: string, formData: FormData
|
|||||||
(payload as { message?: string; error?: string } | null)?.message ||
|
(payload as { message?: string; error?: string } | null)?.message ||
|
||||||
(payload as { message?: string; error?: string } | null)?.error ||
|
(payload as { message?: string; error?: string } | null)?.error ||
|
||||||
`Request failed (${response.status}) for ${path}`;
|
`Request failed (${response.status}) for ${path}`;
|
||||||
throw new Error(message);
|
throw new ApiError(message, response.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload as T;
|
return payload as T;
|
||||||
@@ -629,17 +648,37 @@ export async function renderPrintLabel(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginWithEmailPassword(email: string, password: string): Promise<string> {
|
function requireAuthSession(payload: Partial<AuthSession>, action: string): AuthSession {
|
||||||
const payload = await apiRequest<{ token?: string }>('/auth/login', {
|
if (!payload?.token || !payload?.refreshToken) {
|
||||||
|
throw new Error(`${action} did not return a complete session.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token: payload.token, refreshToken: payload.refreshToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginWithEmailPassword(email: string, password: string): Promise<AuthSession> {
|
||||||
|
const payload = await apiRequest<Partial<AuthSession>>('/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { email, password },
|
body: { email, password },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!payload?.token) {
|
return requireAuthSession(payload, 'Login');
|
||||||
throw new Error('Login did not return a token.');
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return payload.token;
|
export async function refreshAuthSession(refreshToken: string): Promise<AuthSession> {
|
||||||
|
const payload = await apiRequest<Partial<AuthSession>>('/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { refreshToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
return requireAuthSession(payload, 'Session refresh');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logoutSession(refreshToken: string): Promise<void> {
|
||||||
|
await apiRequest('/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { refreshToken },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMe(token: string): Promise<MeResponse> {
|
export async function fetchMe(token: string): Promise<MeResponse> {
|
||||||
@@ -666,18 +705,18 @@ export async function sendMobileTestPush(token: string): Promise<{ accepted: num
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function switchTenantRequest(tenantId: number, token: string): Promise<string> {
|
export async function switchTenantRequest(
|
||||||
const payload = await apiRequest<{ token?: string }>('/api/tenant/switch', {
|
tenantId: number,
|
||||||
|
token: string,
|
||||||
|
refreshToken: string
|
||||||
|
): Promise<AuthSession> {
|
||||||
|
const payload = await apiRequest<Partial<AuthSession>>('/api/tenant/switch', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token,
|
token,
|
||||||
body: { tenant_id: String(tenantId) },
|
body: { tenant_id: String(tenantId), refreshToken },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!payload?.token) {
|
return requireAuthSession(payload, 'Tenant switch');
|
||||||
throw new Error('Tenant switch did not return a token.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload.token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchTasks(token: string): Promise<Task[]> {
|
export async function fetchTasks(token: string): Promise<Task[]> {
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import * as SecureStore from 'expo-secure-store';
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
const TOKEN_KEY = 'fedeo.mobile.auth.token';
|
const TOKEN_KEY = 'fedeo.mobile.auth.token';
|
||||||
|
const REFRESH_TOKEN_KEY = 'fedeo.mobile.auth.refresh-token';
|
||||||
|
|
||||||
let memoryToken: string | null = null;
|
let memoryToken: string | null = null;
|
||||||
|
let memoryRefreshToken: string | null = null;
|
||||||
|
|
||||||
|
export type StoredSession = {
|
||||||
|
token: string;
|
||||||
|
refreshToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
async function hasSecureStore(): Promise<boolean> {
|
async function hasSecureStore(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
@@ -30,11 +37,41 @@ export async function setStoredToken(token: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearStoredToken(): Promise<void> {
|
export async function getStoredSession(): Promise<StoredSession | null> {
|
||||||
memoryToken = null;
|
if (await hasSecureStore()) {
|
||||||
|
const [token, refreshToken] = await Promise.all([
|
||||||
|
SecureStore.getItemAsync(TOKEN_KEY),
|
||||||
|
SecureStore.getItemAsync(REFRESH_TOKEN_KEY),
|
||||||
|
]);
|
||||||
|
memoryToken = token;
|
||||||
|
memoryRefreshToken = refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!memoryToken || !memoryRefreshToken) return null;
|
||||||
|
return { token: memoryToken, refreshToken: memoryRefreshToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setStoredSession(session: StoredSession): Promise<void> {
|
||||||
|
memoryToken = session.token;
|
||||||
|
memoryRefreshToken = session.refreshToken;
|
||||||
|
|
||||||
if (await hasSecureStore()) {
|
if (await hasSecureStore()) {
|
||||||
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
await Promise.all([
|
||||||
|
SecureStore.setItemAsync(TOKEN_KEY, session.token),
|
||||||
|
SecureStore.setItemAsync(REFRESH_TOKEN_KEY, session.refreshToken),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearStoredToken(): Promise<void> {
|
||||||
|
memoryToken = null;
|
||||||
|
memoryRefreshToken = null;
|
||||||
|
|
||||||
|
if (await hasSecureStore()) {
|
||||||
|
await Promise.all([
|
||||||
|
SecureStore.deleteItemAsync(TOKEN_KEY),
|
||||||
|
SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { fetchMe, loginWithEmailPassword, MeResponse, switchTenantRequest, Tenant } from '@/src/lib/api';
|
import {
|
||||||
|
fetchMe,
|
||||||
|
loginWithEmailPassword,
|
||||||
|
logoutSession,
|
||||||
|
MeResponse,
|
||||||
|
isAuthenticationError,
|
||||||
|
refreshAuthSession,
|
||||||
|
switchTenantRequest,
|
||||||
|
Tenant,
|
||||||
|
} from '@/src/lib/api';
|
||||||
import { hydrateApiBaseUrl } from '@/src/lib/server-config';
|
import { hydrateApiBaseUrl } from '@/src/lib/server-config';
|
||||||
import { clearStoredToken, getStoredToken, setStoredToken, tokenStorageInfo } from '@/src/lib/token-storage';
|
import {
|
||||||
|
clearStoredToken,
|
||||||
|
getStoredSession,
|
||||||
|
setStoredSession,
|
||||||
|
tokenStorageInfo,
|
||||||
|
} from '@/src/lib/token-storage';
|
||||||
|
|
||||||
export type AuthUser = MeResponse['user'];
|
export type AuthUser = MeResponse['user'];
|
||||||
|
|
||||||
@@ -27,6 +41,7 @@ type AuthContextValue = {
|
|||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||||
const BOOTSTRAP_TIMEOUT_MS = 20000;
|
const BOOTSTRAP_TIMEOUT_MS = 20000;
|
||||||
|
const SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 60 * 1000;
|
||||||
|
|
||||||
function normalizeTenantId(value: number | string | null | undefined): number | null {
|
function normalizeTenantId(value: number | string | null | undefined): number | null {
|
||||||
if (value === null || value === undefined || value === '') {
|
if (value === null || value === undefined || value === '') {
|
||||||
@@ -52,6 +67,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [isBootstrapping, setIsBootstrapping] = useState(true);
|
const [isBootstrapping, setIsBootstrapping] = useState(true);
|
||||||
const [bootstrapError, setBootstrapError] = useState<string | null>(null);
|
const [bootstrapError, setBootstrapError] = useState<string | null>(null);
|
||||||
const [token, setToken] = useState<string | null>(null);
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [refreshToken, setRefreshToken] = useState<string | null>(null);
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||||
const [activeTenantId, setActiveTenantId] = useState<number | null>(null);
|
const [activeTenantId, setActiveTenantId] = useState<number | null>(null);
|
||||||
@@ -80,11 +96,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
|
if (refreshToken) {
|
||||||
|
void logoutSession(refreshToken).catch(() => undefined);
|
||||||
|
}
|
||||||
await clearStoredToken();
|
await clearStoredToken();
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
setRefreshToken(null);
|
||||||
setBootstrapError(null);
|
setBootstrapError(null);
|
||||||
resetSession();
|
resetSession();
|
||||||
}, [resetSession]);
|
}, [refreshToken, resetSession]);
|
||||||
|
|
||||||
const refreshUser = useCallback(async () => {
|
const refreshUser = useCallback(async () => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -101,6 +121,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const resetLocalSession = useCallback(async () => {
|
const resetLocalSession = useCallback(async () => {
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
setRefreshToken(null);
|
||||||
setBootstrapError(null);
|
setBootstrapError(null);
|
||||||
setIsBootstrapping(false);
|
setIsBootstrapping(false);
|
||||||
resetSession();
|
resetSession();
|
||||||
@@ -117,19 +138,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
await withTimeout(
|
await withTimeout(
|
||||||
(async () => {
|
(async () => {
|
||||||
await hydrateApiBaseUrl();
|
await hydrateApiBaseUrl();
|
||||||
const storedToken = await getStoredToken();
|
const storedSession = await getStoredSession();
|
||||||
|
|
||||||
if (!storedToken) {
|
if (!storedSession) {
|
||||||
if (bootstrapRunRef.current !== runId) return;
|
if (bootstrapRunRef.current !== runId) return;
|
||||||
setToken(null);
|
setToken(null);
|
||||||
resetSession();
|
resetSession();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await hydrateSession(storedToken);
|
let activeSession = storedSession;
|
||||||
|
try {
|
||||||
|
await hydrateSession(activeSession.token);
|
||||||
|
} catch {
|
||||||
|
activeSession = await refreshAuthSession(activeSession.refreshToken);
|
||||||
|
await setStoredSession(activeSession);
|
||||||
|
await hydrateSession(activeSession.token);
|
||||||
|
}
|
||||||
|
|
||||||
if (bootstrapRunRef.current !== runId) return;
|
if (bootstrapRunRef.current !== runId) return;
|
||||||
setToken(storedToken);
|
setToken(activeSession.token);
|
||||||
|
setRefreshToken(activeSession.refreshToken);
|
||||||
})(),
|
})(),
|
||||||
BOOTSTRAP_TIMEOUT_MS,
|
BOOTSTRAP_TIMEOUT_MS,
|
||||||
'Die mobile Session konnte nicht rechtzeitig initialisiert werden.'
|
'Die mobile Session konnte nicht rechtzeitig initialisiert werden.'
|
||||||
@@ -137,9 +166,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (bootstrapRunRef.current === runId) {
|
if (bootstrapRunRef.current === runId) {
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
setRefreshToken(null);
|
||||||
resetSession();
|
resetSession();
|
||||||
setBootstrapError(err instanceof Error ? err.message : 'Die mobile Session konnte nicht initialisiert werden.');
|
setBootstrapError(err instanceof Error ? err.message : 'Die mobile Session konnte nicht initialisiert werden.');
|
||||||
void clearStoredToken().catch(() => undefined);
|
if (isAuthenticationError(err)) {
|
||||||
|
void clearStoredToken().catch(() => undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (bootstrapRunRef.current === runId) {
|
if (bootstrapRunRef.current === runId) {
|
||||||
@@ -154,28 +186,49 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
async (email: string, password: string) => {
|
async (email: string, password: string) => {
|
||||||
const nextToken = await loginWithEmailPassword(email, password);
|
const session = await loginWithEmailPassword(email, password);
|
||||||
await setStoredToken(nextToken);
|
await setStoredSession(session);
|
||||||
await hydrateSession(nextToken);
|
await hydrateSession(session.token);
|
||||||
setToken(nextToken);
|
setToken(session.token);
|
||||||
|
setRefreshToken(session.refreshToken);
|
||||||
},
|
},
|
||||||
[hydrateSession]
|
[hydrateSession]
|
||||||
);
|
);
|
||||||
|
|
||||||
const switchTenant = useCallback(
|
const switchTenant = useCallback(
|
||||||
async (tenantId: number) => {
|
async (tenantId: number) => {
|
||||||
if (!token) {
|
if (!token || !refreshToken) {
|
||||||
throw new Error('No active session found.');
|
throw new Error('No active session found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextToken = await switchTenantRequest(tenantId, token);
|
const session = await switchTenantRequest(tenantId, token, refreshToken);
|
||||||
await setStoredToken(nextToken);
|
await setStoredSession(session);
|
||||||
await hydrateSession(nextToken);
|
await hydrateSession(session.token);
|
||||||
setToken(nextToken);
|
setToken(session.token);
|
||||||
|
setRefreshToken(session.refreshToken);
|
||||||
},
|
},
|
||||||
[token, hydrateSession]
|
[token, refreshToken, hydrateSession]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!refreshToken) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const session = await refreshAuthSession(refreshToken);
|
||||||
|
await setStoredSession(session);
|
||||||
|
setToken(session.token);
|
||||||
|
setRefreshToken(session.refreshToken);
|
||||||
|
} catch {
|
||||||
|
// Bei einem temporären Netzfehler bleibt die gespeicherte Session erhalten.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, SESSION_REFRESH_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [refreshToken]);
|
||||||
|
|
||||||
const activeTenant = useMemo(
|
const activeTenant = useMemo(
|
||||||
() => tenants.find((tenant) => Number(tenant.id) === activeTenantId) || null,
|
() => tenants.find((tenant) => Number(tenant.id) === activeTenantId) || null,
|
||||||
[activeTenantId, tenants]
|
[activeTenantId, tenants]
|
||||||
|
|||||||
Reference in New Issue
Block a user