Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-01-finanzen-plan-2-stripe-auswertung.md
T

248 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Finanzen Plan 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"`