feat: zentrale selfhost-dienste bereitstellen
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
// /services/bankStatementService.ts
|
||||
import axios from "axios"
|
||||
import { centralServicesClient } from "../push-server.client"
|
||||
import dayjs from "dayjs"
|
||||
import utc from "dayjs/plugin/utc.js"
|
||||
import {secrets} from "../../utils/secrets"
|
||||
@@ -57,11 +58,13 @@ const normalizeDate = (val: any) => {
|
||||
export function bankStatementService(server: FastifyInstance) {
|
||||
|
||||
let accessToken: string | null = null
|
||||
const useCentralBanking = Boolean(secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured())
|
||||
|
||||
// -----------------------------------------------
|
||||
// ✔ TOKEN LADEN
|
||||
// -----------------------------------------------
|
||||
const getToken = async () => {
|
||||
if (useCentralBanking) return
|
||||
console.log("Fetching GoCardless token…")
|
||||
|
||||
const response = await axios.post(
|
||||
@@ -80,6 +83,7 @@ export function bankStatementService(server: FastifyInstance) {
|
||||
// -----------------------------------------------
|
||||
const getBalanceData = async (accountId: string): Promise<any | false> => {
|
||||
try {
|
||||
if (useCentralBanking) return await centralServicesClient.getBankingBalances(accountId)
|
||||
const {data} = await axios.get(
|
||||
`${secrets.GOCARDLESS_BASE_URL}/accounts/${accountId}/balances`,
|
||||
{
|
||||
@@ -114,6 +118,10 @@ export function bankStatementService(server: FastifyInstance) {
|
||||
// -----------------------------------------------
|
||||
const getTransactionData = async (accountId: string) => {
|
||||
try {
|
||||
if (useCentralBanking) {
|
||||
const data = await centralServicesClient.getBankingTransactions(accountId)
|
||||
return data.transactions.booked
|
||||
}
|
||||
const {data} = await axios.get<TransactionsResponse>(
|
||||
`${secrets.GOCARDLESS_BASE_URL}/accounts/${accountId}/transactions`,
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash, createHmac } from "node:crypto"
|
||||
import { createHash, createHmac, randomUUID } from "node:crypto"
|
||||
|
||||
import { secrets } from "../utils/secrets"
|
||||
|
||||
@@ -54,13 +54,14 @@ function signature(method: string, path: string, timestamp: string, body: string
|
||||
return createHmac("sha256", secrets.PUSH_SERVER_SECRET).update(canonical).digest("hex")
|
||||
}
|
||||
|
||||
async function requestPushServer<T>(method: "POST" | "DELETE", path: string, payload?: unknown): Promise<T> {
|
||||
async function requestPushServer<T>(method: "GET" | "POST" | "DELETE", path: string, payload?: unknown): Promise<T> {
|
||||
if (!configured()) {
|
||||
throw new Error("Zentraler Push-Server ist nicht konfiguriert")
|
||||
}
|
||||
|
||||
const body = payload === undefined ? "" : JSON.stringify(payload)
|
||||
const timestamp = new Date().toISOString()
|
||||
const signedPath = path.split("?")[0]
|
||||
const response = await fetch(`${normalizeBaseUrl()}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
@@ -68,7 +69,8 @@ async function requestPushServer<T>(method: "POST" | "DELETE", path: string, pay
|
||||
"Content-Type": "application/json",
|
||||
"X-Fedeo-Instance-Id": secrets.PUSH_SERVER_INSTANCE_ID,
|
||||
"X-Fedeo-Timestamp": timestamp,
|
||||
"X-Fedeo-Signature": signature(method, path, timestamp, body),
|
||||
"X-Fedeo-Signature": signature(method, signedPath, timestamp, body),
|
||||
"X-Fedeo-Request-Id": randomUUID(),
|
||||
},
|
||||
body: body || undefined,
|
||||
})
|
||||
@@ -99,3 +101,35 @@ export const pushServerClient = {
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const centralServicesClient = {
|
||||
configured,
|
||||
|
||||
aiChatCompletions(payload: Record<string, unknown>) {
|
||||
return requestPushServer<any>("POST", "/v1/services/ai/chat-completions", payload)
|
||||
},
|
||||
|
||||
bankingInstitutions(country = "DE") {
|
||||
return requestPushServer<any[]>("GET", `/v1/services/banking/institutions?country=${encodeURIComponent(country)}`)
|
||||
},
|
||||
|
||||
createBankingRequisition(input: { institutionId: string; redirect: string; userLanguage?: string }) {
|
||||
return requestPushServer<any>("POST", "/v1/services/banking/requisitions", input)
|
||||
},
|
||||
|
||||
getBankingRequisition(id: string) {
|
||||
return requestPushServer<any>("GET", `/v1/services/banking/requisitions/${encodeURIComponent(id)}`)
|
||||
},
|
||||
|
||||
getBankingAccount(id: string) {
|
||||
return requestPushServer<any>("GET", `/v1/services/banking/accounts/${encodeURIComponent(id)}`)
|
||||
},
|
||||
|
||||
getBankingBalances(id: string) {
|
||||
return requestPushServer<any>("GET", `/v1/services/banking/accounts/${encodeURIComponent(id)}/balances`)
|
||||
},
|
||||
|
||||
getBankingTransactions(id: string) {
|
||||
return requestPushServer<any>("GET", `/v1/services/banking/accounts/${encodeURIComponent(id)}/transactions`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { insertHistoryItem } from "../utils/history"
|
||||
import { decrypt, encrypt } from "../utils/crypt"
|
||||
import { DE_BANK_CODE_TO_NAME } from "../utils/deBankCodes"
|
||||
import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
|
||||
import { centralServicesClient } from "../modules/push-server.client"
|
||||
|
||||
import {
|
||||
bankrequisitions,
|
||||
@@ -902,6 +903,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
const goCardLessBaseUrl = secrets.GOCARDLESS_BASE_URL
|
||||
const goCardLessSecretId = secrets.GOCARDLESS_SECRET_ID
|
||||
const goCardLessSecretKey = secrets.GOCARDLESS_SECRET_KEY
|
||||
const useCentralBanking = Boolean(secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured())
|
||||
|
||||
let tokenData: any = null
|
||||
|
||||
@@ -918,6 +920,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
}
|
||||
|
||||
const checkToken = async () => {
|
||||
if (useCentralBanking) return
|
||||
if (!tokenData) return await getToken()
|
||||
|
||||
const expired = dayjs(tokenData.created_at)
|
||||
@@ -935,24 +938,23 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
// ------------------------------------------------------------------
|
||||
server.get("/banking/link/:institutionid", async (req, reply) => {
|
||||
try {
|
||||
await checkToken()
|
||||
|
||||
const { institutionid } = req.params as { institutionid: string }
|
||||
const tenantId = req.user?.tenant_id
|
||||
|
||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const { data } = await axios.post(
|
||||
`${goCardLessBaseUrl}/requisitions/`,
|
||||
{
|
||||
redirect: "https://app.fedeo.de/settings/banking",
|
||||
institution_id: institutionid,
|
||||
user_language: "de",
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenData.access}` },
|
||||
}
|
||||
)
|
||||
const redirect = new URL("/settings/banking", secrets.API_BASE_URL).toString()
|
||||
let data: any
|
||||
if (useCentralBanking) {
|
||||
data = await centralServicesClient.createBankingRequisition({ institutionId: institutionid, redirect, userLanguage: "de" })
|
||||
} else {
|
||||
await checkToken()
|
||||
;({ data } = await axios.post(
|
||||
`${goCardLessBaseUrl}/requisitions/`,
|
||||
{ redirect, institution_id: institutionid, user_language: "de" },
|
||||
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
|
||||
))
|
||||
}
|
||||
|
||||
// DB: Requisition speichern
|
||||
await server.db.insert(bankrequisitions).values({
|
||||
@@ -977,12 +979,16 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
const { bic } = req.params as { bic: string }
|
||||
if (!bic) return reply.code(400).send("BIC missing")
|
||||
|
||||
await checkToken()
|
||||
|
||||
const { data } = await axios.get(
|
||||
`${goCardLessBaseUrl}/institutions/?country=de`,
|
||||
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
|
||||
)
|
||||
let data: any[]
|
||||
if (useCentralBanking) {
|
||||
data = await centralServicesClient.bankingInstitutions("DE")
|
||||
} else {
|
||||
await checkToken()
|
||||
;({ data } = await axios.get(
|
||||
`${goCardLessBaseUrl}/institutions/?country=de`,
|
||||
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
|
||||
))
|
||||
}
|
||||
|
||||
const bank = data.find((i: any) => i.bic.toLowerCase() === bic.toLowerCase())
|
||||
|
||||
@@ -1004,21 +1010,23 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
const { reqId } = req.params as { reqId: string }
|
||||
if (!reqId) return reply.code(400).send("Requisition ID missing")
|
||||
|
||||
await checkToken()
|
||||
|
||||
const { data } = await axios.get(
|
||||
`${goCardLessBaseUrl}/requisitions/${reqId}`,
|
||||
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
|
||||
)
|
||||
let data: any
|
||||
if (useCentralBanking) {
|
||||
data = await centralServicesClient.getBankingRequisition(reqId)
|
||||
} else {
|
||||
await checkToken()
|
||||
;({ data } = await axios.get(
|
||||
`${goCardLessBaseUrl}/requisitions/${reqId}`,
|
||||
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
|
||||
))
|
||||
}
|
||||
|
||||
// Load account details
|
||||
if (data.accounts) {
|
||||
data.accounts = await Promise.all(
|
||||
data.accounts.map(async (accId: string) => {
|
||||
const { data: acc } = await axios.get(
|
||||
`${goCardLessBaseUrl}/accounts/${accId}`,
|
||||
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
|
||||
)
|
||||
if (useCentralBanking) return centralServicesClient.getBankingAccount(accId)
|
||||
const { data: acc } = await axios.get(`${goCardLessBaseUrl}/accounts/${accId}`, { headers: { Authorization: `Bearer ${tokenData.access}` } })
|
||||
return acc
|
||||
})
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FastifyInstance } from "fastify";
|
||||
import { storeExtractedTextForFile } from "./documentText";
|
||||
import { loadFileBuffer } from "./fileBuffer";
|
||||
import { secrets } from "./secrets";
|
||||
import { centralServicesClient } from "../modules/push-server.client";
|
||||
|
||||
// Drizzle schema
|
||||
import { vendors, accounts, tenants } from "../../db/schema";
|
||||
@@ -21,6 +22,7 @@ const nullableNumber = z.number().nullable();
|
||||
// INITIALIZE OPENAI
|
||||
// ---------------------------------------------------------
|
||||
export const initOpenAi = async () => {
|
||||
if (secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured()) return;
|
||||
openai = new OpenAI({
|
||||
apiKey: secrets.OPENAI_API_KEY,
|
||||
});
|
||||
@@ -84,8 +86,9 @@ export const getInvoiceDataFromGPT = async function (
|
||||
suppliedFileData?: Buffer,
|
||||
) {
|
||||
await initOpenAi();
|
||||
const useCentralAi = Boolean(secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured());
|
||||
|
||||
if (!openai) {
|
||||
if (!useCentralAi && !openai) {
|
||||
throw new Error("OpenAI not initialized. Call initOpenAi() first.");
|
||||
}
|
||||
|
||||
@@ -164,7 +167,7 @@ export const getInvoiceDataFromGPT = async function (
|
||||
|
||||
|
||||
|
||||
const completion = await openai.chat.completions.parse({
|
||||
const completionRequest: any = {
|
||||
model: "gpt-4o",
|
||||
store: true,
|
||||
response_format: zodResponseFormat(InstructionFormat as any, "instruction"),
|
||||
@@ -190,9 +193,13 @@ export const getInvoiceDataFromGPT = async function (
|
||||
"Keep invoice items in original order.\n",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const parsed = completion.choices[0].message.parsed;
|
||||
};
|
||||
const completion = useCentralAi
|
||||
? await centralServicesClient.aiChatCompletions(completionRequest)
|
||||
: await openai!.chat.completions.parse(completionRequest);
|
||||
const parsed = useCentralAi
|
||||
? InstructionFormat.parse(JSON.parse(completion.choices[0]?.message?.content || "{}"))
|
||||
: completion.choices[0].message.parsed;
|
||||
|
||||
console.log(`🧾 Extracted invoice data for file ${file.id}`);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { zodResponseFormat } from "openai/helpers/zod";
|
||||
import { and, desc, eq, gte } from "drizzle-orm";
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { createHash } from "node:crypto";
|
||||
import { centralServicesClient } from "../modules/push-server.client";
|
||||
|
||||
import {
|
||||
bankaccounts,
|
||||
@@ -382,9 +383,10 @@ const detectRecurringHeuristically = (statements: any[]): RecurringCandidate[] =
|
||||
};
|
||||
|
||||
const detectRecurringWithAi = async (server: FastifyInstance, statements: any[]): Promise<RecurringCandidate[]> => {
|
||||
if (!secrets.OPENAI_API_KEY || statements.length < 6) return [];
|
||||
const useCentralAi = Boolean(secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured());
|
||||
if ((!secrets.OPENAI_API_KEY && !useCentralAi) || statements.length < 6) return [];
|
||||
|
||||
const openai = new OpenAI({ apiKey: secrets.OPENAI_API_KEY });
|
||||
const openai = useCentralAi ? null : new OpenAI({ apiKey: secrets.OPENAI_API_KEY });
|
||||
const compactStatements = statements.slice(0, 220).map((statement) => ({
|
||||
date: statement.valueDate || statement.date,
|
||||
amount: roundMoney(Number(statement.amount || 0)),
|
||||
@@ -393,7 +395,7 @@ const detectRecurringWithAi = async (server: FastifyInstance, statements: any[])
|
||||
}));
|
||||
|
||||
try {
|
||||
const completion = await openai.chat.completions.parse({
|
||||
const completionRequest: any = {
|
||||
model: "gpt-4o",
|
||||
store: true,
|
||||
response_format: zodResponseFormat(AiRecurringFormat as any, "liquidity_recurring_transactions"),
|
||||
@@ -416,9 +418,15 @@ const detectRecurringWithAi = async (server: FastifyInstance, statements: any[])
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
const completion = useCentralAi
|
||||
? await centralServicesClient.aiChatCompletions(completionRequest)
|
||||
: await openai!.chat.completions.parse(completionRequest);
|
||||
const parsed = useCentralAi
|
||||
? AiRecurringFormat.parse(JSON.parse(completion.choices[0]?.message?.content || "{}"))
|
||||
: completion.choices[0].message.parsed;
|
||||
|
||||
return (completion.choices[0].message.parsed?.candidates || [])
|
||||
return (parsed?.candidates || [])
|
||||
.filter((candidate) => dayjs(candidate.nextDate).isValid())
|
||||
.filter((candidate) => Number(candidate.amount || 0) < 0)
|
||||
.map((candidate) => ({
|
||||
@@ -832,7 +840,7 @@ export const generateLiquidityForecast = async (
|
||||
},
|
||||
points,
|
||||
ai: {
|
||||
enabled: Boolean(secrets.OPENAI_API_KEY),
|
||||
enabled: Boolean(secrets.OPENAI_API_KEY || (secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured())),
|
||||
candidates: aiRecurring.length,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -53,6 +53,7 @@ export let secrets = {
|
||||
PUSH_SERVER_URL?: string
|
||||
PUSH_SERVER_INSTANCE_ID?: string
|
||||
PUSH_SERVER_SECRET?: string
|
||||
FEDEO_CENTRAL_SERVICES_ENABLED?: boolean
|
||||
}
|
||||
|
||||
const secretKeys = [
|
||||
@@ -100,10 +101,11 @@ const secretKeys = [
|
||||
"PUSH_SERVER_URL",
|
||||
"PUSH_SERVER_INSTANCE_ID",
|
||||
"PUSH_SERVER_SECRET",
|
||||
"FEDEO_CENTRAL_SERVICES_ENABLED",
|
||||
] as const
|
||||
|
||||
const numberKeys = new Set(["PORT", "MAILER_SMTP_PORT", "DOKUBOX_IMAP_PORT"])
|
||||
const booleanKeys = new Set(["DOKUBOX_IMAP_SECURE"])
|
||||
const booleanKeys = new Set(["DOKUBOX_IMAP_SECURE", "FEDEO_CENTRAL_SERVICES_ENABLED"])
|
||||
|
||||
function normalizeEnvValue(key: string, value: string) {
|
||||
if (numberKeys.has(key)) return Number(value)
|
||||
|
||||
Reference in New Issue
Block a user