Add bank synchronization features with FinTS support and update dependencies

This commit is contained in:
Matthias
2026-06-15 13:56:32 +02:00
parent fc0a6fb975
commit d65e7681ac
23 changed files with 2609 additions and 150 deletions

View File

@@ -0,0 +1,209 @@
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 };
}

209
convex/bank/config.ts Normal file
View File

@@ -0,0 +1,209 @@
import { query, mutation } from "../_generated/server";
import { v } from "convex/values";
import { requireUserId } from "../lib/helpers";
const bankConfigReturn = v.object({
_id: v.id("bankConfig"),
_creationTime: v.number(),
userId: v.id("users"),
providerPreference: v.union(
v.literal("auto"),
v.literal("comdirect"),
v.literal("fints"),
),
comdirectHasCredentials: v.boolean(),
fints: v.object({
blz: v.string(),
url: v.string(),
login: v.string(),
productId: v.string(),
productVersion: v.optional(v.string()),
tanMethodId: v.optional(v.number()),
tanMediaName: v.optional(v.string()),
}),
});
export const getConfig = query({
args: {},
returns: v.union(bankConfigReturn, v.null()),
handler: async (ctx) => {
const userId = await requireUserId(ctx);
const config = await ctx.db
.query("bankConfig")
.withIndex("by_user", (q) => q.eq("userId", userId))
.unique();
if (!config) return null;
const { bankingInformationJson: _omit, ...rest } = config.fints;
return {
...config,
fints: rest,
};
},
});
export const getSyncState = query({
args: {},
returns: v.union(
v.object({
_id: v.id("syncState"),
_creationTime: v.number(),
userId: v.id("users"),
lastSync: v.optional(v.number()),
lastProviderUsed: v.optional(v.union(v.literal("comdirect"), v.literal("fints"))),
lastError: v.optional(v.string()),
}),
v.null(),
),
handler: async (ctx) => {
const userId = await requireUserId(ctx);
return await ctx.db
.query("syncState")
.withIndex("by_user", (q) => q.eq("userId", userId))
.unique();
},
});
export const getPendingTan = query({
args: {},
returns: v.union(
v.object({
_id: v.id("pendingTan"),
status: v.union(
v.literal("idle"),
v.literal("awaiting"),
v.literal("done"),
v.literal("error"),
),
challengeMessage: v.optional(v.string()),
photoTanMimeType: v.optional(v.string()),
photoTanBase64: v.optional(v.string()),
isDecoupled: v.optional(v.boolean()),
errorMessage: v.optional(v.string()),
updatedAt: v.number(),
}),
v.null(),
),
handler: async (ctx) => {
const userId = await requireUserId(ctx);
const pending = await ctx.db
.query("pendingTan")
.withIndex("by_user", (q) => q.eq("userId", userId))
.unique();
if (!pending) return null;
return {
_id: pending._id,
status: pending.status,
challengeMessage: pending.challengeMessage,
photoTanMimeType: pending.photoTanMimeType,
photoTanBase64: pending.photoTanBase64,
isDecoupled: pending.isDecoupled,
errorMessage: pending.errorMessage,
updatedAt: pending.updatedAt,
};
},
});
export const updateConfig = mutation({
args: {
providerPreference: v.optional(
v.union(v.literal("auto"), v.literal("comdirect"), v.literal("fints")),
),
fints: v.optional(
v.object({
blz: v.string(),
url: v.string(),
login: v.string(),
productId: v.string(),
productVersion: v.optional(v.string()),
tanMethodId: v.optional(v.number()),
tanMediaName: v.optional(v.string()),
}),
),
},
returns: v.null(),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const existing = await ctx.db
.query("bankConfig")
.withIndex("by_user", (q) => q.eq("userId", userId))
.unique();
if (existing) {
const patch: Record<string, unknown> = {};
if (args.providerPreference !== undefined) {
patch.providerPreference = args.providerPreference;
}
if (args.fints !== undefined) {
patch.fints = { ...existing.fints, ...args.fints };
}
if (Object.keys(patch).length > 0) {
await ctx.db.patch(existing._id, patch);
}
} else {
await ctx.db.insert("bankConfig", {
userId,
providerPreference: args.providerPreference ?? "auto",
comdirectHasCredentials: false,
fints: args.fints ?? {
blz: "",
url: "",
login: "",
productId: "",
},
});
}
return null;
},
});
export const submitTan = mutation({
args: { tan: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const tan = args.tan.trim();
if (!tan) throw new Error("Bitte TAN eingeben");
const pending = await ctx.db
.query("pendingTan")
.withIndex("by_user", (q) => q.eq("userId", userId))
.unique();
if (!pending || pending.status !== "awaiting") {
throw new Error("Keine laufende TAN-Anfrage");
}
await ctx.db.patch(pending._id, {
submittedTan: tan,
updatedAt: Date.now(),
});
return null;
},
});
export const resetPendingTan = mutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
const userId = await requireUserId(ctx);
const existing = await ctx.db
.query("pendingTan")
.withIndex("by_user", (q) => q.eq("userId", userId))
.unique();
if (existing) {
await ctx.db.patch(existing._id, {
status: "idle",
challengeRef: undefined,
challengeMessage: undefined,
photoTanMimeType: undefined,
photoTanBase64: undefined,
errorMessage: undefined,
syncJobJson: undefined,
pollAttempt: undefined,
isDecoupled: undefined,
submittedTan: undefined,
updatedAt: Date.now(),
});
}
return null;
},
});

View File

