From d65e7681ac104bd25b26cf1f9e106c9340584624 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 15 Jun 2026 13:56:32 +0200 Subject: [PATCH] Add bank synchronization features with FinTS support and update dependencies --- .pnpm-store/v11/index.db | Bin 0 -> 8192 bytes convex/_generated/ai/ai-files.state.json | 2 +- convex/_generated/ai/guidelines.md | 9 +- convex/_generated/api.d.ts | 18 + convex/bank/comdirectProvider.ts | 209 +++++ convex/bank/config.ts | 209 +++++ convex/bank/fintsConfig.ts | 81 ++ convex/bank/fintsMap.ts | 63 ++ convex/bank/fintsSession.ts | 161 ++++ convex/bank/internal.ts | 288 ++++++ convex/bank/orchestrator.ts | 884 +++++++++++++++++++ convex/bank/sync.ts | 92 ++ convex/bank/types.ts | 73 ++ convex/comdirect/sync.ts | 139 +-- convex/schema.ts | 48 + convex/transactions.ts | 6 +- package.json | 1 + pnpm-lock.yaml | 60 ++ src/components/import/BankConfigForm.tsx | 173 ++++ src/components/import/ComdirectSyncPanel.tsx | 132 ++- src/components/import/TanAwaitDialog.tsx | 102 +++ src/pages/ImportPage.tsx | 6 +- src/pages/SettingsPage.tsx | 3 + 23 files changed, 2609 insertions(+), 150 deletions(-) create mode 100644 .pnpm-store/v11/index.db create mode 100644 convex/bank/comdirectProvider.ts create mode 100644 convex/bank/config.ts create mode 100644 convex/bank/fintsConfig.ts create mode 100644 convex/bank/fintsMap.ts create mode 100644 convex/bank/fintsSession.ts create mode 100644 convex/bank/internal.ts create mode 100644 convex/bank/orchestrator.ts create mode 100644 convex/bank/sync.ts create mode 100644 convex/bank/types.ts create mode 100644 src/components/import/BankConfigForm.tsx create mode 100644 src/components/import/TanAwaitDialog.tsx diff --git a/.pnpm-store/v11/index.db b/.pnpm-store/v11/index.db new file mode 100644 index 0000000000000000000000000000000000000000..d583c087b2ea7237a82fd6abbd7a82897e4249b9 GIT binary patch literal 8192 zcmeIuzpBD86bA4#2p0s=&GDX11#$5OY&BppTCFMSqD0LV@%|C%po4=C;Y;~cx0O=t zfB*y_009U<00Izz00bZafgB3lKCO>xt!CY>pipIV>wEYDQ#G?7q-|A44BRz*ko}y78W!h}e%vF6aP~>|v kx0l=(WA#c7>G359KmY;|fB*y_009U<00Izz00dHje+G3k*8l(j literal 0 HcmV?d00001 diff --git a/convex/_generated/ai/ai-files.state.json b/convex/_generated/ai/ai-files.state.json index a4a6918..36896b2 100644 --- a/convex/_generated/ai/ai-files.state.json +++ b/convex/_generated/ai/ai-files.state.json @@ -1,5 +1,5 @@ { - "guidelinesHash": "62d72acb9afcc18f658d88dd772f34b5b1da5fa60ef0402e57a784d97c458e57", + "guidelinesHash": "31cdf5763fda9ffee83f538073d80fd995883c95a2bfaf4f6441010f3c391819", "agentsMdSectionHash": "5934f676ea9a332e7cd4a4f64aa23b59d926e9faca026c758d4b1f87d2101cc3", "claudeMdHash": "5934f676ea9a332e7cd4a4f64aa23b59d926e9faca026c758d4b1f87d2101cc3", "agentSkillsSha": "7a6fcc6882f344577a34365fdadbd0f8f8c467d7" diff --git a/convex/_generated/ai/guidelines.md b/convex/_generated/ai/guidelines.md index e41bedd..62240b6 100644 --- a/convex/_generated/ai/guidelines.md +++ b/convex/_generated/ai/guidelines.md @@ -1,5 +1,7 @@ # Convex guidelines +These guidelines target Convex `^1.41.0`. + ## Function guidelines ### Http endpoint syntax @@ -224,6 +226,7 @@ export const exampleQuery = query({ ``` - Be strict with types, particularly around id's of documents. For example, if a function takes in an id for a document in the 'users' table, take in `Id<'users'>` rather than `string`. +- For typed app environment variables, declare them in `convex/convex.config.ts` with `defineApp({ env: { MY_KEY: v.optional(v.string()) } })` and read them with `env` from `./_generated/server` instead of `process.env`. ## Full text search guidelines @@ -241,7 +244,7 @@ q.search("body", "hello hi").eq("channel", "#general"), - Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead. - If the user does not explicitly tell you to return all results from a query you should ALWAYS return a bounded collection instead. So that is instead of using `.collect()` you should use `.take()` or paginate on database queries. This prevents future performance issues when tables grow in an unbounded way. - Never use `.collect().length` to count rows. Convex has no built-in count operator, so if you need a count that stays efficient at scale, maintain a denormalized counter in a separate document and update it in your mutations. -- Convex queries do NOT support `.delete()`. If you need to delete all documents matching a query, use `.take(n)` to read them in batches, iterate over each batch calling `ctx.db.delete(row._id)`, and repeat until no more results are returned. +- Convex queries do NOT support `.delete()`. If you need to delete all documents matching a query, use `.take(n)` to read them in batches, iterate over each batch calling `ctx.db.delete("tasks", row._id)`, and repeat until no more results are returned. - Convex mutations are transactions with limits on the number of documents read and written. If a mutation needs to process more documents than fit in a single transaction (e.g. bulk deletion on a large table), process a batch with `.take(n)` and then call `ctx.scheduler.runAfter(0, api.myModule.myMutation, args)` to schedule itself to continue. This way each invocation stays within transaction limits. - Use `.unique()` to get a single document from a query. This method will throw an error if there are multiple documents that match the query. - When using async iteration, don't use `.collect()` or `.take(n)` on the result of a query. Instead, use the `for await (const row of query)` syntax. @@ -254,8 +257,8 @@ q.search("body", "hello hi").eq("channel", "#general"), ## Mutation guidelines -- Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.replace('tasks', taskId, { name: 'Buy milk', completed: false })` -- Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.patch('tasks', taskId, { completed: true })` +- Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.replace("tasks", taskId, { name: "Buy milk", completed: false })` +- Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.patch("tasks", taskId, { completed: true })` ## Action guidelines diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 9604986..39fcb79 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -10,6 +10,15 @@ import type * as accounts from "../accounts.js"; import type * as auth from "../auth.js"; +import type * as bank_comdirectProvider from "../bank/comdirectProvider.js"; +import type * as bank_config from "../bank/config.js"; +import type * as bank_fintsConfig from "../bank/fintsConfig.js"; +import type * as bank_fintsMap from "../bank/fintsMap.js"; +import type * as bank_fintsSession from "../bank/fintsSession.js"; +import type * as bank_internal from "../bank/internal.js"; +import type * as bank_orchestrator from "../bank/orchestrator.js"; +import type * as bank_sync from "../bank/sync.js"; +import type * as bank_types from "../bank/types.js"; import type * as categories from "../categories.js"; import type * as comdirect_auth from "../comdirect/auth.js"; import type * as comdirect_client from "../comdirect/client.js"; @@ -38,6 +47,15 @@ import type { declare const fullApi: ApiFromModules<{ accounts: typeof accounts; auth: typeof auth; + "bank/comdirectProvider": typeof bank_comdirectProvider; + "bank/config": typeof bank_config; + "bank/fintsConfig": typeof bank_fintsConfig; + "bank/fintsMap": typeof bank_fintsMap; + "bank/fintsSession": typeof bank_fintsSession; + "bank/internal": typeof bank_internal; + "bank/orchestrator": typeof bank_orchestrator; + "bank/sync": typeof bank_sync; + "bank/types": typeof bank_types; categories: typeof categories; "comdirect/auth": typeof comdirect_auth; "comdirect/client": typeof comdirect_client; diff --git a/convex/bank/comdirectProvider.ts b/convex/bank/comdirectProvider.ts new file mode 100644 index 0000000..3d62e1c --- /dev/null +++ b/convex/bank/comdirectProvider.ts @@ -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 { + 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 }; +} diff --git a/convex/bank/config.ts b/convex/bank/config.ts new file mode 100644 index 0000000..3a9b1a6 --- /dev/null +++ b/convex/bank/config.ts @@ -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 = {}; + 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; + }, +}); diff --git a/convex/bank/fintsConfig.ts b/convex/bank/fintsConfig.ts new file mode 100644 index 0000000..d58a0cc --- /dev/null +++ b/convex/bank/fintsConfig.ts @@ -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, + }; +} diff --git a/convex/bank/fintsMap.ts b/convex/bank/fintsMap.ts new file mode 100644 index 0000000..8c450ee --- /dev/null +++ b/convex/bank/fintsMap.ts @@ -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 & { + 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, + }; +} diff --git a/convex/bank/fintsSession.ts b/convex/bank/fintsSession.ts new file mode 100644 index 0000000..b0bd329 --- /dev/null +++ b/convex/bank/fintsSession.ts @@ -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 { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/convex/bank/internal.ts b/convex/bank/internal.ts new file mode 100644 index 0000000..f1a56a9 --- /dev/null +++ b/convex/bank/internal.ts @@ -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> = {}; + 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, + }); + }, +}); diff --git a/convex/bank/orchestrator.ts b/convex/bank/orchestrator.ts new file mode 100644 index 0000000..afd5ef8 --- /dev/null +++ b/convex/bank/orchestrator.ts @@ -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 +// `@@` 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) => { + 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; +}; + +async function waitForDecoupledTan( + ctx: ActionCtx, + userId: Id<"users">, + client: FinTSClient, + session: SerializedFintsSession, + initialResponse: ClientResponse, + syncJob: PendingSyncJob, +): Promise { + 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 { + 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 { + 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 { + // 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; +}> { + 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(); + + 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, +): Promise<{ importedCount: number; skippedCount: number }> { + const accountIdMap = new Map>(); + 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 = {}; + 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; + }, +}); diff --git a/convex/bank/sync.ts b/convex/bank/sync.ts new file mode 100644 index 0000000..3106981 --- /dev/null +++ b/convex/bank/sync.ts @@ -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 => { + 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, + }); + }, +}); diff --git a/convex/bank/types.ts b/convex/bank/types.ts new file mode 100644 index 0000000..3d8ddde --- /dev/null +++ b/convex/bank/types.ts @@ -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; + getBalance(accountExternalId: string): Promise; + getTransactions( + accountExternalId: string, + from: string, + to: string, + ): Promise; +} + +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; +}; diff --git a/convex/comdirect/sync.ts b/convex/comdirect/sync.ts index 5e10a3a..946f353 100644 --- a/convex/comdirect/sync.ts +++ b/convex/comdirect/sync.ts @@ -1,148 +1,37 @@ import { action } from "../_generated/server"; import { v } from "convex/values"; import { internal } from "../_generated/api"; -import type { Id } from "../_generated/dataModel"; import { getAuthUserId } from "@convex-dev/auth/server"; -import { getAccountBalances, getTransactions } from "./client"; -import { mapComdirectTransaction } from "../lib/comdirectMap"; +/** @deprecated Nutze api.bank.sync.run – leitet an den Orchestrator mit FinTS-Fallback weiter. */ export const run = action({ args: { accountId: v.optional(v.id("accounts")), from: v.string(), to: v.string(), + 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 }> => { + 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"); - 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; - - const balances = await getAccountBalances(accessToken, sessionUuid); - const accountIdMap = new Map(); - - for (const item of balances.values ?? []) { - const account = item.account as { - accountId?: string; - iban?: string; - accountType?: { text?: string }; - }; - const accountIdExternal = account?.accountId; - if (!accountIdExternal) continue; - const balanceValue = Number((item.balance as { value?: string })?.value ?? 0); - const convexAccountId = await ctx.runMutation( - internal.comdirect.internal.upsertAccountFromComdirect, - { - userId, - externalId: accountIdExternal, - name: account.accountType?.text ?? "comdirect Konto", - iban: account.iban, - balance: balanceValue, - }, - ); - accountIdMap.set(accountIdExternal, convexAccountId); - } - - const settings = await ctx.runQuery(internal.settings.getInternal, { userId }); - const ownNames = settings?.ownNames ?? []; - const salaryShift = settings?.salaryShift ?? { - enabled: true, - categoryNames: ["Gehalt & Besoldung"], - dayThreshold: 25, - }; - - const rows: Array<{ - accountId?: typeof args.accountId; - categoryName: string; - bookingDate?: string; - valueDate?: string; - description: string; - counterparty?: string; - amount: number; - vorgang?: string; - isPending: boolean; - rawText?: string; - assignedMonth?: string; - effectiveMonth?: string; - externalRef?: string; - }> = []; - - const targetAccounts = args.accountId - ? [...accountIdMap.entries()].filter(([, id]) => id === args.accountId) - : [...accountIdMap.entries()]; - - for (const [externalAccountId, convexAccountId] of targetAccounts) { - for (const state of ["BOOKED", "NOTBOOKED"] as const) { - let offset = 0; - let matches = 0; - do { - const result = await getTransactions(accessToken, sessionUuid, externalAccountId, { - transactionState: state, - pagingFirst: offset, - minBookingDate: args.from, - maxBookingDate: args.to, - }); - matches = result.paging.matches; - for (const tx of result.values ?? []) { - const mapped = mapComdirectTransaction( - tx as Parameters[0], - ownNames, - salaryShift, - ); - rows.push({ - accountId: convexAccountId, - categoryName: mapped.categoryName, - bookingDate: mapped.bookingDate, - valueDate: mapped.valueDate, - description: mapped.description, - counterparty: mapped.counterparty, - amount: mapped.amount, - vorgang: mapped.vorgang, - isPending: mapped.isPending, - rawText: mapped.rawText, - assignedMonth: mapped.assignedMonth, - effectiveMonth: mapped.effectiveMonth, - externalRef: mapped.externalRef, - }); - } - offset += result.values?.length ?? 0; - } while (offset < matches); - } - } - - const commitResult: { - importId: Id<"imports">; - importedCount: number; - skippedCount: number; - } = await ctx.runMutation(internal.imports.commitRowsInternal, { + return await ctx.runAction(internal.bank.orchestrator.runSyncInternal, { userId, - filename: `comdirect-sync-${args.from}-${args.to}`, - source: "comdirect-api", + from: args.from, + to: args.to, accountId: args.accountId, - rows, + pin: args.pin, }); - - await ctx.runMutation(internal.comdirect.internal.clearSession, { userId }); - - return { - importedCount: commitResult.importedCount, - skippedCount: commitResult.skippedCount, - }; }, }); diff --git a/convex/schema.ts b/convex/schema.ts index 7c08d1e..462d356 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -118,4 +118,52 @@ export default defineSchema({ status: v.string(), expiresAt: v.optional(v.number()), }).index("by_user", ["userId"]), + + bankConfig: defineTable({ + 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()), + }), + }).index("by_user", ["userId"]), + + syncState: defineTable({ + 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()), + }).index("by_user", ["userId"]), + + pendingTan: defineTable({ + 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()), + }).index("by_user", ["userId"]), }); diff --git a/convex/transactions.ts b/convex/transactions.ts index 0178e1f..8d1db81 100644 --- a/convex/transactions.ts +++ b/convex/transactions.ts @@ -90,7 +90,11 @@ export const list = query({ ); } - return { ...result, page }; + return { + page, + isDone: result.isDone, + continueCursor: result.continueCursor, + }; }, }); diff --git a/package.json b/package.json index 6b3896f..40933b7 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "cmdk": "^1.1.1", "convex": "^1.41.0", "date-fns": "^4.4.0", + "lib-fints": "^1.4.8", "lucide-react": "^1.18.0", "papaparse": "^5.5.3", "react": "^19.2.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c54769..b0536f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: date-fns: specifier: ^4.4.0 version: 4.4.0 + lib-fints: + specifier: ^1.4.8 + version: 1.4.8 lucide-react: specifier: ^1.18.0 version: 1.18.0(react@19.2.7) @@ -520,6 +523,9 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@nodable/entities@2.2.0': + resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@oslojs/asn1@1.0.0': resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} @@ -1355,6 +1361,9 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + anynum@1.0.0: + resolution: {integrity: sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -1583,6 +1592,13 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} + + fast-xml-parser@5.8.0: + resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==} + hasBin: true + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1717,6 +1733,10 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lib-fints@1.4.8: + resolution: {integrity: sha512-MrkTHuZDXLaRjURNetQUMYiZ1qKflO6m3/oNq5zs67NkyQxemMuUxO33FLr2OHWyA4mL/QTyHI/ICqrunQqnwA==} + engines: {node: '>=18.0.0'} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1851,6 +1871,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -2037,6 +2061,9 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + strnum@2.4.0: + resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==} + tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} @@ -2183,6 +2210,10 @@ packages: utf-8-validate: optional: true + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2520,6 +2551,8 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@nodable/entities@2.2.0': {} + '@oslojs/asn1@1.0.0': dependencies: '@oslojs/binary': 1.0.0 @@ -3308,6 +3341,8 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + anynum@1.0.0: {} + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -3554,6 +3589,19 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-xml-builder@1.2.0: + dependencies: + path-expression-matcher: 1.5.0 + xml-naming: 0.1.0 + + fast-xml-parser@5.8.0: + dependencies: + '@nodable/entities': 2.2.0 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.4.0 + xml-naming: 0.1.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -3646,6 +3694,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lib-fints@1.4.8: + dependencies: + fast-xml-parser: 5.8.0 + lightningcss-android-arm64@1.32.0: optional: true @@ -3751,6 +3803,8 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.5.0: {} + path-key@3.1.1: {} path-to-regexp@6.3.0: {} @@ -3920,6 +3974,10 @@ snapshots: source-map-js@1.2.1: {} + strnum@2.4.0: + dependencies: + anynum: 1.0.0 + tailwind-merge@3.6.0: {} tailwindcss@4.3.1: {} @@ -4025,6 +4083,8 @@ snapshots: ws@8.20.1: {} + xml-naming@0.1.0: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/src/components/import/BankConfigForm.tsx b/src/components/import/BankConfigForm.tsx new file mode 100644 index 0000000..d24b777 --- /dev/null +++ b/src/components/import/BankConfigForm.tsx @@ -0,0 +1,173 @@ +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "convex/react"; +import { api } from "../../../convex/_generated/api"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { toast } from "sonner"; + +const schema = z.object({ + providerPreference: z.enum(["auto", "comdirect", "fints"]), + fintsBlz: z.string().min(1, "BLZ erforderlich").optional(), + fintsUrl: z.string().url("Gültige URL erforderlich").optional(), + fintsLogin: z.string().optional(), + fintsProductId: z.string().optional(), + fintsProductVersion: z.string().optional(), +}); + +type FormValues = z.infer; + +export function BankConfigForm() { + const config = useQuery(api.bank.config.getConfig); + const syncState = useQuery(api.bank.config.getSyncState); + const updateConfig = useMutation(api.bank.config.updateConfig); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + providerPreference: "auto", + fintsBlz: "", + fintsUrl: "", + fintsLogin: "", + fintsProductId: "", + fintsProductVersion: "1.0.0", + }, + }); + + useEffect(() => { + if (config) { + form.reset({ + providerPreference: config.providerPreference, + fintsBlz: config.fints.blz, + fintsUrl: config.fints.url, + fintsLogin: config.fints.login, + fintsProductId: config.fints.productId, + fintsProductVersion: config.fints.productVersion ?? "1.0.0", + }); + } + }, [config, form]); + + const [saving, setSaving] = useState(false); + + const onSubmit = form.handleSubmit(async (values) => { + setSaving(true); + try { + await updateConfig({ + providerPreference: values.providerPreference, + fints: { + blz: values.fintsBlz ?? "", + url: values.fintsUrl ?? "", + login: values.fintsLogin ?? "", + productId: values.fintsProductId ?? "", + productVersion: values.fintsProductVersion, + }, + }); + toast.success("Bank-Konfiguration gespeichert"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Speichern fehlgeschlagen"); + } finally { + setSaving(false); + } + }); + + return ( + + + Bank-Sync & FinTS-Fallback + + comdirect REST wird bevorzugt. Fehlen Credentials oder schlägt REST fehl, greift FinTS + automatisch (Provider „Auto“). PIN gehört in Convex-Env (FINTS_PIN), nicht in die DB. + + + + {syncState?.lastError && ( + + Letzter Sync-Fehler: {syncState.lastError} + + )} + {syncState?.lastSync && ( +

