import type { ActionCtx } from "../_generated/server"; import type { Id } from "../_generated/dataModel"; import { internal } from "../_generated/api"; import { getAccountBalances, getTransactions } from "../comdirect/client"; import { mapComdirectTransaction } from "../lib/comdirectMap"; import type { BankDataProvider, NormalizedAccount, NormalizedBalance, NormalizedTransaction, } from "./types"; export function hasComdirectCredentials(): boolean { return Boolean(process.env.COMDIRECT_CLIENT_ID && process.env.COMDIRECT_CLIENT_SECRET); } export function isRestFallbackError(error: unknown): boolean { if (!(error instanceof Error)) return true; const msg = error.message.toLowerCase(); if (msg.includes("nicht konfiguriert")) return true; if (msg.includes("session nicht aktiv")) return true; if (msg.includes("oauth fehlgeschlagen")) return true; if (msg.includes("fehlgeschlagen: 5")) return true; if (msg.includes("fehlgeschlagen: 401")) return true; if (msg.includes("fehlgeschlagen: 403")) return true; if (msg.includes("network") || msg.includes("fetch")) return true; if (msg.includes("clientcredentials")) return true; return false; } type ComdirectProviderContext = { ctx: ActionCtx; userId: Id<"users">; }; type ComdirectBalancePayload = { values?: Array>; }; function getNestedText(value: unknown): string | undefined { if (!value || typeof value !== "object") return undefined; const text = (value as { text?: unknown }).text; return typeof text === "string" ? text : undefined; } export function mapComdirectBalances( payload: ComdirectBalancePayload, ): NormalizedBalance[] { return (payload.values ?? []).flatMap((item) => { const account = item.account as | { accountId?: unknown; } | undefined; const balance = item.balance as | { value?: unknown; unit?: unknown; } | undefined; const externalId = typeof account?.accountId === "string" ? account.accountId : undefined; if (!externalId) return []; const balanceValue = Number(balance?.value ?? 0); const currency = typeof balance?.unit === "string" ? balance.unit : "EUR"; const asOf = typeof item.date === "string" ? item.date : undefined; return [ { externalId, balance: Number.isFinite(balanceValue) ? balanceValue : 0, currency, asOf, }, ]; }); } export function mapComdirectAccounts( payload: ComdirectBalancePayload, ): NormalizedAccount[] { return (payload.values ?? []).flatMap((item) => { const account = item.account as | { accountId?: unknown; iban?: unknown; accountType?: unknown; } | undefined; const accountIdExternal = typeof account?.accountId === "string" ? account.accountId : undefined; if (!accountIdExternal) return []; const balance = mapComdirectBalances({ values: [item] })[0]; return [ { externalId: accountIdExternal, name: getNestedText(account?.accountType) ?? "comdirect Konto", iban: typeof account?.iban === "string" ? account.iban : undefined, balance: balance?.balance ?? 0, currency: balance?.currency ?? "EUR", }, ]; }); } export async function createComdirectRestProvider( context: ComdirectProviderContext, ): Promise { const { ctx, userId } = context; const clientId = process.env.COMDIRECT_CLIENT_ID; const clientSecret = process.env.COMDIRECT_CLIENT_SECRET; if (!clientId || !clientSecret) { throw new Error("comdirect API-Zugangsdaten nicht konfiguriert"); } const session = await ctx.runQuery(internal.comdirect.internal.getSession, { userId }); if (!session?.accessToken || !session.secondaryActive) { throw new Error("comdirect-Session nicht aktiv. Bitte erneut anmelden."); } const accessToken = session.accessToken; const sessionUuid = session.sessionUuid; return { name: "comdirect", async getAccounts(): Promise { const balances = await getAccountBalances(accessToken, sessionUuid); return mapComdirectAccounts(balances); }, async getBalance(accountExternalId: string): Promise { const balances = await getAccountBalances(accessToken, sessionUuid); const match = mapComdirectBalances(balances).find( (item) => item.externalId === accountExternalId, ); if (!match) throw new Error(`Konto ${accountExternalId} nicht gefunden`); return match; }, async getTransactions( accountExternalId: string, from: string, to: string, ): Promise { const rows: NormalizedTransaction[] = []; for (const state of ["BOOKED", "NOTBOOKED"] as const) { let offset = 0; let matches: number; do { const result = await getTransactions(accessToken, sessionUuid, accountExternalId, { transactionState: state, pagingFirst: offset, minBookingDate: from, maxBookingDate: to, }); matches = result.paging.matches; for (const tx of result.values ?? []) { const mapped = mapComdirectTransaction( tx as Parameters[0], [], { enabled: true, categoryNames: ["Gehalt & Besoldung"], dayThreshold: 25, }, ); rows.push({ bookingDate: mapped.bookingDate, valueDate: mapped.valueDate, description: mapped.description, counterparty: mapped.counterparty, amount: mapped.amount, vorgang: mapped.vorgang, isPending: mapped.isPending, rawText: mapped.rawText, externalRef: mapped.externalRef, }); } offset += result.values?.length ?? 0; } while (offset < matches); } return rows; }, }; } export async function fetchComdirectData( ctx: ActionCtx, userId: Id<"users">, from: string, to: string, filterAccountId: Id<"accounts"> | undefined, ownNames: string[], salaryShift: { enabled: boolean; categoryNames: string[]; dayThreshold: number; }, ): Promise<{ accounts: NormalizedAccount[]; transactionsByAccount: Map; }> { const provider = await createComdirectRestProvider({ ctx, userId }); const accounts = await provider.getAccounts(); const transactionsByAccount = new Map(); for (const account of accounts) { if (filterAccountId) { const convexId = await ctx.runMutation(internal.bank.internal.upsertAccountFromProvider, { userId, externalId: account.externalId, name: account.name, iban: account.iban, balance: account.balance, currency: account.currency, }); if (convexId !== filterAccountId) continue; } const rawTxs = await provider.getTransactions(account.externalId, from, to); const txs = rawTxs.map((tx) => { const mapped = mapComdirectTransaction( { bookingStatus: tx.isPending ? "NOTBOOKED" : "BOOKED", bookingDate: tx.bookingDate, valueDate: tx.valueDate, amount: { value: String(tx.amount) }, remittanceInfo: tx.rawText, remitter: tx.counterparty ? { holderName: tx.counterparty } : undefined, transactionType: tx.vorgang ? { text: tx.vorgang } : undefined, reference: tx.externalRef, }, ownNames, salaryShift, ); return { bookingDate: mapped.bookingDate, valueDate: mapped.valueDate, description: mapped.description, counterparty: mapped.counterparty, amount: mapped.amount, vorgang: mapped.vorgang, isPending: mapped.isPending, rawText: mapped.rawText, externalRef: mapped.externalRef, categoryName: mapped.categoryName, assignedMonth: mapped.assignedMonth, effectiveMonth: mapped.effectiveMonth, }; }); transactionsByAccount.set(account.externalId, txs); } return { accounts, transactionsByAccount }; } export async function fetchComdirectBalanceData( ctx: ActionCtx, userId: Id<"users">, filterAccountId: Id<"accounts"> | undefined, ): Promise<{ accounts: NormalizedAccount[]; balances: NormalizedBalance[]; }> { const clientId = process.env.COMDIRECT_CLIENT_ID; const clientSecret = process.env.COMDIRECT_CLIENT_SECRET; if (!clientId || !clientSecret) { throw new Error("comdirect API-Zugangsdaten nicht konfiguriert"); } const session = await ctx.runQuery(internal.comdirect.internal.getSession, { userId }); if (!session?.accessToken || !session.secondaryActive) { throw new Error("comdirect-Session nicht aktiv. Bitte erneut anmelden."); } const payload = await getAccountBalances(session.accessToken, session.sessionUuid); const accounts = mapComdirectAccounts(payload); const balances = mapComdirectBalances(payload); if (!filterAccountId) { return { accounts, balances }; } const filteredAccounts: NormalizedAccount[] = []; const allowedExternalIds = new Set(); for (const account of accounts) { const convexId = await ctx.runMutation(internal.bank.internal.upsertAccountFromProvider, { userId, externalId: account.externalId, name: account.name, iban: account.iban, balance: account.balance, currency: account.currency, }); if (convexId === filterAccountId) { filteredAccounts.push(account); allowedExternalIds.add(account.externalId); } } return { accounts: filteredAccounts, balances: balances.filter((balance) => allowedExternalIds.has(balance.externalId)), }; }