386 lines
11 KiB
TypeScript
386 lines
11 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()),
|
|
});
|
|
|
|
const accountBalanceStatus = v.union(
|
|
v.literal("fresh"),
|
|
v.literal("stale"),
|
|
v.literal("error"),
|
|
);
|
|
|
|
const accountBalanceValidator = v.object({
|
|
_id: v.id("accountBalances"),
|
|
_creationTime: v.number(),
|
|
userId: v.id("users"),
|
|
accountId: v.id("accounts"),
|
|
externalId: v.string(),
|
|
provider: v.union(v.literal("comdirect"), v.literal("fints")),
|
|
balance: v.number(),
|
|
currency: v.string(),
|
|
asOf: v.optional(v.string()),
|
|
fetchedAt: v.number(),
|
|
status: accountBalanceStatus,
|
|
errorMessage: 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 listLatestAccountBalances = internalQuery({
|
|
args: {
|
|
userId: v.id("users"),
|
|
includeArchived: v.optional(v.boolean()),
|
|
},
|
|
returns: v.array(accountBalanceValidator),
|
|
handler: async (ctx, args) => {
|
|
const accounts = await ctx.db
|
|
.query("accounts")
|
|
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
|
.collect();
|
|
const visibleAccountIds = new Set(
|
|
accounts
|
|
.filter((account) => args.includeArchived || !account.isArchived)
|
|
.map((account) => account._id),
|
|
);
|
|
const balances = await ctx.db
|
|
.query("accountBalances")
|
|
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
|
.collect();
|
|
|
|
return balances
|
|
.filter((balance) => visibleAccountIds.has(balance.accountId))
|
|
.sort((a, b) => b.fetchedAt - a.fetchedAt);
|
|
},
|
|
});
|
|
|
|
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,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const upsertAccountBalance = internalMutation({
|
|
args: {
|
|
userId: v.id("users"),
|
|
accountId: v.id("accounts"),
|
|
externalId: v.string(),
|
|
provider: v.union(v.literal("comdirect"), v.literal("fints")),
|
|
balance: v.number(),
|
|
currency: v.string(),
|
|
asOf: v.optional(v.string()),
|
|
fetchedAt: v.number(),
|
|
status: accountBalanceStatus,
|
|
errorMessage: v.optional(v.string()),
|
|
},
|
|
returns: v.id("accountBalances"),
|
|
handler: async (ctx, args) => {
|
|
const account = await ctx.db.get(args.accountId);
|
|
if (!account || account.userId !== args.userId) {
|
|
throw new Error("Konto nicht gefunden");
|
|
}
|
|
|
|
const existing = await ctx.db
|
|
.query("accountBalances")
|
|
.withIndex("by_user_account", (q) =>
|
|
q.eq("userId", args.userId).eq("accountId", args.accountId),
|
|
)
|
|
.unique();
|
|
|
|
const fields = {
|
|
userId: args.userId,
|
|
accountId: args.accountId,
|
|
externalId: args.externalId,
|
|
provider: args.provider,
|
|
balance: args.balance,
|
|
currency: args.currency,
|
|
asOf: args.asOf,
|
|
fetchedAt: args.fetchedAt,
|
|
status: args.status,
|
|
errorMessage: args.errorMessage,
|
|
};
|
|
|
|
if (existing) {
|
|
await ctx.db.patch(existing._id, fields);
|
|
return existing._id;
|
|
}
|
|
|
|
return await ctx.db.insert("accountBalances", fields);
|
|
},
|
|
});
|