+ Letzter erfolgreicher Sync:{" "} + {new Date(syncState.lastSync).toLocaleString("de-DE")}{" "} + {syncState.lastProviderUsed && `(${syncState.lastProviderUsed})`} +

+ )} + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + REST: COMDIRECT_CLIENT_ID/SECRET in Convex-Env. FinTS: BLZ, URL, PIN in Env – + Produkt-ID optional bis zur ZKA-Registrierung. + + + + +
+
+
+ ); +} diff --git a/src/components/import/ComdirectSyncPanel.tsx b/src/components/import/ComdirectSyncPanel.tsx index 71bb1e6..235d86c 100644 --- a/src/components/import/ComdirectSyncPanel.tsx +++ b/src/components/import/ComdirectSyncPanel.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { useAction } from "convex/react"; +import { useEffect, useState } from "react"; +import { useAction, useQuery } from "convex/react"; import { api } from "../../../convex/_generated/api"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -8,23 +8,60 @@ import { Label } from "@/components/ui/label"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { toast } from "sonner"; import { subDays, format } from "date-fns"; +import { de } from "date-fns/locale"; + +type Capabilities = { + comdirectRestAvailable: boolean; + fintsReady: boolean; + fintsMissing: string[]; + fintsWarnings: string[]; + useFinTsDirect: boolean; +}; export function ComdirectSyncPanel() { + const getCapabilities = useAction(api.bank.sync.getCapabilities); const startAuth = useAction(api.comdirect.auth.start); const confirmAuth = useAction(api.comdirect.auth.confirm); - const runSync = useAction(api.comdirect.sync.run); + const runSync = useAction(api.bank.sync.run); + const syncState = useQuery(api.bank.config.getSyncState); + const bankConfig = useQuery(api.bank.config.getConfig); + const [capabilities, setCapabilities] = useState(null); const [zugangsnummer, setZugangsnummer] = useState(""); const [pin, setPin] = useState(""); + const [fintsPin, setFintsPin] = useState(""); const [tan, setTan] = useState(""); const [challengeType, setChallengeType] = useState(null); const [photoTan, setPhotoTan] = useState(null); - const [step, setStep] = useState<"login" | "confirm" | "sync">("login"); + const [step, setStep] = useState<"login" | "confirm" | "sync">("sync"); const [from, setFrom] = useState(format(subDays(new Date(), 90), "yyyy-MM-dd")); const [to, setTo] = useState(format(new Date(), "yyyy-MM-dd")); const [loading, setLoading] = useState(false); + useEffect(() => { + void getCapabilities() + .then((cap) => { + setCapabilities(cap); + if (cap.useFinTsDirect || !cap.comdirectRestAvailable) { + setStep("sync"); + } + }) + .catch(() => { + setCapabilities(null); + }); + }, [getCapabilities]); + + const showRestLogin = + capabilities?.comdirectRestAvailable && + !capabilities.useFinTsDirect && + bankConfig?.providerPreference !== "fints"; + const handleStart = async () => { + if (!capabilities?.comdirectRestAvailable) { + toast.message("comdirect REST nicht konfiguriert – direkt über FinTS synchronisieren"); + setStep("sync"); + return; + } setLoading(true); try { const result = await startAuth({ zugangsnummer, pin }); @@ -57,13 +94,38 @@ export function ComdirectSyncPanel() { }; const handleSync = async () => { + if (capabilities && !capabilities.fintsReady && capabilities.useFinTsDirect) { + toast.error(`FinTS unvollständig: ${capabilities.fintsMissing.join(", ")} fehlt`); + return; + } + setLoading(true); try { - const result = await runSync({ from, to }); - toast.success(`${result.importedCount} importiert, ${result.skippedCount} übersprungen`); - setStep("login"); + const result = await runSync({ + from, + to, + pin: fintsPin || undefined, + }); + if (result.awaitingTan) { + toast.message("FinTS-Freigabe erforderlich – bitte photoTAN bestätigen"); + } else { + toast.success( + `${result.importedCount} importiert (${result.provider}), ${result.skippedCount} übersprungen`, + ); + if (capabilities?.fintsWarnings.length) { + toast.message(capabilities.fintsWarnings[0]); + } + setStep(capabilities?.comdirectRestAvailable && !capabilities.useFinTsDirect ? "login" : "sync"); + } } catch (e) { - toast.error(e instanceof Error ? e.message : "Sync fehlgeschlagen"); + const message = e instanceof Error ? e.message : "Sync fehlgeschlagen"; + if (syncState?.lastSync) { + toast.error( + `${message}. Letzter erfolgreicher Sync: ${format(new Date(syncState.lastSync), "PPp", { locale: de })}`, + ); + } else { + toast.error(message); + } } finally { setLoading(false); } @@ -72,28 +134,48 @@ export function ComdirectSyncPanel() { return ( - comdirect-Sync + Bank-Sync (comdirect + FinTS-Fallback) - Halbautomatischer Abruf über Convex Actions. PIN wird nicht gespeichert. Nach dem Sync werden - Tokens gelöscht. Achtung: 3× falsche TAN sperrt den Zugang. + Ohne comdirect REST-Credentials wird automatisch FinTS genutzt. Fehlende Produkt-ID + blockiert den Start nicht. + {syncState?.lastSync && ( +

+ Letzter Sync: {format(new Date(syncState.lastSync), "PPp", { locale: de })} + {syncState.lastProviderUsed && ` via ${syncState.lastProviderUsed}`} +

+ )} + - Nur lesende Endpoints. Client-ID/Secret müssen in Convex-Env gesetzt sein ( - COMDIRECT_CLIENT_ID, COMDIRECT_CLIENT_SECRET). + {capabilities?.useFinTsDirect ? ( + <> + Modus: FinTS + {capabilities.comdirectRestAvailable + ? " (Präferenz oder kein REST-Zwang)" + : " (comdirect REST-Credentials fehlen)"} + + ) : ( + <>Modus: comdirect REST mit FinTS-Fallback + )} + {capabilities?.fintsWarnings.map((w) => ( + + {w} + + ))} - {step === "login" && ( + {showRestLogin && step === "login" && ( <>
- + setZugangsnummer(e.target.value)} />
- + setPin(e.target.value)} />
+ + ) : ( +

Warte auf Freigabe in der App …

+ )} + + + ); +} diff --git a/src/pages/ImportPage.tsx b/src/pages/ImportPage.tsx index db06639..11c0b7a 100644 --- a/src/pages/ImportPage.tsx +++ b/src/pages/ImportPage.tsx @@ -1,10 +1,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { CsvImportWizard } from "@/components/import/CsvImportWizard"; import { ComdirectSyncPanel } from "@/components/import/ComdirectSyncPanel"; +import { TanAwaitDialog } from "@/components/import/TanAwaitDialog"; export function ImportPage() { return ( - + <> + + CSV-Import comdirect-Sync @@ -16,5 +19,6 @@ export function ImportPage() { + ); } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 31190ff..dc5dc1b 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -9,6 +9,7 @@ import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { toast } from "sonner"; +import { BankConfigForm } from "@/components/import/BankConfigForm"; export function SettingsPage() { const settings = useQuery(api.settings.get); @@ -50,6 +51,8 @@ export function SettingsPage() { return (
+ + Konten