Add bank synchronization features with FinTS support and update dependencies
This commit is contained in:
BIN
.pnpm-store/v11/index.db
Normal file
BIN
.pnpm-store/v11/index.db
Normal file
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"guidelinesHash": "62d72acb9afcc18f658d88dd772f34b5b1da5fa60ef0402e57a784d97c458e57",
|
||||
"guidelinesHash": "31cdf5763fda9ffee83f538073d80fd995883c95a2bfaf4f6441010f3c391819",
|
||||
"agentsMdSectionHash": "5934f676ea9a332e7cd4a4f64aa23b59d926e9faca026c758d4b1f87d2101cc3",
|
||||
"claudeMdHash": "5934f676ea9a332e7cd4a4f64aa23b59d926e9faca026c758d4b1f87d2101cc3",
|
||||
"agentSkillsSha": "7a6fcc6882f344577a34365fdadbd0f8f8c467d7"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
18
convex/_generated/api.d.ts
vendored
18
convex/_generated/api.d.ts
vendored
@@ -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;
|
||||
|
||||
209
convex/bank/comdirectProvider.ts
Normal file
209
convex/bank/comdirectProvider.ts
Normal 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
209
convex/bank/config.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
81
convex/bank/fintsConfig.ts
Normal file
81
convex/bank/fintsConfig.ts
Normal 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
63
convex/bank/fintsMap.ts
Normal 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
161
convex/bank/fintsSession.ts
Normal 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
288
convex/bank/internal.ts
Normal 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
884
convex/bank/orchestrator.ts
Normal 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
92
convex/bank/sync.ts
Normal 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
73
convex/bank/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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<string, typeof args.accountId>();
|
||||
|
||||
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<typeof mapComdirectTransaction>[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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"]),
|
||||
});
|
||||
|
||||
@@ -90,7 +90,11 @@ export const list = query({
|
||||
);
|
||||
}
|
||||
|
||||
return { ...result, page };
|
||||
return {
|
||||
page,
|
||||
isDone: result.isDone,
|
||||
continueCursor: result.continueCursor,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
60
pnpm-lock.yaml
generated
60
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
173
src/components/import/BankConfigForm.tsx
Normal file
173
src/components/import/BankConfigForm.tsx
Normal file
@@ -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<typeof schema>;
|
||||
|
||||
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<FormValues>({
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bank-Sync & FinTS-Fallback</CardTitle>
|
||||
<CardDescription>
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{syncState?.lastError && (
|
||||
<Alert className="border-destructive/50 text-destructive">
|
||||
<AlertDescription>Letzter Sync-Fehler: {syncState.lastError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{syncState?.lastSync && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Letzter erfolgreicher Sync:{" "}
|
||||
{new Date(syncState.lastSync).toLocaleString("de-DE")}{" "}
|
||||
{syncState.lastProviderUsed && `(${syncState.lastProviderUsed})`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label>Provider-Präferenz</Label>
|
||||
<Select
|
||||
value={form.watch("providerPreference")}
|
||||
onValueChange={(v) =>
|
||||
form.setValue("providerPreference", v as FormValues["providerPreference"])
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-2 w-full sm:w-[280px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto (REST → FinTS-Fallback)</SelectItem>
|
||||
<SelectItem value="comdirect">Nur comdirect REST</SelectItem>
|
||||
<SelectItem value="fints">Nur FinTS</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="fintsBlz">FinTS BLZ</Label>
|
||||
<Input id="fintsBlz" {...form.register("fintsBlz")} placeholder="20041111" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="fintsUrl">FinTS URL</Label>
|
||||
<Input
|
||||
id="fintsUrl"
|
||||
{...form.register("fintsUrl")}
|
||||
placeholder="https://fints.comdirect.de/fints"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="fintsLogin">Zugangsnummer (Login)</Label>
|
||||
<Input id="fintsLogin" {...form.register("fintsLogin")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="fintsProductId">Produkt-ID (ZKA, optional)</Label>
|
||||
<Input
|
||||
id="fintsProductId"
|
||||
{...form.register("fintsProductId")}
|
||||
placeholder="Nach Registrierung eintragen"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="fintsProductVersion">Produkt-Version</Label>
|
||||
<Input id="fintsProductVersion" {...form.register("fintsProductVersion")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
REST: COMDIRECT_CLIENT_ID/SECRET in Convex-Env. FinTS: BLZ, URL, PIN in Env –
|
||||
Produkt-ID optional bis zur ZKA-Registrierung.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button type="submit" disabled={saving}>
|
||||
Konfiguration speichern
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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<Capabilities | null>(null);
|
||||
const [zugangsnummer, setZugangsnummer] = useState("");
|
||||
const [pin, setPin] = useState("");
|
||||
const [fintsPin, setFintsPin] = useState("");
|
||||
const [tan, setTan] = useState("");
|
||||
const [challengeType, setChallengeType] = useState<string | null>(null);
|
||||
const [photoTan, setPhotoTan] = useState<string | null>(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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>comdirect-Sync</CardTitle>
|
||||
<CardTitle>Bank-Sync (comdirect + FinTS-Fallback)</CardTitle>
|
||||
<CardDescription>
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{syncState?.lastSync && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Letzter Sync: {format(new Date(syncState.lastSync), "PPp", { locale: de })}
|
||||
{syncState.lastProviderUsed && ` via ${syncState.lastProviderUsed}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Nur lesende Endpoints. Client-ID/Secret müssen in Convex-Env gesetzt sein (
|
||||
COMDIRECT_CLIENT_ID, COMDIRECT_CLIENT_SECRET).
|
||||
{capabilities?.useFinTsDirect ? (
|
||||
<>
|
||||
Modus: <strong>FinTS</strong>
|
||||
{capabilities.comdirectRestAvailable
|
||||
? " (Präferenz oder kein REST-Zwang)"
|
||||
: " (comdirect REST-Credentials fehlen)"}
|
||||
</>
|
||||
) : (
|
||||
<>Modus: comdirect REST mit FinTS-Fallback</>
|
||||
)}
|
||||
{capabilities?.fintsWarnings.map((w) => (
|
||||
<span key={w} className="mt-2 block text-amber-600 dark:text-amber-400">
|
||||
{w}
|
||||
</span>
|
||||
))}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{step === "login" && (
|
||||
{showRestLogin && step === "login" && (
|
||||
<>
|
||||
<div>
|
||||
<Label>Zugangsnummer</Label>
|
||||
<Label>Zugangsnummer (REST)</Label>
|
||||
<Input value={zugangsnummer} onChange={(e) => setZugangsnummer(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Online-Banking-PIN</Label>
|
||||
<Label>Online-Banking-PIN (REST, transient)</Label>
|
||||
<Input type="password" value={pin} onChange={(e) => setPin(e.target.value)} />
|
||||
</div>
|
||||
<Button onClick={handleStart} disabled={loading}>
|
||||
@@ -102,10 +184,12 @@ export function ComdirectSyncPanel() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "confirm" && (
|
||||
{step === "confirm" && showRestLogin && (
|
||||
<>
|
||||
{challengeType === "P_TAN_PUSH" && (
|
||||
<p className="text-sm">Bitte die Push-TAN in der comdirect-App freigeben, dann bestätigen.</p>
|
||||
<p className="text-sm">
|
||||
Bitte die Push-TAN in der comdirect-App freigeben, dann bestätigen.
|
||||
</p>
|
||||
)}
|
||||
{photoTan && (
|
||||
<img src={`data:image/png;base64,${photoTan}`} alt="photoTAN" className="mx-auto max-w-xs" />
|
||||
@@ -122,8 +206,18 @@ export function ComdirectSyncPanel() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "sync" && (
|
||||
{(step === "sync" || capabilities?.useFinTsDirect) && (
|
||||
<>
|
||||
{capabilities?.useFinTsDirect && (
|
||||
<div>
|
||||
<Label>FinTS-PIN (optional, wenn FINTS_PIN nicht in Env)</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={fintsPin}
|
||||
onChange={(e) => setFintsPin(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label>Von</Label>
|
||||
|
||||
102
src/components/import/TanAwaitDialog.tsx
Normal file
102
src/components/import/TanAwaitDialog.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function TanAwaitDialog() {
|
||||
const pendingTan = useQuery(api.bank.config.getPendingTan);
|
||||
const submitTan = useMutation(api.bank.config.submitTan);
|
||||
const [tan, setTan] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingTan?.status === "done") {
|
||||
toast.success("TAN bestätigt – Sync wird fortgesetzt");
|
||||
setTan("");
|
||||
}
|
||||
if (pendingTan?.status === "error" && pendingTan.errorMessage) {
|
||||
toast.error(pendingTan.errorMessage);
|
||||
setTan("");
|
||||
}
|
||||
}, [pendingTan?.status, pendingTan?.errorMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingTan?.status !== "awaiting") {
|
||||
setTan("");
|
||||
}
|
||||
}, [pendingTan?.status, pendingTan?._id]);
|
||||
|
||||
const open = pendingTan?.status === "awaiting";
|
||||
const needsManualTan = pendingTan?.isDecoupled !== true;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!tan.trim()) {
|
||||
toast.error("Bitte die TAN aus der photoTAN-App eingeben");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitTan({ tan: tan.trim() });
|
||||
toast.message("TAN übermittelt – Sync läuft weiter …");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "TAN konnte nicht übermittelt werden");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogContent className="sm:max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>photoTAN erforderlich</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{pendingTan?.challengeMessage ??
|
||||
(needsManualTan
|
||||
? "Scannen Sie das Bild mit der photoTAN-App und geben Sie die angezeigte TAN hier ein."
|
||||
: "Bitte die Freigabe in der photoTAN-App bestätigen.")}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
{pendingTan?.photoTanBase64 && (
|
||||
<img
|
||||
src={`data:${pendingTan.photoTanMimeType ?? "image/png"};base64,${pendingTan.photoTanBase64}`}
|
||||
alt="photoTAN"
|
||||
className="mx-auto max-w-xs"
|
||||
/>
|
||||
)}
|
||||
{needsManualTan ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="fints-tan">TAN aus der photoTAN-App</Label>
|
||||
<Input
|
||||
id="fints-tan"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="z. B. 123456"
|
||||
value={tan}
|
||||
onChange={(e) => setTan(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleSubmit();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button className="w-full" onClick={() => void handleSubmit()} disabled={submitting}>
|
||||
TAN bestätigen
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-sm text-muted-foreground">Warte auf Freigabe in der App …</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Tabs defaultValue="csv">
|
||||
<>
|
||||
<TanAwaitDialog />
|
||||
<Tabs defaultValue="csv">
|
||||
<TabsList>
|
||||
<TabsTrigger value="csv">CSV-Import</TabsTrigger>
|
||||
<TabsTrigger value="comdirect">comdirect-Sync</TabsTrigger>
|
||||
@@ -16,5 +19,6 @@ export function ImportPage() {
|
||||
<ComdirectSyncPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<BankConfigForm />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Konten</CardTitle>
|
||||
|
||||
Reference in New Issue
Block a user