feat: add guarded savings agent tools
This commit is contained in:
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
@@ -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
178
convex/balances.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
34
convex/bank/balanceProviders.test.ts
Normal file
34
convex/bank/balanceProviders.test.ts
Normal 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
131
convex/bank/balances.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -14,6 +14,7 @@ export type NormalizedBalance = {
|
||||
externalId: string;
|
||||
balance: number;
|
||||
currency: string;
|
||||
asOf?: string;
|
||||
};
|
||||
|
||||
export type NormalizedTransaction = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
266
convex/savingsChatActionPlans.ts
Normal file
266
convex/savingsChatActionPlans.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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"]),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user