@@ -0,0 +1,81 @@
/** Platzhalter bis ZKA-Produktregistrierung vorliegt Bank kann ablehnen, Config-Check bricht nicht ab. */
export const FINTS_PRODUCT_ID_PLACEHOLDER = "XXXXX";
export type FintsConfigStatus = {
ready: boolean;
missing: string[];
warnings: string[];
usesProductIdPlaceholder: boolean;
};
export function getFintsConfigStatus(overrides?: {
blz?: string;
url?: string;
login?: string;
productId?: string;
pin?: string;
}): FintsConfigStatus {
const url = overrides?.url || process.env.FINTS_BANK_URL || "";
const blz = overrides?.blz || process.env.FINTS_BANK_BLZ || "";
const login = overrides?.login || process.env.FINTS_USER_ID || "";
const pin = overrides?.pin || process.env.FINTS_PIN || "";
const productId = overrides?.productId || process.env.FINTS_PRODUCT_ID || "";
const missing: string[] = [];
if (!url) missing.push("FinTS-URL");
if (!blz) missing.push("BLZ");
if (!login) missing.push("Zugangsnummer");
if (!pin) missing.push("PIN");
const warnings: string[] = [];
const usesProductIdPlaceholder = !productId;
if (usesProductIdPlaceholder) {
warnings.push(
"Produkt-ID (ZKA) noch nicht gesetzt Sync wird versucht, die Bank kann die Anfrage ablehnen.",
);
}
return {
ready: missing.length === 0,
missing,
warnings,
usesProductIdPlaceholder,
};
}
export function resolveFintsEnvFields(overrides?: {
blz?: string;
url?: string;
login?: string;
productId?: string;
productVersion?: string;
pin?: string;
}): {
productId: string;
productVersion: string;
url: string;
blz: string;
login: string;
pin: string;
usesProductIdPlaceholder: boolean;
} {
const status = getFintsConfigStatus(overrides);
if (!status.ready) {
throw new Error(
`FinTS-Zugang unvollständig: ${status.missing.join(", ")} fehlt. Bitte in Convex-Env oder Einstellungen ergänzen.`,
);
}
const productId =
overrides?.productId || process.env.FINTS_PRODUCT_ID || FINTS_PRODUCT_ID_PLACEHOLDER;
return {
productId,
productVersion: overrides?.productVersion || process.env.FINTS_PRODUCT_VERSION || "1.0.0",
url: overrides?.url || process.env.FINTS_BANK_URL || "",
blz: overrides?.blz || process.env.FINTS_BANK_BLZ || "",
login: overrides?.login || process.env.FINTS_USER_ID || "",
pin: overrides?.pin || process.env.FINTS_PIN || "",
usesProductIdPlaceholder: status.usesProductIdPlaceholder,
};
}

63
convex/bank/fintsMap.ts Normal file
View File

@@ -0,0 +1,63 @@
import { categorize, roundEur } from "../lib/categorize";
import { resolveAssignedAndEffective, type SalaryShiftSettings } from "../lib/month";
import type { NormalizedTransaction } from "./types";
export type FintsStatementTransaction = {
valueDate: Date;
entryDate: Date;
amount: number;
transactionType: string;
bankReference: string;
bookingText?: string;
purpose?: string;
remoteName?: string;
customerReference?: string;
};
export function formatFinTsDate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
export function mapFinTsTransaction(
tx: FintsStatementTransaction,
ownNames: string[],
salaryShift: SalaryShiftSettings,
): Omit<NormalizedTransaction, "externalRef"> & {
categoryName: string;
assignedMonth?: string;
effectiveMonth?: string;
externalRef?: string;
} {
const amount = roundEur(tx.amount);
const rawText = [tx.purpose, tx.bookingText, tx.transactionType].filter(Boolean).join(" ");
const counterparty = tx.remoteName || undefined;
const description = counterparty ?? (rawText.slice(0, 80) || "Umsatz");
const vorgang = tx.transactionType || tx.bookingText;
const bookingDate = formatFinTsDate(tx.entryDate);
const valueDate = formatFinTsDate(tx.valueDate);
const categoryName = categorize(rawText, amount, vorgang ?? "", ownNames);
const { assignedMonth, effectiveMonth } = resolveAssignedAndEffective(
bookingDate,
amount,
categoryName,
salaryShift,
);
return {
bookingDate,
valueDate,
description,
counterparty,
amount,
vorgang,
isPending: false,
rawText: rawText || undefined,
externalRef: tx.bankReference || tx.customerReference || undefined,
categoryName,
assignedMonth,
effectiveMonth,
};
}

161
convex/bank/fintsSession.ts Normal file
View File

