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">; }; 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 (balances.values ?? []).flatMap((item) => { const account = item.account as { accountId?: string; iban?: string; accountType?: { text?: string }; }; const accountIdExternal = account?.accountId; if (!accountIdExternal) return []; const balanceValue = Number((item.balance as { value?: string })?.value ?? 0); return [ { externalId: accountIdExternal, name: account.accountType?.text ?? "comdirect Konto", iban: account.iban, balance: balanceValue, currency: "EUR", }, ]; }); }, async getBalance(accountExternalId: string): Promise { const balances = await getAccountBalances(accessToken, sessionUuid); const match = (balances.values ?? []).find((item) => { const account = item.account as { accountId?: string }; return account?.accountId === accountExternalId; }); if (!match) throw new Error(`Konto ${accountExternalId} nicht gefunden`); return { externalId: accountExternalId, balance: Number((match.balance as { value?: string })?.value ?? 0), currency: "EUR", }; }, 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 = 0; 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 }; }