Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-01-finanzen-plan-1-bank.md
T

220 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Finanzen Plan 1: Bankanbindung (Enable Banking → Finom) 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:** Der Kundenportal-Server ruft Kontostand und Umsätze des Finom-Kontos über Enable Banking ab, speichert sie dauerhaft in Appwrite, erneuert den Bankzugang auf Knopfdruck und warnt vor dessen Ablauf.
**Architecture:** Ein dünner Enable-Banking-Client (`node:crypto` signiert das RS256-JWT, `fetch` spricht die API) unter `server/services/finance/`, ein Sync-Dienst, der Buchungen idempotent in `bankTransactions` schreibt, und ein Admin-Router `/api/admin/finanzen` (requireAppwriteStaff). Jobs laufen wie die übrigen Portal-Jobs per `setInterval` in `server/index.js`.
**Tech Stack:** Node 20 (ESM), Express 4, Appwrite REST (`services/appwriteAdmin.js`), `node:test`, Nachbauten in `test/support/`.
**Spec:** `docs/superpowers/specs/2026-10-01-finanzen-design.md`
## Global Constraints
- Arbeitsverzeichnis: Worktree `/home/webklar/apps/kundenbereich.webklar.com/dev-finanzen`, Branch `feature/finanzen` (von `main` c37dd49). **Nie pushen** ohne ausdrückliches OK des Nutzers (der Deploy-Webhook kann jede gepushte Branch live stellen).
- Tests: `npm test` (= `node --test test/*.test.js`) muss vor jedem Commit grün sein; einzelne Datei: `node --test test/<datei>.test.js`.
- Keine neuen npm-Abhängigkeiten.
- Enable Banking: API `https://api.enablebanking.com`, JWT-Header `{ typ: 'JWT', alg: 'RS256', kid: <App-ID> }`, Payload `{ iss: 'enablebanking.com', aud: 'api.enablebanking.com', iat, exp: iat + 3600 }`.
- ASPSP `{ name: 'Finom', country: 'DE' }`, `psu_type: 'business'`, Redirect `https://ticket.webklar.com/finanzen/bank-callback`, Zugang `valid_until` = jetzt + 179 Tage.
- `ENABLE_BANKING_API_BASE` (nur Tests) wird nur für `127.0.0.1`/`localhost` akzeptiert, sonst ignoriert (wie `STRIPE_API_BASE`).
- Beträge intern immer als ganze Cent (`amountCents`, Integer); Eingang positiv, Ausgang negativ.
- Daten im Format `YYYY-MM-DD`; Berechnungen „heute“ in Europe/Berlin.
- Der private Schlüssel und Sitzungs-IDs erscheinen nie in Logs, Fehlermeldungen oder API-Antworten.
- Kommentare und Fehlermeldungen auf Deutsch, Stil wie die umgebenden Dateien (ASCII-Umlaute in Code-Kommentaren wie im Bestand, echte Umlaute in Texten für Menschen).
## Review Focus
- **Doppelte Abrufe:** Dieselbe Buchung kommt bei jedem Abruf erneut → darf nie doppelt gespeichert werden und darf Nutzerfelder (`category`, `matched*`, `fixedCostId`, `note`) nie überschreiben. Test in Task 3.
- **Vorgemerkte Buchungen:** Eine `PDNG`-Buchung verschwindet beim nächsten Abruf (wird gebucht oder storniert) → die alte Vormerkung wird gelöscht, die gebuchte erscheint einmal. Test in Task 3.
- **Abgelaufener Zugang:** Enable Banking antwortet 401/403 → Status `abgelaufen`, alte Daten bleiben, kein Absturz des Jobs, keine Wiederholungsschleife. Test in Task 3.
- **Fremde Rückkehr:** `state` passt nicht oder ist älter als 30 Minuten, oder Finom meldet `error` → 400 mit verständlicher Meldung, bestehende Sitzung bleibt unverändert. Test in Task 4.
- **Paging:** Enable Banking liefert `continuation_key` → alle Seiten werden geholt, aber höchstens 20 Seiten (Schutz vor Endlosschleife). Test in Task 1.
---
### Task 1: Konfiguration und Enable-Banking-Client
**Files:**
- Modify: `server/config.js` (neuer Block `enableBanking` nach `stripe`)
- Create: `server/services/finance/enableBanking.js`
- Create: `test/support/fakeEnableBanking.js`
- Test: `test/financeEnableBanking.test.js`
**Interfaces:**
- Produces:
- `config.enableBanking = { appId, keyPath, apiBase, redirectUrl, aspsp: { name: 'Finom', country: 'DE' }, enabled }` – `appId` aus `ENABLE_BANKING_APP_ID`, `keyPath` aus `ENABLE_BANKING_KEY_PATH`, `apiBase` = `ENABLE_BANKING_API_BASE` nur bei 127.0.0.1/localhost sonst `'https://api.enablebanking.com'`, `redirectUrl` = `${config.womsBaseUrl}/finanzen/bank-callback`, `enabled` = beide gesetzt.
- `createJwt({ now = Date.now() } = {}) → string`
- `startAuth({ state, validUntil }) → Promise<{ url, authorizationId }>`
- `createSession(code) → Promise<{ sessionId, validUntil, accounts: Array<{ uid, identificationHash, iban, name, currency }> }>`
- `getSession(sessionId) → Promise<{ status, validUntil, accountUids: string[] }>`
- `getBalances(accountUid, { psu } = {}) → Promise<Array<{ type, amountCents, currency, date }>>`
- `getTransactions(accountUid, { dateFrom, psu } = {}) → Promise<object[]>` (Rohobjekte von Enable Banking, alle Seiten)
- `class EnableBankingError extends Error { status, code }` – `status` = HTTP-Status, `code` = `body.code ?? body.error ?? null`
- `psu` = `{ ip, userAgent }` → Kopfzeilen `Psu-Ip-Address`, `Psu-User-Agent`
- `startFakeEnableBanking({ publicKeyPem })` in `test/support/fakeEnableBanking.js` → `{ url, calls, sessions: Map, accounts: Map, transactions: Map<uid, object[]>, pageSize, failNext({ path, status, body }), close() }`; prüft jedes JWT mit `publicKeyPem` (`crypto.verify('sha256', …)`) und Claims, sonst 401.
- [ ] **Step 1: Write the failing tests** in `test/financeEnableBanking.test.js`. Schlüsselpaar im Test erzeugen (`crypto.generateKeyPairSync('rsa', { modulusLength: 2048 })`), PEM in eine Datei im `os.tmpdir()` schreiben, `ENABLE_BANKING_APP_ID='app-test'`, `ENABLE_BANKING_KEY_PATH=<datei>`, `ENABLE_BANKING_API_BASE=<fake.url>` vor dem Import von `config.js` setzen.
```js
test('createJwt: RS256, kid = App-ID, Claims', () => {
const [h, p, s] = createJwt({ now: 1_790_000_000_000 }).split('.')
const header = JSON.parse(Buffer.from(h, 'base64url'))
const payload = JSON.parse(Buffer.from(p, 'base64url'))
assert.deepEqual(header, { typ: 'JWT', alg: 'RS256', kid: 'app-test' })
assert.deepEqual(payload, { iss: 'enablebanking.com', aud: 'api.enablebanking.com', iat: 1_790_000_000, exp: 1_790_003_600 })
assert.ok(crypto.verify('sha256', Buffer.from(`${h}.${p}`), publicKey, Buffer.from(s, 'base64url')))
})
test('startAuth schickt Finom, business, Redirect und valid_until', async () => {
const r = await startAuth({ state: 'abc', validUntil: '2027-03-29T00:00:00.000Z' })
assert.match(r.url, /^https?:\/\//)
const call = fake.calls.at(-1)
assert.equal(call.path, '/auth')
assert.deepEqual(call.body, { access: { valid_until: '2027-03-29T00:00:00.000Z' }, aspsp: { name: 'Finom', country: 'DE' }, state: 'abc', redirect_url: 'https://ticket.webklar.com/finanzen/bank-callback', psu_type: 'business' })
})
test('createSession liefert Sitzung und Konten', async () => { /* fake: code 'ok' → session_id 's1', accounts [{uid:'u1', identification_hash:'h1', account_id:{iban:'DE62…9313'}, name:'Webklar', currency:'EUR'}], access.valid_until */
const s = await createSession('ok')
assert.equal(s.sessionId, 's1'); assert.equal(s.accounts[0].uid, 'u1'); assert.equal(s.accounts[0].identificationHash, 'h1'); assert.equal(s.accounts[0].iban, 'DE62…9313')
})
test('getTransactions holt alle Seiten (continuation_key), höchstens 20', async () => { /* 5 Buchungen, pageSize 2 → 3 Aufrufe, 5 Ergebnisse; endlose continuation_key → nach 20 Aufrufen Schluss */ })
test('getBalances rechnet in Cent', async () => { /* "108.93" → 10893, "0.5" → 50 */ })
test('PSU-Kopfzeilen nur wenn psu übergeben', async () => { /* calls[].headers['psu-ip-address'] */ })
test('Fehler → EnableBankingError mit status und code, ohne Sitzungs-ID in der Meldung', async () => { /* failNext({ path: /\/accounts\//, status: 401, body: { code: 'EXPIRED_SESSION' } }) */ })
test('ENABLE_BANKING_API_BASE außerhalb von 127.0.0.1 wird ignoriert', () => { /* reine Funktion resolveApiBase(value) exportieren und prüfen */ })
```
- [ ] **Step 2: Run** `node --test test/financeEnableBanking.test.js` – Expected: FAIL (Modul fehlt).
- [ ] **Step 3: Implement** `test/support/fakeEnableBanking.js` (Routen `POST /auth`, `POST /sessions`, `GET /sessions/:id`, `GET /accounts/:uid/balances`, `GET /accounts/:uid/transactions` mit `continuation_key` = Index als String), dann `server/config.js` und `server/services/finance/enableBanking.js`. Den Schlüssel bei jedem `createJwt` lesen (`readFileSync(config.enableBanking.keyPath)`), damit ein Schlüsseltausch ohne Neustart wirkt. `toCents(amountString)` als exportierte Hilfsfunktion: Ganzzahl- und Nachkommateil getrennt parsen, keine Gleitkommarechnung.
- [ ] **Step 4: Run** `node --test test/financeEnableBanking.test.js` – Expected: PASS. Dann `npm test` – Expected: alles grün.
- [ ] **Step 5: Commit**
```bash
git add server/config.js server/services/finance/enableBanking.js test/support/fakeEnableBanking.js test/financeEnableBanking.test.js
git commit -m "Finanzen: Enable-Banking-Client (JWT, Freigabe, Sitzung, Salden, Umsaetze)"
```
### Task 2: Collections und Übernahme der bestehenden Sitzung
**Files:**
- Create: `scripts/setup-finanzen.mjs`
- Modify: `test/support/schema.js` (zweite Quelldatei)
- Modify: `server/config.js` (`collections.bankConnections`, `bankTransactions`, `financeSettings`, `financeFixedCosts`)
- Test: `test/financeSetup.test.js`
**Interfaces:**
- Produces: Collections nach Spec „Datenmodell“ mit genau diesen Attributen (Größen):
- `bankConnections`: `status` 16, `sessionId` 64, `accountUid` 64, `accountIdHash` 128, `ibanMasked` 40, `iban` 40, `validUntil` 32, `lastSyncAt` 32, `lastSyncOk` boolean, `lastError` 500, `lastManualSyncAt` 32, `balanceBookedCents` integer, `balanceAvailableCents` integer, `balanceDate` 10, `latestBookingDate` 10, `authState` 64, `authStartedAt` 32, `warnedThresholds` 32
- `bankTransactions`: `accountUid` 64, `status` 8, `bookingDate` 10, `valueDate` 10, `amountCents` integer, `currency` 3, `counterpartyName` 255, `counterpartyIban` 40, `remittance` 1000, `mcc` 8, `entryReference` 128, `category` 32, `matchedInvoiceId` 64, `matchedInvoiceNumber` 64, `matchedPayoutId` 64, `fixedCostId` 64, `note` 500, `syncedAt` 32; Index `idx_booking` (key, `bookingDate`)
- `financeSettings`: `minBufferCents` integer, `taxReservePercent` integer, `previousYearRevenueCents` integer, `estPrepaymentCents` integer, `bankDetailsText` 500
- `financeFixedCosts`: `name` 120, `amountCents` integer, `interval` 16, `nextDate` 10, `counterpartyMatch` 120, `active` boolean
- Aufruf mit Übernahme: `node scripts/setup-finanzen.mjs --session <id> --account <uid> --valid-until <iso>` legt/aktualisiert `bankConnections/finom` mit `status: 'aktiv'` und legt `financeSettings/main` mit `taxReservePercent: 30`, `minBufferCents: 0`, `estPrepaymentCents: 0` an, falls nicht vorhanden. Ohne Argumente nur Collections.
- `schemaKeys(collection)` sucht in `setup-abo-collections.mjs` und `setup-finanzen.mjs`.
- [ ] **Step 1: Write the failing test** `test/financeSetup.test.js`: `schemaKeys('bankTransactions')` enthält `amountCents`, `category`, `matchedInvoiceId`; `schemaKeys('bankConnections')` enthält `status`, `warnedThresholds`; `schemaKeys('financeFixedCosts')` enthält `counterpartyMatch`; `schemaKeys('subscriptions')` funktioniert weiter.
- [ ] **Step 2: Run** `node --test test/financeSetup.test.js` – Expected: FAIL („Collection bankTransactions fehlt im Setup-Skript“).
- [ ] **Step 3: Implement** `scripts/setup-finanzen.mjs` nach dem Muster von `setup-abo-collections.mjs` (gleiche Helfer `aw`, `ensure`, `waitForAttributes`, `ensureCollection`, Blockformat `'<id>',` … `\n ],` damit `schemaKeys` greift; `permissions: []`). Argumente mit `node:util` `parseArgs`. Sitzungs-ID nie ausgeben (nur „Sitzung übernommen, gültig bis …“).
- [ ] **Step 4: Run** `node --test test/financeSetup.test.js` und `npm test` – Expected: PASS.
- [ ] **Step 5: Commit** `git commit -m "Finanzen: Collections und Setup-Skript mit Sitzungsuebernahme"` (Dateien einzeln adden).
### Task 3: Buchungen abrufen und speichern
**Files:**
- Create: `server/services/finance/dates.js`
- Create: `server/services/finance/bankStore.js`
- Create: `server/services/finance/bankSync.js`
- Test: `test/financeDates.test.js`
- Test: `test/financeBankSync.test.js`
**Interfaces:**
- Consumes: Task 1 (`getBalances`, `getTransactions`, `EnableBankingError`), Task 2 (Collections).
- Produces:
- `dates.js`: `berlinDate(date = new Date()) → 'YYYY-MM-DD'` (Europe/Berlin), `addDays(ymd, n) → 'YYYY-MM-DD'`, `daysBetween(fromYmd, toYmd) → number` (ganze Tage, `toYmd − fromYmd`), `formatDe(ymd) → 'TT.MM.JJJJ'`. Tests: `berlinDate(new Date('2026-09-30T22:30:00Z')) === '2026-10-01'`; `addDays('2026-03-29', 1) === '2026-03-30'` (Zeitumstellung); `daysBetween('2026-10-01', '2027-03-29') === 179`; `formatDe('2027-03-29') === '29.03.2027'`.
- `transactionDocId(raw) → string` (36 Zeichen Hex, Regel aus Spec)
- `toTransactionDoc(raw, { accountUid, syncedAt }) → object` – Felder aus Spec; `CRDT` → `debtor`/`debtor_account`, `DBIT` → `creditor`/`creditor_account`; `remittance` = `remittance_information.join(' ')` (gekürzt auf 1000); `iban` aus `account.iban ?? account.other?.identification ?? ''`.
- `bankStore.getConnection() → Promise<object|null>`, `saveConnection(patch) → Promise<object>` (legt `finom` an oder aktualisiert), `upsertTransaction(doc) → Promise<'neu'|'aktualisiert'>` (aktualisiert nur Bankfelder: `status`, `bookingDate`, `valueDate`, `amountCents`, `currency`, `counterpartyName`, `counterpartyIban`, `remittance`, `mcc`, `entryReference`, `accountUid`, `syncedAt`), `listTransactions({ limit = 1000 } = {}) → Promise<object[]>` (neueste zuerst nach `bookingDate`), `getTransaction(id)`, `updateTransaction(id, patch)`, `deleteTransaction(id)`.
- `syncBank({ now = new Date(), psu = null } = {}) → Promise<{ ok: boolean, neu: number, aktualisiert: number, geloescht: number, error?: string }>`
Ablauf `syncBank`: Verbindung laden (keine oder `status: 'abgelaufen'` → `{ ok: false, error: 'Kein aktiver Bankzugang' }` ohne API-Aufruf); `dateFrom` = `latestBookingDate − 7 Tage`, mindestens `heute − 89 Tage`, ohne `latestBookingDate` `heute − 89 Tage`; Salden holen (`ITBD` oder `CLBD` → `balanceBookedCents`, `ITAV` → `balanceAvailableCents`, Datum des gewählten Saldos → `balanceDate`); Umsätze holen und upserten; `PDNG`-Dokumente mit `bookingDate >= dateFrom`, die in diesem Abruf nicht vorkamen, löschen; Verbindung aktualisieren (`lastSyncAt`, `lastSyncOk: true`, `lastError: ''`, `latestBookingDate` = jüngstes `bookingDate` der `BOOK`-Buchungen). Fehler: `EnableBankingError` mit Status 401/403 → `status: 'abgelaufen'`, `lastError: 'Bankzugang abgelaufen oder widerrufen'`; andere Fehler → `status` bleibt, `lastSyncOk: false`, `lastError` = Meldung. Nie werfen.
- [ ] **Step 1: Write the failing tests** (`startAppwriteMock({ schema: { bankConnections: schemaKeys('bankConnections'), bankTransactions: schemaKeys('bankTransactions') } })` + `startFakeEnableBanking`):
- `toTransactionDoc`: Eingang „EWV SOFTWARE GMBH“, `168.79`, `CRDT` → `amountCents: 16879`, `counterpartyName: 'EWV SOFTWARE GMBH'`, `remittance: 'Rechnung 0005 vom 28.07.2026'`; Kartenzahlung `DBIT` `4.49` mit `merchant_category_code: '4816'` → `amountCents: -449`, `mcc: '4816'`, `counterpartyIban: ''`.
- `transactionDocId`: gleiche `entry_reference` → gleiche ID; ohne `entry_reference` und `transaction_id` → stabil aus Datum|Betrag|Gegenpartei|Zweck; Ergebnis passt auf `/^[a-f0-9]{36}$/`.
- Erster Abruf: 9 Buchungen → `neu: 9`, Saldo `balanceBookedCents: 10893`, `latestBookingDate: '2026-09-29'`, `dateFrom` im Aufruf = heute − 89 Tage.
- Zweiter Abruf mit denselben Buchungen nach `updateTransaction(id, { category: 'rechnung-extern', note: 'x' })` → `neu: 0`, `category`/`note` unverändert.
- Vormerkung `PDNG` (entry_reference `p1`) im ersten Abruf, im zweiten stattdessen `BOOK` (entry_reference `b1`) → Dokument `p1` gelöscht (`geloescht: 1`), `b1` vorhanden.
- Fake antwortet 401 auf `/accounts/…/balances` → Ergebnis `ok: false`, Verbindung `status: 'abgelaufen'`, vorhandene Buchungen bleiben; dritter Aufruf macht keinen API-Aufruf.
- Fake antwortet 500 → `status: 'aktiv'`, `lastSyncOk: false`, `lastError` gesetzt.
- [ ] **Step 2: Run** `node --test test/financeDates.test.js test/financeBankSync.test.js` – Expected: FAIL.
- [ ] **Step 3: Implement** `bankStore.js` (über `listDocuments`/`getDocument`/`createDocument`/`updateDocument`/`deleteDocument` aus `appwriteAdmin.js`, `Query.orderDesc('bookingDate')`, `Query.limit`) und `bankSync.js`. „Heute“ immer über `berlinDate()` aus `dates.js` (`Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' })`; Tagesrechnung in UTC-Mitternacht, damit die Zeitumstellung nicht stört).
- [ ] **Step 4: Run** `node --test test/financeDates.test.js test/financeBankSync.test.js` und `npm test` – Expected: PASS.
- [ ] **Step 5: Commit** `git commit -m "Finanzen: Bankabruf speichert Buchungen idempotent, Vormerkungen, Ablauf"`
### Task 4: Bankzugang neu verbinden
**Files:**
- Modify: `server/services/finance/bankSync.js`
- Test: `test/financeBankAuth.test.js`
**Interfaces:**
- Consumes: Task 1 (`startAuth`, `createSession`), Task 3 (`bankStore`, `syncBank`).
- Produces:
- `startBankAuth({ now = new Date() } = {}) → Promise<{ url }>` – `state` = `randomBytes(12).toString('hex')`, speichert `authState`, `authStartedAt`.
- `completeBankAuth({ code, state, error }, { now = new Date() } = {}) → Promise<{ ok: true, validUntil, sync }>`; Fehler als `Error` mit `status = 400` und Meldung:
- `state` leer/abweichend → „Diese Rückkehr passt zu keiner gestarteten Freigabe.“
- `authStartedAt` älter als 30 Minuten → „Die Freigabe ist abgelaufen. Bitte neu starten.“
- `error` gesetzt → „Finom hat die Freigabe nicht erteilt (<error>).“
- Kontowahl: gleiches `identificationHash` wie gespeichert, sonst gleiche IBAN, sonst erstes EUR-Konto; keins → 400 „Kein Euro-Konto in der Freigabe.“
- Erfolg setzt `sessionId`, `accountUid`, `accountIdHash`, `iban`, `ibanMasked` (erste 4 + „…“ + letzte 4), `validUntil`, `status: 'aktiv'`, `authState: ''`, `warnedThresholds: ''`, `lastError: ''` und startet `syncBank`.
- [ ] **Step 1: Write the failing tests:** Start speichert `authState` und liefert URL; Rückkehr mit richtigem `state` → neue `sessionId`, `status: 'aktiv'`, `warnedThresholds: ''`, Sync gelaufen; falscher `state` → 400 und alte `sessionId` unverändert; 31 Minuten später → 400; `error: 'access_denied'` → 400 mit „access_denied“ in der Meldung; zwei Konten, gespeicherter Hash passt aufs zweite → zweites gewählt.
- [ ] **Step 2: Run** `node --test test/financeBankAuth.test.js` – Expected: FAIL.
- [ ] **Step 3: Implement** in `bankSync.js`.
- [ ] **Step 4: Run** die Testdatei und `npm test` – Expected: PASS.
- [ ] **Step 5: Commit** `git commit -m "Finanzen: Bankzugang aus WOMS neu verbinden (Freigabe, Rueckkehr, Kontowahl)"`
### Task 5: Ablaufwarnung (Vorlage T18) und Jobs
**Files:**
- Create: `server/mail-templates/T18 Bankzugang erneuern.html`
- Modify: `server/services/finance/bankSync.js`
- Modify: `server/index.js` (Jobs im `app.listen`-Callback)
- Test: `test/financeBankConsent.test.js`
**Interfaces:**
- Produces: `checkBankConsent({ now = new Date() } = {}) → Promise<{ sent: number|null }>` – `tage` = volle Tage bis `validUntil` (aufgerundet); Schwellen `[30, 14, 3, 0]`; die kleinste erreichte, noch nicht in `warnedThresholds` stehende Schwelle löst `sendAdminNotice({ template: 'T18', values, blocks })` aus und wird angehängt; `sent` = Schwelle oder `null`.
- T18: Betreff `Bankzugang Finom läuft {{ablauf_text}}` mit `ablauf_text` = „in {{tage}} Tagen ab“ bzw. „heute ab“/„ist abgelaufen“; Platzhalter `{{ablauf_text}}`, `{{gueltig_bis}}` (`29.03.2027`), `{{link}}` (`${config.womsBaseUrl}/finance/einstellungen`); optionaler Block `abgelaufen`. Kopfkommentar und Aufbau wie `T17 Repo zuordnen.html`.
- Jobs (nur wenn `config.enableBanking.enabled`): `syncBank()` 2 Minuten nach Start und alle 6 Stunden; `checkBankConsent()` 3 Minuten nach Start und alle 24 Stunden; beide `.unref()`, Fehler nur `console.warn` mit Präfix `[finanzen]`.
- [ ] **Step 1: Write the failing tests** (mailSink wie in `aboRoutes.test.js`): `validUntil` in 29 Tagen → T18 einmal mit `gueltig_bis`, zweiter Aufruf am selben Tag → keine Mail; 13 Tage → Schwelle 14; abgelaufen → Block `abgelaufen` enthalten; `test/mailTemplates.test.js` bleibt grün (T18 hat Kopfkommentar mit Betreff und Platzhaltern).
- [ ] **Step 2: Run** `node --test test/financeBankConsent.test.js` – Expected: FAIL.
- [ ] **Step 3: Implement** Vorlage, `checkBankConsent`, Jobs in `server/index.js`.
- [ ] **Step 4: Run** `node --test test/financeBankConsent.test.js test/mailTemplates.test.js` und `npm test` – Expected: PASS.
- [ ] **Step 5: Commit** `git commit -m "Finanzen: Warnung vor Ablauf des Bankzugangs (T18) und Abruf-Jobs"`
### Task 6: Admin-Router für Bank und Buchungen
**Files:**
- Create: `server/routes/admin/finanzen.js`
- Modify: `server/index.js` (`app.use('/api/admin/finanzen', adminFinanzenRoutes)` bei den übrigen `/api/admin/*`)
- Modify: `server/index.js` CORS-Methoden bleiben `GET, POST, PATCH, DELETE, OPTIONS` (kein PUT verwenden)
- Test: `test/financeRoutesBank.test.js`
**Interfaces:**
- Consumes: Tasks 3–4.
- Produces (alle hinter `requireAppwriteStaff`):
- `GET /api/admin/finanzen/bank` → `{ verbindung: { status, ibanMasked, validUntil, lastSyncAt, lastSyncOk, lastError, balanceBookedCents, balanceAvailableCents, balanceDate, latestBookingDate } | null }` (ohne `sessionId`, `iban`, `authState`)
- `POST /api/admin/finanzen/bank/aktualisieren` → Ergebnis von `syncBank({ psu: { ip: req.ip, userAgent: req.get('User-Agent') } })`; war `lastManualSyncAt` vor < 10 Minuten → 429 `{ error: 'Bitte in N Minuten erneut versuchen.' }`; setzt `lastManualSyncAt`.
- `POST /api/admin/finanzen/bank/verbinden` → `{ url }`
- `POST /api/admin/finanzen/bank/rueckkehr` Body `{ code, state, error }` → `{ ok: true, validUntil }` oder 400 `{ error }`
- `GET /api/admin/finanzen/buchungen` → `{ buchungen: object[] }` (Felder aus `bankTransactions`, ohne `$`-Metafelder außer `$id`)
- Router exportiert zusätzlich nichts; Plan 2 ergänzt Routen in derselben Datei.
- [ ] **Step 1: Write the failing tests** mit `startTestApp({ '/api/admin/finanzen': routes })` und Header `Authorization: Bearer jwt-admin`: ohne JWT → 401; `jwt-staff` → 403; `GET /bank` enthält kein `sessionId`; zweimal `aktualisieren` hintereinander → 200 dann 429; `rueckkehr` mit falschem `state` → 400 mit Meldung; `GET /buchungen` sortiert neueste zuerst.
- [ ] **Step 2: Run** `node --test test/financeRoutesBank.test.js` – Expected: FAIL.
- [ ] **Step 3: Implement** Router und Mount.
- [ ] **Step 4: Run** die Testdatei und `npm test` – Expected: PASS.
- [ ] **Step 5: Commit** `git commit -m "Finanzen: Admin-Routen fuer Bankstatus, Abruf, Neuverbindung und Buchungen"`