220 lines
23 KiB
Markdown
220 lines
23 KiB
Markdown
# 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"`
|