feat: add guarded savings agent tools

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

View File

@@ -0,0 +1,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.