Compare commits
27
Commits
30412cb0ea
...
c0c1a3d7a9
No files matched your search
@@ -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).
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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. ͏ ͏ ͏</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 → 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 →</a></td></tr></table></td></tr>
|
||||
<tr><td style="padding:0 0 40px 0;font-size:0;line-height:0;"> </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>
|
||||
@@ -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
|
||||
@@ -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) })) }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 || '' },
|
||||
})
|
||||
|
||||
@@ -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, [])
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
@@ -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'])
|
||||
})
|
||||
@@ -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: [] })
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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`)
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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
@@ -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`)
|
||||
}
|
||||
Reference in new issue
Block a user