Files
finanzen/convex/bank/comdirectProvider.ts

210 lines
7.0 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">;
};
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 (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<NormalizedBalance> {
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<NormalizedTransaction[]> {
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<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 };
}