1185 lines
38 KiB
TypeScript
1185 lines
38 KiB
TypeScript
/// <reference types="vite/client" />
|
|
|
|
import { convexTest } from "convex-test";
|
|
import { generateText } from "ai";
|
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
import { api, internal } from "./_generated/api";
|
|
import type { Id } from "./_generated/dataModel";
|
|
import { buildToolTraceFromSteps } from "./savingsChat";
|
|
import schema from "./schema";
|
|
|
|
vi.mock("ai", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("ai")>();
|
|
return {
|
|
...actual,
|
|
generateText: vi.fn(async (options: {
|
|
tools: {
|
|
get_transactions: { execute: (input: unknown) => Promise<unknown> };
|
|
summarize_spending: { execute: (input: unknown) => Promise<unknown> };
|
|
get_accounts: { execute: (input: unknown) => Promise<unknown> };
|
|
get_categories: { execute: (input: unknown) => Promise<unknown> };
|
|
detect_recurring_transactions: { execute: (input: unknown) => Promise<unknown> };
|
|
find_anomalies: { execute: (input: unknown) => Promise<unknown> };
|
|
get_uncategorized_transactions: { execute: (input: unknown) => Promise<unknown> };
|
|
compare_periods: { execute: (input: unknown) => Promise<unknown> };
|
|
forecast_fixed_costs: { execute: (input: unknown) => Promise<unknown> };
|
|
explain_savings_rate: { 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 transactionOutput = await options.tools.get_transactions.execute(transactionInput);
|
|
const summaryOutput = await options.tools.summarize_spending.execute(summaryInput);
|
|
|
|
return {
|
|
text: "Agenten-Antwort",
|
|
steps: [
|
|
{
|
|
toolResults: [
|
|
{
|
|
toolName: "get_transactions",
|
|
input: transactionInput,
|
|
output: transactionOutput,
|
|
},
|
|
{
|
|
toolName: "summarize_spending",
|
|
input: summaryInput,
|
|
output: summaryOutput,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
|
|
const modules = import.meta.glob("./**/*.ts");
|
|
delete modules["./savingsChat.test.ts"];
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
async function seedSavingsInsightFixture() {
|
|
const t = convexTest(schema, modules);
|
|
|
|
const seeded = await t.run(async (ctx) => {
|
|
const userId = await ctx.db.insert("users", {
|
|
name: "Insight User",
|
|
email: "insight@example.com",
|
|
});
|
|
const otherUserId = await ctx.db.insert("users", {
|
|
name: "Hidden User",
|
|
email: "hidden@example.com",
|
|
});
|
|
const accountId = await ctx.db.insert("accounts", {
|
|
userId,
|
|
name: "Girokonto",
|
|
type: "checking",
|
|
openingBalance: 1000,
|
|
currency: "EUR",
|
|
isArchived: false,
|
|
});
|
|
const oldAccountId = await ctx.db.insert("accounts", {
|
|
userId,
|
|
name: "Altes Konto",
|
|
type: "checking",
|
|
openingBalance: 50,
|
|
currency: "EUR",
|
|
isArchived: true,
|
|
});
|
|
const hiddenAccountId = await ctx.db.insert("accounts", {
|
|
userId: otherUserId,
|
|
name: "Hidden",
|
|
type: "checking",
|
|
openingBalance: 0,
|
|
currency: "EUR",
|
|
isArchived: false,
|
|
});
|
|
|
|
const salaryId = await ctx.db.insert("categories", {
|
|
userId,
|
|
name: "Gehalt",
|
|
kind: "einnahme",
|
|
color: "#0ea5e9",
|
|
sortOrder: 1,
|
|
isSystem: false,
|
|
});
|
|
const rentId = await ctx.db.insert("categories", {
|
|
userId,
|
|
name: "Miete",
|
|
kind: "ausgabe",
|
|
block: "wiederkehrend",
|
|
color: "#64748b",
|
|
sortOrder: 2,
|
|
isSystem: false,
|
|
});
|
|
const subscriptionId = await ctx.db.insert("categories", {
|
|
userId,
|
|
name: "Abos",
|
|
kind: "ausgabe",
|
|
block: "wiederkehrend",
|
|
color: "#a855f7",
|
|
sortOrder: 3,
|
|
isSystem: false,
|
|
});
|
|
const groceriesId = await ctx.db.insert("categories", {
|
|
userId,
|
|
name: "Lebensmittel",
|
|
kind: "ausgabe",
|
|
block: "variabel",
|
|
color: "#22c55e",
|
|
sortOrder: 4,
|
|
isSystem: false,
|
|
});
|
|
|
|
async function insertTx(input: {
|
|
date: string;
|
|
description: string;
|
|
amount: number;
|
|
categoryId?: Id<"categories">;
|
|
counterparty?: string;
|
|
account?: Id<"accounts">;
|
|
owner?: Id<"users">;
|
|
}) {
|
|
await ctx.db.insert("transactions", {
|
|
userId: input.owner ?? userId,
|
|
accountId: input.account ?? accountId,
|
|
categoryId: input.categoryId,
|
|
bookingDate: input.date,
|
|
valueDate: input.date,
|
|
description: input.description,
|
|
counterparty: input.counterparty,
|
|
amount: input.amount,
|
|
isPending: false,
|
|
effectiveMonth: input.date.slice(0, 7),
|
|
rawText: "RAW SHOULD NOT LEAK",
|
|
notes: "PRIVATE NOTE SHOULD NOT LEAK",
|
|
});
|
|
}
|
|
|
|
for (const month of ["2026-01", "2026-02", "2026-03"]) {
|
|
await insertTx({
|
|
date: `${month}-01`,
|
|
description: "Gehalt",
|
|
counterparty: "Arbeitgeber",
|
|
amount: 3000,
|
|
categoryId: salaryId,
|
|
});
|
|
await insertTx({
|
|
date: `${month}-03`,
|
|
description: "Netflix",
|
|
counterparty: "Netflix",
|
|
amount: -15,
|
|
categoryId: subscriptionId,
|
|
});
|
|
}
|
|
|
|
await insertTx({ date: "2026-01-02", description: "Miete", counterparty: "Vermieter", amount: -1000, categoryId: rentId });
|
|
await insertTx({ date: "2026-02-02", description: "Miete", counterparty: "Vermieter", amount: -1000, categoryId: rentId });
|
|
await insertTx({ date: "2026-01-10", description: "Supermarkt", counterparty: "REWE", amount: -100, categoryId: groceriesId });
|
|
await insertTx({ date: "2026-02-10", description: "Supermarkt", counterparty: "REWE", amount: -110, categoryId: groceriesId });
|
|
await insertTx({ date: "2026-03-10", description: "Supermarkt", counterparty: "REWE", amount: -500, categoryId: groceriesId });
|
|
await insertTx({ date: "2026-02-15", description: "Mystery Shop", counterparty: "Mystery GmbH", amount: -40 });
|
|
await insertTx({ date: "2026-03-15", description: "Mystery Shop", counterparty: "Mystery GmbH", amount: -60 });
|
|
await insertTx({ date: "2026-02-20", description: "Archived", amount: -20, account: oldAccountId });
|
|
await insertTx({
|
|
date: "2026-02-20",
|
|
description: "Hidden other user",
|
|
amount: 9999,
|
|
account: hiddenAccountId,
|
|
owner: otherUserId,
|
|
});
|
|
|
|
return { userId, accountId, oldAccountId, salaryId, rentId, subscriptionId, groceriesId };
|
|
});
|
|
|
|
return {
|
|
t,
|
|
seeded,
|
|
asUser: t.withIdentity({
|
|
subject: `${seeded.userId}|test-session`,
|
|
tokenIdentifier: `test:${seeded.userId}`,
|
|
}),
|
|
};
|
|
}
|
|
|
|
describe("savingsChat.getContext", () => {
|
|
test("counts and sums every matching transaction before applying prompt limits", async () => {
|
|
const t = convexTest(schema, modules);
|
|
|
|
const seeded = await t.run(async (ctx) => {
|
|
const userId = await ctx.db.insert("users", {
|
|
name: "Test User",
|
|
email: "test@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 amounts: number[] = [];
|
|
const months = ["2025-12", "2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06"];
|
|
for (let index = 0; index < 450; index++) {
|
|
const month = months[index % months.length];
|
|
const day = String((index % 27) + 1).padStart(2, "0");
|
|
const bookingDate = `${month}-${day}`;
|
|
const amount = index % 3 === 0 ? 100 : -25;
|
|
amounts.push(amount);
|
|
await ctx.db.insert("transactions", {
|
|
userId,
|
|
accountId: giroAccountId,
|
|
bookingDate,
|
|
valueDate: bookingDate,
|
|
description: `Giro transaction ${index}`,
|
|
counterparty: "Counterparty",
|
|
amount,
|
|
isPending: false,
|
|
effectiveMonth: index % 10 === 0 ? undefined : bookingDate.slice(0, 7),
|
|
});
|
|
}
|
|
|
|
for (let index = 0; index < 50; index++) {
|
|
const bookingDate = `2026-06-${String((index % 27) + 1).padStart(2, "0")}`;
|
|
await ctx.db.insert("transactions", {
|
|
userId,
|
|
accountId: otherAccountId,
|
|
bookingDate,
|
|
valueDate: bookingDate,
|
|
description: `Other account transaction ${index}`,
|
|
amount: 999,
|
|
isPending: false,
|
|
effectiveMonth: bookingDate.slice(0, 7),
|
|
});
|
|
}
|
|
|
|
return {
|
|
userId,
|
|
giroAccountId,
|
|
expectedIncome: amounts.filter((amount) => amount > 0).reduce((sum, amount) => sum + amount, 0),
|
|
expectedExpenses: amounts.filter((amount) => amount < 0).reduce((sum, amount) => sum + amount, 0),
|
|
expectedBalance: amounts.reduce((sum, amount) => sum + amount, 0),
|
|
};
|
|
});
|
|
|
|
const asUser = t.withIdentity({
|
|
subject: `${seeded.userId}|test-session`,
|
|
tokenIdentifier: `test:${seeded.userId}`,
|
|
});
|
|
|
|
const context = await asUser.query(api.savingsChat.getContext, {
|
|
from: "2025-12-01",
|
|
to: "2026-06-30",
|
|
accountId: seeded.giroAccountId as Id<"accounts">,
|
|
basis: "effective",
|
|
});
|
|
|
|
expect(context.accountName).toBe("Girokonto");
|
|
expect(context.isComplete).toBe(true);
|
|
expect(context.totals.transactionCount).toBe(450);
|
|
expect(context.totals.income).toBe(seeded.expectedIncome);
|
|
expect(context.totals.expenses).toBe(seeded.expectedExpenses);
|
|
expect(context.totals.balance).toBe(seeded.expectedBalance);
|
|
});
|
|
|
|
test("builds complete prompt lines for every matching transaction", async () => {
|
|
const t = convexTest(schema, modules);
|
|
|
|
const seeded = await t.run(async (ctx) => {
|
|
const userId = await ctx.db.insert("users", {
|
|
name: "Prompt User",
|
|
email: "prompt@example.com",
|
|
});
|
|
const categoryId = await ctx.db.insert("categories", {
|
|
userId,
|
|
name: "Lebensmittel",
|
|
kind: "ausgabe",
|
|
block: "variabel",
|
|
color: "#22c55e",
|
|
sortOrder: 1,
|
|
isSystem: false,
|
|
});
|
|
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: "Depot",
|
|
type: "investment",
|
|
openingBalance: 0,
|
|
currency: "EUR",
|
|
isArchived: false,
|
|
});
|
|
|
|
await ctx.db.insert("transactions", {
|
|
userId,
|
|
accountId: giroAccountId,
|
|
categoryId,
|
|
bookingDate: "2026-02-14",
|
|
valueDate: "2026-02-14",
|
|
description: "Supermarkt",
|
|
counterparty: "Markt GmbH",
|
|
amount: -42.5,
|
|
isPending: false,
|
|
});
|
|
await ctx.db.insert("transactions", {
|
|
userId,
|
|
accountId: giroAccountId,
|
|
bookingDate: "2026-02-15",
|
|
valueDate: "2026-02-15",
|
|
description: "Gehalt",
|
|
counterparty: "Arbeitgeber",
|
|
amount: 2500,
|
|
isPending: false,
|
|
effectiveMonth: "2026-02",
|
|
});
|
|
await ctx.db.insert("transactions", {
|
|
userId,
|
|
accountId: otherAccountId,
|
|
bookingDate: "2026-02-16",
|
|
valueDate: "2026-02-16",
|
|
description: "Other account should not appear",
|
|
amount: 999,
|
|
isPending: false,
|
|
effectiveMonth: "2026-02",
|
|
});
|
|
|
|
return { userId, giroAccountId };
|
|
});
|
|
|
|
const asUser = t.withIdentity({
|
|
subject: `${seeded.userId}|test-session`,
|
|
tokenIdentifier: `test:${seeded.userId}`,
|
|
});
|
|
|
|
const context = await asUser.query(internal.savingsChat.getPromptContext, {
|
|
from: "2026-02-01",
|
|
to: "2026-02-28",
|
|
accountId: seeded.giroAccountId as Id<"accounts">,
|
|
basis: "effective",
|
|
});
|
|
|
|
expect(context.totals.transactionCount).toBe(2);
|
|
expect(context.transactionLines).toHaveLength(2);
|
|
expect(context.transactionLines.join("\n")).toContain(
|
|
"2026-02-14 | Supermarkt (Markt GmbH) | -42.50€ | Lebensmittel | Girokonto",
|
|
);
|
|
expect(context.transactionLines.join("\n")).toContain(
|
|
"2026-02-15 | Gehalt (Arbeitgeber) | 2500.00€ | Ohne Kategorie | Girokonto",
|
|
);
|
|
expect(context.transactionLines.join("\n")).not.toContain("Other account should not appear");
|
|
});
|
|
});
|
|
|
|
describe("savingsChat read-only agent tools", () => {
|
|
test("getTransactionsTool applies account scope, exact totals, limits, and sanitizes rows", async () => {
|
|
const t = convexTest(schema, modules);
|
|
|
|
const seeded = await t.run(async (ctx) => {
|
|
const userId = await ctx.db.insert("users", {
|
|
name: "Tool User",
|
|
email: "tool@example.com",
|
|
});
|
|
const categoryId = await ctx.db.insert("categories", {
|
|
userId,
|
|
name: "Lebensmittel",
|
|
kind: "ausgabe",
|
|
block: "variabel",
|
|
color: "#22c55e",
|
|
sortOrder: 1,
|
|
isSystem: false,
|
|
});
|
|
const giroAccountId = await ctx.db.insert("accounts", {
|
|
userId,
|
|
name: "Girokonto",
|
|
type: "checking",
|
|
openingBalance: 0,
|
|
currency: "EUR",
|
|
isArchived: false,
|
|
});
|
|
const savingsAccountId = await ctx.db.insert("accounts", {
|
|
userId,
|
|
name: "Tagesgeld",
|
|
type: "savings",
|
|
openingBalance: 0,
|
|
currency: "EUR",
|
|
isArchived: false,
|
|
});
|
|
|
|
await ctx.db.insert("transactions", {
|
|
userId,
|
|
accountId: giroAccountId,
|
|
categoryId,
|
|
bookingDate: "2026-01-05",
|
|
valueDate: "2026-01-05",
|
|
description: "Supermarkt",
|
|
counterparty: "Markt GmbH",
|
|
amount: -100,
|
|
isPending: false,
|
|
rawText: "RAW BANK PAYLOAD",
|
|
notes: "private note",
|
|
dedupHash: "secret-hash",
|
|
externalRef: "external-ref",
|
|
effectiveMonth: "2026-01",
|
|
});
|
|
await ctx.db.insert("transactions", {
|
|
userId,
|
|
accountId: giroAccountId,
|
|
bookingDate: "2026-01-07",
|
|
valueDate: "2026-01-07",
|
|
description: "Baecker",
|
|
amount: -15,
|
|
isPending: false,
|
|
effectiveMonth: "2026-01",
|
|
});
|
|
await ctx.db.insert("transactions", {
|
|
userId,
|
|
accountId: giroAccountId,
|
|
bookingDate: "2026-01-03",
|
|
valueDate: "2026-01-03",
|
|
description: "Kaffee",
|
|
amount: -5,
|
|
isPending: false,
|
|
effectiveMonth: "2026-01",
|
|
});
|
|
await ctx.db.insert("transactions", {
|
|
userId,
|
|
accountId: savingsAccountId,
|
|
bookingDate: "2026-01-08",
|
|
valueDate: "2026-01-08",
|
|
description: "Other account",
|
|
amount: 999,
|
|
isPending: false,
|
|
effectiveMonth: "2026-01",
|
|
});
|
|
|
|
return { userId, giroAccountId };
|
|
});
|
|
|
|
const asUser = t.withIdentity({
|
|
subject: `${seeded.userId}|test-session`,
|
|
tokenIdentifier: `test:${seeded.userId}`,
|
|
});
|
|
|
|
const result = await asUser.query(internal.savingsChat.getTransactionsTool, {
|
|
scope: {
|
|
from: "2026-01-01",
|
|
to: "2026-01-31",
|
|
accountId: seeded.giroAccountId as Id<"accounts">,
|
|
basis: "booking",
|
|
},
|
|
limit: 2,
|
|
});
|
|
|
|
expect(result.totalCount).toBe(3);
|
|
expect(result.hasMore).toBe(true);
|
|
expect(result.rows).toHaveLength(2);
|
|
expect(result.totals).toEqual({
|
|
transactionCount: 3,
|
|
income: 0,
|
|
expenses: -120,
|
|
balance: -120,
|
|
});
|
|
expect(result.rows[0].accountName).toBe("Girokonto");
|
|
expect(result.rows.find((row) => row.description === "Supermarkt")?.categoryName).toBe(
|
|
"Lebensmittel",
|
|
);
|
|
expect(Object.keys(result.rows[0])).not.toContain("rawText");
|
|
expect(Object.keys(result.rows[0])).not.toContain("notes");
|
|
expect(Object.keys(result.rows[0])).not.toContain("dedupHash");
|
|
expect(Object.keys(result.rows[0])).not.toContain("externalRef");
|
|
expect(Object.keys(result.rows[0])).not.toContain("userId");
|
|
});
|
|
|
|
test("getTransactionsTool includes legacy effective-month rows via booking fallback", async () => {
|
|
const t = convexTest(schema, modules);
|
|
|
|
const seeded = await t.run(async (ctx) => {
|
|
const userId = await ctx.db.insert("users", {
|
|
name: "Legacy User",
|
|
email: "legacy@example.com",
|
|
});
|
|
const accountId = await ctx.db.insert("accounts", {
|
|
userId,
|
|
name: "Girokonto",
|
|
type: "checking",
|
|
openingBalance: 0,
|
|
currency: "EUR",
|
|
isArchived: false,
|
|
});
|
|
await ctx.db.insert("transactions", {
|
|
userId,
|
|
accountId,
|
|
bookingDate: "2026-03-12",
|
|
valueDate: "2026-03-12",
|
|
description: "Legacy no effective month",
|
|
amount: -30,
|
|
isPending: false,
|
|
});
|
|
await ctx.db.insert("transactions", {
|
|
userId,
|
|
accountId,
|
|
bookingDate: "2026-03-20",
|
|
valueDate: "2026-03-20",
|
|
description: "Modern effective month",
|
|
amount: -20,
|
|
isPending: false,
|
|
effectiveMonth: "2026-03",
|
|
});
|
|
return { userId, accountId };
|
|
});
|
|
|
|
const asUser = t.withIdentity({
|
|
subject: `${seeded.userId}|test-session`,
|
|
tokenIdentifier: `test:${seeded.userId}`,
|
|
});
|
|
|
|
const result = await asUser.query(internal.savingsChat.getTransactionsTool, {
|
|
scope: {
|
|
from: "2026-03-01",
|
|
to: "2026-03-31",
|
|
accountId: seeded.accountId as Id<"accounts">,
|
|
basis: "effective",
|
|
},
|
|
limit: 10,
|
|
});
|
|
|
|
expect(result.totalCount).toBe(2);
|
|
expect(result.totals.expenses).toBe(-50);
|
|
expect(result.rows.map((row) => row.description)).toContain("Legacy no effective month");
|
|
});
|
|
|
|
test("summarizeSpendingTool computes exact monthly and category aggregates", async () => {
|
|
const t = convexTest(schema, modules);
|
|
|
|
const seeded = await t.run(async (ctx) => {
|
|
const userId = await ctx.db.insert("users", {
|
|
name: "Summary User",
|
|
email: "summary@example.com",
|
|
});
|
|
const accountId = await ctx.db.insert("accounts", {
|
|
userId,
|
|
name: "Girokonto",
|
|
type: "checking",
|
|
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 foodId = 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-01-01", description: "Gehalt Januar", amount: 3000, categoryId: salaryId },
|
|
{ date: "2026-01-02", description: "Miete Januar", amount: -1000, categoryId: rentId },
|
|
{ date: "2026-01-10", description: "Supermarkt Januar", amount: -200, categoryId: foodId },
|
|
{ date: "2026-02-01", description: "Gehalt Februar", amount: 3000, categoryId: salaryId },
|
|
{ date: "2026-02-02", description: "Sonstiges Februar", amount: -50, categoryId: undefined },
|
|
]) {
|
|
await ctx.db.insert("transactions", {
|
|
userId,
|
|
accountId,
|
|
categoryId: tx.categoryId,
|
|
bookingDate: tx.date,
|
|
valueDate: tx.date,
|
|
description: tx.description,
|
|
amount: tx.amount,
|
|
isPending: false,
|
|
effectiveMonth: tx.date.slice(0, 7),
|
|
});
|
|
}
|
|
|
|
return { userId, accountId };
|
|
});
|
|
|
|
const asUser = t.withIdentity({
|
|
subject: `${seeded.userId}|test-session`,
|
|
tokenIdentifier: `test:${seeded.userId}`,
|
|
});
|
|
|
|
const result = await asUser.query(internal.savingsChat.summarizeSpendingTool, {
|
|
scope: {
|
|
from: "2026-01-01",
|
|
to: "2026-02-28",
|
|
accountId: seeded.accountId as Id<"accounts">,
|
|
basis: "effective",
|
|
},
|
|
});
|
|
|
|
expect(result.totals).toEqual({
|
|
transactionCount: 5,
|
|
income: 6000,
|
|
expenses: -1250,
|
|
balance: 4750,
|
|
});
|
|
expect(result.fixedCosts).toBe(-1000);
|
|
expect(result.variableCosts).toBe(-200);
|
|
expect(result.monthlyTrend).toEqual([
|
|
{ month: "2026-01", income: 3000, expenses: -1200, balance: 1800 },
|
|
{ month: "2026-02", income: 3000, expenses: -50, balance: 2950 },
|
|
]);
|
|
expect(result.categoryBreakdown.map((entry) => [entry.name, entry.amount])).toEqual([
|
|
["Miete", -1000],
|
|
["Lebensmittel", -200],
|
|
["Ohne Kategorie", -50],
|
|
]);
|
|
});
|
|
|
|
test("forecastCashflowTool excludes partial current month from the baseline", async () => {
|
|
const t = convexTest(schema, modules);
|
|
|
|
const seeded = await t.run(async (ctx) => {
|
|
const userId = await ctx.db.insert("users", {
|
|
name: "Forecast User",
|
|
email: "forecast@example.com",
|
|
});
|
|
const accountId = await ctx.db.insert("accounts", {
|
|
userId,
|
|
name: "Girokonto",
|
|
type: "checking",
|
|
openingBalance: 0,
|
|
currency: "EUR",
|
|
isArchived: false,
|
|
});
|
|
|
|
for (const tx of [
|
|
{ date: "2026-04-01", description: "Gehalt April", amount: 3000 },
|
|
{ date: "2026-04-10", description: "Kosten April", amount: -2000 },
|
|
{ date: "2026-05-01", description: "Gehalt Mai", amount: 3200 },
|
|
{ date: "2026-05-10", description: "Kosten Mai", amount: -2200 },
|
|
{ date: "2026-06-01", description: "Gehalt Juni", amount: 3000 },
|
|
{ date: "2026-06-10", description: "Teilkosten Juni", amount: -500 },
|
|
]) {
|
|
await ctx.db.insert("transactions", {
|
|
userId,
|
|
accountId,
|
|
bookingDate: tx.date,
|
|
valueDate: tx.date,
|
|
description: tx.description,
|
|
amount: tx.amount,
|
|
isPending: false,
|
|
effectiveMonth: tx.date.slice(0, 7),
|
|
});
|
|
}
|
|
|
|
return { userId, accountId };
|
|
});
|
|
|
|
const asUser = t.withIdentity({
|
|
subject: `${seeded.userId}|test-session`,
|
|
tokenIdentifier: `test:${seeded.userId}`,
|
|
});
|
|
|
|
const result = await asUser.query(internal.savingsChat.forecastCashflowTool, {
|
|
scope: {
|
|
from: "2026-04-01",
|
|
to: "2026-06-30",
|
|
accountId: seeded.accountId as Id<"accounts">,
|
|
basis: "effective",
|
|
},
|
|
horizonMonths: 3,
|
|
asOf: "2026-06-15",
|
|
});
|
|
|
|
expect(result.baselineMonths).toEqual(["2026-04", "2026-05"]);
|
|
expect(result.excludedPartialMonth).toBe("2026-06");
|
|
expect(result.monthlyAverage).toEqual({ income: 3100, expenses: -2100, balance: 1000 });
|
|
expect(result.projection).toEqual([
|
|
{ month: "2026-07", income: 3100, expenses: -2100, balance: 1000 },
|
|
{ month: "2026-08", income: 3100, expenses: -2100, balance: 1000 },
|
|
{ month: "2026-09", income: 3100, expenses: -2100, balance: 1000 },
|
|
]);
|
|
});
|
|
|
|
test("buildToolTraceFromSteps returns compact summaries without raw tool output", () => {
|
|
const trace = buildToolTraceFromSteps([
|
|
{
|
|
toolResults: [
|
|
{
|
|
toolName: "get_transactions",
|
|
input: { from: "2026-01-01", to: "2026-01-31", limit: 50 },
|
|
output: {
|
|
totalCount: 2,
|
|
hasMore: false,
|
|
totals: { income: 0, expenses: -115, balance: -115, transactionCount: 2 },
|
|
rows: [
|
|
{ description: "Supermarkt", amount: -100 },
|
|
{ description: "Baecker", amount: -15 },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
expect(trace).toEqual([
|
|
{
|
|
name: "get_transactions",
|
|
inputSummary: "2026-01-01 bis 2026-01-31, Limit 50",
|
|
resultSummary: "2 Umsätze, Saldo -115.00€, vollständig",
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("ask returns compact tool traces from executed read-only tools", async () => {
|
|
const t = convexTest(schema, modules);
|
|
const previousKey = process.env.OPENAI_API_KEY;
|
|
const previousModel = process.env.SAVINGS_CHAT_MODEL;
|
|
|
|
const seeded = await t.run(async (ctx) => {
|
|
const userId = await ctx.db.insert("users", {
|
|
name: "Ask User",
|
|
email: "ask@example.com",
|
|
});
|
|
const accountId = await ctx.db.insert("accounts", {
|
|
userId,
|
|
name: "Girokonto",
|
|
type: "checking",
|
|
openingBalance: 0,
|
|
currency: "EUR",
|
|
isArchived: false,
|
|
});
|
|
await ctx.db.insert("transactions", {
|
|
userId,
|
|
accountId,
|
|
bookingDate: "2026-02-01",
|
|
valueDate: "2026-02-01",
|
|
description: "Gehalt",
|
|
amount: 3000,
|
|
isPending: false,
|
|
effectiveMonth: "2026-02",
|
|
rawText: "RAW PAYLOAD SHOULD NOT LEAK",
|
|
});
|
|
await ctx.db.insert("transactions", {
|
|
userId,
|
|
accountId,
|
|
bookingDate: "2026-02-10",
|
|
valueDate: "2026-02-10",
|
|
description: "Supermarkt",
|
|
amount: -120,
|
|
isPending: false,
|
|
effectiveMonth: "2026-02",
|
|
notes: "private note should not leak",
|
|
});
|
|
return { userId, accountId };
|
|
});
|
|
|
|
try {
|
|
process.env.OPENAI_API_KEY = "test-key";
|
|
delete process.env.SAVINGS_CHAT_MODEL;
|
|
const asUser = t.withIdentity({
|
|
subject: `${seeded.userId}|test-session`,
|
|
tokenIdentifier: `test:${seeded.userId}`,
|
|
});
|
|
|
|
const result = await asUser.action(api.savingsChat.ask, {
|
|
messages: [{ role: "user", content: "Wie sieht Februar aus?" }],
|
|
from: "2026-02-01",
|
|
to: "2026-02-28",
|
|
accountId: seeded.accountId as Id<"accounts">,
|
|
basis: "effective",
|
|
});
|
|
|
|
expect(result.answer).toBe("Agenten-Antwort");
|
|
expect(result.model).toBe("gpt-5.4-mini");
|
|
expect(result.usedTransactions).toBe(2);
|
|
expect(result.usedBalance).toEqual({ income: 3000, expenses: -120, balance: 2880 });
|
|
expect(result.toolTrace).toEqual([
|
|
{
|
|
name: "get_transactions",
|
|
inputSummary: "2026-02-01 bis 2026-02-28, Limit 2",
|
|
resultSummary: "2 Umsätze, Saldo 2880.00€, vollständig",
|
|
},
|
|
{
|
|
name: "summarize_spending",
|
|
inputSummary: "2026-02-01 bis 2026-02-28",
|
|
resultSummary: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
|
|
},
|
|
]);
|
|
expect(JSON.stringify(result.toolTrace)).not.toContain("RAW PAYLOAD");
|
|
expect(JSON.stringify(result.toolTrace)).not.toContain("private note");
|
|
|
|
expect(generateText).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
tools: expect.objectContaining({
|
|
get_transactions: expect.any(Object),
|
|
summarize_spending: expect.any(Object),
|
|
forecast_cashflow: expect.any(Object),
|
|
get_accounts: expect.any(Object),
|
|
get_categories: expect.any(Object),
|
|
detect_recurring_transactions: expect.any(Object),
|
|
find_anomalies: expect.any(Object),
|
|
get_uncategorized_transactions: expect.any(Object),
|
|
compare_periods: expect.any(Object),
|
|
forecast_fixed_costs: expect.any(Object),
|
|
explain_savings_rate: expect.any(Object),
|
|
}),
|
|
stopWhen: expect.any(Function),
|
|
}),
|
|
);
|
|
} finally {
|
|
if (previousKey === undefined) {
|
|
delete process.env.OPENAI_API_KEY;
|
|
} else {
|
|
process.env.OPENAI_API_KEY = previousKey;
|
|
}
|
|
if (previousModel === undefined) {
|
|
delete process.env.SAVINGS_CHAT_MODEL;
|
|
} else {
|
|
process.env.SAVINGS_CHAT_MODEL = previousModel;
|
|
}
|
|
}
|
|
});
|
|
|
|
test("getAccountsTool returns sanitized account totals for the selected scope", async () => {
|
|
const t = convexTest(schema, modules);
|
|
|
|
const seeded = await t.run(async (ctx) => {
|
|
const userId = await ctx.db.insert("users", { name: "Accounts User", email: "accounts@example.com" });
|
|
const otherUserId = await ctx.db.insert("users", { name: "Other User", email: "other@example.com" });
|
|
const giroAccountId = await ctx.db.insert("accounts", {
|
|
userId,
|
|
name: "Girokonto",
|
|
type: "checking",
|
|
iban: "DE123",
|
|
openingBalance: 1000,
|
|
currency: "EUR",
|
|
isArchived: false,
|
|
});
|
|
const archiveAccountId = await ctx.db.insert("accounts", {
|
|
userId,
|
|
name: "Altes Konto",
|
|
type: "checking",
|
|
openingBalance: 50,
|
|
currency: "EUR",
|
|
isArchived: true,
|
|
});
|
|
const otherAccountId = await ctx.db.insert("accounts", {
|
|
userId: otherUserId,
|
|
name: "Fremdes Konto",
|
|
type: "checking",
|
|
openingBalance: 0,
|
|
currency: "EUR",
|
|
isArchived: false,
|
|
});
|
|
|
|
await ctx.db.insert("transactions", {
|
|
userId,
|
|
accountId: giroAccountId,
|
|
bookingDate: "2026-02-01",
|
|
valueDate: "2026-02-01",
|
|
description: "Gehalt",
|
|
amount: 3000,
|
|
isPending: false,
|
|
effectiveMonth: "2026-02",
|
|
});
|
|
await ctx.db.insert("transactions", {
|
|
userId,
|
|
accountId: giroAccountId,
|
|
bookingDate: "2026-02-05",
|
|
valueDate: "2026-02-05",
|
|
description: "Miete",
|
|
amount: -1000,
|
|
isPending: false,
|
|
effectiveMonth: "2026-02",
|
|
});
|
|
await ctx.db.insert("transactions", {
|
|
userId,
|
|
accountId: archiveAccountId,
|
|
bookingDate: "2026-02-10",
|
|
valueDate: "2026-02-10",
|
|
description: "Altlast",
|
|
amount: -20,
|
|
isPending: false,
|
|
effectiveMonth: "2026-02",
|
|
});
|
|
await ctx.db.insert("transactions", {
|
|
userId: otherUserId,
|
|
accountId: otherAccountId,
|
|
bookingDate: "2026-02-10",
|
|
valueDate: "2026-02-10",
|
|
description: "Should not appear",
|
|
amount: 9999,
|
|
isPending: false,
|
|
effectiveMonth: "2026-02",
|
|
});
|
|
|
|
return { userId };
|
|
});
|
|
|
|
const result = await t.withIdentity({
|
|
subject: `${seeded.userId}|test-session`,
|
|
tokenIdentifier: `test:${seeded.userId}`,
|
|
}).query(internal.savingsChat.getAccountsTool, {
|
|
scope: { from: "2026-02-01", to: "2026-02-28", basis: "effective" },
|
|
includeArchived: true,
|
|
});
|
|
|
|
expect(result.accounts.map((account) => account.name)).toEqual(["Girokonto", "Altes Konto"]);
|
|
expect(result.accounts[0]).toEqual({
|
|
name: "Girokonto",
|
|
type: "checking",
|
|
currency: "EUR",
|
|
isArchived: false,
|
|
openingBalance: 1000,
|
|
transactionCount: 2,
|
|
balance: 2000,
|
|
});
|
|
expect(JSON.stringify(result)).not.toContain("DE123");
|
|
expect(JSON.stringify(result)).not.toContain("Should not appear");
|
|
});
|
|
|
|
test("getCategoriesTool returns scoped category totals and shares", async () => {
|
|
const { asUser, seeded } = await seedSavingsInsightFixture();
|
|
|
|
const result = await asUser.query(internal.savingsChat.getCategoriesTool, {
|
|
scope: {
|
|
from: "2026-01-01",
|
|
to: "2026-02-28",
|
|
accountId: seeded.accountId as Id<"accounts">,
|
|
basis: "effective",
|
|
},
|
|
});
|
|
|
|
expect(result.categories.map((category) => [category.name, category.transactionCount, category.amount])).toEqual([
|
|
["Gehalt", 2, 6000],
|
|
["Miete", 2, -2000],
|
|
["Abos", 2, -30],
|
|
["Lebensmittel", 2, -210],
|
|
["Ohne Kategorie", 1, -40],
|
|
]);
|
|
expect(result.categories.find((category) => category.name === "Miete")).toMatchObject({
|
|
kind: "ausgabe",
|
|
block: "wiederkehrend",
|
|
shareOfExpenses: 0.877,
|
|
});
|
|
expect(JSON.stringify(result)).not.toContain("Hidden other user");
|
|
});
|
|
|
|
test("getUncategorizedTransactionsTool returns bounded sanitized uncategorized insight", async () => {
|
|
const { asUser, seeded } = await seedSavingsInsightFixture();
|
|
|
|
const result = await asUser.query(internal.savingsChat.getUncategorizedTransactionsTool, {
|
|
scope: {
|
|
from: "2026-02-01",
|
|
to: "2026-03-31",
|
|
accountId: seeded.accountId as Id<"accounts">,
|
|
basis: "effective",
|
|
},
|
|
limit: 1,
|
|
});
|
|
|
|
expect(result.totalCount).toBe(2);
|
|
expect(result.hasMore).toBe(true);
|
|
expect(result.totals).toEqual({ transactionCount: 2, income: 0, expenses: -100, balance: -100 });
|
|
expect(result.rows).toHaveLength(1);
|
|
expect(result.topCounterparties).toEqual([{ name: "Mystery GmbH", count: 2, amount: -100 }]);
|
|
expect(JSON.stringify(result)).not.toContain("RAW SHOULD NOT LEAK");
|
|
expect(JSON.stringify(result)).not.toContain("PRIVATE NOTE SHOULD NOT LEAK");
|
|
});
|
|
|
|
test("comparePeriodsTool computes totals and category deltas", async () => {
|
|
const { asUser, seeded } = await seedSavingsInsightFixture();
|
|
|
|
const result = await asUser.query(internal.savingsChat.comparePeriodsTool, {
|
|
scope: {
|
|
from: "2026-03-01",
|
|
to: "2026-03-31",
|
|
accountId: seeded.accountId as Id<"accounts">,
|
|
basis: "effective",
|
|
},
|
|
compareFrom: "2026-02-01",
|
|
compareTo: "2026-02-28",
|
|
});
|
|
|
|
expect(result.current.totals).toEqual({ transactionCount: 4, income: 3000, expenses: -575, balance: 2425 });
|
|
expect(result.previous.totals).toEqual({ transactionCount: 5, income: 3000, expenses: -1165, balance: 1835 });
|
|
expect(result.deltas).toMatchObject({ income: 0, expenses: 590, balance: 590, fixedCosts: 1000, variableCosts: -390 });
|
|
expect(result.categoryDeltas.find((entry) => entry.name === "Miete")).toMatchObject({
|
|
currentAmount: 0,
|
|
previousAmount: -1000,
|
|
delta: 1000,
|
|
});
|
|
expect(result.categoryDeltas.find((entry) => entry.name === "Lebensmittel")).toMatchObject({
|
|
currentAmount: -500,
|
|
previousAmount: -110,
|
|
delta: -390,
|
|
});
|
|
});
|
|
|
|
test("explainSavingsRateTool reports formula inputs and deterministic drivers", async () => {
|
|
const { asUser, seeded } = await seedSavingsInsightFixture();
|
|
|
|
const result = await asUser.query(internal.savingsChat.explainSavingsRateTool, {
|
|
scope: {
|
|
from: "2026-02-01",
|
|
to: "2026-02-28",
|
|
accountId: seeded.accountId as Id<"accounts">,
|
|
basis: "effective",
|
|
},
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
income: 3000,
|
|
expenses: -1165,
|
|
savedAmount: 1835,
|
|
savingsRate: 0.612,
|
|
fixedCosts: -1015,
|
|
variableCosts: -110,
|
|
transactionCount: 5,
|
|
});
|
|
expect(result.drivers.map((driver) => driver.name)).toEqual(["Miete", "Lebensmittel", "Ohne Kategorie"]);
|
|
expect(result.levers).toContainEqual({
|
|
label: "Variable Ausgaben um 10% senken",
|
|
monthlyImpact: 11,
|
|
});
|
|
});
|
|
|
|
test("detectRecurringTransactionsTool finds monthly patterns and skips one-offs", async () => {
|
|
const { asUser, seeded } = await seedSavingsInsightFixture();
|
|
|
|
const result = await asUser.query(internal.savingsChat.detectRecurringTransactionsTool, {
|
|
scope: {
|
|
from: "2026-01-01",
|
|
to: "2026-03-31",
|
|
accountId: seeded.accountId as Id<"accounts">,
|
|
basis: "effective",
|
|
},
|
|
});
|
|
|
|
expect(result.patterns.map((pattern) => pattern.label)).toEqual(["Gehalt", "Netflix", "Miete"]);
|
|
expect(result.patterns.find((pattern) => pattern.label === "Miete")).toMatchObject({
|
|
months: ["2026-01", "2026-02"],
|
|
occurrenceCount: 2,
|
|
averageAmount: -1000,
|
|
frequency: "monthly",
|
|
});
|
|
expect(result.patterns.map((pattern) => pattern.label)).not.toContain("Mystery Shop");
|
|
});
|
|
|
|
test("forecastFixedCostsTool forecasts recurring fixed costs for the requested horizon", async () => {
|
|
const { asUser, seeded } = await seedSavingsInsightFixture();
|
|
|
|
const result = await asUser.query(internal.savingsChat.forecastFixedCostsTool, {
|
|
scope: {
|
|
from: "2026-01-01",
|
|
to: "2026-02-28",
|
|
accountId: seeded.accountId as Id<"accounts">,
|
|
basis: "effective",
|
|
},
|
|
horizonMonths: 2,
|
|
asOf: "2026-02-28",
|
|
});
|
|
|
|
expect(result.items.map((item) => [item.label, item.averageAmount])).toEqual([
|
|
["Miete", -1000],
|
|
["Netflix", -15],
|
|
]);
|
|
expect(result.forecast).toEqual([
|
|
{ month: "2026-03", totalFixedCosts: -1015 },
|
|
{ month: "2026-04", totalFixedCosts: -1015 },
|
|
]);
|
|
});
|
|
|
|
test("findAnomaliesTool reports amount spikes and missing recurring transactions", async () => {
|
|
const { asUser, seeded } = await seedSavingsInsightFixture();
|
|
|
|
const result = await asUser.query(internal.savingsChat.findAnomaliesTool, {
|
|
scope: {
|
|
from: "2026-01-01",
|
|
to: "2026-03-31",
|
|
accountId: seeded.accountId as Id<"accounts">,
|
|
basis: "effective",
|
|
},
|
|
asOf: "2026-03-31",
|
|
});
|
|
|
|
expect(result.anomalies).toContainEqual({
|
|
kind: "amount_spike",
|
|
label: "Lebensmittel",
|
|
month: "2026-03",
|
|
amount: -500,
|
|
expectedAmount: -105,
|
|
severity: "high",
|
|
});
|
|
expect(result.anomalies).toContainEqual({
|
|
kind: "missing_recurring",
|
|
label: "Miete",
|
|
month: "2026-03",
|
|
amount: 0,
|
|
expectedAmount: -1000,
|
|
severity: "medium",
|
|
});
|
|
});
|
|
|
|
test("buildToolTraceFromSteps summarizes every insight tool without private payloads", () => {
|
|
const trace = buildToolTraceFromSteps([
|
|
{
|
|
toolResults: [
|
|
{ toolName: "get_accounts", input: {}, output: { accounts: [{ name: "Girokonto" }] } },
|
|
{ toolName: "get_categories", input: {}, output: { categories: [{ name: "Miete" }] } },
|
|
{ toolName: "detect_recurring_transactions", input: {}, output: { patterns: [{ label: "Miete" }] } },
|
|
{ toolName: "find_anomalies", input: {}, output: { anomalies: [{ label: "Lebensmittel" }] } },
|
|
{ toolName: "get_uncategorized_transactions", input: { limit: 2 }, output: { totalCount: 3, hasMore: true } },
|
|
{ toolName: "compare_periods", input: {}, output: { deltas: { balance: 590 } } },
|
|
{ toolName: "forecast_fixed_costs", input: { horizonMonths: 2 }, output: { forecast: [{}, {}] } },
|
|
{ toolName: "explain_savings_rate", input: {}, output: { savingsRate: 0.612, savedAmount: 1835 } },
|
|
],
|
|
},
|
|
]);
|
|
|
|
expect(trace.map((entry) => entry.resultSummary)).toEqual([
|
|
"1 Konto ausgewertet",
|
|
"1 Kategorie ausgewertet",
|
|
"1 wiederkehrendes Muster erkannt",
|
|
"1 Auffälligkeit erkannt",
|
|
"3 unklassifizierte Umsätze, weitere vorhanden",
|
|
"Periodenvergleich, Saldo-Differenz 590.00€",
|
|
"Fixkosten-Prognose 2 Monate",
|
|
"Sparquote 61.2%, gespart 1835.00€",
|
|
]);
|
|
expect(JSON.stringify(trace)).not.toMatch(/rawText|notes|dedupHash|externalRef|userId|_id|iban|externalId/);
|
|
});
|
|
});
|