diff --git a/docs/superpowers/plans/2026-10-01-finanzen-plan-1-bank.md b/docs/superpowers/plans/2026-10-01-finanzen-plan-1-bank.md new file mode 100644 index 0000000..34c82fc --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-finanzen-plan-1-bank.md @@ -0,0 +1,219 @@ +# 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/.test.js`. +- Keine neuen npm-Abhängigkeiten. +- Enable Banking: API `https://api.enablebanking.com`, JWT-Header `{ typ: 'JWT', alg: 'RS256', kid: }`, 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>` + - `getTransactions(accountUid, { dateFrom, psu } = {}) → Promise` (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, 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=`, `ENABLE_BANKING_API_BASE=` 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 --account --valid-until ` 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 `'',` … `\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`, `saveConnection(patch) → Promise` (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` (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 ().“ + - 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"` diff --git a/docs/superpowers/plans/2026-10-01-finanzen-plan-2-stripe-auswertung.md b/docs/superpowers/plans/2026-10-01-finanzen-plan-2-stripe-auswertung.md new file mode 100644 index 0000000..a8c5ffb --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-finanzen-plan-2-stripe-auswertung.md @@ -0,0 +1,247 @@ +# Finanzen Plan 2: Stripe-Rechnungen, Zahlungsabgleich und Auswertung 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 Portal-Server liest und schreibt Stripe-Rechnungen, schlägt Zuordnungen von Bankeingängen vor, verwaltet Einstellungen und Fixkosten und liefert unter `GET /api/admin/finanzen/uebersicht` alles, was die WOMS-Seite braucht (Kopf, Aufgaben, Vorschau, Steuer, Abos, Altersgruppen). + +**Architecture:** Reine Rechenmodule ohne I/O (`matching.js`, `recurring.js`, `forecast.js`, `tax.js`, `todos.js`) plus dünne I/O-Dienste (`stripeFinance.js`, `assign.js`, `settings.js`, `overview.js`) unter `server/services/finance/`; Routen in `server/routes/admin/finanzen.js` (aus Plan 1). Stripe wird nicht gespiegelt, sondern live gelesen (Zwischenspeicher 60 s). + +**Tech Stack:** Node 20, Express 4, stripe-node ^19.3 (API 2025-10-29.clover), Appwrite REST, `node:test`, `test/support/fakeStripe.js`. + +**Spec:** `docs/superpowers/specs/2026-10-01-finanzen-design.md` + +## Global Constraints + +- Setzt Plan 1 voraus (gleicher Worktree `dev-finanzen`, Branch `feature/finanzen`). **Nie pushen** ohne OK des Nutzers. +- `npm test` grün vor jedem Commit. Keine neuen Abhängigkeiten. +- Beträge in ganzen Cent; Stripe-Beträge sind schon Cent (EUR). +- Statuswerte nach außen (deutsch): Rechnung `entwurf` | `offen` | `ueberfaellig` | `bezahlt` | `storniert` | `uneinbringlich`; Sicherheit `sicher` | `erwartet`; Dringlichkeit `hoch` | `mittel` | `info`; Zuordnungskategorien `rechnung` | `rechnung-extern` | `stripe-auszahlung` | `fixkosten` | `privat` | `sonstiges`. +- Kleinunternehmer-Grenzen: 2.500.000 Cent (Folgejahr), 10.000.000 Cent (hart); Warnung ab 70 %. +- Gewerbesteuer-Hinweis ab Gewinn > 2.450.000 Cent. ESt-Vorauszahlungstermine: 10.03., 10.06., 10.09., 10.12. +- Standard-Einstellungen: `minBufferCents 0`, `taxReservePercent 30`, `previousYearRevenueCents null`, `estPrepaymentCents 0`, `bankDetailsText ''`. +- Rechnungsfuß = `config.stripe.invoiceFooter` + Zeilenumbruch + `bankDetailsText` (falls gesetzt) + Zeilenumbruch + `Bitte geben Sie bei Überweisung die Rechnungsnummer als Verwendungszweck an.` +- Projektrechnungen: `collection_method: 'send_invoice'`, `days_until_due` Standard 14 (0–60), `auto_advance: false`, `pending_invoice_items_behavior: 'exclude'`, `metadata: { webklarCustomerId, quelle: 'woms', erstelltVon }`. +- Abo-Rechnungen (aus Abos entstanden) lassen sich in WOMS weder stornieren noch löschen (409 „Abo-Rechnungen verwaltet Stripe.“). +- Eine Zuordnung zu einer Rechnung markiert sie mit `paid_out_of_band: true` und ist nicht umkehrbar. +- Hilfsfunktionen aus Plan 1: `berlinDate`, `addDays`, `daysBetween`, `formatDe` (`server/services/finance/dates.js`). + +## Review Focus + +- **Stripe nicht erreichbar:** `GET /uebersicht` liefert trotzdem Bankteil, Kopf (ohne Stripe-Posten) und Aufgaben aus Bankdaten; `stand.stripe.ok = false` mit Meldung. Test in Task 8. +- **Doppelte Bestätigung:** Zwei schnelle Klicks auf „Zuordnen“ für dieselbe Buchung → die zweite bekommt 409, Stripe wird nur einmal als bezahlt markiert. Test in Task 4. +- **Betrag weicht ab:** Eingang 100 € für Rechnung mit offenen 120 € → Vorschlag „mittel, Betrag weicht ab“, Bestätigen → 409 mit Hinweis auf Teilzahlung. Test in Tasks 3 und 4. +- **Monatsende in Vorschau:** Fixkosten am 31. → nächste Termine 30.11., 31.12., 28.02. (kein Überlauf in den Folgemonat). Test in Task 6. +- **Leere Daten:** Keine Rechnungen, keine Abos, keine Fixkosten, nur Kontostand → Übersicht ohne Fehler, Vorschau = flache Linie, keine Aufgaben außer Bank. Test in Task 8. + +--- + +### Task 1: Stripe-Rechnungen und Auszahlungen lesen + +**Files:** +- Modify: `test/support/fakeStripe.js` (Routen `GET /v1/invoices` mit `expand[]=data.customer`, `GET /v1/payouts`; Rückgabeobjekt `payouts: Map`) +- Create: `server/services/finance/format.js` +- Create: `server/services/finance/stripeFinance.js` +- Test: `test/financeStripeRead.test.js` + +**Interfaces:** +- Produces: + - `format.js`: `euroDe(cents) → string` (`euroDe(123456) === '1.234,56 €'`, `euroDe(-449) === '−4,49 €'` mit echtem Minuszeichen U+2212, geschütztes Leerzeichen wie `Intl` es liefert). + - `mapInvoice(raw, { today }) → { id, number, status, customerName, customerEmail, webklarCustomerId, createdDate, dueDate, totalCents, remainingCents, paidCents, paidDate, overdueDays, source, hostedUrl, pdfUrl, outOfBand, bankTransactionId }` + - `status`: `draft`→`entwurf`, `open`→`offen` bzw. `ueberfaellig` wenn `dueDate < today`, `paid`→`bezahlt`, `void`→`storniert`, `uncollectible`→`uneinbringlich` + - `webklarCustomerId` = `raw.metadata.webklarCustomerId` oder `raw.customer.metadata.webklarCustomerId` (bei expandiertem Kunden) + - `paidDate` = `raw.metadata.bankBookingDate` oder Berlin-Datum von `status_transitions.paid_at` + - `overdueDays` = `daysBetween(dueDate, today)` bei `ueberfaellig`, sonst 0 + - `source` = `'abo'` wenn `subscriptionIdOfInvoice(raw)` (aus `stripeBilling.js`) etwas liefert, sonst `'projekt'` + - `outOfBand` = `Boolean(raw.metadata?.bankTransactionId)` + - `listInvoices({ force = false } = {}) → Promise` – `stripe.invoices.list({ limit: 100, created: { gte: }, expand: ['data.customer'] })` mit Auto-Paging, höchstens 1000; Zwischenspeicher 60 s. + - `listPayouts({ force = false } = {}) → Promise>` – `stripe.payouts.list({ limit: 50 })`, 60 s. + - `invalidateFinanceCache() → void` + +- [ ] **Step 1: Write the failing tests:** `mapInvoice` für je einen Rohdatensatz pro Stripe-Status; offene Rechnung fällig `2026-09-20`, `today = '2026-10-01'` → `ueberfaellig`, `overdueDays 11`; Rechnung mit `parent.subscription_details.subscription` → `source 'abo'`; `metadata.bankBookingDate '2026-08-05'` → `paidDate '2026-08-05'`, `outOfBand true`; `listInvoices` zweimal in 60 s → ein Stripe-Aufruf (`st.calls`), nach `invalidateFinanceCache()` zwei; `euroDe`-Fälle oben. +- [ ] **Step 2: Run** `node --test test/financeStripeRead.test.js` – Expected: FAIL. +- [ ] **Step 3: Implement** Fake-Routen, `format.js`, `stripeFinance.js` (`getStripe()` aus `stripeBilling.js`). +- [ ] **Step 4: Run** Testdatei und `npm test` – Expected: PASS. +- [ ] **Step 5: Commit** `git commit -m "Finanzen: Stripe-Rechnungen und Auszahlungen lesen (deutsch gemappt, 60-s-Cache)"` + +### Task 2: Rechnungen schreiben, senden, stornieren, löschen + +**Files:** +- Modify: `server/services/stripeBilling.js` (`getOrCreateStripeCustomer` exportieren, sonst unverändert) +- Modify: `test/support/fakeStripe.js` (`POST /v1/invoices`, `POST /v1/invoiceitems`, `POST /v1/invoices/:id` (Metadaten), `POST /v1/invoices/:id/finalize`, `/send`, `/void`, `/pay`, `DELETE /v1/invoices/:id`, `GET /v1/customers/search` falls fehlend; Statusübergänge wie Stripe: finalize nur `draft`→`open`, void nur `open`, pay nur `open`, del nur `draft`, sonst 400) +- Modify: `server/services/finance/stripeFinance.js` +- Test: `test/financeStripeWrite.test.js` + +**Interfaces:** +- Consumes: Task 1; `getSettings()` aus Task 5 – bis dahin `bankDetailsText` als Parameter: `invoiceFooter(bankDetailsText) → string`. +- Produces: + - `invoiceFooter(bankDetailsText = '') → string` (Regel in Global Constraints) + - `createProjectInvoice({ customerId, items, daysUntilDue = 14, memo = '', senden = false, createdBy, bankDetailsText = '' }) → Promise`; Validierung (Fehler mit `status 400`): Kunde existiert und `customerStatus` ist nicht `lead`/`lost` („Rechnungen nur für feste Kunden.“); 1–20 Positionen; `description` 1–200 Zeichen; `quantity` ganze Zahl 1–999; `unitAmountCents` ganze Zahl 1–10.000.000; `daysUntilDue` 0–60. + - `sendInvoiceNow(invoiceId) → Promise` (finalize mit `auto_advance: false`, dann send); nur `entwurf`, sonst 409 „Nur Entwürfe können gesendet werden.“ + - `voidInvoice(invoiceId) → Promise`; nur `offen`/`ueberfaellig` und `source 'projekt'`, sonst 409. + - `deleteDraft(invoiceId) → Promise`; nur `entwurf` und `source 'projekt'`, sonst 409. + - Alle schreibenden Funktionen rufen `invalidateFinanceCache()`. +- Positionen: `stripe.invoiceItems.create({ customer, invoice, description, quantity, unit_amount_decimal: String(unitAmountCents), currency: 'eur' })`. **Vor dem Implementieren** in der Stripe-API-Referenz für die gepinnte Version prüfen, ob `unit_amount_decimal` beim Invoice Item noch erlaubt ist; falls nicht, `pricing`/`price_data` nach Referenz verwenden und den Test-Assert entsprechend anpassen. Der Test prüft, dass auf der Rechnung Menge und Einzelpreis ankommen. + +- [ ] **Step 1: Write the failing tests:** Kunde `c1` (Fixtures) mit zwei Positionen (`'Website-Anpassung', 2, 5000` und `'Domain', 1, 1200`) → `st.calls` enthält `POST /v1/invoices` mit `collection_method=send_invoice`, `days_until_due=14`, `auto_advance=false`, `metadata[quelle]=woms`, `footer` beginnt mit dem §-19-Hinweis und endet mit dem Verwendungszweck-Satz; zwei `POST /v1/invoiceitems`; Ergebnis `status 'entwurf'`; mit `senden: true` → finalize + send, Ergebnis `offen`; Lead-Kunde → 400; 21 Positionen → 400; `voidInvoice` auf Entwurf → 409; `deleteDraft` auf Abo-Rechnung → 409; `sendInvoiceNow` auf offene → 409. +- [ ] **Step 2: Run** `node --test test/financeStripeWrite.test.js` – Expected: FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run** Testdatei und `npm test` (insb. `stripeBilling.test.js`, `aboRoutes.test.js` unverändert grün) – Expected: PASS. +- [ ] **Step 5: Commit** `git commit -m "Finanzen: Projektrechnungen in Stripe anlegen, senden, stornieren, Entwurf loeschen"` + +### Task 3: Zuordnungsvorschläge und wiederkehrende Ausgaben (rein) + +**Files:** +- Create: `server/services/finance/matching.js` +- Create: `server/services/finance/recurring.js` +- Test: `test/financeMatching.test.js`, `test/financeRecurring.test.js` + +**Interfaces:** +- Produces: + - `normalizeRef(text) → string` (Großbuchstaben, ohne Leerraum, `-`, `_`, `.`, `/`) + - `nameTokens(name) → string[]` (klein, an Nicht-Buchstaben trennen, Umlaute bleiben, Länge ≥ 4, ohne `gmbh gbr ohg mbh und der die das kg ag ug haftungsbeschränkt`) + - `suggestMatches(tx, { invoices, payouts }) → Array<{ kind: 'invoice'|'payout', id, number, confidence: 'hoch'|'mittel'|'niedrig', reason, amountDiffers }>` – nur für `tx.amountCents > 0` ohne `category`; Regeln 1–4 aus Spec „Zuordnungsregeln“ in dieser Reihenfolge; je Rechnung/Auszahlung höchstens ein Vorschlag (der stärkste); sortiert hoch → niedrig; höchstens 3. Nur Rechnungen mit Status `offen`/`ueberfaellig`; Nummern mit normalisierter Länge < 4 ignorieren. `reason` deutsch, z. B. „Rechnungsnummer im Verwendungszweck“, „Betrag und Name passen“, „Nur der Betrag passt“, „Stripe-Auszahlung gleicher Betrag“. + - `detectRecurring(transactions, { fixedCosts = [] } = {}) → Array<{ key, name, amountCents, interval: 'monat'|'quartal'|'jahr', lastDate, count }>` – nur `DBIT`-Buchungen (`amountCents < 0`, `status 'BOOK'`); Gruppierung nach normalisierter Gegenpartei (klein, ohne Ziffern und Satzzeichen, Leerraum zusammengefasst); ≥ 3 Buchungen; Median der Abstände 25–35 Tage → `monat`, 80–100 → `quartal`, 350–380 → `jahr`, sonst keine; Variationskoeffizient der Beträge ≤ 0,15; Gruppen, deren Name einen `counterpartyMatch` einer aktiven Fixkostenposition enthält, entfallen; `amountCents` = Median als positiver Wert. + +- [ ] **Step 1: Write the failing tests:** + - Eingang 16879 „EWV SOFTWARE GMBH“ mit Zweck „Rechnung WK-0007 vom …“, offene Rechnung `WK-0007` über 16879 → ein Vorschlag `hoch`; gleiche Rechnung über 20000 → `mittel`, `amountDiffers true`. + - Zweck ohne Nummer, Betrag passt, Kunde „EWV Software GmbH“ → `mittel` („Betrag und Name passen“); Kunde „Döner Nation“ → `niedrig`; zwei offene Rechnungen gleichen Betrags ohne Namensbezug → keine `niedrig`-Vorschläge (Regel 4 nur bei genau einer). + - Gegenpartei „Stripe Technologies Europe Ltd“, Betrag = Auszahlung, Datum 2 Tage nach `arrivalDate` → `payout`, `hoch`; 5 Tage → kein Vorschlag. + - Ausgang (`amountCents < 0`) oder schon zugeordnet → `[]`. + - `detectRecurring`: drei PORKBUN-Buchungen 449 am 27.09., 29.09., 29.09. → `[]`; OPENROUTER 4358 am 16.06., 16.07., 16.08. → `monat`, `amountCents 4358`; Beträge 1000/2000/3000 monatlich → `[]`; aktive Fixkosten mit `counterpartyMatch 'openrouter'` → `[]`. +- [ ] **Step 2: Run** `node --test test/financeMatching.test.js test/financeRecurring.test.js` – Expected: FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run** beide Testdateien und `npm test` – Expected: PASS. +- [ ] **Step 5: Commit** `git commit -m "Finanzen: Zuordnungsvorschlaege und Erkennung wiederkehrender Ausgaben"` + +### Task 4: Buchungen zuordnen + +**Files:** +- Create: `server/services/finance/assign.js` +- Modify: `server/routes/admin/finanzen.js` (`GET /buchungen` mit Vorschlägen; `POST /buchungen/:id/zuordnen`; `DELETE /buchungen/:id/zuordnung`) +- Test: `test/financeAssign.test.js` + +**Interfaces:** +- Consumes: Plan 1 `bankStore` (`getTransaction`, `updateTransaction`, `listTransactions`); Task 1 (`listInvoices`, `listPayouts`, `invalidateFinanceCache`); Task 3 (`suggestMatches`); Task 5 `getFixedCost(id)` (bis dahin über `getDocument('financeFixedCosts', id)`). +- Produces: + - `assignTransaction(txId, { invoiceId, payoutId, fixedCostId, category, note }) → Promise` – genau eines der vier Ziele, sonst 400. Regeln: + - bereits `category` gesetzt → 409 „Bereits zugeordnet.“ (vor jedem Stripe-Aufruf geprüft; gleichzeitige Aufrufe für dieselbe Buchung mit `withLocks([`finanzen-buchung:${txId}`], fn)` aus `server/services/keyedLock.js` serialisieren) + - `invoiceId`: Eingang; Rechnung per `stripe.invoices.retrieve`; Status `open`, sonst 409; `amount_remaining === tx.amountCents`, sonst 409 „Betrag weicht vom offenen Betrag ab (). Teilzahlungen bitte direkt in Stripe erfassen.“; dann `stripe.invoices.pay(id, { paid_out_of_band: true })`, `stripe.invoices.update(id, { metadata: { bankTransactionId, bankBookingDate } })`, Buchung `{ category: 'rechnung', matchedInvoiceId, matchedInvoiceNumber }` + - `payoutId`: Auszahlung aus `listPayouts({ force: true })`, gleicher Betrag, sonst 409 → `{ category: 'stripe-auszahlung', matchedPayoutId }` + - `fixedCostId`: Ausgang, Position existiert → `{ category: 'fixkosten', fixedCostId }` + - `category` ∈ `rechnung-extern` (nur Eingang), `privat`, `sonstiges` → `{ category }` + - `note` optional, ≤ 500 Zeichen + - `unassignTransaction(txId) → Promise` – `category 'rechnung'` → 409 „Die Rechnung ist in Stripe schon als bezahlt markiert und lässt sich nicht zurücksetzen.“; sonst `category`, `matchedInvoiceId`, `matchedInvoiceNumber`, `matchedPayoutId`, `fixedCostId` leeren. + - `GET /buchungen` → `{ buchungen: Array, stripeFehler?: string }` + - Routen: `POST /buchungen/:id/zuordnen` Body = Ziel → `{ buchung }`; `DELETE /buchungen/:id/zuordnung` → `{ buchung }`. + +- [ ] **Step 1: Write the failing tests:** Zuordnen zu offener Rechnung → Stripe-Aufrufe `pay` (mit `paid_out_of_band=true`) und Update mit `metadata[bankTransactionId]`, Buchung `category 'rechnung'`; zwei parallele Zuordnungen derselben Buchung (`Promise.allSettled`) → eine 200, eine 409, genau ein `pay`-Aufruf; Betrag weicht ab → 409, kein `pay`; Zuordnung lösen bei `rechnung` → 409, bei `privat` → Felder leer; `rechnung-extern` auf Ausgang → 400; `GET /buchungen` liefert für den EWV-Eingang einen Vorschlag; Stripe-Fehler (`st.failNext({ path: '/v1/invoices', status: 500 })` und `/v1/payouts`) → `GET /buchungen` 200 mit `stripeFehler` und leeren `vorschlaege`. +- [ ] **Step 2: Run** `node --test test/financeAssign.test.js` – Expected: FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run** Testdatei und `npm test` – Expected: PASS. +- [ ] **Step 5: Commit** `git commit -m "Finanzen: Bankbuchungen Rechnungen, Auszahlungen, Fixkosten und Kategorien zuordnen"` + +### Task 5: Einstellungen und Fixkosten + +**Files:** +- Create: `server/services/finance/settings.js` +- Modify: `server/routes/admin/finanzen.js` (`GET`/`PATCH /einstellungen`, `GET`/`POST /fixkosten`, `PATCH`/`DELETE /fixkosten/:id`) +- Modify: `server/services/finance/stripeFinance.js` (`createProjectInvoice` holt `bankDetailsText` selbst über `getSettings()`, Parameter entfällt) +- Test: `test/financeSettings.test.js` + +**Interfaces:** +- Produces: + - `getSettings() → Promise` (Dokument `financeSettings/main` mit Standardwerten zusammengeführt; fehlt es, nur Standardwerte, nichts anlegen) + - `patchSettings(patch) → Promise`; erlaubte Schlüssel und Grenzen: `minBufferCents` 0–100.000.000, `taxReservePercent` 0–60, `previousYearRevenueCents` ≥ 0 oder `null`, `estPrepaymentCents` ≥ 0, `bankDetailsText` ≤ 500 Zeichen; unbekannter Schlüssel oder Verstoß → 400 mit Feldname in der Meldung. Legt `main` an, falls nötig. + - `listFixedCosts() → Promise`, `getFixedCost(id)`, `createFixedCost(input)`, `updateFixedCost(id, patch)`, `deleteFixedCost(id)`; Validierung: `name` 1–120, `amountCents` ganze Zahl > 0, `interval` ∈ `monat|quartal|jahr`, `nextDate` `YYYY-MM-DD`, `counterpartyMatch` ≤ 120 (wird klein gespeichert), `active` Standard `true`. + +- [ ] **Step 1: Write the failing tests:** ohne Dokument → Standardwerte; `PATCH { taxReservePercent: 25 }` → 25 und Dokument angelegt; `{ taxReservePercent: 80 }` → 400 mit „taxReservePercent“; `{ foo: 1 }` → 400; Fixkosten anlegen mit `counterpartyMatch 'PORKBUN'` → gespeichert `porkbun`; `interval 'woche'` → 400; Rechnung über Task-2-Funktion nach `PATCH { bankDetailsText: 'IBAN DE00 …' }` → Fuß enthält die IBAN-Zeile. Routen: `jwt-staff` → 403. +- [ ] **Step 2: Run** `node --test test/financeSettings.test.js` – Expected: FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run** Testdatei und `npm test` – Expected: PASS. +- [ ] **Step 5: Commit** `git commit -m "Finanzen: Einstellungen und Fixkostenliste"` + +### Task 6: Liquiditätsvorschau (rein) + +**Files:** +- Create: `server/services/finance/forecast.js` +- Test: `test/financeForecast.test.js` + +**Interfaces:** +- Produces: `buildForecast({ today, horizonDays = 30, balanceCents, payouts = [], invoices = [], subscriptions = [], fixedCosts = [], settings }) → { posten: Array<{ date, label, amountCents, sicherheit }>, minSicher: { amountCents, date }, minErwartet: { amountCents, date }, endeSicherCents, endeErwartetCents, fixkosten30Cents, annahme }` + - `sicher`: Auszahlungen `pending`/`in_transit` mit `arrivalDate` im Zeitraum (+); aktive Fixkosten (−) an allen Terminen ab `nextDate` (vor heute liegende Termine vorspulen) im Rhythmus +1/+3/+12 Monate, Tag auf Monatsende begrenzt, aber vom ursprünglichen Tag aus gerechnet (31. → 30.11. → 31.12.); ESt-Vorauszahlung (−) am 10.03./10.06./10.09./10.12., wenn `estPrepaymentCents > 0`. + - `erwartet`: Rechnungen `offen` (+ `remainingCents`) am `dueDate`, `ueberfaellig` am `today + 14` mit Label-Zusatz „(überfällig, angenommen)“; Abos mit Status `active` (+ `priceAmount`) an `currentPeriodEnd` und monatlich danach, jeweils + 4 Tage (Auszahlung), ohne Termine ab `cancelAt` bzw. nach `currentPeriodEnd` bei `cancelAtPeriodEnd`. + - Zeitraum: `today` bis `today + horizonDays` einschließlich; Posten nach Datum, bei gleichem Datum Abflüsse zuerst. + - `minSicher`/`minErwartet`: tiefster laufender Saldo der jeweiligen Linie inklusive Startwert (`date = today`, falls nie tiefer); bei Gleichstand das früheste Datum. + - `fixkosten30Cents` = Summe der `sicher`-Abflüsse (positiv). + - `annahme` = `'Erwartet: offene Rechnungen zum Fälligkeitstag, überfällige in 14 Tagen, Abo-Verlängerungen 4 Tage nach Abbuchung, vor Stripe-Gebühren.'` + +- [ ] **Step 1: Write the failing tests** (`today '2026-10-01'`, `balanceCents 10893`): + - nur Kontostand → `posten []`, beide Minima 10893 am `2026-10-01`, `fixkosten30Cents 0`. + - Fixkosten 449 monatlich ab `2026-09-27` → Termin `2026-10-27` (−449), `minSicher 10444` am `2026-10-27`. + - Fixkosten am 31. ab `2026-10-31`, Horizont 120 → Termine `2026-10-31`, `2026-11-30`, `2026-12-31`, `2027-01-31`. + - Rechnung offen 50000 fällig `2026-10-14` → `erwartet` am `2026-10-14`, `endeErwartetCents 60893`, `endeSicherCents 10893`. + - Rechnung überfällig → am `2026-10-15` mit „(überfällig, angenommen)“. + - Abo 2000, `currentPeriodEnd 2026-10-05` → Posten am `2026-10-09`; mit `cancelAtPeriodEnd: true` und `currentPeriodEnd 2026-10-05` → kein Posten am 09.11. + - `estPrepaymentCents 50000` → Posten am `2026-12-10` bei Horizont 90, keiner bei 30. + - Auszahlung `in_transit` 1500 am `2026-10-03` → `sicher` +1500. +- [ ] **Step 2: Run** `node --test test/financeForecast.test.js` – Expected: FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run** Testdatei und `npm test` – Expected: PASS. +- [ ] **Step 5: Commit** `git commit -m "Finanzen: Liquiditaetsvorschau 30 Tage (sicher/erwartet)"` + +### Task 7: Steuer und Aufgabenliste (rein) + +**Files:** +- Create: `server/services/finance/tax.js` +- Create: `server/services/finance/todos.js` +- Test: `test/financeTax.test.js`, `test/financeTodos.test.js` + +**Interfaces:** +- Produces: + - `computeTax({ today, transactions, invoices, settings }) → { jahr, einnahmenCents, ausgabenCents, gewinnCents, ruecklageCents, prozent, gewerbesteuerHinweis, kleinunternehmer: { jahrCents, vorjahrCents, grenzeFolgejahrCents: 2500000, grenzeHartCents: 10000000, anteilFolgejahr }, naechsteVorauszahlung: { date, amountCents } | null }` + - Einnahmen/Ausgaben: Buchungen `BOOK` im Kalenderjahr von `today`, ohne `category 'privat'`; Eingänge positiv summiert, Ausgänge als positiver Betrag. + - `ruecklageCents` = `Math.round(max(0, gewinnCents) * prozent / 100)` + - `kleinunternehmer.jahrCents` = Summe `paidCents` der Stripe-Rechnungen mit `paidDate` im Jahr + Buchungen `category 'rechnung-extern'` im Jahr (Stripe-Auszahlungen zählen nicht, sonst doppelt). + - `anteilFolgejahr` = `jahrCents / 2500000` + - `buildTodos({ today, now, connection, invoices, transactions, subscriptions, recurring, tax }) → Array<{ art, dringlichkeit, titel, text, datum, aktion }>` nach Spec-Tabelle „Zu erledigen – Arten“, sortiert hoch → mittel → info, darin nach `datum` aufsteigend. `aktion.typ` ∈ `bank-verbinden`, `bank-aktualisieren`, `rechnung-oeffnen` (`invoiceId`, `hostedUrl`), `zuordnen` (`transactionId`), `kunde-oeffnen` (`customerId`), `fixkosten-uebernehmen` (`vorschlag`), `einstellungen`. Texte mit `euroDe` und `formatDe`. `transactions` enthalten bereits `vorschlaege`; bei Vorschlag `hoch` endet `text` mit „Vorschlag: Rechnung “ bzw. „Vorschlag: Stripe-Auszahlung“. + +- [ ] **Step 1: Write the failing tests:** + - Steuer: Eingänge 16879 (`rechnung-extern`) und Ausgänge 449+449+449+50+50+50+131+4358 im Jahr 2026, eine Buchung `privat` −10000 → `einnahmenCents 16879`, `ausgabenCents 5986`, `gewinnCents 10893`, `ruecklageCents 3268` (30 %), `kleinunternehmer.jahrCents 16879`; Gewinn negativ → Rücklage 0; Stripe-Rechnung bezahlt 2026-10-02 über 5000 → `jahrCents` + 5000; `estPrepaymentCents 50000` und `today 2026-10-01` → `naechsteVorauszahlung { date: '2026-12-10', amountCents: 50000 }`. + - Aufgaben: Zugang gültig bis `2026-10-20` (19 Tage) → `bank-zugang` `mittel`; bis `2026-10-10` → `hoch`; `status 'abgelaufen'` → „Bankzugang abgelaufen“ `hoch`; `lastSyncAt` vor 25 h → `bank-abruf`; Rechnung 31 Tage überfällig → `hoch`, 5 Tage → `mittel`; ungeordneter Eingang → `zuordnung` mit `aktion.transactionId`; Abo `past_due` → `abo-zahlung` `hoch`; Entwurf von vor 4 Tagen → `entwurf` `info`, von gestern → keine Aufgabe; `anteilFolgejahr 0.72` → `kleinunternehmer` `mittel`, `1.05` → `hoch`; Reihenfolge hoch vor mittel vor info. +- [ ] **Step 2: Run** `node --test test/financeTax.test.js test/financeTodos.test.js` – Expected: FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run** beide Testdateien und `npm test` – Expected: PASS. +- [ ] **Step 5: Commit** `git commit -m "Finanzen: Steuerruecklage, Kleinunternehmer-Zaehler und Aufgabenliste"` + +### Task 8: Übersicht und Rechnungsrouten + +**Files:** +- Create: `server/services/finance/overview.js` +- Modify: `server/routes/admin/finanzen.js` (`GET /uebersicht`, `GET /rechnungen`, `POST /rechnungen`, `POST /rechnungen/:id/senden`, `POST /rechnungen/:id/stornieren`, `DELETE /rechnungen/:id`) +- Test: `test/financeOverview.test.js` + +**Interfaces:** +- Consumes: alle vorigen Tasks; `listDocuments(config.collections.subscriptions, [Query.limit(500)])` und Kundennamen per `getDocument(config.collections.customers, id)` (nur für Abo-Kunden). +- Produces: + - `buildOverview({ now = new Date() } = {}) → Promise` mit + - `stand: { bank: <öffentliche Verbindung wie GET /bank>, stripe: { ok, fehler?, abgerufenUm } }` + - `kopf: { kontostandCents, verfuegbarCents, aufschluesselung: { kontostandCents, steuerRuecklageCents, fixkosten30Cents, mindestpufferCents }, tiefsterSicher, tiefsterErwartet }` mit `verfuegbarCents = kontostand − steuerRuecklage − fixkosten30 − mindestpuffer` + - `aufgaben` (Task 7), `vorschau` (Task 6), `steuer` (Task 7) + - `abos: { monatlichCents, zahlendeKunden, gefaehrdetCents, liste: Array<{ customerId, kunde, produkt, status, betragCents, naechsteAbbuchung }> }` – `monatlichCents` = Summe `priceAmount` der Status `active` und `past_due`; `gefaehrdetCents` = Summe `past_due`; `zahlendeKunden` = verschiedene `customerId` mit `active`; `produkt` = `productKey ?? planKey` + - `rechnungen: { altersgruppen: Array<{ key: 'nicht-faellig'|'1-30'|'31-60'|'ueber-60', anzahl, summeCents }>, entwuerfe }` aus `offen`/`ueberfaellig` + - Stripe-Fehler: Rechnungen/Auszahlungen als leere Listen weiterrechnen, `stand.stripe.ok false`. + - `GET /rechnungen?status=offen|ueberfaellig|bezahlt|entwurf|alle&kunde=` → `{ rechnungen }` (Standard `alle`, neueste zuerst; `kunde` filtert auf `webklarCustomerId`) + - `POST /rechnungen` Body `{ customerId, items: [{ description, quantity, unitAmountCents }], daysUntilDue, memo, senden }` → `{ rechnung }`; `createdBy` = `req.appwriteUser.email` + - `POST /rechnungen/:id/senden` → `{ rechnung }`; `POST /rechnungen/:id/stornieren` → `{ rechnung }`; `DELETE /rechnungen/:id` → `{ ok: true }` + +- [ ] **Step 1: Write the failing tests** (Appwrite-Mock mit Verbindung, 9 Buchungen, Einstellungen; Fake-Stripe mit einer offenen Projekt-, einer überfälligen und einer bezahlten Rechnung; ein Abo `active` 2000 und ein `past_due` 5000): + - `GET /uebersicht` → `kopf.kontostandCents 10893`; `verfuegbarCents` = 10893 − Rücklage − Fixkosten30 − Puffer (konkrete Zahl aus den Testdaten ausrechnen und festschreiben); `abos.monatlichCents 7000`, `gefaehrdetCents 5000`, `zahlendeKunden 1`; Altersgruppen enthalten die überfällige Rechnung in der richtigen Gruppe; `aufgaben` enthält `ueberfaellig` und `abo-zahlung`. + - Leere Daten (keine Rechnungen, Abos, Fixkosten) → 200, `vorschau.posten []`, `aufgaben` ohne Rechnungs-/Abo-Arten. + - Stripe 500 → 200, `stand.stripe.ok false`, `kopf.kontostandCents 10893`. + - `GET /rechnungen?status=ueberfaellig` → nur die überfällige; `?kunde=c1` → nur Rechnungen mit `webklarCustomerId c1`. + - `POST /rechnungen` → 200 Entwurf; `POST /rechnungen/:id/senden` → `offen`; `DELETE` auf offene → 409. + - `jwt-staff` auf `/uebersicht` → 403. +- [ ] **Step 2: Run** `node --test test/financeOverview.test.js` – Expected: FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run** Testdatei und `npm test` – Expected: PASS (gesamte Suite). +- [ ] **Step 5: Commit** `git commit -m "Finanzen: Uebersicht und Rechnungsrouten fuer WOMS"` diff --git a/docs/superpowers/plans/2026-10-01-finanzen-plan-3-woms-livegang.md b/docs/superpowers/plans/2026-10-01-finanzen-plan-3-woms-livegang.md new file mode 100644 index 0000000..177ab33 --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-finanzen-plan-3-woms-livegang.md @@ -0,0 +1,208 @@ +# Finanzen Plan 3: WOMS-Oberfläche und Livegang 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:** Die WOMS-Seite „Finanzen“ zeigt Stripe-Rechnungen und das Finom-Konto nach dem Seitenaufbau der Spec, Projektrechnungen werden in WOMS geschrieben, InvoiceNinja verschwindet aus der Oberfläche, und das Ganze geht kontrolliert live. + +**Architecture:** WOMS spricht ausschließlich die Portal-Routen `/api/admin/finanzen/*` aus Plan 1 und 2 an (Appwrite-JWT, wiederverwendeter JWT-Cache wie Serverpuls). Reine Formatierungs- und Parse-Logik in `src/lib/finance*.js` (mit `node --test` geprüft), Darstellung in `src/components/finance/*` und drei Seiten. + +**Tech Stack:** React 18 + Vite (WOMS), vorhandene UI-Bausteine `src/components/ui` (PageHeader, Card, Button, Badge, Tabs, EmptyState, Field, Kpi, AutocompleteInput), `styles/global.css`, `node --test` mit `tests/unit/load.mjs`. + +**Spec:** `docs/superpowers/specs/2026-10-01-finanzen-design.md` (im Portal-Repo, Worktree `kundenbereich.webklar.com/dev-finanzen`) + +## Global Constraints + +- WOMS-Worktree: `/home/webklar/apps/ticket.webklar.com/dev-finanzen`, Branch `feature/finanzen` von `origin/test`, `node_modules` als Symlink auf `../build/node_modules`. `node_modules` ist in Git getrackt: **nur einzelne Dateien adden, nie `git add -A`**. **Nie pushen** ohne OK des Nutzers (der Webhook stellt jede gepushte Branch live). +- Tests: `npm test` (`node --test tests/*.test.mjs tests/unit/*.test.mjs`) grün vor jedem Commit. Reine Logik gehört nach `src/lib/` (ohne Appwrite-Import), damit `loadModule` sie bündeln kann. +- Build-Prüfung unter Speichergrenze: `systemd-run --scope -p MemoryMax=1500M -p MemorySwapMax=0 npm run build` im Worktree. +- Portal-Basis-URL: `import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'`; Auth `Authorization: Bearer ` über `createJwtCache` + `fetchWithRetry` aus `src/lib/serverpulsAuth.js`. Kein `PUT` (Portal-CORS erlaubt GET, POST, PATCH, DELETE). +- Nur Admins (`isAdmin` aus `useAuth()`) sehen Menüpunkt und Seiten; Nicht-Admins werden auf `/tickets` umgeleitet. +- Darstellung: Beträge de-DE mit `€`, rechtsbündig, Klasse mit `font-variant-numeric: tabular-nums`; Daten `TT.MM.JJJJ`; nie Farbe allein (immer Text oder Symbol dazu); keine Prozent-Veränderungen, keine Diagramme außer optionaler Treppenlinie (nicht in diesem Plan). +- Texte für Nutzer auf Deutsch mit echten Umlauten. +- InvoiceNinja-Server und `ticket-integrations` bleiben unangetastet. +- Seitenblöcke, Aufgabenarten und Zuordnungsregeln exakt wie in der Spec. + +## Review Focus + +- **Rückkehrseite doppelt geladen** (React StrictMode, Neuladen, Zurück-Taste): der Code darf nur einmal an `/bank/rueckkehr` gehen; zweiter Versuch zeigt die Serverantwort verständlich statt „Fehler“. Test in Task 2 (`parseCallback` + Einmal-Schutz in der Seite). +- **Betragseingabe** „1.234,50“, „50“, „50,5“, „ 12 “, „-5“, „abc“ → 123450, 5000, 5050, 1200, Fehler, Fehler. Test in Task 1. +- **Portal nicht erreichbar oder 403** (Admin ohne 2FA-Sitzung): Seite zeigt eine Fehlerkarte mit Text und „Erneut versuchen“, kein weißer Bildschirm. Test in Task 7 (Smoke). +- **Unumkehrbare Zuordnung:** Bestätigen einer Rechnungszuordnung verlangt einen zweiten Klick mit dem Hinweis „wird in Stripe als bezahlt markiert, nicht umkehrbar“. Test in Task 7 (Smoke). +- **Kunde ohne E-Mail:** „Rechnung schreiben“ für diesen Kunden ist gesperrt mit Hinweis „E-Mail fehlt“ (Stripe verschickt per Mail). Test in Task 6 (`canInvoice`). + +--- + +### Task 1: Formatierung, Parser und API-Client + +**Files:** +- Create: `src/lib/financeFormat.js` +- Create: `src/lib/financeApi.js` +- Test: `tests/unit/financeFormat.test.mjs` + +**Interfaces:** +- Produces (`financeFormat.js`, rein): + - `euro(cents) → string` (`euro(123456) === '1.234,56 €'`, `euro(null) === '–'`) + - `euroWhole(cents) → string` (`euroWhole(10893) === '109 €'`) + - `signedEuro(cents) → string` (`'+168,79 €'`, `'−4,49 €'` mit U+2212) + - `dateDe('2027-03-29') === '29.03.2027'`; `sinceLabel(iso, now) → 'gerade eben' | 'vor 12 Min.' | 'vor 3 Std.' | 'vor 2 Tagen'` + - `parseEuro(text) → number` (Cent) oder wirft `Error('Ungültiger Betrag')`; erlaubt Tausenderpunkte und Dezimalkomma, keine negativen Werte + - `INVOICE_STATUS = { entwurf: { label: 'Entwurf', tone: 'muted' }, offen: { label: 'Offen', tone: 'info' }, ueberfaellig: { label: 'Überfällig', tone: 'warn' }, bezahlt: { label: 'Bezahlt', tone: 'ok' }, storniert: { label: 'Storniert', tone: 'muted' }, uneinbringlich: { label: 'Uneinbringlich', tone: 'danger' } }` + - `CATEGORY_LABEL = { rechnung: 'Rechnung', 'rechnung-extern': 'Einnahme ohne Stripe-Rechnung', 'stripe-auszahlung': 'Stripe-Auszahlung', fixkosten: 'Fixkosten', privat: 'Privat/Umbuchung', sonstiges: 'Sonstiges', '': 'Offen' }` + - `URGENCY = { hoch: { label: 'Dringend', tone: 'danger' }, mittel: { label: 'Bald', tone: 'warn' }, info: { label: 'Hinweis', tone: 'muted' } }` + - `AGE_GROUP_LABEL = { 'nicht-faellig': 'Nicht fällig', '1-30': '1–30 Tage', '31-60': '31–60 Tage', 'ueber-60': 'Über 60 Tage' }` + - `pageOf(list, page, size = 50) → { items, page, pages }` + - `canInvoice(customer) → { ok: boolean, reason?: string }` – `customerStatus` `lead`/`lost` → „Noch kein fester Kunde“; ohne `email` → „E-Mail fehlt“. +- Produces (`financeApi.js`): `getOverview()`, `getInvoices({ status, kunde })`, `createInvoice(body)`, `sendInvoice(id)`, `voidInvoice(id)`, `deleteInvoice(id)`, `getTransactions()`, `assignTransaction(id, target)`, `unassignTransaction(id)`, `getBank()`, `refreshBank()`, `startBankAuth()`, `completeBankAuth({ code, state, error })`, `getSettings()`, `patchSettings(patch)`, `getFixedCosts()`, `createFixedCost(body)`, `updateFixedCost(id, patch)`, `deleteFixedCost(id)` – Pfade und Methoden wie in Plan 1/2; Fehler werfen `Error(data.error || 'API-Fehler ')` mit `.status`. + +- [ ] **Step 1: Worktree anlegen** + +```bash +cd /home/webklar/apps/ticket.webklar.com && git -C build fetch -q origin && git -C build worktree add ../dev-finanzen -b feature/finanzen origin/test && ln -s ../build/node_modules dev-finanzen/node_modules +``` + +- [ ] **Step 2: Write the failing tests** in `tests/unit/financeFormat.test.mjs` mit allen Beispielwerten oben (inkl. Review-Focus-Fälle für `parseEuro` und `canInvoice`). +- [ ] **Step 3: Run** `npm test` – Expected: FAIL (Modul fehlt). +- [ ] **Step 4: Implement** beide Dateien (`financeApi.js` ohne Test, folgt dem Muster von `serverpulsApi.js`). +- [ ] **Step 5: Run** `npm test` – Expected: PASS. +- [ ] **Step 6: Commit** `git add src/lib/financeFormat.js src/lib/financeApi.js tests/unit/financeFormat.test.mjs && git commit -m "Finanzen: Formatierung, Betragsparser und Portal-API-Client"` + +### Task 2: Zugriff nur für Admins, Routen, Rückkehrseite + +**Files:** +- Create: `src/components/AdminRoute.jsx` +- Create: `src/lib/bankCallback.js` +- Create: `src/pages/BankCallbackPage.jsx` +- Modify: `src/App.jsx` (Routen `/finance`, `/finance/einstellungen`, `/finanzen/bank-callback`, alle in `ProtectedRoute` + `AdminRoute`) +- Modify: `src/components/Navbar.jsx:64` (Eintrag „Finanzen“ nur bei `isAdmin`) +- Test: `tests/unit/bankCallback.test.mjs` + +**Interfaces:** +- Produces: + - `AdminRoute({ children })` – wartet auf geladenen Nutzer, sonst `` für Nicht-Admins. + - `parseCallback(search) → { code, state, error } | null` (`null`, wenn weder `code` noch `error`); `error_description` wird an `error` angehängt („access_denied: …“). + - `BankCallbackPage`: liest `location.search`, ruft `completeBankAuth` genau einmal (`useRef`-Schutz), zeigt „Bankzugang wird eingerichtet …“, dann Erfolg „Bankzugang erneuert, gültig bis TT.MM.JJJJ“ oder die Fehlermeldung des Servers, jeweils mit Knopf „Zu den Finanzen“ (`/finance`). Entfernt danach `code`/`state` aus der Adresszeile (`navigate('/finanzen/bank-callback', { replace: true, state: result })`). + +- [ ] **Step 1: Write the failing tests:** `parseCallback('?state=a&code=b') → { code: 'b', state: 'a', error: '' }`; `'?state=a&error=access_denied&error_description=abgebrochen' → error 'access_denied: abgebrochen'`; `'' → null`. +- [ ] **Step 2: Run** `npm test` – Expected: FAIL. +- [ ] **Step 3: Implement** Dateien und Routen. +- [ ] **Step 4: Run** `npm test` und den Build-Befehl aus Global Constraints – Expected: PASS, Build ohne Fehler. +- [ ] **Step 5: Commit** `git commit -m "Finanzen: nur fuer Admins, Rueckkehrseite fuer die Bankfreigabe"` + +### Task 3: Seitenkopf – Statuszeile, Liquidität, Zu erledigen + +**Files:** +- Modify: `src/pages/FinancePage.jsx` (komplett neu, InvoiceNinja-Inhalt entfällt) +- Create: `src/components/finance/StatusLine.jsx`, `LiquidityHead.jsx`, `TodoList.jsx` +- Create: `src/hooks/useFinanceOverview.js` (`{ data, loading, error, reload }` über `getOverview`) +- Modify: `src/styles/global.css` (Klassen `.fin-amount` mit `tabular-nums` und rechtsbündig, `.fin-todo`, `.fin-status`; Farben nur über vorhandene Variablen) + +**Interfaces:** +- Consumes: Task 1, Übersicht-Format aus Plan 2 Task 8. +- Produces: + - `StatusLine({ stand, onRefresh, refreshing })` – Text nach Spec Block 0; „Aktualisieren“ ruft `refreshBank`, zeigt 429-Meldung des Servers als Hinweis. + - `LiquidityHead({ kopf })` – drei `Kpi`: „Kontostand“ (`euroWhole`), „Verfügbar nach Rücklagen“ (`euroWhole`, Unterzeile „Aufschlüsselung“ klappt `kontostand − Steuer-Rücklage − Fixkosten 30 Tage − Mindestpuffer` als Liste auf), „Tiefster Stand in 30 Tagen“ (`sicher: … am TT.MM.` / `erwartet: … am TT.MM.`). + - `TodoList({ aufgaben, onAction })` – höchstens 7 Zeilen, danach „Alle N anzeigen“; je Zeile Badge (`URGENCY`), Titel, Text, ein Knopf. `onAction(aktion)` wird in `FinancePage` behandelt: `bank-verbinden` → `startBankAuth()` und `window.location.assign(url)`; `bank-aktualisieren` → Refresh; `rechnung-oeffnen` → `hostedUrl` in neuem Tab bzw. Sprung zum Rechnungsblock; `zuordnen` → öffnet Zuordnen-Dialog (Task 5); `kunde-oeffnen` → `/customers/:id`; `fixkosten-uebernehmen` → Bestätigung, dann `createFixedCost({ name, amountCents, interval, nextDate: , counterpartyMatch: key })`; `einstellungen` → `/finance/einstellungen`. Leerzustand: `EmptyState` „Nichts offen“. + - `FinancePage`: Ladezustand, Fehlerkarte mit Meldung und „Erneut versuchen“, danach Blöcke 0–2. + +- [ ] **Step 1: Implement** Komponenten, Hook, Seite (Blöcke 0–2). +- [ ] **Step 2: Run** `npm test` und Build – Expected: PASS. +- [ ] **Step 3: Commit** `git commit -m "Finanzen: Statuszeile, Liquiditaetskopf und Aufgabenliste"` + +### Task 4: Vorschau, Steuer, Abos + +**Files:** +- Create: `src/components/finance/ForecastTable.jsx`, `TaxCard.jsx`, `AboCard.jsx` +- Modify: `src/pages/FinancePage.jsx` (Blöcke 3–5; Desktop: Vorschau links, Steuer rechts; Abos halbe Breite) + +**Interfaces:** +- Produces: + - `ForecastTable({ vorschau })` – Spalten Datum, Posten, „sicher“/„erwartet“ (Text, nicht nur Farbe), Betrag (`signedEuro`), Saldo sicher, Saldo erwartet (laufend ab Kontostand); darunter `vorschau.annahme`; leer → „Keine Zahlungen in den nächsten 30 Tagen bekannt.“ + - `TaxCard({ steuer })` – Rücklage (`euro`, „30 % von Gewinn 2026“), Kleinunternehmer-Zeile „ von 25.000 € (2026)“ und „Grenze 100.000 €“, Vorjahr nur wenn gesetzt, Gewerbesteuer-Hinweis nur wenn `gewerbesteuerHinweis`, nächste ESt-Vorauszahlung nur wenn gesetzt. + - `AboCard({ abos })` – „Abo-Einnahmen pro Monat“, „zahlende Kunden“, „gefährdet“ (nur > 0), Liste Kunde · Produkt · Status · Betrag · nächste Abbuchung; leer → „Noch keine Abos.“ + +- [ ] **Step 1: Implement.** +- [ ] **Step 2: Run** `npm test` und Build – Expected: PASS. +- [ ] **Step 3: Commit** `git commit -m "Finanzen: Vorschau, Steuer und Abos"` + +### Task 5: Rechnungen, Rechnung schreiben, Buchungen, Zuordnen + +**Files:** +- Create: `src/components/finance/InvoiceTable.jsx`, `InvoiceCreateModal.jsx`, `TransactionTable.jsx`, `AssignDialog.jsx` +- Modify: `src/pages/FinancePage.jsx` (Blöcke 6–7, Dialoge) + +**Interfaces:** +- Consumes: Task 1 (`financeApi`, `financeFormat`), `useCustomers` (vorhanden) für die Kundenauswahl. +- Produces: + - `InvoiceTable({ altersgruppen, customerId = null, onChanged })` – lädt `getInvoices({ status, kunde: customerId })`; Filter `.seg` Offen/Überfällig/Bezahlt/Entwürfe/Alle (Standard Offen); Altersgruppen-Zeile mit Anzahl und Summe; Spalten nach Spec Block 6; Aktionen je Status: Entwurf → „Senden“, „Löschen“ (mit Rückfrage); offen/überfällig → „Zahlungsseite“, „PDF“, „Stornieren“ (Rückfrage, nur `source 'projekt'`); bezahlt → „PDF“, bei `outOfBand` Hinweis „per Überweisung“. Knopf „Rechnung schreiben“. + - `InvoiceCreateModal({ open, onClose, onCreated, fixedCustomer = null })` – Kunde (AutocompleteInput über Kunden mit `canInvoice(c).ok`, gesperrte mit Grund), Positionen (Beschreibung, Menge, Einzelpreis in Euro → `parseEuro`), „+ Position“, Zahlungsziel (Standard 14 Tage), Notiz; Summe; Hinweis „Gemäß § 19 UStG ohne Umsatzsteuer.“; Knöpfe „Als Entwurf speichern“ und „Senden“ (`senden: true`); Serverfehler im Dialog anzeigen. + - `TransactionTable({ onAssign })` – `getTransactions()`, 50 je Seite (`pageOf`), neueste zuerst; Spalten Datum, Gegenpartei + Verwendungszweck (zweizeilig), Betrag (`signedEuro`), Zuordnung (`CATEGORY_LABEL`, bei `rechnung` mit Nummer); offene Eingänge mit stärkstem Vorschlag als Text und Knopf „Zuordnen“. + - `AssignDialog({ transaction, invoices, onClose, onDone })` – Auswahl: Vorschläge, andere offene Rechnung, Stripe-Auszahlung (aus Vorschlägen), Fixkosten (nur Ausgänge, aus `getFixedCosts`), Kategorie (Einnahme ohne Stripe-Rechnung nur bei Eingang, Privat/Umbuchung, Sonstiges), Notiz; bei Rechnung zweistufig: erst „Zuordnen“, dann Bestätigungstext „Die Rechnung wird in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.“ mit „Ja, als bezahlt markieren“; bei bestehender Zuordnung (außer `rechnung`) Knopf „Zuordnung lösen“. Serverfehler (409 Betrag weicht ab usw.) im Dialog. + +- [ ] **Step 1: Implement.** +- [ ] **Step 2: Run** `npm test` und Build – Expected: PASS. +- [ ] **Step 3: Commit** `git commit -m "Finanzen: Rechnungen schreiben und verwalten, Buchungen zuordnen"` + +### Task 6: Einstellungen, Kunden-Tab, InvoiceNinja aus der Oberfläche + +**Files:** +- Create: `src/pages/FinanceSettingsPage.jsx` +- Create: `src/components/finance/CustomerInvoices.jsx` +- Modify: `src/pages/CustomerDetailPage.jsx` (Tab „Rechnungen“ → `CustomerInvoices`; Ninja-Sync beim Speichern `:133-156` entfernen; Karte „Rechnungsdaten (für InvoiceNinja)“ → „Rechnungsdaten“; Badge „verknüpft“ entfernen; `syntheticTicket` entfernen) +- Modify: `src/components/TicketCard.jsx` (Tab `invoices` und `InvoicePanel` entfernen) +- Modify: `src/pages/CustomersPage.jsx` (Untertitel „Sync mit InvoiceNinja“ und Badge „Ninja“ entfernen) +- Modify: `src/lib/leads.js` (`ninjaPayload`, `syncCustomerToNinja` und den Ninja-Teil von `upgradeToCustomer` entfernen; `isInvoiceReady` entfernen, falls danach unbenutzt) +- Delete: `src/components/InvoicePanel.jsx`, `src/hooks/useInvoices.js`, `src/hooks/useFinanceSummary.js`, `src/lib/integrationsApi.js` (nur wenn `grep -rn integrationsApi src` danach leer ist) + +**Interfaces:** +- Produces: + - `FinanceSettingsPage` (`/finance/einstellungen`): Formular Einstellungen (Mindestpuffer und Vorjahresumsatz/ESt-Vorauszahlung in Euro über `parseEuro`, Rücklage in %, Bankverbindungstext) mit „Speichern“; Fixkostenliste (anlegen, bearbeiten, aktiv/inaktiv, löschen mit Rückfrage); Karte „Bankzugang“ (Status, IBAN maskiert, gültig bis, letzter Abruf, Fehler) mit „Jetzt abrufen“ und „Neu verbinden“; Link zurück zu `/finance`. + - `CustomerInvoices({ customer })` – `canInvoice(customer)`: nicht ok → `EmptyState` mit Grund und „Daten erfassen“ (wechselt zum Übersicht-Tab); ok → `InvoiceTable({ customerId })` + `InvoiceCreateModal({ fixedCustomer: customer })`. + +- [ ] **Step 1: Implement** Seite und Komponente. +- [ ] **Step 2: Remove** InvoiceNinja-Stellen wie oben; danach `grep -rni "ninja\|invoicePanel\|useInvoices\|useFinanceSummary" src` – Expected: keine Treffer. +- [ ] **Step 3: Run** `npm test` und Build – Expected: PASS. +- [ ] **Step 4: Commit** (geänderte und gelöschte Dateien einzeln: `git add ` und `git rm `) `git commit -m "Finanzen: Einstellungen, Rechnungen am Kunden, InvoiceNinja aus der Oberflaeche entfernt"` + +### Task 7: Oberflächentest (Smoke) gegen Nachbau + +**Files:** +- Create (nicht committen): Scratchpad `finanzen-ui/` mit `fixtures.json` (Übersicht, Rechnungen, Buchungen aus Plan-2-Testdaten) und `smoke.mjs` + +**Interfaces:** +- Consumes: gebautes `dist/` aus dem Worktree. + +- [ ] **Step 1: Write** `smoke.mjs`: statischer Server für `dist/` (SPA-Fallback auf `index.html`) unter einer festen Herkunft; Playwright (`headless shell`, unter `systemd-run --scope -p MemoryMax=700M`) mit `page.route` für `**/v1/account*` (Admin-Nutzer mit Label `admin`), `**/v1/account/jwt*` und `https://project.webklar.com/api/admin/finanzen/**` aus `fixtures.json`. Prüfungen: + 1. `/finance` zeigt „Kontostand“ mit „109 €“, „Zu erledigen“ mit der Aufgabe „Zahlungseingang zuordnen“, Vorschau-Annahmezeile, Rechnungsfilter „Offen“. + 2. Zuordnen eines Eingangs zu einer Rechnung: nach erstem Klick erscheint der Satz „lässt sich nicht rückgängig machen“, erst der zweite Klick sendet `POST …/zuordnen` (Anfrage mitzählen). + 3. Übersicht antwortet 403 → Fehlerkarte mit „Erneut versuchen“, kein leerer Bildschirm. + 4. Nutzer ohne Label `admin` → `/finance` landet auf `/tickets`, Menüpunkt „Finanzen“ fehlt. + 5. `/finanzen/bank-callback?state=s&code=c` → genau ein `POST …/bank/rueckkehr`, Text „Bankzugang erneuert, gültig bis 29.03.2027“. + 6. Breite 390 px: keine horizontale Scrollleiste auf `/finance`. +- [ ] **Step 2: Run** `node smoke.mjs` – Expected: alle 6 Prüfungen „ok“. +- [ ] **Step 3:** Gefundene Fehler in den Komponenten beheben, `npm test` + Build, Commit `git commit -m "Finanzen: Korrekturen aus dem Oberflaechentest"` (nur falls Änderungen). + +### Task 8: Livegang (jeder Schritt mit OK des Nutzers) + +**Files:** +- Modify: `/home/webklar/apps/docker-compose.yml` (Sicherung vorher: `docker-compose.yml.bak-2026-10-XX-finanzen`) +- Move: `/home/webklar/apps/ticket-integrations/enablebanking.pem` und `.pub` → `/home/webklar/apps/secrets/enablebanking/` + +- [ ] **Step 1: Schlüssel verschieben** (Ordner 700, Schlüssel 600; Inhalt nie ausgeben): + +```bash +install -d -m 700 /home/webklar/apps/secrets/enablebanking && mv /home/webklar/apps/ticket-integrations/enablebanking.pem /home/webklar/apps/ticket-integrations/enablebanking.pub /home/webklar/apps/secrets/enablebanking/ && chmod 600 /home/webklar/apps/secrets/enablebanking/enablebanking.pem +``` + +- [ ] **Step 2: Compose** beim Dienst `kundenbereich-server` ergänzen: Volume `- ./secrets/enablebanking:/run/secrets/enablebanking:ro`; Umgebung `- ENABLE_BANKING_APP_ID=b2582270-11bf-4489-affb-1a151f076c07` und `- ENABLE_BANKING_KEY_PATH=/run/secrets/enablebanking/enablebanking.pem`. Prüfen: `docker compose config -q` ohne Fehler. +- [ ] **Step 3: Portal live** (OK des Nutzers): `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-finanzen push origin feature/finanzen:main`; warten, bis `build/` auf dem neuen Stand ist (`git -C …/build log -1`); dann `cd /home/webklar/apps && docker compose up -d kundenbereich-server` (übernimmt Volume und Umgebung). Erwartet: `docker logs webklar-kundenbereich-server --since 2m` zeigt den Start ohne Fehler; `curl -s https://project.webklar.com/api/health` → `ok: true`. +- [ ] **Step 4: Collections und Sitzung übernehmen** (Werte aus `ticket-integrations/enablebanking-session.txt`, ohne sie auszugeben): + +```bash +F=/home/webklar/apps/ticket-integrations/enablebanking-session.txt; docker exec -w /app webklar-kundenbereich-server node scripts/setup-finanzen.mjs --session "$(sed -n 's/^session_id=//p' $F)" --account "$(sed -n 's/^account_uid=//p' $F)" --valid-until "$(sed -n 's/^valid_until=//p' $F)" +``` + + Erwartet: „Sitzung übernommen, gültig bis 29.03.2027“. Danach `enablebanking-session.txt` und `enablebanking-app-id.txt` löschen. Nach höchstens 2 Minuten (Job) oder per `POST /bank/aktualisieren`: 9 Buchungen, Kontostand 108,93 €. +- [ ] **Step 5: Stripe-Dashboard** (Nutzer): Rechnungsnummern fortlaufend auf Kontoebene; Zahlarten für Rechnungen Karte und SEPA-Lastschrift; in WOMS-Einstellungen den Bankverbindungstext (IBAN, BIC) eintragen. +- [ ] **Step 6: WOMS live** (OK des Nutzers): `git -C /home/webklar/apps/ticket.webklar.com/dev-finanzen push origin feature/finanzen:test`. Erwartet: neues Bundle in `ticket.webklar.com/out` enthält „Verfügbar nach Rücklagen“; Seite lädt für Kenso. +- [ ] **Step 7: Daten nachziehen:** Buchung „Rechnung 0005 vom 28.07.2026“ als „Einnahme ohne Stripe-Rechnung“ zuordnen; prüfen, dass Justin das Label `admin` hat (sonst Nutzer fragen). +- [ ] **Step 8: Merken:** Memory-Datei `webklar-finanzen.md` mit Livestand, Commits und offenen Punkten aktualisieren (Enable-Banking-Vertrag, Kundenportal-Tab „Rechnungen“ noch InvoiceNinja). diff --git a/docs/superpowers/specs/2026-10-01-finanzen-design.md b/docs/superpowers/specs/2026-10-01-finanzen-design.md new file mode 100644 index 0000000..8274c49 --- /dev/null +++ b/docs/superpowers/specs/2026-10-01-finanzen-design.md @@ -0,0 +1,196 @@ +# Finanzen: Stripe-Rechnungen und Finom-Konto im Ticketsystem + +Stand: 2026-10-01 · Status: Entwurf zur Freigabe + +Grundlage: Recherche `/root/reports/Finanzübersicht für kleine Webagentur.md` (Notizen in +`/root/research_notes/Finanzübersicht für kleine Webagentur/`) und die Machbarkeitsprüfung vom +30.09./01.10.2026 (Enable Banking produktiv getestet: Finom-Konto, Salden, Umsätze). + +## Ziel + +Die Seite „Finanzen“ im Ticketsystem (WOMS, `ticket.webklar.com/finance`) zeigt nicht mehr +InvoiceNinja, sondern **Stripe-Rechnungen** und das **Finom-Geschäftskonto**. Sie beantwortet beim +wöchentlichen Blick drei Fragen: Wie viel Geld ist wirklich frei? Was muss ich heute tun? Reicht das +Geld die nächsten 30 Tage? Projektrechnungen werden direkt aus WOMS über Stripe geschrieben und +verschickt. + +Erfolg: Kein Zahlungseingang bleibt unzugeordnet, keine überfällige Rechnung bleibt unbemerkt, und +der Bankzugang läuft nie unbemerkt ab. + +## Feststehende Rahmenbedingungen (geprüft) + +- **WEBklar GbR, Kleinunternehmer nach § 19 UStG** (Impressum, Stripe-Rechnungsfuß im Portal). Daraus + folgt: keine Umsatzsteuer auf Rechnungen, keine USt-Voranmeldung, keine USt-Rücklage, **keine + E-Rechnungspflicht beim Ausstellen** – das Stripe-PDF genügt. Einkommensteuer zahlen die + Gesellschafter privat; Gewerbesteuer erst über 24.500 € Gewinn. +- **Bank:** Enable Banking, App „WEBklar Finanzen“, ID `b2582270-11bf-4489-affb-1a151f076c07`, + Produktion, nur Kontoinformationen (AIS). Schlüssel auf dem Server erzeugt + (`/home/webklar/apps/ticket-integrations/enablebanking.pem`, verlässt den Server nie). Sitzung zum + Finom-Konto „Webklar“ besteht, gültig bis **29.03.2027**; Sitzungs-ID und Konto-UID in + `/home/webklar/apps/ticket-integrations/enablebanking-session.txt`. Finom liefert bei Eingängen + Zahlername, IBAN und Verwendungszweck; Kartenzahlungen haben eine Händlerkategorie (MCC). + PSD2-Grenzen: etwa 4 automatische Abrufe pro Tag, Umsätze nur der letzten ~90 Tage, Zugang + höchstens 180 Tage. +- **Enable Banking ohne Vertrag ist nur zur Erprobung erlaubt** („solely for evaluation purposes or + for the personal use of private individuals“, Terms → Restriction of Use). Für den Dauerbetrieb + braucht es einen Vertrag (support.api@enablebanking.com). Bauen und testen ist gedeckt. +- **Stripe** ist im Kundenportal eingerichtet (Live-Schlüssel, Abos per Karte und SEPA-Lastschrift, + Rechnungsfuß § 19 UStG). Bisher hat noch keine Stripe-Auszahlung das Finom-Konto erreicht. +- **InvoiceNinja bleibt installiert** und unverändert in Betrieb, verschwindet aber aus der + WOMS-Oberfläche. Einzige relevante Altrechnung: 0005 (EWV Software GmbH, 168,79 €), am 05.08.2026 + per Überweisung bezahlt und in InvoiceNinja seit 01.10.2026 als bezahlt markiert. +- Die Kundenstammdaten (`customers` in `woms-database`) teilen sich WOMS und Portal; Stripe-Kunden + hängen über `metadata.webklarCustomerId` daran (`getOrCreateStripeCustomer` im Portal). + +## Entscheidungen (Standardwerte zur Freigabe) + +1. **Backend im Kundenportal-Server** unter `/api/admin/finanzen/*`, geschützt mit + `requireAppwriteStaff` (Appwrite-JWT, Label `admin`, Sitzung mit zweitem Faktor). Dort liegen schon + Stripe, Jobs, Mails, Appwrite-Setup-Skripte und Tests. `ticket-integrations` bleibt unverändert. +2. **Nur Admins** sehen „Finanzen“ (Menüpunkt und Seiten). Justin braucht als Gesellschafter das Label + `admin` (Prüfung beim Livegang). +3. **Schlüssel** wandert nach `/home/webklar/apps/secrets/enablebanking/` und wird schreibgeschützt in + den Portal-Container eingebunden. Die Sitzungs-ID steht in Appwrite (ohne Schlüssel wertlos). +4. **Bankdaten werden dauerhaft gespeichert** (Collection `bankTransactions`), weil die Bank nur 90 Tage + liefert. Abruf automatisch alle 6 Stunden (4× täglich) und per Knopf „Aktualisieren“ (höchstens alle + 10 Minuten; mit PSU-Kopfzeilen, damit es nicht auf das Tageslimit zählt). +5. **Bankzugang erneuern** direkt aus WOMS: Knopf → Finom-Freigabe → Rückkehr auf + `https://ticket.webklar.com/finanzen/bank-callback` (bereits als Redirect-URL registriert). + Hinweis in „Zu erledigen“ ab 30 Tagen vor Ablauf, Team-Mail (Vorlage T18) bei 30, 14 und 3 Tagen. +6. **Projektrechnungen** entstehen in WOMS über Stripe: Kunde wählen, Positionen (Beschreibung, Menge, + Einzelpreis), Zahlungsziel 14 Tage, Versand per Stripe-Mail mit PDF und Zahlungsseite. Zahlarten + stellt der Nutzer im Stripe-Dashboard ein (Empfehlung: Karte und SEPA-Lastschrift). Im Rechnungsfuß + stehen der § 19-Hinweis und die Bankverbindung mit der Bitte, die Rechnungsnummer als + Verwendungszweck anzugeben. Entwürfe lassen sich löschen, versendete Rechnungen nur stornieren. +7. **Zahlungsabgleich als Vorschlag mit Bestätigung**, nie still automatisch: Eine Bestätigung markiert + die Stripe-Rechnung „außerhalb von Stripe bezahlt“ (`paid_out_of_band`, nicht umkehrbar) und merkt + sich die Bankbuchung. Stripe-Auszahlungen werden der Auszahlung zugeordnet (Umbuchung, kein Umsatz). + Teilzahlungen werden nicht unterstützt (Hinweis: in Stripe direkt erfassen). +8. **Fixkosten** pflegt der Nutzer als Liste (Name, Betrag, Rhythmus, nächstes Datum, Suchbegriff für die + Gegenpartei). Erkannte wiederkehrende Ausgaben werden nur vorgeschlagen (mindestens 3 Buchungen). +9. **Vorschau 30 Tage** mit zwei Linien: „nur sicher“ (Kontostand, unterwegs befindliche + Stripe-Auszahlungen, Fixkosten, ESt-Vorauszahlungen) und „erwartet“ (zusätzlich offene Rechnungen + und Abo-Verlängerungen). Kein Konfidenzkegel, eine Annahmezeile in Klartext. +10. **Steuer:** Rücklage = einstellbarer Prozentsatz (Standard 30 %) vom Gewinn des laufenden Jahres + (Einnahmen minus Ausgaben laut Bank). Kleinunternehmer-Zähler nach Zahlungseingang: laufendes Jahr + gegen 25.000 € (Status im Folgejahr) und 100.000 € (harte Grenze), Vorjahr als Einstellung. + ESt-Vorauszahlungen als optionale Einstellung (Betrag je Quartal, fällig 10.3./10.6./10.9./10.12.). +11. **Kundenansicht:** Der Kunden-Tab „Rechnungen“ zeigt Stripe-Rechnungen des Kunden und „Rechnung + schreiben“. Der Ticket-Tab „Rechnungen“ entfällt. +12. **Nicht im Umfang:** Kundenportal-Tab „Rechnungen“ (zeigt weiter InvoiceNinja, eigener Folgeschritt), + Stundenguthaben je Kunde, Projektmarge, Kundenkonzentration, Angebote, Teilzahlungen, E-Rechnung, + Ausgabenbelege, mehrere Bankkonten. + +## Seitenaufbau (WOMS `/finance`) + +Reihenfolge von oben nach unten; Blöcke 0–2 stehen im ersten Bildschirm. Beträge in de-DE +(`1.234,50 €`, rechtsbündig, `tabular-nums`), Daten `01.10.2026`, Farbe nie allein als Signal. + +| Nr. | Block | Inhalt | +|---|---|---| +| 0 | Statuszeile | „Bank: Stand vor 3 Std. · jüngste Buchung 29.09. · Zugang bis 29.03.2027“, „Stripe: Stand …“; bei Fehler „Abruf fehlgeschlagen: …“; Knopf „Aktualisieren“ | +| 1 | Liquiditätskopf | Kontostand (gebucht), „Verfügbar nach Rücklagen“ (Kontostand − Steuer-Rücklage − Fixkosten der nächsten 30 Tage − Mindestpuffer; negativ erlaubt; Aufschlüsselung per Klick), „Tiefster Stand in 30 Tagen“ (nur sicher / erwartet, jeweils mit Datum) | +| 2 | Zu erledigen | Höchstens 7 Zeilen, je mit genau einer Aktion; Leerzustand „Nichts offen“ | +| 3 | Vorschau 30 Tage | Tabelle nach Datum: Posten, sicher/erwartet, Betrag, laufender Saldo beider Linien; Annahmezeile | +| 4 | Steuer | Rücklage (Betrag, Prozentsatz, Gewinn lfd. Jahr), Kleinunternehmer-Zähler, nächste ESt-Vorauszahlung (falls eingestellt) | +| 5 | Abos | Monatliche Abo-Einnahmen (aktive + `past_due`, Jahrespreise ÷ 12), Anzahl zahlender Kunden, gefährdete Einnahmen (`past_due`), Liste der Abos | +| 6 | Rechnungen | Filter Offen / Überfällig / Bezahlt / Entwürfe / Alle; Altersgruppen (nicht fällig, 1–30, 31–60, über 60 Tage) mit Summe und Anzahl; Tabelle Nr., Kunde, Datum, Fällig, Betrag, Offen, Status, Aktionen (PDF, Zahlungsseite, Senden, Stornieren, Löschen); Knopf „Rechnung schreiben“ | +| 7 | Buchungen | Alle gespeicherten Buchungen, neueste zuerst, 50 je Seite: Datum, Gegenpartei + Verwendungszweck, Betrag mit Vorzeichen, Zuordnung (Rechnung, Stripe-Auszahlung, Fixkosten, Einnahme ohne Stripe-Rechnung, Privat/Umbuchung, Sonstiges, offen) | + +Unterseite `/finance/einstellungen`: Mindestpuffer, Rücklage-Prozentsatz, Vorjahresumsatz, +ESt-Vorauszahlung je Quartal, Bankverbindungstext für den Rechnungsfuß, Fixkostenliste, Bankzugang +(Status, „Neu verbinden“). + +Mobil: Blöcke gestapelt in derselben Reihenfolge, Tabellen als zweizeilige Zeilen. + +### „Zu erledigen“ – Arten + +| Art | Wann | Aktion | Dringlichkeit | +|---|---|---|---| +| `bank-zugang` | Zugang läuft in ≤ 30 Tagen ab oder ist abgelaufen | Neu verbinden | hoch ab ≤ 14 Tagen | +| `bank-abruf` | Letzter erfolgreicher Abruf älter als 24 h | Aktualisieren | mittel | +| `ueberfaellig` | Offene Rechnung, Fälligkeit < heute | Zahlungsseite öffnen / Erinnerung über Stripe senden | hoch ab 30 Tagen (Verzug) | +| `zuordnung` | Eingang (Betrag > 0) ohne Zuordnung | Zuordnen (mit Vorschlag, falls vorhanden) | mittel | +| `abo-zahlung` | Abo im Status `past_due` oder `unpaid` | Kunde öffnen | hoch | +| `entwurf` | Rechnungsentwurf älter als 3 Tage | Öffnen | info | +| `fixkosten-vorschlag` | Wiederkehrende Ausgabe erkannt, nicht in der Fixkostenliste | Übernehmen | info | +| `kleinunternehmer` | Einnahmen lfd. Jahr ≥ 70 % von 25.000 € | Einstellungen | mittel, hoch ab 100 % | + +Sortierung: hoch vor mittel vor info, innerhalb nach Datum (älteste zuerst). + +## Datenmodell (Appwrite, `woms-database`) + +- `bankConnections` – ein Dokument `finom`: `status` (`aktiv` | `abgelaufen` | `fehler`), `sessionId`, `accountUid`, `accountIdHash`, `ibanMasked`, + `iban`, `validUntil` (ISO), `lastSyncAt`, `lastSyncOk` (bool), `lastError`, `lastManualSyncAt`, + `balanceBookedCents`, `balanceAvailableCents`, `balanceDate`, `latestBookingDate`, `authState`, + `authStartedAt`, `warnedThresholds` (z. B. `30,14`). +- `bankTransactions` – Dokument-ID = erste 36 Zeichen des SHA-256 aus `entry_reference` (sonst + `transaction_id`, sonst Datum|Betrag|Gegenpartei|Verwendungszweck): `accountUid`, `status` + (`BOOK`/`PDNG`), `bookingDate`, `valueDate` (`YYYY-MM-DD`), `amountCents` (Integer, Eingang +, + Ausgang −), `currency`, `counterpartyName`, `counterpartyIban`, `remittance`, `mcc`, `entryReference`, + `category` (leer | `rechnung` | `rechnung-extern` | `stripe-auszahlung` | `fixkosten` | `privat` | + `sonstiges`), `matchedInvoiceId`, `matchedInvoiceNumber`, `matchedPayoutId`, `fixedCostId`, `note`, + `syncedAt`. +- `financeSettings` – ein Dokument `main`: `minBufferCents` (0), `taxReservePercent` (30), + `previousYearRevenueCents` (leer), `estPrepaymentCents` (0), `bankDetailsText`. +- `financeFixedCosts`: `name`, `amountCents` (positiv), `interval` (`monat` | `quartal` | `jahr`), + `nextDate`, `counterpartyMatch`, `active`. + +Stripe-Rechnungen, -Auszahlungen und Abos werden **nicht** gespiegelt: Rechnungen und Auszahlungen +liest der Server live (Zwischenspeicher 60 s, nach eigenen Änderungen geleert), Abos kommen aus der +vorhandenen Collection `subscriptions`. + +## Schnittstellen (`/api/admin/finanzen`) + +| Methode, Pfad | Zweck | +|---|---| +| GET `/uebersicht` | Alles für Blöcke 0–6 in einer Antwort (Status, Kopf, Aufgaben, Vorschau, Steuer, Abos, Rechnungs-Altersgruppen) | +| GET `/rechnungen?status=&kunde=` | Stripe-Rechnungen (status: `offen`, `ueberfaellig`, `bezahlt`, `entwurf`, `alle`) | +| POST `/rechnungen` | Rechnung anlegen (`{ customerId, items, daysUntilDue, memo, senden }`) | +| POST `/rechnungen/:id/senden` | Entwurf festschreiben und senden | +| POST `/rechnungen/:id/stornieren` | Offene Rechnung stornieren | +| DELETE `/rechnungen/:id` | Entwurf löschen | +| GET `/buchungen` | Gespeicherte Buchungen (höchstens 1000, neueste zuerst; die Oberfläche blättert in 50ern) mit Zuordnungsvorschlägen für offene Eingänge | +| POST `/buchungen/:id/zuordnen` | `{ invoiceId }` oder `{ payoutId }` oder `{ fixedCostId }` oder `{ category }` | +| DELETE `/buchungen/:id/zuordnung` | Zuordnung lösen (nicht bei Rechnungen, die schon als bezahlt markiert sind) | +| POST `/bank/aktualisieren` | Sofortabruf | +| POST `/bank/verbinden` | Freigabe starten → `{ url }` | +| POST `/bank/rueckkehr` | `{ code, state }` oder `{ error, state }` → neue Sitzung | +| GET, PATCH `/einstellungen` | Einstellungen | +| GET, POST `/fixkosten`; PATCH, DELETE `/fixkosten/:id` | Fixkostenliste | + +## Zuordnungsregeln (Vorschläge, höchstens 3 je Buchung) + +1. **Stripe-Auszahlung (hoch):** Gegenpartei oder Verwendungszweck enthält „stripe“, Betrag = Betrag + einer Auszahlung, Buchungsdatum höchstens 3 Tage von `arrival_date` entfernt. +2. **Rechnungsnummer (hoch / mittel):** normalisierter Verwendungszweck (Großbuchstaben, ohne + Leerzeichen und Bindestriche) enthält die normalisierte Stripe-Rechnungsnummer. Betrag = offener + Betrag → hoch, sonst mittel mit Hinweis „Betrag weicht ab“. +3. **Betrag und Name (mittel):** Betrag = offener Betrag und ein Wort (≥ 4 Zeichen, ohne GmbH, UG, GbR, + AG, KG, e.K., und, der, die) aus Firmen- oder Kundenname kommt im Zahlernamen vor. +4. **Nur Betrag (niedrig):** Betrag = offener Betrag genau einer offenen Rechnung. + +Bestätigen mit Rechnung ist nur möglich, wenn der Betrag dem offenen Betrag entspricht. + +## Fehlerverhalten + +- Bankabruf scheitert: alte Daten bleiben sichtbar und werden als veraltet markiert; `lastError` in der + Statuszeile; kein Datenverlust. +- Zugang abgelaufen (Enable Banking 401/403 oder Sitzung `EXPIRED`): Statuszeile + Aufgabe + `bank-zugang` (hoch), Abrufe pausieren bis zur Neuverbindung. +- Stripe nicht erreichbar: Übersicht liefert Bankteil trotzdem; Stripe-Blöcke zeigen „Stripe nicht + erreichbar“. +- Rückkehr mit falschem `state` oder Fehler von Finom: Meldung auf der Rückkehrseite, bestehende Sitzung + bleibt gültig. + +## Livegang (Reihenfolge) + +1. Schlüssel nach `secrets/enablebanking/` verschieben, Compose: Mount und Umgebungsvariablen + `ENABLE_BANKING_APP_ID`, `ENABLE_BANKING_KEY_PATH`. +2. Setup-Skript legt Collections an und übernimmt die bestehende Sitzung (kein neuer Finom-Login). +3. Portal `main` pushen (Nutzer-OK), Container neu starten, erster Abruf prüfen. +4. Stripe-Dashboard: Rechnungsnummern fortlaufend auf Kontoebene, Zahlarten für Rechnungen (Karte, + SEPA-Lastschrift) – Nutzer. +5. WOMS `test` pushen (Nutzer-OK). +6. Buchung „Rechnung 0005“ als „Einnahme ohne Stripe-Rechnung“ zuordnen. +7. Vor dem Dauerbetrieb: Vertrag mit Enable Banking (Nutzer).