Add financing data import and normalization flow

This commit is contained in:
2026-06-15 21:52:39 +02:00
parent 238a30ae0c
commit 3ceccafa57
3 changed files with 1474 additions and 0 deletions

View File

@@ -16,6 +16,14 @@ vi.mock("ai", async (importOriginal) => {
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 };
@@ -53,6 +61,150 @@ 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);
@@ -694,6 +846,14 @@ describe("savingsChat read-only agent tools", () => {
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),
}),
@@ -711,4 +871,314 @@ describe("savingsChat read-only agent tools", () => {
}
}
});
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/);
});
});