From b7dae31fe192fc211aba185c8df2ec3ec4c9ceda Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 23 Jun 2026 21:13:02 +0200 Subject: [PATCH] feat: add guarded savings agent tools --- ... - Plane-Kontostandabruf-und-UI-Anzeige.md | 45 ++ .../task-16 - Add-guarded-agent-tools.md | 41 + convex/_generated/api.d.ts | 4 + convex/balances.test.ts | 178 +++++ convex/bank/balanceProviders.test.ts | 34 + convex/bank/balances.ts | 131 ++++ convex/bank/comdirectProvider.ts | 156 +++- convex/bank/internal.ts | 97 +++ convex/bank/orchestrator.ts | 305 +++++++- convex/bank/types.ts | 1 + convex/savingsChat.test.ts | 725 ++++++++++++++++- convex/savingsChat.ts | 736 +++++++++++++++++- convex/savingsChatActionPlans.ts | 266 +++++++ convex/schema.ts | 77 ++ .../plans/2026-06-23-kontostandabruf-ui.md | 225 ++++++ .../accounts/AccountBalanceStrip.test.tsx | 76 ++ .../accounts/AccountBalanceStrip.tsx | 177 +++++ src/components/chat/AgentChat.test.tsx | 53 ++ src/components/chat/AgentChat.tsx | 122 ++- src/pages/DashboardPage.tsx | 5 +- src/pages/SavingsChatPage.tsx | 51 ++ src/pages/SettingsPage.tsx | 78 +- 22 files changed, 3510 insertions(+), 73 deletions(-) create mode 100644 backlog/tasks/task-14 - Plane-Kontostandabruf-und-UI-Anzeige.md create mode 100644 backlog/tasks/task-16 - Add-guarded-agent-tools.md create mode 100644 convex/balances.test.ts create mode 100644 convex/bank/balanceProviders.test.ts create mode 100644 convex/bank/balances.ts create mode 100644 convex/savingsChatActionPlans.ts create mode 100644 docs/superpowers/plans/2026-06-23-kontostandabruf-ui.md create mode 100644 src/components/accounts/AccountBalanceStrip.test.tsx create mode 100644 src/components/accounts/AccountBalanceStrip.tsx diff --git a/backlog/tasks/task-14 - Plane-Kontostandabruf-und-UI-Anzeige.md b/backlog/tasks/task-14 - Plane-Kontostandabruf-und-UI-Anzeige.md new file mode 100644 index 0000000..af2d424 --- /dev/null +++ b/backlog/tasks/task-14 - Plane-Kontostandabruf-und-UI-Anzeige.md @@ -0,0 +1,45 @@ +--- +id: TASK-14 +title: Plane Kontostandabruf und UI-Anzeige +status: In Progress +assignee: [] +created_date: '2026-06-23 09:57' +updated_date: '2026-06-23 10:18' +labels: [] +dependencies: [] +priority: high +ordinal: 14000 +--- + +## Description + + +Architektur- und Umsetzungsplan für Live-Kontostände über FinTS und comdirect REST, inklusive Persistenzmodell, Sync-Route und Dashboard/UI-Anzeige. + + +## Acceptance Criteria + +- [x] #1 Der Plan beschreibt die Datenmodell-Erweiterung für aktuelle Kontostände und Abruf-Metadaten. +- [x] #2 Der Plan beschreibt getrennte Abrufpfade für FinTS und comdirect REST mit Auto/Fallback-Verhalten. +- [x] #3 Der Plan beschreibt eine Dashboard- und Konto-UI für Kontostände inklusive Lade-, Fehler- und Veraltet-Zuständen. +- [x] #4 Der Plan nennt konkrete Dateien, Tests und Validierungsschritte für eine spätere Implementierung. + + +## Implementation Plan + + +1. Modell für Live-Kontostände ergänzen +2. Provider-Abrufpfade für comdirect REST und FinTS vereinheitlichen +3. Public Convex Route/Action für reinen Saldoabruf planen +4. Persistenz und Sync-State für Salden aktualisieren +5. Dashboard- und Konten-UI für aktuelle Salden ergänzen +6. Tests für Mapping, Persistenz, Fallback und UI-Zustände definieren + + +## Implementation Notes + + +Plan dokumentiert in docs/superpowers/plans/2026-06-23-kontostandabruf-ui.md. ctx7: FinTS-Doku gefunden (/nemiah/phpfints), comdirect API in ctx7 nicht brauchbar gefunden; Planung nutzt vorhandenen lokalen comdirect REST Client. + +Umsetzung: accountBalances Schema + interne Upsert-Query ergänzt; public api.bank.balances.listLatest/refresh hinzugefügt; comdirect Balance-Mapping und Balance-only Fetch ergänzt; FinTS HKSAL Helper extrahiert; Full-Sync persistiert aktuelle Salden; Dashboard zeigt AccountBalanceStrip; Settings zeigt Live-Saldo pro Konto. Verifiziert: pnpm vitest run convex/balances.test.ts convex/bank/balanceProviders.test.ts src/components/accounts/AccountBalanceStrip.test.tsx; pnpm build; eslint auf neuen/berührten Kern-Dateien ohne Fehler. Vollständiges pnpm lint scheitert weiterhin an bestehenden unrelated Regeln in anderen Dateien. + diff --git a/backlog/tasks/task-16 - Add-guarded-agent-tools.md b/backlog/tasks/task-16 - Add-guarded-agent-tools.md new file mode 100644 index 0000000..f707c9e --- /dev/null +++ b/backlog/tasks/task-16 - Add-guarded-agent-tools.md @@ -0,0 +1,41 @@ +--- +id: TASK-16 +title: Add guarded agent tools +status: Done +assignee: [] +created_date: '2026-06-23 11:55' +updated_date: '2026-06-23 19:12' +labels: [] +dependencies: [] +priority: high +ordinal: 16000 +--- + +## Description + + +Build AI agent tools for category health, category suggestions, guarded category write plans, and current-month spending preview. + + +## Acceptance Criteria + +- [x] #1 Read-only agent tools cover category health, category suggestions, and current-month expense preview +- [x] #2 Write-capable agent tools create pending action plans without directly changing data +- [x] #3 Pending action plans can be listed, applied, and dismissed from the chat UI +- [x] #4 Applying action plans revalidates ownership, status, expiry, and operation limits +- [x] #5 Convex and UI tests cover the new tool and confirmation behavior + + +## Implementation Notes + + +Implemented guarded agent tool package. Verification: npx vitest run passed (14 files, 79 tests); npm run build passed. npm run lint still fails on pre-existing unrelated files (bank/config.ts, TanAwaitDialog, fast-refresh/export warnings, SettingsPage effect), with no remaining lint error from the new action-plan module. + +Post-review hardening: added tests and fixes for empty category names, duplicate category renames, stale transaction category plans, and expired plans. Re-verified: convex/savingsChat.test.ts 33 passed; full npx vitest run 81 passed; npm run build passed; npx convex codegen passed. + + +## Final Summary + + +Implemented guarded AI agent tools with current-month expense preview, category health/suggestion tools, pending action plans, UI apply/dismiss controls, and post-review validation hardening. Verified with full Vitest suite, build, and Convex codegen; lint remains blocked by pre-existing unrelated project issues. + diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 951c595..f303996 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -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; diff --git a/convex/balances.test.ts b/convex/balances.test.ts new file mode 100644 index 0000000..976a96d --- /dev/null +++ b/convex/balances.test.ts @@ -0,0 +1,178 @@ +/// + +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", + }); + }); +}); diff --git a/convex/bank/balanceProviders.test.ts b/convex/bank/balanceProviders.test.ts new file mode 100644 index 0000000..0005779 --- /dev/null +++ b/convex/bank/balanceProviders.test.ts @@ -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", + }, + ]); + }); +}); + diff --git a/convex/bank/balances.ts b/convex/bank/balances.ts new file mode 100644 index 0000000..3da2ef5 --- /dev/null +++ b/convex/bank/balances.ts @@ -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, 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, + }); + }, +}); diff --git a/convex/bank/comdirectProvider.ts b/convex/bank/comdirectProvider.ts index 3d62e1c..a0c6785 100644 --- a/convex/bank/comdirectProvider.ts +++ b/convex/bank/comdirectProvider.ts @@ -33,6 +33,77 @@ type ComdirectProviderContext = { userId: Id<"users">; }; +type ComdirectBalancePayload = { + values?: Array>; +}; + +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 { @@ -57,39 +128,16 @@ export async function createComdirectRestProvider( async getAccounts(): Promise { 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 { 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(); + 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)), + }; +} diff --git a/convex/bank/internal.ts b/convex/bank/internal.ts index f1a56a9..291ba2c 100644 --- a/convex/bank/internal.ts +++ b/convex/bank/internal.ts @@ -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); + }, +}); diff --git a/convex/bank/orchestrator.ts b/convex/bank/orchestrator.ts index afd5ef8..c4643a9 100644 --- a/convex/bank/orchestrator.ts +++ b/convex/bank/orchestrator.ts @@ -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; }; +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 { + 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>(); + 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, ): Promise<{ importedCount: number; skippedCount: number }> { const accountIdMap = new Map>(); + 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"), diff --git a/convex/bank/types.ts b/convex/bank/types.ts index 3d8ddde..204a0cd 100644 --- a/convex/bank/types.ts +++ b/convex/bank/types.ts @@ -14,6 +14,7 @@ export type NormalizedBalance = { externalId: string; balance: number; currency: string; + asOf?: string; }; export type NormalizedTransaction = { diff --git a/convex/savingsChat.test.ts b/convex/savingsChat.test.ts index 7625275..a0a2c0d 100644 --- a/convex/savingsChat.test.ts +++ b/convex/savingsChat.test.ts @@ -25,12 +25,19 @@ vi.mock("ai", async (importOriginal) => { compare_periods: { execute: (input: unknown) => Promise }; forecast_fixed_costs: { execute: (input: unknown) => Promise }; explain_savings_rate: { execute: (input: unknown) => Promise }; + inspect_category_health: { execute: (input: unknown) => Promise }; + suggest_category_for_transactions: { execute: (input: unknown) => Promise }; + preview_month_end_spending: { execute: (input: unknown) => Promise }; + propose_bulk_recategory: { execute: (input: unknown) => Promise }; + propose_category_changes: { 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 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(); diff --git a/convex/savingsChat.ts b/convex/savingsChat.ts index f94ae0b..52a7d24 100644 --- a/convex/savingsChat.ts +++ b/convex/savingsChat.ts @@ -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[]> { @@ -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 { @@ -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>(); + 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 } + >(); + let historicalVariableTotal = 0; + const historicalMonths = new Set(); + 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(), + }; + 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(); + 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[] } + >(); + + 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 { 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 => { 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) { diff --git a/convex/savingsChatActionPlans.ts b/convex/savingsChatActionPlans.ts new file mode 100644 index 0000000..6ba1ad7 --- /dev/null +++ b/convex/savingsChatActionPlans.ts @@ -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["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["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["operations"][number], { type: "update_category" }>, +) { + const category = await ctx.db.get(operation.categoryId); + if (!category || category.userId !== userId) return false; + const patch: Partial> = {}; + 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, + }; + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index ccc209a..d9f889a 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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"]), }); diff --git a/docs/superpowers/plans/2026-06-23-kontostandabruf-ui.md b/docs/superpowers/plans/2026-06-23-kontostandabruf-ui.md new file mode 100644 index 0000000..02402fe --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-kontostandabruf-ui.md @@ -0,0 +1,225 @@ +# Kontostandabruf und UI-Anzeige Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a dedicated live balance refresh path for FinTS and comdirect REST, persist latest balances separately from account master data, and show them clearly in the dashboard and account UI. + +**Architecture:** Keep current transaction sync intact and add a separate balance capability. Persist balance snapshots in a dedicated Convex table because live balances are operational/high-churn data, while `accounts` remains stable account metadata. The UI reads the latest persisted balances through a query and can trigger a refresh action that uses provider preference `auto | comdirect | fints` with the same fallback behavior as the current sync. + +**Tech Stack:** React 19, Vite, Convex 1.41, `lib-fints`, comdirect REST client, Vitest, convex-test. + +--- + +## Context + +- Backlog task: `TASK-14 Plane Kontostandabruf und UI-Anzeige`. +- Existing balance-capable types already exist in `convex/bank/types.ts`: `NormalizedAccount.balance` and `NormalizedBalance`. +- Existing comdirect balance endpoint wrapper exists in `convex/comdirect/client.ts`: `getAccountBalances()`. +- Existing FinTS balance logic exists inside transaction sync in `convex/bank/orchestrator.ts`: `client.canGetAccountBalance()` and `client.getAccountBalance()`. +- Current persistence only uses `balance` as `openingBalance` when creating a new account; it does not store or display latest live balance. +- `ctx7` found FinTS docs for `/nemiah/phpfints`; relevant notes: FinTS/HBCI supports account access and balance retrieval, and real deployments need a registered product/application id. `ctx7` did not find a usable comdirect API reference, so implementation should rely on the existing local comdirect client wrappers and verify against real responses. + +## Data Model + +### Task 1: Store Latest Account Balances + +**Files:** +- Modify: `convex/schema.ts` +- Modify: `convex/bank/internal.ts` +- Test: `convex/bank/balances.test.ts` + +- [ ] Add `accountBalances` table in `convex/schema.ts`: + +```ts +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"]), +``` + +- [ ] Add `upsertAccountBalance` internal mutation in `convex/bank/internal.ts`. +- [ ] Add `listLatestAccountBalances` internal query that returns one latest balance per account for the authenticated user's accounts. +- [ ] Keep `accounts.openingBalance` unchanged; do not repurpose it as live balance. +- [ ] Test that inserting a newer balance replaces the displayed latest balance without mutating `accounts.openingBalance`. + +## Provider Flow + +### Task 2: Normalize Provider Balance Fetching + +**Files:** +- Modify: `convex/bank/types.ts` +- Modify: `convex/bank/comdirectProvider.ts` +- Modify: `convex/bank/orchestrator.ts` +- Test: `convex/bank/balanceProviders.test.ts` + +- [ ] Extend `NormalizedBalance` with optional `asOf?: string`. +- [ ] Keep comdirect `getBalance(accountExternalId)` as the per-account lookup, but add a bulk path that maps `/api/banking/clients/user/v2/accounts/balances` into `NormalizedBalance[]`. +- [ ] Extract FinTS balance fetching from `fetchFinTsAccountData()` into a reusable helper: + +```ts +async function fetchFinTsAccountBalance( + ctx: ActionCtx, + userId: Id<"users">, + client: FinTSClient, + account: NormalizedAccount, + syncJob: PendingSyncJob, +): Promise +``` + +- [ ] If FinTS reports `!client.canGetAccountBalance(account.externalId)`, return an error result for that account instead of failing the whole refresh. +- [ ] Preserve TAN handling by reusing `resolveTanResponse()` for balance requests. +- [ ] Test provider selection: + - `providerPreference: "fints"` uses FinTS only. + - `providerPreference: "comdirect"` uses REST and only falls back if the current fallback rules say the error is recoverable. + - `providerPreference: "auto"` tries REST when configured, otherwise FinTS. + +## Convex Public API + +### Task 3: Add Balance Query and Refresh Action + +**Files:** +- Create: `convex/bank/balances.ts` +- Modify: `convex/bank/orchestrator.ts` +- Modify: `convex/bank/internal.ts` +- Test: `convex/bank/balances.test.ts` + +- [ ] Add public query `api.bank.balances.listLatest`. + +Return shape: + +```ts +{ + accountId: Id<"accounts">; + accountName: string; + accountType: string; + iban?: string; + externalId?: string; + balance: number | null; + currency: string; + provider: "comdirect" | "fints" | null; + fetchedAt: number | null; + asOf?: string; + status: "fresh" | "stale" | "error" | "missing"; + errorMessage?: string; +} +``` + +- [ ] Add public action `api.bank.balances.refresh`. + +Arguments: + +```ts +{ + accountId?: Id<"accounts">; + pin?: string; +} +``` + +Return shape: + +```ts +{ + updatedCount: number; + provider: "comdirect" | "fints"; + awaitingTan: boolean; + errors: Array<{ accountId?: Id<"accounts">; externalId?: string; message: string }>; +} +``` + +- [ ] Add internal action `refreshBalancesInternal` in `convex/bank/orchestrator.ts`, reusing provider preference and fallback logic from `runSyncInternal`. +- [ ] When transaction sync already fetches balances, call `upsertAccountBalance` during `persistSyncResults()` so a full sync also updates UI balances. +- [ ] Keep this as a Convex action/query API first; add an HTTP route in `convex/http.ts` only if an external scheduler or webhook needs it. + +## UI + +### Task 4: Add Balance UI Components + +**Files:** +- Create: `src/components/accounts/AccountBalanceStrip.tsx` +- Modify: `src/pages/DashboardPage.tsx` +- Modify: `src/pages/SettingsPage.tsx` +- Optional modify: `src/components/import/ComdirectSyncPanel.tsx` +- Test: `src/components/accounts/AccountBalanceStrip.test.tsx` + +- [ ] Create `AccountBalanceStrip` that calls `api.bank.balances.listLatest`. +- [ ] Show compact account rows with: + - account name + - IBAN tail or type + - live balance + - provider badge + - fetched timestamp + - stale/error/missing state +- [ ] Add refresh button with `RefreshCw` icon from `lucide-react`. +- [ ] Put the strip near the top of `DashboardPage`, above the period-based KPI cards. This avoids confusing live bank balance with the existing filtered `Saldo` KPI. +- [ ] In `SettingsPage`, show the latest live balance in each account row next to account metadata. +- [ ] For stale data, use subdued text and a warning badge; for errors, show the last known balance plus the error message. +- [ ] Keep cards shallow; do not nest balance cards inside other cards. + +## UX Rules + +- Label live bank balance as `Aktueller Kontostand`. +- Keep existing dashboard `Saldo` label as period result, or rename it to `Periodensaldo` to avoid ambiguity. +- Show `Noch nicht abgerufen` for accounts without a balance snapshot. +- Show `Aktualisiert vor ...` or localized timestamp for fetched balances. +- If refresh returns `awaitingTan`, reuse the existing TAN dialog/status flow rather than adding a second TAN UX. +- If comdirect REST fails and FinTS succeeds, show a non-blocking toast: `comdirect REST fehlgeschlagen, FinTS verwendet`. + +## Testing + +### Task 5: Verify Backend Behavior + +**Files:** +- Create: `convex/bank/balances.test.ts` +- Create or extend: `convex/bank/balanceProviders.test.ts` + +- [ ] Use `convex-test` with `edge-runtime`, matching existing project guidance. +- [ ] Test latest-balance query for `fresh`, `missing`, and `error` states. +- [ ] Test that archived accounts are either hidden by default or marked clearly; choose hidden for dashboard and visible in settings. +- [ ] Test full sync calls balance persistence for both providers. +- [ ] Test balance-only refresh does not import transactions. + +### Task 6: Verify Frontend Behavior + +**Files:** +- Create: `src/components/accounts/AccountBalanceStrip.test.tsx` +- Modify: `src/pages/DashboardPage.tsx` + +- [ ] Test loading skeleton. +- [ ] Test missing state. +- [ ] Test fresh EUR formatting with existing `formatAmount`. +- [ ] Test error state keeps last known balance visible. +- [ ] Test refresh button disables while the action is running. + +## Execution Order + +1. Add schema and internal persistence. +2. Add backend query tests and make them fail. +3. Implement internal balance mutations/queries. +4. Extract provider balance helpers. +5. Add refresh action and wire provider fallback. +6. Update transaction sync to persist balances opportunistically. +7. Add UI component and Dashboard placement. +8. Add Settings account-row balance display. +9. Run `pnpm lint`. +10. Run `pnpm build`. +11. Run targeted Vitest suites. + +## Manual Verification + +1. Set provider preference to `fints`, enter FinTS PIN if not in Convex env, refresh balances. +2. Confirm dashboard shows `Aktueller Kontostand` per account. +3. Confirm existing `Saldo`/`Periodensaldo` still follows the selected date range. +4. Set provider preference to `auto` with broken comdirect REST credentials and valid FinTS config; confirm fallback works. +5. Run a normal transaction sync and confirm balances update without pressing the balance refresh button separately. + diff --git a/src/components/accounts/AccountBalanceStrip.test.tsx b/src/components/accounts/AccountBalanceStrip.test.tsx new file mode 100644 index 0000000..760bb73 --- /dev/null +++ b/src/components/accounts/AccountBalanceStrip.test.tsx @@ -0,0 +1,76 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, test } from "vitest"; +import { AccountBalanceStripView, type AccountBalanceRow } from "./AccountBalanceStrip"; + +const freshRow: AccountBalanceRow = { + accountId: "account-1", + accountName: "Girokonto", + accountType: "giro", + iban: "DE89370400440532013000", + externalId: "giro-1", + balance: 1234.56, + currency: "EUR", + provider: "fints", + fetchedAt: new Date("2026-06-23T08:00:00.000Z").getTime(), + status: "fresh", +}; + +describe("AccountBalanceStripView", () => { + test("renders a loading skeleton while balances load", () => { + const markup = renderToStaticMarkup( + undefined} />, + ); + + expect(markup).toContain("Kontostände werden geladen"); + }); + + test("renders missing and fresh balance states", () => { + const markup = renderToStaticMarkup( + undefined} + />, + ); + + expect(markup).toContain("Aktueller Kontostand"); + expect(markup).toContain("Girokonto"); + expect(markup).toContain("1.234,56"); + expect(markup).toContain("FinTS"); + expect(markup).toContain("Tagesgeld"); + expect(markup).toContain("Noch nicht abgerufen"); + }); + + test("keeps last known balance visible for error states and disables refresh while busy", () => { + const markup = renderToStaticMarkup( + undefined} + />, + ); + + expect(markup).toContain("1.234,56"); + expect(markup).toContain("Fehler"); + expect(markup).toContain("HKSAL wird nicht unterstuetzt"); + expect(markup).toContain("disabled"); + }); +}); + diff --git a/src/components/accounts/AccountBalanceStrip.tsx b/src/components/accounts/AccountBalanceStrip.tsx new file mode 100644 index 0000000..dcc888c --- /dev/null +++ b/src/components/accounts/AccountBalanceStrip.tsx @@ -0,0 +1,177 @@ +import { useState } from "react"; +import { useAction, useQuery } from "convex/react"; +import { RefreshCw } from "lucide-react"; +import { toast } from "sonner"; +import { api } from "../../../convex/_generated/api"; +import { amountClass, formatAmount } from "@/lib/format"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +export type AccountBalanceRow = { + accountId: string; + accountName: string; + accountType: string; + iban?: string; + externalId?: string; + balance: number | null; + currency: string; + provider: "comdirect" | "fints" | null; + fetchedAt: number | null; + asOf?: string; + status: "fresh" | "stale" | "error" | "missing"; + errorMessage?: string; +}; + +function providerLabel(provider: AccountBalanceRow["provider"]) { + if (provider === "fints") return "FinTS"; + if (provider === "comdirect") return "comdirect"; + return "offline"; +} + +function accountHint(row: AccountBalanceRow) { + if (row.iban) return `IBAN ...${row.iban.slice(-4)}`; + return row.accountType; +} + +function fetchedLabel(row: AccountBalanceRow) { + if (!row.fetchedAt) return "Noch nicht abgerufen"; + return `Aktualisiert ${new Date(row.fetchedAt).toLocaleString("de-DE", { + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", + })}`; +} + +function statusLabel(row: AccountBalanceRow) { + if (row.status === "error") return "Fehler"; + if (row.status === "stale") return "Veraltet"; + if (row.status === "missing") return "Offen"; + return providerLabel(row.provider); +} + +function statusClass(row: AccountBalanceRow) { + if (row.status === "error") return "border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300"; + if (row.status === "stale") return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-300"; + if (row.status === "missing") return "text-muted-foreground"; + return "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-300"; +} + +export function AccountBalanceStripView({ + rows, + isRefreshing, + onRefresh, +}: { + rows: AccountBalanceRow[] | undefined; + isRefreshing: boolean; + onRefresh: () => void; +}) { + return ( + + + Aktueller Kontostand + + + + {rows === undefined ? ( +
+ + +
+ ) : rows.length === 0 ? ( +

Keine aktiven Konten

+ ) : ( +
+ {rows.map((row) => ( +
+
+
+
{row.accountName}
+
{accountHint(row)}
+
+ + {statusLabel(row)} + +
+
+
+ {row.balance === null ? "Noch nicht abgerufen" : formatAmount(row.balance)} +
+
+ {fetchedLabel(row)} +
+
+ {row.errorMessage && ( +
+ {row.errorMessage} +
+ )} +
+ ))} +
+ )} +
+
+ ); +} + +export function AccountBalanceStrip() { + const rows = useQuery(api.bank.balances.listLatest, {}) as + | AccountBalanceRow[] + | undefined; + const refreshBalances = useAction(api.bank.balances.refresh); + const [isRefreshing, setIsRefreshing] = useState(false); + + const handleRefresh = async () => { + setIsRefreshing(true); + try { + const result = await refreshBalances({}); + if (result.errors.length > 0 && result.updatedCount === 0) { + toast.error(result.errors[0].message); + } else { + toast.success( + `${result.updatedCount} Kontostand${result.updatedCount === 1 ? "" : "e"} aktualisiert (${providerLabel(result.provider)})`, + ); + if (result.errors.length > 0) { + toast.message(result.errors[0].message); + } + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "Kontostandabruf fehlgeschlagen"); + } finally { + setIsRefreshing(false); + } + }; + + return ( + { + void handleRefresh(); + }} + /> + ); +} + diff --git a/src/components/chat/AgentChat.test.tsx b/src/components/chat/AgentChat.test.tsx index 1225401..d8cad32 100644 --- a/src/components/chat/AgentChat.test.tsx +++ b/src/components/chat/AgentChat.test.tsx @@ -2,6 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, test } from "vitest"; import { AgentConversation, + AgentActionPlanList, AgentMessage, AgentPromptInput, type AgentChatMessage, @@ -241,3 +242,55 @@ describe("AgentChat phase 3 sources and inline citations", () => { expect(markup).not.toContain(">Quellen<"); }); }); + +describe("AgentChat action plan preview cards", () => { + test("renders pending action plans with examples and confirmation controls", () => { + const markup = renderToStaticMarkup( + undefined} + onDismiss={() => undefined} + />, + ); + + expect(markup).toContain("Vorschläge zur Bestätigung"); + expect(markup).toContain("1 Umsatz zur Kategorie Abos zuordnen"); + expect(markup).toContain("1 Änderung"); + expect(markup).toContain("Netflix"); + expect(markup).toContain("Ohne Kategorie"); + expect(markup).toContain("Abos"); + expect(markup).toContain("Anwenden"); + expect(markup).toContain("Verwerfen"); + }); + + test("does not render an empty action plan section", () => { + const markup = renderToStaticMarkup( + undefined} + onDismiss={() => undefined} + />, + ); + + expect(markup).toBe(""); + }); +}); diff --git a/src/components/chat/AgentChat.tsx b/src/components/chat/AgentChat.tsx index 60f438a..42afa71 100644 --- a/src/components/chat/AgentChat.tsx +++ b/src/components/chat/AgentChat.tsx @@ -4,7 +4,7 @@ import { type HTMLAttributes, type Ref, } from "react"; -import { Loader2, Send, Wrench } from "lucide-react"; +import { Check, Loader2, Send, Wrench, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { @@ -37,6 +37,24 @@ type AgentPromptInputProps = { onSubmit: (event: FormEvent) => void; }; +export type AgentActionPlanPreviewRow = { + date?: string; + description: string; + amount?: number; + currentCategoryName?: string; + targetCategoryName?: string; + action?: string; +}; + +export type AgentActionPlan = { + _id: string; + kind: "bulk_recategory" | "category_changes"; + summary: string; + affectedCount: number; + expiresAt: number; + previewRows: AgentActionPlanPreviewRow[]; +}; + export function AgentConversation({ messages, isSubmitting = false, @@ -61,6 +79,92 @@ export function AgentConversation({ ); } +export function AgentActionPlanList({ + plans, + isApplying, + onApply, + onDismiss, +}: { + plans: AgentActionPlan[]; + isApplying: boolean; + onApply: (planId: string) => void; + onDismiss: (planId: string) => void; +}) { + if (plans.length === 0) return null; + + return ( +
+
+
+

Vorschläge zur Bestätigung

+

+ Änderungen werden erst nach deiner Bestätigung angewendet. +

+
+
+
+ {plans.map((plan) => ( +
+
+
+

{plan.summary}

+

+ {plan.affectedCount} {plan.affectedCount === 1 ? "Änderung" : "Änderungen"} · läuft ab{" "} + {formatActionPlanExpiry(plan.expiresAt)} +

+
+
+ + +
+
+ {plan.previewRows.length > 0 && ( +
+ {plan.previewRows.slice(0, 5).map((row, index) => ( +
+
+

+ {row.date ? `${row.date} · ` : ""} + {row.description} +

+

+ {row.action ?? [row.currentCategoryName, row.targetCategoryName].filter(Boolean).join(" → ")} +

+
+ {row.amount !== undefined && ( +

{formatActionPlanAmount(row.amount)}

+ )} +
+ ))} +
+ )} +
+ ))} +
+
+ ); +} + export function AgentMessage({ message, className, ...props }: AgentMessageProps) { const isUser = message.role === "user"; @@ -132,6 +236,22 @@ function MessageContentWithCitations({ ); } +function formatActionPlanAmount(amount: number) { + return new Intl.NumberFormat("de-DE", { + style: "currency", + currency: "EUR", + }).format(amount); +} + +function formatActionPlanExpiry(expiresAt: number) { + return new Intl.DateTimeFormat("de-DE", { + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(expiresAt)); +} + function AgentSources({ sources }: { sources: AgentSource[] }) { return (
diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index 8eb8296..482d286 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -9,6 +9,7 @@ import { CategoryBreakdownChart, FixedVariableSplit } from "@/components/charts/ import { amountClass, formatAmount, formatDate, pct } from "@/lib/format"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import type { Id } from "../../convex/_generated/dataModel"; +import { AccountBalanceStrip } from "@/components/accounts/AccountBalanceStrip"; function KpiCard({ title, value, className }: { title: string; value: string; className?: string }) { return ( @@ -43,12 +44,14 @@ export function DashboardPage() { return (
+ +
- + { @@ -195,6 +204,7 @@ export function SavingsChatPage() { const [draft, setDraft] = useState(""); const [selectedSessionId, setSelectedSessionId] = useState | undefined>(); const [isSubmitting, setIsSubmitting] = useState(false); + const [applyingPlanId, setApplyingPlanId] = useState(); const [legacyImportResult, setLegacyImportResult] = useState<{ key: string; importedCount: number; @@ -250,9 +260,15 @@ export function SavingsChatPage() { basis: monthBasis, }); const currentUser = useQuery(api.users.currentUser); + const pendingActionPlans = useQuery( + api.savingsChatActionPlans.listPendingActionPlans, + activeSessionId ? { sessionId: activeSessionId } : "skip", + ); const createSession = useMutation(api.savingsChatHistory.createSession); const deleteSession = useMutation(api.savingsChatHistory.deleteSession); const importLocalSession = useMutation(api.savingsChatHistory.importLocalSession); + const applyActionPlan = useMutation(api.savingsChatActionPlans.applyActionPlan); + const dismissActionPlan = useMutation(api.savingsChatActionPlans.dismissActionPlan); const sendMessage = useAction(api.savingsChat.sendMessage); const importMarkerKey = currentUser ? `${IMPORTED_KEY}:${currentUser._id}` : undefined; const legacyImportComplete = Boolean( @@ -373,6 +389,33 @@ export function SavingsChatPage() { })), [sessions], ); + const actionPlans = (pendingActionPlans ?? []) as AgentActionPlan[]; + + const applyPlan = (planId: string) => { + setApplyingPlanId(planId); + void applyActionPlan({ planId: planId as Id<"agentActionPlans"> }) + .then((result) => { + toast.success(result.summary); + }) + .catch((error) => { + console.error(error); + toast.error(error instanceof Error ? error.message : "Vorschlag konnte nicht angewendet werden."); + }) + .finally(() => setApplyingPlanId(undefined)); + }; + + const dismissPlan = (planId: string) => { + setApplyingPlanId(planId); + void dismissActionPlan({ planId: planId as Id<"agentActionPlans"> }) + .then(() => { + toast.message("Vorschlag verworfen"); + }) + .catch((error) => { + console.error(error); + toast.error(error instanceof Error ? error.message : "Vorschlag konnte nicht verworfen werden."); + }) + .finally(() => setApplyingPlanId(undefined)); + }; const submit = async (event: FormEvent) => { event.preventDefault(); @@ -390,6 +433,7 @@ export function SavingsChatPage() { to, accountId, basis: monthBasis, + today: localDateKey(), }); } catch (error) { console.error(error); @@ -436,6 +480,13 @@ export function SavingsChatPage() { scrollRef={listRef} /> + + [balance.accountId, balance]) ?? [], + ); return (
@@ -58,33 +66,53 @@ export function SettingsPage() { Konten - {accounts?.map((account) => ( -
-
-
{account.name}
-
- {account.type} · {account.iban ?? "keine IBAN"} - {account.externalId && " · comdirect verbunden"} + {accounts?.map((account) => { + const liveBalance = balanceByAccountId.get(account._id); + return ( +
+
+
{account.name}
+
+ {account.type} · {account.iban ?? "keine IBAN"} + {account.externalId && " · Bank verbunden"} +
+ {liveBalance && ( +
+ Aktueller Kontostand: + {liveBalance.balance === null ? ( + Noch nicht abgerufen + ) : ( + + {formatAmount(liveBalance.balance)} + + )} + {liveBalance.status === "error" && ( + + {liveBalance.errorMessage ?? "Fehler"} + + )} +
+ )}
+ +
- - -
- ))} + ); + })}