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

View File

@@ -10,6 +10,7 @@
import type * as accounts from "../accounts.js";
import type * as auth from "../auth.js";
import type * as bank_balances from "../bank/balances.js";
import type * as bank_comdirectProvider from "../bank/comdirectProvider.js";
import type * as bank_config from "../bank/config.js";
import type * as bank_fintsConfig from "../bank/fintsConfig.js";
@@ -35,6 +36,7 @@ import type * as lib_month from "../lib/month.js";
import type * as lib_seedCategories from "../lib/seedCategories.js";
import type * as loans from "../loans.js";
import type * as savingsChat from "../savingsChat.js";
import type * as savingsChatActionPlans from "../savingsChatActionPlans.js";
import type * as savingsChatHistory from "../savingsChatHistory.js";
import type * as settings from "../settings.js";
import type * as transactions from "../transactions.js";
@@ -49,6 +51,7 @@ import type {
declare const fullApi: ApiFromModules<{
accounts: typeof accounts;
auth: typeof auth;
"bank/balances": typeof bank_balances;
"bank/comdirectProvider": typeof bank_comdirectProvider;
"bank/config": typeof bank_config;
"bank/fintsConfig": typeof bank_fintsConfig;
@@ -74,6 +77,7 @@ declare const fullApi: ApiFromModules<{
"lib/seedCategories": typeof lib_seedCategories;
loans: typeof loans;
savingsChat: typeof savingsChat;
savingsChatActionPlans: typeof savingsChatActionPlans;
savingsChatHistory: typeof savingsChatHistory;
settings: typeof settings;
transactions: typeof transactions;

178
convex/balances.test.ts Normal file
View File

@@ -0,0 +1,178 @@
/// <reference types="vite/client" />
import { convexTest } from "convex-test";
import { makeFunctionReference } from "convex/server";
import { describe, expect, test } from "vitest";
import type { Id } from "./_generated/dataModel";
import schema from "./schema";
const modules = import.meta.glob("./**/*.ts");
delete modules["./balances.test.ts"];
const listLatest = makeFunctionReference<"query">("bank/balances:listLatest");
const upsertAccountBalance = makeFunctionReference<"mutation">(
"bank/internal:upsertAccountBalance",
);
type LatestBalanceRow = {
accountId: Id<"accounts">;
accountName: string;
balance: number | null;
status: string;
errorMessage?: string;
};
async function seedBalanceFixture() {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Balance User",
email: "balance@example.com",
});
const otherUserId = await ctx.db.insert("users", {
name: "Other User",
email: "other-balance@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "giro",
iban: "DE89370400440532013000",
openingBalance: 125,
currency: "EUR",
isArchived: false,
externalId: "giro-1",
});
const archivedAccountId = await ctx.db.insert("accounts", {
userId,
name: "Altes Konto",
type: "giro",
openingBalance: 50,
currency: "EUR",
isArchived: true,
externalId: "old-1",
});
const otherAccountId = await ctx.db.insert("accounts", {
userId: otherUserId,
name: "Hidden Konto",
type: "giro",
openingBalance: 999,
currency: "EUR",
isArchived: false,
externalId: "hidden-1",
});
return { userId, accountId, archivedAccountId, otherUserId, otherAccountId };
});
return {
t,
seeded,
asUser: t.withIdentity({
subject: `${seeded.userId}|test-session`,
tokenIdentifier: `test:${seeded.userId}`,
}),
};
}
describe("bank/balances.listLatest", () => {
test("returns a missing state for active accounts without live balance snapshots", async () => {
const { asUser, seeded } = await seedBalanceFixture();
const result = await asUser.query(listLatest, {});
expect(result).toEqual([
{
accountId: seeded.accountId,
accountName: "Girokonto",
accountType: "giro",
iban: "DE89370400440532013000",
externalId: "giro-1",
balance: null,
currency: "EUR",
provider: null,
fetchedAt: null,
status: "missing",
},
]);
});
test("returns the newest live balance without mutating openingBalance", async () => {
const { t, asUser, seeded } = await seedBalanceFixture();
await t.mutation(upsertAccountBalance, {
userId: seeded.userId,
accountId: seeded.accountId as Id<"accounts">,
externalId: "giro-1",
provider: "fints",
balance: 900,
currency: "EUR",
fetchedAt: 1_780_000_000_000,
status: "fresh",
});
await t.mutation(upsertAccountBalance, {
userId: seeded.userId,
accountId: seeded.accountId as Id<"accounts">,
externalId: "giro-1",
provider: "comdirect",
balance: 1234.56,
currency: "EUR",
asOf: "2026-06-23",
fetchedAt: 1_780_000_060_000,
status: "fresh",
});
const result = await asUser.query(listLatest, {});
const account = await t.run((ctx) => ctx.db.get(seeded.accountId));
expect(result).toMatchObject([
{
accountId: seeded.accountId,
accountName: "Girokonto",
balance: 1234.56,
currency: "EUR",
provider: "comdirect",
fetchedAt: 1_780_000_060_000,
asOf: "2026-06-23",
status: "fresh",
},
]);
expect(account?.openingBalance).toBe(125);
});
test("can include archived accounts for settings and preserves error snapshots", async () => {
const { t, asUser, seeded } = await seedBalanceFixture();
await t.mutation(upsertAccountBalance, {
userId: seeded.userId,
accountId: seeded.archivedAccountId as Id<"accounts">,
externalId: "old-1",
provider: "fints",
balance: 50,
currency: "EUR",
fetchedAt: 1_780_000_060_000,
status: "error",
errorMessage: "HKSAL wird nicht unterstuetzt",
});
const defaultResult = await asUser.query(listLatest, {});
const settingsResult = await asUser.query(listLatest, { includeArchived: true });
expect(defaultResult.map((row: LatestBalanceRow) => row.accountId)).toEqual([
seeded.accountId,
]);
expect(settingsResult).toHaveLength(2);
expect(
settingsResult.find(
(row: LatestBalanceRow) => row.accountId === seeded.archivedAccountId,
),
)
.toMatchObject({
accountName: "Altes Konto",
balance: 50,
status: "error",
errorMessage: "HKSAL wird nicht unterstuetzt",
});
});
});

View File

@@ -0,0 +1,34 @@
import { describe, expect, test } from "vitest";
import { mapComdirectBalances } from "./comdirectProvider";
describe("mapComdirectBalances", () => {
test("normalizes comdirect balance payloads into provider balances", () => {
const result = mapComdirectBalances({
values: [
{
account: {
accountId: "account-1",
iban: "DE89370400440532013000",
accountType: { text: "Girokonto" },
},
balance: { value: "1234.56", unit: "EUR" },
date: "2026-06-23",
},
{
account: {},
balance: { value: "999" },
},
],
});
expect(result).toEqual([
{
externalId: "account-1",
balance: 1234.56,
currency: "EUR",
asOf: "2026-06-23",
},
]);
});
});

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

View File

