initial commit
This commit is contained in:
120
convex/comdirect/auth.ts
Normal file
120
convex/comdirect/auth.ts
Normal 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
196
convex/comdirect/client.ts
Normal 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();
|
||||
}
|
||||
104
convex/comdirect/internal.ts
Normal file
104
convex/comdirect/internal.ts
Normal 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
148
convex/comdirect/sync.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user