Compare commits
16 Commits
codex/savi
...
b7dae31fe1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7dae31fe1 | ||
|
|
6e687dc9b5 | ||
|
|
993616c2f9 | ||
|
|
4b0128a7ee | ||
|
|
c072ab4593 | ||
|
|
fba74ec217 | ||
|
|
7ed9b521c4 | ||
|
|
15bf5d2036 | ||
|
|
9f17d4d1e1 | ||
|
|
85af9d7078 | ||
|
|
28d0f4f852 | ||
| 76b73ddb76 | |||
| 4836e12a11 | |||
| 3541d00864 | |||
| 0061d5ad82 | |||
| 3ceccafa57 |
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: TASK-1
|
id: TASK-1
|
||||||
title: Add complete chat transaction context
|
title: Add complete chat transaction context
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-15 13:52'
|
created_date: '2026-06-15 13:52'
|
||||||
updated_date: '2026-06-15 14:02'
|
updated_date: '2026-06-15 19:54'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -61,3 +61,9 @@ Final review:
|
|||||||
- Prior blocker resolved: effective-basis loading now includes rows without effectiveMonth via bookingDate fallback queries.
|
- Prior blocker resolved: effective-basis loading now includes rows without effectiveMonth via bookingDate fallback queries.
|
||||||
- Fresh full npm run lint still fails only on unrelated existing files; no SavingsChatPage issue remains.
|
- Fresh full npm run lint still fails only on unrelated existing files; no SavingsChatPage issue remains.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Closed as Done after explicit user confirmation to mark all tasks done.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
41
backlog/tasks/task-16 - Add-guarded-agent-tools.md
Normal file
41
backlog/tasks/task-16 - Add-guarded-agent-tools.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
id: TASK-16
|
||||||
|
title: Add guarded agent tools
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-06-23 11:55'
|
||||||
|
updated_date: '2026-06-23 19:12'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
ordinal: 16000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Build AI agent tools for category health, category suggestions, guarded category write plans, and current-month spending preview.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Read-only agent tools cover category health, category suggestions, and current-month expense preview
|
||||||
|
- [x] #2 Write-capable agent tools create pending action plans without directly changing data
|
||||||
|
- [x] #3 Pending action plans can be listed, applied, and dismissed from the chat UI
|
||||||
|
- [x] #4 Applying action plans revalidates ownership, status, expiry, and operation limits
|
||||||
|
- [x] #5 Convex and UI tests cover the new tool and confirmation behavior
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Implemented guarded agent tool package. Verification: npx vitest run passed (14 files, 79 tests); npm run build passed. npm run lint still fails on pre-existing unrelated files (bank/config.ts, TanAwaitDialog, fast-refresh/export warnings, SettingsPage effect), with no remaining lint error from the new action-plan module.
|
||||||
|
|
||||||
|
Post-review hardening: added tests and fixes for empty category names, duplicate category renames, stale transaction category plans, and expired plans. Re-verified: convex/savingsChat.test.ts 33 passed; full npx vitest run 81 passed; npm run build passed; npx convex codegen passed.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Implemented guarded AI agent tools with current-month expense preview, category health/suggestion tools, pending action plans, UI apply/dismiss controls, and post-review validation hardening. Verified with full Vitest suite, build, and Convex codegen; lint remains blocked by pre-existing unrelated project issues.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: TASK-2
|
id: TASK-2
|
||||||
title: Render category expense chart values as positive slices
|
title: Render category expense chart values as positive slices
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-15 14:34'
|
created_date: '2026-06-15 14:34'
|
||||||
updated_date: '2026-06-15 14:48'
|
updated_date: '2026-06-15 19:54'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -60,3 +60,9 @@ Verification after layout update:
|
|||||||
|
|
||||||
Note: npm run build still emits the existing Vite chunk-size warning for the main bundle.
|
Note: npm run build still emits the existing Vite chunk-size warning for the main bundle.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Closed as Done after explicit user confirmation to mark all tasks done.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: TASK-3
|
id: TASK-3
|
||||||
title: Build read-only savings agent with tool calls
|
title: Build read-only savings agent with tool calls
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-15 19:02'
|
created_date: '2026-06-15 19:02'
|
||||||
updated_date: '2026-06-15 19:11'
|
updated_date: '2026-06-15 19:54'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -51,3 +51,9 @@ Verification:
|
|||||||
- PASS npm run build
|
- PASS npm run build
|
||||||
- BLOCKED npm run lint on pre-existing unrelated files: convex/bank/comdirectProvider.ts, convex/bank/config.ts, src/components/import/TanAwaitDialog.tsx, layout/ui fast-refresh exports, SettingsPage.tsx, generated eslint-disable warnings, and existing React compiler warnings. No TASK-3 touched file appears in the full-lint error list.
|
- BLOCKED npm run lint on pre-existing unrelated files: convex/bank/comdirectProvider.ts, convex/bank/config.ts, src/components/import/TanAwaitDialog.tsx, layout/ui fast-refresh exports, SettingsPage.tsx, generated eslint-disable warnings, and existing React compiler warnings. No TASK-3 touched file appears in the full-lint error list.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Closed as Done after explicit user confirmation to mark all tasks done.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
43
backlog/tasks/task-3 - Repariere-CSV-Import-Pipeline.md
Normal file
43
backlog/tasks/task-3 - Repariere-CSV-Import-Pipeline.md
Normal 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 -->
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: TASK-4
|
id: TASK-4
|
||||||
title: Add selection total to transactions
|
title: Add selection total to transactions
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-15 19:21'
|
created_date: '2026-06-15 19:21'
|
||||||
updated_date: '2026-06-15 19:24'
|
updated_date: '2026-06-15 19:54'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -39,3 +39,9 @@ Add table selection controls on the transactions page so visible/filtered rows c
|
|||||||
<!-- SECTION:NOTES:BEGIN -->
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
Added failing Vitest coverage for visible bulk selection and selected totals, then implemented transactionsSelection helpers and wired TransactionsPage header checkbox plus selected count/sum. Focused test and build pass. Global lint currently fails on pre-existing unrelated lint errors in Convex/generated/UI files.
|
Added failing Vitest coverage for visible bulk selection and selected totals, then implemented transactionsSelection helpers and wired TransactionsPage header checkbox plus selected count/sum. Focused test and build pass. Global lint currently fails on pre-existing unrelated lint errors in Convex/generated/UI files.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Closed as Done after explicit user confirmation to mark all tasks done.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: TASK-5
|
id: TASK-5
|
||||||
title: Make transaction filters combinable
|
title: Make transaction filters combinable
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-15 19:32'
|
created_date: '2026-06-15 19:32'
|
||||||
updated_date: '2026-06-15 19:34'
|
updated_date: '2026-06-15 19:54'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -38,3 +38,9 @@ Fix the transactions page so global timeframe, account, and month-basis filters
|
|||||||
<!-- SECTION:NOTES:BEGIN -->
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
Root cause: TransactionsPage did not pass global from/to/account/monthBasis filters into api.transactions.list, and transactions.list ignored date filters on the search-index path. Added red Convex tests covering combined filters and effective-month basis, then added basis/date filtering in the query and wired global filters from the page. Focused tests and build pass.
|
Root cause: TransactionsPage did not pass global from/to/account/monthBasis filters into api.transactions.list, and transactions.list ignored date filters on the search-index path. Added red Convex tests covering combined filters and effective-month basis, then added basis/date filtering in the query and wired global filters from the page. Focused tests and build pass.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Closed as Done after explicit user confirmation to mark all tasks done.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: TASK-6
|
id: TASK-6
|
||||||
title: Add transaction filter reset button
|
title: Add transaction filter reset button
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-15 19:35'
|
created_date: '2026-06-15 19:35'
|
||||||
updated_date: '2026-06-15 19:37'
|
updated_date: '2026-06-15 19:54'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: medium
|
priority: medium
|
||||||
@@ -30,3 +30,9 @@ Add a reset button on the transactions page that clears the active filter combin
|
|||||||
<!-- SECTION:NOTES:BEGIN -->
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
Added a transactions toolbar reset button using shared reset defaults. The button clears global filters (current month, all accounts, no categories, effective month basis), clears page filters (search, type, pending-only), and clears row selection. Added Vitest coverage for reset defaults. Focused tests and build pass.
|
Added a transactions toolbar reset button using shared reset defaults. The button clears global filters (current month, all accounts, no categories, effective month basis), clears page filters (search, type, pending-only), and clears row selection. Added Vitest coverage for reset defaults. Focused tests and build pass.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Closed as Done after explicit user confirmation to mark all tasks done.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
id: TASK-7
|
||||||
|
title: Add all read-only savings agent insight tools
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-06-15 19:39'
|
||||||
|
updated_date: '2026-06-15 19:54'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
ordinal: 7000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Expand Talk to Savings with eight additional read-only AI SDK tools for accounts, categories, recurring transactions, anomalies, uncategorized transactions, period comparison, fixed-cost forecasting, and savings-rate explanations.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Agent registers all eight new read-only tools and returns compact trace summaries without raw/private fields
|
||||||
|
- [x] #2 Metadata and data-quality tools return scoped, sanitized accounts, categories, and uncategorized transaction insight
|
||||||
|
- [x] #3 Period, savings-rate, recurring, fixed-cost forecast, and anomaly tools compute deterministic aggregates
|
||||||
|
- [x] #4 Convex tests cover each new tool and mocked ask() registration
|
||||||
|
- [x] #5 Focused tests, targeted lint, build, and full lint status are documented
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Add failing coverage for all eight read-only savings insight tools and ask() registry
|
||||||
|
2. Implement scoped/sanitized internal Convex queries and deterministic finance helpers
|
||||||
|
3. Register the tools in savingsChat.ask and add compact trace summaries
|
||||||
|
4. Verify focused tests, targeted lint, build, and document full-lint blockers
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Implemented eight new read-only tools in convex/savingsChat.ts: accounts, categories, recurring patterns, anomalies, uncategorized transactions, period comparison, fixed-cost forecast, and savings-rate explanation.
|
||||||
|
Verification: npx vitest convex/savingsChat.test.ts --run --reporter=dot passed (17 tests).
|
||||||
|
Verification: npx eslint convex/savingsChat.ts convex/savingsChat.test.ts src/pages/SavingsChatPage.tsx passed.
|
||||||
|
Verification: npm run build passed with existing Vite chunk-size warning.
|
||||||
|
Full npm run lint still fails on unrelated existing files: convex/bank/comdirectProvider.ts, convex/bank/config.ts, src/components/import/TanAwaitDialog.tsx, several react-refresh only-export-components files, and src/pages/SettingsPage.tsx; warnings also remain in generated Convex files and React Hook Form/TanStack Table locations.
|
||||||
|
|
||||||
|
Hardened compare_periods snapshots to omit category IDs while keeping existing summarize_spending output compatible. Re-ran focused Vitest, targeted ESLint, and build successfully after this change.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Closed as Done after explicit user confirmation to mark all tasks done. Implementation and verification notes are already recorded on the task.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
47
backlog/tasks/task-8 - Fix-savings-tool-category-matching.md
Normal file
47
backlog/tasks/task-8 - Fix-savings-tool-category-matching.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
id: TASK-8
|
||||||
|
title: Fix savings tool category matching
|
||||||
|
status: In Progress
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-06-15 20:10'
|
||||||
|
updated_date: '2026-06-15 20:19'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
ordinal: 8000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Make Talk to Savings read-only tools resolve user/model category names like Supermärkte or Lebensmittel und Supermarkt to existing categories such as Lebensmittel & Supermarkt, and surface diagnostics instead of misleading silent zero results.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Category filter aliases resolve ampersand/und, casing, punctuation, umlauts, and simple German plural/flexion variants
|
||||||
|
- [x] #2 All savings tools that accept categoryNames use shared resolution diagnostics
|
||||||
|
- [x] #3 Unknown or ambiguous category filters return diagnostics and compact trace summaries instead of silently confident zero results
|
||||||
|
- [x] #4 Regression tests cover Lebensmittel und Supermarkt, Supermärkte, unknown diagnostics, and trace summaries
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Add failing Convex regression tests for tolerant categoryNames and diagnostics
|
||||||
|
2. Implement shared category-name normalization and resolver in convex/savingsChat.ts
|
||||||
|
3. Thread diagnostics through every categoryNames-aware tool output and compact traces
|
||||||
|
4. Update tool/prompt guidance to avoid confident zero answers on unresolved filters
|
||||||
|
5. Run focused Vitest, targeted ESLint, and production build
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Implemented shared tolerant category resolver for savings tools and added diagnostics to categoryNames-aware outputs.
|
||||||
|
Verification passed: npx vitest convex/savingsChat.test.ts --run (20 tests), npx eslint convex/savingsChat.ts convex/savingsChat.test.ts, npm run build (with existing Vite chunk-size warning).
|
||||||
|
|
||||||
|
Added an additional regression test to preserve the virtual Ohne Kategorie filter while moving categoryNames to resolver-based category IDs. Re-ran focused Vitest, targeted ESLint, and build successfully after this adjustment.
|
||||||
|
|
||||||
|
Addressed QA review findings: added ASCII transliteration coverage for Supermaerkte, explicit ambiguous category filter coverage, and multi-diagnostic compact trace summaries. Final verification passed: npx vitest convex/savingsChat.test.ts --run (22 tests), npx eslint convex/savingsChat.ts convex/savingsChat.test.ts, npm run build (with existing Vite chunk-size warning).
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
id: TASK-9
|
||||||
|
title: Add Convex-synced savings chat history
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-06-16 07:58'
|
||||||
|
updated_date: '2026-06-16 08:23'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
ordinal: 9000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Persist savings chat sessions and messages in Convex per authenticated user so chat history syncs across devices, with automatic one-time import of existing localStorage chats.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Chat sessions and messages are stored in Convex per authenticated user and synchronize across devices
|
||||||
|
- [x] #2 Existing localStorage savings chats are imported once, deduplicated by legacy local id, and old local data is not deleted
|
||||||
|
- [x] #3 Sending a chat message stores both user and assistant messages, including assistant tool traces
|
||||||
|
- [x] #4 Deleting a chat hides it account-wide and unauthorized users cannot read or mutate another user's chats
|
||||||
|
- [x] #5 Focused Convex tests, focused ESLint, and build verification are run and recorded
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Add failing Convex tests for user-scoped chat history, legacy import, sendMessage persistence, and deletion
|
||||||
|
2. Add chatSessions/chatMessages schema tables
|
||||||
|
3. Implement savingsChatHistory queries and mutations
|
||||||
|
4. Refactor savingsChat AI generation helper and add sendMessage action
|
||||||
|
5. Move SavingsChatPage to Convex-backed state and automatic legacy import
|
||||||
|
6. Run focused tests, focused lint, build, and record verification notes
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Implementation completed on branch codex/convex-chat-history-sync.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- RED verified first: npx vitest convex/savingsChat.test.ts --run failed with 3 missing chat-history/sendMessage tests.
|
||||||
|
- PASS npx vitest convex/savingsChat.test.ts --run (25 tests).
|
||||||
|
- PASS npx eslint convex/schema.ts convex/savingsChat.ts convex/savingsChatHistory.ts convex/savingsChat.test.ts src/pages/SavingsChatPage.tsx src/components/chat/ChatHistory.tsx.
|
||||||
|
- PASS npm run build (Vite chunk-size warning remains).
|
||||||
|
- FULL LINT npm run lint still fails on pre-existing unrelated files: convex/bank/comdirectProvider.ts, convex/bank/config.ts, src/components/import/TanAwaitDialog.tsx, layout/ui fast-refresh exports, src/main.tsx, src/pages/SettingsPage.tsx, plus existing React Compiler warnings. No changed feature file appears in the full-lint error list.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Ran npx convex codegen; first sandboxed attempt failed on DNS/fetch to Sentry, rerun outside sandbox succeeded and updated convex/_generated/api.d.ts.
|
||||||
|
- Backlog task remains In Progress pending explicit user confirmation after manual testing.
|
||||||
|
|
||||||
|
Review follow-up:
|
||||||
|
- Addressed code-review finding: legacy import marker is now scoped by current Convex user id instead of only browser-global.
|
||||||
|
- Addressed code-review finding: sendMessage now persists an assistant failure message if AI generation fails after saving the user message.
|
||||||
|
- Added regression coverage for unauthorized listMessages, unauthorized sendMessage, and failed-generation persistence.
|
||||||
|
|
||||||
|
Final verification after review fixes:
|
||||||
|
- PASS npx vitest convex/savingsChat.test.ts --run (26 tests).
|
||||||
|
- PASS npx eslint convex/schema.ts convex/savingsChat.ts convex/savingsChatHistory.ts convex/savingsChat.test.ts src/pages/SavingsChatPage.tsx src/components/chat/ChatHistory.tsx.
|
||||||
|
- PASS npm run build (Vite chunk-size warning remains).
|
||||||
|
- FULL LINT npm run lint still fails only on pre-existing unrelated files and generated-file warnings; no changed feature file is in the full-lint error list.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Shipped Convex-backed savings chat history sync with user-scoped sessions/messages, automatic per-user legacy localStorage import, server-persisted sendMessage flow, account-wide delete behavior, authorization coverage, and focused verification. Full lint still has unrelated pre-existing failures documented in notes.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
6
convex/_generated/api.d.ts
vendored
6
convex/_generated/api.d.ts
vendored
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import type * as accounts from "../accounts.js";
|
import type * as accounts from "../accounts.js";
|
||||||
import type * as auth from "../auth.js";
|
import type * as auth from "../auth.js";
|
||||||
|
import type * as bank_balances from "../bank/balances.js";
|
||||||
import type * as bank_comdirectProvider from "../bank/comdirectProvider.js";
|
import type * as bank_comdirectProvider from "../bank/comdirectProvider.js";
|
||||||
import type * as bank_config from "../bank/config.js";
|
import type * as bank_config from "../bank/config.js";
|
||||||
import type * as bank_fintsConfig from "../bank/fintsConfig.js";
|
import type * as bank_fintsConfig from "../bank/fintsConfig.js";
|
||||||
@@ -35,6 +36,8 @@ import type * as lib_month from "../lib/month.js";
|
|||||||
import type * as lib_seedCategories from "../lib/seedCategories.js";
|
import type * as lib_seedCategories from "../lib/seedCategories.js";
|
||||||
import type * as loans from "../loans.js";
|
import type * as loans from "../loans.js";
|
||||||
import type * as savingsChat from "../savingsChat.js";
|
import type * as savingsChat from "../savingsChat.js";
|
||||||
|
import type * as savingsChatActionPlans from "../savingsChatActionPlans.js";
|
||||||
|
import type * as savingsChatHistory from "../savingsChatHistory.js";
|
||||||
import type * as settings from "../settings.js";
|
import type * as settings from "../settings.js";
|
||||||
import type * as transactions from "../transactions.js";
|
import type * as transactions from "../transactions.js";
|
||||||
import type * as users from "../users.js";
|
import type * as users from "../users.js";
|
||||||
@@ -48,6 +51,7 @@ import type {
|
|||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
accounts: typeof accounts;
|
accounts: typeof accounts;
|
||||||
auth: typeof auth;
|
auth: typeof auth;
|
||||||
|
"bank/balances": typeof bank_balances;
|
||||||
"bank/comdirectProvider": typeof bank_comdirectProvider;
|
"bank/comdirectProvider": typeof bank_comdirectProvider;
|
||||||
"bank/config": typeof bank_config;
|
"bank/config": typeof bank_config;
|
||||||
"bank/fintsConfig": typeof bank_fintsConfig;
|
"bank/fintsConfig": typeof bank_fintsConfig;
|
||||||
@@ -73,6 +77,8 @@ declare const fullApi: ApiFromModules<{
|
|||||||
"lib/seedCategories": typeof lib_seedCategories;
|
"lib/seedCategories": typeof lib_seedCategories;
|
||||||
loans: typeof loans;
|
loans: typeof loans;
|
||||||
savingsChat: typeof savingsChat;
|
savingsChat: typeof savingsChat;
|
||||||
|
savingsChatActionPlans: typeof savingsChatActionPlans;
|
||||||
|
savingsChatHistory: typeof savingsChatHistory;
|
||||||
settings: typeof settings;
|
settings: typeof settings;
|
||||||
transactions: typeof transactions;
|
transactions: typeof transactions;
|
||||||
users: typeof users;
|
users: typeof users;
|
||||||
|
|||||||
178
convex/balances.test.ts
Normal file
178
convex/balances.test.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
import { convexTest } from "convex-test";
|
||||||
|
import { makeFunctionReference } from "convex/server";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import type { Id } from "./_generated/dataModel";
|
||||||
|
import schema from "./schema";
|
||||||
|
|
||||||
|
const modules = import.meta.glob("./**/*.ts");
|
||||||
|
delete modules["./balances.test.ts"];
|
||||||
|
|
||||||
|
const listLatest = makeFunctionReference<"query">("bank/balances:listLatest");
|
||||||
|
const upsertAccountBalance = makeFunctionReference<"mutation">(
|
||||||
|
"bank/internal:upsertAccountBalance",
|
||||||
|
);
|
||||||
|
|
||||||
|
type LatestBalanceRow = {
|
||||||
|
accountId: Id<"accounts">;
|
||||||
|
accountName: string;
|
||||||
|
balance: number | null;
|
||||||
|
status: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function seedBalanceFixture() {
|
||||||
|
const t = convexTest(schema, modules);
|
||||||
|
|
||||||
|
const seeded = await t.run(async (ctx) => {
|
||||||
|
const userId = await ctx.db.insert("users", {
|
||||||
|
name: "Balance User",
|
||||||
|
email: "balance@example.com",
|
||||||
|
});
|
||||||
|
const otherUserId = await ctx.db.insert("users", {
|
||||||
|
name: "Other User",
|
||||||
|
email: "other-balance@example.com",
|
||||||
|
});
|
||||||
|
const accountId = await ctx.db.insert("accounts", {
|
||||||
|
userId,
|
||||||
|
name: "Girokonto",
|
||||||
|
type: "giro",
|
||||||
|
iban: "DE89370400440532013000",
|
||||||
|
openingBalance: 125,
|
||||||
|
currency: "EUR",
|
||||||
|
isArchived: false,
|
||||||
|
externalId: "giro-1",
|
||||||
|
});
|
||||||
|
const archivedAccountId = await ctx.db.insert("accounts", {
|
||||||
|
userId,
|
||||||
|
name: "Altes Konto",
|
||||||
|
type: "giro",
|
||||||
|
openingBalance: 50,
|
||||||
|
currency: "EUR",
|
||||||
|
isArchived: true,
|
||||||
|
externalId: "old-1",
|
||||||
|
});
|
||||||
|
const otherAccountId = await ctx.db.insert("accounts", {
|
||||||
|
userId: otherUserId,
|
||||||
|
name: "Hidden Konto",
|
||||||
|
type: "giro",
|
||||||
|
openingBalance: 999,
|
||||||
|
currency: "EUR",
|
||||||
|
isArchived: false,
|
||||||
|
externalId: "hidden-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { userId, accountId, archivedAccountId, otherUserId, otherAccountId };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
t,
|
||||||
|
seeded,
|
||||||
|
asUser: t.withIdentity({
|
||||||
|
subject: `${seeded.userId}|test-session`,
|
||||||
|
tokenIdentifier: `test:${seeded.userId}`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bank/balances.listLatest", () => {
|
||||||
|
test("returns a missing state for active accounts without live balance snapshots", async () => {
|
||||||
|
const { asUser, seeded } = await seedBalanceFixture();
|
||||||
|
|
||||||
|
const result = await asUser.query(listLatest, {});
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
accountId: seeded.accountId,
|
||||||
|
accountName: "Girokonto",
|
||||||
|
accountType: "giro",
|
||||||
|
iban: "DE89370400440532013000",
|
||||||
|
externalId: "giro-1",
|
||||||
|
balance: null,
|
||||||
|
currency: "EUR",
|
||||||
|
provider: null,
|
||||||
|
fetchedAt: null,
|
||||||
|
status: "missing",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns the newest live balance without mutating openingBalance", async () => {
|
||||||
|
const { t, asUser, seeded } = await seedBalanceFixture();
|
||||||
|
|
||||||
|
await t.mutation(upsertAccountBalance, {
|
||||||
|
userId: seeded.userId,
|
||||||
|
accountId: seeded.accountId as Id<"accounts">,
|
||||||
|
externalId: "giro-1",
|
||||||
|
provider: "fints",
|
||||||
|
balance: 900,
|
||||||
|
currency: "EUR",
|
||||||
|
fetchedAt: 1_780_000_000_000,
|
||||||
|
status: "fresh",
|
||||||
|
});
|
||||||
|
await t.mutation(upsertAccountBalance, {
|
||||||
|
userId: seeded.userId,
|
||||||
|
accountId: seeded.accountId as Id<"accounts">,
|
||||||
|
externalId: "giro-1",
|
||||||
|
provider: "comdirect",
|
||||||
|
balance: 1234.56,
|
||||||
|
currency: "EUR",
|
||||||
|
asOf: "2026-06-23",
|
||||||
|
fetchedAt: 1_780_000_060_000,
|
||||||
|
status: "fresh",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await asUser.query(listLatest, {});
|
||||||
|
const account = await t.run((ctx) => ctx.db.get(seeded.accountId));
|
||||||
|
|
||||||
|
expect(result).toMatchObject([
|
||||||
|
{
|
||||||
|
accountId: seeded.accountId,
|
||||||
|
accountName: "Girokonto",
|
||||||
|
balance: 1234.56,
|
||||||
|
currency: "EUR",
|
||||||
|
provider: "comdirect",
|
||||||
|
fetchedAt: 1_780_000_060_000,
|
||||||
|
asOf: "2026-06-23",
|
||||||
|
status: "fresh",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(account?.openingBalance).toBe(125);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can include archived accounts for settings and preserves error snapshots", async () => {
|
||||||
|
const { t, asUser, seeded } = await seedBalanceFixture();
|
||||||
|
|
||||||
|
await t.mutation(upsertAccountBalance, {
|
||||||
|
userId: seeded.userId,
|
||||||
|
accountId: seeded.archivedAccountId as Id<"accounts">,
|
||||||
|
externalId: "old-1",
|
||||||
|
provider: "fints",
|
||||||
|
balance: 50,
|
||||||
|
currency: "EUR",
|
||||||
|
fetchedAt: 1_780_000_060_000,
|
||||||
|
status: "error",
|
||||||
|
errorMessage: "HKSAL wird nicht unterstuetzt",
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultResult = await asUser.query(listLatest, {});
|
||||||
|
const settingsResult = await asUser.query(listLatest, { includeArchived: true });
|
||||||
|
|
||||||
|
expect(defaultResult.map((row: LatestBalanceRow) => row.accountId)).toEqual([
|
||||||
|
seeded.accountId,
|
||||||
|
]);
|
||||||
|
expect(settingsResult).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
settingsResult.find(
|
||||||
|
(row: LatestBalanceRow) => row.accountId === seeded.archivedAccountId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toMatchObject({
|
||||||
|
accountName: "Altes Konto",
|
||||||
|
balance: 50,
|
||||||
|
status: "error",
|
||||||
|
errorMessage: "HKSAL wird nicht unterstuetzt",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
34
convex/bank/balanceProviders.test.ts
Normal file
34
convex/bank/balanceProviders.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { mapComdirectBalances } from "./comdirectProvider";
|
||||||
|
|
||||||
|
describe("mapComdirectBalances", () => {
|
||||||
|
test("normalizes comdirect balance payloads into provider balances", () => {
|
||||||
|
const result = mapComdirectBalances({
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
account: {
|
||||||
|
accountId: "account-1",
|
||||||
|
iban: "DE89370400440532013000",
|
||||||
|
accountType: { text: "Girokonto" },
|
||||||
|
},
|
||||||
|
balance: { value: "1234.56", unit: "EUR" },
|
||||||
|
date: "2026-06-23",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
account: {},
|
||||||
|
balance: { value: "999" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
externalId: "account-1",
|
||||||
|
balance: 1234.56,
|
||||||
|
currency: "EUR",
|
||||||
|
asOf: "2026-06-23",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
131
convex/bank/balances.ts
Normal file
131
convex/bank/balances.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { action, query } from "../_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { internal } from "../_generated/api";
|
||||||
|
import { requireUserId } from "../lib/helpers";
|
||||||
|
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||||
|
import type { Doc, Id } from "../_generated/dataModel";
|
||||||
|
|
||||||
|
const balanceProvider = v.union(v.literal("comdirect"), v.literal("fints"));
|
||||||
|
const balanceStatus = v.union(
|
||||||
|
v.literal("fresh"),
|
||||||
|
v.literal("stale"),
|
||||||
|
v.literal("error"),
|
||||||
|
v.literal("missing"),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const listLatest = query({
|
||||||
|
args: {
|
||||||
|
includeArchived: v.optional(v.boolean()),
|
||||||
|
},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
accountId: v.id("accounts"),
|
||||||
|
accountName: v.string(),
|
||||||
|
accountType: v.string(),
|
||||||
|
iban: v.optional(v.string()),
|
||||||
|
externalId: v.optional(v.string()),
|
||||||
|
balance: v.union(v.number(), v.null()),
|
||||||
|
currency: v.string(),
|
||||||
|
provider: v.union(balanceProvider, v.null()),
|
||||||
|
fetchedAt: v.union(v.number(), v.null()),
|
||||||
|
asOf: v.optional(v.string()),
|
||||||
|
status: balanceStatus,
|
||||||
|
errorMessage: v.optional(v.string()),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
const accounts = await ctx.db
|
||||||
|
.query("accounts")
|
||||||
|
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||||
|
.collect();
|
||||||
|
const visibleAccounts = accounts
|
||||||
|
.filter((account) => args.includeArchived || !account.isArchived)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
const balances = await ctx.db
|
||||||
|
.query("accountBalances")
|
||||||
|
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||||
|
.collect();
|
||||||
|
const latestByAccount = new Map<Id<"accounts">, Doc<"accountBalances">>();
|
||||||
|
for (const balance of balances.sort((a, b) => b.fetchedAt - a.fetchedAt)) {
|
||||||
|
if (!latestByAccount.has(balance.accountId)) {
|
||||||
|
latestByAccount.set(balance.accountId, balance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return visibleAccounts.map((account) => {
|
||||||
|
const latest = latestByAccount.get(account._id);
|
||||||
|
if (!latest) {
|
||||||
|
return {
|
||||||
|
accountId: account._id,
|
||||||
|
accountName: account.name,
|
||||||
|
accountType: account.type,
|
||||||
|
iban: account.iban,
|
||||||
|
externalId: account.externalId,
|
||||||
|
balance: null,
|
||||||
|
currency: account.currency,
|
||||||
|
provider: null,
|
||||||
|
fetchedAt: null,
|
||||||
|
status: "missing" as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId: account._id,
|
||||||
|
accountName: account.name,
|
||||||
|
accountType: account.type,
|
||||||
|
iban: account.iban,
|
||||||
|
externalId: account.externalId ?? latest.externalId,
|
||||||
|
balance: latest.balance,
|
||||||
|
currency: latest.currency,
|
||||||
|
provider: latest.provider,
|
||||||
|
fetchedAt: latest.fetchedAt,
|
||||||
|
asOf: latest.asOf,
|
||||||
|
status: latest.status,
|
||||||
|
errorMessage: latest.errorMessage,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const refresh = action({
|
||||||
|
args: {
|
||||||
|
accountId: v.optional(v.id("accounts")),
|
||||||
|
pin: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
returns: v.object({
|
||||||
|
updatedCount: v.number(),
|
||||||
|
provider: balanceProvider,
|
||||||
|
awaitingTan: v.boolean(),
|
||||||
|
errors: v.array(
|
||||||
|
v.object({
|
||||||
|
accountId: v.optional(v.id("accounts")),
|
||||||
|
externalId: v.optional(v.string()),
|
||||||
|
message: v.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
handler: async (
|
||||||
|
ctx,
|
||||||
|
args,
|
||||||
|
): Promise<{
|
||||||
|
updatedCount: number;
|
||||||
|
provider: "comdirect" | "fints";
|
||||||
|
awaitingTan: boolean;
|
||||||
|
errors: Array<{
|
||||||
|
accountId?: Id<"accounts">;
|
||||||
|
externalId?: string;
|
||||||
|
message: string;
|
||||||
|
}>;
|
||||||
|
}> => {
|
||||||
|
const userId = await getAuthUserId(ctx);
|
||||||
|
if (!userId) throw new Error("Nicht angemeldet");
|
||||||
|
|
||||||
|
return await ctx.runAction(internal.bank.orchestrator.refreshBalancesInternal, {
|
||||||
|
userId,
|
||||||
|
accountId: args.accountId,
|
||||||
|
pin: args.pin,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -33,6 +33,77 @@ type ComdirectProviderContext = {
|
|||||||
userId: Id<"users">;
|
userId: Id<"users">;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ComdirectBalancePayload = {
|
||||||
|
values?: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getNestedText(value: unknown): string | undefined {
|
||||||
|
if (!value || typeof value !== "object") return undefined;
|
||||||
|
const text = (value as { text?: unknown }).text;
|
||||||
|
return typeof text === "string" ? text : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapComdirectBalances(
|
||||||
|
payload: ComdirectBalancePayload,
|
||||||
|
): NormalizedBalance[] {
|
||||||
|
return (payload.values ?? []).flatMap((item) => {
|
||||||
|
const account = item.account as
|
||||||
|
| {
|
||||||
|
accountId?: unknown;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
const balance = item.balance as
|
||||||
|
| {
|
||||||
|
value?: unknown;
|
||||||
|
unit?: unknown;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
const externalId =
|
||||||
|
typeof account?.accountId === "string" ? account.accountId : undefined;
|
||||||
|
if (!externalId) return [];
|
||||||
|
|
||||||
|
const balanceValue = Number(balance?.value ?? 0);
|
||||||
|
const currency = typeof balance?.unit === "string" ? balance.unit : "EUR";
|
||||||
|
const asOf = typeof item.date === "string" ? item.date : undefined;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
externalId,
|
||||||
|
balance: Number.isFinite(balanceValue) ? balanceValue : 0,
|
||||||
|
currency,
|
||||||
|
asOf,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapComdirectAccounts(
|
||||||
|
payload: ComdirectBalancePayload,
|
||||||
|
): NormalizedAccount[] {
|
||||||
|
return (payload.values ?? []).flatMap((item) => {
|
||||||
|
const account = item.account as
|
||||||
|
| {
|
||||||
|
accountId?: unknown;
|
||||||
|
iban?: unknown;
|
||||||
|
accountType?: unknown;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
const accountIdExternal =
|
||||||
|
typeof account?.accountId === "string" ? account.accountId : undefined;
|
||||||
|
if (!accountIdExternal) return [];
|
||||||
|
|
||||||
|
const balance = mapComdirectBalances({ values: [item] })[0];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
externalId: accountIdExternal,
|
||||||
|
name: getNestedText(account?.accountType) ?? "comdirect Konto",
|
||||||
|
iban: typeof account?.iban === "string" ? account.iban : undefined,
|
||||||
|
balance: balance?.balance ?? 0,
|
||||||
|
currency: balance?.currency ?? "EUR",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function createComdirectRestProvider(
|
export async function createComdirectRestProvider(
|
||||||
context: ComdirectProviderContext,
|
context: ComdirectProviderContext,
|
||||||
): Promise<BankDataProvider> {
|
): Promise<BankDataProvider> {
|
||||||
@@ -57,39 +128,16 @@ export async function createComdirectRestProvider(
|
|||||||
|
|
||||||
async getAccounts(): Promise<NormalizedAccount[]> {
|
async getAccounts(): Promise<NormalizedAccount[]> {
|
||||||
const balances = await getAccountBalances(accessToken, sessionUuid);
|
const balances = await getAccountBalances(accessToken, sessionUuid);
|
||||||
return (balances.values ?? []).flatMap((item) => {
|
return mapComdirectAccounts(balances);
|
||||||
const account = item.account as {
|
|
||||||
accountId?: string;
|
|
||||||
iban?: string;
|
|
||||||
accountType?: { text?: string };
|
|
||||||
};
|
|
||||||
const accountIdExternal = account?.accountId;
|
|
||||||
if (!accountIdExternal) return [];
|
|
||||||
const balanceValue = Number((item.balance as { value?: string })?.value ?? 0);
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
externalId: accountIdExternal,
|
|
||||||
name: account.accountType?.text ?? "comdirect Konto",
|
|
||||||
iban: account.iban,
|
|
||||||
balance: balanceValue,
|
|
||||||
currency: "EUR",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getBalance(accountExternalId: string): Promise<NormalizedBalance> {
|
async getBalance(accountExternalId: string): Promise<NormalizedBalance> {
|
||||||
const balances = await getAccountBalances(accessToken, sessionUuid);
|
const balances = await getAccountBalances(accessToken, sessionUuid);
|
||||||
const match = (balances.values ?? []).find((item) => {
|
const match = mapComdirectBalances(balances).find(
|
||||||
const account = item.account as { accountId?: string };
|
(item) => item.externalId === accountExternalId,
|
||||||
return account?.accountId === accountExternalId;
|
);
|
||||||
});
|
|
||||||
if (!match) throw new Error(`Konto ${accountExternalId} nicht gefunden`);
|
if (!match) throw new Error(`Konto ${accountExternalId} nicht gefunden`);
|
||||||
return {
|
return match;
|
||||||
externalId: accountExternalId,
|
|
||||||
balance: Number((match.balance as { value?: string })?.value ?? 0),
|
|
||||||
currency: "EUR",
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getTransactions(
|
async getTransactions(
|
||||||
@@ -100,7 +148,7 @@ export async function createComdirectRestProvider(
|
|||||||
const rows: NormalizedTransaction[] = [];
|
const rows: NormalizedTransaction[] = [];
|
||||||
for (const state of ["BOOKED", "NOTBOOKED"] as const) {
|
for (const state of ["BOOKED", "NOTBOOKED"] as const) {
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let matches = 0;
|
let matches: number;
|
||||||
do {
|
do {
|
||||||
const result = await getTransactions(accessToken, sessionUuid, accountExternalId, {
|
const result = await getTransactions(accessToken, sessionUuid, accountExternalId, {
|
||||||
transactionState: state,
|
transactionState: state,
|
||||||
@@ -207,3 +255,53 @@ export async function fetchComdirectData(
|
|||||||
|
|
||||||
return { accounts, transactionsByAccount };
|
return { accounts, transactionsByAccount };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchComdirectBalanceData(
|
||||||
|
ctx: ActionCtx,
|
||||||
|
userId: Id<"users">,
|
||||||
|
filterAccountId: Id<"accounts"> | undefined,
|
||||||
|
): Promise<{
|
||||||
|
accounts: NormalizedAccount[];
|
||||||
|
balances: NormalizedBalance[];
|
||||||
|
}> {
|
||||||
|
const clientId = process.env.COMDIRECT_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.COMDIRECT_CLIENT_SECRET;
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
throw new Error("comdirect API-Zugangsdaten nicht konfiguriert");
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await ctx.runQuery(internal.comdirect.internal.getSession, { userId });
|
||||||
|
if (!session?.accessToken || !session.secondaryActive) {
|
||||||
|
throw new Error("comdirect-Session nicht aktiv. Bitte erneut anmelden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await getAccountBalances(session.accessToken, session.sessionUuid);
|
||||||
|
const accounts = mapComdirectAccounts(payload);
|
||||||
|
const balances = mapComdirectBalances(payload);
|
||||||
|
|
||||||
|
if (!filterAccountId) {
|
||||||
|
return { accounts, balances };
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredAccounts: NormalizedAccount[] = [];
|
||||||
|
const allowedExternalIds = new Set<string>();
|
||||||
|
for (const account of accounts) {
|
||||||
|
const convexId = await ctx.runMutation(internal.bank.internal.upsertAccountFromProvider, {
|
||||||
|
userId,
|
||||||
|
externalId: account.externalId,
|
||||||
|
name: account.name,
|
||||||
|
iban: account.iban,
|
||||||
|
balance: account.balance,
|
||||||
|
currency: account.currency,
|
||||||
|
});
|
||||||
|
if (convexId === filterAccountId) {
|
||||||
|
filteredAccounts.push(account);
|
||||||
|
allowedExternalIds.add(account.externalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accounts: filteredAccounts,
|
||||||
|
balances: balances.filter((balance) => allowedExternalIds.has(balance.externalId)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
65
convex/bank/fintsMap.test.ts
Normal file
65
convex/bank/fintsMap.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,54 @@ export type FintsStatementTransaction = {
|
|||||||
customerReference?: string;
|
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 {
|
export function formatFinTsDate(date: Date): string {
|
||||||
const y = date.getFullYear();
|
const y = date.getFullYear();
|
||||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
@@ -55,7 +103,7 @@ export function mapFinTsTransaction(
|
|||||||
vorgang,
|
vorgang,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
rawText: rawText || undefined,
|
rawText: rawText || undefined,
|
||||||
externalRef: tx.bankReference || tx.customerReference || undefined,
|
externalRef: normalizeFinTsExternalRef(tx.bankReference, tx.customerReference),
|
||||||
categoryName,
|
categoryName,
|
||||||
assignedMonth,
|
assignedMonth,
|
||||||
effectiveMonth,
|
effectiveMonth,
|
||||||
|
|||||||
@@ -56,6 +56,27 @@ const pendingTanValidator = v.object({
|
|||||||
submittedTan: v.optional(v.string()),
|
submittedTan: v.optional(v.string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const accountBalanceStatus = v.union(
|
||||||
|
v.literal("fresh"),
|
||||||
|
v.literal("stale"),
|
||||||
|
v.literal("error"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const accountBalanceValidator = v.object({
|
||||||
|
_id: v.id("accountBalances"),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
userId: v.id("users"),
|
||||||
|
accountId: v.id("accounts"),
|
||||||
|
externalId: v.string(),
|
||||||
|
provider: v.union(v.literal("comdirect"), v.literal("fints")),
|
||||||
|
balance: v.number(),
|
||||||
|
currency: v.string(),
|
||||||
|
asOf: v.optional(v.string()),
|
||||||
|
fetchedAt: v.number(),
|
||||||
|
status: accountBalanceStatus,
|
||||||
|
errorMessage: v.optional(v.string()),
|
||||||
|
});
|
||||||
|
|
||||||
export const getBankConfig = internalQuery({
|
export const getBankConfig = internalQuery({
|
||||||
args: { userId: v.id("users") },
|
args: { userId: v.id("users") },
|
||||||
returns: v.union(bankConfigValidator, v.null()),
|
returns: v.union(bankConfigValidator, v.null()),
|
||||||
@@ -89,6 +110,33 @@ export const getPendingTan = internalQuery({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const listLatestAccountBalances = internalQuery({
|
||||||
|
args: {
|
||||||
|
userId: v.id("users"),
|
||||||
|
includeArchived: v.optional(v.boolean()),
|
||||||
|
},
|
||||||
|
returns: v.array(accountBalanceValidator),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const accounts = await ctx.db
|
||||||
|
.query("accounts")
|
||||||
|
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||||
|
.collect();
|
||||||
|
const visibleAccountIds = new Set(
|
||||||
|
accounts
|
||||||
|
.filter((account) => args.includeArchived || !account.isArchived)
|
||||||
|
.map((account) => account._id),
|
||||||
|
);
|
||||||
|
const balances = await ctx.db
|
||||||
|
.query("accountBalances")
|
||||||
|
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return balances
|
||||||
|
.filter((balance) => visibleAccountIds.has(balance.accountId))
|
||||||
|
.sort((a, b) => b.fetchedAt - a.fetchedAt);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const upsertBankConfig = internalMutation({
|
export const upsertBankConfig = internalMutation({
|
||||||
args: {
|
args: {
|
||||||
userId: v.id("users"),
|
userId: v.id("users"),
|
||||||
@@ -286,3 +334,52 @@ export const upsertAccountFromProvider = internalMutation({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const upsertAccountBalance = internalMutation({
|
||||||
|
args: {
|
||||||
|
userId: v.id("users"),
|
||||||
|
accountId: v.id("accounts"),
|
||||||
|
externalId: v.string(),
|
||||||
|
provider: v.union(v.literal("comdirect"), v.literal("fints")),
|
||||||
|
balance: v.number(),
|
||||||
|
currency: v.string(),
|
||||||
|
asOf: v.optional(v.string()),
|
||||||
|
fetchedAt: v.number(),
|
||||||
|
status: accountBalanceStatus,
|
||||||
|
errorMessage: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
returns: v.id("accountBalances"),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const account = await ctx.db.get(args.accountId);
|
||||||
|
if (!account || account.userId !== args.userId) {
|
||||||
|
throw new Error("Konto nicht gefunden");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("accountBalances")
|
||||||
|
.withIndex("by_user_account", (q) =>
|
||||||
|
q.eq("userId", args.userId).eq("accountId", args.accountId),
|
||||||
|
)
|
||||||
|
.unique();
|
||||||
|
|
||||||
|
const fields = {
|
||||||
|
userId: args.userId,
|
||||||
|
accountId: args.accountId,
|
||||||
|
externalId: args.externalId,
|
||||||
|
provider: args.provider,
|
||||||
|
balance: args.balance,
|
||||||
|
currency: args.currency,
|
||||||
|
asOf: args.asOf,
|
||||||
|
fetchedAt: args.fetchedAt,
|
||||||
|
status: args.status,
|
||||||
|
errorMessage: args.errorMessage,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, fields);
|
||||||
|
return existing._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ctx.db.insert("accountBalances", fields);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -25,11 +25,17 @@ import {
|
|||||||
} from "./fintsSession";
|
} from "./fintsSession";
|
||||||
import { mapFinTsTransaction } from "./fintsMap";
|
import { mapFinTsTransaction } from "./fintsMap";
|
||||||
import {
|
import {
|
||||||
|
fetchComdirectBalanceData,
|
||||||
fetchComdirectData,
|
fetchComdirectData,
|
||||||
hasComdirectCredentials,
|
hasComdirectCredentials,
|
||||||
isRestFallbackError,
|
isRestFallbackError,
|
||||||
} from "./comdirectProvider";
|
} from "./comdirectProvider";
|
||||||
import type { ImportRow, NormalizedAccount, NormalizedTransaction } from "./types";
|
import type {
|
||||||
|
ImportRow,
|
||||||
|
NormalizedAccount,
|
||||||
|
NormalizedBalance,
|
||||||
|
NormalizedTransaction,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export const TAN_POLL_INTERVAL_MS = 4000;
|
export const TAN_POLL_INTERVAL_MS = 4000;
|
||||||
export const TAN_TIMEOUT_MS = 5 * 60 * 1000;
|
export const TAN_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
@@ -96,6 +102,12 @@ type PendingSyncJob = {
|
|||||||
partialTransactions?: Record<string, NormalizedTransaction[]>;
|
partialTransactions?: Record<string, NormalizedTransaction[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BalanceRefreshError = {
|
||||||
|
accountId?: Id<"accounts">;
|
||||||
|
externalId?: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
async function waitForDecoupledTan(
|
async function waitForDecoupledTan(
|
||||||
ctx: ActionCtx,
|
ctx: ActionCtx,
|
||||||
userId: Id<"users">,
|
userId: Id<"users">,
|
||||||
@@ -368,6 +380,34 @@ async function fetchStatementsAllPages(
|
|||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchFinTsAccountBalance(
|
||||||
|
ctx: ActionCtx,
|
||||||
|
userId: Id<"users">,
|
||||||
|
client: FinTSClient,
|
||||||
|
account: NormalizedAccount,
|
||||||
|
syncJob: PendingSyncJob,
|
||||||
|
): Promise<NormalizedBalance> {
|
||||||
|
if (!client.canGetAccountBalance(account.externalId)) {
|
||||||
|
throw new Error("Konto unterstützt keinen HKSAL-Kontostandabruf");
|
||||||
|
}
|
||||||
|
|
||||||
|
let balanceResponse = await client.getAccountBalance(account.externalId);
|
||||||
|
balanceResponse = await resolveTanResponse(
|
||||||
|
ctx,
|
||||||
|
userId,
|
||||||
|
client,
|
||||||
|
balanceResponse,
|
||||||
|
"balance",
|
||||||
|
syncJob,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
externalId: account.externalId,
|
||||||
|
balance: balanceResponse.balance?.balance ?? account.balance,
|
||||||
|
currency: balanceResponse.balance?.currency ?? account.currency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchFinTsAccountData(
|
async function fetchFinTsAccountData(
|
||||||
client: FinTSClient,
|
client: FinTSClient,
|
||||||
account: NormalizedAccount,
|
account: NormalizedAccount,
|
||||||
@@ -408,16 +448,14 @@ async function fetchFinTsAccountData(
|
|||||||
|
|
||||||
let balance = account.balance;
|
let balance = account.balance;
|
||||||
if (canFetchBalance) {
|
if (canFetchBalance) {
|
||||||
let balanceResponse = await client.getAccountBalance(account.externalId);
|
const balanceResult = await fetchFinTsAccountBalance(
|
||||||
balanceResponse = await resolveTanResponse(
|
|
||||||
ctx,
|
ctx,
|
||||||
userId,
|
userId,
|
||||||
client,
|
client,
|
||||||
balanceResponse,
|
account,
|
||||||
"balance",
|
|
||||||
syncJob,
|
syncJob,
|
||||||
);
|
);
|
||||||
balance = balanceResponse.balance?.balance ?? account.balance;
|
balance = balanceResult.balance;
|
||||||
} else {
|
} else {
|
||||||
console.warn("[fints] Konto unterstützt keinen HKSAL-Kontostandabruf", {
|
console.warn("[fints] Konto unterstützt keinen HKSAL-Kontostandabruf", {
|
||||||
account: account.externalId,
|
account: account.externalId,
|
||||||
@@ -639,6 +677,156 @@ async function fetchFinTsData(
|
|||||||
return { accounts, transactionsByAccount };
|
return { accounts, transactionsByAccount };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchFinTsBalanceData(
|
||||||
|
ctx: ActionCtx,
|
||||||
|
userId: Id<"users">,
|
||||||
|
filterAccountId: Id<"accounts"> | undefined,
|
||||||
|
pin: string | undefined,
|
||||||
|
): Promise<{
|
||||||
|
accounts: NormalizedAccount[];
|
||||||
|
balances: NormalizedBalance[];
|
||||||
|
errors: BalanceRefreshError[];
|
||||||
|
}> {
|
||||||
|
const bankConfig = await ctx.runQuery(internal.bank.internal.getBankConfig, { userId });
|
||||||
|
const env = resolveFintsEnv({
|
||||||
|
blz: bankConfig?.fints.blz,
|
||||||
|
url: bankConfig?.fints.url,
|
||||||
|
login: bankConfig?.fints.login,
|
||||||
|
productId: bankConfig?.fints.productId,
|
||||||
|
productVersion: bankConfig?.fints.productVersion,
|
||||||
|
tanMethodId: bankConfig?.fints.tanMethodId,
|
||||||
|
tanMediaName: bankConfig?.fints.tanMediaName,
|
||||||
|
bankingInformationJson: bankConfig?.fints.bankingInformationJson,
|
||||||
|
pin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const syncJob: PendingSyncJob = {
|
||||||
|
from: today,
|
||||||
|
to: today,
|
||||||
|
accountId: filterAccountId,
|
||||||
|
provider: "fints",
|
||||||
|
phase: "fetch",
|
||||||
|
};
|
||||||
|
let client = createFinTsClient(env);
|
||||||
|
client = await ensureFinTsReady(client, env, ctx, userId, syncJob);
|
||||||
|
|
||||||
|
const bankAccounts = client.config.bankingInformation.upd?.bankAccounts ?? [];
|
||||||
|
const accounts = bankAccounts.map(mapBankAccount);
|
||||||
|
const balances: NormalizedBalance[] = [];
|
||||||
|
const errors: BalanceRefreshError[] = [];
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
try {
|
||||||
|
const balance = await fetchFinTsAccountBalance(
|
||||||
|
ctx,
|
||||||
|
userId,
|
||||||
|
client,
|
||||||
|
account,
|
||||||
|
syncJob,
|
||||||
|
);
|
||||||
|
account.balance = balance.balance;
|
||||||
|
balances.push(balance);
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
externalId: account.externalId,
|
||||||
|
message: error instanceof Error ? error.message : "Kontostandabruf fehlgeschlagen",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.runMutation(internal.bank.internal.upsertBankConfig, {
|
||||||
|
userId,
|
||||||
|
fints: {
|
||||||
|
blz: env.blz,
|
||||||
|
url: env.url,
|
||||||
|
login: env.login,
|
||||||
|
productId: env.productId,
|
||||||
|
bankingInformationJson: JSON.stringify(client.config.bankingInformation),
|
||||||
|
tanMethodId: client.config.selectedTanMethod?.id,
|
||||||
|
tanMediaName: methodMediaName(client),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { accounts, balances, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistBalanceResults(
|
||||||
|
ctx: ActionCtx,
|
||||||
|
userId: Id<"users">,
|
||||||
|
provider: "comdirect" | "fints",
|
||||||
|
filterAccountId: Id<"accounts"> | undefined,
|
||||||
|
accounts: NormalizedAccount[],
|
||||||
|
balances: NormalizedBalance[],
|
||||||
|
errors: BalanceRefreshError[],
|
||||||
|
): Promise<{ updatedCount: number; errors: BalanceRefreshError[] }> {
|
||||||
|
const fetchedAt = Date.now();
|
||||||
|
const accountIdMap = new Map<string, Id<"accounts">>();
|
||||||
|
const accountByExternalId = new Map(accounts.map((account) => [account.externalId, account]));
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
const convexAccountId = await ctx.runMutation(internal.bank.internal.upsertAccountFromProvider, {
|
||||||
|
userId,
|
||||||
|
externalId: account.externalId,
|
||||||
|
name: account.name,
|
||||||
|
iban: account.iban,
|
||||||
|
balance: account.balance,
|
||||||
|
currency: account.currency,
|
||||||
|
});
|
||||||
|
accountIdMap.set(account.externalId, convexAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let updatedCount = 0;
|
||||||
|
for (const balance of balances) {
|
||||||
|
const accountId = accountIdMap.get(balance.externalId);
|
||||||
|
if (!accountId || (filterAccountId && accountId !== filterAccountId)) continue;
|
||||||
|
await ctx.runMutation(internal.bank.internal.upsertAccountBalance, {
|
||||||
|
userId,
|
||||||
|
accountId,
|
||||||
|
externalId: balance.externalId,
|
||||||
|
provider,
|
||||||
|
balance: balance.balance,
|
||||||
|
currency: balance.currency,
|
||||||
|
asOf: balance.asOf,
|
||||||
|
fetchedAt,
|
||||||
|
status: "fresh",
|
||||||
|
});
|
||||||
|
updatedCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopedErrors: BalanceRefreshError[] = [];
|
||||||
|
for (const error of errors) {
|
||||||
|
const externalId = error.externalId;
|
||||||
|
const accountId = externalId ? accountIdMap.get(externalId) : error.accountId;
|
||||||
|
if (filterAccountId && accountId !== filterAccountId) continue;
|
||||||
|
|
||||||
|
scopedErrors.push({ ...error, accountId });
|
||||||
|
const account = externalId ? accountByExternalId.get(externalId) : undefined;
|
||||||
|
if (accountId && account) {
|
||||||
|
await ctx.runMutation(internal.bank.internal.upsertAccountBalance, {
|
||||||
|
userId,
|
||||||
|
accountId,
|
||||||
|
externalId: account.externalId,
|
||||||
|
provider,
|
||||||
|
balance: account.balance,
|
||||||
|
currency: account.currency,
|
||||||
|
fetchedAt,
|
||||||
|
status: "error",
|
||||||
|
errorMessage: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.runMutation(internal.bank.internal.updateSyncState, {
|
||||||
|
userId,
|
||||||
|
lastSync: updatedCount > 0 ? fetchedAt : undefined,
|
||||||
|
lastProviderUsed: provider,
|
||||||
|
lastError: scopedErrors[0]?.message ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { updatedCount, errors: scopedErrors };
|
||||||
|
}
|
||||||
|
|
||||||
async function persistSyncResults(
|
async function persistSyncResults(
|
||||||
ctx: ActionCtx,
|
ctx: ActionCtx,
|
||||||
userId: Id<"users">,
|
userId: Id<"users">,
|
||||||
@@ -650,6 +838,7 @@ async function persistSyncResults(
|
|||||||
transactionsByAccount: Map<string, NormalizedTransaction[]>,
|
transactionsByAccount: Map<string, NormalizedTransaction[]>,
|
||||||
): Promise<{ importedCount: number; skippedCount: number }> {
|
): Promise<{ importedCount: number; skippedCount: number }> {
|
||||||
const accountIdMap = new Map<string, Id<"accounts">>();
|
const accountIdMap = new Map<string, Id<"accounts">>();
|
||||||
|
const fetchedAt = Date.now();
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
const convexAccountId = await ctx.runMutation(internal.bank.internal.upsertAccountFromProvider, {
|
const convexAccountId = await ctx.runMutation(internal.bank.internal.upsertAccountFromProvider, {
|
||||||
userId,
|
userId,
|
||||||
@@ -660,6 +849,16 @@ async function persistSyncResults(
|
|||||||
currency: account.currency,
|
currency: account.currency,
|
||||||
});
|
});
|
||||||
accountIdMap.set(account.externalId, convexAccountId);
|
accountIdMap.set(account.externalId, convexAccountId);
|
||||||
|
await ctx.runMutation(internal.bank.internal.upsertAccountBalance, {
|
||||||
|
userId,
|
||||||
|
accountId: convexAccountId,
|
||||||
|
externalId: account.externalId,
|
||||||
|
provider,
|
||||||
|
balance: account.balance,
|
||||||
|
currency: account.currency,
|
||||||
|
fetchedAt,
|
||||||
|
status: "fresh",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows: ImportRow[] = [];
|
const rows: ImportRow[] = [];
|
||||||
@@ -850,6 +1049,100 @@ export const runSyncInternal = internalAction({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const refreshBalancesInternal = internalAction({
|
||||||
|
args: {
|
||||||
|
userId: v.id("users"),
|
||||||
|
accountId: v.optional(v.id("accounts")),
|
||||||
|
pin: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
returns: v.object({
|
||||||
|
updatedCount: v.number(),
|
||||||
|
provider: v.union(v.literal("comdirect"), v.literal("fints")),
|
||||||
|
awaitingTan: v.boolean(),
|
||||||
|
errors: v.array(
|
||||||
|
v.object({
|
||||||
|
accountId: v.optional(v.id("accounts")),
|
||||||
|
externalId: v.optional(v.string()),
|
||||||
|
message: v.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const comdirectReady = hasComdirectCredentials();
|
||||||
|
await ctx.runMutation(internal.bank.internal.upsertBankConfig, {
|
||||||
|
userId: args.userId,
|
||||||
|
comdirectHasCredentials: comdirectReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bankConfig = await ctx.runQuery(internal.bank.internal.getBankConfig, {
|
||||||
|
userId: args.userId,
|
||||||
|
});
|
||||||
|
const preference = bankConfig?.providerPreference ?? "auto";
|
||||||
|
const useFinTsDirect =
|
||||||
|
preference === "fints" || (preference === "auto" && !comdirectReady);
|
||||||
|
|
||||||
|
const tryFinTs = async (reason: string) => {
|
||||||
|
await logProvider(ctx, args.userId, "fints", reason);
|
||||||
|
const { accounts, balances, errors } = await fetchFinTsBalanceData(
|
||||||
|
ctx,
|
||||||
|
args.userId,
|
||||||
|
args.accountId,
|
||||||
|
args.pin,
|
||||||
|
);
|
||||||
|
const result = await persistBalanceResults(
|
||||||
|
ctx,
|
||||||
|
args.userId,
|
||||||
|
"fints",
|
||||||
|
args.accountId,
|
||||||
|
accounts,
|
||||||
|
balances,
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
provider: "fints" as const,
|
||||||
|
awaitingTan: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (useFinTsDirect) {
|
||||||
|
return await tryFinTs(
|
||||||
|
!comdirectReady
|
||||||
|
? "comdirect-Credentials fehlen"
|
||||||
|
: "Provider-Präferenz FinTS",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await logProvider(ctx, args.userId, "comdirect", "REST-Saldenabruf");
|
||||||
|
const { accounts, balances } = await fetchComdirectBalanceData(
|
||||||
|
ctx,
|
||||||
|
args.userId,
|
||||||
|
args.accountId,
|
||||||
|
);
|
||||||
|
const result = await persistBalanceResults(
|
||||||
|
ctx,
|
||||||
|
args.userId,
|
||||||
|
"comdirect",
|
||||||
|
args.accountId,
|
||||||
|
accounts,
|
||||||
|
balances,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
provider: "comdirect" as const,
|
||||||
|
awaitingTan: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (!isRestFallbackError(error)) throw error;
|
||||||
|
const reason = error instanceof Error ? error.message : "REST-Fehler";
|
||||||
|
console.warn("[bank-balance] REST fehlgeschlagen, Fallback FinTS:", reason);
|
||||||
|
return await tryFinTs(`REST-Fallback: ${reason}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const pollTan = internalAction({
|
export const pollTan = internalAction({
|
||||||
args: {
|
args: {
|
||||||
userId: v.id("users"),
|
userId: v.id("users"),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type NormalizedBalance = {
|
|||||||
externalId: string;
|
externalId: string;
|
||||||
balance: number;
|
balance: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
asOf?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NormalizedTransaction = {
|
export type NormalizedTransaction = {
|
||||||
|
|||||||
198
convex/imports.test.ts
Normal file
198
convex/imports.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -132,6 +132,8 @@ async function commitRowsHandler(
|
|||||||
|
|
||||||
let importedCount = 0;
|
let importedCount = 0;
|
||||||
let skippedCount = 0;
|
let skippedCount = 0;
|
||||||
|
let skippedByExternalRef = 0;
|
||||||
|
let skippedByDedupHash = 0;
|
||||||
|
|
||||||
for (const row of args.rows) {
|
for (const row of args.rows) {
|
||||||
if (row.externalRef) {
|
if (row.externalRef) {
|
||||||
@@ -143,6 +145,7 @@ async function commitRowsHandler(
|
|||||||
.unique();
|
.unique();
|
||||||
if (existingRef) {
|
if (existingRef) {
|
||||||
skippedCount++;
|
skippedCount++;
|
||||||
|
skippedByExternalRef++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,6 +165,7 @@ async function commitRowsHandler(
|
|||||||
.unique();
|
.unique();
|
||||||
if (existingDedup) {
|
if (existingDedup) {
|
||||||
skippedCount++;
|
skippedCount++;
|
||||||
|
skippedByDedupHash++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,5 +208,14 @@ async function commitRowsHandler(
|
|||||||
status: "completed",
|
status: "completed",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.info("[imports] Commit-Ergebnis", {
|
||||||
|
source: args.source,
|
||||||
|
rowCount: args.rows.length,
|
||||||
|
importedCount,
|
||||||
|
skippedCount,
|
||||||
|
skippedByExternalRef,
|
||||||
|
skippedByDedupHash,
|
||||||
|
});
|
||||||
|
|
||||||
return { importId, importedCount, skippedCount };
|
return { importId, importedCount, skippedCount };
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
266
convex/savingsChatActionPlans.ts
Normal file
266
convex/savingsChatActionPlans.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import { mutation, query } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { assertOwned, requireUserId } from "./lib/helpers";
|
||||||
|
import type { Doc, Id } from "./_generated/dataModel";
|
||||||
|
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||||
|
|
||||||
|
const MAX_PLAN_OPERATIONS = 100;
|
||||||
|
|
||||||
|
const planKindValidator = v.union(
|
||||||
|
v.literal("bulk_recategory"),
|
||||||
|
v.literal("category_changes"),
|
||||||
|
);
|
||||||
|
const planStatusValidator = v.union(
|
||||||
|
v.literal("pending"),
|
||||||
|
v.literal("applied"),
|
||||||
|
v.literal("dismissed"),
|
||||||
|
v.literal("expired"),
|
||||||
|
);
|
||||||
|
const operationValidator = v.union(
|
||||||
|
v.object({
|
||||||
|
type: v.literal("set_transaction_category"),
|
||||||
|
transactionId: v.id("transactions"),
|
||||||
|
fromCategoryId: v.optional(v.id("categories")),
|
||||||
|
toCategoryId: v.id("categories"),
|
||||||
|
}),
|
||||||
|
v.object({
|
||||||
|
type: v.literal("create_category"),
|
||||||
|
name: v.string(),
|
||||||
|
kind: v.union(v.literal("einnahme"), v.literal("ausgabe")),
|
||||||
|
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
|
||||||
|
color: v.string(),
|
||||||
|
icon: v.optional(v.string()),
|
||||||
|
sortOrder: v.number(),
|
||||||
|
}),
|
||||||
|
v.object({
|
||||||
|
type: v.literal("update_category"),
|
||||||
|
categoryId: v.id("categories"),
|
||||||
|
name: v.optional(v.string()),
|
||||||
|
kind: v.optional(v.union(v.literal("einnahme"), v.literal("ausgabe"))),
|
||||||
|
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
|
||||||
|
color: v.optional(v.string()),
|
||||||
|
icon: v.optional(v.string()),
|
||||||
|
sortOrder: v.optional(v.number()),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const previewRowValidator = v.object({
|
||||||
|
date: v.optional(v.string()),
|
||||||
|
description: v.string(),
|
||||||
|
amount: v.optional(v.number()),
|
||||||
|
currentCategoryName: v.optional(v.string()),
|
||||||
|
targetCategoryName: v.optional(v.string()),
|
||||||
|
action: v.optional(v.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionPlanValidator = v.object({
|
||||||
|
_id: v.id("agentActionPlans"),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
userId: v.id("users"),
|
||||||
|
sessionId: v.id("chatSessions"),
|
||||||
|
kind: planKindValidator,
|
||||||
|
status: planStatusValidator,
|
||||||
|
summary: v.string(),
|
||||||
|
affectedCount: v.number(),
|
||||||
|
createdAt: v.number(),
|
||||||
|
expiresAt: v.number(),
|
||||||
|
operations: v.array(operationValidator),
|
||||||
|
previewRows: v.array(previewRowValidator),
|
||||||
|
resultSummary: v.optional(v.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function requireOwnedSession(
|
||||||
|
ctx: QueryCtx | MutationCtx,
|
||||||
|
sessionId: Id<"chatSessions">,
|
||||||
|
userId: Id<"users">,
|
||||||
|
) {
|
||||||
|
const session = await assertOwned(await ctx.db.get(sessionId), userId, "Chat");
|
||||||
|
if (session.isDeleted) throw new Error("Chat nicht gefunden");
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireOwnedPlan(
|
||||||
|
ctx: QueryCtx | MutationCtx,
|
||||||
|
planId: Id<"agentActionPlans">,
|
||||||
|
userId: Id<"users">,
|
||||||
|
) {
|
||||||
|
const plan = await assertOwned(await ctx.db.get(planId), userId, "Vorschlag");
|
||||||
|
await requireOwnedSession(ctx, plan.sessionId, userId);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyTransactionCategory(
|
||||||
|
ctx: MutationCtx,
|
||||||
|
userId: Id<"users">,
|
||||||
|
operation: Extract<Doc<"agentActionPlans">["operations"][number], { type: "set_transaction_category" }>,
|
||||||
|
) {
|
||||||
|
const tx = await ctx.db.get(operation.transactionId);
|
||||||
|
if (!tx || tx.userId !== userId) return false;
|
||||||
|
const targetCategory = await ctx.db.get(operation.toCategoryId);
|
||||||
|
if (!targetCategory || targetCategory.userId !== userId) return false;
|
||||||
|
if (tx.categoryId !== operation.fromCategoryId) return false;
|
||||||
|
await ctx.db.patch(tx._id, { categoryId: operation.toCategoryId });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyCreateCategory(
|
||||||
|
ctx: MutationCtx,
|
||||||
|
userId: Id<"users">,
|
||||||
|
operation: Extract<Doc<"agentActionPlans">["operations"][number], { type: "create_category" }>,
|
||||||
|
) {
|
||||||
|
const name = operation.name.trim();
|
||||||
|
if (!name) return false;
|
||||||
|
if (operation.kind === "ausgabe" && !operation.block) return false;
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("categories")
|
||||||
|
.withIndex("by_user_name", (index) =>
|
||||||
|
index.eq("userId", userId).eq("name", name),
|
||||||
|
)
|
||||||
|
.unique();
|
||||||
|
if (existing) return false;
|
||||||
|
await ctx.db.insert("categories", {
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
kind: operation.kind,
|
||||||
|
block: operation.kind === "ausgabe" ? operation.block : undefined,
|
||||||
|
color: operation.color,
|
||||||
|
icon: operation.icon,
|
||||||
|
sortOrder: operation.sortOrder,
|
||||||
|
isSystem: false,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyUpdateCategory(
|
||||||
|
ctx: MutationCtx,
|
||||||
|
userId: Id<"users">,
|
||||||
|
operation: Extract<Doc<"agentActionPlans">["operations"][number], { type: "update_category" }>,
|
||||||
|
) {
|
||||||
|
const category = await ctx.db.get(operation.categoryId);
|
||||||
|
if (!category || category.userId !== userId) return false;
|
||||||
|
const patch: Partial<Doc<"categories">> = {};
|
||||||
|
for (const key of ["name", "kind", "block", "color", "icon", "sortOrder"] as const) {
|
||||||
|
if (operation[key] !== undefined) {
|
||||||
|
patch[key] = operation[key] as never;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (patch.name !== undefined) {
|
||||||
|
const name = patch.name.trim();
|
||||||
|
if (!name) return false;
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("categories")
|
||||||
|
.withIndex("by_user_name", (index) => index.eq("userId", userId).eq("name", name))
|
||||||
|
.unique();
|
||||||
|
if (existing && existing._id !== operation.categoryId) return false;
|
||||||
|
patch.name = name;
|
||||||
|
}
|
||||||
|
const kind = patch.kind ?? category.kind;
|
||||||
|
const block = patch.block ?? category.block;
|
||||||
|
if (kind === "ausgabe" && !block) return false;
|
||||||
|
if (kind === "einnahme") patch.block = undefined;
|
||||||
|
if (Object.keys(patch).length === 0) return false;
|
||||||
|
await ctx.db.patch(operation.categoryId, patch);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listPendingActionPlans = query({
|
||||||
|
args: { sessionId: v.id("chatSessions"), now: v.optional(v.number()) },
|
||||||
|
returns: v.array(actionPlanValidator),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
const session = await ctx.db.get(args.sessionId);
|
||||||
|
if (!session || session.userId !== userId || session.isDeleted) return [];
|
||||||
|
const now = args.now ?? Date.now();
|
||||||
|
const plans = await ctx.db
|
||||||
|
.query("agentActionPlans")
|
||||||
|
.withIndex("by_user_session_status_created", (index) =>
|
||||||
|
index
|
||||||
|
.eq("userId", userId)
|
||||||
|
.eq("sessionId", args.sessionId)
|
||||||
|
.eq("status", "pending"),
|
||||||
|
)
|
||||||
|
.order("desc")
|
||||||
|
.take(20);
|
||||||
|
return plans.filter((plan) => plan.expiresAt > now);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dismissActionPlan = mutation({
|
||||||
|
args: { planId: v.id("agentActionPlans") },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
const plan = await requireOwnedPlan(ctx, args.planId, userId);
|
||||||
|
if (plan.status !== "pending") return null;
|
||||||
|
await ctx.db.patch(args.planId, {
|
||||||
|
status: "dismissed",
|
||||||
|
resultSummary: "Vorschlag verworfen",
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const applyActionPlan = mutation({
|
||||||
|
args: { planId: v.id("agentActionPlans"), now: v.optional(v.number()) },
|
||||||
|
returns: v.object({
|
||||||
|
appliedCount: v.number(),
|
||||||
|
skippedCount: v.number(),
|
||||||
|
status: planStatusValidator,
|
||||||
|
summary: v.string(),
|
||||||
|
}),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
const plan = await requireOwnedPlan(ctx, args.planId, userId);
|
||||||
|
const now = args.now ?? Date.now();
|
||||||
|
if (plan.status !== "pending") {
|
||||||
|
return {
|
||||||
|
appliedCount: 0,
|
||||||
|
skippedCount: plan.operations.length,
|
||||||
|
status: plan.status as "pending" | "applied" | "dismissed" | "expired",
|
||||||
|
summary: plan.resultSummary ?? "Vorschlag ist nicht mehr offen",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (plan.expiresAt <= now) {
|
||||||
|
await ctx.db.patch(args.planId, {
|
||||||
|
status: "expired" as const,
|
||||||
|
resultSummary: "Vorschlag ist abgelaufen",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
appliedCount: 0,
|
||||||
|
skippedCount: plan.operations.length,
|
||||||
|
status: "expired" as const,
|
||||||
|
summary: "Vorschlag ist abgelaufen",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (plan.operations.length > MAX_PLAN_OPERATIONS) {
|
||||||
|
throw new Error("Zu viele Änderungen in einem Vorschlag");
|
||||||
|
}
|
||||||
|
|
||||||
|
let appliedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
for (const operation of plan.operations) {
|
||||||
|
const applied =
|
||||||
|
operation.type === "set_transaction_category"
|
||||||
|
? await applyTransactionCategory(ctx, userId, operation)
|
||||||
|
: operation.type === "create_category"
|
||||||
|
? await applyCreateCategory(ctx, userId, operation)
|
||||||
|
: await applyUpdateCategory(ctx, userId, operation);
|
||||||
|
if (applied) {
|
||||||
|
appliedCount++;
|
||||||
|
} else {
|
||||||
|
skippedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = `${appliedCount} Änderung${appliedCount === 1 ? "" : "en"} angewendet, ${skippedCount} übersprungen`;
|
||||||
|
await ctx.db.patch(args.planId, {
|
||||||
|
status: "applied" as const,
|
||||||
|
resultSummary: summary,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
appliedCount,
|
||||||
|
skippedCount,
|
||||||
|
status: "applied" as const,
|
||||||
|
summary,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
285
convex/savingsChatHistory.ts
Normal file
285
convex/savingsChatHistory.ts
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import { paginationOptsValidator, paginationResultValidator } from "convex/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { internalMutation, internalQuery, mutation, query } from "./_generated/server";
|
||||||
|
import type { Id } from "./_generated/dataModel";
|
||||||
|
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||||
|
import { assertOwned, requireUserId } from "./lib/helpers";
|
||||||
|
|
||||||
|
const initialAssistantMessage =
|
||||||
|
"Frag mich zu deinen Umsätzen – ich werte sie im aktuellen Zeitraum aus.";
|
||||||
|
|
||||||
|
const toolTraceValidator = v.object({
|
||||||
|
name: v.string(),
|
||||||
|
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"));
|
||||||
|
|
||||||
|
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({
|
||||||
|
_id: v.id("chatSessions"),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
userId: v.id("users"),
|
||||||
|
title: v.string(),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
messageCount: v.number(),
|
||||||
|
legacyLocalId: v.optional(v.string()),
|
||||||
|
isDeleted: v.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageValidator = v.object({
|
||||||
|
_id: v.id("chatMessages"),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
userId: v.id("users"),
|
||||||
|
sessionId: v.id("chatSessions"),
|
||||||
|
role: chatRoleValidator,
|
||||||
|
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({
|
||||||
|
role: chatRoleValidator,
|
||||||
|
content: v.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizeTitle(title: string | undefined) {
|
||||||
|
const trimmed = title?.trim();
|
||||||
|
return trimmed ? trimmed : "Neuer Chat";
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleFromContent(content: string) {
|
||||||
|
const trimmed = content.trim();
|
||||||
|
if (!trimmed) return "Neuer Chat";
|
||||||
|
return trimmed.length > 44 ? `${trimmed.slice(0, 44)}…` : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listSessions = query({
|
||||||
|
args: { paginationOpts: paginationOptsValidator },
|
||||||
|
returns: paginationResultValidator(sessionValidator),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
return await ctx.db
|
||||||
|
.query("chatSessions")
|
||||||
|
.withIndex("by_user_deleted_updated", (index) =>
|
||||||
|
index.eq("userId", userId).eq("isDeleted", false),
|
||||||
|
)
|
||||||
|
.order("desc")
|
||||||
|
.paginate(args.paginationOpts);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listMessages = query({
|
||||||
|
args: {
|
||||||
|
sessionId: v.id("chatSessions"),
|
||||||
|
paginationOpts: paginationOptsValidator,
|
||||||
|
},
|
||||||
|
returns: paginationResultValidator(messageValidator),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
await requireOwnedSession(ctx, args.sessionId, userId);
|
||||||
|
return await ctx.db
|
||||||
|
.query("chatMessages")
|
||||||
|
.withIndex("by_user_session_created", (index) =>
|
||||||
|
index.eq("userId", userId).eq("sessionId", args.sessionId),
|
||||||
|
)
|
||||||
|
.order("desc")
|
||||||
|
.paginate(args.paginationOpts);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createSession = mutation({
|
||||||
|
args: { title: v.optional(v.string()) },
|
||||||
|
returns: v.object({ sessionId: v.id("chatSessions") }),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
const now = Date.now();
|
||||||
|
const sessionId = await ctx.db.insert("chatSessions", {
|
||||||
|
userId,
|
||||||
|
title: normalizeTitle(args.title),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
messageCount: 1,
|
||||||
|
isDeleted: false,
|
||||||
|
});
|
||||||
|
await ctx.db.insert("chatMessages", {
|
||||||
|
userId,
|
||||||
|
sessionId,
|
||||||
|
role: "assistant",
|
||||||
|
content: initialAssistantMessage,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
return { sessionId };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteSession = mutation({
|
||||||
|
args: { sessionId: v.id("chatSessions") },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
await requireOwnedSession(ctx, args.sessionId, userId);
|
||||||
|
await ctx.db.patch(args.sessionId, {
|
||||||
|
isDeleted: true,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const importLocalSession = mutation({
|
||||||
|
args: {
|
||||||
|
legacyLocalId: v.string(),
|
||||||
|
title: v.string(),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
messages: v.array(importMessageValidator),
|
||||||
|
},
|
||||||
|
returns: v.object({ sessionId: v.id("chatSessions") }),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("chatSessions")
|
||||||
|
.withIndex("by_user_legacyLocalId", (index) =>
|
||||||
|
index.eq("userId", userId).eq("legacyLocalId", args.legacyLocalId),
|
||||||
|
)
|
||||||
|
.unique();
|
||||||
|
if (existing) return { sessionId: existing._id };
|
||||||
|
|
||||||
|
const sessionId = await ctx.db.insert("chatSessions", {
|
||||||
|
userId,
|
||||||
|
title: normalizeTitle(args.title),
|
||||||
|
createdAt: args.createdAt,
|
||||||
|
updatedAt: args.updatedAt,
|
||||||
|
messageCount: args.messages.length,
|
||||||
|
legacyLocalId: args.legacyLocalId,
|
||||||
|
isDeleted: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [index, message] of args.messages.entries()) {
|
||||||
|
await ctx.db.insert("chatMessages", {
|
||||||
|
userId,
|
||||||
|
sessionId,
|
||||||
|
role: message.role,
|
||||||
|
content: message.content,
|
||||||
|
createdAt: args.createdAt + index,
|
||||||
|
...(message.toolTrace ? { toolTrace: message.toolTrace } : {}),
|
||||||
|
...(message.sources ? { sources: message.sources } : {}),
|
||||||
|
...(message.citations ? { citations: message.citations } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sessionId };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const appendUserMessage = internalMutation({
|
||||||
|
args: {
|
||||||
|
sessionId: v.id("chatSessions"),
|
||||||
|
content: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.object({ messageId: v.id("chatMessages") }),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
const session = await requireOwnedSession(ctx, args.sessionId, userId);
|
||||||
|
const now = Date.now();
|
||||||
|
const messageId = await ctx.db.insert("chatMessages", {
|
||||||
|
userId,
|
||||||
|
sessionId: args.sessionId,
|
||||||
|
role: "user",
|
||||||
|
content: args.content,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
await ctx.db.patch(args.sessionId, {
|
||||||
|
title: session.title === "Neuer Chat" ? titleFromContent(args.content) : session.title,
|
||||||
|
updatedAt: now,
|
||||||
|
messageCount: session.messageCount + 1,
|
||||||
|
});
|
||||||
|
return { messageId };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const appendAssistantMessage = internalMutation({
|
||||||
|
args: {
|
||||||
|
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) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
const session = await requireOwnedSession(ctx, args.sessionId, userId);
|
||||||
|
const now = Date.now();
|
||||||
|
const messageId = await ctx.db.insert("chatMessages", {
|
||||||
|
userId,
|
||||||
|
sessionId: args.sessionId,
|
||||||
|
role: "assistant",
|
||||||
|
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,
|
||||||
|
messageCount: session.messageCount + 1,
|
||||||
|
});
|
||||||
|
return { messageId };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getRecentMessagesForPrompt = internalQuery({
|
||||||
|
args: {
|
||||||
|
sessionId: v.id("chatSessions"),
|
||||||
|
limit: v.number(),
|
||||||
|
},
|
||||||
|
returns: v.array(promptMessageValidator),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
await requireOwnedSession(ctx, args.sessionId, userId);
|
||||||
|
const limit = Math.max(1, Math.min(50, Math.floor(args.limit)));
|
||||||
|
const messages = await ctx.db
|
||||||
|
.query("chatMessages")
|
||||||
|
.withIndex("by_user_session_created", (index) =>
|
||||||
|
index.eq("userId", userId).eq("sessionId", args.sessionId),
|
||||||
|
)
|
||||||
|
.order("desc")
|
||||||
|
.take(limit);
|
||||||
|
return messages.reverse().map((message) => ({
|
||||||
|
role: message.role,
|
||||||
|
content: message.content,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
116
convex/schema.ts
116
convex/schema.ts
@@ -9,6 +9,66 @@ const loanStatus = v.union(
|
|||||||
v.literal("abbezahlt"),
|
v.literal("abbezahlt"),
|
||||||
v.literal("pausiert"),
|
v.literal("pausiert"),
|
||||||
);
|
);
|
||||||
|
const chatRole = v.union(v.literal("user"), v.literal("assistant"));
|
||||||
|
const chatToolTrace = v.object({
|
||||||
|
name: v.string(),
|
||||||
|
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({
|
export default defineSchema({
|
||||||
...authTables,
|
...authTables,
|
||||||
@@ -26,6 +86,22 @@ export default defineSchema({
|
|||||||
.index("by_user", ["userId"])
|
.index("by_user", ["userId"])
|
||||||
.index("by_user_external", ["userId", "externalId"]),
|
.index("by_user_external", ["userId", "externalId"]),
|
||||||
|
|
||||||
|
accountBalances: defineTable({
|
||||||
|
userId: v.id("users"),
|
||||||
|
accountId: v.id("accounts"),
|
||||||
|
externalId: v.string(),
|
||||||
|
provider: v.union(v.literal("comdirect"), v.literal("fints")),
|
||||||
|
balance: v.number(),
|
||||||
|
currency: v.string(),
|
||||||
|
asOf: v.optional(v.string()),
|
||||||
|
fetchedAt: v.number(),
|
||||||
|
status: v.union(v.literal("fresh"), v.literal("stale"), v.literal("error")),
|
||||||
|
errorMessage: v.optional(v.string()),
|
||||||
|
})
|
||||||
|
.index("by_user", ["userId"])
|
||||||
|
.index("by_user_account", ["userId", "accountId"])
|
||||||
|
.index("by_user_provider_fetched", ["userId", "provider", "fetchedAt"]),
|
||||||
|
|
||||||
categories: defineTable({
|
categories: defineTable({
|
||||||
userId: v.id("users"),
|
userId: v.id("users"),
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
@@ -62,6 +138,7 @@ export default defineSchema({
|
|||||||
.index("by_user", ["userId"])
|
.index("by_user", ["userId"])
|
||||||
.index("by_user_booking", ["userId", "bookingDate"])
|
.index("by_user_booking", ["userId", "bookingDate"])
|
||||||
.index("by_user_effmonth", ["userId", "effectiveMonth"])
|
.index("by_user_effmonth", ["userId", "effectiveMonth"])
|
||||||
|
.index("by_user_effmonth_booking", ["userId", "effectiveMonth", "bookingDate"])
|
||||||
.index("by_user_category", ["userId", "categoryId"])
|
.index("by_user_category", ["userId", "categoryId"])
|
||||||
.index("by_user_account", ["userId", "accountId"])
|
.index("by_user_account", ["userId", "accountId"])
|
||||||
.index("by_user_account_booking", ["userId", "accountId", "bookingDate"])
|
.index("by_user_account_booking", ["userId", "accountId", "bookingDate"])
|
||||||
@@ -175,4 +252,43 @@ export default defineSchema({
|
|||||||
isDecoupled: v.optional(v.boolean()),
|
isDecoupled: v.optional(v.boolean()),
|
||||||
submittedTan: v.optional(v.string()),
|
submittedTan: v.optional(v.string()),
|
||||||
}).index("by_user", ["userId"]),
|
}).index("by_user", ["userId"]),
|
||||||
|
|
||||||
|
chatSessions: defineTable({
|
||||||
|
userId: v.id("users"),
|
||||||
|
title: v.string(),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
messageCount: v.number(),
|
||||||
|
legacyLocalId: v.optional(v.string()),
|
||||||
|
isDeleted: v.boolean(),
|
||||||
|
})
|
||||||
|
.index("by_user_deleted_updated", ["userId", "isDeleted", "updatedAt"])
|
||||||
|
.index("by_user_legacyLocalId", ["userId", "legacyLocalId"]),
|
||||||
|
|
||||||
|
chatMessages: defineTable({
|
||||||
|
userId: v.id("users"),
|
||||||
|
sessionId: v.id("chatSessions"),
|
||||||
|
role: chatRole,
|
||||||
|
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"]),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -186,4 +186,76 @@ describe("transactions.list", () => {
|
|||||||
expect(effectiveResult.page.map((tx) => tx._id)).toEqual([seeded.shiftedId]);
|
expect(effectiveResult.page.map((tx) => tx._id)).toEqual([seeded.shiftedId]);
|
||||||
expect(bookingResult.page).toEqual([]);
|
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,
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const list = query({
|
|||||||
if (basis === "effective") {
|
if (basis === "effective") {
|
||||||
q = ctx.db
|
q = ctx.db
|
||||||
.query("transactions")
|
.query("transactions")
|
||||||
.withIndex("by_user_effmonth", (iq) => {
|
.withIndex("by_user_effmonth_booking", (iq) => {
|
||||||
if (fromMonth && toMonth) {
|
if (fromMonth && toMonth) {
|
||||||
return iq.eq("userId", userId).gte("effectiveMonth", fromMonth).lte("effectiveMonth", toMonth);
|
return iq.eq("userId", userId).gte("effectiveMonth", fromMonth).lte("effectiveMonth", toMonth);
|
||||||
}
|
}
|
||||||
|
|||||||
225
docs/superpowers/plans/2026-06-23-kontostandabruf-ui.md
Normal file
225
docs/superpowers/plans/2026-06-23-kontostandabruf-ui.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# Kontostandabruf und UI-Anzeige Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build a dedicated live balance refresh path for FinTS and comdirect REST, persist latest balances separately from account master data, and show them clearly in the dashboard and account UI.
|
||||||
|
|
||||||
|
**Architecture:** Keep current transaction sync intact and add a separate balance capability. Persist balance snapshots in a dedicated Convex table because live balances are operational/high-churn data, while `accounts` remains stable account metadata. The UI reads the latest persisted balances through a query and can trigger a refresh action that uses provider preference `auto | comdirect | fints` with the same fallback behavior as the current sync.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19, Vite, Convex 1.41, `lib-fints`, comdirect REST client, Vitest, convex-test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Backlog task: `TASK-14 Plane Kontostandabruf und UI-Anzeige`.
|
||||||
|
- Existing balance-capable types already exist in `convex/bank/types.ts`: `NormalizedAccount.balance` and `NormalizedBalance`.
|
||||||
|
- Existing comdirect balance endpoint wrapper exists in `convex/comdirect/client.ts`: `getAccountBalances()`.
|
||||||
|
- Existing FinTS balance logic exists inside transaction sync in `convex/bank/orchestrator.ts`: `client.canGetAccountBalance()` and `client.getAccountBalance()`.
|
||||||
|
- Current persistence only uses `balance` as `openingBalance` when creating a new account; it does not store or display latest live balance.
|
||||||
|
- `ctx7` found FinTS docs for `/nemiah/phpfints`; relevant notes: FinTS/HBCI supports account access and balance retrieval, and real deployments need a registered product/application id. `ctx7` did not find a usable comdirect API reference, so implementation should rely on the existing local comdirect client wrappers and verify against real responses.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### Task 1: Store Latest Account Balances
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `convex/schema.ts`
|
||||||
|
- Modify: `convex/bank/internal.ts`
|
||||||
|
- Test: `convex/bank/balances.test.ts`
|
||||||
|
|
||||||
|
- [ ] Add `accountBalances` table in `convex/schema.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
accountBalances: defineTable({
|
||||||
|
userId: v.id("users"),
|
||||||
|
accountId: v.id("accounts"),
|
||||||
|
externalId: v.string(),
|
||||||
|
provider: v.union(v.literal("comdirect"), v.literal("fints")),
|
||||||
|
balance: v.number(),
|
||||||
|
currency: v.string(),
|
||||||
|
asOf: v.optional(v.string()),
|
||||||
|
fetchedAt: v.number(),
|
||||||
|
status: v.union(v.literal("fresh"), v.literal("stale"), v.literal("error")),
|
||||||
|
errorMessage: v.optional(v.string()),
|
||||||
|
})
|
||||||
|
.index("by_user", ["userId"])
|
||||||
|
.index("by_user_account", ["userId", "accountId"])
|
||||||
|
.index("by_user_provider_fetched", ["userId", "provider", "fetchedAt"]),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Add `upsertAccountBalance` internal mutation in `convex/bank/internal.ts`.
|
||||||
|
- [ ] Add `listLatestAccountBalances` internal query that returns one latest balance per account for the authenticated user's accounts.
|
||||||
|
- [ ] Keep `accounts.openingBalance` unchanged; do not repurpose it as live balance.
|
||||||
|
- [ ] Test that inserting a newer balance replaces the displayed latest balance without mutating `accounts.openingBalance`.
|
||||||
|
|
||||||
|
## Provider Flow
|
||||||
|
|
||||||
|
### Task 2: Normalize Provider Balance Fetching
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `convex/bank/types.ts`
|
||||||
|
- Modify: `convex/bank/comdirectProvider.ts`
|
||||||
|
- Modify: `convex/bank/orchestrator.ts`
|
||||||
|
- Test: `convex/bank/balanceProviders.test.ts`
|
||||||
|
|
||||||
|
- [ ] Extend `NormalizedBalance` with optional `asOf?: string`.
|
||||||
|
- [ ] Keep comdirect `getBalance(accountExternalId)` as the per-account lookup, but add a bulk path that maps `/api/banking/clients/user/v2/accounts/balances` into `NormalizedBalance[]`.
|
||||||
|
- [ ] Extract FinTS balance fetching from `fetchFinTsAccountData()` into a reusable helper:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function fetchFinTsAccountBalance(
|
||||||
|
ctx: ActionCtx,
|
||||||
|
userId: Id<"users">,
|
||||||
|
client: FinTSClient,
|
||||||
|
account: NormalizedAccount,
|
||||||
|
syncJob: PendingSyncJob,
|
||||||
|
): Promise<NormalizedBalance>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] If FinTS reports `!client.canGetAccountBalance(account.externalId)`, return an error result for that account instead of failing the whole refresh.
|
||||||
|
- [ ] Preserve TAN handling by reusing `resolveTanResponse()` for balance requests.
|
||||||
|
- [ ] Test provider selection:
|
||||||
|
- `providerPreference: "fints"` uses FinTS only.
|
||||||
|
- `providerPreference: "comdirect"` uses REST and only falls back if the current fallback rules say the error is recoverable.
|
||||||
|
- `providerPreference: "auto"` tries REST when configured, otherwise FinTS.
|
||||||
|
|
||||||
|
## Convex Public API
|
||||||
|
|
||||||
|
### Task 3: Add Balance Query and Refresh Action
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `convex/bank/balances.ts`
|
||||||
|
- Modify: `convex/bank/orchestrator.ts`
|
||||||
|
- Modify: `convex/bank/internal.ts`
|
||||||
|
- Test: `convex/bank/balances.test.ts`
|
||||||
|
|
||||||
|
- [ ] Add public query `api.bank.balances.listLatest`.
|
||||||
|
|
||||||
|
Return shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
accountId: Id<"accounts">;
|
||||||
|
accountName: string;
|
||||||
|
accountType: string;
|
||||||
|
iban?: string;
|
||||||
|
externalId?: string;
|
||||||
|
balance: number | null;
|
||||||
|
currency: string;
|
||||||
|
provider: "comdirect" | "fints" | null;
|
||||||
|
fetchedAt: number | null;
|
||||||
|
asOf?: string;
|
||||||
|
status: "fresh" | "stale" | "error" | "missing";
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Add public action `api.bank.balances.refresh`.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
accountId?: Id<"accounts">;
|
||||||
|
pin?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Return shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
updatedCount: number;
|
||||||
|
provider: "comdirect" | "fints";
|
||||||
|
awaitingTan: boolean;
|
||||||
|
errors: Array<{ accountId?: Id<"accounts">; externalId?: string; message: string }>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Add internal action `refreshBalancesInternal` in `convex/bank/orchestrator.ts`, reusing provider preference and fallback logic from `runSyncInternal`.
|
||||||
|
- [ ] When transaction sync already fetches balances, call `upsertAccountBalance` during `persistSyncResults()` so a full sync also updates UI balances.
|
||||||
|
- [ ] Keep this as a Convex action/query API first; add an HTTP route in `convex/http.ts` only if an external scheduler or webhook needs it.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
### Task 4: Add Balance UI Components
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/accounts/AccountBalanceStrip.tsx`
|
||||||
|
- Modify: `src/pages/DashboardPage.tsx`
|
||||||
|
- Modify: `src/pages/SettingsPage.tsx`
|
||||||
|
- Optional modify: `src/components/import/ComdirectSyncPanel.tsx`
|
||||||
|
- Test: `src/components/accounts/AccountBalanceStrip.test.tsx`
|
||||||
|
|
||||||
|
- [ ] Create `AccountBalanceStrip` that calls `api.bank.balances.listLatest`.
|
||||||
|
- [ ] Show compact account rows with:
|
||||||
|
- account name
|
||||||
|
- IBAN tail or type
|
||||||
|
- live balance
|
||||||
|
- provider badge
|
||||||
|
- fetched timestamp
|
||||||
|
- stale/error/missing state
|
||||||
|
- [ ] Add refresh button with `RefreshCw` icon from `lucide-react`.
|
||||||
|
- [ ] Put the strip near the top of `DashboardPage`, above the period-based KPI cards. This avoids confusing live bank balance with the existing filtered `Saldo` KPI.
|
||||||
|
- [ ] In `SettingsPage`, show the latest live balance in each account row next to account metadata.
|
||||||
|
- [ ] For stale data, use subdued text and a warning badge; for errors, show the last known balance plus the error message.
|
||||||
|
- [ ] Keep cards shallow; do not nest balance cards inside other cards.
|
||||||
|
|
||||||
|
## UX Rules
|
||||||
|
|
||||||
|
- Label live bank balance as `Aktueller Kontostand`.
|
||||||
|
- Keep existing dashboard `Saldo` label as period result, or rename it to `Periodensaldo` to avoid ambiguity.
|
||||||
|
- Show `Noch nicht abgerufen` for accounts without a balance snapshot.
|
||||||
|
- Show `Aktualisiert vor ...` or localized timestamp for fetched balances.
|
||||||
|
- If refresh returns `awaitingTan`, reuse the existing TAN dialog/status flow rather than adding a second TAN UX.
|
||||||
|
- If comdirect REST fails and FinTS succeeds, show a non-blocking toast: `comdirect REST fehlgeschlagen, FinTS verwendet`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Task 5: Verify Backend Behavior
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `convex/bank/balances.test.ts`
|
||||||
|
- Create or extend: `convex/bank/balanceProviders.test.ts`
|
||||||
|
|
||||||
|
- [ ] Use `convex-test` with `edge-runtime`, matching existing project guidance.
|
||||||
|
- [ ] Test latest-balance query for `fresh`, `missing`, and `error` states.
|
||||||
|
- [ ] Test that archived accounts are either hidden by default or marked clearly; choose hidden for dashboard and visible in settings.
|
||||||
|
- [ ] Test full sync calls balance persistence for both providers.
|
||||||
|
- [ ] Test balance-only refresh does not import transactions.
|
||||||
|
|
||||||
|
### Task 6: Verify Frontend Behavior
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/accounts/AccountBalanceStrip.test.tsx`
|
||||||
|
- Modify: `src/pages/DashboardPage.tsx`
|
||||||
|
|
||||||
|
- [ ] Test loading skeleton.
|
||||||
|
- [ ] Test missing state.
|
||||||
|
- [ ] Test fresh EUR formatting with existing `formatAmount`.
|
||||||
|
- [ ] Test error state keeps last known balance visible.
|
||||||
|
- [ ] Test refresh button disables while the action is running.
|
||||||
|
|
||||||
|
## Execution Order
|
||||||
|
|
||||||
|
1. Add schema and internal persistence.
|
||||||
|
2. Add backend query tests and make them fail.
|
||||||
|
3. Implement internal balance mutations/queries.
|
||||||
|
4. Extract provider balance helpers.
|
||||||
|
5. Add refresh action and wire provider fallback.
|
||||||
|
6. Update transaction sync to persist balances opportunistically.
|
||||||
|
7. Add UI component and Dashboard placement.
|
||||||
|
8. Add Settings account-row balance display.
|
||||||
|
9. Run `pnpm lint`.
|
||||||
|
10. Run `pnpm build`.
|
||||||
|
11. Run targeted Vitest suites.
|
||||||
|
|
||||||
|
## Manual Verification
|
||||||
|
|
||||||
|
1. Set provider preference to `fints`, enter FinTS PIN if not in Convex env, refresh balances.
|
||||||
|
2. Confirm dashboard shows `Aktueller Kontostand` per account.
|
||||||
|
3. Confirm existing `Saldo`/`Periodensaldo` still follows the selected date range.
|
||||||
|
4. Set provider preference to `auto` with broken comdirect REST credentials and valid FinTS config; confirm fallback works.
|
||||||
|
5. Run a normal transaction sync and confirm balances update without pressing the balance refresh button separately.
|
||||||
|
|
||||||
76
src/components/accounts/AccountBalanceStrip.test.tsx
Normal file
76
src/components/accounts/AccountBalanceStrip.test.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { AccountBalanceStripView, type AccountBalanceRow } from "./AccountBalanceStrip";
|
||||||
|
|
||||||
|
const freshRow: AccountBalanceRow = {
|
||||||
|
accountId: "account-1",
|
||||||
|
accountName: "Girokonto",
|
||||||
|
accountType: "giro",
|
||||||
|
iban: "DE89370400440532013000",
|
||||||
|
externalId: "giro-1",
|
||||||
|
balance: 1234.56,
|
||||||
|
currency: "EUR",
|
||||||
|
provider: "fints",
|
||||||
|
fetchedAt: new Date("2026-06-23T08:00:00.000Z").getTime(),
|
||||||
|
status: "fresh",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("AccountBalanceStripView", () => {
|
||||||
|
test("renders a loading skeleton while balances load", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<AccountBalanceStripView rows={undefined} isRefreshing={false} onRefresh={() => undefined} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("Kontostände werden geladen");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders missing and fresh balance states", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<AccountBalanceStripView
|
||||||
|
rows={[
|
||||||
|
freshRow,
|
||||||
|
{
|
||||||
|
...freshRow,
|
||||||
|
accountId: "account-2",
|
||||||
|
accountName: "Tagesgeld",
|
||||||
|
balance: null,
|
||||||
|
provider: null,
|
||||||
|
fetchedAt: null,
|
||||||
|
status: "missing",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
isRefreshing={false}
|
||||||
|
onRefresh={() => undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("Aktueller Kontostand");
|
||||||
|
expect(markup).toContain("Girokonto");
|
||||||
|
expect(markup).toContain("1.234,56");
|
||||||
|
expect(markup).toContain("FinTS");
|
||||||
|
expect(markup).toContain("Tagesgeld");
|
||||||
|
expect(markup).toContain("Noch nicht abgerufen");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps last known balance visible for error states and disables refresh while busy", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<AccountBalanceStripView
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
...freshRow,
|
||||||
|
status: "error",
|
||||||
|
errorMessage: "HKSAL wird nicht unterstuetzt",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
isRefreshing
|
||||||
|
onRefresh={() => undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("1.234,56");
|
||||||
|
expect(markup).toContain("Fehler");
|
||||||
|
expect(markup).toContain("HKSAL wird nicht unterstuetzt");
|
||||||
|
expect(markup).toContain("disabled");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
177
src/components/accounts/AccountBalanceStrip.tsx
Normal file
177
src/components/accounts/AccountBalanceStrip.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useAction, useQuery } from "convex/react";
|
||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { api } from "../../../convex/_generated/api";
|
||||||
|
import { amountClass, formatAmount } from "@/lib/format";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
export type AccountBalanceRow = {
|
||||||
|
accountId: string;
|
||||||
|
accountName: string;
|
||||||
|
accountType: string;
|
||||||
|
iban?: string;
|
||||||
|
externalId?: string;
|
||||||
|
balance: number | null;
|
||||||
|
currency: string;
|
||||||
|
provider: "comdirect" | "fints" | null;
|
||||||
|
fetchedAt: number | null;
|
||||||
|
asOf?: string;
|
||||||
|
status: "fresh" | "stale" | "error" | "missing";
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function providerLabel(provider: AccountBalanceRow["provider"]) {
|
||||||
|
if (provider === "fints") return "FinTS";
|
||||||
|
if (provider === "comdirect") return "comdirect";
|
||||||
|
return "offline";
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountHint(row: AccountBalanceRow) {
|
||||||
|
if (row.iban) return `IBAN ...${row.iban.slice(-4)}`;
|
||||||
|
return row.accountType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchedLabel(row: AccountBalanceRow) {
|
||||||
|
if (!row.fetchedAt) return "Noch nicht abgerufen";
|
||||||
|
return `Aktualisiert ${new Date(row.fetchedAt).toLocaleString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(row: AccountBalanceRow) {
|
||||||
|
if (row.status === "error") return "Fehler";
|
||||||
|
if (row.status === "stale") return "Veraltet";
|
||||||
|
if (row.status === "missing") return "Offen";
|
||||||
|
return providerLabel(row.provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(row: AccountBalanceRow) {
|
||||||
|
if (row.status === "error") return "border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300";
|
||||||
|
if (row.status === "stale") return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-300";
|
||||||
|
if (row.status === "missing") return "text-muted-foreground";
|
||||||
|
return "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-300";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountBalanceStripView({
|
||||||
|
rows,
|
||||||
|
isRefreshing,
|
||||||
|
onRefresh,
|
||||||
|
}: {
|
||||||
|
rows: AccountBalanceRow[] | undefined;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||||
|
<CardTitle>Aktueller Kontostand</CardTitle>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isRefreshing || rows === undefined}
|
||||||
|
title="Kontostände aktualisieren"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||||
|
<span className="ml-2 hidden sm:inline">
|
||||||
|
{isRefreshing ? "Aktualisiert..." : "Aktualisieren"}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{rows === undefined ? (
|
||||||
|
<div className="space-y-3" aria-label="Kontostände werden geladen">
|
||||||
|
<Skeleton className="h-14" />
|
||||||
|
<Skeleton className="h-14" />
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Keine aktiven Konten</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.accountId}
|
||||||
|
className="rounded-lg border bg-background px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium">{row.accountName}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{accountHint(row)}</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className={statusClass(row)}>
|
||||||
|
{statusLabel(row)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-end justify-between gap-3">
|
||||||
|
<div
|
||||||
|
className={`text-xl font-semibold ${
|
||||||
|
row.balance === null ? "text-muted-foreground" : amountClass(row.balance)
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.balance === null ? "Noch nicht abgerufen" : formatAmount(row.balance)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-xs text-muted-foreground">
|
||||||
|
{fetchedLabel(row)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{row.errorMessage && (
|
||||||
|
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||||
|
{row.errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountBalanceStrip() {
|
||||||
|
const rows = useQuery(api.bank.balances.listLatest, {}) as
|
||||||
|
| AccountBalanceRow[]
|
||||||
|
| undefined;
|
||||||
|
const refreshBalances = useAction(api.bank.balances.refresh);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
const result = await refreshBalances({});
|
||||||
|
if (result.errors.length > 0 && result.updatedCount === 0) {
|
||||||
|
toast.error(result.errors[0].message);
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
`${result.updatedCount} Kontostand${result.updatedCount === 1 ? "" : "e"} aktualisiert (${providerLabel(result.provider)})`,
|
||||||
|
);
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
toast.message(result.errors[0].message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Kontostandabruf fehlgeschlagen");
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccountBalanceStripView
|
||||||
|
rows={rows}
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
onRefresh={() => {
|
||||||
|
void handleRefresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
296
src/components/chat/AgentChat.test.tsx
Normal file
296
src/components/chat/AgentChat.test.tsx
Normal 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 & 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 & 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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
372
src/components/chat/AgentChat.tsx
Normal file
372
src/components/chat/AgentChat.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/components/chat/agentChatModel.ts
Normal file
63
src/components/chat/agentChatModel.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useCallback, useState } from "react";
|
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 { useMutation, useQuery } from "convex/react";
|
||||||
import { api } from "../../../convex/_generated/api";
|
import { api } from "../../../convex/_generated/api";
|
||||||
import type { Id } from "../../../convex/_generated/dataModel";
|
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { ImportPreviewTable, type PreviewRow } from "./ImportPreviewTable";
|
import { ImportPreviewTable, type PreviewRow } from "./ImportPreviewTable";
|
||||||
|
import { parseComdirectCsv, parseComdirectDate } from "./csvParser";
|
||||||
import { toast } from "sonner";
|
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() {
|
export function CsvImportWizard() {
|
||||||
const accounts = useQuery(api.accounts.list);
|
const accounts = useQuery(api.accounts.list);
|
||||||
const settings = useQuery(api.settings.get);
|
const settings = useQuery(api.settings.get);
|
||||||
@@ -77,11 +39,7 @@ export function CsvImportWizard() {
|
|||||||
const preview: PreviewRow[] = [];
|
const preview: PreviewRow[] = [];
|
||||||
for (const row of parsed) {
|
for (const row of parsed) {
|
||||||
const isPending = row.buchungstag.trim().toLowerCase() === "offen";
|
const isPending = row.buchungstag.trim().toLowerCase() === "offen";
|
||||||
let bookingDate: string | undefined;
|
const bookingDate = parseComdirectDate(row.buchungstag);
|
||||||
if (!isPending) {
|
|
||||||
const d = parseDate(row.buchungstag, "dd.MM.yyyy", new Date());
|
|
||||||
bookingDate = format(d, "yyyy-MM-dd");
|
|
||||||
}
|
|
||||||
const amount = parseGermanAmount(row.betrag);
|
const amount = parseGermanAmount(row.betrag);
|
||||||
const { counterparty, description, rawText } = parseCounterpartyFromBuchungstext(row.buchungstext);
|
const { counterparty, description, rawText } = parseCounterpartyFromBuchungstext(row.buchungstext);
|
||||||
const categoryName = categorize(rawText, amount, row.vorgang, ownNames);
|
const categoryName = categorize(rawText, amount, row.vorgang, ownNames);
|
||||||
@@ -100,9 +58,7 @@ export function CsvImportWizard() {
|
|||||||
});
|
});
|
||||||
preview.push({
|
preview.push({
|
||||||
bookingDate,
|
bookingDate,
|
||||||
valueDate: row.valuta
|
valueDate: parseComdirectDate(row.valuta),
|
||||||
? format(parseDate(row.valuta, "dd.MM.yyyy", new Date()), "yyyy-MM-dd")
|
|
||||||
: undefined,
|
|
||||||
description,
|
description,
|
||||||
counterparty,
|
counterparty,
|
||||||
amount,
|
amount,
|
||||||
|
|||||||
48
src/components/import/csvParser.test.ts
Normal file
48
src/components/import/csvParser.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
97
src/components/import/csvParser.ts
Normal file
97
src/components/import/csvParser.ts
Normal 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;
|
||||||
|
}
|
||||||
154
src/components/transactions/CategoryPicker.tsx
Normal file
154
src/components/transactions/CategoryPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/components/transactions/categoryPickerModel.test.ts
Normal file
71
src/components/transactions/categoryPickerModel.test.ts
Normal 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]],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
56
src/components/transactions/categoryPickerModel.ts
Normal file
56
src/components/transactions/categoryPickerModel.ts
Normal 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";
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { CategoryBreakdownChart, FixedVariableSplit } from "@/components/charts/
|
|||||||
import { amountClass, formatAmount, formatDate, pct } from "@/lib/format";
|
import { amountClass, formatAmount, formatDate, pct } from "@/lib/format";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import type { Id } from "../../convex/_generated/dataModel";
|
import type { Id } from "../../convex/_generated/dataModel";
|
||||||
|
import { AccountBalanceStrip } from "@/components/accounts/AccountBalanceStrip";
|
||||||
|
|
||||||
function KpiCard({ title, value, className }: { title: string; value: string; className?: string }) {
|
function KpiCard({ title, value, className }: { title: string; value: string; className?: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -43,12 +44,14 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<AccountBalanceStrip />
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<KpiCard title="Einnahmen" value={formatAmount(summary.income)} className={amountClass(summary.income)} />
|
<KpiCard title="Einnahmen" value={formatAmount(summary.income)} className={amountClass(summary.income)} />
|
||||||
<KpiCard title="Ausgaben" value={formatAmount(summary.expenses)} className={amountClass(summary.expenses)} />
|
<KpiCard title="Ausgaben" value={formatAmount(summary.expenses)} className={amountClass(summary.expenses)} />
|
||||||
<KpiCard title="Fixkosten" value={formatAmount(summary.fixedCosts)} className={amountClass(summary.fixedCosts)} />
|
<KpiCard title="Fixkosten" value={formatAmount(summary.fixedCosts)} className={amountClass(summary.fixedCosts)} />
|
||||||
<KpiCard title="Variabel" value={formatAmount(summary.variableCosts)} className={amountClass(summary.variableCosts)} />
|
<KpiCard title="Variabel" value={formatAmount(summary.variableCosts)} className={amountClass(summary.variableCosts)} />
|
||||||
<KpiCard title="Saldo" value={formatAmount(summary.balance)} className={amountClass(summary.balance)} />
|
<KpiCard title="Periodensaldo" value={formatAmount(summary.balance)} className={amountClass(summary.balance)} />
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title="Sparquote"
|
title="Sparquote"
|
||||||
value={summary.savingsRate === null ? "–" : pct.format(summary.savingsRate)}
|
value={summary.savingsRate === null ? "–" : pct.format(summary.savingsRate)}
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { type FormEvent, useEffect, useRef, useState } from "react";
|
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useAction, useQuery } from "convex/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 { api } from "../../convex/_generated/api";
|
||||||
|
import type { Id } from "../../convex/_generated/dataModel";
|
||||||
import { useAccountFilterId } from "@/components/layout/AccountFilter";
|
import { useAccountFilterId } from "@/components/layout/AccountFilter";
|
||||||
import { useFilters } from "@/context/FilterContext";
|
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
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 { ChatHistory, type ChatHistoryItem } from "@/components/chat/ChatHistory";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -16,27 +23,47 @@ type ToolTrace = {
|
|||||||
inputSummary: string;
|
inputSummary: string;
|
||||||
resultSummary: string;
|
resultSummary: string;
|
||||||
};
|
};
|
||||||
|
type ChatSource = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
type ChatCitation = {
|
||||||
|
marker: string;
|
||||||
|
sourceId: string;
|
||||||
|
};
|
||||||
type UserChatMessage = { role: "user"; content: string };
|
type UserChatMessage = { role: "user"; content: string };
|
||||||
type AssistantChatMessage = {
|
type AssistantChatMessage = {
|
||||||
role: "assistant";
|
role: "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
toolTrace?: ToolTrace[];
|
toolTrace?: ToolTrace[];
|
||||||
|
sources?: ChatSource[];
|
||||||
|
citations?: ChatCitation[];
|
||||||
};
|
};
|
||||||
type ChatMessage = UserChatMessage | AssistantChatMessage;
|
type ChatMessage = UserChatMessage | AssistantChatMessage;
|
||||||
type ChatSession = {
|
type LegacyChatSession = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
};
|
};
|
||||||
|
type DisplayChatMessage = ChatMessage & { _id: string };
|
||||||
|
|
||||||
const STORAGE_KEY = "savings-chat-sessions";
|
const STORAGE_KEY = "savings-chat-sessions";
|
||||||
|
const IMPORTED_KEY = "savings-chat-sessions-imported-v1";
|
||||||
const initialAssistantMessage: ChatMessage = {
|
const initialAssistantMessage: ChatMessage = {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "Frag mich zu deinen Umsätzen – ich werte sie im aktuellen Zeitraum aus.",
|
content: "Frag mich zu deinen Umsätzen – ich werte sie im aktuellen Zeitraum aus.",
|
||||||
};
|
};
|
||||||
const fallbackMessages = [initialAssistantMessage];
|
const fallbackMessages: DisplayChatMessage[] = [{ ...initialAssistantMessage, _id: "fallback-0" }];
|
||||||
|
|
||||||
|
function localDateKey(date = new Date()) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeToolTrace(value: unknown): ToolTrace[] | undefined {
|
function normalizeToolTrace(value: unknown): ToolTrace[] | undefined {
|
||||||
if (!Array.isArray(value)) return undefined;
|
if (!Array.isArray(value)) return undefined;
|
||||||
@@ -63,6 +90,46 @@ function normalizeToolTrace(value: unknown): ToolTrace[] | undefined {
|
|||||||
return trace.length > 0 ? trace : 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 {
|
function normalizeMessage(value: unknown): ChatMessage | null {
|
||||||
if (!value || typeof value !== "object") return null;
|
if (!value || typeof value !== "object") return null;
|
||||||
const candidate = value as Record<string, unknown>;
|
const candidate = value as Record<string, unknown>;
|
||||||
@@ -72,9 +139,15 @@ function normalizeMessage(value: unknown): ChatMessage | null {
|
|||||||
}
|
}
|
||||||
if (candidate.role === "assistant") {
|
if (candidate.role === "assistant") {
|
||||||
const toolTrace = normalizeToolTrace(candidate.toolTrace);
|
const toolTrace = normalizeToolTrace(candidate.toolTrace);
|
||||||
return toolTrace
|
const sources = normalizeSources(candidate.sources);
|
||||||
? { role: "assistant", content: candidate.content, toolTrace }
|
const citations = normalizeCitations(candidate.citations);
|
||||||
: { role: "assistant", content: candidate.content };
|
return {
|
||||||
|
role: "assistant",
|
||||||
|
content: candidate.content,
|
||||||
|
...(toolTrace ? { toolTrace } : {}),
|
||||||
|
...(sources ? { sources } : {}),
|
||||||
|
...(citations ? { citations } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -84,7 +157,7 @@ function isChatMessage(value: ChatMessage | null): value is ChatMessage {
|
|||||||
return value !== null;
|
return value !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSession(value: unknown): ChatSession | null {
|
function normalizeSession(value: unknown): LegacyChatSession | null {
|
||||||
if (!value || typeof value !== "object") return null;
|
if (!value || typeof value !== "object") return null;
|
||||||
const candidate = value as Record<string, unknown>;
|
const candidate = value as Record<string, unknown>;
|
||||||
if (
|
if (
|
||||||
@@ -109,53 +182,76 @@ function normalizeSession(value: unknown): ChatSession | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSession(): ChatSession {
|
function loadLegacySessions(): LegacyChatSession[] {
|
||||||
const now = Date.now();
|
|
||||||
const randomId =
|
|
||||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: `${now}-${Math.random().toString(36).slice(2)}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: randomId,
|
|
||||||
title: "Neuer Chat",
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
messages: [initialAssistantMessage],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadSessions(): ChatSession[] {
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return [createSession()];
|
if (!raw) return [];
|
||||||
const parsed: unknown = JSON.parse(raw);
|
const parsed: unknown = JSON.parse(raw);
|
||||||
if (!Array.isArray(parsed) || parsed.length === 0) return [createSession()];
|
if (!Array.isArray(parsed)) return [];
|
||||||
const sessions = parsed.map(normalizeSession);
|
return parsed
|
||||||
if (sessions.some((session) => session === null)) return [createSession()];
|
.map(normalizeSession)
|
||||||
return sessions as ChatSession[];
|
.filter((session): session is LegacyChatSession => session !== null)
|
||||||
|
.filter((session) => session.messages.some((message) => message.role === "user"));
|
||||||
} catch {
|
} catch {
|
||||||
return [createSession()];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function titleFromMessages(messages: ChatMessage[]) {
|
|
||||||
const firstUserMessage = messages.find((message) => message.role === "user")?.content.trim();
|
|
||||||
if (!firstUserMessage) return "Neuer Chat";
|
|
||||||
return firstUserMessage.length > 44 ? `${firstUserMessage.slice(0, 44)}…` : firstUserMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SavingsChatPage() {
|
export function SavingsChatPage() {
|
||||||
const { from, to, monthBasis } = useFilters();
|
const { from, to, monthBasis } = useFilters();
|
||||||
const accountId = useAccountFilterId();
|
const accountId = useAccountFilterId();
|
||||||
|
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [sessions, setSessions] = useState<ChatSession[]>(loadSessions);
|
const [selectedSessionId, setSelectedSessionId] = useState<Id<"chatSessions"> | undefined>();
|
||||||
const [activeSessionId, setActiveSessionId] = useState(() => sessions[0]?.id ?? "");
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [applyingPlanId, setApplyingPlanId] = useState<string | undefined>();
|
||||||
|
const [legacyImportResult, setLegacyImportResult] = useState<{
|
||||||
|
key: string;
|
||||||
|
importedCount: number;
|
||||||
|
} | null>(null);
|
||||||
|
const legacyImportStartedRef = useRef<string | undefined>(undefined);
|
||||||
|
const createInitialStartedRef = useRef(false);
|
||||||
const listRef = useRef<HTMLDivElement | null>(null);
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0];
|
|
||||||
const messages = activeSession?.messages ?? fallbackMessages;
|
const sessionsQuery = usePaginatedQuery(
|
||||||
|
api.savingsChatHistory.listSessions,
|
||||||
|
{},
|
||||||
|
{ initialNumItems: 50 },
|
||||||
|
);
|
||||||
|
const sessions = sessionsQuery.results;
|
||||||
|
const activeSessionId =
|
||||||
|
selectedSessionId && sessions.some((session) => session._id === selectedSessionId)
|
||||||
|
? selectedSessionId
|
||||||
|
: sessions[0]?._id;
|
||||||
|
const activeSession = sessions.find((session) => session._id === activeSessionId);
|
||||||
|
const messagesQuery = usePaginatedQuery(
|
||||||
|
api.savingsChatHistory.listMessages,
|
||||||
|
activeSessionId ? { sessionId: activeSessionId } : "skip",
|
||||||
|
{ initialNumItems: 100 },
|
||||||
|
);
|
||||||
|
const messages = useMemo(
|
||||||
|
() => [...messagesQuery.results].reverse() as DisplayChatMessage[],
|
||||||
|
[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, {
|
const context = useQuery(api.savingsChat.getContext, {
|
||||||
from,
|
from,
|
||||||
@@ -163,9 +259,27 @@ export function SavingsChatPage() {
|
|||||||
accountId,
|
accountId,
|
||||||
basis: monthBasis,
|
basis: monthBasis,
|
||||||
});
|
});
|
||||||
const ask = useAction(api.savingsChat.ask);
|
const currentUser = useQuery(api.users.currentUser);
|
||||||
|
const pendingActionPlans = useQuery(
|
||||||
const buttonDisabled = isSubmitting || draft.trim().length === 0;
|
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(
|
||||||
|
importMarkerKey &&
|
||||||
|
(legacyImportResult?.key === importMarkerKey ||
|
||||||
|
localStorage.getItem(importMarkerKey) === "true"),
|
||||||
|
);
|
||||||
|
const legacyImportedCount =
|
||||||
|
legacyImportResult && legacyImportResult.key === importMarkerKey
|
||||||
|
? legacyImportResult.importedCount
|
||||||
|
: 0;
|
||||||
|
|
||||||
const formatAmount = (amount: number) =>
|
const formatAmount = (amount: number) =>
|
||||||
new Intl.NumberFormat("de-DE", {
|
new Intl.NumberFormat("de-DE", {
|
||||||
@@ -181,93 +295,149 @@ export function SavingsChatPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listRef.current?.lastElementChild?.scrollIntoView({ behavior: "smooth" });
|
scrollConversationToBottom(listRef.current);
|
||||||
}, [messages]);
|
}, [displayMessages.length, activeSessionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions));
|
if (!importMarkerKey || legacyImportComplete || legacyImportStartedRef.current === importMarkerKey) return;
|
||||||
}, [sessions]);
|
legacyImportStartedRef.current = importMarkerKey;
|
||||||
|
const legacySessions = loadLegacySessions();
|
||||||
const updateSession = (id: string, nextMessages: ChatMessage[]) => {
|
if (legacySessions.length === 0) {
|
||||||
const now = Date.now();
|
localStorage.setItem(importMarkerKey, "true");
|
||||||
setSessions((prev) =>
|
void Promise.resolve().then(() =>
|
||||||
prev.map((session) =>
|
setLegacyImportResult({ key: importMarkerKey, importedCount: 0 }),
|
||||||
session.id === id
|
|
||||||
? {
|
|
||||||
...session,
|
|
||||||
title: titleFromMessages(nextMessages),
|
|
||||||
updatedAt: now,
|
|
||||||
messages: nextMessages,
|
|
||||||
}
|
|
||||||
: session,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
};
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let importedCount = 0;
|
||||||
|
void Promise.all(
|
||||||
|
legacySessions.map((session) =>
|
||||||
|
importLocalSession({
|
||||||
|
legacyLocalId: session.id,
|
||||||
|
title: session.title,
|
||||||
|
createdAt: session.createdAt,
|
||||||
|
updatedAt: session.updatedAt,
|
||||||
|
messages: session.messages,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
importedCount = legacySessions.length;
|
||||||
|
localStorage.setItem(importMarkerKey, "true");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Lokale Chat-Historie konnte nicht importiert werden.");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLegacyImportResult({ key: importMarkerKey, importedCount });
|
||||||
|
});
|
||||||
|
}, [importLocalSession, importMarkerKey, legacyImportComplete]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!legacyImportComplete ||
|
||||||
|
legacyImportedCount > 0 ||
|
||||||
|
sessionsQuery.status === "LoadingFirstPage" ||
|
||||||
|
sessions.length > 0 ||
|
||||||
|
createInitialStartedRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createInitialStartedRef.current = true;
|
||||||
|
void createSession({ title: "Neuer Chat" })
|
||||||
|
.then((session) => setSelectedSessionId(session.sessionId))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Neuer Chat konnte nicht erstellt werden.");
|
||||||
|
});
|
||||||
|
}, [createSession, legacyImportComplete, legacyImportedCount, sessions.length, sessionsQuery.status]);
|
||||||
|
|
||||||
const createNewChat = () => {
|
const createNewChat = () => {
|
||||||
const session = createSession();
|
|
||||||
setSessions((prev) => [session, ...prev]);
|
|
||||||
setActiveSessionId(session.id);
|
|
||||||
setDraft("");
|
setDraft("");
|
||||||
|
void createSession({ title: "Neuer Chat" })
|
||||||
|
.then((session) => setSelectedSessionId(session.sessionId))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Neuer Chat konnte nicht erstellt werden.");
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteChat = (id: string) => {
|
const deleteChat = (id: string) => {
|
||||||
const remaining = sessions.filter((session) => session.id !== id);
|
void deleteSession({ sessionId: id as Id<"chatSessions"> })
|
||||||
const nextSessions = remaining.length > 0 ? remaining : [createSession()];
|
.then(() => {
|
||||||
setSessions(nextSessions);
|
if (id === activeSessionId) {
|
||||||
if (id === activeSessionId) setActiveSessionId(nextSessions[0].id);
|
const nextSession = sessions.find((session) => session._id !== id);
|
||||||
|
setSelectedSessionId(nextSession?._id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Chat konnte nicht gelöscht werden.");
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const historyItems: ChatHistoryItem[] = sessions
|
const historyItems: ChatHistoryItem[] = useMemo(
|
||||||
.map((session) => ({
|
() =>
|
||||||
id: session.id,
|
sessions.map((session) => ({
|
||||||
|
id: session._id,
|
||||||
title: session.title,
|
title: session.title,
|
||||||
updatedAt: session.updatedAt,
|
updatedAt: session.updatedAt,
|
||||||
messageCount: session.messages.length,
|
messageCount: session.messageCount,
|
||||||
}))
|
})),
|
||||||
.sort((a, b) => b.updatedAt - a.updatedAt);
|
[sessions],
|
||||||
|
);
|
||||||
|
const actionPlans = (pendingActionPlans ?? []) as AgentActionPlan[];
|
||||||
|
|
||||||
|
const applyPlan = (planId: string) => {
|
||||||
|
setApplyingPlanId(planId);
|
||||||
|
void applyActionPlan({ planId: planId as Id<"agentActionPlans"> })
|
||||||
|
.then((result) => {
|
||||||
|
toast.success(result.summary);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(error instanceof Error ? error.message : "Vorschlag konnte nicht angewendet werden.");
|
||||||
|
})
|
||||||
|
.finally(() => setApplyingPlanId(undefined));
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissPlan = (planId: string) => {
|
||||||
|
setApplyingPlanId(planId);
|
||||||
|
void dismissActionPlan({ planId: planId as Id<"agentActionPlans"> })
|
||||||
|
.then(() => {
|
||||||
|
toast.message("Vorschlag verworfen");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(error instanceof Error ? error.message : "Vorschlag konnte nicht verworfen werden.");
|
||||||
|
})
|
||||||
|
.finally(() => setApplyingPlanId(undefined));
|
||||||
|
};
|
||||||
|
|
||||||
const submit = async (event: FormEvent) => {
|
const submit = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const content = draft.trim();
|
const content = draft.trim();
|
||||||
if (!content || isSubmitting) return;
|
if (!content || isSubmitting || !activeSessionId) return;
|
||||||
|
|
||||||
const submittedSessionId = activeSessionId;
|
|
||||||
const typedNextMessages: ChatMessage[] = [...messages, { role: "user", content }];
|
|
||||||
updateSession(submittedSessionId, typedNextMessages);
|
|
||||||
setDraft("");
|
setDraft("");
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await ask({
|
await sendMessage({
|
||||||
messages: typedNextMessages.map((message) => ({
|
sessionId: activeSessionId,
|
||||||
role: message.role,
|
content,
|
||||||
content: message.content,
|
|
||||||
})),
|
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
accountId,
|
accountId,
|
||||||
basis: monthBasis,
|
basis: monthBasis,
|
||||||
}) as { answer: string; toolTrace?: unknown };
|
today: localDateKey(),
|
||||||
|
});
|
||||||
updateSession(submittedSessionId, [
|
|
||||||
...typedNextMessages,
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: response.answer,
|
|
||||||
toolTrace: normalizeToolTrace(response.toolTrace),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error("Antwort konnte nicht geladen werden.");
|
toast.error("Antwort konnte nicht geladen werden.");
|
||||||
updateSession(submittedSessionId, [
|
|
||||||
...typedNextMessages,
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: "Ich konnte gerade keine Antwort erzeugen. Bitte später erneut versuchen.",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -277,8 +447,8 @@ export function SavingsChatPage() {
|
|||||||
<div className="grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
|
<div className="grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
|
||||||
<ChatHistory
|
<ChatHistory
|
||||||
items={historyItems}
|
items={historyItems}
|
||||||
activeId={activeSessionId}
|
activeId={activeSessionId ?? ""}
|
||||||
onSelect={setActiveSessionId}
|
onSelect={(id) => setSelectedSessionId(id as Id<"chatSessions">)}
|
||||||
onCreate={createNewChat}
|
onCreate={createNewChat}
|
||||||
onDelete={deleteChat}
|
onDelete={deleteChat}
|
||||||
/>
|
/>
|
||||||
@@ -300,66 +470,31 @@ export function SavingsChatPage() {
|
|||||||
Konto: {context?.accountName ?? "Alle Konten"} (Basis-Saldo{" "}
|
Konto: {context?.accountName ?? "Alle Konten"} (Basis-Saldo{" "}
|
||||||
{context ? formatAmount(context.totals.balance) : "—"})
|
{context ? formatAmount(context.totals.balance) : "—"})
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>{context ? getContextSummary() : "Lade Kontext…"}</p>
|
||||||
{context ? getContextSummary() : "Lade Kontext…"}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<AgentConversation
|
||||||
<CardContent className="p-4">
|
messages={agentMessages}
|
||||||
<div className="h-[52vh] overflow-y-auto" ref={listRef}>
|
isSubmitting={isSubmitting}
|
||||||
<div className="space-y-3">
|
scrollRef={listRef}
|
||||||
{messages.map((message, index) => (
|
/>
|
||||||
<div
|
|
||||||
key={`${message.role}-${index}`}
|
<AgentActionPlanList
|
||||||
className={`rounded-lg border p-3 ${
|
plans={actionPlans}
|
||||||
message.role === "user" ? "bg-muted/50" : "bg-background"
|
isApplying={Boolean(applyingPlanId)}
|
||||||
}`}
|
onApply={applyPlan}
|
||||||
>
|
onDismiss={dismissPlan}
|
||||||
<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 && (
|
<AgentPromptInput
|
||||||
<div className="mt-3 rounded-md border bg-muted/30 p-2">
|
value={draft}
|
||||||
<p className="text-xs font-medium text-muted-foreground">
|
onChange={setDraft}
|
||||||
Verwendete Werkzeuge
|
onSubmit={submit}
|
||||||
</p>
|
placeholder={activeSession ? "Welche Auswertung soll ich machen?" : "Chat wird vorbereitet..."}
|
||||||
<div className="mt-2 space-y-2">
|
disabled={isSubmitting || !activeSessionId}
|
||||||
{message.toolTrace.map((tool, toolIndex) => (
|
isSubmitting={isSubmitting}
|
||||||
<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>
|
|
||||||
|
|
||||||
<form className="flex gap-2" onSubmit={submit}>
|
|
||||||
<Input
|
|
||||||
value={draft}
|
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
|
||||||
placeholder="Welche Auswertung soll ich machen?"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
<Button type="submit" disabled={buttonDisabled}>
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
Senden
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -10,10 +10,15 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { BankConfigForm } from "@/components/import/BankConfigForm";
|
import { BankConfigForm } from "@/components/import/BankConfigForm";
|
||||||
|
import { amountClass, formatAmount } from "@/lib/format";
|
||||||
|
import type { AccountBalanceRow } from "@/components/accounts/AccountBalanceStrip";
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const settings = useQuery(api.settings.get);
|
const settings = useQuery(api.settings.get);
|
||||||
const accounts = useQuery(api.accounts.list);
|
const accounts = useQuery(api.accounts.list);
|
||||||
|
const balances = useQuery(api.bank.balances.listLatest, { includeArchived: true }) as
|
||||||
|
| AccountBalanceRow[]
|
||||||
|
| undefined;
|
||||||
const updateSettings = useMutation(api.settings.update);
|
const updateSettings = useMutation(api.settings.update);
|
||||||
const applySalaryShift = useMutation(api.transactions.applySalaryShift);
|
const applySalaryShift = useMutation(api.transactions.applySalaryShift);
|
||||||
const createAccount = useMutation(api.accounts.create);
|
const createAccount = useMutation(api.accounts.create);
|
||||||
@@ -48,6 +53,9 @@ export function SettingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [newAccount, setNewAccount] = useState({ name: "", type: "giro", openingBalance: 0 });
|
const [newAccount, setNewAccount] = useState({ name: "", type: "giro", openingBalance: 0 });
|
||||||
|
const balanceByAccountId = new Map(
|
||||||
|
balances?.map((balance) => [balance.accountId, balance]) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl space-y-6">
|
<div className="mx-auto max-w-3xl space-y-6">
|
||||||
@@ -58,14 +66,33 @@ export function SettingsPage() {
|
|||||||
<CardTitle>Konten</CardTitle>
|
<CardTitle>Konten</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{accounts?.map((account) => (
|
{accounts?.map((account) => {
|
||||||
|
const liveBalance = balanceByAccountId.get(account._id);
|
||||||
|
return (
|
||||||
<div key={account._id} className="flex flex-wrap items-center gap-2 rounded-lg border p-3">
|
<div key={account._id} className="flex flex-wrap items-center gap-2 rounded-lg border p-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="font-medium">{account.name}</div>
|
<div className="font-medium">{account.name}</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{account.type} · {account.iban ?? "keine IBAN"}
|
{account.type} · {account.iban ?? "keine IBAN"}
|
||||||
{account.externalId && " · comdirect verbunden"}
|
{account.externalId && " · Bank verbunden"}
|
||||||
</div>
|
</div>
|
||||||
|
{liveBalance && (
|
||||||
|
<div className="mt-1 text-xs">
|
||||||
|
<span className="text-muted-foreground">Aktueller Kontostand: </span>
|
||||||
|
{liveBalance.balance === null ? (
|
||||||
|
<span className="text-muted-foreground">Noch nicht abgerufen</span>
|
||||||
|
) : (
|
||||||
|
<span className={amountClass(liveBalance.balance)}>
|
||||||
|
{formatAmount(liveBalance.balance)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{liveBalance.status === "error" && (
|
||||||
|
<span className="ml-2 text-red-600 dark:text-red-400">
|
||||||
|
{liveBalance.errorMessage ?? "Fehler"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -84,7 +111,8 @@ export function SettingsPage() {
|
|||||||
Löschen
|
Löschen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { amountClass, formatAmount, formatDate, formatMonth } from "@/lib/format";
|
import { amountClass, formatAmount, formatDate, formatMonth } from "@/lib/format";
|
||||||
import { TransactionFormDialog } from "@/components/transactions/TransactionFormDialog";
|
import { TransactionFormDialog } from "@/components/transactions/TransactionFormDialog";
|
||||||
import { AssignMonthDialog } from "@/components/transactions/AssignMonthDialog";
|
import { AssignMonthDialog } from "@/components/transactions/AssignMonthDialog";
|
||||||
|
import { CategoryPicker } from "@/components/transactions/CategoryPicker";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
getVisibleSelectionState,
|
getVisibleSelectionState,
|
||||||
@@ -82,64 +83,18 @@ const AccountCell = memo(function AccountCell({ name }: { name: string | undefin
|
|||||||
const CategoryCell = memo(function CategoryCell({
|
const CategoryCell = memo(function CategoryCell({
|
||||||
tx,
|
tx,
|
||||||
categories,
|
categories,
|
||||||
categoryMap,
|
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}: {
|
}: {
|
||||||
tx: Tx;
|
tx: Tx;
|
||||||
categories: Category[] | undefined;
|
categories: Category[] | undefined;
|
||||||
categoryMap: Map<Id<"categories">, Category>;
|
onUpdate: (id: Id<"transactions">, categoryId: Id<"categories">) => Promise<void>;
|
||||||
onUpdate: (id: Id<"transactions">, categoryId: Id<"categories">) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const cat = tx.categoryId ? categoryMap.get(tx.categoryId) : null;
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
return (
|
return (
|
||||||
<button
|
<CategoryPicker
|
||||||
type="button"
|
categories={categories}
|
||||||
onClick={() => setOpen(true)}
|
value={tx.categoryId}
|
||||||
className="inline-flex h-6 items-center rounded px-1.5 text-xs hover:bg-accent"
|
onSelect={(categoryId) => onUpdate(tx._id, categoryId)}
|
||||||
>
|
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -228,10 +183,6 @@ export function TransactionsPage() {
|
|||||||
{ initialNumItems: 50 },
|
{ initialNumItems: 50 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const categoryMap = useMemo(
|
|
||||||
() => new Map(categories?.map((c) => [c._id, c])),
|
|
||||||
[categories],
|
|
||||||
);
|
|
||||||
const accountMap = useMemo(
|
const accountMap = useMemo(
|
||||||
() => new Map(accounts?.map((a) => [a._id, a.name])),
|
() => new Map(accounts?.map((a) => [a._id, a.name])),
|
||||||
[accounts],
|
[accounts],
|
||||||
@@ -247,8 +198,8 @@ export function TransactionsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleUpdateCategory = useCallback(
|
const handleUpdateCategory = useCallback(
|
||||||
(id: Id<"transactions">, categoryId: Id<"categories">) => {
|
async (id: Id<"transactions">, categoryId: Id<"categories">) => {
|
||||||
void updateTx({ id, categoryId });
|
await updateTx({ id, categoryId });
|
||||||
},
|
},
|
||||||
[updateTx],
|
[updateTx],
|
||||||
);
|
);
|
||||||
@@ -343,7 +294,6 @@ export function TransactionsPage() {
|
|||||||
<CategoryCell
|
<CategoryCell
|
||||||
tx={row.original}
|
tx={row.original}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
categoryMap={categoryMap}
|
|
||||||
onUpdate={handleUpdateCategory}
|
onUpdate={handleUpdateCategory}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -382,7 +332,6 @@ export function TransactionsPage() {
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
categories,
|
categories,
|
||||||
categoryMap,
|
|
||||||
accountMap,
|
accountMap,
|
||||||
handleUpdateCategory,
|
handleUpdateCategory,
|
||||||
handleEdit,
|
handleEdit,
|
||||||
|
|||||||
Reference in New Issue
Block a user