@@ -33,6 +33,77 @@ type ComdirectProviderContext = {
userId: Id<"users">;
};
type ComdirectBalancePayload = {
values?: Array<Record<string, unknown>>;
};
function getNestedText(value: unknown): string | undefined {
if (!value || typeof value !== "object") return undefined;
const text = (value as { text?: unknown }).text;
return typeof text === "string" ? text : undefined;
}
export function mapComdirectBalances(
payload: ComdirectBalancePayload,
): NormalizedBalance[] {
return (payload.values ?? []).flatMap((item) => {
const account = item.account as
| {
accountId?: unknown;
}
| undefined;
const balance = item.balance as
| {
value?: unknown;
unit?: unknown;
}
| undefined;
const externalId =
typeof account?.accountId === "string" ? account.accountId : undefined;
if (!externalId) return [];
const balanceValue = Number(balance?.value ?? 0);
const currency = typeof balance?.unit === "string" ? balance.unit : "EUR";
const asOf = typeof item.date === "string" ? item.date : undefined;
return [
{
externalId,
balance: Number.isFinite(balanceValue) ? balanceValue : 0,
currency,
asOf,
},
];
});
}
export function mapComdirectAccounts(
payload: ComdirectBalancePayload,
): NormalizedAccount[] {
return (payload.values ?? []).flatMap((item) => {
const account = item.account as
| {
accountId?: unknown;
iban?: unknown;
accountType?: unknown;
}
| undefined;
const accountIdExternal =
typeof account?.accountId === "string" ? account.accountId : undefined;
if (!accountIdExternal) return [];
const balance = mapComdirectBalances({ values: [item] })[0];
return [
{
externalId: accountIdExternal,
name: getNestedText(account?.accountType) ?? "comdirect Konto",
iban: typeof account?.iban === "string" ? account.iban : undefined,
balance: balance?.balance ?? 0,
currency: balance?.currency ?? "EUR",
},
];
});
}
export async function createComdirectRestProvider(
context: ComdirectProviderContext,
): Promise<BankDataProvider> {
@@ -57,39 +128,16 @@ export async function createComdirectRestProvider(
async getAccounts(): Promise<NormalizedAccount[]> {
const balances = await getAccountBalances(accessToken, sessionUuid);
return (balances.values ?? []).flatMap((item) => {
const account = item.account as {
accountId?: string;
iban?: string;
accountType?: { text?: string };
};
const accountIdExternal = account?.accountId;
if (!accountIdExternal) return [];
const balanceValue = Number((item.balance as { value?: string })?.value ?? 0);
return [
{
externalId: accountIdExternal,
name: account.accountType?.text ?? "comdirect Konto",
iban: account.iban,
balance: balanceValue,
currency: "EUR",
},
];
});
return mapComdirectAccounts(balances);
},
async getBalance(accountExternalId: string): Promise<NormalizedBalance> {
const balances = await getAccountBalances(accessToken, sessionUuid);
const match = (balances.values ?? []).find((item) => {
const account = item.account as { accountId?: string };
return account?.accountId === accountExternalId;
});
const match = mapComdirectBalances(balances).find(
(item) => item.externalId === accountExternalId,
);
if (!match) throw new Error(`Konto ${accountExternalId} nicht gefunden`);
return {
externalId: accountExternalId,
balance: Number((match.balance as { value?: string })?.value ?? 0),
currency: "EUR",
};
return match;
},
async getTransactions(
@@ -100,7 +148,7 @@ export async function createComdirectRestProvider(
const rows: NormalizedTransaction[] = [];
for (const state of ["BOOKED", "NOTBOOKED"] as const) {
let offset = 0;
let matches = 0;
let matches: number;
do {
const result = await getTransactions(accessToken, sessionUuid, accountExternalId, {
transactionState: state,
@@ -207,3 +255,53 @@ export async function fetchComdirectData(
return { accounts, transactionsByAccount };
}
export async function fetchComdirectBalanceData(
ctx: ActionCtx,
userId: Id<"users">,
filterAccountId: Id<"accounts"> | undefined,
): Promise<{
accounts: NormalizedAccount[];
balances: NormalizedBalance[];
}> {
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 payload = await getAccountBalances(session.accessToken, session.sessionUuid);
const accounts = mapComdirectAccounts(payload);
const balances = mapComdirectBalances(payload);
if (!filterAccountId) {
return { accounts, balances };
}
const filteredAccounts: NormalizedAccount[] = [];
const allowedExternalIds = new Set<string>();
for (const account of accounts) {
const convexId = await ctx.runMutation(internal.bank.internal.upsertAccountFromProvider, {
userId,
externalId: account.externalId,
name: account.name,
iban: account.iban,
balance: account.balance,
currency: account.currency,
});
if (convexId === filterAccountId) {
filteredAccounts.push(account);
allowedExternalIds.add(account.externalId);
}
}
return {
accounts: filteredAccounts,
balances: balances.filter((balance) => allowedExternalIds.has(balance.externalId)),
};
}

View File

@@ -56,6 +56,27 @@ const pendingTanValidator = v.object({
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()),
@@ -89,6 +110,33 @@ export const getPendingTan = internalQuery({
},
});
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"),
@@ -286,3 +334,52 @@ export const upsertAccountFromProvider = internalMutation({
});
},
});
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);
},
});

View File

@@ -25,11 +25,17 @@ import {
} from "./fintsSession";
import { mapFinTsTransaction } from "./fintsMap";
import {
fetchComdirectBalanceData,
fetchComdirectData,
hasComdirectCredentials,
isRestFallbackError,
} from "./comdirectProvider";
import type { ImportRow, NormalizedAccount, NormalizedTransaction } from "./types";
import type {
ImportRow,
NormalizedAccount,
NormalizedBalance,
NormalizedTransaction,
} from "./types";
export const TAN_POLL_INTERVAL_MS = 4000;
export const TAN_TIMEOUT_MS = 5 * 60 * 1000;
@@ -96,6 +102,12 @@ type PendingSyncJob = {
partialTransactions?: Record<string, NormalizedTransaction[]>;
};
type BalanceRefreshError = {
accountId?: Id<"accounts">;
externalId?: string;
message: string;
};
async function waitForDecoupledTan(
ctx: ActionCtx,
userId: Id<"users">,
@@ -368,6 +380,34 @@ async function fetchStatementsAllPages(
return resp;
}
async function fetchFinTsAccountBalance(
ctx: ActionCtx,
userId: Id<"users">,
client: FinTSClient,
account: NormalizedAccount,
syncJob: PendingSyncJob,
): Promise<NormalizedBalance> {
if (!client.canGetAccountBalance(account.externalId)) {
throw new Error("Konto unterstützt keinen HKSAL-Kontostandabruf");
}
let balanceResponse = await client.getAccountBalance(account.externalId);
balanceResponse = await resolveTanResponse(
ctx,
userId,
client,
balanceResponse,
"balance",
syncJob,
);
return {
externalId: account.externalId,
balance: balanceResponse.balance?.balance ?? account.balance,
currency: balanceResponse.balance?.currency ?? account.currency,
};
}
async function fetchFinTsAccountData(
client: FinTSClient,
account: NormalizedAccount,
@@ -408,16 +448,14 @@ async function fetchFinTsAccountData(
let balance = account.balance;
if (canFetchBalance) {
let balanceResponse = await client.getAccountBalance(account.externalId);
balanceResponse = await resolveTanResponse(
const balanceResult = await fetchFinTsAccountBalance(
ctx,
userId,
client,
balanceResponse,
"balance",
account,
syncJob,
);
balance = balanceResponse.balance?.balance ?? account.balance;
balance = balanceResult.balance;
} else {
console.warn("[fints] Konto unterstützt keinen HKSAL-Kontostandabruf", {
account: account.externalId,
@@ -639,6 +677,156 @@ async function fetchFinTsData(
return { accounts, transactionsByAccount };
}
async function fetchFinTsBalanceData(
ctx: ActionCtx,
userId: Id<"users">,
filterAccountId: Id<"accounts"> | undefined,
pin: string | undefined,
): Promise<{
accounts: NormalizedAccount[];
balances: NormalizedBalance[];
errors: BalanceRefreshError[];
}> {
const bankConfig = await ctx.runQuery(internal.bank.internal.getBankConfig, { userId });
const env = resolveFintsEnv({
blz: bankConfig?.fints.blz,
url: bankConfig?.fints.url,
login: bankConfig?.fints.login,
productId: bankConfig?.fints.productId,
productVersion: bankConfig?.fints.productVersion,
tanMethodId: bankConfig?.fints.tanMethodId,
tanMediaName: bankConfig?.fints.tanMediaName,
bankingInformationJson: bankConfig?.fints.bankingInformationJson,
pin,
});
const today = new Date().toISOString().slice(0, 10);
const syncJob: PendingSyncJob = {
from: today,
to: today,
accountId: filterAccountId,
provider: "fints",
phase: "fetch",
};
let client = createFinTsClient(env);
client = await ensureFinTsReady(client, env, ctx, userId, syncJob);
const bankAccounts = client.config.bankingInformation.upd?.bankAccounts ?? [];
const accounts = bankAccounts.map(mapBankAccount);
const balances: NormalizedBalance[] = [];
const errors: BalanceRefreshError[] = [];
for (const account of accounts) {
try {
const balance = await fetchFinTsAccountBalance(
ctx,
userId,
client,
account,
syncJob,
);
account.balance = balance.balance;
balances.push(balance);
} catch (error) {
errors.push({
externalId: account.externalId,
message: error instanceof Error ? error.message : "Kontostandabruf fehlgeschlagen",
});
}
}
await ctx.runMutation(internal.bank.internal.upsertBankConfig, {
userId,
fints: {
blz: env.blz,
url: env.url,
login: env.login,
productId: env.productId,
bankingInformationJson: JSON.stringify(client.config.bankingInformation),
tanMethodId: client.config.selectedTanMethod?.id,
tanMediaName: methodMediaName(client),
},
});
return { accounts, balances, errors };
}
async function persistBalanceResults(
ctx: ActionCtx,
userId: Id<"users">,
provider: "comdirect" | "fints",
filterAccountId: Id<"accounts"> | undefined,
accounts: NormalizedAccount[],
balances: NormalizedBalance[],
errors: BalanceRefreshError[],
): Promise<{ updatedCount: number; errors: BalanceRefreshError[] }> {
const fetchedAt = Date.now();
const accountIdMap = new Map<string, Id<"accounts">>();
const accountByExternalId = new Map(accounts.map((account) => [account.externalId, account]));
for (const account of accounts) {
const convexAccountId = await ctx.runMutation(internal.bank.internal.upsertAccountFromProvider, {
userId,
externalId: account.externalId,
name: account.name,
iban: account.iban,
balance: account.balance,
currency: account.currency,
});
accountIdMap.set(account.externalId, convexAccountId);
}
let updatedCount = 0;
for (const balance of balances) {
const accountId = accountIdMap.get(balance.externalId);
if (!accountId || (filterAccountId && accountId !== filterAccountId)) continue;
await ctx.runMutation(internal.bank.internal.upsertAccountBalance, {
userId,
accountId,
externalId: balance.externalId,
provider,
balance: balance.balance,
currency: balance.currency,
asOf: balance.asOf,
fetchedAt,
status: "fresh",
});
updatedCount += 1;
}
const scopedErrors: BalanceRefreshError[] = [];
for (const error of errors) {
const externalId = error.externalId;
const accountId = externalId ? accountIdMap.get(externalId) : error.accountId;
if (filterAccountId && accountId !== filterAccountId) continue;
scopedErrors.push({ ...error, accountId });
const account = externalId ? accountByExternalId.get(externalId) : undefined;
if (accountId && account) {
await ctx.runMutation(internal.bank.internal.upsertAccountBalance, {
userId,
accountId,
externalId: account.externalId,
provider,
balance: account.balance,
currency: account.currency,
fetchedAt,
status: "error",
errorMessage: error.message,
});
}
}
await ctx.runMutation(internal.bank.internal.updateSyncState, {
userId,
lastSync: updatedCount > 0 ? fetchedAt : undefined,
lastProviderUsed: provider,
lastError: scopedErrors[0]?.message ?? null,
});
return { updatedCount, errors: scopedErrors };
}
async function persistSyncResults(
ctx: ActionCtx,
userId: Id<"users">,
@@ -650,6 +838,7 @@ async function persistSyncResults(
transactionsByAccount: Map<string, NormalizedTransaction[]>,
): Promise<{ importedCount: number; skippedCount: number }> {
const accountIdMap = new Map<string, Id<"accounts">>();
const fetchedAt = Date.now();
for (const account of accounts) {
const convexAccountId = await ctx.runMutation(internal.bank.internal.upsertAccountFromProvider, {
userId,
@@ -660,6 +849,16 @@ async function persistSyncResults(
currency: account.currency,
});
accountIdMap.set(account.externalId, convexAccountId);
await ctx.runMutation(internal.bank.internal.upsertAccountBalance, {
userId,
accountId: convexAccountId,
externalId: account.externalId,
provider,
balance: account.balance,
currency: account.currency,
fetchedAt,
status: "fresh",
});
}
const rows: ImportRow[] = [];
@@ -850,6 +1049,100 @@ export const runSyncInternal = internalAction({
},
});
export const refreshBalancesInternal = internalAction({
args: {
userId: v.id("users"),
accountId: v.optional(v.id("accounts")),
pin: v.optional(v.string()),
},
returns: v.object({
updatedCount: v.number(),
provider: v.union(v.literal("comdirect"), v.literal("fints")),
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) => {
const comdirectReady = hasComdirectCredentials();
await ctx.runMutation(internal.bank.internal.upsertBankConfig, {
userId: args.userId,
comdirectHasCredentials: comdirectReady,
});
const bankConfig = await ctx.runQuery(internal.bank.internal.getBankConfig, {
userId: args.userId,
});
const preference = bankConfig?.providerPreference ?? "auto";
const useFinTsDirect =
preference === "fints" || (preference === "auto" && !comdirectReady);
const tryFinTs = async (reason: string) => {
await logProvider(ctx, args.userId, "fints", reason);
const { accounts, balances, errors } = await fetchFinTsBalanceData(
ctx,
args.userId,
args.accountId,
args.pin,
);
const result = await persistBalanceResults(
ctx,
args.userId,
"fints",
args.accountId,
accounts,
balances,
errors,
);
return {
...result,
provider: "fints" as const,
awaitingTan: false,
};
};
if (useFinTsDirect) {
return await tryFinTs(
!comdirectReady
? "comdirect-Credentials fehlen"
: "Provider-Präferenz FinTS",
);
}
try {
await logProvider(ctx, args.userId, "comdirect", "REST-Saldenabruf");
const { accounts, balances } = await fetchComdirectBalanceData(
ctx,
args.userId,
args.accountId,
);
const result = await persistBalanceResults(
ctx,
args.userId,
"comdirect",
args.accountId,
accounts,
balances,
[],
);
return {
...result,
provider: "comdirect" as const,
awaitingTan: false,
};
} catch (error) {
if (!isRestFallbackError(error)) throw error;
const reason = error instanceof Error ? error.message : "REST-Fehler";
console.warn("[bank-balance] REST fehlgeschlagen, Fallback FinTS:", reason);
return await tryFinTs(`REST-Fallback: ${reason}`);
}
},
});
export const pollTan = internalAction({
args: {
userId: v.id("users"),

View File

@@ -14,6 +14,7 @@ export type NormalizedBalance = {
externalId: string;
balance: number;
currency: string;
asOf?: string;
};
export type NormalizedTransaction = {

View File

@@ -25,12 +25,19 @@ vi.mock("ai", async (importOriginal) => {
compare_periods: { execute: (input: unknown) => Promise<unknown> };
forecast_fixed_costs: { execute: (input: unknown) => Promise<unknown> };
explain_savings_rate: { execute: (input: unknown) => Promise<unknown> };
inspect_category_health: { execute: (input: unknown) => Promise<unknown> };
suggest_category_for_transactions: { execute: (input: unknown) => Promise<unknown> };
preview_month_end_spending: { execute: (input: unknown) => Promise<unknown> };
propose_bulk_recategory: { execute: (input: unknown) => Promise<unknown> };
propose_category_changes: { execute: (input: unknown) => Promise<unknown> };
};
}) => {
const transactionInput = { from: "2026-02-01", to: "2026-02-28", limit: 2 };
const summaryInput = { from: "2026-02-01", to: "2026-02-28" };
const previewInput = { today: "2026-02-20" };
const transactionOutput = await options.tools.get_transactions.execute(transactionInput);
const summaryOutput = await options.tools.summarize_spending.execute(summaryInput);
const previewOutput = await options.tools.preview_month_end_spending.execute(previewInput);
return {
text: "Agenten-Antwort",
@@ -47,6 +54,11 @@ vi.mock("ai", async (importOriginal) => {
input: summaryInput,
output: summaryOutput,
},
{
toolName: "preview_month_end_spending",
input: previewInput,
output: previewOutput,
},
],
},
],
@@ -67,6 +79,16 @@ const historyApi = {
};
const sendMessageAction = makeFunctionReference<"action">("savingsChat:sendMessage");
const previewMonthEndSpendingTool = makeFunctionReference<"query">("savingsChat:previewMonthEndSpendingTool");
const inspectCategoryHealthTool = makeFunctionReference<"query">("savingsChat:inspectCategoryHealthTool");
const suggestCategoryForTransactionsTool = makeFunctionReference<"query">("savingsChat:suggestCategoryForTransactionsTool");
const proposeBulkRecategoryTool = makeFunctionReference<"mutation">("savingsChat:proposeBulkRecategoryTool");
const proposeCategoryChangesTool = makeFunctionReference<"mutation">("savingsChat:proposeCategoryChangesTool");
const actionPlanApi = {
listPendingActionPlans: makeFunctionReference<"query">("savingsChatActionPlans:listPendingActionPlans"),
applyActionPlan: makeFunctionReference<"mutation">("savingsChatActionPlans:applyActionPlan"),
dismissActionPlan: makeFunctionReference<"mutation">("savingsChatActionPlans:dismissActionPlan"),
};
const paginationOpts = { cursor: null, numItems: 20 };
@@ -454,10 +476,11 @@ describe("savingsChat.sendMessage", () => {
to: "2026-02-28",
accountId: seeded.accountId as Id<"accounts">,
basis: "effective",
today: "2026-02-20",
});
expect(result.answer).toBe("Agenten-Antwort [1] [2]");
expect(result.toolTrace).toHaveLength(2);
expect(result.answer).toBe("Agenten-Antwort [1] [2] [3]");
expect(result.toolTrace).toHaveLength(3);
expect(result.sources).toEqual([
{
id: "tool-1",
@@ -469,10 +492,16 @@ describe("savingsChat.sendMessage", () => {
title: "summarize_spending",
description: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
},
{
id: "tool-3",
title: "preview_month_end_spending",
description: "Monatsvorschau 2026-02: erwartet -120.00€ Ausgaben, Rest 0.00€",
},
]);
expect(result.citations).toEqual([
{ marker: "1", sourceId: "tool-1" },
{ marker: "2", sourceId: "tool-2" },
{ marker: "3", sourceId: "tool-3" },
]);
const generateCall = vi.mocked(generateText).mock.calls[0][0] as {
@@ -493,7 +522,7 @@ describe("savingsChat.sendMessage", () => {
{ role: "user", content: "Wie sieht Februar aus?" },
{
role: "assistant",
content: "Agenten-Antwort [1] [2]",
content: "Agenten-Antwort [1] [2] [3]",
toolTrace: [
{
name: "get_transactions",
@@ -505,6 +534,11 @@ describe("savingsChat.sendMessage", () => {
inputSummary: "2026-02-01 bis 2026-02-28",
resultSummary: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
},
{
name: "preview_month_end_spending",
inputSummary: "Monatsvorschau für 2026-02-20",
resultSummary: "Monatsvorschau 2026-02: erwartet -120.00€ Ausgaben, Rest 0.00€",
},
],
sources: [
{
@@ -517,10 +551,16 @@ describe("savingsChat.sendMessage", () => {
title: "summarize_spending",
description: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
},
{
id: "tool-3",
title: "preview_month_end_spending",
description: "Monatsvorschau 2026-02: erwartet -120.00€ Ausgaben, Rest 0.00€",
},
],
citations: [
{ marker: "1", sourceId: "tool-1" },
{ marker: "2", sourceId: "tool-2" },
{ marker: "3", sourceId: "tool-3" },
],
},
]);
@@ -567,6 +607,7 @@ describe("savingsChat.sendMessage", () => {
from: "2026-02-01",
to: "2026-02-28",
basis: "effective",
today: "2026-02-20",
}),
).rejects.toThrow("KI-Anfrage fehlgeschlagen");
@@ -1428,9 +1469,10 @@ describe("savingsChat read-only agent tools", () => {
to: "2026-02-28",
accountId: seeded.accountId as Id<"accounts">,
basis: "effective",
today: "2026-02-20",
});
expect(result.answer).toBe("Agenten-Antwort [1] [2]");
expect(result.answer).toBe("Agenten-Antwort [1] [2] [3]");
expect(result.model).toBe("gpt-5.4-mini");
expect(result.usedTransactions).toBe(2);
expect(result.usedBalance).toEqual({ income: 3000, expenses: -120, balance: 2880 });
@@ -1445,6 +1487,11 @@ describe("savingsChat read-only agent tools", () => {
inputSummary: "2026-02-01 bis 2026-02-28",
resultSummary: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
},
{
name: "preview_month_end_spending",
inputSummary: "Monatsvorschau für 2026-02-20",
resultSummary: "Monatsvorschau 2026-02: erwartet -120.00€ Ausgaben, Rest 0.00€",
},
]);
expect(result.sources).toEqual([
{
@@ -1457,10 +1504,16 @@ describe("savingsChat read-only agent tools", () => {
title: "summarize_spending",
description: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
},
{
id: "tool-3",
title: "preview_month_end_spending",
description: "Monatsvorschau 2026-02: erwartet -120.00€ Ausgaben, Rest 0.00€",
},
]);
expect(result.citations).toEqual([
{ marker: "1", sourceId: "tool-1" },
{ marker: "2", sourceId: "tool-2" },
{ marker: "3", sourceId: "tool-3" },
]);
expect(JSON.stringify(result.toolTrace)).not.toContain("RAW PAYLOAD");
expect(JSON.stringify(result.toolTrace)).not.toContain("private note");
@@ -1479,6 +1532,11 @@ describe("savingsChat read-only agent tools", () => {
compare_periods: expect.any(Object),
forecast_fixed_costs: expect.any(Object),
explain_savings_rate: expect.any(Object),
inspect_category_health: expect.any(Object),
suggest_category_for_transactions: expect.any(Object),
preview_month_end_spending: expect.any(Object),
propose_bulk_recategory: expect.any(Object),
propose_category_changes: expect.any(Object),
}),
stopWhen: expect.any(Function),
}),
@@ -1644,6 +1702,665 @@ describe("savingsChat read-only agent tools", () => {
expect(JSON.stringify(result)).not.toContain("PRIVATE NOTE SHOULD NOT LEAK");
});
test("previewMonthEndSpendingTool forecasts remaining current-month expenses for the selected account", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Preview User",
email: "preview@example.com",
});
const giroAccountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const otherAccountId = await ctx.db.insert("accounts", {
userId,
name: "Tagesgeld",
type: "savings",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const rentId = await ctx.db.insert("categories", {
userId,
name: "Miete",
kind: "ausgabe",
block: "wiederkehrend",
color: "#64748b",
sortOrder: 1,
isSystem: false,
});
const groceriesId = await ctx.db.insert("categories", {
userId,
name: "Lebensmittel",
kind: "ausgabe",
block: "variabel",
color: "#22c55e",
sortOrder: 2,
isSystem: false,
});
const salaryId = await ctx.db.insert("categories", {
userId,
name: "Gehalt",
kind: "einnahme",
color: "#0ea5e9",
sortOrder: 3,
isSystem: false,
});
for (const tx of [
{ date: "2026-04-28", description: "Miete April", amount: -1000, categoryId: rentId },
{ date: "2026-05-28", description: "Miete Mai", amount: -1000, categoryId: rentId },
{ date: "2026-04-05", description: "Supermarkt April", amount: -120, categoryId: groceriesId },
{ date: "2026-05-05", description: "Supermarkt Mai", amount: -150, categoryId: groceriesId },
{ date: "2026-06-01", description: "Gehalt Juni", amount: 3000, categoryId: salaryId },
{ date: "2026-06-05", description: "Supermarkt Juni", amount: -100, categoryId: groceriesId },
{ date: "2026-06-10", description: "Drogerie Juni", amount: -50, categoryId: groceriesId },
]) {
await ctx.db.insert("transactions", {
userId,
accountId: giroAccountId,
categoryId: tx.categoryId,
bookingDate: tx.date,
valueDate: tx.date,
description: tx.description,
amount: tx.amount,
isPending: false,
effectiveMonth: tx.date.slice(0, 7),
});
}
await ctx.db.insert("transactions", {
userId,
accountId: otherAccountId,
bookingDate: "2026-06-07",
valueDate: "2026-06-07",
description: "Other account should not affect preview",
amount: -999,
isPending: false,
effectiveMonth: "2026-06",
});
return { userId, giroAccountId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|preview`,
tokenIdentifier: `test:${seeded.userId}`,
});
const result = await asUser.query(previewMonthEndSpendingTool, {
scope: {
from: "2026-01-01",
to: "2026-12-31",
accountId: seeded.giroAccountId as Id<"accounts">,
basis: "booking",
},
today: "2026-06-10",
});
expect(result).toMatchObject({
month: "2026-06",
today: "2026-06-10",
accountName: "Girokonto",
actualIncome: 3000,
actualExpenses: -150,
predictedRemainingFixed: -1000,
predictedRemainingVariable: -300,
predictedRemainingExpenses: -1300,
expectedMonthExpenses: -1450,
projectedMonthEndBalance: 1550,
confidence: "medium",
});
expect(result.drivers.map((driver: { label: string }) => driver.label)).toEqual([
"Noch erwartete Fixkosten: Miete",
"Variable Ausgaben auf Basis des laufenden Monats",
]);
});
test("inspectCategoryHealthTool reports uncategorized usage, unused categories, and similar names", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Category Health User",
email: "category-health@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const groceriesId = await ctx.db.insert("categories", {
userId,
name: "Lebensmittel",
kind: "ausgabe",
block: "variabel",
color: "#22c55e",
sortOrder: 1,
isSystem: false,
});
await ctx.db.insert("categories", {
userId,
name: "Lebensmittel & Supermarkt",
kind: "ausgabe",
block: "variabel",
color: "#16a34a",
sortOrder: 2,
isSystem: false,
});
await ctx.db.insert("categories", {
userId,
name: "Ungenutzt",
kind: "ausgabe",
block: "variabel",
color: "#f97316",
sortOrder: 3,
isSystem: false,
});
await ctx.db.insert("transactions", {
userId,
accountId,
categoryId: groceriesId,
bookingDate: "2026-06-01",
description: "Supermarkt",
amount: -40,
isPending: false,
effectiveMonth: "2026-06",
});
await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-06-02",
description: "Mystery Shop",
counterparty: "Mystery GmbH",
amount: -30,
isPending: false,
effectiveMonth: "2026-06",
});
return { userId, accountId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|health`,
tokenIdentifier: `test:${seeded.userId}`,
});
const result = await asUser.query(inspectCategoryHealthTool, {
scope: {
from: "2026-06-01",
to: "2026-06-30",
accountId: seeded.accountId as Id<"accounts">,
basis: "booking",
},
});
expect(result.totalCategories).toBe(3);
expect(result.uncategorizedCount).toBe(1);
expect(result.uncategorizedAmount).toBe(-30);
expect(result.topUncategorizedCounterparties).toEqual([
{ name: "Mystery GmbH", count: 1, amount: -30 },
]);
expect(result.unusedCategories.map((category: { name: string }) => category.name)).toContain("Ungenutzt");
expect(result.similarCategories).toEqual([
{ names: ["Lebensmittel", "Lebensmittel & Supermarkt"], reason: "Ähnliche Kategoriebegriffe" },
]);
});
test("suggestCategoryForTransactionsTool proposes categories without leaking raw private fields", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Suggestion User",
email: "suggestion@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const subscriptionsId = await ctx.db.insert("categories", {
userId,
name: "Abos",
kind: "ausgabe",
block: "wiederkehrend",
color: "#a855f7",
sortOrder: 1,
isSystem: false,
});
await ctx.db.insert("transactions", {
userId,
accountId,
categoryId: subscriptionsId,
bookingDate: "2026-05-03",
description: "Netflix",
counterparty: "Netflix",
amount: -15,
isPending: false,
effectiveMonth: "2026-05",
rawText: "RAW SHOULD NOT LEAK",
notes: "PRIVATE NOTE SHOULD NOT LEAK",
});
await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-06-03",
description: "Netflix",
counterparty: "Netflix",
amount: -16,
isPending: false,
effectiveMonth: "2026-06",
rawText: "RAW SHOULD NOT LEAK",
notes: "PRIVATE NOTE SHOULD NOT LEAK",
});
return { userId, accountId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|suggestion`,
tokenIdentifier: `test:${seeded.userId}`,
});
const result = await asUser.query(suggestCategoryForTransactionsTool, {
scope: {
from: "2026-06-01",
to: "2026-06-30",
accountId: seeded.accountId as Id<"accounts">,
basis: "booking",
},
search: "Netflix",
onlyUncategorized: true,
limit: 10,
});
expect(result.suggestions).toEqual([
{
categoryName: "Abos",
confidence: "high",
matchCount: 1,
amount: -16,
examples: [
{
date: "2026-06-03",
description: "Netflix",
counterparty: "Netflix",
amount: -16,
accountName: "Girokonto",
},
],
reason: "Ähnliche frühere Umsätze waren bereits dieser Kategorie zugeordnet.",
},
]);
expect(JSON.stringify(result)).not.toContain("RAW SHOULD NOT LEAK");
expect(JSON.stringify(result)).not.toContain("PRIVATE NOTE SHOULD NOT LEAK");
});
test("proposeBulkRecategoryTool creates a pending plan and applyActionPlan revalidates before changing transactions", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Plan User",
email: "plan@example.com",
});
const otherUserId = await ctx.db.insert("users", {
name: "Other Plan User",
email: "other-plan@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const categoryId = await ctx.db.insert("categories", {
userId,
name: "Abos",
kind: "ausgabe",
block: "wiederkehrend",
color: "#a855f7",
sortOrder: 1,
isSystem: false,
});
const txId = await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-06-03",
description: "Netflix",
counterparty: "Netflix",
amount: -16,
isPending: false,
effectiveMonth: "2026-06",
});
return { userId, otherUserId, accountId, categoryId, txId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|plan`,
tokenIdentifier: `test:${seeded.userId}`,
});
const asOtherUser = t.withIdentity({
subject: `${seeded.otherUserId}|plan`,
tokenIdentifier: `test:${seeded.otherUserId}`,
});
const session = await asUser.mutation(historyApi.createSession, { title: "Plan Chat" });
const proposed = await asUser.mutation(proposeBulkRecategoryTool, {
scope: {
from: "2026-06-01",
to: "2026-06-30",
accountId: seeded.accountId as Id<"accounts">,
basis: "booking",
},
sessionId: session.sessionId,
targetCategoryName: "Abos",
search: "Netflix",
onlyUncategorized: true,
limit: 10,
now: 1_800_000_000_000,
});
expect(proposed).toMatchObject({
kind: "bulk_recategory",
status: "pending",
affectedCount: 1,
summary: "1 Umsatz zur Kategorie Abos zuordnen",
});
let tx = await t.run(async (ctx) => await ctx.db.get(seeded.txId));
expect(tx?.categoryId).toBeUndefined();
const otherPlans = await asOtherUser.query(actionPlanApi.listPendingActionPlans, {
sessionId: session.sessionId,
});
expect(otherPlans).toEqual([]);
await expect(
asOtherUser.mutation(actionPlanApi.applyActionPlan, {
planId: proposed.planId,
now: 1_800_000_000_001,
}),
).rejects.toThrow("Nicht autorisiert");
const pending = await asUser.query(actionPlanApi.listPendingActionPlans, {
sessionId: session.sessionId,
});
expect(pending).toHaveLength(1);
expect(pending[0]).toMatchObject({
_id: proposed.planId,
kind: "bulk_recategory",
affectedCount: 1,
previewRows: [
{
description: "Netflix",
amount: -16,
currentCategoryName: "Ohne Kategorie",
targetCategoryName: "Abos",
},
],
});
const applied = await asUser.mutation(actionPlanApi.applyActionPlan, {
planId: proposed.planId,
now: 1_800_000_000_002,
});
expect(applied).toEqual({
appliedCount: 1,
skippedCount: 0,
status: "applied",
summary: "1 Änderung angewendet, 0 übersprungen",
});
tx = await t.run(async (ctx) => await ctx.db.get(seeded.txId));
expect(tx?.categoryId).toBe(seeded.categoryId);
});
test("proposeCategoryChangesTool applies category creates and updates but does not support deletes", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Category Plan User",
email: "category-plan@example.com",
});
const subscriptionId = await ctx.db.insert("categories", {
userId,
name: "Abos",
kind: "ausgabe",
block: "variabel",
color: "#a855f7",
sortOrder: 1,
isSystem: false,
});
return { userId, subscriptionId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|category-plan`,
tokenIdentifier: `test:${seeded.userId}`,
});
const session = await asUser.mutation(historyApi.createSession, { title: "Category Plan Chat" });
const proposed = await asUser.mutation(proposeCategoryChangesTool, {
sessionId: session.sessionId,
creates: [
{
name: "Versicherungen",
kind: "ausgabe",
block: "wiederkehrend",
color: "#0f766e",
sortOrder: 10,
},
],
updates: [
{
name: "Abos",
block: "wiederkehrend",
},
],
now: 1_800_000_000_000,
});
expect(proposed).toMatchObject({
kind: "category_changes",
status: "pending",
affectedCount: 2,
summary: "2 Kategorieänderungen vorbereiten",
});
const applied = await asUser.mutation(actionPlanApi.applyActionPlan, {
planId: proposed.planId,
now: 1_800_000_000_001,
});
expect(applied.appliedCount).toBe(2);
const categories = await asUser.query(api.categories.list, {});
expect(categories.find((category) => category.name === "Versicherungen")).toMatchObject({
kind: "ausgabe",
block: "wiederkehrend",
isSystem: false,
});
expect(categories.find((category) => category.name === "Abos")).toMatchObject({
block: "wiederkehrend",
});
});
test("category action plans skip empty names and duplicate renames", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Guarded Category User",
email: "guarded-category@example.com",
});
await ctx.db.insert("categories", {
userId,
name: "Abos",
kind: "ausgabe",
block: "wiederkehrend",
color: "#a855f7",
sortOrder: 1,
isSystem: false,
});
await ctx.db.insert("categories", {
userId,
name: "Versicherungen",
kind: "ausgabe",
block: "wiederkehrend",
color: "#0f766e",
sortOrder: 2,
isSystem: false,
});
return { userId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|guarded-category`,
tokenIdentifier: `test:${seeded.userId}`,
});
const session = await asUser.mutation(historyApi.createSession, { title: "Guarded Category Chat" });
await expect(
asUser.mutation(proposeCategoryChangesTool, {
sessionId: session.sessionId,
creates: [
{
name: " ",
kind: "ausgabe",
block: "variabel",
color: "#f97316",
sortOrder: 3,
},
],
updates: [{ name: "Abos", newName: " " }],
now: 1_800_000_000_000,
}),
).rejects.toThrow("Keine Kategorieänderungen vorbereitet");
const proposed = await asUser.mutation(proposeCategoryChangesTool, {
sessionId: session.sessionId,
updates: [{ name: "Abos", newName: "Versicherungen" }],
now: 1_800_000_000_000,
});
const applied = await asUser.mutation(actionPlanApi.applyActionPlan, {
planId: proposed.planId,
now: 1_800_000_000_001,
});
expect(applied).toEqual({
appliedCount: 0,
skippedCount: 1,
status: "applied",
summary: "0 Änderungen angewendet, 1 übersprungen",
});
const categories = await asUser.query(api.categories.list, {});
expect(categories.map((category) => category.name).sort()).toEqual(["Abos", "Versicherungen"]);
});
test("applyActionPlan skips stale category assignments and expires old plans", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Stale Plan User",
email: "stale-plan@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const subscriptionsId = await ctx.db.insert("categories", {
userId,
name: "Abos",
kind: "ausgabe",
block: "wiederkehrend",
color: "#a855f7",
sortOrder: 1,
isSystem: false,
});
const groceriesId = await ctx.db.insert("categories", {
userId,
name: "Lebensmittel",
kind: "ausgabe",
block: "variabel",
color: "#22c55e",
sortOrder: 2,
isSystem: false,
});
const txId = await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-06-03",
description: "Netflix",
counterparty: "Netflix",
amount: -16,
isPending: false,
effectiveMonth: "2026-06",
});
return { userId, accountId, subscriptionsId, groceriesId, txId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|stale-plan`,
tokenIdentifier: `test:${seeded.userId}`,
});
const session = await asUser.mutation(historyApi.createSession, { title: "Stale Plan Chat" });
const proposed = await asUser.mutation(proposeBulkRecategoryTool, {
scope: {
from: "2026-06-01",
to: "2026-06-30",
accountId: seeded.accountId as Id<"accounts">,
basis: "booking",
},
sessionId: session.sessionId,
targetCategoryName: "Abos",
search: "Netflix",
onlyUncategorized: true,
limit: 10,
now: 1_800_000_000_000,
});
await t.run(async (ctx) => {
await ctx.db.patch(seeded.txId, { categoryId: seeded.groceriesId });
});
const staleApplied = await asUser.mutation(actionPlanApi.applyActionPlan, {
planId: proposed.planId,
now: 1_800_000_000_001,
});
expect(staleApplied).toEqual({
appliedCount: 0,
skippedCount: 1,
status: "applied",
summary: "0 Änderungen angewendet, 1 übersprungen",
});
const tx = await t.run(async (ctx) => await ctx.db.get(seeded.txId));
expect(tx?.categoryId).toBe(seeded.groceriesId);
const expired = await asUser.mutation(proposeBulkRecategoryTool, {
scope: {
from: "2026-06-01",
to: "2026-06-30",
accountId: seeded.accountId as Id<"accounts">,
basis: "booking",
},
sessionId: session.sessionId,
targetCategoryName: "Abos",
search: "Netflix",
limit: 10,
now: 1_800_000_000_000,
});
const expiredApplied = await asUser.mutation(actionPlanApi.applyActionPlan, {
planId: expired.planId,
now: 1_800_086_400_001,
});
expect(expiredApplied).toEqual({
appliedCount: 0,
skippedCount: 1,
status: "expired",
summary: "Vorschlag ist abgelaufen",
});
});
test("comparePeriodsTool computes totals and category deltas", async () => {
const { asUser, seeded } = await seedSavingsInsightFixture();

View File

@@ -1,4 +1,4 @@
import { action, internalQuery, query } from "./_generated/server";
import { action, internalMutation, internalQuery, query } from "./_generated/server";
import { v } from "convex/values";
import { generateText, stepCountIs, tool } from "ai";
import { openai } from "@ai-sdk/openai";
@@ -7,7 +7,7 @@ import { z } from "zod";
import { addMonthsToMonthKey, bookingMonth, monthKeyFromBasis } from "./lib/month";
import { requireUserId } from "./lib/helpers";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx, QueryCtx } from "./_generated/server";
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
type ChatRole = "user" | "assistant";
type ChatMessage = { role: ChatRole; content: string };
@@ -98,16 +98,19 @@ function formatEuro(value: number): string {
return `${value.toFixed(2)}`;
}
function buildSystemPrompt(context: { from: string; to: string; basis: string; accountName?: string }) {
function buildSystemPrompt(context: { from: string; to: string; basis: string; today: string; accountName?: string }) {
return [
"Du bist ein präziser Finanz-Chat-Assistent für Privatanwender.",
"Nutze ausschließlich die bereitgestellten Werkzeuge und deren Ergebnisse als Finanzkontext.",
"Rufe Werkzeuge auf, wenn du Umsätze, Zusammenfassungen oder Prognosen brauchst; erfinde keine Beträge.",
"Nutze get_categories ohne Kategorie-Filter, wenn du vorhandene Kategorienamen prüfen musst.",
"Wenn ein Werkzeug categoryFilter-Diagnosen mit unresolved oder ambiguous liefert, nenne die Kategorie als nicht sicher gefunden und verwende keine gesicherte 0-Euro-Aussage.",
"Monatsvorschauen sind Schätzungen; kennzeichne sie klar als Prognose.",
"Wenn du einen Änderungs-Vorschlag vorbereitest, sage ausdrücklich, dass er erst über den Button in der Oberfläche angewendet wird.",
"Antworte auf Deutsch, kurz und handlungsorientiert.",
`Zeitraum: ${context.from} bis ${context.to}.`,
`Basis: ${context.basis}.`,
`Heute: ${context.today}.`,
context.accountName ? `Konto: ${context.accountName}.` : "Konto: Alle Konten.",
"Wenn eine Aussage nur grob geschätzt werden kann, kennzeichne sie als Schätzung.",
"Nenne keine internen IDs und keine Rohdatenfelder.",
@@ -135,7 +138,7 @@ function sortTransactionsForContext(
}
async function loadMatchingTransactions(
ctx: QueryCtx,
ctx: QueryCtx | MutationCtx,
userId: Id<"users">,
args: ChatContextArgs,
): Promise<Doc<"transactions">[]> {
@@ -458,6 +461,60 @@ const savingsLeverValidator = v.object({
monthlyImpact: v.number(),
});
const previewDriverValidator = v.object({
label: v.string(),
amount: v.number(),
kind: v.union(v.literal("fixed"), v.literal("variable"), v.literal("actual")),
});
const categoryHealthCategoryValidator = v.object({
name: v.string(),
kind: v.union(v.literal("einnahme"), v.literal("ausgabe")),
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
isSystem: v.boolean(),
});
const similarCategoryValidator = v.object({
names: v.array(v.string()),
reason: v.string(),
});
const categorySuggestionExampleValidator = v.object({
date: v.string(),
description: v.string(),
counterparty: v.optional(v.string()),
amount: v.number(),
accountName: v.string(),
});
const categorySuggestionValidator = v.object({
categoryName: v.string(),
confidence: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
matchCount: v.number(),
amount: v.number(),
examples: v.array(categorySuggestionExampleValidator),
reason: v.string(),
});
const proposalPreviewRowValidator = v.object({
date: v.optional(v.string()),
description: v.string(),
amount: v.optional(v.number()),
currentCategoryName: v.optional(v.string()),
targetCategoryName: v.optional(v.string()),
action: v.optional(v.string()),
});
const proposalResultValidator = v.object({
planId: v.id("agentActionPlans"),
kind: v.union(v.literal("bulk_recategory"), v.literal("category_changes")),
status: v.literal("pending"),
summary: v.string(),
affectedCount: v.number(),
expiresAt: v.number(),
previewRows: v.array(proposalPreviewRowValidator),
});
export const getContext = query({
args: contextArgsValidator,
returns: contextSummaryValidator,
@@ -496,7 +553,7 @@ function normalizeToolRange(scope: AgentToolScope, from?: string, to?: string) {
return range;
}
async function loadNameMaps(ctx: QueryCtx, userId: Id<"users">) {
async function loadNameMaps(ctx: QueryCtx | MutationCtx, userId: Id<"users">) {
const categories = await ctx.db
.query("categories")
.withIndex("by_user", (index) => index.eq("userId", userId))
@@ -693,7 +750,7 @@ function transactionMatchesToolFilters(
}
async function buildToolTransactionContext(
ctx: QueryCtx,
ctx: QueryCtx | MutationCtx,
userId: Id<"users">,
args: TransactionToolArgs,
): Promise<ToolTransactionContext> {
@@ -850,6 +907,27 @@ function dateForTransaction(tx: Doc<"transactions">) {
return tx.valueDate || tx.bookingDate || tx.effectiveMonth || "n/a";
}
function daysInMonth(month: string) {
const [year, monthNumber] = month.split("-").map(Number);
return new Date(year, monthNumber, 0).getDate();
}
function monthStart(month: string) {
return `${month}-01`;
}
function monthEnd(month: string) {
return `${month}-${String(daysInMonth(month)).padStart(2, "0")}`;
}
function dayOfMonth(date: string) {
return Number(date.slice(8, 10));
}
function previousMonthStart(month: string, monthsBack: number) {
return monthStart(addMonthsToMonthKey(month, -monthsBack));
}
function monthIndexesAreConsecutive(months: string[]) {
if (months.length < 2) return false;
for (let index = 1; index < months.length; index++) {
@@ -1302,6 +1380,508 @@ export const getUncategorizedTransactionsTool = internalQuery({
},
});
export const previewMonthEndSpendingTool = internalQuery({
args: {
scope: toolScopeValidator,
today: v.string(),
accountName: v.optional(v.string()),
},
returns: v.object({
month: v.string(),
today: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
actualIncome: v.number(),
actualExpenses: v.number(),
actualBalance: v.number(),
predictedRemainingFixed: v.number(),
predictedRemainingVariable: v.number(),
predictedRemainingExpenses: v.number(),
expectedMonthExpenses: v.number(),
projectedMonthEndBalance: v.number(),
confidence: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
drivers: v.array(previewDriverValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const month = args.today.slice(0, 7);
const day = Math.max(1, Math.min(dayOfMonth(args.today), daysInMonth(month)));
const remainingDays = Math.max(0, daysInMonth(month) - day);
const currentContext = await buildToolTransactionContext(ctx, userId, {
scope: args.scope,
from: monthStart(month),
to: args.today,
accountName: args.accountName,
});
const historyContext = await buildToolTransactionContext(ctx, userId, {
scope: args.scope,
from: previousMonthStart(month, 3),
to: monthEnd(addMonthsToMonthKey(month, -1)),
accountName: args.accountName,
});
const currentTotals = calculateTotals(currentContext.transactions);
const fixedSeenThisMonth = new Set<Id<"categories">>();
let currentVariableExpenses = 0;
for (const tx of currentContext.transactions) {
if (tx.amount >= 0 || !tx.categoryId) continue;
const category = currentContext.categoryById.get(tx.categoryId);
if (category?.block === "wiederkehrend") fixedSeenThisMonth.add(tx.categoryId);
if (category?.block === "variabel") currentVariableExpenses += tx.amount;
}
const fixedByCategoryMonth = new Map<
Id<"categories">,
{ categoryName: string; amountsByMonth: Map<string, number> }
>();
let historicalVariableTotal = 0;
const historicalMonths = new Set<string>();
for (const tx of historyContext.transactions) {
if (tx.amount >= 0) continue;
const monthKey = monthKeyFromBasis(tx, historyContext.basis);
if (!monthKey) continue;
historicalMonths.add(monthKey);
const category = tx.categoryId ? historyContext.categoryById.get(tx.categoryId) : undefined;
if (category?.block === "wiederkehrend" && tx.categoryId) {
const entry = fixedByCategoryMonth.get(tx.categoryId) ?? {
categoryName: category.name,
amountsByMonth: new Map<string, number>(),
};
entry.amountsByMonth.set(monthKey, (entry.amountsByMonth.get(monthKey) ?? 0) + tx.amount);
fixedByCategoryMonth.set(tx.categoryId, entry);
}
if (category?.block === "variabel") {
historicalVariableTotal += tx.amount;
}
}
const fixedDrivers = [...fixedByCategoryMonth.entries()]
.filter(([categoryId]) => !fixedSeenThisMonth.has(categoryId))
.map(([, entry]) => ({
label: entry.categoryName,
amount: roundMoney(
[...entry.amountsByMonth.values()].reduce((sum, amount) => sum + amount, 0) /
entry.amountsByMonth.size,
),
}))
.filter((entry) => entry.amount < 0)
.sort((a, b) => a.amount - b.amount || a.label.localeCompare(b.label, "de-DE"));
const predictedRemainingFixed = roundMoney(
fixedDrivers.reduce((sum, entry) => sum + entry.amount, 0),
);
const predictedRemainingVariable =
currentVariableExpenses < 0
? roundMoney((currentVariableExpenses / day) * remainingDays)
: roundMoney(
(historicalVariableTotal / Math.max(1, historicalMonths.size) / daysInMonth(month)) *
remainingDays,
);
const predictedRemainingExpenses = roundMoney(predictedRemainingFixed + predictedRemainingVariable);
const confidence: "low" | "medium" | "high" =
day >= 15 && historicalMonths.size >= 2
? "high"
: historicalMonths.size > 0 || day >= 7
? "medium"
: "low";
const drivers = [
...(fixedDrivers.length > 0
? [
{
label: `Noch erwartete Fixkosten: ${fixedDrivers.map((entry) => entry.label).join(", ")}`,
amount: predictedRemainingFixed,
kind: "fixed" as const,
},
]
: []),
...(predictedRemainingVariable < 0
? [
{
label:
currentVariableExpenses < 0
? "Variable Ausgaben auf Basis des laufenden Monats"
: "Variable Ausgaben auf Basis historischer Monate",
amount: predictedRemainingVariable,
kind: "variable" as const,
},
]
: []),
];
return {
month,
today: args.today,
basis: args.scope.basis,
accountName: currentContext.accountName,
actualIncome: currentTotals.income,
actualExpenses: currentTotals.expenses,
actualBalance: currentTotals.balance,
predictedRemainingFixed,
predictedRemainingVariable,
predictedRemainingExpenses,
expectedMonthExpenses: roundMoney(currentTotals.expenses + predictedRemainingExpenses),
projectedMonthEndBalance: roundMoney(currentTotals.balance + predictedRemainingExpenses),
confidence,
drivers,
};
},
});
export const inspectCategoryHealthTool = internalQuery({
args: {
scope: toolScopeValidator,
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
},
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
totalCategories: v.number(),
uncategorizedCount: v.number(),
uncategorizedAmount: v.number(),
topUncategorizedCounterparties: v.array(topCounterpartyValidator),
unusedCategories: v.array(categoryHealthCategoryValidator),
similarCategories: v.array(similarCategoryValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
const usedCategoryIds = new Set(
context.transactions.flatMap((tx) => (tx.categoryId ? [tx.categoryId] : [])),
);
const uncategorized = context.transactions.filter((tx) => !tx.categoryId);
const counterpartyMap = new Map<string, { count: number; amount: number }>();
for (const tx of uncategorized) {
const name = tx.counterparty?.trim() || tx.description.trim() || "Unbekannt";
const entry = counterpartyMap.get(name) ?? { count: 0, amount: 0 };
entry.count += 1;
entry.amount += tx.amount;
counterpartyMap.set(name, entry);
}
const similarCategories = [];
for (let a = 0; a < context.categories.length; a++) {
for (let b = a + 1; b < context.categories.length; b++) {
const first = context.categories[a];
const second = context.categories[b];
const firstTokens = new Set(categoryTokens(first.name));
const secondTokens = new Set(categoryTokens(second.name));
const overlap = [...firstTokens].filter((token) => secondTokens.has(token));
const subset =
overlap.length > 0 &&
(overlap.length === firstTokens.size || overlap.length === secondTokens.size);
if (!subset) continue;
similarCategories.push({
names: [first.name, second.name].sort((x, y) => x.localeCompare(y, "de-DE")),
reason: "Ähnliche Kategoriebegriffe",
});
}
}
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
totalCategories: context.categories.length,
uncategorizedCount: uncategorized.length,
uncategorizedAmount: roundMoney(uncategorized.reduce((sum, tx) => sum + tx.amount, 0)),
topUncategorizedCounterparties: [...counterpartyMap.entries()]
.map(([name, entry]) => ({ name, count: entry.count, amount: roundMoney(entry.amount) }))
.sort((a, b) => b.count - a.count || Math.abs(b.amount) - Math.abs(a.amount) || a.name.localeCompare(b.name, "de-DE"))
.slice(0, 5),
unusedCategories: context.categories
.filter((category) => !usedCategoryIds.has(category._id))
.map((category) => ({
name: category.name,
kind: category.kind,
block: category.block,
isSystem: category.isSystem,
})),
similarCategories,
};
},
});
export const suggestCategoryForTransactionsTool = internalQuery({
args: {
...transactionToolArgsValidator,
onlyUncategorized: v.optional(v.boolean()),
},
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
suggestions: v.array(categorySuggestionValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
const limit = clampToolLimit(args.limit);
const candidates = context.transactions
.filter((tx) => (args.onlyUncategorized ? !tx.categoryId : true))
.slice(0, limit);
const history = await loadMatchingTransactions(ctx, userId, {
from: previousMonthStart(context.from.slice(0, 7), 12),
to: context.from,
accountId: context.accountId,
basis: context.basis,
});
const suggestionMap = new Map<
Id<"categories">,
{ categoryName: string; matchCount: number; amount: number; examples: ReturnType<typeof safeTransactionRow>[] }
>();
for (const tx of candidates) {
const txText = [tx.description, tx.counterparty].map(normalizedText).filter(Boolean);
const matches = history.filter((historical) => {
if (!historical.categoryId) return false;
const historicalText = [historical.description, historical.counterparty].map(normalizedText).filter(Boolean);
return txText.some((text) => text && historicalText.includes(text));
});
for (const match of matches) {
const categoryId = match.categoryId;
if (!categoryId) continue;
const category = context.categoryById.get(categoryId);
if (!category) continue;
const entry = suggestionMap.get(categoryId) ?? {
categoryName: category.name,
matchCount: 0,
amount: 0,
examples: [],
};
entry.matchCount += 1;
entry.amount += tx.amount;
if (entry.examples.length < 3) entry.examples.push(safeTransactionRow(tx, context));
suggestionMap.set(categoryId, entry);
}
}
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
suggestions: [...suggestionMap.values()]
.map((entry) => ({
categoryName: entry.categoryName,
confidence: entry.matchCount >= 1 ? "high" as const : "medium" as const,
matchCount: entry.examples.length,
amount: roundMoney(entry.amount),
examples: entry.examples.map((row) => ({
date: row.date,
description: row.description,
counterparty: row.counterparty,
amount: row.amount,
accountName: row.accountName,
})),
reason: "Ähnliche frühere Umsätze waren bereits dieser Kategorie zugeordnet.",
}))
.sort((a, b) => b.matchCount - a.matchCount || Math.abs(b.amount) - Math.abs(a.amount))
.slice(0, 5),
};
},
});
async function requireOwnedChatSession(
ctx: MutationCtx,
sessionId: Id<"chatSessions">,
userId: Id<"users">,
) {
const session = await ctx.db.get(sessionId);
if (!session) throw new Error("Chat nicht gefunden");
if (session.userId !== userId) throw new Error("Nicht autorisiert");
if (session.isDeleted) throw new Error("Chat nicht gefunden");
}
export const proposeBulkRecategoryTool = internalMutation({
args: {
scope: toolScopeValidator,
sessionId: v.id("chatSessions"),
targetCategoryName: v.string(),
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
search: v.optional(v.string()),
onlyUncategorized: v.optional(v.boolean()),
limit: v.optional(v.number()),
now: v.optional(v.number()),
},
returns: proposalResultValidator,
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
await requireOwnedChatSession(ctx, args.sessionId, userId);
const targetCategory = await ctx.db
.query("categories")
.withIndex("by_user_name", (index) =>
index.eq("userId", userId).eq("name", args.targetCategoryName.trim()),
)
.unique();
if (!targetCategory) throw new Error("Zielkategorie nicht gefunden");
const context = await buildToolTransactionContext(ctx, userId, args);
const limit = Math.min(100, clampToolLimit(args.limit));
const transactions = context.transactions
.filter((tx) => (args.onlyUncategorized ? !tx.categoryId : true))
.filter((tx) => tx.categoryId !== targetCategory._id)
.slice(0, limit);
if (transactions.length === 0) throw new Error("Keine passenden Umsätze gefunden");
const operations = transactions.map((tx) => ({
type: "set_transaction_category" as const,
transactionId: tx._id,
fromCategoryId: tx.categoryId,
toCategoryId: targetCategory._id,
}));
const previewRows = transactions.slice(0, 10).map((tx) => ({
date: dateForTransaction(tx),
description: tx.description,
amount: roundMoney(tx.amount),
currentCategoryName: tx.categoryId
? context.categoryById.get(tx.categoryId)?.name ?? "Ohne Kategorie"
: "Ohne Kategorie",
targetCategoryName: targetCategory.name,
}));
const now = args.now ?? Date.now();
const summary = `${operations.length} Umsatz${operations.length === 1 ? "" : "e"} zur Kategorie ${targetCategory.name} zuordnen`;
const planId = await ctx.db.insert("agentActionPlans", {
userId,
sessionId: args.sessionId,
kind: "bulk_recategory",
status: "pending",
summary,
affectedCount: operations.length,
createdAt: now,
expiresAt: now + 24 * 60 * 60 * 1000,
operations,
previewRows,
});
return {
planId,
kind: "bulk_recategory" as const,
status: "pending" as const,
summary,
affectedCount: operations.length,
expiresAt: now + 24 * 60 * 60 * 1000,
previewRows,
};
},
});
const categoryCreateValidator = v.object({
name: v.string(),
kind: v.union(v.literal("einnahme"), v.literal("ausgabe")),
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
color: v.string(),
icon: v.optional(v.string()),
sortOrder: v.number(),
});
const categoryUpdateByNameValidator = v.object({
name: v.string(),
newName: v.optional(v.string()),
kind: v.optional(v.union(v.literal("einnahme"), v.literal("ausgabe"))),
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
color: v.optional(v.string()),
icon: v.optional(v.string()),
sortOrder: v.optional(v.number()),
});
export const proposeCategoryChangesTool = internalMutation({
args: {
sessionId: v.id("chatSessions"),
creates: v.optional(v.array(categoryCreateValidator)),
updates: v.optional(v.array(categoryUpdateByNameValidator)),
now: v.optional(v.number()),
},
returns: proposalResultValidator,
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
await requireOwnedChatSession(ctx, args.sessionId, userId);
const operations = [];
const previewRows = [];
for (const create of (args.creates ?? []).slice(0, 100)) {
const name = create.name.trim();
if (!name || (create.kind === "ausgabe" && !create.block)) continue;
operations.push({
type: "create_category" as const,
name,
kind: create.kind,
block: create.kind === "ausgabe" ? create.block : undefined,
color: create.color,
icon: create.icon,
sortOrder: create.sortOrder,
});
previewRows.push({
description: name,
action: "Kategorie erstellen",
targetCategoryName: name,
});
}
for (const update of (args.updates ?? []).slice(0, 100 - operations.length)) {
const trimmedNewName = update.newName?.trim();
if (update.newName !== undefined && !trimmedNewName) continue;
const category = await ctx.db
.query("categories")
.withIndex("by_user_name", (index) =>
index.eq("userId", userId).eq("name", update.name.trim()),
)
.unique();
if (!category) continue;
operations.push({
type: "update_category" as const,
categoryId: category._id,
name: trimmedNewName,
kind: update.kind,
block: update.block,
color: update.color,
icon: update.icon,
sortOrder: update.sortOrder,
});
previewRows.push({
description: category.name,
action: "Kategorie aktualisieren",
currentCategoryName: category.name,
targetCategoryName: trimmedNewName ?? category.name,
});
}
if (operations.length === 0) throw new Error("Keine Kategorieänderungen vorbereitet");
const now = args.now ?? Date.now();
const summary = `${operations.length} Kategorieänderung${operations.length === 1 ? "" : "en"} vorbereiten`;
const planId = await ctx.db.insert("agentActionPlans", {
userId,
sessionId: args.sessionId,
kind: "category_changes",
status: "pending",
summary,
affectedCount: operations.length,
createdAt: now,
expiresAt: now + 24 * 60 * 60 * 1000,
operations,
previewRows,
});
return {
planId,
kind: "category_changes" as const,
status: "pending" as const,
summary,
affectedCount: operations.length,
expiresAt: now + 24 * 60 * 60 * 1000,
previewRows,
};
},
});
export const comparePeriodsTool = internalQuery({
args: {
scope: toolScopeValidator,
@@ -1701,13 +2281,17 @@ function summarizeToolInput(input: unknown) {
const search = maybeString(record.search);
const limit = maybeNumber(record.limit);
const horizonMonths = maybeNumber(record.horizonMonths);
const today = maybeString(record.today);
const targetCategoryName = maybeString(record.targetCategoryName);
const type = maybeString(record.type);
const categoryNames = Array.isArray(record.categoryNames)
? record.categoryNames.filter((name): name is string => typeof name === "string")
: [];
if (from || to) parts.push(`${from ?? "?"} bis ${to ?? "?"}`);
if (today) parts.push(`Monatsvorschau für ${today}`);
if (search) parts.push(`Suche "${search}"`);
if (targetCategoryName) parts.push(`Zielkategorie ${targetCategoryName}`);
if (categoryNames.length > 0) parts.push(`Kategorien ${categoryNames.join(", ")}`);
if (type) parts.push(type === "income" ? "Einnahmen" : "Ausgaben");
if (horizonMonths) parts.push(`${horizonMonths} Monate Prognose`);
@@ -1822,6 +2406,31 @@ function summarizeToolOutput(toolName: string, output: unknown) {
return `Sparquote ${(savingsRate * 100).toFixed(1)}%, gespart ${formatEuro(savedAmount)}${categoryFilterSummary}`;
}
if (toolName === "preview_month_end_spending") {
const month = maybeString(record.month) ?? "unbekannt";
const expectedMonthExpenses = maybeNumber(record.expectedMonthExpenses) ?? 0;
const predictedRemainingExpenses = maybeNumber(record.predictedRemainingExpenses) ?? 0;
return `Monatsvorschau ${month}: erwartet ${formatEuro(expectedMonthExpenses)} Ausgaben, Rest ${formatEuro(predictedRemainingExpenses)}`;
}
if (toolName === "inspect_category_health") {
const uncategorizedCount = maybeNumber(record.uncategorizedCount) ?? 0;
const unusedCategories = Array.isArray(record.unusedCategories) ? record.unusedCategories.length : 0;
const similarCategories = Array.isArray(record.similarCategories) ? record.similarCategories.length : 0;
return `${uncategorizedCount} unklassifizierte Umsätze, ${unusedCategories} ungenutzte Kategorien, ${similarCategories} ähnliche Gruppen`;
}
if (toolName === "suggest_category_for_transactions") {
const count = Array.isArray(record.suggestions) ? record.suggestions.length : 0;
return `${count} ${count === 1 ? "Kategorie-Vorschlag" : "Kategorie-Vorschläge"} gefunden`;
}
if (toolName === "propose_bulk_recategory" || toolName === "propose_category_changes") {
const affectedCount = maybeNumber(record.affectedCount) ?? 0;
const summary = maybeString(record.summary) ?? "Vorschlag vorbereitet";
return `${summary}, ${affectedCount} Änderung${affectedCount === 1 ? "" : "en"} warten auf Bestätigung`;
}
return "Werkzeug ausgeführt";
}
@@ -1936,9 +2545,59 @@ const fixedCostsForecastToolInputSchema = z.object({
asOf: z.string().optional().describe("Stichtag für den Start der Prognose im Format YYYY-MM-DD."),
});
const monthEndPreviewToolInputSchema = z.object({
today: z.string().describe("Lokales heutiges Datum im Format YYYY-MM-DD."),
accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."),
});
const categoryHealthToolInputSchema = z.object({
from: z.string().optional().describe("Optionales Startdatum im Format YYYY-MM-DD."),
to: z.string().optional().describe("Optionales Enddatum im Format YYYY-MM-DD."),
accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."),
});
const categorySuggestionToolInputSchema = z.object({
from: z.string().optional().describe("Optionales Startdatum im Format YYYY-MM-DD."),
to: z.string().optional().describe("Optionales Enddatum im Format YYYY-MM-DD."),
accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."),
search: z.string().optional().describe("Suchtext für Beschreibung, Gegenpartei oder Kategorie."),
onlyUncategorized: z.boolean().optional().describe("Nur Umsätze ohne Kategorie vorschlagen."),
limit: z.number().int().min(1).max(MAX_TOOL_ROW_LIMIT).optional().describe("Maximale Anzahl geprüfter Umsatzzeilen."),
});
const bulkRecategoryProposalToolInputSchema = z.object({
targetCategoryName: z.string().describe("Bestehende Zielkategorie, der die gefundenen Umsätze zugeordnet werden sollen."),
from: z.string().optional().describe("Optionales Startdatum im Format YYYY-MM-DD."),
to: z.string().optional().describe("Optionales Enddatum im Format YYYY-MM-DD."),
accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."),
search: z.string().optional().describe("Suchtext für die zuzuordnenden Umsätze."),
onlyUncategorized: z.boolean().optional().describe("Nur Umsätze ohne Kategorie in den Vorschlag aufnehmen."),
limit: z.number().int().min(1).max(MAX_TOOL_ROW_LIMIT).optional().describe("Maximale Anzahl Änderungen."),
});
const categoryChangeProposalToolInputSchema = z.object({
creates: z.array(z.object({
name: z.string(),
kind: z.enum(["einnahme", "ausgabe"]),
block: z.enum(["wiederkehrend", "variabel"]).optional(),
color: z.string(),
icon: z.string().optional(),
sortOrder: z.number(),
})).optional().describe("Neue Kategorien, die nach UI-Bestätigung erstellt werden sollen."),
updates: z.array(z.object({
name: z.string(),
newName: z.string().optional(),
kind: z.enum(["einnahme", "ausgabe"]).optional(),
block: z.enum(["wiederkehrend", "variabel"]).optional(),
color: z.string().optional(),
icon: z.string().optional(),
sortOrder: z.number().optional(),
})).optional().describe("Bestehende Kategorien nach Name, die nach UI-Bestätigung geändert werden sollen."),
});
async function generateSavingsChatResponse(
ctx: ActionCtx,
args: ChatContextArgs & { messages: ChatMessage[] },
args: ChatContextArgs & { messages: ChatMessage[]; today?: string; sessionId?: Id<"chatSessions"> },
): Promise<ChatAskResult> {
if (args.messages.length === 0) {
throw new Error("Kein Nutzernachrichttext vorhanden.");
@@ -1957,6 +2616,7 @@ async function generateSavingsChatResponse(
accountId: args.accountId,
basis: args.basis,
};
const today = args.today ?? new Date().toISOString().slice(0, 10);
const selectedSummary: {
totalCount: number;
@@ -1975,6 +2635,7 @@ async function generateSavingsChatResponse(
from: args.from,
to: args.to,
basis: args.basis,
today,
accountName: selectedSummary.accountName,
});
@@ -2089,6 +2750,62 @@ async function generateSavingsChatResponse(
...input,
}),
}),
inspect_category_health: tool({
description:
"Analysiert read-only Kategoriequalität: unklassifizierte Umsätze, ungenutzte Kategorien, ähnliche Kategorien und Top-Gegenparteien ohne Kategorie.",
inputSchema: categoryHealthToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.inspectCategoryHealthTool, {
scope,
...input,
}),
}),
suggest_category_for_transactions: tool({
description:
"Schlägt read-only Kategorien für passende oder unklassifizierte Umsätze vor. Nutze es vor Änderungs-Vorschlägen, um eine sichere Zielkategorie zu begründen.",
inputSchema: categorySuggestionToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.suggestCategoryForTransactionsTool, {
scope,
...input,
}),
}),
preview_month_end_spending: tool({
description:
"Berechnet eine deterministische Monatsvorschau vom lokalen heutigen Datum bis Monatsende. Nutze dieses Tool für vermutete Ausgaben im Rest des aktuellen Monats.",
inputSchema: monthEndPreviewToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.previewMonthEndSpendingTool, {
scope,
today: input.today || today,
accountName: input.accountName,
}),
}),
propose_bulk_recategory: tool({
description:
"Erstellt nur einen bestätigungspflichtigen Vorschlag, um passende Umsätze einer bestehenden Kategorie zuzuordnen. Das Tool ändert keine Daten direkt.",
inputSchema: bulkRecategoryProposalToolInputSchema,
execute: async (input) => {
if (!args.sessionId) throw new Error("Änderungs-Vorschläge sind nur in gespeicherten Chats möglich.");
return await ctx.runMutation(internal.savingsChat.proposeBulkRecategoryTool, {
scope,
sessionId: args.sessionId,
...input,
});
},
}),
propose_category_changes: tool({
description:
"Erstellt nur einen bestätigungspflichtigen Vorschlag für Kategorie-Erstellung oder Kategorie-Updates. Löschen wird nicht unterstützt.",
inputSchema: categoryChangeProposalToolInputSchema,
execute: async (input) => {
if (!args.sessionId) throw new Error("Änderungs-Vorschläge sind nur in gespeicherten Chats möglich.");
return await ctx.runMutation(internal.savingsChat.proposeCategoryChangesTool, {
sessionId: args.sessionId,
...input,
});
},
}),
};
const envModel = process.env.SAVINGS_CHAT_MODEL?.trim();
@@ -2144,6 +2861,7 @@ export const ask = action({
to: v.string(),
accountId: v.optional(v.id("accounts")),
basis: v.union(v.literal("effective"), v.literal("booking")),
today: v.optional(v.string()),
},
returns: v.object({
model: v.string(),
@@ -2161,6 +2879,7 @@ export const ask = action({
handler: async (ctx, args): Promise<ChatAskResult> => {
return await generateSavingsChatResponse(ctx, {
...args,
today: args.today,
messages: args.messages.map((message) => ({
role: normalizeRole(message.role),
content: message.content,
@@ -2177,6 +2896,7 @@ export const sendMessage = action({
to: v.string(),
accountId: v.optional(v.id("accounts")),
basis: v.union(v.literal("effective"), v.literal("booking")),
today: v.optional(v.string()),
},
returns: v.object({
model: v.string(),
@@ -2215,6 +2935,8 @@ export const sendMessage = action({
to: args.to,
accountId: args.accountId,
basis: args.basis,
today: args.today,
sessionId: args.sessionId,
messages,
});
} catch (error) {

View File

@@ -0,0 +1,266 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { assertOwned, requireUserId } from "./lib/helpers";
import type { Doc, Id } from "./_generated/dataModel";
import type { MutationCtx, QueryCtx } from "./_generated/server";
const MAX_PLAN_OPERATIONS = 100;
const planKindValidator = v.union(
v.literal("bulk_recategory"),
v.literal("category_changes"),
);
const planStatusValidator = v.union(
v.literal("pending"),
v.literal("applied"),
v.literal("dismissed"),
v.literal("expired"),
);
const operationValidator = v.union(
v.object({
type: v.literal("set_transaction_category"),
transactionId: v.id("transactions"),
fromCategoryId: v.optional(v.id("categories")),
toCategoryId: v.id("categories"),
}),
v.object({
type: v.literal("create_category"),
name: v.string(),
kind: v.union(v.literal("einnahme"), v.literal("ausgabe")),
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
color: v.string(),
icon: v.optional(v.string()),
sortOrder: v.number(),
}),
v.object({
type: v.literal("update_category"),
categoryId: v.id("categories"),
name: v.optional(v.string()),
kind: v.optional(v.union(v.literal("einnahme"), v.literal("ausgabe"))),
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
color: v.optional(v.string()),
icon: v.optional(v.string()),
sortOrder: v.optional(v.number()),
}),
);
const previewRowValidator = v.object({
date: v.optional(v.string()),
description: v.string(),
amount: v.optional(v.number()),
currentCategoryName: v.optional(v.string()),
targetCategoryName: v.optional(v.string()),
action: v.optional(v.string()),
});
const actionPlanValidator = v.object({
_id: v.id("agentActionPlans"),
_creationTime: v.number(),
userId: v.id("users"),
sessionId: v.id("chatSessions"),
kind: planKindValidator,
status: planStatusValidator,
summary: v.string(),
affectedCount: v.number(),
createdAt: v.number(),
expiresAt: v.number(),
operations: v.array(operationValidator),
previewRows: v.array(previewRowValidator),
resultSummary: v.optional(v.string()),
});
async function requireOwnedSession(
ctx: QueryCtx | MutationCtx,
sessionId: Id<"chatSessions">,
userId: Id<"users">,
) {
const session = await assertOwned(await ctx.db.get(sessionId), userId, "Chat");
if (session.isDeleted) throw new Error("Chat nicht gefunden");
return session;
}
async function requireOwnedPlan(
ctx: QueryCtx | MutationCtx,
planId: Id<"agentActionPlans">,
userId: Id<"users">,
) {
const plan = await assertOwned(await ctx.db.get(planId), userId, "Vorschlag");
await requireOwnedSession(ctx, plan.sessionId, userId);
return plan;
}
async function applyTransactionCategory(
ctx: MutationCtx,
userId: Id<"users">,
operation: Extract<Doc<"agentActionPlans">["operations"][number], { type: "set_transaction_category" }>,
) {
const tx = await ctx.db.get(operation.transactionId);
if (!tx || tx.userId !== userId) return false;
const targetCategory = await ctx.db.get(operation.toCategoryId);
if (!targetCategory || targetCategory.userId !== userId) return false;
if (tx.categoryId !== operation.fromCategoryId) return false;
await ctx.db.patch(tx._id, { categoryId: operation.toCategoryId });
return true;
}
async function applyCreateCategory(
ctx: MutationCtx,
userId: Id<"users">,
operation: Extract<Doc<"agentActionPlans">["operations"][number], { type: "create_category" }>,
) {
const name = operation.name.trim();
if (!name) return false;
if (operation.kind === "ausgabe" && !operation.block) return false;
const existing = await ctx.db
.query("categories")
.withIndex("by_user_name", (index) =>
index.eq("userId", userId).eq("name", name),
)
.unique();
if (existing) return false;
await ctx.db.insert("categories", {
userId,
name,
kind: operation.kind,
block: operation.kind === "ausgabe" ? operation.block : undefined,
color: operation.color,
icon: operation.icon,
sortOrder: operation.sortOrder,
isSystem: false,
});
return true;
}
async function applyUpdateCategory(
ctx: MutationCtx,
userId: Id<"users">,
operation: Extract<Doc<"agentActionPlans">["operations"][number], { type: "update_category" }>,
) {
const category = await ctx.db.get(operation.categoryId);
if (!category || category.userId !== userId) return false;
const patch: Partial<Doc<"categories">> = {};
for (const key of ["name", "kind", "block", "color", "icon", "sortOrder"] as const) {
if (operation[key] !== undefined) {
patch[key] = operation[key] as never;
}
}
if (patch.name !== undefined) {
const name = patch.name.trim();
if (!name) return false;
const existing = await ctx.db
.query("categories")
.withIndex("by_user_name", (index) => index.eq("userId", userId).eq("name", name))
.unique();
if (existing && existing._id !== operation.categoryId) return false;
patch.name = name;
}
const kind = patch.kind ?? category.kind;
const block = patch.block ?? category.block;
if (kind === "ausgabe" && !block) return false;
if (kind === "einnahme") patch.block = undefined;
if (Object.keys(patch).length === 0) return false;
await ctx.db.patch(operation.categoryId, patch);
return true;
}
export const listPendingActionPlans = query({
args: { sessionId: v.id("chatSessions"), now: v.optional(v.number()) },
returns: v.array(actionPlanValidator),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const session = await ctx.db.get(args.sessionId);
if (!session || session.userId !== userId || session.isDeleted) return [];
const now = args.now ?? Date.now();
const plans = await ctx.db
.query("agentActionPlans")
.withIndex("by_user_session_status_created", (index) =>
index
.eq("userId", userId)
.eq("sessionId", args.sessionId)
.eq("status", "pending"),
)
.order("desc")
.take(20);
return plans.filter((plan) => plan.expiresAt > now);
},
});
export const dismissActionPlan = mutation({
args: { planId: v.id("agentActionPlans") },
returns: v.null(),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const plan = await requireOwnedPlan(ctx, args.planId, userId);
if (plan.status !== "pending") return null;
await ctx.db.patch(args.planId, {
status: "dismissed",
resultSummary: "Vorschlag verworfen",
});
return null;
},
});
export const applyActionPlan = mutation({
args: { planId: v.id("agentActionPlans"), now: v.optional(v.number()) },
returns: v.object({
appliedCount: v.number(),
skippedCount: v.number(),
status: planStatusValidator,
summary: v.string(),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const plan = await requireOwnedPlan(ctx, args.planId, userId);
const now = args.now ?? Date.now();
if (plan.status !== "pending") {
return {
appliedCount: 0,
skippedCount: plan.operations.length,
status: plan.status as "pending" | "applied" | "dismissed" | "expired",
summary: plan.resultSummary ?? "Vorschlag ist nicht mehr offen",
};
}
if (plan.expiresAt <= now) {
await ctx.db.patch(args.planId, {
status: "expired" as const,
resultSummary: "Vorschlag ist abgelaufen",
});
return {
appliedCount: 0,
skippedCount: plan.operations.length,
status: "expired" as const,
summary: "Vorschlag ist abgelaufen",
};
}
if (plan.operations.length > MAX_PLAN_OPERATIONS) {
throw new Error("Zu viele Änderungen in einem Vorschlag");
}
let appliedCount = 0;
let skippedCount = 0;
for (const operation of plan.operations) {
const applied =
operation.type === "set_transaction_category"
? await applyTransactionCategory(ctx, userId, operation)
: operation.type === "create_category"
? await applyCreateCategory(ctx, userId, operation)
: await applyUpdateCategory(ctx, userId, operation);
if (applied) {
appliedCount++;
} else {
skippedCount++;
}
}
const summary = `${appliedCount} Änderung${appliedCount === 1 ? "" : "en"} angewendet, ${skippedCount} übersprungen`;
await ctx.db.patch(args.planId, {
status: "applied" as const,
resultSummary: summary,
});
return {
appliedCount,
skippedCount,
status: "applied" as const,
summary,
};
},
});

View File

@@ -24,6 +24,51 @@ const chatCitation = v.object({
marker: v.string(),
sourceId: v.string(),
});
const agentActionPlanStatus = v.union(
v.literal("pending"),
v.literal("applied"),
v.literal("dismissed"),
v.literal("expired"),
);
const agentActionPlanKind = v.union(
v.literal("bulk_recategory"),
v.literal("category_changes"),
);
const agentActionPlanOperation = v.union(
v.object({
type: v.literal("set_transaction_category"),
transactionId: v.id("transactions"),
fromCategoryId: v.optional(v.id("categories")),
toCategoryId: v.id("categories"),
}),
v.object({
type: v.literal("create_category"),
name: v.string(),
kind: categoryKind,
block: v.optional(expenseBlock),
color: v.string(),
icon: v.optional(v.string()),
sortOrder: v.number(),
}),
v.object({
type: v.literal("update_category"),
categoryId: v.id("categories"),
name: v.optional(v.string()),
kind: v.optional(categoryKind),
block: v.optional(expenseBlock),
color: v.optional(v.string()),
icon: v.optional(v.string()),
sortOrder: v.optional(v.number()),
}),
);
const agentActionPlanPreviewRow = v.object({
date: v.optional(v.string()),
description: v.string(),
amount: v.optional(v.number()),
currentCategoryName: v.optional(v.string()),
targetCategoryName: v.optional(v.string()),
action: v.optional(v.string()),
});
export default defineSchema({
...authTables,
@@ -41,6 +86,22 @@ export default defineSchema({
.index("by_user", ["userId"])
.index("by_user_external", ["userId", "externalId"]),
accountBalances: defineTable({
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: v.union(v.literal("fresh"), v.literal("stale"), v.literal("error")),
errorMessage: v.optional(v.string()),
})
.index("by_user", ["userId"])
.index("by_user_account", ["userId", "accountId"])
.index("by_user_provider_fetched", ["userId", "provider", "fetchedAt"]),
categories: defineTable({
userId: v.id("users"),
name: v.string(),
@@ -214,4 +275,20 @@ export default defineSchema({
sources: v.optional(v.array(chatSource)),
citations: v.optional(v.array(chatCitation)),
}).index("by_user_session_created", ["userId", "sessionId", "createdAt"]),
agentActionPlans: defineTable({
userId: v.id("users"),
sessionId: v.id("chatSessions"),
kind: agentActionPlanKind,
status: agentActionPlanStatus,
summary: v.string(),
affectedCount: v.number(),
createdAt: v.number(),
expiresAt: v.number(),
operations: v.array(agentActionPlanOperation),
previewRows: v.array(agentActionPlanPreviewRow),
resultSummary: v.optional(v.string()),
})
.index("by_user_session_status_created", ["userId", "sessionId", "status", "createdAt"])
.index("by_user_status_expires", ["userId", "status", "expiresAt"]),
});