Compare commits

..

11 Commits

Author SHA1 Message Date
Matthias
b7dae31fe1 feat: add guarded savings agent tools 2026-06-23 21:13:02 +02:00
Matthias
6e687dc9b5 Fix FinTS transaction deduplication 2026-06-23 12:37:41 +02:00
Matthias
993616c2f9 Merge category picker improvements 2026-06-23 11:46:35 +02:00
Matthias
4b0128a7ee Mark category picker task done 2026-06-23 11:46:16 +02:00
Matthias
c072ab4593 Reuse CategoryPicker in transactions page 2026-06-23 11:42:33 +02:00
Matthias
fba74ec217 Extract Comdirect CSV parsing helpers 2026-06-16 12:04:48 +02:00
Matthias
7ed9b521c4 Unify chat evidence display 2026-06-16 11:10:10 +02:00
Matthias
15bf5d2036 Fix savings chat autoscroll 2026-06-16 11:02:00 +02:00
Matthias
9f17d4d1e1 Add chat sources and citations 2026-06-16 10:58:15 +02:00
Matthias
85af9d7078 Add safe chat reasoning disclosure 2026-06-16 10:47:56 +02:00
Matthias
28d0f4f852 Add AI Elements-inspired chat primitives 2026-06-16 10:42:19 +02:00
43 changed files with 5427 additions and 245 deletions

View File

@@ -0,0 +1,50 @@
---
id: TASK-10
title: Modernize savings chat agent UI with AI Elements phases
status: In Progress
assignee: []
created_date: '2026-06-16 08:38'
updated_date: '2026-06-16 08:57'
labels: []
dependencies: []
priority: high
ordinal: 10000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement the planned AI Elements-inspired savings chat agent UI in three sequential phases: conversation/message/prompt/tool trace primitives, reasoning/work-progress disclosure, and sources/citation support.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Phase 1 replaces the basic chat surface with reusable conversation, message, prompt input, and tool trace UI primitives
- [x] #2 Phase 2 adds a safe reasoning/work-progress disclosure derived from existing tool traces, without exposing hidden chain-of-thought
- [x] #3 Phase 3 adds structured source/citation support through stored assistant metadata and visible UI affordances
- [x] #4 Each phase is covered by failing-first tests, verified after implementation, and committed separately
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Phase 1 TDD: add tested chat UI view-model helpers, reusable conversation/message/prompt/tool trace components, and integrate them into SavingsChatPage.
2. Commit Phase 1.
3. Phase 2 TDD: derive safe reasoning/work-progress summaries from tool traces and render them as collapsible disclosure.
4. Commit Phase 2.
5. Phase 3 TDD: extend chat message metadata with structured sources/citations, persist it through Convex history, and render sources/inline citations.
6. Commit Phase 3.
7. Run focused tests, lint, build, and record verification notes without closing the task until user confirmation.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Phase 1 complete: added AgentChat primitives for conversation, message rendering, prompt input, and tool trace disclosure; integrated SavingsChatPage. Verification: npx vitest src/components/chat/AgentChat.test.tsx --run, npx eslint targeted chat/page files, npm run build (Vite chunk-size warning only).
Phase 2 complete locally: added safe work-progress/reasoning disclosure derived from toolTrace result summaries, removed raw inputSummary display from the disclosure, and added active progress state while a response is pending. Verification: npx vitest src/components/chat/AgentChat.test.tsx --run (9 tests), targeted eslint, npm run build (Vite chunk-size warning only). Spec subagent review approved.
Phase 3 complete locally: added optional sources/citations metadata to chat messages, preserved it through import/list/append history flows, generated private finance sources from executed tool traces in savings chat responses, and rendered source lists plus inline citation markers in AgentChat. Verification: npx vitest src/components/chat/AgentChat.test.tsx convex/savingsChat.test.ts --run (37 tests), targeted eslint, npm run build (Vite chunk-size warning only).
Phase 3 review follow-up: addressed spec blocker by generating citation metadata from tool-derived sources and appending visible citation markers to live ask/sendMessage answers. Re-verified: npx vitest src/components/chat/AgentChat.test.tsx convex/savingsChat.test.ts --run (37 tests), targeted eslint, npm run build. Spec re-review approved.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,41 @@
---
id: TASK-11
title: Fix savings chat autoscroll after agent replies
status: In Progress
assignee: []
created_date: '2026-06-16 09:00'
updated_date: '2026-06-16 09:01'
labels: []
dependencies: []
priority: high
ordinal: 11000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Prevent the savings chat window from jumping to the first message when the agent appends a response. The chat viewport should stay scrolled to the newest message after message count or active session changes.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Chat autoscroll sets the scroll container to its bottom instead of calling scrollIntoView on an inner wrapper
- [x] #2 A regression test covers the scroll-to-bottom helper behavior
- [x] #3 Focused chat tests and build pass
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Confirm current scroll path and root cause.
2. Add failing AgentChat model test for direct scroll-container bottoming.
3. Replace scrollIntoView on inner wrapper with direct scrollTop assignment on the conversation container.
4. Run focused tests, targeted lint, and build.
5. Commit the fix; keep task In Progress until user confirms manual behavior.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Root cause: SavingsChatPage used listRef.current.lastElementChild.scrollIntoView after AgentConversation moved the ref to the scroll container. The last child is the inner wrapper, so browser alignment can jump to the top of the chat content. Fixed by setting the scroll container scrollTop to scrollHeight via scrollConversationToBottom. Verification passed: npx vitest src/components/chat/AgentChat.test.tsx --run (12 tests), targeted eslint, npm run build (existing Vite chunk-size warning only).
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,42 @@
---
id: TASK-12
title: Unify savings chat tools and sources UI
status: In Progress
assignee: []
created_date: '2026-06-16 09:07'
updated_date: '2026-06-16 09:09'
labels: []
dependencies: []
priority: high
ordinal: 12000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Replace duplicated Tools and Quellen panels in savings chat assistant messages with one combined evidence block when tool traces and generated sources describe the same data basis.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Assistant messages with both toolTrace and matching sources render a single Nachweis & Arbeitsweg block
- [x] #2 The combined block exposes citation source ids on the evidence rows so inline markers still point at the same evidence
- [x] #3 Sources-only assistant messages can still render a Quellen block for genuinely external sources
- [x] #4 Focused component tests, targeted lint, and build pass
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add regression tests for assistant messages where toolTrace and sources describe the same evidence.
2. Replace the separate Tools + Quellen rendering path with a single Nachweis & Arbeitsweg evidence panel when toolTrace exists.
3. Keep the Quellen-only fallback for messages that have sources without tool traces.
4. Verify focused component tests, targeted lint, and production build.
5. Commit the UI refinement; keep task In Progress until user confirms manual behavior.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Unified generated tool traces and generated sources in AgentChat. Assistant messages with toolTrace now render one Nachweis & Arbeitsweg disclosure and attach source ids to the evidence rows, so inline citation markers still resolve to the same evidence. Sources-only messages still render the Quellen fallback for external/document sources. Verification passed: npx vitest src/components/chat/AgentChat.test.tsx --run (13 tests), targeted eslint, npm run build (existing Vite chunk-size warning only).
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,53 @@
---
id: TASK-13
title: Improve transaction category picker
status: Done
assignee: []
created_date: '2026-06-16 10:08'
updated_date: '2026-06-23 09:45'
labels: []
dependencies: []
priority: high
ordinal: 13000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Replace the cramped inline category Select in the transactions table with a polished popover picker that preserves row height, supports search, and groups categories.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Clicking a transaction category badge opens a compact popover instead of the current narrow Select.
- [x] #2 The picker supports client-side category search and groups income, fixed expense, and variable expense categories.
- [x] #3 Long category names are truncated cleanly without increasing table row height.
- [x] #4 Selecting a category updates the transaction, closes the popover, and reports failures with a toast.
- [x] #5 The project builds successfully.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Extract category grouping/search helpers and cover them with tests
2. Replace inline category Select with a stable Popover picker
3. Wire async update handling and failure toast
4. Verify build and relevant tests
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented CategoryPicker popover with search, grouped category options, selected-state checkmark, truncation, and async update error toast.
Verification: categoryPickerModel test passes; npm run build passes.
Lint: changed files pass with one existing TanStack React Compiler warning in TransactionsPage; full npm run lint still fails on pre-existing unrelated project errors.
Manual browser verification is still open because the in-app browser redirects to /login without the user session.
Final verification: npx vitest run src/components/transactions/categoryPickerModel.test.ts passed (2 tests); npm run build passed; targeted eslint on changed files exited 0 with the existing TanStack useReactTable compiler warning only.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Shipped a reusable inline CategoryPicker popover for transaction category changes, with grouped searchable categories, truncation for long labels, async update handling, and a regression test for grouping/search behavior.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,45 @@
---
id: TASK-14
title: Plane Kontostandabruf und UI-Anzeige
status: In Progress
assignee: []
created_date: '2026-06-23 09:57'
updated_date: '2026-06-23 10:18'
labels: []
dependencies: []
priority: high
ordinal: 14000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Architektur- und Umsetzungsplan für Live-Kontostände über FinTS und comdirect REST, inklusive Persistenzmodell, Sync-Route und Dashboard/UI-Anzeige.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Der Plan beschreibt die Datenmodell-Erweiterung für aktuelle Kontostände und Abruf-Metadaten.
- [x] #2 Der Plan beschreibt getrennte Abrufpfade für FinTS und comdirect REST mit Auto/Fallback-Verhalten.
- [x] #3 Der Plan beschreibt eine Dashboard- und Konto-UI für Kontostände inklusive Lade-, Fehler- und Veraltet-Zuständen.
- [x] #4 Der Plan nennt konkrete Dateien, Tests und Validierungsschritte für eine spätere Implementierung.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Modell für Live-Kontostände ergänzen
2. Provider-Abrufpfade für comdirect REST und FinTS vereinheitlichen
3. Public Convex Route/Action für reinen Saldoabruf planen
4. Persistenz und Sync-State für Salden aktualisieren
5. Dashboard- und Konten-UI für aktuelle Salden ergänzen
6. Tests für Mapping, Persistenz, Fallback und UI-Zustände definieren
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Plan dokumentiert in docs/superpowers/plans/2026-06-23-kontostandabruf-ui.md. ctx7: FinTS-Doku gefunden (/nemiah/phpfints), comdirect API in ctx7 nicht brauchbar gefunden; Planung nutzt vorhandenen lokalen comdirect REST Client.
Umsetzung: accountBalances Schema + interne Upsert-Query ergänzt; public api.bank.balances.listLatest/refresh hinzugefügt; comdirect Balance-Mapping und Balance-only Fetch ergänzt; FinTS HKSAL Helper extrahiert; Full-Sync persistiert aktuelle Salden; Dashboard zeigt AccountBalanceStrip; Settings zeigt Live-Saldo pro Konto. Verifiziert: pnpm vitest run convex/balances.test.ts convex/bank/balanceProviders.test.ts src/components/accounts/AccountBalanceStrip.test.tsx; pnpm build; eslint auf neuen/berührten Kern-Dateien ohne Fehler. Vollständiges pnpm lint scheitert weiterhin an bestehenden unrelated Regeln in anderen Dateien.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,52 @@
---
id: TASK-15
title: Fix FinTS transaction deduplication
status: Done
assignee: []
created_date: '2026-06-23 10:22'
updated_date: '2026-06-23 10:36'
labels: []
dependencies: []
priority: high
ordinal: 15000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Repair FinTS import deduplication so placeholder MT940/CAMT references do not cause valid comdirect Girokonto transactions to be skipped.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 FinTS placeholder references such as NONREF and NOTPROVIDED are not stored as externalRef
- [x] #2 Multiple FinTS rows with placeholder references but distinct transaction content import successfully
- [x] #3 Real duplicate externalRef values are still skipped
- [x] #4 Repeated import of the same rows skips via dedupHash
- [x] #5 Focused tests cover FinTS mapping and import commit behavior
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add failing regression tests for FinTS placeholder references and import skip behavior
2. Normalize FinTS external references so placeholders are discarded
3. Add import skip diagnostics for externalRef vs dedupHash
4. Run focused tests and project verification
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented FinTS externalRef normalization for MT940/CAMT placeholder values and added import commit diagnostics split by externalRef and dedupHash skips. Verified with focused Vitest, broader Convex tests, focused ESLint, diff whitespace check, and production build.
Follow-up from live Convex logs: all 42 rows were skipped by externalRef. Read-only Convex data inspection showed FinTS externalRef values like POS 13, which are MT940 positional markers and collide across statement periods. Extended normalization to discard POS-style references and added regression coverage for legacy POS externalRef records not blocking new rows.
Follow-up from visible UI gaps: imported rows existed, but transactions.list used by_user_effmonth ordering for assignment-month mode, which sorts by effectiveMonth and creation time rather than bookingDate. Added by_user_effmonth_booking index and switched effective-basis listing to it so current month rows are ordered by booking date descending. Added regression coverage.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed FinTS transaction deduplication by discarding placeholder and POS-style external references, added import skip diagnostics, and corrected effective-month transaction listing to order by booking date. Verified with focused and related Vitest suites, focused ESLint, diff check, and production build.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,41 @@
---
id: TASK-16
title: Add guarded agent tools
status: Done
assignee: []
created_date: '2026-06-23 11:55'
updated_date: '2026-06-23 19:12'
labels: []
dependencies: []
priority: high
ordinal: 16000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Build AI agent tools for category health, category suggestions, guarded category write plans, and current-month spending preview.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Read-only agent tools cover category health, category suggestions, and current-month expense preview
- [x] #2 Write-capable agent tools create pending action plans without directly changing data
- [x] #3 Pending action plans can be listed, applied, and dismissed from the chat UI
- [x] #4 Applying action plans revalidates ownership, status, expiry, and operation limits
- [x] #5 Convex and UI tests cover the new tool and confirmation behavior
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented guarded agent tool package. Verification: npx vitest run passed (14 files, 79 tests); npm run build passed. npm run lint still fails on pre-existing unrelated files (bank/config.ts, TanAwaitDialog, fast-refresh/export warnings, SettingsPage effect), with no remaining lint error from the new action-plan module.
Post-review hardening: added tests and fixes for empty category names, duplicate category renames, stale transaction category plans, and expired plans. Re-verified: convex/savingsChat.test.ts 33 passed; full npx vitest run 81 passed; npm run build passed; npx convex codegen passed.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented guarded AI agent tools with current-month expense preview, category health/suggestion tools, pending action plans, UI apply/dismiss controls, and post-review validation hardening. Verified with full Vitest suite, build, and Convex codegen; lint remains blocked by pre-existing unrelated project issues.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,43 @@
---
id: TASK-3
title: Repariere CSV-Import-Pipeline
status: In Progress
assignee: []
created_date: '2026-06-15 18:10'
updated_date: '2026-06-15 18:13'
labels: []
dependencies: []
priority: high
ordinal: 3000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
CSV-Dateien aus Bankexporten werden im Import-Preview nicht als Daten angezeigt. Die Importstrecke soll das vorliegende Format erkennen, Header-/Metazeilen überspringen und Transaktionen ins interne Format übersetzen.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 CSV-Dateien mit Metazeilen vor der Kopfzeile werden korrekt eingelesen
- [x] #2 Deutsche Zahlenformate mit Dezimalkomma werden korrekt als Beträge geparst
- [x] #3 Import-Preview zeigt Datum, Beschreibung, Betrag, Kategorie und Monat für erkannte Transaktionen
- [x] #4 Parser-Verhalten ist durch Tests oder einen reproduzierbaren Check abgesichert
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. CSV-Import-Code und erwartetes Datenformat lokalisieren
2. Reproduzierbaren Parser-Test mit Bankexport-Metazeilen schreiben
3. Parser minimal erweitern: Header erkennen, deutsche Beträge und Datum normalisieren
4. Import-Preview gegen interne Transaktionsform prüfen
5. Tests/Checks ausführen und Task-Notizen aktualisieren
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Root cause: Parser hat nur exakt "Wertstellung (Valuta)" erkannt, der beobachtete Export nutzt "Wertstellung". Danach hätten zweistellige Daten wie 15.06.26 mit dd.MM.yyyy falsch als Jahr 0026 geparst.
Umsetzung: CSV-Parsing in testbares Modul extrahiert, Header-Erkennung toleranter gemacht, Metazeilen übersprungen und deutsche 2-/4-stellige Datumswerte normalisiert.
<!-- SECTION:NOTES:END -->

View File

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

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

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

View File

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

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

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

View File

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

View File

@@ -0,0 +1,65 @@
import { describe, expect, test } from "vitest";
import { mapFinTsTransaction } from "./fintsMap";
const salaryShift = {
enabled: true,
categoryNames: ["Gehalt & Besoldung"],
dayThreshold: 25,
};
function baseTransaction(overrides: Partial<Parameters<typeof mapFinTsTransaction>[0]> = {}) {
return {
valueDate: new Date(2026, 5, 22),
entryDate: new Date(2026, 5, 22),
amount: -12.34,
transactionType: "NMSC",
bankReference: "",
customerReference: "",
bookingText: "Kartenzahlung",
purpose: "REWE SAGT DANKE",
remoteName: "REWE",
...overrides,
};
}
describe("mapFinTsTransaction", () => {
test("does not expose MT940 placeholder references as external references", () => {
const placeholders = [
"NONREF",
"NOTPROVIDED",
"NOT PROVIDED",
"N/A",
"POS 13",
"POS-42",
"POS_1155",
"-",
"",
];
for (const placeholder of placeholders) {
const mapped = mapFinTsTransaction(
baseTransaction({
bankReference: placeholder,
customerReference: placeholder,
}),
[],
salaryShift,
);
expect(mapped.externalRef).toBeUndefined();
}
});
test("keeps real FinTS references for provider-level duplicate detection", () => {
const mapped = mapFinTsTransaction(
baseTransaction({
bankReference: "5J2C21XL0470L56V/39761",
customerReference: "NONREF",
}),
[],
salaryShift,
);
expect(mapped.externalRef).toBe("5J2C21XL0470L56V/39761");
});
});

View File

