/// 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(); return { ...actual, generateText: vi.fn(async (options: { tools: { get_transactions: { execute: (input: unknown) => Promise }; summarize_spending: { execute: (input: unknown) => Promise }; get_accounts: { execute: (input: unknown) => Promise }; get_categories: { execute: (input: unknown) => Promise }; detect_recurring_transactions: { execute: (input: unknown) => Promise }; find_anomalies: { execute: (input: unknown) => Promise }; get_uncategorized_transactions: { execute: (input: unknown) => Promise }; compare_periods: { execute: (input: unknown) => Promise }; forecast_fixed_costs: { execute: (input: unknown) => Promise }; explain_savings_rate: { execute: (input: unknown) => Promise }; }; }) => { 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("summarizeSpendingTool resolves supermarket category aliases", async () => { const t = convexTest(schema, modules); const seeded = await t.run(async (ctx) => { const userId = await ctx.db.insert("users", { name: "Alias User", email: "alias@example.com", }); const accountId = await ctx.db.insert("accounts", { userId, name: "Girokonto", type: "checking", openingBalance: 0, currency: "EUR", isArchived: false, }); const groceryId = await ctx.db.insert("categories", { userId, name: "Lebensmittel & Supermarkt", kind: "ausgabe", block: "variabel", color: "#ef4444", sortOrder: 1, isSystem: true, }); const householdId = await ctx.db.insert("categories", { userId, name: "Haushalt & Discounter", kind: "ausgabe", block: "variabel", color: "#fb923c", sortOrder: 2, isSystem: true, }); for (const tx of [ { date: "2026-01-10", description: "REWE", amount: -100, categoryId: groceryId }, { date: "2026-02-10", description: "Kaufland", amount: -120, categoryId: groceryId }, { date: "2026-02-12", description: "Drogerie", amount: -40, categoryId: householdId }, ]) { 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}`, }); for (const categoryName of ["Lebensmittel und Supermarkt", "Supermärkte", "Supermaerkte"]) { 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", }, categoryNames: [categoryName], }); expect(result.totals).toEqual({ transactionCount: 2, income: 0, expenses: -220, balance: -220, }); expect(result.categoryBreakdown.map((entry) => [entry.name, entry.amount])).toEqual([ ["Lebensmittel & Supermarkt", -220], ]); expect(result.categoryFilter?.diagnostics).toEqual([ { requested: categoryName, status: "resolved", matchedName: "Lebensmittel & Supermarkt", suggestions: [], }, ]); } }); test("getCategoriesTool reports unresolved category filters with suggestions", async () => { const { asUser, seeded } = await seedSavingsInsightFixture(); const result = await asUser.query(internal.savingsChat.getCategoriesTool, { scope: { from: "2026-01-01", to: "2026-03-31", accountId: seeded.accountId as Id<"accounts">, basis: "effective", }, categoryNames: ["Lebensmittel Urlaub"], }); expect(result.categories).toEqual([]); expect(result.categoryFilter?.diagnostics).toEqual([ { requested: "Lebensmittel Urlaub", status: "unresolved", suggestions: ["Lebensmittel"], }, ]); }); test("getCategoriesTool reports ambiguous category filters without applying them", async () => { const t = convexTest(schema, modules); const seeded = await t.run(async (ctx) => { const userId = await ctx.db.insert("users", { name: "Ambiguous User", email: "ambiguous@example.com", }); const accountId = await ctx.db.insert("accounts", { userId, name: "Girokonto", type: "checking", openingBalance: 0, currency: "EUR", isArchived: false, }); const onlineShoppingId = await ctx.db.insert("categories", { userId, name: "Shopping & Online", kind: "ausgabe", block: "variabel", color: "#9333ea", sortOrder: 1, isSystem: true, }); const clothesShoppingId = await ctx.db.insert("categories", { userId, name: "Shopping & Kleidung", kind: "ausgabe", block: "variabel", color: "#db2777", sortOrder: 2, isSystem: true, }); for (const tx of [ { date: "2026-02-01", description: "Online Shop", amount: -50, categoryId: onlineShoppingId }, { date: "2026-02-02", description: "Schuhe", amount: -80, categoryId: clothesShoppingId }, ]) { 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 result = await t.withIdentity({ subject: `${seeded.userId}|test-session`, tokenIdentifier: `test:${seeded.userId}`, }).query(internal.savingsChat.getCategoriesTool, { scope: { from: "2026-02-01", to: "2026-02-28", accountId: seeded.accountId as Id<"accounts">, basis: "effective", }, categoryNames: ["Shopping"], }); expect(result.categories).toEqual([]); expect(result.categoryFilter?.diagnostics).toEqual([ { requested: "Shopping", status: "ambiguous", suggestions: ["Shopping & Kleidung", "Shopping & Online"], }, ]); }); test("summarizeSpendingTool preserves the virtual Ohne Kategorie filter", async () => { const { asUser, seeded } = await seedSavingsInsightFixture(); const result = await asUser.query(internal.savingsChat.summarizeSpendingTool, { scope: { from: "2026-02-01", to: "2026-02-28", accountId: seeded.accountId as Id<"accounts">, basis: "effective", }, categoryNames: ["Ohne Kategorie"], }); expect(result.totals).toEqual({ transactionCount: 1, income: 0, expenses: -40, balance: -40 }); expect(result.categoryBreakdown).toEqual([{ name: "Ohne Kategorie", amount: -40 }]); expect(result.categoryFilter?.diagnostics).toEqual([ { requested: "Ohne Kategorie", status: "resolved", matchedName: "Ohne Kategorie", suggestions: [], }, ]); }); 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/); }); test("buildToolTraceFromSteps surfaces category filter diagnostics", () => { const trace = buildToolTraceFromSteps([ { toolResults: [ { toolName: "summarize_spending", input: { categoryNames: ["Lebensmittel Urlaub"] }, output: { totals: { income: 0, expenses: 0, balance: 0, transactionCount: 0 }, categoryBreakdown: [], categoryFilter: { diagnostics: [ { requested: "Lebensmittel Urlaub", status: "unresolved", suggestions: ["Lebensmittel & Supermarkt"], }, { requested: "Shopping", status: "ambiguous", suggestions: ["Shopping & Kleidung", "Shopping & Online"], }, ], }, }, }, ], }, ]); expect(trace[0].resultSummary).toBe( "0 Umsätze, Saldo 0.00€, 0 Kategorien, Kategorie-Filter: Lebensmittel Urlaub unklar (Vorschlag: Lebensmittel & Supermarkt); Shopping mehrdeutig (Vorschläge: Shopping & Kleidung, Shopping & Online)", ); }); });