308 lines
9.8 KiB
TypeScript
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)),
|
|
};
|
|
}
|