@@ -0,0 +1,161 @@
"use node";
import {
FinTSClient,
FinTSConfig,
type BankingInformation,
type ClientResponse,
} from "lib-fints";
import { resolveFintsEnvFields } from "./fintsConfig";
export type FintsInteractionKind =
| { type: "sync"; syncSystemId: boolean }
| { type: "balance"; accountNumber: string }
| { type: "statements"; accountNumber: string; from?: string; to?: string; preferCamt: boolean };
export type SerializedFintsSession = {
bankingInformation: BankingInformation;
tanMethodId?: number;
tanMediaName?: string;
tanReference: string;
tanContinuation: "sync" | "balance" | "statements";
};
export type FintsEnvConfig = {
productId: string;
productVersion: string;
url: string;
blz: string;
login: string;
pin: string;
tanMethodId?: number;
tanMediaName?: string;
bankingInformation?: BankingInformation;
};
/** @deprecated Import from ./fintsConfig */
export { FINTS_PRODUCT_ID_PLACEHOLDER, getFintsConfigStatus } from "./fintsConfig";
export type { FintsConfigStatus } from "./fintsConfig";
export function resolveFintsEnv(overrides?: {
blz?: string;
url?: string;
login?: string;
productId?: string;
productVersion?: string;
tanMethodId?: number;
tanMediaName?: string;
bankingInformationJson?: string;
pin?: string;
}): FintsEnvConfig {
const fields = resolveFintsEnvFields(overrides);
if (fields.usesProductIdPlaceholder) {
console.warn("[fints] FINTS_PRODUCT_ID fehlt Platzhalter wird verwendet");
}
let bankingInformation: BankingInformation | undefined;
if (overrides?.bankingInformationJson) {
bankingInformation = JSON.parse(overrides.bankingInformationJson) as BankingInformation;
}
return {
productId: fields.productId,
productVersion: fields.productVersion,
url: fields.url,
blz: fields.blz,
login: fields.login,
pin: fields.pin,
tanMethodId: overrides?.tanMethodId,
tanMediaName: overrides?.tanMediaName,
bankingInformation,
};
}
export function createFinTsClient(config: FintsEnvConfig): FinTSClient {
const fintsConfig = config.bankingInformation
? FinTSConfig.fromBankingInformation(
config.productId,
config.productVersion,
config.bankingInformation,
config.login,
config.pin,
config.tanMethodId,
config.tanMediaName,
)
: FinTSConfig.forFirstTimeUse(
config.productId,
config.productVersion,
config.url,
config.blz,
config.login,
config.pin,
);
const client = new FinTSClient(fintsConfig);
if (config.tanMethodId) {
client.selectTanMethod(config.tanMethodId);
}
if (config.tanMediaName) {
client.selectTanMedia(config.tanMediaName);
}
return client;
}
export function buildSessionSnapshot(
client: FinTSClient,
tanReference: string,
tanContinuation: SerializedFintsSession["tanContinuation"],
): SerializedFintsSession {
return {
bankingInformation: client.config.bankingInformation,
tanMethodId: client.config.selectedTanMethod?.id,
tanMediaName: client.config.selectedTanMethod?.activeTanMedia?.[0],
tanReference,
tanContinuation,
};
}
export async function continueWithTan(
client: FinTSClient,
session: SerializedFintsSession,
tan?: string,
): Promise<ClientResponse> {
switch (session.tanContinuation) {
case "sync":
return await client.synchronizeWithTan(session.tanReference, tan);
case "balance":
return await client.getAccountBalanceWithTan(session.tanReference, tan);
case "statements":
return await client.getAccountStatementsWithTan(session.tanReference, tan);
}
}
export function encodePhotoTan(response: ClientResponse): {
mimeType?: string;
base64?: string;
} {
if (!response.tanPhoto?.image) return {};
const bytes =
response.tanPhoto.image instanceof Uint8Array
? response.tanPhoto.image
: new Uint8Array(response.tanPhoto.image);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return {
mimeType: response.tanPhoto.mimeType,
base64: btoa(binary),
};
}
export function pickDecoupledTanMethod(client: FinTSClient): number | undefined {
const methods = client.config.availableTanMethods;
const decoupled = methods.find((m) => m.isDecoupled);
if (decoupled) return decoupled.id;
return methods[0]?.id;
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

288
convex/bank/internal.ts Normal file
View File

@@ -0,0 +1,288 @@
import { internalMutation, internalQuery } from "../_generated/server";
import { v } from "convex/values";
import type { Doc } from "../_generated/dataModel";
const bankConfigValidator = v.object({
_id: v.id("bankConfig"),
_creationTime: v.number(),
userId: v.id("users"),
providerPreference: v.union(
v.literal("auto"),
v.literal("comdirect"),
v.literal("fints"),
),
comdirectHasCredentials: v.boolean(),
fints: v.object({
blz: v.string(),
url: v.string(),
login: v.string(),
productId: v.string(),
productVersion: v.optional(v.string()),
tanMethodId: v.optional(v.number()),
tanMediaName: v.optional(v.string()),
bankingInformationJson: v.optional(v.string()),
}),
});
const syncStateValidator = v.object({
_id: v.id("syncState"),
_creationTime: v.number(),
userId: v.id("users"),
lastSync: v.optional(v.number()),
lastProviderUsed: v.optional(v.union(v.literal("comdirect"), v.literal("fints"))),
lastError: v.optional(v.string()),
});
const pendingTanValidator = v.object({
_id: v.id("pendingTan"),
_creationTime: v.number(),
userId: v.id("users"),
status: v.union(
v.literal("idle"),
v.literal("awaiting"),
v.literal("done"),
v.literal("error"),
),
challengeRef: v.optional(v.string()),
challengeMessage: v.optional(v.string()),
photoTanMimeType: v.optional(v.string()),
photoTanBase64: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
errorMessage: v.optional(v.string()),
pollAttempt: v.optional(v.number()),
syncJobJson: v.optional(v.string()),
isDecoupled: v.optional(v.boolean()),
submittedTan: v.optional(v.string()),
});
export const getBankConfig = internalQuery({
args: { userId: v.id("users") },
returns: v.union(bankConfigValidator, v.null()),
handler: async (ctx, args) => {
return await ctx.db
.query("bankConfig")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.unique();
},
});
export const getSyncState = internalQuery({
args: { userId: v.id("users") },
returns: v.union(syncStateValidator, v.null()),
handler: async (ctx, args) => {
return await ctx.db
.query("syncState")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.unique();
},
});
export const getPendingTan = internalQuery({
args: { userId: v.id("users") },
returns: v.union(pendingTanValidator, v.null()),
handler: async (ctx, args) => {
return await ctx.db
.query("pendingTan")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.unique();
},
});
export const upsertBankConfig = internalMutation({
args: {
userId: v.id("users"),
providerPreference: v.optional(
v.union(v.literal("auto"), v.literal("comdirect"), v.literal("fints")),
),
comdirectHasCredentials: v.optional(v.boolean()),
fints: v.optional(
v.object({
blz: v.string(),
url: v.string(),
login: v.string(),
productId: v.string(),
productVersion: v.optional(v.string()),
tanMethodId: v.optional(v.number()),
tanMediaName: v.optional(v.string()),
bankingInformationJson: v.optional(v.string()),
}),
),
},
returns: v.id("bankConfig"),
handler: async (ctx, args) => {
const existing = await ctx.db
.query("bankConfig")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.unique();
if (existing) {
const patch: Partial<Doc<"bankConfig">> = {};
if (args.providerPreference !== undefined) {
patch.providerPreference = args.providerPreference;
}
if (args.comdirectHasCredentials !== undefined) {
patch.comdirectHasCredentials = args.comdirectHasCredentials;
}
if (args.fints !== undefined) {
patch.fints = { ...existing.fints, ...args.fints };
}
if (Object.keys(patch).length > 0) {
await ctx.db.patch(existing._id, patch);
}
return existing._id;
}
return await ctx.db.insert("bankConfig", {
userId: args.userId,
providerPreference: args.providerPreference ?? "auto",
comdirectHasCredentials: args.comdirectHasCredentials ?? false,
fints: args.fints ?? {
blz: "",
url: "",
login: "",
productId: "",
},
});
},
});
export const updateSyncState = internalMutation({
args: {
userId: v.id("users"),
lastSync: v.optional(v.number()),
lastProviderUsed: v.optional(v.union(v.literal("comdirect"), v.literal("fints"))),
lastError: v.optional(v.union(v.string(), v.null())),
},
returns: v.null(),
handler: async (ctx, args) => {
const existing = await ctx.db
.query("syncState")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.unique();
const patch: {
lastSync?: number;
lastProviderUsed?: "comdirect" | "fints";
lastError?: string;
} = {};
if (args.lastSync !== undefined) patch.lastSync = args.lastSync;
if (args.lastProviderUsed !== undefined) patch.lastProviderUsed = args.lastProviderUsed;
if (args.lastError !== undefined) {
patch.lastError = args.lastError === null ? undefined : args.lastError;
}
if (existing) {
await ctx.db.patch(existing._id, patch);
} else {
await ctx.db.insert("syncState", {
userId: args.userId,
...patch,
});
}
return null;
},
});
export const upsertPendingTan = internalMutation({
args: {
userId: v.id("users"),
status: v.union(
v.literal("idle"),
v.literal("awaiting"),
v.literal("done"),
v.literal("error"),
),
challengeRef: v.optional(v.union(v.string(), v.null())),
challengeMessage: v.optional(v.union(v.string(), v.null())),
photoTanMimeType: v.optional(v.union(v.string(), v.null())),
photoTanBase64: v.optional(v.union(v.string(), v.null())),
errorMessage: v.optional(v.union(v.string(), v.null())),
pollAttempt: v.optional(v.number()),
syncJobJson: v.optional(v.union(v.string(), v.null())),
isDecoupled: v.optional(v.union(v.boolean(), v.null())),
submittedTan: v.optional(v.union(v.string(), v.null())),
},
returns: v.id("pendingTan"),
handler: async (ctx, args) => {
const now = Date.now();
const existing = await ctx.db
.query("pendingTan")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.unique();
const fields = {
status: args.status,
challengeRef:
args.challengeRef === null ? undefined : (args.challengeRef ?? undefined),
challengeMessage:
args.challengeMessage === null
? undefined
: (args.challengeMessage ?? undefined),
photoTanMimeType:
args.photoTanMimeType === null
? undefined
: (args.photoTanMimeType ?? undefined),
photoTanBase64:
args.photoTanBase64 === null ? undefined : (args.photoTanBase64 ?? undefined),
errorMessage:
args.errorMessage === null ? undefined : (args.errorMessage ?? undefined),
pollAttempt: args.pollAttempt,
syncJobJson:
args.syncJobJson === null ? undefined : (args.syncJobJson ?? undefined),
isDecoupled:
args.isDecoupled === null ? undefined : (args.isDecoupled ?? undefined),
submittedTan:
args.submittedTan === null ? undefined : (args.submittedTan ?? undefined),
updatedAt: now,
};
if (existing) {
await ctx.db.patch(existing._id, fields);
return existing._id;
}
return await ctx.db.insert("pendingTan", {
userId: args.userId,
createdAt: now,
...fields,
});
},
});
export const upsertAccountFromProvider = internalMutation({
args: {
userId: v.id("users"),
externalId: v.string(),
name: v.string(),
iban: v.optional(v.string()),
balance: v.number(),
currency: v.string(),
},
returns: v.id("accounts"),
handler: async (ctx, args) => {
const existing = await ctx.db
.query("accounts")
.withIndex("by_user_external", (q) =>
q.eq("userId", args.userId).eq("externalId", args.externalId),
)
.unique();
if (existing) {
await ctx.db.patch(existing._id, {
name: args.name,
iban: args.iban,
});
return existing._id;
}
return await ctx.db.insert("accounts", {
userId: args.userId,
name: args.name,
type: "giro",
iban: args.iban,
openingBalance: args.balance,
currency: args.currency,
isArchived: false,
externalId: args.externalId,
});
},
});

884
convex/bank/orchestrator.ts Normal file
View File

@@ -0,0 +1,884 @@
"use node";
import { internalAction } from "../_generated/server";
import { v } from "convex/values";
import { internal } from "../_generated/api";
import type { Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import {
FinTSClient,
Mt940Parser,
type BankAccount,
type ClientResponse,
type StatementResponse,
} from "lib-fints";
import {
createFinTsClient,
continueWithTan,
encodePhotoTan,
pickDecoupledTanMethod,
resolveFintsEnv,
buildSessionSnapshot,
sleep,
type FintsEnvConfig,
type SerializedFintsSession,
} from "./fintsSession";
import { mapFinTsTransaction } from "./fintsMap";
import {
fetchComdirectData,
hasComdirectCredentials,
isRestFallbackError,
} from "./comdirectProvider";
import type { ImportRow, NormalizedAccount, NormalizedTransaction } from "./types";
export const TAN_POLL_INTERVAL_MS = 4000;
export const TAN_TIMEOUT_MS = 5 * 60 * 1000;
export const TAN_MAX_POLL_ATTEMPTS = Math.ceil(TAN_TIMEOUT_MS / TAN_POLL_INTERVAL_MS);
// comdirect (and other banks) split large MT940 statement results across
// multiple responses using continuation marks (return code 3040). lib-fints
// sends every continuation request correctly but mis-merges the binary MT940
// pages (it string-concats the `@len@` framed payloads, so only the first page
// survives the decode). We capture the raw MT940 booked payload of every HIKAZ
// response here and reassemble all pages ourselves in fetchStatementsAllPages.
let mt940Pages: string[] = [];
// Extracts the booked MT940 content from every HIKAZ segment in a raw FinTS
// response. HIKAZ encodes its booked transactions as a Binary element
// `@<len>@<mt940text>` directly after the segment header `HIKAZ:n:7+`.
function extractHikazMt940Pages(text: string): string[] {
const pages: string[] = [];
let idx = 0;
for (;;) {
const h = text.indexOf("HIKAZ:", idx);
if (h === -1) break;
const at1 = text.indexOf("@", h);
if (at1 === -1) break;
const at2 = text.indexOf("@", at1 + 1);
if (at2 === -1) break;
const len = Number.parseInt(text.slice(at1 + 1, at2), 10);
if (!Number.isFinite(len) || len <= 0) {
idx = at2 + 1;
continue;
}
const content = text.slice(at2 + 1, at2 + 1 + len);
pages.push(content);
idx = at2 + 1 + len;
}
return pages;
}
const origFetch: typeof fetch = globalThis.fetch.bind(globalThis);
globalThis.fetch = (async (...args: Parameters<typeof fetch>) => {
const res = await origFetch(...args);
try {
const text = Buffer.from(await res.clone().text(), "base64").toString(
"latin1",
);
if (text.includes("HIKAZ:")) {
for (const page of extractHikazMt940Pages(text)) {
mt940Pages.push(page);
}
}
} catch {
/* ignore non-FinTS responses */
}
return res;
}) as typeof fetch;
type PendingSyncJob = {
from: string;
to: string;
accountId?: Id<"accounts">;
provider: "fints" | "comdirect";
phase: "fetch" | "persist";
partialAccounts?: NormalizedAccount[];
partialTransactions?: Record<string, NormalizedTransaction[]>;
};
async function waitForDecoupledTan(
ctx: ActionCtx,
userId: Id<"users">,
client: FinTSClient,
session: SerializedFintsSession,
initialResponse: ClientResponse,
syncJob: PendingSyncJob,
): Promise<ClientResponse> {
let response = initialResponse;
let sessionState = session;
const isDecoupled = client.config.selectedTanMethod?.isDecoupled ?? false;
if (!response.requiresTan || !response.tanReference) {
return response;
}
const photo = encodePhotoTan(response);
await ctx.runMutation(internal.bank.internal.upsertPendingTan, {
userId,
status: "awaiting",
challengeRef: response.tanReference,
challengeMessage: response.tanChallenge ?? "Bitte TAN in der Banking-App freigeben",
photoTanMimeType: photo.mimeType ?? null,
photoTanBase64: photo.base64 ?? null,
pollAttempt: 0,
syncJobJson: JSON.stringify({ syncJob }),
isDecoupled,
submittedTan: null,
});
await ctx.scheduler.runAfter(TAN_POLL_INTERVAL_MS, internal.bank.orchestrator.pollTan, {
userId,
attempt: 1,
});
for (let attempt = 1; attempt <= TAN_MAX_POLL_ATTEMPTS; attempt += 1) {
await sleep(TAN_POLL_INTERVAL_MS);
const pending = await ctx.runQuery(internal.bank.internal.getPendingTan, { userId });
const submittedTan = pending?.submittedTan?.trim() || undefined;
if (!isDecoupled && !submittedTan) {
continue;
}
response = await continueWithTan(
client,
{
...sessionState,
tanReference: response.tanReference ?? sessionState.tanReference,
},
submittedTan,
);
if (submittedTan) {
await ctx.runMutation(internal.bank.internal.upsertPendingTan, {
userId,
status: "awaiting",
submittedTan: null,
pollAttempt: attempt,
});
}
if (!response.requiresTan) {
await ctx.runMutation(internal.bank.internal.upsertPendingTan, {
userId,
status: "done",
challengeRef: null,
challengeMessage: null,
photoTanMimeType: null,
photoTanBase64: null,
syncJobJson: null,
submittedTan: null,
pollAttempt: attempt,
});
return response;
}
sessionState = buildSessionSnapshot(
client,
response.tanReference ?? sessionState.tanReference,
sessionState.tanContinuation,
);
const nextPhoto = encodePhotoTan(response);
await ctx.runMutation(internal.bank.internal.upsertPendingTan, {
userId,
status: "awaiting",
challengeRef: response.tanReference ?? sessionState.tanReference,
challengeMessage: response.tanChallenge ?? "Bitte TAN in der Banking-App freigeben",
photoTanMimeType: nextPhoto.mimeType ?? null,
photoTanBase64: nextPhoto.base64 ?? null,
pollAttempt: attempt,
syncJobJson: JSON.stringify({ syncJob }),
isDecoupled,
});
}
await ctx.runMutation(internal.bank.internal.upsertPendingTan, {
userId,
status: "error",
errorMessage: "TAN-Freigabe Timeout (5 Minuten)",
});
throw new Error("TAN-Freigabe Timeout (5 Minuten)");
}
async function resolveTanResponse(
ctx: ActionCtx,
userId: Id<"users">,
client: FinTSClient,
response: ClientResponse,
continuation: SerializedFintsSession["tanContinuation"],
syncJob: PendingSyncJob,
): Promise<ClientResponse> {
if (!response.requiresTan || !response.tanReference) {
return response;
}
const session = buildSessionSnapshot(client, response.tanReference, continuation);
return await waitForDecoupledTan(ctx, userId, client, session, response, syncJob);
}
async function logProvider(
ctx: ActionCtx,
userId: Id<"users">,
provider: "comdirect" | "fints",
reason: string,
) {
console.info("[bank-sync]", { userId, provider, reason });
await ctx.runMutation(internal.bank.internal.updateSyncState, {
userId,
lastProviderUsed: provider,
});
}
async function ensureFinTsReady(
client: FinTSClient,
env: FintsEnvConfig,
ctx: ActionCtx,
userId: Id<"users">,
syncJob: PendingSyncJob,
): Promise<FinTSClient> {
let syncResponse = await client.synchronize();
syncResponse = await resolveTanResponse(ctx, userId, client, syncResponse, "sync", syncJob);
if (!syncResponse.success) {
throw new Error(
syncResponse.bankAnswers.map((a) => a.text).join("; ") || "FinTS-Synchronisation fehlgeschlagen",
);
}
const tanMethodId = pickDecoupledTanMethod(client);
if (tanMethodId && !client.config.selectedTanMethod) {
const method = client.selectTanMethod(tanMethodId);
if (method.tanMediaRequirement === 2 && method.activeTanMedia[0]) {
client.selectTanMedia(method.activeTanMedia[0]);
}
}
if (!client.config.bankingInformation.upd?.bankAccounts?.length) {
syncResponse = await client.synchronize();
syncResponse = await resolveTanResponse(ctx, userId, client, syncResponse, "sync", syncJob);
if (!syncResponse.success) {
throw new Error(
syncResponse.bankAnswers.map((a) => a.text).join("; ") ||
"FinTS-Synchronisation fehlgeschlagen",
);
}
}
await ctx.runMutation(internal.bank.internal.upsertBankConfig, {
userId,
fints: {
blz: env.blz,
url: env.url,
login: env.login,
productId: env.productId,
productVersion: env.productVersion,
tanMethodId: client.config.selectedTanMethod?.id,
tanMediaName: methodMediaName(client),
bankingInformationJson: JSON.stringify(client.config.bankingInformation),
},
});
return client;
}
function methodMediaName(client: FinTSClient): string | undefined {
const method = client.config.selectedTanMethod;
if (!method?.activeTanMedia?.length) return undefined;
return method.activeTanMedia[0];
}
function mapBankAccount(account: BankAccount): NormalizedAccount {
return {
externalId: account.accountNumber,
name: account.product ?? account.holder1 ?? "Bankkonto",
iban: account.iban,
balance: 0,
currency: account.currency || "EUR",
};
}
/**
* Fetches account statements and reassembles multi-part (3040) MT940 results.
*
* comdirect ignores the `to` date and returns the full range `[from, now]`,
* paginated across multiple responses via continuation marks (return code 3040
* "Weitere Informationen liegen vor"). lib-fints sends every continuation
* request correctly, but mis-merges the binary MT940 pages (it string-concats
* the `@len@` framed payloads, so only the first page survives the decode).
*
* We capture the raw MT940 booked content of every HIKAZ response via the fetch
* interceptor (`mt940Pages`) and parse the concatenated pages ourselves with
* lib-fints' own Mt940Parser, which yields the complete, non-overlapping set.
*/
async function fetchStatementsAllPages(
ctx: ActionCtx,
userId: Id<"users">,
client: FinTSClient,
account: NormalizedAccount,
fromDate: Date,
toDate: Date,
preferCamt: boolean,
syncJob: PendingSyncJob,
): Promise<StatementResponse | null> {
// Reset the per-fetch MT940 page collector; the fetch interceptor fills it
// while getAccountStatements walks through all 3040 continuation responses.
mt940Pages = [];
let resp = await client.getAccountStatements(
account.externalId,
fromDate,
toDate,
preferCamt,
);
resp = (await resolveTanResponse(
ctx,
userId,
client,
resp,
"statements",
syncJob,
)) as StatementResponse;
if (!resp.success) {
return resp;
}
// Reassemble all captured MT940 pages ourselves to bypass lib-fints' broken
// multi-part merge. Only override when we actually captured MT940 content
// (i.e. MT940/HKKAZ path; CAMT accounts produce no HIKAZ pages) and our
// reassembly is at least as complete as lib-fints' own result.
if (mt940Pages.length > 0) {
const libFintsCount = countStatementTransactions(resp);
try {
const statements = new Mt940Parser(mt940Pages.join("\r\n")).parse();
const reassembled = statements.reduce(
(sum, s) => sum + (s.transactions?.length ?? 0),
0,
);
if (reassembled >= libFintsCount) {
resp = { ...resp, statements };
}
} catch (error) {
console.warn("[fints] Eigenes MT940-Reassembly fehlgeschlagen", {
account: account.externalId,
error: error instanceof Error ? error.message : String(error),
});
}
}
return resp;
}
async function fetchFinTsAccountData(
client: FinTSClient,
account: NormalizedAccount,
from: string,
to: string,
ctx: ActionCtx,
userId: Id<"users">,
syncJob: PendingSyncJob,
ownNames: string[],
salaryShift: {
enabled: boolean;
categoryNames: string[];
dayThreshold: number;
},
): Promise<{ balance: number; transactions: NormalizedTransaction[] }> {
const fromDate = new Date(from);
const toDate = new Date(to);
const canFetchBalance = client.canGetAccountBalance(account.externalId);
const canFetchStatements = client.canGetAccountStatements(account.externalId);
const canFetchCamtStatements = client.config.isAccountTransactionSupported(
account.externalId,
"HKCAZ",
);
const canFetchMt940Statements = client.config.isAccountTransactionSupported(
account.externalId,
"HKKAZ",
);
console.info("[fints] Konto-Capabilities", {
account: account.externalId,
iban: account.iban,
name: account.name,
canFetchBalance,
canFetchStatements,
canFetchCamtStatements,
canFetchMt940Statements,
});
let balance = account.balance;
if (canFetchBalance) {
let balanceResponse = await client.getAccountBalance(account.externalId);
balanceResponse = await resolveTanResponse(
ctx,
userId,
client,
balanceResponse,
"balance",
syncJob,
);
balance = balanceResponse.balance?.balance ?? account.balance;
} else {
console.warn("[fints] Konto unterstützt keinen HKSAL-Kontostandabruf", {
account: account.externalId,
});
}
if (!canFetchStatements) {
console.warn("[fints] Konto unterstützt keinen Umsatzabruf", {
account: account.externalId,
});
return { balance, transactions: [] };
}
// Prefer MT940 (HKKAZ) when available: comdirect's CAMT (HKCAZ) responds with
// "Kontonummer ist ungültig" and MT940 is the reliable path. Large windows are
// split adaptively to avoid lib-fints' broken multi-part (3040) reassembly.
const preferCamtForFetch = !canFetchMt940Statements;
let statementResponse = await fetchStatementsAllPages(
ctx,
userId,
client,
account,
fromDate,
toDate,
preferCamtForFetch,
syncJob,
);
if (
canFetchCamtStatements &&
canFetchMt940Statements &&
(!statementResponse ||
(statementResponse.success &&
countStatementTransactions(statementResponse) === 0))
) {
console.info("[fints] Bevorzugtes Format ohne Umsätze, versuche Alternative", {
account: account.externalId,
preferCamtForFetch,
});
const altResponse = await fetchStatementsAllPages(
ctx,
userId,
client,
account,
fromDate,
toDate,
!preferCamtForFetch,
syncJob,
);
if (
altResponse &&
altResponse.success &&
countStatementTransactions(altResponse) > 0
) {
statementResponse = altResponse;
}
}
if (!statementResponse || !statementResponse.success) {
console.warn("[fints] Umsatzabruf nicht erfolgreich", {
account: account.externalId,
bankAnswers: statementResponse?.bankAnswers.map((answer) => answer.text) ?? [],
});
return { balance, transactions: [] };
}
let transactions = mapStatements(statementResponse, ownNames, salaryShift);
transactions = transactions.filter(
(tx) =>
(!tx.bookingDate || tx.bookingDate >= from) &&
(!tx.bookingDate || tx.bookingDate <= to),
);
const statementCount = statementResponse.statements?.length ?? 0;
const rawTransactionCount = countStatementTransactions(statementResponse);
console.info("[fints] Umsatzabruf Ergebnis", {
account: account.externalId,
statementCount,
rawTransactionCount,
mappedTransactionCount: transactions.length,
});
return { balance, transactions };
}
function countStatementTransactions(response: StatementResponse): number {
return (
response.statements?.reduce(
(sum, statement) => sum + (statement.transactions?.length ?? 0),
0,
) ?? 0
);
}
function mapStatements(
response: StatementResponse,
ownNames: string[],
salaryShift: {
enabled: boolean;
categoryNames: string[];
dayThreshold: number;
},
): NormalizedTransaction[] {
const rows: NormalizedTransaction[] = [];
for (const statement of response.statements ?? []) {
for (const tx of statement.transactions ?? []) {
const mapped = mapFinTsTransaction(
{
valueDate: tx.valueDate,
entryDate: tx.entryDate,
amount: tx.amount,
transactionType: tx.transactionType,
bankReference: tx.bankReference,
bookingText: tx.bookingText,
purpose: tx.purpose,
remoteName: tx.remoteName,
customerReference: tx.customerReference,
},
ownNames,
salaryShift,
);
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,
categoryName: mapped.categoryName,
assignedMonth: mapped.assignedMonth,
effectiveMonth: mapped.effectiveMonth,
});
}
}
return rows;
}
async function fetchFinTsData(
ctx: ActionCtx,
userId: Id<"users">,
from: string,
to: string,
filterAccountId: Id<"accounts"> | undefined,
pin: string | undefined,
ownNames: string[],
salaryShift: {
enabled: boolean;
categoryNames: string[];
dayThreshold: number;
},
): Promise<{
accounts: NormalizedAccount[];
transactionsByAccount: Map<string, NormalizedTransaction[]>;
}> {
const bankConfig = await ctx.runQuery(internal.bank.internal.getBankConfig, { userId });
const env = resolveFintsEnv({
blz: bankConfig?.fints.blz,
url: bankConfig?.fints.url,
login: bankConfig?.fints.login,
productId: bankConfig?.fints.productId,
productVersion: bankConfig?.fints.productVersion,
tanMethodId: bankConfig?.fints.tanMethodId,
tanMediaName: bankConfig?.fints.tanMediaName,
bankingInformationJson: bankConfig?.fints.bankingInformationJson,
pin,
});
const syncJob: PendingSyncJob = { from, to, accountId: filterAccountId, provider: "fints", phase: "fetch" };
let client = createFinTsClient(env);
client = await ensureFinTsReady(client, env, ctx, userId, syncJob);
const bankAccounts = client.config.bankingInformation.upd?.bankAccounts ?? [];
const accounts = bankAccounts.map(mapBankAccount);
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 { balance, transactions } = await fetchFinTsAccountData(
client,
account,
from,
to,
ctx,
userId,
syncJob,
ownNames,
salaryShift,
);
account.balance = balance;
transactionsByAccount.set(account.externalId, transactions);
}
await ctx.runMutation(internal.bank.internal.upsertBankConfig, {
userId,
fints: {
blz: env.blz,
url: env.url,
login: env.login,
productId: env.productId,
bankingInformationJson: JSON.stringify(client.config.bankingInformation),
tanMethodId: client.config.selectedTanMethod?.id,
tanMediaName: methodMediaName(client),
},
});
return { accounts, transactionsByAccount };
}
async function persistSyncResults(
ctx: ActionCtx,
userId: Id<"users">,
provider: "comdirect" | "fints",
from: string,
to: string,
filterAccountId: Id<"accounts"> | undefined,
accounts: NormalizedAccount[],
transactionsByAccount: Map<string, NormalizedTransaction[]>,
): Promise<{ importedCount: number; skippedCount: number }> {
const accountIdMap = new Map<string, Id<"accounts">>();
for (const account of accounts) {
const convexAccountId = await ctx.runMutation(internal.bank.internal.upsertAccountFromProvider, {
userId,
externalId: account.externalId,
name: account.name,
iban: account.iban,
balance: account.balance,
currency: account.currency,
});
accountIdMap.set(account.externalId, convexAccountId);
}
const rows: ImportRow[] = [];
const rowCountsByAccount: Record<string, number> = {};
const targetAccounts = filterAccountId
? [...accountIdMap.entries()].filter(([, id]) => id === filterAccountId)
: [...accountIdMap.entries()];
for (const [externalAccountId, convexAccountId] of targetAccounts) {
const txs = transactionsByAccount.get(externalAccountId) ?? [];
rowCountsByAccount[externalAccountId] = txs.length;
for (const tx of txs) {
rows.push({
accountId: convexAccountId,
categoryName: tx.categoryName ?? "Sonstiges",
bookingDate: tx.bookingDate,
valueDate: tx.valueDate,
description: tx.description,
counterparty: tx.counterparty,
amount: tx.amount,
vorgang: tx.vorgang,
isPending: tx.isPending,
rawText: tx.rawText,
assignedMonth: tx.assignedMonth,
effectiveMonth: tx.effectiveMonth,
externalRef:
provider === "fints" && tx.externalRef
? `${externalAccountId}:${tx.externalRef}`
: tx.externalRef,
});
}
}
console.info("[bank-sync] Persistiere Umsätze", {
provider,
accountCount: accounts.length,
targetAccountCount: targetAccounts.length,
rowCount: rows.length,
rowCountsByAccount,
});
const commitResult = await ctx.runMutation(internal.imports.commitRowsInternal, {
userId,
filename: `${provider}-sync-${from}-${to}`,
source: provider === "comdirect" ? "comdirect-api" : "fints",
accountId: filterAccountId,
rows,
});
console.info("[bank-sync] Import-Ergebnis", {
provider,
importedCount: commitResult.importedCount,
skippedCount: commitResult.skippedCount,
});
if (provider === "comdirect") {
await ctx.runMutation(internal.comdirect.internal.clearSession, { userId });
}
await ctx.runMutation(internal.bank.internal.updateSyncState, {
userId,
lastSync: Date.now(),
lastProviderUsed: provider,
lastError: null,
});
await ctx.runMutation(internal.bank.internal.upsertPendingTan, {
userId,
status: "done",
challengeRef: null,
challengeMessage: null,
photoTanMimeType: null,
photoTanBase64: null,
syncJobJson: null,
});
return {
importedCount: commitResult.importedCount,
skippedCount: commitResult.skippedCount,
};
}
export const runSyncInternal = internalAction({
args: {
userId: v.id("users"),
from: v.string(),
to: v.string(),
accountId: v.optional(v.id("accounts")),
pin: v.optional(v.string()),
},
returns: v.object({
importedCount: v.number(),
skippedCount: v.number(),
provider: v.union(v.literal("comdirect"), v.literal("fints")),
awaitingTan: v.boolean(),
}),
handler: async (ctx, args) => {
const comdirectReady = hasComdirectCredentials();
await ctx.runMutation(internal.bank.internal.upsertBankConfig, {
userId: args.userId,
comdirectHasCredentials: comdirectReady,
});
const bankConfig = await ctx.runQuery(internal.bank.internal.getBankConfig, { userId: args.userId });
const preference = bankConfig?.providerPreference ?? "auto";
const settings = await ctx.runQuery(internal.settings.getInternal, { userId: args.userId });
const ownNames = settings?.ownNames ?? [];
const salaryShift = settings?.salaryShift ?? {
enabled: true,
categoryNames: ["Gehalt & Besoldung"],
dayThreshold: 25,
};
const tryFinTs = async (reason: string) => {
await logProvider(ctx, args.userId, "fints", reason);
try {
const { accounts, transactionsByAccount } = await fetchFinTsData(
ctx,
args.userId,
args.from,
args.to,
args.accountId,
args.pin,
ownNames,
salaryShift,
);
const result = await persistSyncResults(
ctx,
args.userId,
"fints",
args.from,
args.to,
args.accountId,
accounts,
transactionsByAccount,
);
return { ...result, provider: "fints" as const, awaitingTan: false };
} catch (error) {
if (error instanceof Error && error.message.includes("TAN-Freigabe Timeout")) {
await ctx.runMutation(internal.bank.internal.updateSyncState, {
userId: args.userId,
lastError: error.message,
});
throw error;
}
throw error;
}
};
const useFinTsDirect =
preference === "fints" || (preference === "auto" && !comdirectReady);
if (useFinTsDirect) {
return await tryFinTs(
!comdirectReady
? "comdirect-Credentials fehlen"
: "Provider-Präferenz FinTS",
);
}
try {
await logProvider(ctx, args.userId, "comdirect", "REST-Versuch");
const { accounts, transactionsByAccount } = await fetchComdirectData(
ctx,
args.userId,
args.from,
args.to,
args.accountId,
ownNames,
salaryShift,
);
const result = await persistSyncResults(
ctx,
args.userId,
"comdirect",
args.from,
args.to,
args.accountId,
accounts,
transactionsByAccount,
);
return { ...result, provider: "comdirect" as const, awaitingTan: false };
} catch (error) {
if (!isRestFallbackError(error)) throw error;
const reason = error instanceof Error ? error.message : "REST-Fehler";
console.warn("[bank-sync] REST fehlgeschlagen, Fallback FinTS:", reason);
return await tryFinTs(`REST-Fallback: ${reason}`);
}
},
});
export const pollTan = internalAction({
args: {
userId: v.id("users"),
attempt: v.optional(v.number()),
},
returns: v.null(),
handler: async (ctx, args) => {
const pending = await ctx.runQuery(internal.bank.internal.getPendingTan, {
userId: args.userId,
});
if (!pending || pending.status !== "awaiting") {
return null;
}
const attempt = args.attempt ?? (pending.pollAttempt ?? 0) + 1;
if (attempt > TAN_MAX_POLL_ATTEMPTS) {
await ctx.runMutation(internal.bank.internal.upsertPendingTan, {
userId: args.userId,
status: "error",
errorMessage: "TAN-Freigabe Timeout (5 Minuten)",
pollAttempt: attempt,
});
return null;
}
await ctx.scheduler.runAfter(TAN_POLL_INTERVAL_MS, internal.bank.orchestrator.pollTan, {
userId: args.userId,
attempt: attempt + 1,
});
return null;
},
});

