import { internalMutation, internalQuery } from "../_generated/server"; import { v } from "convex/values"; import type { Doc } from "../_generated/dataModel"; const bankConfigValidator = v.object({ _id: v.id("bankConfig"), _creationTime: v.number(), userId: v.id("users"), providerPreference: v.union( v.literal("auto"), v.literal("comdirect"), v.literal("fints"), ), comdirectHasCredentials: v.boolean(), fints: v.object({ blz: v.string(), url: v.string(), login: v.string(), productId: v.string(), productVersion: v.optional(v.string()), tanMethodId: v.optional(v.number()), tanMediaName: v.optional(v.string()), bankingInformationJson: v.optional(v.string()), }), }); const syncStateValidator = v.object({ _id: v.id("syncState"), _creationTime: v.number(), userId: v.id("users"), lastSync: v.optional(v.number()), lastProviderUsed: v.optional(v.union(v.literal("comdirect"), v.literal("fints"))), lastError: v.optional(v.string()), }); const pendingTanValidator = v.object({ _id: v.id("pendingTan"), _creationTime: v.number(), userId: v.id("users"), status: v.union( v.literal("idle"), v.literal("awaiting"), v.literal("done"), v.literal("error"), ), challengeRef: v.optional(v.string()), challengeMessage: v.optional(v.string()), photoTanMimeType: v.optional(v.string()), photoTanBase64: v.optional(v.string()), createdAt: v.number(), updatedAt: v.number(), errorMessage: v.optional(v.string()), pollAttempt: v.optional(v.number()), syncJobJson: v.optional(v.string()), isDecoupled: v.optional(v.boolean()), submittedTan: v.optional(v.string()), }); export const getBankConfig = internalQuery({ args: { userId: v.id("users") }, returns: v.union(bankConfigValidator, v.null()), handler: async (ctx, args) => { return await ctx.db .query("bankConfig") .withIndex("by_user", (q) => q.eq("userId", args.userId)) .unique(); }, }); export const getSyncState = internalQuery({ args: { userId: v.id("users") }, returns: v.union(syncStateValidator, v.null()), handler: async (ctx, args) => { return await ctx.db .query("syncState") .withIndex("by_user", (q) => q.eq("userId", args.userId)) .unique(); }, }); export const getPendingTan = internalQuery({ args: { userId: v.id("users") }, returns: v.union(pendingTanValidator, v.null()), handler: async (ctx, args) => { return await ctx.db .query("pendingTan") .withIndex("by_user", (q) => q.eq("userId", args.userId)) .unique(); }, }); export const upsertBankConfig = internalMutation({ args: { userId: v.id("users"), providerPreference: v.optional( v.union(v.literal("auto"), v.literal("comdirect"), v.literal("fints")), ), comdirectHasCredentials: v.optional(v.boolean()), fints: v.optional( v.object({ blz: v.string(), url: v.string(), login: v.string(), productId: v.string(), productVersion: v.optional(v.string()), tanMethodId: v.optional(v.number()), tanMediaName: v.optional(v.string()), bankingInformationJson: v.optional(v.string()), }), ), }, returns: v.id("bankConfig"), handler: async (ctx, args) => { const existing = await ctx.db .query("bankConfig") .withIndex("by_user", (q) => q.eq("userId", args.userId)) .unique(); if (existing) { const patch: Partial> = {}; if (args.providerPreference !== undefined) { patch.providerPreference = args.providerPreference; } if (args.comdirectHasCredentials !== undefined) { patch.comdirectHasCredentials = args.comdirectHasCredentials; } if (args.fints !== undefined) { patch.fints = { ...existing.fints, ...args.fints }; } if (Object.keys(patch).length > 0) { await ctx.db.patch(existing._id, patch); } return existing._id; } return await ctx.db.insert("bankConfig", { userId: args.userId, providerPreference: args.providerPreference ?? "auto", comdirectHasCredentials: args.comdirectHasCredentials ?? false, fints: args.fints ?? { blz: "", url: "", login: "", productId: "", }, }); }, }); export const updateSyncState = internalMutation({ args: { userId: v.id("users"), lastSync: v.optional(v.number()), lastProviderUsed: v.optional(v.union(v.literal("comdirect"), v.literal("fints"))), lastError: v.optional(v.union(v.string(), v.null())), }, returns: v.null(), handler: async (ctx, args) => { const existing = await ctx.db .query("syncState") .withIndex("by_user", (q) => q.eq("userId", args.userId)) .unique(); const patch: { lastSync?: number; lastProviderUsed?: "comdirect" | "fints"; lastError?: string; } = {}; if (args.lastSync !== undefined) patch.lastSync = args.lastSync; if (args.lastProviderUsed !== undefined) patch.lastProviderUsed = args.lastProviderUsed; if (args.lastError !== undefined) { patch.lastError = args.lastError === null ? undefined : args.lastError; } if (existing) { await ctx.db.patch(existing._id, patch); } else { await ctx.db.insert("syncState", { userId: args.userId, ...patch, }); } return null; }, }); export const upsertPendingTan = internalMutation({ args: { userId: v.id("users"), status: v.union( v.literal("idle"), v.literal("awaiting"), v.literal("done"), v.literal("error"), ), challengeRef: v.optional(v.union(v.string(), v.null())), challengeMessage: v.optional(v.union(v.string(), v.null())), photoTanMimeType: v.optional(v.union(v.string(), v.null())), photoTanBase64: v.optional(v.union(v.string(), v.null())), errorMessage: v.optional(v.union(v.string(), v.null())), pollAttempt: v.optional(v.number()), syncJobJson: v.optional(v.union(v.string(), v.null())), isDecoupled: v.optional(v.union(v.boolean(), v.null())), submittedTan: v.optional(v.union(v.string(), v.null())), }, returns: v.id("pendingTan"), handler: async (ctx, args) => { const now = Date.now(); const existing = await ctx.db .query("pendingTan") .withIndex("by_user", (q) => q.eq("userId", args.userId)) .unique(); const fields = { status: args.status, challengeRef: args.challengeRef === null ? undefined : (args.challengeRef ?? undefined), challengeMessage: args.challengeMessage === null ? undefined : (args.challengeMessage ?? undefined), photoTanMimeType: args.photoTanMimeType === null ? undefined : (args.photoTanMimeType ?? undefined), photoTanBase64: args.photoTanBase64 === null ? undefined : (args.photoTanBase64 ?? undefined), errorMessage: args.errorMessage === null ? undefined : (args.errorMessage ?? undefined), pollAttempt: args.pollAttempt, syncJobJson: args.syncJobJson === null ? undefined : (args.syncJobJson ?? undefined), isDecoupled: args.isDecoupled === null ? undefined : (args.isDecoupled ?? undefined), submittedTan: args.submittedTan === null ? undefined : (args.submittedTan ?? undefined), updatedAt: now, }; if (existing) { await ctx.db.patch(existing._id, fields); return existing._id; } return await ctx.db.insert("pendingTan", { userId: args.userId, createdAt: now, ...fields, }); }, }); export const upsertAccountFromProvider = internalMutation({ args: { userId: v.id("users"), externalId: v.string(), name: v.string(), iban: v.optional(v.string()), balance: v.number(), currency: v.string(), }, returns: v.id("accounts"), handler: async (ctx, args) => { const existing = await ctx.db .query("accounts") .withIndex("by_user_external", (q) => q.eq("userId", args.userId).eq("externalId", args.externalId), ) .unique(); if (existing) { await ctx.db.patch(existing._id, { name: args.name, iban: args.iban, }); return existing._id; } return await ctx.db.insert("accounts", { userId: args.userId, name: args.name, type: "giro", iban: args.iban, openingBalance: args.balance, currency: args.currency, isArchived: false, externalId: args.externalId, }); }, });