feat: add guarded savings agent tools

This commit is contained in:
Matthias
2026-06-23 21:13:02 +02:00
parent 6e687dc9b5
commit b7dae31fe1
22 changed files with 3510 additions and 73 deletions

View File

@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
Architektur- und Umsetzungsplan für Live-Kontostände über FinTS und comdirect REST, inklusive Persistenzmodell, Sync-Route und Dashboard/UI-Anzeige.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
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
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->

View File

@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
Build AI agent tools for category health, category suggestions, guarded category write plans, and current-month spending preview.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -10,6 +10,7 @@
import type * as accounts from "../accounts.js"; import type * as accounts from "../accounts.js";
import type * as auth from "../auth.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_comdirectProvider from "../bank/comdirectProvider.js";
import type * as bank_config from "../bank/config.js"; import type * as bank_config from "../bank/config.js";
import type * as bank_fintsConfig from "../bank/fintsConfig.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 lib_seedCategories from "../lib/seedCategories.js";
import type * as loans from "../loans.js"; import type * as loans from "../loans.js";
import type * as savingsChat from "../savingsChat.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 savingsChatHistory from "../savingsChatHistory.js";
import type * as settings from "../settings.js"; import type * as settings from "../settings.js";
import type * as transactions from "../transactions.js"; import type * as transactions from "../transactions.js";
@@ -49,6 +51,7 @@ import type {
declare const fullApi: ApiFromModules<{ declare const fullApi: ApiFromModules<{
accounts: typeof accounts; accounts: typeof accounts;
auth: typeof auth; auth: typeof auth;
"bank/balances": typeof bank_balances;
"bank/comdirectProvider": typeof bank_comdirectProvider; "bank/comdirectProvider": typeof bank_comdirectProvider;
"bank/config": typeof bank_config; "bank/config": typeof bank_config;
"bank/fintsConfig": typeof bank_fintsConfig; "bank/fintsConfig": typeof bank_fintsConfig;
@@ -74,6 +77,7 @@ declare const fullApi: ApiFromModules<{
"lib/seedCategories": typeof lib_seedCategories; "lib/seedCategories": typeof lib_seedCategories;
loans: typeof loans; loans: typeof loans;
savingsChat: typeof savingsChat; savingsChat: typeof savingsChat;
savingsChatActionPlans: typeof savingsChatActionPlans;
savingsChatHistory: typeof savingsChatHistory; savingsChatHistory: typeof savingsChatHistory;
settings: typeof settings; settings: typeof settings;
transactions: typeof transactions; transactions: typeof transactions;

178
convex/balances.test.ts Normal file
View File

@@ -0,0 +1,178 @@
/// <reference types="vite/client" />
import { convexTest } from "convex-test";
import { makeFunctionReference } from "convex/server";
import { describe, expect, test } from "vitest";
import type { Id } from "./_generated/dataModel";
import schema from "./schema";
const modules = import.meta.glob("./**/*.ts");
delete modules["./balances.test.ts"];
const listLatest = makeFunctionReference<"query">("bank/balances:listLatest");
const upsertAccountBalance = makeFunctionReference<"mutation">(
"bank/internal:upsertAccountBalance",
);
type LatestBalanceRow = {
accountId: Id<"accounts">;
accountName: string;
balance: number | null;
status: string;
errorMessage?: string;
};
async function seedBalanceFixture() {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Balance User",
email: "balance@example.com",
});
const otherUserId = await ctx.db.insert("users", {
name: "Other User",
email: "other-balance@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "giro",
iban: "DE89370400440532013000",
openingBalance: 125,
currency: "EUR",
isArchived: false,
externalId: "giro-1",
});
const archivedAccountId = await ctx.db.insert("accounts", {
userId,
name: "Altes Konto",
type: "giro",
openingBalance: 50,
currency: "EUR",
isArchived: true,
externalId: "old-1",
});
const otherAccountId = await ctx.db.insert("accounts", {
userId: otherUserId,
name: "Hidden Konto",
type: "giro",
openingBalance: 999,
currency: "EUR",
isArchived: false,
externalId: "hidden-1",
});
return { userId, accountId, archivedAccountId, otherUserId, otherAccountId };
});
return {
t,
seeded,
asUser: t.withIdentity({
subject: `${seeded.userId}|test-session`,
tokenIdentifier: `test:${seeded.userId}`,
}),
};
}
describe("bank/balances.listLatest", () => {
test("returns a missing state for active accounts without live balance snapshots", async () => {
const { asUser, seeded } = await seedBalanceFixture();
const result = await asUser.query(listLatest, {});
expect(result).toEqual([
{
accountId: seeded.accountId,
accountName: "Girokonto",
accountType: "giro",
iban: "DE89370400440532013000",
externalId: "giro-1",
balance: null,
currency: "EUR",
provider: null,
fetchedAt: null,
status: "missing",
},
]);
});
test("returns the newest live balance without mutating openingBalance", async () => {
const { t, asUser, seeded } = await seedBalanceFixture();
await t.mutation(upsertAccountBalance, {
userId: seeded.userId,
accountId: seeded.accountId as Id<"accounts">,
externalId: "giro-1",
provider: "fints",
balance: 900,
currency: "EUR",
fetchedAt: 1_780_000_000_000,
status: "fresh",
});
await t.mutation(upsertAccountBalance, {
userId: seeded.userId,
accountId: seeded.accountId as Id<"accounts">,
externalId: "giro-1",
provider: "comdirect",
balance: 1234.56,
currency: "EUR",
asOf: "2026-06-23",
fetchedAt: 1_780_000_060_000,
status: "fresh",
});
const result = await asUser.query(listLatest, {});
const account = await t.run((ctx) => ctx.db.get(seeded.accountId));
expect(result).toMatchObject([
{
accountId: seeded.accountId,
accountName: "Girokonto",
balance: 1234.56,
currency: "EUR",
provider: "comdirect",
fetchedAt: 1_780_000_060_000,
asOf: "2026-06-23",
status: "fresh",
},
]);
expect(account?.openingBalance).toBe(125);
});
test("can include archived accounts for settings and preserves error snapshots", async () => {
const { t, asUser, seeded } = await seedBalanceFixture();
await t.mutation(upsertAccountBalance, {
userId: seeded.userId,
accountId: seeded.archivedAccountId as Id<"accounts">,
externalId: "old-1",
provider: "fints",
balance: 50,
currency: "EUR",
fetchedAt: 1_780_000_060_000,
status: "error",
errorMessage: "HKSAL wird nicht unterstuetzt",
});
const defaultResult = await asUser.query(listLatest, {});
const settingsResult = await asUser.query(listLatest, { includeArchived: true });
expect(defaultResult.map((row: LatestBalanceRow) => row.accountId)).toEqual([
seeded.accountId,
]);
expect(settingsResult).toHaveLength(2);
expect(
settingsResult.find(
(row: LatestBalanceRow) => row.accountId === seeded.archivedAccountId,
),
)
.toMatchObject({
accountName: "Altes Konto",
balance: 50,
status: "error",
errorMessage: "HKSAL wird nicht unterstuetzt",
});
});
});

View File

@@ -0,0 +1,34 @@
import { describe, expect, test } from "vitest";
import { mapComdirectBalances } from "./comdirectProvider";
describe("mapComdirectBalances", () => {
test("normalizes comdirect balance payloads into provider balances", () => {
const result = mapComdirectBalances({
values: [
{
account: {
accountId: "account-1",
iban: "DE89370400440532013000",
accountType: { text: "Girokonto" },
},
balance: { value: "1234.56", unit: "EUR" },
date: "2026-06-23",
},
{
account: {},
balance: { value: "999" },
},
],
});
expect(result).toEqual([
{
externalId: "account-1",
balance: 1234.56,
currency: "EUR",
asOf: "2026-06-23",
},
]);
});
});

131
convex/bank/balances.ts Normal file
View File

@@ -0,0 +1,131 @@
import { action, query } from "../_generated/server";
import { v } from "convex/values";
import { internal } from "../_generated/api";
import { requireUserId } from "../lib/helpers";
import { getAuthUserId } from "@convex-dev/auth/server";
import type { Doc, Id } from "../_generated/dataModel";
const balanceProvider = v.union(v.literal("comdirect"), v.literal("fints"));
const balanceStatus = v.union(
v.literal("fresh"),
v.literal("stale"),
v.literal("error"),
v.literal("missing"),
);
export const listLatest = query({
args: {
includeArchived: v.optional(v.boolean()),
},
returns: v.array(
v.object({
accountId: v.id("accounts"),
accountName: v.string(),
accountType: v.string(),
iban: v.optional(v.string()),
externalId: v.optional(v.string()),
balance: v.union(v.number(), v.null()),
currency: v.string(),
provider: v.union(balanceProvider, v.null()),
fetchedAt: v.union(v.number(), v.null()),
asOf: v.optional(v.string()),
status: balanceStatus,
errorMessage: v.optional(v.string()),
}),
),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const accounts = await ctx.db
.query("accounts")
.withIndex("by_user", (q) => q.eq("userId", userId))
.collect();
const visibleAccounts = accounts
.filter((account) => args.includeArchived || !account.isArchived)
.sort((a, b) => a.name.localeCompare(b.name));
const balances = await ctx.db
.query("accountBalances")
.withIndex("by_user", (q) => q.eq("userId", userId))
.collect();
const latestByAccount = new Map<Id<"accounts">, Doc<"accountBalances">>();
for (const balance of balances.sort((a, b) => b.fetchedAt - a.fetchedAt)) {
if (!latestByAccount.has(balance.accountId)) {
latestByAccount.set(balance.accountId, balance);
}
}
return visibleAccounts.map((account) => {
const latest = latestByAccount.get(account._id);
if (!latest) {
return {
accountId: account._id,
accountName: account.name,
accountType: account.type,
iban: account.iban,
externalId: account.externalId,
balance: null,
currency: account.currency,
provider: null,
fetchedAt: null,
status: "missing" as const,
};
}
return {
accountId: account._id,
accountName: account.name,
accountType: account.type,
iban: account.iban,
externalId: account.externalId ?? latest.externalId,
balance: latest.balance,
currency: latest.currency,
provider: latest.provider,
fetchedAt: latest.fetchedAt,
asOf: latest.asOf,
status: latest.status,
errorMessage: latest.errorMessage,
};
});
},
});
export const refresh = action({
args: {
accountId: v.optional(v.id("accounts")),
pin: v.optional(v.string()),
},
returns: v.object({
updatedCount: v.number(),
provider: balanceProvider,
awaitingTan: v.boolean(),
errors: v.array(
v.object({
accountId: v.optional(v.id("accounts")),
externalId: v.optional(v.string()),
message: v.string(),
}),
),
}),
handler: async (
ctx,
args,
): Promise<{
updatedCount: number;
provider: "comdirect" | "fints";
awaitingTan: boolean;
errors: Array<{
accountId?: Id<"accounts">;
externalId?: string;
message: string;
}>;
}> => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Nicht angemeldet");
return await ctx.runAction(internal.bank.orchestrator.refreshBalancesInternal, {
userId,
accountId: args.accountId,
pin: args.pin,
});
},
});