@@ -14,6 +14,54 @@ export type FintsStatementTransaction = {
customerReference?: string;
};
const FINTS_EXTERNAL_REF_PLACEHOLDERS = new Set([
"0",
"00",
"000",
"0000",
"00000",
"000000",
"0000000",
"00000000",
"000000000",
"0000000000",
"KEINE",
"N/A",
"NA",
"NONE",
"NONREF",
"NOREF",
"NOTPROVIDED",
"NULL",
"UNBEKANNT",
]);
function normalizeFinTsExternalRefValue(value?: string): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
const compact = trimmed.replace(/[\s_-]+/g, "").toUpperCase();
if (
!compact ||
FINTS_EXTERNAL_REF_PLACEHOLDERS.has(compact) ||
/^POS\d+$/.test(compact)
) {
return undefined;
}
return trimmed;
}
export function normalizeFinTsExternalRef(
bankReference?: string,
customerReference?: string,
): string | undefined {
return (
normalizeFinTsExternalRefValue(bankReference) ??
normalizeFinTsExternalRefValue(customerReference)
);
}
export function formatFinTsDate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
@@ -55,7 +103,7 @@ export function mapFinTsTransaction(
vorgang,
isPending: false,
rawText: rawText || undefined,
externalRef: tx.bankReference || tx.customerReference || undefined,
externalRef: normalizeFinTsExternalRef(tx.bankReference, tx.customerReference),
categoryName,
assignedMonth,
effectiveMonth,

View File

@@ -56,6 +56,27 @@ const pendingTanValidator = v.object({
submittedTan: v.optional(v.string()),
});
const accountBalanceStatus = v.union(
v.literal("fresh"),
v.literal("stale"),
v.literal("error"),
);
const accountBalanceValidator = v.object({
_id: v.id("accountBalances"),
_creationTime: v.number(),
userId: v.id("users"),
accountId: v.id("accounts"),
externalId: v.string(),
provider: v.union(v.literal("comdirect"), v.literal("fints")),
balance: v.number(),
currency: v.string(),
asOf: v.optional(v.string()),
fetchedAt: v.number(),
status: accountBalanceStatus,
errorMessage: v.optional(v.string()),
});
export const getBankConfig = internalQuery({
args: { userId: v.id("users") },
returns: v.union(bankConfigValidator, v.null()),
@@ -89,6 +110,33 @@ export const getPendingTan = internalQuery({
},
});
export const listLatestAccountBalances = internalQuery({
args: {
userId: v.id("users"),
includeArchived: v.optional(v.boolean()),
},
returns: v.array(accountBalanceValidator),
handler: async (ctx, args) => {
const accounts = await ctx.db
.query("accounts")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect();
const visibleAccountIds = new Set(
accounts
.filter((account) => args.includeArchived || !account.isArchived)
.map((account) => account._id),
);
const balances = await ctx.db
.query("accountBalances")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect();
return balances
.filter((balance) => visibleAccountIds.has(balance.accountId))
.sort((a, b) => b.fetchedAt - a.fetchedAt);
},
});
export const upsertBankConfig = internalMutation({
args: {
userId: v.id("users"),
@@ -286,3 +334,52 @@ export const upsertAccountFromProvider = internalMutation({
});
},
});
export const upsertAccountBalance = internalMutation({
args: {
userId: v.id("users"),
accountId: v.id("accounts"),
externalId: v.string(),
provider: v.union(v.literal("comdirect"), v.literal("fints")),
balance: v.number(),
currency: v.string(),
asOf: v.optional(v.string()),
fetchedAt: v.number(),
status: accountBalanceStatus,
errorMessage: v.optional(v.string()),
},
returns: v.id("accountBalances"),
handler: async (ctx, args) => {
const account = await ctx.db.get(args.accountId);
if (!account || account.userId !== args.userId) {
throw new Error("Konto nicht gefunden");
}
const existing = await ctx.db
.query("accountBalances")
.withIndex("by_user_account", (q) =>
q.eq("userId", args.userId).eq("accountId", args.accountId),
)
.unique();
const fields = {
userId: args.userId,
accountId: args.accountId,
externalId: args.externalId,
provider: args.provider,
balance: args.balance,
currency: args.currency,
asOf: args.asOf,
fetchedAt: args.fetchedAt,
status: args.status,
errorMessage: args.errorMessage,
};
if (existing) {
await ctx.db.patch(existing._id, fields);
return existing._id;
}
return await ctx.db.insert("accountBalances", fields);
},
});

View File

@@ -25,11 +25,17 @@ import {
} from "./fintsSession";
import { mapFinTsTransaction } from "./fintsMap";
import {
fetchComdirectBalanceData,
fetchComdirectData,
hasComdirectCredentials,
isRestFallbackError,
} from "./comdirectProvider";
import type { ImportRow, NormalizedAccount, NormalizedTransaction } from "./types";
import type {
ImportRow,
NormalizedAccount,
NormalizedBalance,
NormalizedTransaction,
} from "./types";
export const TAN_POLL_INTERVAL_MS = 4000;
export const TAN_TIMEOUT_MS = 5 * 60 * 1000;
@@ -96,6 +102,12 @@ type PendingSyncJob = {
partialTransactions?: Record<string, NormalizedTransaction[]>;
};
type BalanceRefreshError = {
accountId?: Id<"accounts">;
externalId?: string;
message: string;
};
async function waitForDecoupledTan(
ctx: ActionCtx,
userId: Id<"users">,
@@ -368,6 +380,34 @@ async function fetchStatementsAllPages(
return resp;
}
async function fetchFinTsAccountBalance(
ctx: ActionCtx,
userId: Id<"users">,
client: FinTSClient,
account: NormalizedAccount,
syncJob: PendingSyncJob,
): Promise<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(
client: FinTSClient,
account: NormalizedAccount,
@@ -408,16 +448,14 @@ async function fetchFinTsAccountData(
let balance = account.balance;
if (canFetchBalance) {
let balanceResponse = await client.getAccountBalance(account.externalId);
balanceResponse = await resolveTanResponse(
const balanceResult = await fetchFinTsAccountBalance(
ctx,
userId,
client,
balanceResponse,
"balance",
account,
syncJob,
);
balance = balanceResponse.balance?.balance ?? account.balance;
balance = balanceResult.balance;
} else {
console.warn("[fints] Konto unterstützt keinen HKSAL-Kontostandabruf", {
account: account.externalId,
@@ -639,6 +677,156 @@ async function fetchFinTsData(
return { accounts, transactionsByAccount };
}
async function fetchFinTsBalanceData(
ctx: ActionCtx,
userId: Id<"users">,
filterAccountId: Id<"accounts"> | undefined,
pin: string | undefined,
): Promise<{
accounts: NormalizedAccount[];
balances: NormalizedBalance[];
errors: BalanceRefreshError[];
}> {
const bankConfig = await ctx.runQuery(internal.bank.internal.getBankConfig, { userId });
const env = resolveFintsEnv({
blz: bankConfig?.fints.blz,
url: bankConfig?.fints.url,
login: bankConfig?.fints.login,
productId: bankConfig?.fints.productId,
productVersion: bankConfig?.fints.productVersion,
tanMethodId: bankConfig?.fints.tanMethodId,
tanMediaName: bankConfig?.fints.tanMediaName,
bankingInformationJson: bankConfig?.fints.bankingInformationJson,
pin,
});
const today = new Date().toISOString().slice(0, 10);
const syncJob: PendingSyncJob = {
from: today,
to: today,
accountId: filterAccountId,
provider: "fints",
phase: "fetch",
};
let client = createFinTsClient(env);
client = await ensureFinTsReady(client, env, ctx, userId, syncJob);
const bankAccounts = client.config.bankingInformation.upd?.bankAccounts ?? [];
const accounts = bankAccounts.map(mapBankAccount);
const balances: NormalizedBalance[] = [];
const errors: BalanceRefreshError[] = [];
for (const account of accounts) {
try {
const balance = await fetchFinTsAccountBalance(
ctx,
userId,
client,
account,
syncJob,
);
account.balance = balance.balance;
balances.push(balance);
} catch (error) {
errors.push({
externalId: account.externalId,
message: error instanceof Error ? error.message : "Kontostandabruf fehlgeschlagen",
});
}
}
await ctx.runMutation(internal.bank.internal.upsertBankConfig, {
userId,
fints: {
blz: env.blz,
url: env.url,
login: env.login,
productId: env.productId,
bankingInformationJson: JSON.stringify(client.config.bankingInformation),
tanMethodId: client.config.selectedTanMethod?.id,
tanMediaName: methodMediaName(client),
},
});
return { accounts, balances, errors };
}
async function persistBalanceResults(
ctx: ActionCtx,
userId: Id<"users">,
provider: "comdirect" | "fints",
filterAccountId: Id<"accounts"> | undefined,
accounts: NormalizedAccount[],
balances: NormalizedBalance[],
errors: BalanceRefreshError[],
): Promise<{ updatedCount: number; errors: BalanceRefreshError[] }> {
const fetchedAt = Date.now();
const accountIdMap = new Map<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(
ctx: ActionCtx,
userId: Id<"users">,
@@ -650,6 +838,7 @@ async function persistSyncResults(
transactionsByAccount: Map<string, NormalizedTransaction[]>,
): Promise<{ importedCount: number; skippedCount: number }> {
const accountIdMap = new Map<string, Id<"accounts">>();
const fetchedAt = Date.now();
for (const account of accounts) {
const convexAccountId = await ctx.runMutation(internal.bank.internal.upsertAccountFromProvider, {
userId,
@@ -660,6 +849,16 @@ async function persistSyncResults(
currency: account.currency,
});
accountIdMap.set(account.externalId, convexAccountId);
await ctx.runMutation(internal.bank.internal.upsertAccountBalance, {
userId,
accountId: convexAccountId,
externalId: account.externalId,
provider,
balance: account.balance,
currency: account.currency,
fetchedAt,
status: "fresh",
});
}
const rows: ImportRow[] = [];
@@ -850,6 +1049,100 @@ export const runSyncInternal = internalAction({
},
});
export const refreshBalancesInternal = internalAction({
args: {
userId: v.id("users"),
accountId: v.optional(v.id("accounts")),
pin: v.optional(v.string()),
},
returns: v.object({
updatedCount: v.number(),
provider: v.union(v.literal("comdirect"), v.literal("fints")),
awaitingTan: v.boolean(),
errors: v.array(
v.object({
accountId: v.optional(v.id("accounts")),
externalId: v.optional(v.string()),
message: v.string(),
}),
),
}),
handler: async (ctx, args) => {
const comdirectReady = hasComdirectCredentials();
await ctx.runMutation(internal.bank.internal.upsertBankConfig, {
userId: args.userId,
comdirectHasCredentials: comdirectReady,
});
const bankConfig = await ctx.runQuery(internal.bank.internal.getBankConfig, {
userId: args.userId,
});
const preference = bankConfig?.providerPreference ?? "auto";
const useFinTsDirect =
preference === "fints" || (preference === "auto" && !comdirectReady);
const tryFinTs = async (reason: string) => {
await logProvider(ctx, args.userId, "fints", reason);
const { accounts, balances, errors } = await fetchFinTsBalanceData(
ctx,
args.userId,
args.accountId,
args.pin,
);
const result = await persistBalanceResults(
ctx,
args.userId,
"fints",
args.accountId,
accounts,
balances,
errors,
);
return {
...result,
provider: "fints" as const,
awaitingTan: false,
};
};
if (useFinTsDirect) {
return await tryFinTs(
!comdirectReady
? "comdirect-Credentials fehlen"
: "Provider-Präferenz FinTS",
);
}
try {
await logProvider(ctx, args.userId, "comdirect", "REST-Saldenabruf");
const { accounts, balances } = await fetchComdirectBalanceData(
ctx,
args.userId,
args.accountId,
);
const result = await persistBalanceResults(
ctx,
args.userId,
"comdirect",
args.accountId,
accounts,
balances,
[],
);
return {
...result,
provider: "comdirect" as const,
awaitingTan: false,
};
} catch (error) {
if (!isRestFallbackError(error)) throw error;
const reason = error instanceof Error ? error.message : "REST-Fehler";
console.warn("[bank-balance] REST fehlgeschlagen, Fallback FinTS:", reason);
return await tryFinTs(`REST-Fallback: ${reason}`);
}
},
});
export const pollTan = internalAction({
args: {
userId: v.id("users"),

View File

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

198
convex/imports.test.ts Normal file
View File

@@ -0,0 +1,198 @@
/// <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["./imports.test.ts"];
const commitRowsInternal = makeFunctionReference<"mutation">(
"imports:commitRowsInternal",
);
async function seedImportFixture() {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Import User",
email: "import@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "giro",
iban: "DE40200411110592825400",
openingBalance: 0,
currency: "EUR",
isArchived: false,
externalId: "592825400",
});
return { userId, accountId };
});
return { t, seeded };
}
describe("imports.commitRowsInternal", () => {
test("imports multiple FinTS rows without external references when transaction content differs", async () => {
const { t, seeded } = await seedImportFixture();
const result = await t.mutation(commitRowsInternal, {
userId: seeded.userId,
filename: "fints-sync-2026-06-15-2026-06-23",
source: "fints",
accountId: seeded.accountId as Id<"accounts">,
rows: [
{
accountId: seeded.accountId as Id<"accounts">,
bookingDate: "2026-06-22",
valueDate: "2026-06-22",
description: "REWE",
amount: -12.34,
vorgang: "NMSC",
isPending: false,
rawText: "REWE SAGT DANKE",
},
{
accountId: seeded.accountId as Id<"accounts">,
bookingDate: "2026-06-22",
valueDate: "2026-06-22",
description: "ALDI",
amount: -23.45,
vorgang: "NMSC",
isPending: false,
rawText: "ALDI SAGT DANKE",
},
],
});
const rows = await t.run(async (ctx) =>
ctx.db
.query("transactions")
.withIndex("by_user_account", (q) =>
q.eq("userId", seeded.userId).eq("accountId", seeded.accountId),
)
.collect(),
);
expect(result.importedCount).toBe(2);
expect(result.skippedCount).toBe(0);
expect(rows.map((row) => row.description).sort()).toEqual(["ALDI", "REWE"]);
expect(rows.every((row) => row.externalRef === undefined)).toBe(true);
});
test("does not let a legacy positional external reference block new FinTS rows without external references", async () => {
const { t, seeded } = await seedImportFixture();
await t.run(async (ctx) => {
await ctx.db.insert("transactions", {
userId: seeded.userId,
accountId: seeded.accountId,
bookingDate: "2024-09-30",
valueDate: "2024-09-30",
description: "Old positional transaction",
amount: -9.99,
isPending: false,
effectiveMonth: "2024-09",
dedupHash: "legacy-dedup",
externalRef: "592825400:POS 13",
});
});
const result = await t.mutation(commitRowsInternal, {
userId: seeded.userId,
filename: "fints-sync-2026-06-15-2026-06-23",
source: "fints",
accountId: seeded.accountId as Id<"accounts">,
rows: [
{
accountId: seeded.accountId as Id<"accounts">,
bookingDate: "2026-06-22",
valueDate: "2026-06-22",
description: "HEM TANKSTELLE",
amount: -62.01,
vorgang: "NMSC",
isPending: false,
rawText: "HEM TANKSTELLE",
},
],
});
expect(result.importedCount).toBe(1);
expect(result.skippedCount).toBe(0);
});
test("still skips real duplicate external references", async () => {
const { t, seeded } = await seedImportFixture();
const row = {
accountId: seeded.accountId as Id<"accounts">,
bookingDate: "2026-06-22",
valueDate: "2026-06-22",
description: "Kartenzahlung",
amount: -12.34,
vorgang: "NMSC",
isPending: false,
rawText: "Kartenzahlung",
externalRef: "592825400:5J2C21XL0470L56V/39761",
};
const first = await t.mutation(commitRowsInternal, {
userId: seeded.userId,
filename: "fints-sync-first",
source: "fints",
accountId: seeded.accountId as Id<"accounts">,
rows: [row],
});
const second = await t.mutation(commitRowsInternal, {
userId: seeded.userId,
filename: "fints-sync-second",
source: "fints",
accountId: seeded.accountId as Id<"accounts">,
rows: [{ ...row, description: "Different text same bank ref", amount: -99 }],
});
expect(first.importedCount).toBe(1);
expect(first.skippedCount).toBe(0);
expect(second.importedCount).toBe(0);
expect(second.skippedCount).toBe(1);
});
test("skips repeated rows without external references via dedup hash", async () => {
const { t, seeded } = await seedImportFixture();
const row = {
accountId: seeded.accountId as Id<"accounts">,
bookingDate: "2026-06-22",
valueDate: "2026-06-22",
description: "REWE",
amount: -12.34,
vorgang: "NMSC",
isPending: false,
rawText: "REWE SAGT DANKE",
};
const first = await t.mutation(commitRowsInternal, {
userId: seeded.userId,
filename: "fints-sync-first",
source: "fints",
accountId: seeded.accountId as Id<"accounts">,
rows: [row],
});
const second = await t.mutation(commitRowsInternal, {
userId: seeded.userId,
filename: "fints-sync-second",
source: "fints",
accountId: seeded.accountId as Id<"accounts">,
rows: [row],
});
expect(first.importedCount).toBe(1);
expect(first.skippedCount).toBe(0);
expect(second.importedCount).toBe(0);
expect(second.skippedCount).toBe(1);
});
});

View File

@@ -132,6 +132,8 @@ async function commitRowsHandler(
let importedCount = 0;
let skippedCount = 0;
let skippedByExternalRef = 0;
let skippedByDedupHash = 0;
for (const row of args.rows) {
if (row.externalRef) {
@@ -143,6 +145,7 @@ async function commitRowsHandler(
.unique();
if (existingRef) {
skippedCount++;
skippedByExternalRef++;
continue;
}
}
@@ -162,6 +165,7 @@ async function commitRowsHandler(
.unique();
if (existingDedup) {
skippedCount++;
skippedByDedupHash++;
continue;
}
@@ -204,5 +208,14 @@ async function commitRowsHandler(
status: "completed",
});
console.info("[imports] Commit-Ergebnis", {
source: args.source,
rowCount: args.rows.length,
importedCount,
skippedCount,
skippedByExternalRef,
skippedByDedupHash,
});
return { importId, importedCount, skippedCount };
}

View File

@@ -25,12 +25,19 @@ vi.mock("ai", async (importOriginal) => {
compare_periods: { execute: (input: unknown) => Promise<unknown> };
forecast_fixed_costs: { 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 summaryInput = { from: "2026-02-01", to: "2026-02-28" };
const previewInput = { today: "2026-02-20" };
const transactionOutput = await options.tools.get_transactions.execute(transactionInput);
const summaryOutput = await options.tools.summarize_spending.execute(summaryInput);
const previewOutput = await options.tools.preview_month_end_spending.execute(previewInput);
return {
text: "Agenten-Antwort",
@@ -47,6 +54,11 @@ vi.mock("ai", async (importOriginal) => {
input: summaryInput,
output: summaryOutput,
},
{
toolName: "preview_month_end_spending",
input: previewInput,
output: previewOutput,
},
],
},
],
@@ -67,6 +79,16 @@ const historyApi = {
};
const sendMessageAction = makeFunctionReference<"action">("savingsChat:sendMessage");
const previewMonthEndSpendingTool = makeFunctionReference<"query">("savingsChat:previewMonthEndSpendingTool");
const inspectCategoryHealthTool = makeFunctionReference<"query">("savingsChat:inspectCategoryHealthTool");
const suggestCategoryForTransactionsTool = makeFunctionReference<"query">("savingsChat:suggestCategoryForTransactionsTool");
const proposeBulkRecategoryTool = makeFunctionReference<"mutation">("savingsChat:proposeBulkRecategoryTool");
const proposeCategoryChangesTool = makeFunctionReference<"mutation">("savingsChat:proposeCategoryChangesTool");
const actionPlanApi = {
listPendingActionPlans: makeFunctionReference<"query">("savingsChatActionPlans:listPendingActionPlans"),
applyActionPlan: makeFunctionReference<"mutation">("savingsChatActionPlans:applyActionPlan"),
dismissActionPlan: makeFunctionReference<"mutation">("savingsChatActionPlans:dismissActionPlan"),
};
const paginationOpts = { cursor: null, numItems: 20 };
@@ -253,6 +275,14 @@ describe("savingsChatHistory", () => {
resultSummary: "2 Umsätze, Saldo 100.00€, 1 Kategorien",
},
],
sources: [
{
id: "tool-1",
title: "summarize_spending",
description: "2 Umsätze, Saldo 100.00€, 1 Kategorien",
},
],
citations: [{ marker: "1", sourceId: "tool-1" }],
},
],
});
@@ -282,6 +312,14 @@ describe("savingsChatHistory", () => {
resultSummary: "2 Umsätze, Saldo 100.00€, 1 Kategorien",
},
],
sources: [
{
id: "tool-1",
title: "summarize_spending",
description: "2 Umsätze, Saldo 100.00€, 1 Kategorien",
},
],
citations: [{ marker: "1", sourceId: "tool-1" }],
},
]);
@@ -438,10 +476,33 @@ describe("savingsChat.sendMessage", () => {
to: "2026-02-28",
accountId: seeded.accountId as Id<"accounts">,
basis: "effective",
today: "2026-02-20",
});
expect(result.answer).toBe("Agenten-Antwort");
expect(result.toolTrace).toHaveLength(2);
expect(result.answer).toBe("Agenten-Antwort [1] [2] [3]");
expect(result.toolTrace).toHaveLength(3);
expect(result.sources).toEqual([
{
id: "tool-1",
title: "get_transactions",
description: "2 Umsätze, Saldo 2880.00€, vollständig",
},
{
id: "tool-2",
title: "summarize_spending",
description: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
},
{
id: "tool-3",
title: "preview_month_end_spending",
description: "Monatsvorschau 2026-02: erwartet -120.00€ Ausgaben, Rest 0.00€",
},
]);
expect(result.citations).toEqual([
{ marker: "1", sourceId: "tool-1" },
{ marker: "2", sourceId: "tool-2" },
{ marker: "3", sourceId: "tool-3" },
]);
const generateCall = vi.mocked(generateText).mock.calls[0][0] as {
messages: Array<{ role: string; content: string }>;
@@ -461,7 +522,7 @@ describe("savingsChat.sendMessage", () => {
{ role: "user", content: "Wie sieht Februar aus?" },
{
role: "assistant",
content: "Agenten-Antwort",
content: "Agenten-Antwort [1] [2] [3]",
toolTrace: [
{
name: "get_transactions",
@@ -473,6 +534,33 @@ describe("savingsChat.sendMessage", () => {
inputSummary: "2026-02-01 bis 2026-02-28",
resultSummary: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
},
{
name: "preview_month_end_spending",
inputSummary: "Monatsvorschau für 2026-02-20",
resultSummary: "Monatsvorschau 2026-02: erwartet -120.00€ Ausgaben, Rest 0.00€",
},
],
sources: [
{
id: "tool-1",
title: "get_transactions",
description: "2 Umsätze, Saldo 2880.00€, vollständig",
},
{
id: "tool-2",
title: "summarize_spending",
description: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
},
{
id: "tool-3",
title: "preview_month_end_spending",
description: "Monatsvorschau 2026-02: erwartet -120.00€ Ausgaben, Rest 0.00€",
},
],
citations: [
{ marker: "1", sourceId: "tool-1" },
{ marker: "2", sourceId: "tool-2" },
{ marker: "3", sourceId: "tool-3" },
],
},
]);
@@ -519,6 +607,7 @@ describe("savingsChat.sendMessage", () => {
from: "2026-02-01",
to: "2026-02-28",
basis: "effective",
today: "2026-02-20",
}),
).rejects.toThrow("KI-Anfrage fehlgeschlagen");
@@ -1380,9 +1469,10 @@ describe("savingsChat read-only agent tools", () => {
to: "2026-02-28",
accountId: seeded.accountId as Id<"accounts">,
basis: "effective",
today: "2026-02-20",
});
expect(result.answer).toBe("Agenten-Antwort");
expect(result.answer).toBe("Agenten-Antwort [1] [2] [3]");
expect(result.model).toBe("gpt-5.4-mini");
expect(result.usedTransactions).toBe(2);
expect(result.usedBalance).toEqual({ income: 3000, expenses: -120, balance: 2880 });
@@ -1397,6 +1487,33 @@ describe("savingsChat read-only agent tools", () => {
inputSummary: "2026-02-01 bis 2026-02-28",
resultSummary: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
},
{
name: "preview_month_end_spending",
inputSummary: "Monatsvorschau für 2026-02-20",
resultSummary: "Monatsvorschau 2026-02: erwartet -120.00€ Ausgaben, Rest 0.00€",
},
]);
expect(result.sources).toEqual([
{
id: "tool-1",
title: "get_transactions",
description: "2 Umsätze, Saldo 2880.00€, vollständig",
},
{
id: "tool-2",
title: "summarize_spending",
description: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
},
{
id: "tool-3",
title: "preview_month_end_spending",
description: "Monatsvorschau 2026-02: erwartet -120.00€ Ausgaben, Rest 0.00€",
},
]);
expect(result.citations).toEqual([
{ marker: "1", sourceId: "tool-1" },
{ marker: "2", sourceId: "tool-2" },
{ marker: "3", sourceId: "tool-3" },
]);
expect(JSON.stringify(result.toolTrace)).not.toContain("RAW PAYLOAD");
expect(JSON.stringify(result.toolTrace)).not.toContain("private note");
@@ -1415,6 +1532,11 @@ describe("savingsChat read-only agent tools", () => {
compare_periods: expect.any(Object),
forecast_fixed_costs: expect.any(Object),
explain_savings_rate: expect.any(Object),
inspect_category_health: expect.any(Object),
suggest_category_for_transactions: expect.any(Object),
preview_month_end_spending: expect.any(Object),
propose_bulk_recategory: expect.any(Object),
propose_category_changes: expect.any(Object),
}),
stopWhen: expect.any(Function),
}),
@@ -1580,6 +1702,665 @@ describe("savingsChat read-only agent tools", () => {
expect(JSON.stringify(result)).not.toContain("PRIVATE NOTE SHOULD NOT LEAK");
});
test("previewMonthEndSpendingTool forecasts remaining current-month expenses for the selected account", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Preview User",
email: "preview@example.com",
});
const giroAccountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const otherAccountId = await ctx.db.insert("accounts", {
userId,
name: "Tagesgeld",
type: "savings",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const rentId = await ctx.db.insert("categories", {
userId,
name: "Miete",
kind: "ausgabe",
block: "wiederkehrend",
color: "#64748b",
sortOrder: 1,
isSystem: false,
});
const groceriesId = await ctx.db.insert("categories", {
userId,
name: "Lebensmittel",
kind: "ausgabe",
block: "variabel",
color: "#22c55e",
sortOrder: 2,
isSystem: false,
});
const salaryId = await ctx.db.insert("categories", {
userId,
name: "Gehalt",
kind: "einnahme",
color: "#0ea5e9",
sortOrder: 3,
isSystem: false,
});
for (const tx of [
{ date: "2026-04-28", description: "Miete April", amount: -1000, categoryId: rentId },
{ date: "2026-05-28", description: "Miete Mai", amount: -1000, categoryId: rentId },
{ date: "2026-04-05", description: "Supermarkt April", amount: -120, categoryId: groceriesId },
{ date: "2026-05-05", description: "Supermarkt Mai", amount: -150, categoryId: groceriesId },
{ date: "2026-06-01", description: "Gehalt Juni", amount: 3000, categoryId: salaryId },
{ date: "2026-06-05", description: "Supermarkt Juni", amount: -100, categoryId: groceriesId },
{ date: "2026-06-10", description: "Drogerie Juni", amount: -50, categoryId: groceriesId },
]) {
await ctx.db.insert("transactions", {
userId,
accountId: giroAccountId,
categoryId: tx.categoryId,
bookingDate: tx.date,
valueDate: tx.date,
description: tx.description,
amount: tx.amount,
isPending: false,
effectiveMonth: tx.date.slice(0, 7),
});
}
await ctx.db.insert("transactions", {
userId,
accountId: otherAccountId,
bookingDate: "2026-06-07",
valueDate: "2026-06-07",
description: "Other account should not affect preview",
amount: -999,
isPending: false,
effectiveMonth: "2026-06",
});
return { userId, giroAccountId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|preview`,
tokenIdentifier: `test:${seeded.userId}`,
});
const result = await asUser.query(previewMonthEndSpendingTool, {
scope: {
from: "2026-01-01",
to: "2026-12-31",
accountId: seeded.giroAccountId as Id<"accounts">,
basis: "booking",
},
today: "2026-06-10",
});
expect(result).toMatchObject({
month: "2026-06",
today: "2026-06-10",
accountName: "Girokonto",
actualIncome: 3000,
actualExpenses: -150,
predictedRemainingFixed: -1000,
predictedRemainingVariable: -300,
predictedRemainingExpenses: -1300,
expectedMonthExpenses: -1450,
projectedMonthEndBalance: 1550,
confidence: "medium",
});
expect(result.drivers.map((driver: { label: string }) => driver.label)).toEqual([
"Noch erwartete Fixkosten: Miete",
"Variable Ausgaben auf Basis des laufenden Monats",
]);
});
test("inspectCategoryHealthTool reports uncategorized usage, unused categories, and similar names", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Category Health User",
email: "category-health@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const groceriesId = await ctx.db.insert("categories", {
userId,
name: "Lebensmittel",
kind: "ausgabe",
block: "variabel",
color: "#22c55e",
sortOrder: 1,
isSystem: false,
});
await ctx.db.insert("categories", {
userId,
name: "Lebensmittel & Supermarkt",
kind: "ausgabe",
block: "variabel",
color: "#16a34a",
sortOrder: 2,
isSystem: false,
});
await ctx.db.insert("categories", {
userId,
name: "Ungenutzt",
kind: "ausgabe",
block: "variabel",
color: "#f97316",
sortOrder: 3,
isSystem: false,
});
await ctx.db.insert("transactions", {
userId,
accountId,
categoryId: groceriesId,
bookingDate: "2026-06-01",
description: "Supermarkt",
amount: -40,
isPending: false,
effectiveMonth: "2026-06",
});
await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-06-02",
description: "Mystery Shop",
counterparty: "Mystery GmbH",
amount: -30,
isPending: false,
effectiveMonth: "2026-06",
});
return { userId, accountId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|health`,
tokenIdentifier: `test:${seeded.userId}`,
});
const result = await asUser.query(inspectCategoryHealthTool, {
scope: {
from: "2026-06-01",
to: "2026-06-30",
accountId: seeded.accountId as Id<"accounts">,
basis: "booking",
},
});
expect(result.totalCategories).toBe(3);
expect(result.uncategorizedCount).toBe(1);
expect(result.uncategorizedAmount).toBe(-30);
expect(result.topUncategorizedCounterparties).toEqual([
{ name: "Mystery GmbH", count: 1, amount: -30 },
]);
expect(result.unusedCategories.map((category: { name: string }) => category.name)).toContain("Ungenutzt");
expect(result.similarCategories).toEqual([
{ names: ["Lebensmittel", "Lebensmittel & Supermarkt"], reason: "Ähnliche Kategoriebegriffe" },
]);
});
test("suggestCategoryForTransactionsTool proposes categories without leaking raw private fields", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Suggestion User",
email: "suggestion@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const subscriptionsId = await ctx.db.insert("categories", {
userId,
name: "Abos",
kind: "ausgabe",
block: "wiederkehrend",
color: "#a855f7",
sortOrder: 1,
isSystem: false,
});
await ctx.db.insert("transactions", {
userId,
accountId,
categoryId: subscriptionsId,
bookingDate: "2026-05-03",
description: "Netflix",
counterparty: "Netflix",
amount: -15,
isPending: false,
effectiveMonth: "2026-05",
rawText: "RAW SHOULD NOT LEAK",
notes: "PRIVATE NOTE SHOULD NOT LEAK",
});
await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-06-03",
description: "Netflix",
counterparty: "Netflix",
amount: -16,
isPending: false,
effectiveMonth: "2026-06",
rawText: "RAW SHOULD NOT LEAK",
notes: "PRIVATE NOTE SHOULD NOT LEAK",
});
return { userId, accountId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|suggestion`,
tokenIdentifier: `test:${seeded.userId}`,
});
const result = await asUser.query(suggestCategoryForTransactionsTool, {
scope: {
from: "2026-06-01",
to: "2026-06-30",
accountId: seeded.accountId as Id<"accounts">,
basis: "booking",
},
search: "Netflix",
onlyUncategorized: true,
limit: 10,
});
expect(result.suggestions).toEqual([
{
categoryName: "Abos",
confidence: "high",
matchCount: 1,
amount: -16,
examples: [
{
date: "2026-06-03",
description: "Netflix",
counterparty: "Netflix",
amount: -16,
accountName: "Girokonto",
},
],
reason: "Ähnliche frühere Umsätze waren bereits dieser Kategorie zugeordnet.",
},
]);
expect(JSON.stringify(result)).not.toContain("RAW SHOULD NOT LEAK");
expect(JSON.stringify(result)).not.toContain("PRIVATE NOTE SHOULD NOT LEAK");
});
test("proposeBulkRecategoryTool creates a pending plan and applyActionPlan revalidates before changing transactions", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Plan User",
email: "plan@example.com",
});
const otherUserId = await ctx.db.insert("users", {
name: "Other Plan User",
email: "other-plan@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const categoryId = await ctx.db.insert("categories", {
userId,
name: "Abos",
kind: "ausgabe",
block: "wiederkehrend",
color: "#a855f7",
sortOrder: 1,
isSystem: false,
});
const txId = await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-06-03",
description: "Netflix",
counterparty: "Netflix",
amount: -16,
isPending: false,
effectiveMonth: "2026-06",
});
return { userId, otherUserId, accountId, categoryId, txId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|plan`,
tokenIdentifier: `test:${seeded.userId}`,
});
const asOtherUser = t.withIdentity({
subject: `${seeded.otherUserId}|plan`,
tokenIdentifier: `test:${seeded.otherUserId}`,
});
const session = await asUser.mutation(historyApi.createSession, { title: "Plan Chat" });
const proposed = await asUser.mutation(proposeBulkRecategoryTool, {
scope: {
from: "2026-06-01",
to: "2026-06-30",
accountId: seeded.accountId as Id<"accounts">,
basis: "booking",
},
sessionId: session.sessionId,
targetCategoryName: "Abos",
search: "Netflix",
onlyUncategorized: true,
limit: 10,
now: 1_800_000_000_000,
});
expect(proposed).toMatchObject({
kind: "bulk_recategory",
status: "pending",
affectedCount: 1,
summary: "1 Umsatz zur Kategorie Abos zuordnen",
});
let tx = await t.run(async (ctx) => await ctx.db.get(seeded.txId));
expect(tx?.categoryId).toBeUndefined();
const otherPlans = await asOtherUser.query(actionPlanApi.listPendingActionPlans, {
sessionId: session.sessionId,
});
expect(otherPlans).toEqual([]);
await expect(
asOtherUser.mutation(actionPlanApi.applyActionPlan, {
planId: proposed.planId,
now: 1_800_000_000_001,
}),
).rejects.toThrow("Nicht autorisiert");
const pending = await asUser.query(actionPlanApi.listPendingActionPlans, {
sessionId: session.sessionId,
});
expect(pending).toHaveLength(1);
expect(pending[0]).toMatchObject({
_id: proposed.planId,
kind: "bulk_recategory",
affectedCount: 1,
previewRows: [
{
description: "Netflix",
amount: -16,
currentCategoryName: "Ohne Kategorie",
targetCategoryName: "Abos",
},
],
});
const applied = await asUser.mutation(actionPlanApi.applyActionPlan, {
planId: proposed.planId,
now: 1_800_000_000_002,
});
expect(applied).toEqual({
appliedCount: 1,
skippedCount: 0,
status: "applied",
summary: "1 Änderung angewendet, 0 übersprungen",
});
tx = await t.run(async (ctx) => await ctx.db.get(seeded.txId));
expect(tx?.categoryId).toBe(seeded.categoryId);
});
test("proposeCategoryChangesTool applies category creates and updates but does not support deletes", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Category Plan User",
email: "category-plan@example.com",
});
const subscriptionId = await ctx.db.insert("categories", {
userId,
name: "Abos",
kind: "ausgabe",
block: "variabel",
color: "#a855f7",
sortOrder: 1,
isSystem: false,
});
return { userId, subscriptionId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|category-plan`,
tokenIdentifier: `test:${seeded.userId}`,
});
const session = await asUser.mutation(historyApi.createSession, { title: "Category Plan Chat" });
const proposed = await asUser.mutation(proposeCategoryChangesTool, {
sessionId: session.sessionId,
creates: [
{
name: "Versicherungen",
kind: "ausgabe",
block: "wiederkehrend",
color: "#0f766e",
sortOrder: 10,
},
],
updates: [
{
name: "Abos",
block: "wiederkehrend",
},
],
now: 1_800_000_000_000,
});
expect(proposed).toMatchObject({
kind: "category_changes",
status: "pending",
affectedCount: 2,
summary: "2 Kategorieänderungen vorbereiten",
});
const applied = await asUser.mutation(actionPlanApi.applyActionPlan, {
planId: proposed.planId,
now: 1_800_000_000_001,
});
expect(applied.appliedCount).toBe(2);
const categories = await asUser.query(api.categories.list, {});
expect(categories.find((category) => category.name === "Versicherungen")).toMatchObject({
kind: "ausgabe",
block: "wiederkehrend",
isSystem: false,
});
expect(categories.find((category) => category.name === "Abos")).toMatchObject({
block: "wiederkehrend",
});
});
test("category action plans skip empty names and duplicate renames", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Guarded Category User",
email: "guarded-category@example.com",
});
await ctx.db.insert("categories", {
userId,
name: "Abos",
kind: "ausgabe",
block: "wiederkehrend",
color: "#a855f7",
sortOrder: 1,
isSystem: false,
});
await ctx.db.insert("categories", {
userId,
name: "Versicherungen",
kind: "ausgabe",
block: "wiederkehrend",
color: "#0f766e",
sortOrder: 2,
isSystem: false,
});
return { userId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|guarded-category`,
tokenIdentifier: `test:${seeded.userId}`,
});
const session = await asUser.mutation(historyApi.createSession, { title: "Guarded Category Chat" });
await expect(
asUser.mutation(proposeCategoryChangesTool, {
sessionId: session.sessionId,
creates: [
{
name: " ",
kind: "ausgabe",
block: "variabel",
color: "#f97316",
sortOrder: 3,
},
],
updates: [{ name: "Abos", newName: " " }],
now: 1_800_000_000_000,
}),
).rejects.toThrow("Keine Kategorieänderungen vorbereitet");
const proposed = await asUser.mutation(proposeCategoryChangesTool, {
sessionId: session.sessionId,
updates: [{ name: "Abos", newName: "Versicherungen" }],
now: 1_800_000_000_000,
});
const applied = await asUser.mutation(actionPlanApi.applyActionPlan, {
planId: proposed.planId,
now: 1_800_000_000_001,
});
expect(applied).toEqual({
appliedCount: 0,
skippedCount: 1,
status: "applied",
summary: "0 Änderungen angewendet, 1 übersprungen",
});
const categories = await asUser.query(api.categories.list, {});
expect(categories.map((category) => category.name).sort()).toEqual(["Abos", "Versicherungen"]);
});
test("applyActionPlan skips stale category assignments and expires old plans", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Stale Plan User",
email: "stale-plan@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const subscriptionsId = await ctx.db.insert("categories", {
userId,
name: "Abos",
kind: "ausgabe",
block: "wiederkehrend",
color: "#a855f7",
sortOrder: 1,
isSystem: false,
});
const groceriesId = await ctx.db.insert("categories", {
userId,
name: "Lebensmittel",
kind: "ausgabe",
block: "variabel",
color: "#22c55e",
sortOrder: 2,
isSystem: false,
});
const txId = await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-06-03",
description: "Netflix",
counterparty: "Netflix",
amount: -16,
isPending: false,
effectiveMonth: "2026-06",
});
return { userId, accountId, subscriptionsId, groceriesId, txId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|stale-plan`,
tokenIdentifier: `test:${seeded.userId}`,
});
const session = await asUser.mutation(historyApi.createSession, { title: "Stale Plan Chat" });
const proposed = await asUser.mutation(proposeBulkRecategoryTool, {
scope: {
from: "2026-06-01",
to: "2026-06-30",
accountId: seeded.accountId as Id<"accounts">,
basis: "booking",
},
sessionId: session.sessionId,
targetCategoryName: "Abos",
search: "Netflix",
onlyUncategorized: true,
limit: 10,
now: 1_800_000_000_000,
});
await t.run(async (ctx) => {
await ctx.db.patch(seeded.txId, { categoryId: seeded.groceriesId });
});
const staleApplied = await asUser.mutation(actionPlanApi.applyActionPlan, {
planId: proposed.planId,
now: 1_800_000_000_001,
});
expect(staleApplied).toEqual({
appliedCount: 0,
skippedCount: 1,
status: "applied",
summary: "0 Änderungen angewendet, 1 übersprungen",
});
const tx = await t.run(async (ctx) => await ctx.db.get(seeded.txId));
expect(tx?.categoryId).toBe(seeded.groceriesId);
const expired = await asUser.mutation(proposeBulkRecategoryTool, {
scope: {
from: "2026-06-01",
to: "2026-06-30",
accountId: seeded.accountId as Id<"accounts">,
basis: "booking",
},
sessionId: session.sessionId,
targetCategoryName: "Abos",
search: "Netflix",
limit: 10,
now: 1_800_000_000_000,
});
const expiredApplied = await asUser.mutation(actionPlanApi.applyActionPlan, {
planId: expired.planId,
now: 1_800_086_400_001,
});
expect(expiredApplied).toEqual({
appliedCount: 0,
skippedCount: 1,
status: "expired",
summary: "Vorschlag ist abgelaufen",
});
});
test("comparePeriodsTool computes totals and category deltas", async () => {
const { asUser, seeded } = await seedSavingsInsightFixture();

View File

@@ -1,4 +1,4 @@
import { action, internalQuery, query } from "./_generated/server";
import { action, internalMutation, internalQuery, query } from "./_generated/server";
import { v } from "convex/values";
import { generateText, stepCountIs, tool } from "ai";
import { openai } from "@ai-sdk/openai";
@@ -7,7 +7,7 @@ import { z } from "zod";
import { addMonthsToMonthKey, bookingMonth, monthKeyFromBasis } from "./lib/month";
import { requireUserId } from "./lib/helpers";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx, QueryCtx } from "./_generated/server";
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
type ChatRole = "user" | "assistant";
type ChatMessage = { role: ChatRole; content: string };
@@ -46,8 +46,12 @@ type ChatAskResult = {
usedTransactions: number;
usedBalance: { income: number; expenses: number; balance: number };
toolTrace: ToolTrace[];
sources: ChatSource[];
citations: ChatCitation[];
};
type ToolTrace = { name: string; inputSummary: string; resultSummary: string };
type ChatSource = { id: string; title: string; description?: string };
type ChatCitation = { marker: string; sourceId: string };
type TransactionTypeFilter = "income" | "expense";
type CategoryFilterStatus = "resolved" | "unresolved" | "ambiguous";
type CategoryFilterDiagnostic = {
@@ -94,16 +98,19 @@ function formatEuro(value: number): string {
return `${value.toFixed(2)}`;
}
function buildSystemPrompt(context: { from: string; to: string; basis: string; accountName?: string }) {
function buildSystemPrompt(context: { from: string; to: string; basis: string; today: string; accountName?: string }) {
return [
"Du bist ein präziser Finanz-Chat-Assistent für Privatanwender.",
"Nutze ausschließlich die bereitgestellten Werkzeuge und deren Ergebnisse als Finanzkontext.",
"Rufe Werkzeuge auf, wenn du Umsätze, Zusammenfassungen oder Prognosen brauchst; erfinde keine Beträge.",
"Nutze get_categories ohne Kategorie-Filter, wenn du vorhandene Kategorienamen prüfen musst.",
"Wenn ein Werkzeug categoryFilter-Diagnosen mit unresolved oder ambiguous liefert, nenne die Kategorie als nicht sicher gefunden und verwende keine gesicherte 0-Euro-Aussage.",
"Monatsvorschauen sind Schätzungen; kennzeichne sie klar als Prognose.",
"Wenn du einen Änderungs-Vorschlag vorbereitest, sage ausdrücklich, dass er erst über den Button in der Oberfläche angewendet wird.",
"Antworte auf Deutsch, kurz und handlungsorientiert.",
`Zeitraum: ${context.from} bis ${context.to}.`,
`Basis: ${context.basis}.`,
`Heute: ${context.today}.`,
context.accountName ? `Konto: ${context.accountName}.` : "Konto: Alle Konten.",
"Wenn eine Aussage nur grob geschätzt werden kann, kennzeichne sie als Schätzung.",
"Nenne keine internen IDs und keine Rohdatenfelder.",
@@ -131,7 +138,7 @@ function sortTransactionsForContext(
}
async function loadMatchingTransactions(
ctx: QueryCtx,
ctx: QueryCtx | MutationCtx,
userId: Id<"users">,
args: ChatContextArgs,
): Promise<Doc<"transactions">[]> {
@@ -287,6 +294,15 @@ const toolTraceValidator = v.object({
inputSummary: v.string(),
resultSummary: v.string(),
});
const sourceValidator = v.object({
id: v.string(),
title: v.string(),
description: v.optional(v.string()),
});
const citationValidator = v.object({
marker: v.string(),
sourceId: v.string(),
});
const toolScopeValidator = v.object(contextArgsValidator);
@@ -445,6 +461,60 @@ const savingsLeverValidator = v.object({
monthlyImpact: v.number(),
});
const previewDriverValidator = v.object({
label: v.string(),
amount: v.number(),
kind: v.union(v.literal("fixed"), v.literal("variable"), v.literal("actual")),
});
const categoryHealthCategoryValidator = v.object({
name: v.string(),
kind: v.union(v.literal("einnahme"), v.literal("ausgabe")),
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
isSystem: v.boolean(),
});
const similarCategoryValidator = v.object({
names: v.array(v.string()),
reason: v.string(),
});
const categorySuggestionExampleValidator = v.object({
date: v.string(),
description: v.string(),
counterparty: v.optional(v.string()),
amount: v.number(),
accountName: v.string(),
});
const categorySuggestionValidator = v.object({
categoryName: v.string(),
confidence: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
matchCount: v.number(),
amount: v.number(),
examples: v.array(categorySuggestionExampleValidator),
reason: v.string(),
});
const proposalPreviewRowValidator = v.object({
date: v.optional(v.string()),
description: v.string(),
amount: v.optional(v.number()),
currentCategoryName: v.optional(v.string()),
targetCategoryName: v.optional(v.string()),
action: v.optional(v.string()),
});
const proposalResultValidator = v.object({
planId: v.id("agentActionPlans"),
kind: v.union(v.literal("bulk_recategory"), v.literal("category_changes")),
status: v.literal("pending"),
summary: v.string(),
affectedCount: v.number(),
expiresAt: v.number(),
previewRows: v.array(proposalPreviewRowValidator),
});
export const getContext = query({
args: contextArgsValidator,
returns: contextSummaryValidator,
@@ -483,7 +553,7 @@ function normalizeToolRange(scope: AgentToolScope, from?: string, to?: string) {
return range;
}
async function loadNameMaps(ctx: QueryCtx, userId: Id<"users">) {
async function loadNameMaps(ctx: QueryCtx | MutationCtx, userId: Id<"users">) {
const categories = await ctx.db
.query("categories")
.withIndex("by_user", (index) => index.eq("userId", userId))
@@ -680,7 +750,7 @@ function transactionMatchesToolFilters(
}
async function buildToolTransactionContext(
ctx: QueryCtx,
ctx: QueryCtx | MutationCtx,
userId: Id<"users">,
args: TransactionToolArgs,
): Promise<ToolTransactionContext> {
@@ -837,6 +907,27 @@ function dateForTransaction(tx: Doc<"transactions">) {
return tx.valueDate || tx.bookingDate || tx.effectiveMonth || "n/a";
}
function daysInMonth(month: string) {
const [year, monthNumber] = month.split("-").map(Number);
return new Date(year, monthNumber, 0).getDate();
}
function monthStart(month: string) {
return `${month}-01`;
}
function monthEnd(month: string) {
return `${month}-${String(daysInMonth(month)).padStart(2, "0")}`;
}
function dayOfMonth(date: string) {
return Number(date.slice(8, 10));
}
function previousMonthStart(month: string, monthsBack: number) {
return monthStart(addMonthsToMonthKey(month, -monthsBack));
}
function monthIndexesAreConsecutive(months: string[]) {
if (months.length < 2) return false;
for (let index = 1; index < months.length; index++) {
@@ -1289,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({
args: {
scope: toolScopeValidator,
@@ -1688,13 +2281,17 @@ function summarizeToolInput(input: unknown) {
const search = maybeString(record.search);
const limit = maybeNumber(record.limit);
const horizonMonths = maybeNumber(record.horizonMonths);
const today = maybeString(record.today);
const targetCategoryName = maybeString(record.targetCategoryName);
const type = maybeString(record.type);
const categoryNames = Array.isArray(record.categoryNames)
? record.categoryNames.filter((name): name is string => typeof name === "string")
: [];
if (from || to) parts.push(`${from ?? "?"} bis ${to ?? "?"}`);
if (today) parts.push(`Monatsvorschau für ${today}`);
if (search) parts.push(`Suche "${search}"`);
if (targetCategoryName) parts.push(`Zielkategorie ${targetCategoryName}`);
if (categoryNames.length > 0) parts.push(`Kategorien ${categoryNames.join(", ")}`);
if (type) parts.push(type === "income" ? "Einnahmen" : "Ausgaben");
if (horizonMonths) parts.push(`${horizonMonths} Monate Prognose`);
@@ -1809,6 +2406,31 @@ function summarizeToolOutput(toolName: string, output: unknown) {
return `Sparquote ${(savingsRate * 100).toFixed(1)}%, gespart ${formatEuro(savedAmount)}${categoryFilterSummary}`;
}
if (toolName === "preview_month_end_spending") {
const month = maybeString(record.month) ?? "unbekannt";
const expectedMonthExpenses = maybeNumber(record.expectedMonthExpenses) ?? 0;
const predictedRemainingExpenses = maybeNumber(record.predictedRemainingExpenses) ?? 0;
return `Monatsvorschau ${month}: erwartet ${formatEuro(expectedMonthExpenses)} Ausgaben, Rest ${formatEuro(predictedRemainingExpenses)}`;
}
if (toolName === "inspect_category_health") {
const uncategorizedCount = maybeNumber(record.uncategorizedCount) ?? 0;
const unusedCategories = Array.isArray(record.unusedCategories) ? record.unusedCategories.length : 0;
const similarCategories = Array.isArray(record.similarCategories) ? record.similarCategories.length : 0;
return `${uncategorizedCount} unklassifizierte Umsätze, ${unusedCategories} ungenutzte Kategorien, ${similarCategories} ähnliche Gruppen`;
}
if (toolName === "suggest_category_for_transactions") {
const count = Array.isArray(record.suggestions) ? record.suggestions.length : 0;
return `${count} ${count === 1 ? "Kategorie-Vorschlag" : "Kategorie-Vorschläge"} gefunden`;
}
if (toolName === "propose_bulk_recategory" || toolName === "propose_category_changes") {
const affectedCount = maybeNumber(record.affectedCount) ?? 0;
const summary = maybeString(record.summary) ?? "Vorschlag vorbereitet";
return `${summary}, ${affectedCount} Änderung${affectedCount === 1 ? "" : "en"} warten auf Bestätigung`;
}
return "Werkzeug ausgeführt";
}
@@ -1831,6 +2453,29 @@ export function buildToolTraceFromSteps(steps: unknown[]): ToolTrace[] {
return trace;
}
export function buildSourcesFromToolTrace(toolTrace: ToolTrace[]): ChatSource[] {
return toolTrace.map((trace, index) => ({
id: `tool-${index + 1}`,
title: trace.name,
description: trace.resultSummary,
}));
}
export function buildCitationsFromSources(sources: ChatSource[]): ChatCitation[] {
return sources.map((source, index) => ({
marker: `${index + 1}`,
sourceId: source.id,
}));
}
function appendMissingCitationMarkers(answer: string, citations: ChatCitation[]): string {
const missingMarkers = citations
.map((citation) => citation.marker)
.filter((marker) => !answer.includes(`[${marker}]`));
if (missingMarkers.length === 0) return answer;
return `${answer.trimEnd()} ${missingMarkers.map((marker) => `[${marker}]`).join(" ")}`;
}
const transactionToolInputSchema = 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."),
@@ -1900,9 +2545,59 @@ const fixedCostsForecastToolInputSchema = z.object({
asOf: z.string().optional().describe("Stichtag für den Start der Prognose im Format YYYY-MM-DD."),
});
const monthEndPreviewToolInputSchema = z.object({
today: z.string().describe("Lokales heutiges Datum im Format YYYY-MM-DD."),
accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."),
});
const categoryHealthToolInputSchema = z.object({
from: z.string().optional().describe("Optionales Startdatum im Format YYYY-MM-DD."),
to: z.string().optional().describe("Optionales Enddatum im Format YYYY-MM-DD."),
accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."),
});
const categorySuggestionToolInputSchema = z.object({
from: z.string().optional().describe("Optionales Startdatum im Format YYYY-MM-DD."),
to: z.string().optional().describe("Optionales Enddatum im Format YYYY-MM-DD."),
accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."),
search: z.string().optional().describe("Suchtext für Beschreibung, Gegenpartei oder Kategorie."),
onlyUncategorized: z.boolean().optional().describe("Nur Umsätze ohne Kategorie vorschlagen."),
limit: z.number().int().min(1).max(MAX_TOOL_ROW_LIMIT).optional().describe("Maximale Anzahl geprüfter Umsatzzeilen."),
});
const bulkRecategoryProposalToolInputSchema = z.object({
targetCategoryName: z.string().describe("Bestehende Zielkategorie, der die gefundenen Umsätze zugeordnet werden sollen."),
from: z.string().optional().describe("Optionales Startdatum im Format YYYY-MM-DD."),
to: z.string().optional().describe("Optionales Enddatum im Format YYYY-MM-DD."),
accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."),
search: z.string().optional().describe("Suchtext für die zuzuordnenden Umsätze."),
onlyUncategorized: z.boolean().optional().describe("Nur Umsätze ohne Kategorie in den Vorschlag aufnehmen."),
limit: z.number().int().min(1).max(MAX_TOOL_ROW_LIMIT).optional().describe("Maximale Anzahl Änderungen."),
});
const categoryChangeProposalToolInputSchema = z.object({
creates: z.array(z.object({
name: z.string(),
kind: z.enum(["einnahme", "ausgabe"]),
block: z.enum(["wiederkehrend", "variabel"]).optional(),
color: z.string(),
icon: z.string().optional(),
sortOrder: z.number(),
})).optional().describe("Neue Kategorien, die nach UI-Bestätigung erstellt werden sollen."),
updates: z.array(z.object({
name: z.string(),
newName: z.string().optional(),
kind: z.enum(["einnahme", "ausgabe"]).optional(),
block: z.enum(["wiederkehrend", "variabel"]).optional(),
color: z.string().optional(),
icon: z.string().optional(),
sortOrder: z.number().optional(),
})).optional().describe("Bestehende Kategorien nach Name, die nach UI-Bestätigung geändert werden sollen."),
});
async function generateSavingsChatResponse(
ctx: ActionCtx,
args: ChatContextArgs & { messages: ChatMessage[] },
args: ChatContextArgs & { messages: ChatMessage[]; today?: string; sessionId?: Id<"chatSessions"> },
): Promise<ChatAskResult> {
if (args.messages.length === 0) {
throw new Error("Kein Nutzernachrichttext vorhanden.");
@@ -1921,6 +2616,7 @@ async function generateSavingsChatResponse(
accountId: args.accountId,
basis: args.basis,
};
const today = args.today ?? new Date().toISOString().slice(0, 10);
const selectedSummary: {
totalCount: number;
@@ -1939,6 +2635,7 @@ async function generateSavingsChatResponse(
from: args.from,
to: args.to,
basis: args.basis,
today,
accountName: selectedSummary.accountName,
});
@@ -2053,6 +2750,62 @@ async function generateSavingsChatResponse(
...input,
}),
}),
inspect_category_health: tool({
description:
"Analysiert read-only Kategoriequalität: unklassifizierte Umsätze, ungenutzte Kategorien, ähnliche Kategorien und Top-Gegenparteien ohne Kategorie.",
inputSchema: categoryHealthToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.inspectCategoryHealthTool, {
scope,
...input,
}),
}),
suggest_category_for_transactions: tool({
description:
"Schlägt read-only Kategorien für passende oder unklassifizierte Umsätze vor. Nutze es vor Änderungs-Vorschlägen, um eine sichere Zielkategorie zu begründen.",
inputSchema: categorySuggestionToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.suggestCategoryForTransactionsTool, {
scope,
...input,
}),
}),
preview_month_end_spending: tool({
description:
"Berechnet eine deterministische Monatsvorschau vom lokalen heutigen Datum bis Monatsende. Nutze dieses Tool für vermutete Ausgaben im Rest des aktuellen Monats.",
inputSchema: monthEndPreviewToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.previewMonthEndSpendingTool, {
scope,
today: input.today || today,
accountName: input.accountName,
}),
}),
propose_bulk_recategory: tool({
description:
"Erstellt nur einen bestätigungspflichtigen Vorschlag, um passende Umsätze einer bestehenden Kategorie zuzuordnen. Das Tool ändert keine Daten direkt.",
inputSchema: bulkRecategoryProposalToolInputSchema,
execute: async (input) => {
if (!args.sessionId) throw new Error("Änderungs-Vorschläge sind nur in gespeicherten Chats möglich.");
return await ctx.runMutation(internal.savingsChat.proposeBulkRecategoryTool, {
scope,
sessionId: args.sessionId,
...input,
});
},
}),
propose_category_changes: tool({
description:
"Erstellt nur einen bestätigungspflichtigen Vorschlag für Kategorie-Erstellung oder Kategorie-Updates. Löschen wird nicht unterstützt.",
inputSchema: categoryChangeProposalToolInputSchema,
execute: async (input) => {
if (!args.sessionId) throw new Error("Änderungs-Vorschläge sind nur in gespeicherten Chats möglich.");
return await ctx.runMutation(internal.savingsChat.proposeCategoryChangesTool, {
sessionId: args.sessionId,
...input,
});
},
}),
};
const envModel = process.env.SAVINGS_CHAT_MODEL?.trim();
@@ -2073,16 +2826,21 @@ async function generateSavingsChatResponse(
tools: savingsTools,
stopWhen: stepCountIs(5),
});
const toolTrace = buildToolTraceFromSteps(result.steps);
const sources = buildSourcesFromToolTrace(toolTrace);
const citations = buildCitationsFromSources(sources);
return {
model: modelName,
answer: result.text,
answer: appendMissingCitationMarkers(result.text, citations),
usedTransactions: selectedSummary.totals.transactionCount,
usedBalance: {
income: selectedSummary.totals.income,
expenses: selectedSummary.totals.expenses,
balance: selectedSummary.totals.balance,
},
toolTrace: buildToolTraceFromSteps(result.steps),
toolTrace,
sources,
citations,
};
} catch (error) {
lastError = error;
@@ -2103,6 +2861,7 @@ export const ask = action({
to: v.string(),
accountId: v.optional(v.id("accounts")),
basis: v.union(v.literal("effective"), v.literal("booking")),
today: v.optional(v.string()),
},
returns: v.object({
model: v.string(),
@@ -2114,10 +2873,13 @@ export const ask = action({
balance: v.number(),
}),
toolTrace: v.array(toolTraceValidator),
sources: v.array(sourceValidator),
citations: v.array(citationValidator),
}),
handler: async (ctx, args): Promise<ChatAskResult> => {
return await generateSavingsChatResponse(ctx, {
...args,
today: args.today,
messages: args.messages.map((message) => ({
role: normalizeRole(message.role),
content: message.content,
@@ -2134,6 +2896,7 @@ export const sendMessage = action({
to: v.string(),
accountId: v.optional(v.id("accounts")),
basis: v.union(v.literal("effective"), v.literal("booking")),
today: v.optional(v.string()),
},
returns: v.object({
model: v.string(),
@@ -2145,6 +2908,8 @@ export const sendMessage = action({
balance: v.number(),
}),
toolTrace: v.array(toolTraceValidator),
sources: v.array(sourceValidator),
citations: v.array(citationValidator),
}),
handler: async (ctx, args): Promise<ChatAskResult> => {
const content = args.content.trim();
@@ -2170,6 +2935,8 @@ export const sendMessage = action({
to: args.to,
accountId: args.accountId,
basis: args.basis,
today: args.today,
sessionId: args.sessionId,
messages,
});
} catch (error) {
@@ -2183,6 +2950,8 @@ export const sendMessage = action({
sessionId: args.sessionId,
content: response.answer,
toolTrace: response.toolTrace,
sources: response.sources,
citations: response.citations,
});
return response;
},

View File

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

View File

@@ -13,6 +13,15 @@ const toolTraceValidator = v.object({
inputSummary: v.string(),
resultSummary: v.string(),
});
const sourceValidator = v.object({
id: v.string(),
title: v.string(),
description: v.optional(v.string()),
});
const citationValidator = v.object({
marker: v.string(),
sourceId: v.string(),
});
const chatRoleValidator = v.union(v.literal("user"), v.literal("assistant"));
@@ -20,6 +29,8 @@ const importMessageValidator = v.object({
role: chatRoleValidator,
content: v.string(),
toolTrace: v.optional(v.array(toolTraceValidator)),
sources: v.optional(v.array(sourceValidator)),
citations: v.optional(v.array(citationValidator)),
});
const sessionValidator = v.object({
@@ -43,6 +54,8 @@ const messageValidator = v.object({
content: v.string(),
createdAt: v.number(),
toolTrace: v.optional(v.array(toolTraceValidator)),
sources: v.optional(v.array(sourceValidator)),
citations: v.optional(v.array(citationValidator)),
});
const promptMessageValidator = v.object({
@@ -181,6 +194,8 @@ export const importLocalSession = mutation({
content: message.content,
createdAt: args.createdAt + index,
...(message.toolTrace ? { toolTrace: message.toolTrace } : {}),
...(message.sources ? { sources: message.sources } : {}),
...(message.citations ? { citations: message.citations } : {}),
});
}
@@ -219,6 +234,8 @@ export const appendAssistantMessage = internalMutation({
sessionId: v.id("chatSessions"),
content: v.string(),
toolTrace: v.optional(v.array(toolTraceValidator)),
sources: v.optional(v.array(sourceValidator)),
citations: v.optional(v.array(citationValidator)),
},
returns: v.object({ messageId: v.id("chatMessages") }),
handler: async (ctx, args) => {
@@ -232,6 +249,8 @@ export const appendAssistantMessage = internalMutation({
content: args.content,
createdAt: now,
...(args.toolTrace ? { toolTrace: args.toolTrace } : {}),
...(args.sources ? { sources: args.sources } : {}),
...(args.citations ? { citations: args.citations } : {}),
});
await ctx.db.patch(args.sessionId, {
updatedAt: now,

View File

@@ -15,6 +15,60 @@ const chatToolTrace = v.object({
inputSummary: v.string(),
resultSummary: v.string(),
});
const chatSource = v.object({
id: v.string(),
title: v.string(),
description: v.optional(v.string()),
});
const chatCitation = v.object({
marker: v.string(),
sourceId: v.string(),
});
const agentActionPlanStatus = v.union(
v.literal("pending"),
v.literal("applied"),
v.literal("dismissed"),
v.literal("expired"),
);
const agentActionPlanKind = v.union(
v.literal("bulk_recategory"),
v.literal("category_changes"),
);
const agentActionPlanOperation = v.union(
v.object({
type: v.literal("set_transaction_category"),
transactionId: v.id("transactions"),
fromCategoryId: v.optional(v.id("categories")),
toCategoryId: v.id("categories"),
}),
v.object({
type: v.literal("create_category"),
name: v.string(),
kind: categoryKind,
block: v.optional(expenseBlock),
color: v.string(),
icon: v.optional(v.string()),
sortOrder: v.number(),
}),
v.object({
type: v.literal("update_category"),
categoryId: v.id("categories"),
name: v.optional(v.string()),
kind: v.optional(categoryKind),
block: v.optional(expenseBlock),
color: v.optional(v.string()),
icon: v.optional(v.string()),
sortOrder: v.optional(v.number()),
}),
);
const agentActionPlanPreviewRow = v.object({
date: v.optional(v.string()),
description: v.string(),
amount: v.optional(v.number()),
currentCategoryName: v.optional(v.string()),
targetCategoryName: v.optional(v.string()),
action: v.optional(v.string()),
});
export default defineSchema({
...authTables,
@@ -32,6 +86,22 @@ export default defineSchema({
.index("by_user", ["userId"])
.index("by_user_external", ["userId", "externalId"]),
accountBalances: defineTable({
userId: v.id("users"),
accountId: v.id("accounts"),
externalId: v.string(),
provider: v.union(v.literal("comdirect"), v.literal("fints")),
balance: v.number(),
currency: v.string(),
asOf: v.optional(v.string()),
fetchedAt: v.number(),
status: v.union(v.literal("fresh"), v.literal("stale"), v.literal("error")),
errorMessage: v.optional(v.string()),
})
.index("by_user", ["userId"])
.index("by_user_account", ["userId", "accountId"])
.index("by_user_provider_fetched", ["userId", "provider", "fetchedAt"]),
categories: defineTable({
userId: v.id("users"),
name: v.string(),
@@ -68,6 +138,7 @@ export default defineSchema({
.index("by_user", ["userId"])
.index("by_user_booking", ["userId", "bookingDate"])
.index("by_user_effmonth", ["userId", "effectiveMonth"])
.index("by_user_effmonth_booking", ["userId", "effectiveMonth", "bookingDate"])
.index("by_user_category", ["userId", "categoryId"])
.index("by_user_account", ["userId", "accountId"])
.index("by_user_account_booking", ["userId", "accountId", "bookingDate"])
@@ -201,5 +272,23 @@ export default defineSchema({
content: v.string(),
createdAt: v.number(),
toolTrace: v.optional(v.array(chatToolTrace)),
sources: v.optional(v.array(chatSource)),
citations: v.optional(v.array(chatCitation)),
}).index("by_user_session_created", ["userId", "sessionId", "createdAt"]),
agentActionPlans: defineTable({
userId: v.id("users"),
sessionId: v.id("chatSessions"),
kind: agentActionPlanKind,
status: agentActionPlanStatus,
summary: v.string(),
affectedCount: v.number(),
createdAt: v.number(),
expiresAt: v.number(),
operations: v.array(agentActionPlanOperation),
previewRows: v.array(agentActionPlanPreviewRow),
resultSummary: v.optional(v.string()),
})
.index("by_user_session_status_created", ["userId", "sessionId", "status", "createdAt"])
.index("by_user_status_expires", ["userId", "status", "expiresAt"]),
});

View File

@@ -186,4 +186,76 @@ describe("transactions.list", () => {
expect(effectiveResult.page.map((tx) => tx._id)).toEqual([seeded.shiftedId]);
expect(bookingResult.page).toEqual([]);
});
test("orders effective month results by booking date instead of import creation time", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Ordering User",
email: "ordering@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const olderCreatedNewestBookingId = await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-06-23",
description: "Newest booking created first",
amount: -10,
isPending: false,
effectiveMonth: "2026-06",
});
const newerCreatedOldestBookingId = await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-06-15",
description: "Oldest booking created later",
amount: -20,
isPending: false,
effectiveMonth: "2026-06",
});
const middleBookingId = await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-06-22",
description: "Middle booking created last",
amount: -30,
isPending: false,
effectiveMonth: "2026-06",
});
return {
userId,
olderCreatedNewestBookingId,
newerCreatedOldestBookingId,
middleBookingId,
};
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|test-session`,
tokenIdentifier: `test:${seeded.userId}`,
});
const result = await asUser.query(api.transactions.list, {
paginationOpts: { cursor: null, numItems: 20 },
from: "2026-06-01",
to: "2026-06-30",
basis: "effective",
});
expect(result.page.map((tx) => tx._id)).toEqual([
seeded.olderCreatedNewestBookingId,
seeded.middleBookingId,
seeded.newerCreatedOldestBookingId,
]);
});
});

