feat: add guarded savings agent tools
This commit is contained in:
@@ -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 -->
|
||||||
41
backlog/tasks/task-16 - Add-guarded-agent-tools.md
Normal file
41
backlog/tasks/task-16 - Add-guarded-agent-tools.md
Normal 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 -->
|
||||||
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
@@ -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
178
convex/balances.test.ts
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
34
convex/bank/balanceProviders.test.ts
Normal file
34
convex/bank/balanceProviders.test.ts
Normal 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
131
convex/bank/balances.ts
Normal 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
266
convex/savingsChatActionPlans.ts
Normal file
266
convex/savingsChatActionPlans.ts
Normal 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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"]),
|
||||||
});
|
});
|
||||||
|
|||||||
225
docs/superpowers/plans/2026-06-23-kontostandabruf-ui.md
Normal file
225
docs/superpowers/plans/2026-06-23-kontostandabruf-ui.md
Normal 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.
|
||||||
|
|
||||||
76
src/components/accounts/AccountBalanceStrip.test.tsx
Normal file
76
src/components/accounts/AccountBalanceStrip.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
177
src/components/accounts/AccountBalanceStrip.tsx
Normal file
177
src/components/accounts/AccountBalanceStrip.tsx
Normal 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();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,14 +66,33 @@ 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) => {
|
||||||
|
const liveBalance = balanceByAccountId.get(account._id);
|
||||||
|
return (
|
||||||
<div key={account._id} className="flex flex-wrap items-center gap-2 rounded-lg border p-3">
|
<div key={account._id} className="flex flex-wrap items-center gap-2 rounded-lg border p-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="font-medium">{account.name}</div>
|
<div className="font-medium">{account.name}</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{account.type} · {account.iban ?? "keine IBAN"}
|
{account.type} · {account.iban ?? "keine IBAN"}
|
||||||
{account.externalId && " · comdirect verbunden"}
|
{account.externalId && " · Bank verbunden"}
|
||||||
</div>
|
</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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -84,7 +111,8 @@ export function SettingsPage() {
|
|||||||
Löschen
|
Löschen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
Reference in New Issue
Block a user