initial commit

This commit is contained in:
Matthias
2026-06-15 11:33:23 +02:00
commit fc0a6fb975
155 changed files with 24526 additions and 0 deletions

120
convex/comdirect/auth.ts Normal file
View File

@@ -0,0 +1,120 @@
import { action } from "../_generated/server";
import { v } from "convex/values";
import { internal } from "../_generated/api";
import { getAuthUserId } from "@convex-dev/auth/server";
import {
activateSession,
getSecondaryToken,
getSessions,
oauthToken,
validateSession,
} from "./client";
function randomUuid(): string {
return crypto.randomUUID();
}
export const start = action({
args: {
zugangsnummer: v.string(),
pin: v.string(),
},
returns: v.object({
challengeType: v.string(),
photoTanPngBase64: v.optional(v.string()),
}),
handler: async (ctx, args) => {
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 sessionUuid = randomUuid();
const tokenResponse = await oauthToken(
{
grant_type: "password",
username: args.zugangsnummer,
password: args.pin,
},
clientId,
clientSecret,
);
const sessions = await getSessions(tokenResponse.access_token, sessionUuid);
const session = sessions[0];
if (!session) throw new Error("Keine comdirect-Session gefunden");
const challenge = await validateSession(
tokenResponse.access_token,
sessionUuid,
session.identifier,
);
await ctx.runMutation(internal.comdirect.internal.upsertSession, {
userId,
sessionUuid,
identifier: session.identifier,
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
secondaryActive: false,
challengeId: challenge.challengeId,
challengeType: challenge.challengeType,
status: "challenged",
expiresAt: Date.now() + tokenResponse.expires_in * 1000,
});
return {
challengeType: challenge.challengeType,
photoTanPngBase64:
challenge.challengeType === "P_TAN" ? challenge.challenge : undefined,
};
},
});
export const confirm = action({
args: { tan: v.optional(v.string()) },
returns: v.object({ success: v.boolean() }),
handler: async (ctx, args) => {
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.identifier || !session.challengeId) {
throw new Error("Keine aktive comdirect-Session");
}
await activateSession(
session.accessToken,
session.sessionUuid,
session.identifier,
session.challengeId,
args.tan,
);
const secondary = await getSecondaryToken(session.accessToken, clientId, clientSecret);
await ctx.runMutation(internal.comdirect.internal.upsertSession, {
userId,
sessionUuid: session.sessionUuid,
identifier: session.identifier,
accessToken: secondary.access_token,
refreshToken: secondary.refresh_token,
secondaryActive: true,
challengeId: session.challengeId,
challengeType: session.challengeType,
status: "active",
});
return { success: true };
},
});

196
convex/comdirect/client.ts Normal file
View File

@@ -0,0 +1,196 @@
const BASE_URL = "https://api.comdirect.de";
export function createRequestId(): string {
return Math.floor(Math.random() * 1_000_000_000)
.toString()
.padStart(9, "0");
}
export function buildRequestInfo(sessionUuid: string, requestId?: string): string {
return JSON.stringify({
clientRequestId: {
sessionId: sessionUuid,
requestId: requestId ?? createRequestId(),
},
});
}
export async function oauthToken(
body: Record<string, string>,
clientId: string,
clientSecret: string,
): Promise<{ access_token: string; refresh_token: string; expires_in: number }> {
const params = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
...body,
});
const response = await fetch(`${BASE_URL}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
if (!response.ok) {
throw new Error(`OAuth fehlgeschlagen: ${response.status}`);
}
return await response.json();
}
export async function getSessions(
accessToken: string,
sessionUuid: string,
): Promise<Array<{ identifier: string; sessionTanActive: boolean; activated2FA: boolean }>> {
const response = await fetch(`${BASE_URL}/api/session/clients/user/v1/sessions`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"Content-Type": "application/json",
"x-http-request-info": buildRequestInfo(sessionUuid),
},
});
if (!response.ok) throw new Error(`Sessions-Abruf fehlgeschlagen: ${response.status}`);
return await response.json();
}
export async function validateSession(
accessToken: string,
sessionUuid: string,
identifier: string,
): Promise<{ challengeId: string; challengeType: string; challenge?: string }> {
const response = await fetch(
`${BASE_URL}/api/session/clients/user/v1/sessions/${identifier}/validate`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"Content-Type": "application/json",
"x-http-request-info": buildRequestInfo(sessionUuid),
},
body: JSON.stringify({
identifier,
sessionTanActive: true,
activated2FA: true,
}),
},
);
if (response.status !== 201) {
throw new Error(`Session-Validierung fehlgeschlagen: ${response.status}`);
}
const authInfo = response.headers.get("x-once-authentication-info");
if (!authInfo) throw new Error("Keine TAN-Challenge erhalten");
const parsed = JSON.parse(authInfo) as { id: string; typ: string; challenge?: string };
return {
challengeId: parsed.id,
challengeType: parsed.typ,
challenge: parsed.challenge,
};
}
export async function activateSession(
accessToken: string,
sessionUuid: string,
identifier: string,
challengeId: string,
tan?: string,
): Promise<void> {
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"Content-Type": "application/json",
"x-http-request-info": buildRequestInfo(sessionUuid),
"x-once-authentication-info": JSON.stringify({ id: challengeId }),
};
const body: Record<string, unknown> = {
identifier,
sessionTanActive: true,
activated2FA: true,
};
if (tan) {
body.tan = tan;
}
const response = await fetch(
`${BASE_URL}/api/session/clients/user/v1/sessions/${identifier}`,
{
method: "PATCH",
headers,
body: JSON.stringify(body),
},
);
if (!response.ok) {
throw new Error(`Session-Aktivierung fehlgeschlagen: ${response.status}`);
}
}
export async function getSecondaryToken(
accessToken: string,
clientId: string,
clientSecret: string,
): Promise<{ access_token: string; refresh_token: string }> {
return await oauthToken(
{ grant_type: "cd_secondary", token: accessToken },
clientId,
clientSecret,
);
}
export async function refreshAccessToken(
refreshToken: string,
clientId: string,
clientSecret: string,
): Promise<{ access_token: string; refresh_token: string }> {
return await oauthToken(
{ grant_type: "refresh_token", refresh_token: refreshToken },
clientId,
clientSecret,
);
}
export async function getAccountBalances(
accessToken: string,
sessionUuid: string,
): Promise<{ values: Array<Record<string, unknown>> }> {
const response = await fetch(`${BASE_URL}/api/banking/clients/user/v2/accounts/balances`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"Content-Type": "application/json",
"x-http-request-info": buildRequestInfo(sessionUuid),
},
});
if (!response.ok) throw new Error(`Salden-Abruf fehlgeschlagen: ${response.status}`);
return await response.json();
}
export async function getTransactions(
accessToken: string,
sessionUuid: string,
accountId: string,
params: {
transactionState: "BOOKED" | "NOTBOOKED";
pagingFirst: number;
minBookingDate: string;
maxBookingDate: string;
},
): Promise<{ paging: { index: number; matches: number }; values: Array<Record<string, unknown>> }> {
const query = new URLSearchParams({
transactionState: params.transactionState,
"paging-first": String(params.pagingFirst),
"paging-count": "50",
"min-bookingDate": params.minBookingDate,
"max-bookingDate": params.maxBookingDate,
});
const response = await fetch(
`${BASE_URL}/api/banking/v1/accounts/${accountId}/transactions?${query.toString()}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"Content-Type": "application/json",
"x-http-request-info": buildRequestInfo(sessionUuid),
},
},
);
if (!response.ok) throw new Error(`Umsatz-Abruf fehlgeschlagen: ${response.status}`);
return await response.json();
}

