248 lines
28 KiB
Markdown
248 lines
28 KiB
Markdown
# 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"`
|