Files
finanzen/convex/bank/internal.ts

289 lines
8.4 KiB
TypeScript

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