View File

@@ -0,0 +1,104 @@
import { internalMutation, internalQuery } from "../_generated/server";
import { v } from "convex/values";
export const getSession = internalQuery({
args: { userId: v.id("users") },
returns: v.union(
v.object({
_id: v.id("comdirectSessions"),
sessionUuid: v.string(),
identifier: v.optional(v.string()),
accessToken: v.optional(v.string()),
refreshToken: v.optional(v.string()),
secondaryActive: v.boolean(),
challengeId: v.optional(v.string()),
challengeType: v.optional(v.string()),
status: v.string(),
}),
v.null(),
),
handler: async (ctx, args) => {
return await ctx.db
.query("comdirectSessions")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.unique();
},
});
export const upsertSession = internalMutation({
args: {
userId: v.id("users"),
sessionUuid: v.string(),
identifier: v.optional(v.string()),
accessToken: v.optional(v.string()),
refreshToken: v.optional(v.string()),
secondaryActive: v.boolean(),
challengeId: v.optional(v.string()),
challengeType: v.optional(v.string()),
status: v.string(),
expiresAt: v.optional(v.number()),
},
returns: v.id("comdirectSessions"),
handler: async (ctx, args) => {
const existing = await ctx.db
.query("comdirectSessions")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.unique();
if (existing) {
await ctx.db.patch(existing._id, args);
return existing._id;
}
return await ctx.db.insert("comdirectSessions", args);
},
});
export const clearSession = internalMutation({
args: { userId: v.id("users") },
returns: v.null(),
handler: async (ctx, args) => {
const existing = await ctx.db
.query("comdirectSessions")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.unique();
if (existing) {
await ctx.db.delete(existing._id);
}
return null;
},
});
export const upsertAccountFromComdirect = internalMutation({
args: {
userId: v.id("users"),
externalId: v.string(),
name: v.string(),
iban: v.optional(v.string()),
balance: v.number(),
},
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: "EUR",
isArchived: false,
externalId: args.externalId,
});
},
});

148
convex/comdirect/sync.ts Normal file
View File

@@ -0,0 +1,148 @@
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";
export const run = action({
args: {
accountId: v.optional(v.id("accounts")),
from: v.string(),
to: v.string(),
},
returns: v.object({
importedCount: v.number(),
skippedCount: v.number(),
}),
handler: async (ctx, args): Promise<{ importedCount: number; skippedCount: number }> => {
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, {
userId,
filename: `comdirect-sync-${args.from}-${args.to}`,
source: "comdirect-api",
accountId: args.accountId,
rows,
});
await ctx.runMutation(internal.comdirect.internal.clearSession, { userId });
return {
importedCount: commitResult.importedCount,
skippedCount: commitResult.skippedCount,
};
},
});