View File

@@ -33,6 +33,77 @@ type ComdirectProviderContext = {
userId: Id<"users">; userId: Id<"users">;
}; };
type ComdirectBalancePayload = {
values?: Array<Record<string, unknown>>;
};
function getNestedText(value: unknown): string | undefined {
if (!value || typeof value !== "object") return undefined;
const text = (value as { text?: unknown }).text;
return typeof text === "string" ? text : undefined;
}
export function mapComdirectBalances(
payload: ComdirectBalancePayload,
): NormalizedBalance[] {
return (payload.values ?? []).flatMap((item) => {
const account = item.account as
| {
accountId?: unknown;
}
| undefined;
const balance = item.balance as
| {
value?: unknown;
unit?: unknown;
}
| undefined;
const externalId =
typeof account?.accountId === "string" ? account.accountId : undefined;
if (!externalId) return [];
const balanceValue = Number(balance?.value ?? 0);
const currency = typeof balance?.unit === "string" ? balance.unit : "EUR";
const asOf = typeof item.date === "string" ? item.date : undefined;
return [
{
externalId,
balance: Number.isFinite(balanceValue) ? balanceValue : 0,
currency,
asOf,
},
];
});
}
export function mapComdirectAccounts(
payload: ComdirectBalancePayload,
): NormalizedAccount[] {
return (payload.values ?? []).flatMap((item) => {
const account = item.account as
| {
accountId?: unknown;
iban?: unknown;
accountType?: unknown;
}
| undefined;
const accountIdExternal =
typeof account?.accountId === "string" ? account.accountId : undefined;
if (!accountIdExternal) return [];
const balance = mapComdirectBalances({ values: [item] })[0];
return [
{
externalId: accountIdExternal,
name: getNestedText(account?.accountType) ?? "comdirect Konto",
iban: typeof account?.iban === "string" ? account.iban : undefined,
balance: balance?.balance ?? 0,
currency: balance?.currency ?? "EUR",
},
];
});
}
export async function createComdirectRestProvider( export async function createComdirectRestProvider(
context: ComdirectProviderContext, context: ComdirectProviderContext,
): Promise<BankDataProvider> { ): Promise<BankDataProvider> {
@@ -57,39 +128,16 @@ export async function createComdirectRestProvider(
async getAccounts(): Promise<NormalizedAccount[]> { async getAccounts(): Promise<NormalizedAccount[]> {
const balances = await getAccountBalances(accessToken, sessionUuid); const balances = await getAccountBalances(accessToken, sessionUuid);
return (balances.values ?? []).flatMap((item) => { return mapComdirectAccounts(balances);
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",
},
];
});
}, },
async getBalance(accountExternalId: string): Promise<NormalizedBalance> { async getBalance(accountExternalId: string): Promise<NormalizedBalance> {
const balances = await getAccountBalances(accessToken, sessionUuid); const balances = await getAccountBalances(accessToken, sessionUuid);
const match = (balances.values ?? []).find((item) => { const match = mapComdirectBalances(balances).find(
const account = item.account as { accountId?: string }; (item) => item.externalId === accountExternalId,
return account?.accountId === accountExternalId; );
});
if (!match) throw new Error(`Konto ${accountExternalId} nicht gefunden`); if (!match) throw new Error(`Konto ${accountExternalId} nicht gefunden`);
return { return match;
externalId: accountExternalId,
balance: Number((match.balance as { value?: string })?.value ?? 0),
currency: "EUR",
};
}, },
async getTransactions( async getTransactions(
@@ -100,7 +148,7 @@ export async function createComdirectRestProvider(
const rows: NormalizedTransaction[] = []; const rows: NormalizedTransaction[] = [];
for (const state of ["BOOKED", "NOTBOOKED"] as const) { for (const state of ["BOOKED", "NOTBOOKED"] as const) {
let offset = 0; let offset = 0;
let matches = 0; let matches: number;
do { do {
const result = await getTransactions(accessToken, sessionUuid, accountExternalId, { const result = await getTransactions(accessToken, sessionUuid, accountExternalId, {
transactionState: state, transactionState: state,
@@ -207,3 +255,53 @@ export async function fetchComdirectData(
return { accounts, transactionsByAccount }; return { accounts, transactionsByAccount };
} }
export async function fetchComdirectBalanceData(
ctx: ActionCtx,
userId: Id<"users">,
filterAccountId: Id<"accounts"> | undefined,
): Promise<{
accounts: NormalizedAccount[];
balances: NormalizedBalance[];
}> {
const clientId = process.env.COMDIRECT_CLIENT_ID;
const clientSecret = process.env.COMDIRECT_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error("comdirect API-Zugangsdaten nicht konfiguriert");
}
const session = await ctx.runQuery(internal.comdirect.internal.getSession, { userId });
if (!session?.accessToken || !session.secondaryActive) {
throw new Error("comdirect-Session nicht aktiv. Bitte erneut anmelden.");
}
const payload = await getAccountBalances(session.accessToken, session.sessionUuid);
const accounts = mapComdirectAccounts(payload);
const balances = mapComdirectBalances(payload);
if (!filterAccountId) {
return { accounts, balances };
}
const filteredAccounts: NormalizedAccount[] = [];
const allowedExternalIds = new Set<string>();
for (const account of accounts) {
const convexId = await ctx.runMutation(internal.bank.internal.upsertAccountFromProvider, {
userId,
externalId: account.externalId,
name: account.name,
iban: account.iban,
balance: account.balance,
currency: account.currency,
});
if (convexId === filterAccountId) {
filteredAccounts.push(account);
allowedExternalIds.add(account.externalId);
}
}
return {
accounts: filteredAccounts,
balances: balances.filter((balance) => allowedExternalIds.has(balance.externalId)),
};
}

View File

@@ -56,6 +56,27 @@ const pendingTanValidator = v.object({
submittedTan: v.optional(v.string()), 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({ export const getBankConfig = internalQuery({
args: { userId: v.id("users") }, args: { userId: v.id("users") },
returns: v.union(bankConfigValidator, v.null()), 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({ export const upsertBankConfig = internalMutation({
args: { args: {
userId: v.id("users"), 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);
},
});

View File

@@ -25,11 +25,17 @@ import {
} from "./fintsSession"; } from "./fintsSession";
import { mapFinTsTransaction } from "./fintsMap"; import { mapFinTsTransaction } from "./fintsMap";
import { import {
fetchComdirectBalanceData,
fetchComdirectData, fetchComdirectData,
hasComdirectCredentials, hasComdirectCredentials,
isRestFallbackError, isRestFallbackError,
} from "./comdirectProvider"; } 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_POLL_INTERVAL_MS = 4000;
export const TAN_TIMEOUT_MS = 5 * 60 * 1000; export const TAN_TIMEOUT_MS = 5 * 60 * 1000;
@@ -96,6 +102,12 @@ type PendingSyncJob = {
partialTransactions?: Record<string, NormalizedTransaction[]>; partialTransactions?: Record<string, NormalizedTransaction[]>;
}; };
type BalanceRefreshError = {
accountId?: Id<"accounts">;
externalId?: string;
message: string;
};
async function waitForDecoupledTan( async function waitForDecoupledTan(
ctx: ActionCtx, ctx: ActionCtx,
userId: Id<"users">, userId: Id<"users">,
@@ -368,6 +380,34 @@ async function fetchStatementsAllPages(
return resp; return resp;
} }
async function fetchFinTsAccountBalance(
ctx: ActionCtx,
userId: Id<"users">,
client: FinTSClient,
account: NormalizedAccount,
syncJob: PendingSyncJob,
): Promise<NormalizedBalance> {
if (!client.canGetAccountBalance(account.externalId)) {
throw new Error("Konto unterstützt keinen HKSAL-Kontostandabruf");
}
let balanceResponse = await client.getAccountBalance(account.externalId);
balanceResponse = await resolveTanResponse(
ctx,
userId,
client,
balanceResponse,
"balance",
syncJob,
);
return {
externalId: account.externalId,
balance: balanceResponse.balance?.balance ?? account.balance,
currency: balanceResponse.balance?.currency ?? account.currency,
};
}
async function fetchFinTsAccountData( async function fetchFinTsAccountData(
client: FinTSClient, client: FinTSClient,
account: NormalizedAccount, account: NormalizedAccount,
@@ -408,16 +448,14 @@ async function fetchFinTsAccountData(
let balance = account.balance; let balance = account.balance;
if (canFetchBalance) { if (canFetchBalance) {
let balanceResponse = await client.getAccountBalance(account.externalId); const balanceResult = await fetchFinTsAccountBalance(
balanceResponse = await resolveTanResponse(
ctx, ctx,
userId, userId,
client, client,
balanceResponse, account,
"balance",
syncJob, syncJob,
); );
balance = balanceResponse.balance?.balance ?? account.balance; balance = balanceResult.balance;
} else { } else {
console.warn("[fints] Konto unterstützt keinen HKSAL-Kontostandabruf", { console.warn("[fints] Konto unterstützt keinen HKSAL-Kontostandabruf", {
account: account.externalId, account: account.externalId,
@@ -639,6 +677,156 @@ async function fetchFinTsData(
return { accounts, transactionsByAccount }; return { accounts, transactionsByAccount };
} }
async function fetchFinTsBalanceData(
ctx: ActionCtx,
userId: Id<"users">,
filterAccountId: Id<"accounts"> | undefined,
pin: string | undefined,
): Promise<{
accounts: NormalizedAccount[];
balances: NormalizedBalance[];
errors: BalanceRefreshError[];
}> {
const bankConfig = await ctx.runQuery(internal.bank.internal.getBankConfig, { userId });
const env = resolveFintsEnv({
blz: bankConfig?.fints.blz,
url: bankConfig?.fints.url,
login: bankConfig?.fints.login,
productId: bankConfig?.fints.productId,
productVersion: bankConfig?.fints.productVersion,
tanMethodId: bankConfig?.fints.tanMethodId,
tanMediaName: bankConfig?.fints.tanMediaName,
bankingInformationJson: bankConfig?.fints.bankingInformationJson,
pin,
});
const today = new Date().toISOString().slice(0, 10);
const syncJob: PendingSyncJob = {
from: today,
to: today,
accountId: filterAccountId,
provider: "fints",
phase: "fetch",
};
let client = createFinTsClient(env);
client = await ensureFinTsReady(client, env, ctx, userId, syncJob);
const bankAccounts = client.config.bankingInformation.upd?.bankAccounts ?? [];
const accounts = bankAccounts.map(mapBankAccount);
const balances: NormalizedBalance[] = [];
const errors: BalanceRefreshError[] = [];
for (const account of accounts) {
try {
const balance = await fetchFinTsAccountBalance(
ctx,
userId,
client,
account,
syncJob,
);
account.balance = balance.balance;
balances.push(balance);
} catch (error) {
errors.push({
externalId: account.externalId,
message: error instanceof Error ? error.message : "Kontostandabruf fehlgeschlagen",
});
}
}
await ctx.runMutation(internal.bank.internal.upsertBankConfig, {
userId,
fints: {
blz: env.blz,
url: env.url,
login: env.login,
productId: env.productId,
bankingInformationJson: JSON.stringify(client.config.bankingInformation),
tanMethodId: client.config.selectedTanMethod?.id,
tanMediaName: methodMediaName(client),
},
});
return { accounts, balances, errors };
}
async function persistBalanceResults(
ctx: ActionCtx,
userId: Id<"users">,
provider: "comdirect" | "fints",
filterAccountId: Id<"accounts"> | undefined,
accounts: NormalizedAccount[],
balances: NormalizedBalance[],
errors: BalanceRefreshError[],
): Promise<{ updatedCount: number; errors: BalanceRefreshError[] }> {
const fetchedAt = Date.now();
const accountIdMap = new Map<string, Id<"accounts">>();
const accountByExternalId = new Map(accounts.map((account) => [account.externalId, account]));
for (const account of accounts) {
const convexAccountId = await ctx.runMutation(internal.bank.internal.upsertAccountFromProvider, {
userId,
externalId: account.externalId,
name: account.name,
iban: account.iban,
balance: account.balance,
currency: account.currency,
});
accountIdMap.set(account.externalId, convexAccountId);
}
let updatedCount = 0;
for (const balance of balances) {
const accountId = accountIdMap.get(balance.externalId);
if (!accountId || (filterAccountId && accountId !== filterAccountId)) continue;
await ctx.runMutation(internal.bank.internal.upsertAccountBalance, {
userId,
accountId,
externalId: balance.externalId,
provider,
balance: balance.balance,
currency: balance.currency,
asOf: balance.asOf,
fetchedAt,
status: "fresh",
});
updatedCount += 1;
}
const scopedErrors: BalanceRefreshError[] = [];
for (const error of errors) {
const externalId = error.externalId;
const accountId = externalId ? accountIdMap.get(externalId) : error.accountId;
if (filterAccountId && accountId !== filterAccountId) continue;
scopedErrors.push({ ...error, accountId });
const account = externalId ? accountByExternalId.get(externalId) : undefined;
if (accountId && account) {
await ctx.runMutation(internal.bank.internal.upsertAccountBalance, {
userId,
accountId,
externalId: account.externalId,
provider,
balance: account.balance,
currency: account.currency,
fetchedAt,
status: "error",
errorMessage: error.message,
});
}
}
await ctx.runMutation(internal.bank.internal.updateSyncState, {
userId,
lastSync: updatedCount > 0 ? fetchedAt : undefined,
lastProviderUsed: provider,
lastError: scopedErrors[0]?.message ?? null,
});
return { updatedCount, errors: scopedErrors };
}
async function persistSyncResults( async function persistSyncResults(
ctx: ActionCtx, ctx: ActionCtx,
userId: Id<"users">, userId: Id<"users">,
@@ -650,6 +838,7 @@ async function persistSyncResults(
transactionsByAccount: Map<string, NormalizedTransaction[]>, transactionsByAccount: Map<string, NormalizedTransaction[]>,
): Promise<{ importedCount: number; skippedCount: number }> { ): Promise<{ importedCount: number; skippedCount: number }> {
const accountIdMap = new Map<string, Id<"accounts">>(); const accountIdMap = new Map<string, Id<"accounts">>();
const fetchedAt = Date.now();
for (const account of accounts) { for (const account of accounts) {
const convexAccountId = await ctx.runMutation(internal.bank.internal.upsertAccountFromProvider, { const convexAccountId = await ctx.runMutation(internal.bank.internal.upsertAccountFromProvider, {
userId, userId,
@@ -660,6 +849,16 @@ async function persistSyncResults(
currency: account.currency, currency: account.currency,
}); });
accountIdMap.set(account.externalId, convexAccountId); 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[] = []; 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({ export const pollTan = internalAction({
args: { args: {
userId: v.id("users"), userId: v.id("users"),

View File

@@ -14,6 +14,7 @@ export type NormalizedBalance = {
externalId: string; externalId: string;
balance: number; balance: number;
currency: string; currency: string;
asOf?: string;
}; };
export type NormalizedTransaction = { export type NormalizedTransaction = {

View File

@@ -25,12 +25,19 @@ vi.mock("ai", async (importOriginal) => {
compare_periods: { execute: (input: unknown) => Promise<unknown> }; compare_periods: { execute: (input: unknown) => Promise<unknown> };
forecast_fixed_costs: { execute: (input: unknown) => Promise<unknown> }; forecast_fixed_costs: { execute: (input: unknown) => Promise<unknown> };
explain_savings_rate: { execute: (input: unknown) => Promise<unknown> }; explain_savings_rate: { execute: (input: unknown) => Promise<unknown> };
inspect_category_health: { execute: (input: unknown) => Promise<unknown> };
suggest_category_for_transactions: { execute: (input: unknown) => Promise<unknown> };
preview_month_end_spending: { execute: (input: unknown) => Promise<unknown> };
propose_bulk_recategory: { execute: (input: unknown) => Promise<unknown> };
propose_category_changes: { execute: (input: unknown) => Promise<unknown> };
}; };
}) => { }) => {
const transactionInput = { from: "2026-02-01", to: "2026-02-28", limit: 2 }; const transactionInput = { from: "2026-02-01", to: "2026-02-28", limit: 2 };
const summaryInput = { from: "2026-02-01", to: "2026-02-28" }; 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 transactionOutput = await options.tools.get_transactions.execute(transactionInput);
const summaryOutput = await options.tools.summarize_spending.execute(summaryInput); const summaryOutput = await options.tools.summarize_spending.execute(summaryInput);
const previewOutput = await options.tools.preview_month_end_spending.execute(previewInput);
return { return {
text: "Agenten-Antwort", text: "Agenten-Antwort",
@@ -47,6 +54,11 @@ vi.mock("ai", async (importOriginal) => {
input: summaryInput, input: summaryInput,
output: summaryOutput, output: summaryOutput,
}, },
{
toolName: "preview_month_end_spending",
input: previewInput,
output: previewOutput,
},
], ],
}, },
], ],
@@ -67,6 +79,16 @@ const historyApi = {
}; };
const sendMessageAction = makeFunctionReference<"action">("savingsChat:sendMessage"); 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 }; const paginationOpts = { cursor: null, numItems: 20 };
@@ -454,10 +476,11 @@ describe("savingsChat.sendMessage", () => {
to: "2026-02-28", to: "2026-02-28",
accountId: seeded.accountId as Id<"accounts">, accountId: seeded.accountId as Id<"accounts">,
basis: "effective", 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.toolTrace).toHaveLength(2); expect(result.toolTrace).toHaveLength(3);
expect(result.sources).toEqual([ expect(result.sources).toEqual([
{ {
id: "tool-1", id: "tool-1",
@@ -469,10 +492,16 @@ describe("savingsChat.sendMessage", () => {
title: "summarize_spending", title: "summarize_spending",
description: "2 Umsätze, Saldo 2880.00€, 1 Kategorien", 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([ expect(result.citations).toEqual([
{ marker: "1", sourceId: "tool-1" }, { marker: "1", sourceId: "tool-1" },
{ marker: "2", sourceId: "tool-2" }, { marker: "2", sourceId: "tool-2" },
{ marker: "3", sourceId: "tool-3" },
]); ]);
const generateCall = vi.mocked(generateText).mock.calls[0][0] as { 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: "user", content: "Wie sieht Februar aus?" },
{ {
role: "assistant", role: "assistant",
content: "Agenten-Antwort [1] [2]", content: "Agenten-Antwort [1] [2] [3]",
toolTrace: [ toolTrace: [
{ {
name: "get_transactions", name: "get_transactions",
@@ -505,6 +534,11 @@ describe("savingsChat.sendMessage", () => {
inputSummary: "2026-02-01 bis 2026-02-28", inputSummary: "2026-02-01 bis 2026-02-28",
resultSummary: "2 Umsätze, Saldo 2880.00€, 1 Kategorien", 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: [ sources: [
{ {
@@ -517,10 +551,16 @@ describe("savingsChat.sendMessage", () => {
title: "summarize_spending", title: "summarize_spending",
description: "2 Umsätze, Saldo 2880.00€, 1 Kategorien", 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: [ citations: [
{ marker: "1", sourceId: "tool-1" }, { marker: "1", sourceId: "tool-1" },
{ marker: "2", sourceId: "tool-2" }, { marker: "2", sourceId: "tool-2" },
{ marker: "3", sourceId: "tool-3" },
], ],
}, },
]); ]);
@@ -567,6 +607,7 @@ describe("savingsChat.sendMessage", () => {
from: "2026-02-01", from: "2026-02-01",
to: "2026-02-28", to: "2026-02-28",
basis: "effective", basis: "effective",
today: "2026-02-20",
}), }),
).rejects.toThrow("KI-Anfrage fehlgeschlagen"); ).rejects.toThrow("KI-Anfrage fehlgeschlagen");
@@ -1428,9 +1469,10 @@ describe("savingsChat read-only agent tools", () => {
to: "2026-02-28", to: "2026-02-28",
accountId: seeded.accountId as Id<"accounts">, accountId: seeded.accountId as Id<"accounts">,
basis: "effective", 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.model).toBe("gpt-5.4-mini");
expect(result.usedTransactions).toBe(2); expect(result.usedTransactions).toBe(2);
expect(result.usedBalance).toEqual({ income: 3000, expenses: -120, balance: 2880 }); 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", inputSummary: "2026-02-01 bis 2026-02-28",
resultSummary: "2 Umsätze, Saldo 2880.00€, 1 Kategorien", 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([ expect(result.sources).toEqual([
{ {
@@ -1457,10 +1504,16 @@ describe("savingsChat read-only agent tools", () => {
title: "summarize_spending", title: "summarize_spending",
description: "2 Umsätze, Saldo 2880.00€, 1 Kategorien", 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([ expect(result.citations).toEqual([
{ marker: "1", sourceId: "tool-1" }, { marker: "1", sourceId: "tool-1" },
{ marker: "2", sourceId: "tool-2" }, { 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("RAW PAYLOAD");
expect(JSON.stringify(result.toolTrace)).not.toContain("private note"); expect(JSON.stringify(result.toolTrace)).not.toContain("private note");
@@ -1479,6 +1532,11 @@ describe("savingsChat read-only agent tools", () => {
compare_periods: expect.any(Object), compare_periods: expect.any(Object),
forecast_fixed_costs: expect.any(Object), forecast_fixed_costs: expect.any(Object),
explain_savings_rate: 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), 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"); 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 () => { test("comparePeriodsTool computes totals and category deltas", async () => {
const { asUser, seeded } = await seedSavingsInsightFixture(); const { asUser, seeded } = await seedSavingsInsightFixture();

View File

@@ -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 { v } from "convex/values";
import { generateText, stepCountIs, tool } from "ai"; import { generateText, stepCountIs, tool } from "ai";
import { openai } from "@ai-sdk/openai"; import { openai } from "@ai-sdk/openai";
@@ -7,7 +7,7 @@ import { z } from "zod";
import { addMonthsToMonthKey, bookingMonth, monthKeyFromBasis } from "./lib/month"; import { addMonthsToMonthKey, bookingMonth, monthKeyFromBasis } from "./lib/month";
import { requireUserId } from "./lib/helpers"; import { requireUserId } from "./lib/helpers";
import type { Doc, Id } from "./_generated/dataModel"; 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 ChatRole = "user" | "assistant";
type ChatMessage = { role: ChatRole; content: string }; type ChatMessage = { role: ChatRole; content: string };
@@ -98,16 +98,19 @@ function formatEuro(value: number): string {
return `${value.toFixed(2)}`; 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 [ return [
"Du bist ein präziser Finanz-Chat-Assistent für Privatanwender.", "Du bist ein präziser Finanz-Chat-Assistent für Privatanwender.",
"Nutze ausschließlich die bereitgestellten Werkzeuge und deren Ergebnisse als Finanzkontext.", "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.", "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.", "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.", "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.", "Antworte auf Deutsch, kurz und handlungsorientiert.",
`Zeitraum: ${context.from} bis ${context.to}.`, `Zeitraum: ${context.from} bis ${context.to}.`,
`Basis: ${context.basis}.`, `Basis: ${context.basis}.`,
`Heute: ${context.today}.`,
context.accountName ? `Konto: ${context.accountName}.` : "Konto: Alle Konten.", context.accountName ? `Konto: ${context.accountName}.` : "Konto: Alle Konten.",
"Wenn eine Aussage nur grob geschätzt werden kann, kennzeichne sie als Schätzung.", "Wenn eine Aussage nur grob geschätzt werden kann, kennzeichne sie als Schätzung.",
"Nenne keine internen IDs und keine Rohdatenfelder.", "Nenne keine internen IDs und keine Rohdatenfelder.",
@@ -135,7 +138,7 @@ function sortTransactionsForContext(
} }
async function loadMatchingTransactions( async function loadMatchingTransactions(
ctx: QueryCtx, ctx: QueryCtx | MutationCtx,
userId: Id<"users">, userId: Id<"users">,
args: ChatContextArgs, args: ChatContextArgs,
): Promise<Doc<"transactions">[]> { ): Promise<Doc<"transactions">[]> {
@@ -458,6 +461,60 @@ const savingsLeverValidator = v.object({
monthlyImpact: v.number(), 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({ export const getContext = query({
args: contextArgsValidator, args: contextArgsValidator,
returns: contextSummaryValidator, returns: contextSummaryValidator,
@@ -496,7 +553,7 @@ function normalizeToolRange(scope: AgentToolScope, from?: string, to?: string) {
return range; return range;
} }
async function loadNameMaps(ctx: QueryCtx, userId: Id<"users">) { async function loadNameMaps(ctx: QueryCtx | MutationCtx, userId: Id<"users">) {
const categories = await ctx.db const categories = await ctx.db
.query("categories") .query("categories")
.withIndex("by_user", (index) => index.eq("userId", userId)) .withIndex("by_user", (index) => index.eq("userId", userId))
@@ -693,7 +750,7 @@ function transactionMatchesToolFilters(
} }
async function buildToolTransactionContext( async function buildToolTransactionContext(
ctx: QueryCtx, ctx: QueryCtx | MutationCtx,
userId: Id<"users">, userId: Id<"users">,
args: TransactionToolArgs, args: TransactionToolArgs,
): Promise<ToolTransactionContext> { ): Promise<ToolTransactionContext> {
@@ -850,6 +907,27 @@ function dateForTransaction(tx: Doc<"transactions">) {
return tx.valueDate || tx.bookingDate || tx.effectiveMonth || "n/a"; 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[]) { function monthIndexesAreConsecutive(months: string[]) {
if (months.length < 2) return false; if (months.length < 2) return false;
for (let index = 1; index < months.length; index++) { for (let index = 1; index < months.length; index++) {
@@ -1302,6 +1380,508 @@ export const getUncategorizedTransactionsTool = internalQuery({
}, },
}); });
export const previewMonthEndSpendingTool = internalQuery({
args: {
scope: toolScopeValidator,
today: v.string(),
accountName: v.optional(v.string()),
},
returns: v.object({
month: v.string(),
today: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
actualIncome: v.number(),
actualExpenses: v.number(),
actualBalance: v.number(),
predictedRemainingFixed: v.number(),
predictedRemainingVariable: v.number(),
predictedRemainingExpenses: v.number(),
expectedMonthExpenses: v.number(),
projectedMonthEndBalance: v.number(),
confidence: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
drivers: v.array(previewDriverValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const month = args.today.slice(0, 7);
const day = Math.max(1, Math.min(dayOfMonth(args.today), daysInMonth(month)));
const remainingDays = Math.max(0, daysInMonth(month) - day);
const currentContext = await buildToolTransactionContext(ctx, userId, {
scope: args.scope,
from: monthStart(month),
to: args.today,
accountName: args.accountName,
});
const historyContext = await buildToolTransactionContext(ctx, userId, {
scope: args.scope,
from: previousMonthStart(month, 3),
to: monthEnd(addMonthsToMonthKey(month, -1)),
accountName: args.accountName,
});
const currentTotals = calculateTotals(currentContext.transactions);
const fixedSeenThisMonth = new Set<Id<"categories">>();
let currentVariableExpenses = 0;
for (const tx of currentContext.transactions) {
if (tx.amount >= 0 || !tx.categoryId) continue;
const category = currentContext.categoryById.get(tx.categoryId);
if (category?.block === "wiederkehrend") fixedSeenThisMonth.add(tx.categoryId);
if (category?.block === "variabel") currentVariableExpenses += tx.amount;
}
const fixedByCategoryMonth = new Map<
Id<"categories">,
{ categoryName: string; amountsByMonth: Map<string, number> }
>();
let historicalVariableTotal = 0;
const historicalMonths = new Set<string>();
for (const tx of historyContext.transactions) {
if (tx.amount >= 0) continue;
const monthKey = monthKeyFromBasis(tx, historyContext.basis);
if (!monthKey) continue;
historicalMonths.add(monthKey);
const category = tx.categoryId ? historyContext.categoryById.get(tx.categoryId) : undefined;
if (category?.block === "wiederkehrend" && tx.categoryId) {
const entry = fixedByCategoryMonth.get(tx.categoryId) ?? {
categoryName: category.name,
amountsByMonth: new Map<string, number>(),
};
entry.amountsByMonth.set(monthKey, (entry.amountsByMonth.get(monthKey) ?? 0) + tx.amount);
fixedByCategoryMonth.set(tx.categoryId, entry);
}
if (category?.block === "variabel") {
historicalVariableTotal += tx.amount;
}
}
const fixedDrivers = [...fixedByCategoryMonth.entries()]
.filter(([categoryId]) => !fixedSeenThisMonth.has(categoryId))
.map(([, entry]) => ({
label: entry.categoryName,
amount: roundMoney(
[...entry.amountsByMonth.values()].reduce((sum, amount) => sum + amount, 0) /
entry.amountsByMonth.size,
),
}))
.filter((entry) => entry.amount < 0)
.sort((a, b) => a.amount - b.amount || a.label.localeCompare(b.label, "de-DE"));
const predictedRemainingFixed = roundMoney(
fixedDrivers.reduce((sum, entry) => sum + entry.amount, 0),
);
const predictedRemainingVariable =
currentVariableExpenses < 0
? roundMoney((currentVariableExpenses / day) * remainingDays)
: roundMoney(
(historicalVariableTotal / Math.max(1, historicalMonths.size) / daysInMonth(month)) *
remainingDays,
);
const predictedRemainingExpenses = roundMoney(predictedRemainingFixed + predictedRemainingVariable);
const confidence: "low" | "medium" | "high" =
day >= 15 && historicalMonths.size >= 2
? "high"
: historicalMonths.size > 0 || day >= 7
? "medium"
: "low";
const drivers = [
...(fixedDrivers.length > 0
? [
{
label: `Noch erwartete Fixkosten: ${fixedDrivers.map((entry) => entry.label).join(", ")}`,
amount: predictedRemainingFixed,
kind: "fixed" as const,
},
]
: []),
...(predictedRemainingVariable < 0
? [
{
label:
currentVariableExpenses < 0
? "Variable Ausgaben auf Basis des laufenden Monats"
: "Variable Ausgaben auf Basis historischer Monate",
amount: predictedRemainingVariable,
kind: "variable" as const,
},
]
: []),
];
return {
month,
today: args.today,
basis: args.scope.basis,
accountName: currentContext.accountName,
actualIncome: currentTotals.income,
actualExpenses: currentTotals.expenses,
actualBalance: currentTotals.balance,
predictedRemainingFixed,
predictedRemainingVariable,
predictedRemainingExpenses,
expectedMonthExpenses: roundMoney(currentTotals.expenses + predictedRemainingExpenses),
projectedMonthEndBalance: roundMoney(currentTotals.balance + predictedRemainingExpenses),
confidence,
drivers,
};
},
});
export const inspectCategoryHealthTool = internalQuery({
args: {
scope: toolScopeValidator,
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
},
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
totalCategories: v.number(),
uncategorizedCount: v.number(),
uncategorizedAmount: v.number(),
topUncategorizedCounterparties: v.array(topCounterpartyValidator),
unusedCategories: v.array(categoryHealthCategoryValidator),
similarCategories: v.array(similarCategoryValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
const usedCategoryIds = new Set(
context.transactions.flatMap((tx) => (tx.categoryId ? [tx.categoryId] : [])),
);
const uncategorized = context.transactions.filter((tx) => !tx.categoryId);
const counterpartyMap = new Map<string, { count: number; amount: number }>();
for (const tx of uncategorized) {
const name = tx.counterparty?.trim() || tx.description.trim() || "Unbekannt";
const entry = counterpartyMap.get(name) ?? { count: 0, amount: 0 };
entry.count += 1;
entry.amount += tx.amount;
counterpartyMap.set(name, entry);
}
const similarCategories = [];
for (let a = 0; a < context.categories.length; a++) {
for (let b = a + 1; b < context.categories.length; b++) {
const first = context.categories[a];
const second = context.categories[b];
const firstTokens = new Set(categoryTokens(first.name));
const secondTokens = new Set(categoryTokens(second.name));
const overlap = [...firstTokens].filter((token) => secondTokens.has(token));
const subset =
overlap.length > 0 &&
(overlap.length === firstTokens.size || overlap.length === secondTokens.size);
if (!subset) continue;
similarCategories.push({
names: [first.name, second.name].sort((x, y) => x.localeCompare(y, "de-DE")),
reason: "Ähnliche Kategoriebegriffe",
});
}
}
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
totalCategories: context.categories.length,
uncategorizedCount: uncategorized.length,
uncategorizedAmount: roundMoney(uncategorized.reduce((sum, tx) => sum + tx.amount, 0)),
topUncategorizedCounterparties: [...counterpartyMap.entries()]
.map(([name, entry]) => ({ name, count: entry.count, amount: roundMoney(entry.amount) }))
.sort((a, b) => b.count - a.count || Math.abs(b.amount) - Math.abs(a.amount) || a.name.localeCompare(b.name, "de-DE"))
.slice(0, 5),
unusedCategories: context.categories
.filter((category) => !usedCategoryIds.has(category._id))
.map((category) => ({
name: category.name,
kind: category.kind,
block: category.block,
isSystem: category.isSystem,
})),
similarCategories,
};
},
});
export const suggestCategoryForTransactionsTool = internalQuery({
args: {
...transactionToolArgsValidator,
onlyUncategorized: v.optional(v.boolean()),
},
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
suggestions: v.array(categorySuggestionValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
const limit = clampToolLimit(args.limit);
const candidates = context.transactions
.filter((tx) => (args.onlyUncategorized ? !tx.categoryId : true))
.slice(0, limit);
const history = await loadMatchingTransactions(ctx, userId, {
from: previousMonthStart(context.from.slice(0, 7), 12),
to: context.from,
accountId: context.accountId,
basis: context.basis,
});
const suggestionMap = new Map<
Id<"categories">,
{ categoryName: string; matchCount: number; amount: number; examples: ReturnType<typeof safeTransactionRow>[] }
>();
for (const tx of candidates) {
const txText = [tx.description, tx.counterparty].map(normalizedText).filter(Boolean);
const matches = history.filter((historical) => {
if (!historical.categoryId) return false;
const historicalText = [historical.description, historical.counterparty].map(normalizedText).filter(Boolean);
return txText.some((text) => text && historicalText.includes(text));
});
for (const match of matches) {
const categoryId = match.categoryId;
if (!categoryId) continue;
const category = context.categoryById.get(categoryId);
if (!category) continue;
const entry = suggestionMap.get(categoryId) ?? {
categoryName: category.name,
matchCount: 0,
amount: 0,
examples: [],
};
entry.matchCount += 1;
entry.amount += tx.amount;
if (entry.examples.length < 3) entry.examples.push(safeTransactionRow(tx, context));
suggestionMap.set(categoryId, entry);
}
}
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
suggestions: [...suggestionMap.values()]
.map((entry) => ({
categoryName: entry.categoryName,
confidence: entry.matchCount >= 1 ? "high" as const : "medium" as const,
matchCount: entry.examples.length,
amount: roundMoney(entry.amount),
examples: entry.examples.map((row) => ({
date: row.date,
description: row.description,
counterparty: row.counterparty,
amount: row.amount,
accountName: row.accountName,
})),
reason: "Ähnliche frühere Umsätze waren bereits dieser Kategorie zugeordnet.",
}))
.sort((a, b) => b.matchCount - a.matchCount || Math.abs(b.amount) - Math.abs(a.amount))
.slice(0, 5),
};
},
});
async function requireOwnedChatSession(
ctx: MutationCtx,
sessionId: Id<"chatSessions">,
userId: Id<"users">,
) {
const session = await ctx.db.get(sessionId);
if (!session) throw new Error("Chat nicht gefunden");
if (session.userId !== userId) throw new Error("Nicht autorisiert");
if (session.isDeleted) throw new Error("Chat nicht gefunden");
}
export const proposeBulkRecategoryTool = internalMutation({
args: {
scope: toolScopeValidator,
sessionId: v.id("chatSessions"),
targetCategoryName: v.string(),
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
search: v.optional(v.string()),
onlyUncategorized: v.optional(v.boolean()),
limit: v.optional(v.number()),
now: v.optional(v.number()),
},
returns: proposalResultValidator,
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
await requireOwnedChatSession(ctx, args.sessionId, userId);
const targetCategory = await ctx.db
.query("categories")
.withIndex("by_user_name", (index) =>
index.eq("userId", userId).eq("name", args.targetCategoryName.trim()),
)
.unique();
if (!targetCategory) throw new Error("Zielkategorie nicht gefunden");
const context = await buildToolTransactionContext(ctx, userId, args);
const limit = Math.min(100, clampToolLimit(args.limit));
const transactions = context.transactions
.filter((tx) => (args.onlyUncategorized ? !tx.categoryId : true))
.filter((tx) => tx.categoryId !== targetCategory._id)
.slice(0, limit);
if (transactions.length === 0) throw new Error("Keine passenden Umsätze gefunden");
const operations = transactions.map((tx) => ({
type: "set_transaction_category" as const,
transactionId: tx._id,
fromCategoryId: tx.categoryId,
toCategoryId: targetCategory._id,
}));
const previewRows = transactions.slice(0, 10).map((tx) => ({
date: dateForTransaction(tx),
description: tx.description,
amount: roundMoney(tx.amount),
currentCategoryName: tx.categoryId
? context.categoryById.get(tx.categoryId)?.name ?? "Ohne Kategorie"
: "Ohne Kategorie",
targetCategoryName: targetCategory.name,
}));
const now = args.now ?? Date.now();
const summary = `${operations.length} Umsatz${operations.length === 1 ? "" : "e"} zur Kategorie ${targetCategory.name} zuordnen`;
const planId = await ctx.db.insert("agentActionPlans", {
userId,
sessionId: args.sessionId,
kind: "bulk_recategory",
status: "pending",
summary,
affectedCount: operations.length,
createdAt: now,
expiresAt: now + 24 * 60 * 60 * 1000,
operations,
previewRows,
});
return {
planId,
kind: "bulk_recategory" as const,
status: "pending" as const,
summary,
affectedCount: operations.length,
expiresAt: now + 24 * 60 * 60 * 1000,
previewRows,
};
},
});
const categoryCreateValidator = v.object({
name: v.string(),
kind: v.union(v.literal("einnahme"), v.literal("ausgabe")),
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
color: v.string(),
icon: v.optional(v.string()),
sortOrder: v.number(),
});
const categoryUpdateByNameValidator = v.object({
name: v.string(),
newName: v.optional(v.string()),
kind: v.optional(v.union(v.literal("einnahme"), v.literal("ausgabe"))),
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
color: v.optional(v.string()),
icon: v.optional(v.string()),
sortOrder: v.optional(v.number()),
});
export const proposeCategoryChangesTool = internalMutation({
args: {
sessionId: v.id("chatSessions"),
creates: v.optional(v.array(categoryCreateValidator)),
updates: v.optional(v.array(categoryUpdateByNameValidator)),
now: v.optional(v.number()),
},
returns: proposalResultValidator,
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
await requireOwnedChatSession(ctx, args.sessionId, userId);
const operations = [];
const previewRows = [];
for (const create of (args.creates ?? []).slice(0, 100)) {
const name = create.name.trim();
if (!name || (create.kind === "ausgabe" && !create.block)) continue;
operations.push({
type: "create_category" as const,
name,
kind: create.kind,
block: create.kind === "ausgabe" ? create.block : undefined,
color: create.color,
icon: create.icon,
sortOrder: create.sortOrder,
});
previewRows.push({
description: name,
action: "Kategorie erstellen",
targetCategoryName: name,
});
}
for (const update of (args.updates ?? []).slice(0, 100 - operations.length)) {
const trimmedNewName = update.newName?.trim();
if (update.newName !== undefined && !trimmedNewName) continue;
const category = await ctx.db
.query("categories")
.withIndex("by_user_name", (index) =>
index.eq("userId", userId).eq("name", update.name.trim()),
)
.unique();
if (!category) continue;
operations.push({
type: "update_category" as const,
categoryId: category._id,
name: trimmedNewName,
kind: update.kind,
block: update.block,
color: update.color,
icon: update.icon,
sortOrder: update.sortOrder,
});
previewRows.push({
description: category.name,
action: "Kategorie aktualisieren",
currentCategoryName: category.name,
targetCategoryName: trimmedNewName ?? category.name,
});
}
if (operations.length === 0) throw new Error("Keine Kategorieänderungen vorbereitet");
const now = args.now ?? Date.now();
const summary = `${operations.length} Kategorieänderung${operations.length === 1 ? "" : "en"} vorbereiten`;
const planId = await ctx.db.insert("agentActionPlans", {
userId,
sessionId: args.sessionId,
kind: "category_changes",
status: "pending",
summary,
affectedCount: operations.length,
createdAt: now,
expiresAt: now + 24 * 60 * 60 * 1000,
operations,
previewRows,
});
return {
planId,
kind: "category_changes" as const,
status: "pending" as const,
summary,
affectedCount: operations.length,
expiresAt: now + 24 * 60 * 60 * 1000,
previewRows,
};
},
});
export const comparePeriodsTool = internalQuery({ export const comparePeriodsTool = internalQuery({
args: { args: {
scope: toolScopeValidator, scope: toolScopeValidator,
@@ -1701,13 +2281,17 @@ function summarizeToolInput(input: unknown) {
const search = maybeString(record.search); const search = maybeString(record.search);
const limit = maybeNumber(record.limit); const limit = maybeNumber(record.limit);
const horizonMonths = maybeNumber(record.horizonMonths); const horizonMonths = maybeNumber(record.horizonMonths);
const today = maybeString(record.today);
const targetCategoryName = maybeString(record.targetCategoryName);
const type = maybeString(record.type); const type = maybeString(record.type);
const categoryNames = Array.isArray(record.categoryNames) const categoryNames = Array.isArray(record.categoryNames)
? record.categoryNames.filter((name): name is string => typeof name === "string") ? record.categoryNames.filter((name): name is string => typeof name === "string")
: []; : [];
if (from || to) parts.push(`${from ?? "?"} bis ${to ?? "?"}`); if (from || to) parts.push(`${from ?? "?"} bis ${to ?? "?"}`);
if (today) parts.push(`Monatsvorschau für ${today}`);
if (search) parts.push(`Suche "${search}"`); if (search) parts.push(`Suche "${search}"`);
if (targetCategoryName) parts.push(`Zielkategorie ${targetCategoryName}`);
if (categoryNames.length > 0) parts.push(`Kategorien ${categoryNames.join(", ")}`); if (categoryNames.length > 0) parts.push(`Kategorien ${categoryNames.join(", ")}`);
if (type) parts.push(type === "income" ? "Einnahmen" : "Ausgaben"); if (type) parts.push(type === "income" ? "Einnahmen" : "Ausgaben");
if (horizonMonths) parts.push(`${horizonMonths} Monate Prognose`); 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}`; 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"; 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."), 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( async function generateSavingsChatResponse(
ctx: ActionCtx, ctx: ActionCtx,
args: ChatContextArgs & { messages: ChatMessage[] }, args: ChatContextArgs & { messages: ChatMessage[]; today?: string; sessionId?: Id<"chatSessions"> },
): Promise<ChatAskResult> { ): Promise<ChatAskResult> {
if (args.messages.length === 0) { if (args.messages.length === 0) {
throw new Error("Kein Nutzernachrichttext vorhanden."); throw new Error("Kein Nutzernachrichttext vorhanden.");
@@ -1957,6 +2616,7 @@ async function generateSavingsChatResponse(
accountId: args.accountId, accountId: args.accountId,
basis: args.basis, basis: args.basis,
}; };
const today = args.today ?? new Date().toISOString().slice(0, 10);
const selectedSummary: { const selectedSummary: {
totalCount: number; totalCount: number;
@@ -1975,6 +2635,7 @@ async function generateSavingsChatResponse(
from: args.from, from: args.from,
to: args.to, to: args.to,
basis: args.basis, basis: args.basis,
today,
accountName: selectedSummary.accountName, accountName: selectedSummary.accountName,
}); });
@@ -2089,6 +2750,62 @@ async function generateSavingsChatResponse(
...input, ...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(); const envModel = process.env.SAVINGS_CHAT_MODEL?.trim();
@@ -2144,6 +2861,7 @@ export const ask = action({
to: v.string(), to: v.string(),
accountId: v.optional(v.id("accounts")), accountId: v.optional(v.id("accounts")),
basis: v.union(v.literal("effective"), v.literal("booking")), basis: v.union(v.literal("effective"), v.literal("booking")),
today: v.optional(v.string()),
}, },
returns: v.object({ returns: v.object({
model: v.string(), model: v.string(),
@@ -2161,6 +2879,7 @@ export const ask = action({
handler: async (ctx, args): Promise<ChatAskResult> => { handler: async (ctx, args): Promise<ChatAskResult> => {
return await generateSavingsChatResponse(ctx, { return await generateSavingsChatResponse(ctx, {
...args, ...args,
today: args.today,
messages: args.messages.map((message) => ({ messages: args.messages.map((message) => ({
role: normalizeRole(message.role), role: normalizeRole(message.role),
content: message.content, content: message.content,
@@ -2177,6 +2896,7 @@ export const sendMessage = action({
to: v.string(), to: v.string(),
accountId: v.optional(v.id("accounts")), accountId: v.optional(v.id("accounts")),
basis: v.union(v.literal("effective"), v.literal("booking")), basis: v.union(v.literal("effective"), v.literal("booking")),
today: v.optional(v.string()),
}, },
returns: v.object({ returns: v.object({
model: v.string(), model: v.string(),
@@ -2215,6 +2935,8 @@ export const sendMessage = action({
to: args.to, to: args.to,
accountId: args.accountId, accountId: args.accountId,
basis: args.basis, basis: args.basis,
today: args.today,
sessionId: args.sessionId,
messages, messages,
}); });
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,266 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { assertOwned, requireUserId } from "./lib/helpers";
import type { Doc, Id } from "./_generated/dataModel";
import type { MutationCtx, QueryCtx } from "./_generated/server";
const MAX_PLAN_OPERATIONS = 100;
const planKindValidator = v.union(
v.literal("bulk_recategory"),
v.literal("category_changes"),
);
const planStatusValidator = v.union(
v.literal("pending"),
v.literal("applied"),
v.literal("dismissed"),
v.literal("expired"),
);
const operationValidator = v.union(
v.object({
type: v.literal("set_transaction_category"),
transactionId: v.id("transactions"),
fromCategoryId: v.optional(v.id("categories")),
toCategoryId: v.id("categories"),
}),
v.object({
type: v.literal("create_category"),
name: v.string(),
kind: v.union(v.literal("einnahme"), v.literal("ausgabe")),
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
color: v.string(),
icon: v.optional(v.string()),
sortOrder: v.number(),
}),
v.object({
type: v.literal("update_category"),
categoryId: v.id("categories"),
name: v.optional(v.string()),
kind: v.optional(v.union(v.literal("einnahme"), v.literal("ausgabe"))),
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
color: v.optional(v.string()),
icon: v.optional(v.string()),
sortOrder: v.optional(v.number()),
}),
);
const previewRowValidator = v.object({
date: v.optional(v.string()),
description: v.string(),
amount: v.optional(v.number()),
currentCategoryName: v.optional(v.string()),
targetCategoryName: v.optional(v.string()),
action: v.optional(v.string()),
});
const actionPlanValidator = v.object({
_id: v.id("agentActionPlans"),
_creationTime: v.number(),
userId: v.id("users"),
sessionId: v.id("chatSessions"),
kind: planKindValidator,
status: planStatusValidator,
summary: v.string(),
affectedCount: v.number(),
createdAt: v.number(),
expiresAt: v.number(),
operations: v.array(operationValidator),
previewRows: v.array(previewRowValidator),
resultSummary: v.optional(v.string()),
});
async function requireOwnedSession(
ctx: QueryCtx | MutationCtx,
sessionId: Id<"chatSessions">,
userId: Id<"users">,
) {
const session = await assertOwned(await ctx.db.get(sessionId), userId, "Chat");
if (session.isDeleted) throw new Error("Chat nicht gefunden");
return session;
}
async function requireOwnedPlan(
ctx: QueryCtx | MutationCtx,
planId: Id<"agentActionPlans">,
userId: Id<"users">,
) {
const plan = await assertOwned(await ctx.db.get(planId), userId, "Vorschlag");
await requireOwnedSession(ctx, plan.sessionId, userId);
return plan;
}
async function applyTransactionCategory(
ctx: MutationCtx,
userId: Id<"users">,
operation: Extract<Doc<"agentActionPlans">["operations"][number], { type: "set_transaction_category" }>,
) {
const tx = await ctx.db.get(operation.transactionId);
if (!tx || tx.userId !== userId) return false;
const targetCategory = await ctx.db.get(operation.toCategoryId);
if (!targetCategory || targetCategory.userId !== userId) return false;
if (tx.categoryId !== operation.fromCategoryId) return false;
await ctx.db.patch(tx._id, { categoryId: operation.toCategoryId });
return true;
}
async function applyCreateCategory(
ctx: MutationCtx,
userId: Id<"users">,
operation: Extract<Doc<"agentActionPlans">["operations"][number], { type: "create_category" }>,
) {
const name = operation.name.trim();
if (!name) return false;
if (operation.kind === "ausgabe" && !operation.block) return false;
const existing = await ctx.db
.query("categories")
.withIndex("by_user_name", (index) =>
index.eq("userId", userId).eq("name", name),
)
.unique();
if (existing) return false;
await ctx.db.insert("categories", {
userId,
name,
kind: operation.kind,
block: operation.kind === "ausgabe" ? operation.block : undefined,
color: operation.color,
icon: operation.icon,
sortOrder: operation.sortOrder,
isSystem: false,
});
return true;
}
async function applyUpdateCategory(
ctx: MutationCtx,
userId: Id<"users">,
operation: Extract<Doc<"agentActionPlans">["operations"][number], { type: "update_category" }>,
) {
const category = await ctx.db.get(operation.categoryId);
if (!category || category.userId !== userId) return false;
const patch: Partial<Doc<"categories">> = {};
for (const key of ["name", "kind", "block", "color", "icon", "sortOrder"] as const) {
if (operation[key] !== undefined) {
patch[key] = operation[key] as never;
}
}
if (patch.name !== undefined) {
const name = patch.name.trim();
if (!name) return false;
const existing = await ctx.db
.query("categories")
.withIndex("by_user_name", (index) => index.eq("userId", userId).eq("name", name))
.unique();
if (existing && existing._id !== operation.categoryId) return false;
patch.name = name;
}
const kind = patch.kind ?? category.kind;
const block = patch.block ?? category.block;
if (kind === "ausgabe" && !block) return false;
if (kind === "einnahme") patch.block = undefined;
if (Object.keys(patch).length === 0) return false;
await ctx.db.patch(operation.categoryId, patch);
return true;
}
export const listPendingActionPlans = query({
args: { sessionId: v.id("chatSessions"), now: v.optional(v.number()) },
returns: v.array(actionPlanValidator),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const session = await ctx.db.get(args.sessionId);
if (!session || session.userId !== userId || session.isDeleted) return [];
const now = args.now ?? Date.now();
const plans = await ctx.db
.query("agentActionPlans")
.withIndex("by_user_session_status_created", (index) =>
index
.eq("userId", userId)
.eq("sessionId", args.sessionId)
.eq("status", "pending"),
)
.order("desc")
.take(20);
return plans.filter((plan) => plan.expiresAt > now);
},
});
export const dismissActionPlan = mutation({
args: { planId: v.id("agentActionPlans") },
returns: v.null(),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const plan = await requireOwnedPlan(ctx, args.planId, userId);
if (plan.status !== "pending") return null;
await ctx.db.patch(args.planId, {
status: "dismissed",
resultSummary: "Vorschlag verworfen",
});
return null;
},
});
export const applyActionPlan = mutation({
args: { planId: v.id("agentActionPlans"), now: v.optional(v.number()) },
returns: v.object({
appliedCount: v.number(),
skippedCount: v.number(),
status: planStatusValidator,
summary: v.string(),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const plan = await requireOwnedPlan(ctx, args.planId, userId);
const now = args.now ?? Date.now();
if (plan.status !== "pending") {
return {
appliedCount: 0,
skippedCount: plan.operations.length,
status: plan.status as "pending" | "applied" | "dismissed" | "expired",
summary: plan.resultSummary ?? "Vorschlag ist nicht mehr offen",
};
}
if (plan.expiresAt <= now) {
await ctx.db.patch(args.planId, {
status: "expired" as const,
resultSummary: "Vorschlag ist abgelaufen",
});
return {
appliedCount: 0,
skippedCount: plan.operations.length,
status: "expired" as const,
summary: "Vorschlag ist abgelaufen",
};
}
if (plan.operations.length > MAX_PLAN_OPERATIONS) {
throw new Error("Zu viele Änderungen in einem Vorschlag");
}
let appliedCount = 0;
let skippedCount = 0;
for (const operation of plan.operations) {
const applied =
operation.type === "set_transaction_category"
? await applyTransactionCategory(ctx, userId, operation)
: operation.type === "create_category"
? await applyCreateCategory(ctx, userId, operation)
: await applyUpdateCategory(ctx, userId, operation);
if (applied) {
appliedCount++;
} else {
skippedCount++;
}
}
const summary = `${appliedCount} Änderung${appliedCount === 1 ? "" : "en"} angewendet, ${skippedCount} übersprungen`;
await ctx.db.patch(args.planId, {
status: "applied" as const,
resultSummary: summary,
});
return {
appliedCount,
skippedCount,
status: "applied" as const,
summary,
};
},
});

View File

@@ -24,6 +24,51 @@ const chatCitation = v.object({
marker: v.string(), marker: v.string(),
sourceId: 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({ export default defineSchema({
...authTables, ...authTables,
@@ -41,6 +86,22 @@ export default defineSchema({
.index("by_user", ["userId"]) .index("by_user", ["userId"])
.index("by_user_external", ["userId", "externalId"]), .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({ categories: defineTable({
userId: v.id("users"), userId: v.id("users"),
name: v.string(), name: v.string(),
@@ -214,4 +275,20 @@ export default defineSchema({
sources: v.optional(v.array(chatSource)), sources: v.optional(v.array(chatSource)),
citations: v.optional(v.array(chatCitation)), citations: v.optional(v.array(chatCitation)),
}).index("by_user_session_created", ["userId", "sessionId", "createdAt"]), }).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"]),
}); });

View File

@@ -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<NormalizedBalance>
```
- [ ] 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.

View File

@@ -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(
<AccountBalanceStripView rows={undefined} isRefreshing={false} onRefresh={() => undefined} />,
);
expect(markup).toContain("Kontostände werden geladen");
});
test("renders missing and fresh balance states", () => {
const markup = renderToStaticMarkup(
<AccountBalanceStripView
rows={[
freshRow,
{
...freshRow,
accountId: "account-2",
accountName: "Tagesgeld",
balance: null,
provider: null,
fetchedAt: null,
status: "missing",
},
]}
isRefreshing={false}
onRefresh={() => 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(
<AccountBalanceStripView
rows={[
{
...freshRow,
status: "error",
errorMessage: "HKSAL wird nicht unterstuetzt",
},
]}
isRefreshing
onRefresh={() => undefined}
/>,
);
expect(markup).toContain("1.234,56");
expect(markup).toContain("Fehler");
expect(markup).toContain("HKSAL wird nicht unterstuetzt");
expect(markup).toContain("disabled");
});
});

View File

@@ -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 (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3">
<CardTitle>Aktueller Kontostand</CardTitle>
<Button
type="button"
variant="outline"
size="sm"
onClick={onRefresh}
disabled={isRefreshing || rows === undefined}
title="Kontostände aktualisieren"
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
<span className="ml-2 hidden sm:inline">
{isRefreshing ? "Aktualisiert..." : "Aktualisieren"}
</span>
</Button>
</CardHeader>
<CardContent>
{rows === undefined ? (
<div className="space-y-3" aria-label="Kontostände werden geladen">
<Skeleton className="h-14" />
<Skeleton className="h-14" />
</div>
) : rows.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine aktiven Konten</p>
) : (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{rows.map((row) => (
<div
key={row.accountId}
className="rounded-lg border bg-background px-4 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{row.accountName}</div>
<div className="text-xs text-muted-foreground">{accountHint(row)}</div>
</div>
<Badge variant="outline" className={statusClass(row)}>
{statusLabel(row)}
</Badge>
</div>
<div className="mt-3 flex items-end justify-between gap-3">
<div
className={`text-xl font-semibold ${
row.balance === null ? "text-muted-foreground" : amountClass(row.balance)
}`}
>
{row.balance === null ? "Noch nicht abgerufen" : formatAmount(row.balance)}
</div>
<div className="text-right text-xs text-muted-foreground">
{fetchedLabel(row)}
</div>
</div>
{row.errorMessage && (
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
{row.errorMessage}
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
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 (
<AccountBalanceStripView
rows={rows}
isRefreshing={isRefreshing}
onRefresh={() => {
void handleRefresh();
}}
/>
);
}

View File

@@ -2,6 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { import {
AgentConversation, AgentConversation,
AgentActionPlanList,
AgentMessage, AgentMessage,
AgentPromptInput, AgentPromptInput,
type AgentChatMessage, type AgentChatMessage,
@@ -241,3 +242,55 @@ describe("AgentChat phase 3 sources and inline citations", () => {
expect(markup).not.toContain(">Quellen<"); expect(markup).not.toContain(">Quellen<");
}); });
}); });
describe("AgentChat action plan preview cards", () => {
test("renders pending action plans with examples and confirmation controls", () => {
const markup = renderToStaticMarkup(
<AgentActionPlanList
plans={[
{
_id: "plan-1",
kind: "bulk_recategory",
summary: "1 Umsatz zur Kategorie Abos zuordnen",
affectedCount: 1,
expiresAt: 1_800_086_400_000,
previewRows: [
{
date: "2026-06-03",
description: "Netflix",
amount: -16,
currentCategoryName: "Ohne Kategorie",
targetCategoryName: "Abos",
},
],
},
]}
isApplying={false}
onApply={() => 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(
<AgentActionPlanList
plans={[]}
isApplying={false}
onApply={() => undefined}
onDismiss={() => undefined}
/>,
);
expect(markup).toBe("");
});
});

View File

@@ -4,7 +4,7 @@ import {
type HTMLAttributes, type HTMLAttributes,
type Ref, type Ref,
} from "react"; } 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 { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@@ -37,6 +37,24 @@ type AgentPromptInputProps = {
onSubmit: (event: FormEvent<HTMLFormElement>) => void; onSubmit: (event: FormEvent<HTMLFormElement>) => 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({ export function AgentConversation({
messages, messages,
isSubmitting = false, 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 (
<section className="rounded-md border bg-card p-3" aria-label="Vorschläge zur Bestätigung">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold">Vorschläge zur Bestätigung</p>
<p className="text-xs text-muted-foreground">
Änderungen werden erst nach deiner Bestätigung angewendet.
</p>
</div>
</div>
<div className="space-y-3">
{plans.map((plan) => (
<article className="rounded-md border bg-muted/20 p-3" key={plan._id}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="text-sm font-medium">{plan.summary}</p>
<p className="mt-1 text-xs text-muted-foreground">
{plan.affectedCount} {plan.affectedCount === 1 ? "Änderung" : "Änderungen"} · läuft ab{" "}
{formatActionPlanExpiry(plan.expiresAt)}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button
type="button"
size="sm"
onClick={() => onApply(plan._id)}
disabled={isApplying}
>
{isApplying ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
Anwenden
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => onDismiss(plan._id)}
disabled={isApplying}
>
<X className="h-4 w-4" />
Verwerfen
</Button>
</div>
</div>
{plan.previewRows.length > 0 && (
<div className="mt-3 overflow-hidden rounded-md border bg-background/80">
{plan.previewRows.slice(0, 5).map((row, index) => (
<div
className="grid gap-1 border-b px-2 py-2 text-xs last:border-b-0 sm:grid-cols-[1fr_auto]"
key={`${plan._id}-${row.description}-${index}`}
>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{row.date ? `${row.date} · ` : ""}
{row.description}
</p>
<p className="truncate text-muted-foreground">
{row.action ?? [row.currentCategoryName, row.targetCategoryName].filter(Boolean).join(" → ")}
</p>
</div>
{row.amount !== undefined && (
<p className="font-medium tabular-nums">{formatActionPlanAmount(row.amount)}</p>
)}
</div>
))}
</div>
)}
</article>
))}
</div>
</section>
);
}
export function AgentMessage({ message, className, ...props }: AgentMessageProps) { export function AgentMessage({ message, className, ...props }: AgentMessageProps) {
const isUser = message.role === "user"; 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[] }) { function AgentSources({ sources }: { sources: AgentSource[] }) {
return ( return (
<div className="mt-3 rounded-md border bg-muted/20 p-2"> <div className="mt-3 rounded-md border bg-muted/20 p-2">

View File

@@ -9,6 +9,7 @@ import { CategoryBreakdownChart, FixedVariableSplit } from "@/components/charts/
import { amountClass, formatAmount, formatDate, pct } from "@/lib/format"; import { amountClass, formatAmount, formatDate, pct } from "@/lib/format";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import type { Id } from "../../convex/_generated/dataModel"; import type { Id } from "../../convex/_generated/dataModel";
import { AccountBalanceStrip } from "@/components/accounts/AccountBalanceStrip";
function KpiCard({ title, value, className }: { title: string; value: string; className?: string }) { function KpiCard({ title, value, className }: { title: string; value: string; className?: string }) {
return ( return (
@@ -43,12 +44,14 @@ export function DashboardPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<AccountBalanceStrip />
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<KpiCard title="Einnahmen" value={formatAmount(summary.income)} className={amountClass(summary.income)} /> <KpiCard title="Einnahmen" value={formatAmount(summary.income)} className={amountClass(summary.income)} />
<KpiCard title="Ausgaben" value={formatAmount(summary.expenses)} className={amountClass(summary.expenses)} /> <KpiCard title="Ausgaben" value={formatAmount(summary.expenses)} className={amountClass(summary.expenses)} />
<KpiCard title="Fixkosten" value={formatAmount(summary.fixedCosts)} className={amountClass(summary.fixedCosts)} /> <KpiCard title="Fixkosten" value={formatAmount(summary.fixedCosts)} className={amountClass(summary.fixedCosts)} />
<KpiCard title="Variabel" value={formatAmount(summary.variableCosts)} className={amountClass(summary.variableCosts)} /> <KpiCard title="Variabel" value={formatAmount(summary.variableCosts)} className={amountClass(summary.variableCosts)} />
<KpiCard title="Saldo" value={formatAmount(summary.balance)} className={amountClass(summary.balance)} /> <KpiCard title="Periodensaldo" value={formatAmount(summary.balance)} className={amountClass(summary.balance)} />
<KpiCard <KpiCard
title="Sparquote" title="Sparquote"
value={summary.savingsRate === null ? "" : pct.format(summary.savingsRate)} value={summary.savingsRate === null ? "" : pct.format(summary.savingsRate)}

View File

@@ -8,8 +8,10 @@ import { useFilters } from "@/context/FilterContext";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { import {
AgentActionPlanList,
AgentConversation, AgentConversation,
AgentPromptInput, AgentPromptInput,
type AgentActionPlan,
type AgentChatMessage, type AgentChatMessage,
} from "@/components/chat/AgentChat"; } from "@/components/chat/AgentChat";
import { scrollConversationToBottom } from "@/components/chat/agentChatModel"; import { scrollConversationToBottom } from "@/components/chat/agentChatModel";
@@ -56,6 +58,13 @@ const initialAssistantMessage: ChatMessage = {
}; };
const fallbackMessages: DisplayChatMessage[] = [{ ...initialAssistantMessage, _id: "fallback-0" }]; const fallbackMessages: DisplayChatMessage[] = [{ ...initialAssistantMessage, _id: "fallback-0" }];
function localDateKey(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function normalizeToolTrace(value: unknown): ToolTrace[] | undefined { function normalizeToolTrace(value: unknown): ToolTrace[] | undefined {
if (!Array.isArray(value)) return undefined; if (!Array.isArray(value)) return undefined;
const trace = value.flatMap((item) => { const trace = value.flatMap((item) => {
@@ -195,6 +204,7 @@ export function SavingsChatPage() {
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [selectedSessionId, setSelectedSessionId] = useState<Id<"chatSessions"> | undefined>(); const [selectedSessionId, setSelectedSessionId] = useState<Id<"chatSessions"> | undefined>();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [applyingPlanId, setApplyingPlanId] = useState<string | undefined>();
const [legacyImportResult, setLegacyImportResult] = useState<{ const [legacyImportResult, setLegacyImportResult] = useState<{
key: string; key: string;
importedCount: number; importedCount: number;
@@ -250,9 +260,15 @@ export function SavingsChatPage() {
basis: monthBasis, basis: monthBasis,
}); });
const currentUser = useQuery(api.users.currentUser); const currentUser = useQuery(api.users.currentUser);
const pendingActionPlans = useQuery(
api.savingsChatActionPlans.listPendingActionPlans,
activeSessionId ? { sessionId: activeSessionId } : "skip",
);
const createSession = useMutation(api.savingsChatHistory.createSession); const createSession = useMutation(api.savingsChatHistory.createSession);
const deleteSession = useMutation(api.savingsChatHistory.deleteSession); const deleteSession = useMutation(api.savingsChatHistory.deleteSession);
const importLocalSession = useMutation(api.savingsChatHistory.importLocalSession); 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 sendMessage = useAction(api.savingsChat.sendMessage);
const importMarkerKey = currentUser ? `${IMPORTED_KEY}:${currentUser._id}` : undefined; const importMarkerKey = currentUser ? `${IMPORTED_KEY}:${currentUser._id}` : undefined;
const legacyImportComplete = Boolean( const legacyImportComplete = Boolean(
@@ -373,6 +389,33 @@ export function SavingsChatPage() {
})), })),
[sessions], [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) => { const submit = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
@@ -390,6 +433,7 @@ export function SavingsChatPage() {
to, to,
accountId, accountId,
basis: monthBasis, basis: monthBasis,
today: localDateKey(),
}); });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -436,6 +480,13 @@ export function SavingsChatPage() {
scrollRef={listRef} scrollRef={listRef}
/> />
<AgentActionPlanList
plans={actionPlans}
isApplying={Boolean(applyingPlanId)}
onApply={applyPlan}
onDismiss={dismissPlan}
/>
<AgentPromptInput <AgentPromptInput
value={draft} value={draft}
onChange={setDraft} onChange={setDraft}

View File

@@ -10,10 +10,15 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { toast } from "sonner"; import { toast } from "sonner";
import { BankConfigForm } from "@/components/import/BankConfigForm"; import { BankConfigForm } from "@/components/import/BankConfigForm";
import { amountClass, formatAmount } from "@/lib/format";
import type { AccountBalanceRow } from "@/components/accounts/AccountBalanceStrip";
export function SettingsPage() { export function SettingsPage() {
const settings = useQuery(api.settings.get); const settings = useQuery(api.settings.get);
const accounts = useQuery(api.accounts.list); const accounts = useQuery(api.accounts.list);
const balances = useQuery(api.bank.balances.listLatest, { includeArchived: true }) as
| AccountBalanceRow[]
| undefined;
const updateSettings = useMutation(api.settings.update); const updateSettings = useMutation(api.settings.update);
const applySalaryShift = useMutation(api.transactions.applySalaryShift); const applySalaryShift = useMutation(api.transactions.applySalaryShift);
const createAccount = useMutation(api.accounts.create); const createAccount = useMutation(api.accounts.create);
@@ -48,6 +53,9 @@ export function SettingsPage() {
}; };
const [newAccount, setNewAccount] = useState({ name: "", type: "giro", openingBalance: 0 }); const [newAccount, setNewAccount] = useState({ name: "", type: "giro", openingBalance: 0 });
const balanceByAccountId = new Map(
balances?.map((balance) => [balance.accountId, balance]) ?? [],
);
return ( return (
<div className="mx-auto max-w-3xl space-y-6"> <div className="mx-auto max-w-3xl space-y-6">
@@ -58,33 +66,53 @@ export function SettingsPage() {
<CardTitle>Konten</CardTitle> <CardTitle>Konten</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{accounts?.map((account) => ( {accounts?.map((account) => {
<div key={account._id} className="flex flex-wrap items-center gap-2 rounded-lg border p-3"> const liveBalance = balanceByAccountId.get(account._id);
<div className="flex-1"> return (
<div className="font-medium">{account.name}</div> <div key={account._id} className="flex flex-wrap items-center gap-2 rounded-lg border p-3">
<div className="text-xs text-muted-foreground"> <div className="flex-1">
{account.type} · {account.iban ?? "keine IBAN"} <div className="font-medium">{account.name}</div>
{account.externalId && " · comdirect verbunden"} <div className="text-xs text-muted-foreground">
{account.type} · {account.iban ?? "keine IBAN"}
{account.externalId && " · Bank verbunden"}
</div>
{liveBalance && (
<div className="mt-1 text-xs">
<span className="text-muted-foreground">Aktueller Kontostand: </span>
{liveBalance.balance === null ? (
<span className="text-muted-foreground">Noch nicht abgerufen</span>
) : (
<span className={amountClass(liveBalance.balance)}>
{formatAmount(liveBalance.balance)}
</span>
)}
{liveBalance.status === "error" && (
<span className="ml-2 text-red-600 dark:text-red-400">
{liveBalance.errorMessage ?? "Fehler"}
</span>
)}
</div>
)}
</div> </div>
<Button
size="sm"
variant="outline"
onClick={() => updateAccount({ id: account._id, isArchived: !account.isArchived })}
>
{account.isArchived ? "Reaktivieren" : "Archivieren"}
</Button>
<Button
size="sm"
variant="destructive"
onClick={async () => {
if (confirm("Konto löschen?")) await removeAccount({ id: account._id });
}}
>
Löschen
</Button>
</div> </div>
<Button );
size="sm" })}
variant="outline"
onClick={() => updateAccount({ id: account._id, isArchived: !account.isArchived })}
>
{account.isArchived ? "Reaktivieren" : "Archivieren"}
</Button>
<Button
size="sm"
variant="destructive"
onClick={async () => {
if (confirm("Konto löschen?")) await removeAccount({ id: account._id });
}}
>
Löschen
</Button>
</div>
))}
<Separator /> <Separator />
<div className="grid gap-2 sm:grid-cols-3"> <div className="grid gap-2 sm:grid-cols-3">
<Input <Input