Add bank synchronization features with FinTS support and update dependencies

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

View File

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

View File

@@ -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>

View 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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>