import type { FastifyInstance, FastifyRequest } from "fastify"; import { z } from "zod"; import { env } from "../config/env.js"; import { requireInstance } from "../lib/auth.js"; import { recordServiceUsage, requireCentralService } from "../services/central-services.js"; const aiSchema = z.object({ model: z.enum(["gpt-4o", "gpt-4o-mini"]), messages: z.array(z.object({ role: z.enum(["system", "user", "assistant"]), content: z.union([z.string(), z.array(z.unknown())]), }).passthrough()).min(1).max(100), response_format: z.record(z.string(), z.unknown()).optional(), temperature: z.number().min(0).max(2).optional(), max_tokens: z.number().int().positive().max(16_384).optional(), }).passthrough(); const requisitionSchema = z.object({ institutionId: z.string().min(1).max(200), redirect: z.string().url(), userLanguage: z.string().length(2).default("de"), }); let bankingToken: { access: string; expiresAt: number } | null = null; async function providerJson(url: string, init: RequestInit) { const response = await fetch(url, init); const text = await response.text(); let data: any = null; try { data = text ? JSON.parse(text) : null; } catch { data = { message: text }; } if (!response.ok) { const error = new Error(data?.detail || data?.message || `Provider antwortete mit HTTP ${response.status}`) as Error & { code?: string; status?: number }; error.code = data?.code || `provider_http_${response.status}`; error.status = response.status; throw error; } return { data, response }; } async function getBankingToken() { if (bankingToken && bankingToken.expiresAt > Date.now() + 60_000) return bankingToken.access; if (!env.GOCARDLESS_SECRET_ID || !env.GOCARDLESS_SECRET_KEY) throw Object.assign(new Error("GoCardless ist zentral nicht konfiguriert."), { code: "banking_provider_not_configured" }); const { data } = await providerJson(`${env.GOCARDLESS_BASE_URL.replace(/\/$/, "")}/token/new/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ secret_id: env.GOCARDLESS_SECRET_ID, secret_key: env.GOCARDLESS_SECRET_KEY }), }); bankingToken = { access: data.access, expiresAt: Date.now() + Number(data.access_expires || 86_400) * 1000 }; return bankingToken.access; } async function bankingRequest(path: string, init: RequestInit = {}) { const access = await getBankingToken(); return providerJson(`${env.GOCARDLESS_BASE_URL.replace(/\/$/, "")}${path}`, { ...init, headers: { Accept: "application/json", Authorization: `Bearer ${access}`, ...(init.headers || {}) }, }); } function safeProviderError(error: any) { return { error: error?.code || "provider_error", message: error?.message || "Zentraler Provider-Aufruf fehlgeschlagen." }; } async function withBankingUsage(request: FastifyRequest, reply: any, operation: string, handler: () => Promise<{ data: any; response: Response }>) { const entitlement = await requireCentralService(request, reply, "banking"); if (!entitlement) return; try { const result = await handler(); const requestId = await recordServiceUsage({ request, service: "banking", operation, status: "succeeded", unitPriceMicros: entitlement.unitPriceMicros, provider: "gocardless", providerRequestId: result.response.headers.get("x-request-id") }); reply.header("X-Fedeo-Request-Id", requestId); return result.data; } catch (error: any) { await recordServiceUsage({ request, service: "banking", operation, status: "failed", units: 0, unitPriceMicros: entitlement.unitPriceMicros, provider: "gocardless", errorCode: error?.code }); return reply.code(error?.status && error.status < 500 ? error.status : 502).send(safeProviderError(error)); } } export async function serviceRoutes(app: FastifyInstance): Promise { app.addHook("preHandler", requireInstance); app.post("/v1/services/ai/chat-completions", async (request, reply) => { const entitlement = await requireCentralService(request, reply, "ai"); if (!entitlement) return; if (!env.OPENAI_API_KEY) return reply.code(503).send({ error: "ai_provider_not_configured", message: "OpenAI ist zentral nicht konfiguriert." }); const body = aiSchema.parse(request.body); try { const { data, response } = await providerJson(`${env.OPENAI_BASE_URL.replace(/\/$/, "")}/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${env.OPENAI_API_KEY}` }, body: JSON.stringify(body), }); const units = Number(data?.usage?.total_tokens || 0); const requestId = await recordServiceUsage({ request, service: "ai", operation: "chat_completions", units, status: "succeeded", unitPriceMicros: entitlement.unitPriceMicros, provider: "openai", providerRequestId: response.headers.get("x-request-id"), metadata: { model: body.model, promptTokens: data?.usage?.prompt_tokens || 0, completionTokens: data?.usage?.completion_tokens || 0 } }); reply.header("X-Fedeo-Request-Id", requestId); return data; } catch (error: any) { await recordServiceUsage({ request, service: "ai", operation: "chat_completions", units: 0, status: "failed", unitPriceMicros: entitlement.unitPriceMicros, provider: "openai", errorCode: error?.code, metadata: { model: body.model } }); return reply.code(error?.status && error.status < 500 ? error.status : 502).send(safeProviderError(error)); } }); app.get("/v1/services/banking/institutions", async (request, reply) => { const query = z.object({ country: z.string().length(2).default("DE") }).parse(request.query); return withBankingUsage(request, reply, "institutions", () => bankingRequest(`/institutions/?country=${encodeURIComponent(query.country.toLowerCase())}`)); }); app.post("/v1/services/banking/requisitions", async (request, reply) => { const body = requisitionSchema.parse(request.body); if (new URL(body.redirect).origin !== new URL(request.pushInstance!.baseUrl).origin) { return reply.code(400).send({ error: "redirect_origin_invalid", message: "Banking-Redirect muss zur registrierten Instanz gehören." }); } return withBankingUsage(request, reply, "create_requisition", () => bankingRequest("/requisitions/", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ redirect: body.redirect, institution_id: body.institutionId, user_language: body.userLanguage }) })); }); app.get("/v1/services/banking/requisitions/:id", async (request, reply) => { const { id } = z.object({ id: z.string().min(1).max(200) }).parse(request.params); return withBankingUsage(request, reply, "get_requisition", () => bankingRequest(`/requisitions/${encodeURIComponent(id)}/`)); }); app.get("/v1/services/banking/accounts/:id", async (request, reply) => { const { id } = z.object({ id: z.string().min(1).max(200) }).parse(request.params); return withBankingUsage(request, reply, "get_account", () => bankingRequest(`/accounts/${encodeURIComponent(id)}/`)); }); for (const resource of ["balances", "transactions"] as const) { app.get(`/v1/services/banking/accounts/:id/${resource}`, async (request, reply) => { const { id } = z.object({ id: z.string().min(1).max(200) }).parse(request.params); return withBankingUsage(request, reply, `get_${resource}`, () => bankingRequest(`/accounts/${encodeURIComponent(id)}/${resource}/`)); }); } }