feat: add guarded savings agent tools

This commit is contained in:
Matthias
2026-06-23 21:13:02 +02:00
parent 6e687dc9b5
commit b7dae31fe1
22 changed files with 3510 additions and 73 deletions

131
convex/bank/balances.ts Normal file
View File

@@ -0,0 +1,131 @@
import { action, query } from "../_generated/server";
import { v } from "convex/values";
import { internal } from "../_generated/api";
import { requireUserId } from "../lib/helpers";
import { getAuthUserId } from "@convex-dev/auth/server";
import type { Doc, Id } from "../_generated/dataModel";
const balanceProvider = v.union(v.literal("comdirect"), v.literal("fints"));
const balanceStatus = v.union(
v.literal("fresh"),
v.literal("stale"),
v.literal("error"),
v.literal("missing"),
);
export const listLatest = query({
args: {
includeArchived: v.optional(v.boolean()),
},
returns: v.array(
v.object({
accountId: v.id("accounts"),
accountName: v.string(),
accountType: v.string(),
iban: v.optional(v.string()),
externalId: v.optional(v.string()),
balance: v.union(v.number(), v.null()),
currency: v.string(),
provider: v.union(balanceProvider, v.null()),
fetchedAt: v.union(v.number(), v.null()),
asOf: v.optional(v.string()),
status: balanceStatus,
errorMessage: v.optional(v.string()),
}),
),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const accounts = await ctx.db
.query("accounts")
.withIndex("by_user", (q) => q.eq("userId", userId))
.collect();
const visibleAccounts = accounts
.filter((account) => args.includeArchived || !account.isArchived)
.sort((a, b) => a.name.localeCompare(b.name));
const balances = await ctx.db
.query("accountBalances")
.withIndex("by_user", (q) => q.eq("userId", userId))
.collect();
const latestByAccount = new Map<Id<"accounts">, Doc<"accountBalances">>();
for (const balance of balances.sort((a, b) => b.fetchedAt - a.fetchedAt)) {
if (!latestByAccount.has(balance.accountId)) {
latestByAccount.set(balance.accountId, balance);
}
}
return visibleAccounts.map((account) => {
const latest = latestByAccount.get(account._id);
if (!latest) {
return {
accountId: account._id,
accountName: account.name,
accountType: account.type,
iban: account.iban,
externalId: account.externalId,
balance: null,
currency: account.currency,
provider: null,
fetchedAt: null,
status: "missing" as const,
};
}
return {
accountId: account._id,
accountName: account.name,
accountType: account.type,
iban: account.iban,
externalId: account.externalId ?? latest.externalId,
balance: latest.balance,
currency: latest.currency,
provider: latest.provider,
fetchedAt: latest.fetchedAt,
asOf: latest.asOf,
status: latest.status,
errorMessage: latest.errorMessage,
};
});
},
});
export const refresh = action({
args: {
accountId: v.optional(v.id("accounts")),
pin: v.optional(v.string()),
},
returns: v.object({
updatedCount: v.number(),
provider: balanceProvider,
awaitingTan: v.boolean(),
errors: v.array(
v.object({
accountId: v.optional(v.id("accounts")),
externalId: v.optional(v.string()),
message: v.string(),
}),
),
}),
handler: async (
ctx,
args,
): Promise<{
updatedCount: number;
provider: "comdirect" | "fints";
awaitingTan: boolean;
errors: Array<{
accountId?: Id<"accounts">;
externalId?: string;
message: string;
}>;
}> => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Nicht angemeldet");
return await ctx.runAction(internal.bank.orchestrator.refreshBalancesInternal, {
userId,
accountId: args.accountId,
pin: args.pin,
});
},
});