92
convex/bank/sync.ts Normal file
View File

@@ -0,0 +1,92 @@
import { action } from "../_generated/server";
import { v } from "convex/values";
import { internal } from "../_generated/api";
import { getAuthUserId } from "@convex-dev/auth/server";
import { hasComdirectCredentials } from "./comdirectProvider";
import { getFintsConfigStatus } from "./fintsConfig";
import type { Doc } from "../_generated/dataModel";
type CapabilitiesResult = {
comdirectRestAvailable: boolean;
fintsReady: boolean;
fintsMissing: string[];
fintsWarnings: string[];
useFinTsDirect: boolean;
};
export const getCapabilities = action({
args: {},
returns: v.object({
comdirectRestAvailable: v.boolean(),
fintsReady: v.boolean(),
fintsMissing: v.array(v.string()),
fintsWarnings: v.array(v.string()),
useFinTsDirect: v.boolean(),
}),
handler: async (ctx): Promise<CapabilitiesResult> => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Nicht angemeldet");
const comdirectRestAvailable = hasComdirectCredentials();
await ctx.runMutation(internal.bank.internal.upsertBankConfig, {
userId,
comdirectHasCredentials: comdirectRestAvailable,
});
const bankConfig: Doc<"bankConfig"> | null = await ctx.runQuery(
internal.bank.internal.getBankConfig,
{ userId },
);
const preference: "auto" | "comdirect" | "fints" =
bankConfig?.providerPreference ?? "auto";
const fintsStatus = getFintsConfigStatus({
blz: bankConfig?.fints.blz,
url: bankConfig?.fints.url,
login: bankConfig?.fints.login,
productId: bankConfig?.fints.productId,
});
const useFinTsDirect: boolean =
preference === "fints" || (preference === "auto" && !comdirectRestAvailable);
return {
comdirectRestAvailable,
fintsReady: fintsStatus.ready,
fintsMissing: fintsStatus.missing,
fintsWarnings: fintsStatus.warnings,
useFinTsDirect,
};
},
});
export const run = action({
args: {
from: v.string(),
to: v.string(),
accountId: v.optional(v.id("accounts")),
pin: v.optional(v.string()),
},
returns: v.object({
importedCount: v.number(),
skippedCount: v.number(),
provider: v.union(v.literal("comdirect"), v.literal("fints")),
awaitingTan: v.boolean(),
}),
handler: async (ctx, args): Promise<{
importedCount: number;
skippedCount: number;
provider: "comdirect" | "fints";
awaitingTan: boolean;
}> => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Nicht angemeldet");
return await ctx.runAction(internal.bank.orchestrator.runSyncInternal, {
userId,
from: args.from,
to: args.to,
accountId: args.accountId,
pin: args.pin,
});
},
});