View File

@@ -67,7 +67,7 @@ export const list = query({
if (basis === "effective") {
q = ctx.db
.query("transactions")
.withIndex("by_user_effmonth", (iq) => {
.withIndex("by_user_effmonth_booking", (iq) => {
if (fromMonth && toMonth) {
return iq.eq("userId", userId).gte("effectiveMonth", fromMonth).lte("effectiveMonth", toMonth);
}

View File

@@ -0,0 +1,225 @@
# Kontostandabruf und UI-Anzeige Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a dedicated live balance refresh path for FinTS and comdirect REST, persist latest balances separately from account master data, and show them clearly in the dashboard and account UI.
**Architecture:** Keep current transaction sync intact and add a separate balance capability. Persist balance snapshots in a dedicated Convex table because live balances are operational/high-churn data, while `accounts` remains stable account metadata. The UI reads the latest persisted balances through a query and can trigger a refresh action that uses provider preference `auto | comdirect | fints` with the same fallback behavior as the current sync.
**Tech Stack:** React 19, Vite, Convex 1.41, `lib-fints`, comdirect REST client, Vitest, convex-test.
---
## Context
- Backlog task: `TASK-14 Plane Kontostandabruf und UI-Anzeige`.
- Existing balance-capable types already exist in `convex/bank/types.ts`: `NormalizedAccount.balance` and `NormalizedBalance`.
- Existing comdirect balance endpoint wrapper exists in `convex/comdirect/client.ts`: `getAccountBalances()`.
- Existing FinTS balance logic exists inside transaction sync in `convex/bank/orchestrator.ts`: `client.canGetAccountBalance()` and `client.getAccountBalance()`.
- Current persistence only uses `balance` as `openingBalance` when creating a new account; it does not store or display latest live balance.
- `ctx7` found FinTS docs for `/nemiah/phpfints`; relevant notes: FinTS/HBCI supports account access and balance retrieval, and real deployments need a registered product/application id. `ctx7` did not find a usable comdirect API reference, so implementation should rely on the existing local comdirect client wrappers and verify against real responses.
## Data Model
### Task 1: Store Latest Account Balances
**Files:**
- Modify: `convex/schema.ts`
- Modify: `convex/bank/internal.ts`
- Test: `convex/bank/balances.test.ts`
- [ ] Add `accountBalances` table in `convex/schema.ts`:
```ts
accountBalances: defineTable({
userId: v.id("users"),
accountId: v.id("accounts"),
externalId: v.string(),
provider: v.union(v.literal("comdirect"), v.literal("fints")),
balance: v.number(),
currency: v.string(),
asOf: v.optional(v.string()),
fetchedAt: v.number(),
status: v.union(v.literal("fresh"), v.literal("stale"), v.literal("error")),
errorMessage: v.optional(v.string()),
})
.index("by_user", ["userId"])
.index("by_user_account", ["userId", "accountId"])
.index("by_user_provider_fetched", ["userId", "provider", "fetchedAt"]),
```
- [ ] Add `upsertAccountBalance` internal mutation in `convex/bank/internal.ts`.
- [ ] Add `listLatestAccountBalances` internal query that returns one latest balance per account for the authenticated user's accounts.
- [ ] Keep `accounts.openingBalance` unchanged; do not repurpose it as live balance.
- [ ] Test that inserting a newer balance replaces the displayed latest balance without mutating `accounts.openingBalance`.
## Provider Flow
### Task 2: Normalize Provider Balance Fetching
**Files:**
- Modify: `convex/bank/types.ts`
- Modify: `convex/bank/comdirectProvider.ts`
- Modify: `convex/bank/orchestrator.ts`
- Test: `convex/bank/balanceProviders.test.ts`
- [ ] Extend `NormalizedBalance` with optional `asOf?: string`.
- [ ] Keep comdirect `getBalance(accountExternalId)` as the per-account lookup, but add a bulk path that maps `/api/banking/clients/user/v2/accounts/balances` into `NormalizedBalance[]`.
- [ ] Extract FinTS balance fetching from `fetchFinTsAccountData()` into a reusable helper:
```ts
async function fetchFinTsAccountBalance(
ctx: ActionCtx,
userId: Id<"users">,
client: FinTSClient,
account: NormalizedAccount,
syncJob: PendingSyncJob,
): Promise<NormalizedBalance>
```
- [ ] If FinTS reports `!client.canGetAccountBalance(account.externalId)`, return an error result for that account instead of failing the whole refresh.
- [ ] Preserve TAN handling by reusing `resolveTanResponse()` for balance requests.
- [ ] Test provider selection:
- `providerPreference: "fints"` uses FinTS only.
- `providerPreference: "comdirect"` uses REST and only falls back if the current fallback rules say the error is recoverable.
- `providerPreference: "auto"` tries REST when configured, otherwise FinTS.
## Convex Public API
### Task 3: Add Balance Query and Refresh Action
**Files:**
- Create: `convex/bank/balances.ts`
- Modify: `convex/bank/orchestrator.ts`
- Modify: `convex/bank/internal.ts`
- Test: `convex/bank/balances.test.ts`
- [ ] Add public query `api.bank.balances.listLatest`.
Return shape:
```ts
{
accountId: Id<"accounts">;
accountName: string;
accountType: string;
iban?: string;
externalId?: string;
balance: number | null;
currency: string;
provider: "comdirect" | "fints" | null;
fetchedAt: number | null;
asOf?: string;
status: "fresh" | "stale" | "error" | "missing";
errorMessage?: string;
}
```
- [ ] Add public action `api.bank.balances.refresh`.
Arguments:
```ts
{
accountId?: Id<"accounts">;
pin?: string;
}
```
Return shape:
```ts
{
updatedCount: number;
provider: "comdirect" | "fints";
awaitingTan: boolean;
errors: Array<{ accountId?: Id<"accounts">; externalId?: string; message: string }>;
}
```
- [ ] Add internal action `refreshBalancesInternal` in `convex/bank/orchestrator.ts`, reusing provider preference and fallback logic from `runSyncInternal`.
- [ ] When transaction sync already fetches balances, call `upsertAccountBalance` during `persistSyncResults()` so a full sync also updates UI balances.
- [ ] Keep this as a Convex action/query API first; add an HTTP route in `convex/http.ts` only if an external scheduler or webhook needs it.
## UI
### Task 4: Add Balance UI Components
**Files:**
- Create: `src/components/accounts/AccountBalanceStrip.tsx`
- Modify: `src/pages/DashboardPage.tsx`
- Modify: `src/pages/SettingsPage.tsx`
- Optional modify: `src/components/import/ComdirectSyncPanel.tsx`
- Test: `src/components/accounts/AccountBalanceStrip.test.tsx`
- [ ] Create `AccountBalanceStrip` that calls `api.bank.balances.listLatest`.
- [ ] Show compact account rows with:
- account name
- IBAN tail or type
- live balance
- provider badge
- fetched timestamp
- stale/error/missing state
- [ ] Add refresh button with `RefreshCw` icon from `lucide-react`.
- [ ] Put the strip near the top of `DashboardPage`, above the period-based KPI cards. This avoids confusing live bank balance with the existing filtered `Saldo` KPI.
- [ ] In `SettingsPage`, show the latest live balance in each account row next to account metadata.
- [ ] For stale data, use subdued text and a warning badge; for errors, show the last known balance plus the error message.
- [ ] Keep cards shallow; do not nest balance cards inside other cards.
## UX Rules
- Label live bank balance as `Aktueller Kontostand`.
- Keep existing dashboard `Saldo` label as period result, or rename it to `Periodensaldo` to avoid ambiguity.
- Show `Noch nicht abgerufen` for accounts without a balance snapshot.
- Show `Aktualisiert vor ...` or localized timestamp for fetched balances.
- If refresh returns `awaitingTan`, reuse the existing TAN dialog/status flow rather than adding a second TAN UX.
- If comdirect REST fails and FinTS succeeds, show a non-blocking toast: `comdirect REST fehlgeschlagen, FinTS verwendet`.
## Testing
### Task 5: Verify Backend Behavior
**Files:**
- Create: `convex/bank/balances.test.ts`
- Create or extend: `convex/bank/balanceProviders.test.ts`
- [ ] Use `convex-test` with `edge-runtime`, matching existing project guidance.
- [ ] Test latest-balance query for `fresh`, `missing`, and `error` states.
- [ ] Test that archived accounts are either hidden by default or marked clearly; choose hidden for dashboard and visible in settings.
- [ ] Test full sync calls balance persistence for both providers.
- [ ] Test balance-only refresh does not import transactions.
### Task 6: Verify Frontend Behavior
**Files:**
- Create: `src/components/accounts/AccountBalanceStrip.test.tsx`
- Modify: `src/pages/DashboardPage.tsx`
- [ ] Test loading skeleton.
- [ ] Test missing state.
- [ ] Test fresh EUR formatting with existing `formatAmount`.
- [ ] Test error state keeps last known balance visible.
- [ ] Test refresh button disables while the action is running.
## Execution Order
1. Add schema and internal persistence.
2. Add backend query tests and make them fail.
3. Implement internal balance mutations/queries.
4. Extract provider balance helpers.
5. Add refresh action and wire provider fallback.
6. Update transaction sync to persist balances opportunistically.
7. Add UI component and Dashboard placement.
8. Add Settings account-row balance display.
9. Run `pnpm lint`.
10. Run `pnpm build`.
11. Run targeted Vitest suites.
## Manual Verification
1. Set provider preference to `fints`, enter FinTS PIN if not in Convex env, refresh balances.
2. Confirm dashboard shows `Aktueller Kontostand` per account.
3. Confirm existing `Saldo`/`Periodensaldo` still follows the selected date range.
4. Set provider preference to `auto` with broken comdirect REST credentials and valid FinTS config; confirm fallback works.
5. Run a normal transaction sync and confirm balances update without pressing the balance refresh button separately.

View File

@@ -0,0 +1,76 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, test } from "vitest";
import { AccountBalanceStripView, type AccountBalanceRow } from "./AccountBalanceStrip";
const freshRow: AccountBalanceRow = {
accountId: "account-1",
accountName: "Girokonto",
accountType: "giro",
iban: "DE89370400440532013000",
externalId: "giro-1",
balance: 1234.56,
currency: "EUR",
provider: "fints",
fetchedAt: new Date("2026-06-23T08:00:00.000Z").getTime(),
status: "fresh",
};
describe("AccountBalanceStripView", () => {
test("renders a loading skeleton while balances load", () => {
const markup = renderToStaticMarkup(
<AccountBalanceStripView rows={undefined} isRefreshing={false} onRefresh={() => undefined} />,
);
expect(markup).toContain("Kontostände werden geladen");
});
test("renders missing and fresh balance states", () => {
const markup = renderToStaticMarkup(
<AccountBalanceStripView
rows={[
freshRow,
{
...freshRow,
accountId: "account-2",
accountName: "Tagesgeld",
balance: null,
provider: null,
fetchedAt: null,
status: "missing",
},
]}
isRefreshing={false}
onRefresh={() => undefined}
/>,
);
expect(markup).toContain("Aktueller Kontostand");
expect(markup).toContain("Girokonto");
expect(markup).toContain("1.234,56");
expect(markup).toContain("FinTS");
expect(markup).toContain("Tagesgeld");
expect(markup).toContain("Noch nicht abgerufen");
});
test("keeps last known balance visible for error states and disables refresh while busy", () => {
const markup = renderToStaticMarkup(
<AccountBalanceStripView
rows={[
{
...freshRow,
status: "error",
errorMessage: "HKSAL wird nicht unterstuetzt",
},
]}
isRefreshing
onRefresh={() => undefined}
/>,
);
expect(markup).toContain("1.234,56");
expect(markup).toContain("Fehler");
expect(markup).toContain("HKSAL wird nicht unterstuetzt");
expect(markup).toContain("disabled");
});
});

View File

@@ -0,0 +1,177 @@
import { useState } from "react";
import { useAction, useQuery } from "convex/react";
import { RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { api } from "../../../convex/_generated/api";
import { amountClass, formatAmount } from "@/lib/format";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export type AccountBalanceRow = {
accountId: string;
accountName: string;
accountType: string;
iban?: string;
externalId?: string;
balance: number | null;
currency: string;
provider: "comdirect" | "fints" | null;
fetchedAt: number | null;
asOf?: string;
status: "fresh" | "stale" | "error" | "missing";
errorMessage?: string;
};
function providerLabel(provider: AccountBalanceRow["provider"]) {
if (provider === "fints") return "FinTS";
if (provider === "comdirect") return "comdirect";
return "offline";
}
function accountHint(row: AccountBalanceRow) {
if (row.iban) return `IBAN ...${row.iban.slice(-4)}`;
return row.accountType;
}
function fetchedLabel(row: AccountBalanceRow) {
if (!row.fetchedAt) return "Noch nicht abgerufen";
return `Aktualisiert ${new Date(row.fetchedAt).toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}`;
}
function statusLabel(row: AccountBalanceRow) {
if (row.status === "error") return "Fehler";
if (row.status === "stale") return "Veraltet";
if (row.status === "missing") return "Offen";
return providerLabel(row.provider);
}
function statusClass(row: AccountBalanceRow) {
if (row.status === "error") return "border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300";
if (row.status === "stale") return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-300";
if (row.status === "missing") return "text-muted-foreground";
return "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-300";
}
export function AccountBalanceStripView({
rows,
isRefreshing,
onRefresh,
}: {
rows: AccountBalanceRow[] | undefined;
isRefreshing: boolean;
onRefresh: () => void;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3">
<CardTitle>Aktueller Kontostand</CardTitle>
<Button
type="button"
variant="outline"
size="sm"
onClick={onRefresh}
disabled={isRefreshing || rows === undefined}
title="Kontostände aktualisieren"
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
<span className="ml-2 hidden sm:inline">
{isRefreshing ? "Aktualisiert..." : "Aktualisieren"}
</span>
</Button>
</CardHeader>
<CardContent>
{rows === undefined ? (
<div className="space-y-3" aria-label="Kontostände werden geladen">
<Skeleton className="h-14" />
<Skeleton className="h-14" />
</div>
) : rows.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine aktiven Konten</p>
) : (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{rows.map((row) => (
<div
key={row.accountId}
className="rounded-lg border bg-background px-4 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{row.accountName}</div>
<div className="text-xs text-muted-foreground">{accountHint(row)}</div>
</div>
<Badge variant="outline" className={statusClass(row)}>
{statusLabel(row)}
</Badge>
</div>
<div className="mt-3 flex items-end justify-between gap-3">
<div
className={`text-xl font-semibold ${
row.balance === null ? "text-muted-foreground" : amountClass(row.balance)
}`}
>
{row.balance === null ? "Noch nicht abgerufen" : formatAmount(row.balance)}
</div>
<div className="text-right text-xs text-muted-foreground">
{fetchedLabel(row)}
</div>
</div>
{row.errorMessage && (
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
{row.errorMessage}
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
export function AccountBalanceStrip() {
const rows = useQuery(api.bank.balances.listLatest, {}) as
| AccountBalanceRow[]
| undefined;
const refreshBalances = useAction(api.bank.balances.refresh);
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
setIsRefreshing(true);
try {
const result = await refreshBalances({});
if (result.errors.length > 0 && result.updatedCount === 0) {
toast.error(result.errors[0].message);
} else {
toast.success(
`${result.updatedCount} Kontostand${result.updatedCount === 1 ? "" : "e"} aktualisiert (${providerLabel(result.provider)})`,
);
if (result.errors.length > 0) {
toast.message(result.errors[0].message);
}
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Kontostandabruf fehlgeschlagen");
} finally {
setIsRefreshing(false);
}
};
return (
<AccountBalanceStripView
rows={rows}
isRefreshing={isRefreshing}
onRefresh={() => {
void handleRefresh();
}}
/>
);
}

View File

@@ -0,0 +1,296 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, test } from "vitest";
import {
AgentConversation,
AgentActionPlanList,
AgentMessage,
AgentPromptInput,
type AgentChatMessage,
} from "./AgentChat";
import {
buildSourcesFromToolTrace,
buildReasoningSteps,
getToolTraceSummary,
scrollConversationToBottom,
} from "./agentChatModel";
const assistantMessage: AgentChatMessage = {
id: "assistant-1",
role: "assistant",
content: "Deine groesste Ausgabe war Miete.",
toolTrace: [
{
name: "summarize_transactions",
inputSummary: "Mai 2026",
resultSummary: "12 Umsaetze zusammengefasst",
},
{
name: "list_transactions",
inputSummary: "Miete",
resultSummary: "1 Treffer",
},
],
};
describe("AgentChat phase 1 components", () => {
test("scrolls the conversation container directly to the bottom", () => {
const container = {
scrollTop: 0,
scrollHeight: 1200,
};
scrollConversationToBottom(container);
expect(container.scrollTop).toBe(1200);
});
test("summarizes tool traces for compact agent transparency", () => {
expect(getToolTraceSummary(undefined)).toBe("Keine Werkzeuge");
expect(getToolTraceSummary([])).toBe("Keine Werkzeuge");
expect(getToolTraceSummary(assistantMessage.toolTrace)).toBe("2 Werkzeuge verwendet");
});
test("renders conversation messages with assistant tool trace details", () => {
const markup = renderToStaticMarkup(
<AgentConversation
messages={[
{ id: "user-1", role: "user", content: "Was war teuer?" },
assistantMessage,
]}
/>,
);
expect(markup).toContain("Was war teuer?");
expect(markup).toContain("Deine groesste Ausgabe war Miete.");
expect(markup).toContain("2 Werkzeuge verwendet");
expect(markup).toContain("summarize_transactions");
expect(markup).toContain("12 Umsaetze zusammengefasst");
});
test("renders the prompt input with ready and busy states", () => {
const readyMarkup = renderToStaticMarkup(
<AgentPromptInput
value="Bitte analysieren"
disabled={false}
isSubmitting={false}
placeholder="Welche Auswertung soll ich machen?"
onChange={() => undefined}
onSubmit={() => undefined}
/>,
);
const busyMarkup = renderToStaticMarkup(
<AgentPromptInput
value="Bitte analysieren"
disabled
isSubmitting
placeholder="Chat wird vorbereitet..."
onChange={() => undefined}
onSubmit={() => undefined}
/>,
);
expect(readyMarkup).toContain("Welche Auswertung soll ich machen?");
expect(readyMarkup).toContain("Senden");
expect(busyMarkup).toContain("Antwort laeuft");
expect(busyMarkup).toContain("disabled");
});
test("disables prompt submit for whitespace-only input", () => {
const markup = renderToStaticMarkup(
<AgentPromptInput
value=" "
disabled={false}
isSubmitting={false}
placeholder="Welche Auswertung soll ich machen?"
onChange={() => undefined}
onSubmit={() => undefined}
/>,
);
expect(markup).toContain("disabled");
});
test("renders a thinking indicator while the agent is submitting", () => {
const markup = renderToStaticMarkup(
<AgentConversation messages={[assistantMessage]} isSubmitting />,
);
expect(markup).toContain("Denk mit der KI nach...");
});
test("renders a single message without a tool section when no trace exists", () => {
const markup = renderToStaticMarkup(
<AgentMessage message={{ id: "assistant-empty", role: "assistant", content: "Hallo" }} />,
);
expect(markup).toContain("Assistant");
expect(markup).toContain("Hallo");
expect(markup).not.toContain("Werkzeuge verwendet");
});
});
describe("AgentChat phase 2 reasoning disclosure", () => {
test("builds safe reasoning steps from tool traces without exposing raw inputs", () => {
const steps = buildReasoningSteps(assistantMessage.toolTrace);
expect(steps).toEqual([
{
label: "summarize_transactions",
description: "12 Umsaetze zusammengefasst",
status: "complete",
},
{
label: "list_transactions",
description: "1 Treffer",
status: "complete",
},
]);
expect(JSON.stringify(steps)).not.toContain("Mai 2026");
});
test("renders an assistant work-progress disclosure from tool traces", () => {
const markup = renderToStaticMarkup(<AgentMessage message={assistantMessage} />);
expect(markup).toContain("Nachweis &amp; Arbeitsweg");
expect(markup).toContain("summarize_transactions");
expect(markup).toContain("12 Umsaetze zusammengefasst");
});
test("marks the current agent step as active while submitting", () => {
const markup = renderToStaticMarkup(
<AgentConversation messages={[assistantMessage]} isSubmitting />,
);
expect(markup).toContain("Antwort wird vorbereitet");
expect(markup).toContain("data-status=\"active\"");
});
});
describe("AgentChat phase 3 sources and inline citations", () => {
test("builds private finance sources from tool traces", () => {
expect(buildSourcesFromToolTrace(assistantMessage.toolTrace)).toEqual([
{
id: "tool-1",
title: "summarize_transactions",
description: "12 Umsaetze zusammengefasst",
},
{
id: "tool-2",
title: "list_transactions",
description: "1 Treffer",
},
]);
});
test("renders source list and inline citation markers for assistant messages", () => {
const markup = renderToStaticMarkup(
<AgentMessage
message={{
id: "assistant-cited",
role: "assistant",
content: "Die Miete war der groesste Posten [1].",
sources: [
{
id: "tool-1",
title: "list_transactions",
description: "1 Mietumsatz gefunden",
},
],
citations: [{ marker: "1", sourceId: "tool-1" }],
}}
/>,
);
expect(markup).toContain("Quellen");
expect(markup).toContain("list_transactions");
expect(markup).toContain("1 Mietumsatz gefunden");
expect(markup).toContain("[1]");
expect(markup).toContain("data-source-id=\"tool-1\"");
});
test("combines matching tool traces and sources into one evidence block", () => {
const markup = renderToStaticMarkup(
<AgentMessage
message={{
id: "assistant-evidence",
role: "assistant",
content: "Es gab 24 Buchungen [1].",
toolTrace: [
{
name: "get_transactions",
inputSummary: "Kategorie Rahmenkredite",
resultSummary: "24 Umsaetze, Saldo -13483.41€, vollstaendig",
},
],
sources: [
{
id: "tool-1",
title: "get_transactions",
description: "24 Umsaetze, Saldo -13483.41€, vollstaendig",
},
],
citations: [{ marker: "1", sourceId: "tool-1" }],
}}
/>,
);
expect(markup).toContain("Nachweis &amp; Arbeitsweg");
expect(markup).toContain("1 Werkzeug verwendet");
expect(markup).toContain("get_transactions");
expect(markup).toContain("24 Umsaetze, Saldo -13483.41€, vollstaendig");
expect(markup).toContain("data-source-id=\"tool-1\"");
expect(markup).not.toContain(">Quellen<");
});
});
describe("AgentChat action plan preview cards", () => {
test("renders pending action plans with examples and confirmation controls", () => {
const markup = renderToStaticMarkup(
<AgentActionPlanList
plans={[
{
_id: "plan-1",
kind: "bulk_recategory",
summary: "1 Umsatz zur Kategorie Abos zuordnen",
affectedCount: 1,
expiresAt: 1_800_086_400_000,
previewRows: [
{
date: "2026-06-03",
description: "Netflix",
amount: -16,
currentCategoryName: "Ohne Kategorie",
targetCategoryName: "Abos",
},
],
},
]}
isApplying={false}
onApply={() => undefined}
onDismiss={() => undefined}
/>,
);
expect(markup).toContain("Vorschläge zur Bestätigung");
expect(markup).toContain("1 Umsatz zur Kategorie Abos zuordnen");
expect(markup).toContain("1 Änderung");
expect(markup).toContain("Netflix");
expect(markup).toContain("Ohne Kategorie");
expect(markup).toContain("Abos");
expect(markup).toContain("Anwenden");
expect(markup).toContain("Verwerfen");
});
test("does not render an empty action plan section", () => {
const markup = renderToStaticMarkup(
<AgentActionPlanList
plans={[]}
isApplying={false}
onApply={() => undefined}
onDismiss={() => undefined}
/>,
);
expect(markup).toBe("");
});
});

View File

@@ -0,0 +1,372 @@
import {
type ChangeEvent,
type FormEvent,
type HTMLAttributes,
type Ref,
} from "react";
import { Check, Loader2, Send, Wrench, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
buildReasoningSteps,
getToolTraceSummary,
type AgentChatMessage,
type AgentCitation,
type AgentReasoningStep,
type AgentSource,
type AgentToolTrace,
} from "./agentChatModel";
export type { AgentChatMessage, AgentToolTrace } from "./agentChatModel";
type AgentConversationProps = HTMLAttributes<HTMLDivElement> & {
messages: AgentChatMessage[];
isSubmitting?: boolean;
scrollRef?: Ref<HTMLDivElement>;
};
type AgentMessageProps = HTMLAttributes<HTMLDivElement> & {
message: AgentChatMessage;
};
type AgentPromptInputProps = {
value: string;
placeholder: string;
disabled: boolean;
isSubmitting: boolean;
onChange: (value: string) => 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({
messages,
isSubmitting = false,
scrollRef,
className,
...props
}: AgentConversationProps) {
return (
<section
aria-label="Chatverlauf"
className={cn("h-[52vh] overflow-y-auto rounded-md border bg-background", className)}
ref={scrollRef}
{...props}
>
<div className="space-y-3 p-3">
{messages.map((message) => (
<AgentMessage key={message.id} message={message} />
))}
{isSubmitting && <AgentThinkingIndicator />}
</div>
</section>
);
}
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) {
const isUser = message.role === "user";
return (
<article
className={cn(
"flex w-full",
isUser ? "justify-end" : "justify-start",
className,
)}
{...props}
>
<div
className={cn(
"max-w-[min(100%,46rem)] rounded-lg border px-3 py-2 text-sm",
isUser ? "bg-muted/60" : "bg-card text-card-foreground",
)}
>
<p className="text-[0.7rem] font-medium uppercase text-muted-foreground">
{isUser ? "User" : "Assistant"}
</p>
<p className="mt-1 whitespace-pre-wrap leading-6">
<MessageContentWithCitations
content={message.content}
citations={message.citations}
/>
</p>
{!isUser && message.toolTrace && message.toolTrace.length > 0 && (
<AgentEvidencePanel sources={message.sources} toolTrace={message.toolTrace} />
)}
{!isUser && (!message.toolTrace || message.toolTrace.length === 0) && message.sources && message.sources.length > 0 && (
<AgentSources sources={message.sources} />
)}
</div>
</article>
);
}
function MessageContentWithCitations({
content,
citations,
}: {
content: string;
citations?: AgentCitation[];
}) {
if (!citations || citations.length === 0) return content;
const citationByMarker = new Map(citations.map((citation) => [citation.marker, citation]));
const parts = content.split(/(\[\d+\])/g);
return (
<>
{parts.map((part, index) => {
const marker = part.match(/^\[(\d+)\]$/)?.[1];
const citation = marker ? citationByMarker.get(marker) : undefined;
if (!citation) return <span key={`${part}-${index}`}>{part}</span>;
return (
<span
className="font-medium text-foreground"
aria-label={`Quelle ${citation.marker}: ${citation.sourceId}`}
data-source-id={citation.sourceId}
key={`${citation.sourceId}-${index}`}
>
{part}
</span>
);
})}
</>
);
}
function formatActionPlanAmount(amount: number) {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(amount);
}
function formatActionPlanExpiry(expiresAt: number) {
return new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(expiresAt));
}
function AgentSources({ sources }: { sources: AgentSource[] }) {
return (
<div className="mt-3 rounded-md border bg-muted/20 p-2">
<p className="text-xs font-medium text-muted-foreground">Quellen</p>
<div className="mt-2 space-y-2">
{sources.map((source) => (
<div
className="rounded-md bg-background/80 p-2 text-xs"
data-source-id={source.id}
key={source.id}
>
<p className="font-medium text-foreground">{source.title}</p>
{source.description && (
<p className="mt-1 text-muted-foreground">{source.description}</p>
)}
</div>
))}
</div>
</div>
);
}
function AgentEvidencePanel({
sources,
toolTrace,
}: {
sources?: AgentSource[];
toolTrace: AgentToolTrace[];
}) {
const steps = buildReasoningSteps(toolTrace);
return (
<details className="mt-3 rounded-md border bg-muted/30 px-2 py-1.5">
<summary className="flex cursor-pointer list-none items-center gap-2 text-xs font-medium text-muted-foreground">
<Wrench className="h-3.5 w-3.5" />
Nachweis & Arbeitsweg ({getToolTraceSummary(toolTrace)})
</summary>
<div className="mt-2 space-y-2">
{steps.map((step, stepIndex) => (
<ReasoningStep
key={`${step.label}-${stepIndex}`}
sourceId={sources?.[stepIndex]?.id}
step={step}
/>
))}
</div>
</details>
);
}
function ReasoningStep({
sourceId,
step,
}: {
sourceId?: string;
step: AgentReasoningStep;
}) {
return (
<div
className="rounded-md bg-background/80 p-2 text-xs"
data-source-id={sourceId}
data-status={step.status}
>
<p className="font-medium text-foreground">{step.label}</p>
<p className="mt-1 text-muted-foreground">{step.description}</p>
</div>
);
}
function AgentThinkingIndicator() {
return (
<div className="space-y-2 px-1 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Denk mit der KI nach...
</div>
<div className="rounded-md border bg-muted/30 p-2 text-xs" data-status="active">
<p className="font-medium text-foreground">Antwort wird vorbereitet</p>
<p className="mt-1">Der Agent prueft den aktuellen Finanzkontext.</p>
</div>
</div>
);
}
export function AgentPromptInput({
value,
placeholder,
disabled,
isSubmitting,
onChange,
onSubmit,
}: AgentPromptInputProps) {
const submitDisabled = disabled || value.trim().length === 0;
const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value);
return (
<form
className="rounded-lg border bg-card p-2 shadow-sm"
aria-label="Chatnachricht senden"
onSubmit={onSubmit}
>
<div className="flex items-end gap-2">
<textarea
className="min-h-11 flex-1 resize-none bg-transparent px-2 py-2 text-sm leading-6 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-60"
value={value}
onChange={handleChange}
placeholder={placeholder}
disabled={disabled}
rows={1}
/>
<Button type="submit" disabled={submitDisabled} className="shrink-0">
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
{isSubmitting ? "Antwort laeuft" : "Senden"}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,63 @@
export type AgentToolTrace = {
name: string;
inputSummary: string;
resultSummary: string;
};
export type AgentChatMessage = {
id: string;
role: "user" | "assistant";
content: string;
toolTrace?: AgentToolTrace[];
sources?: AgentSource[];
citations?: AgentCitation[];
};
export type AgentSource = {
id: string;
title: string;
description?: string;
};
export type AgentCitation = {
marker: string;
sourceId: string;
};
export type AgentReasoningStep = {
label: string;
description: string;
status: "complete" | "active" | "pending";
};
export function getToolTraceSummary(toolTrace: AgentToolTrace[] | undefined) {
if (!toolTrace || toolTrace.length === 0) return "Keine Werkzeuge";
return `${toolTrace.length} ${toolTrace.length === 1 ? "Werkzeug" : "Werkzeuge"} verwendet`;
}
export function buildReasoningSteps(toolTrace: AgentToolTrace[] | undefined): AgentReasoningStep[] {
if (!toolTrace || toolTrace.length === 0) return [];
return toolTrace.map((tool) => ({
label: tool.name,
description: tool.resultSummary,
status: "complete",
}));
}
export function buildSourcesFromToolTrace(toolTrace: AgentToolTrace[] | undefined): AgentSource[] {
if (!toolTrace || toolTrace.length === 0) return [];
return toolTrace.map((tool, index) => ({
id: `tool-${index + 1}`,
title: tool.name,
description: tool.resultSummary,
}));
}
export function scrollConversationToBottom(
container: Pick<HTMLDivElement, "scrollHeight" | "scrollTop"> | null,
) {
if (!container) return;
container.scrollTop = container.scrollHeight;
}

View File

@@ -1,6 +1,4 @@
import { useCallback, useState } from "react";
import { parse, type ParseResult } from "papaparse";
import { format, parse as parseDate } from "date-fns";
import { useMutation, useQuery } from "convex/react";
import { api } from "../../../convex/_generated/api";
import type { Id } from "../../../convex/_generated/dataModel";
@@ -11,45 +9,9 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ImportPreviewTable, type PreviewRow } from "./ImportPreviewTable";
import { parseComdirectCsv, parseComdirectDate } from "./csvParser";
import { toast } from "sonner";
const HEADER = "Buchungstag;Wertstellung (Valuta);Vorgang;Buchungstext;Umsatz in EUR";
function parseComdirectCsv(text: string): Array<Record<string, string>> {
const lines = text.split(/\r?\n/);
const rows: Array<Record<string, string>> = [];
let inBlock = false;
for (const line of lines) {
if (line.trim() === HEADER) {
inBlock = true;
continue;
}
if (!inBlock) continue;
if (
!line.trim() ||
line.startsWith("Alter Kontostand") ||
line.startsWith("Neuer Kontostand") ||
line.startsWith("Umsätze") ||
line.startsWith("Keine Umsätze")
) {
inBlock = false;
continue;
}
const parsed: ParseResult<string[]> = parse(line, { delimiter: ";", quoteChar: '"' });
const fields = parsed.data[0];
if (!fields || fields.length < 5) continue;
rows.push({
buchungstag: fields[0],
valuta: fields[1],
vorgang: fields[2],
buchungstext: fields[3],
betrag: fields[4],
});
}
return rows;
}
export function CsvImportWizard() {
const accounts = useQuery(api.accounts.list);
const settings = useQuery(api.settings.get);
@@ -77,11 +39,7 @@ export function CsvImportWizard() {
const preview: PreviewRow[] = [];
for (const row of parsed) {
const isPending = row.buchungstag.trim().toLowerCase() === "offen";
let bookingDate: string | undefined;
if (!isPending) {
const d = parseDate(row.buchungstag, "dd.MM.yyyy", new Date());
bookingDate = format(d, "yyyy-MM-dd");
}
const bookingDate = parseComdirectDate(row.buchungstag);
const amount = parseGermanAmount(row.betrag);
const { counterparty, description, rawText } = parseCounterpartyFromBuchungstext(row.buchungstext);
const categoryName = categorize(rawText, amount, row.vorgang, ownNames);
@@ -100,9 +58,7 @@ export function CsvImportWizard() {
});
preview.push({
bookingDate,
valueDate: row.valuta
? format(parseDate(row.valuta, "dd.MM.yyyy", new Date()), "yyyy-MM-dd")
: undefined,
valueDate: parseComdirectDate(row.valuta),
description,
counterparty,
amount,

View File

@@ -0,0 +1,48 @@
import { describe, expect, test } from "vitest";
import { parseComdirectCsv, parseComdirectDate } from "./csvParser";
describe("CSV import parser", () => {
test("parses comdirect exports with metadata rows and Wertstellung header", () => {
const csv = [
"Umsätze Girokonto;Zeitraum: 15.06.2024 - 15.06.2026",
"Neuer Kontostand;-4.031,25 EUR",
"",
"Buchungstag;Wertstellung;Vorgang;Buchungstext;Umsatz in EUR",
"offen;--;Kartenverfügung;Kto/IBAN: 1111 Buchungstext: Reservierung;-35",
"15.06.26;15.06.26;<übertrag / gutschrift>;Empfänger: Max Mustermann Buchungstext: Testzahlung;-174,3",
"15.06.26;15.06.26;Lastschrift / Belastung;Auftraggeber: Stadtwerke Buchungstext: Abschlag;-2,89",
"",
].join("\n");
expect(parseComdirectCsv(csv)).toEqual([
{
buchungstag: "offen",
valuta: "",
vorgang: "Kartenverfügung",
buchungstext: "Kto/IBAN: 1111 Buchungstext: Reservierung",
betrag: "-35",
},
{
buchungstag: "15.06.26",
valuta: "15.06.26",
vorgang: "<übertrag / gutschrift>",
buchungstext: "Empfänger: Max Mustermann Buchungstext: Testzahlung",
betrag: "-174,3",
},
{
buchungstag: "15.06.26",
valuta: "15.06.26",
vorgang: "Lastschrift / Belastung",
buchungstext: "Auftraggeber: Stadtwerke Buchungstext: Abschlag",
betrag: "-2,89",
},
]);
});
test("normalizes German two-digit and four-digit dates", () => {
expect(parseComdirectDate("15.06.26")).toBe("2026-06-15");
expect(parseComdirectDate("15.06.2026")).toBe("2026-06-15");
expect(parseComdirectDate("--")).toBeUndefined();
expect(parseComdirectDate("offen")).toBeUndefined();
});
});

View File

@@ -0,0 +1,97 @@
import { format, isValid, parse as parseDate } from "date-fns";
import { parse, type ParseResult } from "papaparse";
export type ComdirectCsvRow = {
buchungstag: string;
valuta: string;
vorgang: string;
buchungstext: string;
betrag: string;
};
const FOOTER_PREFIXES = [
"Alter Kontostand",
"Neuer Kontostand",
"Umsätze",
"Keine Umsätze",
];
function normalizeHeader(value: string): string {
return value
.replace(/\uFEFF/g, "")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
function isTransactionHeader(fields: string[]): boolean {
const normalized = fields.map(normalizeHeader);
return (
normalized[0] === "buchungstag" &&
(normalized[1] === "wertstellung" || normalized[1] === "wertstellung (valuta)") &&
normalized[2] === "vorgang" &&
normalized[3] === "buchungstext" &&
normalized[4] === "umsatz in eur"
);
}
function isFooter(fields: string[]): boolean {
const firstCell = fields[0]?.trim() ?? "";
return FOOTER_PREFIXES.some((prefix) => firstCell.startsWith(prefix));
}
function normalizeValuta(value: string): string {
const trimmed = value.trim();
return trimmed === "--" ? "" : trimmed;
}
export function parseComdirectCsv(text: string): ComdirectCsvRow[] {
const parsed: ParseResult<string[]> = parse(text, {
delimiter: ";",
quoteChar: '"',
skipEmptyLines: false,
});
const rows: ComdirectCsvRow[] = [];
let inTransactionBlock = false;
for (const fields of parsed.data) {
if (!fields?.length || fields.every((field) => !field.trim())) {
if (inTransactionBlock) break;
continue;
}
if (!inTransactionBlock) {
inTransactionBlock = isTransactionHeader(fields);
continue;
}
if (isFooter(fields)) break;
if (fields.length < 5) continue;
rows.push({
buchungstag: fields[0].trim(),
valuta: normalizeValuta(fields[1]),
vorgang: fields[2].trim(),
buchungstext: fields[3].trim(),
betrag: fields[4].trim(),
});
}
return rows;
}
export function parseComdirectDate(value: string): string | undefined {
const trimmed = value.trim();
if (!trimmed || trimmed === "--" || trimmed.toLowerCase() === "offen") {
return undefined;
}
const match = trimmed.match(/^(\d{2})\.(\d{2})\.(\d{2}|\d{4})$/);
if (match) {
const pattern = match[3].length === 2 ? "dd.MM.yy" : "dd.MM.yyyy";
const date = parseDate(trimmed, pattern, new Date());
if (isValid(date)) return format(date, "yyyy-MM-dd");
}
return undefined;
}

View File

@@ -0,0 +1,154 @@
import { useMemo, useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { Check, ChevronDown, Search } from "lucide-react";
import type { Doc, Id } from "../../../convex/_generated/dataModel";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { groupCategoryOptions, searchCategoryOptions } from "./categoryPickerModel";
import { toast } from "sonner";
type Category = Doc<"categories">;
export function CategoryPicker({
categories,
value,
onSelect,
}: {
categories: Category[] | undefined;
value: Id<"categories"> | undefined;
onSelect: (categoryId: Id<"categories">) => Promise<void>;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [pendingId, setPendingId] = useState<Id<"categories"> | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const selectedCategory = useMemo(
() => categories?.find((category) => category._id === value),
[categories, value],
);
const groupedCategories = useMemo(
() => groupCategoryOptions(searchCategoryOptions(categories ?? [], query)),
[categories, query],
);
const handleSelect = async (categoryId: Id<"categories">) => {
if (categoryId === value) {
setOpen(false);
return;
}
setPendingId(categoryId);
try {
await onSelect(categoryId);
setOpen(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Kategorie konnte nicht gespeichert werden");
} finally {
setPendingId(null);
}
};
return (
<Popover.Root
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) setQuery("");
}}
>
<Popover.Trigger asChild>
<button
type="button"
className="group inline-flex h-7 max-w-[180px] items-center gap-1 rounded px-1 text-left text-xs transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={
selectedCategory
? `Kategorie ändern: ${selectedCategory.name}`
: "Kategorie auswählen"
}
>
{selectedCategory ? (
<Badge
className="max-w-[155px] whitespace-nowrap border-none px-2 py-0.5 text-[11px] leading-4 shadow-none"
style={{ backgroundColor: selectedCategory.color, color: "#fff" }}
>
<span className="min-w-0 truncate">{selectedCategory.name}</span>
</Badge>
) : (
<span className="max-w-[145px] truncate text-muted-foreground">Kategorie...</span>
)}
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-70 group-focus-visible:opacity-70 group-data-[state=open]:opacity-70" />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="start"
sideOffset={6}
className="z-50 w-[min(320px,calc(100vw-2rem))] rounded-md border bg-popover p-2 text-popover-foreground shadow-lg"
onOpenAutoFocus={(event) => {
event.preventDefault();
searchRef.current?.focus();
}}
>
<div className="relative mb-2">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Kategorie suchen..."
className="h-8 pl-8 text-sm"
/>
</div>
<div className="max-h-80 overflow-y-auto pr-1">
{groupedCategories.length === 0 ? (
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
Keine Kategorie gefunden
</div>
) : (
groupedCategories.map((group) => (
<div key={group.key} className="pb-1 last:pb-0">
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{group.label}
</div>
<div className="space-y-0.5">
{group.options.map((category) => {
const selected = category._id === value;
const pending = category._id === pendingId;
return (
<button
key={category._id}
type="button"
disabled={pendingId !== null}
onClick={() => void handleSelect(category._id)}
className={cn(
"flex h-8 w-full items-center gap-2 rounded-sm px-2 text-left text-sm transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-60",
selected && "bg-accent/70",
)}
>
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: category.color }}
/>
<span className="min-w-0 flex-1 truncate">{category.name}</span>
{pending ? (
<span className="text-xs text-muted-foreground">Speichert</span>
) : (
selected && <Check className="h-4 w-4 shrink-0" />
)}
</button>
);
})}
</div>
</div>
))
)}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { groupCategoryOptions, searchCategoryOptions } from "./categoryPickerModel";
const categories = [
{
_id: "income-1",
name: "Gehalt & Besoldung",
kind: "einnahme",
color: "#22c55e",
sortOrder: 1,
},
{
_id: "fixed-1",
name: "Miete & Wohnen",
kind: "ausgabe",
block: "wiederkehrend",
color: "#6366f1",
sortOrder: 10,
},
{
_id: "variable-1",
name: "Restaurant, Lieferdienst & Gastro",
kind: "ausgabe",
block: "variabel",
color: "#dc2626",
sortOrder: 37,
},
{
_id: "fallback-1",
name: "Nicht zugeordnetes Einkommen",
kind: "einnahme",
color: "#6ee7b7",
sortOrder: 99,
},
] as const;
describe("categoryPickerModel", () => {
it("groups category options by income, fixed expenses, and variable expenses", () => {
const groups = groupCategoryOptions(categories);
expect(groups).toEqual([
{
key: "income",
label: "Einnahmen",
options: [categories[0], categories[3]],
},
{
key: "fixed-expenses",
label: "Fixe Ausgaben",
options: [categories[1]],
},
{
key: "variable-expenses",
label: "Variable Ausgaben",
options: [categories[2]],
},
]);
});
it("filters category options by name before grouping them", () => {
const filtered = searchCategoryOptions(categories, "liefer");
expect(groupCategoryOptions(filtered)).toEqual([
{
key: "variable-expenses",
label: "Variable Ausgaben",
options: [categories[2]],
},
]);
});
});

View File

@@ -0,0 +1,56 @@
export type CategoryPickerOption = {
_id: string;
name: string;
kind: "einnahme" | "ausgabe";
block?: "wiederkehrend" | "variabel";
color: string;
sortOrder: number;
};
export type CategoryPickerGroup<T extends CategoryPickerOption = CategoryPickerOption> = {
key: "income" | "fixed-expenses" | "variable-expenses";
label: string;
options: T[];
};
const GROUPS: Array<Pick<CategoryPickerGroup, "key" | "label">> = [
{ key: "income", label: "Einnahmen" },
{ key: "fixed-expenses", label: "Fixe Ausgaben" },
{ key: "variable-expenses", label: "Variable Ausgaben" },
];
export function searchCategoryOptions<T extends CategoryPickerOption>(
categories: readonly T[],
query: string,
): T[] {
const normalizedQuery = query.trim().toLocaleLowerCase("de-DE");
if (!normalizedQuery) return [...categories];
return categories.filter((category) =>
category.name.toLocaleLowerCase("de-DE").includes(normalizedQuery),
);
}
export function groupCategoryOptions<T extends CategoryPickerOption>(
categories: readonly T[],
): Array<CategoryPickerGroup<T>> {
const groups = new Map<CategoryPickerGroup["key"], T[]>(
GROUPS.map((group) => [group.key, []]),
);
for (const category of categories) {
groups.get(getCategoryGroupKey(category))?.push(category);
}
return GROUPS.map((group) => ({
...group,
options: groups.get(group.key) ?? [],
})).filter((group) => group.options.length > 0);
}
function getCategoryGroupKey(category: CategoryPickerOption): CategoryPickerGroup["key"] {
if (category.kind === "einnahme") return "income";
if (category.block === "wiederkehrend") return "fixed-expenses";
return "variable-expenses";
}

View File

@@ -9,6 +9,7 @@ import { CategoryBreakdownChart, FixedVariableSplit } from "@/components/charts/
import { amountClass, formatAmount, formatDate, pct } from "@/lib/format";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import type { Id } from "../../convex/_generated/dataModel";
import { AccountBalanceStrip } from "@/components/accounts/AccountBalanceStrip";
function KpiCard({ title, value, className }: { title: string; value: string; className?: string }) {
return (
@@ -43,12 +44,14 @@ export function DashboardPage() {
return (
<div className="space-y-6">
<AccountBalanceStrip />
<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="Ausgaben" value={formatAmount(summary.expenses)} className={amountClass(summary.expenses)} />
<KpiCard title="Fixkosten" value={formatAmount(summary.fixedCosts)} className={amountClass(summary.fixedCosts)} />
<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
title="Sparquote"
value={summary.savingsRate === null ? "" : pct.format(summary.savingsRate)}

View File

@@ -1,14 +1,20 @@
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { useAction, useMutation, usePaginatedQuery, useQuery } from "convex/react";
import { MessageCircle, Send, Loader2 } from "lucide-react";
import { MessageCircle } from "lucide-react";
import { api } from "../../convex/_generated/api";
import type { Id } from "../../convex/_generated/dataModel";
import { useAccountFilterId } from "@/components/layout/AccountFilter";
import { useFilters } from "@/context/FilterContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import {
AgentActionPlanList,
AgentConversation,
AgentPromptInput,
type AgentActionPlan,
type AgentChatMessage,
} from "@/components/chat/AgentChat";
import { scrollConversationToBottom } from "@/components/chat/agentChatModel";
import { ChatHistory, type ChatHistoryItem } from "@/components/chat/ChatHistory";
import { toast } from "sonner";
@@ -17,11 +23,22 @@ type ToolTrace = {
inputSummary: string;
resultSummary: string;
};
type ChatSource = {
id: string;
title: string;
description?: string;
};
type ChatCitation = {
marker: string;
sourceId: string;
};
type UserChatMessage = { role: "user"; content: string };
type AssistantChatMessage = {
role: "assistant";
content: string;
toolTrace?: ToolTrace[];
sources?: ChatSource[];
citations?: ChatCitation[];
};
type ChatMessage = UserChatMessage | AssistantChatMessage;
type LegacyChatSession = {
@@ -41,6 +58,13 @@ const initialAssistantMessage: ChatMessage = {
};
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 {
if (!Array.isArray(value)) return undefined;
const trace = value.flatMap((item) => {
@@ -66,6 +90,46 @@ function normalizeToolTrace(value: unknown): ToolTrace[] | undefined {
return trace.length > 0 ? trace : undefined;
}
function normalizeSources(value: unknown): ChatSource[] | undefined {
if (!Array.isArray(value)) return undefined;
const sources = value.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const candidate = item as Record<string, unknown>;
if (typeof candidate.id !== "string" || typeof candidate.title !== "string") return [];
if (
candidate.description !== undefined &&
typeof candidate.description !== "string"
) {
return [];
}
return [
{
id: candidate.id,
title: candidate.title,
...(candidate.description ? { description: candidate.description } : {}),
},
];
});
return sources.length > 0 ? sources : undefined;
}
function normalizeCitations(value: unknown): ChatCitation[] | undefined {
if (!Array.isArray(value)) return undefined;
const citations = value.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const candidate = item as Record<string, unknown>;
if (typeof candidate.marker !== "string" || typeof candidate.sourceId !== "string") {
return [];
}
return [{ marker: candidate.marker, sourceId: candidate.sourceId }];
});
return citations.length > 0 ? citations : undefined;
}
function normalizeMessage(value: unknown): ChatMessage | null {
if (!value || typeof value !== "object") return null;
const candidate = value as Record<string, unknown>;
@@ -75,9 +139,15 @@ function normalizeMessage(value: unknown): ChatMessage | null {
}
if (candidate.role === "assistant") {
const toolTrace = normalizeToolTrace(candidate.toolTrace);
return toolTrace
? { role: "assistant", content: candidate.content, toolTrace }
: { role: "assistant", content: candidate.content };
const sources = normalizeSources(candidate.sources);
const citations = normalizeCitations(candidate.citations);
return {
role: "assistant",
content: candidate.content,
...(toolTrace ? { toolTrace } : {}),
...(sources ? { sources } : {}),
...(citations ? { citations } : {}),
};
}
return null;
@@ -134,6 +204,7 @@ export function SavingsChatPage() {
const [draft, setDraft] = useState("");
const [selectedSessionId, setSelectedSessionId] = useState<Id<"chatSessions"> | undefined>();
const [isSubmitting, setIsSubmitting] = useState(false);
const [applyingPlanId, setApplyingPlanId] = useState<string | undefined>();
const [legacyImportResult, setLegacyImportResult] = useState<{
key: string;
importedCount: number;
@@ -163,6 +234,24 @@ export function SavingsChatPage() {
[messagesQuery.results],
);
const displayMessages = activeSessionId && messages.length > 0 ? messages : fallbackMessages;
const agentMessages: AgentChatMessage[] = useMemo(
() =>
displayMessages.map((message) => ({
id: message._id,
role: message.role,
content: message.content,
...(message.role === "assistant" && message.toolTrace
? { toolTrace: message.toolTrace }
: {}),
...(message.role === "assistant" && message.sources
? { sources: message.sources }
: {}),
...(message.role === "assistant" && message.citations
? { citations: message.citations }
: {}),
})),
[displayMessages],
);
const context = useQuery(api.savingsChat.getContext, {
from,
@@ -171,9 +260,15 @@ export function SavingsChatPage() {
basis: monthBasis,
});
const currentUser = useQuery(api.users.currentUser);
const pendingActionPlans = useQuery(
api.savingsChatActionPlans.listPendingActionPlans,
activeSessionId ? { sessionId: activeSessionId } : "skip",
);
const createSession = useMutation(api.savingsChatHistory.createSession);
const deleteSession = useMutation(api.savingsChatHistory.deleteSession);
const importLocalSession = useMutation(api.savingsChatHistory.importLocalSession);
const applyActionPlan = useMutation(api.savingsChatActionPlans.applyActionPlan);
const dismissActionPlan = useMutation(api.savingsChatActionPlans.dismissActionPlan);
const sendMessage = useAction(api.savingsChat.sendMessage);
const importMarkerKey = currentUser ? `${IMPORTED_KEY}:${currentUser._id}` : undefined;
const legacyImportComplete = Boolean(
@@ -186,8 +281,6 @@ export function SavingsChatPage() {
? legacyImportResult.importedCount
: 0;
const buttonDisabled = isSubmitting || draft.trim().length === 0 || !activeSessionId;
const formatAmount = (amount: number) =>
new Intl.NumberFormat("de-DE", {
style: "currency",
@@ -202,7 +295,7 @@ export function SavingsChatPage() {
};
useEffect(() => {
listRef.current?.lastElementChild?.scrollIntoView({ behavior: "smooth" });
scrollConversationToBottom(listRef.current);
}, [displayMessages.length, activeSessionId]);
useEffect(() => {
@@ -296,6 +389,33 @@ export function SavingsChatPage() {
})),
[sessions],
);
const actionPlans = (pendingActionPlans ?? []) as AgentActionPlan[];
const applyPlan = (planId: string) => {
setApplyingPlanId(planId);
void applyActionPlan({ planId: planId as Id<"agentActionPlans"> })
.then((result) => {
toast.success(result.summary);
})
.catch((error) => {
console.error(error);
toast.error(error instanceof Error ? error.message : "Vorschlag konnte nicht angewendet werden.");
})
.finally(() => setApplyingPlanId(undefined));
};
const dismissPlan = (planId: string) => {
setApplyingPlanId(planId);
void dismissActionPlan({ planId: planId as Id<"agentActionPlans"> })
.then(() => {
toast.message("Vorschlag verworfen");
})
.catch((error) => {
console.error(error);
toast.error(error instanceof Error ? error.message : "Vorschlag konnte nicht verworfen werden.");
})
.finally(() => setApplyingPlanId(undefined));
};
const submit = async (event: FormEvent) => {
event.preventDefault();
@@ -313,6 +433,7 @@ export function SavingsChatPage() {
to,
accountId,
basis: monthBasis,
today: localDateKey(),
});
} catch (error) {
console.error(error);
@@ -353,60 +474,27 @@ export function SavingsChatPage() {
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="h-[52vh] overflow-y-auto" ref={listRef}>
<div className="space-y-3">
{displayMessages.map((message) => (
<div
key={message._id}
className={`rounded-lg border p-3 ${
message.role === "user" ? "bg-muted/50" : "bg-background"
}`}
>
<p className="text-xs uppercase text-muted-foreground">{message.role}</p>
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
{message.role === "assistant" && message.toolTrace && message.toolTrace.length > 0 && (
<div className="mt-3 rounded-md border bg-muted/30 p-2">
<p className="text-xs font-medium text-muted-foreground">
Verwendete Werkzeuge
</p>
<div className="mt-2 space-y-2">
{message.toolTrace.map((tool, toolIndex) => (
<div key={`${tool.name}-${toolIndex}`} className="text-xs">
<p className="font-medium">{tool.name}</p>
<p className="text-muted-foreground">{tool.resultSummary}</p>
</div>
))}
</div>
</div>
)}
</div>
))}
{isSubmitting && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Denk mit der KI nach
</div>
)}
</div>
</div>
</CardContent>
</Card>
<AgentConversation
messages={agentMessages}
isSubmitting={isSubmitting}
scrollRef={listRef}
/>
<form className="flex gap-2" onSubmit={submit}>
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder={activeSession ? "Welche Auswertung soll ich machen?" : "Chat wird vorbereitet…"}
disabled={isSubmitting || !activeSessionId}
autoFocus
/>
<Button type="submit" disabled={buttonDisabled}>
<Send className="h-4 w-4" />
Senden
</Button>
</form>
<AgentActionPlanList
plans={actionPlans}
isApplying={Boolean(applyingPlanId)}
onApply={applyPlan}
onDismiss={dismissPlan}
/>
<AgentPromptInput
value={draft}
onChange={setDraft}
onSubmit={submit}
placeholder={activeSession ? "Welche Auswertung soll ich machen?" : "Chat wird vorbereitet..."}
disabled={isSubmitting || !activeSessionId}
isSubmitting={isSubmitting}
/>
<Separator />
<p className="text-xs text-muted-foreground">

View File

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

View File

@@ -28,6 +28,7 @@ import { Badge } from "@/components/ui/badge";
import { amountClass, formatAmount, formatDate, formatMonth } from "@/lib/format";
import { TransactionFormDialog } from "@/components/transactions/TransactionFormDialog";
import { AssignMonthDialog } from "@/components/transactions/AssignMonthDialog";
import { CategoryPicker } from "@/components/transactions/CategoryPicker";
import { toast } from "sonner";
import {
getVisibleSelectionState,
@@ -82,64 +83,18 @@ const AccountCell = memo(function AccountCell({ name }: { name: string | undefin
const CategoryCell = memo(function CategoryCell({
tx,
categories,
categoryMap,
onUpdate,
}: {
tx: Tx;
categories: Category[] | undefined;
categoryMap: Map<Id<"categories">, Category>;
onUpdate: (id: Id<"transactions">, categoryId: Id<"categories">) => void;
onUpdate: (id: Id<"transactions">, categoryId: Id<"categories">) => Promise<void>;
}) {
const [open, setOpen] = useState(false);
const cat = tx.categoryId ? categoryMap.get(tx.categoryId) : null;
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex h-6 items-center rounded px-1.5 text-xs hover:bg-accent"
>
{cat ? (
<Badge style={{ backgroundColor: cat.color, color: "#fff", border: "none" }}>
{cat.name}
</Badge>
) : (
<span className="text-muted-foreground">Kategorie</span>
)}
</button>
);
}
return (
<Select
defaultOpen
value={tx.categoryId ?? "none"}
onValueChange={(v) => {
if (v !== "none") onUpdate(tx._id, v as Id<"categories">);
setOpen(false);
}}
onOpenChange={(o) => {
if (!o) setOpen(false);
}}
>
<SelectTrigger className="h-7 w-[130px]">
<SelectValue placeholder="Kategorie" />
</SelectTrigger>
<SelectContent>
{categories?.map((c) => (
<SelectItem key={c._id} value={c._id}>
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: c.color }}
/>
{c.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<CategoryPicker
categories={categories}
value={tx.categoryId}
onSelect={(categoryId) => onUpdate(tx._id, categoryId)}
/>
);
});
@@ -228,10 +183,6 @@ export function TransactionsPage() {
{ initialNumItems: 50 },
);
const categoryMap = useMemo(
() => new Map(categories?.map((c) => [c._id, c])),
[categories],
);
const accountMap = useMemo(
() => new Map(accounts?.map((a) => [a._id, a.name])),
[accounts],
@@ -247,8 +198,8 @@ export function TransactionsPage() {
);
const handleUpdateCategory = useCallback(
(id: Id<"transactions">, categoryId: Id<"categories">) => {
void updateTx({ id, categoryId });
async (id: Id<"transactions">, categoryId: Id<"categories">) => {
await updateTx({ id, categoryId });
},
[updateTx],
);
@@ -343,7 +294,6 @@ export function TransactionsPage() {
<CategoryCell
tx={row.original}
categories={categories}
categoryMap={categoryMap}
onUpdate={handleUpdateCategory}
/>
),
@@ -382,7 +332,6 @@ export function TransactionsPage() {
],
[
categories,
categoryMap,
accountMap,
handleUpdateCategory,
handleEdit,