Files
finanzen/convex/bank/comdirectProvider.ts
2026-06-23 21:13:02 +02:00

308 lines
9.8 KiB
TypeScript

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<Record<string, unknown>>;
};
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<BankDataProvider> {
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<NormalizedAccount[]> {
const balances = await getAccountBalances(accessToken, sessionUuid);
return mapComdirectAccounts(balances);
},
async getBalance(accountExternalId: string): Promise<NormalizedBalance> {
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<NormalizedTransaction[]> {
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<typeof mapComdirectTransaction>[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<string, NormalizedTransaction[]>;
}> {
const provider = await createComdirectRestProvider({ ctx, userId });
const accounts = await provider.getAccounts();
const transactionsByAccount = new Map<string, NormalizedTransaction[]>();
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<string>();
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)),
};
}