Compare commits

...
27 Commits
Author SHA1 Message Date
ClaudeandClaude Sonnet 5.5 c0c1a3d7a9 Finanzen: Laendersuche ohne Prototypschluessel, beim Anlegen des Stripe-Kunden keine leeren Adressfelder
- stripeCountry: Object.hasOwn statt Zugriff ueber die Prototypkette ('constructor', '__proto__' ergaben ein Objekt als Land)
- stripeCustomerFields(customer, { forCreate }): beim Anlegen (getOrCreateStripeCustomer) fehlen leere PLZ/Ort ganz;
  das Loeschen per '' bleibt beim Abgleich (Aktualisieren)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 f0b57ebe41 Finanzen: Nachtrag vor Livegang (Kundenabgleich beim Senden, Adress-Sperre, Land und Anschrift sauber an Stripe)
- sendInvoiceNow: Entwurf mit webklarCustomerId laedt den WOMS-Kunden und bringt den Stripe-Kunden vor dem
  Finalisieren auf die aktuellen Daten (Fehler: 502, nichts finalisiert); ohne Kennung kein Abgleich
- Adress-Sperre (§ 14 Abs. 4 Nr. 1 UStG): Senden ueber 250 € ohne Strasse/PLZ/Ort -> 409 vor jedem Stripe-Schreibzugriff
  (Anlegen mit senden=true und Entwurf senden; Text und Grenze wie im WOMS)
- stripeCustomerFields: Land aus deutschen Namen (unbekannt -> weglassen), PLZ/Ort mit Strasse immer mitsenden ('' loescht)
- Stripe-Nachbau: Kunden-Update fuehrt die Anschrift feldweise zusammen, '' loescht ein Feld

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 73811e0300 Finanzen: Stripe-Kunde vor der Rechnung auf die aktuellen Kundendaten bringen
Ein wiederverwendeter Stripe-Kunde (ueber das Abo oder die Metadaten gefunden) trug
noch die alte E-Mail und Anschrift, die Stripe auf die Rechnung schreibt
(Empfaengeranschrift, § 14 Abs. 4 Nr. 1 UStG). createProjectInvoice aktualisiert ihn
jetzt nach getOrCreateStripeCustomer und vor dem ersten Rechnungsaufruf mit E-Mail,
Name (Firma vor Name) und Anschrift (Land DE, wenn keine zweistellige Angabe).
Scheitert das, entsteht keine Rechnung: 502 "Kundendaten konnten nicht an Stripe
uebertragen werden." ohne invoiceId. Die Felder baut stripeCustomerFields fuer das
Anlegen und das Aktualisieren gemeinsam. Der Stripe-Nachbau kennt POST /v1/customers/:id.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 5ed4fbd188 Finanzen: Setup-Skript mit --iban und strenger Pruefung von --valid-until, Enable-Banking-Variablen in .env.example
M5: --iban speichert iban und ibanMasked (erste und letzte vier Zeichen); --valid-until nur als Datum
oder voller ISO-Zeitpunkt mit Zeitzone, sonst Abbruch.
M16: ENABLE_BANKING_APP_ID und ENABLE_BANKING_KEY_PATH auskommentiert dokumentiert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 039fdcaf27 Finanzen: Vorschau, Steuer und Aufgaben genauer (Fixkosten ohne ESt, verspaetete Auszahlungen, Abos nicht ladbar)
M6: fixkosten30Cents zaehlt nur Fixkosten, die ESt-Vorauszahlung bleibt sicherer Posten.
M7: verspaetete Auszahlung (Ankunftstag vorbei, noch nicht bezahlt) kommt heute.
M8: ohne Stripe sind Kleinunternehmer-Zaehler und Anteil unbekannt (null), keine Warnung daraus.
M12: Verbindungsdokument ohne Sitzung zaehlt als keine Verbindung.
M13: Buchungen privat/fixkosten gehen nicht in die Fixkosten-Vorschlaege.
M14: Vorjahresumsatz ueber 25.000 EUR ist eine hohe Aufgabe.
M15: scheitert das Laden der Abos, meldet abos.fehler statt die ganze Uebersicht zu kippen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 83ee9d5a25 Finanzen: Zuordnung zu Rechnungen robuster (Speicherfehler nach pay, Doppelzuordnung, Protokoll)
M1: scheitert Appwrite nach dem Bezahlen, kommt 502 mit klarer Meldung statt eines blanken 500.
M2: Wiederaufnahme nur bei passendem bezahltem Betrag; eine Buchung, die schon an einer anderen
bezahlten Rechnung haengt, wird nicht ein zweites Mal vermerkt und bezahlt (409).
M3: Protokollzeile je erfolgreicher, nicht umkehrbarer Zuordnung (Buchung, Rechnung, Nutzer).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 daa451094a Finanzen: Zeitgrenzen fuer Stripe-Lesen, Schutz vor doppelt angelegten Rechnungen
I2: Leseanfragen an Stripe mit timeout 15 s und einer Wiederholung (auch Folgeseiten), die Uebersicht
wartet hoechstens 20 s auf Stripe und rechnet sonst ohne Stripe weiter.
I3: idempotencyKey vom Aufrufer an stripe.invoices.create, Schutz im Prozess gegen gleichzeitiges und
30 s spaeteres Doppelanlegen (409), 502 mit invoiceId/Nummer wenn die fertige Rechnung nicht lesbar ist.
M9: Warnung, wenn die Rechnungsliste bei 1000 abgeschnitten wird.
M11: Ursache eines 502 an einer bestehenden Rechnung (Art, Status, Code) im Protokoll.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 ef2ce49669 Finanzen: Start-Abruf nur bei altem Stand, unlesbare Buchungen ueberspringen, Mail bei Widerruf des Bankzugangs
I1: shouldRunStartupSync; der Abruf 2 Minuten nach dem Start entfaellt, wenn der letzte erfolgreiche
Abruf weniger als 5,5 Stunden her ist (Deployments verbrauchten das Tageslimit der Bank).
M4: eine unlesbare Buchung wird uebersprungen und gezaehlt, der Abruf endet ok.
M10: 401/403 beim Abruf sendet T18 einmal (Stufe 0 in warnedThresholds).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 3c5e06723f Finanzen: Uebersicht und Rechnungsrouten fuer WOMS
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 7dbf2ce75f Finanzen: Steuerruecklage, Kleinunternehmer-Zaehler und Aufgabenliste
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 34dfe181e4 Finanzen: Liquiditaetsvorschau, Abo-Kuendigung: cancelAt allein massgeblich, cancelAtPeriodEnd ohne cancelAt keine Verlaengerung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 9361000a70 Finanzen: Liquiditaetsvorschau 30 Tage (sicher/erwartet)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 34ff97e924 Finanzen: Einstellungen und Fixkostenliste
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 6e43d54fe8 Finanzen: Zuordnung zu Rechnungen erst vermerken, dann bezahlen; Vormerkungen nicht zuordnen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 2bf67cb48d Finanzen: Bankbuchungen Rechnungen, Auszahlungen, Fixkosten und Kategorien zuordnen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 f14a6b7b57 Finanzen: Zuordnungsvorschlaege und Erkennung wiederkehrender Ausgaben
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 3cedb5feef Finanzen: Rechnung senden ohne Doppelanlage (E-Mail vorab, erneut senden, Summengrenze)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 05e3478229 Finanzen: Projektrechnungen in Stripe anlegen, senden, stornieren, Entwurf loeschen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 db243f50bb Finanzen: Stripe-Rechnungen und Auszahlungen lesen (deutsch gemappt, 60-s-Cache)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 6af9834293 Finanzen: Admin-Routen fuer Bankstatus, Abruf, Neuverbindung und Buchungen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00
ClaudeandClaude Sonnet 5.5 7b35ed67a5 Finanzen: Warnung vor Ablauf des Bankzugangs (T18) und Abruf-Jobs
checkBankConsent mailt bei 30, 14, 3 Tagen und am Ablauftag (Berliner Kalendertage),
merkt sich die erreichten Stufen in warnedThresholds und holt eine nicht versandte
Mail beim naechsten Lauf nach. Jobs nur mit Enable Banking: Abruf nach 2 Minuten und
alle 6 Stunden, Pruefung nach 3 Minuten und taeglich.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:01 +00:00
ClaudeandClaude Sonnet 5.5 cba14ed0f0 Finanzen: Bankzugang aus WOMS neu verbinden (Freigabe, Rueckkehr, Kontowahl)
startBankAuth/completeBankAuth in bankSync.js. Die Rueckkehr prueft und speichert die
Sitzung unter der Sperre der Abrufe (ein laufender Abruf kann den neuen Zugang nicht
mehr ueberschreiben) und ruft danach einmal ab. Nachbau: holdNext haelt einen Aufruf an.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:41:54 +00:00
ClaudeandClaude Sonnet 5.5 75ca8b78c0 Finanzen: Index auf bankTransactions.status, Vormerkungssuche mit Rueckfall
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:41:54 +00:00
ClaudeandClaude Sonnet 5.5 588cc95f5d Finanzen: Bankabruf speichert Buchungen idempotent, Vormerkungen, Ablauf
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:41:54 +00:00
ClaudeandClaude Sonnet 5.5 3dd2fac50b Finanzen: Collections und Setup-Skript mit Sitzungsuebernahme
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:41:54 +00:00
ClaudeandClaude Sonnet 5.5 9329318df5 Finanzen: Enable-Banking-Client (JWT, Freigabe, Sitzung, Salden, Umsaetze)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:41:54 +00:00
ClaudeandClaude Opus 5.5 53b50011e8 Finanzen: Spec und Plaene 1-3 (Bank, Stripe/Auswertung, WOMS/Livegang)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:41:54 +00:00
46 changed files with 12489 additions and 26 deletions

No files matched your search

+5
View File
@@ -22,3 +22,8 @@ GITEA_API_TOKEN=
PREVIEW_DEPLOY_ROOT=/var/www/previews
PREVIEW_BASE_HOST=project.webklar.com
ALLOWED_CUSTOMER_STATUSES=active
# Finanzen (Bankabruf Finom über Enable Banking): beides setzen, sonst ist der Bankabruf aus.
# APP_ID = Anwendungs-ID bei Enable Banking, KEY_PATH = Pfad zum privaten Schlüssel (PEM) auf dem Server, nie im Repo.
# ENABLE_BANKING_APP_ID=
# ENABLE_BANKING_KEY_PATH=
@@ -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/<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"`
@@ -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<Invoice[]>` – `stripe.invoices.list({ limit: 100, created: { gte: <jetzt − 400 Tage> }, expand: ['data.customer'] })` mit Auto-Paging, höchstens 1000; Zwischenspeicher 60 s.
- `listPayouts({ force = false } = {}) → Promise<Array<{ id, amountCents, arrivalDate, status }>>` – `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<Invoice>`; 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<Invoice>` (finalize mit `auto_advance: false`, dann send); nur `entwurf`, sonst 409 „Nur Entwürfe können gesendet werden.“
- `voidInvoice(invoiceId) → Promise<Invoice>`; nur `offen`/`ueberfaellig` und `source 'projekt'`, sonst 409.
- `deleteDraft(invoiceId) → Promise<void>`; 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<Transaction>` – 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 (<euroDe>). 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<Transaction>` – `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<Transaction & { vorschlaege: Suggestion[] }>, 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<Settings>` (Dokument `financeSettings/main` mit Standardwerten zusammengeführt; fehlt es, nur Standardwerte, nichts anlegen)
- `patchSettings(patch) → Promise<Settings>`; 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<FixedCost[]>`, `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 <Nr.>“ 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<Overview>` 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=<customerId>` → `{ 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"`
@@ -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 <JWT>` ü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 <status>')` 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 `<Navigate to="/tickets" replace />` 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: <lastDate + Rhythmus>, 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> Gewinn 2026“), Kleinunternehmer-Zeile „<Betrag> 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 <Nr.> 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 <dateien>` und `git rm <dateien>`) `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).
@@ -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).
+313
View File
@@ -0,0 +1,313 @@
#!/usr/bin/env node
/**
* Finanzen: legt die Collections bankConnections (Bankzugang Finom ueber Enable Banking), bankTransactions
* (Buchungen), financeSettings (Einstellungen) und financeFixedCosts (Fixkosten) in woms-database an.
* Idempotent: existierende Collection/Attribute/Indexe werden uebersprungen.
*
* Optional uebernimmt das Skript eine bereits bestehende Enable-Banking-Sitzung (einmalig beim Livegang):
* --session <id> Sitzungs-ID (wird nie ausgegeben)
* --account <uid> Konto-UID aus der Sitzung
* --valid-until <iso> Ende der Freigabe: Datum (JJJJ-MM-TT) oder voller ISO-Zeitpunkt mit Zeitzone
* (z. B. 2027-03-29T00:00:00Z); alles andere bricht ab
* --iban <iban> (optional) IBAN des Kontos: wird gespeichert, die Seite zeigt sie als DE00...1234
* Dann wird bankConnections/finom mit status "aktiv" angelegt bzw. aktualisiert und financeSettings/main
* mit den Standardwerten angelegt, falls es fehlt. Ohne diese Argumente werden nur die Collections angelegt.
*
* Usage (im Container):
* docker exec -w /app webklar-kundenbereich-server node scripts/setup-finanzen.mjs \
* --session <id> --account <uid> --valid-until <iso> [--iban <iban>]
*/
import { parseArgs } from 'node:util'
// Argumente zuerst pruefen und Fehler ohne den Wert melden: parseArgs nennt bei Tippfehlern sonst den
// falschen Wert (etwa eine Sitzungs-ID ohne Option) im Klartext.
let args
try {
args = parseArgs({
options: {
session: { type: 'string' },
account: { type: 'string' },
'valid-until': { type: 'string' },
iban: { type: 'string' },
},
strict: true,
allowPositionals: false,
}).values
} catch {
console.error('Ungueltige Argumente. Erlaubt: --session <id> --account <uid> --valid-until <iso> [--iban <iban>]')
process.exit(1)
}
/**
* Strenger ISO-Zeitpunkt: JJJJ-MM-TT (Mitternacht UTC) oder JJJJ-MM-TTThh:mm:ss[.f]Z bzw. mit Versatz +hh:mm. Gibt es den
* Tag oder die Uhrzeit nicht (30.02., 25 Uhr), ist es kein Zeitpunkt. Sonst null.
*/
function parseIsoInstant(value) {
const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(Z|[+-]\d{2}:\d{2}))?$/.exec(value)
if (!m) return null
const [year, month, day] = [Number(m[1]), Number(m[2]), Number(m[3])]
const [hour, minute, second] = [Number(m[4] ?? 0), Number(m[5] ?? 0), Number(m[6] ?? 0)]
const calendar = new Date(Date.UTC(year, month - 1, day))
if (calendar.getUTCFullYear() !== year || calendar.getUTCMonth() !== month - 1 || calendar.getUTCDate() !== day) return null
if (hour > 23 || minute > 59 || second > 59) return null
if (m[7] && m[7] !== 'Z' && (Number(m[7].slice(1, 3)) > 23 || Number(m[7].slice(4)) > 59)) return null
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
/** IBAN ohne Leerzeichen in Grossbuchstaben, oder null, wenn sie nicht wie eine IBAN aussieht (2 Buchstaben, 2 Ziffern, 11 bis 30 Zeichen). */
function normalizeIban(value) {
const compact = String(value).replace(/\s+/g, '').toUpperCase()
return /^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(compact) ? compact : null
}
const takeover = Object.values(args).some((v) => v !== undefined)
let takeoverData = null
if (takeover) {
const session = String(args.session ?? '').trim()
const account = String(args.account ?? '').trim()
const validUntil = parseIsoInstant(String(args['valid-until'] ?? '').trim())
if (!session || !account || !args['valid-until']) {
console.error('Zur Uebernahme sind --session, --account und --valid-until zusammen noetig')
process.exit(1)
}
if (session.length > 64 || account.length > 64) {
console.error('--session und --account duerfen hoechstens 64 Zeichen lang sein')
process.exit(1)
}
if (!validUntil) {
console.error('--valid-until ist kein gueltiger ISO-Zeitpunkt (erlaubt: JJJJ-MM-TT oder JJJJ-MM-TTThh:mm:ssZ)')
process.exit(1)
}
let iban = ''
if (args.iban !== undefined) {
iban = normalizeIban(args.iban)
if (!iban) {
console.error('--iban ist keine gueltige IBAN')
process.exit(1)
}
}
takeoverData = { session, account, validUntil: validUntil.toISOString(), iban }
}
const endpoint = (process.env.APPWRITE_ENDPOINT || 'https://ticket.webklar.com/v1').replace(/\/$/, '')
const projectId = process.env.APPWRITE_PROJECT_ID || '6a1058610003c5a13a05'
const apiKey = process.env.APPWRITE_API_KEY
const databaseId = process.env.APPWRITE_DATABASE_ID || 'woms-database'
if (!apiKey) {
console.error('APPWRITE_API_KEY erforderlich')
process.exit(1)
}
async function aw(path, { method = 'GET', body } = {}) {
const response = await fetch(`${endpoint}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'X-Appwrite-Project': projectId,
'X-Appwrite-Key': apiKey,
},
body: body ? JSON.stringify(body) : undefined,
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const err = new Error(data.message || `Appwrite ${response.status}`)
err.status = response.status
throw err
}
return data
}
async function ensure(label, fn) {
try {
await fn()
console.log(`+ ${label}`)
} catch (e) {
if (e.status === 409 || /already exists/i.test(e.message)) {
console.log(`= ${label} (existiert)`)
} else {
throw new Error(`${label}: ${e.message}`)
}
}
}
async function waitForAttributes(collectionId, keys, maxSec = 60) {
// Ohne Limit liefert Appwrite nur 25 Attribute -> spaetere Attribute waeren nie "verfuegbar"
const limit = encodeURIComponent(JSON.stringify({ method: 'limit', values: [100] }))
for (let i = 0; i < maxSec; i += 1) {
const { attributes } = await aw(`/databases/${databaseId}/collections/${collectionId}/attributes?queries[]=${limit}`)
const pending = keys.filter((k) => attributes.find((a) => a.key === k)?.status !== 'available')
if (!pending.length) return
await new Promise((r) => setTimeout(r, 1000))
}
throw new Error(`Attribute in ${collectionId} wurden nicht verfuegbar`)
}
const col = (id) => `/databases/${databaseId}/collections/${id}`
async function ensureCollection(id, name, attributes, indexes) {
// Keine Dokument-Permissions: Zugriff ausschliesslich ueber den Server-API-Key
await ensure(`Collection ${id}`, () =>
aw(`/databases/${databaseId}/collections`, {
method: 'POST',
body: { collectionId: id, name, permissions: [], documentSecurity: false },
})
)
for (const a of attributes) {
const { type, key, ...rest } = a
await ensure(`${id}.${key}`, () =>
aw(`${col(id)}/attributes/${type}`, { method: 'POST', body: { key, required: false, ...rest } })
)
}
await waitForAttributes(id, attributes.map((a) => a.key))
for (const idx of indexes) {
await ensure(`${id} index ${idx.key}`, () =>
aw(`${col(id)}/indexes`, { method: 'POST', body: idx })
)
}
}
await ensureCollection(
'bankConnections',
'Bankzugang (Enable Banking)',
[
// Ein Dokument "finom": Zugang, Abrufstatus, letzter Kontostand
{ type: 'string', key: 'status', size: 16 },
{ type: 'string', key: 'sessionId', size: 64 },
{ type: 'string', key: 'accountUid', size: 64 },
{ type: 'string', key: 'accountIdHash', size: 128 },
{ type: 'string', key: 'ibanMasked', size: 40 },
{ type: 'string', key: 'iban', size: 40 },
{ type: 'string', key: 'validUntil', size: 32 },
{ type: 'string', key: 'lastSyncAt', size: 32 },
{ type: 'boolean', key: 'lastSyncOk' },
{ type: 'string', key: 'lastError', size: 500 },
{ type: 'string', key: 'lastManualSyncAt', size: 32 },
{ type: 'integer', key: 'balanceBookedCents' },
{ type: 'integer', key: 'balanceAvailableCents' },
{ type: 'string', key: 'balanceDate', size: 10 },
{ type: 'string', key: 'latestBookingDate', size: 10 },
// Freigabe in Arbeit (state der Rueckkehradresse) und bereits gemeldete Warnstufen (z. B. "30,14")
{ type: 'string', key: 'authState', size: 64 },
{ type: 'string', key: 'authStartedAt', size: 32 },
{ type: 'string', key: 'warnedThresholds', size: 32 },
],
[]
)
// Buchungen: Dokument-ID = Hash der entry_reference (siehe bankStore). Betrag in Cent, Eingang +, Ausgang -.
await ensureCollection(
'bankTransactions',
'Bankbuchungen',
[
{ type: 'string', key: 'accountUid', size: 64 },
{ type: 'string', key: 'status', size: 8 },
{ type: 'string', key: 'bookingDate', size: 10 },
{ type: 'string', key: 'valueDate', size: 10 },
{ type: 'integer', key: 'amountCents' },
{ type: 'string', key: 'currency', size: 3 },
{ type: 'string', key: 'counterpartyName', size: 255 },
{ type: 'string', key: 'counterpartyIban', size: 40 },
{ type: 'string', key: 'remittance', size: 1000 },
{ type: 'string', key: 'mcc', size: 8 },
{ type: 'string', key: 'entryReference', size: 128 },
// Zuordnung: leer | rechnung | rechnung-extern | stripe-auszahlung | fixkosten | privat | sonstiges
{ type: 'string', key: 'category', size: 32 },
{ type: 'string', key: 'matchedInvoiceId', size: 64 },
{ type: 'string', key: 'matchedInvoiceNumber', size: 64 },
{ type: 'string', key: 'matchedPayoutId', size: 64 },
{ type: 'string', key: 'fixedCostId', size: 64 },
{ type: 'string', key: 'note', size: 500 },
{ type: 'string', key: 'syncedAt', size: 32 },
],
[
{ key: 'idx_booking', type: 'key', attributes: ['bookingDate'] },
// Abruf: vorgemerkte Buchungen (PDNG) suchen
{ key: 'idx_status', type: 'key', attributes: ['status'] },
]
)
// Einstellungen: ein Dokument "main"
await ensureCollection(
'financeSettings',
'Finanz-Einstellungen',
[
{ type: 'integer', key: 'minBufferCents' },
{ type: 'integer', key: 'taxReservePercent' },
{ type: 'integer', key: 'previousYearRevenueCents' },
{ type: 'integer', key: 'estPrepaymentCents' },
{ type: 'string', key: 'bankDetailsText', size: 500 },
],
[]
)
// Fixkosten: Betrag positiv, Intervall monat | quartal | jahr
await ensureCollection(
'financeFixedCosts',
'Fixkosten',
[
{ type: 'string', key: 'name', size: 120 },
{ type: 'integer', key: 'amountCents' },
{ type: 'string', key: 'interval', size: 16 },
{ type: 'string', key: 'nextDate', size: 10 },
{ type: 'string', key: 'counterpartyMatch', size: 120 },
{ type: 'boolean', key: 'active' },
],
[]
)
/**
* Uebernimmt die bestehende Enable-Banking-Sitzung: bankConnections/finom anlegen bzw. aktualisieren und
* financeSettings/main mit den Standardwerten anlegen, falls es fehlt. Die Sitzungs-ID wird weder
* ausgegeben noch in Fehlermeldungen wiederholt.
*/
async function takeOverSession({ session, account, validUntil, iban }) {
const docs = (id) => `${col(id)}/documents`
const exists = async (collectionId, documentId) => {
try {
await aw(`${docs(collectionId)}/${documentId}`)
return true
} catch (e) {
if (e.status === 404) return false
throw e
}
}
const connection = {
status: 'aktiv',
sessionId: session,
accountUid: account,
validUntil,
// IBAN nur, wenn angegeben (sonst bleibt eine gespeicherte stehen); die Seite zeigt nur die ersten und letzten vier Zeichen
...(iban ? { iban, ibanMasked: `${iban.slice(0, 4)}…${iban.slice(-4)}` } : {}),
// neue Freigabe: Warnstufen und alte Fehler gelten nicht mehr
authState: '',
warnedThresholds: '',
lastError: '',
}
try {
if (await exists('bankConnections', 'finom')) {
await aw(`${docs('bankConnections')}/finom`, { method: 'PATCH', body: { data: connection } })
} else {
await aw(docs('bankConnections'), { method: 'POST', body: { documentId: 'finom', data: connection } })
}
} catch (e) {
throw new Error(`bankConnections/finom: ${e.message.split(session).join('***')}`)
}
console.log(`+ Sitzung uebernommen, gueltig bis ${validUntil}`)
if (await exists('financeSettings', 'main')) {
console.log('= financeSettings/main (existiert)')
} else {
await aw(docs('financeSettings'), {
method: 'POST',
body: { documentId: 'main', data: { taxReservePercent: 30, minBufferCents: 0, estPrepaymentCents: 0 } },
})
console.log('+ financeSettings/main')
}
}
if (takeoverData) await takeOverSession(takeoverData)
console.log('Fertig.')
+41 -1
View File
@@ -98,6 +98,30 @@ const newsSetup = {
priceNickname: 'News-Einrichtung 30 €',
}
// Ticketsystem (WOMS); eigene Konstante, weil auch die Enable-Banking-Rueckkehradresse darauf aufbaut
const womsBaseUrl = (process.env.WOMS_PUBLIC_URL || 'https://ticket.webklar.com').replace(/\/$/, '')
const ENABLE_BANKING_API_BASE = 'https://api.enablebanking.com'
const enableBankingAppId = (process.env.ENABLE_BANKING_APP_ID || '').trim()
const enableBankingKeyPath = (process.env.ENABLE_BANKING_KEY_PATH || '').trim()
/**
* Basisadresse der Enable-Banking-API. ENABLE_BANKING_API_BASE (nur fuer Tests: Nachbau) gilt nur fuer
* 127.0.0.1 oder localhost; jeder andere Wert wird ignoriert, damit ein verirrter Wert den signierten
* Zugriff (JWT) nie an einen fremden Server schickt.
*/
export function resolveEnableBankingApiBase(value) {
const raw = String(value || '').trim()
if (!raw) return ENABLE_BANKING_API_BASE
try {
const url = new URL(raw)
if (['http:', 'https:'].includes(url.protocol) && ['127.0.0.1', 'localhost'].includes(url.hostname)) return url.origin
} catch {
// keine gueltige Adresse: Standard
}
return ENABLE_BANKING_API_BASE
}
export const config = {
port: Number(process.env.PORT) || 3000,
sessionSecret: process.env.SESSION_SECRET || '',
@@ -129,11 +153,16 @@ export const config = {
aboOffers: process.env.APPWRITE_COLLECTION_ABO_OFFERS || 'aboOffers',
contractEvents: process.env.APPWRITE_COLLECTION_CONTRACT_EVENTS || 'contractEvents',
newsPosts: process.env.APPWRITE_COLLECTION_NEWS_POSTS || 'newsPosts',
// Finanzen (Bankkonto, Buchungen, Einstellungen, Fixkosten)
bankConnections: process.env.APPWRITE_COLLECTION_BANK_CONNECTIONS || 'bankConnections',
bankTransactions: process.env.APPWRITE_COLLECTION_BANK_TRANSACTIONS || 'bankTransactions',
financeSettings: process.env.APPWRITE_COLLECTION_FINANCE_SETTINGS || 'financeSettings',
financeFixedCosts: process.env.APPWRITE_COLLECTION_FINANCE_FIXED_COSTS || 'financeFixedCosts',
},
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
// Ticketsystem (WOMS), z. B. fuer den Link "Ticket oeffnen" in der Push-Zeitmail
womsBaseUrl: (process.env.WOMS_PUBLIC_URL || 'https://ticket.webklar.com').replace(/\/$/, ''),
womsBaseUrl,
// Push-Zeitmail: signierte Links zum Eintragen der Zeit eines Git-Pushes (ohne Login)
pushTime: {
@@ -172,6 +201,17 @@ export const config = {
'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet (Kleinunternehmerregelung). WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler',
},
// Finom-Geschaeftskonto lesend ueber Enable Banking (PSD2). Der private Schluessel liegt als Datei auf dem
// Server (nie im Repo); der Client liest ihn bei jedem Aufruf, ein Schluesseltausch wirkt ohne Neustart.
enableBanking: {
appId: enableBankingAppId,
keyPath: enableBankingKeyPath,
apiBase: resolveEnableBankingApiBase(process.env.ENABLE_BANKING_API_BASE),
redirectUrl: `${womsBaseUrl}/finanzen/bank-callback`,
aspsp: { name: 'Finom', country: 'DE' },
enabled: Boolean(enableBankingAppId && enableBankingKeyPath),
},
abo: {
// Bei jeder Textaenderung an Bedingungen oder Bestellseite hochziehen (wird mit der Zustimmung gespeichert)
termsVersion: '2026-09-30-v5',
+31
View File
@@ -6,6 +6,8 @@ import { config, assertServerConfig, WOMS_DATABASE_ID } from './config.js'
import { verifyDatabaseAccess } from './services/appwriteAdmin.js'
import { syncPreviewTlsOnStartup } from './services/previewDeploy.js'
import { runReconcile } from './services/previewReconcile.js'
import { checkBankConsent, shouldRunStartupSync, syncBank } from './services/finance/bankSync.js'
import { getConnection } from './services/finance/bankStore.js'
import { requireSession, sessionMiddleware } from './middleware/session.js'
import authRoutes from './routes/auth.js'
import projectsRoutes from './routes/projects.js'
@@ -35,6 +37,7 @@ import newsRoutes from './routes/news.js'
import publicNewsRoutes from './routes/publicNews.js'
import adminAnalyseRoutes from './routes/admin/analyse.js'
import adminSwitchLinkRoutes from './routes/admin/switchLink.js'
import adminFinanzenRoutes from './routes/admin/finanzen.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const publicDir = path.join(__dirname, '..', 'public')
@@ -85,6 +88,7 @@ app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes)
app.use('/api/admin/preview-open', adminPreviewOpenRoutes)
app.use('/api/admin/switch-link', adminSwitchLinkRoutes)
app.use('/api/admin/analyse', adminAnalyseRoutes)
app.use('/api/admin/finanzen', adminFinanzenRoutes)
app.use('/webhook', giteaWebhookRoutes)
// Push-Zeitmail: Zeit eines Git-Pushes per Link aus der Mail eintragen (ohne Login, signierter Token)
app.use('/zeit', express.urlencoded({ extended: false, limit: '4kb' }), pushTimeRoutes)
@@ -186,6 +190,33 @@ const server = app.listen(config.port, '0.0.0.0', () => {
setTimeout(repoAssign, 60 * 1000).unref()
setInterval(repoAssign, 10 * 60 * 1000).unref()
// Finanzen (nur wenn Enable Banking eingerichtet ist): Bankabruf 2 Minuten nach dem Start (nur wenn der letzte
// nicht frisch war) und alle 6 Stunden, Warnung vor dem Ablauf des Bankzugangs 3 Minuten nach dem Start und
// taeglich. syncBank wirft nie, meldet einen Fehlschlag aber im Ergebnis; die Meldung enthaelt keine Sitzungs-
// oder Schluesselangaben.
if (config.enableBanking.enabled) {
const bankSync = () =>
syncBank()
.then((r) => !r.ok && console.warn('[finanzen] Bankabruf fehlgeschlagen:', r.error))
.catch((err) => console.warn('[finanzen] Bankabruf fehlgeschlagen:', err.message))
// Beim Start nur abrufen, wenn der letzte Abruf nicht frisch war: Deployments starten den Server oft neu und
// wuerden sonst das Tageslimit der Bank aufbrauchen
const startupBankSync = () =>
getConnection()
.then((connection) => {
if (shouldRunStartupSync(connection, new Date())) return bankSync()
console.log('[finanzen] Bankabruf beim Start übersprungen (letzter Abruf ist frisch)')
})
.catch((err) => console.warn('[finanzen] Bankabruf fehlgeschlagen:', err.message))
setTimeout(startupBankSync, 2 * 60 * 1000).unref()
setInterval(bankSync, 6 * 60 * 60 * 1000).unref()
const bankConsent = () =>
checkBankConsent().catch((err) => console.warn('[finanzen] Prüfung des Bankzugangs fehlgeschlagen:', err.message))
setTimeout(bankConsent, 3 * 60 * 1000).unref()
setInterval(bankConsent, 24 * 60 * 60 * 1000).unref()
}
// Preview-Runner: Zustaende mit dem Runner abgleichen (alle 2 Minuten, nur wenn eingeschaltet)
if (config.previewRunner.enabled) {
setTimeout(runReconcile, 30 * 1000).unref()
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
T18 · Bankzugang erneuern (Finanzen)
Betreff: [Finanzen] Bankzugang Finom {{ablauf_text}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: das Team (kenso@webklar.com, andrej@webklar.com)
Platzhalter: {{ablauf_text}}, {{gueltig_bis}}, {{link}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): abgelaufen
ablauf_text ist „läuft in N Tagen ab“ (ab 2 Tagen), „läuft morgen ab“, „läuft heute ab“ oder „ist abgelaufen“; gueltig_bis steht als TT.MM.JJJJ; link öffnet in WOMS Finanzen → Einstellungen („Neu verbinden“); der Block abgelaufen erscheint nur, wenn der Zugang schon abgelaufen ist. Die Mail kommt 30, 14 und 3 Tage vor Ablauf und am Ablauftag.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>[Finanzen] Bankzugang Finom {{ablauf_text}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.btn2{background:#2a2a28!important;border-color:#3a3a37!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Bankzugang Finom {{ablauf_text}} – in WOMS erneuern.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Intern · Finanzen</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#fdebd3;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#8a4b00;">Bankzugang</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Der Bankzugang zu Finom {{ablauf_text}}</td></tr>
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Läuft der Zugang ab, werden Kontostand und Umsätze nicht mehr abgerufen. Das Erneuern dauert etwa eine Minute.</td></tr>
<!-- WENN abgelaufen -->
<tr><td class="px" style="padding:20px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:14px 20px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;">Der Zugang ist bereits abgelaufen. Kontostand und Umsätze werden nicht mehr abgerufen, bis er erneuert ist.</td></tr></table></td></tr>
<!-- ENDE abgelaufen -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Gültig bis</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;">{{gueltig_bis}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Erneuern</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;">In WOMS unter Finanzen &rarr; Einstellungen auf „Neu verbinden“ klicken und die Freigabe bei Finom bestätigen.</td></tr>
</table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{link}}" style="display:block;padding:12px 22px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Bankzugang erneuern &rarr;</a></td></tr></table></td></tr>
<tr><td style="padding:0 0 40px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal. Sie kommt 30, 14 und 3 Tage vor Ablauf und am Ablauftag.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+314
View File
@@ -0,0 +1,314 @@
import { Router } from 'express'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { withLocks } from '../../services/keyedLock.js'
import { EnableBankingError } from '../../services/finance/enableBanking.js'
import { completeBankAuth, startBankAuth, syncBank } from '../../services/finance/bankSync.js'
import { getConnection, listTransactions, publicConnection, saveConnection } from '../../services/finance/bankStore.js'
import { AssignError, assignTransaction, unassignTransaction, withSuggestions } from '../../services/finance/assign.js'
import { buildOverview } from '../../services/finance/overview.js'
import {
SettingsError,
createFixedCost,
deleteFixedCost,
getSettings,
listFixedCosts,
patchSettings,
updateFixedCost,
} from '../../services/finance/settings.js'
import {
InvoiceError,
STRIPE_UNAVAILABLE_MESSAGE,
createProjectInvoice,
deleteDraft,
listInvoices,
sendInvoiceNow,
voidInvoice,
} from '../../services/finance/stripeFinance.js'
/**
* Finanzen im WOMS (nur Admins): Übersicht, Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung,
* Rechnungen (lesen, anlegen, senden, stornieren, Entwurf löschen), Einstellungen und Fixkosten. Die Handler sind klein
* und rufen die Dienste in server/services/finance auf.
*/
const router = Router()
router.use(requireAppwriteStaff)
// Der Knopf "Aktualisieren" darf die Bank hoechstens alle 10 Minuten abfragen
const MANUAL_SYNC_COOLDOWN_MS = 10 * 60_000
const MANUAL_SYNC_LOCK = ['finanzen-bank-manual-sync']
// Eine Meldung fuer jeden Fehler auf dem Weg zur Bank (Freigabe starten, Rueckkehr): der Status der Bank
// (z. B. 401) und alles, was Sitzungen verraten koennte, bleibt auf dem Server
const BANK_UNAVAILABLE_MESSAGE = 'Die Bank hat die Freigabe nicht bestätigt. Bitte erneut verbinden.'
/** Dokument ohne Appwrite-Metafelder ($createdAt, $permissions, ...); nur $id bleibt. */
const publicDoc = (doc) => Object.fromEntries(Object.entries(doc).filter(([key]) => key === '$id' || !key.startsWith('$')))
/** Fehlerart fuers Protokoll: nie die Meldung (sie koennte Kennungen der Bank oder von Appwrite enthalten). */
const logFailure = (what, err) =>
console.error(`[finanzen] ${what} fehlgeschlagen:`, err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '')
router.get('/uebersicht', async (_req, res) => {
try {
return res.json(await buildOverview())
} catch (err) {
logFailure('Übersicht laden', err)
return res.status(500).json({ error: 'Die Übersicht konnte nicht geladen werden.' })
}
})
router.get('/bank', async (_req, res) => {
try {
const connection = await getConnection()
return res.json({ verbindung: connection ? publicConnection(connection) : null })
} catch (err) {
logFailure('Bankstatus laden', err)
return res.status(500).json({ error: 'Bankstatus konnte nicht geladen werden.' })
}
})
/**
* Sperre des Knopfs "Aktualisieren": prueft lastManualSyncAt und setzt es gleich, bevor der Abruf beginnt (zwei
* Klicks gleichzeitig starten so nur einen Abruf). Liefert null (frei) oder die Minuten bis zum naechsten Abruf.
* Ohne Bankzugang gibt es nichts zu sperren; der Abruf meldet das dann selbst.
*/
function claimManualSync() {
return withLocks(MANUAL_SYNC_LOCK, async () => {
const connection = await getConnection()
if (!connection) return null
const now = Date.now()
const lastMs = Date.parse(connection.lastManualSyncAt ?? '')
if (!Number.isNaN(lastMs) && now - lastMs < MANUAL_SYNC_COOLDOWN_MS) {
return Math.ceil((MANUAL_SYNC_COOLDOWN_MS - (now - lastMs)) / 60_000)
}
await saveConnection({ lastManualSyncAt: new Date(now).toISOString() })
return null
})
}
router.post('/bank/aktualisieren', async (req, res) => {
try {
const waitMinutes = await claimManualSync()
if (waitMinutes !== null) {
return res.status(429).json({ error: `Bitte in ${waitMinutes} ${waitMinutes === 1 ? 'Minute' : 'Minuten'} erneut versuchen.` })
}
// Ein Mensch loest den Abruf aus: IP und Browser gehen an die Bank (zaehlt nicht auf ihr Tageslimit)
return res.json(await syncBank({ psu: { ip: req.ip, userAgent: req.get('User-Agent') } }))
} catch (err) {
logFailure('Bankabruf starten', err)
return res.status(500).json({ error: 'Der Bankabruf konnte nicht gestartet werden.' })
}
})
router.post('/bank/verbinden', async (_req, res) => {
try {
return res.json(await startBankAuth())
} catch (err) {
logFailure('Freigabe starten', err)
return res.status(502).json({ error: BANK_UNAVAILABLE_MESSAGE })
}
})
router.post('/bank/rueckkehr', async (req, res) => {
const { code, state, error } = req.body || {}
try {
const { validUntil } = await completeBankAuth({ code, state, error }, { psu: { ip: req.ip, userAgent: req.get('User-Agent') } })
return res.json({ ok: true, validUntil })
} catch (err) {
// 400 mit deutscher Meldung kommt aus der Pruefung der Rueckkehr (state, Ablauf, Fehler von Finom); ein 400 der
// Bank selbst (EnableBankingError, z. B. ungueltiger Code) gehoert nicht dazu
if (err?.status === 400 && !(err instanceof EnableBankingError)) return res.status(400).json({ error: err.message })
// Alles andere (auch ein 401 der Bank beim Codetausch) ist fuer die Seite: Freigabe nicht zustande gekommen
logFailure('Rückkehr von der Bank', err)
return res.status(502).json({ error: BANK_UNAVAILABLE_MESSAGE })
}
})
router.get('/buchungen', async (_req, res) => {
try {
// Vorschlaege kommen von Stripe: ein Ausfall dort (stripeFehler) macht die Liste nie kaputt
return res.json(await withSuggestions((await listTransactions()).map(publicDoc)))
} catch (err) {
logFailure('Buchungen laden', err)
return res.status(500).json({ error: 'Buchungen konnten nicht geladen werden.' })
}
})
const STRIPE_REJECTED_MESSAGE = 'Stripe hat die Änderung abgelehnt.'
// Fehler des Stripe-Pakets (StripeInvalidRequestError, StripeConnectionError, ...) tragen type "Stripe..."
const isStripeError = (err) => typeof err?.type === 'string' && err.type.startsWith('Stripe')
/**
* Antwort auf einen Fehler beim Zuordnen und bei den Rechnungen: AssignError und InvoiceError (eigene, anzeigbare
* Meldung) mit ihrem Status, bei einer festgeschriebenen, aber nicht gesendeten Rechnung (502) samt invoiceId und
* invoiceNumber; Stripe lehnt ab (4xx, ausser 429) -> 409; Stripe sonst nicht erreichbar -> 502; alles andere 500.
* Meldungen von Stripe, Appwrite und Netzfehlern (Adressen, Schluessel) gehen nie an die Seite, auch wenn ihre
* Fehler einen status tragen: nur die beiden eigenen Fehlerarten werden weitergegeben.
*/
function sendFinanceError(res, what, err) {
if (err instanceof AssignError || err instanceof InvoiceError) {
// Ein 502 an einer bestehenden Rechnung (nicht gesendet, nicht lesbar) hat seine Ursache bei Stripe: nur Art, Status und Code ins Protokoll
if (err.status === 502 && err.cause) logFailure(what, err.cause)
const body = { error: err.message }
if (err.invoiceId) body.invoiceId = err.invoiceId
if (err.invoiceNumber !== undefined) body.invoiceNumber = err.invoiceNumber
return res.status(err.status).json(body)
}
logFailure(what, err)
if (isStripeError(err)) {
const status = err.statusCode
if (status >= 400 && status < 500 && status !== 429) return res.status(409).json({ error: STRIPE_REJECTED_MESSAGE })
return res.status(502).json({ error: STRIPE_UNAVAILABLE_MESSAGE })
}
return res.status(500).json({ error: 'Interner Fehler.' })
}
router.post('/buchungen/:id/zuordnen', async (req, res) => {
const { invoiceId, payoutId, fixedCostId, category, note } = req.body || {}
try {
const buchung = await assignTransaction(req.params.id, { invoiceId, payoutId, fixedCostId, category, note }, { by: req.appwriteUser?.email })
return res.json({ buchung: publicDoc(buchung) })
} catch (err) {
return sendFinanceError(res, 'Buchung zuordnen', err)
}
})
router.delete('/buchungen/:id/zuordnung', async (req, res) => {
try {
return res.json({ buchung: publicDoc(await unassignTransaction(req.params.id)) })
} catch (err) {
return sendFinanceError(res, 'Zuordnung lösen', err)
}
})
// --- Rechnungen (Stripe) ----------------------------------------------------------------------------------------
const INVOICE_FILTERS = new Set(['alle', 'offen', 'ueberfaellig', 'bezahlt', 'entwurf'])
const DEFAULT_DAYS_UNTIL_DUE = 14
const createdOf = (invoice) => invoice.createdDate ?? ''
router.get('/rechnungen', async (req, res) => {
const status = req.query.status || 'alle'
const customer = req.query.kunde || ''
if (!INVOICE_FILTERS.has(status)) {
return res.status(400).json({ error: 'Unbekannter Filter (erlaubt: alle, offen, ueberfaellig, bezahlt, entwurf).' })
}
if (typeof customer !== 'string') return res.status(400).json({ error: 'Ungültiger Kunde.' })
try {
const invoices = (await listInvoices())
.filter((invoice) => (status === 'alle' || invoice.status === status) && (!customer || invoice.webklarCustomerId === customer))
// neueste zuerst (der Sortieralgorithmus ist stabil: am selben Tag bleibt Stripes Reihenfolge)
.sort((a, b) => (createdOf(a) < createdOf(b) ? 1 : createdOf(a) > createdOf(b) ? -1 : 0))
return res.json({ rechnungen: invoices })
} catch (err) {
return sendFinanceError(res, 'Rechnungen laden', err)
}
})
router.post('/rechnungen', async (req, res) => {
const { customerId, items, daysUntilDue, memo, senden, idempotencyKey } = req.body || {}
try {
const rechnung = await createProjectInvoice({
customerId,
items,
daysUntilDue: daysUntilDue ?? DEFAULT_DAYS_UNTIL_DUE,
memo,
// Nur ein echtes Ja sendet sofort: alles andere bleibt ein Entwurf
senden: senden === true || senden === 'true',
// Optional: ein Schluessel je beabsichtigter Rechnung schuetzt vor doppeltem Anlegen (siehe createProjectInvoice)
idempotencyKey,
createdBy: req.appwriteUser?.email,
})
return res.json({ rechnung })
} catch (err) {
return sendFinanceError(res, 'Rechnung anlegen', err)
}
})
router.post('/rechnungen/:id/senden', async (req, res) => {
try {
return res.json({ rechnung: await sendInvoiceNow(req.params.id) })
} catch (err) {
return sendFinanceError(res, 'Rechnung senden', err)
}
})
router.post('/rechnungen/:id/stornieren', async (req, res) => {
try {
return res.json({ rechnung: await voidInvoice(req.params.id) })
} catch (err) {
return sendFinanceError(res, 'Rechnung stornieren', err)
}
})
router.delete('/rechnungen/:id', async (req, res) => {
try {
await deleteDraft(req.params.id)
return res.json({ ok: true })
} catch (err) {
return sendFinanceError(res, 'Entwurf löschen', err)
}
})
/**
* Antwort auf einen Fehler bei Einstellungen und Fixkosten: SettingsError (400 ungueltige Eingabe, 404 unbekannte
* Position, mit anzeigbarer Meldung); alles andere (Appwrite) wird protokolliert und ist ein 500 ohne dessen Meldung.
*/
function sendSettingsError(res, what, err) {
if (err instanceof SettingsError) return res.status(err.status).json({ error: err.message })
logFailure(what, err)
return res.status(500).json({ error: 'Interner Fehler.' })
}
router.get('/einstellungen', async (_req, res) => {
try {
return res.json({ einstellungen: await getSettings() })
} catch (err) {
return sendSettingsError(res, 'Einstellungen laden', err)
}
})
router.patch('/einstellungen', async (req, res) => {
try {
return res.json({ einstellungen: await patchSettings(req.body) })
} catch (err) {
return sendSettingsError(res, 'Einstellungen speichern', err)
}
})
router.get('/fixkosten', async (_req, res) => {
try {
return res.json({ fixkosten: (await listFixedCosts()).map(publicDoc) })
} catch (err) {
return sendSettingsError(res, 'Fixkosten laden', err)
}
})
router.post('/fixkosten', async (req, res) => {
try {
return res.status(201).json({ position: publicDoc(await createFixedCost(req.body)) })
} catch (err) {
return sendSettingsError(res, 'Fixkosten anlegen', err)
}
})
router.patch('/fixkosten/:id', async (req, res) => {
try {
return res.json({ position: publicDoc(await updateFixedCost(req.params.id, req.body)) })
} catch (err) {
return sendSettingsError(res, 'Fixkosten ändern', err)
}
})
router.delete('/fixkosten/:id', async (req, res) => {
try {
await deleteFixedCost(req.params.id)
return res.json({ ok: true })
} catch (err) {
return sendSettingsError(res, 'Fixkosten löschen', err)
}
})
export default router
+247
View File
@@ -0,0 +1,247 @@
import { withLocks } from '../keyedLock.js'
import { getStripe } from '../stripeBilling.js'
import { getTransaction, listTransactions, updateTransaction } from './bankStore.js'
import { euroDe } from './format.js'
import { suggestMatches } from './matching.js'
import { getFixedCost } from './settings.js'
import { invalidateFinanceCache, listInvoices, listPayouts } from './stripeFinance.js'
/**
* Bankbuchungen zuordnen: eine Gutschrift zu einer offenen Stripe-Rechnung (die Rechnung wird in Stripe als
* bezahlt markiert, "paid_out_of_band", und das ist nicht umkehrbar) oder zu einer Stripe-Auszahlung, eine
* Belastung zu einer Fixkosten-Position, oder eine Buchung nur mit Kategorie (Rechnung ausserhalb von Stripe,
* privat, sonstiges). Zuordnungen laufen je Buchung nacheinander (keyedLock), damit zwei Klicks nie zweimal bei
* Stripe bezahlen. Rechnung und Auszahlung nur zu gebuchten Eintraegen (nicht zu Vormerkungen). Bei einer Rechnung
* steht erst der Vermerk (Buchungs-ID in den Metadaten) und dann das Bezahlen, damit eine Wiederholung nach einem
* Fehler an Appwrite die Zuordnung ohne zweites Bezahlen abschliesst. Die Meldungen sind fuer die Oberflaeche
* bestimmt (AssignError mit status).
*/
/** Fehler mit HTTP-Status und einer Meldung, die die Seite anzeigen darf (nur diese gibt die Route weiter). */
export class AssignError extends Error {
constructor(status, message) {
super(message)
this.name = 'AssignError'
this.status = status
}
}
// Kategorien, die ohne Rechnung/Auszahlung/Fixkosten gesetzt werden duerfen; rechnung, stripe-auszahlung und
// fixkosten entstehen nur ueber ihr Ziel
const PLAIN_CATEGORIES = new Set(['rechnung-extern', 'privat', 'sonstiges'])
const MAX_NOTE = 500
const MAX_ID = 64
const STRIPE_INVOICE_ID = /^in_\w+$/
// Dokument-IDs wie in Appwrite (hoechstens 36 Zeichen); alles andere kann es nicht geben
const DOCUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
const UNASSIGNED = { category: '', matchedInvoiceId: '', matchedInvoiceNumber: '', matchedPayoutId: '', fixedCostId: '' }
const PENDING_MESSAGE = 'Die Buchung ist noch vorgemerkt. Bitte zuordnen, wenn sie gebucht ist.'
export const STRIPE_SUGGESTIONS_ERROR = 'Stripe ist gerade nicht erreichbar. Zuordnungsvorschläge fehlen.'
const isIncoming = (tx) => Number.isSafeInteger(tx.amountCents) && tx.amountCents > 0
const isOutgoing = (tx) => Number.isSafeInteger(tx.amountCents) && tx.amountCents < 0
const present = (value) => value !== undefined && value !== null && value !== ''
/** Eingabe pruefen (ohne I/O): genau ein Ziel, bekannte Kategorie, Notiz. Liefert { kind, value, note }. */
function parseInput(input) {
const body = input && typeof input === 'object' ? input : {}
const targets = [
['invoice', body.invoiceId],
['payout', body.payoutId],
['fixedCost', body.fixedCostId],
['category', body.category],
].filter(([, value]) => present(value))
if (targets.length !== 1) {
throw new AssignError(400, 'Bitte genau ein Ziel angeben: Rechnung, Auszahlung, Fixkosten oder Kategorie.')
}
const [kind, value] = targets[0]
if (typeof value !== 'string' || value.length > MAX_ID) throw new AssignError(400, 'Das Ziel der Zuordnung ist ungültig.')
if (kind === 'category' && !PLAIN_CATEGORIES.has(value)) {
throw new AssignError(400, 'Unbekannte Kategorie (erlaubt: Rechnung außerhalb von Stripe, privat, sonstiges).')
}
let note
if (present(body.note) || body.note === '') {
if (typeof body.note !== 'string') throw new AssignError(400, 'Die Notiz muss ein Text sein.')
note = body.note.trim()
if (note.length > MAX_NOTE) throw new AssignError(400, `Die Notiz darf höchstens ${MAX_NOTE} Zeichen lang sein.`)
}
return { kind, value, note }
}
function documentId(id) {
if (typeof id !== 'string' || !DOCUMENT_ID.test(id)) throw new AssignError(404, 'Buchung nicht gefunden.')
return id
}
async function loadTransaction(id) {
const tx = await getTransaction(id)
if (!tx) throw new AssignError(404, 'Buchung nicht gefunden.')
return tx
}
// Gesperrt wird die Buchung; bei einer Rechnung oder Auszahlung auch diese, damit dieselbe Rechnung von zwei
// Buchungen gleichzeitig nur einmal bezahlt und dieselbe Auszahlung nur einmal vergeben wird
function lockKeys(txId, { kind, value }) {
const keys = [`finanzen-buchung:${txId}`]
if (kind === 'invoice') keys.push(`finanzen-rechnung:${value}`)
if (kind === 'payout') keys.push(`finanzen-auszahlung:${value}`)
return keys
}
/** Stripe-Aufruf; "gibt es nicht" (404, resource_missing) wird zu einem AssignError, alles andere fliegt weiter. */
async function stripeOrNotFound(call, message) {
try {
return await call()
} catch (err) {
if (err?.statusCode === 404 || err?.code === 'resource_missing') throw new AssignError(404, message)
throw err
}
}
/** Rechnungen und Auszahlungen nur zu gebuchten Eintraegen: eine Vormerkung kann bis zur Buchung wieder verschwinden. */
function requireBooked(tx) {
if (tx.status !== 'BOOK') throw new AssignError(409, PENDING_MESSAGE)
}
async function resolveInvoice(tx, invoiceId) {
if (!isIncoming(tx)) throw new AssignError(400, 'Nur Eingänge lassen sich einer Rechnung zuordnen.')
requireBooked(tx)
if (!STRIPE_INVOICE_ID.test(invoiceId)) throw new AssignError(404, 'Rechnung nicht gefunden.')
const stripe = getStripe()
try {
const invoice = await stripeOrNotFound(() => stripe.invoices.retrieve(invoiceId), 'Rechnung nicht gefunden.')
// Der Vermerk steht vor dem Bezahlen an der Rechnung: er verraet einer Wiederholung, dass die Rechnung zu dieser
// Buchung gehoert (Stripe liefert paid_out_of_band nicht an der Rechnung zurueck)
const linked = invoice.metadata?.bankTransactionId === tx.$id
// Schon bezahlt und mit dieser Buchung gekennzeichnet (und in genau dieser Hoehe bezahlt): ein frueherer Versuch
// scheiterte erst danach (Appwrite), es bleibt nur, die Buchung zu speichern
const resumed = invoice.status === 'paid' && linked && invoice.amount_paid === tx.amountCents
if (!resumed) {
if (invoice.status !== 'open') throw new AssignError(409, 'Diese Rechnung ist nicht mehr offen.')
if (invoice.amount_remaining !== tx.amountCents) {
throw new AssignError(409, `Betrag weicht vom offenen Betrag ab (${euroDe(invoice.amount_remaining)}). Teilzahlungen bitte direkt in Stripe erfassen.`)
}
// Gehoert die Buchung schon zu einer anderen, bezahlten Rechnung (deren Speichern in Appwrite scheiterte), wuerde sie
// sonst ein zweites Mal bezahlt verbucht. Frisch von Stripe, nicht aus dem Zwischenspeicher.
const claimed = (await listInvoices({ force: true })).some((other) => other.id !== invoice.id && other.bankTransactionId === tx.$id && other.status === 'bezahlt')
if (claimed) throw new AssignError(409, 'Diese Buchung ist bereits einer anderen Rechnung zugeordnet.')
// Erst der Vermerk (umkehrbar), dann das Bezahlen (nicht umkehrbar): scheitert der Vermerk, bleibt die Rechnung offen
if (!linked) {
await stripe.invoices.update(invoice.id, { metadata: { bankTransactionId: tx.$id, bankBookingDate: tx.bookingDate || '' } })
}
await stripe.invoices.pay(invoice.id, { paid_out_of_band: true })
}
return { category: 'rechnung', matchedInvoiceId: invoice.id, matchedInvoiceNumber: invoice.number || '' }
} finally {
invalidateFinanceCache()
}
}
async function resolvePayout(tx, payoutId) {
if (!isIncoming(tx)) throw new AssignError(400, 'Nur Eingänge lassen sich einer Auszahlung zuordnen.')
requireBooked(tx)
// Frisch von Stripe (nicht aus dem 60-s-Zwischenspeicher): die Zuordnung darf nicht auf einem alten Stand beruhen
const payout = (await listPayouts({ force: true })).find((item) => item.id === payoutId)
if (!payout) throw new AssignError(404, 'Auszahlung nicht gefunden.')
if (payout.status === 'failed' || payout.status === 'canceled') {
throw new AssignError(409, 'Diese Auszahlung ist fehlgeschlagen oder storniert.')
}
if (payout.amountCents !== tx.amountCents) {
throw new AssignError(409, `Betrag weicht von der Auszahlung ab (${euroDe(payout.amountCents)}).`)
}
const taken = (await listTransactions()).some((other) => other.$id !== tx.$id && other.matchedPayoutId === payoutId)
if (taken) throw new AssignError(409, 'Diese Auszahlung ist schon einer Buchung zugeordnet.')
return { category: 'stripe-auszahlung', matchedPayoutId: payout.id }
}
async function resolveFixedCost(tx, fixedCostId) {
if (!isOutgoing(tx)) throw new AssignError(400, 'Fixkosten lassen sich nur Ausgängen zuordnen.')
// getFixedCost kennt keine unmoeglichen IDs und liefert null, wenn es die Position nicht gibt
if (!(await getFixedCost(fixedCostId))) throw new AssignError(400, 'Diese Fixkosten-Position gibt es nicht.')
return { category: 'fixkosten', fixedCostId }
}
function resolveCategory(tx, category) {
if (category === 'rechnung-extern' && !isIncoming(tx)) {
throw new AssignError(400, 'Eine Rechnung außerhalb von Stripe passt nur zu einem Eingang.')
}
return { category }
}
/**
* Buchung zuordnen. Genau eines von invoiceId, payoutId, fixedCostId oder category (rechnung-extern, privat,
* sonstiges); note ist optional (hoechstens 500 Zeichen). Liefert die gespeicherte Buchung. Fehler (AssignError):
* 400 bei falscher Eingabe oder falscher Richtung, 404 bei unbekannter Buchung, Rechnung oder Auszahlung, 409 wenn
* die Buchung schon zugeordnet ist, die Rechnung nicht (mehr) offen ist, der Betrag nicht passt oder die Buchung schon
* zu einer anderen bezahlten Rechnung gehoert; 502, wenn die Rechnung in Stripe bezahlt wurde, die Buchung aber nicht
* gespeichert werden konnte (erneut zuordnen schliesst ab). Stripe-Fehler fliegen unveraendert weiter. by = Mail des
* Nutzers fuer die Protokollzeile bei einer Rechnung.
*/
export async function assignTransaction(txId, input = {}, { by = '' } = {}) {
const request = parseInput(input)
const id = documentId(txId)
return withLocks(lockKeys(id, request), async () => {
const tx = await loadTransaction(id)
// Vor jedem Stripe-Aufruf: eine zugeordnete Buchung wird nie ein zweites Mal bearbeitet
if (tx.category) throw new AssignError(409, 'Bereits zugeordnet.')
let fields
if (request.kind === 'invoice') fields = await resolveInvoice(tx, request.value)
else if (request.kind === 'payout') fields = await resolvePayout(tx, request.value)
else if (request.kind === 'fixedCost') fields = await resolveFixedCost(tx, request.value)
else fields = resolveCategory(tx, request.value)
let saved
try {
saved = await updateTransaction(id, request.note === undefined ? fields : { ...fields, note: request.note })
} catch (cause) {
// Bei einer Rechnung ist sie in Stripe schon bezahlt (nicht umkehrbar): die Wiederholung schliesst die Zuordnung ab
if (request.kind !== 'invoice') throw cause
const err = new AssignError(502, 'In Stripe als bezahlt markiert, aber die Buchung wurde nicht gespeichert. Bitte erneut zuordnen.')
err.cause = cause
throw err
}
// Nicht umkehrbar (Stripe hat die Rechnung bezahlt gebucht): wer, welche Buchung, welche Rechnung; ohne Betraege
if (request.kind === 'invoice') {
console.log(`[finanzen] Zuordnung ${id} -> Rechnung ${fields.matchedInvoiceNumber || fields.matchedInvoiceId} durch ${by || 'unbekannt'}`)
}
return saved
})
}
/**
* Zuordnung loesen: Kategorie und Verweise werden leer (die Notiz bleibt). Eine Rechnung laesst sich nicht
* loesen (409): Stripe hat sie als bezahlt gebucht. Eine nicht zugeordnete Buchung bleibt, wie sie ist.
*/
export async function unassignTransaction(txId) {
const id = documentId(txId)
return withLocks([`finanzen-buchung:${id}`], async () => {
const tx = await loadTransaction(id)
if (tx.category === 'rechnung') {
throw new AssignError(409, 'Die Rechnung ist in Stripe schon als bezahlt markiert und lässt sich nicht zurücksetzen.')
}
if (!tx.category) return tx
return updateTransaction(id, UNASSIGNED)
})
}
/**
* Vorschlaege an die Buchungen haengen: { buchungen: [{ ...buchung, vorschlaege }], stripeFehler? }. Nur
* Eingaenge ohne Kategorie bekommen Vorschlaege; gibt es keinen, wird Stripe gar nicht gefragt. Scheitert Stripe,
* bleiben alle Vorschlaege leer und stripeFehler nennt den Grund (die Liste selbst scheitert deshalb nie).
*/
export async function withSuggestions(transactions) {
const open = transactions.some((tx) => tx.amountCents > 0 && !tx.category)
const plain = () => transactions.map((tx) => ({ ...tx, vorschlaege: [] }))
if (!open) return { buchungen: plain() }
let stripeData
try {
const [invoices, payouts] = await Promise.all([listInvoices(), listPayouts()])
stripeData = { invoices, payouts }
} catch (err) {
console.error('[finanzen] Zuordnungsvorschläge laden fehlgeschlagen:', err?.name || 'Error', err?.statusCode ?? '', err?.code ?? '')
return { buchungen: plain(), stripeFehler: STRIPE_SUGGESTIONS_ERROR }
}
return { buchungen: transactions.map((tx) => ({ ...tx, vorschlaege: suggestMatches(tx, stripeData) })) }
}
+141
View File
@@ -0,0 +1,141 @@
import { config } from '../../config.js'
import { createDocument, deleteDocument, getDocument, listDocuments, Query, updateDocument } from '../appwriteAdmin.js'
/**
* Speicher fuer den Bankzugang (ein Dokument "finom" in bankConnections) und die Buchungen
* (bankTransactions). Die Dokument-ID einer Buchung ist der Hash ihrer Bank-Referenz (siehe bankSync),
* dieselbe Buchung landet also bei jedem Abruf im selben Dokument.
*/
/** ID des einen Verbindungsdokuments. */
export const CONNECTION_ID = 'finom'
// Felder, die von der Bank kommen und bei jedem Abruf ueberschrieben werden duerfen. Alles andere an einer
// Buchung (category, matched*, fixedCostId, note) gehoert dem Nutzer und bleibt beim Abruf unberuehrt.
const BANK_FIELDS = [
'status',
'bookingDate',
'valueDate',
'amountCents',
'currency',
'counterpartyName',
'counterpartyIban',
'remittance',
'mcc',
'entryReference',
'accountUid',
'syncedAt',
]
// Zuordnungsfelder einer neuen Buchung: leer (Spec: "category leer" = noch nicht zugeordnet)
const USER_FIELD_DEFAULTS = {
category: '',
matchedInvoiceId: '',
matchedInvoiceNumber: '',
matchedPayoutId: '',
fixedCostId: '',
note: '',
}
const withoutMeta = (doc) => Object.fromEntries(Object.entries(doc).filter(([key, value]) => !key.startsWith('$') && value !== undefined))
const pick = (doc, keys) => Object.fromEntries(keys.filter((key) => doc[key] !== undefined).map((key) => [key, doc[key]]))
const isStatus = (err, status) => err?.status === status
/**
* Dokument aktualisieren; gibt es das Dokument noch nicht (404), wird es mit createData angelegt. Meldet
* zwischen beiden Schritten ein anderer Aufruf dasselbe Dokument an (409), gilt wieder das Aktualisieren.
* Liefert 'neu' oder 'aktualisiert' (und das Dokument).
*/
async function upsert(collectionId, id, patch, createData) {
try {
return { how: 'aktualisiert', doc: await updateDocument(collectionId, id, patch) }
} catch (err) {
if (!isStatus(err, 404)) throw err
}
try {
return { how: 'neu', doc: await createDocument(collectionId, createData, id) }
} catch (err) {
if (!isStatus(err, 409)) throw err
}
return { how: 'aktualisiert', doc: await updateDocument(collectionId, id, patch) }
}
/** Was die Seite vom Bankzugang sieht (GET /bank und die Statuszeile der Übersicht): nie Sitzung, IBAN, state oder Konto-Kennungen. */
export function publicConnection(connection) {
const pick = (key) => connection[key] ?? null
return {
status: pick('status'),
ibanMasked: pick('ibanMasked'),
validUntil: pick('validUntil'),
lastSyncAt: pick('lastSyncAt'),
lastSyncOk: pick('lastSyncOk'),
lastError: pick('lastError'),
balanceBookedCents: pick('balanceBookedCents'),
balanceAvailableCents: pick('balanceAvailableCents'),
balanceDate: pick('balanceDate'),
latestBookingDate: pick('latestBookingDate'),
}
}
/** Der Bankzugang oder null, wenn es noch keinen gibt. */
export async function getConnection() {
try {
return await getDocument(config.collections.bankConnections, CONNECTION_ID)
} catch (err) {
if (isStatus(err, 404)) return null
throw err
}
}
/** Bankzugang anlegen oder die genannten Felder aktualisieren (die anderen bleiben). Liefert das Dokument. */
export async function saveConnection(patch) {
const data = withoutMeta(patch)
return (await upsert(config.collections.bankConnections, CONNECTION_ID, data, data)).doc
}
/**
* Buchung speichern. doc traegt die Dokument-ID in $id (so liefert sie toTransactionDoc). Neu: alle
* Bankfelder plus leere Zuordnungsfelder; vorhanden: nur die Bankfelder. 'neu' | 'aktualisiert'.
*/
export async function upsertTransaction(doc) {
const id = doc.$id
if (!id) throw new Error('upsertTransaction: $id fehlt')
const fields = pick(doc, BANK_FIELDS)
return (await upsert(config.collections.bankTransactions, id, fields, { ...USER_FIELD_DEFAULTS, ...fields })).how
}
/** Gespeicherte Buchungen, neueste zuerst nach Buchungsdatum (hoechstens limit). */
export async function listTransactions({ limit = 1000 } = {}) {
return listDocuments(config.collections.bankTransactions, [Query.orderDesc('bookingDate'), Query.limit(limit)])
}
/**
* Alle vorgemerkten Buchungen (Status PDNG). Die Suche nutzt den Index idx_status; fehlt er in Appwrite noch
* (Setup-Skript nicht erneut gelaufen) oder scheitert die Suche, wird in den gespeicherten Buchungen gefiltert,
* damit die Aufraeumung der Vormerkungen den Abruf nie bricht.
*/
export async function listPendingTransactions() {
try {
return await listDocuments(config.collections.bankTransactions, [Query.equal('status', 'PDNG'), Query.limit(1000)])
} catch {
return (await listTransactions()).filter((doc) => doc.status === 'PDNG')
}
}
/** Eine Buchung oder null. */
export async function getTransaction(id) {
try {
return await getDocument(config.collections.bankTransactions, id)
} catch (err) {
if (isStatus(err, 404)) return null
throw err
}
}
/** Felder einer Buchung aendern (z. B. Zuordnung, Notiz). */
export async function updateTransaction(id, patch) {
return updateDocument(config.collections.bankTransactions, id, withoutMeta(patch))
}
export async function deleteTransaction(id) {
return deleteDocument(config.collections.bankTransactions, id)
}
+380
View File
@@ -0,0 +1,380 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import { config } from '../../config.js'
import { withLocks } from '../keyedLock.js'
import { sendAdminNotice } from '../mailer.js'
import { createSession, EnableBankingError, getBalances, getTransactions, startAuth, toCents } from './enableBanking.js'
import { addDays, berlinDate, daysBetween, formatDe } from './dates.js'
import { deleteTransaction, getConnection, listPendingTransactions, saveConnection, upsertTransaction } from './bankStore.js'
/**
* Bankabruf: holt Salden und Umsaetze des Finom-Kontos ueber Enable Banking und speichert die Buchungen
* idempotent in Appwrite (bankStore). Dieselbe Buchung kommt bei jedem Abruf wieder und landet im selben
* Dokument; Zuordnung und Notiz des Nutzers bleiben dabei unberuehrt. Vorgemerkte Buchungen (PDNG), die die
* Bank nicht mehr nennt, werden geloescht. syncBank wirft nie: jeder Fehler landet in der Verbindung (lastError).
*
* Ausserdem der Weg zum neuen Bankzugang (startBankAuth, completeBankAuth): Freigabe bei Finom starten, nach der
* Rueckkehr die Sitzung speichern und sofort einmal abrufen, und die Warnung vor dem Ablauf des Zugangs
* (checkBankConsent, Mail T18).
*/
// Die Bank liefert hoechstens ~90 Tage: ein Abruf beginnt spaetestens heute - 89 Tage, sonst eine Woche vor der
// juengsten bekannten Buchung (Vormerkungen und nachgebuchte Eintraege der letzten Tage werden so erneut gelesen)
const MAX_HISTORY_DAYS = 89
const OVERLAP_DAYS = 7
const EXPIRED_MESSAGE = 'Bankzugang abgelaufen oder widerrufen'
const NO_CONNECTION_MESSAGE = 'Kein aktiver Bankzugang'
// Laenge von lastError im Schema
const ERROR_MAX = 500
// Abrufe nacheinander (Job und Knopf "Aktualisieren" duerfen sich nicht ueberholen)
const SYNC_LOCK = ['finanzen-bank-sync']
// Feldgroessen wie im Schema (scripts/setup-finanzen.mjs): zu lange Werte werden gekuerzt statt abgelehnt,
// sonst blockiert eine einzige Buchung jeden weiteren Abruf
const MAX = { accountUid: 64, status: 8, name: 255, iban: 40, mcc: 8, reference: 128, remittance: 1000 }
const text = (value, max) => (typeof value === 'string' || typeof value === 'number' ? String(value).trim().slice(0, max) : '')
const day = (value) => (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : '')
/** Betrag mit Vorzeichen in Cent: Eingang (CRDT) positiv, Ausgang (DBIT) negativ. Unbekannte Richtung wird nicht geraten. */
function signedCents(raw) {
const cents = Math.abs(toCents(raw.transaction_amount?.amount))
if (raw.credit_debit_indicator === 'CRDT') return cents
if (raw.credit_debit_indicator === 'DBIT') return cents === 0 ? 0 : -cents
throw new Error('Ungültige Buchung von der Bank (Richtung unbekannt).')
}
/** Die von der Bank stammenden Felder einer Buchung (ohne ID, Konto und Abrufzeit). */
function bankFields(raw) {
const incoming = raw.credit_debit_indicator === 'CRDT'
const party = (incoming ? raw.debtor : raw.creditor) || {}
const account = (incoming ? raw.debtor_account : raw.creditor_account) || {}
const remittance = [].concat(raw.remittance_information ?? []).map((line) => text(line, MAX.remittance)).filter(Boolean)
return {
status: text(raw.status, MAX.status),
// Vormerkungen haben oft kein Buchungsdatum: dann das Wertstellungs- bzw. Buchungsdatum der Bank
bookingDate: day(raw.booking_date) || day(raw.value_date) || day(raw.transaction_date),
valueDate: day(raw.value_date),
amountCents: signedCents(raw),
currency: text(raw.transaction_amount?.currency, 3),
counterpartyName: text(party.name, MAX.name),
counterpartyIban: text(account.iban ?? account.other?.identification, MAX.iban),
remittance: remittance.join(' ').slice(0, MAX.remittance),
mcc: text(raw.merchant_category_code, MAX.mcc),
entryReference: text(raw.entry_reference, MAX.reference),
}
}
/**
* Dokument-ID einer Buchung: die ersten 36 Zeichen (Appwrite-Grenze) des SHA-256 aus entry_reference, sonst
* transaction_id, sonst Datum|Betrag|Gegenpartei|Verwendungszweck. Gleiche Buchung -> gleiche ID.
*/
export function transactionDocId(raw) {
let key = raw.entry_reference || raw.transaction_id
if (!key) {
const fields = bankFields(raw)
key = [fields.bookingDate, fields.amountCents, fields.counterpartyName, fields.remittance].join('|')
}
return createHash('sha256').update(String(key)).digest('hex').slice(0, 36)
}
/** Rohbuchung von Enable Banking als Dokument fuer bankTransactions ($id = transactionDocId, Betrag in Cent). */
export function toTransactionDoc(raw, { accountUid, syncedAt }) {
return { $id: transactionDocId(raw), accountUid: text(accountUid, MAX.accountUid), ...bankFields(raw), syncedAt }
}
/** Gebuchter Saldo (ITBD, sonst CLBD) und verfuegbarer Saldo (ITAV) als Felder der Verbindung. */
function balanceFields(balances) {
const booked = balances.find((b) => b.type === 'ITBD') ?? balances.find((b) => b.type === 'CLBD')
const available = balances.find((b) => b.type === 'ITAV')
const fields = {}
if (booked) {
fields.balanceBookedCents = booked.amountCents
fields.balanceDate = day(booked.date)
}
if (available) fields.balanceAvailableCents = available.amountCents
return fields
}
const errorText = (err) => String(err?.message || 'Unbekannter Fehler beim Bankabruf.').slice(0, ERROR_MAX)
async function runSync({ now, psu }) {
const result = { ok: false, neu: 0, aktualisiert: 0, geloescht: 0 }
let connection
try {
connection = await getConnection()
} catch (err) {
return { ...result, error: errorText(err) }
}
const accountUid = typeof connection?.accountUid === 'string' ? connection.accountUid.trim() : ''
if (!connection || connection.status === 'abgelaufen' || !accountUid) return { ...result, error: NO_CONNECTION_MESSAGE }
try {
const earliest = addDays(berlinDate(now), -MAX_HISTORY_DAYS)
const latest = /^\d{4}-\d{2}-\d{2}$/.test(connection.latestBookingDate || '') ? connection.latestBookingDate : null
const overlapStart = latest ? addDays(latest, -OVERLAP_DAYS) : earliest
const dateFrom = overlapStart > earliest ? overlapStart : earliest
const balances = await getBalances(accountUid, { psu })
const rawTransactions = await getTransactions(accountUid, { dateFrom, psu })
// Erst alles umrechnen, dann schreiben. Eine unlesbare Buchung der Bank (kaputter Betrag, unbekannte Richtung)
// wird uebersprungen und gezaehlt, sie blockiert nicht jeden weiteren Abruf
const syncedAt = now.toISOString()
const docs = new Map()
let skipped = 0
for (const raw of rawTransactions) {
try {
const doc = toTransactionDoc(raw, { accountUid, syncedAt })
docs.set(doc.$id, doc)
} catch {
skipped += 1
}
}
let latestBookingDate = latest || ''
for (const doc of docs.values()) {
if ((await upsertTransaction(doc)) === 'neu') result.neu += 1
else result.aktualisiert += 1
if (doc.status === 'BOOK' && doc.bookingDate > latestBookingDate) latestBookingDate = doc.bookingDate
}
// Eine Vormerkung, die die Bank im Abrufzeitraum nicht mehr nennt, ist gebucht (dann unter neuer Referenz)
// oder storniert: loeschen. Vormerkungen vor dem Zeitraum kann der Abruf nicht beurteilen, ohne Datum schon.
for (const pending of await listPendingTransactions()) {
if (docs.has(pending.$id) || (pending.bookingDate && pending.bookingDate < dateFrom)) continue
await deleteTransaction(pending.$id)
result.geloescht += 1
}
await saveConnection({
...balanceFields(balances),
latestBookingDate,
lastSyncAt: syncedAt,
lastSyncOk: true,
lastError: skipped ? `${skipped} Buchung(en) übersprungen (unlesbar).` : '',
})
return { ...result, ok: true }
} catch (err) {
// 401/403: Sitzung abgelaufen oder widerrufen -> keine weiteren Abrufe bis zur Neuverbindung
const expired = err instanceof EnableBankingError && (err.status === 401 || err.status === 403)
const error = expired ? EXPIRED_MESSAGE : errorText(err)
try {
const saved = await saveConnection({ ...(expired ? { status: 'abgelaufen' } : {}), lastSyncOk: false, lastError: error })
if (expired) await notifyRevoked(saved)
} catch {
// Appwrite selbst gestoert: der Fehler geht trotzdem an den Aufrufer
}
return { ...result, error }
}
}
/**
* Ablauf oder Widerruf per Mail (T18) melden, hoechstens einmal je Freigabe: Stufe 0 steht danach in
* warnedThresholds, dann mailt auch checkBankConsent nicht mehr. War Stufe 0 schon gemeldet (am Ablauftag), bleibt es
* bei dieser Mail. Kommt die Mail nicht raus, bleibt die Stufe offen (checkBankConsent versucht es spaeter).
*/
async function notifyRevoked(connection) {
const warned = parseWarned(connection?.warnedThresholds)
if (warned.includes(0)) return
const validUntilMs = Date.parse(connection?.validUntil ?? '')
const result = await sendAdminNotice({
template: 'T18',
values: {
ablauf_text: 'ist abgelaufen oder wurde widerrufen',
gueltig_bis: Number.isNaN(validUntilMs) ? 'unbekannt' : formatDe(berlinDate(new Date(validUntilMs))),
link: `${config.womsBaseUrl}/finance/einstellungen`,
},
blocks: { abgelaufen: true },
})
if (result.sent) await saveConnection({ warnedThresholds: [...new Set([...warned, 0])].sort((a, b) => b - a).join(',') })
}
// Ein Abruf beim Start des Servers wird uebersprungen, wenn der letzte erfolgreiche weniger als 5,5 Stunden her ist:
// jedes Deployment startet den Server neu, und die Bank erlaubt nur etwa vier Abrufe ohne Nutzer pro Tag
const STARTUP_SYNC_MIN_AGE_MS = 5.5 * 3_600_000
/** Soll der Abruf nach dem Start des Servers laufen? Nein, wenn der letzte Abruf gelang und noch frisch ist. */
export function shouldRunStartupSync(connection, now = new Date()) {
if (connection?.lastSyncOk !== true) return true
const lastMs = Date.parse(connection.lastSyncAt ?? '')
if (Number.isNaN(lastMs)) return true
return now.getTime() - lastMs >= STARTUP_SYNC_MIN_AGE_MS
}
/**
* Salden und Buchungen abrufen und speichern. psu = { ip, userAgent } nur, wenn ein Mensch den Abruf ausgeloest
* hat (zaehlt dann nicht auf das Tageslimit der Bank). Liefert { ok, neu, aktualisiert, geloescht, error? } und
* wirft nie. Gleichzeitige Aufrufe laufen nacheinander.
*/
export function syncBank({ now = new Date(), psu = null } = {}) {
return withLocks(SYNC_LOCK, () => runSync({ now, psu }))
}
// ---------- Bankzugang neu verbinden ----------
// Freigabe beim Start fuer 179 Tage (die Bank erlaubt hoechstens 180); die Rueckkehr muss binnen 30 Minuten kommen
const ACCESS_DAYS = 179
const DAY_MS = 86_400_000
const AUTH_MAX_AGE_MS = 30 * 60_000
// So viel vom error der Rueckkehradresse kommt in die Meldung (der Wert stammt aus der Adresse)
const AUTH_ERROR_SHOWN_MAX = 80
const badRequest = (message) => Object.assign(new Error(message), { status: 400 })
const requestedValidUntil = (startedAtMs) => new Date(startedAtMs + ACCESS_DAYS * DAY_MS).toISOString()
const compactIban = (iban) => String(iban || '').replace(/\s+/g, '').toUpperCase()
const queryText = (value) => (typeof value === 'string' ? value.trim() : '')
function sameText(a, b) {
const left = Buffer.from(a)
const right = Buffer.from(b)
return left.length === right.length && timingSafeEqual(left, right)
}
/** Erste vier und letzte vier Zeichen der IBAN, dazwischen "…" (zu kurze Werte werden nicht gezeigt). */
function maskIban(iban) {
const compact = compactIban(iban)
return compact.length > 8 ? `${compact.slice(0, 4)}…${compact.slice(-4)}` : ''
}
/**
* Das Konto der neuen Freigabe: dasselbe identificationHash wie gespeichert, sonst dieselbe IBAN, sonst das erste
* Euro-Konto. known = es ist das bisherige Konto. null, wenn die Freigabe kein passendes Konto enthaelt.
*/
function pickAccount(accounts, connection) {
const usable = accounts.filter((account) => typeof account?.uid === 'string' && account.uid)
const hash = text(connection?.accountIdHash, 128)
const iban = compactIban(connection?.iban)
const known = (hash && usable.find((account) => account.identificationHash === hash)) || (iban && usable.find((account) => compactIban(account.iban) === iban))
if (known) return { account: known, known: true }
const euro = usable.find((account) => account.currency === 'EUR')
return euro ? { account: euro, known: false } : null
}
/**
* Freigabe bei Finom starten. Liefert die Adresse, zu der der Nutzer geschickt wird; state und Startzeit stehen
* danach in der Verbindung (authState, authStartedAt) und gelten fuer die Rueckkehr (completeBankAuth).
*/
export async function startBankAuth({ now = new Date() } = {}) {
const state = randomBytes(12).toString('hex')
const { url } = await startAuth({ state, validUntil: requestedValidUntil(now.getTime()) })
await saveConnection({ authState: state, authStartedAt: now.toISOString() })
return { url }
}
/** Prueft die Rueckkehr und speichert die neue Sitzung. Laeuft unter der Sperre der Abrufe. */
async function saveReturnedSession({ code, state, error, now }) {
const connection = await getConnection()
const expected = text(connection?.authState, 64)
if (!state || !expected || !sameText(state, expected)) throw badRequest('Diese Rückkehr passt zu keiner gestarteten Freigabe.')
const startedAt = Date.parse(connection.authStartedAt)
// NaN (keine lesbare Startzeit) besteht den Vergleich nicht: dann gilt die Freigabe als abgelaufen
if (!(now.getTime() - startedAt <= AUTH_MAX_AGE_MS)) throw badRequest('Die Freigabe ist abgelaufen. Bitte neu starten.')
if (error) throw badRequest(`Finom hat die Freigabe nicht erteilt (${error.slice(0, AUTH_ERROR_SHOWN_MAX)}).`)
if (!code) throw badRequest('Die Rückkehr enthält keinen Freigabecode.')
const session = await createSession(code)
const picked = pickAccount(session.accounts, connection)
if (!picked) throw badRequest('Kein Euro-Konto in der Freigabe.')
const { account, known } = picked
const grantedUntil = Date.parse(session.validUntil ?? '')
const validUntil = Number.isNaN(grantedUntil) ? requestedValidUntil(startedAt) : new Date(grantedUntil).toISOString()
const iban = text(account.iban, 40)
await saveConnection({
sessionId: text(session.sessionId, 64),
accountUid: text(account.uid, 64),
accountIdHash: text(account.identificationHash, 128),
iban,
ibanMasked: maskIban(iban),
validUntil,
status: 'aktiv',
authState: '',
authStartedAt: '',
warnedThresholds: '',
lastError: '',
// Ein anderes Konto hat andere Buchungen: der naechste Abruf liest wieder den ganzen Zeitraum
...(known ? {} : { latestBookingDate: '' }),
})
return { validUntil }
}
/**
* Rueckkehr von Finom (code, state, error aus der Adresse): bei passender, nicht abgelaufener Freigabe wird die
* Sitzung gespeichert (Status "aktiv", Warnstufen und Fehler geleert) und einmal abgerufen. Die Pruefung und das
* Speichern laufen unter derselben Sperre wie der Abruf: ein gerade laufender Abruf endet zuerst und kann den neuen
* Zugang nicht mehr ueberschreiben. Der Abruf danach laeuft nach Freigabe der Sperre (psu wie bei syncBank).
* Fehler der Rueckkehr sind Error mit status 400 und deutscher Meldung. Liefert { ok: true, validUntil, sync }
* (sync = Ergebnis von syncBank).
*/
export async function completeBankAuth({ code, state, error } = {}, { now = new Date(), psu = null } = {}) {
const failure = typeof error === 'string' ? error.trim() : error ? 'unbekannt' : ''
const { validUntil } = await withLocks(SYNC_LOCK, () => saveReturnedSession({ code: queryText(code), state: queryText(state), error: failure, now }))
const sync = await syncBank({ now, psu })
return { ok: true, validUntil, sync }
}
// ---------- Warnung vor Ablauf des Bankzugangs ----------
// Warnstufen in Tagen bis zum Ablauf; 0 = am Ablauftag (und danach, falls die Mail zuvor nicht ankam)
const WARN_THRESHOLDS = [30, 14, 3, 0]
const parseWarned = (value) =>
String(value || '')
.split(',')
.map((part) => Number.parseInt(part, 10))
.filter((n) => Number.isInteger(n))
/** Der Satzteil im Betreff: "läuft in 14 Tagen ab", "läuft morgen ab", "läuft heute ab", "ist abgelaufen". */
function ablaufText({ expired, tage }) {
if (expired) return 'ist abgelaufen'
if (tage <= 0) return 'läuft heute ab'
if (tage === 1) return 'läuft morgen ab'
return `läuft in ${tage} Tagen ab`
}
/**
* Warnt das Team per Mail (T18), wenn der Bankzugang bald ablaeuft oder abgelaufen ist. tage = Berliner
* Kalendertage von heute bis zum Ablauftag (0 am Ablauftag, solange der Zugang noch gilt; abgelaufen, sobald
* validUntil vorbei ist). Warnstufen 30, 14, 3 und 0: erreicht ist eine Stufe, wenn tage <= Stufe; gemailt wird
* die kleinste erreichte, falls sie noch nicht in warnedThresholds steht. Gespeichert werden danach alle
* erreichten Stufen (sonst kaeme nach "14" am naechsten Tag noch "30"). Die Stufen stehen in der Verbindung
* und werden bei einer Neuverbindung geleert (completeBankAuth). Kommt die Mail nicht raus, bleibt die Stufe
* offen und der naechste Lauf versucht es erneut. Liefert { sent: Stufe } oder { sent: null }; wirft nur, wenn
* Appwrite nicht erreichbar ist (der Job faengt das ab).
*/
export async function checkBankConsent({ now = new Date() } = {}) {
const connection = await getConnection()
const validUntilMs = Date.parse(connection?.validUntil ?? '')
if (Number.isNaN(validUntilMs)) return { sent: null }
const ablaufTag = berlinDate(new Date(validUntilMs))
const tage = daysBetween(berlinDate(now), ablaufTag)
const expired = validUntilMs <= now.getTime()
const reached = WARN_THRESHOLDS.filter((threshold) => tage <= threshold)
if (!reached.length) return { sent: null }
const threshold = Math.min(...reached)
if (parseWarned(connection.warnedThresholds).includes(threshold)) return { sent: null }
const result = await sendAdminNotice({
template: 'T18',
values: {
ablauf_text: ablaufText({ expired, tage }),
gueltig_bis: formatDe(ablaufTag),
link: `${config.womsBaseUrl}/finance/einstellungen`,
},
blocks: { abgelaufen: expired },
})
if (!result.sent) return { sent: null }
// Speichern unter der Sperre der Abrufe: eine Neuverbindung, die waehrend des Versands ihre Warnstufen leert
// (completeBankAuth), wird nicht mit den Stufen der alten Frist ueberschrieben
await withLocks(SYNC_LOCK, async () => {
const current = await getConnection()
if (!current || current.validUntil !== connection.validUntil) return
const warned = new Set([...parseWarned(current.warnedThresholds), ...reached])
await saveConnection({ warnedThresholds: [...warned].sort((a, b) => b - a).join(',') })
})
return { sent: threshold }
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Kalendertage fuer die Finanzen als Zeichenkette YYYY-MM-DD ("ymd"). "Heute" ist immer der Berliner Tag
* (Europe/Berlin, MESZ/MEZ automatisch). Gerechnet wird mit UTC-Mitternacht des jeweiligen Tages, damit
* die Zeitumstellung (23- und 25-Stunden-Tage) keinen Tag verschluckt oder verdoppelt.
*/
const DAY_MS = 86_400_000
// Formatierer einmal anlegen (neu anlegen ist teuer); sv-SE liefert YYYY-MM-DD
const berlinDayFormat = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit' })
/** Kalendertag in Berlin (YYYY-MM-DD) zu einem Zeitpunkt. */
export function berlinDate(date = new Date()) {
return berlinDayFormat.format(date)
}
/** UTC-Mitternacht (Millisekunden) eines ymd; wirft bei Formfehlern und Tagen, die es nicht gibt (30.02.). */
function dayMs(ymd) {
const match = typeof ymd === 'string' ? ymd.match(/^(\d{4})-(\d{2})-(\d{2})$/) : null
if (match) {
const [year, month, day] = match.slice(1).map(Number)
const ms = Date.UTC(year, month - 1, day)
const check = new Date(ms)
if (check.getUTCFullYear() === year && check.getUTCMonth() === month - 1 && check.getUTCDate() === day) return ms
}
throw new Error(`Ungültiges Datum: ${typeof ymd === 'string' ? ymd : typeof ymd}`)
}
const ymdOf = (ms) => new Date(ms).toISOString().slice(0, 10)
/** ymd plus n ganze Tage (n darf negativ sein). */
export function addDays(ymd, n) {
if (!Number.isInteger(n)) throw new Error('Tage müssen eine ganze Zahl sein.')
return ymdOf(dayMs(ymd) + n * DAY_MS)
}
/** Ganze Tage von fromYmd bis toYmd (toYmd minus fromYmd, negativ wenn toYmd frueher liegt). */
export function daysBetween(fromYmd, toYmd) {
return Math.round((dayMs(toYmd) - dayMs(fromYmd)) / DAY_MS)
}
/** ymd als TT.MM.JJJJ. */
export function formatDe(ymd) {
dayMs(ymd)
const [year, month, day] = ymd.split('-')
return `${day}.${month}.${year}`
}
+203
View File
@@ -0,0 +1,203 @@
import crypto from 'node:crypto'
import { readFileSync } from 'node:fs'
import { config } from '../../config.js'
/**
* Client fuer die Enable-Banking-API (PSD2-Lesezugriff auf das Finom-Geschaeftskonto).
*
* Jeder Aufruf traegt ein frisch signiertes JWT (RS256, kid = App-ID). Der private Schluessel wird bei
* jedem JWT von der Platte gelesen, ein Schluesseltausch wirkt also ohne Neustart. Betraege sind intern
* immer ganze Cent (Ganzzahl). Weder der Schluessel noch Sitzungs- oder Konto-IDs landen in Fehlermeldungen
* oder Logs: Meldungen nennen nur den Vorgang, den HTTP-Status und den Fehlercode der API.
*/
// JWT-Laufzeit (Sekunden) und Zeitlimit je Anfrage (Millisekunden)
const JWT_TTL_SECONDS = 3600
const REQUEST_TIMEOUT_MS = 30_000
// Obergrenze fuer das Blaettern durch Umsatzseiten (Schutz vor einer endlosen continuation_key-Kette)
const MAX_TRANSACTION_PAGES = 20
/** Fehler der Bank-Schnittstelle. status = HTTP-Status (0 = keine Antwort), code = Fehlercode der API oder null. */
export class EnableBankingError extends Error {
constructor(message, { status = 0, code = null } = {}) {
super(message)
this.name = 'EnableBankingError'
this.status = status
this.code = code
}
}
const base64url = (value) => Buffer.from(value).toString('base64url')
/**
* Signiertes JWT fuer die API. now in Millisekunden (nur fuer Tests ueberschreiben).
* Header { typ: 'JWT', alg: 'RS256', kid: App-ID }, Claims iss/aud/iat/exp mit einer Stunde Laufzeit.
*/
export function createJwt({ now = Date.now() } = {}) {
const { appId, keyPath, enabled } = config.enableBanking
if (!enabled) {
throw new EnableBankingError('Die Bankanbindung ist noch nicht eingerichtet (ENABLE_BANKING_APP_ID/ENABLE_BANKING_KEY_PATH fehlen).', {
status: 503,
code: 'NOT_CONFIGURED',
})
}
const iat = Math.floor(now / 1000)
const header = base64url(JSON.stringify({ typ: 'JWT', alg: 'RS256', kid: appId }))
const payload = base64url(JSON.stringify({ iss: 'enablebanking.com', aud: 'api.enablebanking.com', iat, exp: iat + JWT_TTL_SECONDS }))
let signature
try {
// Absichtlich ohne Fehlerdetails weitergeben: Pfad und Schluesselinhalt sollen nirgends auftauchen
const key = crypto.createPrivateKey(readFileSync(keyPath))
signature = crypto.sign('sha256', Buffer.from(`${header}.${payload}`), key).toString('base64url')
} catch {
throw new EnableBankingError('Der Schlüssel für die Bankanbindung konnte nicht gelesen werden.', { status: 500, code: 'KEY_UNREADABLE' })
}
return `${header}.${payload}.${signature}`
}
/**
* Ein API-Aufruf. label beschreibt den Vorgang fuer die Fehlermeldung (ohne IDs). psu = { ip, userAgent }
* wird als Psu-Ip-Address / Psu-User-Agent mitgeschickt, wenn der Nutzer den Abruf selbst ausgeloest hat.
*/
async function request(method, path, { label, query = null, body = null, psu = null } = {}) {
const url = new URL(`${config.enableBanking.apiBase}${path}`)
for (const [key, value] of Object.entries(query || {})) {
if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, value)
}
const headers = { Authorization: `Bearer ${createJwt()}`, Accept: 'application/json' }
if (body) headers['Content-Type'] = 'application/json'
if (psu?.ip) headers['Psu-Ip-Address'] = psu.ip
if (psu?.userAgent) headers['Psu-User-Agent'] = psu.userAgent
let res
let text
try {
res = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
// Der Bearer-Token geht nie an eine andere Adresse
redirect: 'error',
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
text = await res.text()
} catch (err) {
// Die Originalmeldung nennt die Adresse (mit Sitzungs-/Konto-ID) und bleibt deshalb draussen
throw new EnableBankingError(`Bankanbindung: ${label} nicht erreichbar.`, {
status: 0,
code: err?.name === 'TimeoutError' ? 'TIMEOUT' : 'NETWORK',
})
}
let data = null
try {
data = text ? JSON.parse(text) : null
} catch {
data = null
}
if (!res.ok) {
const code = data && typeof data === 'object' ? (data.code ?? data.error ?? null) : null
throw new EnableBankingError(`Bankanbindung: ${label} fehlgeschlagen (HTTP ${res.status}${code === null ? '' : `, ${code}`}).`, {
status: res.status,
code,
})
}
if (data === null || typeof data !== 'object') {
throw new EnableBankingError(`Bankanbindung: ${label} lieferte eine unlesbare Antwort.`, { status: res.status, code: null })
}
return data
}
/**
* Dezimalbetrag als Zeichenkette ("108.93", "-0.5") in ganze Cent. Ganzzahl- und Nachkommateil werden
* getrennt gelesen, es gibt keine Gleitkommarechnung und keine stille Rundung: mehr als zwei von 0
* verschiedene Nachkommastellen oder alles, was kein Dezimalbetrag ist, wirft einen Fehler.
*/
export function toCents(amount) {
const text = typeof amount === 'number' && Number.isFinite(amount) ? String(amount) : amount
const match = typeof text === 'string' ? text.trim().match(/^([+-]?)(\d+)(?:\.(\d+))?$/) : null
if (!match) throw new Error('Ungültiger Betrag von der Bank.')
const [, sign, whole, fraction = ''] = match
if (/[^0]/.test(fraction.slice(2))) throw new Error('Ungültiger Betrag von der Bank (mehr als zwei Nachkommastellen).')
const cents = Number(whole) * 100 + Number(fraction.slice(0, 2).padEnd(2, '0'))
if (!Number.isSafeInteger(cents)) throw new Error('Ungültiger Betrag von der Bank (zu groß).')
if (cents === 0) return 0
return sign === '-' ? -cents : cents
}
/**
* Freigabe starten (POST /auth): liefert die Bank-Adresse, zu der der Nutzer geschickt wird, und die
* Freigabe-ID. state kommt unveraendert an die Rueckkehradresse zurueck; validUntil (ISO) ist das Ende der Freigabe.
*/
export async function startAuth({ state, validUntil }) {
const { aspsp, redirectUrl } = config.enableBanking
const data = await request('POST', '/auth', {
label: 'Freigabe starten',
body: {
access: { valid_until: validUntil },
aspsp: { name: aspsp.name, country: aspsp.country },
state,
redirect_url: redirectUrl,
psu_type: 'business',
},
})
return { url: data.url, authorizationId: data.authorization_id }
}
/** Aus dem Code der Rueckkehradresse eine Sitzung mit den freigegebenen Konten machen (POST /sessions). */
export async function createSession(code) {
const data = await request('POST', '/sessions', { label: 'Sitzung anlegen', body: { code } })
return {
sessionId: data.session_id,
validUntil: data.access?.valid_until ?? null,
accounts: (data.accounts || []).map((account) => ({
uid: account.uid,
identificationHash: account.identification_hash ?? null,
iban: account.account_id?.iban ?? null,
name: account.name ?? null,
currency: account.currency ?? null,
})),
}
}
/** Zustand einer Sitzung (GET /sessions/:id): status (z. B. AUTHORIZED, EXPIRED), Ende der Freigabe, Konto-IDs. */
export async function getSession(sessionId) {
const data = await request('GET', `/sessions/${encodeURIComponent(sessionId)}`, { label: 'Sitzung abrufen' })
return {
status: data.status,
validUntil: data.access?.valid_until ?? null,
// Die API nennt hier nur die IDs; Konto-Objekte werden trotzdem toleriert
accountUids: (data.accounts || []).map((account) => (typeof account === 'string' ? account : account.uid)),
}
}
/** Salden eines Kontos (GET /accounts/:uid/balances) in ganzen Cent. */
export async function getBalances(accountUid, { psu = null } = {}) {
const data = await request('GET', `/accounts/${encodeURIComponent(accountUid)}/balances`, { label: 'Salden abrufen', psu })
return (data.balances || []).map((balance) => ({
type: balance.balance_type,
amountCents: toCents(balance.balance_amount?.amount),
currency: balance.balance_amount?.currency ?? null,
date: balance.reference_date ?? null,
}))
}
/**
* Umsaetze eines Kontos ab dateFrom (YYYY-MM-DD), alle Seiten (continuation_key), hoechstens 20 Aufrufe.
* Liefert die Rohobjekte von Enable Banking; die Aufbereitung uebernimmt der Aufrufer.
*/
export async function getTransactions(accountUid, { dateFrom = null, psu = null } = {}) {
const all = []
let continuationKey = null
for (let page = 0; page < MAX_TRANSACTION_PAGES; page += 1) {
const data = await request('GET', `/accounts/${encodeURIComponent(accountUid)}/transactions`, {
label: 'Umsätze abrufen',
query: { date_from: dateFrom, continuation_key: continuationKey },
psu,
})
all.push(...(data.transactions || []))
continuationKey = data.continuation_key || null
if (!continuationKey) break
}
return all
}
+197
View File
@@ -0,0 +1,197 @@
import { addDays, berlinDate, daysBetween } from './dates.js'
/**
* Liquiditaetsvorschau (rein, ohne I/O). Zwei Linien ueber denselben Zeitraum (heute bis heute + horizonDays,
* beide Tage eingeschlossen):
* - "sicher": Stripe-Auszahlungen, die schon unterwegs sind (mit Ankunftstag vor heute: heute), Fixkosten und
* ESt-Vorauszahlungen.
* - "erwartet": offene Rechnungen und Abo-Verlaengerungen; sie kommen zu den sicheren Posten dazu.
* Betraege sind ganze Cent mit Vorzeichen (+ Zufluss, - Abfluss), Tage YYYY-MM-DD; "heute" kommt von aussen.
* fixkosten30Cents ist die Summe der Fixkosten im Zeitraum (ohne ESt-Vorauszahlungen).
*/
export const FORECAST_ANNAHME =
'Erwartet: offene Rechnungen zum Fälligkeitstag, überfällige in 14 Tagen, Abo-Verlängerungen 4 Tage nach Abbuchung, vor Stripe-Gebühren.'
const OVERDUE_DELAY_DAYS = 14 // ueberfaellige Rechnungen: Zahlung in 14 Tagen angenommen
const PAYOUT_DELAY_DAYS = 4 // Abo-Abbuchung -> Auszahlung auf das Konto
const INTERVAL_MONTHS = { monat: 1, quartal: 3, jahr: 12 }
const EST_MONTHS = [3, 6, 9, 12] // ESt-Vorauszahlung jeweils am 10.
const EST_DAY = 10
const SICHER = 'sicher'
const ERWARTET = 'erwartet'
const pad = (n) => String(n).padStart(2, '0')
const isCents = (n) => Number.isSafeInteger(n)
/** Ein Wert, der ein echter Kalendertag sein muss, sonst null (kaputte Datensaetze fallen weg, statt alles zu kippen). */
function validYmd(ymd) {
try {
daysBetween(ymd, ymd)
return ymd
} catch {
return null
}
}
/** Zeitstempel (ISO-Text) als Berliner Kalendertag, null bei fehlendem oder unlesbarem Wert. */
function berlinDayOf(iso) {
if (!iso) return null
const date = new Date(iso)
return Number.isNaN(date.getTime()) ? null : berlinDate(date)
}
/**
* ymd plus n Monate. Der Tag bleibt der Tag des Ausgangsdatums, wird aber auf das Monatsende begrenzt:
* 31.10. + 1 = 30.11., + 2 = 31.12. (immer vom Ausgangsdatum aus gerechnet, nie vom bereits begrenzten Termin).
*/
function addMonths(ymd, n) {
const [year, month, day] = ymd.split('-').map(Number)
const total = year * 12 + (month - 1) + n
const newYear = Math.floor(total / 12)
const newMonth = total % 12
const lastDay = new Date(Date.UTC(newYear, newMonth + 1, 0)).getUTCDate()
return `${String(newYear).padStart(4, '0')}-${pad(newMonth + 1)}-${pad(Math.min(day, lastDay))}`
}
/** Monatliche Folge ab startYmd (k = 0, 1, 2, ...); step = Monate zwischen zwei Terminen. */
function* recurring(startYmd, step) {
for (let k = 0; ; k++) yield addMonths(startYmd, k * step)
}
/**
* Buchungstage einer wiederkehrenden Folge im Zeitraum [from, to]; Termine vor from werden vorgespult.
* Die Termine liegen aufsteigend, die Schleife endet mit dem ersten Termin nach `to`.
*/
function occurrencesIn(startYmd, step, from, to) {
const result = []
for (const date of recurring(startYmd, step)) {
if (date > to) break
if (date >= from) result.push(date)
}
return result
}
/**
* @param {object} args
* @param {string} args.today Berliner Tag (YYYY-MM-DD)
* @param {number} [args.horizonDays=30]
* @param {number} args.balanceCents aktueller Kontostand
* @param {Array} [args.payouts] { id, amountCents, arrivalDate, status }
* @param {Array} [args.invoices] { id, number, status, dueDate, remainingCents, customerName }
* @param {Array} [args.subscriptions] Dokumente der Abo-Sammlung (status, priceAmount, currentPeriodEnd, cancelAtPeriodEnd, cancelAt)
* @param {Array} [args.fixedCosts] { $id, name, amountCents, interval, nextDate, active }
* @param {object} [args.settings] { estPrepaymentCents, ... }
*/
export function buildForecast({
today,
horizonDays = 30,
balanceCents,
payouts = [],
invoices = [],
subscriptions = [],
fixedCosts = [],
settings,
}) {
if (!isCents(balanceCents)) throw new Error('balanceCents muss eine ganze Zahl sein.')
if (!Number.isInteger(horizonDays) || horizonDays < 0) throw new Error('horizonDays muss eine ganze Zahl ab 0 sein.')
const end = addDays(today, horizonDays) // wirft bei einem ungueltigen "heute"
const inRange = (ymd) => ymd >= today && ymd <= end
const posten = []
const add = (date, label, amountCents, sicherheit) => posten.push({ date, label, amountCents, sicherheit })
// --- sicher -------------------------------------------------------------------------------------------------
for (const payout of payouts || []) {
if (payout?.status !== 'pending' && payout?.status !== 'in_transit') continue
if (!isCents(payout.amountCents) || payout.amountCents === 0) continue
const arrival = validYmd(payout.arrivalDate)
if (!arrival) continue
// Eine noch nicht angekommene Auszahlung mit Ankunftstag vor heute (Stripe ist verspaetet) kommt frueh, nicht nie: heute
const date = arrival < today ? today : arrival
if (inRange(date)) add(date, 'Stripe-Auszahlung', payout.amountCents, SICHER)
}
// Nur die Fixkosten zaehlen fuer fixkosten30Cents: die ESt-Vorauszahlung steckt schon in der Steuerruecklage
let fixkosten30Cents = 0
for (const cost of fixedCosts || []) {
if (!cost || cost.active === false) continue
const step = INTERVAL_MONTHS[cost.interval]
const start = validYmd(cost.nextDate)
if (!step || !start || !isCents(cost.amountCents) || cost.amountCents <= 0) continue
for (const date of occurrencesIn(start, step, today, end)) {
add(date, cost.name || 'Fixkosten', -cost.amountCents, SICHER)
fixkosten30Cents += cost.amountCents
}
}
const prepayment = settings?.estPrepaymentCents
if (isCents(prepayment) && prepayment > 0) {
const firstYear = Number(today.slice(0, 4))
const lastYear = Number(end.slice(0, 4))
for (let year = firstYear; year <= lastYear; year++) {
for (const month of EST_MONTHS) {
const date = `${String(year).padStart(4, '0')}-${pad(month)}-${pad(EST_DAY)}`
if (inRange(date)) add(date, 'ESt-Vorauszahlung', -prepayment, SICHER)
}
}
}
// --- erwartet -----------------------------------------------------------------------------------------------
for (const invoice of invoices || []) {
if (invoice?.status !== 'offen' && invoice?.status !== 'ueberfaellig') continue
if (!isCents(invoice.remainingCents) || invoice.remainingCents <= 0) continue
const due = validYmd(invoice.dueDate)
// "offen" mit einem Faelligkeitstag vor heute (Daten von gestern) wird wie "ueberfaellig" behandelt
const overdue = invoice.status === 'ueberfaellig' || (due !== null && due < today)
const date = overdue ? addDays(today, OVERDUE_DELAY_DAYS) : due
if (!date || !inRange(date)) continue
const label = `Rechnung ${invoice.number || ''}`.trim() + (invoice.customerName ? ` · ${invoice.customerName}` : '')
add(date, overdue ? `${label} (überfällig, angenommen)` : label, invoice.remainingCents, ERWARTET)
}
for (const subscription of subscriptions || []) {
if (subscription?.status !== 'active') continue
if (!isCents(subscription.priceAmount) || subscription.priceAmount <= 0) continue
const periodEnd = berlinDayOf(subscription.currentPeriodEnd)
if (!periodEnd) continue
// Kuendigung: Ein gesetztes cancelAt gilt allein (es kann mehrere Perioden voraus liegen, z.B. Mindestlaufzeit);
// cancelAtPeriodEnd allein heisst: Stripe bucht zum Periodenende nichts mehr ab, also gar keine Verlaengerung.
const cancelAt = berlinDayOf(subscription.cancelAt)
if (!cancelAt && subscription.cancelAtPeriodEnd) continue
for (const renewal of recurring(periodEnd, 1)) {
const payoutDate = addDays(renewal, PAYOUT_DELAY_DAYS)
if (payoutDate > end) break
if (cancelAt && renewal >= cancelAt) break // nur Verlaengerungen vor dem Kuendigungstag
if (payoutDate >= today) add(payoutDate, 'Abo-Verlängerung', subscription.priceAmount, ERWARTET)
}
}
// --- Reihenfolge: nach Datum, am selben Tag Abfluesse zuerst (stabil, sonst in Eingangsreihenfolge) -----------
posten.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : (a.amountCents < 0 ? 0 : 1) - (b.amountCents < 0 ? 0 : 1)))
// --- Verlauf je Linie: tiefster laufender Saldo inklusive Startwert, bei Gleichstand das fruehere Datum -------
const line = (includes) => {
let balance = balanceCents
let min = { amountCents: balance, date: today }
for (const item of posten) {
if (!includes(item)) continue
balance += item.amountCents
if (balance < min.amountCents) min = { amountCents: balance, date: item.date }
}
return { min, end: balance }
}
const sicher = line((item) => item.sicherheit === SICHER)
const erwartet = line(() => true)
return {
posten,
minSicher: sicher.min,
minErwartet: erwartet.min,
endeSicherCents: sicher.end,
endeErwartetCents: erwartet.end,
fixkosten30Cents,
annahme: FORECAST_ANNAHME,
}
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Anzeigeformate fuer die Finanzen. Betraege sind immer ganze Cent (Ganzzahl), nie Gleitkomma-Euro.
*/
// Einmal anlegen (neu anlegen ist teuer); de-DE liefert "1.234,56 €" mit geschuetztem Leerzeichen
const euroFormat = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' })
/** Cent als deutscher Euro-Betrag, z. B. 123456 -> "1.234,56 €", -449 -> "−4,49 €" (echtes Minuszeichen U+2212). */
export function euroDe(cents) {
if (!Number.isSafeInteger(cents)) throw new Error('Betrag muss eine ganze Zahl in Cent sein.')
const text = euroFormat.format(Math.abs(cents) / 100)
return cents < 0 ? `−${text}` : text
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Zuordnungsvorschlaege fuer Bankeingaenge (rein, ohne I/O): Zu einer Gutschrift auf dem Konto werden
* offene Stripe-Rechnungen oder Stripe-Auszahlungen vorgeschlagen. Die Buchung wird nie automatisch
* zugeordnet; die Vorschlaege sind nur Hilfe fuer die Auswahl in der Oberflaeche.
*
* Regeln in dieser Reihenfolge, je Rechnung/Auszahlung gilt der staerkste Treffer:
* 1. Stripe-Auszahlung (hoch): "stripe" in Gegenpartei oder Verwendungszweck, gleicher Betrag, Buchung
* hoechstens 3 Tage von der Ankunft entfernt.
* 2. Rechnungsnummer (hoch / mittel): Nummer im Verwendungszweck; Betrag = offener Betrag -> hoch, sonst mittel.
* 3. Betrag und Name (mittel): Betrag = offener Betrag und ein Namenswort der Rechnung steht im Zahlernamen.
* 4. Nur Betrag (niedrig): Betrag = offener Betrag genau einer offenen Rechnung.
*/
import { daysBetween } from './dates.js'
const MAX_SUGGESTIONS = 3
const MAX_PAYOUT_DAYS = 3
// Kuerzere Rechnungsnummern ("R1") stecken zufaellig in fast jedem Verwendungszweck
const MIN_NUMBER_LENGTH = 4
const MIN_TOKEN_LENGTH = 4
const OPEN_STATUS = new Set(['offen', 'ueberfaellig'])
const NAME_STOPWORDS = new Set(['gmbh', 'gbr', 'ohg', 'mbh', 'und', 'der', 'die', 'das', 'kg', 'ag', 'ug', 'haftungsbeschränkt'])
const RANK = { hoch: 0, mittel: 1, niedrig: 2 }
/** Grossbuchstaben, ohne Leerraum, Bindestrich, Unterstrich, Punkt und Schraegstrich: "wk-0007" -> "WK0007". */
export function normalizeRef(text) {
return String(text ?? '')
.toUpperCase()
.replace(/[\s\-_./]+/g, '')
}
/** Namenswoerter zum Vergleich: klein, an Nicht-Buchstaben getrennt (Umlaute bleiben), mindestens 4 Zeichen, ohne Rechtsformen. */
export function nameTokens(name) {
return String(name ?? '')
.toLowerCase()
.split(/[^\p{L}]+/u)
.filter((token) => token.length >= MIN_TOKEN_LENGTH && !NAME_STOPWORDS.has(token))
}
/** Abstand in Tagen; bei einem kaputten Datum Infinity, damit nichts zugeordnet wird (statt einen Fehler zu werfen). */
function dayDistance(fromYmd, toYmd) {
try {
return Math.abs(daysBetween(fromYmd, toYmd))
} catch {
return Infinity
}
}
/**
* Bis zu 3 Vorschlaege fuer eine noch nicht zugeordnete Gutschrift, hoch -> niedrig:
* [{ kind: 'invoice' | 'payout', id, number, confidence, reason, amountDiffers }]
*/
export function suggestMatches(tx, { invoices = [], payouts = [] } = {}) {
if (!(tx.amountCents > 0) || tx.category) return []
const suggestions = []
const amount = tx.amountCents
const payerName = String(tx.counterpartyName ?? '').toLowerCase()
const remittance = normalizeRef(tx.remittance)
// Regel 1: Stripe-Auszahlung
if (`${tx.counterpartyName ?? ''} ${tx.remittance ?? ''}`.toLowerCase().includes('stripe')) {
for (const payout of payouts || []) {
if (payout.amountCents === amount && dayDistance(payout.arrivalDate, tx.bookingDate) <= MAX_PAYOUT_DAYS) {
suggestions.push({
kind: 'payout',
id: payout.id,
number: '',
confidence: 'hoch',
reason: 'Stripe-Auszahlung gleicher Betrag',
amountDiffers: false,
})
}
}
}
const open = (invoices || []).filter((invoice) => OPEN_STATUS.has(invoice.status))
const taken = new Set()
const suggestInvoice = (invoice, confidence, reason, amountDiffers) => {
taken.add(invoice.id)
suggestions.push({ kind: 'invoice', id: invoice.id, number: invoice.number || '', confidence, reason, amountDiffers })
}
// Regel 2: Rechnungsnummer im Verwendungszweck
for (const invoice of open) {
const number = normalizeRef(invoice.number)
if (number.length < MIN_NUMBER_LENGTH || !remittance.includes(number)) continue
if (invoice.remainingCents === amount) suggestInvoice(invoice, 'hoch', 'Rechnungsnummer im Verwendungszweck', false)
else suggestInvoice(invoice, 'mittel', 'Rechnungsnummer im Verwendungszweck, Betrag weicht ab', true)
}
// Regel 3: Betrag und Name
for (const invoice of open) {
if (taken.has(invoice.id) || invoice.remainingCents !== amount) continue
if (nameTokens(invoice.customerName).some((token) => payerName.includes(token))) {
suggestInvoice(invoice, 'mittel', 'Betrag und Name passen', false)
}
}
// Regel 4: nur der Betrag, aber nur wenn er zu genau einer offenen Rechnung passt
const sameAmount = open.filter((invoice) => invoice.remainingCents === amount)
if (sameAmount.length === 1 && !taken.has(sameAmount[0].id)) {
suggestInvoice(sameAmount[0], 'niedrig', 'Nur der Betrag passt', false)
}
// Stabil sortiert: innerhalb einer Stufe bleibt die Reihenfolge der Regeln und der Eingabe erhalten
return suggestions.sort((a, b) => RANK[a.confidence] - RANK[b.confidence]).slice(0, MAX_SUGGESTIONS)
}
+272
View File
@@ -0,0 +1,272 @@
import { config } from '../../config.js'
import { getDocument, listDocuments, Query } from '../appwriteAdmin.js'
import { withSuggestions } from './assign.js'
import { getConnection, listTransactions, publicConnection } from './bankStore.js'
import { berlinDate, daysBetween } from './dates.js'
import { buildForecast } from './forecast.js'
import { detectRecurring } from './recurring.js'
import { getSettings, listFixedCosts } from './settings.js'
import { listInvoices, listPayouts, STRIPE_UNAVAILABLE_MESSAGE } from './stripeFinance.js'
import { computeTax } from './tax.js'
import { buildTodos } from './todos.js'
/**
* Alles für die Seite /finance in einer Antwort (GET /uebersicht): Statuszeile, Liquiditätskopf, Aufgaben, Vorschau,
* Steuer, Abos und Altersgruppen der Rechnungen. Die Rechnen-Module (forecast, tax, todos, recurring) sind rein; hier
* werden nur die Daten geladen (Bank und Einstellungen aus Appwrite, Rechnungen und Auszahlungen von Stripe) und
* zusammengesetzt. Scheitert Stripe, rechnet die Übersicht mit leeren Rechnungs- und Auszahlungslisten weiter und
* meldet das in stand.stripe; scheitert das Laden der Abos, meldet das abos.fehler. Andere Appwrite-Fehler gehen als Fehler
* an den Aufrufer.
*/
const SUBSCRIPTION_LIMIT = 500
// Abos in der Liste: laufende (auch in der Testphase) und solche mit Zahlungsproblem; beendete fehlen
const LISTED_STATUSES = new Set(['active', 'trialing', 'past_due', 'unpaid'])
const NAME_BATCH = 10 // Kundennamen: höchstens so viele Anfragen an Appwrite gleichzeitig
const AGE_GROUPS = ['nicht-faellig', '1-30', '31-60', 'ueber-60']
const isCents = (n) => Number.isSafeInteger(n)
const trimmed = (value) => (typeof value === 'string' ? value.trim() : '')
/** Fehlerart fürs Protokoll: nie die Meldung (sie könnte Adressen oder Schlüssel enthalten). */
const logFailure = (what, err) => console.error(`[finanzen] ${what} fehlgeschlagen:`, err?.name || 'Error', err?.statusCode ?? err?.status ?? '', err?.code ?? '')
/** Zeitstempel (ISO-Text) als Berliner Kalendertag, null bei fehlendem oder unlesbarem Wert. */
function berlinDayOf(iso) {
if (!iso) return null
const date = new Date(iso)
return Number.isNaN(date.getTime()) ? null : berlinDate(date)
}
// --- Stripe -------------------------------------------------------------------------------------------------
// So lange wartet die Uebersicht hoechstens auf Stripe (die Anfragen selbst haben eigene Zeitgrenzen, siehe stripeFinance)
const STRIPE_WAIT_MS = 20_000
const failedStatus = () => ({ ok: false, fehler: STRIPE_UNAVAILABLE_MESSAGE, abgerufenUm: null })
/**
* Rechnungen und Auszahlungen; was nicht kommt, ist eine leere Liste und macht ok false. Antwortet Stripe nicht binnen
* waitMs, rechnet die Uebersicht ohne Stripe weiter (wie bei einem Fehler); die Anfragen laufen im Hintergrund aus.
*/
async function loadStripe(now, waitMs) {
let timer
const timedOut = new Promise((resolve) => {
timer = setTimeout(resolve, waitMs, null)
timer.unref?.()
})
const settled = await Promise.race([Promise.allSettled([listInvoices(), listPayouts()]), timedOut])
clearTimeout(timer)
if (!settled) {
console.error(`[finanzen] Stripe laden fehlgeschlagen: keine Antwort nach ${waitMs} ms`)
return { ok: false, invoices: [], payouts: [], status: failedStatus() }
}
const [invoices, payouts] = settled
if (invoices.status === 'rejected') logFailure('Rechnungen laden', invoices.reason)
if (payouts.status === 'rejected') logFailure('Auszahlungen laden', payouts.reason)
const ok = invoices.status === 'fulfilled' && payouts.status === 'fulfilled'
return {
ok,
invoices: invoices.status === 'fulfilled' ? invoices.value : [],
payouts: payouts.status === 'fulfilled' ? payouts.value : [],
// abgerufenUm: wann der Stand geholt wurde; bei einem Fehler gibt es keinen
status: ok ? { ok: true, abgerufenUm: now.toISOString() } : failedStatus(),
}
}
// --- Abos ---------------------------------------------------------------------------------------------------
// Abos aus Appwrite: scheitert das Laden, rechnet die Uebersicht ohne Abos weiter und die Seite zeigt "unbekannt"
const SUBSCRIPTIONS_FAILED = Object.freeze({
monatlichCents: null,
zahlendeKunden: null,
gefaehrdetCents: null,
liste: Object.freeze([]),
fehler: 'Abos konnten nicht geladen werden.',
})
async function loadSubscriptions() {
try {
return { ok: true, docs: await listDocuments(config.collections.subscriptions, [Query.limit(SUBSCRIPTION_LIMIT)]) }
} catch (err) {
logFailure('Abos laden', err)
return { ok: false, docs: [] }
}
}
/** Kundennamen (Firmenname vor Name) zu den Kunden-IDs; ein fehlender oder nicht lesbarer Kunde bekommt den Namen "". */
async function customerNames(ids) {
const names = new Map()
for (let i = 0; i < ids.length; i += NAME_BATCH) {
await Promise.all(
ids.slice(i, i + NAME_BATCH).map(async (id) => {
try {
const customer = await getDocument(config.collections.customers, id)
names.set(id, trimmed(customer.companyName) || trimmed(customer.name))
} catch (err) {
if (err?.status !== 404) logFailure('Kundenname laden', err)
names.set(id, '')
}
})
)
}
return names
}
/**
* Tag der nächsten Abbuchung (Berliner Tag) oder null: Periodenende eines laufenden Abos, solange keine Kündigung
* davor greift (wie in forecast.js: ein gesetztes cancelAt gilt allein; cancelAtPeriodEnd ohne cancelAt heißt, dass
* zum Periodenende nichts mehr abgebucht wird). Bei Zahlungsproblemen steht kein Termin fest.
*/
function nextCharge(subscription) {
if (subscription.status !== 'active' && subscription.status !== 'trialing') return null
const periodEnd = berlinDayOf(subscription.currentPeriodEnd)
if (!periodEnd) return null
const cancelAt = berlinDayOf(subscription.cancelAt)
if (!cancelAt && subscription.cancelAtPeriodEnd) return null
if (cancelAt && periodEnd >= cancelAt) return null
return periodEnd
}
const RISK_ORDER = { past_due: 0, unpaid: 0 }
function buildSubscriptions(subscriptions) {
const amount = (subscription) => (isCents(subscription.priceAmount) ? subscription.priceAmount : 0)
const sumOf = (statuses) => subscriptions.filter((s) => statuses.includes(s.status)).reduce((sum, s) => sum + amount(s), 0)
const payingCustomers = new Set(subscriptions.filter((s) => s.status === 'active' && s.customerId).map((s) => s.customerId))
const liste = subscriptions
.map((s) => ({
customerId: s.customerId ?? '',
kunde: s.customerName,
produkt: s.productKey ?? s.planKey ?? '',
status: s.status,
betragCents: amount(s),
naechsteAbbuchung: nextCharge(s),
}))
// Zahlungsprobleme zuerst, dann nach Kunde (Reihenfolge der Eingabe bei Gleichstand)
.sort((a, b) => (RISK_ORDER[a.status] ?? 1) - (RISK_ORDER[b.status] ?? 1) || a.kunde.localeCompare(b.kunde, 'de', { sensitivity: 'base' }))
return {
monatlichCents: sumOf(['active', 'past_due']),
zahlendeKunden: payingCustomers.size,
gefaehrdetCents: sumOf(['past_due']),
liste,
}
}
// --- Rechnungen ---------------------------------------------------------------------------------------------
/** Tage im Verzug zum Stichtag (0 = nicht fällig); ohne lesbares Fälligkeitsdatum gilt der Wert aus dem Mapping. */
function daysLate(invoice, today) {
try {
return Math.max(0, daysBetween(invoice.dueDate, today))
} catch {
return invoice.status === 'ueberfaellig' && isCents(invoice.overdueDays) ? invoice.overdueDays : 0
}
}
const ageGroupOf = (late) => (late <= 0 ? 'nicht-faellig' : late <= 30 ? '1-30' : late <= 60 ? '31-60' : 'ueber-60')
function buildInvoiceSummary(invoices, today) {
const groups = Object.fromEntries(AGE_GROUPS.map((key) => [key, { key, anzahl: 0, summeCents: 0 }]))
const drafts = { anzahl: 0, summeCents: 0 }
for (const invoice of invoices) {
if (invoice.status === 'entwurf') {
drafts.anzahl += 1
if (isCents(invoice.totalCents)) drafts.summeCents += invoice.totalCents
} else if ((invoice.status === 'offen' || invoice.status === 'ueberfaellig') && isCents(invoice.remainingCents)) {
const group = groups[ageGroupOf(daysLate(invoice, today))]
group.anzahl += 1
group.summeCents += invoice.remainingCents
}
}
return { altersgruppen: AGE_GROUPS.map((key) => groups[key]), entwuerfe: drafts }
}
// --- Übersicht ----------------------------------------------------------------------------------------------
const isOpenIncoming = (tx) => tx.status === 'BOOK' && isCents(tx.amountCents) && tx.amountCents > 0 && !tx.category
/**
* @param {object} [args]
* @param {Date} [args.now] Zeitpunkt; "heute" ist der Berliner Tag dazu
* @param {number} [args.stripeWaitMs] so lange wartet die Uebersicht hoechstens auf Stripe (Voreinstellung 20 s)
* @returns {Promise<{ stand, kopf, aufgaben, vorschau, steuer, abos, rechnungen }>}
*/
export async function buildOverview({ now = new Date(), stripeWaitMs = STRIPE_WAIT_MS } = {}) {
const today = berlinDate(now)
const [storedConnection, transactions, settings, fixedCosts, loadedSubscriptions, stripe] = await Promise.all([
getConnection(),
listTransactions(),
getSettings(),
listFixedCosts(),
loadSubscriptions(),
loadStripe(now, stripeWaitMs),
])
// Ein Dokument ohne Sitzung (erste Freigabe begonnen, nie abgeschlossen) ist kein Bankzugang
const connection = trimmed(storedConnection?.sessionId) ? storedConnection : null
const subscriptions = loadedSubscriptions.docs
// Kundennamen nur für die Abos der Liste
const listed = subscriptions.filter((s) => LISTED_STATUSES.has(s.status))
const names = await customerNames([...new Set(listed.map((s) => s.customerId).filter((id) => typeof id === 'string' && id))])
const named = listed.map((s) => ({ ...s, customerName: names.get(s.customerId) ?? '' }))
// Kontostand (gebucht) laut letztem Abruf; ohne Zugang oder ohne Abruf unbekannt (null)
const balance = connection && isCents(connection.balanceBookedCents) ? connection.balanceBookedCents : null
const minBuffer = isCents(settings.minBufferCents) ? settings.minBufferCents : 0
const forecast = buildForecast({
today,
balanceCents: balance ?? 0,
payouts: stripe.payouts,
invoices: stripe.invoices,
subscriptions,
fixedCosts,
settings,
})
const computedTax = computeTax({ today, transactions, invoices: stripe.invoices, settings })
// Der Kleinunternehmer-Zaehler stuetzt sich auf die Zahltage der Stripe-Rechnungen: ohne Stripe waere er zu niedrig und
// wuerde Sicherheit vortaeuschen (die Seite zeigt "unbekannt"), eine Warnung daraus waere falsch
const tax = stripe.ok ? computedTax : { ...computedTax, kleinunternehmer: { ...computedTax.kleinunternehmer, jahrCents: null, anteilFolgejahr: null } }
// Zuordnungsvorschläge wie bei GET /buchungen, nur für gebuchte, offene Eingänge; ohne vollständige Stripe-Daten keine
const openIncoming = transactions.filter(isOpenIncoming)
const todoTransactions = stripe.ok && openIncoming.length ? (await withSuggestions(openIncoming)).buchungen : openIncoming
const aufgaben = buildTodos({
today,
now,
connection,
invoices: stripe.invoices,
transactions: todoTransactions,
subscriptions: named,
// Was schon zugeordnet ist (privat, Fixkosten), ist kein Vorschlag mehr
recurring: detectRecurring(
transactions.filter((tx) => tx.category !== 'privat' && tx.category !== 'fixkosten'),
{ fixedCosts }
),
tax,
settings,
})
return {
stand: { bank: connection ? publicConnection(connection) : null, stripe: stripe.status },
kopf: {
kontostandCents: balance,
verfuegbarCents: balance === null ? null : balance - tax.ruecklageCents - forecast.fixkosten30Cents - minBuffer,
aufschluesselung: {
kontostandCents: balance,
steuerRuecklageCents: tax.ruecklageCents,
fixkosten30Cents: forecast.fixkosten30Cents,
mindestpufferCents: minBuffer,
},
// ohne Kontostand wäre der tiefste Stand nur die Summe der Posten, nicht der Stand des Kontos
tiefsterSicher: balance === null ? null : forecast.minSicher,
tiefsterErwartet: balance === null ? null : forecast.minErwartet,
},
aufgaben,
vorschau: forecast,
steuer: tax,
abos: loadedSubscriptions.ok ? buildSubscriptions(named) : SUBSCRIPTIONS_FAILED,
rechnungen: buildInvoiceSummary(stripe.invoices, today),
}
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Erkennung wiederkehrender Ausgaben aus den Buchungen (rein, ohne I/O): Abbuchungen derselben Gegenpartei
* in regelmaessigem Abstand und mit nahezu gleichem Betrag, die noch nicht als feste Kosten erfasst sind.
* Die Oberflaeche schlaegt sie dann als neue Fixkostenposition vor.
*/
import { daysBetween } from './dates.js'
const MIN_COUNT = 3
// Abstand (Tage, Median) -> Rhythmus
const INTERVALS = [
{ interval: 'monat', min: 25, max: 35 },
{ interval: 'quartal', min: 80, max: 100 },
{ interval: 'jahr', min: 350, max: 380 },
]
// Variationskoeffizient (Standardabweichung / Mittelwert) der Betraege
const MAX_AMOUNT_VARIATION = 0.15
/** Gegenpartei als Gruppenschluessel: klein, ohne Ziffern und Satzzeichen, Leerraum zusammengefasst. */
function counterpartyKey(name) {
return String(name ?? '')
.toLowerCase()
.replace(/[^\p{L}\s]/gu, '')
.replace(/\s+/g, ' ')
.trim()
}
function median(numbers) {
const sorted = [...numbers].sort((a, b) => a - b)
const mid = sorted.length >> 1
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
/** Variationskoeffizient (Grundgesamtheit); Infinity, wenn der Mittelwert 0 ist. */
function variation(numbers) {
const mean = numbers.reduce((sum, n) => sum + n, 0) / numbers.length
if (!mean) return Infinity
const variance = numbers.reduce((sum, n) => sum + (n - mean) ** 2, 0) / numbers.length
return Math.sqrt(variance) / Math.abs(mean)
}
/** true, wenn das Datum ein echter Kalendertag YYYY-MM-DD ist (daysBetween wirft sonst). */
function validDay(ymd) {
try {
daysBetween(ymd, ymd)
return true
} catch {
return false
}
}
/**
* Wiederkehrende Abbuchungen: [{ key, name, amountCents, interval: 'monat'|'quartal'|'jahr', lastDate, count }],
* groesster Betrag zuerst. name ist die Schreibweise der juengsten Buchung, amountCents der Median als positiver Wert.
*/
export function detectRecurring(transactions, { fixedCosts = [] } = {}) {
const matches = (fixedCosts || [])
.filter((fixedCost) => fixedCost.active)
.map((fixedCost) => String(fixedCost.counterpartyMatch ?? '').trim().toLowerCase())
.filter(Boolean)
const groups = new Map()
for (const tx of transactions || []) {
if (!(tx.amountCents < 0) || tx.status !== 'BOOK' || !validDay(tx.bookingDate)) continue
const key = counterpartyKey(tx.counterpartyName)
if (!key) continue
if (!groups.has(key)) groups.set(key, [])
groups.get(key).push(tx)
}
const result = []
for (const [key, group] of groups) {
if (group.length < MIN_COUNT) continue
group.sort((a, b) => (a.bookingDate < b.bookingDate ? -1 : a.bookingDate > b.bookingDate ? 1 : 0))
const last = group[group.length - 1]
const name = String(last.counterpartyName).trim()
// Schon als feste Kosten erfasst (Treffer gegen den bereinigten Schluessel und den Originalnamen, denn
// ein counterpartyMatch darf Ziffern enthalten)
const lowerName = name.toLowerCase()
if (matches.some((match) => key.includes(match) || lowerName.includes(match))) continue
const gaps = []
for (let i = 1; i < group.length; i++) gaps.push(daysBetween(group[i - 1].bookingDate, group[i].bookingDate))
const gap = median(gaps)
const rhythm = INTERVALS.find(({ min, max }) => gap >= min && gap <= max)
if (!rhythm) continue
const amounts = group.map((tx) => -tx.amountCents)
if (variation(amounts) > MAX_AMOUNT_VARIATION) continue
result.push({
key,
name,
amountCents: Math.round(median(amounts)),
interval: rhythm.interval,
lastDate: last.bookingDate,
count: group.length,
})
}
return result.sort((a, b) => b.amountCents - a.amountCents || (a.key < b.key ? -1 : 1))
}
+234
View File
@@ -0,0 +1,234 @@
import { config } from '../../config.js'
import { createDocument, deleteDocument, getDocument, ID, listDocuments, Query, updateDocument } from '../appwriteAdmin.js'
/**
* Einstellungen (ein Dokument "main" in financeSettings) und Fixkostenliste (financeFixedCosts) der Finanzen.
* Jede Eingabe wird hier geprueft; Fehler sind SettingsError mit status 400 (ungueltig) oder 404 (Position
* unbekannt) und einer deutschen Meldung, die die Seite anzeigen darf. Alles andere (Appwrite) fliegt
* unveraendert weiter und gehoert nicht an die Seite.
*/
/** Fehler mit HTTP-Status und einer Meldung, die die Seite anzeigen darf (nur diese gibt die Route weiter). */
export class SettingsError extends Error {
constructor(status, message) {
super(message)
this.name = 'SettingsError'
this.status = status
}
}
const SETTINGS_ID = 'main'
// Dokument-IDs wie in Appwrite (hoechstens 36 Zeichen, auch eine UUID); alles andere kann es nicht geben
const DOCUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
const MAX_BANK_DETAILS = 500
const MAX_NAME = 120
const MAX_MATCH = 120
const MAX_BUFFER_CENTS = 100_000_000
const MAX_TAX_RESERVE_PERCENT = 60
const MAX_LIST = 1000
const INTERVALS = ['monat', 'quartal', 'jahr']
/** Standardwerte der Einstellungen; previousYearRevenueCents null heisst "nicht gesetzt". */
export const DEFAULT_SETTINGS = Object.freeze({
minBufferCents: 0,
taxReservePercent: 30,
previousYearRevenueCents: null,
estPrepaymentCents: 0,
bankDetailsText: '',
})
const invalid = (field, message) => new SettingsError(400, `${field}: ${message}`)
const isPlainObject = (value) => Object.prototype.toString.call(value) === '[object Object]'
function wholeNumber(field, value, min, max, message) {
if (!Number.isSafeInteger(value) || value < min || value > max) throw invalid(field, message)
return value
}
/** Text ohne Rand-Leerraum mit 0 bis max Zeichen (min 1: nicht leer). */
function text(field, value, { min, max }, message) {
const trimmed = typeof value === 'string' ? value.trim() : null
if (trimmed === null || trimmed.length < min || trimmed.length > max) throw invalid(field, message)
return trimmed
}
function calendarDate(field, value) {
const message = 'Das Datum muss im Format JJJJ-MM-TT stehen und es geben.'
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) throw invalid(field, message)
const date = new Date(`${value}T00:00:00Z`)
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) throw invalid(field, message)
return value
}
// Je Feld eine Pruefung: liefert den bereinigten Wert oder wirft SettingsError 400 mit dem Feldnamen
const SETTINGS_RULES = {
minBufferCents: (v) =>
wholeNumber('minBufferCents', v, 0, MAX_BUFFER_CENTS, 'Der Mindestpuffer muss eine ganze Zahl von 0 bis 100.000.000 Cent sein.'),
taxReservePercent: (v) =>
wholeNumber('taxReservePercent', v, 0, MAX_TAX_RESERVE_PERCENT, `Die Steuer-Rücklage muss eine ganze Zahl von 0 bis ${MAX_TAX_RESERVE_PERCENT} Prozent sein.`),
previousYearRevenueCents: (v) =>
v === null
? null
: wholeNumber('previousYearRevenueCents', v, 0, Number.MAX_SAFE_INTEGER, 'Der Vorjahresumsatz muss eine ganze Zahl ab 0 Cent sein (oder leer).'),
estPrepaymentCents: (v) =>
wholeNumber('estPrepaymentCents', v, 0, Number.MAX_SAFE_INTEGER, 'Die ESt-Vorauszahlung muss eine ganze Zahl ab 0 Cent sein.'),
bankDetailsText: (v) =>
text('bankDetailsText', v, { min: 0, max: MAX_BANK_DETAILS }, `Der Bankverbindungstext muss ein Text mit höchstens ${MAX_BANK_DETAILS} Zeichen sein.`),
}
const FIXED_COST_RULES = {
name: (v) => text('name', v, { min: 1, max: MAX_NAME }, `Der Name braucht 1 bis ${MAX_NAME} Zeichen.`),
amountCents: (v) => wholeNumber('amountCents', v, 1, Number.MAX_SAFE_INTEGER, 'Der Betrag muss eine ganze Zahl über 0 Cent sein.'),
interval: (v) => {
if (!INTERVALS.includes(v)) throw invalid('interval', 'Der Rhythmus muss monat, quartal oder jahr sein.')
return v
},
nextDate: (v) => calendarDate('nextDate', v),
// klein gespeichert: der Abgleich mit der Gegenpartei einer Buchung ignoriert Gross- und Kleinschreibung
counterpartyMatch: (v) =>
text('counterpartyMatch', v, { min: 0, max: MAX_MATCH }, `Der Suchbegriff muss ein Text mit höchstens ${MAX_MATCH} Zeichen sein.`).toLowerCase(),
active: (v) => {
if (typeof v !== 'boolean') throw invalid('active', 'Aktiv muss wahr oder falsch sein.')
return v
},
}
const FIXED_COST_REQUIRED = ['name', 'amountCents', 'interval', 'nextDate']
/**
* Eingabe gegen die Regeln pruefen: ein Objekt, nur bekannte Felder (Metafelder wie $id und Schluessel wie
* __proto__ sind unbekannt), jeder Wert gueltig; fehlende (undefined) Felder bleiben weg. Liefert die bereinigten Felder.
*/
function parseFields(input, rules) {
if (!isPlainObject(input)) throw new SettingsError(400, 'Ungültige Eingabe: ein Objekt mit Feldern wird erwartet.')
const data = {}
for (const key of Object.keys(input)) {
if (!Object.hasOwn(rules, key)) throw new SettingsError(400, `Unbekanntes Feld: ${key.slice(0, 40)}.`)
if (input[key] !== undefined) data[key] = rules[key](input[key])
}
return data
}
const isMissing = (err) => err?.status === 404
// ---------------------------------------------------------------------------
// Einstellungen
// ---------------------------------------------------------------------------
/** Dokument mit den Standardwerten zusammenfuehren (fehlende und null-Felder fallen auf sie zurueck); ohne Metafelder. */
function mergeSettings(doc) {
const merged = {}
for (const [key, fallback] of Object.entries(DEFAULT_SETTINGS)) {
const value = doc?.[key]
merged[key] = value === undefined || value === null ? fallback : value
}
return merged
}
/** Die Einstellungen; gibt es das Dokument noch nicht, nur die Standardwerte (es wird nichts angelegt). */
export async function getSettings() {
try {
return mergeSettings(await getDocument(config.collections.financeSettings, SETTINGS_ID))
} catch (err) {
if (isMissing(err)) return mergeSettings(null)
throw err
}
}
// Aktualisieren; fehlt das Dokument (404), mit den Standardwerten und der Aenderung anlegen. Legt zwischen beiden
// Schritten ein anderer Aufruf dasselbe Dokument an (409), gilt wieder das Aktualisieren.
async function saveSettings(data) {
const collection = config.collections.financeSettings
try {
return await updateDocument(collection, SETTINGS_ID, data)
} catch (err) {
if (!isMissing(err)) throw err
}
try {
return await createDocument(collection, { ...DEFAULT_SETTINGS, ...data }, SETTINGS_ID)
} catch (err) {
if (err?.status !== 409) throw err
}
return updateDocument(collection, SETTINGS_ID, data)
}
/**
* Einstellungen aendern; patch nennt nur die zu aendernden Felder: minBufferCents 0 bis 100.000.000,
* taxReservePercent 0 bis 60, previousYearRevenueCents >= 0 oder null (null loescht den Wert), estPrepaymentCents >= 0,
* bankDetailsText hoechstens 500 Zeichen (ohne Rand-Leerraum gespeichert). Ein unbekanntes oder ungueltiges Feld:
* SettingsError 400 mit dem Feldnamen, es wird nichts gespeichert. Legt das Dokument an, falls es fehlt; ein leerer
* patch aendert und legt nichts an. Liefert die Einstellungen danach.
*/
export async function patchSettings(patch) {
const data = parseFields(patch, SETTINGS_RULES)
if (Object.keys(data).length === 0) return getSettings()
return mergeSettings(await saveSettings(data))
}
// ---------------------------------------------------------------------------
// Fixkosten
// ---------------------------------------------------------------------------
const notFound = () => new SettingsError(404, 'Fixkosten-Position nicht gefunden.')
function fixedCostId(id) {
if (typeof id !== 'string' || !DOCUMENT_ID.test(id)) throw notFound()
return id
}
/** Alle Fixkosten-Positionen, nach Name sortiert. */
export async function listFixedCosts() {
const docs = await listDocuments(config.collections.financeFixedCosts, [Query.limit(MAX_LIST)])
return docs.sort((a, b) => String(a.name).localeCompare(String(b.name), 'de', { sensitivity: 'base' }) || a.$id.localeCompare(b.$id))
}
/** Eine Position oder null (auch bei einer ID, die es nie geben kann). */
export async function getFixedCost(id) {
if (typeof id !== 'string' || !DOCUMENT_ID.test(id)) return null
try {
return await getDocument(config.collections.financeFixedCosts, id)
} catch (err) {
if (isMissing(err)) return null
throw err
}
}
/**
* Position anlegen: name 1 bis 120 Zeichen, amountCents ganze Zahl > 0, interval monat | quartal | jahr, nextDate
* JJJJ-MM-TT (ein echtes Datum), counterpartyMatch hoechstens 120 Zeichen (leer als Standard, klein gespeichert),
* active (Standard true). Ungueltig oder unbekanntes Feld: SettingsError 400 mit dem Feldnamen. Liefert die Position.
*/
export async function createFixedCost(input) {
const given = parseFields(input, FIXED_COST_RULES)
for (const key of FIXED_COST_REQUIRED) {
if (given[key] === undefined) throw invalid(key, 'Pflichtfeld.')
}
return createDocument(config.collections.financeFixedCosts, { counterpartyMatch: '', active: true, ...given }, ID.unique())
}
/** Felder einer Position aendern (dieselben Regeln, jedes Feld einzeln); unbekannte Position: SettingsError 404. */
export async function updateFixedCost(id, patch) {
const docId = fixedCostId(id)
const data = parseFields(patch, FIXED_COST_RULES)
if (Object.keys(data).length === 0) {
const current = await getFixedCost(docId)
if (!current) throw notFound()
return current
}
try {
return await updateDocument(config.collections.financeFixedCosts, docId, data)
} catch (err) {
if (isMissing(err)) throw notFound()
throw err
}
}
/** Position loeschen; unbekannte Position: SettingsError 404. Gebuchte Zuordnungen behalten die ID. */
export async function deleteFixedCost(id) {
const docId = fixedCostId(id)
try {
await deleteDocument(config.collections.financeFixedCosts, docId)
} catch (err) {
if (isMissing(err)) throw notFound()
throw err
}
}
+493
View File
@@ -0,0 +1,493 @@
import { createHash } from 'node:crypto'
import { config } from '../../config.js'
import { getStripe, getOrCreateStripeCustomer, stripeCustomerFields, subscriptionIdOfInvoice } from '../stripeBilling.js'
import { getDocument } from '../appwriteAdmin.js'
import { addDays, berlinDate, daysBetween } from './dates.js'
import { getSettings } from './settings.js'
/**
* Stripe-Rechnungen und -Auszahlungen fuer die Finanzen. Rechnungen und Auszahlungen werden nicht
* gespiegelt: sie kommen bei jeder Anfrage von Stripe, hoechstens alle 60 Sekunden (Zwischenspeicher je Art).
* Die Rohdaten liegen im Zwischenspeicher, gemappt wird bei jedem Aufruf neu: so stimmt "ueberfaellig" auch,
* wenn um Mitternacht ein neuer Tag beginnt, und ein Aufrufer kann den Zwischenspeicher nicht veraendern.
* Projektrechnungen werden hier auch geschrieben (anlegen, senden, stornieren, Entwurf loeschen, siehe unten);
* jede Schreibfunktion leert danach den Zwischenspeicher.
*/
const CACHE_MS = 60_000
// Rueckblick: Rechnungen der letzten 400 Tage (reicht fuer ein volles Jahr samt Nachzuegler)
const LOOKBACK_DAYS = 400
const MAX_INVOICES = 1000
// Zeitgrenze der Leseanfragen an Stripe: das SDK wartet sonst je Anfrage bis zu 80 Sekunden (und wiederholt), bei bis
// zu zehn Seiten haengt die Seite minutenlang. Die Grenze gilt je Anfrage, auch fuer die Folgeseiten.
const DEFAULT_READ_OPTIONS = { timeout: 15_000, maxNetworkRetries: 1 }
let readOptions = DEFAULT_READ_OPTIONS
/** Nur fuer Tests: andere Optionen fuer die Leseanfragen (null stellt die Voreinstellung wieder her). */
export function setStripeReadOptionsForTests(options) {
readOptions = options ?? DEFAULT_READ_OPTIONS
}
/** Meldung an die Seite, wenn Stripe nicht antwortet (Übersicht, Rechnungsliste, Schreibzugriffe). */
export const STRIPE_UNAVAILABLE_MESSAGE = 'Stripe ist gerade nicht erreichbar.'
// Stripe-Status -> Status nach aussen; "open" wird je nach Faelligkeit zu "offen" oder "ueberfaellig"
const STATUS_DE = { draft: 'entwurf', open: 'offen', paid: 'bezahlt', void: 'storniert', uncollectible: 'uneinbringlich' }
// Je Art ein Eintrag { at, promise }; die Zusage (statt der Daten) wird gemerkt, damit gleichzeitige Aufrufe
// dieselbe Stripe-Anfrage teilen. Ein Fehler wird nicht zwischengespeichert.
const caches = { invoices: null, payouts: null }
function cached(kind, force, load) {
const entry = caches[kind]
if (!force && entry && Date.now() - entry.at < CACHE_MS) return entry.promise
const fresh = { at: Date.now(), promise: null }
fresh.promise = load().catch((err) => {
if (caches[kind] === fresh) caches[kind] = null
throw err
})
caches[kind] = fresh
return fresh.promise
}
/** Zwischenspeicher leeren (nach jeder Aenderung an Rechnungen oder Auszahlungen, damit die Ansicht stimmt). */
export function invalidateFinanceCache() {
caches.invoices = null
caches.payouts = null
}
// Stripe liefert Zeitpunkte als Unix-Sekunden; die Finanzen rechnen mit Berliner Kalendertagen
const ymdOfUnix = (seconds) => (Number.isFinite(seconds) ? berlinDate(new Date(seconds * 1000)) : null)
/** Eine rohe Stripe-Rechnung (Kunde moeglichst expandiert) in die deutsche Form der Finanzen; today = YYYY-MM-DD. */
export function mapInvoice(raw, { today }) {
const customer = raw.customer && typeof raw.customer === 'object' ? raw.customer : null
const metadata = raw.metadata || {}
const dueDate = ymdOfUnix(raw.due_date)
let status = STATUS_DE[raw.status] || raw.status
if (raw.status === 'open' && dueDate && dueDate < today) status = 'ueberfaellig'
return {
id: raw.id,
number: raw.number || '',
status,
customerName: raw.customer_name || customer?.name || '',
customerEmail: raw.customer_email || customer?.email || '',
webklarCustomerId: metadata.webklarCustomerId || customer?.metadata?.webklarCustomerId || '',
createdDate: ymdOfUnix(raw.created),
dueDate,
totalCents: raw.total ?? 0,
remainingCents: raw.amount_remaining ?? 0,
paidCents: raw.amount_paid ?? 0,
// Bei einer Bankzuordnung zaehlt der Tag, an dem das Geld auf dem Konto ankam, nicht der der Stripe-Buchung
paidDate: metadata.bankBookingDate || ymdOfUnix(raw.status_transitions?.paid_at),
overdueDays: status === 'ueberfaellig' ? daysBetween(dueDate, today) : 0,
source: subscriptionIdOfInvoice(raw) ? 'abo' : 'projekt',
hostedUrl: raw.hosted_invoice_url || null,
pdfUrl: raw.invoice_pdf || null,
outOfBand: Boolean(metadata.bankTransactionId),
bankTransactionId: metadata.bankTransactionId || '',
}
}
/** Rechnungen der letzten 400 Tage (hoechstens 1000, neueste zuerst), mit Kunde, auf Deutsch gemappt. */
export async function listInvoices({ force = false } = {}) {
const raw = await cached('invoices', force, async () => {
const invoices = await getStripe()
.invoices.list(
{
limit: 100,
created: { gte: Math.floor(Date.now() / 1000) - LOOKBACK_DAYS * 86_400 },
expand: ['data.customer'],
},
readOptions
)
.autoPagingToArray({ limit: MAX_INVOICES })
// Bei der Obergrenze fehlen aeltere Rechnungen: Zaehler und Altersgruppen waeren zu niedrig, ohne dass es auffaellt
if (invoices.length >= MAX_INVOICES) console.warn(`[finanzen] Rechnungsliste bei ${MAX_INVOICES} abgeschnitten`)
return invoices
})
const today = berlinDate()
return raw.map((invoice) => mapInvoice(invoice, { today }))
}
/** Die letzten 50 Stripe-Auszahlungen: { id, amountCents, arrivalDate, status }. */
export async function listPayouts({ force = false } = {}) {
const raw = await cached('payouts', force, () => getStripe().payouts.list({ limit: 50 }, readOptions).then((page) => page.data))
return raw.map((payout) => ({
id: payout.id,
amountCents: payout.amount,
arrivalDate: ymdOfUnix(payout.arrival_date),
status: payout.status,
}))
}
// ---------------------------------------------------------------------------
// Projektrechnungen schreiben
// ---------------------------------------------------------------------------
const MAX_ITEMS = 20
const MAX_DESCRIPTION = 200
const MAX_QUANTITY = 999
const MAX_UNIT_CENTS = 10_000_000
const MAX_TOTAL_CENTS = 99_999_999
const MAX_DAYS_UNTIL_DUE = 60
const DEFAULT_DAYS_UNTIL_DUE = 14
const MAX_MEMO = 1000
const PAYMENT_REFERENCE_NOTE = 'Bitte geben Sie bei Überweisung die Rechnungsnummer als Verwendungszweck an.'
const ABO_MESSAGE = 'Abo-Rechnungen verwaltet Stripe.'
const NO_EMAIL_MESSAGE = 'Für den Versand fehlt die E-Mail-Adresse des Kunden.'
const MAX_IDEMPOTENCY_KEY = 100
const IN_PROGRESS_MESSAGE = 'Diese Rechnung wird gerade schon erstellt.'
// So lange nach einem Erfolg nimmt der Server dieselbe Rechnung (gleicher Inhalt) nicht noch einmal an
const DUPLICATE_WINDOW_MS = 30_000
/**
* Fehler dieses Moduls mit HTTP-Status und einer Meldung, die die Seite anzeigen darf (nur diese gibt die Route
* weiter; Stripe- und Appwrite-Fehler tragen auch status/statusCode, ihre Meldungen bleiben auf dem Server).
*/
export class InvoiceError extends Error {
constructor(status, message) {
super(message)
this.name = 'InvoiceError'
this.status = status
}
}
const httpError = (status, message) => new InvoiceError(status, message)
const CUSTOMER_SYNC_MESSAGE = 'Kundendaten konnten nicht an Stripe übertragen werden.'
/**
* Den Stripe-Kunden auf den Stand der WOMS-Kundendaten bringen (E-Mail, Name, Anschrift), bevor eine Rechnung entsteht:
* ein frueher angelegter oder ueber die Metadaten gefundener Stripe-Kunde traegt sonst noch die alte E-Mail und
* Anschrift, und Stripe schreibt beides auf die Rechnung (Empfaengeranschrift, § 14 Abs. 4 Nr. 1 UStG). Scheitert
* das, entsteht keine Rechnung: status 502 mit fester Meldung (die Ursache bleibt als cause fuers Protokoll).
*/
async function syncStripeCustomer(stripe, stripeCustomerId, customer) {
const fields = stripeCustomerFields(customer)
if (!fields.email && !fields.name && !fields.address) return
try {
await stripe.customers.update(stripeCustomerId, fields)
} catch (cause) {
const err = httpError(502, CUSTOMER_SYNC_MESSAGE)
err.cause = cause
throw err
}
}
// Kleinbetragsrechnung (§ 33 UStDV): bis 250 € braucht der Empfaenger keine Anschrift auf der Rechnung. Wert und Text
// wie im WOMS (financeView.js: SMALL_INVOICE_MAX_CENTS, ADDRESS_INCOMPLETE_TEXT).
const SMALL_INVOICE_MAX_CENTS = 25_000
const ADDRESS_INCOMPLETE_MESSAGE = 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.'
const hasText = (value) => typeof value === 'string' && value.trim() !== ''
/**
* Adress-Sperre vor dem Senden (§ 14 Abs. 4 Nr. 1 UStG): Fehlt beim WOMS-Kunden Strasse, PLZ oder Ort (nur Leerzeichen
* zaehlt als fehlend) und liegt die Summe ueber 250 €, gibt es status 409 mit fester Meldung. Gilt nur fuer das Senden
* (Entwuerfe ohne Versand und erneutes Senden einer schon festgeschriebenen Rechnung bleiben unberuehrt) und wird vor dem
* ersten Stripe-Schreibzugriff geprueft.
*/
function assertAddressForSend(customer, totalCents) {
const complete = hasText(customer.street) && hasText(customer.postalCode) && hasText(customer.city)
if (!complete && totalCents > SMALL_INVOICE_MAX_CENTS) throw httpError(409, ADDRESS_INCOMPLETE_MESSAGE)
}
/** Rechnungsfuss: Kleinunternehmer-Hinweis, Bankdaten (falls gesetzt), Hinweis auf den Verwendungszweck. */
export function invoiceFooter(bankDetailsText = '') {
const bank = typeof bankDetailsText === 'string' ? bankDetailsText.trim() : ''
return [config.stripe.invoiceFooter, bank, PAYMENT_REFERENCE_NOTE].filter(Boolean).join('\n')
}
const isWholeNumberBetween = (value, min, max) => Number.isInteger(value) && value >= min && value <= max
/** Positionen pruefen und bereinigen (Beschreibung ohne Rand-Leerzeichen); Fehler mit status 400. */
function validateItems(items) {
if (!Array.isArray(items) || items.length < 1 || items.length > MAX_ITEMS) {
throw httpError(400, `Eine Rechnung braucht 1 bis ${MAX_ITEMS} Positionen.`)
}
const lines = items.map((item, index) => {
const label = `Position ${index + 1}`
const description = typeof item?.description === 'string' ? item.description.trim() : ''
if (description.length < 1 || description.length > MAX_DESCRIPTION) {
throw httpError(400, `${label}: Die Beschreibung braucht 1 bis ${MAX_DESCRIPTION} Zeichen.`)
}
if (!isWholeNumberBetween(item.quantity, 1, MAX_QUANTITY)) {
throw httpError(400, `${label}: Die Menge muss eine ganze Zahl von 1 bis ${MAX_QUANTITY} sein.`)
}
if (!isWholeNumberBetween(item.unitAmountCents, 1, MAX_UNIT_CENTS)) {
throw httpError(400, `${label}: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`)
}
return { description, quantity: item.quantity, unitAmountCents: item.unitAmountCents }
})
const totalCents = lines.reduce((sum, line) => sum + line.quantity * line.unitAmountCents, 0)
if (totalCents > MAX_TOTAL_CENTS) throw httpError(400, 'Rechnungssumme zu hoch.')
return lines
}
/** Kunde aus Appwrite; nur feste Kunden (nicht lead/lost) bekommen Rechnungen. */
async function loadBillableCustomer(customerId) {
if (typeof customerId !== 'string' || !customerId) throw httpError(400, 'Kunde nicht gefunden.')
let customer
try {
customer = await getDocument(config.collections.customers, customerId)
} catch (err) {
if (err.status === 404) throw httpError(400, 'Kunde nicht gefunden.')
throw err
}
const stage = String(customer.customerStatus || '').trim().toLowerCase()
if (stage === 'lead' || stage === 'lost') throw httpError(400, 'Rechnungen nur für feste Kunden.')
return customer
}
/** Eine Rechnung frisch von Stripe (mit Kunde), roh; unbekannte Rechnung -> status 404. */
async function retrieveInvoice(invoiceId) {
if (typeof invoiceId !== 'string' || !/^in_\w+$/.test(invoiceId)) throw httpError(404, 'Rechnung nicht gefunden.')
try {
return await getStripe().invoices.retrieve(invoiceId, { expand: ['customer'] })
} catch (err) {
if (err?.statusCode === 404 || err?.code === 'resource_missing') throw httpError(404, 'Rechnung nicht gefunden.')
throw err
}
}
/** Eine Rechnung frisch von Stripe (mit Kunde), gemappt; unbekannte Rechnung -> status 404. */
async function readInvoice(invoiceId) {
return mapInvoice(await retrieveInvoice(invoiceId), { today: berlinDate() })
}
// Finalisieren ohne Auto-Advance (die Rechnung bleibt in unserer Hand), dann per E-Mail senden. Scheitert nur das
// Senden, ist die Rechnung schon festgeschrieben (offen, mit Nummer): der Fehler (status 502) nennt sie, damit der
// Aufrufer sie erneut senden kann (sendInvoiceNow), statt eine zweite anzulegen.
async function finalizeAndSend(stripe, invoiceId) {
const finalized = await stripe.invoices.finalizeInvoice(invoiceId, { auto_advance: false })
const number = finalized.number || ''
try {
await stripe.invoices.sendInvoice(invoiceId)
} catch (cause) {
throw invoiceExistsError(`Rechnung ${number} wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.`, invoiceId, number, cause)
}
return number
}
/** Kennung des Stripe-Kunden einer rohen Rechnung (expandiert oder als Text). */
const stripeCustomerIdOf = (raw) => (raw.customer && typeof raw.customer === 'object' ? raw.customer.id : raw.customer)
/** Fehler 502 an einer Rechnung, die bei Stripe schon besteht: traegt invoiceId und invoiceNumber (und die Ursache) fuer die Seite. */
function invoiceExistsError(message, invoiceId, number, cause) {
const err = httpError(502, message)
err.invoiceId = invoiceId
err.invoiceNumber = number
err.cause = cause
return err
}
// Schutz vor doppeltem Anlegen (Doppelklick, Wiederholung nach Zeitueberschreitung): je Inhalt der Rechnung ein Eintrag
// { at, running }. Er blockiert, solange die Anlage laeuft und DUPLICATE_WINDOW_MS nach einem Erfolg; scheitert die
// Anlage, ohne dass eine Rechnung bei Stripe bleibt, wird er freigegeben.
const createGuard = new Map()
/** Nur fuer Tests: den Schutz vor doppeltem Anlegen leeren. */
export function resetInvoiceCreateGuardForTests() {
createGuard.clear()
}
function claimCreate(fingerprint) {
const now = Date.now()
for (const [key, entry] of createGuard) {
if (!entry.running && now - entry.at >= DUPLICATE_WINDOW_MS) createGuard.delete(key)
}
if (createGuard.has(fingerprint)) throw httpError(409, IN_PROGRESS_MESSAGE)
createGuard.set(fingerprint, { at: now, running: true })
}
function releaseCreate(fingerprint, invoiceKept) {
if (invoiceKept) createGuard.set(fingerprint, { at: Date.now(), running: false })
else createGuard.delete(fingerprint)
}
/** Wiederholungsschluessel des Aufrufers: leer = keiner; sonst druckbare Zeichen ohne Leerzeichen, hoechstens 100. */
function validateIdempotencyKey(key) {
if (key === undefined || key === null || key === '') return undefined
if (typeof key !== 'string' || key.length > MAX_IDEMPOTENCY_KEY || !/^[\x21-\x7e]+$/.test(key)) {
throw httpError(400, `Der Wiederholungsschlüssel ist ungültig (1 bis ${MAX_IDEMPOTENCY_KEY} Zeichen ohne Leerzeichen).`)
}
return key
}
/**
* Projektrechnung bei Stripe anlegen: Entwurf mit den Positionen (Menge und Einzelpreis je Position), Zahlungsziel
* in Tagen, Rechnungsfuss mit Kleinunternehmer-Hinweis und den Bankdaten aus den Einstellungen (bankDetailsText, siehe
* settings.js). Mit senden=true wird sie sofort finalisiert und per E-Mail gesendet (offen), sonst bleibt sie ein
* Entwurf. Fehler mit status 400 bei ungueltigen Eingaben (auch Summe ueber 99.999.999 Cent), einem Kunden, der noch
* Lead/verloren ist, oder fehlender E-Mail-Adresse bei senden=true (alles vor dem ersten Stripe-Aufruf, ebenso ein
* Fehler beim Lesen der Einstellungen). Mit senden=true und unvollstaendiger Anschrift des Kunden (Strasse, PLZ, Ort)
* bei einer Summe ueber 250 €: status 409 mit fester Meldung, ebenfalls vor dem ersten Stripe-Aufruf (Entwuerfe ohne
* Senden sind davon nicht betroffen). Vor der Rechnung wird der Stripe-Kunde (auch ein schon bestehender) auf die
* aktuellen WOMS-Kundendaten gebracht (E-Mail, Name, Anschrift); scheitert das: status 502 „Kundendaten konnten nicht
* an Stripe übertragen werden.“ ohne invoiceId, es entsteht keine Rechnung. Scheitert nur das Senden nach dem Festschreiben: status 502 mit invoiceId und
* invoiceNumber (die offene Rechnung bleibt, erneut senden mit sendInvoiceNow); ebenso, wenn die fertige Rechnung nicht
* mehr gelesen werden kann (sie besteht dann trotzdem).
*
* Doppeltes Anlegen: idempotencyKey (optional, vom Aufrufer je beabsichtigter Rechnung, hoechstens 100 Zeichen) geht als
* Idempotency-Key an stripe.invoices.create, eine Wiederholung nach einer Zeitueberschreitung liefert dann denselben
* Entwurf. Nach einem Fehler, der den Entwurf wieder geloescht hat, braucht ein neuer Versuch einen neuen Schluessel.
* Zusaetzlich nimmt dieser Server dieselbe Rechnung (Kunde, Positionen, Vermerk, Zahlungsziel, senden) nicht an, solange
* sie angelegt wird und 30 Sekunden danach: status 409.
*/
export async function createProjectInvoice({
customerId,
items,
daysUntilDue = DEFAULT_DAYS_UNTIL_DUE,
memo = '',
senden = false,
idempotencyKey,
createdBy,
}) {
const lines = validateItems(items)
if (!isWholeNumberBetween(daysUntilDue, 0, MAX_DAYS_UNTIL_DUE)) {
throw httpError(400, `Das Zahlungsziel muss eine ganze Zahl von 0 bis ${MAX_DAYS_UNTIL_DUE} Tagen sein.`)
}
const note = typeof memo === 'string' ? memo.trim() : ''
if (note.length > MAX_MEMO) throw httpError(400, `Der Vermerk darf höchstens ${MAX_MEMO} Zeichen lang sein.`)
const key = validateIdempotencyKey(idempotencyKey)
const fingerprint = createHash('sha256').update(JSON.stringify([customerId, lines, note, daysUntilDue, senden === true])).digest('hex')
claimCreate(fingerprint)
try {
const invoice = await createInvoice({ customerId, lines, daysUntilDue, note, senden: senden === true, key, createdBy })
releaseCreate(fingerprint, true)
return invoice
} catch (err) {
// Eine Rechnung, die bei Stripe bleibt (festgeschrieben, aber nicht gesendet oder nicht lesbar), haelt den Schutz
releaseCreate(fingerprint, Boolean(err?.invoiceId))
throw err
}
}
async function createInvoice({ customerId, lines, daysUntilDue, note, senden, key, createdBy }) {
const customer = await loadBillableCustomer(customerId)
// Ohne Adresse lehnt Stripe das Senden erst nach dem Festschreiben ab: lieber vorab pruefen
if (senden && !String(customer.email || '').trim()) throw httpError(400, NO_EMAIL_MESSAGE)
if (senden) assertAddressForSend(customer, lines.reduce((sum, line) => sum + line.quantity * line.unitAmountCents, 0))
const { bankDetailsText } = await getSettings()
const stripe = getStripe()
const stripeCustomerId = await getOrCreateStripeCustomer(customer)
// Vor dem ersten Rechnungsaufruf: scheitert das, gibt es keine halbfertige Rechnung
await syncStripeCustomer(stripe, stripeCustomerId, customer)
let invoiceId = null
try {
const draft = await stripe.invoices.create(
{
customer: stripeCustomerId,
collection_method: 'send_invoice',
days_until_due: daysUntilDue,
auto_advance: false,
pending_invoice_items_behavior: 'exclude',
currency: 'eur',
footer: invoiceFooter(bankDetailsText),
description: note || undefined,
metadata: { webklarCustomerId: customer.$id, quelle: 'woms', erstelltVon: String(createdBy || '').slice(0, 500) },
},
key ? { idempotencyKey: key } : undefined
)
invoiceId = draft.id
// der Reihe nach, damit die Positionen in der eingegebenen Reihenfolge auf der Rechnung stehen
for (const line of lines) {
await stripe.invoiceItems.create({
customer: stripeCustomerId,
invoice: invoiceId,
description: line.description,
quantity: line.quantity,
unit_amount_decimal: String(line.unitAmountCents),
currency: 'eur',
})
}
} catch (err) {
// Halbfertigen Entwurf nicht liegen lassen; scheitert auch das, bleibt er als Entwurf sichtbar und loeschbar
if (invoiceId) await stripe.invoices.del(invoiceId).catch(() => {})
invalidateFinanceCache()
throw err
}
try {
const number = senden ? await finalizeAndSend(stripe, invoiceId) : ''
try {
return await readInvoice(invoiceId)
} catch (cause) {
// Die Rechnung besteht (und ist bei senden gesendet): die Seite bekommt ihre Kennung statt eines blanken Fehlers
const what = senden ? `Rechnung ${number} wurde gesendet` : 'Der Entwurf wurde angelegt'
throw invoiceExistsError(`${what}, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.`, invoiceId, number, cause)
}
} finally {
invalidateFinanceCache()
}
}
/**
* Projektrechnung senden: ein Entwurf wird finalisiert (ohne Auto-Advance) und gesendet; eine offene oder ueberfaellige
* wird nur erneut gesendet (Erinnerung, kein Finalisieren, keine Pruefung der Kundendaten). Bei einem Entwurf mit
* webklarCustomerId wird der Kunde aus dem WOMS geladen (400 bei unbekanntem oder nicht festem Kunden), die Adress-Sperre
* geprueft (409, siehe createProjectInvoice) und der Stripe-Kunde vor dem Finalisieren auf die aktuellen Kundendaten
* gebracht (scheitert das: status 502, nichts wird finalisiert). Ein Entwurf ohne webklarCustomerId (Stripe-Dashboard)
* wird ohne beides gesendet. Fehler: 409 bei Abo-Rechnungen (verwaltet Stripe) und bei bezahlten/stornierten/
* uneinbringlichen, 400 ohne E-Mail-Adresse des Kunden, 404 unbekannt. Scheitert beim Entwurf nur das Senden nach dem
* Festschreiben: status 502 mit invoiceId und invoiceNumber (die Rechnung ist dann offen und kann erneut gesendet werden).
*/
export async function sendInvoiceNow(invoiceId) {
const raw = await retrieveInvoice(invoiceId)
const invoice = mapInvoice(raw, { today: berlinDate() })
// Ein Abo-Entwurf gehoert Stripe: Finalisieren mit auto_advance=false wuerde den Einzug des Abos anhalten
if (invoice.source !== 'projekt') throw httpError(409, ABO_MESSAGE)
const isDraft = invoice.status === 'entwurf'
if (!isDraft && invoice.status !== 'offen' && invoice.status !== 'ueberfaellig') {
throw httpError(409, 'Diese Rechnung kann nicht mehr gesendet werden.')
}
// Nur ein Entwurf wird festgeschrieben: erst dann zaehlen Kundendaten und Anschrift. Eine offene Rechnung steht schon
// fest (Anschrift und Empfaenger sind festgeschrieben), sie wird nur erneut gesendet.
let customer = null
if (isDraft && invoice.webklarCustomerId) customer = await loadBillableCustomer(invoice.webklarCustomerId)
// Nach dem Abgleich traegt der Stripe-Kunde die E-Mail des WOMS-Kunden; ohne die gilt die, die Stripe schon hat
const email = String(customer?.email || '').trim() || String(invoice.customerEmail || '').trim()
if (!email) throw httpError(400, NO_EMAIL_MESSAGE)
if (customer) assertAddressForSend(customer, invoice.totalCents)
try {
if (isDraft) {
// Der Entwurf kann Wochen alt sein: den Stripe-Kunden vor dem Festschreiben auf die aktuellen Kundendaten bringen
// (Name, E-Mail, Anschrift stehen danach unveraenderlich auf der Rechnung). Ohne Kennung (z. B. Entwurf aus dem
// Stripe-Dashboard) gibt es keine WOMS-Daten zum Abgleichen.
if (customer) await syncStripeCustomer(getStripe(), stripeCustomerIdOf(raw), customer)
await finalizeAndSend(getStripe(), invoice.id)
} else await getStripe().invoices.sendInvoice(invoice.id)
return await readInvoice(invoice.id)
} finally {
invalidateFinanceCache()
}
}
/** Offene oder ueberfaellige Projektrechnung stornieren (Stripe "void"), sonst status 409. */
export async function voidInvoice(invoiceId) {
const invoice = await readInvoice(invoiceId)
if (invoice.source !== 'projekt') throw httpError(409, ABO_MESSAGE)
if (invoice.status !== 'offen' && invoice.status !== 'ueberfaellig') throw httpError(409, 'Nur offene Rechnungen können storniert werden.')
try {
await getStripe().invoices.voidInvoice(invoice.id)
return await readInvoice(invoice.id)
} finally {
invalidateFinanceCache()
}
}
/** Projekt-Entwurf bei Stripe loeschen, sonst status 409 (nur Entwuerfe lassen sich loeschen). */
export async function deleteDraft(invoiceId) {
const invoice = await readInvoice(invoiceId)
if (invoice.source !== 'projekt') throw httpError(409, ABO_MESSAGE)
if (invoice.status !== 'entwurf') throw httpError(409, 'Nur Entwürfe können gelöscht werden.')
try {
await getStripe().invoices.del(invoice.id)
} finally {
invalidateFinanceCache()
}
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Steuer-Auswertung der Finanzen (rein, ohne I/O): Rücklage auf den Gewinn des laufenden Jahres, Zähler für die
* Kleinunternehmer-Grenzen (§ 19 UStG) und die nächste Einkommensteuer-Vorauszahlung. Beträge sind ganze Cent;
* "heute" kommt als Berliner Kalendertag von außen.
*/
import { daysBetween } from './dates.js'
const GRENZE_FOLGEJAHR_CENTS = 2_500_000 // 25.000 EUR: wer im Jahr darüber liegt, ist im Folgejahr kein Kleinunternehmer mehr
const GRENZE_HART_CENTS = 10_000_000 // 100.000 EUR: darüber endet die Regelung sofort
const GEWERBESTEUER_FREIBETRAG_CENTS = 2_450_000 // 24.500 EUR Gewinn bei Personengesellschaften
const DEFAULT_RESERVE_PERCENT = 30 // wie DEFAULT_SETTINGS.taxReservePercent (settings.js, hier nicht importiert: Modul bleibt rein)
const EST_MONTHS = [3, 6, 9, 12] // ESt-Vorauszahlung jeweils am 10.
const EST_DAY = 10
const isCents = (n) => Number.isSafeInteger(n)
const pad = (n) => String(n).padStart(2, '0')
/** Nächster ESt-Vorauszahlungstermin ab heute (heute zählt mit) als YYYY-MM-DD. */
function nextEstDate(today) {
const year = Number(today.slice(0, 4))
for (const month of EST_MONTHS) {
const date = `${String(year).padStart(4, '0')}-${pad(month)}-${pad(EST_DAY)}`
if (date >= today) return date
}
return `${String(year + 1).padStart(4, '0')}-${pad(EST_MONTHS[0])}-${pad(EST_DAY)}`
}
/**
* @param {object} args
* @param {string} args.today Berliner Tag YYYY-MM-DD
* @param {Array} [args.transactions] Bankbuchungen { status, bookingDate, amountCents (+ Eingang / - Ausgang), category }
* @param {Array} [args.invoices] Stripe-Rechnungen (gemappt) { paidCents, paidDate }
* @param {object} [args.settings] { taxReservePercent, previousYearRevenueCents, estPrepaymentCents }
*
* Einnahmen und Ausgaben: gebuchte Bankbuchungen (BOOK) des Kalenderjahres von today ohne Kategorie "privat".
* Der Kleinunternehmer-Zähler nimmt statt der Bankeingänge die Zahltage der Stripe-Rechnungen plus Buchungen
* "rechnung-extern"; Stripe-Auszahlungen und Rechnungsbuchungen zählen dort nicht (sonst doppelt).
*/
export function computeTax({ today, transactions = [], invoices = [], settings } = {}) {
daysBetween(today, today) // wirft bei einem ungültigen Tag
const jahr = Number(today.slice(0, 4))
const prefix = `${today.slice(0, 4)}-`
let einnahmenCents = 0
let ausgabenCents = 0
let externCents = 0
for (const tx of transactions || []) {
if (!tx || tx.status !== 'BOOK' || typeof tx.bookingDate !== 'string' || !tx.bookingDate.startsWith(prefix)) continue
if (!isCents(tx.amountCents) || tx.category === 'privat') continue
if (tx.amountCents > 0) {
einnahmenCents += tx.amountCents
if (tx.category === 'rechnung-extern') externCents += tx.amountCents
} else {
ausgabenCents -= tx.amountCents
}
}
let stripeCents = 0
for (const invoice of invoices || []) {
if (!invoice || typeof invoice.paidDate !== 'string' || !invoice.paidDate.startsWith(prefix)) continue
if (isCents(invoice.paidCents) && invoice.paidCents > 0) stripeCents += invoice.paidCents
}
const gewinnCents = einnahmenCents - ausgabenCents
const prozent = Number.isFinite(settings?.taxReservePercent) ? settings.taxReservePercent : DEFAULT_RESERVE_PERCENT
const jahrCents = stripeCents + externCents
const prepayment = settings?.estPrepaymentCents
return {
jahr,
einnahmenCents,
ausgabenCents,
gewinnCents,
ruecklageCents: Math.round((Math.max(0, gewinnCents) * prozent) / 100),
prozent,
gewerbesteuerHinweis: gewinnCents > GEWERBESTEUER_FREIBETRAG_CENTS,
kleinunternehmer: {
jahrCents,
vorjahrCents: isCents(settings?.previousYearRevenueCents) ? settings.previousYearRevenueCents : null,
grenzeFolgejahrCents: GRENZE_FOLGEJAHR_CENTS,
grenzeHartCents: GRENZE_HART_CENTS,
anteilFolgejahr: jahrCents / GRENZE_FOLGEJAHR_CENTS,
},
naechsteVorauszahlung: isCents(prepayment) && prepayment > 0 ? { date: nextEstDate(today), amountCents: prepayment } : null,
}
}
+352
View File
@@ -0,0 +1,352 @@
/**
* Aufgabenliste "Zu erledigen" der Finanzen (rein, ohne I/O). Jede Aufgabe gehört zu einer festen Art
* (Spec "Zu erledigen – Arten"), hat eine Dringlichkeit (hoch, mittel, info), einen deutschen Titel und Text,
* ein Datum (YYYY-MM-DD, wonach innerhalb der Dringlichkeit sortiert wird) und eine Aktion für die Oberfläche.
* Beträge sind ganze Cent; "heute" (Berliner Tag) und "jetzt" kommen von außen. Kaputte Datensätze fallen weg,
* statt die ganze Liste zu kippen.
*/
import { addDays, berlinDate, daysBetween, formatDe } from './dates.js'
import { euroDe } from './format.js'
const HOCH = 'hoch'
const MITTEL = 'mittel'
const INFO = 'info'
const RANK = { [HOCH]: 0, [MITTEL]: 1, [INFO]: 2 }
const BANK_WARN_DAYS = 30 // Zugang läuft in <= 30 Tagen ab: mittel
const BANK_URGENT_DAYS = 14 // ... in <= 14 Tagen: hoch
const SYNC_MAX_AGE_MS = 24 * 3_600_000
const OVERDUE_URGENT_DAYS = 30 // ab 30 Tagen Verzug: hoch
const DRAFT_MAX_AGE_DAYS = 3 // Entwurf älter als 3 Tage
const RECURRING_MAX_AGE_DAYS = 60 // Vorschläge nur für Ausgaben, die zuletzt in den letzten 60 Tagen liefen
const KLEIN_WARN_SHARE = 0.7
// Wie in tax.js; gelten nur, wenn das tax-Objekt die Grenzen nicht mitgibt
const KLEIN_LIMIT_CENTS = 2_500_000
const KLEIN_HARD_LIMIT_CENTS = 10_000_000
const MAX_REMITTANCE = 80
const INTERVAL_TEXT = { monat: 'monatlich', quartal: 'vierteljährlich', jahr: 'jährlich' }
const isCents = (n) => Number.isSafeInteger(n)
/** Wert, der ein echter Kalendertag sein muss, sonst null. */
function validYmd(ymd) {
try {
daysBetween(ymd, ymd)
return ymd
} catch {
return null
}
}
/** Zeitstempel (ISO-Text) als Date, null bei fehlendem oder unlesbarem Wert. */
function parseInstant(iso) {
if (!iso || typeof iso !== 'string') return null
const date = new Date(iso)
return Number.isNaN(date.getTime()) ? null : date
}
const trimmed = (value) => (typeof value === 'string' ? value.trim() : '')
/** "1 Tag" / "5 Tage" (Nominativ) bzw. "1 Tag" / "5 Tagen" (Dativ). */
const days = (n, dative = false) => `${n} ${n === 1 ? 'Tag' : dative ? 'Tagen' : 'Tage'}`
const clip = (text, max) => (text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text)
function todo(art, dringlichkeit, titel, text, datum, aktion) {
return { art, dringlichkeit, titel, text, datum, aktion }
}
// --- Bank ---------------------------------------------------------------------------------------------------
function bankTodos({ today, now, connection }) {
const result = []
// Ein Dokument ohne Sitzung (erste Freigabe begonnen, nie abgeschlossen) ist kein Bankzugang
if (!connection || !trimmed(connection.sessionId)) {
result.push(
todo(
'bank-zugang',
HOCH,
'Bankkonto verbinden',
'Es ist noch kein Bankkonto verbunden. Verbinden Sie das Konto, damit Zahlungseingänge abgerufen werden.',
today,
{ typ: 'bank-verbinden' }
)
)
return result
}
const validUntilDate = parseInstant(connection.validUntil)
const validUntil = validUntilDate ? berlinDate(validUntilDate) : null
const daysLeft = validUntil ? daysBetween(today, validUntil) : null
const expired = connection.status === 'abgelaufen' || (daysLeft !== null && daysLeft < 0)
if (expired) {
const when = validUntil && validUntil < today ? `am ${formatDe(validUntil)} abgelaufen` : 'abgelaufen oder widerrufen worden'
result.push(
todo(
'bank-zugang',
HOCH,
'Bankzugang abgelaufen',
`Der Zugang zum Bankkonto ist ${when}. Verbinden Sie das Konto neu, damit wieder Buchungen abgerufen werden.`,
validUntil && validUntil < today ? validUntil : today,
{ typ: 'bank-verbinden' }
)
)
} else if (daysLeft !== null && daysLeft <= BANK_WARN_DAYS) {
const phrase = daysLeft === 0 ? 'heute' : daysLeft === 1 ? 'morgen' : `in ${days(daysLeft, true)}`
result.push(
todo(
'bank-zugang',
daysLeft <= BANK_URGENT_DAYS ? HOCH : MITTEL,
'Bankzugang läuft ab',
`Der Zugang zum Bankkonto läuft am ${formatDe(validUntil)} ab (${phrase}). Verbinden Sie das Konto neu, damit der Abruf nicht unterbrochen wird.`,
validUntil,
{ typ: 'bank-verbinden' }
)
)
}
if (connection.status === 'aktiv') {
const lastSync = parseInstant(connection.lastSyncAt)
const stale = !lastSync || now.getTime() - lastSync.getTime() > SYNC_MAX_AGE_MS
if (connection.lastSyncOk === false || stale) {
const lastDay = lastSync ? berlinDate(lastSync) : today
const failed = connection.lastSyncOk === false
const error = trimmed(connection.lastError)
const last = lastSync ? `Der letzte erfolgreiche Abruf war am ${formatDe(lastDay)}.` : 'Es gab noch keinen erfolgreichen Abruf.'
result.push(
todo(
'bank-abruf',
MITTEL,
failed ? 'Bankabruf fehlgeschlagen' : 'Bankabruf veraltet',
`${last}${failed && error ? ` Fehler: ${error}` : ''} Rufen Sie die Buchungen jetzt neu ab.`,
lastDay,
{ typ: 'bank-aktualisieren' }
)
)
}
}
return result
}
// --- Rechnungen ---------------------------------------------------------------------------------------------
function overdueTodos({ today, invoices }) {
const result = []
for (const invoice of invoices) {
if (!invoice || (invoice.status !== 'ueberfaellig' && invoice.status !== 'offen')) continue
const due = validYmd(invoice.dueDate)
if (!due || due >= today || !isCents(invoice.remainingCents)) continue
const late = daysBetween(due, today)
const customer = trimmed(invoice.customerName)
result.push(
todo(
'ueberfaellig',
late >= OVERDUE_URGENT_DAYS ? HOCH : MITTEL,
['Rechnung', trimmed(invoice.number), 'überfällig'].filter(Boolean).join(' '),
`${customer ? `${customer}: ` : ''}${euroDe(invoice.remainingCents)} offen, fällig am ${formatDe(due)} (${days(late)} überfällig).`,
due,
{ typ: 'rechnung-oeffnen', invoiceId: invoice.id, hostedUrl: invoice.hostedUrl || null }
)
)
}
return result
}
function draftTodos({ today, invoices }) {
const result = []
for (const invoice of invoices) {
if (!invoice || invoice.status !== 'entwurf') continue
const created = validYmd(invoice.createdDate)
if (!created) continue
const age = daysBetween(created, today)
if (age <= DRAFT_MAX_AGE_DAYS) continue
const number = trimmed(invoice.number)
const customer = trimmed(invoice.customerName)
const amount = isCents(invoice.totalCents) ? invoice.totalCents : invoice.remainingCents
result.push(
todo(
'entwurf',
INFO,
'Rechnungsentwurf nicht versendet',
`Der Entwurf${number ? ` ${number}` : ''}${customer ? ` für ${customer}` : ''}${isCents(amount) ? ` über ${euroDe(amount)}` : ''} wurde vor ${days(age, true)} angelegt und noch nicht gesendet.`,
created,
{ typ: 'rechnung-oeffnen', invoiceId: invoice.id, hostedUrl: invoice.hostedUrl || null }
)
)
}
return result
}
// --- Buchungen ----------------------------------------------------------------------------------------------
/** Erster Vorschlag mit Sicherheit "hoch" als Satzende, sonst leer. */
function hint(transaction) {
const best = Array.isArray(transaction.vorschlaege) ? transaction.vorschlaege.find((item) => item?.confidence === 'hoch') : null
if (!best) return ''
if (best.kind === 'payout') return 'Vorschlag: Stripe-Auszahlung'
if (best.kind === 'invoice') return `Vorschlag: Rechnung ${trimmed(best.number) || best.id || ''}`.trimEnd()
return ''
}
function assignTodos({ transactions }) {
const result = []
for (const tx of transactions) {
if (!tx || tx.status !== 'BOOK' || tx.category || !isCents(tx.amountCents) || tx.amountCents <= 0) continue
const date = validYmd(tx.bookingDate)
if (!date) continue
const from = trimmed(tx.counterpartyName)
const remittance = clip(trimmed(tx.remittance), MAX_REMITTANCE)
const suggestion = hint(tx)
result.push(
todo(
'zuordnung',
MITTEL,
'Zahlungseingang zuordnen',
`${euroDe(tx.amountCents)}${from ? ` von ${from}` : ''} am ${formatDe(date)}${remittance ? `: „${remittance}“` : ''}.${suggestion ? ` ${suggestion}` : ''}`,
date,
{ typ: 'zuordnen', transactionId: tx.$id }
)
)
}
return result
}
function recurringTodos({ today, recurring }) {
const result = []
const earliest = addDays(today, -RECURRING_MAX_AGE_DAYS)
for (const entry of recurring) {
const last = entry ? validYmd(entry.lastDate) : null
if (!last || last < earliest || !isCents(entry.amountCents)) continue
const rhythm = INTERVAL_TEXT[entry.interval]
result.push(
todo(
'fixkosten-vorschlag',
INFO,
'Wiederkehrende Ausgabe erkannt',
`${trimmed(entry.name) || 'Eine Ausgabe'}: ${euroDe(entry.amountCents)}${rhythm ? ` ${rhythm}` : ''}, zuletzt am ${formatDe(last)}${isCents(entry.count) ? ` (${entry.count} Buchungen)` : ''}. Als Fixkosten übernehmen?`,
last,
{ typ: 'fixkosten-uebernehmen', vorschlag: entry }
)
)
}
return result
}
// --- Abos und Kleinunternehmer ------------------------------------------------------------------------------
function subscriptionTodos({ today, subscriptions }) {
const result = []
for (const subscription of subscriptions) {
if (!subscription || (subscription.status !== 'past_due' && subscription.status !== 'unpaid')) continue
const failed = subscription.status === 'past_due'
const customer = trimmed(subscription.customerName)
const product = trimmed(subscription.productKey) || trimmed(subscription.planKey)
result.push(
todo(
'abo-zahlung',
HOCH,
failed ? 'Abo-Zahlung fehlgeschlagen' : 'Abo unbezahlt',
`${customer ? `${customer}: ` : ''}Das Abo${product ? ` „${product}“` : ''}${isCents(subscription.priceAmount) ? ` (${euroDe(subscription.priceAmount)})` : ''} ${failed ? 'konnte nicht abgebucht werden.' : 'ist unbezahlt: Die Zahlung blieb aus.'}`,
today,
{ typ: 'kunde-oeffnen', customerId: subscription.customerId }
)
)
}
return result
}
// Vorjahresumsatz ueber der Grenze: die Kleinunternehmerregelung gilt im laufenden Jahr gar nicht erst (§ 19 UStG)
function previousYearTodos({ today, tax, settings }) {
const revenue = settings?.previousYearRevenueCents
const limit = isCents(tax?.kleinunternehmer?.grenzeFolgejahrCents) ? tax.kleinunternehmer.grenzeFolgejahrCents : KLEIN_LIMIT_CENTS
if (!isCents(revenue) || revenue <= limit) return []
return [
todo(
'kleinunternehmer',
HOCH,
'Vorjahresumsatz über 25.000 €',
`Im Vorjahr lagen die Einnahmen bei ${euroDe(revenue)}. Damit gilt die Kleinunternehmerregelung (§ 19 UStG) in diesem Jahr nicht. Bitte Rechnungen und Rechnungsfuß mit der Steuerberatung klären.`,
today,
{ typ: 'einstellungen' }
),
]
}
function smallBusinessTodos({ today, tax }) {
const small = tax?.kleinunternehmer
const share = small?.anteilFolgejahr
if (typeof share !== 'number' || !Number.isFinite(share) || share < KLEIN_WARN_SHARE) return []
const limit = isCents(small.grenzeFolgejahrCents) ? small.grenzeFolgejahrCents : KLEIN_LIMIT_CENTS
const hardLimit = isCents(small.grenzeHartCents) ? small.grenzeHartCents : KLEIN_HARD_LIMIT_CENTS
const revenue = isCents(small.jahrCents) ? small.jahrCents : Math.round(share * limit)
const percent = Math.floor(share * 100 + 1e-9)
const year = today.slice(0, 4)
if (revenue > hardLimit) {
return [
todo(
'kleinunternehmer',
HOCH,
'Kleinunternehmer-Grenze überschritten',
`Die Einnahmen ${year} betragen ${euroDe(revenue)} und liegen über der Grenze von ${euroDe(hardLimit)}. Ab dem Umsatz, mit dem die Grenze überschritten wurde, gilt die Kleinunternehmerregelung (§ 19 UStG) nicht mehr – auch im laufenden Jahr. Bitte umgehend mit der Steuerberatung klären.`,
today,
{ typ: 'einstellungen' }
),
]
}
if (share >= 1) {
return [
todo(
'kleinunternehmer',
HOCH,
'Kleinunternehmer-Grenze erreicht',
`Die Einnahmen ${year} betragen ${euroDe(revenue)} (${percent} % der Grenze von ${euroDe(limit)}). Über ${euroDe(limit)} endet die Kleinunternehmerregelung (§ 19 UStG) im Folgejahr, über ${euroDe(hardLimit)} schon im laufenden Jahr. Bitte mit der Steuerberatung klären.`,
today,
{ typ: 'einstellungen' }
),
]
}
return [
todo(
'kleinunternehmer',
MITTEL,
'Kleinunternehmer-Grenze rückt näher',
`Die Einnahmen ${year} liegen bei ${euroDe(revenue)} und damit bei ${percent} % der Grenze von ${euroDe(limit)}. Wird sie überschritten, endet die Kleinunternehmerregelung (§ 19 UStG) im Folgejahr.`,
today,
{ typ: 'einstellungen' }
),
]
}
/**
* @param {object} args
* @param {string} args.today Berliner Tag YYYY-MM-DD
* @param {Date} args.now Zeitpunkt (Alter des letzten Bankabrufs)
* @param {object|null} args.connection Bankverbindung { sessionId, status, validUntil, lastSyncAt, lastSyncOk, lastError } oder null (ohne sessionId zaehlt sie als nicht verbunden)
* @param {Array} [args.invoices] gemappte Stripe-Rechnungen
* @param {Array} [args.transactions] Bankbuchungen, optional mit vorschlaege
* @param {Array} [args.subscriptions] Abos { customerId, status, priceAmount, productKey|planKey, customerName? }
* @param {Array} [args.recurring] erkannte wiederkehrende Ausgaben (detectRecurring)
* @param {object} [args.tax] Ergebnis von computeTax
* @param {object} [args.settings] Einstellungen { previousYearRevenueCents }: ein Vorjahresumsatz ueber 25.000 EUR ist eine Aufgabe
* @returns {Array<{ art, dringlichkeit, titel, text, datum, aktion }>} hoch vor mittel vor info, darin ältestes Datum zuerst
*/
export function buildTodos({ today, now = new Date(), connection, invoices, transactions, subscriptions, recurring, tax, settings } = {}) {
daysBetween(today, today) // wirft bei einem ungültigen Tag
const list = (value) => (Array.isArray(value) ? value : [])
const all = [
...bankTodos({ today, now, connection }),
...overdueTodos({ today, invoices: list(invoices) }),
...assignTodos({ transactions: list(transactions) }),
...subscriptionTodos({ today, subscriptions: list(subscriptions) }),
...draftTodos({ today, invoices: list(invoices) }),
...recurringTodos({ today, recurring: list(recurring) }),
...previousYearTodos({ today, tax, settings }),
...smallBusinessTodos({ today, tax }),
]
// Array.prototype.sort ist stabil: bei gleicher Dringlichkeit und gleichem Datum bleibt die Reihenfolge der Arten
return all.sort((a, b) => RANK[a.dringlichkeit] - RANK[b.dringlichkeit] || (a.datum < b.datum ? -1 : a.datum > b.datum ? 1 : 0))
}
+58 -11
View File
@@ -431,7 +431,63 @@ export async function grantCreditForInvoice(invoice, { fresh = false } = {}) {
// Checkout / Kundenportal / Kuendigung
// ---------------------------------------------------------------------------
async function getOrCreateStripeCustomer(customer) {
// Deutsche Laendernamen (klein geschrieben) -> ISO-Code fuer Stripe; die Liste deckt die ueblichen Faelle ab
const COUNTRY_CODES = {
deutschland: 'DE',
österreich: 'AT',
oesterreich: 'AT',
schweiz: 'CH',
niederlande: 'NL',
belgien: 'BE',
frankreich: 'FR',
italien: 'IT',
spanien: 'ES',
polen: 'PL',
dänemark: 'DK',
luxemburg: 'LU',
liechtenstein: 'LI',
}
/**
* ISO-Code fuer das Land eines WOMS-Kunden: zweistelliger Code -> gross; deutscher Name (ohne Gross-/Kleinschreibung) ->
* Code; leer -> DE; alles andere -> null (das Land bleibt dann bei Stripe unberuehrt, statt falsch auf DE zu springen).
*/
function stripeCountry(value) {
const raw = typeof value === 'string' ? value.normalize('NFC').trim() : ''
if (!raw) return 'DE'
if (/^[A-Za-z]{2}$/.test(raw)) return raw.toUpperCase()
// nur eigene Schluessel: 'constructor' oder '__proto__' duerfen nicht ueber die Prototypkette treffen
const key = raw.toLowerCase()
return Object.hasOwn(COUNTRY_CODES, key) ? COUNTRY_CODES[key] : null
}
/**
* Stripe-Kundendaten aus dem WOMS-Kunden (Appwrite-Dokument): E-Mail, Name (Firmenname vor Name) und, nur wenn eine
* Strasse eingetragen ist, die Anschrift. Mit Strasse gehen PLZ und Ort immer mit (fehlende als '': Stripe loescht dann
* einen alten Wert, statt ihn neben der neuen Strasse stehen zu lassen). Land: siehe stripeCountry (unbekannt -> fehlt).
* Leere Werte fehlen (undefined), damit Stripe sie nicht anfasst. Gemeinsam fuer das Anlegen und das Aktualisieren des
* Stripe-Kunden; forCreate: beim Anlegen gibt es nichts zu loeschen, leere PLZ/Ort fehlen dort ganz.
*/
export function stripeCustomerFields(customer, { forCreate = false } = {}) {
const t = (v) => (typeof v === 'string' ? v.trim() : '')
const country = stripeCountry(customer.country)
const postalCode = t(customer.postalCode)
const city = t(customer.city)
return {
email: t(customer.email) || undefined,
name: t(customer.companyName) || t(customer.name) || undefined,
address: t(customer.street)
? {
line1: t(customer.street),
...(postalCode || !forCreate ? { postal_code: postalCode } : {}),
...(city || !forCreate ? { city } : {}),
...(country ? { country } : {}),
}
: undefined,
}
}
export async function getOrCreateStripeCustomer(customer) {
const stripe = getStripe()
const subs = await listSubscriptionsForCustomer(customer.$id)
const known = subs.find((s) => s.stripeCustomerId)?.stripeCustomerId
@@ -443,17 +499,8 @@ async function getOrCreateStripeCustomer(customer) {
if (found.data[0]) return found.data[0].id
const created = await stripe.customers.create({
email: customer.email || undefined,
name: customer.companyName || customer.name || undefined,
...stripeCustomerFields(customer, { forCreate: true }),
preferred_locales: ['de'],
address: customer.street
? {
line1: customer.street,
postal_code: customer.postalCode || undefined,
city: customer.city || undefined,
country: customer.country && customer.country.length === 2 ? customer.country : 'DE',
}
: undefined,
invoice_settings: { footer: config.stripe.invoiceFooter },
metadata: { webklarCustomerId: customer.$id, kundennummer: customer.code || '' },
})
+676
View File
@@ -0,0 +1,676 @@
import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import Stripe from 'stripe'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird (Muster wie financeRoutesBank.test.js)
const aw = await startAppwriteMock({
schema: { bankTransactions: schemaKeys('bankTransactions'), financeFixedCosts: schemaKeys('financeFixedCosts') },
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, get, post, rawGet } = await import('./support/testApp.js')
const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js')
const { invalidateFinanceCache } = await import('../server/services/finance/stripeFinance.js')
const { getStripe } = await import('../server/services/stripeBilling.js')
const { euroDe } = await import('../server/services/finance/format.js')
const app = await startTestApp({ '/api/admin/finanzen': financeRoutes })
after(async () => {
mock.restoreAll()
await Promise.all([app.close(), aw.close(), st.close()])
})
const admin = { headers: { Authorization: 'Bearer jwt-admin' } }
const base = '/api/admin/finanzen'
const TX = () => config.collections.bankTransactions
const stored = (id) => aw.docs(TX()).get(id)
const callsTo = (method, path) => st.calls.filter((c) => c.method === method && (path instanceof RegExp ? path.test(c.path) : c.path === path))
const payCalls = () => callsTo('POST', /\/pay$/)
const unix = (ymd) => Math.floor(new Date(`${ymd}T12:00:00+02:00`).getTime() / 1000)
const BOOKING = '2026-09-29'
const tx = (id, over = {}) => ({
$id: id,
accountUid: 'konto-1',
status: 'BOOK',
bookingDate: BOOKING,
valueDate: BOOKING,
amountCents: 16879,
currency: 'EUR',
counterpartyName: 'EWV SOFTWARE GMBH',
counterpartyIban: '',
remittance: 'Rechnung WK-0005 vom 28.07.2026',
mcc: '',
entryReference: id,
category: '',
matchedInvoiceId: '',
matchedInvoiceNumber: '',
matchedPayoutId: '',
fixedCostId: '',
note: '',
syncedAt: '2026-10-01T09:00:00.000Z',
...over,
})
const rawInvoice = (over = {}) => ({
id: 'in_ewv',
object: 'invoice',
number: 'WK-0005',
status: 'open',
customer: 'cus_ewv',
customer_name: 'EWV Software GmbH',
customer_email: 'ewv@example.invalid',
created: unix('2026-08-01'),
due_date: unix('2026-10-20'),
total: 16879,
amount_due: 16879,
amount_paid: 0,
amount_remaining: 16879,
status_transitions: { paid_at: null },
metadata: {},
lines: { object: 'list', data: [], has_more: false },
...over,
})
const rawPayout = (over = {}) => ({
id: 'po_1',
object: 'payout',
amount: 45000,
arrival_date: unix('2026-09-28'),
status: 'paid',
currency: 'eur',
...over,
})
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
// aw.docs liefert eine Kopie: Dokumente loescht der Test ueber den Server wie die Anwendung
async function reset() {
mock.restoreAll()
invalidateFinanceCache()
st.calls.length = 0
st.invoices.clear()
st.customers.clear()
st.payouts.clear()
for (const collection of [TX(), config.collections.financeFixedCosts]) {
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
}
}
beforeEach(reset)
const seedTx = (id, over) => aw.seed(TX(), tx(id, over))
const zuordnen = (id, body) => post(`${base}/buchungen/${id}/zuordnen`, body, admin)
const loesen = async (id) => {
const res = await fetch(app.url + `${base}/buchungen/${id}/zuordnung`, { method: 'DELETE', headers: admin.headers })
const text = await res.text()
return { status: res.status, body: text ? JSON.parse(text) : null }
}
// ---------- Zugriff ----------
test('zuordnen und loesen: ohne JWT 401', async () => {
seedTx('b1')
assert.equal((await post(`${base}/buchungen/b1/zuordnen`, { category: 'privat' })).status, 401)
const res = await fetch(app.url + `${base}/buchungen/b1/zuordnung`, { method: 'DELETE' })
assert.equal(res.status, 401)
assert.equal(stored('b1').category, '')
})
// ---------- Rechnung ----------
test('Rechnung: Stripe pay (paid_out_of_band) + Metadaten, Buchung wird rechnung', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
const res = await zuordnen('b1', { invoiceId: 'in_ewv', note: ' Überweisung EWV ' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(Object.keys(res.body), ['buchung'])
assert.equal(res.body.buchung.$id, 'b1')
assert.equal(res.body.buchung.category, 'rechnung')
assert.equal(res.body.buchung.matchedInvoiceId, 'in_ewv')
assert.equal(res.body.buchung.matchedInvoiceNumber, 'WK-0005')
assert.equal(res.body.buchung.note, 'Überweisung EWV')
assert.deepEqual(Object.keys(res.body.buchung).filter((key) => key.startsWith('$')), ['$id'])
assert.equal(payCalls().length, 1)
assert.equal(payCalls()[0].path, '/v1/invoices/in_ewv/pay')
assert.equal(payCalls()[0].body.paid_out_of_band, 'true')
const updates = callsTo('POST', '/v1/invoices/in_ewv')
assert.equal(updates.length, 1)
assert.equal(updates[0].body['metadata[bankTransactionId]'], 'b1')
assert.equal(updates[0].body['metadata[bankBookingDate]'], BOOKING)
// erst der Vermerk, dann das Bezahlen (der Vermerk ist umkehrbar, das Bezahlen nicht)
assert.ok(st.calls.indexOf(updates[0]) < st.calls.indexOf(payCalls()[0]))
const invoice = st.invoices.get('in_ewv')
assert.equal(invoice.status, 'paid')
assert.equal(invoice.metadata.bankTransactionId, 'b1')
const doc = stored('b1')
assert.equal(doc.category, 'rechnung')
assert.equal(doc.matchedInvoiceId, 'in_ewv')
assert.equal(doc.matchedInvoiceNumber, 'WK-0005')
})
test('Rechnung: Zwischenspeicher wird geleert (die Liste zeigt die Rechnung nicht mehr als offen)', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
const { listInvoices } = await import('../server/services/finance/stripeFinance.js')
assert.equal((await listInvoices())[0].status, 'offen')
assert.equal((await zuordnen('b1', { invoiceId: 'in_ewv' })).status, 200)
const [invoice] = await listInvoices()
assert.equal(invoice.status, 'bezahlt')
assert.equal(invoice.outOfBand, true)
assert.equal(invoice.paidDate, BOOKING)
})
test('Rechnung: zwei parallele Zuordnungen derselben Buchung -> eine 200, eine 409, genau ein pay', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
const results = await Promise.allSettled([zuordnen('b1', { invoiceId: 'in_ewv' }), zuordnen('b1', { invoiceId: 'in_ewv' })])
const statuses = results.map((r) => r.value.status).sort()
assert.deepEqual(statuses, [200, 409])
const loser = results.map((r) => r.value).find((r) => r.status === 409)
assert.equal(loser.body.error, 'Bereits zugeordnet.')
assert.equal(payCalls().length, 1)
})
test('Rechnung: dieselbe Rechnung fuer zwei Buchungen gleichzeitig -> eine 200, eine 409, genau ein pay', async () => {
seedTx('b1')
seedTx('b2')
st.invoices.set('in_ewv', rawInvoice())
const results = await Promise.all([zuordnen('b1', { invoiceId: 'in_ewv' }), zuordnen('b2', { invoiceId: 'in_ewv' })])
assert.deepEqual(results.map((r) => r.status).sort(), [200, 409])
assert.equal(payCalls().length, 1)
const assigned = ['b1', 'b2'].filter((id) => stored(id).category === 'rechnung')
assert.equal(assigned.length, 1)
})
test('Rechnung: Betrag weicht ab -> 409 mit Betrag, kein pay, Buchung unveraendert', async () => {
seedTx('b1', { amountCents: 10000 })
st.invoices.set('in_ewv', rawInvoice())
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(res.status, 409)
assert.equal(res.body.error, `Betrag weicht vom offenen Betrag ab (${euroDe(16879)}). Teilzahlungen bitte direkt in Stripe erfassen.`)
assert.equal(payCalls().length, 0)
assert.equal(stored('b1').category, '')
assert.equal(st.invoices.get('in_ewv').status, 'open')
})
test('Rechnung: nicht offen (bezahlt) -> 409, kein pay', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice({ status: 'paid', amount_remaining: 0, amount_paid: 16879 }))
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Diese Rechnung ist nicht mehr offen.')
assert.equal(payCalls().length, 0)
assert.equal(stored('b1').category, '')
})
test('Rechnung: unbekannte oder ungueltige Rechnungs-ID -> 404', async () => {
seedTx('b1')
assert.equal((await zuordnen('b1', { invoiceId: 'in_gibtsnicht' })).status, 404)
assert.equal((await zuordnen('b1', { invoiceId: '../x' })).status, 404)
assert.equal(payCalls().length, 0)
})
test('Rechnung: nur Eingaenge, eine Ausgangsbuchung -> 400', async () => {
seedTx('b1', { amountCents: -16879 })
st.invoices.set('in_ewv', rawInvoice())
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(res.status, 400)
assert.equal(payCalls().length, 0)
})
test('bereits zugeordnet -> 409 "Bereits zugeordnet." ohne jeden Stripe-Aufruf', async () => {
seedTx('b1', { category: 'privat' })
st.invoices.set('in_ewv', rawInvoice())
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Bereits zugeordnet.')
assert.equal(st.calls.length, 0)
})
test('Stripe lehnt pay ab (4xx) -> 409 ohne Stripe-Text, Buchung bleibt offen', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
st.failNext({ method: 'POST', path: /\/pay$/, status: 400, message: 'Geheimer Stripe-Text sk_live_abc' })
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Stripe hat die Änderung abgelehnt.')
assert.ok(!JSON.stringify(res.body).includes('Geheimer'))
assert.equal(stored('b1').category, '')
})
test('Stripe nicht erreichbar -> 502 mit deutscher Meldung', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
mock.method(getStripe().invoices, 'retrieve', async () => {
throw new Stripe.errors.StripeConnectionError({ message: 'connect ECONNREFUSED 10.0.0.1:443', type: 'api_connection_error' })
})
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(res.status, 502)
assert.equal(res.body.error, 'Stripe ist gerade nicht erreichbar.')
assert.ok(!JSON.stringify(res.body).includes('10.0.0.1'))
})
test('Vermerk scheitert (4xx): 409, kein pay, Rechnung bleibt offen, Buchung unveraendert', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
st.failNext({ method: 'POST', path: '/v1/invoices/in_ewv', status: 400, message: 'Geheimer Vermerk-Text' })
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Stripe hat die Änderung abgelehnt.')
assert.ok(!JSON.stringify(res.body).includes('Geheimer'))
assert.equal(payCalls().length, 0)
assert.equal(st.invoices.get('in_ewv').status, 'open')
assert.equal(stored('b1').category, '')
})
test('Vermerk scheitert (Verbindungsfehler): 502, kein pay', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
mock.method(getStripe().invoices, 'update', async () => {
throw new Stripe.errors.StripeConnectionError({ message: 'connect ECONNREFUSED 10.0.0.1:443', type: 'api_connection_error' })
})
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(res.status, 502)
assert.equal(res.body.error, 'Stripe ist gerade nicht erreichbar.')
assert.equal(payCalls().length, 0)
assert.equal(st.invoices.get('in_ewv').status, 'open')
})
test('pay scheitert nach dem Vermerk: Wiederholung bezahlt genau einmal und vermerkt nicht erneut', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
st.failNext({ method: 'POST', path: /\/pay$/, status: 400, message: 'pay abgelehnt' })
const failed = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(failed.status, 409)
assert.equal(st.invoices.get('in_ewv').status, 'open')
assert.equal(st.invoices.get('in_ewv').metadata.bankTransactionId, 'b1')
assert.equal(stored('b1').category, '')
const retry = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(retry.status, 200)
assert.equal(stored('b1').category, 'rechnung')
assert.equal(st.invoices.get('in_ewv').status, 'paid')
// pay: ein gescheiterter und ein erfolgreicher Aufruf; der Vermerk wurde nur einmal gesetzt
assert.equal(payCalls().length, 2)
assert.equal(callsTo('POST', '/v1/invoices/in_ewv').length, 1)
})
test('offene Rechnung mit dem Vermerk einer anderen Buchung: Vermerk wird ueberschrieben', async () => {
seedTx('b2')
st.invoices.set('in_ewv', rawInvoice({ metadata: { bankTransactionId: 'b1-alt', bankBookingDate: '2026-09-01' } }))
assert.equal((await zuordnen('b2', { invoiceId: 'in_ewv' })).status, 200)
assert.equal(st.invoices.get('in_ewv').metadata.bankTransactionId, 'b2')
assert.equal(st.invoices.get('in_ewv').metadata.bankBookingDate, BOOKING)
})
test('bezahlte Rechnung mit dem Vermerk einer anderen Buchung -> 409 nicht mehr offen', async () => {
seedTx('b2')
st.invoices.set('in_ewv', rawInvoice({ status: 'paid', amount_remaining: 0, amount_paid: 16879, metadata: { bankTransactionId: 'b1' } }))
const res = await zuordnen('b2', { invoiceId: 'in_ewv' })
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Diese Rechnung ist nicht mehr offen.')
assert.equal(stored('b2').category, '')
})
test('Appwrite scheitert nach pay: 502 mit klarer Meldung, die Wiederholung schliesst ab ohne zweites pay (Rechnung ohne paid_out_of_band wie bei Stripe) (M1)', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
const error = mock.method(console, 'error', () => {})
aw.failNext({ collection: TX(), method: 'PATCH', status: 500, message: 'Appwrite kaputt' })
const failed = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(failed.status, 502)
assert.equal(failed.body.error, 'In Stripe als bezahlt markiert, aber die Buchung wurde nicht gespeichert. Bitte erneut zuordnen.')
assert.deepEqual(Object.keys(failed.body), ['error'])
assert.ok(!JSON.stringify(failed.body).includes('Appwrite kaputt'))
// die Ursache steht im Protokoll (Art und Status), nie ihre Meldung
assert.equal(error.mock.callCount(), 1)
assert.equal(error.mock.calls[0].arguments[0], '[finanzen] Buchung zuordnen fehlgeschlagen:')
assert.ok(!JSON.stringify(error.mock.calls).includes('Appwrite kaputt'))
mock.restoreAll()
assert.equal(stored('b1').category, '')
assert.equal(st.invoices.get('in_ewv').status, 'paid')
// wie die echte API: an der bezahlten Rechnung steht nichts von paid_out_of_band
assert.equal(st.invoices.get('in_ewv').paid_out_of_band, undefined)
const retry = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(retry.status, 200)
assert.equal(stored('b1').category, 'rechnung')
assert.equal(stored('b1').matchedInvoiceId, 'in_ewv')
assert.equal(stored('b1').matchedInvoiceNumber, 'WK-0005')
assert.equal(payCalls().length, 1)
assert.equal(callsTo('POST', '/v1/invoices/in_ewv').length, 1)
})
test('Appwrite scheitert beim Zuordnen ohne Rechnung (Kategorie): weiter ein blanker 500 (M1)', async () => {
seedTx('b1')
mock.method(console, 'error', () => {})
aw.failNext({ collection: TX(), method: 'PATCH', status: 500, message: 'Appwrite kaputt' })
const failed = await zuordnen('b1', { category: 'privat' })
assert.equal(failed.status, 500)
assert.equal(failed.body.error, 'Interner Fehler.')
})
test('bezahlt und gekennzeichnet, aber in anderer Hoehe bezahlt als die Buchung -> keine Wiederaufnahme, 409 nicht mehr offen (M2)', async () => {
seedTx('b1', { amountCents: 10000 })
st.invoices.set('in_ewv', rawInvoice({ status: 'paid', amount_remaining: 0, amount_paid: 16879, metadata: { bankTransactionId: 'b1' } }))
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Diese Rechnung ist nicht mehr offen.')
assert.equal(stored('b1').category, '')
assert.equal(payCalls().length, 0)
})
test('Buchung gehoert schon zu einer anderen bezahlten Rechnung -> 409, kein Vermerk, kein pay (M2)', async () => {
seedTx('b1')
// in_alt wurde fuer b1 bezahlt, aber das Speichern in Appwrite scheiterte: b1 steht noch ohne Kategorie da
st.invoices.set('in_alt', rawInvoice({ id: 'in_alt', number: 'WK-0004', status: 'paid', amount_remaining: 0, amount_paid: 16879, metadata: { bankTransactionId: 'b1' } }))
st.invoices.set('in_ewv', rawInvoice())
const res = await zuordnen('b1', { invoiceId: 'in_ewv' })
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Diese Buchung ist bereits einer anderen Rechnung zugeordnet.')
assert.equal(payCalls().length, 0)
assert.equal(callsTo('POST', '/v1/invoices/in_ewv').length, 0, 'kein Vermerk')
assert.equal(st.invoices.get('in_ewv').status, 'open')
assert.equal(stored('b1').category, '')
// die Pruefung liest frisch von Stripe
assert.ok(callsTo('GET', '/v1/invoices').length >= 1)
// dieselbe Rechnung noch einmal zuordnen (Wiederaufnahme) bleibt moeglich
const resume = await zuordnen('b1', { invoiceId: 'in_alt' })
assert.equal(resume.status, 200, JSON.stringify(resume.body))
assert.equal(stored('b1').matchedInvoiceId, 'in_alt')
assert.equal(payCalls().length, 0)
})
test('Buchung ist nur an einer offenen oder fremden Rechnung vermerkt -> kein Hindernis (M2)', async () => {
seedTx('b1')
seedTx('b2')
// offene Rechnung mit Vermerk von b1 (pay scheiterte frueher) und eine bezahlte mit dem Vermerk von b2
st.invoices.set('in_offen', rawInvoice({ id: 'in_offen', number: 'WK-0003', amount_remaining: 5000, total: 5000, metadata: { bankTransactionId: 'b1' } }))
st.invoices.set('in_b2', rawInvoice({ id: 'in_b2', number: 'WK-0002', status: 'paid', amount_remaining: 0, amount_paid: 16879, metadata: { bankTransactionId: 'b2' } }))
st.invoices.set('in_ewv', rawInvoice())
assert.equal((await zuordnen('b1', { invoiceId: 'in_ewv' })).status, 200)
assert.equal(payCalls().length, 1)
})
test('Zuordnung zu einer Rechnung schreibt eine Protokollzeile mit Buchung, Rechnungsnummer und Nutzer, ohne Betrag (M3)', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
const log = mock.method(console, 'log', () => {})
assert.equal((await zuordnen('b1', { invoiceId: 'in_ewv' })).status, 200)
assert.deepEqual(log.mock.calls.map((c) => c.arguments), [['[finanzen] Zuordnung b1 -> Rechnung WK-0005 durch kenso@webklar.com']])
assert.ok(!log.mock.calls[0].arguments[0].includes('168'))
})
test('Wiederaufnahme schreibt die Protokollzeile einmal beim Abschluss, nicht beim Scheitern (M3)', async () => {
seedTx('b1')
st.invoices.set('in_ewv', rawInvoice())
mock.method(console, 'error', () => {})
const log = mock.method(console, 'log', () => {})
aw.failNext({ collection: TX(), method: 'PATCH', status: 500 })
assert.equal((await zuordnen('b1', { invoiceId: 'in_ewv' })).status, 502)
assert.equal(log.mock.callCount(), 0)
assert.equal((await zuordnen('b1', { invoiceId: 'in_ewv' })).status, 200)
assert.deepEqual(log.mock.calls.map((c) => c.arguments[0]), ['[finanzen] Zuordnung b1 -> Rechnung WK-0005 durch kenso@webklar.com'])
})
test('Auszahlung, Fixkosten und Kategorie schreiben keine Zuordnungs-Protokollzeile (umkehrbar) (M3)', async () => {
seedTx('b1')
const log = mock.method(console, 'log', () => {})
assert.equal((await zuordnen('b1', { category: 'privat' })).status, 200)
assert.equal(log.mock.callCount(), 0)
})
// ---------- Vorgemerkte Buchungen ----------
test('vorgemerkte Buchung: Rechnung und Auszahlung -> 409 vor jedem Stripe-Aufruf', async () => {
seedTx('p1', { status: 'PDNG' })
seedTx('p2', { status: 'PDNG', amountCents: 45000 })
st.invoices.set('in_ewv', rawInvoice())
st.payouts.set('po_1', rawPayout())
const message = 'Die Buchung ist noch vorgemerkt. Bitte zuordnen, wenn sie gebucht ist.'
const invoice = await zuordnen('p1', { invoiceId: 'in_ewv' })
assert.equal(invoice.status, 409)
assert.equal(invoice.body.error, message)
const payout = await zuordnen('p2', { payoutId: 'po_1' })
assert.equal(payout.status, 409)
assert.equal(payout.body.error, message)
assert.equal(st.calls.length, 0)
assert.equal(stored('p1').category, '')
assert.equal(stored('p2').category, '')
})
test('vorgemerkte Buchung: Kategorien und Fixkosten bleiben moeglich', async () => {
seedTx('p1', { status: 'PDNG', amountCents: 700 })
seedTx('p2', { status: 'PDNG', amountCents: -449 })
seedTx('p3', { status: 'PDNG', amountCents: -300 })
aw.seed(config.collections.financeFixedCosts, { $id: 'fk1', name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: '2026-10-29', counterpartyMatch: 'porkbun', active: true })
assert.equal((await zuordnen('p1', { category: 'rechnung-extern' })).status, 200)
assert.equal((await zuordnen('p2', { fixedCostId: 'fk1' })).status, 200)
assert.equal((await zuordnen('p3', { category: 'privat' })).status, 200)
assert.equal(st.calls.length, 0)
})
// ---------- Auszahlung ----------
test('Auszahlung: gleicher Betrag -> stripe-auszahlung mit matchedPayoutId', async () => {
seedTx('b1', { amountCents: 45000, counterpartyName: 'STRIPE PAYMENTS EUROPE', remittance: '' })
st.payouts.set('po_1', rawPayout())
const res = await zuordnen('b1', { payoutId: 'po_1' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.buchung.category, 'stripe-auszahlung')
assert.equal(res.body.buchung.matchedPayoutId, 'po_1')
assert.equal(stored('b1').matchedPayoutId, 'po_1')
assert.equal(payCalls().length, 0)
})
test('Auszahlung: Betrag weicht ab -> 409; unbekannt -> 404; fehlgeschlagen/storniert -> 409', async () => {
seedTx('b1', { amountCents: 44000 })
st.payouts.set('po_1', rawPayout())
st.payouts.set('po_f', rawPayout({ id: 'po_f', status: 'failed', amount: 44000 }))
st.payouts.set('po_c', rawPayout({ id: 'po_c', status: 'canceled', amount: 44000 }))
const diff = await zuordnen('b1', { payoutId: 'po_1' })
assert.equal(diff.status, 409)
assert.match(diff.body.error, /Betrag weicht von der Auszahlung ab/)
assert.equal((await zuordnen('b1', { payoutId: 'po_gibtsnicht' })).status, 404)
for (const id of ['po_f', 'po_c']) {
const res = await zuordnen('b1', { payoutId: id })
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Diese Auszahlung ist fehlgeschlagen oder storniert.')
}
assert.equal(stored('b1').category, '')
})
test('Auszahlung: dieselbe Auszahlung nicht zweimal vergeben', async () => {
seedTx('b1', { amountCents: 45000 })
seedTx('b2', { amountCents: 45000 })
st.payouts.set('po_1', rawPayout())
assert.equal((await zuordnen('b1', { payoutId: 'po_1' })).status, 200)
const second = await zuordnen('b2', { payoutId: 'po_1' })
assert.equal(second.status, 409)
assert.equal(second.body.error, 'Diese Auszahlung ist schon einer Buchung zugeordnet.')
assert.equal(stored('b2').category, '')
})
test('Auszahlung: Ausgangsbuchung -> 400', async () => {
seedTx('b1', { amountCents: -45000 })
st.payouts.set('po_1', rawPayout())
assert.equal((await zuordnen('b1', { payoutId: 'po_1' })).status, 400)
})
// ---------- Fixkosten ----------
test('Fixkosten: Ausgang mit vorhandener Position -> fixkosten mit fixedCostId', async () => {
seedTx('b1', { amountCents: -449, counterpartyName: 'PORKBUN.COM', remittance: '' })
aw.seed(config.collections.financeFixedCosts, { $id: 'fk1', name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: '2026-10-29', counterpartyMatch: 'porkbun', active: true })
const res = await zuordnen('b1', { fixedCostId: 'fk1' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.buchung.category, 'fixkosten')
assert.equal(res.body.buchung.fixedCostId, 'fk1')
assert.equal(stored('b1').fixedCostId, 'fk1')
})
test('Fixkosten: unbekannte Position -> 400, Eingangsbuchung -> 400', async () => {
seedTx('b1', { amountCents: -449 })
seedTx('b2', { amountCents: 449 })
aw.seed(config.collections.financeFixedCosts, { $id: 'fk1', name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: '2026-10-29', counterpartyMatch: 'porkbun', active: true })
const missing = await zuordnen('b1', { fixedCostId: 'fk-gibtsnicht' })
assert.equal(missing.status, 400)
assert.equal(missing.body.error, 'Diese Fixkosten-Position gibt es nicht.')
assert.equal((await zuordnen('b2', { fixedCostId: 'fk1' })).status, 400)
assert.equal(stored('b1').category, '')
assert.equal(stored('b2').category, '')
})
// ---------- Kategorien ----------
test('Kategorien: privat und sonstiges fuer Ein- und Ausgang, rechnung-extern nur fuer Eingang', async () => {
seedTx('in1', { amountCents: 5000 })
seedTx('out1', { amountCents: -5000 })
seedTx('out2', { amountCents: -700 })
seedTx('in2', { amountCents: 700 })
const extern = await zuordnen('in1', { category: 'rechnung-extern', note: 'Rechnung 17 außerhalb von Stripe' })
assert.equal(extern.status, 200)
assert.equal(extern.body.buchung.category, 'rechnung-extern')
assert.equal(stored('in1').note, 'Rechnung 17 außerhalb von Stripe')
const externOut = await zuordnen('out1', { category: 'rechnung-extern' })
assert.equal(externOut.status, 400)
assert.equal(stored('out1').category, '')
assert.equal((await zuordnen('out1', { category: 'privat' })).status, 200)
assert.equal(stored('out1').category, 'privat')
assert.equal((await zuordnen('in2', { category: 'sonstiges' })).status, 200)
assert.equal(stored('in2').category, 'sonstiges')
assert.equal((await zuordnen('out2', { category: 'sonstiges' })).status, 200)
assert.equal(st.calls.length, 0)
})
test('Eingaben: genau ein Ziel, bekannte Kategorie, Notiz hoechstens 500 Zeichen, bekannte Buchung', async () => {
seedTx('b1')
const cases = [
[{}, 400],
[{ invoiceId: 'in_ewv', payoutId: 'po_1' }, 400],
[{ category: 'privat', fixedCostId: 'fk1' }, 400],
// rechnung, stripe-auszahlung und fixkosten entstehen nur ueber ihr Ziel, nicht als nackte Kategorie
[{ category: 'rechnung' }, 400],
[{ category: 'stripe-auszahlung' }, 400],
[{ category: 'fixkosten' }, 400],
[{ category: 'irgendwas' }, 400],
[{ category: 'privat', note: 'x'.repeat(501) }, 400],
[{ category: 'privat', note: 42 }, 400],
[{ invoiceId: 42 }, 400],
]
for (const [body, status] of cases) {
const res = await zuordnen('b1', body)
assert.equal(res.status, status, JSON.stringify(body))
assert.ok(res.body.error)
}
assert.equal(stored('b1').category, '')
assert.equal(st.calls.length, 0)
assert.equal((await zuordnen('b1', { category: 'privat', note: 'x'.repeat(500) })).status, 200)
assert.equal((await zuordnen('gibtsnicht', { category: 'privat' })).status, 404)
assert.equal((await zuordnen('../b1', { category: 'privat' })).status, 404)
})
// ---------- Zuordnung loesen ----------
test('loesen: rechnung -> 409, Stripe und Buchung bleiben unveraendert', async () => {
seedTx('b1', { category: 'rechnung', matchedInvoiceId: 'in_ewv', matchedInvoiceNumber: 'WK-0005' })
const res = await loesen('b1')
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Die Rechnung ist in Stripe schon als bezahlt markiert und lässt sich nicht zurücksetzen.')
assert.equal(stored('b1').category, 'rechnung')
assert.equal(stored('b1').matchedInvoiceId, 'in_ewv')
assert.equal(st.calls.length, 0)
})
test('loesen: privat, stripe-auszahlung und fixkosten -> alle Zuordnungsfelder leer, Notiz bleibt', async () => {
seedTx('b1', { category: 'privat', note: 'Mittagessen' })
seedTx('b2', { category: 'stripe-auszahlung', matchedPayoutId: 'po_1' })
seedTx('b3', { amountCents: -449, category: 'fixkosten', fixedCostId: 'fk1' })
const res = await loesen('b1')
assert.equal(res.status, 200)
assert.equal(res.body.buchung.category, '')
assert.equal(res.body.buchung.note, 'Mittagessen')
assert.deepEqual(Object.keys(res.body.buchung).filter((key) => key.startsWith('$')), ['$id'])
assert.equal((await loesen('b2')).status, 200)
assert.equal((await loesen('b3')).status, 200)
for (const id of ['b1', 'b2', 'b3']) {
const doc = stored(id)
for (const key of ['category', 'matchedInvoiceId', 'matchedInvoiceNumber', 'matchedPayoutId', 'fixedCostId']) {
assert.equal(doc[key], '', `${id}.${key}`)
}
}
// danach laesst sich die Buchung neu zuordnen
assert.equal((await zuordnen('b1', { category: 'sonstiges' })).status, 200)
})
test('loesen: unbekannte Buchung -> 404; nicht zugeordnete Buchung bleibt unveraendert (200)', async () => {
seedTx('b1')
assert.equal((await loesen('gibtsnicht')).status, 404)
const res = await loesen('b1')
assert.equal(res.status, 200)
assert.equal(res.body.buchung.category, '')
})
// ---------- GET /buchungen ----------
test('GET /buchungen: Eingang bekommt einen Vorschlag, Ausgang und zugeordnete nicht', async () => {
seedTx('ewv', { bookingDate: '2026-09-29' })
seedTx('aus', { bookingDate: '2026-09-28', amountCents: -449, counterpartyName: 'PORKBUN.COM', remittance: '' })
seedTx('fertig', { bookingDate: '2026-09-27', category: 'privat' })
st.invoices.set('in_ewv', rawInvoice())
const body = await get(`${base}/buchungen`, admin)
assert.equal(body.stripeFehler, undefined)
const byId = Object.fromEntries(body.buchungen.map((b) => [b.$id, b]))
assert.equal(byId.ewv.vorschlaege.length, 1)
assert.equal(byId.ewv.vorschlaege[0].kind, 'invoice')
assert.equal(byId.ewv.vorschlaege[0].id, 'in_ewv')
assert.equal(byId.ewv.vorschlaege[0].number, 'WK-0005')
assert.equal(byId.ewv.vorschlaege[0].confidence, 'hoch')
assert.deepEqual(byId.aus.vorschlaege, [])
assert.deepEqual(byId.fertig.vorschlaege, [])
})
test('GET /buchungen: Stripe-Aufrufe nur, wenn es einen offenen Eingang gibt', async () => {
seedTx('aus', { amountCents: -449 })
seedTx('fertig', { category: 'privat' })
const body = await get(`${base}/buchungen`, admin)
assert.equal(body.buchungen.length, 2)
assert.equal(body.stripeFehler, undefined)
assert.equal(st.calls.length, 0)
})
for (const path of ['/v1/invoices', '/v1/payouts']) {
test(`GET /buchungen: Stripe-Fehler bei ${path} -> 200 mit stripeFehler und leeren Vorschlaegen`, async () => {
seedTx('ewv')
seedTx('aus', { amountCents: -449 })
st.invoices.set('in_ewv', rawInvoice())
st.failNext({ method: 'GET', path, status: 400, message: 'Geheimer Stripe-Text' })
const res = await rawGet(`${base}/buchungen`, admin)
assert.equal(res.status, 200)
assert.equal(res.body.stripeFehler, 'Stripe ist gerade nicht erreichbar. Zuordnungsvorschläge fehlen.')
assert.ok(!JSON.stringify(res.body).includes('Geheimer'))
assert.equal(res.body.buchungen.length, 2)
for (const buchung of res.body.buchungen) assert.deepEqual(buchung.vorschlaege, [])
})
}
+476
View File
@@ -0,0 +1,476 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { startFakeEnableBanking } from './support/fakeEnableBanking.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird (Muster wie financeBankSync.test.js)
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 })
const pem = (key, type) => key.export({ type, format: 'pem' })
const keyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bank-auth-test-'))
const keyPath = path.join(keyDir, 'private.pem')
fs.writeFileSync(keyPath, pem(privateKey, 'pkcs8'))
const OLD_ACCOUNT = 'konto-alt-4711'
const OLD_SESSION = 'sess-alt-geheim-4711'
const NEW_SESSION = 'sess-neu-geheim-4712'
const fake = await startFakeEnableBanking({ publicKeyPem: pem(publicKey, 'spki'), appId: 'app-test' })
const account = (uid, { hash, iban, currency = 'EUR', name = 'Webklar' }) => ({
uid,
identification_hash: hash,
account_id: iban ? { iban } : {},
name,
currency,
})
const aw = await startAppwriteMock({
schema: { bankConnections: schemaKeys('bankConnections'), bankTransactions: schemaKeys('bankTransactions') },
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
Object.assign(process.env, { ENABLE_BANKING_APP_ID: 'app-test', ENABLE_BANKING_KEY_PATH: keyPath, ENABLE_BANKING_API_BASE: fake.url })
const { config } = await import('../server/config.js')
const { startBankAuth, completeBankAuth, syncBank } = await import('../server/services/finance/bankSync.js')
const store = await import('../server/services/finance/bankStore.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
after(async () => {
await Promise.all([fake.close(), aw.close(), st.close()])
fs.rmSync(keyDir, { recursive: true, force: true })
})
const NOW = new Date('2026-10-01T09:00:00Z')
const MINUTE = 60_000
const later = (minutes) => new Date(NOW.getTime() + minutes * MINUTE)
const IBAN_A = 'DE00000000000000009313'
const IBAN_B = 'DE00000000000000001234'
const txDocs = () => aw.docs(config.collections.bankTransactions)
const connection = () => aw.docs(config.collections.bankConnections).get('finom')
const callsTo = (suffix) => fake.calls.filter((c) => c.path.endsWith(suffix))
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
/**
* Ausgangszustand: alter Zugang (Konto OLD_ACCOUNT, Hash h-alt, IBAN_A) mit Warnstufen und Fehler, keine Buchungen.
* Der Nachbau kennt danach nur das alte Konto und den Code 'neu' -> Sitzung NEW_SESSION mit dem Konto aus accounts.
*/
async function reset({ accounts = [account('konto-neu-1', { hash: 'h-alt', iban: IBAN_A })], stored = {}, validUntil = '2027-03-29T00:00:00.000Z' } = {}) {
for (const id of txDocs().keys()) await store.deleteTransaction(id)
await deleteDocument(config.collections.bankConnections, 'finom').catch(() => {})
fake.calls.length = 0
fake.accounts.clear()
fake.sessions.clear()
fake.codes.clear()
fake.transactions.clear()
fake.balances.clear()
fake.accounts.set(OLD_ACCOUNT, account(OLD_ACCOUNT, { hash: 'h-alt', iban: IBAN_A }))
for (const a of accounts) fake.accounts.set(a.uid, a)
fake.sessions.set('s-neu', {
session_id: NEW_SESSION,
status: 'AUTHORIZED',
accounts: accounts.map((a) => a.uid),
...(validUntil ? { access: { valid_until: validUntil, balances: true, transactions: true } } : {}),
aspsp: { name: 'Finom', country: 'DE' },
psu_type: 'business',
})
fake.codes.set('neu', 's-neu')
await store.saveConnection({
status: 'abgelaufen',
sessionId: OLD_SESSION,
accountUid: OLD_ACCOUNT,
accountIdHash: 'h-alt',
iban: IBAN_A,
ibanMasked: 'DE00…9313',
validUntil: '2026-10-02T00:00:00.000Z',
warnedThresholds: '30,14',
lastError: 'Bankzugang abgelaufen oder widerrufen',
lastSyncOk: false,
...stored,
})
}
/** Freigabe starten und den gespeicherten state lesen. */
async function begin(now = NOW) {
const { url } = await startBankAuth({ now })
return { url, state: connection().authState }
}
const rejects400 = (promise, message) =>
assert.rejects(promise, (err) => {
assert.equal(err.status, 400)
if (message instanceof RegExp) assert.match(err.message, message)
else assert.equal(err.message, message)
return true
})
// ---------- startBankAuth ----------
test('startBankAuth: liefert die Bank-Adresse, speichert state und Startzeit, Freigabe 179 Tage', async () => {
await reset()
const { url } = await startBankAuth({ now: NOW })
assert.match(url, /^http:\/\/127\.0\.0\.1:\d+\/fake-bank\/auth\?state=/)
const conn = connection()
assert.match(conn.authState, /^[a-f0-9]{24}$/)
assert.equal(conn.authStartedAt, '2026-10-01T09:00:00.000Z')
assert.ok(url.endsWith(`state=${conn.authState}`))
const [call] = callsTo('/auth')
assert.equal(callsTo('/auth').length, 1)
assert.equal(call.body.state, conn.authState)
assert.equal(call.body.access.valid_until, '2027-03-29T09:00:00.000Z')
assert.equal(call.body.psu_type, 'business')
assert.equal(call.body.redirect_url, config.enableBanking.redirectUrl)
// der laufende Zugang bleibt unberuehrt
assert.equal(conn.sessionId, OLD_SESSION)
assert.equal(conn.status, 'abgelaufen')
})
test('startBankAuth: jeder Start erzeugt einen neuen state, der alte gilt nicht mehr', async () => {
await reset()
const first = (await begin()).state
const second = (await begin()).state
assert.notEqual(first, second)
await rejects400(completeBankAuth({ code: 'neu', state: first }, { now: NOW }), 'Diese Rückkehr passt zu keiner gestarteten Freigabe.')
assert.equal(connection().sessionId, OLD_SESSION)
})
test('startBankAuth: legt die Verbindung an, wenn es noch keine gibt; scheitert die Bank, wird nichts gespeichert', async () => {
await reset()
await deleteDocument(config.collections.bankConnections, 'finom')
fake.failNext({ path: '/auth', status: 500 })
await assert.rejects(startBankAuth({ now: NOW }), /Freigabe starten/)
assert.equal(connection(), undefined)
await startBankAuth({ now: NOW })
assert.match(connection().authState, /^[a-f0-9]{24}$/)
})
// ---------- completeBankAuth: Erfolg ----------
test('completeBankAuth: richtiger state -> neue Sitzung, aktiv, Warnstufen und Fehler leer, Abruf gelaufen', async () => {
await reset()
fake.transactions.set('konto-neu-1', [
{
entry_reference: 'n1',
status: 'BOOK',
booking_date: '2026-09-29',
transaction_amount: { amount: '4.49', currency: 'EUR' },
credit_debit_indicator: 'DBIT',
creditor: { name: 'PORKBUN.COM' },
},
])
fake.balances.set('konto-neu-1', [{ balance_type: 'ITBD', balance_amount: { amount: '108.93', currency: 'EUR' }, reference_date: '2026-10-01' }])
const { state } = await begin()
const result = await completeBankAuth({ code: 'neu', state }, { now: later(5) })
assert.equal(result.ok, true)
assert.equal(result.validUntil, '2027-03-29T00:00:00.000Z')
assert.equal(result.sync.ok, true)
assert.equal(result.sync.neu, 1)
const conn = connection()
assert.equal(conn.sessionId, NEW_SESSION)
assert.equal(conn.accountUid, 'konto-neu-1')
assert.equal(conn.accountIdHash, 'h-alt')
assert.equal(conn.iban, IBAN_A)
assert.equal(conn.ibanMasked, 'DE00…9313')
assert.equal(conn.validUntil, '2027-03-29T00:00:00.000Z')
assert.equal(conn.status, 'aktiv')
assert.equal(conn.authState, '')
assert.equal(conn.warnedThresholds, '')
assert.equal(conn.lastError, '')
// der Abruf nach dem Speichern hat das neue Konto gelesen
assert.equal(conn.lastSyncOk, true)
assert.equal(conn.balanceBookedCents, 10893)
assert.equal(txDocs().size, 1)
assert.equal(callsTo('/balances').length, 1)
assert.match(callsTo('/balances')[0].path, /konto-neu-1/)
})
test('completeBankAuth: die Sitzungs-ID steht nie in der Antwort', async () => {
await reset()
const { state } = await begin()
const result = await completeBankAuth({ code: 'neu', state }, { now: NOW })
assert.ok(!JSON.stringify(result).includes(NEW_SESSION))
})
test('completeBankAuth: derselbe Rückkehr-Aufruf ein zweites Mal wird abgewiesen (state ist verbraucht)', async () => {
await reset()
const { state } = await begin()
await completeBankAuth({ code: 'neu', state }, { now: NOW })
fake.calls.length = 0
await rejects400(completeBankAuth({ code: 'neu', state }, { now: NOW }), 'Diese Rückkehr passt zu keiner gestarteten Freigabe.')
assert.equal(callsTo('/sessions').length, 0)
})
test('completeBankAuth: PSU-Kopfzeilen des Menschen gehen an den Abruf', async () => {
await reset()
const { state } = await begin()
await completeBankAuth({ code: 'neu', state }, { now: NOW, psu: { ip: '203.0.113.7', userAgent: 'Mozilla/5.0 Test' } })
const call = callsTo('/balances')[0]
assert.equal(call.headers['psu-ip-address'], '203.0.113.7')
assert.equal(call.headers['psu-user-agent'], 'Mozilla/5.0 Test')
})
test('completeBankAuth: ohne Ende der Freigabe von der Bank gilt die beantragte Frist (Start + 179 Tage)', async () => {
await reset({ validUntil: null })
const { state } = await begin()
const result = await completeBankAuth({ code: 'neu', state }, { now: later(3) })
assert.equal(result.validUntil, '2027-03-29T09:00:00.000Z')
assert.equal(connection().validUntil, '2027-03-29T09:00:00.000Z')
})
test('completeBankAuth: Ende der Freigabe in anderer ISO-Schreibweise wird als ISO-Zeitpunkt gespeichert', async () => {
await reset({ validUntil: '2027-03-29T00:00:00.000000+00:00' })
const { state } = await begin()
const result = await completeBankAuth({ code: 'neu', state }, { now: NOW })
assert.equal(result.validUntil, '2027-03-29T00:00:00.000Z')
})
test('completeBankAuth: genau 30 Minuten nach dem Start gilt noch', async () => {
await reset()
const { state } = await begin()
const result = await completeBankAuth({ code: 'neu', state }, { now: later(30) })
assert.equal(result.ok, true)
})
test('completeBankAuth: scheitert der Abruf danach, bleibt der neue Zugang gespeichert und der Fehler steht im Ergebnis', async () => {
await reset()
const { state } = await begin()
fake.failNext({ path: /\/balances$/, status: 500, body: { code: 500, error: 'FAKE_ERROR' } })
const result = await completeBankAuth({ code: 'neu', state }, { now: NOW })
assert.equal(result.ok, true)
assert.equal(result.sync.ok, false)
assert.ok(result.sync.error)
assert.equal(connection().sessionId, NEW_SESSION)
assert.equal(connection().status, 'aktiv')
})
// ---------- completeBankAuth: Fehler ----------
test('completeBankAuth: falscher state -> 400, alte Sitzung bleibt, der Code wird nicht eingelöst', async () => {
await reset()
const { state } = await begin()
fake.calls.length = 0
await rejects400(completeBankAuth({ code: 'neu', state: `${state}x` }, { now: NOW }), 'Diese Rückkehr passt zu keiner gestarteten Freigabe.')
await rejects400(completeBankAuth({ code: 'neu', state: 'ffffffffffffffffffffffff' }, { now: NOW }), 'Diese Rückkehr passt zu keiner gestarteten Freigabe.')
assert.equal(connection().sessionId, OLD_SESSION)
assert.equal(connection().status, 'abgelaufen')
assert.equal(connection().warnedThresholds, '30,14')
assert.equal(callsTo('/sessions').length, 0)
// die gestartete Freigabe ist weiter gültig
assert.equal(connection().authState, state)
})
test('completeBankAuth: leerer oder fehlender state passt nie, auch wenn keine Freigabe gestartet wurde', async () => {
await reset()
// keine Freigabe gestartet: authState nicht gesetzt
await rejects400(completeBankAuth({ code: 'neu', state: '' }, { now: NOW }), 'Diese Rückkehr passt zu keiner gestarteten Freigabe.')
await rejects400(completeBankAuth({ code: 'neu' }, { now: NOW }), 'Diese Rückkehr passt zu keiner gestarteten Freigabe.')
// gespeicherter state leer (nach Abschluss), Rückkehr ebenfalls leer
await reset({ stored: { authState: '', authStartedAt: '' } })
await rejects400(completeBankAuth({ code: 'neu', state: '' }, { now: NOW }), 'Diese Rückkehr passt zu keiner gestarteten Freigabe.')
// gar keine Verbindung
await deleteDocument(config.collections.bankConnections, 'finom')
await rejects400(completeBankAuth({ code: 'neu', state: 'abc' }, { now: NOW }), 'Diese Rückkehr passt zu keiner gestarteten Freigabe.')
assert.equal(callsTo('/sessions').length, 0)
})
test('completeBankAuth: state und code, die keine Zeichenketten sind, werden wie leer behandelt', async () => {
await reset()
const { state } = await begin()
await rejects400(completeBankAuth({ code: 'neu', state: [state, state] }, { now: NOW }), 'Diese Rückkehr passt zu keiner gestarteten Freigabe.')
await rejects400(completeBankAuth({ code: ['neu'], state }, { now: NOW }), /code/i)
assert.equal(callsTo('/sessions').length, 0)
})
test('completeBankAuth: Start 31 Minuten her -> 400 abgelaufen, alte Sitzung bleibt', async () => {
await reset()
const { state } = await begin()
fake.calls.length = 0
await rejects400(completeBankAuth({ code: 'neu', state }, { now: later(31) }), 'Die Freigabe ist abgelaufen. Bitte neu starten.')
assert.equal(connection().sessionId, OLD_SESSION)
assert.equal(callsTo('/sessions').length, 0)
})
test('completeBankAuth: ohne lesbare Startzeit gilt die Freigabe als abgelaufen', async () => {
await reset({ stored: { authState: 'a'.repeat(24), authStartedAt: '' } })
await rejects400(completeBankAuth({ code: 'neu', state: 'a'.repeat(24) }, { now: NOW }), 'Die Freigabe ist abgelaufen. Bitte neu starten.')
await reset({ stored: { authState: 'a'.repeat(24), authStartedAt: 'gestern' } })
await rejects400(completeBankAuth({ code: 'neu', state: 'a'.repeat(24) }, { now: NOW }), 'Die Freigabe ist abgelaufen. Bitte neu starten.')
})
test('completeBankAuth: error von der Bank -> 400 mit dem Fehlercode, alte Sitzung bleibt', async () => {
await reset()
const { state } = await begin()
fake.calls.length = 0
await rejects400(completeBankAuth({ error: 'access_denied', state }, { now: NOW }), 'Finom hat die Freigabe nicht erteilt (access_denied).')
assert.equal(connection().sessionId, OLD_SESSION)
assert.equal(callsTo('/sessions').length, 0)
})
test('completeBankAuth: error mit falschem state meldet zuerst die fremde Rückkehr; ein sehr langer error wird gekürzt', async () => {
await reset()
const { state } = await begin()
await rejects400(completeBankAuth({ error: 'access_denied', state: 'nope' }, { now: NOW }), 'Diese Rückkehr passt zu keiner gestarteten Freigabe.')
await assert.rejects(completeBankAuth({ error: 'x'.repeat(5000), state }, { now: NOW }), (err) => {
assert.equal(err.status, 400)
assert.ok(err.message.length < 200)
return true
})
})
test('completeBankAuth: Rückkehr ohne Code und ohne Fehler -> 400', async () => {
await reset()
const { state } = await begin()
await rejects400(completeBankAuth({ state }, { now: NOW }), /code/i)
assert.equal(connection().sessionId, OLD_SESSION)
})
test('completeBankAuth: lehnt die Bank den Code ab, bleibt der alte Zugang, die Meldung nennt keine Sitzungs-ID', async () => {
await reset()
const { state } = await begin()
await assert.rejects(completeBankAuth({ code: 'falsch', state }, { now: NOW }), (err) => {
assert.match(err.message, /Sitzung anlegen/)
assert.ok(!err.message.includes(OLD_SESSION) && !err.message.includes(NEW_SESSION))
return true
})
assert.equal(connection().sessionId, OLD_SESSION)
assert.equal(connection().authState, state)
})
// ---------- completeBankAuth: Kontowahl ----------
const TWO = [account('konto-eins', { hash: 'h-eins', iban: IBAN_B }), account('konto-zwei', { hash: 'h-alt', iban: IBAN_A })]
test('Kontowahl: gespeicherter identificationHash passt aufs zweite Konto -> zweites gewählt', async () => {
await reset({ accounts: TWO })
const { state } = await begin()
await completeBankAuth({ code: 'neu', state }, { now: NOW })
assert.equal(connection().accountUid, 'konto-zwei')
assert.equal(connection().accountIdHash, 'h-alt')
assert.equal(connection().iban, IBAN_A)
})
test('Kontowahl: der Hash gewinnt gegen eine passende IBAN eines anderen Kontos', async () => {
const accounts = [account('konto-eins', { hash: 'h-neu', iban: IBAN_A }), account('konto-zwei', { hash: 'h-alt', iban: IBAN_B })]
await reset({ accounts })
const { state } = await begin()
await completeBankAuth({ code: 'neu', state }, { now: NOW })
assert.equal(connection().accountUid, 'konto-zwei')
})
test('Kontowahl: ohne Hash-Treffer entscheidet die gleiche IBAN (auch mit Leerzeichen und Kleinschreibung)', async () => {
const accounts = [account('konto-eins', { hash: 'h-x', iban: IBAN_B }), account('konto-zwei', { hash: 'h-y', iban: IBAN_A.toLowerCase().replace(/(.{4})/g, '$1 ').trim() })]
await reset({ accounts })
const { state } = await begin()
await completeBankAuth({ code: 'neu', state }, { now: NOW })
assert.equal(connection().accountUid, 'konto-zwei')
})
test('Kontowahl: ohne Treffer das erste Euro-Konto; ein anderes Konto setzt den Abrufbeginn zurück', async () => {
const accounts = [account('konto-usd', { hash: 'h-1', iban: IBAN_B, currency: 'USD' }), account('konto-eur', { hash: 'h-2', iban: 'DE00000000000000005555' }), account('konto-eur-2', { hash: 'h-3', iban: 'DE00000000000000006666' })]
await reset({ accounts, stored: { latestBookingDate: '2026-09-29' } })
const { state } = await begin()
await completeBankAuth({ code: 'neu', state }, { now: NOW })
assert.equal(connection().accountUid, 'konto-eur')
assert.equal(connection().accountIdHash, 'h-2')
assert.equal(connection().ibanMasked, 'DE00…5555')
// der Abruf beginnt beim neuen Konto nicht erst bei der letzten Buchung des alten
assert.equal(callsTo('/transactions')[0].query.date_from, '2026-07-04')
})
test('Kontowahl: dasselbe Konto behält den Abrufbeginn (nur ab letzter Buchung minus 7 Tage)', async () => {
await reset({ stored: { latestBookingDate: '2026-09-29' } })
const { state } = await begin()
await completeBankAuth({ code: 'neu', state }, { now: NOW })
assert.equal(callsTo('/transactions')[0].query.date_from, '2026-09-22')
})
test('Kontowahl: kein Euro-Konto in der Freigabe -> 400, alter Zugang bleibt', async () => {
await reset({ accounts: [account('konto-usd', { hash: 'h-1', iban: IBAN_B, currency: 'USD' })], stored: { accountIdHash: 'h-9', iban: 'DE99' } })
const { state } = await begin()
await rejects400(completeBankAuth({ code: 'neu', state }, { now: NOW }), 'Kein Euro-Konto in der Freigabe.')
assert.equal(connection().sessionId, OLD_SESSION)
assert.equal(connection().status, 'abgelaufen')
assert.equal(connection().authState, state)
// keine Konten überhaupt
await reset({ accounts: [] })
const again = await begin()
await rejects400(completeBankAuth({ code: 'neu', state: again.state }, { now: NOW }), 'Kein Euro-Konto in der Freigabe.')
})
test('Kontowahl: ohne gespeicherte Konto-Kennung (Übernahme) zählt das erste Euro-Konto, leere Werte treffen nicht', async () => {
// gespeicherter Hash und IBAN leer, das erste Konto der Freigabe hat ebenfalls keinen Hash und keine IBAN
const accounts = [{ uid: 'konto-eins', name: 'Webklar', currency: 'EUR' }, account('konto-zwei', { hash: 'h-2', iban: IBAN_A })]
await reset({ accounts, stored: { accountIdHash: '', iban: '' } })
const { state } = await begin()
await completeBankAuth({ code: 'neu', state }, { now: NOW })
assert.equal(connection().accountUid, 'konto-eins')
assert.equal(connection().accountIdHash, '')
assert.equal(connection().iban, '')
assert.equal(connection().ibanMasked, '')
})
// ---------- Rennen mit einem laufenden Abruf ----------
test('completeBankAuth: wartet auf einen laufenden Abruf; dessen spätes Ergebnis überschreibt den neuen Zugang nicht', async () => {
// alter Zugang aktiv; der laufende Abruf bekommt (verspätet) ein 401 und würde den Zugang auf "abgelaufen" setzen
await reset({ stored: { status: 'aktiv', warnedThresholds: '', lastError: '' } })
fake.balances.set('konto-neu-1', [{ balance_type: 'ITBD', balance_amount: { amount: '5.00', currency: 'EUR' }, reference_date: '2026-10-01' }])
const { state } = await begin()
const hold = fake.holdNext({ path: /\/balances$/ })
fake.failNext({ path: /\/balances$/, status: 401, body: { code: 401, error: 'UNAUTHORIZED' } })
const running = syncBank({ now: NOW })
await hold.reached
let finished = false
const completing = completeBankAuth({ code: 'neu', state }, { now: NOW }).then((result) => {
finished = true
return result
})
await sleep(150)
// der laufende Abruf hält die Sperre: der neue Zugang ist noch nicht geschrieben
assert.equal(finished, false)
assert.equal(connection().sessionId, OLD_SESSION)
assert.equal(connection().authState, state)
hold.release()
const [oldSync, result] = await Promise.all([running, completing])
// der alte Abruf ist wie geplant am 401 gescheitert ...
assert.equal(oldSync.ok, false)
// ... aber erst danach wurde der neue Zugang geschrieben und frisch abgerufen
assert.equal(result.ok, true)
assert.equal(result.sync.ok, true)
const conn = connection()
assert.equal(conn.sessionId, NEW_SESSION)
assert.equal(conn.accountUid, 'konto-neu-1')
assert.equal(conn.status, 'aktiv')
assert.equal(conn.lastError, '')
assert.equal(conn.lastSyncOk, true)
assert.equal(conn.balanceBookedCents, 500)
assert.equal(conn.authState, '')
// die Sitzung wurde erst nach dem Ende des alten Abrufs angelegt
const order = fake.calls.map((c) => c.path)
assert.ok(order.findIndex((p) => p === '/sessions') > order.findIndex((p) => p.endsWith('/balances')))
})
test('completeBankAuth: ein Abruf direkt danach läuft nicht in die Sperre der Rückkehr (kein Deadlock)', async () => {
await reset()
const { state } = await begin()
const [result, other] = await Promise.all([completeBankAuth({ code: 'neu', state }, { now: NOW }), syncBank({ now: NOW })])
assert.equal(result.ok, true)
assert.equal(typeof other.ok, 'boolean')
})
+229
View File
@@ -0,0 +1,229 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { mailSink } from './support/mailSink.js'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird (Muster wie financeBankSync.test.js)
const aw = await startAppwriteMock({
schema: { bankConnections: schemaKeys('bankConnections'), bankTransactions: schemaKeys('bankTransactions') },
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { checkBankConsent } = await import('../server/services/finance/bankSync.js')
const store = await import('../server/services/finance/bankStore.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const mails = mailSink()
setMailTransportForTests(mails)
after(async () => {
setMailTransportForTests(null)
await Promise.all([aw.close(), st.close()])
})
// "Jetzt" der Pruefung: 01.10.2026 11:00 Berliner Zeit
const NOW = new Date('2026-10-01T09:00:00Z')
const DAY_MS = 86_400_000
const SESSION = 'sess-geheim-4711'
const ACCOUNT = 'konto-uid-4711'
const connection = () => aw.docs(config.collections.bankConnections).get('finom')
/** validUntil, das von NOW aus gesehen in tage Tagen (zur selben Uhrzeit) liegt. */
const inDays = (tage, from = NOW) => new Date(from.getTime() + tage * DAY_MS).toISOString()
const connect = (patch = {}) => store.saveConnection({ status: 'aktiv', sessionId: SESSION, accountUid: ACCOUNT, warnedThresholds: '', ...patch })
beforeEach(async () => {
await deleteDocument(config.collections.bankConnections, 'finom').catch(() => {})
mails.sent.length = 0
setMailTransportForTests(mails)
})
test('29 Tage: Warnung bei 30 Tagen, am selben Tag nicht noch einmal', async () => {
await connect({ validUntil: inDays(29) })
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: 30 })
assert.equal(mails.sent.length, 1)
const [mail] = mails.sent
assert.equal(mail.subject, '[Finanzen] Bankzugang Finom läuft in 29 Tagen ab')
assert.match(mail.html, /30\.10\.2026/)
assert.ok(mail.html.includes(`${config.womsBaseUrl}/finance/einstellungen`), 'Link zu WOMS Finanzen Einstellungen')
assert.doesNotMatch(mail.html, /Der Zugang ist bereits abgelaufen/)
assert.equal(connection().warnedThresholds, '30')
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: null })
assert.deepEqual(await checkBankConsent({ now: new Date(NOW.getTime() + 3 * 3_600_000) }), { sent: null })
assert.equal(mails.sent.length, 1, 'keine zweite Mail')
})
test('der Beispielwert 29.03.2027 steht als gueltig_bis in der Mail', async () => {
await connect({ validUntil: '2027-03-29T00:00:00.000Z' })
assert.deepEqual(await checkBankConsent({ now: new Date('2027-03-10T09:00:00Z') }), { sent: 30 })
assert.match(mails.sent[0].html, /29\.03\.2027/)
assert.match(mails.sent[0].text, /29\.03\.2027/)
})
test('13 Tage: Schwelle 14 (die darueberliegende 30 gilt damit als erledigt)', async () => {
await connect({ validUntil: inDays(13) })
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: 14 })
assert.equal(mails.sent[0].subject, '[Finanzen] Bankzugang Finom läuft in 13 Tagen ab')
assert.equal(connection().warnedThresholds, '30,14')
// am naechsten Tag weder 14 noch 30
assert.deepEqual(await checkBankConsent({ now: new Date(NOW.getTime() + DAY_MS) }), { sent: null })
assert.equal(mails.sent.length, 1)
})
test('mehr als 30 Tage: keine Mail', async () => {
await connect({ validUntil: inDays(31) })
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: null })
assert.equal(mails.sent.length, 0)
assert.equal(connection().warnedThresholds, '')
})
test('Schwellen nacheinander: 30, 14, 3, am Ablauftag 0, jeweils einmal', async () => {
// Ablauf am 10.11. um 14:00 Berliner Zeit: am Ablauftag um 11:00 gilt der Zugang noch
await connect({ validUntil: new Date(NOW.getTime() + 40 * DAY_MS + 3 * 3_600_000).toISOString() })
const sentAt = []
for (let day = 0; day <= 41; day += 1) {
const now = new Date(NOW.getTime() + day * DAY_MS)
const { sent } = await checkBankConsent({ now })
if (sent !== null) sentAt.push([40 - day, sent])
}
// [verbleibende Tage, Schwelle]; 41 = der Tag nach dem Ablauf: 0 war schon gemeldet
assert.deepEqual(sentAt, [[30, 30], [14, 14], [3, 3], [0, 0]])
assert.deepEqual(
mails.sent.map((m) => m.subject),
[
'[Finanzen] Bankzugang Finom läuft in 30 Tagen ab',
'[Finanzen] Bankzugang Finom läuft in 14 Tagen ab',
'[Finanzen] Bankzugang Finom läuft in 3 Tagen ab',
'[Finanzen] Bankzugang Finom läuft heute ab',
]
)
assert.equal(connection().warnedThresholds, '30,14,3,0')
})
test('Betreff: 2 Tage, morgen, heute (noch gueltig) und abgelaufen', async () => {
const cases = [
[2, 3, 'läuft in 2 Tagen ab'],
[1, 3, 'läuft morgen ab'],
[0, 0, 'läuft heute ab'],
]
for (const [tage, threshold, text] of cases) {
await deleteDocument(config.collections.bankConnections, 'finom').catch(() => {})
mails.sent.length = 0
// gleicher Berliner Tag wie NOW, aber noch in der Zukunft (13:00 Berliner Zeit)
const validUntil = tage === 0 ? '2026-10-01T11:00:00.000Z' : inDays(tage)
await connect({ validUntil })
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: threshold }, text)
assert.equal(mails.sent[0].subject, `[Finanzen] Bankzugang Finom ${text}`)
assert.doesNotMatch(mails.sent[0].html, /Der Zugang ist bereits abgelaufen/, text)
}
})
test('abgelaufen: Schwelle 0, Betreff "ist abgelaufen", Block abgelaufen, nur einmal', async () => {
await connect({ validUntil: inDays(-3) })
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: 0 })
const [mail] = mails.sent
assert.equal(mail.subject, '[Finanzen] Bankzugang Finom ist abgelaufen')
assert.match(mail.html, /Der Zugang ist bereits abgelaufen/)
assert.match(mail.html, /28\.09\.2026/)
assert.equal(connection().warnedThresholds, '30,14,3,0')
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: null })
assert.equal(mails.sent.length, 1)
})
test('Ablaufzeit heute schon vorbei: abgelaufen, nicht "heute"', async () => {
await connect({ validUntil: '2026-10-01T06:00:00.000Z' })
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: 0 })
assert.equal(mails.sent[0].subject, '[Finanzen] Bankzugang Finom ist abgelaufen')
})
test('der Berliner Kalendertag zaehlt (Ablauf kurz nach Mitternacht, spaeter Abend davor)', async () => {
// 23:30 Berliner Zeit am 01.10.; Ablauf 02.10. 00:30 Berliner Zeit -> morgen
await connect({ validUntil: '2026-10-01T22:30:00.000Z' })
assert.deepEqual(await checkBankConsent({ now: new Date('2026-10-01T21:30:00Z') }), { sent: 3 })
assert.equal(mails.sent[0].subject, '[Finanzen] Bankzugang Finom läuft morgen ab')
assert.match(mails.sent[0].html, /02\.10\.2026/, 'gueltig_bis ist der Berliner Tag')
})
test('keine Verbindung, kein oder kaputtes validUntil: nichts zu tun', async () => {
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: null })
await connect({ validUntil: '' })
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: null })
await connect({ validUntil: 'morgen' })
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: null })
assert.equal(mails.sent.length, 0)
})
test('Versand scheitert: Schwelle bleibt offen und wird beim naechsten Lauf nachgeholt', async () => {
await connect({ validUntil: inDays(29) })
setMailTransportForTests({
async sendMail() {
throw new Error('SMTP down')
},
})
const originalError = console.error
console.error = () => {}
try {
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: null })
} finally {
console.error = originalError
}
assert.equal(connection().warnedThresholds, '')
setMailTransportForTests(mails)
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: 30 })
assert.equal(mails.sent.length, 1)
})
test('ohne Mailversand (SMTP nicht eingerichtet) bleibt die Schwelle offen', async () => {
await connect({ validUntil: inDays(29) })
setMailTransportForTests(null)
// useTestEnv leert SMTP_HOST: sendAdminNotice meldet "nicht konfiguriert"
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: null })
assert.equal(connection().warnedThresholds, '')
})
test('keine Sitzungs-ID, kein Kontoschluessel in der Mail', async () => {
await connect({ validUntil: inDays(2) })
await checkBankConsent({ now: NOW })
const [mail] = mails.sent
for (const secret of [SESSION, ACCOUNT]) {
assert.ok(!mail.html.includes(secret) && !mail.text.includes(secret) && !mail.subject.includes(secret))
}
})
test('Mail an das Team (chatNotify) mit Text- und HTML-Fassung', async () => {
await connect({ validUntil: inDays(2) })
await checkBankConsent({ now: NOW })
const [mail] = mails.sent
assert.deepEqual(mail.to.map((t) => t.address).sort(), [...config.chatNotify.emails].sort())
assert.match(mail.text, /Neu verbinden/)
assert.match(mail.text, /Kontostand und Umsätze/)
assert.match(mail.html, /Neu verbinden/)
})
test('Neuverbindung loescht die Warnstufen: die naechste Frist warnt wieder bei 30', async () => {
await connect({ validUntil: inDays(2), warnedThresholds: '30,14,3' })
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: null })
// Erneuerung wie in completeBankAuth: neue Frist, Warnstufen leer
await store.saveConnection({ validUntil: inDays(179), warnedThresholds: '' })
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: null }, 'noch weit weg')
assert.deepEqual(await checkBankConsent({ now: new Date(NOW.getTime() + 150 * DAY_MS) }), { sent: 30 })
})
test('Frist zwischen Pruefung und Speichern erneuert: die neuen Warnstufen bleiben leer', async () => {
await connect({ validUntil: inDays(29) })
// Der Versand dauert; waehrenddessen wird der Zugang erneuert (neue Frist, Warnstufen geleert)
setMailTransportForTests({
async sendMail(mail) {
await store.saveConnection({ validUntil: inDays(179), warnedThresholds: '' })
return mails.sendMail(mail)
},
})
assert.deepEqual(await checkBankConsent({ now: NOW }), { sent: 30 })
assert.equal(connection().warnedThresholds, '', 'die Erneuerung wurde nicht mit der alten Schwelle ueberschrieben')
})
+689
View File
@@ -0,0 +1,689 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { startFakeEnableBanking } from './support/fakeEnableBanking.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { mailSink } from './support/mailSink.js'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird (Muster wie aboRoutes.test.js)
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 })
const pem = (key, type) => key.export({ type, format: 'pem' })
const keyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bank-sync-test-'))
const keyPath = path.join(keyDir, 'private.pem')
fs.writeFileSync(keyPath, pem(privateKey, 'pkcs8'))
const ACCOUNT = 'konto-uid-4711'
const SESSION = 'sess-geheim-4711'
const fake = await startFakeEnableBanking({ publicKeyPem: pem(publicKey, 'spki'), appId: 'app-test' })
fake.accounts.set(ACCOUNT, { uid: ACCOUNT, identification_hash: 'h1', account_id: { iban: 'DE00000000000000009313' }, name: 'Webklar', currency: 'EUR' })
const aw = await startAppwriteMock({
schema: { bankConnections: schemaKeys('bankConnections'), bankTransactions: schemaKeys('bankTransactions') },
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
Object.assign(process.env, { ENABLE_BANKING_APP_ID: 'app-test', ENABLE_BANKING_KEY_PATH: keyPath, ENABLE_BANKING_API_BASE: fake.url })
const { config } = await import('../server/config.js')
const { syncBank, transactionDocId, toTransactionDoc, shouldRunStartupSync, checkBankConsent } = await import('../server/services/finance/bankSync.js')
const store = await import('../server/services/finance/bankStore.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { addDays, berlinDate } = await import('../server/services/finance/dates.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const mails = mailSink()
setMailTransportForTests(mails)
after(async () => {
setMailTransportForTests(null)
await Promise.all([fake.close(), aw.close(), st.close()])
fs.rmSync(keyDir, { recursive: true, force: true })
})
// "Jetzt" der Abrufe: 01.10.2026 11:00 Berliner Zeit, heute - 89 Tage = 04.07.2026
const NOW = new Date('2026-10-01T09:00:00Z')
const TODAY = '2026-10-01'
const WINDOW_START = '2026-07-04'
/** Rohbuchung in der Form von Enable Banking (Finom). */
function raw({ ref, date, amount, indicator = 'DBIT', name = '', iban = null, mcc = null, remittance = [], status = 'BOOK', extra = {} }) {
const party = indicator === 'CRDT' ? 'debtor' : 'creditor'
return {
...(ref ? { entry_reference: ref } : {}),
status,
booking_date: date,
value_date: date,
transaction_amount: { amount, currency: 'EUR' },
credit_debit_indicator: indicator,
[party]: { name },
...(iban ? { [`${party}_account`]: { iban } } : {}),
...(mcc ? { merchant_category_code: mcc } : {}),
remittance_information: remittance,
...extra,
}
}
// Die neun Buchungen des echten Abrufs vom 01.10.2026 (IBANs gekuerzt/ersetzt)
const nine = () => [
raw({ ref: 'r1', date: '2026-08-05', amount: '168.79', indicator: 'CRDT', name: 'EWV SOFTWARE GMBH', iban: 'DE00000000000000008511', remittance: ['Rechnung 0005 vom 28.07.2026'] }),
raw({ ref: 'r2', date: '2026-08-16', amount: '43.58', name: 'OPENROUTER, INC', mcc: '5734' }),
raw({ ref: 'r3', date: '2026-09-15', amount: '1.31', name: 'PNL Fintech B.V.', iban: 'NL00000000000000003855' }),
raw({ ref: 'r4', date: '2026-09-27', amount: '0.5', name: 'WEBKLAR WARTUNG', mcc: '7372' }),
raw({ ref: 'r5', date: '2026-09-27', amount: '0.5', name: 'WEBKLAR WARTUNG', mcc: '7372' }),
raw({ ref: 'r6', date: '2026-09-27', amount: '0.5', name: 'WEBKLAR WARTUNG', mcc: '7372' }),
raw({ ref: 'r7', date: '2026-09-27', amount: '4.49', name: 'PORKBUN.COM', mcc: '4816' }),
raw({ ref: 'r8', date: '2026-09-29', amount: '4.49', name: 'PORKBUN.COM', mcc: '4816' }),
raw({ ref: 'r9', date: '2026-09-29', amount: '4.49', name: 'PORKBUN.COM', mcc: '4816' }),
]
const balances = () => [
{ balance_type: 'ITAV', balance_amount: { amount: '108.93', currency: 'EUR' }, reference_date: '2026-10-01' },
{ balance_type: 'ITBD', balance_amount: { amount: '108.93', currency: 'EUR' }, reference_date: '2026-10-01' },
{ balance_type: 'CLBD', balance_amount: { amount: '108.93', currency: 'EUR' }, reference_date: '2026-09-30' },
]
const docId = (ref) => transactionDocId({ entry_reference: ref })
const txDocs = () => aw.docs(config.collections.bankTransactions)
const connection = () => aw.docs(config.collections.bankConnections).get('finom')
const callsTo = (suffix) => fake.calls.filter((c) => c.path.endsWith(suffix))
/** Leerer Ausgangszustand: keine Buchungen, keine Verbindung, Nachbau mit den neun Buchungen und Salden. */
async function reset({ transactions = nine(), bal = balances() } = {}) {
for (const id of txDocs().keys()) await store.deleteTransaction(id)
await deleteDocument(config.collections.bankConnections, 'finom').catch(() => {})
fake.calls.length = 0
mails.sent.length = 0
fake.transactions.set(ACCOUNT, transactions)
fake.balances.set(ACCOUNT, bal)
}
const connect = (patch = {}) =>
store.saveConnection({ status: 'aktiv', sessionId: SESSION, accountUid: ACCOUNT, validUntil: '2027-03-29T00:00:00.000Z', ...patch })
// ---------- Aufbereitung einer Buchung ----------
test('toTransactionDoc: Eingang mit Zahler, IBAN und Verwendungszweck', () => {
const doc = toTransactionDoc(nine()[0], { accountUid: ACCOUNT, syncedAt: '2026-10-01T09:00:00.000Z' })
assert.equal(doc.$id, docId('r1'))
assert.equal(doc.amountCents, 16879)
assert.equal(doc.counterpartyName, 'EWV SOFTWARE GMBH')
assert.equal(doc.counterpartyIban, 'DE00000000000000008511')
assert.equal(doc.remittance, 'Rechnung 0005 vom 28.07.2026')
assert.equal(doc.status, 'BOOK')
assert.equal(doc.bookingDate, '2026-08-05')
assert.equal(doc.valueDate, '2026-08-05')
assert.equal(doc.currency, 'EUR')
assert.equal(doc.mcc, '')
assert.equal(doc.entryReference, 'r1')
assert.equal(doc.accountUid, ACCOUNT)
assert.equal(doc.syncedAt, '2026-10-01T09:00:00.000Z')
})
test('toTransactionDoc: Kartenzahlung ist negativ, hat MCC und keine IBAN', () => {
const doc = toTransactionDoc(nine()[7], { accountUid: ACCOUNT, syncedAt: 'x' })
assert.equal(doc.amountCents, -449)
assert.equal(doc.mcc, '4816')
assert.equal(doc.counterpartyName, 'PORKBUN.COM')
assert.equal(doc.counterpartyIban, '')
assert.equal(doc.remittance, '')
// halbe Euro: 0.5 -> 50 Cent
assert.equal(toTransactionDoc(nine()[3], { accountUid: ACCOUNT, syncedAt: 'x' }).amountCents, -50)
})
test('toTransactionDoc: Ausgang liest den Empfaenger, Eingang den Zahler (nicht die eigene Seite)', () => {
const dbit = raw({ ref: 'x', date: '2026-09-01', amount: '10', name: 'EMPFAENGER' })
dbit.debtor = { name: 'WEBKLAR (eigene Seite)' }
assert.equal(toTransactionDoc(dbit, { accountUid: ACCOUNT, syncedAt: 'x' }).counterpartyName, 'EMPFAENGER')
const crdt = raw({ ref: 'y', date: '2026-09-01', amount: '10', indicator: 'CRDT', name: 'ZAHLER' })
crdt.creditor = { name: 'WEBKLAR (eigene Seite)' }
assert.equal(toTransactionDoc(crdt, { accountUid: ACCOUNT, syncedAt: 'x' }).counterpartyName, 'ZAHLER')
})
test('toTransactionDoc: IBAN auch aus other.identification, mehrere Zweckzeilen, Kuerzung auf 1000', () => {
const sepa = raw({ ref: 'a', date: '2026-09-01', amount: '1', name: 'Konto ohne IBAN' })
sepa.creditor_account = { other: { identification: 'ABC123', scheme_name: 'BBAN' } }
assert.equal(toTransactionDoc(sepa, { accountUid: ACCOUNT, syncedAt: 'x' }).counterpartyIban, 'ABC123')
const lines = raw({ ref: 'b', date: '2026-09-01', amount: '1', name: 'N', remittance: ['Rechnung 1', 'Kunde 2'] })
assert.equal(toTransactionDoc(lines, { accountUid: ACCOUNT, syncedAt: 'x' }).remittance, 'Rechnung 1 Kunde 2')
const long = raw({ ref: 'c', date: '2026-09-01', amount: '1', name: 'N', remittance: ['x'.repeat(1500)] })
assert.equal(toTransactionDoc(long, { accountUid: ACCOUNT, syncedAt: 'x' }).remittance.length, 1000)
})
test('toTransactionDoc: Feldlaengen passen zum Schema, Vormerkung ohne Buchungsdatum nimmt das Wertstellungsdatum', () => {
const pending = raw({ ref: 'p', date: undefined, amount: '2.5', name: 'N'.repeat(400), status: 'PDNG' })
delete pending.booking_date
pending.value_date = '2026-09-30'
const doc = toTransactionDoc(pending, { accountUid: ACCOUNT, syncedAt: 'x' })
assert.equal(doc.bookingDate, '2026-09-30')
assert.equal(doc.status, 'PDNG')
assert.equal(doc.counterpartyName.length, 255)
})
test('toTransactionDoc: unbekannte Richtung oder kaputter Betrag wird abgelehnt statt geraten', () => {
const noDirection = nine()[1]
delete noDirection.credit_debit_indicator
assert.throws(() => toTransactionDoc(noDirection, { accountUid: ACCOUNT, syncedAt: 'x' }), /Richtung/)
const broken = nine()[1]
broken.transaction_amount.amount = '4,49 EUR'
assert.throws(() => toTransactionDoc(broken, { accountUid: ACCOUNT, syncedAt: 'x' }), /Betrag/)
})
test('transactionDocId: gleiche entry_reference -> gleiche ID, 36 Hex-Zeichen, Hash der Referenz', () => {
const a = transactionDocId({ entry_reference: 'ref-1', booking_date: '2026-01-01' })
const b = transactionDocId({ entry_reference: 'ref-1', booking_date: '2026-02-02', transaction_amount: { amount: '9.99' } })
assert.equal(a, b)
assert.match(a, /^[a-f0-9]{36}$/)
assert.equal(a, crypto.createHash('sha256').update('ref-1').digest('hex').slice(0, 36))
assert.notEqual(a, transactionDocId({ entry_reference: 'ref-2' }))
})
test('transactionDocId: ohne entry_reference zaehlt transaction_id, danach Datum|Betrag|Gegenpartei|Zweck', () => {
const viaTransactionId = transactionDocId({ transaction_id: 't-77', booking_date: '2026-01-01' })
assert.equal(viaTransactionId, crypto.createHash('sha256').update('t-77').digest('hex').slice(0, 36))
const base = raw({ date: '2026-09-27', amount: '4.49', name: 'PORKBUN.COM', remittance: ['Domain'] })
const id = transactionDocId(base)
assert.match(id, /^[a-f0-9]{36}$/)
// stabil: gleiche Angaben (auch anders formatierter Betrag) -> gleiche ID
assert.equal(id, transactionDocId(raw({ date: '2026-09-27', amount: '4.490', name: 'PORKBUN.COM', remittance: ['Domain'] })))
// jede der vier Angaben aendert die ID, ebenso die Richtung
assert.notEqual(id, transactionDocId(raw({ date: '2026-09-28', amount: '4.49', name: 'PORKBUN.COM', remittance: ['Domain'] })))
assert.notEqual(id, transactionDocId(raw({ date: '2026-09-27', amount: '4.50', name: 'PORKBUN.COM', remittance: ['Domain'] })))
assert.notEqual(id, transactionDocId(raw({ date: '2026-09-27', amount: '4.49', name: 'ANDERER', remittance: ['Domain'] })))
assert.notEqual(id, transactionDocId(raw({ date: '2026-09-27', amount: '4.49', name: 'PORKBUN.COM', remittance: ['Anderes'] })))
assert.notEqual(id, transactionDocId(raw({ date: '2026-09-27', amount: '4.49', indicator: 'CRDT', name: 'PORKBUN.COM', remittance: ['Domain'] })))
})
// ---------- bankStore ----------
test('bankStore: Verbindung anlegen, aktualisieren, fehlende Verbindung ist null', async () => {
await reset()
assert.equal(await store.getConnection(), null)
const created = await store.saveConnection({ status: 'aktiv', accountUid: ACCOUNT, sessionId: SESSION })
assert.equal(created.$id, 'finom')
assert.equal((await store.getConnection()).accountUid, ACCOUNT)
const updated = await store.saveConnection({ lastError: 'x', lastSyncOk: false })
assert.equal(updated.lastError, 'x')
assert.equal(updated.status, 'aktiv')
assert.equal(aw.docs(config.collections.bankConnections).size, 1)
})
test('bankStore: upsertTransaction legt neu an und aktualisiert nur Bankfelder', async () => {
await reset()
const doc = toTransactionDoc(nine()[0], { accountUid: ACCOUNT, syncedAt: 'erst' })
assert.equal(await store.upsertTransaction(doc), 'neu')
const created = await store.getTransaction(doc.$id)
// Zuordnungsfelder starten leer
for (const key of ['category', 'matchedInvoiceId', 'matchedInvoiceNumber', 'matchedPayoutId', 'fixedCostId', 'note']) assert.equal(created[key], '')
await store.updateTransaction(doc.$id, { category: 'rechnung-extern', note: 'x', matchedInvoiceId: 'in_1' })
assert.equal(await store.upsertTransaction({ ...doc, remittance: 'neuer Zweck', syncedAt: 'zweit', category: 'privat', note: 'ueberschrieben?' }), 'aktualisiert')
const after = await store.getTransaction(doc.$id)
assert.equal(after.remittance, 'neuer Zweck')
assert.equal(after.syncedAt, 'zweit')
assert.equal(after.category, 'rechnung-extern')
assert.equal(after.note, 'x')
assert.equal(after.matchedInvoiceId, 'in_1')
assert.equal(txDocs().size, 1)
})
test('bankStore: listTransactions neueste zuerst, mit Limit; getTransaction null; deleteTransaction', async () => {
await reset()
for (const r of nine()) await store.upsertTransaction(toTransactionDoc(r, { accountUid: ACCOUNT, syncedAt: 'x' }))
const all = await store.listTransactions()
assert.equal(all.length, 9)
const dates = all.map((t) => t.bookingDate)
assert.deepEqual(dates, [...dates].sort().reverse())
assert.equal(dates[0], '2026-09-29')
assert.equal((await store.listTransactions({ limit: 2 })).length, 2)
assert.equal(await store.getTransaction('gibtesnicht'), null)
await store.deleteTransaction(all[0].$id)
assert.equal(txDocs().size, 8)
})
// ---------- syncBank ----------
test('syncBank: ohne Verbindung oder bei abgelaufenem Zugang kein API-Aufruf', async () => {
await reset()
assert.deepEqual(await syncBank({ now: NOW }), { ok: false, neu: 0, aktualisiert: 0, geloescht: 0, error: 'Kein aktiver Bankzugang' })
await connect({ status: 'abgelaufen' })
const result = await syncBank({ now: NOW })
assert.equal(result.ok, false)
assert.equal(result.error, 'Kein aktiver Bankzugang')
assert.equal(fake.calls.length, 0)
assert.equal(connection().status, 'abgelaufen')
})
test('syncBank: erster Abruf speichert alle Buchungen, Saldo und jüngstes Buchungsdatum', async () => {
await reset()
await connect()
const result = await syncBank({ now: NOW })
assert.deepEqual(result, { ok: true, neu: 9, aktualisiert: 0, geloescht: 0 })
assert.equal(txDocs().size, 9)
const conn = connection()
assert.equal(conn.balanceBookedCents, 10893)
assert.equal(conn.balanceAvailableCents, 10893)
assert.equal(conn.balanceDate, '2026-10-01')
assert.equal(conn.latestBookingDate, '2026-09-29')
assert.equal(conn.lastSyncOk, true)
assert.equal(conn.lastError, '')
assert.equal(conn.lastSyncAt, NOW.toISOString())
assert.equal(conn.status, 'aktiv')
// Zugangsdaten bleiben unberuehrt
assert.equal(conn.sessionId, SESSION)
assert.equal(conn.accountUid, ACCOUNT)
// ohne bekannte Buchung: heute - 89 Tage
assert.equal(addDays(berlinDate(NOW), -89), WINDOW_START)
const txCalls = callsTo('/transactions')
assert.equal(txCalls.length, 1)
assert.equal(txCalls[0].query.date_from, WINDOW_START)
// Konto-UID in der Anfrage, nichts anderes als die gespeicherte
assert.equal(txCalls[0].path, `/accounts/${ACCOUNT}/transactions`)
const ewv = txDocs().get(docId('r1'))
assert.equal(ewv.amountCents, 16879)
assert.equal(ewv.counterpartyName, 'EWV SOFTWARE GMBH')
assert.equal(txDocs().get(docId('r8')).amountCents, -449)
})
test('syncBank: Abrufzeitraum ab letzter Buchung minus 7 Tage, nie weiter zurück als 89 Tage', async () => {
await reset()
await connect()
await syncBank({ now: NOW })
fake.calls.length = 0
await syncBank({ now: NOW })
assert.equal(callsTo('/transactions')[0].query.date_from, '2026-09-22')
// sehr alte letzte Buchung: Untergrenze heute - 89 Tage
await store.saveConnection({ latestBookingDate: '2026-01-01' })
fake.calls.length = 0
await syncBank({ now: NOW })
assert.equal(callsTo('/transactions')[0].query.date_from, WINDOW_START)
// letzte Buchung heute: sieben Tage zurueck
await store.saveConnection({ latestBookingDate: TODAY })
fake.calls.length = 0
await syncBank({ now: NOW })
assert.equal(callsTo('/transactions')[0].query.date_from, '2026-09-24')
})
test('syncBank: "heute" ist der Berliner Tag (kurz nach Mitternacht Berliner Zeit)', async () => {
await reset()
await connect()
// 30.09. 22:30 UTC = 01.10. 00:30 in Berlin
await syncBank({ now: new Date('2026-09-30T22:30:00Z') })
assert.equal(callsTo('/transactions')[0].query.date_from, WINDOW_START)
// 30.09. 21:30 UTC = 30.09. 23:30 in Berlin -> ein Tag frueher
await reset()
await connect()
await syncBank({ now: new Date('2026-09-30T21:30:00Z') })
assert.equal(callsTo('/transactions')[0].query.date_from, '2026-07-03')
})
test('syncBank: zweiter Abruf mit denselben Buchungen legt nichts neu an und lässt Zuordnung und Notiz stehen', async () => {
await reset()
await connect()
await syncBank({ now: NOW })
const id = docId('r1')
await store.updateTransaction(id, { category: 'rechnung-extern', note: 'x', matchedInvoiceNumber: '0005' })
const result = await syncBank({ now: new Date('2026-10-01T15:00:00Z') })
assert.equal(result.ok, true)
assert.equal(result.neu, 0)
assert.equal(result.geloescht, 0)
assert.equal(txDocs().size, 9)
const doc = txDocs().get(id)
assert.equal(doc.category, 'rechnung-extern')
assert.equal(doc.note, 'x')
assert.equal(doc.matchedInvoiceNumber, '0005')
assert.equal(doc.syncedAt, '2026-10-01T15:00:00.000Z')
})
test('syncBank: geänderte Bankfelder werden übernommen (Zweck, Gegenpartei, Datum)', async () => {
await reset()
await connect()
await syncBank({ now: NOW })
const changed = nine()
changed[0].remittance_information = ['Rechnung 0005 vom 28.07.2026 (korrigiert)']
changed[1].booking_date = '2026-08-17'
fake.transactions.set(ACCOUNT, changed)
const result = await syncBank({ now: NOW })
assert.equal(result.neu, 0)
assert.equal(txDocs().get(docId('r1')).remittance, 'Rechnung 0005 vom 28.07.2026 (korrigiert)')
assert.equal(txDocs().get(docId('r2')).bookingDate, '2026-08-17')
})
test('syncBank: Vormerkung verschwindet, die Buchung erscheint einmal', async () => {
await reset({ transactions: [...nine(), raw({ ref: 'p1', date: '2026-09-30', amount: '12.00', name: 'VORGEMERKT', status: 'PDNG' })] })
await connect()
const first = await syncBank({ now: NOW })
assert.equal(first.neu, 10)
assert.equal(txDocs().get(docId('p1')).status, 'PDNG')
// eine Vormerkung zaehlt nicht als jüngste Buchung
assert.equal(connection().latestBookingDate, '2026-09-29')
fake.transactions.set(ACCOUNT, [...nine(), raw({ ref: 'b1', date: '2026-09-30', amount: '12.00', name: 'VORGEMERKT' })])
const second = await syncBank({ now: NOW })
assert.equal(second.ok, true)
assert.equal(second.geloescht, 1)
assert.equal(second.neu, 1)
assert.equal(txDocs().has(docId('p1')), false)
assert.equal(txDocs().get(docId('b1')).status, 'BOOK')
assert.equal(txDocs().size, 10)
assert.equal(connection().latestBookingDate, '2026-09-30')
})
test('syncBank: gleiche entry_reference bei Vormerkung und Buchung -> ein Dokument, Status wechselt auf BOOK', async () => {
await reset({ transactions: [raw({ ref: 'same', date: '2026-09-30', amount: '3.00', name: 'N', status: 'PDNG' })] })
await connect()
await syncBank({ now: NOW })
fake.transactions.set(ACCOUNT, [raw({ ref: 'same', date: '2026-09-30', amount: '3.00', name: 'N' })])
const result = await syncBank({ now: NOW })
assert.deepEqual(result, { ok: true, neu: 0, aktualisiert: 1, geloescht: 0 })
assert.equal(txDocs().size, 1)
assert.equal(txDocs().get(docId('same')).status, 'BOOK')
})
test('syncBank: scheitert die Suche nach Vormerkungen, wird im Speicher gefiltert und der Abruf endet trotzdem ok', async () => {
await reset({ transactions: [...nine(), raw({ ref: 'p1', date: '2026-09-30', amount: '12.00', name: 'VORGEMERKT', status: 'PDNG' })] })
await connect()
assert.equal((await syncBank({ now: NOW })).neu, 10)
assert.equal(txDocs().get(docId('p1')).status, 'PDNG')
// die Bank nennt die Vormerkung nicht mehr; die Suche nach status=PDNG (erste Lese-Anfrage an bankTransactions) scheitert
fake.transactions.set(ACCOUNT, nine())
aw.failNext({ collection: config.collections.bankTransactions, method: 'GET', status: 400, message: 'Nachbau: Index status fehlt' })
const result = await syncBank({ now: NOW })
assert.deepEqual(result, { ok: true, neu: 0, aktualisiert: 9, geloescht: 1 })
assert.equal(txDocs().has(docId('p1')), false)
assert.equal(txDocs().size, 9)
assert.equal(connection().lastSyncOk, true)
assert.equal(connection().lastError, '')
})
test('syncBank: Vormerkungen vor dem Abrufzeitraum bleiben, gebuchte Dokumente werden nie gelöscht', async () => {
await reset()
await connect()
await syncBank({ now: NOW })
// alte Vormerkung ausserhalb des Abrufzeitraums (ab 2026-09-22) und eine innerhalb, die die Bank nicht mehr nennt
aw.seed(config.collections.bankTransactions, { $id: 'alt-vormerkung', status: 'PDNG', bookingDate: '2026-08-01', amountCents: -100 })
aw.seed(config.collections.bankTransactions, { $id: 'neu-vormerkung', status: 'PDNG', bookingDate: '2026-09-25', amountCents: -100 })
// Buchung, die die Bank (im Zeitraum) nicht mehr liefert, bleibt: nur Vormerkungen werden gelöscht
fake.transactions.set(ACCOUNT, nine().slice(5))
const result = await syncBank({ now: NOW })
assert.equal(result.geloescht, 1)
assert.equal(txDocs().has('alt-vormerkung'), true)
assert.equal(txDocs().has('neu-vormerkung'), false)
assert.equal(txDocs().has(docId('r1')), true)
})
test('syncBank: Saldo-Auswahl ITBD vor CLBD, ITAV für verfügbar, Datum des gebuchten Saldos', async () => {
await reset({
transactions: [],
bal: [
{ balance_type: 'CLBD', balance_amount: { amount: '100.00', currency: 'EUR' }, reference_date: '2026-09-30' },
{ balance_type: 'ITAV', balance_amount: { amount: '90.50', currency: 'EUR' }, reference_date: '2026-10-01' },
{ balance_type: 'ITBD', balance_amount: { amount: '120.25', currency: 'EUR' }, reference_date: '2026-10-01' },
],
})
await connect()
assert.equal((await syncBank({ now: NOW })).ok, true)
assert.equal(connection().balanceBookedCents, 12025)
assert.equal(connection().balanceAvailableCents, 9050)
assert.equal(connection().balanceDate, '2026-10-01')
// nur CLBD: gilt als gebuchter Saldo
fake.balances.set(ACCOUNT, [{ balance_type: 'CLBD', balance_amount: { amount: '-5.10', currency: 'EUR' }, reference_date: '2026-09-30' }])
assert.equal((await syncBank({ now: NOW })).ok, true)
assert.equal(connection().balanceBookedCents, -510)
assert.equal(connection().balanceDate, '2026-09-30')
})
test('syncBank: 401 beim Abruf -> Zugang abgelaufen, Buchungen bleiben, danach kein API-Aufruf mehr', async () => {
await reset()
await connect()
assert.equal((await syncBank({ now: NOW })).ok, true)
fake.failNext({ path: /\/balances$/, status: 401, body: { code: 401, error: 'UNAUTHORIZED' } })
const result = await syncBank({ now: new Date('2026-10-01T15:00:00Z') })
assert.equal(result.ok, false)
assert.equal(result.error, 'Bankzugang abgelaufen oder widerrufen')
assert.equal(connection().status, 'abgelaufen')
assert.equal(connection().lastSyncOk, false)
assert.equal(connection().lastError, 'Bankzugang abgelaufen oder widerrufen')
// letzter erfolgreicher Abruf bleibt stehen
assert.equal(connection().lastSyncAt, NOW.toISOString())
assert.equal(txDocs().size, 9)
const callsBefore = fake.calls.length
const third = await syncBank({ now: NOW })
assert.equal(third.ok, false)
assert.equal(fake.calls.length, callsBefore)
})
test('syncBank: 403 bei den Umsätzen gilt ebenfalls als abgelaufen', async () => {
await reset()
await connect()
fake.failNext({ path: /\/transactions$/, status: 403, body: { code: 403, error: 'ACCESS_DENIED' } })
const result = await syncBank({ now: NOW })
assert.equal(result.ok, false)
assert.equal(connection().status, 'abgelaufen')
})
test('syncBank: 500 -> Status bleibt aktiv, Fehler gemerkt, keine Zugangsdaten in der Meldung', async () => {
await reset()
await connect()
fake.failNext({ path: /\/balances$/, status: 500, body: { code: 500, error: 'FAKE_ERROR' } })
const result = await syncBank({ now: NOW })
assert.equal(result.ok, false)
assert.ok(result.error.length > 0)
const conn = connection()
assert.equal(conn.status, 'aktiv')
assert.equal(conn.lastSyncOk, false)
assert.equal(conn.lastError, result.error)
assert.match(conn.lastError, /500/)
for (const secret of [ACCOUNT, SESSION, keyPath]) {
assert.ok(!result.error.includes(secret), `Meldung enthält ${secret}`)
assert.ok(!conn.lastError.includes(secret), `lastError enthält ${secret}`)
}
// nächster Abruf läuft normal weiter und räumt den Fehler weg
const retry = await syncBank({ now: NOW })
assert.equal(retry.ok, true)
assert.equal(connection().lastSyncOk, true)
assert.equal(connection().lastError, '')
})
test('syncBank: Fehler bei den Umsätzen lässt den Saldo der Verbindung unverändert', async () => {
await reset()
await connect()
await syncBank({ now: NOW })
fake.balances.set(ACCOUNT, [{ balance_type: 'ITBD', balance_amount: { amount: '5.00', currency: 'EUR' }, reference_date: '2026-10-02' }])
fake.failNext({ path: /\/transactions$/, status: 500 })
const result = await syncBank({ now: NOW })
assert.equal(result.ok, false)
assert.equal(connection().balanceBookedCents, 10893)
assert.equal(connection().lastSyncOk, false)
})
test('syncBank: wirft nie - fehlerhafter Saldo, Netzwerkfehler, Appwrite-Fehler', async () => {
// fehlerhafter Saldo
await reset({ bal: [{ balance_type: 'ITBD', balance_amount: { amount: 'kaputt', currency: 'EUR' }, reference_date: '2026-10-01' }] })
await connect()
let result = await syncBank({ now: NOW })
assert.equal(result.ok, false)
assert.equal(connection().lastSyncOk, false)
// Netzwerkabbruch
await reset()
await connect()
fake.failNext({ path: /\/balances$/, destroy: true })
result = await syncBank({ now: NOW })
assert.equal(result.ok, false)
assert.equal(connection().status, 'aktiv')
// Appwrite scheitert beim Speichern einer Buchung
await reset()
await connect()
aw.failNext({ collection: config.collections.bankTransactions, method: 'PATCH', status: 500, message: 'Nachbau: Appwrite gestoert' })
result = await syncBank({ now: NOW })
assert.equal(result.ok, false)
assert.equal(connection().lastSyncOk, false)
assert.match(connection().lastError, /Appwrite gestoert/)
})
test('syncBank: eine unlesbare Buchung wird übersprungen und gezählt, der Abruf endet ok (M4)', async () => {
const broken = nine()
broken[4].transaction_amount.amount = 'abc'
broken[5].credit_debit_indicator = 'XXXX'
await reset({ transactions: broken })
await connect()
const result = await syncBank({ now: NOW })
assert.equal(result.ok, true)
assert.equal(result.neu, 7)
assert.equal(txDocs().size, 7, 'die sieben lesbaren Buchungen sind gespeichert')
assert.ok(!txDocs().has(docId('r5')) && !txDocs().has(docId('r6')))
const conn = connection()
assert.equal(conn.status, 'aktiv')
assert.equal(conn.lastSyncOk, true)
assert.equal(conn.lastError, '2 Buchung(en) übersprungen (unlesbar).')
assert.equal(conn.balanceBookedCents, 10893)
// der nächste Abruf ohne kaputte Buchung räumt die Meldung weg
fake.transactions.set(ACCOUNT, nine())
assert.equal((await syncBank({ now: NOW })).ok, true)
assert.equal(connection().lastError, '')
})
test('shouldRunStartupSync: frischer erfolgreicher Abruf (unter 5,5 Stunden) überspringt den Start-Abruf (I1)', () => {
const at = (hoursAgo) => new Date(NOW.getTime() - hoursAgo * 3_600_000).toISOString()
assert.equal(shouldRunStartupSync({ lastSyncOk: true, lastSyncAt: at(1) }, NOW), false)
assert.equal(shouldRunStartupSync({ lastSyncOk: true, lastSyncAt: at(5.4) }, NOW), false)
// ab 5,5 Stunden läuft der Abruf wieder (der 6-Stunden-Takt wäre sonst fast eine Stunde zu spät)
assert.equal(shouldRunStartupSync({ lastSyncOk: true, lastSyncAt: at(5.5) }, NOW), true)
assert.equal(shouldRunStartupSync({ lastSyncOk: true, lastSyncAt: at(20) }, NOW), true)
// ein fehlgeschlagener oder nie gelaufener Abruf wird beim Start wiederholt
assert.equal(shouldRunStartupSync({ lastSyncOk: false, lastSyncAt: at(1) }, NOW), true)
assert.equal(shouldRunStartupSync({ lastSyncAt: at(1) }, NOW), true)
assert.equal(shouldRunStartupSync({ lastSyncOk: true }, NOW), true)
assert.equal(shouldRunStartupSync({ lastSyncOk: true, lastSyncAt: 'kein Datum' }, NOW), true)
assert.equal(shouldRunStartupSync(null, NOW), true)
})
test('syncBank: 401 -> einmalige Mail T18 "abgelaufen oder widerrufen", Stufe 0 gemerkt, checkBankConsent mailt nicht noch einmal (M10)', async () => {
await reset()
await connect({ warnedThresholds: '30,14' })
fake.failNext({ path: /\/balances$/, status: 401, body: { code: 401, error: 'UNAUTHORIZED' } })
const result = await syncBank({ now: NOW })
assert.equal(result.ok, false)
assert.equal(connection().status, 'abgelaufen')
assert.equal(mails.sent.length, 1)
const [mail] = mails.sent
assert.equal(mail.subject, '[Finanzen] Bankzugang Finom ist abgelaufen oder wurde widerrufen')
assert.match(mail.html, /Der Zugang ist bereits abgelaufen/, 'Block abgelaufen')
assert.match(mail.html, /29\.03\.2027/)
assert.ok(mail.html.includes(`${config.womsBaseUrl}/finance/einstellungen`))
assert.deepEqual(connection().warnedThresholds.split(','), ['30', '14', '0'])
// weitere Abrufe (kein API-Aufruf mehr) und die tägliche Prüfung senden nichts
await syncBank({ now: NOW })
assert.deepEqual(await checkBankConsent({ now: new Date('2027-03-30T09:00:00Z') }), { sent: null })
assert.equal(mails.sent.length, 1, 'keine zweite Mail')
})
test('syncBank: 403 meldet ebenso, ein anderer Fehler (500) und ein bereits gemeldeter Ablauf nicht (M10)', async () => {
await reset()
await connect()
fake.failNext({ path: /\/transactions$/, status: 403 })
await syncBank({ now: NOW })
assert.equal(mails.sent.length, 1)
await reset()
await connect()
fake.failNext({ path: /\/balances$/, status: 500 })
await syncBank({ now: NOW })
assert.equal(connection().status, 'aktiv')
assert.equal(mails.sent.length, 0, '500 ist kein Ablauf')
// am Ablauftag war schon gewarnt (Stufe 0): der 401 danach löst keine zweite Mail aus
await reset()
await connect({ warnedThresholds: '30,14,3,0' })
fake.failNext({ path: /\/balances$/, status: 401 })
await syncBank({ now: NOW })
assert.equal(connection().status, 'abgelaufen')
assert.equal(mails.sent.length, 0)
})
test('syncBank: kommt die Mail zum Ablauf nicht raus, bleibt Stufe 0 offen und der Abruf endet trotzdem sauber (M10)', async () => {
await reset()
await connect()
setMailTransportForTests({ sendMail: async () => Promise.reject(new Error('SMTP kaputt')) })
try {
fake.failNext({ path: /\/balances$/, status: 401 })
const result = await syncBank({ now: NOW })
assert.equal(result.error, 'Bankzugang abgelaufen oder widerrufen')
assert.equal(connection().status, 'abgelaufen')
assert.ok(!String(connection().warnedThresholds || '').split(',').includes('0'))
} finally {
setMailTransportForTests(mails)
}
})
test('syncBank: wirft auch dann nicht, wenn die Verbindung nicht geladen werden kann', async () => {
await reset()
await connect()
aw.failNext({ collection: config.collections.bankConnections, method: 'GET', status: 500, message: 'Nachbau: Verbindung nicht lesbar' })
const result = await syncBank({ now: NOW })
assert.equal(result.ok, false)
assert.match(result.error, /Verbindung nicht lesbar/)
assert.equal(fake.calls.length, 0)
})
test('syncBank: PSU-Kopfzeilen werden an alle Aufrufe weitergegeben', async () => {
await reset()
await connect()
await syncBank({ now: NOW, psu: { ip: '203.0.113.7', userAgent: 'Mozilla/5.0 Test' } })
assert.ok(fake.calls.length >= 2)
for (const call of fake.calls) {
assert.equal(call.headers['psu-ip-address'], '203.0.113.7')
assert.equal(call.headers['psu-user-agent'], 'Mozilla/5.0 Test')
}
fake.calls.length = 0
await syncBank({ now: NOW })
for (const call of fake.calls) assert.equal(call.headers['psu-ip-address'], undefined)
})
test('syncBank: gleichzeitige Abrufe laufen nacheinander und legen keine Buchung doppelt an', async () => {
await reset()
await connect()
const [a, b] = await Promise.all([syncBank({ now: NOW }), syncBank({ now: NOW })])
assert.equal(a.ok, true)
assert.equal(b.ok, true)
assert.equal(a.neu + b.neu, 9)
assert.equal(txDocs().size, 9)
// nacheinander: erst Salden + Umsätze des ersten Abrufs, dann die des zweiten (nicht ineinander verschränkt)
assert.deepEqual(
fake.calls.map((c) => c.path.split('/').pop()),
['balances', 'transactions', 'balances', 'transactions']
)
})
test('syncBank: mehrere Seiten der Bank werden vollständig gespeichert', async () => {
await reset()
await connect()
fake.pageSize = 4
try {
const result = await syncBank({ now: NOW })
assert.equal(result.neu, 9)
assert.equal(callsTo('/transactions').length, 3)
} finally {
fake.pageSize = 100
}
})
+51
View File
@@ -0,0 +1,51 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { berlinDate, addDays, daysBetween, formatDe } from '../server/services/finance/dates.js'
test('berlinDate: Kalendertag in Europe/Berlin, nicht in UTC', () => {
assert.equal(berlinDate(new Date('2026-09-30T22:30:00Z')), '2026-10-01')
assert.equal(berlinDate(new Date('2026-09-30T21:59:00Z')), '2026-09-30')
// Winterzeit (UTC+1)
assert.equal(berlinDate(new Date('2026-12-31T23:30:00Z')), '2027-01-01')
assert.equal(berlinDate(new Date('2026-12-31T22:59:00Z')), '2026-12-31')
})
test('berlinDate: ohne Argument der heutige Tag im Format YYYY-MM-DD', () => {
assert.match(berlinDate(), /^\d{4}-\d{2}-\d{2}$/)
})
test('addDays: Tagesrechnung ueber die Zeitumstellung hinweg', () => {
assert.equal(addDays('2026-03-29', 1), '2026-03-30')
assert.equal(addDays('2026-10-25', 1), '2026-10-26')
assert.equal(addDays('2026-03-28', 2), '2026-03-30')
})
test('addDays: Monats- und Jahreswechsel, Schaltjahr, negative Werte', () => {
assert.equal(addDays('2026-12-31', 1), '2027-01-01')
assert.equal(addDays('2028-02-28', 1), '2028-02-29')
assert.equal(addDays('2026-10-01', -89), '2026-07-04')
assert.equal(addDays('2026-10-01', 0), '2026-10-01')
})
test('daysBetween: ganze Tage, toYmd minus fromYmd', () => {
assert.equal(daysBetween('2026-10-01', '2027-03-29'), 179)
assert.equal(daysBetween('2027-03-29', '2026-10-01'), -179)
assert.equal(daysBetween('2026-10-01', '2026-10-01'), 0)
// Zeitumstellung: 23-Stunden-Tag zaehlt als ein Tag
assert.equal(daysBetween('2026-03-28', '2026-03-30'), 2)
assert.equal(daysBetween('2026-10-24', '2026-10-26'), 2)
})
test('formatDe: TT.MM.JJJJ', () => {
assert.equal(formatDe('2027-03-29'), '29.03.2027')
assert.equal(formatDe('2026-01-05'), '05.01.2026')
})
test('ungueltige Daten werden abgelehnt', () => {
for (const bad of ['', '2026-13-01', '2026-02-30', '29.03.2027', null, undefined, '2026-1-1']) {
assert.throws(() => addDays(bad, 1), /Ungültiges Datum/)
assert.throws(() => daysBetween(bad, '2026-10-01'), /Ungültiges Datum/)
assert.throws(() => formatDe(bad), /Ungültiges Datum/)
}
assert.throws(() => addDays('2026-10-01', 1.5), /ganze Zahl/)
})
+257
View File
@@ -0,0 +1,257 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { startFakeEnableBanking } from './support/fakeEnableBanking.js'
// Schluesselpaar erzeugen, privaten Schluessel als Datei ablegen und den Nachbau mit dem oeffentlichen
// starten; die Umgebung muss stehen, BEVOR config.js geladen wird
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 })
const pem = (key, type) => key.export({ type, format: 'pem' })
const keyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'enable-banking-test-'))
const keyPath = path.join(keyDir, 'private.pem')
fs.writeFileSync(keyPath, pem(privateKey, 'pkcs8'))
const fake = await startFakeEnableBanking({ publicKeyPem: pem(publicKey, 'spki'), appId: 'app-test' })
Object.assign(process.env, {
ENABLE_BANKING_APP_ID: 'app-test',
ENABLE_BANKING_KEY_PATH: keyPath,
ENABLE_BANKING_API_BASE: fake.url,
})
const { config, resolveEnableBankingApiBase } = await import('../server/config.js')
const { createJwt, startAuth, createSession, getSession, getBalances, getTransactions, toCents, EnableBankingError } = await import(
'../server/services/finance/enableBanking.js'
)
after(async () => {
await fake.close()
fs.rmSync(keyDir, { recursive: true, force: true })
})
const SECRET_SESSION_ID = 'sess-geheim-4711'
test('config.enableBanking: Werte aus der Umgebung, Finom, Rueckkehradresse', () => {
assert.deepEqual(config.enableBanking, {
appId: 'app-test',
keyPath,
apiBase: fake.url,
redirectUrl: 'https://ticket.webklar.com/finanzen/bank-callback',
aspsp: { name: 'Finom', country: 'DE' },
enabled: true,
})
})
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('createJwt liest den Schluessel bei jedem Aufruf (Tausch ohne Neustart)', async () => {
const other = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 })
fs.writeFileSync(keyPath, pem(other.privateKey, 'pkcs8'))
try {
const [h, p, s] = createJwt().split('.')
assert.ok(crypto.verify('sha256', Buffer.from(`${h}.${p}`), other.publicKey, Buffer.from(s, 'base64url')))
assert.ok(!crypto.verify('sha256', Buffer.from(`${h}.${p}`), publicKey, Buffer.from(s, 'base64url')))
// Der Nachbau prueft die Signatur wirklich: mit dem fremden Schluessel gibt es 401
await assert.rejects(
() => getBalances('u1'),
(err) => err instanceof EnableBankingError && err.status === 401
)
} finally {
fs.writeFileSync(keyPath, pem(privateKey, 'pkcs8'))
}
})
test('createJwt: unlesbarer Schluessel -> EnableBankingError ohne Pfad oder Inhalt in der Meldung', () => {
const original = fs.readFileSync(keyPath)
fs.writeFileSync(keyPath, '-----BEGIN PRIVATE KEY-----\nGEHEIMER-INHALT\n-----END PRIVATE KEY-----\n')
try {
assert.throws(
() => createJwt(),
(err) => err instanceof EnableBankingError && !err.message.includes('GEHEIMER') && !err.message.includes(keyPath)
)
} finally {
fs.writeFileSync(keyPath, original)
}
})
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?:\/\//)
assert.equal(r.authorizationId, 'auth-1')
const call = fake.calls.at(-1)
assert.equal(call.method, 'POST')
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 () => {
const s = await createSession('ok')
assert.equal(s.sessionId, 's1')
assert.equal(s.validUntil, '2027-03-29T00:00:00.000Z')
assert.deepEqual(s.accounts, [{ uid: 'u1', identificationHash: 'h1', iban: 'DE00000000000000009313', name: 'Webklar', currency: 'EUR' }])
assert.deepEqual(fake.calls.at(-1).body, { code: 'ok' })
})
test('createSession: abgelehnter Code -> EnableBankingError mit status 422', async () => {
await assert.rejects(
() => createSession('falsch'),
(err) => err instanceof EnableBankingError && err.status === 422
)
})
test('getSession liefert Status, Gueltigkeit und Konto-IDs', async () => {
const s = await getSession('s1')
assert.deepEqual(s, { status: 'AUTHORIZED', validUntil: '2027-03-29T00:00:00.000Z', accountUids: ['u1'] })
assert.equal(fake.calls.at(-1).path, '/sessions/s1')
})
test('getBalances rechnet in Cent', async () => {
fake.balances.set('u1', [
{ balance_type: 'ITAV', balance_amount: { amount: '108.93', currency: 'EUR' }, reference_date: '2026-10-01' },
{ balance_type: 'CLBD', balance_amount: { amount: '0.5', currency: 'EUR' } },
{ balance_type: 'ITBD', balance_amount: { amount: '-12.30', currency: 'EUR' }, reference_date: '2026-09-30' },
])
const balances = await getBalances('u1')
assert.deepEqual(balances, [
{ type: 'ITAV', amountCents: 10893, currency: 'EUR', date: '2026-10-01' },
{ type: 'CLBD', amountCents: 50, currency: 'EUR', date: null },
{ type: 'ITBD', amountCents: -1230, currency: 'EUR', date: '2026-09-30' },
])
})
test('toCents: Ganz- und Nachkommateil getrennt, ohne Rundung', () => {
assert.equal(toCents('108.93'), 10893)
assert.equal(toCents('0.5'), 50)
assert.equal(toCents('5'), 500)
assert.equal(toCents('0.07'), 7)
assert.equal(toCents('-12.30'), -1230)
assert.equal(toCents('+3.10'), 310)
assert.equal(toCents('10.500'), 1050)
assert.equal(toCents('1234567.89'), 123456789)
// 19.99 * 100 ergibt in Gleitkomma 1998.9999999999998; hier exakt
assert.equal(toCents('19.99'), 1999)
assert.equal(toCents('-0.00'), 0)
assert.ok(Object.is(toCents('-0.00'), 0))
assert.equal(toCents(42.5), 4250)
// mehr als zwei echte Nachkommastellen oder Muell: nie still runden
for (const bad of ['10.505', '', 'abc', '1,50', '1.', '.5', '--1', null, undefined, '1e3', '9007199254740993.00']) {
assert.throws(() => toCents(bad), (err) => err instanceof Error, `toCents(${JSON.stringify(bad)}) muss scheitern`)
}
})
test('getTransactions holt alle Seiten (continuation_key), hoechstens 20', async () => {
const tx = (i) => ({ entry_reference: `e${i}`, transaction_id: `t${i}`, transaction_amount: { amount: '1.00', currency: 'EUR' }, status: 'BOOK' })
fake.transactions.set('u1', [0, 1, 2, 3, 4].map(tx))
fake.pageSize = 2
const start = fake.calls.length
const all = await getTransactions('u1', { dateFrom: '2026-09-01' })
assert.deepEqual(all.map((t) => t.entry_reference), ['e0', 'e1', 'e2', 'e3', 'e4'])
const calls = fake.calls.slice(start)
assert.equal(calls.length, 3)
assert.deepEqual(calls.map((c) => c.query), [
{ date_from: '2026-09-01' },
{ date_from: '2026-09-01', continuation_key: '2' },
{ date_from: '2026-09-01', continuation_key: '4' },
])
// ohne dateFrom kein date_from; endlos viele Seiten -> nach 20 Aufrufen Schluss
fake.transactions.set('u1', Array.from({ length: 100 }, (_, i) => tx(i)))
const before = fake.calls.length
const capped = await getTransactions('u1')
assert.equal(fake.calls.length - before, 20)
assert.equal(capped.length, 40)
assert.deepEqual(fake.calls[before].query, {})
fake.pageSize = 100
})
test('PSU-Kopfzeilen nur wenn psu uebergeben', async () => {
fake.transactions.set('u1', [])
await getBalances('u1')
assert.equal(fake.calls.at(-1).headers['psu-ip-address'], undefined)
assert.equal(fake.calls.at(-1).headers['psu-user-agent'], undefined)
await getBalances('u1', { psu: { ip: '203.0.113.7', userAgent: 'Mozilla/5.0 Test' } })
assert.equal(fake.calls.at(-1).headers['psu-ip-address'], '203.0.113.7')
assert.equal(fake.calls.at(-1).headers['psu-user-agent'], 'Mozilla/5.0 Test')
await getTransactions('u1', { dateFrom: '2026-09-01', psu: { ip: '203.0.113.8', userAgent: 'Agent/2' } })
assert.equal(fake.calls.at(-1).headers['psu-ip-address'], '203.0.113.8')
assert.equal(fake.calls.at(-1).headers['psu-user-agent'], 'Agent/2')
})
test('Fehler -> EnableBankingError mit status und code, ohne Sitzungs-ID in der Meldung', async () => {
fake.failNext({ path: /\/accounts\//, status: 401, body: { code: 'EXPIRED_SESSION' } })
await assert.rejects(
() => getBalances('u1', { psu: { ip: '203.0.113.7', userAgent: 'x' } }),
(err) => {
assert.ok(err instanceof EnableBankingError)
assert.equal(err.name, 'EnableBankingError')
assert.equal(err.status, 401)
assert.equal(err.code, 'EXPIRED_SESSION')
assert.ok(!err.message.includes('u1'))
return true
}
)
// Sitzungs-ID steht im Pfad, darf aber weder in Meldung noch im Stack-Text der Meldung landen
fake.failNext({ path: `/sessions/${SECRET_SESSION_ID}`, status: 404, body: { error: 'SESSION_NOT_FOUND', message: `Sitzung ${SECRET_SESSION_ID} unbekannt` } })
await assert.rejects(
() => getSession(SECRET_SESSION_ID),
(err) => {
assert.ok(err instanceof EnableBankingError)
assert.equal(err.status, 404)
assert.equal(err.code, 'SESSION_NOT_FOUND')
assert.ok(!err.message.includes(SECRET_SESSION_ID))
assert.ok(!JSON.stringify(err).includes(SECRET_SESSION_ID))
return true
}
)
// Fehlerantwort ohne code/error: code bleibt null
fake.failNext({ status: 500, body: {} })
await assert.rejects(
() => getBalances('u1'),
(err) => err instanceof EnableBankingError && err.status === 500 && err.code === null
)
})
test('Verbindungsabbruch -> EnableBankingError (status 0), ohne Sitzungs-ID in der Meldung', async () => {
fake.failNext({ destroy: true })
await assert.rejects(
() => getSession(SECRET_SESSION_ID),
(err) => err instanceof EnableBankingError && err.status === 0 && err.code === 'NETWORK' && !err.message.includes(SECRET_SESSION_ID)
)
})
test('ENABLE_BANKING_API_BASE ausserhalb von 127.0.0.1 wird ignoriert', () => {
const standard = 'https://api.enablebanking.com'
assert.equal(resolveEnableBankingApiBase(''), standard)
assert.equal(resolveEnableBankingApiBase(undefined), standard)
assert.equal(resolveEnableBankingApiBase('http://127.0.0.1:12345'), 'http://127.0.0.1:12345')
assert.equal(resolveEnableBankingApiBase('http://localhost:12345/'), 'http://localhost:12345')
for (const fremd of [
'https://api.example.com',
'http://127.0.0.1.evil.example',
'http://127.0.0.1@evil.example',
'http://evil.example/127.0.0.1',
'http://[::1]:80',
'ftp://127.0.0.1',
'keine adresse',
]) {
assert.equal(resolveEnableBankingApiBase(fremd), standard, fremd)
}
})
+339
View File
@@ -0,0 +1,339 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { buildForecast } from '../server/services/finance/forecast.js'
const TODAY = '2026-10-01'
const BALANCE = 10893
const base = (extra = {}) => buildForecast({ today: TODAY, balanceCents: BALANCE, settings: { estPrepaymentCents: 0 }, ...extra })
const dates = (result, sicherheit) => result.posten.filter((p) => !sicherheit || p.sicherheit === sicherheit).map((p) => p.date)
const monthly = (extra = {}) => ({ $id: 'fk1', name: 'Hetzner', amountCents: 449, interval: 'monat', nextDate: '2026-09-27', active: true, ...extra })
const invoice = (extra = {}) => ({ id: 'in_1', number: 'WK-2026-0001', status: 'offen', dueDate: '2026-10-14', remainingCents: 50000, customerName: 'Muster GmbH', ...extra })
const sub = (extra = {}) => ({
customerId: 'c1', status: 'active', priceAmount: 2000, currentPeriodEnd: '2026-10-05T10:00:00.000+00:00', cancelAtPeriodEnd: false, cancelAt: null,
...extra,
})
test('nur Kontostand: keine Posten, beide Minima gleich dem Startwert am heutigen Tag', () => {
const r = base()
assert.deepEqual(r.posten, [])
assert.deepEqual(r.minSicher, { amountCents: 10893, date: TODAY })
assert.deepEqual(r.minErwartet, { amountCents: 10893, date: TODAY })
assert.equal(r.endeSicherCents, 10893)
assert.equal(r.endeErwartetCents, 10893)
assert.equal(r.fixkosten30Cents, 0)
assert.equal(r.annahme, 'Erwartet: offene Rechnungen zum Fälligkeitstag, überfällige in 14 Tagen, Abo-Verlängerungen 4 Tage nach Abbuchung, vor Stripe-Gebühren.')
})
test('Standardwerte: Auszahlungen, Rechnungen, Abos und Fixkosten dürfen fehlen, Horizont 30 Tage', () => {
const r = buildForecast({ today: TODAY, balanceCents: BALANCE })
assert.deepEqual(r.posten, [])
const r2 = buildForecast({ today: TODAY, balanceCents: BALANCE, payouts: null, invoices: null, subscriptions: null, fixedCosts: null, settings: null })
assert.deepEqual(r2.posten, [])
// Horizont 30: der 31.10. liegt noch drin, der 01.11. nicht
const r3 = base({ fixedCosts: [monthly({ nextDate: '2026-10-31' }), monthly({ $id: 'fk2', name: 'Spaet', nextDate: '2026-11-01' })] })
assert.deepEqual(r3.posten.map((p) => p.date), ['2026-10-31'])
})
test('Fixkosten 449 monatlich ab 27.09.: Termin 27.10. (vorgespult), tiefster Stand 10444 am 27.10.', () => {
const r = base({ fixedCosts: [monthly()] })
assert.deepEqual(r.posten, [{ date: '2026-10-27', label: 'Hetzner', amountCents: -449, sicherheit: 'sicher' }])
assert.deepEqual(r.minSicher, { amountCents: 10444, date: '2026-10-27' })
assert.deepEqual(r.minErwartet, { amountCents: 10444, date: '2026-10-27' })
assert.equal(r.endeSicherCents, 10444)
assert.equal(r.fixkosten30Cents, 449)
})
test('Fixkosten: Termin genau heute zählt, gestern nicht; inaktive und ungültige werden ignoriert', () => {
const r = base({
fixedCosts: [
monthly({ $id: 'a', name: 'Heute', nextDate: TODAY }),
monthly({ $id: 'b', name: 'Inaktiv', nextDate: '2026-10-10', active: false }),
monthly({ $id: 'c', name: 'Kaputt', nextDate: 'morgen' }),
monthly({ $id: 'd', name: 'Keine Zahl', amountCents: 0, nextDate: '2026-10-10' }),
monthly({ $id: 'e', name: 'Unbekannt', interval: 'woche', nextDate: '2026-10-10' }),
],
})
assert.deepEqual(r.posten.map((p) => [p.date, p.label]), [[TODAY, 'Heute']])
})
test('Fixkosten am 31.: Tag auf Monatsende begrenzt, aber vom ursprünglichen Tag gerechnet', () => {
// Der 31.01.2027 liegt 122 Tage nach dem 01.10.2026; Horizont 120 endet am 29.01. und enthält ihn nicht
const r = base({ horizonDays: 122, fixedCosts: [monthly({ nextDate: '2026-10-31' })] })
assert.deepEqual(dates(r), ['2026-10-31', '2026-11-30', '2026-12-31', '2027-01-31'])
assert.deepEqual(dates(base({ horizonDays: 121, fixedCosts: [monthly({ nextDate: '2026-10-31' })] })), ['2026-10-31', '2026-11-30', '2026-12-31'])
assert.deepEqual(dates(base({ horizonDays: 120, fixedCosts: [monthly({ nextDate: '2026-10-31' })] })), ['2026-10-31', '2026-11-30', '2026-12-31'])
})
test('Fixkosten: Februar im Schaltjahr und Vorspulen aus der Vergangenheit mit Klammerung', () => {
const r = buildForecast({ today: '2028-02-01', horizonDays: 60, balanceCents: 0, fixedCosts: [monthly({ nextDate: '2027-11-30' })] })
// 30.11.2027 -> 30.12. -> 30.01. (vorgespult) -> 29.02.2028 (Schaltjahr, Klammerung) -> 30.03.2028; Horizontende 01.04.2028
assert.deepEqual(dates(r), ['2028-02-29', '2028-03-30'])
const r2 = buildForecast({ today: '2026-10-01', horizonDays: 200, balanceCents: 0, fixedCosts: [monthly({ nextDate: '2026-01-31' })] })
assert.deepEqual(dates(r2), ['2026-10-31', '2026-11-30', '2026-12-31', '2027-01-31', '2027-02-28', '2027-03-31'])
})
test('Fixkosten quartalsweise und jährlich', () => {
const quartal = monthly({ $id: 'q', name: 'Quartal', amountCents: 3000, interval: 'quartal', nextDate: '2026-07-15' })
const jahr = monthly({ $id: 'j', name: 'Jahr', amountCents: 12000, interval: 'jahr', nextDate: '2025-12-20' })
const r = base({ horizonDays: 120, fixedCosts: [quartal, jahr] })
assert.deepEqual(r.posten.map((p) => [p.date, p.label, p.amountCents]), [
['2026-10-15', 'Quartal', -3000],
['2026-12-20', 'Jahr', -12000],
['2027-01-15', 'Quartal', -3000],
])
assert.equal(r.fixkosten30Cents, 18000)
// Quartal: 31.12. -> 31.03. -> 30.06. (vom Ausgangstag 31 gerechnet)
const q31 = base({ horizonDays: 460, fixedCosts: [monthly({ interval: 'quartal', nextDate: '2026-12-31' })] })
assert.deepEqual(dates(q31), ['2026-12-31', '2027-03-31', '2027-06-30', '2027-09-30', '2027-12-31'])
})
test('offene Rechnung 50000 fällig am 14.10.: erwartet am Fälligkeitstag, Ende erwartet 60893, Ende sicher 10893', () => {
const r = base({ invoices: [invoice()] })
assert.deepEqual(r.posten, [{ date: '2026-10-14', label: 'Rechnung WK-2026-0001 · Muster GmbH', amountCents: 50000, sicherheit: 'erwartet' }])
assert.equal(r.endeErwartetCents, 60893)
assert.equal(r.endeSicherCents, 10893)
// sicherer Verlauf bleibt bei 10893, erwarteter Verlauf steigt: kein tieferer Stand als der Start
assert.deepEqual(r.minSicher, { amountCents: 10893, date: TODAY })
assert.deepEqual(r.minErwartet, { amountCents: 10893, date: TODAY })
assert.equal(r.fixkosten30Cents, 0)
})
test('überfällige Rechnung: heute + 14 Tage mit Zusatz; Entwürfe, bezahlte und Rechnungen ohne Rest zählen nicht', () => {
const r = base({
invoices: [
invoice({ status: 'ueberfaellig', dueDate: '2026-09-20', remainingCents: 30000 }),
invoice({ id: 'in_2', status: 'entwurf', dueDate: '2026-10-10' }),
invoice({ id: 'in_3', status: 'bezahlt', dueDate: '2026-10-10' }),
invoice({ id: 'in_4', dueDate: '2026-10-10', remainingCents: 0 }),
invoice({ id: 'in_5', dueDate: null }),
],
})
assert.deepEqual(r.posten, [
{ date: '2026-10-15', label: 'Rechnung WK-2026-0001 · Muster GmbH (überfällig, angenommen)', amountCents: 30000, sicherheit: 'erwartet' },
])
// überfällig, aber Horizont kürzer als 14 Tage: nichts
assert.deepEqual(base({ horizonDays: 10, invoices: [invoice({ status: 'ueberfaellig', dueDate: '2026-09-20' })] }).posten, [])
})
test('offene Rechnung mit Fälligkeit vor heute (Daten von gestern) wird wie überfällig behandelt, nicht verschluckt', () => {
const r = base({ invoices: [invoice({ dueDate: '2026-09-30' })] })
assert.deepEqual(r.posten.map((p) => p.date), ['2026-10-15'])
assert.match(r.posten[0].label, /\(überfällig, angenommen\)$/)
})
test('Abo 2000 mit Periodenende 05.10.: Posten am 09.10., danach monatlich (09.11. bei langem Horizont)', () => {
const r = base({ subscriptions: [sub()] })
assert.deepEqual(r.posten, [{ date: '2026-10-09', label: 'Abo-Verlängerung', amountCents: 2000, sicherheit: 'erwartet' }])
const long = base({ horizonDays: 70, subscriptions: [sub()] })
assert.deepEqual(dates(long), ['2026-10-09', '2026-11-09', '2026-12-09'])
})
test('Abo: Periodenende wird als Berliner Tag gelesen (Mitternachtsrand), Klammerung am Monatsende', () => {
// 30.10. 23:30 UTC ist in Berlin schon der 31.10.
const r = base({ horizonDays: 100, subscriptions: [sub({ currentPeriodEnd: '2026-10-30T23:30:00.000+00:00' })] })
// Periodenenden 31.10., 30.11., 31.12. -> + 4 Tage
assert.deepEqual(dates(r), ['2026-11-04', '2026-12-04', '2027-01-04'])
})
test('Abo mit cancelAtPeriodEnd ohne cancelAt: gar keine Verlängerung (Stripe bucht zum Periodenende nichts mehr ab)', () => {
const r = base({ horizonDays: 70, subscriptions: [sub({ cancelAtPeriodEnd: true, cancelAt: null })] })
assert.deepEqual(r.posten, [])
// gilt auch ohne das Feld cancelAt
const { cancelAt, ...ohneCancelAt } = sub({ cancelAtPeriodEnd: true })
assert.deepEqual(base({ horizonDays: 70, subscriptions: [ohneCancelAt] }).posten, [])
// andere Abos daneben laufen weiter
const mixed = base({ horizonDays: 40, subscriptions: [sub({ cancelAtPeriodEnd: true }), sub({ customerId: 'c2' })] })
assert.deepEqual(mixed.posten.map((p) => [p.date, p.amountCents]), [['2026-10-09', 2000], ['2026-11-09', 2000]])
})
test('Abo mit cancelAt: keine Termine ab cancelAt', () => {
// gekündigt zum 05.11.: Verlängerung am 05.10. noch, die am 05.11. nicht mehr
const r = base({ horizonDays: 70, subscriptions: [sub({ cancelAt: '2026-11-05T10:00:00.000+00:00', cancelAtPeriodEnd: true })] })
assert.deepEqual(dates(r), ['2026-10-09'])
// gekündigt zum 20.12.: 05.10., 05.11. und 05.12. laufen noch
const r2 = base({ horizonDays: 90, subscriptions: [sub({ cancelAt: '2026-12-20T10:00:00.000+00:00' })] })
assert.deepEqual(dates(r2), ['2026-10-09', '2026-11-09', '2026-12-09'])
// gekündigt zum Tag der Verlängerung selbst: keine Verlängerung
const r3 = base({ horizonDays: 30, subscriptions: [sub({ cancelAt: '2026-10-05T10:00:00.000+00:00' })] })
assert.deepEqual(r3.posten, [])
})
test('Abo mit cancelAt mehrere Perioden voraus (Mindestlaufzeit): Verlängerungen bis vor cancelAt, cancelAtPeriodEnd ändert daran nichts', () => {
// Produktionsform: Stripe liefert cancelAtPeriodEnd = true, sobald cancel_at gesetzt ist
const cancelled = (cancelAt, extra = {}) => sub({ cancelAt, cancelAtPeriodEnd: true, ...extra })
// cancelAt 01.01.2027, Horizont 120 (bis 29.01.2027): 05.10., 05.11., 05.12. -> Auszahlung 09.10., 09.11., 09.12.; die vom 05.01. nicht
const r = base({ horizonDays: 120, subscriptions: [cancelled('2027-01-01T10:00:00.000+00:00')] })
assert.deepEqual(dates(r), ['2026-10-09', '2026-11-09', '2026-12-09'])
assert.ok(r.posten.every((p) => p.amountCents === 2000 && p.sicherheit === 'erwartet'))
// cancelAt am Verlängerungstag selbst: diese Verlängerung entfällt, die davor bleiben
assert.deepEqual(dates(base({ horizonDays: 120, subscriptions: [cancelled('2027-01-05T10:00:00.000+00:00')] })), ['2026-10-09', '2026-11-09', '2026-12-09'])
// einen Tag später: die Verlängerung am 05.01. läuft noch (Auszahlung 09.01.)
assert.deepEqual(dates(base({ horizonDays: 120, subscriptions: [cancelled('2027-01-06T10:00:00.000+00:00')] })), ['2026-10-09', '2026-11-09', '2026-12-09', '2027-01-09'])
// cancelAt wird als Berliner Tag gelesen: 04.01. 23:30 UTC ist in Berlin bereits der 05.01.
assert.deepEqual(dates(base({ horizonDays: 120, subscriptions: [cancelled('2027-01-04T23:30:00.000+00:00')] })), ['2026-10-09', '2026-11-09', '2026-12-09'])
// ohne cancelAtPeriodEnd (nur cancelAt) dasselbe
assert.deepEqual(dates(base({ horizonDays: 120, subscriptions: [sub({ cancelAt: '2027-01-01T10:00:00.000+00:00' })] })), ['2026-10-09', '2026-11-09', '2026-12-09'])
})
test('Abo: nur aktive zählen, Periodenende in der Vergangenheit wird vorgespult', () => {
const r = base({
subscriptions: [
sub({ customerId: 'c2', status: 'past_due' }),
sub({ customerId: 'c3', status: 'canceled' }),
sub({ customerId: 'c4', status: 'trialing' }),
sub({ customerId: 'c5', currentPeriodEnd: '2026-09-20T10:00:00.000+00:00' }),
],
})
// 20.09. + 1 Monat = 20.10. -> Auszahlung 24.10.
assert.deepEqual(r.posten.map((p) => [p.date, p.amountCents]), [['2026-10-24', 2000]])
})
test('ESt-Vorauszahlung 50000: 10.12. bei Horizont 90, keine bei Horizont 30; ohne Betrag keine', () => {
const settings = { estPrepaymentCents: 50000 }
const r90 = buildForecast({ today: TODAY, horizonDays: 90, balanceCents: BALANCE, settings })
assert.deepEqual(r90.posten, [{ date: '2026-12-10', label: 'ESt-Vorauszahlung', amountCents: -50000, sicherheit: 'sicher' }])
// die ESt-Vorauszahlung ist keine Fixkosten-Position: sie steckt schon in der Steuerruecklage (M6)
assert.equal(r90.fixkosten30Cents, 0)
assert.deepEqual(r90.minSicher, { amountCents: -39107, date: '2026-12-10' })
assert.deepEqual(buildForecast({ today: TODAY, horizonDays: 30, balanceCents: BALANCE, settings }).posten, [])
assert.deepEqual(buildForecast({ today: TODAY, horizonDays: 90, balanceCents: BALANCE, settings: { estPrepaymentCents: 0 } }).posten, [])
})
test('ESt-Vorauszahlung: alle vier Termine, auch über den Jahreswechsel', () => {
const r = buildForecast({ today: '2026-11-15', horizonDays: 400, balanceCents: 0, settings: { estPrepaymentCents: 100 } })
assert.deepEqual(dates(r), ['2026-12-10', '2027-03-10', '2027-06-10', '2027-09-10', '2027-12-10'])
// Termin genau heute zählt
assert.deepEqual(dates(buildForecast({ today: '2026-12-10', horizonDays: 0, balanceCents: 0, settings: { estPrepaymentCents: 100 } })), ['2026-12-10'])
})
test('Auszahlung in_transit 1500 am 03.10.: sicher +1500; pending zählt, paid/canceled/failed nicht, eine verspätete (Ankunftstag vorbei) kommt heute (M7)', () => {
const payouts = [
{ id: 'po_1', amountCents: 1500, arrivalDate: '2026-10-03', status: 'in_transit' },
{ id: 'po_2', amountCents: 700, arrivalDate: '2026-10-06', status: 'pending' },
{ id: 'po_3', amountCents: 999, arrivalDate: '2026-10-04', status: 'paid' },
{ id: 'po_4', amountCents: 999, arrivalDate: '2026-10-04', status: 'canceled' },
{ id: 'po_5', amountCents: 999, arrivalDate: '2026-10-04', status: 'failed' },
{ id: 'po_6', amountCents: 999, arrivalDate: '2026-09-29', status: 'pending' },
{ id: 'po_7', amountCents: 999, arrivalDate: '2026-11-05', status: 'pending' },
]
const r = base({ payouts })
assert.deepEqual(r.posten, [
{ date: TODAY, label: 'Stripe-Auszahlung', amountCents: 999, sicherheit: 'sicher' },
{ date: '2026-10-03', label: 'Stripe-Auszahlung', amountCents: 1500, sicherheit: 'sicher' },
{ date: '2026-10-06', label: 'Stripe-Auszahlung', amountCents: 700, sicherheit: 'sicher' },
])
assert.equal(r.endeSicherCents, 14092)
assert.equal(r.endeErwartetCents, 14092)
assert.equal(r.fixkosten30Cents, 0)
})
test('verspätete Auszahlung: Ankunftstag gestern oder vor Wochen -> heute, auch in_transit; ungültige Tage fallen weg (M7)', () => {
const r = base({
payouts: [
{ id: 'po_1', amountCents: 4000, arrivalDate: '2026-09-30', status: 'in_transit' },
{ id: 'po_2', amountCents: 500, arrivalDate: '2026-08-01', status: 'pending' },
{ id: 'po_3', amountCents: 900, arrivalDate: 'gestern', status: 'pending' },
],
})
assert.deepEqual(r.posten.map((p) => [p.date, p.amountCents]), [[TODAY, 4000], [TODAY, 500]])
assert.deepEqual(r.minSicher, { amountCents: 10893, date: TODAY })
assert.equal(r.endeSicherCents, 15393)
// ein Abfluss am selben Tag kommt weiter zuerst
const mixed = base({
payouts: [{ id: 'po_1', amountCents: 4000, arrivalDate: '2026-09-30', status: 'in_transit' }],
fixedCosts: [monthly({ nextDate: TODAY })],
})
assert.deepEqual(mixed.posten.map((p) => p.amountCents), [-449, 4000])
})
test('fixkosten30Cents zählt nur Fixkosten, nicht die ESt-Vorauszahlung; die ESt bleibt ein sicherer Posten (M6)', () => {
const r = buildForecast({
today: TODAY,
horizonDays: 90,
balanceCents: BALANCE,
settings: { estPrepaymentCents: 50000 },
fixedCosts: [monthly({ nextDate: '2026-10-10' })],
})
// 449 monatlich: 10.10., 10.11., 10.12., 10.01. liegt hinter dem Horizont (30.12.)
assert.equal(r.fixkosten30Cents, 3 * 449)
assert.deepEqual(r.posten.filter((p) => p.label === 'ESt-Vorauszahlung'), [{ date: '2026-12-10', label: 'ESt-Vorauszahlung', amountCents: -50000, sicherheit: 'sicher' }])
// die Linie rechnet die ESt weiter ein: 10893 - 3 * 449 - 50000
assert.equal(r.endeSicherCents, 10893 - 3 * 449 - 50000)
// nur ESt, keine Fixkosten: 0
assert.equal(buildForecast({ today: TODAY, horizonDays: 90, balanceCents: 0, settings: { estPrepaymentCents: 50000 } }).fixkosten30Cents, 0)
})
test('Sortierung: nach Datum, am selben Tag Abflüsse zuerst', () => {
const r = base({
payouts: [{ id: 'po_1', amountCents: 1500, arrivalDate: '2026-10-14', status: 'in_transit' }],
invoices: [invoice()],
fixedCosts: [monthly({ nextDate: '2026-10-14' }), monthly({ $id: 'fk0', name: 'Frueh', nextDate: '2026-10-02', amountCents: 100 })],
})
assert.deepEqual(r.posten.map((p) => [p.date, p.amountCents]), [
['2026-10-02', -100],
['2026-10-14', -449],
['2026-10-14', 1500],
['2026-10-14', 50000],
])
})
test('Minima: je Linie getrennt, frühestes Datum bei Gleichstand, am selben Tag zählen Abflüsse vor Zuflüssen', () => {
// Kontostand 1000: Miete 800 am 05.10. (sicher) -> 200; Rechnung 5000 am 03.10. (erwartet) -> erwartet nie unter 1000
const r = buildForecast({
today: TODAY, balanceCents: 1000, settings: {},
fixedCosts: [monthly({ name: 'Miete', amountCents: 800, nextDate: '2026-10-05' })],
invoices: [invoice({ dueDate: '2026-10-03', remainingCents: 5000 })],
})
assert.deepEqual(r.minSicher, { amountCents: 200, date: '2026-10-05' })
// erwartet: 1000 -> 6000 (03.10.) -> 5200 (05.10.); tiefster Stand ist der Start
assert.deepEqual(r.minErwartet, { amountCents: 1000, date: TODAY })
assert.equal(r.endeSicherCents, 200)
assert.equal(r.endeErwartetCents, 5200)
// Gleichstand: zwei Abflüsse, dazwischen ein Zufluss, der den Stand wieder auf das alte Minimum hebt
const tie = buildForecast({
today: TODAY, balanceCents: 1000, settings: {},
fixedCosts: [monthly({ name: 'A', amountCents: 500, nextDate: '2026-10-02' }), monthly({ $id: 'b', name: 'B', amountCents: 500, nextDate: '2026-10-04' })],
payouts: [{ id: 'po_1', amountCents: 500, arrivalDate: '2026-10-03', status: 'pending' }],
})
assert.deepEqual(tie.minSicher, { amountCents: 500, date: '2026-10-02' })
// Abfluss und Zufluss am selben Tag: Abfluss zuerst, also entsteht die Spitze nach unten
const sameDay = buildForecast({
today: TODAY, balanceCents: 100, settings: {},
fixedCosts: [monthly({ amountCents: 300, nextDate: '2026-10-10' })],
payouts: [{ id: 'po_1', amountCents: 300, arrivalDate: '2026-10-10', status: 'pending' }],
})
assert.deepEqual(sameDay.minSicher, { amountCents: -200, date: '2026-10-10' })
})
test('sicher und erwartet gemischt: Erwartetes verändert die sichere Linie nicht', () => {
const r = base({
fixedCosts: [monthly({ amountCents: 20000, nextDate: '2026-10-20' })],
invoices: [invoice({ dueDate: '2026-10-10', remainingCents: 30000 })],
subscriptions: [sub()],
})
assert.deepEqual(r.minSicher, { amountCents: -9107, date: '2026-10-20' })
assert.deepEqual(r.minErwartet, { amountCents: 10893, date: TODAY })
assert.equal(r.endeSicherCents, -9107)
assert.equal(r.endeErwartetCents, 10893 - 20000 + 30000 + 2000)
assert.equal(r.fixkosten30Cents, 20000)
})
test('Ungültige Eingaben: Kontostand und Horizont müssen ganze Zahlen sein', () => {
assert.throws(() => buildForecast({ today: TODAY, balanceCents: 10.5 }), /balanceCents/)
assert.throws(() => buildForecast({ today: TODAY, balanceCents: null }), /balanceCents/)
assert.throws(() => buildForecast({ today: TODAY, balanceCents: 0, horizonDays: -1 }), /horizonDays/)
assert.throws(() => buildForecast({ today: TODAY, balanceCents: 0, horizonDays: 1.5 }), /horizonDays/)
assert.throws(() => buildForecast({ today: 'heute', balanceCents: 0 }))
})
test('Eingaben bleiben unverändert', () => {
const fixedCosts = [monthly()]
const invoices = [invoice()]
const subscriptions = [sub()]
const snapshot = JSON.stringify({ fixedCosts, invoices, subscriptions })
base({ fixedCosts, invoices, subscriptions })
assert.equal(JSON.stringify({ fixedCosts, invoices, subscriptions }), snapshot)
})
+297
View File
@@ -0,0 +1,297 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { normalizeRef, nameTokens, suggestMatches } from '../server/services/finance/matching.js'
const tx = (overrides = {}) => ({
$id: 'tx1',
status: 'BOOK',
bookingDate: '2026-09-30',
amountCents: 16879,
counterpartyName: 'EWV SOFTWARE GMBH',
counterpartyIban: 'DE02120300000000202051',
remittance: 'Rechnung WK-0007 vom 15.09.2026',
mcc: '',
category: '',
...overrides,
})
const invoice = (overrides = {}) => ({
id: 'in_1',
number: 'WK-0007',
status: 'offen',
customerName: 'EWV Software GmbH',
remainingCents: 16879,
...overrides,
})
const payout = (overrides = {}) => ({ id: 'po_1', amountCents: 120000, arrivalDate: '2026-09-28', status: 'paid', ...overrides })
test('normalizeRef: Grossbuchstaben ohne Leerraum, Bindestrich, Unterstrich, Punkt und Schraegstrich', () => {
assert.equal(normalizeRef('wk-0007'), 'WK0007')
assert.equal(normalizeRef(' Re 2026/0042_a.b '), 'RE20260042AB')
assert.equal(normalizeRef('WK 00\n07'), 'WK0007')
assert.equal(normalizeRef(''), '')
assert.equal(normalizeRef(null), '')
assert.equal(normalizeRef(undefined), '')
})
test('nameTokens: klein, an Nicht-Buchstaben getrennt, Umlaute bleiben, mindestens 4 Zeichen, ohne Rechtsformen', () => {
assert.deepEqual(nameTokens('EWV Software GmbH'), ['software'])
assert.deepEqual(nameTokens('Döner Nation'), ['döner', 'nation'])
assert.deepEqual(nameTokens('Müller & Söhne e.K.'), ['müller', 'söhne'])
assert.deepEqual(nameTokens('Meier-Bau GbR'), ['meier'])
assert.deepEqual(nameTokens('Haftungsbeschränkt Fenster und Türen'), ['fenster', 'türen'])
assert.deepEqual(nameTokens('Webklar24 Studio UG'), ['webklar', 'studio'])
assert.deepEqual(nameTokens('Die Der Das'), [])
assert.deepEqual(nameTokens(''), [])
assert.deepEqual(nameTokens(null), [])
})
test('Regel 2: Rechnungsnummer im Verwendungszweck und Betrag passt -> hoch', () => {
const result = suggestMatches(tx(), { invoices: [invoice()], payouts: [] })
assert.equal(result.length, 1)
assert.deepEqual(result[0], {
kind: 'invoice',
id: 'in_1',
number: 'WK-0007',
confidence: 'hoch',
reason: 'Rechnungsnummer im Verwendungszweck',
amountDiffers: false,
})
})
test('Regel 2: Rechnungsnummer passt, Betrag weicht ab -> mittel mit Hinweis', () => {
const result = suggestMatches(tx(), { invoices: [invoice({ remainingCents: 20000 })], payouts: [] })
assert.equal(result.length, 1)
assert.equal(result[0].confidence, 'mittel')
assert.equal(result[0].amountDiffers, true)
assert.match(result[0].reason, /Rechnungsnummer im Verwendungszweck/)
assert.match(result[0].reason, /Betrag weicht ab/)
})
test('Regel 2: Nummer wird normalisiert verglichen (Bindestrich, Leerraum, Gross-/Kleinschreibung)', () => {
for (const remittance of ['wk0007', 'WK 0007', 'Zahlung wk-0007 danke', 'WK_0007', 'WK/0007', 'WK.0007']) {
const result = suggestMatches(tx({ remittance }), { invoices: [invoice()], payouts: [] })
assert.equal(result.length, 1, remittance)
assert.equal(result[0].confidence, 'hoch', remittance)
}
})
test('Regel 2: sehr kurze Rechnungsnummern (normalisiert < 4 Zeichen) und Entwuerfe ohne Nummer werden ignoriert', () => {
// "R-1" -> "R1" steckt in fast jedem Verwendungszweck; ohne Namensbezug zaehlt dann nur der Betrag (niedrig)
const kurz = suggestMatches(tx({ remittance: 'R1 R-1 Zahlung', counterpartyName: 'Fremd AG' }), {
invoices: [invoice({ number: 'R-1' })],
payouts: [],
})
assert.equal(kurz.length, 1)
assert.equal(kurz[0].confidence, 'niedrig')
// Entwurf mit leerer Nummer: '' steckt in jedem Text, darf aber nie als Nummer treffen
const leer = suggestMatches(tx({ counterpartyName: 'Fremd AG', remittance: 'irgendwas' }), {
invoices: [invoice({ number: '', remainingCents: 5 })],
payouts: [],
})
assert.deepEqual(leer, [])
})
test('nur offene und ueberfaellige Rechnungen kommen in Frage', () => {
for (const status of ['entwurf', 'bezahlt', 'storniert', 'uneinbringlich']) {
assert.deepEqual(suggestMatches(tx(), { invoices: [invoice({ status })], payouts: [] }), [], status)
}
const ueberfaellig = suggestMatches(tx(), { invoices: [invoice({ status: 'ueberfaellig' })], payouts: [] })
assert.equal(ueberfaellig.length, 1)
assert.equal(ueberfaellig[0].confidence, 'hoch')
})
test('Regel 3: Betrag passt, Name passt, keine Nummer -> mittel', () => {
const result = suggestMatches(tx({ remittance: 'Zahlung Projekt Website' }), {
invoices: [invoice()],
payouts: [],
})
assert.equal(result.length, 1)
assert.equal(result[0].confidence, 'mittel')
assert.equal(result[0].reason, 'Betrag und Name passen')
assert.equal(result[0].amountDiffers, false)
})
test('Regel 3: Namenswort muss im Zahlernamen vorkommen, Rechtsformen zaehlen nicht', () => {
// Kunde "Beispiel GmbH" und Zahler "Andere GmbH": nur "gmbh" gemeinsam -> kein Namenstreffer, nur Betrag
const result = suggestMatches(tx({ remittance: 'Zahlung', counterpartyName: 'Andere GmbH' }), {
invoices: [invoice({ customerName: 'Beispiel GmbH' })],
payouts: [],
})
assert.equal(result.length, 1)
assert.equal(result[0].confidence, 'niedrig')
})
test('Regel 3: Name passt, aber der Betrag nicht -> kein Vorschlag', () => {
const result = suggestMatches(tx({ remittance: 'Zahlung' }), {
invoices: [invoice({ remainingCents: 20000 })],
payouts: [],
})
assert.deepEqual(result, [])
})
test('Regel 4: nur Betrag, genau eine offene Rechnung -> niedrig', () => {
const result = suggestMatches(tx({ remittance: 'Zahlung', counterpartyName: 'Max Mustermann' }), {
invoices: [invoice({ customerName: 'Döner Nation' })],
payouts: [],
})
assert.equal(result.length, 1)
assert.equal(result[0].confidence, 'niedrig')
assert.equal(result[0].reason, 'Nur der Betrag passt')
assert.equal(result[0].amountDiffers, false)
assert.equal(result[0].number, 'WK-0007')
})
test('Regel 4: zwei offene Rechnungen gleichen Betrags ohne Namensbezug -> kein niedrig-Vorschlag', () => {
const result = suggestMatches(tx({ remittance: 'Zahlung', counterpartyName: 'Max Mustermann' }), {
invoices: [
invoice({ id: 'in_1', number: 'WK-0007', customerName: 'Döner Nation' }),
invoice({ id: 'in_2', number: 'WK-0008', customerName: 'Pizza Palast' }),
],
payouts: [],
})
assert.deepEqual(result, [])
})
test('Regel 3 trifft auch bei gleichem Betrag mehrerer Rechnungen den passenden Namen, die andere bleibt ohne Vorschlag', () => {
const result = suggestMatches(tx({ remittance: 'Zahlung' }), {
invoices: [
invoice({ id: 'in_1', number: 'WK-0007', customerName: 'Döner Nation' }),
invoice({ id: 'in_2', number: 'WK-0008', customerName: 'EWV Software GmbH' }),
],
payouts: [],
})
assert.equal(result.length, 1)
assert.equal(result[0].id, 'in_2')
assert.equal(result[0].confidence, 'mittel')
})
test('je Rechnung hoechstens ein Vorschlag, der staerkste (Nummer vor Name vor Betrag)', () => {
// Nummer UND Name UND Betrag passen: nur der hoch-Vorschlag
const result = suggestMatches(tx(), { invoices: [invoice()], payouts: [] })
assert.equal(result.length, 1)
assert.equal(result[0].confidence, 'hoch')
// Nummer passt mit abweichendem Betrag UND Name passt: mittel wegen Nummer (kein zweiter Eintrag)
const abw = suggestMatches(tx(), { invoices: [invoice({ remainingCents: 1 })], payouts: [] })
assert.equal(abw.length, 1)
assert.equal(abw[0].reason.includes('Betrag weicht ab'), true)
})
test('Sortierung hoch -> mittel -> niedrig und hoechstens 3 Vorschlaege', () => {
const invoices = [
invoice({ id: 'in_n1', number: 'XX-0001', customerName: 'Fremd', remainingCents: 16879 }),
invoice({ id: 'in_m1', number: 'XX-0002', customerName: 'EWV Software GmbH', remainingCents: 16879 }),
invoice({ id: 'in_h', number: 'WK-0007', customerName: 'Fremd', remainingCents: 16879 }),
invoice({ id: 'in_m2', number: 'XX-0003', customerName: 'Software Haus', remainingCents: 16879 }),
invoice({ id: 'in_m3', number: 'XX-0004', customerName: 'Software Kontor', remainingCents: 16879 }),
]
const result = suggestMatches(tx(), { invoices, payouts: [] })
assert.equal(result.length, 3)
assert.deepEqual(
result.map((s) => s.confidence),
['hoch', 'mittel', 'mittel']
)
assert.equal(result[0].id, 'in_h')
// Rangfolge innerhalb einer Stufe: Reihenfolge der Eingabe
assert.deepEqual(
result.slice(1).map((s) => s.id),
['in_m1', 'in_m2']
)
})
test('Sortierung: Rechnungsnummer mit abweichendem Betrag (mittel) steht vor dem reinen Betragstreffer (niedrig), auch bei umgekehrter Eingabe', () => {
const result = suggestMatches(tx({ counterpartyName: 'Max Mustermann' }), {
invoices: [
invoice({ id: 'in_low', number: 'XX-0001', customerName: 'Fremd', remainingCents: 16879 }),
invoice({ id: 'in_mid', number: 'WK-0007', customerName: 'Fremd', remainingCents: 20000 }),
],
payouts: [],
})
assert.deepEqual(
result.map((s) => [s.id, s.confidence]),
[
['in_mid', 'mittel'],
['in_low', 'niedrig'],
]
)
})
test('Auszahlung (hoch) steht vor Rechnungsvorschlaegen', () => {
const result = suggestMatches(
tx({ amountCents: 120000, counterpartyName: 'Stripe Technologies Europe Ltd', remittance: '', bookingDate: '2026-09-29' }),
{ invoices: [invoice({ remainingCents: 120000, customerName: 'Fremd' })], payouts: [payout()] }
)
assert.deepEqual(
result.map((s) => [s.kind, s.confidence]),
[
['payout', 'hoch'],
['invoice', 'niedrig'],
]
)
})
test('Regel 1: Stripe-Auszahlung gleicher Betrag, 2 Tage nach Ankunft -> payout, hoch', () => {
const result = suggestMatches(
tx({ amountCents: 120000, counterpartyName: 'Stripe Technologies Europe Ltd', remittance: 'STRIPE PAYOUT', bookingDate: '2026-09-30' }),
{ invoices: [], payouts: [payout()] }
)
assert.equal(result.length, 1)
assert.deepEqual(result[0], {
kind: 'payout',
id: 'po_1',
number: '',
confidence: 'hoch',
reason: 'Stripe-Auszahlung gleicher Betrag',
amountDiffers: false,
})
})
test('Regel 1: 5 Tage Abstand -> kein Vorschlag, 3 Tage (vor und nach Ankunft) -> Vorschlag', () => {
const base = { amountCents: 120000, counterpartyName: 'Stripe Technologies Europe Ltd', remittance: '' }
assert.deepEqual(suggestMatches(tx({ ...base, bookingDate: '2026-10-03' }), { invoices: [], payouts: [payout()] }), [])
assert.equal(suggestMatches(tx({ ...base, bookingDate: '2026-10-01' }), { invoices: [], payouts: [payout()] }).length, 1)
assert.equal(suggestMatches(tx({ ...base, bookingDate: '2026-09-25' }), { invoices: [], payouts: [payout()] }).length, 1)
assert.deepEqual(suggestMatches(tx({ ...base, bookingDate: '2026-09-24' }), { invoices: [], payouts: [payout()] }), [])
})
test('Regel 1: "stripe" im Verwendungszweck genuegt, Gross-/Kleinschreibung egal; ohne "stripe" kein Auszahlungsvorschlag', () => {
const base = { amountCents: 120000, bookingDate: '2026-09-28' }
const imZweck = suggestMatches(tx({ ...base, counterpartyName: 'Irgendeine Bank', remittance: 'payout via StRiPe' }), {
invoices: [],
payouts: [payout()],
})
assert.equal(imZweck.length, 1)
assert.equal(imZweck[0].kind, 'payout')
const ohne = suggestMatches(tx({ ...base, counterpartyName: 'Irgendeine Bank', remittance: 'Auszahlung' }), {
invoices: [],
payouts: [payout()],
})
assert.deepEqual(ohne, [])
})
test('Regel 1: Betrag muss exakt der Auszahlung entsprechen', () => {
const result = suggestMatches(
tx({ amountCents: 119999, counterpartyName: 'Stripe Technologies Europe Ltd', remittance: '', bookingDate: '2026-09-28' }),
{ invoices: [], payouts: [payout()] }
)
assert.deepEqual(result, [])
})
test('Ausgang (amountCents < 0), Betrag 0 oder schon zugeordnet -> []', () => {
const ctx = { invoices: [invoice()], payouts: [payout({ amountCents: 16879 })] }
assert.deepEqual(suggestMatches(tx({ amountCents: -16879 }), ctx), [])
assert.deepEqual(suggestMatches(tx({ amountCents: 0 }), ctx), [])
assert.deepEqual(suggestMatches(tx({ category: 'einnahme_rechnung' }), ctx), [])
})
test('fehlende Felder und fehlende Listen werfen nicht', () => {
assert.deepEqual(suggestMatches(tx({ counterpartyName: undefined, remittance: undefined }), {}), [])
assert.deepEqual(suggestMatches(tx({ counterpartyName: null, remittance: null }), { invoices: undefined, payouts: undefined }), [])
// kaputtes Datum einer Auszahlung -> kein Treffer, kein Fehler
const result = suggestMatches(
tx({ amountCents: 120000, counterpartyName: 'Stripe', remittance: '' }),
{ invoices: [], payouts: [payout({ arrivalDate: '' })] }
)
assert.deepEqual(result, [])
})
File diff suppressed because it is too large. Load diff
+155
View File
@@ -0,0 +1,155 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { detectRecurring } from '../server/services/finance/recurring.js'
let counter = 0
const out = (counterpartyName, bookingDate, cents, overrides = {}) => ({
$id: `tx${++counter}`,
status: 'BOOK',
bookingDate,
amountCents: -cents,
counterpartyName,
counterpartyIban: '',
remittance: '',
mcc: '',
category: '',
...overrides,
})
test('drei PORKBUN-Buchungen binnen weniger Tage sind keine wiederkehrende Ausgabe', () => {
const txs = [out('PORKBUN LLC', '2026-09-27', 449), out('PORKBUN LLC', '2026-09-29', 449), out('PORKBUN LLC', '2026-09-29', 449)]
assert.deepEqual(detectRecurring(txs), [])
})
test('OPENROUTER monatlich 43,58 EUR -> monat', () => {
const txs = [out('OPENROUTER INC', '2026-06-16', 4358), out('OPENROUTER INC', '2026-07-16', 4358), out('OPENROUTER INC', '2026-08-16', 4358)]
assert.deepEqual(detectRecurring(txs), [
{ key: 'openrouter inc', name: 'OPENROUTER INC', amountCents: 4358, interval: 'monat', lastDate: '2026-08-16', count: 3 },
])
})
test('stark schwankende Betraege (1000/2000/3000) sind keine wiederkehrende Ausgabe', () => {
const txs = [out('Strom AG', '2026-06-16', 1000), out('Strom AG', '2026-07-16', 2000), out('Strom AG', '2026-08-16', 3000)]
assert.deepEqual(detectRecurring(txs), [])
})
test('aktive Fixkostenposition mit passendem counterpartyMatch blendet die Gruppe aus', () => {
const txs = [out('OPENROUTER INC', '2026-06-16', 4358), out('OPENROUTER INC', '2026-07-16', 4358), out('OPENROUTER INC', '2026-08-16', 4358)]
const fixedCosts = [{ $id: 'fc1', name: 'OpenRouter', amountCents: 4358, interval: 'monat', nextDate: '2026-09-16', counterpartyMatch: 'openrouter', active: true }]
assert.deepEqual(detectRecurring(txs, { fixedCosts }), [])
})
test('inaktive Fixkosten, fremde Treffer und leerer counterpartyMatch blenden nichts aus', () => {
const txs = [out('OPENROUTER INC', '2026-06-16', 4358), out('OPENROUTER INC', '2026-07-16', 4358), out('OPENROUTER INC', '2026-08-16', 4358)]
const base = { $id: 'fc1', name: 'X', amountCents: 1, interval: 'monat', nextDate: '2026-09-16' }
assert.equal(detectRecurring(txs, { fixedCosts: [{ ...base, counterpartyMatch: 'openrouter', active: false }] }).length, 1)
assert.equal(detectRecurring(txs, { fixedCosts: [{ ...base, counterpartyMatch: 'hetzner', active: true }] }).length, 1)
assert.equal(detectRecurring(txs, { fixedCosts: [{ ...base, counterpartyMatch: '', active: true }] }).length, 1)
assert.equal(detectRecurring(txs, { fixedCosts: [{ ...base, active: true }] }).length, 1)
})
test('Gruppierung ueber normalisierte Gegenpartei (Gross-/Kleinschreibung, Ziffern, Satzzeichen, Leerraum)', () => {
const txs = [
out('Hetzner Online GmbH', '2026-06-03', 2599),
out('HETZNER ONLINE GMBH 123', '2026-07-03', 2599),
out('hetzner online gmbh.', '2026-08-03', 2599),
]
const result = detectRecurring(txs)
assert.equal(result.length, 1)
assert.equal(result[0].key, 'hetzner online gmbh')
assert.equal(result[0].count, 3)
assert.equal(result[0].interval, 'monat')
// Name der juengsten Buchung
assert.equal(result[0].name, 'hetzner online gmbh.')
})
test('Quartal (80-100 Tage) und Jahr (350-380 Tage)', () => {
const quartal = [out('Versicherung AG', '2026-01-01', 15000), out('Versicherung AG', '2026-04-01', 15000), out('Versicherung AG', '2026-07-01', 15000)]
const jahr = [out('Domain Shop', '2024-03-10', 1999), out('Domain Shop', '2025-03-10', 1999), out('Domain Shop', '2026-03-10', 1999)]
const result = detectRecurring([...quartal, ...jahr])
assert.equal(result.length, 2)
const byKey = Object.fromEntries(result.map((r) => [r.key, r]))
assert.equal(byKey['versicherung ag'].interval, 'quartal')
assert.equal(byKey['domain shop'].interval, 'jahr')
assert.equal(byKey['domain shop'].lastDate, '2026-03-10')
})
test('Intervallgrenzen: 25 und 35 Tage zaehlen als monat, 24 und 36 nicht', () => {
const reihe = (name, gap) => {
const start = Date.UTC(2026, 0, 1)
return [0, 1, 2].map((i) => out(name, new Date(start + i * gap * 86_400_000).toISOString().slice(0, 10), 1000))
}
assert.equal(detectRecurring(reihe('Gap25', 25)).length, 1)
assert.equal(detectRecurring(reihe('Gap35', 35)).length, 1)
assert.deepEqual(detectRecurring(reihe('Gap24', 24)), [])
assert.deepEqual(detectRecurring(reihe('Gap36', 36)), [])
assert.equal(detectRecurring(reihe('Gap80', 80))[0].interval, 'quartal')
assert.equal(detectRecurring(reihe('Gap100', 100))[0].interval, 'quartal')
assert.deepEqual(detectRecurring(reihe('Gap101', 101)), [])
assert.deepEqual(detectRecurring(reihe('Gap60', 60)), [])
assert.equal(detectRecurring(reihe('Gap350', 350))[0].interval, 'jahr')
assert.equal(detectRecurring(reihe('Gap380', 380))[0].interval, 'jahr')
assert.deepEqual(detectRecurring(reihe('Gap381', 381)), [])
})
test('Median der Abstaende: ein Ausreisser stoert nicht', () => {
// Abstaende 30, 30, 30, 90 (eine Buchung uebersprungen) -> Median 30
const txs = [
out('Cloud Dienst', '2026-01-10', 999),
out('Cloud Dienst', '2026-02-09', 999),
out('Cloud Dienst', '2026-03-11', 999),
out('Cloud Dienst', '2026-04-10', 999),
out('Cloud Dienst', '2026-07-09', 999),
]
const result = detectRecurring(txs)
assert.equal(result.length, 1)
assert.equal(result[0].interval, 'monat')
assert.equal(result[0].count, 5)
})
test('Betrag ist der Median als positiver Wert; Variationskoeffizient bis 0,15 erlaubt', () => {
const txs = [out('Telefon', '2026-06-05', 4000), out('Telefon', '2026-07-05', 4500), out('Telefon', '2026-08-05', 4100)]
const result = detectRecurring(txs)
assert.equal(result.length, 1)
assert.equal(result[0].amountCents, 4100)
assert.ok(result[0].amountCents > 0)
// 4 Betraege, gerade Anzahl: Mittel der beiden mittleren, ganzzahlig gerundet
const gerade = [out('Gerade', '2026-05-05', 4000), out('Gerade', '2026-06-05', 4001), out('Gerade', '2026-07-05', 4100), out('Gerade', '2026-08-05', 4101)]
const g = detectRecurring(gerade)
assert.equal(g.length, 1)
assert.equal(Number.isInteger(g[0].amountCents), true)
assert.equal(g[0].amountCents, 4051)
})
test('weniger als 3 Buchungen -> keine Erkennung', () => {
assert.deepEqual(detectRecurring([out('Zwei', '2026-06-16', 1000), out('Zwei', '2026-07-16', 1000)]), [])
assert.deepEqual(detectRecurring([]), [])
})
test('Eingaenge, vorgemerkte (PDNG) und Buchungen ohne Gegenpartei zaehlen nicht', () => {
const eingang = [
out('Kunde', '2026-06-16', 1000, { amountCents: 1000 }),
out('Kunde', '2026-07-16', 1000, { amountCents: 1000 }),
out('Kunde', '2026-08-16', 1000, { amountCents: 1000 }),
]
assert.deepEqual(detectRecurring(eingang), [])
const vorgemerkt = [out('Pending', '2026-06-16', 1000), out('Pending', '2026-07-16', 1000), out('Pending', '2026-08-16', 1000, { status: 'PDNG' })]
assert.deepEqual(detectRecurring(vorgemerkt), [])
const ohneName = [out('', '2026-06-16', 1000), out('', '2026-07-16', 1000), out('123', '2026-08-16', 1000, { counterpartyName: '123' })]
assert.deepEqual(detectRecurring(ohneName), [])
assert.deepEqual(detectRecurring([out(undefined, '2026-06-16', 1000, { counterpartyName: undefined })]), [])
})
test('unsortierte Eingabe und kaputte Datumswerte', () => {
const txs = [out('Reihenfolge', '2026-08-16', 4358), out('Reihenfolge', '2026-06-16', 4358), out('Reihenfolge', '2026-07-16', 4358), out('Reihenfolge', 'kaputt', 4358)]
const result = detectRecurring(txs)
assert.equal(result.length, 1)
assert.equal(result[0].count, 3)
assert.equal(result[0].lastDate, '2026-08-16')
})
test('Ergebnis: groesste Betraege zuerst', () => {
const klein = [out('Klein', '2026-06-01', 500), out('Klein', '2026-07-01', 500), out('Klein', '2026-08-01', 500)]
const gross = [out('Gross', '2026-06-01', 9000), out('Gross', '2026-07-01', 9000), out('Gross', '2026-08-01', 9000)]
assert.deepEqual(detectRecurring([...klein, ...gross]).map((r) => r.key), ['gross', 'klein'])
})
+435
View File
@@ -0,0 +1,435 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { startFakeEnableBanking } from './support/fakeEnableBanking.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird (Muster wie financeBankAuth.test.js)
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 })
const pem = (key, type) => key.export({ type, format: 'pem' })
const keyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bank-routes-test-'))
const keyPath = path.join(keyDir, 'private.pem')
fs.writeFileSync(keyPath, pem(privateKey, 'pkcs8'))
const SESSION = 'sess-geheim-4711'
const ACCOUNT = 'konto-4711'
const NEW_SESSION = 'sess-neu-geheim-4712'
const NEW_ACCOUNT = 'konto-neu-1'
const IBAN = 'DE00000000000000009313'
const BANK_GONE = 'Die Bank hat die Freigabe nicht bestätigt. Bitte erneut verbinden.'
const fake = await startFakeEnableBanking({ publicKeyPem: pem(publicKey, 'spki'), appId: 'app-test' })
const aw = await startAppwriteMock({
schema: { bankConnections: schemaKeys('bankConnections'), bankTransactions: schemaKeys('bankTransactions') },
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
Object.assign(process.env, { ENABLE_BANKING_APP_ID: 'app-test', ENABLE_BANKING_KEY_PATH: keyPath, ENABLE_BANKING_API_BASE: fake.url })
const { config } = await import('../server/config.js')
const { startTestApp, get, post, rawGet } = await import('./support/testApp.js')
const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js')
const store = await import('../server/services/finance/bankStore.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const app = await startTestApp({ '/api/admin/finanzen': financeRoutes })
after(async () => {
await Promise.all([app.close(), fake.close(), aw.close(), st.close()])
fs.rmSync(keyDir, { recursive: true, force: true })
})
const NOW = Date.now()
const MINUTE = 60_000
const minutesAgo = (minutes) => new Date(NOW - minutes * MINUTE).toISOString()
const admin = { headers: { Authorization: 'Bearer jwt-admin' } }
const base = '/api/admin/finanzen'
const connection = () => aw.docs(config.collections.bankConnections).get('finom')
const txDocs = () => aw.docs(config.collections.bankTransactions)
const callsTo = (suffix) => fake.calls.filter((c) => c.path.endsWith(suffix))
const FULL_KEYS = [
'status',
'ibanMasked',
'validUntil',
'lastSyncAt',
'lastSyncOk',
'lastError',
'balanceBookedCents',
'balanceAvailableCents',
'balanceDate',
'latestBookingDate',
]
/**
* Ausgangszustand: aktiver Zugang mit Konto ACCOUNT, zwei Buchungen und Salden beim Nachbau; der Nachbau kennt
* ausserdem den Code 'neu' -> Sitzung NEW_SESSION (Konto NEW_ACCOUNT mit derselben IBAN).
*/
async function reset({ connected = true, stored = {} } = {}) {
for (const id of txDocs().keys()) await store.deleteTransaction(id)
await deleteDocument(config.collections.bankConnections, 'finom').catch(() => {})
fake.calls.length = 0
fake.accounts.clear()
fake.sessions.clear()
fake.codes.clear()
fake.transactions.clear()
fake.balances.clear()
for (const uid of [ACCOUNT, NEW_ACCOUNT]) {
fake.accounts.set(uid, { uid, identification_hash: 'h-4711', account_id: { iban: IBAN }, name: 'Webklar', currency: 'EUR' })
}
fake.sessions.set('s-neu', {
session_id: NEW_SESSION,
status: 'AUTHORIZED',
accounts: [NEW_ACCOUNT],
access: { valid_until: '2027-03-29T00:00:00.000Z', balances: true, transactions: true },
aspsp: { name: 'Finom', country: 'DE' },
psu_type: 'business',
})
fake.codes.set('neu', 's-neu')
fake.transactions.set(ACCOUNT, [
{
entry_reference: 'r1',
status: 'BOOK',
booking_date: '2026-09-29',
transaction_amount: { amount: '4.49', currency: 'EUR' },
credit_debit_indicator: 'DBIT',
creditor: { name: 'PORKBUN.COM' },
},
])
fake.balances.set(ACCOUNT, [{ balance_type: 'ITBD', balance_amount: { amount: '108.93', currency: 'EUR' }, reference_date: '2026-10-01' }])
if (!connected) return
await store.saveConnection({
status: 'aktiv',
sessionId: SESSION,
accountUid: ACCOUNT,
accountIdHash: 'h-4711',
iban: IBAN,
ibanMasked: 'DE00…9313',
validUntil: '2027-03-29T00:00:00.000Z',
...stored,
})
}
/** Freigabe starten und den gespeicherten state lesen (wie die Seite nach "Verbinden"). */
async function begin() {
const { body } = await post(`${base}/bank/verbinden`, {}, admin)
assert.match(body.url, /fake-bank\/auth\?state=/)
return connection().authState
}
// ---------- Zugriff ----------
test('ohne JWT 401, mit Mitarbeiter-JWT ohne Admin-Label 403, bei jeder Route', async () => {
await reset()
const routes = [
['GET', '/bank'],
['POST', '/bank/aktualisieren'],
['POST', '/bank/verbinden'],
['POST', '/bank/rueckkehr'],
['GET', '/buchungen'],
]
for (const [method, route] of routes) {
const call = (headers) => (method === 'GET' ? rawGet(base + route, { headers }) : post(base + route, {}, { headers }))
assert.equal((await call({})).status, 401, `${method} ${route} ohne JWT`)
assert.equal((await call({ Authorization: 'Bearer jwt-unbekannt' })).status, 401, `${method} ${route} mit falschem JWT`)
assert.equal((await call({ Authorization: 'Bearer jwt-staff' })).status, 403, `${method} ${route} ohne Admin-Label`)
}
assert.equal(fake.calls.length, 0)
})
// ---------- GET /bank ----------
test('GET /bank: ohne Verbindung { verbindung: null }', async () => {
await reset({ connected: false })
assert.deepEqual(await get(`${base}/bank`, admin), { verbindung: null })
})
test('GET /bank: genau die Anzeigefelder, nie Sitzung, IBAN, state oder Konto-Kennungen', async () => {
await reset({
stored: {
lastSyncAt: '2026-10-01T09:00:00.000Z',
lastSyncOk: true,
lastError: '',
lastManualSyncAt: '2026-10-01T08:00:00.000Z',
balanceBookedCents: 10893,
balanceAvailableCents: 10893,
balanceDate: '2026-10-01',
latestBookingDate: '2026-09-29',
authState: 'state-geheim-abc',
authStartedAt: '2026-10-01T08:30:00.000Z',
warnedThresholds: '30',
},
})
const res = await rawGet(`${base}/bank`, admin)
assert.equal(res.status, 200)
assert.deepEqual(Object.keys(res.body), ['verbindung'])
assert.deepEqual(res.body.verbindung, {
status: 'aktiv',
ibanMasked: 'DE00…9313',
validUntil: '2027-03-29T00:00:00.000Z',
lastSyncAt: '2026-10-01T09:00:00.000Z',
lastSyncOk: true,
lastError: '',
balanceBookedCents: 10893,
balanceAvailableCents: 10893,
balanceDate: '2026-10-01',
latestBookingDate: '2026-09-29',
})
const text = JSON.stringify(res.body)
for (const secret of [SESSION, ACCOUNT, IBAN, 'h-4711', 'state-geheim-abc', 'sessionId', 'iban"', 'authState', 'accountUid', 'accountIdHash', 'authStartedAt']) {
assert.ok(!text.includes(secret), `Antwort enthält ${secret}`)
}
})
test('GET /bank: noch nie abgerufen -> alle Anzeigefelder vorhanden, fehlende null', async () => {
await reset()
const { verbindung } = await get(`${base}/bank`, admin)
assert.deepEqual(Object.keys(verbindung).sort(), [...FULL_KEYS].sort())
assert.equal(verbindung.status, 'aktiv')
assert.equal(verbindung.lastSyncAt, null)
assert.equal(verbindung.balanceBookedCents, null)
})
// ---------- POST /bank/aktualisieren ----------
test('aktualisieren: zweimal hintereinander -> 200, dann 429 ohne zweiten Bankabruf', async () => {
await reset()
const first = await post(`${base}/bank/aktualisieren`, {}, admin)
assert.equal(first.status, 200)
assert.deepEqual(first.body, { ok: true, neu: 1, aktualisiert: 0, geloescht: 0 })
assert.equal(txDocs().size, 1)
assert.ok(Date.now() - Date.parse(connection().lastManualSyncAt) < 10_000)
const bankCalls = fake.calls.length
const second = await post(`${base}/bank/aktualisieren`, {}, admin)
assert.equal(second.status, 429)
assert.equal(second.body.error, 'Bitte in 10 Minuten erneut versuchen.')
assert.equal(fake.calls.length, bankCalls)
})
test('aktualisieren: die Bank bekommt IP und Browserkennung des Nutzers (Abruf durch einen Menschen)', async () => {
await reset()
const res = await post(`${base}/bank/aktualisieren`, {}, { headers: { ...admin.headers, 'User-Agent': 'WOMS-Test/1.0', 'X-Forwarded-For': '203.0.113.7' } })
assert.equal(res.status, 200)
const [balances] = callsTo('/balances')
assert.equal(balances.headers['psu-user-agent'], 'WOMS-Test/1.0')
// trust proxy 1: die Adresse aus X-Forwarded-For
assert.equal(balances.headers['psu-ip-address'], '203.0.113.7')
})
test('aktualisieren: Wartezeit zählt Minuten nach oben; nach 10 Minuten geht es wieder', async () => {
await reset({ stored: { lastManualSyncAt: minutesAgo(3) } })
const blocked = await post(`${base}/bank/aktualisieren`, {}, admin)
assert.equal(blocked.status, 429)
assert.equal(blocked.body.error, 'Bitte in 7 Minuten erneut versuchen.')
assert.equal(connection().lastManualSyncAt, minutesAgo(3), 'ein abgelehnter Aufruf verlängert die Sperre nicht')
await store.saveConnection({ lastManualSyncAt: new Date(NOW - 9.5 * MINUTE).toISOString() })
const almost = await post(`${base}/bank/aktualisieren`, {}, admin)
assert.equal(almost.status, 429)
assert.equal(almost.body.error, 'Bitte in 1 Minute erneut versuchen.')
await store.saveConnection({ lastManualSyncAt: minutesAgo(10.5) })
assert.equal((await post(`${base}/bank/aktualisieren`, {}, admin)).status, 200)
})
test('aktualisieren: zwei gleichzeitige Klicks -> ein Abruf, der andere 429', async () => {
await reset()
const [a, b] = await Promise.all([post(`${base}/bank/aktualisieren`, {}, admin), post(`${base}/bank/aktualisieren`, {}, admin)])
assert.deepEqual([a.status, b.status].sort(), [200, 429])
assert.equal(callsTo('/balances').length, 1)
})
test('aktualisieren: Fehler der Bank kommen als Ergebnis (200, ok false), ohne Sitzungs-ID', async () => {
await reset()
fake.failNext({ path: /\/balances$/, status: 500, body: { code: 500, error: 'FAKE_ERROR' } })
const res = await post(`${base}/bank/aktualisieren`, {}, admin)
assert.equal(res.status, 200)
assert.equal(res.body.ok, false)
assert.equal(typeof res.body.error, 'string')
assert.ok(!JSON.stringify(res.body).includes(SESSION))
})
test('aktualisieren: ohne Bankzugang -> ok false, es wird keine Verbindung angelegt', async () => {
await reset({ connected: false })
const res = await post(`${base}/bank/aktualisieren`, {}, admin)
assert.equal(res.status, 200)
assert.equal(res.body.ok, false)
assert.equal(res.body.error, 'Kein aktiver Bankzugang')
assert.equal(connection(), undefined)
})
// ---------- POST /bank/verbinden ----------
test('verbinden: liefert { url } der Bank und merkt sich state', async () => {
await reset()
const res = await post(`${base}/bank/verbinden`, {}, admin)
assert.equal(res.status, 200)
assert.deepEqual(Object.keys(res.body), ['url'])
assert.match(res.body.url, /^http:\/\/127\.0\.0\.1:\d+\/fake-bank\/auth\?state=[a-f0-9]{24}$/)
assert.ok(res.body.url.endsWith(connection().authState))
// der laufende Zugang bleibt unberührt
assert.equal(connection().sessionId, SESSION)
assert.equal(connection().status, 'aktiv')
})
test('verbinden: scheitert die Bank -> 502 mit fester Meldung, ohne Einzelheiten', async () => {
await reset()
fake.failNext({ path: '/auth', status: 500 })
const res = await post(`${base}/bank/verbinden`, {}, admin)
assert.equal(res.status, 502)
assert.deepEqual(res.body, { error: BANK_GONE })
assert.equal(connection().authState, undefined)
})
test('verbinden: antwortet die Bank mit 401, kommt trotzdem 502 (nie 401 durchreichen)', async () => {
await reset()
fake.failNext({ path: '/auth', status: 401, body: { code: 401, error: 'UNAUTHORIZED' } })
const res = await post(`${base}/bank/verbinden`, {}, admin)
assert.equal(res.status, 502)
assert.deepEqual(res.body, { error: BANK_GONE })
})
// ---------- POST /bank/rueckkehr ----------
test('rueckkehr: falscher state -> 400 mit Meldung, Zugang bleibt', async () => {
await reset()
await begin()
const res = await post(`${base}/bank/rueckkehr`, { code: 'neu', state: 'falsch' }, admin)
assert.equal(res.status, 400)
assert.deepEqual(res.body, { error: 'Diese Rückkehr passt zu keiner gestarteten Freigabe.' })
assert.equal(connection().sessionId, SESSION)
assert.equal(callsTo('/sessions').length, 0)
})
test('rueckkehr: ohne Body / ohne state -> 400', async () => {
await reset()
await begin()
assert.equal((await post(`${base}/bank/rueckkehr`, {}, admin)).status, 400)
assert.equal((await post(`${base}/bank/rueckkehr`, { code: 'neu' }, admin)).status, 400)
})
test('rueckkehr: Finom meldet einen Fehler -> 400 mit Meldung', async () => {
await reset()
const state = await begin()
const res = await post(`${base}/bank/rueckkehr`, { state, error: 'access_denied' }, admin)
assert.equal(res.status, 400)
assert.equal(res.body.error, 'Finom hat die Freigabe nicht erteilt (access_denied).')
})
test('rueckkehr: abgelaufene Freigabe -> 400', async () => {
await reset()
const state = await begin()
await store.saveConnection({ authStartedAt: minutesAgo(45) })
const res = await post(`${base}/bank/rueckkehr`, { code: 'neu', state }, admin)
assert.equal(res.status, 400)
assert.equal(res.body.error, 'Die Freigabe ist abgelaufen. Bitte neu starten.')
})
test('rueckkehr: richtiger state -> { ok: true, validUntil }, Sitzung gespeichert und nie in der Antwort', async () => {
await reset()
const state = await begin()
const res = await post(`${base}/bank/rueckkehr`, { code: 'neu', state }, admin)
assert.equal(res.status, 200)
assert.deepEqual(res.body, { ok: true, validUntil: '2027-03-29T00:00:00.000Z' })
const text = JSON.stringify(res.body)
assert.ok(!text.includes(NEW_SESSION) && !text.includes(SESSION))
assert.equal(connection().sessionId, NEW_SESSION)
assert.equal(connection().accountUid, NEW_ACCOUNT)
assert.equal(connection().status, 'aktiv')
assert.equal(connection().authState, '')
// die Rückkehr hat einmal abgerufen
assert.equal(callsTo('/balances').length, 1)
assert.equal(txDocs().size, 0, 'Nachbau kennt für das neue Konto keine Buchungen')
})
test('rueckkehr: scheitert der Codetausch bei der Bank (401) -> 502 mit fester Meldung, nie 401, keine Sitzungs-ID', async () => {
await reset()
const state = await begin()
fake.failNext({ path: '/sessions', status: 401, body: { code: 401, error: 'UNAUTHORIZED' } })
const res = await post(`${base}/bank/rueckkehr`, { code: 'neu', state }, admin)
assert.equal(res.status, 502)
assert.deepEqual(res.body, { error: BANK_GONE })
assert.ok(!JSON.stringify(res.body).includes(SESSION))
// der bisherige Zugang bleibt, der state gilt weiter (neuer Versuch möglich)
assert.equal(connection().sessionId, SESSION)
assert.equal(connection().authState, state)
})
test('rueckkehr: lehnt die Bank den Code mit 400 ab, ist das für die Seite ebenfalls 502 (nur die eigene Prüfung gibt 400)', async () => {
await reset()
const state = await begin()
fake.failNext({ path: '/sessions', status: 400, body: { code: 400, error: 'INVALID_CODE' } })
const res = await post(`${base}/bank/rueckkehr`, { code: 'neu', state }, admin)
assert.equal(res.status, 502)
assert.deepEqual(res.body, { error: BANK_GONE })
})
test('rueckkehr: jeder andere Fehler (Bank 500, Appwrite gestört) -> 502 mit fester Meldung', async () => {
await reset()
let state = await begin()
fake.failNext({ path: '/sessions', status: 500 })
const bank = await post(`${base}/bank/rueckkehr`, { code: 'neu', state }, admin)
assert.equal(bank.status, 502)
assert.deepEqual(bank.body, { error: BANK_GONE })
state = connection().authState
aw.failNext({ collection: config.collections.bankConnections, method: 'GET', status: 500, message: 'Appwrite-Geheimnis' })
const appwrite = await post(`${base}/bank/rueckkehr`, { code: 'neu', state }, admin)
assert.equal(appwrite.status, 502)
assert.deepEqual(appwrite.body, { error: BANK_GONE })
assert.ok(!JSON.stringify(appwrite.body).includes('Appwrite-Geheimnis'))
})
// ---------- GET /buchungen ----------
test('buchungen: neueste zuerst, nur Felder der Buchung und $id', async () => {
await reset()
const row = (id, bookingDate, amountCents) => ({
$id: id,
accountUid: ACCOUNT,
status: 'BOOK',
bookingDate,
valueDate: bookingDate,
amountCents,
currency: 'EUR',
counterpartyName: 'PORKBUN.COM',
counterpartyIban: '',
remittance: '',
mcc: '4816',
entryReference: id,
category: '',
matchedInvoiceId: '',
matchedInvoiceNumber: '',
matchedPayoutId: '',
fixedCostId: '',
note: '',
syncedAt: '2026-10-01T09:00:00.000Z',
})
aw.seed(config.collections.bankTransactions, row('b-mitte', '2026-09-15', -449))
aw.seed(config.collections.bankTransactions, row('b-alt', '2026-08-05', 16879))
aw.seed(config.collections.bankTransactions, row('b-neu', '2026-09-29', -50))
const res = await rawGet(`${base}/buchungen`, admin)
assert.equal(res.status, 200)
assert.deepEqual(Object.keys(res.body), ['buchungen'])
assert.deepEqual(res.body.buchungen.map((b) => b.$id), ['b-neu', 'b-mitte', 'b-alt'])
assert.deepEqual(res.body.buchungen.map((b) => b.amountCents), [-50, -449, 16879])
for (const buchung of res.body.buchungen) {
assert.deepEqual(Object.keys(buchung).filter((key) => key.startsWith('$')), ['$id'])
}
assert.equal(res.body.buchungen[0].counterpartyName, 'PORKBUN.COM')
assert.equal(res.body.buchungen[0].category, '')
})
test('buchungen: leer -> { buchungen: [] }', async () => {
await reset()
assert.deepEqual(await get(`${base}/buchungen`, admin), { buchungen: [] })
})
+589
View File
@@ -0,0 +1,589 @@
import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird (Muster wie financeAssign.test.js)
const aw = await startAppwriteMock({
schema: {
financeSettings: schemaKeys('financeSettings'),
financeFixedCosts: schemaKeys('financeFixedCosts'),
bankTransactions: schemaKeys('bankTransactions'),
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { seedFixtures } = await import('./support/fixtures.js')
const { startTestApp, get, post, rawGet } = await import('./support/testApp.js')
const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js')
const settings = await import('../server/services/finance/settings.js')
const { createProjectInvoice, invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const app = await startTestApp({ '/api/admin/finanzen': financeRoutes })
after(async () => {
mock.restoreAll()
await Promise.all([app.close(), aw.close(), st.close()])
})
seedFixtures(aw)
const admin = { headers: { Authorization: 'Bearer jwt-admin' } }
const staff = { headers: { Authorization: 'Bearer jwt-staff' } }
const kunde = { headers: { Authorization: 'Bearer jwt-kunde' } }
const base = '/api/admin/finanzen'
const SETTINGS = () => config.collections.financeSettings
const COSTS = () => config.collections.financeFixedCosts
const TX = () => config.collections.bankTransactions
const storedSettings = () => aw.docs(SETTINGS()).get('main')
const storedCost = (id) => aw.docs(COSTS()).get(id)
const callsTo = (method, path) => st.calls.filter((c) => c.method === method && (path instanceof RegExp ? path.test(c.path) : c.path === path))
const DEFAULTS = { minBufferCents: 0, taxReservePercent: 30, previousYearRevenueCents: null, estPrepaymentCents: 0, bankDetailsText: '' }
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
async function reset() {
mock.restoreAll()
invalidateFinanceCache()
resetInvoiceCreateGuardForTests()
st.calls.length = 0
st.invoices.clear()
st.customers.clear()
st.customers.set('cus_c1', { id: 'cus_c1', object: 'customer', name: 'Kunde Eins', email: 'k@example.invalid', metadata: { webklarCustomerId: 'c1' } })
for (const collection of [SETTINGS(), COSTS(), TX()]) {
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
}
}
beforeEach(reset)
const request = async (method, path, { body, headers } = {}) => {
const res = await fetch(app.url + path, {
method,
headers: { ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...(headers || {}) },
body: body === undefined ? undefined : JSON.stringify(body),
})
const text = await res.text()
return { status: res.status, body: text ? JSON.parse(text) : null }
}
const patchSettings = (body, auth = admin) => request('PATCH', `${base}/einstellungen`, { body, ...auth })
const getSettings = () => get(`${base}/einstellungen`, admin)
const postCost = (body) => post(`${base}/fixkosten`, body, admin)
const patchCost = (id, body) => request('PATCH', `${base}/fixkosten/${id}`, { body, ...admin })
const deleteCost = (id) => request('DELETE', `${base}/fixkosten/${id}`, admin)
const PORKBUN = { name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: '2026-10-29', counterpartyMatch: 'PORKBUN' }
const seedCost = (id, over = {}) =>
aw.seed(COSTS(), { $id: id, name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: '2026-10-29', counterpartyMatch: 'porkbun', active: true, ...over })
// ---------- Zugriff ----------
test('alle Routen: ohne JWT 401, Mitarbeiter ohne Admin-Label und Kunden 403', async () => {
seedCost('fk1')
const calls = [
['GET', `${base}/einstellungen`, undefined],
['PATCH', `${base}/einstellungen`, { taxReservePercent: 10 }],
['GET', `${base}/fixkosten`, undefined],
['POST', `${base}/fixkosten`, PORKBUN],
['PATCH', `${base}/fixkosten/fk1`, { active: false }],
['DELETE', `${base}/fixkosten/fk1`, undefined],
]
for (const [method, path, body] of calls) {
assert.equal((await request(method, path, { body })).status, 401, `${method} ${path} ohne JWT`)
assert.equal((await request(method, path, { body, ...staff })).status, 403, `${method} ${path} jwt-staff`)
assert.equal((await request(method, path, { body, ...kunde })).status, 403, `${method} ${path} jwt-kunde`)
}
// nichts wurde veraendert
assert.equal(storedSettings(), undefined)
assert.equal(storedCost('fk1').active, true)
assert.equal(aw.docs(COSTS()).size, 1)
})
// ---------- Einstellungen lesen ----------
test('GET /einstellungen ohne Dokument: Standardwerte, es wird nichts angelegt', async () => {
assert.deepEqual(await getSettings(), { einstellungen: DEFAULTS })
assert.equal(aw.docs(SETTINGS()).size, 0)
assert.deepEqual(await settings.getSettings(), DEFAULTS)
assert.equal(aw.docs(SETTINGS()).size, 0)
})
test('GET /einstellungen: gespeicherte Werte ueberschreiben die Standardwerte, fehlende und null-Felder fallen auf sie zurueck', async () => {
// wie das Setup-Skript es anlegt: ohne previousYearRevenueCents und bankDetailsText
aw.seed(SETTINGS(), { $id: 'main', taxReservePercent: 25, minBufferCents: 50000, estPrepaymentCents: 0 })
assert.deepEqual((await getSettings()).einstellungen, { ...DEFAULTS, taxReservePercent: 25, minBufferCents: 50000 })
// Appwrite liefert fuer nie gesetzte Felder null
aw.seed(SETTINGS(), { $id: 'main', taxReservePercent: null, minBufferCents: null, estPrepaymentCents: 12300, previousYearRevenueCents: null, bankDetailsText: null })
assert.deepEqual((await getSettings()).einstellungen, { ...DEFAULTS, estPrepaymentCents: 12300 })
aw.seed(SETTINGS(), { $id: 'main', previousYearRevenueCents: 0, bankDetailsText: 'IBAN DE00' })
assert.deepEqual((await getSettings()).einstellungen, { ...DEFAULTS, previousYearRevenueCents: 0, bankDetailsText: 'IBAN DE00' })
})
test('GET /einstellungen: nur die fuenf Felder, nie Appwrite-Metafelder', async () => {
aw.seed(SETTINGS(), { $id: 'main', taxReservePercent: 25, secret: 'intern' })
const { einstellungen } = await getSettings()
assert.deepEqual(Object.keys(einstellungen).sort(), Object.keys(DEFAULTS).sort())
})
// ---------- Einstellungen aendern ----------
test('PATCH { taxReservePercent: 25 } ohne Dokument: 25, das Dokument main wird angelegt (mit den Standardwerten)', async () => {
const res = await patchSettings({ taxReservePercent: 25 })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(res.body, { einstellungen: { ...DEFAULTS, taxReservePercent: 25 } })
const doc = storedSettings()
assert.ok(doc, 'financeSettings/main fehlt')
assert.equal(doc.taxReservePercent, 25)
assert.equal(doc.minBufferCents, 0)
assert.equal(doc.estPrepaymentCents, 0)
assert.deepEqual((await getSettings()).einstellungen, { ...DEFAULTS, taxReservePercent: 25 })
})
test('PATCH aendert nur die genannten Felder, auch mehrere auf einmal', async () => {
aw.seed(SETTINGS(), { $id: 'main', taxReservePercent: 30, minBufferCents: 1000, estPrepaymentCents: 500, bankDetailsText: 'IBAN DE00' })
const res = await patchSettings({ minBufferCents: 250000, bankDetailsText: 'IBAN DE11\nBIC TESTDEFF', estPrepaymentCents: 0 })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(res.body.einstellungen, {
...DEFAULTS,
taxReservePercent: 30,
minBufferCents: 250000,
estPrepaymentCents: 0,
bankDetailsText: 'IBAN DE11\nBIC TESTDEFF',
})
assert.equal(storedSettings().taxReservePercent, 30)
assert.equal(aw.docs(SETTINGS()).size, 1)
})
test('PATCH: Grenzwerte sind erlaubt (0 und 60 Prozent, Puffer 100.000.000, 500 Zeichen Bankdaten, Vorjahr 0)', async () => {
for (const patch of [
{ taxReservePercent: 0 },
{ taxReservePercent: 60 },
{ minBufferCents: 0 },
{ minBufferCents: 100_000_000 },
{ previousYearRevenueCents: 0 },
{ estPrepaymentCents: 0 },
{ bankDetailsText: 'x'.repeat(500) },
{ bankDetailsText: '' },
]) {
const res = await patchSettings(patch)
assert.equal(res.status, 200, `${JSON.stringify(patch).slice(0, 60)}: ${JSON.stringify(res.body).slice(0, 120)}`)
const [key, value] = Object.entries(patch)[0]
assert.equal(res.body.einstellungen[key], value)
}
})
test('PATCH: der Bankverbindungstext wird ohne Rand-Leerraum gespeichert', async () => {
const res = await patchSettings({ bankDetailsText: ' IBAN DE00 0000\n ' })
assert.equal(res.status, 200)
assert.equal(res.body.einstellungen.bankDetailsText, 'IBAN DE00 0000')
assert.equal(storedSettings().bankDetailsText, 'IBAN DE00 0000')
})
test('PATCH { previousYearRevenueCents: null } loescht den Wert wieder', async () => {
assert.equal((await patchSettings({ previousYearRevenueCents: 4500000 })).body.einstellungen.previousYearRevenueCents, 4500000)
assert.equal(storedSettings().previousYearRevenueCents, 4500000)
const res = await patchSettings({ previousYearRevenueCents: null })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.einstellungen.previousYearRevenueCents, null)
assert.equal(storedSettings().previousYearRevenueCents, null)
assert.equal((await getSettings()).einstellungen.previousYearRevenueCents, null)
})
test('PATCH: ungueltige Werte -> 400 mit dem Feldnamen, nichts wird gespeichert oder angelegt', async () => {
const invalid = [
['taxReservePercent', 80],
['taxReservePercent', 61],
['taxReservePercent', -1],
['taxReservePercent', 25.5],
['taxReservePercent', '25'],
['taxReservePercent', null],
['taxReservePercent', true],
['minBufferCents', -1],
['minBufferCents', 100_000_001],
['minBufferCents', 1.5],
['minBufferCents', null],
['minBufferCents', '1000'],
['previousYearRevenueCents', -1],
['previousYearRevenueCents', 1.5],
['previousYearRevenueCents', 'abc'],
['previousYearRevenueCents', Number.MAX_SAFE_INTEGER + 2],
['estPrepaymentCents', -1],
['estPrepaymentCents', 0.5],
['estPrepaymentCents', null],
['bankDetailsText', 'x'.repeat(501)],
['bankDetailsText', 123],
['bankDetailsText', null],
['bankDetailsText', ['IBAN']],
]
for (const [field, value] of invalid) {
const res = await patchSettings({ [field]: value })
assert.equal(res.status, 400, `${field}=${String(value).slice(0, 20)} -> ${res.status}`)
assert.ok(res.body.error.includes(field), `Meldung nennt ${field}: ${res.body.error}`)
}
assert.equal(aw.docs(SETTINGS()).size, 0)
// ein ungueltiges Feld unter gueltigen: gar nichts wird gespeichert
aw.seed(SETTINGS(), { $id: 'main', taxReservePercent: 30, minBufferCents: 100 })
const mixed = await patchSettings({ minBufferCents: 999, taxReservePercent: 80 })
assert.equal(mixed.status, 400)
assert.equal(storedSettings().minBufferCents, 100)
})
test('PATCH: unbekannter Schluessel -> 400 (auch neben gueltigen), nichts wird gespeichert', async () => {
const res = await patchSettings({ foo: 1 })
assert.equal(res.status, 400)
assert.ok(res.body.error.includes('foo'), res.body.error)
assert.equal(aw.docs(SETTINGS()).size, 0)
assert.equal((await patchSettings({ taxReservePercent: 20, foo: 1 })).status, 400)
assert.equal(aw.docs(SETTINGS()).size, 0)
// Metafelder und Prototyp-Schluessel sind genauso unbekannt
assert.equal((await patchSettings({ $id: 'x' })).status, 400)
const res2 = await fetch(app.url + `${base}/einstellungen`, {
method: 'PATCH',
headers: { 'content-type': 'application/json', ...admin.headers },
body: '{"__proto__":{"taxReservePercent":1}}',
})
assert.equal(res2.status, 400)
assert.equal(aw.docs(SETTINGS()).size, 0)
})
test('PATCH: kein Objekt -> 400; ein leeres Objekt aendert nichts und legt nichts an', async () => {
for (const body of [[], 'text', 5, null]) {
assert.equal((await patchSettings(body)).status, 400, JSON.stringify(body))
}
const res = await patchSettings({})
assert.equal(res.status, 200)
assert.deepEqual(res.body, { einstellungen: DEFAULTS })
assert.equal(aw.docs(SETTINGS()).size, 0)
})
test('PATCH: zwei gleichzeitige Aenderungen ohne Dokument legen es einmal an und behalten beide Felder', async () => {
const [a, b] = await Promise.all([patchSettings({ taxReservePercent: 20 }), patchSettings({ minBufferCents: 70000 })])
assert.equal(a.status, 200, JSON.stringify(a.body))
assert.equal(b.status, 200, JSON.stringify(b.body))
assert.equal(aw.docs(SETTINGS()).size, 1)
assert.equal(storedSettings().taxReservePercent, 20)
assert.equal(storedSettings().minBufferCents, 70000)
})
test('Einstellungen: ein Fehler von Appwrite wird 500 "Interner Fehler." ohne dessen Meldung', async () => {
const muted = mock.method(console, 'error', () => {})
aw.failNext({ collection: SETTINGS(), method: 'GET', status: 500, message: 'geheime Appwrite-Meldung' })
const read = await rawGet(`${base}/einstellungen`, admin)
assert.equal(read.status, 500)
assert.deepEqual(read.body, { error: 'Interner Fehler.' })
aw.seed(SETTINGS(), { $id: 'main', taxReservePercent: 30 })
aw.failNext({ collection: SETTINGS(), method: 'PATCH', status: 500, message: 'geheime Appwrite-Meldung' })
const write = await patchSettings({ taxReservePercent: 20 })
assert.equal(write.status, 500)
assert.deepEqual(write.body, { error: 'Interner Fehler.' })
assert.equal(storedSettings().taxReservePercent, 30)
assert.ok(muted.mock.calls.length >= 2)
assert.ok(!JSON.stringify(muted.mock.calls.map((c) => c.arguments)).includes('geheime'))
})
// ---------- Fixkosten ----------
test('POST /fixkosten: PORKBUN wird klein gespeichert, active ist Standard true, die ID ist eine UUID', async () => {
const res = await postCost(PORKBUN)
assert.equal(res.status, 201, JSON.stringify(res.body))
const { position } = res.body
assert.match(position.$id, UUID)
assert.deepEqual(Object.keys(position).filter((key) => key.startsWith('$')), ['$id'])
assert.deepEqual(
{ ...position, $id: undefined },
{ $id: undefined, name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: '2026-10-29', counterpartyMatch: 'porkbun', active: true }
)
const doc = storedCost(position.$id)
assert.equal(doc.counterpartyMatch, 'porkbun')
assert.equal(doc.active, true)
})
test('POST /fixkosten: Name ohne Rand-Leerraum, Suchbegriff getrimmt und klein, ohne Suchbegriff leer, active false moeglich', async () => {
const res = await postCost({ name: ' Hetzner Server ', amountCents: 1990, interval: 'quartal', nextDate: '2027-01-01', counterpartyMatch: ' Hetzner Online GmbH ', active: false })
assert.equal(res.status, 201, JSON.stringify(res.body))
assert.equal(res.body.position.name, 'Hetzner Server')
assert.equal(res.body.position.counterpartyMatch, 'hetzner online gmbh')
assert.equal(res.body.position.active, false)
const bare = await postCost({ name: 'Versicherung', amountCents: 12000, interval: 'jahr', nextDate: '2027-03-31' })
assert.equal(bare.status, 201)
assert.equal(bare.body.position.counterpartyMatch, '')
assert.equal(bare.body.position.active, true)
assert.equal(aw.docs(COSTS()).size, 2)
})
test('POST /fixkosten: Grenzwerte sind erlaubt (Name und Suchbegriff 120 Zeichen, 1 Cent, 29.02. im Schaltjahr)', async () => {
const res = await postCost({ name: 'N'.repeat(120), amountCents: 1, interval: 'jahr', nextDate: '2028-02-29', counterpartyMatch: 'M'.repeat(120) })
assert.equal(res.status, 201, JSON.stringify(res.body))
assert.equal(res.body.position.counterpartyMatch, 'm'.repeat(120))
})
test('POST /fixkosten: ungueltige Eingaben -> 400 mit dem Feldnamen, nichts wird angelegt', async () => {
const invalid = [
['name', ''],
['name', ' '],
['name', 'x'.repeat(121)],
['name', 5],
['name', undefined],
['amountCents', 0],
['amountCents', -449],
['amountCents', 4.49],
['amountCents', '449'],
['amountCents', null],
['amountCents', undefined],
['interval', 'woche'],
['interval', 'Monat'],
['interval', ''],
['interval', undefined],
['nextDate', '2026-13-01'],
['nextDate', '2026-02-30'],
['nextDate', '2027-02-29'],
['nextDate', '29.10.2026'],
['nextDate', '2026-1-1'],
['nextDate', '2026-10-29T00:00:00Z'],
['nextDate', 20261029],
['nextDate', undefined],
['counterpartyMatch', 'x'.repeat(121)],
['counterpartyMatch', 7],
['active', 'ja'],
['active', 1],
['active', null],
]
for (const [field, value] of invalid) {
const res = await postCost({ ...PORKBUN, [field]: value })
assert.equal(res.status, 400, `${field}=${String(value).slice(0, 20)} -> ${res.status}`)
assert.ok(res.body.error.includes(field), `Meldung nennt ${field}: ${res.body.error}`)
}
assert.equal(aw.docs(COSTS()).size, 0)
})
test('POST /fixkosten: unbekannter Schluessel und Nicht-Objekt -> 400', async () => {
const extra = await postCost({ ...PORKBUN, foo: 1 })
assert.equal(extra.status, 400)
assert.ok(extra.body.error.includes('foo'), extra.body.error)
assert.equal((await postCost({ ...PORKBUN, $id: 'mein-id' })).status, 400)
assert.equal((await postCost([])).status, 400)
assert.equal(aw.docs(COSTS()).size, 0)
})
test('GET /fixkosten: leer -> []; sonst alle, nach Name sortiert, ohne Appwrite-Metafelder ausser $id', async () => {
assert.deepEqual(await get(`${base}/fixkosten`, admin), { fixkosten: [] })
seedCost('fk2', { name: 'Zoom' })
seedCost('fk1', { name: 'hetzner', active: false })
seedCost('fk3', { name: 'Adobe' })
const { fixkosten } = await get(`${base}/fixkosten`, admin)
assert.deepEqual(fixkosten.map((item) => item.name), ['Adobe', 'hetzner', 'Zoom'])
assert.deepEqual(fixkosten.map((item) => item.$id), ['fk3', 'fk1', 'fk2'])
assert.equal(fixkosten[1].active, false)
for (const item of fixkosten) assert.deepEqual(Object.keys(item).filter((key) => key.startsWith('$')), ['$id'])
assert.deepEqual(Object.keys(fixkosten[0]).sort(), ['$id', 'active', 'amountCents', 'counterpartyMatch', 'interval', 'name', 'nextDate'])
})
test('GET /fixkosten: fragt Appwrite mit einer Obergrenze von 1000 (Standard waeren nur 25 Positionen)', async () => {
const realFetch = globalThis.fetch
const urls = []
mock.method(globalThis, 'fetch', (url, options) => {
urls.push(decodeURIComponent(String(url)))
return realFetch(url, options)
})
await settings.listFixedCosts()
const listing = urls.find((url) => url.includes(`/collections/${COSTS()}/documents`))
assert.ok(listing, 'keine Anfrage an financeFixedCosts')
assert.match(listing, /"method":"limit","values":\[1000\]/)
})
test('PATCH /fixkosten/:id: aendert genannte Felder, der Suchbegriff wird klein gespeichert', async () => {
seedCost('fk1')
const res = await patchCost('fk1', { name: ' Porkbun Domains ', amountCents: 499, interval: 'jahr', nextDate: '2027-10-29', counterpartyMatch: 'PORKBUN LLC', active: false })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(res.body.position, {
$id: 'fk1',
name: 'Porkbun Domains',
amountCents: 499,
interval: 'jahr',
nextDate: '2027-10-29',
counterpartyMatch: 'porkbun llc',
active: false,
})
assert.equal(storedCost('fk1').counterpartyMatch, 'porkbun llc')
// nur ein Feld: der Rest bleibt; Suchbegriff leeren geht
const second = await patchCost('fk1', { active: true, counterpartyMatch: '' })
assert.equal(second.status, 200)
assert.equal(second.body.position.name, 'Porkbun Domains')
assert.equal(second.body.position.active, true)
assert.equal(second.body.position.counterpartyMatch, '')
})
test('PATCH /fixkosten/:id: ungueltig -> 400 mit Feldnamen, nichts wird geaendert', async () => {
seedCost('fk1')
const invalid = [
['name', ''],
['name', 'x'.repeat(121)],
['amountCents', 0],
['amountCents', 1.5],
['interval', 'woche'],
['nextDate', '2026-02-30'],
['counterpartyMatch', 'x'.repeat(121)],
['active', 'false'],
]
for (const [field, value] of invalid) {
const res = await patchCost('fk1', { [field]: value })
assert.equal(res.status, 400, `${field}=${String(value).slice(0, 20)}`)
assert.ok(res.body.error.includes(field), res.body.error)
}
assert.equal((await patchCost('fk1', { foo: 1 })).status, 400)
assert.equal((await patchCost('fk1', { $id: 'anders' })).status, 400)
assert.equal((await patchCost('fk1', [])).status, 400)
assert.equal((await patchCost('fk1', { name: 'Neu', amountCents: 0 })).status, 400)
assert.equal(storedCost('fk1').name, 'Porkbun')
assert.equal(storedCost('fk1').amountCents, 449)
})
test('PATCH /fixkosten/:id: unbekannte oder unmoegliche ID -> 404; leeres Objekt liefert die Position unveraendert', async () => {
seedCost('fk1')
for (const id of ['gibtsnicht', 'x'.repeat(40), '.punkt', 'a%20b']) {
const res = await patchCost(id, { active: false })
assert.equal(res.status, 404, id)
assert.match(res.body.error, /Fixkosten/)
}
// auch ein leeres Objekt prueft zuerst, ob es die Position gibt
assert.equal((await patchCost('gibtsnicht', {})).status, 404)
const same = await patchCost('fk1', {})
assert.equal(same.status, 200)
assert.equal(same.body.position.$id, 'fk1')
assert.equal(same.body.position.name, 'Porkbun')
assert.equal(aw.docs(COSTS()).size, 1)
})
test('DELETE /fixkosten/:id: loescht die Position; unbekannt -> 404', async () => {
seedCost('fk1')
seedCost('fk2')
const res = await deleteCost('fk1')
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(res.body, { ok: true })
assert.equal(storedCost('fk1'), undefined)
assert.ok(storedCost('fk2'))
assert.equal((await deleteCost('fk1')).status, 404)
assert.equal((await deleteCost('x'.repeat(40))).status, 404)
assert.equal(aw.docs(COSTS()).size, 1)
})
test('Fixkosten: ein Fehler von Appwrite wird 500 "Interner Fehler." ohne dessen Meldung', async () => {
const muted = mock.method(console, 'error', () => {})
seedCost('fk1')
const attempts = [
['GET', () => rawGet(`${base}/fixkosten`, admin)],
['POST', () => postCost(PORKBUN)],
['PATCH', () => patchCost('fk1', { active: false })],
['DELETE', () => deleteCost('fk1')],
]
for (const [method, run] of attempts) {
aw.failNext({ collection: COSTS(), method, status: 500, message: 'geheime Appwrite-Meldung' })
const res = await run()
assert.equal(res.status, 500, method)
assert.deepEqual(res.body, { error: 'Interner Fehler.' }, method)
}
assert.equal(storedCost('fk1').active, true)
assert.equal(aw.docs(COSTS()).size, 1)
assert.ok(!JSON.stringify(muted.mock.calls.map((c) => c.arguments)).includes('geheime'))
})
// ---------- Dienste ----------
test('Dienste: getFixedCost liefert die Position oder null, ungueltige IDs sind unbekannt', async () => {
seedCost('fk1')
assert.equal((await settings.getFixedCost('fk1')).name, 'Porkbun')
assert.equal(await settings.getFixedCost('gibtsnicht'), null)
assert.equal(await settings.getFixedCost('x'.repeat(40)), null)
assert.equal(await settings.getFixedCost(''), null)
assert.equal(await settings.getFixedCost(undefined), null)
assert.equal(await settings.getFixedCost({ $id: 'fk1' }), null)
})
test('Dienste: Validierungsfehler tragen status 400, unbekannte Position status 404', async () => {
await assert.rejects(settings.patchSettings({ taxReservePercent: 80 }), (err) => err.status === 400 && /taxReservePercent/.test(err.message))
await assert.rejects(settings.createFixedCost({ ...PORKBUN, interval: 'woche' }), (err) => err.status === 400 && /interval/.test(err.message))
await assert.rejects(settings.updateFixedCost('gibtsnicht', { active: false }), (err) => err.status === 404)
await assert.rejects(settings.deleteFixedCost('gibtsnicht'), (err) => err.status === 404)
const created = await settings.createFixedCost(PORKBUN)
assert.match(created.$id, UUID)
assert.equal((await settings.listFixedCosts()).length, 1)
await settings.deleteFixedCost(created.$id)
assert.deepEqual(await settings.listFixedCosts(), [])
})
// ---------- Zuordnung mit einer angelegten Position ----------
test('Zuordnen: eine ueber POST /fixkosten angelegte Position (UUID) laesst sich einer Buchung zuordnen', async () => {
const outgoing = {
accountUid: 'konto-1',
status: 'BOOK',
bookingDate: '2026-09-29',
valueDate: '2026-09-29',
amountCents: -449,
currency: 'EUR',
counterpartyName: 'PORKBUN LLC',
counterpartyIban: '',
remittance: '',
mcc: '',
category: '',
matchedInvoiceId: '',
matchedInvoiceNumber: '',
matchedPayoutId: '',
fixedCostId: '',
note: '',
syncedAt: '2026-10-01T09:00:00.000Z',
}
aw.seed(TX(), { ...outgoing, $id: 'b1', entryReference: 'b1' })
aw.seed(TX(), { ...outgoing, $id: 'b2', entryReference: 'b2' })
const { position } = (await postCost(PORKBUN)).body
const res = await post(`${base}/buchungen/b1/zuordnen`, { fixedCostId: position.$id }, admin)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.buchung.category, 'fixkosten')
assert.equal(res.body.buchung.fixedCostId, position.$id)
// gibt es die Position nicht (mehr), bleibt es bei der bisherigen Meldung
await deleteCost(position.$id)
const gone = await post(`${base}/buchungen/b2/zuordnen`, { fixedCostId: position.$id }, admin)
assert.equal(gone.status, 400)
assert.equal(gone.body.error, 'Diese Fixkosten-Position gibt es nicht.')
})
// ---------- Rechnungsfuss aus den Einstellungen ----------
const ITEMS = [{ description: 'Website-Anpassung', quantity: 1, unitAmountCents: 5000 }]
const PAYMENT_NOTE = 'Bitte geben Sie bei Überweisung die Rechnungsnummer als Verwendungszweck an.'
const footerOfLastDraft = () => callsTo('POST', '/v1/invoices').at(-1).body.footer
test('Rechnung: nach PATCH { bankDetailsText } steht die IBAN-Zeile im Rechnungsfuss', async () => {
assert.equal((await patchSettings({ bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00\nBIC TESTDEFF' })).status, 200)
await createProjectInvoice({ customerId: 'c1', items: ITEMS, createdBy: 'kenso@example.invalid' })
assert.equal(
footerOfLastDraft(),
`${config.stripe.invoiceFooter}\nIBAN DE00 0000 0000 0000 0000 00\nBIC TESTDEFF\n${PAYMENT_NOTE}`
)
// wieder leer: der Fuss hat keine Bankzeile mehr (derselbe Inhalt wie eben: den Schutz vor doppeltem Anlegen zuruecksetzen)
assert.equal((await patchSettings({ bankDetailsText: '' })).status, 200)
resetInvoiceCreateGuardForTests()
await createProjectInvoice({ customerId: 'c1', items: ITEMS, createdBy: 'kenso@example.invalid' })
assert.equal(footerOfLastDraft(), `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`)
})
test('Rechnung: ohne Einstellungsdokument nur Kleinunternehmer-Hinweis und Verwendungszweck-Satz; ein uebergebener Text wird ignoriert', async () => {
await createProjectInvoice({ customerId: 'c1', items: ITEMS, createdBy: 'k', bankDetailsText: 'IBAN DE99 aus dem Aufruf' })
assert.equal(footerOfLastDraft(), `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`)
assert.equal(aw.docs(SETTINGS()).size, 0)
})
test('Rechnung: sind die Einstellungen nicht lesbar, scheitert sie vor jedem Stripe-Aufruf', async () => {
aw.failNext({ collection: SETTINGS(), method: 'GET', status: 500 })
await assert.rejects(createProjectInvoice({ customerId: 'c1', items: ITEMS, createdBy: 'k' }))
assert.equal(st.calls.filter((c) => c.method !== 'GET').length, 0)
})
+195
View File
@@ -0,0 +1,195 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import http from 'node:http'
import { execFile } from 'node:child_process'
import { schemaKeys } from './support/schema.js'
test('bankTransactions: Attribute der Spec sind im Setup-Skript angelegt', () => {
const keys = schemaKeys('bankTransactions')
for (const key of ['amountCents', 'category', 'matchedInvoiceId', 'entryReference', 'bookingDate', 'syncedAt']) {
assert.ok(keys.includes(key), `${key} fehlt`)
}
})
test('bankConnections: Status und Warnstufen', () => {
const keys = schemaKeys('bankConnections')
for (const key of ['status', 'sessionId', 'accountUid', 'validUntil', 'warnedThresholds', 'balanceBookedCents']) {
assert.ok(keys.includes(key), `${key} fehlt`)
}
})
test('financeSettings und financeFixedCosts sind angelegt', () => {
assert.ok(schemaKeys('financeSettings').includes('taxReservePercent'))
assert.ok(schemaKeys('financeFixedCosts').includes('counterpartyMatch'))
})
test('subscriptions wird weiter aus dem Abo-Setup gelesen', () => {
assert.ok(schemaKeys('subscriptions').includes('stripeSubscriptionId'))
})
test('unbekannte Collection scheitert mit klarer Meldung', () => {
assert.throws(() => schemaKeys('gibtEsNicht'), /Collection gibtEsNicht fehlt im Setup-Skript/)
})
test('bankTransactions: Indexe auf bookingDate (Sortierung) und status (Vormerkungen suchen)', () => {
const src = readFileSync(new URL('../scripts/setup-finanzen.mjs', import.meta.url), 'utf8')
const start = src.indexOf("'bankTransactions',")
const block = src.slice(start, src.indexOf("// Einstellungen", start))
assert.match(block, /key: 'idx_booking', type: 'key', attributes: \['bookingDate'\]/)
assert.match(block, /key: 'idx_status', type: 'key', attributes: \['status'\]/)
})
// ---------- Uebernahme einer Sitzung (--session, --account, --valid-until, --iban) ----------
const SCRIPT = new URL('../scripts/setup-finanzen.mjs', import.meta.url).pathname
/**
* Appwrite-Stub fuer das Setup-Skript: Collections, Attribute (sofort "available") und Indexe werden angenommen,
* Dokumente gespeichert (GET 404 bis zum Anlegen, POST legt an, PATCH mischt). requests haelt alle Aufrufe.
*/
async function startSetupStub() {
const requests = []
const attributes = new Map()
const docs = new Map()
const server = http.createServer(async (req, res) => {
let raw = ''
for await (const chunk of req) raw += chunk
const body = raw ? JSON.parse(raw) : null
const { pathname } = new URL(req.url, 'http://127.0.0.1')
requests.push({ method: req.method, path: pathname, body })
const reply = (status, payload = {}) => {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(payload))
}
let m
if ((m = pathname.match(/\/collections\/([^/]+)\/attributes\/\w+$/)) && req.method === 'POST') {
attributes.set(m[1], [...(attributes.get(m[1]) || []), { key: body.key, status: 'available' }])
return reply(202)
}
if ((m = pathname.match(/\/collections\/([^/]+)\/attributes$/)) && req.method === 'GET') return reply(200, { attributes: attributes.get(m[1]) || [] })
if ((m = pathname.match(/\/collections\/([^/]+)\/documents(?:\/([^/]+))?$/))) {
const key = `${m[1]}/${m[2] ?? body?.documentId}`
if (req.method === 'GET') return docs.has(key) ? reply(200, docs.get(key)) : reply(404, { message: 'Document not found' })
if (req.method === 'POST') return docs.set(key, { ...body.data }) && reply(201)
if (req.method === 'PATCH') return docs.set(key, { ...docs.get(key), ...body.data }) && reply(200)
}
return reply(200)
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
return { url: `http://127.0.0.1:${server.address().port}/v1`, requests, docs, close: () => new Promise((resolve) => server.close(resolve)) }
}
/** Das Setup-Skript mit Argumenten gegen den Stub laufen lassen -> { code, stdout, stderr }. */
function runSetup(stub, args) {
return new Promise((resolve) => {
execFile(
process.execPath,
[SCRIPT, ...args],
{ env: { PATH: process.env.PATH, APPWRITE_ENDPOINT: stub.url, APPWRITE_API_KEY: 'test-key' }, timeout: 30_000 },
(error, stdout, stderr) => resolve({ code: error ? error.code : 0, stdout, stderr })
)
})
}
const TAKEOVER = ['--session', 'sess-geheim-4711', '--account', 'konto-4711']
const connectionDoc = (stub) => stub.docs.get('bankConnections/finom')
test('Setup --valid-until: nur ein echtes Datum oder ein voller ISO-Zeitpunkt mit Zeitzone, alles andere bricht ab (M5)', async () => {
const stub = await startSetupStub()
try {
for (const bad of ['morgen', '1', '2027', '2027-3-29', '2027-13-01', '2027-02-30', '2027-03-29 00:00:00', '2027-03-29T00:00:00', '2027-03-29T25:00:00Z', '29.03.2027', 'Mar 29 2027', '2027-03-29T00:00:00+25:00']) {
const result = await runSetup(stub, [...TAKEOVER, '--valid-until', bad])
assert.equal(result.code, 1, `${bad}: Exit-Code ${result.code}`)
assert.match(result.stderr, /--valid-until ist kein gueltiger ISO-Zeitpunkt/, bad)
assert.ok(!result.stdout.includes('Fertig'), bad)
assert.ok(!result.stderr.includes('sess-geheim-4711'))
}
// vor jedem Aufruf der API: nichts angelegt, nichts uebernommen
assert.equal(stub.requests.length, 0)
} finally {
await stub.close()
}
})
test('Setup --valid-until: Datum und voller ISO-Zeitpunkt werden uebernommen und als UTC gespeichert (M5)', async () => {
const stub = await startSetupStub()
try {
const cases = [
['2027-03-29', '2027-03-29T00:00:00.000Z'],
['2027-03-29T00:00:00Z', '2027-03-29T00:00:00.000Z'],
['2027-03-29T02:00:00.000+02:00', '2027-03-29T00:00:00.000Z'],
['2027-03-29T00:00:00.000000+00:00', '2027-03-29T00:00:00.000Z'],
]
for (const [input, stored] of cases) {
const result = await runSetup(stub, [...TAKEOVER, '--valid-until', input])
assert.equal(result.code, 0, `${input}: ${result.stderr}`)
assert.equal(connectionDoc(stub).validUntil, stored, input)
assert.equal(connectionDoc(stub).status, 'aktiv')
assert.ok(!result.stdout.includes('sess-geheim-4711'), 'die Sitzungs-ID wird nie ausgegeben')
}
} finally {
await stub.close()
}
})
test('Setup --iban: wird gespeichert, ibanMasked sind die ersten und letzten vier Zeichen (M5)', async () => {
const stub = await startSetupStub()
try {
const result = await runSetup(stub, [...TAKEOVER, '--valid-until', '2027-03-29', '--iban', 'de00 0000 0000 0000 0000 93'])
assert.equal(result.code, 0, result.stderr)
const doc = connectionDoc(stub)
assert.equal(doc.iban, 'DE00000000000000000093')
assert.equal(doc.ibanMasked, 'DE00…0093')
assert.equal(doc.sessionId, 'sess-geheim-4711')
assert.equal(doc.accountUid, 'konto-4711')
// beide Felder passen in die Schemagroessen (40)
assert.ok(doc.iban.length <= 40 && doc.ibanMasked.length <= 40)
assert.ok(!result.stdout.includes('DE0000'), 'die IBAN wird nicht ausgegeben')
// ohne --iban bleibt eine gespeicherte IBAN stehen
const again = await runSetup(stub, [...TAKEOVER, '--valid-until', '2027-04-01'])
assert.equal(again.code, 0, again.stderr)
assert.equal(connectionDoc(stub).iban, 'DE00000000000000000093')
assert.equal(connectionDoc(stub).validUntil, '2027-04-01T00:00:00.000Z')
} finally {
await stub.close()
}
})
test('Setup --iban: keine IBAN -> Abbruch vor jedem API-Aufruf; --iban allein ist keine Uebernahme (M5)', async () => {
const stub = await startSetupStub()
try {
for (const bad of ['', 'abc', 'DE12', '1234567890123456', 'DE00 0000 0000 0000 0000 93 ZZZZZZZZZZZZZZZZZZZZZZZZZZ']) {
const result = await runSetup(stub, [...TAKEOVER, '--valid-until', '2027-03-29', '--iban', bad])
assert.equal(result.code, 1, `"${bad}"`)
assert.match(result.stderr, /--iban ist keine gueltige IBAN/)
}
const alone = await runSetup(stub, ['--iban', 'DE00000000000000000093'])
assert.equal(alone.code, 1)
assert.match(alone.stderr, /--session, --account und --valid-until zusammen/)
assert.equal(stub.requests.length, 0)
} finally {
await stub.close()
}
})
test('Setup: ohne Uebernahme-Argumente werden nur die Collections angelegt, kein Dokument', async () => {
const stub = await startSetupStub()
try {
const result = await runSetup(stub, [])
assert.equal(result.code, 0, result.stderr)
assert.equal(stub.docs.size, 0)
assert.ok(stub.requests.some((r) => r.method === 'POST' && r.path.endsWith('/collections')))
} finally {
await stub.close()
}
})
test('.env.example nennt ENABLE_BANKING_APP_ID und ENABLE_BANKING_KEY_PATH auskommentiert und ohne Wert (M16)', () => {
const lines = readFileSync(new URL('../.env.example', import.meta.url), 'utf8').split('\n')
for (const name of ['ENABLE_BANKING_APP_ID', 'ENABLE_BANKING_KEY_PATH']) {
assert.ok(lines.includes(`# ${name}=`), `${name} fehlt oder hat einen Wert`)
assert.ok(!lines.some((line) => line.startsWith(`${name}=`)), `${name} darf nicht gesetzt sein`)
}
})
+367
View File
@@ -0,0 +1,367 @@
import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber stripeBilling) geladen wird.
// Der Appwrite-Nachbau wird nur fuer die Umgebung gebraucht.
const aw = await startAppwriteMock({ schema: {} })
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { euroDe } = await import('../server/services/finance/format.js')
const { mapInvoice, listInvoices, listPayouts, invalidateFinanceCache, setStripeReadOptionsForTests } = await import(
'../server/services/finance/stripeFinance.js'
)
const { getStripe } = await import('../server/services/stripeBilling.js')
after(() => {
mock.timers.reset()
return Promise.all([aw.close(), st.close()])
})
beforeEach(() => {
mock.restoreAll()
setStripeReadOptionsForTests(null)
mock.timers.reset()
invalidateFinanceCache()
st.calls.length = 0
st.invoices.clear()
st.customers.clear()
st.payouts.clear()
})
const TODAY = '2026-10-01'
// Unix-Sekunden eines Berliner Mittags (MESZ = UTC+2), damit das Berliner Datum eindeutig ist
const noonBerlin = (ymd) => Math.floor(new Date(`${ymd}T12:00:00+02:00`).getTime() / 1000)
const rawInvoice = (over = {}) => ({
id: 'in_1',
object: 'invoice',
number: 'WK-0001',
status: 'open',
customer: 'cus_1',
customer_name: 'Muster GmbH',
customer_email: 'muster@example.invalid',
created: noonBerlin('2026-09-06'),
due_date: noonBerlin('2026-10-20'),
total: 12000,
amount_due: 12000,
amount_paid: 0,
amount_remaining: 12000,
status_transitions: { paid_at: null },
hosted_invoice_url: 'https://invoice.stripe.test/i/in_1',
invoice_pdf: 'https://invoice.stripe.test/i/in_1/pdf',
metadata: {},
lines: { object: 'list', data: [], has_more: false },
...over,
})
test('euroDe: deutsches Euro-Format mit Tausenderpunkt, Komma und echtem Minuszeichen', () => {
// geschuetztes Leerzeichen (U+00A0) vor dem Euro-Zeichen wie Intl, Minus ist U+2212
assert.equal(euroDe(123456), '1.234,56\u00a0€')
assert.equal(euroDe(-449), '\u22124,49\u00a0€')
assert.equal(euroDe(0), '0,00\u00a0€')
assert.equal(euroDe(5), '0,05\u00a0€')
assert.equal(euroDe(100000000), '1.000.000,00\u00a0€')
assert.equal(euroDe(-100), '\u22121,00\u00a0€')
assert.throws(() => euroDe(12.5), /Cent/)
assert.throws(() => euroDe(undefined), /Cent/)
})
test('mapInvoice: Stripe-Status werden deutsch gemappt', () => {
const draft = mapInvoice(rawInvoice({ status: 'draft', number: null, due_date: null }), { today: TODAY })
assert.equal(draft.status, 'entwurf')
assert.equal(draft.number, '')
assert.equal(draft.dueDate, null)
assert.equal(mapInvoice(rawInvoice({ status: 'open' }), { today: TODAY }).status, 'offen')
const paid = mapInvoice(
rawInvoice({ status: 'paid', amount_paid: 12000, amount_remaining: 0, status_transitions: { paid_at: noonBerlin('2026-09-15') } }),
{ today: TODAY }
)
assert.equal(paid.status, 'bezahlt')
assert.equal(paid.paidCents, 12000)
assert.equal(paid.remainingCents, 0)
assert.equal(paid.paidDate, '2026-09-15')
assert.equal(mapInvoice(rawInvoice({ status: 'void' }), { today: TODAY }).status, 'storniert')
assert.equal(mapInvoice(rawInvoice({ status: 'uncollectible' }), { today: TODAY }).status, 'uneinbringlich')
})
test('mapInvoice: Felder, Betraege in Cent und Berliner Kalendertage', () => {
const inv = mapInvoice(
// 22:30 UTC am 05.09. ist in Berlin schon der 06.09.
rawInvoice({ created: Math.floor(new Date('2026-09-05T22:30:00Z').getTime() / 1000) }),
{ today: TODAY }
)
assert.deepEqual(inv, {
id: 'in_1',
number: 'WK-0001',
status: 'offen',
customerName: 'Muster GmbH',
customerEmail: 'muster@example.invalid',
webklarCustomerId: '',
createdDate: '2026-09-06',
dueDate: '2026-10-20',
totalCents: 12000,
remainingCents: 12000,
paidCents: 0,
paidDate: null,
overdueDays: 0,
source: 'projekt',
hostedUrl: 'https://invoice.stripe.test/i/in_1',
pdfUrl: 'https://invoice.stripe.test/i/in_1/pdf',
outOfBand: false,
bankTransactionId: '',
})
})
test('mapInvoice: offen und faellig vor heute -> ueberfaellig mit Verzugstagen', () => {
const inv = mapInvoice(rawInvoice({ due_date: noonBerlin('2026-09-20') }), { today: TODAY })
assert.equal(inv.status, 'ueberfaellig')
assert.equal(inv.overdueDays, 11)
})
test('mapInvoice: am Faelligkeitstag selbst noch nicht ueberfaellig, ohne Faelligkeit nie', () => {
const same = mapInvoice(rawInvoice({ due_date: noonBerlin(TODAY) }), { today: TODAY })
assert.equal(same.status, 'offen')
assert.equal(same.overdueDays, 0)
const none = mapInvoice(rawInvoice({ due_date: null }), { today: TODAY })
assert.equal(none.status, 'offen')
assert.equal(none.overdueDays, 0)
})
test('mapInvoice: nur offene Rechnungen werden ueberfaellig, bezahlte mit alter Faelligkeit nicht', () => {
const paid = mapInvoice(
rawInvoice({ status: 'paid', due_date: noonBerlin('2026-08-01'), amount_paid: 12000, amount_remaining: 0, status_transitions: { paid_at: noonBerlin('2026-08-02') } }),
{ today: TODAY }
)
assert.equal(paid.status, 'bezahlt')
assert.equal(paid.overdueDays, 0)
const voided = mapInvoice(rawInvoice({ status: 'void', due_date: noonBerlin('2026-08-01') }), { today: TODAY })
assert.equal(voided.status, 'storniert')
assert.equal(voided.overdueDays, 0)
})
test('mapInvoice: Abo-Rechnung (parent.subscription_details) -> source abo, sonst projekt', () => {
const abo = mapInvoice(rawInvoice({ parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_1' } } }), { today: TODAY })
assert.equal(abo.source, 'abo')
const alt = mapInvoice(rawInvoice({ subscription: 'sub_2' }), { today: TODAY })
assert.equal(alt.source, 'abo')
const projekt = mapInvoice(rawInvoice({ parent: null, metadata: { quelle: 'woms' } }), { today: TODAY })
assert.equal(projekt.source, 'projekt')
})
test('mapInvoice: Bankzuordnung -> outOfBand, Buchungsdatum als paidDate', () => {
const inv = mapInvoice(
rawInvoice({
status: 'paid',
amount_paid: 12000,
amount_remaining: 0,
// Stripe verbucht erst am 07.08. (paid_out_of_band), das Geld kam aber am 05.08. auf dem Konto an
status_transitions: { paid_at: noonBerlin('2026-08-07') },
metadata: { bankTransactionId: 'tx_abc', bankBookingDate: '2026-08-05' },
}),
{ today: TODAY }
)
assert.equal(inv.paidDate, '2026-08-05')
assert.equal(inv.outOfBand, true)
assert.equal(inv.bankTransactionId, 'tx_abc')
})
test('mapInvoice: webklarCustomerId aus der Rechnung, sonst aus dem expandierten Kunden', () => {
const direkt = mapInvoice(rawInvoice({ metadata: { webklarCustomerId: 'c1' }, customer: { id: 'cus_1', metadata: { webklarCustomerId: 'c9' } } }), { today: TODAY })
assert.equal(direkt.webklarCustomerId, 'c1')
const expandiert = mapInvoice(rawInvoice({ customer: { id: 'cus_1', name: 'Kunde', email: 'k@example.invalid', metadata: { webklarCustomerId: 'c9' } } }), { today: TODAY })
assert.equal(expandiert.webklarCustomerId, 'c9')
// nicht expandiert (nur ID): keine Zuordnung, aber auch kein Fehler
assert.equal(mapInvoice(rawInvoice(), { today: TODAY }).webklarCustomerId, '')
})
test('mapInvoice: Name und E-Mail fallen auf den expandierten Kunden zurueck', () => {
const inv = mapInvoice(
rawInvoice({ customer_name: null, customer_email: null, customer: { id: 'cus_1', name: 'Kunde AG', email: 'k@example.invalid', metadata: {} } }),
{ today: TODAY }
)
assert.equal(inv.customerName, 'Kunde AG')
assert.equal(inv.customerEmail, 'k@example.invalid')
})
test('listInvoices: fragt mit limit, created und expand an, bettet den Kunden ein und mappt', async () => {
st.customers.set('cus_1', { id: 'cus_1', object: 'customer', name: 'Muster GmbH', email: 'm@example.invalid', metadata: { webklarCustomerId: 'c1' } })
st.invoices.set('in_1', rawInvoice({ id: 'in_1', due_date: noonBerlin('2026-09-20') }))
const before = Math.floor(Date.now() / 1000)
const list = await listInvoices()
assert.equal(list.length, 1)
assert.equal(list[0].id, 'in_1')
assert.equal(list[0].webklarCustomerId, 'c1')
assert.equal(list[0].status, 'ueberfaellig')
assert.ok(list[0].overdueDays > 0)
const calls = st.calls.filter((c) => c.path === '/v1/invoices')
assert.equal(calls.length, 1)
assert.equal(calls[0].method, 'GET')
assert.equal(calls[0].query.limit, '100')
assert.equal(calls[0].query['expand[0]'], 'data.customer')
// 400 Tage Rueckblick
const gte = Number(calls[0].query['created[gte]'])
assert.ok(Math.abs(gte - (before - 400 * 86400)) < 60, `created[gte] ${gte}`)
})
test('listInvoices: blaettert ueber alle Seiten', async () => {
for (let i = 1; i <= 230; i += 1) st.invoices.set(`in_${i}`, rawInvoice({ id: `in_${i}`, number: `WK-${i}` }))
const list = await listInvoices()
assert.equal(list.length, 230)
assert.equal(new Set(list.map((inv) => inv.id)).size, 230)
assert.equal(st.calls.filter((c) => c.path === '/v1/invoices').length, 3)
})
test('listInvoices: hoechstens 1000 Rechnungen, bei der Obergrenze genau eine Warnung je Laden (M9)', async () => {
for (let i = 1; i <= 1100; i += 1) st.invoices.set(`in_${i}`, rawInvoice({ id: `in_${i}` }))
const warn = mock.method(console, 'warn', () => {})
const list = await listInvoices()
assert.equal(list.length, 1000)
assert.deepEqual(warn.mock.calls.map((c) => c.arguments), [['[finanzen] Rechnungsliste bei 1000 abgeschnitten']])
// aus dem Zwischenspeicher: kein neues Laden, keine zweite Warnung; ein neues Laden warnt wieder
await listInvoices()
assert.equal(warn.mock.callCount(), 1)
await listInvoices({ force: true })
assert.equal(warn.mock.callCount(), 2)
})
test('listInvoices: unter der Obergrenze keine Warnung (M9)', async () => {
for (let i = 1; i <= 999; i += 1) st.invoices.set(`in_${i}`, rawInvoice({ id: `in_${i}` }))
const warn = mock.method(console, 'warn', () => {})
assert.equal((await listInvoices()).length, 999)
assert.equal(warn.mock.callCount(), 0)
})
test('Lesen: Voreinstellung 15 s Zeitgrenze und eine Wiederholung je Anfrage an Stripe (I2)', async () => {
const stripe = getStripe()
const invoicesList = mock.method(stripe.invoices, 'list', () => ({ autoPagingToArray: async () => [] }))
const payoutsList = mock.method(stripe.payouts, 'list', async () => ({ data: [] }))
await listInvoices()
await listPayouts()
assert.deepEqual(invoicesList.mock.calls[0].arguments[1], { timeout: 15_000, maxNetworkRetries: 1 })
assert.deepEqual(payoutsList.mock.calls[0].arguments[1], { timeout: 15_000, maxNetworkRetries: 1 })
})
test('Lesen: antwortet Stripe nicht, endet die Anfrage an der Zeitgrenze statt nach 80 Sekunden (I2)', async () => {
setStripeReadOptionsForTests({ timeout: 80, maxNetworkRetries: 0 })
st.invoices.set('in_1', rawInvoice())
st.payouts.set('po_1', { id: 'po_1', object: 'payout', amount: 100, arrival_date: noonBerlin('2026-09-12'), status: 'paid' })
const isTimeout = (err) => err.type === 'StripeConnectionError' && /timeout/i.test(err.message)
st.delayNext({ method: 'GET', path: '/v1/invoices', ms: 700 })
let started = Date.now()
await assert.rejects(listInvoices(), isTimeout)
assert.ok(Date.now() - started < 500, `Rechnungen: ${Date.now() - started} ms`)
st.delayNext({ method: 'GET', path: '/v1/payouts', ms: 700 })
started = Date.now()
await assert.rejects(listPayouts(), isTimeout)
assert.ok(Date.now() - started < 500, `Auszahlungen: ${Date.now() - started} ms`)
// der Fehler wird nicht zwischengespeichert: der naechste Aufruf klappt
assert.equal((await listInvoices()).length, 1)
assert.equal((await listPayouts()).length, 1)
})
test('Lesen: die Zeitgrenze gilt auch fuer die Folgeseiten der Rechnungsliste (I2)', async () => {
for (let i = 1; i <= 150; i += 1) st.invoices.set(`in_${i}`, rawInvoice({ id: `in_${i}` }))
setStripeReadOptionsForTests({ timeout: 80, maxNetworkRetries: 0 })
// die erste Seite kommt sofort, die zweite haengt
st.delayNext({ method: 'GET', path: '/v1/invoices', ms: 700, after: 1 })
const started = Date.now()
await assert.rejects(listInvoices(), (err) => err.type === 'StripeConnectionError')
assert.ok(Date.now() - started < 500)
assert.equal(st.calls.filter((c) => c.path === '/v1/invoices').length, 2)
})
test('listInvoices: zweimal innerhalb von 60 s -> ein Stripe-Aufruf, danach invalidateFinanceCache und force', async () => {
st.invoices.set('in_1', rawInvoice())
const countCalls = () => st.calls.filter((c) => c.path === '/v1/invoices').length
await listInvoices()
await listInvoices()
assert.equal(countCalls(), 1)
invalidateFinanceCache()
await listInvoices()
assert.equal(countCalls(), 2)
await listInvoices({ force: true })
assert.equal(countCalls(), 3)
// force frischt auch den Zwischenspeicher auf
await listInvoices()
assert.equal(countCalls(), 3)
})
test('listInvoices: gleichzeitige Aufrufe teilen sich eine Stripe-Anfrage', async () => {
st.invoices.set('in_1', rawInvoice())
const [a, b] = await Promise.all([listInvoices(), listInvoices()])
assert.equal(st.calls.filter((c) => c.path === '/v1/invoices').length, 1)
assert.equal(a.length, 1)
assert.equal(b.length, 1)
})
test('listInvoices: nach 60 s wird neu geladen', async () => {
mock.timers.enable({ apis: ['Date'], now: Date.now() })
st.invoices.set('in_1', rawInvoice())
const countCalls = () => st.calls.filter((c) => c.path === '/v1/invoices').length
await listInvoices()
mock.timers.tick(59_000)
await listInvoices()
assert.equal(countCalls(), 1)
mock.timers.tick(2_000)
await listInvoices()
assert.equal(countCalls(), 2)
})
test('listInvoices: Aenderungen am Ergebnis vergiften den Zwischenspeicher nicht', async () => {
st.invoices.set('in_1', rawInvoice())
const first = await listInvoices()
first[0].status = 'bezahlt'
first.pop()
const second = await listInvoices()
assert.equal(second.length, 1)
assert.equal(second[0].status, 'offen')
})
test('listInvoices: Stripe-Fehler wird nicht zwischengespeichert', async () => {
st.invoices.set('in_1', rawInvoice())
st.failNext({ path: '/v1/invoices', status: 400, message: 'kaputt' })
await assert.rejects(() => listInvoices(), /kaputt/)
const list = await listInvoices()
assert.equal(list.length, 1)
})
test('listPayouts: Betrag, Ankunftstag (Berlin) und Status, 60-s-Zwischenspeicher', async () => {
st.payouts.set('po_1', { id: 'po_1', object: 'payout', amount: 45000, arrival_date: noonBerlin('2026-09-12'), status: 'paid', currency: 'eur' })
st.payouts.set('po_2', { id: 'po_2', object: 'payout', amount: 1999, arrival_date: Math.floor(new Date('2026-09-30T22:30:00Z').getTime() / 1000), status: 'in_transit', currency: 'eur' })
const list = await listPayouts()
assert.deepEqual(list, [
{ id: 'po_1', amountCents: 45000, arrivalDate: '2026-09-12', status: 'paid' },
{ id: 'po_2', amountCents: 1999, arrivalDate: '2026-10-01', status: 'in_transit' },
])
const calls = () => st.calls.filter((c) => c.path === '/v1/payouts')
assert.equal(calls().length, 1)
assert.equal(calls()[0].query.limit, '50')
await listPayouts()
assert.equal(calls().length, 1)
invalidateFinanceCache()
await listPayouts()
assert.equal(calls().length, 2)
await listPayouts({ force: true })
assert.equal(calls().length, 3)
})
test('invalidateFinanceCache leert Rechnungen und Auszahlungen', async () => {
st.invoices.set('in_1', rawInvoice())
st.payouts.set('po_1', { id: 'po_1', object: 'payout', amount: 100, arrival_date: noonBerlin('2026-09-12'), status: 'paid' })
await listInvoices()
await listPayouts()
invalidateFinanceCache()
await listInvoices()
await listPayouts()
assert.equal(st.calls.filter((c) => c.path === '/v1/invoices').length, 2)
assert.equal(st.calls.filter((c) => c.path === '/v1/payouts').length, 2)
})
+929
View File
@@ -0,0 +1,929 @@
import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber stripeBilling) geladen wird
const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions') } })
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { seedFixtures } = await import('./support/fixtures.js')
const { berlinDate } = await import('../server/services/finance/dates.js')
const { getStripe, stripeCustomerFields } = await import('../server/services/stripeBilling.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { invoiceFooter, createProjectInvoice, sendInvoiceNow, voidInvoice, deleteDraft, listInvoices, invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import(
'../server/services/finance/stripeFinance.js'
)
after(() => Promise.all([aw.close(), st.close()]))
seedFixtures(aw)
aw.seed('customers', { $id: 'lead1', email: 'lead@example.invalid', name: 'Interessent', customerStatus: 'lead', status: 'active' })
aw.seed('customers', { $id: 'lost1', email: 'lost@example.invalid', name: 'Verloren', customerStatus: 'Lost', status: 'active' })
aw.seed('customers', { $id: 'act1', email: 'act@example.invalid', name: 'Aktiv', companyName: 'Aktiv GmbH', customerStatus: 'active', status: 'active' })
aw.seed('customers', {
$id: 'addr1', email: 'neu@example.invalid', name: 'Anna Adresse', companyName: 'Adresse GmbH', customerStatus: 'active', status: 'active',
street: ' Hauptstr. 1 ', postalCode: '10115', city: 'Berlin', country: 'Deutschland',
})
aw.seed('customers', { $id: 'noAddr', email: 'ohne@example.invalid', name: 'Ohne Adresse', customerStatus: 'active', status: 'active' })
aw.seed('customers', { $id: 'noPlz', email: 'plz@example.invalid', name: 'Ohne PLZ', customerStatus: 'active', status: 'active', street: 'Hauptstr. 2', postalCode: ' ', city: 'Berlin' })
aw.seed('customers', { $id: 'noCity', email: 'ort@example.invalid', name: 'Ohne Ort', customerStatus: 'active', status: 'active', street: 'Hauptstr. 3', postalCode: '10115', city: '' })
aw.seed('customers', { $id: 'noStreet', email: 'str@example.invalid', name: 'Ohne Strasse', customerStatus: 'active', status: 'active', street: ' ', postalCode: '10115', city: 'Berlin' })
aw.seed('customers', { $id: 'noMail', name: 'Ohne Mail', customerStatus: 'active', status: 'active' })
aw.seed('customers', { $id: 'blankMail', email: ' ', name: 'Leere Mail', customerStatus: 'active', status: 'active' })
const NO_MAIL = 'Für den Versand fehlt die E-Mail-Adresse des Kunden.'
const ADDRESS_INCOMPLETE = 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.'
const SYNC_FAILED = 'Kundendaten konnten nicht an Stripe übertragen werden.'
const NOT_SENDABLE = 'Diese Rechnung kann nicht mehr gesendet werden.'
const PAYMENT_NOTE = 'Bitte geben Sie bei Überweisung die Rechnungsnummer als Verwendungszweck an.'
const BASE_ITEMS = [
{ description: 'Website-Anpassung', quantity: 2, unitAmountCents: 5000 },
{ description: 'Domain', quantity: 1, unitAmountCents: 1200 },
]
beforeEach(() => {
mock.restoreAll()
invalidateFinanceCache()
resetInvoiceCreateGuardForTests()
st.calls.length = 0
st.invoices.clear()
st.customers.clear()
// Das Abo von c1 kennt die Stripe-Kunden-ID cus_c1 (seedFixtures); der Nachbau soll den Kunden ebenfalls kennen
st.customers.set('cus_c1', { id: 'cus_c1', object: 'customer', name: 'Kunde Eins', email: 'k@example.invalid', metadata: { webklarCustomerId: 'c1' } })
})
const callsTo = (method, path) => st.calls.filter((c) => c.method === method && (path instanceof RegExp ? path.test(c.path) : c.path === path))
const writes = () => st.calls.filter((c) => c.method !== 'GET')
const rejectsWith = (promise, status, message) =>
assert.rejects(promise, (err) => {
assert.equal(err.status, status, `Status ${err.status}: ${err.message}`)
if (message) assert.equal(err.message, message)
return true
})
const aboInvoice = (over = {}) => ({
id: 'in_abo',
object: 'invoice',
number: 'WK-0100',
status: 'open',
customer: 'cus_c1',
subscription: 'sub_h',
created: Math.floor(Date.now() / 1000),
due_date: null,
total: 2000,
amount_remaining: 2000,
amount_paid: 0,
metadata: {},
lines: { object: 'list', data: [], has_more: false },
...over,
})
const projectInvoice = (over = {}) => ({
id: 'in_proj',
object: 'invoice',
number: null,
status: 'draft',
customer: 'cus_c1',
created: Math.floor(Date.now() / 1000),
due_date: null,
total: 6000,
amount_remaining: 6000,
amount_paid: 0,
metadata: { quelle: 'woms' },
lines: { object: 'list', data: [], has_more: false },
...over,
})
test('invoiceFooter: Kleinunternehmer-Hinweis, Bankdaten (falls gesetzt), Verwendungszweck-Satz', () => {
assert.equal(invoiceFooter(), `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`)
assert.equal(invoiceFooter(''), `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`)
assert.equal(invoiceFooter(' \n '), `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`)
assert.equal(
invoiceFooter('IBAN DE00 0000 0000 0000 0000 00\nBIC TESTDEFF'),
`${config.stripe.invoiceFooter}\nIBAN DE00 0000 0000 0000 0000 00\nBIC TESTDEFF\n${PAYMENT_NOTE}`
)
})
test('createProjectInvoice: Entwurf mit Positionen, Rechnungsfuss (Bankdaten aus den Einstellungen) und Metadaten (nicht gesendet)', async () => {
// Der Bankverbindungstext kommt aus financeSettings/main (siehe settings.js), nicht aus dem Aufruf
aw.seed(config.collections.financeSettings, { $id: 'main', bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00' })
let result
try {
result = await createProjectInvoice({
customerId: 'c1',
items: BASE_ITEMS,
memo: 'Leistungen im September',
createdBy: 'kenso@example.invalid',
})
} finally {
await deleteDocument(config.collections.financeSettings, 'main')
}
const [create] = callsTo('POST', '/v1/invoices')
assert.ok(create, 'POST /v1/invoices fehlt')
assert.equal(create.body.customer, 'cus_c1')
assert.equal(create.body.collection_method, 'send_invoice')
assert.equal(create.body.days_until_due, '14')
assert.equal(create.body.auto_advance, 'false')
assert.equal(create.body.pending_invoice_items_behavior, 'exclude')
assert.equal(create.body.currency, 'eur')
assert.equal(create.body.description, 'Leistungen im September')
assert.equal(create.body['metadata[quelle]'], 'woms')
assert.equal(create.body['metadata[webklarCustomerId]'], 'c1')
assert.equal(create.body['metadata[erstelltVon]'], 'kenso@example.invalid')
assert.ok(create.body.footer.startsWith(config.stripe.invoiceFooter))
assert.ok(create.body.footer.endsWith(PAYMENT_NOTE))
assert.equal(create.body.footer, `${config.stripe.invoiceFooter}\nIBAN DE00 0000 0000 0000 0000 00\n${PAYMENT_NOTE}`)
// Menge und Einzelpreis jeder Position kommen bei Stripe an: im Aufruf ...
const items = callsTo('POST', '/v1/invoiceitems')
assert.equal(items.length, 2)
assert.deepEqual(items[0].body, {
customer: 'cus_c1',
invoice: result.id,
description: 'Website-Anpassung',
quantity: '2',
unit_amount_decimal: '5000',
currency: 'eur',
})
assert.deepEqual(items[1].body, {
customer: 'cus_c1',
invoice: result.id,
description: 'Domain',
quantity: '1',
unit_amount_decimal: '1200',
currency: 'eur',
})
// ... und auf der Rechnung (Zeilen mit Menge und Einzelpreis, Summe aus Menge mal Einzelpreis)
const lines = st.invoices.get(result.id).lines.data
assert.deepEqual(
lines.map((l) => [l.description, l.quantity, l.pricing.unit_amount_decimal, l.amount]),
[
['Website-Anpassung', 2, '5000', 10000],
['Domain', 1, '1200', 1200],
]
)
assert.equal(result.status, 'entwurf')
assert.equal(result.source, 'projekt')
assert.equal(result.totalCents, 11200)
assert.equal(result.remainingCents, 11200)
assert.equal(result.webklarCustomerId, 'c1')
assert.equal(result.customerName, 'Kunde Eins')
assert.equal(result.number, '')
// kein Finalisieren, kein Senden
assert.equal(callsTo('POST', /finalize|send/).length, 0)
})
test('createProjectInvoice: ohne Vermerk keine Beschreibung, ohne Bankdaten kuerzerer Fuss, Zahlungsziel 0 bleibt 0', async () => {
await createProjectInvoice({ customerId: 'c1', items: [BASE_ITEMS[1]], daysUntilDue: 0, createdBy: 'k' })
const [create] = callsTo('POST', '/v1/invoices')
assert.equal(create.body.days_until_due, '0')
assert.equal('description' in create.body, false)
assert.equal(create.body.footer, `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`)
})
test('createProjectInvoice: legt fuer Kunden ohne Stripe-Kunde einen an (Firmenname)', async () => {
const result = await createProjectInvoice({ customerId: 'act1', items: [BASE_ITEMS[1]], createdBy: 'k' })
const created = callsTo('POST', '/v1/customers')
assert.equal(created.length, 1)
assert.equal(created[0].body.name, 'Aktiv GmbH')
assert.equal(result.webklarCustomerId, 'act1')
assert.equal(result.customerName, 'Aktiv GmbH')
})
test('createProjectInvoice: ein bestehender Stripe-Kunde wird vor der Rechnung auf die aktuellen WOMS-Daten gebracht (E-Mail, Name, Anschrift)', async () => {
// Stripe-Kunde aus frueherer Zeit (ueber die Metadaten gefunden): alte E-Mail, alter Name, alte Anschrift
st.customers.set('cus_addr1', {
id: 'cus_addr1', object: 'customer', name: 'Anna Alt', email: 'alt@example.invalid',
address: { line1: 'Alte Str. 9', postal_code: '80331', city: 'München', country: 'DE' }, metadata: { webklarCustomerId: 'addr1' },
})
const result = await createProjectInvoice({ customerId: 'addr1', items: [BASE_ITEMS[1]], createdBy: 'k' })
assert.equal(callsTo('POST', '/v1/customers').length, 0, 'kein zweiter Stripe-Kunde')
const updates = callsTo('POST', '/v1/customers/cus_addr1')
assert.equal(updates.length, 1)
assert.deepEqual(updates[0].body, {
email: 'neu@example.invalid',
name: 'Adresse GmbH',
'address[line1]': 'Hauptstr. 1',
'address[postal_code]': '10115',
'address[city]': 'Berlin',
'address[country]': 'DE',
})
// der Stripe-Kunde traegt danach die aktuellen Daten, die Rechnung gehoert ihm
const stored = st.customers.get('cus_addr1')
assert.equal(stored.email, 'neu@example.invalid')
assert.equal(stored.name, 'Adresse GmbH')
assert.deepEqual(stored.address, { line1: 'Hauptstr. 1', postal_code: '10115', city: 'Berlin', country: 'DE' })
assert.equal(stored.metadata.webklarCustomerId, 'addr1')
assert.equal(st.invoices.get(result.id).customer, 'cus_addr1')
// zuerst der Kunde, dann die Rechnung
const order = writes().map((c) => c.path.replace(/in_test_\d+/, ':id'))
assert.deepEqual(order.slice(0, 2), ['/v1/customers/cus_addr1', '/v1/invoices'])
})
test('createProjectInvoice: Stripe-Kunde aus dem Abo (ohne Anschrift im WOMS) bekommt E-Mail und Name, keine Anschrift; Land AT bleibt AT', async () => {
await createProjectInvoice({ customerId: 'c1', items: [BASE_ITEMS[1]], createdBy: 'k' })
const [update] = callsTo('POST', '/v1/customers/cus_c1')
assert.deepEqual(update.body, { email: 'k@example.invalid', name: 'Kunde Eins' })
assert.equal(st.customers.get('cus_c1').address ?? null, null)
aw.seed('customers', { $id: 'at1', email: 'at@example.invalid', name: 'Österreicher', customerStatus: 'active', status: 'active', street: 'Ring 1', postalCode: '1010', city: 'Wien', country: 'AT' })
resetInvoiceCreateGuardForTests()
await createProjectInvoice({ customerId: 'at1', items: [BASE_ITEMS[1]], createdBy: 'k' })
const created = callsTo('POST', '/v1/customers').find((c) => c.body.email === 'at@example.invalid')
assert.equal(created.body['address[country]'], 'AT', 'Anlegen und Aktualisieren benutzen dieselben Felder')
const updated = callsTo('POST', /^\/v1\/customers\/cus_test_/).at(-1)
assert.equal(updated.body['address[country]'], 'AT')
assert.equal(updated.body['address[line1]'], 'Ring 1')
})
test('createProjectInvoice: scheitert die Uebertragung der Kundendaten, gibt es keine Rechnung (502) und ein neuer Versuch geht', async () => {
st.customers.set('cus_addr1', { id: 'cus_addr1', object: 'customer', name: 'Anna Alt', email: 'alt@example.invalid', address: null, metadata: { webklarCustomerId: 'addr1' } })
st.failNext({ method: 'POST', path: '/v1/customers/cus_addr1', status: 400, message: 'geheime Stripe-Meldung' })
await assert.rejects(
createProjectInvoice({ customerId: 'addr1', items: [BASE_ITEMS[1]], senden: true, createdBy: 'k' }),
(err) => {
assert.equal(err.name, 'InvoiceError')
assert.equal(err.status, 502)
assert.equal(err.message, 'Kundendaten konnten nicht an Stripe übertragen werden.')
assert.equal(err.invoiceId, undefined, 'keine Rechnung entstanden')
assert.ok(err.cause, 'Ursache bleibt fuers Protokoll')
return true
}
)
// nichts an Rechnungen: weder Entwurf noch Posten noch Senden
assert.equal(callsTo('POST', /^\/v1\/invoice/).length, 0)
assert.equal(st.invoices.size, 0)
// der Schutz vor doppeltem Anlegen ist freigegeben: derselbe Aufruf klappt, sobald Stripe wieder antwortet
const result = await createProjectInvoice({ customerId: 'addr1', items: [BASE_ITEMS[1]], senden: true, createdBy: 'k' })
assert.equal(result.status, 'offen')
assert.equal(st.customers.get('cus_addr1').email, 'neu@example.invalid')
})
test('createProjectInvoice: senden=true finalisiert (ohne Auto-Advance) und sendet, Rechnung ist offen mit Nummer', async () => {
const result = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, daysUntilDue: 30, senden: true, createdBy: 'k' })
const order = writes().map((c) => c.path.replace(/in_test_\d+/, ':id'))
assert.deepEqual(order.slice(-4), ['/v1/invoiceitems', '/v1/invoiceitems', '/v1/invoices/:id/finalize', '/v1/invoices/:id/send'])
const [finalize] = callsTo('POST', /\/finalize$/)
assert.equal(finalize.body.auto_advance, 'false')
assert.equal(result.status, 'offen')
assert.match(result.number, /^WK-\d{4}$/)
assert.equal(result.totalCents, 11200)
assert.equal(result.dueDate, berlinDate(new Date(Date.now() + 30 * 86_400_000)))
assert.ok(result.hostedUrl)
})
test('createProjectInvoice: Lead-, verlorene und unbekannte Kunden werden mit 400 abgelehnt, Stripe bleibt unberuehrt', async () => {
await rejectsWith(createProjectInvoice({ customerId: 'lead1', items: BASE_ITEMS, createdBy: 'k' }), 400, 'Rechnungen nur für feste Kunden.')
await rejectsWith(createProjectInvoice({ customerId: 'lost1', items: BASE_ITEMS, createdBy: 'k' }), 400, 'Rechnungen nur für feste Kunden.')
await rejectsWith(createProjectInvoice({ customerId: 'gibtEsNicht', items: BASE_ITEMS, createdBy: 'k' }), 400)
await rejectsWith(createProjectInvoice({ items: BASE_ITEMS, createdBy: 'k' }), 400)
assert.equal(writes().length, 0)
})
test('createProjectInvoice: Eingaben werden geprueft (Positionen, Texte, Mengen, Betraege, Zahlungsziel)', async () => {
const ok = BASE_ITEMS[1]
const bad = async (label, input) => {
try {
await createProjectInvoice({ customerId: 'c1', items: [ok], createdBy: 'k', ...input })
} catch (err) {
assert.equal(err.status, 400, `${label}: Status ${err.status} (${err.message})`)
return
}
assert.fail(`${label}: wurde nicht abgelehnt`)
}
const item = (over) => ({ ...ok, ...over })
// 21 Positionen, keine Positionen, keine Liste
await rejectsWith(createProjectInvoice({ customerId: 'c1', items: Array.from({ length: 21 }, () => ok), createdBy: 'k' }), 400)
await bad('leer', { items: [] })
await bad('keine Liste', { items: undefined })
// Beschreibung
await bad('Beschreibung leer', { items: [item({ description: '' })] })
await bad('Beschreibung nur Leerzeichen', { items: [item({ description: ' ' })] })
await bad('Beschreibung 201 Zeichen', { items: [item({ description: 'x'.repeat(201) })] })
await bad('Beschreibung keine Zeichenkette', { items: [item({ description: 5 })] })
// Menge
for (const quantity of [0, 1000, 1.5, '2', -1, null]) await bad(`Menge ${quantity}`, { items: [item({ quantity })] })
// Einzelpreis
for (const unitAmountCents of [0, 10_000_001, 12.5, '5000', -100, null]) await bad(`Preis ${unitAmountCents}`, { items: [item({ unitAmountCents })] })
// Zahlungsziel
for (const daysUntilDue of [-1, 61, 1.5, '14', null]) await bad(`Ziel ${daysUntilDue}`, { daysUntilDue })
assert.equal(writes().length, 0, 'bei ungueltigen Eingaben darf nichts an Stripe gehen')
})
test('createProjectInvoice: Rechnungssumme hoechstens 99.999.999 Cent, genau die Grenze ist erlaubt', async () => {
const TOO_HIGH = 'Rechnungssumme zu hoch.'
// eine Position allein ueber der Grenze (999 x 10.000.000) und mehrere zusammen (9 x 10.000.000 + 10.000.000)
await rejectsWith(createProjectInvoice({ customerId: 'c1', items: [{ description: 'Gross', quantity: 999, unitAmountCents: 10_000_000 }], createdBy: 'k' }), 400, TOO_HIGH)
const nine = Array.from({ length: 9 }, (_, i) => ({ description: `Teil ${i + 1}`, quantity: 1, unitAmountCents: 10_000_000 }))
await rejectsWith(createProjectInvoice({ customerId: 'c1', items: [...nine, { description: 'Rest', quantity: 1, unitAmountCents: 10_000_000 }], createdBy: 'k' }), 400, TOO_HIGH)
// vor jedem Stripe-Aufruf, auch vor dem Anlegen des Stripe-Kunden
assert.equal(st.calls.length, 0)
// genau 99.999.999 Cent (9 x 10.000.000 + 9.999.999)
const result = await createProjectInvoice({ customerId: 'c1', items: [...nine, { description: 'Rest', quantity: 1, unitAmountCents: 9_999_999 }], createdBy: 'k' })
assert.equal(result.totalCents, 99_999_999)
})
test('createProjectInvoice: Grenzwerte sind erlaubt (20 Positionen, 200 Zeichen, Menge 999, Einzelpreis 10.000.000 Cent, Ziel 60)', async () => {
// 19 Positionen mit Menge 999, dazu eine mit dem hoechsten Einzelpreis: zusammen unter der Summengrenze
const items = Array.from({ length: 19 }, (_, i) => ({ description: i === 0 ? 'x'.repeat(200) : `Position ${i}`, quantity: 999, unitAmountCents: 1000 }))
items.push({ description: 'Teuer', quantity: 1, unitAmountCents: 10_000_000 })
const result = await createProjectInvoice({ customerId: 'c1', items, daysUntilDue: 60, createdBy: 'k' })
assert.equal(callsTo('POST', '/v1/invoiceitems').length, 20)
assert.equal(result.totalCents, 19 * 999 * 1000 + 10_000_000)
assert.equal(callsTo('POST', '/v1/invoices')[0].body.days_until_due, '60')
assert.equal(callsTo('POST', '/v1/invoiceitems')[19].body.unit_amount_decimal, '10000000')
assert.equal(callsTo('POST', '/v1/invoiceitems')[0].body.quantity, '999')
})
test('createProjectInvoice: scheitert eine Position, wird der halbfertige Entwurf geloescht', async () => {
st.failNext({ method: 'POST', path: '/v1/invoiceitems', status: 400, message: 'Nachbau: Position abgelehnt', after: 1 })
await assert.rejects(createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' }), /Position abgelehnt/)
assert.equal(callsTo('DELETE', /^\/v1\/invoices\/in_/).length, 1)
assert.equal(st.invoices.size, 0)
})
test('sendInvoiceNow: Entwurf wird finalisiert (ohne Auto-Advance) und gesendet', async () => {
const draft = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' })
st.calls.length = 0
const sent = await sendInvoiceNow(draft.id)
// zuerst der Stripe-Kunde (N1), dann finalisieren und senden
assert.deepEqual(
writes().map((c) => c.path.replace(draft.id, ':id')),
['/v1/customers/cus_c1', '/v1/invoices/:id/finalize', '/v1/invoices/:id/send']
)
assert.equal(callsTo('POST', /\/finalize$/)[0].body.auto_advance, 'false')
assert.equal(sent.status, 'offen')
assert.match(sent.number, /^WK-\d{4}$/)
})
test('createProjectInvoice: senden=true ohne E-Mail-Adresse des Kunden -> 400 vor jedem Stripe-Aufruf, als Entwurf geht es', async () => {
for (const customerId of ['noMail', 'blankMail']) {
await rejectsWith(createProjectInvoice({ customerId, items: BASE_ITEMS, senden: true, createdBy: 'k' }), 400, NO_MAIL)
}
assert.equal(st.calls.length, 0)
const draft = await createProjectInvoice({ customerId: 'noMail', items: BASE_ITEMS, createdBy: 'k' })
assert.equal(draft.status, 'entwurf')
})
test('createProjectInvoice: scheitert das Senden nach dem Festschreiben -> 502 mit Rechnungs-ID und -Nummer, Rechnung bleibt offen', async () => {
st.failNext({ method: 'POST', path: /\/send$/, status: 400, message: 'Nachbau: Versand abgelehnt' })
const err = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, senden: true, createdBy: 'k' }).then(
() => assert.fail('haette scheitern muessen'),
(e) => e
)
assert.equal(err.status, 502)
const [stored] = [...st.invoices.values()]
assert.equal(err.invoiceId, stored.id)
assert.equal(err.invoiceNumber, stored.number)
assert.equal(err.message, `Rechnung ${stored.number} wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.`)
assert.equal(stored.status, 'open')
// Zwischenspeicher geleert: die Liste zeigt die offene Rechnung sofort
assert.deepEqual((await listInvoices()).map((i) => [i.id, i.status]), [[stored.id, 'offen']])
// erneutes Senden: nur "send", nicht noch einmal finalisieren, keine zweite Rechnung
st.calls.length = 0
const resent = await sendInvoiceNow(err.invoiceId)
assert.deepEqual(writes().map((c) => c.path.replace(err.invoiceId, ':id')), ['/v1/invoices/:id/send'])
assert.equal(resent.status, 'offen')
assert.equal(resent.number, stored.number)
assert.equal(st.invoices.size, 1)
})
test('sendInvoiceNow: scheitert das Senden eines Entwurfs nach dem Festschreiben -> 502 mit Nummer', async () => {
const draft = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' })
st.failNext({ method: 'POST', path: /\/send$/, status: 400, message: 'Nachbau: Versand abgelehnt' })
const err = await sendInvoiceNow(draft.id).then(
() => assert.fail('haette scheitern muessen'),
(e) => e
)
assert.equal(err.status, 502)
assert.equal(err.invoiceId, draft.id)
assert.match(err.invoiceNumber, /^WK-\d{4}$/)
assert.equal(err.message, `Rechnung ${err.invoiceNumber} wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.`)
assert.equal(st.invoices.get(draft.id).status, 'open')
assert.equal(callsTo('POST', /\/finalize$/).length, 1)
// der Versand-Fehler einer reinen Erinnerung (offen) kommt dagegen unveraendert von Stripe
st.failNext({ method: 'POST', path: /\/send$/, status: 400, message: 'Nachbau: Versand abgelehnt' })
await assert.rejects(sendInvoiceNow(draft.id), (e) => e.status === undefined && /Versand abgelehnt/.test(e.message))
})
test('sendInvoiceNow: offene und ueberfaellige Projektrechnungen werden nur erneut gesendet (kein Finalisieren)', async () => {
st.invoices.set('in_open', projectInvoice({ id: 'in_open', status: 'open', number: 'WK-0001', due_date: Math.floor(Date.now() / 1000) + 5 * 86_400 }))
st.invoices.set('in_late', projectInvoice({ id: 'in_late', status: 'open', number: 'WK-0002', due_date: Math.floor(Date.now() / 1000) - 5 * 86_400 }))
assert.equal((await sendInvoiceNow('in_open')).status, 'offen')
assert.equal((await sendInvoiceNow('in_late')).status, 'ueberfaellig')
assert.deepEqual(writes().map((c) => c.path), ['/v1/invoices/in_open/send', '/v1/invoices/in_late/send'])
})
test('sendInvoiceNow: bezahlt/storniert/uneinbringlich -> 409, Abo-Rechnungen -> 409, ohne E-Mail -> 400, unbekannt -> 404', async () => {
for (const status of ['paid', 'void', 'uncollectible']) st.invoices.set(`in_${status}`, projectInvoice({ id: `in_${status}`, status, number: 'WK-0003' }))
st.invoices.set('in_abo', aboInvoice({ status: 'draft', number: null }))
st.invoices.set('in_abo_open', aboInvoice({ id: 'in_abo_open' }))
for (const status of ['paid', 'void', 'uncollectible']) await rejectsWith(sendInvoiceNow(`in_${status}`), 409, NOT_SENDABLE)
await rejectsWith(sendInvoiceNow('in_abo'), 409, 'Abo-Rechnungen verwaltet Stripe.')
await rejectsWith(sendInvoiceNow('in_abo_open'), 409, 'Abo-Rechnungen verwaltet Stripe.')
await rejectsWith(sendInvoiceNow('in_gibtEsNicht'), 404, 'Rechnung nicht gefunden.')
// Kunde ohne E-Mail-Adresse: Entwurf und offene Rechnung scheitern mit 400 vor jedem Schreibzugriff
st.customers.set('cus_nomail', { id: 'cus_nomail', object: 'customer', name: 'Ohne Mail', email: null, metadata: {} })
st.invoices.set('in_nomail_draft', projectInvoice({ id: 'in_nomail_draft', customer: 'cus_nomail' }))
st.invoices.set('in_nomail_open', projectInvoice({ id: 'in_nomail_open', customer: 'cus_nomail', status: 'open', number: 'WK-0004' }))
await rejectsWith(sendInvoiceNow('in_nomail_draft'), 400, NO_MAIL)
await rejectsWith(sendInvoiceNow('in_nomail_open'), 400, NO_MAIL)
assert.equal(writes().length, 0)
})
test('voidInvoice: offene und ueberfaellige Projektrechnungen werden storniert', async () => {
const open = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, senden: true, createdBy: 'k' })
const voided = await voidInvoice(open.id)
assert.equal(voided.status, 'storniert')
assert.equal(callsTo('POST', `/v1/invoices/${open.id}/void`).length, 1)
// ueberfaellig: offen, Faelligkeit vor heute
st.invoices.set('in_late', projectInvoice({ id: 'in_late', status: 'open', number: 'WK-0007', due_date: Math.floor(Date.now() / 1000) - 10 * 86_400 }))
assert.equal((await voidInvoice('in_late')).status, 'storniert')
})
test('voidInvoice: Entwurf, bezahlte und Abo-Rechnungen werden mit 409 abgelehnt, unbekannte mit 404', async () => {
st.invoices.set('in_draft', projectInvoice({ id: 'in_draft' }))
st.invoices.set('in_paid', projectInvoice({ id: 'in_paid', status: 'paid', number: 'WK-0002' }))
st.invoices.set('in_abo', aboInvoice())
await rejectsWith(voidInvoice('in_draft'), 409)
await rejectsWith(voidInvoice('in_paid'), 409)
await rejectsWith(voidInvoice('in_abo'), 409, 'Abo-Rechnungen verwaltet Stripe.')
await rejectsWith(voidInvoice('in_gibtEsNicht'), 404, 'Rechnung nicht gefunden.')
assert.equal(writes().length, 0)
assert.equal(st.invoices.get('in_draft').status, 'draft')
})
test('deleteDraft: loescht einen Projekt-Entwurf bei Stripe', async () => {
const draft = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' })
const result = await deleteDraft(draft.id)
assert.equal(result, undefined)
assert.equal(callsTo('DELETE', `/v1/invoices/${draft.id}`).length, 1)
assert.equal(st.invoices.has(draft.id), false)
})
test('deleteDraft: Abo-Rechnungen und alles ausser Entwuerfen werden mit 409 abgelehnt', async () => {
st.invoices.set('in_abo', aboInvoice())
st.invoices.set('in_abo_draft', aboInvoice({ id: 'in_abo_draft', status: 'draft', number: null }))
st.invoices.set('in_open', projectInvoice({ id: 'in_open', status: 'open', number: 'WK-0003' }))
await rejectsWith(deleteDraft('in_abo'), 409, 'Abo-Rechnungen verwaltet Stripe.')
await rejectsWith(deleteDraft('in_abo_draft'), 409, 'Abo-Rechnungen verwaltet Stripe.')
await rejectsWith(deleteDraft('in_open'), 409)
await rejectsWith(deleteDraft('in_gibtEsNicht'), 404, 'Rechnung nicht gefunden.')
assert.equal(writes().length, 0)
assert.equal(st.invoices.size, 3)
})
test('Schreibzugriffe leeren den Zwischenspeicher: die Liste zeigt neue, gesendete, stornierte und geloeschte Rechnungen sofort', async () => {
assert.deepEqual(await listInvoices(), [])
const draft = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' })
assert.deepEqual((await listInvoices()).map((i) => [i.id, i.status]), [[draft.id, 'entwurf']])
await sendInvoiceNow(draft.id)
assert.equal((await listInvoices())[0].status, 'offen')
await voidInvoice(draft.id)
assert.equal((await listInvoices())[0].status, 'storniert')
// gleicher Inhalt wie der erste Entwurf: der Schutz vor doppeltem Anlegen waere noch aktiv
resetInvoiceCreateGuardForTests()
const second = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' })
assert.equal((await listInvoices()).length, 2)
await deleteDraft(second.id)
assert.deepEqual((await listInvoices()).map((i) => i.id), [draft.id])
})
test('Stripe-Nachbau: Statusuebergaenge wie Stripe (finalize nur Entwurf, send/void/pay nur offen, loeschen nur Entwurf)', async () => {
const stripe = getStripe()
const status = (id) => st.invoices.get(id).status
const refused = (promise, pattern) => assert.rejects(promise, (err) => err.statusCode === 400 && pattern.test(err.message))
const create = () => stripe.invoices.create({ customer: 'cus_c1', collection_method: 'send_invoice', days_until_due: 7 })
const item = (id) => stripe.invoiceItems.create({ customer: 'cus_c1', invoice: id, description: 'Test', quantity: 3, unit_amount_decimal: '250', currency: 'eur' })
// send_invoice braucht ein Zahlungsziel
await assert.rejects(stripe.invoices.create({ customer: 'cus_c1', collection_method: 'send_invoice' }), (err) => err.statusCode === 400)
const draft = await create()
assert.equal(draft.status, 'draft')
await item(draft.id)
assert.equal(st.invoices.get(draft.id).total, 750)
await refused(stripe.invoices.sendInvoice(draft.id), /open/)
await refused(stripe.invoices.voidInvoice(draft.id), /open/)
await refused(stripe.invoices.pay(draft.id), /open/)
// Metadaten zusammenfuehren, leerer Wert loescht
await stripe.invoices.update(draft.id, { metadata: { a: '1', b: '2' } })
await stripe.invoices.update(draft.id, { metadata: { b: '', c: '3' } })
assert.deepEqual(st.invoices.get(draft.id).metadata, { a: '1', c: '3' })
const open = await stripe.invoices.finalizeInvoice(draft.id, { auto_advance: false })
assert.equal(open.status, 'open')
assert.match(open.number, /^WK-\d{4}$/)
assert.equal(open.auto_advance, false)
await refused(stripe.invoices.finalizeInvoice(draft.id), /draft/)
await refused(stripe.invoices.del(draft.id), /draft/)
await refused(item(draft.id), /draft/)
assert.equal(status(draft.id), 'open')
// Nummern laufen fortlaufend
const second = await create()
assert.equal((await stripe.invoices.finalizeInvoice(second.id)).number, `WK-${String(Number(open.number.slice(3)) + 1).padStart(4, '0')}`)
// bezahlen (ausserhalb von Stripe) und danach nichts mehr
const paid = await stripe.invoices.pay(draft.id, { paid_out_of_band: true })
assert.equal(paid.status, 'paid')
assert.equal(paid.amount_paid, 750)
assert.equal(paid.amount_remaining, 0)
// wie die echte API: die Rechnung traegt paid_out_of_band nicht; der Aufruf (calls) hat es gesendet
assert.equal(paid.paid_out_of_band, undefined)
assert.equal(st.calls.findLast((c) => c.path === `/v1/invoices/${draft.id}/pay`).body.paid_out_of_band, 'true')
assert.ok(paid.status_transitions.paid_at)
await refused(stripe.invoices.voidInvoice(draft.id), /open/)
await refused(stripe.invoices.pay(draft.id), /open/)
// stornieren nur offen; Entwurf loeschen
await stripe.invoices.voidInvoice(second.id)
assert.equal(status(second.id), 'void')
const third = await create()
const removed = await stripe.invoices.del(third.id)
assert.equal(removed.deleted, true)
assert.equal(st.invoices.has(third.id), false)
})
// ---------- Doppeltes Anlegen (I3) ----------
const createdCalls = () => callsTo('POST', '/v1/invoices')
test('createProjectInvoice: idempotencyKey geht als Idempotency-Key nur an die Anlage der Rechnung (I3)', async () => {
await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, idempotencyKey: 'woms-formular-4711', createdBy: 'k@example.invalid' })
const [create] = createdCalls()
assert.equal(create.idempotencyKey, 'woms-formular-4711')
// die Posten tragen nicht den Schluessel des Aufrufers (stripe-node vergibt sonst eigene, zufaellige)
assert.ok(callsTo('POST', '/v1/invoiceitems').every((call) => call.idempotencyKey !== 'woms-formular-4711'))
// ohne Schluessel (oder leer / null) nimmt stripe-node einen eigenen, zufaelligen: nie den des Aufrufers
st.calls.length = 0
for (const idempotencyKey of [undefined, null, '']) {
resetInvoiceCreateGuardForTests()
await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, idempotencyKey })
}
assert.equal(createdCalls().length, 3)
assert.equal(new Set(createdCalls().map((call) => call.idempotencyKey)).size, 3, 'jedes Mal ein anderer')
})
test('createProjectInvoice: ungueltiger Wiederholungsschluessel -> 400, nichts bei Stripe (I3)', async () => {
for (const idempotencyKey of ['x'.repeat(101), 'mit Leerzeichen', 'Zeilen\numbruch', 42, {}]) {
await rejectsWith(
createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, idempotencyKey }),
400,
'Der Wiederholungsschlüssel ist ungültig (1 bis 100 Zeichen ohne Leerzeichen).'
)
}
assert.equal(st.calls.length, 0)
// genau 100 Zeichen sind erlaubt
await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, idempotencyKey: 'k'.repeat(100) })
assert.equal(createdCalls()[0].idempotencyKey, 'k'.repeat(100))
})
test('createProjectInvoice: zwei gleichzeitige gleiche Aufrufe legen nur eine Rechnung an, der zweite bekommt 409 (I3)', async () => {
const args = { customerId: 'c1', items: BASE_ITEMS, memo: 'Doppelklick', senden: true }
const results = await Promise.allSettled([createProjectInvoice(args), createProjectInvoice(args)])
assert.deepEqual(results.map((r) => r.status), ['fulfilled', 'rejected'])
await rejectsWith(Promise.reject(results[1].reason), 409, 'Diese Rechnung wird gerade schon erstellt.')
assert.equal(st.invoices.size, 1)
assert.equal(createdCalls().length, 1)
assert.equal(callsTo('POST', /\/send$/).length, 1, 'nur eine Mail')
})
test('createProjectInvoice: 30 Sekunden nach einem Erfolg 409, danach wieder erlaubt; anderer Inhalt ist nie betroffen (I3)', async () => {
const args = { customerId: 'c1', items: BASE_ITEMS }
await createProjectInvoice(args)
await rejectsWith(createProjectInvoice(args), 409, 'Diese Rechnung wird gerade schon erstellt.')
assert.equal(st.invoices.size, 1)
// anderer Inhalt: anderer Vermerk, anderes Zahlungsziel, senden, anderer Kunde, andere Positionen
await createProjectInvoice({ ...args, memo: 'anders' })
await createProjectInvoice({ ...args, daysUntilDue: 30 })
await createProjectInvoice({ ...args, senden: true })
await createProjectInvoice({ customerId: 'act1', items: BASE_ITEMS })
await createProjectInvoice({ customerId: 'c1', items: [{ ...BASE_ITEMS[0], quantity: 3 }] })
assert.equal(st.invoices.size, 6)
// nach 30 Sekunden gilt dieselbe Rechnung wieder als neu
const real = Date.now
mock.method(Date, 'now', () => real() + 29_000)
await rejectsWith(createProjectInvoice(args), 409)
mock.method(Date, 'now', () => real() + 31_000)
await createProjectInvoice(args)
assert.equal(st.invoices.size, 7)
})
test('createProjectInvoice: scheitert die Anlage und bleibt keine Rechnung, ist der Schutz frei (I3)', async () => {
const args = { customerId: 'c1', items: BASE_ITEMS }
st.failNext({ method: 'POST', path: '/v1/invoices', status: 400, message: 'abgelehnt' })
await assert.rejects(createProjectInvoice(args))
// Ungueltiger Kunde zaehlt ebenso als gescheitert
await rejectsWith(createProjectInvoice({ customerId: 'lead1', items: BASE_ITEMS }), 400)
await rejectsWith(createProjectInvoice({ customerId: 'lead1', items: BASE_ITEMS }), 400)
// Posten scheitert: Entwurf wird geloescht, der Schutz freigegeben
st.failNext({ method: 'POST', path: '/v1/invoiceitems', status: 400, message: 'abgelehnt' })
await assert.rejects(createProjectInvoice(args))
assert.equal(st.invoices.size, 0)
await createProjectInvoice(args)
assert.equal(st.invoices.size, 1)
})
test('createProjectInvoice: Senden scheitert nach dem Festschreiben -> Rechnung besteht, der Schutz bleibt (I3)', async () => {
const args = { customerId: 'c1', items: BASE_ITEMS, senden: true }
st.failNext({ method: 'POST', path: /\/send$/, status: 400, message: 'Mailversand abgelehnt' })
await assert.rejects(createProjectInvoice(args), (err) => err.status === 502 && /^in_/.test(err.invoiceId))
await rejectsWith(createProjectInvoice(args), 409, 'Diese Rechnung wird gerade schon erstellt.')
assert.equal(st.invoices.size, 1)
})
test('createProjectInvoice: Rechnung nach dem Senden nicht lesbar -> 502 mit invoiceId und Nummer statt blankem Fehler (I3)', async () => {
const args = { customerId: 'c1', items: BASE_ITEMS, senden: true }
st.failNext({ method: 'GET', path: /^\/v1\/invoices\/in_/, status: 400, message: 'Lesefehler 10.0.0.7' })
let caught
await assert.rejects(createProjectInvoice(args), (err) => {
caught = err
return true
})
assert.equal(caught.status, 502)
assert.equal(caught.name, 'InvoiceError')
assert.match(caught.invoiceId, /^in_/)
assert.match(caught.invoiceNumber, /^WK-\d{4}$/)
assert.equal(caught.message, `Rechnung ${caught.invoiceNumber} wurde gesendet, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.`)
assert.ok(!caught.message.includes('10.0.0.7'))
assert.ok(caught.cause, 'die Ursache bleibt fuers Protokoll')
assert.equal(st.invoices.get(caught.invoiceId).status, 'open')
assert.equal(callsTo('POST', /\/send$/).length, 1)
// die Rechnung besteht: ein zweiter Versuch mit gleichem Inhalt wird abgewiesen
await rejectsWith(createProjectInvoice(args), 409)
// Entwurf: ohne Nummer, andere Meldung
resetInvoiceCreateGuardForTests()
st.failNext({ method: 'GET', path: /^\/v1\/invoices\/in_/, status: 400 })
await assert.rejects(createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS }), (err) => {
assert.equal(err.status, 502)
assert.equal(err.invoiceNumber, '')
assert.equal(err.message, 'Der Entwurf wurde angelegt, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.')
return true
})
})
// ---------- Nachtrag N1: Kundendaten vor dem Finalisieren eines Entwurfs ----------
test('sendInvoiceNow (N1): ein Entwurf bringt den Stripe-Kunden vor dem Finalisieren auf die aktuellen WOMS-Daten', async () => {
const draft = await createProjectInvoice({ customerId: 'addr1', items: [BASE_ITEMS[1]], createdBy: 'k' })
const stripeCustomerId = st.invoices.get(draft.id).customer
// zwischen Entwurf und Senden hat sich beim Stripe-Kunden (oder im WOMS) etwas geaendert: Stripe traegt wieder alte Daten
st.customers.set(stripeCustomerId, {
...st.customers.get(stripeCustomerId), name: 'Anna Alt', email: 'alt@example.invalid',
address: { line1: 'Alte Str. 9', postal_code: '80331', city: 'München', country: 'DE' },
})
st.calls.length = 0
const sent = await sendInvoiceNow(draft.id)
assert.equal(sent.status, 'offen')
assert.deepEqual(
writes().map((c) => c.path.replace(draft.id, ':id')),
[`/v1/customers/${stripeCustomerId}`, '/v1/invoices/:id/finalize', '/v1/invoices/:id/send']
)
assert.deepEqual(callsTo('POST', `/v1/customers/${stripeCustomerId}`)[0].body, {
email: 'neu@example.invalid', name: 'Adresse GmbH',
'address[line1]': 'Hauptstr. 1', 'address[postal_code]': '10115', 'address[city]': 'Berlin', 'address[country]': 'DE',
})
const stored = st.customers.get(stripeCustomerId)
assert.equal(stored.email, 'neu@example.invalid')
assert.equal(stored.name, 'Adresse GmbH')
assert.deepEqual(stored.address, { line1: 'Hauptstr. 1', postal_code: '10115', city: 'Berlin', country: 'DE' })
// die Rechnung wurde mit den aktuellen Daten festgeschrieben
assert.equal(sent.customerEmail, 'neu@example.invalid')
assert.equal(sent.customerName, 'Adresse GmbH')
})
test('sendInvoiceNow (N1): scheitert die Uebertragung, wird nicht finalisiert (502, feste Meldung), ein neuer Versuch geht', async () => {
const draft = await createProjectInvoice({ customerId: 'addr1', items: [BASE_ITEMS[1]], createdBy: 'k' })
const stripeCustomerId = st.invoices.get(draft.id).customer
st.calls.length = 0
st.failNext({ method: 'POST', path: `/v1/customers/${stripeCustomerId}`, status: 400, message: 'geheime Stripe-Meldung' })
await assert.rejects(sendInvoiceNow(draft.id), (err) => {
assert.equal(err.name, 'InvoiceError')
assert.equal(err.status, 502)
assert.equal(err.message, SYNC_FAILED)
assert.equal(err.invoiceId, undefined)
assert.ok(err.cause, 'Ursache bleibt fuers Protokoll')
return true
})
assert.equal(callsTo('POST', /\/(finalize|send)$/).length, 0)
assert.equal(st.invoices.get(draft.id).status, 'draft')
const sent = await sendInvoiceNow(draft.id)
assert.equal(sent.status, 'offen')
})
test('sendInvoiceNow (N1): Entwurf ohne webklarCustomerId (z. B. aus dem Stripe-Dashboard) -> keine Uebertragung, Senden wie bisher', async () => {
st.customers.set('cus_dash', { id: 'cus_dash', object: 'customer', name: 'Dashboard', email: 'dash@example.invalid', metadata: {} })
st.invoices.set('in_dash', projectInvoice({ id: 'in_dash', customer: 'cus_dash', metadata: {}, total: 90_000 }))
const sent = await sendInvoiceNow('in_dash')
assert.equal(sent.status, 'offen')
assert.deepEqual(writes().map((c) => c.path), ['/v1/invoices/in_dash/finalize', '/v1/invoices/in_dash/send'])
assert.equal(callsTo('POST', /^\/v1\/customers/).length, 0)
})
test('sendInvoiceNow (N1): fehlt die E-Mail nur beim Stripe-Kunden, traegt der Abgleich sie aus dem WOMS nach und der Entwurf wird gesendet', async () => {
st.customers.set('cus_nomail2', { id: 'cus_nomail2', object: 'customer', name: 'Ohne Mail', email: null, metadata: { webklarCustomerId: 'addr1' } })
st.invoices.set('in_nomail2', projectInvoice({ id: 'in_nomail2', customer: 'cus_nomail2', metadata: { webklarCustomerId: 'addr1' } }))
const sent = await sendInvoiceNow('in_nomail2')
assert.equal(sent.status, 'offen')
assert.equal(st.customers.get('cus_nomail2').email, 'neu@example.invalid')
assert.deepEqual(writes().map((c) => c.path), ['/v1/customers/cus_nomail2', '/v1/invoices/in_nomail2/finalize', '/v1/invoices/in_nomail2/send'])
})
test('sendInvoiceNow (N1): offene Rechnung wird nur erneut gesendet, ohne Kundenabgleich und ohne Adresspruefung', async () => {
st.customers.set('cus_noaddr', { id: 'cus_noaddr', object: 'customer', name: 'Ohne Adresse', email: 'ohne@example.invalid', metadata: { webklarCustomerId: 'noAddr' } })
st.invoices.set('in_open_big', projectInvoice({ id: 'in_open_big', customer: 'cus_noaddr', status: 'open', number: 'WK-0009', total: 90_000, metadata: { webklarCustomerId: 'noAddr' } }))
const sent = await sendInvoiceNow('in_open_big')
assert.equal(sent.status, 'offen')
assert.deepEqual(writes().map((c) => c.path), ['/v1/invoices/in_open_big/send'])
})
test('sendInvoiceNow (N1): Entwurf eines unbekannten oder nicht festen Kunden -> 400 vor jedem Schreibzugriff', async () => {
st.customers.set('cus_gone', { id: 'cus_gone', object: 'customer', name: 'Weg', email: 'weg@example.invalid', metadata: { webklarCustomerId: 'gibtEsNicht' } })
st.invoices.set('in_gone', projectInvoice({ id: 'in_gone', customer: 'cus_gone', metadata: { webklarCustomerId: 'gibtEsNicht' } }))
await rejectsWith(sendInvoiceNow('in_gone'), 400, 'Kunde nicht gefunden.')
st.customers.set('cus_lead', { id: 'cus_lead', object: 'customer', name: 'Lead', email: 'lead@example.invalid', metadata: { webklarCustomerId: 'lead1' } })
st.invoices.set('in_lead', projectInvoice({ id: 'in_lead', customer: 'cus_lead', metadata: { webklarCustomerId: 'lead1' } }))
await rejectsWith(sendInvoiceNow('in_lead'), 400, 'Rechnungen nur für feste Kunden.')
assert.equal(writes().length, 0)
})
// ---------- Nachtrag N2: Adress-Sperre beim Senden (§ 14 Abs. 4 Nr. 1 UStG, Kleinbetragsrechnung bis 250 €) ----------
const bigItems = (cents) => [{ description: 'Großauftrag', quantity: 1, unitAmountCents: cents }]
test('createProjectInvoice (N2): senden=true ohne vollstaendige Anschrift und Summe ueber 250 € -> 409 vor jedem Stripe-Aufruf', async () => {
for (const customerId of ['noAddr', 'noPlz', 'noCity', 'noStreet']) {
await rejectsWith(createProjectInvoice({ customerId, items: bigItems(25_001), senden: true, createdBy: 'k' }), 409, ADDRESS_INCOMPLETE)
}
// mehrere Positionen: die Summe zaehlt, nicht die einzelne Position
await rejectsWith(
createProjectInvoice({ customerId: 'noAddr', items: [{ description: 'A', quantity: 2, unitAmountCents: 12_501 }], senden: true, createdBy: 'k' }),
409,
ADDRESS_INCOMPLETE
)
assert.equal(st.calls.length, 0, 'weder Lesen noch Schreiben bei Stripe')
assert.equal(st.customers.size, 1)
})
test('createProjectInvoice (N2): Kleinbetragsrechnung bis genau 250,00 € darf auch ohne Anschrift gesendet werden', async () => {
const result = await createProjectInvoice({ customerId: 'noAddr', items: bigItems(25_000), senden: true, createdBy: 'k' })
assert.equal(result.status, 'offen')
assert.equal(result.totalCents, 25_000)
})
test('createProjectInvoice (N2): als Entwurf geht jede Summe ohne Anschrift, mit vollstaendiger Anschrift darf auch gesendet werden', async () => {
const draft = await createProjectInvoice({ customerId: 'noAddr', items: bigItems(90_000), createdBy: 'k' })
assert.equal(draft.status, 'entwurf')
const sent = await createProjectInvoice({ customerId: 'addr1', items: bigItems(90_000), senden: true, createdBy: 'k' })
assert.equal(sent.status, 'offen')
})
test('createProjectInvoice (N2): nach der Sperre ist der Schutz vor doppeltem Anlegen frei, mit ergaenzter Anschrift geht derselbe Aufruf', async () => {
aw.seed('customers', { $id: 'later1', email: 'later@example.invalid', name: 'Spaeter', customerStatus: 'active', status: 'active' })
const input = { customerId: 'later1', items: bigItems(30_000), senden: true, createdBy: 'k' }
await rejectsWith(createProjectInvoice(input), 409, ADDRESS_INCOMPLETE)
aw.seed('customers', { $id: 'later1', email: 'later@example.invalid', name: 'Spaeter', customerStatus: 'active', status: 'active', street: 'Weg 1', postalCode: '10115', city: 'Berlin' })
const result = await createProjectInvoice(input)
assert.equal(result.status, 'offen')
})
test('sendInvoiceNow (N2): Entwurf ohne vollstaendige Anschrift und Summe ueber 250 € -> 409 vor jedem Schreibzugriff, bis 250,00 € geht es', async () => {
const big = await createProjectInvoice({ customerId: 'noAddr', items: bigItems(25_001), createdBy: 'k' })
const small = await createProjectInvoice({ customerId: 'noAddr', items: bigItems(25_000), createdBy: 'k' })
st.calls.length = 0
await rejectsWith(sendInvoiceNow(big.id), 409, ADDRESS_INCOMPLETE)
assert.equal(writes().length, 0, 'kein Kundenabgleich, kein Finalisieren')
assert.equal(st.invoices.get(big.id).status, 'draft')
assert.equal((await sendInvoiceNow(small.id)).status, 'offen')
})
test('sendInvoiceNow (N2): Entwurf mit vollstaendiger Anschrift darf gesendet werden, auch ueber 250 €', async () => {
const draft = await createProjectInvoice({ customerId: 'addr1', items: bigItems(90_000), createdBy: 'k' })
assert.equal((await sendInvoiceNow(draft.id)).status, 'offen')
})
// ---------- Nachtrag N3/N4: Stripe-Kundenfelder ----------
test('stripeCustomerFields (N3): Land - zweistelliger Code gross, deutsche Namen ohne Gross-/Kleinschreibung, leer = DE, sonst weglassen', () => {
const country = (value) => stripeCustomerFields({ street: 'Str. 1', postalCode: '1', city: 'X', country: value }).address.country
const hasNoCountry = (value) => !('country' in stripeCustomerFields({ street: 'Str. 1', postalCode: '1', city: 'X', country: value }).address)
for (const code of ['de', 'DE', ' at ', 'Ch', 'fr']) assert.equal(country(code), code.trim().toUpperCase())
const names = {
Deutschland: 'DE', deutschland: 'DE', DEUTSCHLAND: 'DE', Österreich: 'AT', österreich: 'AT', ÖSTERREICH: 'AT', Oesterreich: 'AT',
Schweiz: 'CH', Niederlande: 'NL', Belgien: 'BE', Frankreich: 'FR', Italien: 'IT', Spanien: 'ES', Polen: 'PL', Dänemark: 'DK',
Luxemburg: 'LU', Liechtenstein: 'LI', ' schweiz ': 'CH', [`O\u0308sterreich`]: 'AT',
}
for (const [name, code] of Object.entries(names)) assert.equal(country(name), code, name)
// leer oder nicht gesetzt: wie bisher DE
for (const empty of ['', ' ', undefined, null]) assert.equal(country(empty), 'DE', JSON.stringify(empty))
// unbekannt: kein Land (Stripe behaelt seinen Wert)
for (const unknown of ['Atlantis', 'Germany', 'D', 'DEU', 'Deutschland (DE)']) assert.ok(hasNoCountry(unknown), unknown)
// Namen von Object.prototype sind keine Laender (kein Treffer ueber die Prototypkette)
for (const proto of ['constructor', 'Constructor', '__proto__', 'toString', 'hasOwnProperty', 'valueOf']) assert.ok(hasNoCountry(proto), proto)
// ohne Strasse bleibt es bei keiner Anschrift
assert.equal(stripeCustomerFields({ country: 'Österreich' }).address, undefined)
})
test('stripeCustomerFields (N4): mit Strasse gehen PLZ und Ort immer mit, fehlende als leerer Text (loescht alte Werte)', () => {
assert.deepEqual(stripeCustomerFields({ street: ' Weg 1 ', postalCode: ' ', city: undefined }).address, { line1: 'Weg 1', postal_code: '', city: '', country: 'DE' })
assert.deepEqual(stripeCustomerFields({ street: 'Weg 1', postalCode: '10115', city: 'Berlin', country: 'AT' }).address, {
line1: 'Weg 1', postal_code: '10115', city: 'Berlin', country: 'AT',
})
// ohne Strasse keine Anschrift, auch nicht mit PLZ/Ort
assert.equal(stripeCustomerFields({ postalCode: '10115', city: 'Berlin' }).address, undefined)
})
test('stripeCustomerFields (forCreate): beim Anlegen fehlen leere PLZ/Ort ganz, mit Werten bleibt alles gleich', () => {
assert.deepEqual(stripeCustomerFields({ street: 'Weg 1', postalCode: ' ', city: undefined }, { forCreate: true }).address, { line1: 'Weg 1', country: 'DE' })
assert.deepEqual(stripeCustomerFields({ street: 'Weg 1', postalCode: '10115', city: '' }, { forCreate: true }).address, { line1: 'Weg 1', postal_code: '10115', country: 'DE' })
assert.deepEqual(
stripeCustomerFields({ street: 'Weg 1', postalCode: '10115', city: 'Berlin', country: 'AT' }, { forCreate: true }).address,
stripeCustomerFields({ street: 'Weg 1', postalCode: '10115', city: 'Berlin', country: 'AT' }).address
)
assert.equal(stripeCustomerFields({ postalCode: '10115' }, { forCreate: true }).address, undefined)
})
test('createProjectInvoice (forCreate): ein neuer Stripe-Kunde mit nur einer Strasse wird ohne leere Adressfelder angelegt, der Abgleich danach loescht per ""', async () => {
aw.seed('customers', { $id: 'newStr', email: 'ns@example.invalid', name: 'Neu Strasse', customerStatus: 'active', status: 'active', street: 'Neue Str. 5' })
await createProjectInvoice({ customerId: 'newStr', items: [BASE_ITEMS[1]], createdBy: 'k' })
const [created] = callsTo('POST', '/v1/customers')
assert.equal(created.body['address[line1]'], 'Neue Str. 5')
assert.ok(!('address[postal_code]' in created.body), 'keine leere PLZ beim Anlegen')
assert.ok(!('address[city]' in created.body), 'kein leerer Ort beim Anlegen')
assert.deepEqual(Object.keys(created.body).filter((k) => k.startsWith('address[')), ['address[line1]', 'address[country]'])
assert.ok(Object.entries(created.body).filter(([k]) => k.startsWith('address[')).every(([, v]) => v !== ''), 'leerer Adresswert im Anlege-Aufruf')
// der Abgleich direkt danach (Aktualisieren) behaelt das Loeschen per leerem Text
const [update] = callsTo('POST', /^\/v1\/customers\/cus_test_/)
assert.equal(update.body['address[postal_code]'], '')
assert.equal(update.body['address[city]'], '')
})
test('Stripe-Nachbau (N4): Kunden-Update fuehrt die Anschrift zusammen wie Stripe, ein leerer Text loescht das Feld', async () => {
st.customers.set('cus_m', { id: 'cus_m', object: 'customer', name: 'M', email: 'm@example.invalid', address: { line1: 'Alt 1', postal_code: '80331', city: 'München', country: 'DE' }, metadata: {} })
const stripe = getStripe()
// nur ein Feld senden: die anderen bleiben
await stripe.customers.update('cus_m', { address: { line1: 'Neu 2' } })
assert.deepEqual(st.customers.get('cus_m').address, { line1: 'Neu 2', postal_code: '80331', city: 'München', country: 'DE' })
// leerer Text loescht genau dieses Feld (Stripe liefert dann null)
await stripe.customers.update('cus_m', { address: { postal_code: '', city: '' } })
assert.deepEqual(st.customers.get('cus_m').address, { line1: 'Neu 2', postal_code: null, city: null, country: 'DE' })
// ohne address im Aufruf bleibt die Anschrift unberuehrt
await stripe.customers.update('cus_m', { name: 'M2' })
assert.deepEqual(st.customers.get('cus_m').address, { line1: 'Neu 2', postal_code: null, city: null, country: 'DE' })
// Kunde ohne Anschrift: das Update legt sie an
st.customers.set('cus_n', { id: 'cus_n', object: 'customer', name: 'N', email: null, address: null, metadata: {} })
await stripe.customers.update('cus_n', { address: { line1: 'Erst 1', postal_code: '', city: 'Köln' } })
assert.deepEqual(st.customers.get('cus_n').address, { line1: 'Erst 1', postal_code: null, city: 'Köln' })
})
test('createProjectInvoice (N4): alte PLZ und alter Ort beim Stripe-Kunden werden geloescht, wenn der WOMS-Kunde nur eine Strasse hat', async () => {
aw.seed('customers', { $id: 'strOnly', email: 's@example.invalid', name: 'Nur Strasse', customerStatus: 'active', status: 'active', street: 'Neue Str. 5' })
st.customers.set('cus_strOnly', {
id: 'cus_strOnly', object: 'customer', name: 'Alt', email: 'alt@example.invalid',
address: { line1: 'Alte Str. 9', postal_code: '80331', city: 'München', country: 'DE' }, metadata: { webklarCustomerId: 'strOnly' },
})
await createProjectInvoice({ customerId: 'strOnly', items: [BASE_ITEMS[1]], createdBy: 'k' })
const [update] = callsTo('POST', '/v1/customers/cus_strOnly')
assert.equal(update.body['address[postal_code]'], '')
assert.equal(update.body['address[city]'], '')
assert.deepEqual(st.customers.get('cus_strOnly').address, { line1: 'Neue Str. 5', postal_code: null, city: null, country: 'DE' })
})
test('createProjectInvoice (N3): unbekanntes Land ueberschreibt das Land beim Stripe-Kunden nicht', async () => {
aw.seed('customers', { $id: 'atlantis', email: 'a@example.invalid', name: 'Atlantis', customerStatus: 'active', status: 'active', street: 'Weg 1', postalCode: '1', city: 'Z', country: 'Atlantis' })
st.customers.set('cus_atlantis', {
id: 'cus_atlantis', object: 'customer', name: 'Atlantis', email: 'a@example.invalid',
address: { line1: 'Weg 1', postal_code: '1', city: 'Z', country: 'CH' }, metadata: { webklarCustomerId: 'atlantis' },
})
await createProjectInvoice({ customerId: 'atlantis', items: [BASE_ITEMS[1]], createdBy: 'k' })
const [update] = callsTo('POST', '/v1/customers/cus_atlantis')
assert.ok(!('address[country]' in update.body))
assert.equal(st.customers.get('cus_atlantis').address.country, 'CH')
})
+174
View File
@@ -0,0 +1,174 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { computeTax } from '../server/services/finance/tax.js'
const TODAY = '2026-10-01'
const tx = (extra = {}) => ({ $id: 't', status: 'BOOK', bookingDate: '2026-09-15', amountCents: -449, counterpartyName: 'Hetzner', remittance: '', category: 'fixkosten', ...extra })
const settings = (extra = {}) => ({ taxReservePercent: 30, previousYearRevenueCents: null, estPrepaymentCents: 0, ...extra })
const invoice = (extra = {}) => ({ id: 'in_1', number: 'WK-2026-0001', status: 'bezahlt', paidCents: 5000, paidDate: '2026-09-20', source: 'projekt', ...extra })
const run = (extra = {}) => computeTax({ today: TODAY, transactions: [], invoices: [], settings: settings(), ...extra })
// Beispiel aus dem Plan: 168,79 EUR Eingang, 59,86 EUR Ausgaben, 100 EUR privat
const planTransactions = () => [
tx({ $id: 'in', amountCents: 16879, category: 'rechnung-extern', bookingDate: '2026-08-05' }),
...[449, 449, 449, 50, 50, 50, 131, 4358].map((cents, i) => tx({ $id: `out${i}`, amountCents: -cents })),
tx({ $id: 'privat', amountCents: -10000, category: 'privat' }),
]
test('Plan-Beispiel: Einnahmen, Ausgaben, Gewinn, Rücklage und Kleinunternehmer-Zähler', () => {
const r = run({ transactions: planTransactions() })
assert.equal(r.jahr, 2026)
assert.equal(r.einnahmenCents, 16879)
assert.equal(r.ausgabenCents, 5986)
assert.equal(r.gewinnCents, 10893)
assert.equal(r.ruecklageCents, 3268)
assert.equal(r.prozent, 30)
assert.equal(r.kleinunternehmer.jahrCents, 16879)
assert.equal(r.gewerbesteuerHinweis, false)
assert.equal(r.naechsteVorauszahlung, null)
})
test('Kleinunternehmer-Grenzen und Anteil am Folgejahr-Limit', () => {
const r = run({ transactions: planTransactions() })
assert.equal(r.kleinunternehmer.grenzeFolgejahrCents, 2500000)
assert.equal(r.kleinunternehmer.grenzeHartCents, 10000000)
assert.equal(r.kleinunternehmer.anteilFolgejahr, 16879 / 2500000)
assert.equal(r.kleinunternehmer.vorjahrCents, null)
})
test('Vorjahresumsatz kommt aus den Einstellungen, ungültige Werte werden zu null', () => {
assert.equal(run({ settings: settings({ previousYearRevenueCents: 1234500 }) }).kleinunternehmer.vorjahrCents, 1234500)
assert.equal(run({ settings: settings({ previousYearRevenueCents: 0 }) }).kleinunternehmer.vorjahrCents, 0)
assert.equal(run({ settings: settings({ previousYearRevenueCents: 'viel' }) }).kleinunternehmer.vorjahrCents, null)
})
test('negativer Gewinn: Rücklage 0, Gewinn bleibt negativ', () => {
const r = run({ transactions: [tx({ amountCents: 1000, category: 'rechnung-extern' }), tx({ amountCents: -5000 })] })
assert.equal(r.gewinnCents, -4000)
assert.equal(r.ruecklageCents, 0)
})
test('Rücklage wird kaufmännisch auf ganze Cent gerundet und folgt dem Prozentsatz der Einstellungen', () => {
const rows = [tx({ amountCents: 10893, category: 'rechnung-extern' })]
assert.equal(run({ transactions: rows, settings: settings({ taxReservePercent: 30 }) }).ruecklageCents, 3268)
assert.equal(run({ transactions: rows, settings: settings({ taxReservePercent: 25 }) }).ruecklageCents, 2723) // 2723,25
assert.equal(run({ transactions: rows, settings: settings({ taxReservePercent: 0 }) }).ruecklageCents, 0)
assert.equal(run({ transactions: rows, settings: settings({ taxReservePercent: 25 }) }).prozent, 25)
})
test('ohne Einstellungen gelten 30 Prozent', () => {
const rows = [tx({ amountCents: 10000, category: 'rechnung-extern' })]
const r = computeTax({ today: TODAY, transactions: rows, invoices: [], settings: null })
assert.equal(r.prozent, 30)
assert.equal(r.ruecklageCents, 3000)
assert.equal(r.kleinunternehmer.vorjahrCents, null)
})
test('nur gebuchte Buchungen des laufenden Kalenderjahres zählen', () => {
const r = run({
transactions: [
tx({ amountCents: 1000, category: 'rechnung-extern', bookingDate: '2026-01-01' }),
tx({ amountCents: 2000, category: 'rechnung-extern', bookingDate: '2025-12-31' }),
tx({ amountCents: 4000, category: 'rechnung-extern', bookingDate: '2027-01-01' }),
tx({ amountCents: 8000, category: 'rechnung-extern', status: 'PDNG' }),
tx({ amountCents: -300, bookingDate: '2025-12-31' }),
tx({ amountCents: -70, status: 'PDNG' }),
tx({ amountCents: -20, bookingDate: '2026-12-31' }),
],
})
assert.equal(r.einnahmenCents, 1000)
assert.equal(r.ausgabenCents, 20)
assert.equal(r.kleinunternehmer.jahrCents, 1000)
})
test('Privat zählt weder bei Einnahmen noch bei Ausgaben, Rechnung/Auszahlung zählen als Einnahmen', () => {
const r = run({
transactions: [
tx({ amountCents: 700, category: 'privat' }),
tx({ amountCents: -900, category: 'privat' }),
tx({ amountCents: 5000, category: 'rechnung' }),
tx({ amountCents: 3000, category: 'stripe-auszahlung' }),
tx({ amountCents: 1000, category: '' }),
],
})
assert.equal(r.einnahmenCents, 9000)
assert.equal(r.ausgabenCents, 0)
})
test('Kleinunternehmer: Stripe-Rechnungen mit Zahltag im Jahr zählen dazu, Auszahlungen und Rechnungsbuchungen nicht', () => {
const base = run({ transactions: planTransactions() })
const r = run({
transactions: [
...planTransactions(),
tx({ amountCents: 9999, category: 'stripe-auszahlung' }), // sonst doppelt
tx({ amountCents: 7777, category: 'rechnung' }), // gehört zu einer Stripe-Rechnung
],
invoices: [
invoice({ paidCents: 5000, paidDate: '2026-10-02' }),
invoice({ id: 'in_2', paidCents: 300, paidDate: '2026-01-01' }),
invoice({ id: 'in_3', paidCents: 4000, paidDate: '2025-12-31' }),
invoice({ id: 'in_4', paidCents: 6000, paidDate: '2027-01-01' }),
invoice({ id: 'in_5', paidCents: 0, paidDate: null, status: 'offen' }),
],
})
assert.equal(base.kleinunternehmer.jahrCents, 16879)
assert.equal(r.kleinunternehmer.jahrCents, 16879 + 5000 + 300)
assert.equal(r.kleinunternehmer.anteilFolgejahr, (16879 + 5300) / 2500000)
})
test('Gewerbesteuer-Hinweis erst bei Gewinn über 24.500 Euro', () => {
const gain = (cents) => run({ transactions: [tx({ amountCents: cents, category: 'rechnung-extern' })] })
assert.equal(gain(2450000).gewerbesteuerHinweis, false)
assert.equal(gain(2450001).gewerbesteuerHinweis, true)
// Ausgaben mindern den Gewinn
const r = run({ transactions: [tx({ amountCents: 2500000, category: 'rechnung-extern' }), tx({ amountCents: -100000 })] })
assert.equal(r.gewinnCents, 2400000)
assert.equal(r.gewerbesteuerHinweis, false)
})
test('nächste ESt-Vorauszahlung: nächster 10.03./10.06./10.09./10.12., heute zählt mit', () => {
const next = (today, cents = 50000) => computeTax({ today, transactions: [], invoices: [], settings: settings({ estPrepaymentCents: cents }) }).naechsteVorauszahlung
assert.deepEqual(next('2026-10-01'), { date: '2026-12-10', amountCents: 50000 })
assert.deepEqual(next('2026-01-15').date, '2026-03-10')
assert.deepEqual(next('2026-03-10').date, '2026-03-10')
assert.deepEqual(next('2026-03-11').date, '2026-06-10')
assert.deepEqual(next('2026-06-11').date, '2026-09-10')
assert.deepEqual(next('2026-09-11').date, '2026-12-10')
assert.deepEqual(next('2026-12-10').date, '2026-12-10')
assert.deepEqual(next('2026-12-11'), { date: '2027-03-10', amountCents: 50000 })
})
test('keine ESt-Vorauszahlung eingestellt: naechsteVorauszahlung ist null', () => {
assert.equal(run({ settings: settings({ estPrepaymentCents: 0 }) }).naechsteVorauszahlung, null)
assert.equal(run({ settings: settings({ estPrepaymentCents: null }) }).naechsteVorauszahlung, null)
assert.equal(run({ settings: null }).naechsteVorauszahlung, null)
})
test('Jahr folgt today (Jahreswechsel) und Berichtsjahr wird mitgegeben', () => {
const rows = [tx({ amountCents: 1000, category: 'rechnung-extern', bookingDate: '2026-12-31' }), tx({ amountCents: 2000, category: 'rechnung-extern', bookingDate: '2027-01-02' })]
const r = computeTax({ today: '2027-01-05', transactions: rows, invoices: [], settings: settings() })
assert.equal(r.jahr, 2027)
assert.equal(r.einnahmenCents, 2000)
})
test('leere und fehlende Listen, kaputte Datensätze: keine Ausnahme', () => {
const empty = computeTax({ today: TODAY, settings: settings() })
assert.equal(empty.einnahmenCents, 0)
assert.equal(empty.gewinnCents, 0)
assert.equal(empty.ruecklageCents, 0)
assert.equal(empty.kleinunternehmer.jahrCents, 0)
const r = run({
transactions: [tx({ amountCents: 1.5 }), tx({ amountCents: 'x' }), tx({ bookingDate: undefined }), tx({ amountCents: 1000, category: 'rechnung-extern' }), null],
invoices: [invoice({ paidCents: 1.5 }), invoice({ paidDate: 'gestern' }), invoice({ paidCents: -500 }), null],
})
assert.equal(r.einnahmenCents, 1000)
assert.equal(r.kleinunternehmer.jahrCents, 1000)
})
test('Eingaben werden nicht verändert', () => {
const transactions = planTransactions()
const invoices = [invoice()]
const copy = JSON.stringify({ transactions, invoices })
run({ transactions, invoices })
assert.equal(JSON.stringify({ transactions, invoices }), copy)
})
+498
View File
@@ -0,0 +1,498 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { buildTodos } from '../server/services/finance/todos.js'
import { addDays } from '../server/services/finance/dates.js'
import { euroDe } from '../server/services/finance/format.js'
const TODAY = '2026-10-01'
const NOW = new Date('2026-10-01T10:00:00.000Z')
const HOUR = 3_600_000
const ago = (hours) => new Date(NOW.getTime() - hours * HOUR).toISOString()
const connection = (extra = {}) => ({ sessionId: 'sess-1', status: 'aktiv', validUntil: '2027-03-29T00:00:00.000Z', lastSyncAt: ago(1), lastSyncOk: true, lastError: '', ...extra })
const invoice = (extra = {}) => ({
id: 'in_1', number: 'WK-2026-0001', status: 'ueberfaellig', customerName: 'Muster GmbH', createdDate: '2026-08-01', dueDate: addDays(TODAY, -5),
remainingCents: 50000, paidCents: 0, paidDate: null, overdueDays: 5, source: 'projekt', hostedUrl: 'https://pay.example/in_1', ...extra,
})
const inflow = (extra = {}) => ({
$id: 'tx1', status: 'BOOK', bookingDate: '2026-09-28', amountCents: 16879, counterpartyName: 'EWV Software GmbH', remittance: 'Rechnung 0005', category: '', ...extra,
})
const sub = (extra = {}) => ({ customerId: 'c1', status: 'past_due', priceAmount: 2000, productKey: 'hosting', ...extra })
const tax = (anteil = 0.1, extra = {}) => ({ jahr: 2026, kleinunternehmer: { jahrCents: Math.round(anteil * 2500000), grenzeFolgejahrCents: 2500000, grenzeHartCents: 10000000, anteilFolgejahr: anteil, ...extra } })
const recurring = (extra = {}) => ({ key: 'hetzner', name: 'Hetzner', amountCents: 449, interval: 'monat', lastDate: '2026-09-27', count: 4, ...extra })
const todos = (extra = {}) => buildTodos({ today: TODAY, now: NOW, connection: connection(), invoices: [], transactions: [], subscriptions: [], recurring: [], tax: tax(), ...extra })
const only = (list, art) => list.filter((t) => t.art === art)
test('alles ruhig: keine Aufgaben', () => {
assert.deepEqual(todos(), [])
})
test('Standardwerte: fehlende Listen und tax dürfen fehlen', () => {
assert.deepEqual(buildTodos({ today: TODAY, now: NOW, connection: connection() }), [])
assert.deepEqual(buildTodos({ today: TODAY, now: NOW, connection: connection(), invoices: null, transactions: null, subscriptions: null, recurring: null, tax: null }), [])
})
// --- bank-zugang ---------------------------------------------------------------------------------------------
test('bank-zugang: keine Verbindung -> „Bankkonto verbinden“ hoch', () => {
for (const noConnection of [null, undefined]) {
const list = todos({ connection: noConnection })
assert.equal(list.length, 1)
assert.deepEqual(list[0], {
art: 'bank-zugang',
dringlichkeit: 'hoch',
titel: 'Bankkonto verbinden',
text: list[0].text,
datum: TODAY,
aktion: { typ: 'bank-verbinden' },
})
assert.match(list[0].text, /Konto/)
}
})
test('bank-zugang: Verbindungsdokument ohne Sitzung (erste Freigabe nie abgeschlossen) zählt als keine Verbindung (M12)', () => {
for (const abandoned of [{ sessionId: undefined }, { sessionId: null }, { sessionId: '' }, { sessionId: ' ' }]) {
// sonst kaeme "Bankabruf veraltet", weil es nie einen Abruf gab: es bleibt bei der einen Aufgabe
const list = todos({ connection: { authState: 'abc', authStartedAt: '2026-10-01T09:00:00.000Z', ...abandoned } })
assert.equal(list.length, 1, JSON.stringify(list))
assert.equal(list[0].titel, 'Bankkonto verbinden')
assert.equal(list[0].dringlichkeit, 'hoch')
assert.deepEqual(list[0].aktion, { typ: 'bank-verbinden' })
}
// mit Sitzung ist es ein Zugang (hier ruhig)
assert.deepEqual(todos({ connection: connection({ sessionId: 'sess-x' }) }), [])
})
test('bank-zugang: Zugang gültig bis 20.10. (19 Tage) -> mittel, bis 10.10. (9 Tage) -> hoch', () => {
const mid = only(todos({ connection: connection({ validUntil: '2026-10-20T00:00:00.000Z' }) }), 'bank-zugang')
assert.equal(mid.length, 1)
assert.equal(mid[0].dringlichkeit, 'mittel')
assert.equal(mid[0].titel, 'Bankzugang läuft ab')
assert.equal(mid[0].datum, '2026-10-20')
assert.deepEqual(mid[0].aktion, { typ: 'bank-verbinden' })
assert.match(mid[0].text, /20\.10\.2026/)
assert.match(mid[0].text, /19 Tagen/)
const high = only(todos({ connection: connection({ validUntil: '2026-10-10T00:00:00.000Z' }) }), 'bank-zugang')
assert.equal(high.length, 1)
assert.equal(high[0].dringlichkeit, 'hoch')
})
test('bank-zugang: Grenzen 14/15 und 30/31 Tage, Berliner Kalendertag', () => {
const level = (validUntil) => only(todos({ connection: connection({ validUntil }) }), 'bank-zugang')[0]?.dringlichkeit ?? null
assert.equal(level('2026-10-15T12:00:00.000Z'), 'hoch') // 14 Tage
assert.equal(level('2026-10-16T12:00:00.000Z'), 'mittel') // 15 Tage
assert.equal(level('2026-10-31T12:00:00.000Z'), 'mittel') // 30 Tage
assert.equal(level('2026-11-01T12:00:00.000Z'), null) // 31 Tage
// 23:30 UTC ist in Berlin schon der nächste Tag: 15.10. 23:30 UTC = 16.10. -> 15 Tage
assert.equal(level('2026-10-15T23:30:00.000Z'), 'mittel')
assert.equal(level('2026-10-01T12:00:00.000Z'), 'hoch') // läuft heute ab
})
test('bank-zugang: Status abgelaufen -> „Bankzugang abgelaufen“ hoch, egal was validUntil sagt', () => {
for (const validUntil of ['2027-03-29T00:00:00.000Z', '2026-09-01T00:00:00.000Z', '', undefined]) {
const list = only(todos({ connection: connection({ status: 'abgelaufen', validUntil }) }), 'bank-zugang')
assert.equal(list.length, 1)
assert.equal(list[0].titel, 'Bankzugang abgelaufen')
assert.equal(list[0].dringlichkeit, 'hoch')
assert.deepEqual(list[0].aktion, { typ: 'bank-verbinden' })
}
})
test('bank-zugang: Datum der abgelaufenen Aufgabe ist nie in der Zukunft', () => {
const datum = (validUntil) => only(todos({ connection: connection({ status: 'abgelaufen', validUntil }) }), 'bank-zugang')[0].datum
assert.equal(datum('2027-03-29T00:00:00.000Z'), TODAY)
assert.equal(datum('2026-09-01T10:00:00.000Z'), '2026-09-01')
assert.equal(datum(''), TODAY)
})
test('bank-zugang: Text nennt heute, morgen oder die Tage', () => {
const text = (validUntil) => only(todos({ connection: connection({ validUntil }) }), 'bank-zugang')[0].text
assert.match(text('2026-10-01T12:00:00.000Z'), /\(heute\)/)
assert.match(text('2026-10-02T12:00:00.000Z'), /\(morgen\)/)
assert.match(text('2026-10-04T12:00:00.000Z'), /\(in 3 Tagen\)/)
})
test('bank-zugang: Status aktiv, aber validUntil liegt schon zurück -> abgelaufen hoch', () => {
const list = only(todos({ connection: connection({ validUntil: '2026-09-30T10:00:00.000Z' }) }), 'bank-zugang')
assert.equal(list.length, 1)
assert.equal(list[0].titel, 'Bankzugang abgelaufen')
assert.equal(list[0].dringlichkeit, 'hoch')
assert.match(list[0].text, /30\.09\.2026/)
})
test('bank-zugang: ohne lesbares validUntil keine Aufgabe (aktiv), Status fehler folgt validUntil', () => {
assert.deepEqual(only(todos({ connection: connection({ validUntil: '' }) }), 'bank-zugang'), [])
assert.deepEqual(only(todos({ connection: connection({ validUntil: 'bald' }) }), 'bank-zugang'), [])
assert.deepEqual(only(todos({ connection: connection({ status: 'fehler' }) }), 'bank-zugang'), [])
assert.equal(only(todos({ connection: connection({ status: 'fehler', validUntil: '2026-10-05T00:00:00.000Z' }) }), 'bank-zugang')[0].dringlichkeit, 'hoch')
})
// --- bank-abruf ----------------------------------------------------------------------------------------------
test('bank-abruf: letzter Abruf vor 25 h -> mittel, Aktion bank-aktualisieren', () => {
const list = only(todos({ connection: connection({ lastSyncAt: ago(25) }) }), 'bank-abruf')
assert.equal(list.length, 1)
assert.equal(list[0].dringlichkeit, 'mittel')
assert.equal(list[0].titel, 'Bankabruf veraltet')
assert.equal(list[0].datum, '2026-09-30')
assert.deepEqual(list[0].aktion, { typ: 'bank-aktualisieren' })
assert.match(list[0].text, /30\.09\.2026/)
})
test('bank-abruf: 23 h und genau 24 h sind noch frisch', () => {
assert.deepEqual(only(todos({ connection: connection({ lastSyncAt: ago(23) }) }), 'bank-abruf'), [])
assert.deepEqual(only(todos({ connection: connection({ lastSyncAt: ago(24) }) }), 'bank-abruf'), [])
assert.equal(only(todos({ connection: connection({ lastSyncAt: new Date(NOW.getTime() - 24 * HOUR - 1).toISOString() }) }), 'bank-abruf').length, 1)
})
test('bank-abruf: lastSyncOk false oder fehlender Abruf; Fehlertext erscheint', () => {
const failed = only(todos({ connection: connection({ lastSyncOk: false, lastError: 'Die Bank antwortet nicht.' }) }), 'bank-abruf')
assert.equal(failed.length, 1)
assert.equal(failed[0].titel, 'Bankabruf fehlgeschlagen')
assert.match(failed[0].text, /Die Bank antwortet nicht\./)
assert.equal(failed[0].datum, TODAY) // letzter Abruf vor 1 h = heute
const never = only(todos({ connection: connection({ lastSyncAt: '', lastSyncOk: true }) }), 'bank-abruf')
assert.equal(never.length, 1)
assert.equal(never[0].datum, TODAY)
assert.equal(only(todos({ connection: connection({ lastSyncAt: undefined }) }), 'bank-abruf').length, 1)
assert.equal(only(todos({ connection: connection({ lastSyncAt: 'neulich' }) }), 'bank-abruf').length, 1)
})
test('bank-abruf: nur bei Status aktiv und mit Verbindung', () => {
assert.deepEqual(only(todos({ connection: connection({ status: 'abgelaufen', lastSyncAt: ago(100), lastSyncOk: false }) }), 'bank-abruf'), [])
assert.deepEqual(only(todos({ connection: connection({ status: 'fehler', lastSyncAt: ago(100) }) }), 'bank-abruf'), [])
assert.deepEqual(only(todos({ connection: null }), 'bank-abruf'), [])
})
// --- ueberfaellig --------------------------------------------------------------------------------------------
test('ueberfaellig: 31 Tage Verzug -> hoch, 5 Tage -> mittel', () => {
const late = only(todos({ invoices: [invoice({ dueDate: addDays(TODAY, -31), overdueDays: 31 })] }), 'ueberfaellig')
assert.equal(late.length, 1)
assert.equal(late[0].dringlichkeit, 'hoch')
const soon = only(todos({ invoices: [invoice({ dueDate: addDays(TODAY, -5) })] }), 'ueberfaellig')
assert.equal(soon.length, 1)
assert.equal(soon[0].dringlichkeit, 'mittel')
})
test('ueberfaellig: Grenze bei 30 Tagen, Inhalt und Aktion', () => {
const level = (days) => only(todos({ invoices: [invoice({ dueDate: addDays(TODAY, -days) })] }), 'ueberfaellig')[0].dringlichkeit
assert.equal(level(29), 'mittel')
assert.equal(level(30), 'hoch')
assert.equal(level(1), 'mittel')
const [t] = only(todos({ invoices: [invoice()] }), 'ueberfaellig')
assert.equal(t.titel, 'Rechnung WK-2026-0001 überfällig')
assert.equal(t.datum, '2026-09-26')
assert.deepEqual(t.aktion, { typ: 'rechnung-oeffnen', invoiceId: 'in_1', hostedUrl: 'https://pay.example/in_1' })
assert.ok(t.text.includes('Muster GmbH'))
assert.ok(t.text.includes(euroDe(50000)))
assert.ok(t.text.includes('26.09.2026'))
assert.ok(t.text.includes('5 Tage'))
assert.ok(only(todos({ invoices: [invoice({ dueDate: addDays(TODAY, -1) })] }), 'ueberfaellig')[0].text.includes('1 Tag'))
})
test('ueberfaellig: offene Rechnung mit abgelaufener Fälligkeit zählt, nicht fällige, bezahlte und Entwürfe nicht', () => {
assert.equal(only(todos({ invoices: [invoice({ status: 'offen', dueDate: '2026-09-30' })] }), 'ueberfaellig').length, 1)
assert.deepEqual(only(todos({ invoices: [invoice({ status: 'offen', dueDate: TODAY })] }), 'ueberfaellig'), [])
assert.deepEqual(only(todos({ invoices: [invoice({ status: 'offen', dueDate: '2026-10-14' })] }), 'ueberfaellig'), [])
assert.deepEqual(only(todos({ invoices: [invoice({ status: 'bezahlt', paidCents: 50000, remainingCents: 0 })] }), 'ueberfaellig'), [])
assert.deepEqual(only(todos({ invoices: [invoice({ status: 'entwurf' }), invoice({ id: 'in_2', status: 'storniert' }), invoice({ id: 'in_3', status: 'uneinbringlich' })] }), 'ueberfaellig'), [])
})
test('ueberfaellig: ohne hostedUrl -> hostedUrl null', () => {
const [t] = only(todos({ invoices: [invoice({ hostedUrl: undefined })] }), 'ueberfaellig')
assert.deepEqual(t.aktion, { typ: 'rechnung-oeffnen', invoiceId: 'in_1', hostedUrl: null })
})
// --- zuordnung -----------------------------------------------------------------------------------------------
test('zuordnung: ungeordneter Eingang -> mittel mit transactionId', () => {
const list = only(todos({ transactions: [inflow()] }), 'zuordnung')
assert.equal(list.length, 1)
assert.equal(list[0].dringlichkeit, 'mittel')
assert.equal(list[0].titel, 'Zahlungseingang zuordnen')
assert.equal(list[0].datum, '2026-09-28')
assert.deepEqual(list[0].aktion, { typ: 'zuordnen', transactionId: 'tx1' })
assert.ok(list[0].text.includes(euroDe(16879)))
assert.ok(list[0].text.includes('EWV Software GmbH'))
assert.ok(list[0].text.includes('28.09.2026'))
assert.ok(list[0].text.includes('Rechnung 0005'))
assert.ok(!list[0].text.includes('Vorschlag'))
})
test('zuordnung: nur gebuchte Eingänge ohne Kategorie', () => {
const none = [
inflow({ status: 'PDNG' }),
inflow({ amountCents: -500 }),
inflow({ amountCents: 0 }),
inflow({ category: 'rechnung' }),
inflow({ category: 'privat' }),
inflow({ amountCents: 1.5 }),
inflow({ bookingDate: 'heute' }),
]
assert.deepEqual(only(todos({ transactions: none }), 'zuordnung'), [])
for (const category of [undefined, null, '']) assert.equal(only(todos({ transactions: [inflow({ category })] }), 'zuordnung').length, 1)
})
test('zuordnung: Vorschlag hoch hängt „Vorschlag: Rechnung <Nr.>“ bzw. „Vorschlag: Stripe-Auszahlung“ an', () => {
const invoiceHint = { kind: 'invoice', id: 'in_9', number: 'WK-2026-0009', confidence: 'hoch', reason: 'Betrag und Nummer', amountDiffers: false }
const payoutHint = { kind: 'payout', id: 'po_1', number: '', confidence: 'hoch', reason: 'Betrag und Datum', amountDiffers: false }
const [a] = only(todos({ transactions: [inflow({ vorschlaege: [invoiceHint] })] }), 'zuordnung')
assert.ok(a.text.endsWith('Vorschlag: Rechnung WK-2026-0009'))
const [b] = only(todos({ transactions: [inflow({ vorschlaege: [payoutHint] })] }), 'zuordnung')
assert.ok(b.text.endsWith('Vorschlag: Stripe-Auszahlung'))
assert.deepEqual(b.aktion, { typ: 'zuordnen', transactionId: 'tx1' })
})
test('zuordnung: nur ein Vorschlag mit Sicherheit hoch zählt, der erste davon', () => {
const hint = (extra) => ({ kind: 'invoice', id: 'in_x', number: 'WK-2026-0001', confidence: 'mittel', reason: '', amountDiffers: false, ...extra })
const mid = only(todos({ transactions: [inflow({ vorschlaege: [hint({}), hint({ confidence: 'niedrig' })] })] }), 'zuordnung')[0]
assert.ok(!mid.text.includes('Vorschlag'))
const mixed = only(todos({ transactions: [inflow({ vorschlaege: [hint({}), hint({ confidence: 'hoch', number: 'WK-2026-0002' }), hint({ confidence: 'hoch', number: 'WK-2026-0003' })] })] }), 'zuordnung')[0]
assert.ok(mixed.text.endsWith('Vorschlag: Rechnung WK-2026-0002'))
assert.ok(!only(todos({ transactions: [inflow({ vorschlaege: [] })] }), 'zuordnung')[0].text.includes('Vorschlag'))
})
// --- abo-zahlung ---------------------------------------------------------------------------------------------
test('abo-zahlung: past_due und unpaid -> hoch, Aktion kunde-oeffnen', () => {
const list = only(todos({ subscriptions: [sub(), sub({ customerId: 'c2', status: 'unpaid', priceAmount: 5000 })] }), 'abo-zahlung')
assert.equal(list.length, 2)
for (const t of list) assert.equal(t.dringlichkeit, 'hoch')
assert.deepEqual(list.map((t) => t.aktion), [
{ typ: 'kunde-oeffnen', customerId: 'c1' },
{ typ: 'kunde-oeffnen', customerId: 'c2' },
])
assert.equal(list[0].titel, 'Abo-Zahlung fehlgeschlagen')
assert.equal(list[1].titel, 'Abo unbezahlt')
assert.equal(list[0].datum, TODAY)
assert.ok(list[0].text.includes('hosting'))
assert.ok(list[0].text.includes(euroDe(2000)))
})
test('abo-zahlung: aktive, gekündigte und unbekannte Abos erzeugen nichts; Kundenname und planKey werden genutzt', () => {
assert.deepEqual(only(todos({ subscriptions: [sub({ status: 'active' }), sub({ status: 'canceled' }), sub({ status: 'trialing' })] }), 'abo-zahlung'), [])
const [t] = only(todos({ subscriptions: [sub({ productKey: undefined, planKey: 'wartung', customerName: 'Muster GmbH' })] }), 'abo-zahlung')
assert.ok(t.text.includes('Muster GmbH'))
assert.ok(t.text.includes('wartung'))
const [bare] = only(todos({ subscriptions: [sub({ productKey: undefined, priceAmount: undefined })] }), 'abo-zahlung')
assert.equal(bare.art, 'abo-zahlung')
})
// --- entwurf -------------------------------------------------------------------------------------------------
test('entwurf: vor 4 Tagen -> info, von gestern -> keine Aufgabe, auch 3 Tage noch nicht', () => {
const draft = (days) => invoice({ id: 'in_d', number: '', status: 'entwurf', createdDate: addDays(TODAY, -days), remainingCents: 24000, hostedUrl: null })
const [t] = only(todos({ invoices: [draft(4)] }), 'entwurf')
assert.equal(t.dringlichkeit, 'info')
assert.equal(t.titel, 'Rechnungsentwurf nicht versendet')
assert.equal(t.datum, '2026-09-27')
assert.deepEqual(t.aktion, { typ: 'rechnung-oeffnen', invoiceId: 'in_d', hostedUrl: null })
assert.ok(t.text.includes('Muster GmbH'))
assert.ok(t.text.includes(euroDe(24000)))
assert.ok(t.text.includes('4 Tagen'))
assert.deepEqual(only(todos({ invoices: [draft(1)] }), 'entwurf'), [])
assert.deepEqual(only(todos({ invoices: [draft(3)] }), 'entwurf'), [])
assert.equal(only(todos({ invoices: [draft(40)] }), 'entwurf').length, 1)
})
test('entwurf: andere Status nie, Entwurf ohne Nummer wird nicht mit leerem Namen angezeigt', () => {
assert.deepEqual(only(todos({ invoices: [invoice({ status: 'offen', createdDate: '2026-01-01', dueDate: '2026-12-01' })] }), 'entwurf'), [])
const [t] = only(todos({ invoices: [invoice({ status: 'entwurf', number: '', createdDate: '2026-09-01' })] }), 'entwurf')
assert.ok(!/ {2}|\(\)|undefined|null/.test(t.text))
})
// --- fixkosten-vorschlag -------------------------------------------------------------------------------------
test('fixkosten-vorschlag: erkannte wiederkehrende Ausgabe -> info mit Vorschlag in der Aktion', () => {
const entry = recurring()
const [t] = only(todos({ recurring: [entry] }), 'fixkosten-vorschlag')
assert.equal(t.dringlichkeit, 'info')
assert.equal(t.datum, '2026-09-27')
assert.deepEqual(t.aktion, { typ: 'fixkosten-uebernehmen', vorschlag: entry })
assert.ok(t.text.includes('Hetzner'))
assert.ok(t.text.includes(euroDe(449)))
assert.ok(t.text.includes('monatlich'))
assert.match(t.text, /27\.09\.2026/)
})
test('fixkosten-vorschlag: nur wenn die letzte Buchung höchstens 60 Tage zurückliegt', () => {
const at = (days) => only(todos({ recurring: [recurring({ lastDate: addDays(TODAY, -days) })] }), 'fixkosten-vorschlag').length
assert.equal(at(60), 1)
assert.equal(at(61), 0)
assert.equal(at(0), 1)
assert.equal(only(todos({ recurring: [recurring({ lastDate: 'Mai' })] }), 'fixkosten-vorschlag').length, 0)
})
test('fixkosten-vorschlag: Rhythmus im Text', () => {
const text = (interval) => only(todos({ recurring: [recurring({ interval })] }), 'fixkosten-vorschlag')[0].text
assert.ok(text('quartal').includes('vierteljährlich'))
assert.ok(text('jahr').includes('jährlich') && !text('jahr').includes('vierteljährlich'))
assert.ok(text('monat').includes('monatlich'))
})
// --- kleinunternehmer ----------------------------------------------------------------------------------------
test('kleinunternehmer: Anteil 0.72 -> mittel, 1.05 -> hoch, Aktion einstellungen', () => {
const [mid] = only(todos({ tax: tax(0.72) }), 'kleinunternehmer')
assert.equal(mid.dringlichkeit, 'mittel')
assert.deepEqual(mid.aktion, { typ: 'einstellungen' })
assert.equal(mid.datum, TODAY)
assert.ok(mid.text.includes('72 %'))
assert.ok(mid.text.includes(euroDe(1800000)))
assert.ok(mid.text.includes(euroDe(2500000)))
const [high] = only(todos({ tax: tax(1.05) }), 'kleinunternehmer')
assert.equal(high.dringlichkeit, 'hoch')
assert.ok(high.text.includes(euroDe(2625000)))
})
test('kleinunternehmer: Schwellen 70 % und 100 %, darunter nichts', () => {
const level = (anteil) => only(todos({ tax: tax(anteil) }), 'kleinunternehmer')[0]?.dringlichkeit ?? null
assert.equal(level(0.69), null)
assert.equal(level(0), null)
assert.equal(level(0.7), 'mittel')
assert.equal(level(1750000 / 2500000), 'mittel')
assert.equal(level(0.99), 'mittel')
assert.equal(level(1), 'hoch')
assert.equal(level(2.5), 'hoch')
})
test('kleinunternehmer: über 100.000 Euro nennt die harte Grenze', () => {
const [t] = only(todos({ tax: tax(4.2, { jahrCents: 10500000 }) }), 'kleinunternehmer')
assert.equal(t.dringlichkeit, 'hoch')
assert.ok(t.text.includes(euroDe(10000000)))
assert.ok(t.text.includes(euroDe(10500000)))
assert.equal(
t.text,
`Die Einnahmen 2026 betragen ${euroDe(10500000)} und liegen über der Grenze von ${euroDe(10000000)}. ` +
'Ab dem Umsatz, mit dem die Grenze überschritten wurde, gilt die Kleinunternehmerregelung (§ 19 UStG) nicht mehr – auch im laufenden Jahr. ' +
'Bitte umgehend mit der Steuerberatung klären.'
)
})
test('kleinunternehmer: Vorjahresumsatz über 25.000 € -> hoch mit festem Titel und Text (M14)', () => {
const list = only(todos({ settings: { previousYearRevenueCents: 2_600_050 } }), 'kleinunternehmer')
assert.equal(list.length, 1)
assert.deepEqual(list[0], {
art: 'kleinunternehmer',
dringlichkeit: 'hoch',
titel: 'Vorjahresumsatz über 25.000 €',
text: `Im Vorjahr lagen die Einnahmen bei ${euroDe(2_600_050)}. Damit gilt die Kleinunternehmerregelung (§ 19 UStG) in diesem Jahr nicht. Bitte Rechnungen und Rechnungsfuß mit der Steuerberatung klären.`,
datum: TODAY,
aktion: { typ: 'einstellungen' },
})
assert.match(list[0].text, /26\.000,50/)
})
test('kleinunternehmer: Vorjahresumsatz genau 25.000 €, darunter, fehlend oder unlesbar -> keine Vorjahres-Aufgabe (M14)', () => {
for (const previousYearRevenueCents of [2_500_000, 100, 0, null, undefined, '3000000', 30_000.5]) {
assert.deepEqual(only(todos({ settings: { previousYearRevenueCents } }), 'kleinunternehmer'), [], String(previousYearRevenueCents))
}
assert.deepEqual(only(todos({ settings: null }), 'kleinunternehmer'), [])
assert.deepEqual(only(todos(), 'kleinunternehmer'), [])
// 25.000,01 € ist darüber
assert.equal(only(todos({ settings: { previousYearRevenueCents: 2_500_001 } }), 'kleinunternehmer').length, 1)
})
test('kleinunternehmer: Vorjahres-Aufgabe und Aufgabe zum laufenden Jahr stehen nebeneinander, hoch vor mittel (M14)', () => {
const list = only(todos({ tax: tax(0.72), settings: { previousYearRevenueCents: 3_000_000 } }), 'kleinunternehmer')
assert.deepEqual(list.map((t) => [t.dringlichkeit, t.titel]), [
['hoch', 'Vorjahresumsatz über 25.000 €'],
['mittel', 'Kleinunternehmer-Grenze rückt näher'],
])
})
test('kleinunternehmer: ohne tax oder mit unlesbarem Anteil keine Aufgabe', () => {
assert.deepEqual(only(todos({ tax: null }), 'kleinunternehmer'), [])
assert.deepEqual(only(todos({ tax: {} }), 'kleinunternehmer'), [])
assert.deepEqual(only(todos({ tax: { kleinunternehmer: { anteilFolgejahr: 'viel' } } }), 'kleinunternehmer'), [])
})
test('kleinunternehmer: ohne jahrCents wird der Betrag aus dem Anteil abgeleitet', () => {
const [t] = only(todos({ tax: { kleinunternehmer: { anteilFolgejahr: 0.72 } } }), 'kleinunternehmer')
assert.ok(t.text.includes(euroDe(1800000)))
})
// --- Sortierung und Sonstiges --------------------------------------------------------------------------------
test('Sortierung: hoch vor mittel vor info, darin nach Datum aufsteigend', () => {
const list = todos({
connection: connection({ validUntil: '2026-10-20T00:00:00.000Z', lastSyncAt: ago(30) }), // mittel (20.10.), mittel (30.09.)
invoices: [
invoice({ id: 'late', dueDate: '2026-08-01' }), // hoch 01.08.
invoice({ id: 'later', dueDate: '2026-08-15' }), // hoch 15.08.
invoice({ id: 'draft', status: 'entwurf', createdDate: '2026-09-20' }), // info 20.09.
invoice({ id: 'mid', dueDate: '2026-09-29' }), // mittel 29.09.
],
transactions: [inflow({ bookingDate: '2026-09-10' })], // mittel 10.09.
subscriptions: [sub()], // hoch heute
recurring: [recurring({ lastDate: '2026-09-15' })], // info 15.09.
tax: tax(0.8), // mittel heute
})
assert.deepEqual(
list.map((t) => [t.dringlichkeit, t.datum, t.art]),
[
['hoch', '2026-08-01', 'ueberfaellig'],
['hoch', '2026-08-15', 'ueberfaellig'],
['hoch', '2026-10-01', 'abo-zahlung'],
['mittel', '2026-09-10', 'zuordnung'],
['mittel', '2026-09-29', 'ueberfaellig'],
['mittel', '2026-09-30', 'bank-abruf'],
['mittel', '2026-10-01', 'kleinunternehmer'],
['mittel', '2026-10-20', 'bank-zugang'],
['info', '2026-09-15', 'fixkosten-vorschlag'],
['info', '2026-09-20', 'entwurf'],
]
)
})
test('Sortierung: bei gleichem Datum bleibt die Eingabereihenfolge', () => {
const list = todos({ invoices: [invoice({ id: 'b', number: 'WK-B', dueDate: '2026-09-20' }), invoice({ id: 'a', number: 'WK-A', dueDate: '2026-09-20' })] })
assert.deepEqual(list.map((t) => t.aktion.invoiceId), ['b', 'a'])
})
test('jede Aufgabe hat Art, Dringlichkeit, Titel, Text, Datum und Aktion', () => {
const list = todos({
connection: connection({ status: 'abgelaufen', lastSyncAt: ago(30) }),
invoices: [invoice(), invoice({ id: 'd', status: 'entwurf', createdDate: '2026-09-01' })],
transactions: [inflow()],
subscriptions: [sub()],
recurring: [recurring()],
tax: tax(1.1),
})
assert.equal(list.length, 7)
for (const t of list) {
assert.deepEqual(Object.keys(t).sort(), ['aktion', 'art', 'datum', 'dringlichkeit', 'text', 'titel'])
assert.ok(['hoch', 'mittel', 'info'].includes(t.dringlichkeit))
assert.match(t.datum, /^\d{4}-\d{2}-\d{2}$/)
assert.ok(t.titel && t.text)
assert.ok(!/undefined|null|NaN/.test(`${t.titel} ${t.text}`))
}
})
test('kaputte Datensätze werden übersprungen statt alles zu kippen', () => {
const list = todos({
invoices: [null, invoice({ dueDate: 'bald' }), invoice({ id: 'x', remainingCents: 1.5 }), invoice({ id: 'ok' })],
transactions: [null, inflow({ $id: 'bad', amountCents: 'x' }), inflow({ $id: 'ok' })],
subscriptions: [null],
recurring: [null, recurring({ lastDate: undefined })],
})
assert.deepEqual(list.map((t) => t.art).sort(), ['ueberfaellig', 'zuordnung'])
})
test('Eingaben werden nicht verändert', () => {
const input = {
today: TODAY,
now: NOW,
connection: connection({ lastSyncAt: ago(30) }),
invoices: [invoice()],
transactions: [inflow({ vorschlaege: [{ kind: 'invoice', id: 'in_1', number: 'WK-1', confidence: 'hoch', reason: '', amountDiffers: false }] })],
subscriptions: [sub()],
recurring: [recurring()],
tax: tax(0.9),
}
const copy = JSON.stringify(input)
buildTodos(input)
assert.equal(JSON.stringify(input), copy)
})
+4 -3
View File
@@ -6,7 +6,7 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
const DIR = new URL('../server/mail-templates/', import.meta.url)
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1);
// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse
// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; T18 Bankzugang erneuern (Finanzen)
const EXPECTED = [
...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`),
'K12',
@@ -24,6 +24,7 @@ const EXPECTED = [
'T15',
'T16',
'T17',
'T18',
]
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
@@ -39,8 +40,8 @@ for (const id of EXPECTED) {
const source = readFileSync(new URL(tpl.file, DIR), 'utf8')
const title = source.match(/<title>([\s\S]*?)<\/title>/)[1]
assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich')
// F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen) oder [Zeit] (Push-Zeitmail)
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz] oder [Zeit] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)')
// F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen), [Zeit] (Push-Zeitmail) oder [Finanzen] (Bankzugang)
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit] oder [Finanzen] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)')
const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]))
assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter')
+197
View File
@@ -0,0 +1,197 @@
import crypto from 'node:crypto'
import http from 'node:http'
/**
* Enable-Banking-Nachbau fuer Tests (Finom ueber PSD2). Der Client spricht ihn ueber
* config.enableBanking.apiBase (ENABLE_BANKING_API_BASE auf 127.0.0.1) an. Die Antwortformen entsprechen
* einem echten Aufruf vom 01.10.2026:
* POST /auth -> { url, authorization_id, psu_id_hash }
* POST /sessions { code } -> { session_id, accounts: [Konto-Objekte], access, aspsp, psu_type }
* GET /sessions/:id -> { status, accounts: [uid], access: { valid_until }, ... }
* GET /accounts/:uid/balances -> { balances: [...] }
* GET /accounts/:uid/transactions -> { transactions: [...], continuation_key: String|null }
*
* Jeder Aufruf muss ein gueltiges JWT tragen (RS256 gegen publicKeyPem, Claims iss/aud/iat/exp, optional
* kid = appId), sonst 401. Aufrufe landen in calls als { method, path, query, headers, body } (Kopfzeilen klein
* geschrieben, Body als geparstes JSON oder null).
*
* Vorbelegt: Konto u1 (accounts), Sitzung s1 mit u1 (sessions) und der Code 'ok' (codes -> 's1'). Umsaetze je
* Konto in transactions (uid -> Objekte), Salden in balances (uid -> Objekte). Die Umsaetze werden seitenweise
* ausgeliefert (pageSize); continuation_key ist der Index der naechsten Seite als Zeichenkette. Nach date_from
* wird nicht gefiltert, der Wert steht nur in calls.
*/
export async function startFakeEnableBanking({ publicKeyPem, appId = null }) {
const calls = []
const accounts = new Map([
['u1', { uid: 'u1', identification_hash: 'h1', account_id: { iban: 'DE00000000000000009313' }, name: 'Webklar', currency: 'EUR' }],
])
const sessions = new Map([
[
's1',
{
session_id: 's1',
status: 'AUTHORIZED',
accounts: ['u1'],
access: { valid_until: '2027-03-29T00:00:00.000Z', balances: true, transactions: true },
aspsp: { name: 'Finom', country: 'DE' },
psu_type: 'business',
},
],
])
const codes = new Map([['ok', 's1']])
const transactions = new Map()
const balances = new Map()
let pageSize = 100
let authSeq = 0
// Eingeplanter Fehler (failNext): der naechste passende Aufruf scheitert einmalig
let pendingFailure = null
// Angehaltener Aufruf (holdNext): der naechste passende Aufruf wartet, bis release() gerufen wird
let pendingHold = null
const matchesPath = (path, pathname) => (path instanceof RegExp ? path.test(pathname) : path === pathname)
// JWT wie Enable Banking: RS256, typ JWT, Claims iss/aud/iat/exp (Laufzeit 1 Stunde)
const jwtError = (header) => {
const m = String(header || '').match(/^Bearer (\S+)$/)
if (!m) return 'Authorization-Kopfzeile fehlt'
const parts = m[1].split('.')
if (parts.length !== 3) return 'kein JWT'
try {
const head = JSON.parse(Buffer.from(parts[0], 'base64url'))
const claims = JSON.parse(Buffer.from(parts[1], 'base64url'))
if (head.typ !== 'JWT' || head.alg !== 'RS256') return 'typ/alg falsch'
if (!head.kid || (appId && head.kid !== appId)) return 'kid falsch'
if (claims.iss !== 'enablebanking.com' || claims.aud !== 'api.enablebanking.com') return 'iss/aud falsch'
const now = Math.floor(Date.now() / 1000)
if (!Number.isInteger(claims.iat) || claims.iat > now + 60) return 'iat falsch'
if (claims.exp !== claims.iat + 3600 || claims.exp <= now) return 'exp falsch'
const valid = crypto.verify('sha256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKeyPem, Buffer.from(parts[2], 'base64url'))
return valid ? null : 'Signatur falsch'
} catch {
return 'JWT nicht lesbar'
}
}
const readBody = (req) =>
new Promise((resolve) => {
let raw = ''
req.on('data', (chunk) => (raw += chunk))
req.on('end', () => {
try {
resolve(raw ? JSON.parse(raw) : null)
} catch {
resolve(null)
}
})
})
const notFound = (what) => [404, { code: 404, error: 'NOT_FOUND', message: `${what} nicht gefunden` }]
const invalid = (message) => [422, { code: 422, error: 'WRONG_REQUEST_PARAMETERS', message }]
const route = (method, pathname, query, body, baseUrl) => {
let m
if (method === 'POST' && pathname === '/auth') {
if (!body?.access?.valid_until || !body?.aspsp?.name || !body?.aspsp?.country || !body?.state || !body?.redirect_url || !body?.psu_type) {
return invalid('access.valid_until, aspsp, state, redirect_url und psu_type sind Pflicht')
}
authSeq += 1
return [200, { url: `${baseUrl}/fake-bank/auth?state=${encodeURIComponent(body.state)}`, authorization_id: `auth-${authSeq}`, psu_id_hash: 'psu-hash-1' }]
}
if (method === 'POST' && pathname === '/sessions') {
const session = sessions.get(codes.get(body?.code))
if (!session) return invalid('Code ungueltig')
return [200, { ...session, accounts: session.accounts.map((uid) => accounts.get(uid)).filter(Boolean) }]
}
if (method === 'GET' && (m = pathname.match(/^\/sessions\/([^/]+)$/))) {
const session = sessions.get(decodeURIComponent(m[1]))
return session ? [200, session] : notFound('Sitzung')
}
if (method === 'GET' && (m = pathname.match(/^\/accounts\/([^/]+)\/balances$/))) {
const uid = decodeURIComponent(m[1])
return accounts.has(uid) ? [200, { balances: balances.get(uid) || [] }] : notFound('Konto')
}
if (method === 'GET' && (m = pathname.match(/^\/accounts\/([^/]+)\/transactions$/))) {
const uid = decodeURIComponent(m[1])
if (!accounts.has(uid)) return notFound('Konto')
const all = transactions.get(uid) || []
const start = query.has('continuation_key') ? Number(query.get('continuation_key')) : 0
if (!Number.isInteger(start) || start < 0) return invalid('continuation_key ungueltig')
const end = start + pageSize
return [200, { transactions: all.slice(start, end), continuation_key: end < all.length ? String(end) : null }]
}
return notFound('Route')
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://127.0.0.1')
const body = await readBody(req)
calls.push({ method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams.entries()), headers: { ...req.headers }, body })
const authError = jwtError(req.headers.authorization)
if (!authError && pendingHold && (!pendingHold.path || matchesPath(pendingHold.path, url.pathname))) {
const hold = pendingHold
pendingHold = null
hold.arrive()
await hold.gate
}
let result
if (authError) {
result = [401, { code: 401, error: 'UNAUTHORIZED', message: `JWT abgelehnt: ${authError}` }]
} else if (pendingFailure && (!pendingFailure.path || matchesPath(pendingFailure.path, url.pathname))) {
const failure = pendingFailure
pendingFailure = null
if (failure.destroy) {
req.socket.destroy()
return
}
result = [failure.status, failure.body]
} else {
result = route(req.method, url.pathname, url.searchParams, body, `http://127.0.0.1:${server.address().port}`)
}
res.writeHead(result[0], { 'Content-Type': 'application/json' })
res.end(JSON.stringify(result[1]))
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
return {
url: `http://127.0.0.1:${server.address().port}`,
calls,
sessions,
accounts,
codes,
transactions,
balances,
/** Umsaetze je Seite (Standard 100). */
get pageSize() {
return pageSize
},
set pageSize(value) {
pageSize = value
},
/**
* Der naechste Aufruf mit gueltigem JWT (path als String oder RegExp, ohne path jeder) scheitert einmalig mit
* status und body; mit destroy bricht stattdessen die Verbindung ab (Netzwerkfehler). Der Aufruf steht trotzdem
* in calls.
*/
failNext: ({ path = null, status = 500, body = { error: 'FAKE_ERROR' }, destroy = false }) => {
pendingFailure = { path, status, body, destroy }
},
/**
* Der naechste Aufruf mit gueltigem JWT (path wie bei failNext) bleibt stehen, bis release() gerufen wird
* (ein langsamer Bankabruf); erst dann laeuft er weiter (auch ein mit failNext eingeplanter Fehler greift erst
* dann). reached erfuellt sich, sobald der Aufruf angekommen ist. Liefert { reached, release }.
*/
holdNext: ({ path = null } = {}) => {
let release
let arrive
const gate = new Promise((resolve) => (release = resolve))
const reached = new Promise((resolve) => (arrive = resolve))
pendingHold = { path, gate, arrive }
return { reached, release }
},
close: () => {
server.closeAllConnections?.()
return new Promise((resolve) => server.close(resolve))
},
}
}
+263 -3
View File
@@ -14,6 +14,22 @@ import http from 'node:http'
* und Werte höchstens 500 Zeichen. Rechnungen: GET /v1/invoices/:id bettet wie Stripe höchstens 10 Zeilen ein,
* GET /v1/invoices/:id/lines blättert (limit, starting_after);
* POST /v1/invoices/create_preview liefert st.invoicePreview (Objekt, oder Funktion des Aufruf-Bodys).
* Listen (Finanzen): GET /v1/invoices (expand data.customer ersetzt customer durch das Objekt aus st.customers,
* sofern vorhanden) und GET /v1/payouts (st.payouts) blaettern wie Stripe mit limit (hoechstens 100) und
* starting_after samt has_more; die Reihenfolge ist die Einfuegereihenfolge der Map.
*
* Rechnungen schreiben (Finanzen, Projektrechnungen): POST /v1/invoices (Entwurf; send_invoice braucht
* days_until_due), POST /v1/invoiceitems (mit invoice=... als Zeile mit quantity und unit_amount_decimal,
* Summe = Menge mal Einzelpreis; nur an Entwuerfe), POST /v1/invoices/:id (Metadaten zusammenfuehren, leerer
* Wert loescht), .../finalize (nur draft -> open, vergibt die Nummer WK-0001, WK-0002, ... und die Faelligkeit),
* .../send (nur open), .../void (nur open -> void), .../pay (nur open -> paid, auch paid_out_of_band; wie die echte API
* liefert die Rechnung danach kein Feld paid_out_of_band, nur der Aufruf in calls zeigt es) und
* DELETE /v1/invoices/:id (nur draft); jeder andere Zustand ist wie bei Stripe ein 400. Der Kunde einer neuen
* Rechnung muss nicht in st.customers stehen (dann bleiben Name und E-Mail leer). Traegt ein Aufruf die Kopfzeile
* Idempotency-Key, steht sie als idempotencyKey in calls; delayNext verzoegert eine Antwort (Zeitgrenzen testen).
* Kunden: GET /v1/customers/search (Metadaten), POST /v1/customers (anlegen) und POST /v1/customers/:id (aendern:
* email, name, address, metadata; unbekannte ID -> 404). Wie Stripe fuehrt ein Update die Anschrift feldweise zusammen
* (nur gesendete Felder aendern sich) und ein leerer Text loescht das Feld (gespeichert als null).
*/
// Parameter, die Stripe bei ausstehenden Aktualisierungen annimmt (docs.stripe.com/billing/subscriptions/
@@ -32,15 +48,22 @@ export async function startFakeStripe() {
const coupons = new Map()
const billingPortalConfigurations = new Map()
const customers = new Map()
const payouts = new Map()
// Rechnungsposten ohne Rechnung (pending invoice items), je ID
const invoiceItems = new Map()
const checkoutSessions = new Map()
// Abo-Aenderungen, die Stripe bis zur Zahlung zurueckhaelt (pending_update), je Abo-ID
const pendingUpdates = new Map()
const calls = []
let seq = 0
// Fortlaufende Rechnungsnummer, die finalize vergibt (WK-0001, ...)
let invoiceNumberSeq = 0
let nextSubscriptionUpdate = null
let invoicePreview = null
// Eingeplanter Fehler (failNext, F41d): der naechste passende Aufruf scheitert einmalig
let pendingFailure = null
// Eingeplante Verzoegerung (delayNext): die Antwort des naechsten passenden Aufrufs kommt erst nach ms
let pendingDelay = null
const nextId = (prefix) => `${prefix}_test_${(seq += 1)}`
const matchesPath = (path, pathname) => (path instanceof RegExp ? path.test(pathname) : path === pathname)
@@ -57,14 +80,53 @@ export async function startFakeStripe() {
return [status, { error: { type: status >= 500 ? 'api_error' : 'invalid_request_error', message: message || `Nachbau: eingeplanter Fehler ${status}` } }]
}
const plannedDelay = (method, pathname) => {
if (!pendingDelay) return 0
if (pendingDelay.method && pendingDelay.method !== method) return 0
if (pendingDelay.path && !matchesPath(pendingDelay.path, pathname)) return 0
if (pendingDelay.after > 0) {
pendingDelay.after -= 1
return 0
}
const { ms } = pendingDelay
pendingDelay = null
return ms
}
const flat = (params) => Object.fromEntries(params.entries())
const noSuch = (what, id) => [404, { error: { type: 'invalid_request_error', code: 'resource_missing', message: `No such ${what}: '${id}'` } }]
const invalid = (param, message) => [400, { error: { type: 'invalid_request_error', param, message } }]
const nowUnix = () => Math.floor(Date.now() / 1000)
// Rechnungen aendern: nur Entwuerfe nehmen Posten an, finalize/send/void/pay haben feste Ausgangszustaende
const wrongState = (what, invoice, needed) =>
invalid(undefined, `This invoice is ${invoice.status}; ${what} is only possible for ${needed} invoices.`)
// Metadaten wie Stripe: Schluessel hoechstens 40, Werte hoechstens 500 Zeichen, leerer Wert loescht
const mergeMetadata = (existing, incoming) => {
for (const [key, value] of Object.entries(incoming || {})) {
if (key.length > 40) return invalid(`metadata[${key}]`, 'Metadata keys can be at most 40 characters long.')
if (String(value).length > 500) return invalid(`metadata[${key}]`, 'Metadata values can be at most 500 characters long.')
}
const metadata = { ...(existing || {}) }
for (const [key, value] of Object.entries(incoming || {})) {
if (value === '') delete metadata[key]
else metadata[key] = value
}
return metadata
}
const listOf = (data) => ({ object: 'list', data, has_more: false, url: '/v1/list' })
// Rechnungszeilen mit IDs (fuer starting_after), wie Stripe sie liefert; die Quelle behaelt alle Zeilen
const invoiceLinesOf = (invoiceId) =>
(invoices.get(invoiceId)?.lines?.data || []).map((line, i) => ({ id: `li_${invoiceId}_${i + 1}`, object: 'line_item', ...line }))
const EMBEDDED_LINES = 10
// Listenantwort wie Stripe: limit (Standard 10, hoechstens 100) und starting_after (ID des letzten Eintrags der Vorseite)
const pageOf = (items, query, url) => {
const limit = Math.min(Number(query.get('limit')) || 10, 100)
const after = query.get('starting_after')
const afterIndex = after ? items.findIndex((item) => item.id === after) : -1
if (after && afterIndex < 0) return invalid('starting_after', `No such object: '${after}'`)
const start = afterIndex + 1
return [200, { object: 'list', data: items.slice(start, start + limit), has_more: start + limit < items.length, url }]
}
const readBody = (req) =>
new Promise((resolve) => {
@@ -92,6 +154,16 @@ export async function startFakeStripe() {
return root
}
// Anschrift eines Kunden: ein Hash wird feldweise in die bestehende Anschrift geschrieben, '' loescht das Feld (null);
// ein leerer Text statt eines Hashes loescht die ganze Anschrift (wie Stripe)
const mergeAddress = (current, incoming) => {
if (incoming === undefined) return current ?? null
if (typeof incoming !== 'object' || incoming === null) return incoming === '' ? null : (current ?? null)
const merged = { ...(current || {}) }
for (const [field, value] of Object.entries(incoming)) merged[field] = value === '' ? null : value
return merged
}
const route = (method, path, query, body) => {
let m
if (method === 'GET' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) {
@@ -203,14 +275,27 @@ export async function startFakeStripe() {
prices.set(obj.id, obj)
return [200, obj]
}
if (method === 'GET' && path === '/v1/invoices') {
const expandCustomer = [...query.entries()].some(([k, v]) => k.startsWith('expand') && v === 'data.customer')
const all = [...invoices.values()].map((inv) =>
expandCustomer && typeof inv.customer === 'string' && customers.has(inv.customer) ? { ...inv, customer: customers.get(inv.customer) } : inv
)
return pageOf(all, query, '/v1/invoices')
}
if (method === 'GET' && path === '/v1/payouts') {
return pageOf([...payouts.values()], query, '/v1/payouts')
}
if (method === 'GET' && (m = path.match(/^\/v1\/invoices\/([^/]+)$/))) {
if (!invoices.has(m[1])) return noSuch('invoice', m[1])
// Wie Stripe: hoechstens 10 Zeilen eingebettet, weitere ueber GET /v1/invoices/:id/lines (has_more)
const lines = invoiceLinesOf(m[1])
const stored = invoices.get(m[1])
const expandCustomer = [...query.entries()].some(([k, v]) => k.startsWith('expand') && v === 'customer')
return [
200,
{
...invoices.get(m[1]),
...stored,
...(expandCustomer && typeof stored.customer === 'string' && customers.has(stored.customer) ? { customer: customers.get(stored.customer) } : {}),
lines: { object: 'list', data: lines.slice(0, EMBEDDED_LINES), has_more: lines.length > EMBEDDED_LINES, url: `/v1/invoices/${m[1]}/lines` },
},
]
@@ -231,6 +316,151 @@ export async function startFakeStripe() {
const preview = typeof invoicePreview === 'function' ? invoicePreview(data) : invoicePreview
return [200, preview || { object: 'invoice', lines: { object: 'list', data: [], has_more: false }, amount_due: 0, subtotal: 0, total: 0 }]
}
// Rechnungen schreiben (Finanzen): Entwurf anlegen, Posten anhaengen, finalisieren, senden, stornieren, bezahlen, loeschen
if (method === 'POST' && path === '/v1/invoices') {
const data = unflatten(body)
if (!data.customer) return invalid('customer', 'Missing required param: customer.')
const collectionMethod = data.collection_method || 'charge_automatically'
if (collectionMethod === 'send_invoice' && data.days_until_due === undefined && data.due_date === undefined) {
return invalid('days_until_due', 'Either days_until_due or due_date must be set when collection_method=send_invoice.')
}
const metadata = mergeMetadata({}, data.metadata)
if (Array.isArray(metadata)) return metadata
const customer = customers.get(data.customer)
const invoice = {
id: nextId('in'),
object: 'invoice',
number: null,
status: 'draft',
customer: data.customer,
customer_name: customer?.name ?? null,
customer_email: customer?.email ?? null,
collection_method: collectionMethod,
days_until_due: data.days_until_due !== undefined ? Number(data.days_until_due) : null,
due_date: null,
auto_advance: data.auto_advance === 'true',
pending_invoice_items_behavior: data.pending_invoice_items_behavior || 'exclude',
currency: data.currency || 'eur',
description: data.description ?? null,
footer: data.footer ?? null,
metadata,
created: nowUnix(),
total: 0,
subtotal: 0,
amount_due: 0,
amount_paid: 0,
amount_remaining: 0,
status_transitions: { finalized_at: null, paid_at: null, voided_at: null },
hosted_invoice_url: null,
invoice_pdf: null,
lines: { object: 'list', data: [], has_more: false, url: '' },
}
invoices.set(invoice.id, invoice)
return [200, invoice]
}
if (method === 'POST' && path === '/v1/invoiceitems') {
const data = unflatten(body)
if (!data.customer) return invalid('customer', 'Missing required param: customer.')
const quantity = data.quantity !== undefined ? Number(data.quantity) : 1
if (!Number.isInteger(quantity) || quantity < 0) return invalid('quantity', 'Invalid non-negative integer: quantity.')
let unit = null
if (data.unit_amount_decimal !== undefined) {
if (!/^-?\d+(\.\d+)?$/.test(data.unit_amount_decimal)) return invalid('unit_amount_decimal', `Invalid decimal: ${data.unit_amount_decimal}`)
unit = data.unit_amount_decimal
} else if (data.amount !== undefined) {
unit = String(Number(data.amount))
} else {
return invalid('amount', 'Missing required param: amount, unit_amount_decimal or pricing.')
}
const amount = Math.round(Number(unit) * (data.unit_amount_decimal !== undefined ? quantity : 1))
const item = {
id: nextId('ii'),
object: 'line_item',
description: data.description ?? null,
quantity: data.unit_amount_decimal !== undefined ? quantity : 1,
amount,
currency: data.currency || 'eur',
pricing: { type: 'price_details', unit_amount_decimal: unit },
metadata: data.metadata || {},
}
if (data.invoice) {
const invoice = invoices.get(data.invoice)
if (!invoice) return noSuch('invoice', data.invoice)
if (invoice.status !== 'draft') return invalid('invoice', 'You can only add invoice items to draft invoices.')
const total = invoice.total + amount
invoices.set(invoice.id, {
...invoice,
total,
subtotal: total,
amount_due: total,
amount_remaining: total,
lines: { object: 'list', has_more: false, ...invoice.lines, data: [...(invoice.lines?.data || []), item] },
})
item.invoice = invoice.id
} else {
item.invoice = null
}
invoiceItems.set(item.id, item)
return [200, { ...item, object: 'invoiceitem', customer: data.customer }]
}
if (method === 'POST' && (m = path.match(/^\/v1\/invoices\/([^/]+)\/(finalize|send|void|pay)$/))) {
const invoice = invoices.get(m[1])
if (!invoice) return noSuch('invoice', m[1])
const data = unflatten(body)
const at = nowUnix()
let updated
if (m[2] === 'finalize') {
if (invoice.status !== 'draft') return wrongState('finalizing', invoice, 'draft')
invoiceNumberSeq += 1
updated = {
...invoice,
status: 'open',
number: `WK-${String(invoiceNumberSeq).padStart(4, '0')}`,
auto_advance: data.auto_advance !== undefined ? data.auto_advance === 'true' : invoice.auto_advance,
due_date: invoice.collection_method === 'send_invoice' && invoice.days_until_due !== null ? at + invoice.days_until_due * 86_400 : null,
hosted_invoice_url: `https://invoice.stripe.test/i/${invoice.id}`,
invoice_pdf: `https://invoice.stripe.test/i/${invoice.id}/pdf`,
status_transitions: { ...invoice.status_transitions, finalized_at: at },
}
} else if (m[2] === 'send') {
if (invoice.status !== 'open') return wrongState('sending', invoice, 'open')
updated = { ...invoice, sent_at: at }
} else if (m[2] === 'void') {
if (invoice.status !== 'open') return wrongState('voiding', invoice, 'open')
updated = { ...invoice, status: 'void', status_transitions: { ...invoice.status_transitions, voided_at: at } }
} else {
if (invoice.status !== 'open') return wrongState('paying', invoice, 'open')
updated = {
...invoice,
status: 'paid',
paid: true,
amount_paid: invoice.total,
amount_remaining: 0,
status_transitions: { ...invoice.status_transitions, paid_at: at },
}
}
invoices.set(invoice.id, updated)
return [200, updated]
}
if (method === 'POST' && (m = path.match(/^\/v1\/invoices\/([^/]+)$/))) {
const invoice = invoices.get(m[1])
if (!invoice) return noSuch('invoice', m[1])
const data = unflatten(body)
const metadata = data.metadata ? mergeMetadata(invoice.metadata, data.metadata) : invoice.metadata
if (Array.isArray(metadata)) return metadata
const updated = { ...invoice, metadata }
invoices.set(invoice.id, updated)
return [200, updated]
}
if (method === 'DELETE' && (m = path.match(/^\/v1\/invoices\/([^/]+)$/))) {
const invoice = invoices.get(m[1])
if (!invoice) return noSuch('invoice', m[1])
if (invoice.status !== 'draft') {
return invalid(undefined, 'You can only delete draft invoices. Finalized invoices must be voided or marked uncollectible instead.')
}
invoices.delete(m[1])
return [200, { id: m[1], object: 'invoice', deleted: true }]
}
// scripts/stripe-products.mjs --pruefen liest das Produkt zurueck (Beschreibung, Bild)
if (method === 'GET' && (m = path.match(/^\/v1\/products\/([^/]+)$/))) {
return products.has(m[1]) ? [200, products.get(m[1])] : noSuch('product', m[1])
@@ -294,13 +524,30 @@ export async function startFakeStripe() {
object: 'customer',
email: data.email ?? null,
name: data.name ?? null,
address: data.address ?? null,
address: mergeAddress(null, data.address),
invoice_settings: data.invoice_settings || {},
metadata: data.metadata || {},
}
customers.set(obj.id, obj)
return [200, obj]
}
// Kunde aendern (stripe.customers.update): email, name, address (ersetzt) und metadata (zusammengefuehrt) werden uebernommen
if (method === 'POST' && (m = path.match(/^\/v1\/customers\/([^/]+)$/))) {
const customer = customers.get(m[1])
if (!customer) return noSuch('customer', m[1])
const data = unflatten(body)
const metadata = data.metadata ? mergeMetadata(customer.metadata, data.metadata) : customer.metadata
if (Array.isArray(metadata)) return metadata
const updated = {
...customer,
...(data.email !== undefined ? { email: data.email || null } : {}),
...(data.name !== undefined ? { name: data.name || null } : {}),
...(data.address !== undefined ? { address: mergeAddress(customer.address, data.address) } : {}),
metadata,
}
customers.set(customer.id, updated)
return [200, updated]
}
if (method === 'POST' && path === '/v1/checkout/sessions') {
const data = unflatten(body)
const id = nextId('cs')
@@ -371,7 +618,11 @@ export async function startFakeStripe() {
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://127.0.0.1')
const body = await readBody(req)
calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body) })
// idempotencyKey (Kopfzeile Idempotency-Key) steht nur an Aufrufen, die einen mitschicken
const idempotencyKey = req.headers['idempotency-key']
calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body), ...(idempotencyKey ? { idempotencyKey } : {}) })
const delay = plannedDelay(req.method, url.pathname)
if (delay) await new Promise((resolve) => setTimeout(resolve, delay))
const [status, payload] = plannedFailure(req.method, url.pathname) || route(req.method, url.pathname, url.searchParams, body)
res.writeHead(status, { 'Content-Type': 'application/json', 'Request-Id': `req_test_${calls.length}` })
res.end(JSON.stringify(payload))
@@ -388,6 +639,8 @@ export async function startFakeStripe() {
coupons,
billingPortalConfigurations,
customers,
payouts,
invoiceItems,
checkoutSessions,
/**
* Einmalig in die naechste Antwort von POST /v1/subscriptions/:id gemischt (z. B. latest_invoice). Mit
@@ -430,6 +683,13 @@ export async function startFakeStripe() {
failNext: ({ method = null, path = null, status = 500, message = '', after = 0 }) => {
pendingFailure = { method, path, status, message, after }
},
/**
* Die Antwort auf den naechsten passenden Aufruf (method, path als String oder RegExp) kommt erst nach ms
* Millisekunden; after passende Aufrufe gehen vorher ohne Verzoegerung durch. Der Aufruf steht sofort in calls.
*/
delayNext: ({ method = null, path = null, ms, after = 0 }) => {
pendingDelay = { method, path, ms, after }
},
close: () => new Promise((resolve) => server.close(resolve)),
}
}
+15 -8
View File
@@ -1,14 +1,21 @@
import { readFileSync } from 'node:fs'
// Setup-Skripte, die Collections in Appwrite anlegen (Blockformat: 'id', ... \n ],)
const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs']
/**
* Attribut-Schlüssel einer Collection, wie sie scripts/setup-abo-collections.mjs in Appwrite
* anlegt. Tests geben sie dem Appwrite-Nachbau als Schema: Schreibt der Code ein Feld, das das
* Setup-Skript nicht anlegt, scheitert der Test wie Appwrite in Produktion.
* Attribut-Schlüssel einer Collection, wie sie scripts/setup-abo-collections.mjs bzw.
* scripts/setup-finanzen.mjs in Appwrite anlegen. Tests geben sie dem Appwrite-Nachbau als Schema:
* Schreibt der Code ein Feld, das das Setup-Skript nicht anlegt, scheitert der Test wie Appwrite in
* Produktion.
*/
export function schemaKeys(collection) {
const src = readFileSync(new URL('../../scripts/setup-abo-collections.mjs', import.meta.url), 'utf8')
const start = src.indexOf(`'${collection}',`)
if (start < 0) throw new Error(`Collection ${collection} fehlt im Setup-Skript`)
const block = src.slice(start, src.indexOf('\n ],', start))
return [...block.matchAll(/key: '(\w+)'/g)].map((m) => m[1])
for (const script of SETUP_SCRIPTS) {
const src = readFileSync(new URL(`../../scripts/${script}`, import.meta.url), 'utf8')
const start = src.indexOf(`'${collection}',`)
if (start < 0) continue
const block = src.slice(start, src.indexOf('\n ],', start))
return [...block.matchAll(/key: '(\w+)'/g)].map((m) => m[1])
}
throw new Error(`Collection ${collection} fehlt im Setup-Skript`)
}