From 657a4f9e85b7e1d8742297723a5701b7643c0459 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 20 Jul 2026 22:01:08 +0200 Subject: [PATCH 1/7] =?UTF-8?q?KI-AGENT:=20Persistente=20App-Sessions=20mi?= =?UTF-8?q?t=20Refresh-Tokens=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/0056_auth_refresh_tokens.sql | 21 +++++ backend/db/migrations/meta/_journal.json | 7 ++ backend/db/schema/auth_refresh_tokens.ts | 20 ++++ backend/db/schema/index.ts | 1 + backend/src/routes/auth/auth.ts | 50 +++++++--- backend/src/routes/tenant.ts | 20 +++- backend/src/utils/authTokens.ts | 93 +++++++++++++++++++ mobile/app/login.tsx | 50 ++++++++-- mobile/src/lib/api.ts | 71 ++++++++++---- mobile/src/lib/token-storage.ts | 43 ++++++++- mobile/src/providers/auth-provider.tsx | 89 ++++++++++++++---- 11 files changed, 405 insertions(+), 60 deletions(-) create mode 100644 backend/db/migrations/0056_auth_refresh_tokens.sql create mode 100644 backend/db/schema/auth_refresh_tokens.ts create mode 100644 backend/src/utils/authTokens.ts diff --git a/backend/db/migrations/0056_auth_refresh_tokens.sql b/backend/db/migrations/0056_auth_refresh_tokens.sql new file mode 100644 index 0000000..e3786d8 --- /dev/null +++ b/backend/db/migrations/0056_auth_refresh_tokens.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS "auth_refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "tenant_id" bigint, + "token_hash" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "revoked_at" timestamp with time zone, + CONSTRAINT "auth_refresh_tokens_token_hash_unique" UNIQUE("token_hash"), + CONSTRAINT "auth_refresh_tokens_user_id_auth_users_id_fk" + FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") + ON DELETE cascade ON UPDATE cascade, + CONSTRAINT "auth_refresh_tokens_tenant_id_tenants_id_fk" + FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") + ON DELETE set null ON UPDATE cascade +); + +CREATE INDEX IF NOT EXISTS "auth_refresh_tokens_user_id_idx" + ON "auth_refresh_tokens" ("user_id"); +CREATE INDEX IF NOT EXISTS "auth_refresh_tokens_expires_at_idx" + ON "auth_refresh_tokens" ("expires_at"); diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 2b5a13b..6e6b436 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -372,6 +372,13 @@ "when": 1784021669805, "tag": "0055_tenant_export_maintenance_lock", "breakpoints": true + }, + { + "idx": 53, + "version": "7", + "when": 1784548800000, + "tag": "0056_auth_refresh_tokens", + "breakpoints": true } ] } diff --git a/backend/db/schema/auth_refresh_tokens.ts b/backend/db/schema/auth_refresh_tokens.ts new file mode 100644 index 0000000..dd2c84d --- /dev/null +++ b/backend/db/schema/auth_refresh_tokens.ts @@ -0,0 +1,20 @@ +import { pgTable, text, timestamp, uuid, bigint } from "drizzle-orm/pg-core" + +import { authUsers } from "./auth_users" +import { tenants } from "./tenants" + +export const authRefreshTokens = pgTable("auth_refresh_tokens", { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => authUsers.id, { onDelete: "cascade", onUpdate: "cascade" }), + tenantId: bigint("tenant_id", { mode: "number" }) + .references(() => tenants.id, { onDelete: "set null", onUpdate: "cascade" }), + tokenHash: text("token_hash").notNull().unique(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), +}) + +export type AuthRefreshToken = typeof authRefreshTokens.$inferSelect +export type NewAuthRefreshToken = typeof authRefreshTokens.$inferInsert diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index 90a53dd..8f766d6 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -7,6 +7,7 @@ export * from "./auth_roles" export * from "./auth_tenant_users" export * from "./auth_user_roles" export * from "./auth_users" +export * from "./auth_refresh_tokens" export * from "./bankaccounts" export * from "./bankrequisitions" export * from "./bankstatements" diff --git a/backend/src/routes/auth/auth.ts b/backend/src/routes/auth/auth.ts index 7d7881e..74a324c 100644 --- a/backend/src/routes/auth/auth.ts +++ b/backend/src/routes/auth/auth.ts @@ -1,14 +1,18 @@ import { FastifyInstance } from "fastify"; import bcrypt from "bcrypt"; -import jwt from "jsonwebtoken"; import { generateRandomPassword, hashPassword } from "../../utils/password"; import { sendMail } from "../../utils/mailer"; -import { secrets } from "../../utils/secrets"; import { authUsers } from "../../../db/schema"; import { authTenantUsers } from "../../../db/schema"; import { tenants } from "../../../db/schema"; -import { eq, and } from "drizzle-orm"; +import { eq } from "drizzle-orm"; +import { + createAccessToken, + issueRefreshToken, + revokeRefreshToken, + rotateRefreshToken, +} from "../../utils/authTokens"; export default async function authRoutes(server: FastifyInstance) { @@ -122,15 +126,9 @@ export default async function authRoutes(server: FastifyInstance) { return reply.code(401).send({ error: "Invalid credentials" }); } - const token = jwt.sign( - { - user_id: user.id, - email: user.email, - tenant_id: req.tenant?.id ?? null, - }, - secrets.JWT_SECRET!, - { expiresIn: "6h" } - ); + const tenantId = req.tenant?.id ? Number(req.tenant.id) : null; + const token = createAccessToken(user, tenantId); + const refreshToken = await issueRefreshToken(server, user.id, tenantId); reply.setCookie("token", token, { path: "/", @@ -140,9 +138,31 @@ export default async function authRoutes(server: FastifyInstance) { maxAge: 60 * 60 * 6, }); - return { token }; + return { token, refreshToken }; }); + server.post("/auth/refresh", { + schema: { + tags: ["Auth"], + summary: "Refresh a persistent session", + body: { + type: "object", + required: ["refreshToken"], + properties: { + refreshToken: { type: "string" }, + }, + }, + }, + }, async (req, reply) => { + const { refreshToken } = req.body as { refreshToken: string }; + const refreshed = await rotateRefreshToken(server, refreshToken); + + if (!refreshed) { + return reply.code(401).send({ error: "Invalid or expired refresh token" }); + } + + return refreshed; + }); // ----------------------------------------------------- // LOGOUT @@ -153,6 +173,10 @@ export default async function authRoutes(server: FastifyInstance) { summary: "Logout User" } }, async (req, reply) => { + const refreshToken = (req.body as { refreshToken?: string } | undefined)?.refreshToken; + if (refreshToken) { + await revokeRefreshToken(server, refreshToken); + } reply.clearCookie("token", { path: "/", httpOnly: true, diff --git a/backend/src/routes/tenant.ts b/backend/src/routes/tenant.ts index 0c85424..b67fb3a 100644 --- a/backend/src/routes/tenant.ts +++ b/backend/src/routes/tenant.ts @@ -14,6 +14,7 @@ import { import {and, desc, eq, inArray} from "drizzle-orm" import { enrichProfilesWithBranches } from "../utils/profileBranches" import { enrichProfilesWithTeams } from "../utils/profileTeams" +import { rotateRefreshToken } from "../utils/authTokens" export default async function tenantRoutes(server: FastifyInstance) { @@ -65,7 +66,7 @@ export default async function tenantRoutes(server: FastifyInstance) { return reply.code(401).send({ error: "Unauthorized" }) } - const { tenant_id } = req.body as { tenant_id: string } + const { tenant_id, refreshToken } = req.body as { tenant_id: string; refreshToken?: string } if (!tenant_id) return reply.code(400).send({ error: "tenant_id required" }) // prüfen ob der User zu diesem Tenant gehört @@ -81,7 +82,22 @@ export default async function tenantRoutes(server: FastifyInstance) { return reply.code(403).send({ error: "Not a member of this tenant" }) } - // JWT neu erzeugen + if (refreshToken) { + const session = await rotateRefreshToken( + server, + refreshToken, + Number(tenant_id), + req.user.user_id + ) + + if (!session) { + return reply.code(401).send({ error: "Invalid or expired refresh token" }) + } + + return session + } + + // Abwärtskompatibilität für Clients ohne persistente Session const token = jwt.sign( { user_id: req.user.user_id, diff --git a/backend/src/utils/authTokens.ts b/backend/src/utils/authTokens.ts new file mode 100644 index 0000000..16ad5eb --- /dev/null +++ b/backend/src/utils/authTokens.ts @@ -0,0 +1,93 @@ +import { createHash, randomBytes } from "node:crypto" +import jwt from "jsonwebtoken" +import { FastifyInstance } from "fastify" +import { and, eq, gt, isNull } from "drizzle-orm" + +import { authRefreshTokens, authUsers } from "../../db/schema" +import { secrets } from "./secrets" + +const ACCESS_TOKEN_TTL_SECONDS = 60 * 60 * 6 +const REFRESH_TOKEN_TTL_DAYS = 90 + +export function createAccessToken(user: { id: string; email: string }, tenantId: number | null) { + return jwt.sign( + { + user_id: user.id, + email: user.email, + tenant_id: tenantId, + }, + secrets.JWT_SECRET!, + { expiresIn: ACCESS_TOKEN_TTL_SECONDS } + ) +} + +export function hashRefreshToken(token: string) { + return createHash("sha256").update(token, "utf8").digest("hex") +} + +export async function issueRefreshToken( + server: FastifyInstance, + userId: string, + tenantId: number | null +) { + const token = randomBytes(48).toString("base64url") + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000) + + await server.db.insert(authRefreshTokens).values({ + userId, + tenantId, + tokenHash: hashRefreshToken(token), + expiresAt, + }) + + return token +} + +export async function rotateRefreshToken( + server: FastifyInstance, + token: string, + tenantOverride?: number | null, + expectedUserId?: string +) { + const tokenHash = hashRefreshToken(token) + const [session] = await server.db + .update(authRefreshTokens) + .set({ revokedAt: new Date() }) + .where(and( + eq(authRefreshTokens.tokenHash, tokenHash), + isNull(authRefreshTokens.revokedAt), + gt(authRefreshTokens.expiresAt, new Date()) + )) + .returning({ + id: authRefreshTokens.id, + userId: authRefreshTokens.userId, + tenantId: authRefreshTokens.tenantId, + }) + + if (!session) return null + if (expectedUserId && session.userId !== expectedUserId) return null + + const [user] = await server.db + .select({ id: authUsers.id, email: authUsers.email }) + .from(authUsers) + .where(eq(authUsers.id, session.userId)) + .limit(1) + + if (!user) return null + + const tenantId = tenantOverride === undefined ? session.tenantId : tenantOverride + const refreshToken = await issueRefreshToken(server, session.userId, tenantId) + const accessToken = createAccessToken(user, tenantId) + + return { token: accessToken, refreshToken } +} + +export async function revokeRefreshToken(server: FastifyInstance, token: string) { + await server.db + .update(authRefreshTokens) + .set({ revokedAt: new Date() }) + .where(and( + eq(authRefreshTokens.tokenHash, hashRefreshToken(token)), + isNull(authRefreshTokens.revokedAt) + )) +} diff --git a/mobile/app/login.tsx b/mobile/app/login.tsx index 87519ad..6b2c8c1 100644 --- a/mobile/app/login.tsx +++ b/mobile/app/login.tsx @@ -33,6 +33,7 @@ export default function LoginScreen() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const [isPasswordVisible, setIsPasswordVisible] = useState(false); const [apiBaseUrl, setApiBaseUrl] = useState(getApiBaseUrlSync()); const [serverInput, setServerInput] = useState(getApiBaseUrlSync()); const [showServerModal, setShowServerModal] = useState(false); @@ -141,14 +142,23 @@ export default function LoginScreen() { /> Passwort - + + + setIsPasswordVisible((visible) => !visible)}> + {isPasswordVisible ? 'Ausblenden' : 'Anzeigen'} + + {error ? {error} : null} @@ -251,6 +261,30 @@ const styles = StyleSheet.create({ fontSize: 16, color: '#111827', }, + passwordRow: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + }, + passwordInput: { + flex: 1, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 16, + color: '#111827', + }, + passwordToggle: { + alignSelf: 'stretch', + justifyContent: 'center', + paddingHorizontal: 12, + }, + passwordToggleText: { + color: PRIMARY, + fontSize: 13, + fontWeight: '600', + }, button: { marginTop: 6, backgroundColor: PRIMARY, diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts index 2ce138f..ecd20a0 100644 --- a/mobile/src/lib/api.ts +++ b/mobile/src/lib/api.ts @@ -131,6 +131,11 @@ export type MeResponse = { permissions: string[]; }; +export type AuthSession = { + token: string; + refreshToken: string; +}; + export type EncodedLabelRow = { dataType: 'pixels' | 'void' | 'check'; rowNumber: number; @@ -199,6 +204,20 @@ type RequestOptions = { timeoutMs?: number; }; +export class ApiError extends Error { + constructor( + message: string, + public readonly status: number + ) { + super(message); + this.name = 'ApiError'; + } +} + +export function isAuthenticationError(error: unknown): boolean { + return error instanceof ApiError && error.status === 401; +} + export type MatrixStatus = { enabled?: boolean; ready?: boolean; @@ -363,7 +382,7 @@ export async function apiRequest(path: string, options: RequestOptions = {}): (payload as { message?: string; error?: string } | null)?.message || (payload as { message?: string; error?: string } | null)?.error || `Request failed (${response.status}) for ${path}`; - throw new Error(message); + throw new ApiError(message, response.status); } return payload as T; @@ -399,7 +418,7 @@ async function apiFormRequest(path: string, token: string, formData: FormData (payload as { message?: string; error?: string } | null)?.message || (payload as { message?: string; error?: string } | null)?.error || `Request failed (${response.status}) for ${path}`; - throw new Error(message); + throw new ApiError(message, response.status); } return payload as T; @@ -629,17 +648,37 @@ export async function renderPrintLabel( }); } -export async function loginWithEmailPassword(email: string, password: string): Promise { - const payload = await apiRequest<{ token?: string }>('/auth/login', { +function requireAuthSession(payload: Partial, action: string): AuthSession { + if (!payload?.token || !payload?.refreshToken) { + throw new Error(`${action} did not return a complete session.`); + } + + return { token: payload.token, refreshToken: payload.refreshToken }; +} + +export async function loginWithEmailPassword(email: string, password: string): Promise { + const payload = await apiRequest>('/auth/login', { method: 'POST', body: { email, password }, }); - if (!payload?.token) { - throw new Error('Login did not return a token.'); - } + return requireAuthSession(payload, 'Login'); +} - return payload.token; +export async function refreshAuthSession(refreshToken: string): Promise { + const payload = await apiRequest>('/auth/refresh', { + method: 'POST', + body: { refreshToken }, + }); + + return requireAuthSession(payload, 'Session refresh'); +} + +export async function logoutSession(refreshToken: string): Promise { + await apiRequest('/auth/logout', { + method: 'POST', + body: { refreshToken }, + }); } export async function fetchMe(token: string): Promise { @@ -666,18 +705,18 @@ export async function sendMobileTestPush(token: string): Promise<{ accepted: num }); } -export async function switchTenantRequest(tenantId: number, token: string): Promise { - const payload = await apiRequest<{ token?: string }>('/api/tenant/switch', { +export async function switchTenantRequest( + tenantId: number, + token: string, + refreshToken: string +): Promise { + const payload = await apiRequest>('/api/tenant/switch', { method: 'POST', token, - body: { tenant_id: String(tenantId) }, + body: { tenant_id: String(tenantId), refreshToken }, }); - if (!payload?.token) { - throw new Error('Tenant switch did not return a token.'); - } - - return payload.token; + return requireAuthSession(payload, 'Tenant switch'); } export async function fetchTasks(token: string): Promise { diff --git a/mobile/src/lib/token-storage.ts b/mobile/src/lib/token-storage.ts index d86209f..766142a 100644 --- a/mobile/src/lib/token-storage.ts +++ b/mobile/src/lib/token-storage.ts @@ -1,8 +1,15 @@ import * as SecureStore from 'expo-secure-store'; const TOKEN_KEY = 'fedeo.mobile.auth.token'; +const REFRESH_TOKEN_KEY = 'fedeo.mobile.auth.refresh-token'; let memoryToken: string | null = null; +let memoryRefreshToken: string | null = null; + +export type StoredSession = { + token: string; + refreshToken: string; +}; async function hasSecureStore(): Promise { try { @@ -30,11 +37,41 @@ export async function setStoredToken(token: string): Promise { } } -export async function clearStoredToken(): Promise { - memoryToken = null; +export async function getStoredSession(): Promise { + 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 { + memoryToken = session.token; + memoryRefreshToken = session.refreshToken; if (await hasSecureStore()) { - await SecureStore.deleteItemAsync(TOKEN_KEY); + await Promise.all([ + SecureStore.setItemAsync(TOKEN_KEY, session.token), + SecureStore.setItemAsync(REFRESH_TOKEN_KEY, session.refreshToken), + ]); + } +} + +export async function clearStoredToken(): Promise { + memoryToken = null; + memoryRefreshToken = null; + + if (await hasSecureStore()) { + await Promise.all([ + SecureStore.deleteItemAsync(TOKEN_KEY), + SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY), + ]); } } diff --git a/mobile/src/providers/auth-provider.tsx b/mobile/src/providers/auth-provider.tsx index 3e59fd7..9811a4c 100644 --- a/mobile/src/providers/auth-provider.tsx +++ b/mobile/src/providers/auth-provider.tsx @@ -1,8 +1,22 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { fetchMe, loginWithEmailPassword, MeResponse, switchTenantRequest, Tenant } from '@/src/lib/api'; +import { + fetchMe, + loginWithEmailPassword, + logoutSession, + MeResponse, + isAuthenticationError, + refreshAuthSession, + switchTenantRequest, + Tenant, +} from '@/src/lib/api'; import { hydrateApiBaseUrl } from '@/src/lib/server-config'; -import { clearStoredToken, getStoredToken, setStoredToken, tokenStorageInfo } from '@/src/lib/token-storage'; +import { + clearStoredToken, + getStoredSession, + setStoredSession, + tokenStorageInfo, +} from '@/src/lib/token-storage'; export type AuthUser = MeResponse['user']; @@ -27,6 +41,7 @@ type AuthContextValue = { const AuthContext = createContext(undefined); const BOOTSTRAP_TIMEOUT_MS = 20000; +const SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 60 * 1000; function normalizeTenantId(value: number | string | null | undefined): number | null { if (value === null || value === undefined || value === '') { @@ -52,6 +67,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [isBootstrapping, setIsBootstrapping] = useState(true); const [bootstrapError, setBootstrapError] = useState(null); const [token, setToken] = useState(null); + const [refreshToken, setRefreshToken] = useState(null); const [user, setUser] = useState(null); const [tenants, setTenants] = useState([]); const [activeTenantId, setActiveTenantId] = useState(null); @@ -80,11 +96,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { ); const logout = useCallback(async () => { + if (refreshToken) { + void logoutSession(refreshToken).catch(() => undefined); + } await clearStoredToken(); setToken(null); + setRefreshToken(null); setBootstrapError(null); resetSession(); - }, [resetSession]); + }, [refreshToken, resetSession]); const refreshUser = useCallback(async () => { if (!token) { @@ -101,6 +121,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const resetLocalSession = useCallback(async () => { setToken(null); + setRefreshToken(null); setBootstrapError(null); setIsBootstrapping(false); resetSession(); @@ -117,19 +138,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { await withTimeout( (async () => { await hydrateApiBaseUrl(); - const storedToken = await getStoredToken(); + const storedSession = await getStoredSession(); - if (!storedToken) { + if (!storedSession) { if (bootstrapRunRef.current !== runId) return; setToken(null); resetSession(); return; } - await hydrateSession(storedToken); + let activeSession = storedSession; + try { + await hydrateSession(activeSession.token); + } catch { + activeSession = await refreshAuthSession(activeSession.refreshToken); + await setStoredSession(activeSession); + await hydrateSession(activeSession.token); + } if (bootstrapRunRef.current !== runId) return; - setToken(storedToken); + setToken(activeSession.token); + setRefreshToken(activeSession.refreshToken); })(), BOOTSTRAP_TIMEOUT_MS, 'Die mobile Session konnte nicht rechtzeitig initialisiert werden.' @@ -137,9 +166,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } catch (err) { if (bootstrapRunRef.current === runId) { setToken(null); + setRefreshToken(null); resetSession(); setBootstrapError(err instanceof Error ? err.message : 'Die mobile Session konnte nicht initialisiert werden.'); - void clearStoredToken().catch(() => undefined); + if (isAuthenticationError(err)) { + void clearStoredToken().catch(() => undefined); + } } } finally { if (bootstrapRunRef.current === runId) { @@ -154,28 +186,49 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const login = useCallback( async (email: string, password: string) => { - const nextToken = await loginWithEmailPassword(email, password); - await setStoredToken(nextToken); - await hydrateSession(nextToken); - setToken(nextToken); + const session = await loginWithEmailPassword(email, password); + await setStoredSession(session); + await hydrateSession(session.token); + setToken(session.token); + setRefreshToken(session.refreshToken); }, [hydrateSession] ); const switchTenant = useCallback( async (tenantId: number) => { - if (!token) { + if (!token || !refreshToken) { throw new Error('No active session found.'); } - const nextToken = await switchTenantRequest(tenantId, token); - await setStoredToken(nextToken); - await hydrateSession(nextToken); - setToken(nextToken); + const session = await switchTenantRequest(tenantId, token, refreshToken); + await setStoredSession(session); + await hydrateSession(session.token); + setToken(session.token); + setRefreshToken(session.refreshToken); }, - [token, hydrateSession] + [token, refreshToken, hydrateSession] ); + useEffect(() => { + if (!refreshToken) return; + + const interval = setInterval(() => { + void (async () => { + try { + const session = await refreshAuthSession(refreshToken); + await setStoredSession(session); + setToken(session.token); + setRefreshToken(session.refreshToken); + } catch { + // Bei einem temporären Netzfehler bleibt die gespeicherte Session erhalten. + } + })(); + }, SESSION_REFRESH_INTERVAL_MS); + + return () => clearInterval(interval); + }, [refreshToken]); + const activeTenant = useMemo( () => tenants.find((tenant) => Number(tenant.id) === activeTenantId) || null, [activeTenantId, tenants] From 5585bd7012fbe4a0810ff8c7cc492399d247ab1a Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 20 Jul 2026 22:12:09 +0200 Subject: [PATCH 2/7] =?UTF-8?q?KI-AGENT:=20Refresh-Token=20f=C3=BCr=20Web-?= =?UTF-8?q?Sitzungen=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/auth/auth.ts | 5 +- frontend/pages/login.vue | 11 ++- frontend/stores/auth.ts | 125 +++++++++++++++++++++++--------- 3 files changed, 104 insertions(+), 37 deletions(-) diff --git a/backend/src/routes/auth/auth.ts b/backend/src/routes/auth/auth.ts index 74a324c..2537d6c 100644 --- a/backend/src/routes/auth/auth.ts +++ b/backend/src/routes/auth/auth.ts @@ -65,11 +65,12 @@ export default async function authRoutes(server: FastifyInstance) { properties: { email: { type: "string", format: "email" }, password: { type: "string" }, + rememberMe: { type: "boolean" }, }, }, }, }, async (req, reply) => { - const body = req.body as { email: string; password: string }; + const body = req.body as { email: string; password: string; rememberMe?: boolean }; let user: any = null; @@ -135,7 +136,7 @@ export default async function authRoutes(server: FastifyInstance) { httpOnly: true, sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", secure: process.env.NODE_ENV === "production", - maxAge: 60 * 60 * 6, + ...(body.rememberMe ? { maxAge: 60 * 60 * 6 } : {}), }); return { token, refreshToken }; diff --git a/frontend/pages/login.vue b/frontend/pages/login.vue index 19f5e9e..eec016f 100644 --- a/frontend/pages/login.vue +++ b/frontend/pages/login.vue @@ -11,7 +11,8 @@ const router = useRouter() const state = reactive({ email: '', - password: '' + password: '', + rememberMe: true }) const loading = ref(false) @@ -19,7 +20,7 @@ const loading = ref(false) const doLogin = async (event: FormSubmitEvent) => { loading.value = true try { - await auth.login(event.data.email, event.data.password) + await auth.login(event.data.email, event.data.password, event.data.rememberMe) toast.add({title:"Einloggen erfolgreich"}) await router.push("/") } catch (err: any) { @@ -79,6 +80,12 @@ const doLogin = async (event: FormSubmitEvent) => { /> + + Weiter diff --git a/frontend/stores/auth.ts b/frontend/stores/auth.ts index d942b7f..538c096 100644 --- a/frontend/stores/auth.ts +++ b/frontend/stores/auth.ts @@ -27,6 +27,7 @@ export const useAuthStore = defineStore("auth", { sessionWarningTimer: null as ReturnType | null, sessionLogoutTimer: null as ReturnType | null, sessionCountdownTimer: null as ReturnType | null, + sessionPersistent: false, }), actions: { @@ -36,18 +37,18 @@ export const useAuthStore = defineStore("auth", { getStoredToken() { const rootToken = this.tokenCookie().value - if (rootToken || !process.client) return rootToken + if (!process.client) return rootToken - const tokenCookie = document.cookie - .split(";") - .map((part) => part.trim()) - .find((part) => part.startsWith("token=")) + return sessionStorage.getItem("token") + || localStorage.getItem("token") + || rootToken + }, - if (tokenCookie) { - return decodeURIComponent(tokenCookie.slice("token=".length)) - } + getStoredRefreshToken() { + if (!process.client) return null - return localStorage.getItem("token") + return sessionStorage.getItem("refreshToken") + || localStorage.getItem("refreshToken") }, clearScopedTokenCookies() { @@ -155,24 +156,34 @@ export const useAuthStore = defineStore("auth", { const msUntilWarning = msUntilLogout - this.sessionWarningLeadMs if (msUntilWarning <= 0) { + if (this.getStoredRefreshToken()) { + void this.refreshSession() + return + } this.openSessionWarning(expiresAtMs) return } this.sessionWarningTimer = setTimeout(() => { + if (this.getStoredRefreshToken()) { + void this.refreshSession() + return + } this.openSessionWarning(expiresAtMs) }, msUntilWarning) }, - setToken(token: string | null) { + setToken(token: string | null, persistent = this.sessionPersistent) { this.clearScopedTokenCookies() this.tokenCookie().value = token if (process.client) { + localStorage.removeItem("token") + sessionStorage.removeItem("token") + if (token) { - localStorage.setItem("token", token) - } else { - localStorage.removeItem("token") + const storage = persistent ? localStorage : sessionStorage + storage.setItem("token", token) } } @@ -186,6 +197,30 @@ export const useAuthStore = defineStore("auth", { this.scheduleSessionTimers(token) }, + setRefreshToken(refreshToken: string | null, persistent = this.sessionPersistent) { + if (!process.client) return + + localStorage.removeItem("refreshToken") + sessionStorage.removeItem("refreshToken") + + if (refreshToken) { + const storage = persistent ? localStorage : sessionStorage + storage.setItem("refreshToken", refreshToken) + } + }, + + setSession(token: string, refreshToken: string, persistent: boolean) { + this.sessionPersistent = persistent + this.setRefreshToken(refreshToken, persistent) + this.setToken(token, persistent) + }, + + clearStoredSession() { + this.setRefreshToken(null) + this.setToken(null) + this.sessionPersistent = false + }, + async persist(token: string | null) { console.log("On Web") @@ -195,6 +230,7 @@ export const useAuthStore = defineStore("auth", { async initStore() { console.log("Auth initStore") + this.sessionPersistent = process.client && Boolean(localStorage.getItem("refreshToken")) // 1. Check: Haben wir überhaupt ein Token? const token = this.getStoredToken() @@ -207,7 +243,7 @@ export const useAuthStore = defineStore("auth", { } // 2. Token existiert -> Versuche User zu laden - await this.fetchMe(token) + await this.fetchMe(token, true) // Wenn fetchMe fertig ist (egal ob Erfolg oder Fehler), ladebalken weg @@ -236,18 +272,18 @@ export const useAuthStore = defineStore("auth", { } }, - async login(email: string, password: string) { + async login(email: string, password: string, rememberMe = false) { try { console.log("Auth login") - const { token } = await useNuxtApp().$api("/auth/login", { + const { token, refreshToken } = await useNuxtApp().$api("/auth/login", { method: "POST", - body: { email, password } + body: { email, password, rememberMe } }) console.log("Token: " + token) - // 1. WICHTIG: Token sofort ins Cookie schreiben, damit es persistiert wird - this.setToken(token) + // Tokens abhängig von „Angemeldet bleiben“ dauerhaft oder nur für diese Sitzung speichern + this.setSession(token, refreshToken, rememberMe) this.sessionExpired = false // 2. User Daten laden @@ -269,14 +305,18 @@ export const useAuthStore = defineStore("auth", { } catch (e) { console.log("login error:" + e) - // Hier könnte man noch eine Fehlermeldung im UI anzeigen + throw e } }, async logout() { console.log("Auth logout") + const refreshToken = this.getStoredRefreshToken() try { - await useNuxtApp().$api("/auth/logout", { method: "POST" }) + await useNuxtApp().$api("/auth/logout", { + method: "POST", + body: { refreshToken } + }) } catch (e) { console.error("Logout API fehlgeschlagen (egal):", e) } @@ -285,7 +325,7 @@ export const useAuthStore = defineStore("auth", { this.resetState() // Token löschen - this.setToken(null) + this.clearStoredSession() // Nur beim expliziten Logout navigieren wir navigateTo("/login") @@ -295,7 +335,7 @@ export const useAuthStore = defineStore("auth", { console.log("Auth session expired") this.resetState() this.sessionExpired = true - this.setToken(null) + this.clearStoredSession() this.loading = false if (process.client) { @@ -317,7 +357,7 @@ export const useAuthStore = defineStore("auth", { this.loading = false }, - async fetchMe(jwt= null) { + async fetchMe(jwt= null, allowRefresh = true) { console.log("Auth fetchMe") const tempStore = useTempStore() @@ -384,6 +424,10 @@ export const useAuthStore = defineStore("auth", { console.log("fetchMe failed (Invalid Token or Network)", err) if (err?.response?.status === 401 || err?.status === 401 || err?.statusCode === 401) { + if (allowRefresh && this.getStoredRefreshToken()) { + await this.refreshSession() + return + } this.expireSession() return } @@ -395,17 +439,31 @@ export const useAuthStore = defineStore("auth", { }, async refreshSession() { + const refreshToken = this.getStoredRefreshToken() + if (!refreshToken) { + this.expireSession() + return + } + try { - const { token } = await useNuxtApp().$api("/api/auth/refresh", { + const session = await useNuxtApp().$api("/auth/refresh", { method: "POST", + body: { refreshToken } }) - this.setToken(token) - await this.fetchMe(token) + this.setSession(session.token, session.refreshToken, this.sessionPersistent) + await this.fetchMe(session.token, false) this.sessionWarningVisible = false } catch (err) { console.error("JWT refresh failed", err) - await this.logout() + const status = err?.response?.status || err?.status || err?.statusCode + if (status === 401) { + this.expireSession() + } else { + const token = this.getStoredToken() + const expiresAtMs = token ? this.decodeTokenExpiryMs(token) : null + if (expiresAtMs) this.openSessionWarning(expiresAtMs) + } } }, @@ -414,14 +472,15 @@ export const useAuthStore = defineStore("auth", { this.loading = true const res = await useNuxtApp().$api("/api/tenant/switch", { method: "POST", - body: { tenant_id } + body: { + tenant_id, + refreshToken: this.getStoredRefreshToken() + } }) console.log(res) - const {token} = res - - - this.setToken(token) + const {token, refreshToken} = res + this.setSession(token, refreshToken, this.sessionPersistent) await this.init(token) From 6c613857fab0cac22eb9a13b1098f4ef2eab2e65 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Tue, 21 Jul 2026 19:59:44 +0200 Subject: [PATCH 3/7] =?UTF-8?q?KI-AGENT:=20ZUGFeRD-=20und=20E-Rechnungserk?= =?UTF-8?q?ennung=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/cron/prepareIncomingInvoices.ts | 29 +++- .../einvoice/detectElectronicInvoice.ts | 141 ++++++++++++++++++ backend/src/utils/fileBuffer.ts | 26 ++++ backend/src/utils/gpt.ts | 28 +--- 4 files changed, 200 insertions(+), 24 deletions(-) create mode 100644 backend/src/modules/einvoice/detectElectronicInvoice.ts create mode 100644 backend/src/utils/fileBuffer.ts diff --git a/backend/src/modules/cron/prepareIncomingInvoices.ts b/backend/src/modules/cron/prepareIncomingInvoices.ts index 4582d2a..98cbf8d 100644 --- a/backend/src/modules/cron/prepareIncomingInvoices.ts +++ b/backend/src/modules/cron/prepareIncomingInvoices.ts @@ -1,6 +1,8 @@ import { FastifyInstance } from "fastify" import dayjs from "dayjs" import { getInvoiceDataFromGPT } from "../../utils/gpt" +import { loadFileBuffer } from "../../utils/fileBuffer" +import { detectElectronicInvoice } from "../einvoice/detectElectronicInvoice" // Drizzle schema import { @@ -90,7 +92,32 @@ export function prepareIncomingInvoices(server: FastifyInstance) { for (const file of filesRes) { console.log(`Processing file ${file.id} for tenant ${tenantId}`) - const data = await getInvoiceDataFromGPT(server,file, tenantId) + let fileData: Buffer + + try { + fileData = await loadFileBuffer(file.path!) + } catch (error) { + server.log.error(error, `Datei ${file.id} konnte nicht aus S3 geladen werden.`) + continue + } + + const electronicInvoice = await detectElectronicInvoice(fileData, file) + + if (electronicInvoice) { + server.log.info({ + fileId: file.id, + container: electronicInvoice.container, + syntax: electronicInvoice.syntax, + attachmentName: electronicInvoice.attachmentName, + }, "Strukturierte E-Rechnung erkannt.") + + if (electronicInvoice.container === "xml") { + server.log.warn({ fileId: file.id }, "Die Auswertung eigenständiger E-Rechnungs-XML folgt in Etappe 2.") + continue + } + } + + const data = await getInvoiceDataFromGPT(server, file, tenantId, fileData) if (!data) { server.log.warn(`GPT returned no data for file ${file.id}`) diff --git a/backend/src/modules/einvoice/detectElectronicInvoice.ts b/backend/src/modules/einvoice/detectElectronicInvoice.ts new file mode 100644 index 0000000..5b174fe --- /dev/null +++ b/backend/src/modules/einvoice/detectElectronicInvoice.ts @@ -0,0 +1,141 @@ +import { + decodePDFRawStream, + PDFDict, + PDFDocument, + PDFHexString, + PDFName, + PDFRawStream, + PDFString, +} from "pdf-lib" + +export type ElectronicInvoiceSyntax = "cii" | "ubl-invoice" | "ubl-credit-note" + +export type ElectronicInvoiceDetection = { + container: "pdf" | "xml" + syntax: ElectronicInvoiceSyntax + xml: Buffer + attachmentName: string | null +} + +type FileLike = { + name?: string | null + path?: string | null + mimeType?: string | null +} + +const decodePdfText = (value: unknown): string | null => { + if (value instanceof PDFString || value instanceof PDFHexString) { + return value.decodeText() + } + + return null +} + +const detectXmlSyntax = (xml: Buffer): ElectronicInvoiceSyntax | null => { + const sample = xml.subarray(0, Math.min(xml.length, 64 * 1024)).toString("utf8") + + if (/<(?:[A-Za-z_][\w.-]*:)?CrossIndustryInvoice(?:\s|>)/i.test(sample)) { + return "cii" + } + + if ( + /)/i.test(sample) + && /urn:oasis:names:specification:ubl:schema:xsd:Invoice-2/i.test(sample) + ) { + return "ubl-invoice" + } + + if ( + /)/i.test(sample) + && /urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2/i.test(sample) + ) { + return "ubl-credit-note" + } + + return null +} + +const extractXmlFromFileSpec = (fileSpec: PDFDict): { name: string | null, xml: Buffer } | null => { + const context = fileSpec.context + const embeddedFiles = context.lookupMaybe(fileSpec.get(PDFName.of("EF")), PDFDict) + + if (!embeddedFiles) return null + + const stream = context.lookup( + embeddedFiles.get(PDFName.of("UF")) || embeddedFiles.get(PDFName.of("F")), + ) + + if (!(stream instanceof PDFRawStream)) return null + + const name = decodePdfText( + context.lookup(fileSpec.get(PDFName.of("UF")) || fileSpec.get(PDFName.of("F"))), + ) + const contents = Buffer.from(decodePDFRawStream(stream).decode()) + + return { name, xml: contents } +} + +const detectEmbeddedInvoice = async (pdf: Buffer): Promise => { + let document: PDFDocument + + try { + document = await PDFDocument.load(pdf, { + ignoreEncryption: true, + updateMetadata: false, + }) + } catch { + return null + } + + for (const [, object] of document.context.enumerateIndirectObjects()) { + if (!(object instanceof PDFDict)) continue + + const type = object.get(PDFName.of("Type")) + if (!(type instanceof PDFName) || type.asString() !== "/Filespec") continue + + const embedded = extractXmlFromFileSpec(object) + if (!embedded) continue + + const syntax = detectXmlSyntax(embedded.xml) + if (!syntax) continue + + return { + container: "pdf", + syntax, + xml: embedded.xml, + attachmentName: embedded.name, + } + } + + return null +} + +const hasPdfFileType = (file: FileLike, data: Buffer) => { + const filename = String(file.name || file.path || "").toLowerCase() + const mimeType = String(file.mimeType || "").toLowerCase().split(";")[0].trim() + + return filename.endsWith(".pdf") || mimeType === "application/pdf" || data.subarray(0, 5).toString("ascii") === "%PDF-" +} + +export const detectElectronicInvoice = async ( + data: Buffer, + file: FileLike, +): Promise => { + if (hasPdfFileType(file, data)) { + return detectEmbeddedInvoice(data) + } + + const beginsLikeXml = data.subarray(0, 1024).toString("utf8").replace(/^\uFEFF/, "").trimStart().startsWith("<") + const syntax = beginsLikeXml ? detectXmlSyntax(data) : null + + if (syntax) { + return { + container: "xml", + syntax, + xml: data, + attachmentName: file.name || file.path?.split("/").pop() || null, + } + } + + return null +} diff --git a/backend/src/utils/fileBuffer.ts b/backend/src/utils/fileBuffer.ts new file mode 100644 index 0000000..a6d9b4e --- /dev/null +++ b/backend/src/utils/fileBuffer.ts @@ -0,0 +1,26 @@ +import { GetObjectCommand } from "@aws-sdk/client-s3" +import { s3 } from "./s3" +import { secrets } from "./secrets" + +export const streamToBuffer = async (stream: any): Promise => { + const chunks: Buffer[] = [] + + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + + return Buffer.concat(chunks) +} + +export const loadFileBuffer = async (path: string): Promise => { + const response = await s3.send(new GetObjectCommand({ + Bucket: secrets.S3_BUCKET, + Key: path, + })) + + if (!response.Body) { + throw new Error(`S3-Datei '${path}' enthält keinen lesbaren Inhalt.`) + } + + return streamToBuffer(response.Body) +} diff --git a/backend/src/utils/gpt.ts b/backend/src/utils/gpt.ts index 95b9cc7..3577faa 100644 --- a/backend/src/utils/gpt.ts +++ b/backend/src/utils/gpt.ts @@ -2,12 +2,11 @@ import dayjs from "dayjs"; import OpenAI from "openai"; import { z } from "zod"; import { zodResponseFormat } from "openai/helpers/zod"; -import { GetObjectCommand } from "@aws-sdk/client-s3"; import { FastifyInstance } from "fastify"; -import { s3 } from "./s3"; -import { secrets } from "./secrets"; import { storeExtractedTextForFile } from "./documentText"; +import { loadFileBuffer } from "./fileBuffer"; +import { secrets } from "./secrets"; // Drizzle schema import { vendors, accounts, tenants } from "../../db/schema"; @@ -27,18 +26,6 @@ export const initOpenAi = async () => { }); }; -// --------------------------------------------------------- -// STREAM → BUFFER -// --------------------------------------------------------- -async function streamToBuffer(stream: any): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - stream.on("data", (chunk: Buffer) => chunks.push(chunk)); - stream.on("error", reject); - stream.on("end", () => resolve(Buffer.concat(chunks))); - }); -} - // --------------------------------------------------------- // GPT RESPONSE FORMAT (Zod Schema) // --------------------------------------------------------- @@ -93,7 +80,8 @@ const InstructionFormat = z.object({ export const getInvoiceDataFromGPT = async function ( server: FastifyInstance, file: any, - tenantId: number + tenantId: number, + suppliedFileData?: Buffer, ) { await initOpenAi(); @@ -109,13 +97,7 @@ export const getInvoiceDataFromGPT = async function ( let fileData: Buffer; try { - const command = new GetObjectCommand({ - Bucket: secrets.S3_BUCKET, - Key: file.path, - }); - - const response: any = await s3.send(command); - fileData = await streamToBuffer(response.Body); + fileData = suppliedFileData || await loadFileBuffer(file.path); } catch (err) { console.log(`❌ S3 Download failed for file ${file.id}`, err); return null; From 5e7a1f9aee41b30782ed212b09668ce3967a0b5f Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Tue, 21 Jul 2026 20:20:34 +0200 Subject: [PATCH 4/7] KI-AGENT: E-Rechnungen in der Belegvorbereitung verarbeiten --- ...057_incoming_invoice_einvoice_metadata.sql | 5 + backend/db/migrations/meta/_journal.json | 7 + backend/db/schema/incominginvoices.ts | 5 + backend/package-lock.json | 127 +++++++++- backend/package.json | 2 + .../modules/cron/prepareIncomingInvoices.ts | 155 ++++++------ .../einvoice/detectElectronicInvoice.ts | 4 +- .../modules/einvoice/mapElectronicInvoice.ts | 93 ++++++++ .../einvoice/parseElectronicInvoice.ts | 223 ++++++++++++++++++ backend/src/modules/einvoice/types.ts | 42 ++++ backend/tests/einvoice.test.ts | 68 ++++++ .../pages/incomingInvoices/[mode]/[id].vue | 41 ++++ frontend/pages/incomingInvoices/index.vue | 12 + frontend/stores/data.js | 3 + 14 files changed, 708 insertions(+), 79 deletions(-) create mode 100644 backend/db/migrations/0057_incoming_invoice_einvoice_metadata.sql create mode 100644 backend/src/modules/einvoice/mapElectronicInvoice.ts create mode 100644 backend/src/modules/einvoice/parseElectronicInvoice.ts create mode 100644 backend/src/modules/einvoice/types.ts create mode 100644 backend/tests/einvoice.test.ts diff --git a/backend/db/migrations/0057_incoming_invoice_einvoice_metadata.sql b/backend/db/migrations/0057_incoming_invoice_einvoice_metadata.sql new file mode 100644 index 0000000..47080c1 --- /dev/null +++ b/backend/db/migrations/0057_incoming_invoice_einvoice_metadata.sql @@ -0,0 +1,5 @@ +ALTER TABLE "incominginvoices" + ADD COLUMN IF NOT EXISTS "preparation_source" text, + ADD COLUMN IF NOT EXISTS "e_invoice_syntax" text, + ADD COLUMN IF NOT EXISTS "e_invoice_profile" text, + ADD COLUMN IF NOT EXISTS "e_invoice_validation" jsonb; diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 6e6b436..f69d735 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -379,6 +379,13 @@ "when": 1784548800000, "tag": "0056_auth_refresh_tokens", "breakpoints": true + }, + { + "idx": 54, + "version": "7", + "when": 1784635200000, + "tag": "0057_incoming_invoice_einvoice_metadata", + "breakpoints": true } ] } diff --git a/backend/db/schema/incominginvoices.ts b/backend/db/schema/incominginvoices.ts index 07ac711..caa1ffb 100644 --- a/backend/db/schema/incominginvoices.ts +++ b/backend/db/schema/incominginvoices.ts @@ -40,6 +40,11 @@ export const incominginvoices = pgTable("incominginvoices", { paymentType: text("paymentType"), + preparationSource: text("preparation_source"), + eInvoiceSyntax: text("e_invoice_syntax"), + eInvoiceProfile: text("e_invoice_profile"), + eInvoiceValidation: jsonb("e_invoice_validation"), + accounts: jsonb("accounts").notNull().default([ { account: null, diff --git a/backend/package-lock.json b/backend/package-lock.json index 36e4ac0..ae09c0e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -27,6 +27,7 @@ "crypto": "^1.0.1", "dayjs": "^1.11.18", "drizzle-orm": "^0.45.0", + "fast-xml-parser": "^5.10.1", "fastify": "^5.5.0", "fastify-plugin": "^5.0.1", "handlebars": "^4.7.8", @@ -5290,6 +5291,24 @@ "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/xml-builder/node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/@aws/lambda-invoke-store": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz", @@ -5850,6 +5869,18 @@ "eventemitter3": "^5.0.1" } }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@pdf-lib/standard-fonts": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", @@ -6881,6 +6912,18 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/archiver": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", @@ -8004,10 +8047,10 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fast-xml-parser": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", - "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "node_modules/fast-xml-builder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", + "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==", "funding": [ { "type": "github", @@ -8016,7 +8059,28 @@ ], "license": "MIT", "dependencies": { - "strnum": "^2.1.0" + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz", + "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.1", + "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -8517,6 +8581,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-unsafe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -9098,6 +9174,21 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -9940,16 +10031,19 @@ } }, "node_modules/strnum": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", - "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } }, "node_modules/tar-stream": { "version": "3.1.7", @@ -10351,6 +10445,21 @@ "node": ">= 4" } }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/backend/package.json b/backend/package.json index fce10d1..6302391 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,6 +9,7 @@ "fill": "ts-node src/webdav/fill-file-sizes.ts", "dev:dav": "tsx watch src/webdav/server.ts", "build": "tsc", + "test:einvoice": "tsx --test tests/einvoice.test.ts", "start": "node dist/src/index.js", "schema:index": "ts-node scripts/generate-schema-index.ts", "bankcodes:update": "tsx scripts/generate-de-bank-codes.ts", @@ -43,6 +44,7 @@ "crypto": "^1.0.1", "dayjs": "^1.11.18", "drizzle-orm": "^0.45.0", + "fast-xml-parser": "^5.10.1", "fastify": "^5.5.0", "fastify-plugin": "^5.0.1", "handlebars": "^4.7.8", diff --git a/backend/src/modules/cron/prepareIncomingInvoices.ts b/backend/src/modules/cron/prepareIncomingInvoices.ts index 98cbf8d..bb1ef67 100644 --- a/backend/src/modules/cron/prepareIncomingInvoices.ts +++ b/backend/src/modules/cron/prepareIncomingInvoices.ts @@ -3,6 +3,11 @@ import dayjs from "dayjs" import { getInvoiceDataFromGPT } from "../../utils/gpt" import { loadFileBuffer } from "../../utils/fileBuffer" import { detectElectronicInvoice } from "../einvoice/detectElectronicInvoice" +import { parseElectronicInvoice } from "../einvoice/parseElectronicInvoice" +import { + findElectronicInvoiceVendor, + mapElectronicInvoiceToIncomingInvoice, +} from "../einvoice/mapElectronicInvoice" // Drizzle schema import { @@ -26,6 +31,61 @@ const formatInvoiceItemDescription = (item: any) => { return parts.join(" - ") } +const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => { + const itemInfo: any = { + tenant: tenantId, + state: "Vorbereitet", + preparationSource: "gpt", + } + + if (data.invoice_number) itemInfo.reference = data.invoice_number + if (data.invoice_date && dayjs(data.invoice_date).isValid()) itemInfo.date = dayjs(data.invoice_date).toISOString() + if (data.issuer?.id) itemInfo.vendor = data.issuer.id + if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString() + + const mapPayment: Record = { + "Direct Debit": "Einzug", + "Transfer": "Überweisung", + "Credit Card": "Kreditkarte", + "Other": "Sonstiges", + } + if (data.terms) itemInfo.paymentType = mapPayment[data.terms] ?? data.terms + + if (data.invoice_items?.length > 0) { + itemInfo.accounts = data.invoice_items + .filter((item: any) => item.description || item.total !== null || item.total_without_tax !== null) + .map((item: any) => { + const total = typeof item.total === "number" ? item.total : null + const totalWithoutTax = typeof item.total_without_tax === "number" ? item.total_without_tax : null + const amountTax = total !== null && totalWithoutTax !== null + ? Number((total - totalWithoutTax).toFixed(2)) + : null + + return { + account: item.account_id, + description: item.description, + amountNet: totalWithoutTax, + amountTax, + taxType: item.tax_rate !== null ? String(item.tax_rate) : null, + amountGross: total, + costCentre: null, + quantity: item.quantity, + } + }) + } + + let description = "" + if (data.delivery_note_number) description += `Lieferschein: ${data.delivery_note_number}\n` + if (data.reference) description += `Referenz: ${data.reference}\n` + for (const item of data.invoice_items || []) { + const line = formatInvoiceItemDescription(item) + if (line) description += `${line}\n` + } + itemInfo.description = description.trim() + + return itemInfo +} + export function prepareIncomingInvoices(server: FastifyInstance) { const processInvoices = async (tenantId:number) => { console.log("▶ Starting Incoming Invoice Preparation") @@ -87,7 +147,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) { } // ------------------------------------------------------------- - // 3️⃣ Jede Datei einzeln durch GPT jagen & IncomingInvoice erzeugen + // 3️⃣ Strukturierte E-Rechnung bevorzugen, GPT als PDF-Fallback verwenden // ------------------------------------------------------------- for (const file of filesRes) { console.log(`Processing file ${file.id} for tenant ${tenantId}`) @@ -103,6 +163,8 @@ export function prepareIncomingInvoices(server: FastifyInstance) { const electronicInvoice = await detectElectronicInvoice(fileData, file) + let itemInfo: any = null + if (electronicInvoice) { server.log.info({ fileId: file.id, @@ -111,77 +173,34 @@ export function prepareIncomingInvoices(server: FastifyInstance) { attachmentName: electronicInvoice.attachmentName, }, "Strukturierte E-Rechnung erkannt.") - if (electronicInvoice.container === "xml") { - server.log.warn({ fileId: file.id }, "Die Auswertung eigenständiger E-Rechnungs-XML folgt in Etappe 2.") + try { + const parsedInvoice = parseElectronicInvoice(electronicInvoice.xml, electronicInvoice.syntax) + const vendor = await findElectronicInvoiceVendor(server, tenantId, parsedInvoice) + itemInfo = mapElectronicInvoiceToIncomingInvoice( + parsedInvoice, + tenantId, + vendor?.id || null, + electronicInvoice.container, + electronicInvoice.attachmentName, + ) + } catch (error) { + server.log.error(error, `E-Rechnung aus Datei ${file.id} konnte nicht verarbeitet werden.`) + + if (electronicInvoice.container === "xml") continue + } + } + + 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) } - const data = await getInvoiceDataFromGPT(server, file, tenantId, fileData) - - if (!data) { - server.log.warn(`GPT returned no data for file ${file.id}`) - continue - } - - // --------------------------------------------------------- - // 3.1 IncomingInvoice-Objekt vorbereiten - // --------------------------------------------------------- - let itemInfo: any = { - tenant: tenantId, - state: "Vorbereitet" - } - - 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() - - // Payment terms mapping - const mapPayment: any = { - "Direct Debit": "Einzug", - "Transfer": "Überweisung", - "Credit Card": "Kreditkarte", - "Other": "Sonstiges", - } - if (data.terms) itemInfo.paymentType = mapPayment[data.terms] ?? data.terms - - // 3.2 Positionszeilen konvertieren - if (data.invoice_items?.length > 0) { - itemInfo.accounts = data.invoice_items - .filter(item => item.description || item.total !== null || item.total_without_tax !== null) - .map(item => { - const total = typeof item.total === "number" ? item.total : null - const totalWithoutTax = typeof item.total_without_tax === "number" ? item.total_without_tax : null - const amountTax = total !== null && totalWithoutTax !== null - ? Number((total - totalWithoutTax).toFixed(2)) - : null - - return { - account: item.account_id, - description: item.description, - amountNet: totalWithoutTax, - amountTax, - taxType: item.tax_rate !== null ? String(item.tax_rate) : null, - amountGross: total, - costCentre: null, - quantity: item.quantity, - } - }) - } - - // 3.3 Beschreibung generieren - let description = "" - if (data.delivery_note_number) description += `Lieferschein: ${data.delivery_note_number}\n` - if (data.reference) description += `Referenz: ${data.reference}\n` - if (data.invoice_items) { - for (const item of data.invoice_items) { - const line = formatInvoiceItemDescription(item) - if (line) description += `${line}\n` - } - } - itemInfo.description = description.trim() - // --------------------------------------------------------- // 4️⃣ IncomingInvoice erstellen // --------------------------------------------------------- diff --git a/backend/src/modules/einvoice/detectElectronicInvoice.ts b/backend/src/modules/einvoice/detectElectronicInvoice.ts index 5b174fe..689182e 100644 --- a/backend/src/modules/einvoice/detectElectronicInvoice.ts +++ b/backend/src/modules/einvoice/detectElectronicInvoice.ts @@ -39,14 +39,14 @@ const detectXmlSyntax = (xml: Buffer): ElectronicInvoiceSyntax | null => { } if ( - /)/i.test(sample) + /<(?:[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 ( - /)/i.test(sample) + /<(?:[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" diff --git a/backend/src/modules/einvoice/mapElectronicInvoice.ts b/backend/src/modules/einvoice/mapElectronicInvoice.ts new file mode 100644 index 0000000..e8a2531 --- /dev/null +++ b/backend/src/modules/einvoice/mapElectronicInvoice.ts @@ -0,0 +1,93 @@ +import { and, eq } from "drizzle-orm" +import type { FastifyInstance } from "fastify" +import { vendors } from "../../../db/schema" +import type { ParsedElectronicInvoice } from "./types" + +const normalizeIdentifier = (value: unknown) => String(value || "").replace(/[^A-Za-z0-9]/g, "").toUpperCase() +const normalizeName = (value: unknown) => String(value || "").normalize("NFKD").replace(/[^A-Za-z0-9]/g, "").toUpperCase() + +const paymentTypeByCode: Record = { + "10": "Bar", + "30": "Überweisung", + "48": "Kreditkarte", + "49": "Lastschrift", + "57": "Sonstiges", + "58": "Überweisung", + "59": "Lastschrift", +} + +export const findElectronicInvoiceVendor = async ( + server: FastifyInstance, + tenantId: number, + invoice: ParsedElectronicInvoice, +) => { + const candidates = await server.db.select().from(vendors).where(and( + eq(vendors.tenant, tenantId), + eq(vendors.archived, false), + )) + + const vatId = normalizeIdentifier(invoice.seller.vatId) + if (vatId) { + const vatMatches = candidates.filter((vendor: any) => normalizeIdentifier(vendor.infoData?.ustid) === vatId) + if (vatMatches.length === 1) return vatMatches[0] + } + + const sellerName = normalizeName(invoice.seller.name) + if (sellerName) { + const nameMatches = candidates.filter((vendor) => normalizeName(vendor.name) === sellerName) + if (nameMatches.length === 1) return nameMatches[0] + } + + return null +} + +export const mapElectronicInvoiceToIncomingInvoice = ( + invoice: ParsedElectronicInvoice, + tenantId: number, + vendorId: number | null, + container: "pdf" | "xml", + attachmentName: string | null, +) => { + const warnings = [...invoice.validation.warnings] + if (!vendorId) warnings.push("Rechnungssteller konnte keinem Lieferanten eindeutig zugeordnet werden.") + + const state = invoice.validation.errors.length > 0 || !vendorId ? "Prüfung erforderlich" : "Vorbereitet" + const descriptionParts = [ + invoice.buyerReference ? `Käuferreferenz: ${invoice.buyerReference}` : null, + ...invoice.items.map((item) => item.description), + ].filter(Boolean) + + return { + tenant: tenantId, + state, + vendor: vendorId, + reference: invoice.invoiceNumber, + date: invoice.invoiceDate, + dueDate: invoice.dueDate, + paymentType: paymentTypeByCode[invoice.paymentMeansCode || ""] || "Sonstiges", + description: descriptionParts.join("\n"), + accounts: invoice.items.map((item) => ({ + account: null, + description: item.description, + amountNet: item.netAmount, + amountTax: item.taxAmount, + taxType: item.taxCategory === "AE" ? "13B" : item.taxRate === 0 ? "null" : String(item.taxRate), + amountGross: item.grossAmount, + costCentre: null, + quantity: item.quantity, + unitCode: item.unitCode, + })), + preparationSource: "e-invoice", + eInvoiceSyntax: invoice.syntax, + eInvoiceProfile: invoice.profileId, + eInvoiceValidation: { + errors: invoice.validation.errors, + warnings, + totals: invoice.totals, + seller: invoice.seller, + invoiceType: invoice.invoiceType, + container, + attachmentName, + }, + } +} diff --git a/backend/src/modules/einvoice/parseElectronicInvoice.ts b/backend/src/modules/einvoice/parseElectronicInvoice.ts new file mode 100644 index 0000000..cea5477 --- /dev/null +++ b/backend/src/modules/einvoice/parseElectronicInvoice.ts @@ -0,0 +1,223 @@ +import { XMLParser } from "fast-xml-parser" +import type { ElectronicInvoiceSyntax } from "./detectElectronicInvoice" +import type { ElectronicInvoiceItem, ParsedElectronicInvoice } from "./types" + +const parser = new XMLParser({ + ignoreAttributes: false, + removeNSPrefix: true, + parseTagValue: false, + trimValues: true, +}) + +const array = (value: T | T[] | null | undefined): T[] => value == null ? [] : Array.isArray(value) ? value : [value] + +const text = (value: any): string | null => { + if (value == null) return null + if (typeof value === "string" || typeof value === "number") return String(value).trim() || null + if (typeof value === "object" && value["#text"] != null) return text(value["#text"]) + return null +} + +const number = (value: any): number | null => { + const raw = text(value) + if (raw == null) return null + + const parsed = Number(raw) + return Number.isFinite(parsed) ? parsed : null +} + +const roundMoney = (value: number) => Number(value.toFixed(2)) + +const date = (value: any): string | null => { + const raw = text(value?.DateTimeString ?? value) + if (!raw) return null + + if (/^\d{8}$/.test(raw)) { + return `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}` + } + + const match = raw.match(/^\d{4}-\d{2}-\d{2}/) + return match?.[0] || null +} + +const findTaxRegistration = (party: any, schemeId: string) => { + return text(array(party?.SpecifiedTaxRegistration).find((entry: any) => { + return String(entry?.ID?.["@_schemeID"] || "").toUpperCase() === schemeId + })?.ID) +} + +const ciiDescription = (line: any) => { + const product = line?.SpecifiedTradeProduct || {} + return [text(product.Name), text(product.Description)].filter(Boolean).join(" - ") || "Position" +} + +const parseCii = (root: any): Omit => { + const transaction = root?.SupplyChainTradeTransaction || {} + const agreement = transaction?.ApplicableHeaderTradeAgreement || {} + const settlement = transaction?.ApplicableHeaderTradeSettlement || {} + const monetary = settlement?.SpecifiedTradeSettlementHeaderMonetarySummation || {} + const seller = agreement?.SellerTradeParty || {} + const typeCode = text(root?.ExchangedDocument?.TypeCode) + const sign = typeCode === "381" ? -1 : 1 + + const items: ElectronicInvoiceItem[] = array(transaction?.IncludedSupplyChainTradeLineItem).map((line: any) => { + const delivery = line?.SpecifiedLineTradeDelivery || {} + const lineSettlement = line?.SpecifiedLineTradeSettlement || {} + const tradeTax = array(lineSettlement?.ApplicableTradeTax)[0] || {} + const quantity = number(delivery?.BilledQuantity) + const net = number(lineSettlement?.SpecifiedTradeSettlementLineMonetarySummation?.LineTotalAmount) || 0 + const taxRate = number(tradeTax?.RateApplicablePercent) || 0 + const signedNet = roundMoney(sign * net) + const taxAmount = roundMoney(signedNet * taxRate / 100) + + return { + description: ciiDescription(line), + quantity, + unitCode: text(delivery?.BilledQuantity?.["@_unitCode"]), + netAmount: signedNet, + taxRate, + taxCategory: text(tradeTax?.CategoryCode), + taxAmount, + grossAmount: roundMoney(signedNet + taxAmount), + } + }) + + const paymentMeans = array(settlement?.SpecifiedTradeSettlementPaymentMeans)[0] || {} + const paymentTerms = array(settlement?.SpecifiedTradePaymentTerms)[0] || {} + const guideline = root?.ExchangedDocumentContext?.GuidelineSpecifiedDocumentContextParameter?.ID + + return { + syntax: "cii", + profileId: text(guideline), + invoiceNumber: text(root?.ExchangedDocument?.ID), + invoiceDate: date(root?.ExchangedDocument?.IssueDateTime), + dueDate: date(paymentTerms?.DueDateDateTime), + currency: text(settlement?.InvoiceCurrencyCode), + invoiceType: typeCode === "381" ? "credit-note" : "invoice", + seller: { + name: text(seller?.Name), + vatId: findTaxRegistration(seller, "VA"), + taxNumber: findTaxRegistration(seller, "FC"), + iban: text(paymentMeans?.PayeePartyCreditorFinancialAccount?.IBANID), + bic: text(paymentMeans?.PayeeSpecifiedCreditorFinancialInstitution?.BICID), + }, + buyerReference: text(agreement?.BuyerReference), + paymentMeansCode: text(paymentMeans?.TypeCode), + items, + totals: { + lineNet: number(monetary?.LineTotalAmount) != null ? roundMoney(sign * number(monetary.LineTotalAmount)!) : null, + tax: number(monetary?.TaxTotalAmount) != null ? roundMoney(sign * number(monetary.TaxTotalAmount)!) : null, + gross: number(monetary?.GrandTotalAmount) != null ? roundMoney(sign * number(monetary.GrandTotalAmount)!) : null, + payable: number(monetary?.DuePayableAmount) != null ? roundMoney(sign * number(monetary.DuePayableAmount)!) : null, + }, + } +} + +const ublPartyName = (party: any) => text(party?.PartyLegalEntity?.RegistrationName) || text(party?.PartyName?.Name) + +const parseUbl = (root: any, syntax: "ubl-invoice" | "ubl-credit-note"): Omit => { + const isCreditNote = syntax === "ubl-credit-note" + const sign = isCreditNote ? -1 : 1 + const supplier = root?.AccountingSupplierParty?.Party || {} + const monetary = root?.LegalMonetaryTotal || {} + const paymentMeans = array(root?.PaymentMeans)[0] || {} + const sourceLines = array(isCreditNote ? root?.CreditNoteLine : root?.InvoiceLine) + + const items: ElectronicInvoiceItem[] = sourceLines.map((line: any) => { + const quantityValue = isCreditNote ? line?.CreditedQuantity : line?.InvoicedQuantity + const taxCategory = line?.Item?.ClassifiedTaxCategory || {} + const net = number(line?.LineExtensionAmount) || 0 + const taxRate = number(taxCategory?.Percent) || 0 + const signedNet = roundMoney(sign * net) + const taxAmount = roundMoney(signedNet * taxRate / 100) + + return { + description: [text(line?.Item?.Name), text(line?.Item?.Description)].filter(Boolean).join(" - ") || "Position", + quantity: number(quantityValue), + unitCode: text(quantityValue?.["@_unitCode"]), + netAmount: signedNet, + taxRate, + taxCategory: text(taxCategory?.ID), + taxAmount, + grossAmount: roundMoney(signedNet + taxAmount), + } + }) + + const taxSchemeIds = array(supplier?.PartyTaxScheme) + const vatId = text(taxSchemeIds.find((entry: any) => text(entry?.TaxScheme?.ID)?.toUpperCase() === "VAT")?.CompanyID) + + return { + syntax, + profileId: text(root?.CustomizationID) || text(root?.ProfileID), + invoiceNumber: text(root?.ID), + invoiceDate: date(root?.IssueDate), + dueDate: date(root?.DueDate) || date(paymentMeans?.PaymentDueDate), + currency: text(root?.DocumentCurrencyCode), + invoiceType: isCreditNote ? "credit-note" : "invoice", + seller: { + name: ublPartyName(supplier), + vatId, + taxNumber: text(supplier?.PartyTaxScheme?.CompanyID), + iban: text(paymentMeans?.PayeeFinancialAccount?.ID), + bic: text(paymentMeans?.PayeeFinancialAccount?.FinancialInstitutionBranch?.ID), + }, + buyerReference: text(root?.BuyerReference), + paymentMeansCode: text(paymentMeans?.PaymentMeansCode), + items, + totals: { + lineNet: number(monetary?.LineExtensionAmount) != null ? roundMoney(sign * number(monetary.LineExtensionAmount)!) : null, + tax: number(root?.TaxTotal?.TaxAmount) != null ? roundMoney(sign * number(root.TaxTotal.TaxAmount)!) : null, + gross: number(monetary?.TaxInclusiveAmount) != null ? roundMoney(sign * number(monetary.TaxInclusiveAmount)!) : null, + payable: number(monetary?.PayableAmount) != null ? roundMoney(sign * number(monetary.PayableAmount)!) : null, + }, + } +} + +const validate = (invoice: Omit) => { + const errors: string[] = [] + const warnings: string[] = [] + const tolerance = 0.02 + + if (!invoice.invoiceNumber) errors.push("Rechnungsnummer fehlt.") + if (!invoice.invoiceDate) errors.push("Rechnungsdatum fehlt oder ist ungültig.") + if (!invoice.currency) errors.push("Rechnungswährung fehlt.") + if (!invoice.seller.name) errors.push("Name des Rechnungsstellers fehlt.") + if (invoice.items.length === 0) errors.push("Die E-Rechnung enthält keine Rechnungspositionen.") + + const calculatedLineNet = roundMoney(invoice.items.reduce((sum, item) => sum + item.netAmount, 0)) + const calculatedTax = roundMoney(invoice.items.reduce((sum, item) => sum + item.taxAmount, 0)) + + if (invoice.totals.lineNet != null && Math.abs(invoice.totals.lineNet - calculatedLineNet) > tolerance) { + errors.push(`Positionssumme ${calculatedLineNet.toFixed(2)} stimmt nicht mit der Nettosumme ${invoice.totals.lineNet.toFixed(2)} überein.`) + } + if (invoice.totals.tax != null && Math.abs(invoice.totals.tax - calculatedTax) > tolerance) { + warnings.push(`Berechnete Steuer ${calculatedTax.toFixed(2)} weicht von der Steuer-Gesamtsumme ${invoice.totals.tax.toFixed(2)} ab.`) + } + if (invoice.totals.gross != null && invoice.totals.lineNet != null && invoice.totals.tax != null) { + const expectedGross = roundMoney(invoice.totals.lineNet + invoice.totals.tax) + if (Math.abs(expectedGross - invoice.totals.gross) > tolerance) { + errors.push(`Bruttosumme ${invoice.totals.gross.toFixed(2)} stimmt nicht mit Netto plus Steuer ${expectedGross.toFixed(2)} überein.`) + } + } + if (!invoice.seller.vatId) warnings.push("Keine USt-ID des Rechnungsstellers enthalten.") + if (!invoice.dueDate) warnings.push("Kein Fälligkeitsdatum enthalten.") + for (const taxRate of new Set(invoice.items.map((item) => item.taxRate))) { + if (![0, 7, 19].includes(taxRate)) warnings.push(`Steuersatz ${taxRate}% ist in FEDEO noch keinem Standard-Steuerschlüssel zugeordnet.`) + } + + return { errors, warnings } +} + +export const parseElectronicInvoice = (xml: Buffer, syntax: ElectronicInvoiceSyntax): ParsedElectronicInvoice => { + const parsed = parser.parse(xml.toString("utf8")) + const root = syntax === "cii" + ? parsed.CrossIndustryInvoice + : syntax === "ubl-credit-note" + ? parsed.CreditNote + : parsed.Invoice + + if (!root) throw new Error(`XML-Wurzelelement für ${syntax} wurde nicht gefunden.`) + + const invoice = syntax === "cii" ? parseCii(root) : parseUbl(root, syntax) + return { ...invoice, validation: validate(invoice) } +} diff --git a/backend/src/modules/einvoice/types.ts b/backend/src/modules/einvoice/types.ts new file mode 100644 index 0000000..607a44e --- /dev/null +++ b/backend/src/modules/einvoice/types.ts @@ -0,0 +1,42 @@ +import type { ElectronicInvoiceSyntax } from "./detectElectronicInvoice" + +export type ElectronicInvoiceItem = { + description: string + quantity: number | null + unitCode: string | null + netAmount: number + taxRate: number + taxCategory: string | null + taxAmount: number + grossAmount: number +} + +export type ParsedElectronicInvoice = { + syntax: ElectronicInvoiceSyntax + profileId: string | null + invoiceNumber: string | null + invoiceDate: string | null + dueDate: string | null + currency: string | null + invoiceType: "invoice" | "credit-note" + seller: { + name: string | null + vatId: string | null + taxNumber: string | null + iban: string | null + bic: string | null + } + buyerReference: string | null + paymentMeansCode: string | null + items: ElectronicInvoiceItem[] + totals: { + lineNet: number | null + tax: number | null + gross: number | null + payable: number | null + } + validation: { + errors: string[] + warnings: string[] + } +} diff --git a/backend/tests/einvoice.test.ts b/backend/tests/einvoice.test.ts new file mode 100644 index 0000000..e58f9e9 --- /dev/null +++ b/backend/tests/einvoice.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { PDFDocument } from "pdf-lib" +import { detectElectronicInvoice } from "../src/modules/einvoice/detectElectronicInvoice" +import { parseElectronicInvoice } from "../src/modules/einvoice/parseElectronicInvoice" +import { mapElectronicInvoiceToIncomingInvoice } from "../src/modules/einvoice/mapElectronicInvoice" + +const ciiInvoice = Buffer.from(` + + urn:factur-x.eu:1p0:en16931 + RE-2026-10038020260721 + + + Wartung + 2 + S19100.00 + + Leitweg-1Beispiel GmbHDE123456789 + EUR5820260820100.0019.00119.00119.00 + +`) + +const ublInvoice = Buffer.from(` + + urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0XR-422026-07-212026-08-20EUR + UBL LieferantDE987654321VAT + 30 + 7.00 + 100.00107.00107.00 + 11100.00MaterialS7 +`) + +test("erkennt und liest eine eingebettete Factur-X-Rechnung", async () => { + const document = await PDFDocument.create() + document.addPage() + await document.attach(ciiInvoice, "factur-x.xml", { mimeType: "application/xml", afRelationship: "Data" }) + + const detection = await detectElectronicInvoice(Buffer.from(await document.save()), { name: "rechnung.pdf" }) + assert.equal(detection?.container, "pdf") + assert.equal(detection?.syntax, "cii") + + const parsed = parseElectronicInvoice(detection!.xml, detection!.syntax) + assert.equal(parsed.invoiceNumber, "RE-2026-100") + assert.equal(parsed.seller.vatId, "DE123456789") + assert.equal(parsed.items[0].grossAmount, 119) + assert.deepEqual(parsed.validation.errors, []) +}) + +test("liest eine eigenständige UBL-XRechnung und mappt sie nach FEDEO", async () => { + const detection = await detectElectronicInvoice(ublInvoice, { name: "xrechnung.xml" }) + assert.equal(detection?.syntax, "ubl-invoice") + + const parsed = parseElectronicInvoice(detection!.xml, detection!.syntax) + const mapped = mapElectronicInvoiceToIncomingInvoice(parsed, 5, 12, "xml", "xrechnung.xml") + + assert.equal(mapped.reference, "XR-42") + assert.equal(mapped.vendor, 12) + assert.equal(mapped.paymentType, "Überweisung") + assert.equal(mapped.state, "Vorbereitet") + assert.equal(mapped.accounts[0].amountTax, 7) +}) + +test("markiert Summenabweichungen als Prüfungsfehler", () => { + const invalid = Buffer.from(ciiInvoice.toString("utf8").replace("100.00", "90.00")) + const parsed = parseElectronicInvoice(invalid, "cii") + + assert.match(parsed.validation.errors.join(" "), /Positionssumme/) +}) diff --git a/frontend/pages/incomingInvoices/[mode]/[id].vue b/frontend/pages/incomingInvoices/[mode]/[id].vue index d30f8ef..fdc8ff3 100644 --- a/frontend/pages/incomingInvoices/[mode]/[id].vue +++ b/frontend/pages/incomingInvoices/[mode]/[id].vue @@ -149,6 +149,18 @@ const bankBookingDateLabel = computed(() => { return bankBookingDates.value.map(formatDate).join(", ") }) const vendorName = computed(() => vendors.value.find((vendor) => vendor.id === itemInfo.value.vendor)?.name || "-") +const eInvoiceValidation = computed(() => itemInfo.value.eInvoiceValidation || null) +const eInvoiceSourceLabel = computed(() => { + if (itemInfo.value.preparationSource !== "e-invoice") return null + if (itemInfo.value.eInvoiceSyntax === "cii") { + return String(itemInfo.value.eInvoiceProfile || "").toLowerCase().includes("xrechnung") + ? "XRechnung (CII)" + : "ZUGFeRD / Factur-X (CII)" + } + if (itemInfo.value.eInvoiceSyntax === "ubl-credit-note") return "XRechnung / UBL-Gutschrift" + return "XRechnung / UBL" +}) +const eInvoiceProfileLabel = computed(() => itemInfo.value.eInvoiceProfile || "Profil nicht angegeben") const getAccountLabel = (item) => { const account = accounts.value.find((entry) => entry.id === item.account) @@ -356,6 +368,35 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE Dokument andocken + + + + { {{row.original.state}} {{row.original.state}} {{row.original.state}} + {{row.original.state}} + {{row.original.state}} + +