73
convex/bank/types.ts Normal file
View File

@@ -0,0 +1,73 @@
import type { Id } from "../_generated/dataModel";
export type BankProviderName = "comdirect" | "fints";
export type NormalizedAccount = {
externalId: string;
name: string;
iban?: string;
balance: number;
currency: string;
};
export type NormalizedBalance = {
externalId: string;
balance: number;
currency: string;
};
export type NormalizedTransaction = {
externalRef?: string;
bookingDate?: string;
valueDate?: string;
description: string;
counterparty?: string;
amount: number;
vorgang?: string;
isPending: boolean;
rawText?: string;
categoryName?: string;
assignedMonth?: string;
effectiveMonth?: string;
};
export interface BankDataProvider {
readonly name: BankProviderName;
getAccounts(): Promise<NormalizedAccount[]>;
getBalance(accountExternalId: string): Promise<NormalizedBalance>;
getTransactions(
accountExternalId: string,
from: string,
to: string,
): Promise<NormalizedTransaction[]>;
}
export type SyncJobState = {
phase: "init" | "fetch_accounts" | "fetch_transactions" | "persist" | "done";
from: string;
to: string;
accountId?: Id<"accounts">;
provider: BankProviderName;
accounts: NormalizedAccount[];
accountIndex: number;
rows: Array<{
accountExternalId: string;
transactions: NormalizedTransaction[];
}>;
};
export type ImportRow = {
accountId?: Id<"accounts">;
categoryName: string;
bookingDate?: string;
valueDate?: string;
description: string;
counterparty?: string;
amount: number;
vorgang?: string;
isPending: boolean;
rawText?: string;
assignedMonth?: string;
effectiveMonth?: string;
externalRef?: string;
};