Parallele Stränge L0–L7 (Portal), W0–W2 (WOMS), Z (Integration und Kontrolle); Vertragsfragen der Planrunde entschieden (Abschnitt „Nachträge nach der Planrunde“ in der Übersicht). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2094 lines
176 KiB
Markdown
2094 lines
176 KiB
Markdown
# Rechnungen – Umsetzungsplan (Strang L2)
|
||
|
||
> **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 baut Projektrechnungen der Arten `frei`, `festbetrag`, `aufwand`, `anzahlung` und `schluss` selbst (Positionen, Minuszeilen, Zusatzfelder, Metadaten), legt sie als A4-Stripe-Rechnungen an, führt je Projektrechnung einen Datensatz (abgerechnete Arbeitsblätter, AB-Status) und bietet dazu einen Vorschau-Endpunkt.
|
||
|
||
**Architecture:** Zwei neue Module unter `server/services/auftraege/`: `positionen.js` (rein: Zeilen je Art, Rundung, Kalenderwochen, Texte) und `rechnungsbau.js` (lädt Kunde, Projekt, AB, Arbeitsblätter und frühere Rechnungen und baut daraus einen `RechnungsEntwurf`). `stripeFinance.js` ruft `baueRechnung` auf, schreibt Entwurf, Posten und Projektdatensatz nach Stripe und Appwrite und bucht Finalisieren, Storno und Löschen nach; `finanzen.js` bekommt die erweiterten Endpunkte. Alles Auftragsbezogene liest L2 ausschließlich über die L0-Module (`auftragStore`, `projektRechnungenStore`, `arbeitsblaetter`, `schalter`), nie über L3-Module.
|
||
|
||
**Tech Stack:** Node 20.19 (Container) und 24 (Host), Express 4, ES-Module, Appwrite-REST über `server/services/appwriteAdmin.js`, Stripe SDK 19 (API 2025-10-29.clover), Tests mit `node --test` und den Nachbauten aus `test/support/` (`startAppwriteMock`, `startFakeStripe`, `useTestEnv`, `startTestApp`).
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md` (Abschnitt 3 „Rechnungen“, Abschnitt 4 nur für die Begriffe „berechnet“ und „abgerechnet“, Abschnitt 6 Zeile `POST …/rechnungen`, Abschnitt 9 „Fehlerverhalten“)
|
||
|
||
**Übersicht/Verträge:** `docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md` (V2 Konstanten, V3 Datenformen `Position`, `Arbeitsblatt`, `Auftrag`, `ProjektRechnung`, V4 „L2 – Rechnungen“, V5 Zeilen L2). Worktree dieses Strangs: `/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2`, Branch `auftraege/l2-rechnungen`.
|
||
|
||
## Dateien
|
||
|
||
| Datei | Aktion | Verantwortung |
|
||
|---|---|---|
|
||
| `server/services/auftraege/positionen.js` | neu | rein: `Position[]` je Art, Stunden- und Zeitraumtexte, Kalenderwoche, Kürzen, Grenzen |
|
||
| `server/services/auftraege/rechnungsbau.js` | neu | `baueRechnung`, `ladeFestenKunden`: lädt Kunde, Projekt, AB, Arbeitsblätter, frühere Rechnungen; liefert `RechnungsEntwurf` |
|
||
| `server/services/finance/stripeFinance.js` | ändern | `mapInvoice`, `createProjectInvoice`, `rechnungsVorschau`, Sperre, Buchführung bei Finalisieren, Storno, Löschen |
|
||
| `server/routes/admin/finanzen.js` | ändern | `POST /rechnungen` erweitert, `POST /rechnungen/vorschau`, Fehlerweitergabe für `AuftragError` |
|
||
| `test/support/fakeStripe.js` | ändern (nur Ergänzungen) | `custom_fields`, `rendering`, Regel für `amount` |
|
||
| `test/financeStripeRead.test.js` | ändern (fünf Zeilen) | erwartete `mapInvoice`-Form um die neuen Felder (L2-Datei laut Übersicht, Nachträge; Vertragsfrage 1) |
|
||
| `test/auftraegePositionen.test.js` | neu | Unit-Tests `positionen.js` |
|
||
| `test/auftraegeFakeStripe.test.js` | neu | Tests der Nachbau-Ergänzungen |
|
||
| `test/auftraegeRechnungenHilfe.js` | neu | gemeinsame Testdaten (kein Testlauf, endet nicht auf `.test.js`) |
|
||
| `test/auftraegeRechnungsbau.test.js` | neu | Tests `baueRechnung` |
|
||
| `test/auftraegeStripe.test.js` | neu | Tests `mapInvoice`, `createProjectInvoice`, Sperre, Buchführung |
|
||
| `test/auftraegeRoutesRechnungen.test.js` | neu | Routentests |
|
||
|
||
## Global Constraints
|
||
|
||
Alle Punkte der Übersicht gelten. Zusätzlich für L2:
|
||
|
||
- **Fehlerklassen:** Eingabe- und Regelfehler der neuen Module sind `AuftragError` (`server/services/auftraege/fehler.js`, L0) mit genau den Meldungen aus den Tests. `InvoiceError` bleibt für die Stripe-nahen Fehler in `stripeFinance.js`. `sendFinanceError` in `finanzen.js` gibt beide mit Status und Meldung weiter, sonst nichts.
|
||
- **Importzyklus:** `fehler.js` (L0) darf `InvoiceError` aus `stripeFinance.js` importieren, das seinerseits `rechnungsbau.js` → `positionen.js` → `fehler.js` importiert. Darum zur Modulauswertung nichts aus diesen vier Modulen benutzen (kein `class … extends AuftragError` und keine Konstante aus einem Funktionsaufruf auf Modulebene); Zugriffe innerhalb von Funktionen sind unkritisch. Importe aus `konstanten.js` sind überall erlaubt.
|
||
- **Stripe-Aufrufe:** `baueRechnung` liest bei Stripe nur (a) für `anzahlung` und `schluss` die früheren Rechnungen der AB (je Rechnung eine Leseanfrage `invoices.retrieve`) und (b) die belegende Rechnung, bevor es wegen eines schon abgerechneten Arbeitsblatts oder einer abgerechneten AB mit 409 oder 400 antwortet (Selbstheilung, siehe „Buchführung“). Alle übrigen Ablehnungen vor dem Anlegen (Eingabe, Kunde, Projekt, unbekannte oder unabrechenbare Arbeitsblätter, bei `anzahlung` und `schluss` Schalter, Auftrag und Projekt) geschehen ohne jeden Stripe-Aufruf; bestehende Tests prüfen das mit `st.calls.length === 0`.
|
||
- **Sperren:** `aufwand`, `anzahlung` und `schluss` laufen vollständig (Prüfung, Entwurf, Posten, Projektdatensatz, Senden) unter `withLocks(sperreRechnung(customerId), …)`. Jede Statusänderung einer AB durch L2 (`abgerechnet` setzen, zurücknehmen) liest und schreibt unter `withLocks(sperreAuftrag(auftragId), …)` (Entscheidung des Orchestrators; `sperreAuftrag` steht in `konstanten.js`, L0). Die Sperrschlüssel selbst legt V2 fest, L2 ruft nur die Funktionen.
|
||
- **Buchführung:** Der Projektdatensatz gehört zur Anlage (scheitert er, wird der Entwurf bei Stripe gelöscht und der Fehler weitergereicht). Buchungen nach einem Stripe-Erfolg (Rechnungsnummer, AB `abgerechnet`, `aktiv: false`) werden bei Fehlern nur protokolliert (`[finanzen] <Was> fehlgeschlagen: …`), nie geworfen: eine festgeschriebene Rechnung scheitert nicht an Appwrite. **Selbstheilung** (Entscheidung des Orchestrators): ist ein Arbeitsblatt oder eine AB durch einen aktiven Projektdatensatz belegt und L2 stünde deswegen vor einem 409 oder 400, liest es die Rechnung vorher bei Stripe; ist sie dort storniert (`void`) oder gelöscht (404), wird der Datensatz inaktiv (bei einer Schlussrechnung geht die AB von `abgerechnet` zurück) und die Belegung zählt nicht; steht die Rechnung dort finalisiert, der Datensatz aber ohne Nummer, wird die Nummer nachgetragen.
|
||
- **Texte:** genau wie in den Tests: Halbgeviertstrich `–` (U+2013) mit Leerzeichen im Zeilentext, geschütztes Leerzeichen (U+00A0) vor `€` aus `euroDe`, Zeitraum `01.10.–20.10.2026` mit U+2013 ohne Leerzeichen, in Texten nie ein Minuszeichen vor Beträgen. In Code und Tests steht kein Unicode-Escape mit vier Hexziffern (Entscheidung des Orchestrators, L4: das Werkzeug wandelt es in das Zeichen um); das geschützte Leerzeichen entsteht mit `String.fromCharCode(160)`.
|
||
- **Zeilen je Rechnung:** 1 bis 20 (Minuszeilen zählen mit); eine Schlussrechnung darf serverseitig bis 40 Zeilen haben: AB-Positionen höchstens 20, Zusatzpositionen höchstens 10, Abzüge höchstens 10, jede Gruppe mit eigener 400-Meldung (Übersicht, Global Constraints und Nachträge). `LIMITS` hat dafür keine Einträge, L2 führt die Zahlen lokal (`SCHLUSS_GRENZEN` in `positionen.js`).
|
||
- **Zusätzliche Grenzen:** höchstens 400 Arbeitsblätter je Rechnung (das Feld `arbeitsblattIdsJson` fasst 20000 Zeichen); Bezeichnung der Anzahlung 1 bis 60 Zeichen.
|
||
- **Bestehende Tests bleiben grün:** `test/financeStripeWrite.test.js`, `financeSettings`, `financeOverview`, `financeAssign`, `financeRoutesBank` unverändert; `test/financeStripeRead.test.js` darf ausschließlich um die fünf neuen Felder im erwarteten `mapInvoice`-Objekt ergänzt werden.
|
||
- **Git:** Commit je Task, Dateien einzeln, nie `git add -A` (der Symlink `node_modules` ist unversioniert), kein Push, nie `--force`. Am Ende zeigt `git status` nichts oder nur `?? node_modules`.
|
||
- **Ressourcen:** Testläufe nur einzeln und begrenzt (`timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/<Datei>`); vorher `free -m`, unter 1500 MB „available“ warten; keine volle Suite.
|
||
|
||
## Review Focus
|
||
|
||
1. **Zwei Admins oder ein Doppelklick rechnen dieselben Arbeitsblätter ab** (auch nur teilweise überschneidend): genau eine Rechnung entsteht, die andere scheitert mit 409 `Arbeitsblatt bereits abgerechnet (…)`; nach Storno oder Löschen sind die Blätter wieder frei, und eine im Stripe-Dashboard stornierte oder gelöschte Rechnung hält ihre Blätter nicht fest (Selbstheilung vor dem 409). Tests: Task 11 (Rennen, Überschneidung, Doppelklick), Task 12 (Freigabe), Task 7 (Selbstheilung).
|
||
2. **Schlussrechnung neben stornierten Rechnungen, einem offenen Entwurf oder einer voll berechneten AB:** Stornos und bei Stripe gelöschte Rechnungen bringen keine Minuszeile, ein offener Entwurf blockiert (409), Summe ≤ 0 ergibt 409 „Nichts mehr offen.“, Rechnungen und Entwürfe fremder ABs stören nicht. Eine AB mit 20 Positionen bleibt abrechenbar: bis 40 Zeilen (20 AB-Positionen, 10 Nachträge, 10 Abzüge), 11 Abzüge oder 11 Nachträge sind 400. Tests: Task 4, Task 8; Form der Minuszeile bei Stripe: Task 10; AB-Status: Task 12.
|
||
3. **Fremde oder unpassende Verweise:** Projekt, AB oder Arbeitsblatt eines anderen Kunden, erfundene und pfadähnliche Kennungen, Abo-Zeit, Kommentar, 0 Minuten, mehr als 400 Blätter: 400 oder 404 ohne einen Stripe-Aufruf. Tests: Task 6, 7, 8.
|
||
4. **Aufwand mit mehr als 20 Zeilen:** älteste Wochen werden zu KW-Zeilen zusammengefasst (Zusatzpositionen zählen mit, Woche 53 über den Jahreswechsel, 200-Zeichen-Grenze), mehr als 20 Wochen sind nicht abrechenbar (400 mit klarer Meldung). Tests: Task 3, Task 7.
|
||
5. **Appwrite fällt mitten in der Rechnung aus:** scheitert der Projektdatensatz, wird der Entwurf bei Stripe gelöscht und nichts bleibt zurück; scheitert das Buchen nach dem Finalisieren, wird die Rechnung trotzdem gesendet (nur Protokoll). Tests: Task 10, Task 12.
|
||
|
||
## Reihenfolge
|
||
|
||
1 Worktree · 2–4 `positionen.js` · 5 Nachbau · 6–8 `rechnungsbau.js` · 9 `mapInvoice` · 10 `createProjectInvoice` und Vorschau · 11 Sperre · 12 Buchführung · 13 Routen · 14 Selbstprüfung. Die Reihenfolge ist verbindlich (spätere Tasks benutzen die Hilfsdatei und die Exporte der früheren). Am Ende des Plans steht der Abschnitt „Vertragsfragen“ (alle entschieden, siehe Übersicht, Nachträge).
|
||
|
||
---
|
||
|
||
### Task 1: Worktree und Ausgangslage
|
||
|
||
**Files:** keine Änderung (nur der Worktree).
|
||
|
||
**Interfaces:**
|
||
- Consumes: die L0-Dateien aus Phase A in `feature/auftraege`.
|
||
- Produces: den Worktree `…/dev-auftraege-l2` für alle weiteren Tasks und die Gewissheit, dass V1 bis V4 (L0) so vorliegen, wie dieser Plan sie benutzt.
|
||
|
||
- [ ] **Step 1: Worktree anlegen**
|
||
|
||
```bash
|
||
git -C /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege worktree add ../dev-auftraege-l2 -b auftraege/l2-rechnungen feature/auftraege
|
||
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && ln -s ../dev-auftraege/node_modules node_modules
|
||
git -C /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 status --short
|
||
```
|
||
Erwartet: leer oder nur `?? node_modules` (`node_modules/` mit Schrägstrich ignoriert keinen Symlink; hat L0 `.gitignore` auf `node_modules` ohne Schrägstrich umgestellt, bleibt die Ausgabe leer). Der Symlink wird nie hinzugefügt.
|
||
|
||
- [ ] **Step 2: L0-Dateien und ihre Exporte prüfen**
|
||
|
||
```bash
|
||
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && node --max-old-space-size=512 --input-type=module -e "
|
||
for (const f of ['konstanten', 'fehler', 'schalter', 'auftragStore', 'projektRechnungenStore', 'arbeitsblaetter', 'schema']) {
|
||
const m = await import('./server/services/auftraege/' + f + '.js')
|
||
console.log(f + ': ' + Object.keys(m).sort().join(' '))
|
||
}
|
||
const { config } = await import('./server/config.js')
|
||
console.log(config.collections.auftraege, config.collections.projektRechnungen, config.auftraege.mode)"
|
||
```
|
||
Erwartet: jede Zeile enthält mindestens diese Namen, die letzte Zeile lautet `auftraege projektRechnungen off`:
|
||
- `konstanten`: `AB_RECHNUNGS_ARTEN LIMITS RECHNUNGS_ARTEN SCHALTER_AUS_MELDUNG STUNDENSATZ_STANDARD_CENTS VEREINBARTE_STATUS sperreAuftrag sperreRechnung`
|
||
- `fehler`: `AuftragError sendeFehler`
|
||
- `schalter`: `auftraegeAktiv`
|
||
- `auftragStore`: `createAuftrag getAuftrag listAuftraege updateAuftrag`
|
||
- `projektRechnungenStore`: `createProjektRechnung getProjektRechnung listProjektRechnungen updateProjektRechnung`
|
||
- `arbeitsblaetter`: `abgerechneteArbeitsblaetter abrechenbar ladeArbeitsblaetterNachIds offeneArbeitsblaetter zeitquelleFuerProjekt`
|
||
- `schema`: `AUFTRAEGE_COLLECTIONS AUFTRAEGE_DATETIMES AUFTRAEGE_ZUSATZ_ATTRIBUTE`
|
||
|
||
Dazu muss `schemaKeys('auftraege')` 42 und `schemaKeys('projektRechnungen')` 11 Schlüssel liefern (`test/support/schema.js`, L0):
|
||
|
||
```bash
|
||
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && node --max-old-space-size=512 --input-type=module -e "
|
||
const { schemaKeys } = await import('./test/support/schema.js')
|
||
console.log(schemaKeys('auftraege').length, schemaKeys('projektRechnungen').length)"
|
||
```
|
||
Erwartet: `42 11`.
|
||
|
||
Fehlt etwas, ist Strang L0 nicht fertig: anhalten und melden, **keine L0-Datei selbst ändern**.
|
||
|
||
- [ ] **Step 3: Kennungs-Muster des Projektdatensatz-Speichers prüfen**
|
||
|
||
```bash
|
||
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && command grep -a -n "in_" server/services/auftraege/projektRechnungenStore.js server/services/auftraege/schema.js
|
||
```
|
||
Der Stripe-Nachbau vergibt Rechnungskennungen der Form `in_test_<n>` (mit Unterstrich); der Orchestrator hat für den Speicher `/^in_\w{1,33}$/` entschieden. Erlaubt das Muster nach `in_` nur `[A-Za-z0-9]`, ist L0 nicht nach der Entscheidung gebaut: anhalten und melden (siehe „Vertragsfragen“ B); die Tasks 2 bis 9 sind davon unabhängig (ihre Tests benutzen Kennungen wie `in_f1`), ab Task 10 scheitern die Tests am Nachbau. Nichts in L0 ändern.
|
||
|
||
- [ ] **Step 4: Ausgangslage prüfen, alles grün**
|
||
|
||
```bash
|
||
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && for f in financeStripeWrite financeStripeRead financeSettings financeOverview financeAssign financeRoutesBank; do echo "== $f"; timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/$f.test.js 2>&1 | command grep -a -E "^# (pass|fail)"; done
|
||
```
|
||
Erwartet: je Datei `# fail 0`. Kein Commit in diesem Task.
|
||
|
||
---
|
||
|
||
### Task 2: `positionen.js` – Grundlagen und feste Positionen
|
||
|
||
**Files:**
|
||
- Create: `server/services/auftraege/positionen.js`
|
||
- Create: `test/auftraegePositionen.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `AuftragError(status, message)` (`./fehler.js`), `LIMITS` (`./konstanten.js`: `maxPositionen` 20, `maxBeschreibung` 200, `maxMenge` 999, `maxEinzelpreisCents` 10_000_000, `maxSummeCents` 99_999_999), `formatDe(ymd)` (`../finance/dates.js`).
|
||
- Produces (alle `export`, Typ `Position = { schluessel, beschreibung, menge, einzelpreisCents, betragCents }` wie V3):
|
||
- `kuerzen(text: string, max: number): string`
|
||
- `stundenText(minuten: number): string`
|
||
- `gueltigerTag(ymd: string): boolean`
|
||
- `kalenderwoche(ymd: string): { jahr: number, woche: number }` (ISO-Woche samt ISO-Jahr; wirft `RangeError` bei ungültigem Tag)
|
||
- `leistungszeitraumText(von: string, bis: string): string`
|
||
- `festePositionen(items: unknown): Position[]` (Eingabe `{ description, quantity, unitAmountCents }` wie heute; 1 bis 20; Schlüssel `pos-<n>`)
|
||
- `zusatzPositionen(items: unknown): Position[]` (wie `festePositionen`, aber leer erlaubt; Schlüssel `zusatz-<n>`)
|
||
- `pruefePositionen(positionen: Position[], { max = LIMITS.maxPositionen } = {}): number` (prüft Zahl 1 bis `max`, Beschreibungen, Summe; liefert die Summe in Cent; die Obergrenze hebt nur `rechnungsbau.js` für die Schlussrechnung auf 40 an)
|
||
|
||
- [ ] **Step 1: Failing Test** – `test/auftraegePositionen.test.js` mit diesem Inhalt anlegen:
|
||
|
||
```js
|
||
import { test } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import {
|
||
stundenText, leistungszeitraumText, kalenderwoche, gueltigerTag, kuerzen,
|
||
festePositionen, zusatzPositionen, pruefePositionen,
|
||
} from '../server/services/auftraege/positionen.js'
|
||
|
||
/** Wirft die Funktion einen AuftragError mit diesem Status und dieser Meldung? */
|
||
const wirft = (fn, status, message) =>
|
||
assert.throws(fn, (err) => {
|
||
assert.equal(err.status, status, `Status ${err.status}: ${err.message}`)
|
||
assert.equal(err.message, message)
|
||
return true
|
||
})
|
||
const ANZAHL = 'Eine Rechnung braucht 1 bis 20 Positionen.'
|
||
const ok = { description: 'Domain', quantity: 1, unitAmountCents: 1200 }
|
||
|
||
test('stundenText: Minuten als Stunden mit Komma, ohne überflüssige Nullen', () => {
|
||
const faelle = [[0, '0'], [15, '0,25'], [30, '0,5'], [45, '0,75'], [60, '1'], [90, '1,5'], [135, '2,25'], [390, '6,5'], [600, '10']]
|
||
for (const [minuten, text] of faelle) assert.equal(stundenText(minuten), text, `${minuten} Minuten`)
|
||
})
|
||
|
||
test('leistungszeitraumText: ein Tag, ein Jahr, Jahreswechsel (Halbgeviertstrich U+2013 ohne Leerzeichen)', () => {
|
||
assert.equal(leistungszeitraumText('2026-10-06', '2026-10-06'), '06.10.2026')
|
||
assert.equal(leistungszeitraumText('2026-10-01', '2026-10-20'), '01.10.–20.10.2026')
|
||
assert.equal(leistungszeitraumText('2026-09-28', '2026-10-05'), '28.09.–05.10.2026')
|
||
assert.equal(leistungszeitraumText('2026-12-28', '2027-01-05'), '28.12.2026–05.01.2027')
|
||
})
|
||
|
||
test('kalenderwoche: ISO-Woche samt ISO-Jahr, gueltigerTag: nur Tage, die es gibt', () => {
|
||
const faelle = [
|
||
['2026-10-05', 2026, 41], ['2026-10-06', 2026, 41], ['2026-10-11', 2026, 41], ['2026-10-12', 2026, 42],
|
||
['2025-12-29', 2026, 1], ['2026-01-01', 2026, 1], ['2026-12-31', 2026, 53], ['2027-01-03', 2026, 53],
|
||
['2027-01-04', 2027, 1], ['2024-12-30', 2025, 1], ['2021-01-03', 2020, 53],
|
||
]
|
||
for (const [tag, jahr, woche] of faelle) assert.deepEqual(kalenderwoche(tag), { jahr, woche }, tag)
|
||
assert.throws(() => kalenderwoche('2026-02-30'), RangeError)
|
||
assert.equal(gueltigerTag('2026-02-28'), true)
|
||
for (const tag of ['2026-02-30', '2026-13-01', '06.10.2026', '2026-1-1', '', undefined, 20261006]) assert.equal(gueltigerTag(tag), false, String(tag))
|
||
})
|
||
|
||
test('kuerzen: höchstens max Zeichen, Auslassungszeichen am Ende, kein halbes Emoji', () => {
|
||
assert.equal(kuerzen('kurz', 10), 'kurz')
|
||
assert.equal(kuerzen('abcdefghij', 10), 'abcdefghij')
|
||
assert.equal(kuerzen('abcdefghijk', 10), 'abcdefghi…')
|
||
assert.equal(kuerzen('ab cdefghijk', 4), 'ab…')
|
||
assert.equal(kuerzen('abcd😀fghij', 6), 'abcd…')
|
||
})
|
||
|
||
test('festePositionen: Eingabe wie bisher, Beschreibung getrimmt, Schlüssel pos-n, Betrag = Menge × Einzelpreis', () => {
|
||
assert.deepEqual(festePositionen([{ description: ' Website ', quantity: 2, unitAmountCents: 5000 }, ok]), [
|
||
{ schluessel: 'pos-1', beschreibung: 'Website', menge: 2, einzelpreisCents: 5000, betragCents: 10000 },
|
||
{ schluessel: 'pos-2', beschreibung: 'Domain', menge: 1, einzelpreisCents: 1200, betragCents: 1200 },
|
||
])
|
||
assert.equal(festePositionen(Array.from({ length: 20 }, () => ok)).length, 20)
|
||
})
|
||
|
||
test('festePositionen: Grenzen und Meldungen wie bei der bisherigen Rechnung', () => {
|
||
wirft(() => festePositionen([]), 400, ANZAHL)
|
||
wirft(() => festePositionen(undefined), 400, ANZAHL)
|
||
wirft(() => festePositionen(Array.from({ length: 21 }, () => ok)), 400, ANZAHL)
|
||
for (const description of ['', ' ', 'x'.repeat(201), 5]) wirft(() => festePositionen([{ ...ok, description }]), 400, 'Position 1: Die Beschreibung braucht 1 bis 200 Zeichen.')
|
||
for (const quantity of [0, 1000, 1.5, '2', -1, null]) wirft(() => festePositionen([ok, { ...ok, quantity }]), 400, 'Position 2: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
|
||
for (const unitAmountCents of [0, 10_000_001, 12.5, '5000', -100, null]) {
|
||
wirft(() => festePositionen([{ ...ok, unitAmountCents }]), 400, 'Position 1: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.')
|
||
}
|
||
const neun = Array.from({ length: 9 }, (_, i) => ({ description: `Teil ${i + 1}`, quantity: 1, unitAmountCents: 10_000_000 }))
|
||
wirft(() => festePositionen([...neun, { description: 'Rest', quantity: 1, unitAmountCents: 10_000_000 }]), 400, 'Rechnungssumme zu hoch.')
|
||
assert.equal(festePositionen([...neun, { description: 'Rest', quantity: 1, unitAmountCents: 9_999_999 }]).at(-1).betragCents, 9_999_999)
|
||
})
|
||
|
||
test('zusatzPositionen: leer erlaubt, Schlüssel zusatz-n, gleiche Prüfungen; pruefePositionen: Zahl (mit wählbarer Obergrenze), Texte, Summe mit Minuszeilen', () => {
|
||
for (const leer of [undefined, null, []]) assert.deepEqual(zusatzPositionen(leer), [])
|
||
assert.deepEqual(zusatzPositionen([{ description: 'Domain', quantity: 2, unitAmountCents: 1500 }]), [
|
||
{ schluessel: 'zusatz-1', beschreibung: 'Domain', menge: 2, einzelpreisCents: 1500, betragCents: 3000 },
|
||
])
|
||
wirft(() => zusatzPositionen('Domain'), 400, ANZAHL)
|
||
wirft(() => zusatzPositionen(Array.from({ length: 21 }, () => ok)), 400, ANZAHL)
|
||
wirft(() => zusatzPositionen([{ ...ok, description: '' }]), 400, 'Position 1: Die Beschreibung braucht 1 bis 200 Zeichen.')
|
||
|
||
const pos = (beschreibung, cents) => ({ schluessel: 'x', beschreibung, menge: 1, einzelpreisCents: cents, betragCents: cents })
|
||
assert.equal(pruefePositionen([pos('A', 5000), pos('abzüglich Anzahlung', -2000)]), 3000)
|
||
assert.equal(pruefePositionen([pos('A', 99_999_999)]), 99_999_999)
|
||
wirft(() => pruefePositionen([]), 400, ANZAHL)
|
||
wirft(() => pruefePositionen(Array.from({ length: 21 }, () => pos('A', 1))), 400, ANZAHL)
|
||
// eine Schlussrechnung darf bis 40 Zeilen haben: der Aufrufer hebt die Obergrenze an
|
||
assert.equal(pruefePositionen(Array.from({ length: 40 }, () => pos('A', 1)), { max: 40 }), 40)
|
||
wirft(() => pruefePositionen(Array.from({ length: 41 }, () => pos('A', 1)), { max: 40 }), 400, 'Eine Rechnung braucht 1 bis 40 Positionen.')
|
||
wirft(() => pruefePositionen([], { max: 40 }), 400, 'Eine Rechnung braucht 1 bis 40 Positionen.')
|
||
wirft(() => pruefePositionen([pos('A', 1), pos('', 1)]), 400, 'Position 2: Die Beschreibung braucht 1 bis 200 Zeichen.')
|
||
wirft(() => pruefePositionen([pos('x'.repeat(201), 1)]), 400, 'Position 1: Die Beschreibung braucht 1 bis 200 Zeichen.')
|
||
wirft(() => pruefePositionen([pos('A', 99_999_999), pos('B', 1)]), 400, 'Rechnungssumme zu hoch.')
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Lauf, erwartet FAIL**
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegePositionen.test.js`
|
||
Expected: FAIL mit `ERR_MODULE_NOT_FOUND` (`positionen.js` fehlt).
|
||
|
||
- [ ] **Step 3: Implementieren** in `server/services/auftraege/positionen.js` (neu), Signaturen siehe Interfaces. Regeln und Werte:
|
||
- `kuerzen`: Text unverändert, wenn `text.length <= max`; sonst `text.slice(0, max - 1)` (endet der Schnitt auf einem hohen Ersatzzeichen eines Emoji, dieses Zeichen weglassen), `trimEnd()`, dann `…` anhängen. Das Ergebnis ist nie länger als `max`.
|
||
- `stundenText`: `String(Math.round((minuten / 60) * 100) / 100).replace('.', ',')` (ergibt `2,25`, `6,5`, `1`; die Spec zeigt `6,5`, nicht `6,50`).
|
||
- `gueltigerTag`: nur ein String `YYYY-MM-DD`, der beim Rundlauf über `new Date(Date.UTC(j, m - 1, t))` derselbe Tag bleibt.
|
||
- `kalenderwoche` (wirft `RangeError('Ungültiges Datum: …')`, wenn `gueltigerTag` falsch ist):
|
||
|
||
```js
|
||
const tag = new Date(`${ymd}T00:00:00Z`)
|
||
const wochentag = (tag.getUTCDay() + 6) % 7 // Montag = 0
|
||
const donnerstag = new Date(tag.getTime() + (3 - wochentag) * 86_400_000)
|
||
const jahr = donnerstag.getUTCFullYear()
|
||
const woche = Math.floor((donnerstag.getTime() - Date.UTC(jahr, 0, 1)) / 86_400_000 / 7) + 1
|
||
return { jahr, woche }
|
||
```
|
||
- `leistungszeitraumText(von, bis)`: gleicher Tag: `formatDe(von)`; gleiches Jahr: `formatDe(von).slice(0, 6)` + `–` + `formatDe(bis)`; sonst `formatDe(von)` + `–` + `formatDe(bis)`. Liegt `bis` vor `von`: `RangeError('Das Ende liegt vor dem Beginn.')`.
|
||
- Gemeinsamer privater Prüfer für `festePositionen` und `zusatzPositionen` (Parameter: leer erlaubt ja/nein, Schlüsselpräfix `pos` bzw. `zusatz`). Reihenfolge der Prüfungen wie das bisherige `validateItems` in `stripeFinance.js`: kein Array (außer `undefined` und `null` bei leer erlaubt), mehr als 20 oder bei „leer nicht erlaubt“ weniger als 1 → 400 `Eine Rechnung braucht 1 bis 20 Positionen.`; je Position (`item?.description`, getrimmt, Länge 1 bis 200, sonst 400 `Position <n>: Die Beschreibung braucht 1 bis 200 Zeichen.`), `quantity` ganze Zahl 1 bis 999 (400 `Position <n>: Die Menge muss eine ganze Zahl von 1 bis 999 sein.`), `unitAmountCents` ganze Zahl 1 bis 10_000_000 (400 `Position <n>: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`); Summe über 99_999_999 → 400 `Rechnungssumme zu hoch.` Fehler sind `AuftragError`. Leere Eingabe bei „leer erlaubt“ ergibt `[]`.
|
||
- `pruefePositionen(positionen, { max = LIMITS.maxPositionen } = {})`: 1 bis `max` Einträge (400 `` `Eine Rechnung braucht 1 bis ${max} Positionen.` ``, mit der Standardgrenze also `Eine Rechnung braucht 1 bis 20 Positionen.`), je Beschreibung 1 bis 200 Zeichen (400 `Position <n>: Die Beschreibung braucht 1 bis 200 Zeichen.`), Summe der `betragCents` (Minuszeilen zählen mit) höchstens 99_999_999 (400 `Rechnungssumme zu hoch.`); gibt die Summe zurück. Die Prüfung „Summe über 0“ gehört den Aufrufern.
|
||
|
||
- [ ] **Step 4: Lauf, erwartet PASS**
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegePositionen.test.js`
|
||
Expected: PASS, `# fail 0`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2
|
||
git add server/services/auftraege/positionen.js test/auftraegePositionen.test.js
|
||
git commit -m "Rechnungen: Positionen-Grundlagen (Stundentext, Kalenderwoche, feste Positionen, Prüfung)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: `positionen.js` – Aufwandszeilen
|
||
|
||
**Files:**
|
||
- Modify: `server/services/auftraege/positionen.js`
|
||
- Modify: `test/auftraegePositionen.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `kuerzen`, `stundenText`, `kalenderwoche`, `gueltigerTag` (Task 2), `euroDe(cents)` (`../finance/format.js`), `formatDe`, `LIMITS.maxEinzelpreisCents`, `AuftragError`.
|
||
- Produces: `aufwandPositionen(blaetter: Arbeitsblatt[], { satzCents, zeilentexte = {}, maxZeilen = 20 }): { positionen: Position[], minuten: number, von: string|null, bis: string|null }`. Eingabe: normierte `Arbeitsblatt`-Objekte nach V3 (`id`, `datum` `YYYY-MM-DD`, `minuten` bereits je Blatt auf 15 aufgerundet, `text`). Schlüssel einer Tageszeile: der Tag (`2026-10-06`); einer Wochenzeile: `KW-<ISO-Jahr>-<Woche zweistellig>` (`KW-2026-41`).
|
||
|
||
- [ ] **Step 1: Failing Test** – den Import oben in `test/auftraegePositionen.test.js` um `aufwandPositionen` ergänzen und am Dateiende anhängen:
|
||
|
||
```js
|
||
const NBSP = String.fromCharCode(160) // geschütztes Leerzeichen U+00A0, wie euroDe es vor € setzt
|
||
const blatt = (id, datum, minuten, text = '', over = {}) => ({ id, ticketId: 't1', woid: 'WO-1', datum, minuten, text, aboZeit: false, kommentar: false, ...over })
|
||
const werktage = (start, n) => {
|
||
const tage = []
|
||
for (let ms = Date.parse(`${start}T00:00:00Z`); tage.length < n; ms += 86_400_000) {
|
||
const tag = new Date(ms)
|
||
if (tag.getUTCDay() % 6 !== 0) tage.push(tag.toISOString().slice(0, 10))
|
||
}
|
||
return tage
|
||
}
|
||
|
||
test('aufwandPositionen: eine Zeile je Tag mit Texten, Stunden und Satz (Menge 1, Betrag = Stunden × Satz)', () => {
|
||
const erg = aufwandPositionen([blatt('w1', '2026-10-06', 90, 'Startseite, Kontaktformular'), blatt('w2', '2026-10-06', 45, '')], { satzCents: 10000 })
|
||
assert.deepEqual(erg.positionen, [
|
||
{ schluessel: '2026-10-06', beschreibung: `06.10.2026: Startseite, Kontaktformular – 2,25 Std. à 100,00${NBSP}€`, menge: 1, einzelpreisCents: 22500, betragCents: 22500 },
|
||
])
|
||
assert.deepEqual([erg.minuten, erg.von, erg.bis], [135, '2026-10-06', '2026-10-06'])
|
||
})
|
||
|
||
test('aufwandPositionen: Texte eines Tages mit "; " verbunden, Doppelte entfallen, Leerraum vereinheitlicht, ohne Text "Arbeitszeit"', () => {
|
||
const erg = aufwandPositionen([
|
||
blatt('w1', '2026-10-06', 60, 'Startseite'), blatt('w2', '2026-10-06', 60, 'Kontaktformular\nmit Anhang'), blatt('w3', '2026-10-06', 15, ' Startseite '),
|
||
blatt('w4', '2026-10-07', 30, ''),
|
||
], { satzCents: 10000 })
|
||
assert.deepEqual(erg.positionen.map((p) => p.beschreibung), [
|
||
`06.10.2026: Startseite; Kontaktformular mit Anhang – 2,25 Std. à 100,00${NBSP}€`,
|
||
`07.10.2026: Arbeitszeit – 0,5 Std. à 100,00${NBSP}€`,
|
||
])
|
||
assert.deepEqual(erg.positionen.map((p) => p.betragCents), [22500, 5000])
|
||
})
|
||
|
||
test('aufwandPositionen: Reihenfolge der Eingabe egal (nach Tag und Kennung), von und bis, Satz ohne glatte Cent je Viertelstunde', () => {
|
||
const erg = aufwandPositionen([blatt('w3', '2026-10-08', 15, 'C'), blatt('w1', '2026-10-06', 15, 'A'), blatt('w2', '2026-10-07', 15, 'B')], { satzCents: 9999 })
|
||
assert.deepEqual(erg.positionen.map((p) => p.schluessel), ['2026-10-06', '2026-10-07', '2026-10-08'])
|
||
assert.deepEqual([erg.von, erg.bis, erg.minuten], ['2026-10-06', '2026-10-08', 45])
|
||
assert.equal(erg.positionen[0].beschreibung, `06.10.2026: A – 0,25 Std. à 99,99${NBSP}€`)
|
||
assert.equal(erg.positionen[0].betragCents, 2500) // 15 Minuten × 99,99 €/Std. = 24,9975 € → ganze Cent gerundet
|
||
})
|
||
|
||
test('aufwandPositionen: zeilentexte ersetzen den Text vor dem Gedankenstrich; leer oder unbekannt wirkt nicht; Zeile bleibt bei höchstens 200 Zeichen', () => {
|
||
const standard = `06.10.2026: Startseite – 2,25 Std. à 100,00${NBSP}€`
|
||
const mit = (zeilentexte, text = 'Startseite') =>
|
||
aufwandPositionen([blatt('w1', '2026-10-06', 135, text)], { satzCents: 10000, zeilentexte }).positionen[0].beschreibung
|
||
assert.equal(mit({ '2026-10-06': '06.10.2026: Neue Startseite' }), `06.10.2026: Neue Startseite – 2,25 Std. à 100,00${NBSP}€`)
|
||
for (const wirkungslos of [undefined, null, {}, { '2026-10-06': ' ' }, { '2026-10-07': 'anderer Tag' }, { '2026-10-06': 5 }]) assert.equal(mit(wirkungslos), standard)
|
||
const schluss = ` – 2,25 Std. à 100,00${NBSP}€`
|
||
const ersetzt = mit({ '2026-10-06': 'x'.repeat(300) })
|
||
assert.equal(ersetzt.length, 200)
|
||
assert.ok(ersetzt.endsWith(`x…${schluss}`))
|
||
const lang = mit(undefined, 'y'.repeat(300))
|
||
assert.equal(lang.length, 200)
|
||
assert.ok(lang.startsWith('06.10.2026: yyy'))
|
||
assert.ok(lang.endsWith(`y…${schluss}`))
|
||
})
|
||
|
||
test('aufwandPositionen: zu viele Tage -> die ältesten Wochen werden zu KW-Zeilen (ISO-Woche, älteste zuerst), Einzeltage bleiben Tageszeilen', () => {
|
||
// KW 41: 5., 6., 7.10.; KW 42: 12., 13., 14.10.; KW 43: 19., 20.10.; je 90 Minuten
|
||
const tage = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-12', '2026-10-13', '2026-10-14', '2026-10-19', '2026-10-20']
|
||
const blaetter = tage.map((tag, i) => blatt(`w${i + 1}`, tag, 90, 'ABCDEFGH'[i]))
|
||
const lauf = (maxZeilen) => aufwandPositionen(blaetter, { satzCents: 10000, maxZeilen })
|
||
assert.equal(lauf(8).positionen.length, 8)
|
||
assert.deepEqual(lauf(6).positionen.map((p) => p.schluessel), ['KW-2026-41', '2026-10-12', '2026-10-13', '2026-10-14', '2026-10-19', '2026-10-20'])
|
||
assert.deepEqual(lauf(6).positionen[0], {
|
||
schluessel: 'KW-2026-41', beschreibung: `KW 41/2026: A; B; C – 4,5 Std. à 100,00${NBSP}€`, menge: 1, einzelpreisCents: 45000, betragCents: 45000,
|
||
})
|
||
assert.deepEqual(lauf(5).positionen.map((p) => p.schluessel), ['KW-2026-41', 'KW-2026-42', '2026-10-19', '2026-10-20'])
|
||
assert.deepEqual(lauf(3).positionen.map((p) => p.schluessel), ['KW-2026-41', 'KW-2026-42', 'KW-2026-43'])
|
||
assert.equal(lauf(3).positionen[2].beschreibung, `KW 43/2026: G; H – 3 Std. à 100,00${NBSP}€`)
|
||
for (const n of [8, 6, 5, 3]) assert.equal(lauf(n).positionen.reduce((s, p) => s + p.betragCents, 0), 8 * 15000, `Summe bei ${n} Zeilen`)
|
||
assert.deepEqual([lauf(3).von, lauf(3).bis, lauf(3).minuten], ['2026-10-05', '2026-10-20', 720])
|
||
wirft(() => lauf(2), 400, 'Zu viele Arbeitstage für eine Rechnung (auch nach Kalenderwochen mehr als 2 Zeilen). Bitte weniger Arbeitsblätter auswählen.')
|
||
// eine Woche mit nur einem Tag wird nicht zusammengefasst (es spart keine Zeile)
|
||
const einzel = [blatt('a', '2026-10-06', 60, 'A'), blatt('b', '2026-10-12', 60, 'B'), blatt('c', '2026-10-13', 60, 'C'), blatt('d', '2026-10-14', 60, 'D')]
|
||
assert.deepEqual(aufwandPositionen(einzel, { satzCents: 10000, maxZeilen: 2 }).positionen.map((p) => p.schluessel), ['2026-10-06', 'KW-2026-42'])
|
||
})
|
||
|
||
test('aufwandPositionen: Standard 20 Zeilen, zeilentexte auch für KW-Zeilen, Woche 53 über den Jahreswechsel, mehr als 20 Wochen -> 400', () => {
|
||
const blaetter = werktage('2026-10-05', 25).map((tag, i) => blatt(`w${String(i + 1).padStart(2, '0')}`, tag, 60, `T${i + 1}`)) // KW 41 bis 45
|
||
const erg = aufwandPositionen(blaetter, { satzCents: 10000, zeilentexte: { 'KW-2026-42': 'KW 42/2026: Relaunch Teil 2' } })
|
||
assert.equal(erg.positionen.length, 17)
|
||
assert.deepEqual(erg.positionen.slice(0, 3).map((p) => p.schluessel), ['KW-2026-41', 'KW-2026-42', '2026-10-19'])
|
||
assert.equal(erg.positionen[1].beschreibung, `KW 42/2026: Relaunch Teil 2 – 5 Std. à 100,00${NBSP}€`)
|
||
assert.equal(erg.minuten, 1500)
|
||
|
||
const wechsel = ['2026-12-30', '2026-12-31', '2027-01-01', '2027-01-04'].map((tag, i) => blatt(`j${i}`, tag, 60, `J${i}`))
|
||
const zwei = aufwandPositionen(wechsel, { satzCents: 10000, maxZeilen: 2 })
|
||
assert.deepEqual(zwei.positionen.map((p) => p.schluessel), ['KW-2026-53', '2027-01-04'])
|
||
assert.equal(zwei.positionen[0].beschreibung, `KW 53/2026: J0; J1; J2 – 3 Std. à 100,00${NBSP}€`)
|
||
|
||
const montage = Array.from({ length: 21 }, (_, i) => new Date(Date.UTC(2026, 0, 5 + 7 * i)).toISOString().slice(0, 10))
|
||
const wochen = montage.map((tag, i) => blatt(`m${i + 1}`, tag, 60, 'x'))
|
||
wirft(() => aufwandPositionen(wochen, { satzCents: 10000 }), 400, 'Zu viele Arbeitstage für eine Rechnung (auch nach Kalenderwochen mehr als 20 Zeilen). Bitte weniger Arbeitsblätter auswählen.')
|
||
assert.equal(aufwandPositionen(wochen.slice(0, 20), { satzCents: 10000 }).positionen.length, 20)
|
||
})
|
||
|
||
test('aufwandPositionen: ungültiger Satz, leere Auswahl, kaputte Blätter', () => {
|
||
const eins = [blatt('w1', '2026-10-06', 60, 'A')]
|
||
for (const satzCents of [0, -1, 1.5, '10000', 10_000_001, undefined]) wirft(() => aufwandPositionen(eins, { satzCents }), 400, 'Der Stundensatz ist ungültig.')
|
||
assert.deepEqual(aufwandPositionen([], { satzCents: 10000 }), { positionen: [], minuten: 0, von: null, bis: null })
|
||
for (const kaputt of [{ datum: '06.10.2026' }, { datum: '2026-13-01' }, { minuten: 0 }, { minuten: 7.5 }, { minuten: '60' }]) {
|
||
wirft(() => aufwandPositionen([blatt('w1', '2026-10-06', 60, 'A', kaputt)], { satzCents: 10000 }), 400, 'Ein Arbeitsblatt hat kein gültiges Datum oder keine Zeit.')
|
||
}
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Lauf, erwartet FAIL**
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegePositionen.test.js`
|
||
Expected: FAIL (`SyntaxError: … does not provide an export named 'aufwandPositionen'`).
|
||
|
||
- [ ] **Step 3: Implementieren** `aufwandPositionen` in `server/services/auftraege/positionen.js` (Signatur siehe Interfaces; `zeilentexte` darf `undefined` oder `null` sein). Regeln:
|
||
1. Leere `blaetter` → `{ positionen: [], minuten: 0, von: null, bis: null }`.
|
||
2. `satzCents` muss eine ganze Zahl von 1 bis `LIMITS.maxEinzelpreisCents` sein, sonst `AuftragError(400, 'Der Stundensatz ist ungültig.')`. Jedes Blatt braucht `gueltigerTag(datum)` und `Number.isSafeInteger(minuten) && minuten > 0`, sonst `AuftragError(400, 'Ein Arbeitsblatt hat kein gültiges Datum oder keine Zeit.')`.
|
||
3. Eine Kopie nach `datum`, dann `id` sortieren (`localeCompare`); je Datum ein Tag: `minuten` summieren; `text` je Blatt mit `replace(/\s+/g, ' ').trim()` vereinheitlichen, leere weglassen, exakte Doppelte weglassen (Reihenfolge bleibt).
|
||
4. Zeile (Tag oder Woche) bauen: Standardtext `<Kopf>: <Texte mit '; ' verbunden, ohne Texte 'Arbeitszeit'>` mit Kopf `formatDe(tag)` bzw. `KW <WW>/<ISO-Jahr>` (Woche zweistellig, z. B. `KW 41/2026`); gibt es `zeilentexte[schluessel]` (nur eigene Eigenschaft über `Object.hasOwn`, nur Strings, vereinheitlicht und nicht leer), ersetzt dieser Text den gesamten Standardtext (er enthält also Datum bzw. KW-Kopf selbst). Schluss: `` ` – ${stundenText(minuten)} Std. à ${euroDe(satzCents)}` ``. Der Text vor dem Schluss wird mit `kuerzen(text, 200 - schluss.length)` gekürzt, die ganze Beschreibung ist nie länger als 200 Zeichen. Betrag: `Math.round((minuten * satzCents) / 60)`; `menge` 1, `einzelpreisCents` = `betragCents` = Betrag.
|
||
5. Zu viele Zeilen (mehr Tage als `maxZeilen`): Wochen über `kalenderwoche(tag)` bilden (Reihenfolge aufsteigend), dann von der ältesten Woche an zusammenfassen, solange noch mehr als `maxZeilen` Zeilen übrig sind; eine Woche mit nur einem Tag bleibt Tageszeile:
|
||
|
||
```js
|
||
let anzahl = tage.length
|
||
const einheiten = []
|
||
for (const woche of wochen) { // aufsteigend: älteste zuerst
|
||
if (anzahl > maxZeilen && woche.tage.length > 1) {
|
||
anzahl -= woche.tage.length - 1
|
||
einheiten.push(wochenEinheit(woche)) // Schlüssel `KW-${jahr}-${WW}`, Minuten und Texte aller Tage der Woche
|
||
} else einheiten.push(...woche.tage.map(tagesEinheit))
|
||
}
|
||
if (anzahl > maxZeilen) throw new AuftragError(400, `Zu viele Arbeitstage für eine Rechnung (auch nach Kalenderwochen mehr als ${maxZeilen} Zeilen). Bitte weniger Arbeitsblätter auswählen.`)
|
||
```
|
||
|
||
Dabei ist `tage` die nach Datum sortierte Liste `[{ datum, minuten, texte }]`, `wochen` dieselben Tage nach `kalenderwoche(datum)` gruppiert (Reihenfolge des ersten Auftretens, je Eintrag `{ jahr, woche, tage }`), `tagesEinheit(tag)` liefert `{ schluessel: tag.datum, kopf: formatDe(tag.datum), minuten, texte }` und `wochenEinheit(woche)` liefert `{ schluessel: 'KW-<Jahr>-<WW>', kopf: 'KW <WW>/<Jahr>', minuten: Summe der Tage, texte: die Texte der Tage der Reihe nach ohne Doppelte }` (`<WW>` zweistellig); aus einer Einheit entsteht die Zeile nach Punkt 4.
|
||
6. Rückgabe: `positionen` in der Reihenfolge der Einheiten, `minuten` = Summe aller Blätter, `von` = erster, `bis` = letzter Tag (auch bei zusammengefassten Wochen).
|
||
|
||
- [ ] **Step 4: Lauf, erwartet PASS**
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegePositionen.test.js`
|
||
Expected: PASS, `# fail 0`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2
|
||
git add server/services/auftraege/positionen.js test/auftraegePositionen.test.js
|
||
git commit -m "Rechnungen: Aufwandszeilen je Tag, Kalenderwochen-Zusammenfassung ab 20 Zeilen
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: `positionen.js` – Anzahlung und Schlussrechnung
|
||
|
||
**Files:**
|
||
- Modify: `server/services/auftraege/positionen.js`
|
||
- Modify: `test/auftraegePositionen.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `kuerzen`, `zusatzPositionen` (Task 2), `euroDe` (`../finance/format.js`), `formatDe`, `berlinDate` (`../finance/dates.js`), `LIMITS.standardAnzahlungProzent`, `AuftragError`.
|
||
- Produces:
|
||
- `auftragDatum(auftrag): string` – Berliner Tag von `auftrag.gesendetAm`, sonst `auftrag.auftragVom` (`YYYY-MM-DD`).
|
||
- `anzahlungPosition(auftrag, { prozent, betragCents, bezeichnung = 'Anzahlung' } = {}, bereitsBerechnetCents = 0): Position` – Schlüssel `anzahlung`; benutzt `auftrag.nummer`, `titel`, `summeCents`, `zahlungsplan`.
|
||
- `SCHLUSS_GRENZEN = { abPositionen: 20, zusatz: 10, abzuege: 10, zeilen: 40 }` (`export const`; Obergrenzen einer Schlussrechnung laut Übersicht, Nachträge; `LIMITS` hat dafür keine Einträge)
|
||
- `schlussPositionen(auftrag, fruehere, zusatz = []): Position[]` – `fruehere: [{ invoiceId, rechnungsnummer, datum, betragCents, art }]` (nur finalisierte, nicht stornierte; die Auswahl trifft der Aufrufer), `zusatz`: rohe Eingabe wie bei `zusatzPositionen`. Höchstens 20 AB-Positionen, 10 Zusatzpositionen und 10 Abzüge (zusammen höchstens 40 Zeilen), jede Gruppe mit eigener 400-Meldung.
|
||
|
||
- [ ] **Step 1: Failing Test** – den Import oben um `auftragDatum, anzahlungPosition, schlussPositionen, SCHLUSS_GRENZEN` ergänzen und am Dateiende anhängen:
|
||
|
||
```js
|
||
const AB = {
|
||
id: 'a1', nummer: 'AB-2026-0002', titel: 'Website anelia-wolf.com', art: 'festpreis', status: 'versendet',
|
||
gesendetAm: '2026-10-06T08:15:00.000+00:00', auftragVom: '2026-10-05', summeCents: 80000,
|
||
positionen: [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 60000 }, { beschreibung: 'Texte', menge: 2, einzelpreisCents: 10000 }],
|
||
zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' },
|
||
}
|
||
const ANZ_KOPF = 'gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026 (Website anelia-wolf.com)'
|
||
|
||
test('auftragDatum: Berliner Tag des Sendens, sonst der Tag der Absprache', () => {
|
||
assert.equal(auftragDatum(AB), '2026-10-06')
|
||
assert.equal(auftragDatum({ ...AB, gesendetAm: '2026-10-06T22:30:00.000+00:00' }), '2026-10-07')
|
||
assert.equal(auftragDatum({ ...AB, gesendetAm: null }), '2026-10-05')
|
||
})
|
||
|
||
test('anzahlungPosition: ohne Angaben der Prozentsatz aus dem Zahlungsplan (sonst 50 %), Text wie in der Spec', () => {
|
||
assert.deepEqual(anzahlungPosition(AB, {}, 0), {
|
||
schluessel: 'anzahlung', beschreibung: `Anzahlung 50 % ${ANZ_KOPF}`, menge: 1, einzelpreisCents: 40000, betragCents: 40000,
|
||
})
|
||
assert.equal(anzahlungPosition({ ...AB, zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } }, {}, 0).betragCents, 40000)
|
||
assert.equal(anzahlungPosition({ ...AB, zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' } }, undefined, 0).betragCents, 24000)
|
||
})
|
||
|
||
test('anzahlungPosition: Prozent oder Betrag, Bezeichnung änderbar, Text je Variante, lange Titel gekürzt', () => {
|
||
assert.equal(anzahlungPosition(AB, { prozent: 30 }, 0).betragCents, 24000)
|
||
assert.equal(anzahlungPosition(AB, { prozent: 100 }, 0).betragCents, 80000)
|
||
const teil = anzahlungPosition(AB, { prozent: 25, bezeichnung: ' Teilrechnung ' }, 0)
|
||
assert.deepEqual([teil.beschreibung, teil.betragCents], [`Teilrechnung 25 % ${ANZ_KOPF}`, 20000])
|
||
const betrag = anzahlungPosition(AB, { betragCents: 25000 }, 0)
|
||
assert.deepEqual([betrag.beschreibung, betrag.betragCents], [`Anzahlung ${ANZ_KOPF}`, 25000])
|
||
const komma = anzahlungPosition(AB, { prozent: 33.3 }, 0)
|
||
assert.deepEqual([komma.beschreibung, komma.betragCents], [`Anzahlung 33,3 % ${ANZ_KOPF}`, 26640])
|
||
const lang = anzahlungPosition({ ...AB, titel: 'T'.repeat(300) }, { prozent: 50 }, 0).beschreibung
|
||
assert.equal(lang.length, 200)
|
||
assert.ok(lang.startsWith('Anzahlung 50 % gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026 (TTT'))
|
||
assert.ok(lang.endsWith('T…)'))
|
||
assert.equal(anzahlungPosition({ ...AB, titel: '' }, { prozent: 50 }, 0).beschreibung, 'Anzahlung 50 % gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026')
|
||
})
|
||
|
||
test('anzahlungPosition: höchstens Summe minus bereits Berechnetes, nichts mehr offen -> 409', () => {
|
||
assert.equal(anzahlungPosition(AB, {}, 40000).betragCents, 40000)
|
||
wirft(() => anzahlungPosition(AB, {}, 50000), 400, `Die Anzahlung darf höchstens 300,00${NBSP}€ betragen (Summe der Auftragsbestätigung minus bereits berechnet).`)
|
||
wirft(() => anzahlungPosition(AB, { betragCents: 80001 }, 0), 400, `Die Anzahlung darf höchstens 800,00${NBSP}€ betragen (Summe der Auftragsbestätigung minus bereits berechnet).`)
|
||
wirft(() => anzahlungPosition(AB, {}, 80000), 409, 'Nichts mehr offen.')
|
||
wirft(() => anzahlungPosition(AB, {}, 90000), 409, 'Nichts mehr offen.')
|
||
})
|
||
|
||
test('anzahlungPosition: ungültige Angaben', () => {
|
||
wirft(() => anzahlungPosition(AB, { prozent: 30, betragCents: 100 }, 0), 400, 'Anzahlung entweder in Prozent oder als Betrag angeben.')
|
||
for (const prozent of [0, -5, 100.5, Number.NaN, '50', Infinity]) wirft(() => anzahlungPosition(AB, { prozent }, 0), 400, 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.')
|
||
for (const betragCents of [0, -5, 12.5, '100', Number.NaN]) wirft(() => anzahlungPosition(AB, { betragCents }, 0), 400, 'Der Betrag muss eine ganze Zahl in Cent ab 1 sein.')
|
||
wirft(() => anzahlungPosition(AB, { prozent: 0.0001 }, 0), 400, 'Der Betrag muss mindestens 0,01 € betragen.')
|
||
for (const bezeichnung of ['', ' ', 'B'.repeat(61), 7]) wirft(() => anzahlungPosition(AB, { bezeichnung }, 0), 400, 'Die Bezeichnung braucht 1 bis 60 Zeichen.')
|
||
})
|
||
|
||
const FRUEH7 = { invoiceId: 'in_7', rechnungsnummer: 'WEBKLAR-0007', datum: '2026-10-08', betragCents: 40000, art: 'anzahlung' }
|
||
|
||
test('schlussPositionen: alle AB-Positionen, Zusatz, dann je frühere Rechnung eine Minuszeile (negativer Betrag, Menge 1)', () => {
|
||
assert.deepEqual(schlussPositionen(AB, [FRUEH7], [{ description: 'Domain-Registrierung', quantity: 1, unitAmountCents: 1500 }]), [
|
||
{ schluessel: 'ab-1', beschreibung: 'Website', menge: 1, einzelpreisCents: 60000, betragCents: 60000 },
|
||
{ schluessel: 'ab-2', beschreibung: 'Texte', menge: 2, einzelpreisCents: 10000, betragCents: 20000 },
|
||
{ schluessel: 'zusatz-1', beschreibung: 'Domain-Registrierung', menge: 1, einzelpreisCents: 1500, betragCents: 1500 },
|
||
{ schluessel: 'abzug-in_7', beschreibung: 'abzüglich Anzahlung, Rechnung WEBKLAR-0007 vom 08.10.2026', menge: 1, einzelpreisCents: -40000, betragCents: -40000 },
|
||
])
|
||
})
|
||
|
||
test('schlussPositionen: ohne frühere Rechnungen nur AB-Positionen; Teilrechnung für andere Arten; Abzüge nach Datum; Summe 0 ohne Zeile', () => {
|
||
assert.deepEqual(schlussPositionen(AB, [], []).map((p) => p.schluessel), ['ab-1', 'ab-2'])
|
||
assert.deepEqual(schlussPositionen(AB, []).map((p) => p.schluessel), ['ab-1', 'ab-2'])
|
||
const spaeter = { invoiceId: 'in_9', rechnungsnummer: 'WEBKLAR-0009', datum: '2026-10-12', betragCents: 10000, art: 'festbetrag' }
|
||
const ohneBetrag = { ...FRUEH7, invoiceId: 'in_0', betragCents: 0 }
|
||
assert.deepEqual(schlussPositionen(AB, [spaeter, ohneBetrag, FRUEH7]).slice(2).map((p) => [p.schluessel, p.beschreibung, p.betragCents]), [
|
||
['abzug-in_7', 'abzüglich Anzahlung, Rechnung WEBKLAR-0007 vom 08.10.2026', -40000],
|
||
['abzug-in_9', 'abzüglich Teilrechnung, Rechnung WEBKLAR-0009 vom 12.10.2026', -10000],
|
||
])
|
||
})
|
||
|
||
test('schlussPositionen: Summe nie 0 oder darunter (409), nie über 99.999.999 (400)', () => {
|
||
wirft(() => schlussPositionen(AB, [{ ...FRUEH7, betragCents: 80000 }]), 409, 'Nichts mehr offen.')
|
||
wirft(() => schlussPositionen(AB, [{ ...FRUEH7, betragCents: 90000 }]), 409, 'Nichts mehr offen.')
|
||
assert.equal(schlussPositionen(AB, [{ ...FRUEH7, betragCents: 79999 }]).reduce((s, p) => s + p.betragCents, 0), 1)
|
||
const gross = { ...AB, positionen: Array.from({ length: 10 }, (_, i) => ({ beschreibung: `P${i}`, menge: 1, einzelpreisCents: 10_000_000 })) }
|
||
wirft(() => schlussPositionen(gross, []), 400, 'Rechnungssumme zu hoch.')
|
||
})
|
||
|
||
test('schlussPositionen: bis zu 40 Zeilen (20 AB-Positionen, 10 Nachträge, 10 Abzüge), jede Gruppe mit eigener Grenze und Meldung', () => {
|
||
assert.deepEqual(SCHLUSS_GRENZEN, { abPositionen: 20, zusatz: 10, abzuege: 10, zeilen: 40 })
|
||
const abMit = (n) => ({ ...AB, summeCents: n * 1000, positionen: Array.from({ length: n }, (_, i) => ({ beschreibung: `Pos ${i + 1}`, menge: 1, einzelpreisCents: 1000 })) })
|
||
const abzug = (n) => ({ invoiceId: `in_k${String(n).padStart(2, '0')}`, rechnungsnummer: `WK-${String(n).padStart(4, '0')}`, datum: '2026-10-08', betragCents: 100, art: 'anzahlung' })
|
||
const abzuege = (n) => Array.from({ length: n }, (_, i) => abzug(i + 1))
|
||
const nachtraege = (n) => Array.from({ length: n }, (_, i) => ({ description: `Nachtrag ${i + 1}`, quantity: 1, unitAmountCents: 500 }))
|
||
// 20 AB-Positionen und ein Abzug: gültig (21 Zeilen)
|
||
assert.equal(schlussPositionen(abMit(20), abzuege(1)).length, 21)
|
||
// volle Ausbaustufe: 20 + 10 + 10 = 40 Zeilen, Reihenfolge AB, Nachträge, Abzüge
|
||
const voll = schlussPositionen(abMit(20), abzuege(10), nachtraege(10))
|
||
assert.equal(voll.length, 40)
|
||
assert.deepEqual([0, 19, 20, 29, 30, 39].map((i) => voll[i].schluessel), ['ab-1', 'ab-20', 'zusatz-1', 'zusatz-10', 'abzug-in_k01', 'abzug-in_k10'])
|
||
assert.equal(voll.reduce((s, p) => s + p.betragCents, 0), 20000 + 5000 - 1000)
|
||
// jede Gruppe über ihrer Grenze: 400 mit eigener Meldung
|
||
wirft(() => schlussPositionen(abMit(20), abzuege(11)), 400, 'Die Schlussrechnung hätte 11 Abzüge für frühere Rechnungen; erlaubt sind höchstens 10.')
|
||
wirft(() => schlussPositionen(abMit(20), [], nachtraege(11)), 400, 'Eine Schlussrechnung erlaubt höchstens 10 Zusatzpositionen.')
|
||
wirft(() => schlussPositionen(abMit(21), []), 400, 'Die Auftragsbestätigung hat 21 Positionen; eine Schlussrechnung übernimmt höchstens 20.')
|
||
// andere Eingaben für die Zusatzpositionen prüft zusatzPositionen wie sonst
|
||
wirft(() => schlussPositionen(AB, [], 'Domain'), 400, ANZAHL)
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Lauf, erwartet FAIL**
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegePositionen.test.js`
|
||
Expected: FAIL (`SyntaxError: … does not provide an export named 'auftragDatum'`).
|
||
|
||
- [ ] **Step 3: Implementieren** in `server/services/auftraege/positionen.js`. Regeln:
|
||
- `auftragDatum(auftrag)`: ist `gesendetAm` ein lesbares Datum (`new Date(...)` nicht ungültig), `berlinDate(new Date(gesendetAm))`; sonst `auftrag.auftragVom || ''`.
|
||
- `anzahlungPosition(auftrag, angaben = {}, bereitsBerechnetCents = 0)` (`prozent` und `betragCents` gelten als „nicht angegeben“, wenn `undefined` oder `null`), Prüfreihenfolge:
|
||
1. `bezeichnung` (Standard `Anzahlung`) getrimmt, 1 bis 60 Zeichen und ein String, sonst 400 `Die Bezeichnung braucht 1 bis 60 Zeichen.`
|
||
2. beides angegeben → 400 `Anzahlung entweder in Prozent oder als Betrag angeben.`
|
||
3. nichts angegeben → `prozent` = `auftrag.zahlungsplan.prozent`, wenn `zahlungsplan.art === 'anzahlung'` und der Wert gesetzt ist, sonst `LIMITS.standardAnzahlungProzent`.
|
||
4. `prozent` muss eine endliche Zahl (Typ `number`) `> 0` und `<= 100` sein (400 `Der Prozentsatz muss größer als 0 und höchstens 100 sein.`), Betrag = `Math.round(auftrag.summeCents * prozent / 100)`; ist er `< 1`: 400 `Der Betrag muss mindestens 0,01 € betragen.` (normales Leerzeichen). `betragCents` muss eine ganze Zahl `>= 1` sein (400 `Der Betrag muss eine ganze Zahl in Cent ab 1 sein.`).
|
||
5. `rest = auftrag.summeCents - bereitsBerechnetCents`; `rest <= 0` → `AuftragError(409, 'Nichts mehr offen.')`; Betrag über `rest` → 400 `` `Die Anzahlung darf höchstens ${euroDe(rest)} betragen (Summe der Auftragsbestätigung minus bereits berechnet).` ``
|
||
6. Beschreibung: `davor` = `` `${bezeichnung}${prozentTeil} gemäß Auftragsbestätigung ${nummer} vom ${formatDe(auftragDatum(auftrag))}` `` mit `prozentTeil` = `` ` ${String(prozent).replace('.', ',')} %` `` nur bei Prozent; dahinter `` ` (${kuerzen(titel, 200 - davor.length - 3)})` ``, damit das Ganze höchstens 200 Zeichen hat; bei leerem Titel entfällt der Klammerteil. Position: Schlüssel `anzahlung`, `menge` 1, Einzelpreis = Betrag.
|
||
- `export const SCHLUSS_GRENZEN = { abPositionen: 20, zusatz: 10, abzuege: 10, zeilen: 40 }` (Kommentar: Übersicht, Nachträge nach der Planrunde; alle anderen Rechnungen bleiben bei `LIMITS.maxPositionen`).
|
||
- `schlussPositionen(auftrag, fruehere, zusatz = [])`: zuerst die Gruppengrenzen, in dieser Reihenfolge und vor jedem Bau: (1) mehr als `SCHLUSS_GRENZEN.abPositionen` Einträge in `auftrag.positionen` → 400 `` `Die Auftragsbestätigung hat ${n} Positionen; eine Schlussrechnung übernimmt höchstens 20.` ``; (2) `zusatz` ist ein Array mit mehr als `SCHLUSS_GRENZEN.zusatz` Einträgen → 400 `Eine Schlussrechnung erlaubt höchstens 10 Zusatzpositionen.` (jede andere Eingabe prüft `zusatzPositionen`); (3) mehr als `SCHLUSS_GRENZEN.abzuege` Einträge von `fruehere` mit `betragCents > 0` → 400 `` `Die Schlussrechnung hätte ${n} Abzüge für frühere Rechnungen; erlaubt sind höchstens 10.` ``. Dann bauen: `ab-<n>` aus `auftrag.positionen` (`beschreibung`, `menge`, `einzelpreisCents`), dann `zusatzPositionen(zusatz)`, dann je Eintrag von `fruehere` mit `betragCents > 0` (Summe 0 oder darunter erzeugt keine Zeile) eine Minuszeile, sortiert nach `datum`, dann `invoiceId`: Schlüssel `abzug-<invoiceId>`, Beschreibung `` `abzüglich ${art === 'anzahlung' ? 'Anzahlung' : 'Teilrechnung'}, Rechnung ${rechnungsnummer} vom ${formatDe(datum)}` ``, `menge` 1, Einzelpreis und Betrag `-betragCents`. Danach: Summe über 99_999_999 → 400 `Rechnungssumme zu hoch.`; Summe `<= 0` → `AuftragError(409, 'Nichts mehr offen.')`. Die Zeilenzahl kann 40 nicht überschreiten, die Gruppengrenzen genügen; `rechnungsbau.js` prüft sie im Abschluss trotzdem mit `pruefePositionen(…, { max: SCHLUSS_GRENZEN.zeilen })`.
|
||
|
||
- [ ] **Step 4: Lauf, erwartet PASS**
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegePositionen.test.js`
|
||
Expected: PASS, `# fail 0`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2
|
||
git add server/services/auftraege/positionen.js test/auftraegePositionen.test.js
|
||
git commit -m "Rechnungen: Anzahlung und Schlussrechnung mit Minuszeilen (Positionen)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Stripe-Nachbau – Zusatzfelder, Seitengröße, Minuszeilen
|
||
|
||
**Files:**
|
||
- Modify: `test/support/fakeStripe.js` (nur Ergänzungen, Ausnahme von „nur L0“ laut Übersicht)
|
||
- Create: `test/auftraegeFakeStripe.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `startFakeStripe()` (bestehend), `getStripe()` (`server/services/stripeBilling.js`).
|
||
- Produces: `POST /v1/invoices` speichert `custom_fields` und `rendering` (sonst `null`), liefert sie in `GET /v1/invoices/:id` und prüft sie wie Stripe; `POST /v1/invoiceitems` nimmt `amount` (auch negativ) ohne `quantity` an und lehnt `amount` zusammen mit `quantity` oder `unit_amount_decimal` ab.
|
||
|
||
- [ ] **Step 1: Failing Test** – `test/auftraegeFakeStripe.test.js` anlegen:
|
||
|
||
```js
|
||
import { test, after } 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'
|
||
|
||
const aw = await startAppwriteMock({ schema: {} })
|
||
const st = await startFakeStripe()
|
||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||
const { getStripe } = await import('../server/services/stripeBilling.js')
|
||
after(() => Promise.all([aw.close(), st.close()]))
|
||
|
||
const refused = (promise, pattern) => assert.rejects(promise, (err) => err.statusCode === 400 && pattern.test(err.message))
|
||
const create = (extra = {}) => getStripe().invoices.create({ customer: 'cus_x', collection_method: 'send_invoice', days_until_due: 14, ...extra })
|
||
const item = (invoice, extra) => getStripe().invoiceItems.create({ customer: 'cus_x', invoice, description: 'Posten', currency: 'eur', ...extra })
|
||
|
||
test('Stripe-Nachbau: custom_fields und rendering werden gespeichert und mit der Rechnung geliefert', async () => {
|
||
const felder = [{ name: 'Projekt', value: 'Website anelia-wolf.com' }, { name: 'Auftrag', value: 'AB-2026-0002 vom 06.10.2026' }]
|
||
const invoice = await create({ custom_fields: felder, rendering: { pdf: { page_size: 'a4' } } })
|
||
assert.deepEqual(st.invoices.get(invoice.id).custom_fields, felder)
|
||
assert.deepEqual(st.invoices.get(invoice.id).rendering, { pdf: { page_size: 'a4' } })
|
||
const gelesen = await getStripe().invoices.retrieve(invoice.id)
|
||
assert.deepEqual(gelesen.custom_fields, felder)
|
||
assert.deepEqual(gelesen.rendering, { pdf: { page_size: 'a4' } })
|
||
await item(invoice.id, { quantity: 1, unit_amount_decimal: '1000' })
|
||
assert.deepEqual(st.invoices.get(invoice.id).custom_fields, felder, 'Posten ändern die Zusatzfelder nicht')
|
||
const schlicht = await create()
|
||
assert.equal(st.invoices.get(schlicht.id).custom_fields, null)
|
||
assert.equal(st.invoices.get(schlicht.id).rendering, null)
|
||
})
|
||
|
||
test('Stripe-Nachbau: Grenzen der Zusatzfelder (4, Name 40, Wert 140) und der Seitengröße wie bei Stripe', async () => {
|
||
const viele = Array.from({ length: 5 }, (_, i) => ({ name: `Feld ${i}`, value: 'x' }))
|
||
await refused(create({ custom_fields: viele }), /up to 4 custom fields/)
|
||
await refused(create({ custom_fields: [{ name: 'n'.repeat(41), value: 'x' }] }), /names must be between 1 and 40/)
|
||
await refused(create({ custom_fields: [{ name: 'n', value: 'v'.repeat(141) }] }), /values must be between 1 and 140/)
|
||
await refused(create({ rendering: { pdf: { page_size: 'din-a4' } } }), /page_size/)
|
||
const vier = await create({ custom_fields: viele.slice(0, 4).map((f) => ({ ...f, value: 'v'.repeat(140) })), rendering: { pdf: { page_size: 'letter' } } })
|
||
assert.equal(st.invoices.get(vier.id).custom_fields.length, 4)
|
||
})
|
||
|
||
test('Stripe-Nachbau: Posten mit negativem amount (ohne quantity) mindern die Summe; amount mit quantity oder Einzelpreis wird abgelehnt', async () => {
|
||
const invoice = await create()
|
||
await item(invoice.id, { description: 'Leistung', quantity: 1, unit_amount_decimal: '40000' })
|
||
await item(invoice.id, { description: 'abzüglich Anzahlung', amount: -15000 })
|
||
const gespeichert = st.invoices.get(invoice.id)
|
||
assert.deepEqual(gespeichert.lines.data.map((l) => [l.description, l.quantity, l.amount]), [['Leistung', 1, 40000], ['abzüglich Anzahlung', 1, -15000]])
|
||
assert.deepEqual([gespeichert.total, gespeichert.amount_due], [25000, 25000])
|
||
await refused(item(invoice.id, { amount: -100, quantity: 2 }), /only specify one of these parameters/)
|
||
await refused(item(invoice.id, { amount: -100, unit_amount_decimal: '100' }), /only specify one of these parameters/)
|
||
assert.equal(st.invoices.get(invoice.id).total, 25000)
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Lauf, erwartet FAIL**
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeFakeStripe.test.js`
|
||
Expected: FAIL (`custom_fields` ist `undefined` statt der Felder; die Grenzen werden nicht abgelehnt).
|
||
|
||
- [ ] **Step 3: Implementieren** in `test/support/fakeStripe.js` (nur Ergänzungen):
|
||
- Kopfkommentar, Absatz „Rechnungen schreiben“: einen Satz anhängen: `custom_fields` (höchstens 4, Name 1 bis 40, Wert 1 bis 140 Zeichen) und `rendering[pdf][page_size]` (a4, auto, letter) werden wie bei Stripe geprüft, gespeichert (sonst `null`) und mit der Rechnung geliefert; Rechnungsposten mit `amount` (auch negativ) tragen weder `quantity` noch `unit_amount_decimal`.
|
||
- In `POST /v1/invoices`, direkt nach dem Zusammenführen der Metadaten (`const metadata = mergeMetadata(...)`/`if (Array.isArray(metadata)) return metadata`):
|
||
|
||
```js
|
||
const customFields = data.custom_fields
|
||
if (customFields !== undefined) {
|
||
if (!Array.isArray(customFields) || customFields.length > 4) return invalid('custom_fields', 'You can specify up to 4 custom fields.')
|
||
for (const [i, field] of customFields.entries()) {
|
||
if (typeof field?.name !== 'string' || field.name.length < 1 || field.name.length > 40) {
|
||
return invalid(`custom_fields[${i}][name]`, 'Custom field names must be between 1 and 40 characters.')
|
||
}
|
||
if (typeof field.value !== 'string' || field.value.length < 1 || field.value.length > 140) {
|
||
return invalid(`custom_fields[${i}][value]`, 'Custom field values must be between 1 and 140 characters.')
|
||
}
|
||
}
|
||
}
|
||
const pageSize = data.rendering?.pdf?.page_size
|
||
if (pageSize !== undefined && !['a4', 'auto', 'letter'].includes(pageSize)) {
|
||
return invalid('rendering[pdf][page_size]', 'Invalid page_size: must be one of a4, auto, letter.')
|
||
}
|
||
```
|
||
|
||
und im Rechnungsobjekt (neben `metadata,`) `custom_fields: customFields ?? null, rendering: data.rendering ?? null,` ergänzen.
|
||
- In `POST /v1/invoiceitems`, direkt nach der Prüfung `if (!data.customer) …`:
|
||
|
||
```js
|
||
if (data.amount !== undefined && (data.quantity !== undefined || data.unit_amount_decimal !== undefined)) {
|
||
return invalid('amount', 'You may only specify one of these parameters: amount, or quantity with unit_amount_decimal.')
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Lauf, erwartet PASS** – neue Datei und die Tests, die den Nachbau benutzen:
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && for f in auftraegeFakeStripe financeStripeWrite financeStripeRead financeAssign; do echo "== $f"; timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/$f.test.js 2>&1 | command grep -a -E "^# (pass|fail)"; done`
|
||
Expected: je Datei `# fail 0`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2
|
||
git add test/support/fakeStripe.js test/auftraegeFakeStripe.test.js
|
||
git commit -m "Stripe-Nachbau: Zusatzfelder, Seitengröße und Minuszeilen wie bei Stripe
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: `rechnungsbau.js` – Gerüst, freie Rechnung und Festbetrag
|
||
|
||
**Files:**
|
||
- Create: `server/services/auftraege/rechnungsbau.js`
|
||
- Create: `test/auftraegeRechnungenHilfe.js`
|
||
- Create: `test/auftraegeRechnungsbau.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `festePositionen`, `pruefePositionen`, `kuerzen`, `auftragDatum`, `SCHLUSS_GRENZEN` (`./positionen.js`); `AuftragError` (`./fehler.js`); `RECHNUNGS_ARTEN`, `LIMITS` (`./konstanten.js`); `getDocument(collection, id)` (`../appwriteAdmin.js`); `createAuftrag(daten)`, `updateAuftrag(id, patch)` (`./auftragStore.js`) und `createProjektRechnung(invoiceId, daten)` (`./projektRechnungenStore.js`), diese drei nur in der Testhilfe.
|
||
- Produces:
|
||
- `ladeFestenKunden(customerId: string): Promise<KundenDokument>` (`AuftragError` 400; wird von `stripeFinance.js` mitbenutzt).
|
||
- `baueRechnung(eingabe): Promise<RechnungsEntwurf>` mit `eingabe = { customerId, art = 'frei', projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, createdBy }` und `RechnungsEntwurf = { kunde, art, projekt /* { id, name, ticketId }|null */, auftrag /* Auftrag|null */, positionen, summeCents, leistungszeitraum, customFields /* [{ name, value }] */, metadata, projektRechnung /* Daten für createProjektRechnung ohne invoiceId, oder null */ }` (V4; `createdBy` ist die Ergänzung aus Vertragsfrage 2).
|
||
- Testhilfe (`test/auftraegeRechnungenHilfe.js`): `werktage(start, n)`, `seedRechnungsDaten(aw)`, `legeAuftragAn(over = {}, patch = {})`, `legeRechnungAn(st, id, optionen = {})` (Stripe-Rechnung im Nachbau plus Projektdatensatz), `leereRechnungsDaten(aw, st)`.
|
||
|
||
- [ ] **Step 1: Failing Test** – zuerst die Testhilfe `test/auftraegeRechnungenHilfe.js` anlegen (Testdaten, kein Testlauf):
|
||
|
||
```js
|
||
// Gemeinsame Testdaten für die Rechnungstests des Strangs L2. Erst nach useTestEnv() importieren (zieht config.js).
|
||
import { config } from '../server/config.js'
|
||
import { deleteDocument } from '../server/services/appwriteAdmin.js'
|
||
import { createAuftrag, updateAuftrag } from '../server/services/auftraege/auftragStore.js'
|
||
import { createProjektRechnung } from '../server/services/auftraege/projektRechnungenStore.js'
|
||
|
||
/** n Arbeitstage (Montag bis Freitag) ab start (YYYY-MM-DD, einschließlich). */
|
||
export function werktage(start, n) {
|
||
const tage = []
|
||
for (let ms = Date.parse(`${start}T00:00:00Z`); tage.length < n; ms += 86_400_000) {
|
||
const tag = new Date(ms)
|
||
if (tag.getUTCDay() % 6 !== 0) tage.push(tag.toISOString().slice(0, 10))
|
||
}
|
||
return tage
|
||
}
|
||
|
||
const deutsch = (tag) => tag.split('-').reverse().join('.')
|
||
|
||
/**
|
||
* Kunden k1 (fest, mit Anschrift: Frau Wolf), k2 (fest), lead1 (Interessent); Projekte p1 (k1, Ticket t1), p2 (k2, Ticket t2),
|
||
* p3 (k1, Ticket t4); Tickets t1 bis t4 (t2 gehört k2); Arbeitsblätter ws1 bis wsT3 (Oktober) und wl01 bis wl20 (Großprojekt).
|
||
*/
|
||
export function seedRechnungsDaten(aw) {
|
||
const kunde = (id, over = {}) =>
|
||
aw.seed('customers', { $id: id, email: `${id}@example.invalid`, name: `Kunde ${id}`, customerStatus: 'active', status: 'active', street: 'Hauptstr. 1', postalCode: '10115', city: 'Berlin', ...over })
|
||
kunde('k1', { name: 'Anelia Wolf', email: 'wolf@example.invalid' })
|
||
kunde('k2')
|
||
kunde('lead1', { customerStatus: 'lead' })
|
||
const projekt = (id, customerId, projectName, ticketId) => aw.seed('websiteProjects', { $id: id, customerId, projectName, ticketId })
|
||
projekt('p1', 'k1', 'Website anelia-wolf.com', 't1')
|
||
projekt('p2', 'k2', 'Fremdes Projekt', 't2')
|
||
projekt('p3', 'k1', 'Großprojekt', 't4')
|
||
for (const [id, customerId, woid] of [['t1', 'k1', 'WO-100'], ['t2', 'k2', 'WO-200'], ['t3', 'k1', 'WO-101'], ['t4', 'k1', 'WO-102']]) {
|
||
aw.seed('workorders', { $id: id, customerId, woid, topic: `Ticket ${woid}`, status: 'occupied' })
|
||
}
|
||
const blatt = (id, workorderId, startDate, totalTime, details, over = {}) =>
|
||
aw.seed('worksheets', { $id: id, workorderId, woid: 'WO', startDate, totalTime, details, serviceType: 'remote', countsToPlan: false, isComment: false, ...over })
|
||
blatt('ws1', 't1', '06.10.2026', 90, 'Startseite')
|
||
blatt('ws2', 't1', '06.10.2026', 45, 'Kontaktformular')
|
||
blatt('ws3', 't1', '07.10.2026', 60, 'Impressum')
|
||
blatt('wsAbo', 't1', '07.10.2026', 60, 'Wartung', { countsToPlan: true })
|
||
blatt('wsKomm', 't1', '07.10.2026', 30, 'Notiz', { isComment: true })
|
||
blatt('wsNull', 't1', '07.10.2026', 0, 'Leer')
|
||
blatt('wsFremd', 't2', '07.10.2026', 60, 'Fremd')
|
||
blatt('wsT3', 't3', '08.10.2026', 30, 'Zusatzticket')
|
||
// Großprojekt: 20 Arbeitstage (vier Wochen, KW 45 bis 48) zu je 60 Minuten
|
||
werktage('2026-11-02', 20).forEach((tag, i) => blatt(`wl${String(i + 1).padStart(2, '0')}`, 't4', deutsch(tag), 60, `Etappe ${i + 1}`))
|
||
}
|
||
|
||
/**
|
||
* Eine versendete Festpreis-AB des Kunden k1 zu p1 (80.000 Cent: Website 600 €, Texte 2 × 100 €; Anzahlung 50 %; AB-2026-0002,
|
||
* gesendet am 06.10.2026) über die L0-Speicher anlegen. over gilt für createAuftrag, patch für das anschließende updateAuftrag
|
||
* (Status, Nummer, bestaetigtAm, …).
|
||
*/
|
||
export async function legeAuftragAn(over = {}, patch = {}) {
|
||
const roh = await createAuftrag({
|
||
kundenId: 'k1', projektId: 'p1', titel: 'Website anelia-wolf.com', zeitquelle: [], art: 'festpreis',
|
||
positionen: [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 60000 }, { beschreibung: 'Texte', menge: 2, einzelpreisCents: 10000 }],
|
||
stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
|
||
zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14,
|
||
auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '', fertigBis: '', erstelltVon: 'kenso@webklar.com',
|
||
...over,
|
||
})
|
||
return updateAuftrag(roh.id, { status: 'versendet', nummer: 'AB-2026-0002', jahr: 2026, laufnummer: 2, gesendetAm: '2026-10-06T08:15:00.000Z', ...patch })
|
||
}
|
||
|
||
const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000)
|
||
|
||
/**
|
||
* Eine Projektrechnung wie nach createProjectInvoice: Stripe-Rechnung im Nachbau (status draft, open, paid oder void; ohneStripe: die
|
||
* Rechnung gibt es bei Stripe nicht, z. B. im Dashboard gelöscht) plus aktiver Projektdatensatz. docNummer ist die Nummer im Datensatz
|
||
* (Vorgabe: die der Rechnung, bei einem Entwurf leer).
|
||
*/
|
||
export async function legeRechnungAn(st, id, {
|
||
status = 'draft', nummer = 'WEBKLAR-0007', total = 40000, art = 'aufwand', auftragId = '', projektId = 'p1',
|
||
arbeitsblattIds = [], finalisiert = '2026-10-08T10:00:00.000Z', ohneStripe = false, docNummer,
|
||
} = {}) {
|
||
const entwurf = status === 'draft'
|
||
if (!ohneStripe) {
|
||
st.invoices.set(id, {
|
||
id, object: 'invoice', number: entwurf ? null : nummer, status, customer: 'cus_x', created: unix(finalisiert), total, amount_due: total,
|
||
amount_paid: status === 'paid' ? total : 0, amount_remaining: status === 'paid' ? 0 : total,
|
||
status_transitions: { finalized_at: entwurf ? null : unix(finalisiert) }, metadata: { art }, lines: { object: 'list', data: [], has_more: false },
|
||
})
|
||
}
|
||
await createProjektRechnung(id, {
|
||
kundenId: 'k1', projektId, auftragId, art, betragCents: total, arbeitsblattIds,
|
||
rechnungsnummer: docNummer ?? (entwurf ? '' : nummer), aktiv: true, erstelltVon: 'k', erstelltAm: new Date().toISOString(),
|
||
})
|
||
}
|
||
|
||
/** Aufträge und Projektdatensätze löschen, Stripe-Nachbau (Rechnungen, Kunden, Aufrufe) leeren. */
|
||
export async function leereRechnungsDaten(aw, st) {
|
||
for (const sammlung of [config.collections.auftraege, config.collections.projektRechnungen]) {
|
||
for (const id of aw.docs(sammlung).keys()) await deleteDocument(sammlung, id)
|
||
}
|
||
st.invoices.clear()
|
||
st.customers.clear()
|
||
st.calls.length = 0
|
||
}
|
||
```
|
||
|
||
Dann `test/auftraegeRechnungsbau.test.js` anlegen:
|
||
|
||
```js
|
||
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 { schemaKeys } from './support/schema.js'
|
||
import { useTestEnv } from './support/testEnv.js'
|
||
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
|
||
|
||
// Nachbau von Appwrite mit dem Schema und den Datumsfeldern der neuen Collections und dem Standard-Limit 25 (wie in den Tests von L0)
|
||
const aw = await startAppwriteMock({
|
||
schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') },
|
||
datetimes: AUFTRAEGE_DATETIMES,
|
||
defaultLimit: 25,
|
||
})
|
||
const st = await startFakeStripe()
|
||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||
const { config } = await import('../server/config.js')
|
||
const { seedRechnungsDaten, legeAuftragAn, legeRechnungAn, leereRechnungsDaten } = await import('./auftraegeRechnungenHilfe.js')
|
||
const { baueRechnung } = await import('../server/services/auftraege/rechnungsbau.js')
|
||
const { getProjektRechnung, updateProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js')
|
||
const { getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||
|
||
after(() => Promise.all([aw.close(), st.close()]))
|
||
seedRechnungsDaten(aw)
|
||
config.auftraege.mode = 'on'
|
||
beforeEach(() => leereRechnungsDaten(aw, st))
|
||
|
||
const NBSP = String.fromCharCode(160) // geschütztes Leerzeichen U+00A0, wie euroDe es vor € setzt
|
||
const SCHALTER_AUS = 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.'
|
||
const POSTEN = [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }]
|
||
const wirft = (promise, status, message) =>
|
||
assert.rejects(promise, (err) => {
|
||
assert.equal(err.status, status, `Status ${err.status}: ${err.message}`)
|
||
assert.equal(err.message, message)
|
||
return true
|
||
})
|
||
|
||
test('baueRechnung frei: freie Positionen, nur Kopf-Metadaten, keine Zusatzfelder, kein Projektdatensatz', async () => {
|
||
const e = await baueRechnung({ customerId: 'k1', items: POSTEN, createdBy: 'kenso@webklar.com' })
|
||
assert.equal(e.art, 'frei')
|
||
assert.equal(e.kunde.$id, 'k1')
|
||
assert.equal(e.projekt, null)
|
||
assert.equal(e.auftrag, null)
|
||
assert.deepEqual(e.positionen, [{ schluessel: 'pos-1', beschreibung: 'Domain', menge: 1, einzelpreisCents: 1200, betragCents: 1200 }])
|
||
assert.equal(e.summeCents, 1200)
|
||
assert.equal(e.leistungszeitraum, '')
|
||
assert.deepEqual(e.customFields, [])
|
||
assert.deepEqual(e.metadata, { webklarCustomerId: 'k1', quelle: 'woms', erstelltVon: 'kenso@webklar.com', art: 'frei' })
|
||
assert.equal(e.projektRechnung, null)
|
||
assert.equal((await baueRechnung({ customerId: 'k1', items: POSTEN })).metadata.erstelltVon, '')
|
||
})
|
||
|
||
test('baueRechnung frei: Projekt, Auftrag, Arbeitsblätter und Anzahlung der Eingabe bleiben ohne Wirkung', async () => {
|
||
const e = await baueRechnung({ customerId: 'k1', art: 'frei', items: POSTEN, projektId: 'p1', auftragId: 'a1', arbeitsblattIds: ['ws1'], anzahlung: { prozent: 10 } })
|
||
assert.equal(e.projekt, null)
|
||
assert.equal(e.auftrag, null)
|
||
assert.equal(e.projektRechnung, null)
|
||
assert.deepEqual(e.customFields, [])
|
||
assert.deepEqual(Object.keys(e.metadata).sort(), ['art', 'erstelltVon', 'quelle', 'webklarCustomerId'])
|
||
})
|
||
|
||
test('baueRechnung: Leistungszeitraum ist optional, wird getrimmt und hat höchstens 140 Zeichen', async () => {
|
||
const e = await baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum: ' Oktober 2026 ' })
|
||
assert.equal(e.leistungszeitraum, 'Oktober 2026')
|
||
assert.deepEqual(e.customFields, [{ name: 'Leistungszeitraum', value: 'Oktober 2026' }])
|
||
assert.equal((await baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum: 'z'.repeat(140) })).leistungszeitraum.length, 140)
|
||
for (const leer of [undefined, null, '', ' ']) assert.deepEqual((await baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum: leer })).customFields, [])
|
||
const MELDUNG = 'Der Leistungszeitraum muss ein Text mit höchstens 140 Zeichen sein.'
|
||
for (const leistungszeitraum of ['z'.repeat(141), 5, {}]) await wirft(baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum }), 400, MELDUNG)
|
||
})
|
||
|
||
test('baueRechnung: unbekannte Art, unbekannter oder nicht fester Kunde, ungültige Positionen -> 400 ohne Stripe-Aufruf', async () => {
|
||
for (const art of ['quatsch', 'FREI', 5, {}]) await wirft(baueRechnung({ customerId: 'k1', art, items: POSTEN }), 400, 'Unbekannte Rechnungsart.')
|
||
for (const customerId of [undefined, '', 'gibtEsNicht', '../x', 5]) await wirft(baueRechnung({ customerId, items: POSTEN }), 400, 'Kunde nicht gefunden.')
|
||
await wirft(baueRechnung({ customerId: 'lead1', items: POSTEN }), 400, 'Rechnungen nur für feste Kunden.')
|
||
await wirft(baueRechnung({ customerId: 'k1' }), 400, 'Eine Rechnung braucht 1 bis 20 Positionen.')
|
||
assert.equal(st.calls.length, 0)
|
||
})
|
||
|
||
test('baueRechnung festbetrag: Projekt, Zusatzfeld Projekt, Metadaten und Projektdatensatz', async () => {
|
||
const items = [{ description: 'Erstellung der Website anelia-wolf.com, Pauschalpreis laut Absprache', quantity: 1, unitAmountCents: 50000 }]
|
||
const e = await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items, createdBy: 'kenso@webklar.com' })
|
||
assert.deepEqual(e.projekt, { id: 'p1', name: 'Website anelia-wolf.com', ticketId: 't1' })
|
||
assert.equal(e.auftrag, null)
|
||
assert.equal(e.summeCents, 50000)
|
||
assert.deepEqual(e.customFields, [{ name: 'Projekt', value: 'Website anelia-wolf.com' }])
|
||
assert.deepEqual(e.metadata, { webklarCustomerId: 'k1', quelle: 'woms', erstelltVon: 'kenso@webklar.com', art: 'festbetrag', projektId: 'p1', projektName: 'Website anelia-wolf.com' })
|
||
const { erstelltAm, ...rest } = e.projektRechnung
|
||
assert.match(erstelltAm, /^\d{4}-\d{2}-\d{2}T/)
|
||
assert.deepEqual(rest, { kundenId: 'k1', projektId: 'p1', auftragId: '', art: 'festbetrag', betragCents: 50000, arbeitsblattIds: [], rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com' })
|
||
// eine genannte auftragId bleibt bei dieser Art ohne Wirkung
|
||
assert.equal((await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', auftragId: 'a1', items })).auftrag, null)
|
||
})
|
||
|
||
test('baueRechnung festbetrag: lange Projektnamen werden auf 140 Zeichen gekürzt, leere ersetzt', async () => {
|
||
aw.seed('websiteProjects', { $id: 'plang', customerId: 'k1', projectName: 'P'.repeat(200), ticketId: '' })
|
||
aw.seed('websiteProjects', { $id: 'psub', customerId: 'k1', projectName: '', subdomain: 'wolf-test', ticketId: '' })
|
||
aw.seed('websiteProjects', { $id: 'pleer', customerId: 'k1', ticketId: '' })
|
||
const e = await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId: 'plang', items: POSTEN })
|
||
assert.equal(e.customFields[0].value.length, 140)
|
||
assert.ok(e.customFields[0].value.endsWith('P…'))
|
||
assert.equal(e.metadata.projektName, e.customFields[0].value)
|
||
const name = async (projektId) => (await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId, items: POSTEN })).customFields[0].value
|
||
assert.equal(await name('psub'), 'wolf-test')
|
||
assert.equal(await name('pleer'), 'Projekt')
|
||
})
|
||
|
||
test('baueRechnung festbetrag: Projekt ist Pflicht, fremde und unbekannte Projekte sind 404 (kein Unterschied), ungültige Positionen 400', async () => {
|
||
const mit = (projektId, items = POSTEN) => baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId, items })
|
||
for (const projektId of [undefined, '', null, 5]) await wirft(mit(projektId), 400, 'Für diese Rechnungsart ist ein Projekt nötig.')
|
||
for (const projektId of ['gibtEsNicht', 'p2', '../x']) await wirft(mit(projektId), 404, 'Projekt nicht gefunden.')
|
||
await wirft(mit('p1', []), 400, 'Eine Rechnung braucht 1 bis 20 Positionen.')
|
||
assert.equal(st.calls.length, 0)
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Lauf, erwartet FAIL**
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeRechnungsbau.test.js`
|
||
Expected: FAIL mit `ERR_MODULE_NOT_FOUND` (`rechnungsbau.js` fehlt).
|
||
|
||
- [ ] **Step 3: Implementieren** in `server/services/auftraege/rechnungsbau.js` (neu). Imports: `config` (`../../config.js`), `getDocument`, `listDocuments`, `Query` (`../appwriteAdmin.js`), `AuftragError` (`./fehler.js`), `RECHNUNGS_ARTEN`, `LIMITS` (`./konstanten.js`), `festePositionen`, `pruefePositionen`, `kuerzen`, `auftragDatum`, `SCHLUSS_GRENZEN` (`./positionen.js`), `formatDe` (`../finance/dates.js`). **Kein Import aus `stripeFinance.js`.** Interne Konstante `KENNUNG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/` (Appwrite-Dokument-ID).
|
||
- **`export async function ladeFestenKunden(customerId)`** übernimmt die Regeln des bisherigen `loadBillableCustomer` aus `stripeFinance.js`, aber mit `AuftragError`: `customerId` kein String, leer oder nicht zu `KENNUNG` passend → 400 `Kunde nicht gefunden.`; `getDocument(config.collections.customers, id)` mit Status 404 → 400 `Kunde nicht gefunden.` (andere Fehler weiterreichen); `customerStatus` (getrimmt, klein geschrieben) `lead` oder `lost` → 400 `Rechnungen nur für feste Kunden.`
|
||
- **`async function ladeProjekt(kundenId, projektId)`** (intern) → `{ id, name, ticketId }`: `projektId` kein String oder leer → 400 `Für diese Rechnungsart ist ein Projekt nötig.`; passt nicht zu `KENNUNG`, fehlt in `config.collections.websiteProjects` (404) oder gehört einem anderen Kunden (`customerId` ≠ `kundenId`) → 404 `Projekt nicht gefunden.` (kein Unterschied zwischen „gibt es nicht“ und „gehört einem anderen“); `name` = `projectName || subdomain || 'Projekt'`, `ticketId` = `ticketId || ''`.
|
||
- **`export async function baueRechnung(eingabe)`**, Ablauf:
|
||
1. `art = eingabe.art ?? 'frei'`; nicht in `RECHNUNGS_ARTEN` (auch Nicht-Strings) → 400 `Unbekannte Rechnungsart.`
|
||
2. `kunde = await ladeFestenKunden(eingabe.customerId)`.
|
||
3. `leistungszeitraum`: `undefined`, `null` oder nach `trim()` leer → `''`; sonst ein String mit höchstens `LIMITS.maxLeistungszeitraum` Zeichen (getrimmt), sonst 400 `Der Leistungszeitraum muss ein Text mit höchstens 140 Zeichen sein.`
|
||
4. Zweig je Art: `frei`: `positionen = festePositionen(items)`, weder Projekt noch Auftrag; `projektId`, `auftragId`, `arbeitsblattIds`, `anzahlung`, `zeilentexte` der Eingabe haben keine Wirkung. `festbetrag`: `projekt = await ladeProjekt(kunde.$id, projektId)`, `auftrag = null` (eine genannte `auftragId` bleibt bei dieser Art ohne Wirkung, Vertragsfrage 10), `positionen = festePositionen(items)`. `aufwand`, `anzahlung`, `schluss`: vorerst `throw new AuftragError(501, 'Rechnungsart noch nicht umgesetzt.')` (die Tasks 7 und 8 ersetzen das).
|
||
5. **Abschluss** (gemeinsame interne Funktion aller Zweige): `summeCents = pruefePositionen(positionen, { max })` mit `max = SCHLUSS_GRENZEN.zeilen` (40) bei `art === 'schluss'`, sonst `LIMITS.maxPositionen` (20); `projektId = projekt?.id ?? auftrag?.projektId ?? ''`; `projektName = kuerzen(projekt?.name ?? auftrag?.titel ?? '', 140)`. `customFields` in dieser Reihenfolge: `{ name: 'Projekt', value: projektName }` (nur bei `art !== 'frei'` und nicht leerem Namen), `{ name: 'Auftrag', value: <Nummer> vom <TT.MM.JJJJ> }` mit `value` = `auftrag.nummer`, dann ` vom `, dann `formatDe(auftragDatum(auftrag))` (nur mit Auftrag), `{ name: 'Leistungszeitraum', value: leistungszeitraum }` (nur nicht leer). `metadata`: immer `webklarCustomerId` (`kunde.$id`), `quelle: 'woms'`, `erstelltVon: String(createdBy || '').slice(0, 500)` und `art`; dazu nur wenn nicht leer `projektId`, `projektName`, `auftragId`, `auftragNummer`. `projektRechnung`: `null` bei `frei`, sonst `{ kundenId: kunde.$id, projektId, auftragId: auftrag?.id ?? '', art, betragCents: summeCents, arbeitsblattIds, rechnungsnummer: '', aktiv: true, erstelltVon: String(createdBy || '').slice(0, 255), erstelltAm: new Date().toISOString() }` (`arbeitsblattIds` nur bei `aufwand` gefüllt, sortiert; sonst `[]`). Rückgabe `{ kunde, art, projekt, auftrag, positionen, summeCents, leistungszeitraum, customFields, metadata, projektRechnung }`.
|
||
|
||
- [ ] **Step 4: Lauf, erwartet PASS**
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeRechnungsbau.test.js`
|
||
Expected: PASS, `# fail 0`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2
|
||
git add server/services/auftraege/rechnungsbau.js test/auftraegeRechnungenHilfe.js test/auftraegeRechnungsbau.test.js
|
||
git commit -m "Rechnungen: baueRechnung für freie Rechnung und Festbetrag, Testhilfe
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: `rechnungsbau.js` – Aufwand
|
||
|
||
**Files:**
|
||
- Modify: `server/services/auftraege/rechnungsbau.js`
|
||
- Modify: `test/auftraegeRechnungsbau.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `aufwandPositionen`, `zusatzPositionen`, `leistungszeitraumText` (Tasks 2 und 3); `auftraegeAktiv(kunde): boolean` (`./schalter.js`); `getAuftrag(id)`, `listAuftraege({ kundenId, projektId })` (`./auftragStore.js`); `ladeArbeitsblaetterNachIds(ids): Promise<Arbeitsblatt[]>`, `abrechenbar(blatt): boolean`, `abgerechneteArbeitsblaetter(kundenId): Promise<Map<worksheetId, { invoiceId, rechnungsnummer }>>`, `offeneArbeitsblaetter(ticketIds, kundenId): Promise<Arbeitsblatt[]>`, `zeitquelleFuerProjekt(projekt, auftraege): string[]` (`./arbeitsblaetter.js`); `VEREINBARTE_STATUS`, `STUNDENSATZ_STANDARD_CENTS`, `SCHALTER_AUS_MELDUNG`, `sperreAuftrag(auftragId)` (`./konstanten.js`); zusätzlich `ladeArbeitsblaetter(ticketIds)` (`./arbeitsblaetter.js`), `getStripe()` (`../stripeBilling.js`), `withLocks(keys, fn)` (`../keyedLock.js`), `updateProjektRechnung(invoiceId, patch)` (`./projektRechnungenStore.js`) und `updateAuftrag(id, patch)` (`./auftragStore.js`).
|
||
- Produces: `baueRechnung` für `art: 'aufwand'` samt Selbstheilung; intern `ladeAuftrag(kundenId, auftragId)` (Task 8 benutzt es mit); exportiert aus `rechnungsbau.js` für Task 8 und Task 12: `buche(was, fn)`, `setzeAbZurueck(auftragId)`, `gibProjektRechnungFrei(invoiceId)`.
|
||
|
||
- [ ] **Step 1: Failing Test** – am Ende von `test/auftraegeRechnungsbau.test.js` anhängen:
|
||
|
||
```js
|
||
const eingabe = (over = {}) => ({ customerId: 'k1', art: 'aufwand', projektId: 'p1', createdBy: 'kenso@webklar.com', ...over })
|
||
const aufwandAb = (patch = {}, over = {}) =>
|
||
legeAuftragAn(
|
||
{ art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 12000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' }, zeitquelle: ['t1', 't3'], ...over },
|
||
{ nummer: 'AB-2026-0005', laufnummer: 5, status: 'bestaetigt', bestaetigtAm: '2026-10-07T09:00:00.000Z', ...patch }
|
||
)
|
||
|
||
test('baueRechnung aufwand: alle offenen Blätter des Projekttickets, eine Zeile je Tag, Satz 100 €, Zeitraum, Zusatzfeld', async () => {
|
||
const e = await baueRechnung(eingabe())
|
||
assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.betragCents]), [['2026-10-06', 22500], ['2026-10-07', 10000]])
|
||
assert.equal(e.positionen[0].beschreibung, `06.10.2026: Startseite; Kontaktformular – 2,25 Std. à 100,00${NBSP}€`)
|
||
assert.equal(e.positionen[1].beschreibung, `07.10.2026: Impressum – 1 Std. à 100,00${NBSP}€`)
|
||
assert.equal(e.summeCents, 32500)
|
||
assert.equal(e.leistungszeitraum, '06.10.–07.10.2026')
|
||
assert.deepEqual(e.customFields, [{ name: 'Projekt', value: 'Website anelia-wolf.com' }, { name: 'Leistungszeitraum', value: '06.10.–07.10.2026' }])
|
||
assert.deepEqual(e.projekt, { id: 'p1', name: 'Website anelia-wolf.com', ticketId: 't1' })
|
||
assert.equal(e.auftrag, null)
|
||
assert.equal(e.metadata.art, 'aufwand')
|
||
assert.equal(e.metadata.projektId, 'p1')
|
||
const { erstelltAm, ...rest } = e.projektRechnung
|
||
assert.ok(erstelltAm)
|
||
assert.deepEqual(rest, { kundenId: 'k1', projektId: 'p1', auftragId: '', art: 'aufwand', betragCents: 32500, arbeitsblattIds: ['ws1', 'ws2', 'ws3'], rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com' })
|
||
assert.equal(st.calls.length, 0, 'Aufwand liest nichts bei Stripe')
|
||
})
|
||
|
||
test('baueRechnung aufwand: ausgewählte Blätter (auch von einem anderen Ticket des Kunden), Zusatzposition, eigener Zeitraum und Zeilentext', async () => {
|
||
const e = await baueRechnung(eingabe({
|
||
arbeitsblattIds: ['ws2', 'wsT3', 'ws2'], items: POSTEN, leistungszeitraum: ' Oktober 2026 ',
|
||
zeilentexte: { '2026-10-08': '08.10.2026: Zusatzticket Impressum' },
|
||
}))
|
||
assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.betragCents]), [['2026-10-06', 7500], ['2026-10-08', 5000], ['zusatz-1', 1200]])
|
||
assert.equal(e.positionen[1].beschreibung, `08.10.2026: Zusatzticket Impressum – 0,5 Std. à 100,00${NBSP}€`)
|
||
assert.equal(e.summeCents, 13700)
|
||
assert.equal(e.leistungszeitraum, 'Oktober 2026')
|
||
assert.deepEqual(e.projektRechnung.arbeitsblattIds, ['ws2', 'wsT3'])
|
||
})
|
||
|
||
test('baueRechnung aufwand: ungültige Auswahl -> 400 ohne Stripe-Aufruf (fremde, unbekannte, unabrechenbare, zu viele Blätter)', async () => {
|
||
const mit = (arbeitsblattIds) => baueRechnung(eingabe({ arbeitsblattIds }))
|
||
await wirft(mit([]), 400, 'Keine Arbeitsblätter ausgewählt.')
|
||
for (const kaputt of ['ws1', [5], { 0: 'ws1' }]) await wirft(mit(kaputt), 400, 'Die Arbeitsblätter müssen als Liste von Kennungen übergeben werden.')
|
||
for (const id of ['gibtEsNicht', 'wsFremd', '../x']) await wirft(mit(['ws1', id]), 400, 'Arbeitsblatt nicht gefunden.')
|
||
for (const id of ['wsAbo', 'wsKomm', 'wsNull']) {
|
||
await wirft(mit(['ws1', id]), 400, 'Mindestens ein Arbeitsblatt ist nicht abrechenbar (Abo-Zeit, Kommentar oder ohne Zeit).')
|
||
}
|
||
await wirft(mit(Array.from({ length: 401 }, (_, i) => `ws${i}`)), 400, 'Zu viele Arbeitsblätter ausgewählt (höchstens 400).')
|
||
assert.equal(st.calls.length, 0)
|
||
})
|
||
|
||
test('baueRechnung aufwand: bereits abgerechnete Blätter -> 409 mit der Nummer von Stripe oder Entwurf, stornierte geben frei, Standardauswahl nur Offenes', async () => {
|
||
await legeRechnungAn(st, 'in_alt1', { status: 'open', nummer: 'WEBKLAR-0009', arbeitsblattIds: ['ws1'] })
|
||
const mit = () => baueRechnung(eingabe({ arbeitsblattIds: ['ws1', 'ws3'] }))
|
||
await wirft(mit(), 409, 'Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0009).')
|
||
st.invoices.set('in_alt1', { ...st.invoices.get('in_alt1'), status: 'draft', number: null })
|
||
await wirft(mit(), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
|
||
assert.deepEqual((await baueRechnung(eingabe())).projektRechnung.arbeitsblattIds, ['ws2', 'ws3'])
|
||
await updateProjektRechnung('in_alt1', { aktiv: false })
|
||
assert.equal((await mit()).positionen.length, 2)
|
||
await legeRechnungAn(st, 'in_alt2', { arbeitsblattIds: ['ws1', 'ws2', 'ws3'] })
|
||
await wirft(baueRechnung(eingabe()), 409, 'Keine offenen Arbeitsblätter zum Abrechnen.')
|
||
})
|
||
|
||
test('baueRechnung aufwand: Selbstheilung - eine im Stripe-Dashboard stornierte oder gelöschte Rechnung hält ihre Blätter nicht fest, eine fehlende Nummer wird nachgetragen', async () => {
|
||
await legeRechnungAn(st, 'in_leer1', { status: 'void', nummer: 'WEBKLAR-0010', arbeitsblattIds: ['ws1'] })
|
||
await legeRechnungAn(st, 'in_leer2', { ohneStripe: true, arbeitsblattIds: ['ws2'] })
|
||
const e = await baueRechnung(eingabe({ arbeitsblattIds: ['ws1', 'ws2'] }))
|
||
assert.deepEqual([e.positionen.length, e.summeCents], [1, 22500])
|
||
for (const id of ['in_leer1', 'in_leer2']) assert.equal((await getProjektRechnung(id)).aktiv, false, id)
|
||
// auch die Standardauswahl heilt, bevor sie "nichts offen" meldet
|
||
await legeRechnungAn(st, 'in_leer3', { status: 'void', nummer: 'WEBKLAR-0012', arbeitsblattIds: ['ws1', 'ws2', 'ws3'] })
|
||
assert.deepEqual((await baueRechnung(eingabe())).projektRechnung.arbeitsblattIds, ['ws1', 'ws2', 'ws3'])
|
||
assert.equal((await getProjektRechnung('in_leer3')).aktiv, false)
|
||
// bei Stripe finalisiert, im Datensatz fehlt die Nummer: die Meldung nennt die Nummer von Stripe, der Datensatz wird nachgetragen
|
||
await legeRechnungAn(st, 'in_offen', { status: 'open', nummer: 'WEBKLAR-0011', docNummer: '', arbeitsblattIds: ['ws3'] })
|
||
await wirft(baueRechnung(eingabe({ arbeitsblattIds: ['ws3'] })), 409, 'Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0011).')
|
||
assert.equal((await getProjektRechnung('in_offen')).rechnungsnummer, 'WEBKLAR-0011')
|
||
})
|
||
|
||
test('baueRechnung aufwand: Satz, Auftrag und Zeitquelle aus der jüngsten gültigen Aufwands-AB; ohne Schalter bleibt es bei 100 €', async () => {
|
||
const ab = await aufwandAb()
|
||
const e = await baueRechnung(eingabe())
|
||
assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.betragCents]), [['2026-10-06', 27000], ['2026-10-07', 12000], ['2026-10-08', 6000]])
|
||
assert.ok(e.positionen[0].beschreibung.endsWith(` – 2,25 Std. à 120,00${NBSP}€`))
|
||
assert.equal(e.leistungszeitraum, '06.10.–08.10.2026')
|
||
assert.equal(e.auftrag.nummer, 'AB-2026-0005')
|
||
assert.deepEqual(e.customFields.map((f) => f.name), ['Projekt', 'Auftrag', 'Leistungszeitraum'])
|
||
assert.equal(e.customFields[1].value, 'AB-2026-0005 vom 06.10.2026')
|
||
assert.deepEqual([e.metadata.auftragId, e.metadata.auftragNummer], [ab.id, 'AB-2026-0005'])
|
||
assert.equal(e.projektRechnung.auftragId, ab.id)
|
||
// ein neuerer, aber stornierter Aufwandsauftrag ändert nichts
|
||
await aufwandAb({ nummer: 'AB-2026-0006', status: 'storniert' }, { stundensatzCents: 15000 })
|
||
assert.equal((await baueRechnung(eingabe())).auftrag.nummer, 'AB-2026-0005')
|
||
config.auftraege.mode = 'off'
|
||
try {
|
||
const ohne = await baueRechnung(eingabe({ arbeitsblattIds: ['ws1'] }))
|
||
assert.equal(ohne.auftrag, null)
|
||
assert.equal(ohne.positionen[0].betragCents, 15000) // 90 Minuten zu 100 €
|
||
await wirft(baueRechnung(eingabe({ auftragId: ab.id })), 400, SCHALTER_AUS)
|
||
} finally {
|
||
config.auftraege.mode = 'on'
|
||
}
|
||
})
|
||
|
||
test('baueRechnung aufwand: ausdrücklich genannter Auftrag muss passen (Aufwand, eigener Kunde, gültig, gleiches Projekt)', async () => {
|
||
const festpreis = await legeAuftragAn()
|
||
const fremd = await legeAuftragAn({ kundenId: 'k2', projektId: 'p2' }, { nummer: 'AB-2026-0008' })
|
||
const entwurf = await aufwandAb({ nummer: 'AB-2026-0009', status: 'entwurf' })
|
||
const anderes = await aufwandAb({ nummer: 'AB-2026-0010' }, { projektId: 'p3' })
|
||
const mit = (auftragId) => baueRechnung(eingabe({ auftragId }))
|
||
await wirft(mit(festpreis.id), 400, 'Eine Aufwandsrechnung passt nur zu einer Aufwands-Auftragsbestätigung.')
|
||
await wirft(mit(fremd.id), 404, 'Auftrag nicht gefunden.')
|
||
await wirft(mit('gibtEsNicht'), 404, 'Auftrag nicht gefunden.')
|
||
await wirft(mit(entwurf.id), 400, 'Die Auftragsbestätigung ist nicht mehr gültig.')
|
||
await wirft(mit(anderes.id), 400, 'Der Auftrag gehört nicht zu diesem Projekt.')
|
||
const gueltig = await aufwandAb({ nummer: 'AB-2026-0011' })
|
||
assert.equal((await mit(gueltig.id)).auftrag.id, gueltig.id)
|
||
})
|
||
|
||
test('baueRechnung aufwand: Zusatzpositionen zählen zu den 20 Zeilen, die ältesten Wochen werden zu KW-Zeilen', async () => {
|
||
const ohne = await baueRechnung(eingabe({ projektId: 'p3' }))
|
||
assert.equal(ohne.positionen.length, 20)
|
||
assert.equal(ohne.positionen[0].schluessel, '2026-11-02')
|
||
const mit = await baueRechnung(eingabe({ projektId: 'p3', items: POSTEN }))
|
||
assert.equal(mit.positionen.length, 17)
|
||
assert.deepEqual([mit.positionen[0].schluessel, mit.positionen[1].schluessel, mit.positionen.at(-1).schluessel], ['KW-2026-45', '2026-11-09', 'zusatz-1'])
|
||
assert.equal(mit.summeCents, 20 * 10000 + 1200)
|
||
assert.ok(mit.positionen.every((p) => p.beschreibung.length <= 200))
|
||
})
|
||
|
||
test('baueRechnung aufwand: Projekt ist Pflicht, fremdes Projekt 404, Projekt ohne offene Blätter 409, Zeilentexte müssen ein Objekt mit Texten sein', async () => {
|
||
for (const projektId of [undefined, '', null]) await wirft(baueRechnung(eingabe({ projektId })), 400, 'Für diese Rechnungsart ist ein Projekt nötig.')
|
||
await wirft(baueRechnung(eingabe({ projektId: 'p2' })), 404, 'Projekt nicht gefunden.')
|
||
aw.seed('websiteProjects', { $id: 'pohne', customerId: 'k1', projectName: 'Ohne Ticket', ticketId: '' })
|
||
await wirft(baueRechnung(eingabe({ projektId: 'pohne' })), 409, 'Keine offenen Arbeitsblätter zum Abrechnen.')
|
||
for (const zeilentexte of [['x'], 'x', 5, { '2026-10-06': 5 }]) {
|
||
await wirft(baueRechnung(eingabe({ zeilentexte })), 400, 'Die Zeilentexte müssen ein Objekt mit Texten sein.')
|
||
}
|
||
assert.equal((await baueRechnung(eingabe({ zeilentexte: null }))).positionen.length, 2)
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Lauf, erwartet FAIL**
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeRechnungsbau.test.js`
|
||
Expected: FAIL (die neuen Tests scheitern mit Status 501 `Rechnungsart noch nicht umgesetzt.`, die Tests aus Task 6 bleiben grün).
|
||
|
||
- [ ] **Step 3: Implementieren** den Zweig `aufwand` in `server/services/auftraege/rechnungsbau.js` (weitere Importe: `getStripe` aus `../stripeBilling.js`, `withLocks` aus `../keyedLock.js`, `sperreAuftrag` und die anderen Konstanten aus `./konstanten.js`, `getAuftrag`, `listAuftraege`, `updateAuftrag` aus `./auftragStore.js`, `updateProjektRechnung` aus `./projektRechnungenStore.js`, `ladeArbeitsblaetter` und die übrigen Funktionen aus `./arbeitsblaetter.js`):
|
||
1. `projekt = await ladeProjekt(kunde.$id, projektId)`.
|
||
2. **Auftrag.** Ist `eingabe.auftragId` ein nicht leerer Wert: Schalter aus (`!auftraegeAktiv(kunde)`) → 400 `SCHALTER_AUS_MELDUNG`; dann `auftrag = await ladeAuftrag(kunde.$id, auftragId)`; `auftrag.art !== 'aufwand'` → 400 `Eine Aufwandsrechnung passt nur zu einer Aufwands-Auftragsbestätigung.`; Status nicht in `VEREINBARTE_STATUS` → 400 `Die Auftragsbestätigung ist nicht mehr gültig.`; `auftrag.projektId` nicht leer und ≠ `projekt.id` → 400 `Der Auftrag gehört nicht zu diesem Projekt.` Sonst und nur bei Schalter an: `listAuftraege({ kundenId: kunde.$id, projektId: projekt.id })` (neueste zuerst), der erste mit `art === 'aufwand'` und Status in `VEREINBARTE_STATUS`, sonst `null`. Bei Schalter aus gibt es nie einen Auftrag (die ABs werden nicht gelesen). Dazu die interne Funktion `ladeAuftrag(kundenId, auftragId)`: kein String, nicht zu `KENNUNG` passend, `getAuftrag(id)` ist `null` oder `kundenId` weicht ab → 404 `Auftrag nicht gefunden.`
|
||
3. `satzCents = auftrag?.stundensatzCents ?? STUNDENSATZ_STANDARD_CENTS`; `zusatz = zusatzPositionen(items)`.
|
||
4. `zeilentexte`: `undefined` oder `null` → `{}`; sonst ein Objekt (kein Array) mit ausschließlich String-Werten, sonst 400 `Die Zeilentexte müssen ein Objekt mit Texten sein.`
|
||
5. **Blätter.** `eigeneTickets` = Menge der `$id` aus `listDocuments(config.collections.workorders, [Query.equal('customerId', kunde.$id), Query.limit(5000)])`.
|
||
- Ist `arbeitsblattIds` weder `undefined` noch `null` (ausdrückliche Auswahl), in dieser Reihenfolge: kein Array oder ein Eintrag kein String → 400 `Die Arbeitsblätter müssen als Liste von Kennungen übergeben werden.`; `ids` = eindeutige Einträge; leer → 400 `Keine Arbeitsblätter ausgewählt.`; mehr als 400 → 400 `Zu viele Arbeitsblätter ausgewählt (höchstens 400).`; ein Eintrag passt nicht zu `KENNUNG` → 400 `Arbeitsblatt nicht gefunden.`; `blaetter = await ladeArbeitsblaetterNachIds(ids)`; fehlt eines (Anzahl) oder ist ein `ticketId` nicht in `eigeneTickets` → 400 `Arbeitsblatt nicht gefunden.` (fremde und unbekannte Blätter sind nicht zu unterscheiden); ein Blatt mit `!abrechenbar(blatt)` → 400 `Mindestens ein Arbeitsblatt ist nicht abrechenbar (Abo-Zeit, Kommentar oder ohne Zeit).` Dann `belegt = await abgerechneteArbeitsblaetter(kunde.$id)`; sind Blätter der Auswahl belegt, `gueltig = await pruefeBelegungen(<die belegten der Auswahl>)` (Selbstheilung, siehe Punkt 8); das erste Blatt der Auswahl (Reihenfolge der Eingabe), dessen Belegung noch gilt → 409 `` `Arbeitsblatt bereits abgerechnet (${nummer ? `Rechnung ${nummer}` : 'Entwurf'}).` `` mit der Nummer von Stripe (bei einem Entwurf leer).
|
||
- Sonst (Standardauswahl): `zeitquelle = zeitquelleFuerProjekt(projekt, ABs)` mit `ABs` = `listAuftraege({ kundenId: kunde.$id, projektId: projekt.id })` bei Schalter an, sonst `[]`; nur Tickets aus `eigeneTickets`; `offene = zeitquelle.length ? await offeneArbeitsblaetter(zeitquelle, kunde.$id) : []`. Ist sie leer, Selbstheilung vor dem 409: `ladeArbeitsblaetter(zeitquelle)` gefiltert auf `abrechenbar` und belegt (laut `abgerechneteArbeitsblaetter`) mit `pruefeBelegungen` prüfen; wurde dabei etwas freigegeben, `offene` neu laden. Bleibt sie leer → `AuftragError(409, 'Keine offenen Arbeitsblätter zum Abrechnen.')`; mehr als 400 → 400 `Zu viele Arbeitsblätter ausgewählt (höchstens 400).`
|
||
6. `{ positionen: tage, von, bis } = aufwandPositionen(blaetter, { satzCents, zeilentexte, maxZeilen: LIMITS.maxPositionen - zusatz.length })`; `positionen = [...tage, ...zusatz]`; `leistungszeitraum` der Eingabe, sonst `leistungszeitraumText(von, bis)`.
|
||
7. Abschluss mit `arbeitsblattIds` = sortierte (`.sort()`) Kennungen der Blätter und dem gefundenen `auftrag`.
|
||
8. **Exportierte und interne Hilfsfunktionen** (Task 8 und Task 12 benutzen die exportierten):
|
||
- `export async function buche(was, fn)`: `await fn()` in `try/catch`; Fehler mit Status 404 sind still, sonst eine Zeile ins Protokoll: `` console.error(`[finanzen] ${was} fehlgeschlagen:`, err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '') `` (dieselbe Form wie `logFailure` in `finanzen.js`); wirft nie.
|
||
- `export async function setzeAbZurueck(auftragId)`: unter `withLocks(sperreAuftrag(auftragId), …)`: `ab = await getAuftrag(auftragId)`; steht der Status auf `abgerechnet`, `updateAuftrag(ab.id, { status: ab.bestaetigtAm ? 'bestaetigt' : 'versendet' })`.
|
||
- `export async function gibProjektRechnungFrei(invoiceId)`: `doc = await updateProjektRechnung(invoiceId, { aktiv: false })`; ist `doc.art` `schluss` und `doc.auftragId` gesetzt, `await setzeAbZurueck(doc.auftragId)`; gibt `doc` zurück und wirft Fehler weiter (die Aufrufer packen es in `buche`).
|
||
- intern `pruefeBelegungen(belegt)` mit `belegt` = `Map<blattId, { invoiceId, rechnungsnummer }>`: je eindeutiger `invoiceId` `getStripe().invoices.retrieve(invoiceId)`; bei einem Fehler mit `statusCode === 404` oder `code === 'resource_missing'` (wie in `readInvoice`) und beim Status `void` → `buche('Rechnung freigeben', () => gibProjektRechnungFrei(invoiceId))`, diese Belegung gilt nicht; andere Fehler weiterreichen (die Route macht daraus 409 oder 502). Sonst gilt sie, mit der Nummer von Stripe (`''` bei `draft`, sonst `raw.number || ''`); ist diese nicht leer und weicht von der `rechnungsnummer` der Belegung ab, `buche('Rechnungsnummer nachtragen', () => updateProjektRechnung(invoiceId, { rechnungsnummer: nummer }))`. Rückgabe: die Map der noch geltenden Belegungen (mit der Nummer von Stripe).
|
||
|
||
- [ ] **Step 4: Lauf, erwartet PASS**
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeRechnungsbau.test.js`
|
||
Expected: PASS, `# fail 0`. (Den Text eines Blatts liefert der L0-Lader: `details` getrimmt, Leerraum vereinheitlicht, höchstens 200 Zeichen.)
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2
|
||
git add server/services/auftraege/rechnungsbau.js test/auftraegeRechnungsbau.test.js
|
||
git commit -m "Rechnungen: Aufwandsrechnung bauen (Auswahl, Satz aus der AB, Doppelabrechnung, 20 Zeilen)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: `rechnungsbau.js` – Anzahlung und Schlussrechnung
|
||
|
||
**Files:**
|
||
- Modify: `server/services/auftraege/rechnungsbau.js`
|
||
- Modify: `test/auftraegeRechnungsbau.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `anzahlungPosition`, `schlussPositionen` (Task 4); `ladeAuftrag`, `ladeProjekt` (Tasks 6 und 7); `auftraegeAktiv` (`./schalter.js`); `listProjektRechnungen({ auftragId })`, `updateProjektRechnung` (`./projektRechnungenStore.js`); `buche`, `gibProjektRechnungFrei` (Task 7); `getStripe()` (`../stripeBilling.js`); `berlinDate` (`../finance/dates.js`); `euroDe` (`../finance/format.js`); `AB_RECHNUNGS_ARTEN`, `SCHALTER_AUS_MELDUNG`.
|
||
- Produces: `baueRechnung` für `art: 'anzahlung'` und `art: 'schluss'` samt Selbstheilung (stornierte oder gelöschte frühere Rechnungen, abgerechnete AB); `ladeProjekt(kundenId, projektId, { weich = false })`.
|
||
|
||
- [ ] **Step 1: Failing Test** – am Ende von `test/auftraegeRechnungsbau.test.js` anhängen:
|
||
|
||
```js
|
||
/** Frühere Rechnung zur AB (Art anzahlung, wenn nicht anders gesagt): Stripe-Rechnung im Nachbau plus aktiver Projektdatensatz. */
|
||
const fruehereRechnung = (ab, { id, art = 'anzahlung', status = 'open', ...optionen }) => legeRechnungAn(st, id, { art, status, auftragId: ab.id, ...optionen })
|
||
|
||
test('baueRechnung anzahlung: Position aus dem Zahlungsplan, Zusatzfelder Projekt und Auftrag, Metadaten, Projektdatensatz', async () => {
|
||
const ab = await legeAuftragAn()
|
||
const e = await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, createdBy: 'kenso@webklar.com' })
|
||
assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.beschreibung, p.betragCents]), [
|
||
['anzahlung', 'Anzahlung 50 % gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026 (Website anelia-wolf.com)', 40000],
|
||
])
|
||
assert.equal(e.summeCents, 40000)
|
||
assert.deepEqual(e.customFields, [{ name: 'Projekt', value: 'Website anelia-wolf.com' }, { name: 'Auftrag', value: 'AB-2026-0002 vom 06.10.2026' }])
|
||
assert.deepEqual(e.metadata, {
|
||
webklarCustomerId: 'k1', quelle: 'woms', erstelltVon: 'kenso@webklar.com', art: 'anzahlung',
|
||
projektId: 'p1', projektName: 'Website anelia-wolf.com', auftragId: ab.id, auftragNummer: 'AB-2026-0002',
|
||
})
|
||
assert.equal(e.auftrag.id, ab.id)
|
||
assert.equal(e.projekt.id, 'p1')
|
||
const { erstelltAm, ...rest } = e.projektRechnung
|
||
assert.ok(erstelltAm)
|
||
assert.deepEqual(rest, { kundenId: 'k1', projektId: 'p1', auftragId: ab.id, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [], rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com' })
|
||
assert.equal(st.calls.length, 0, 'ohne frühere Rechnungen gibt es nichts bei Stripe zu lesen')
|
||
})
|
||
|
||
test('baueRechnung anzahlung: Prozent oder Betrag änderbar; nur finalisierte Rechnungen mindern das Offene', async () => {
|
||
const ab = await legeAuftragAn()
|
||
const lauf = (anzahlung) => baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung })
|
||
assert.ok((await lauf({ prozent: 25, bezeichnung: 'Teilrechnung' })).positionen[0].beschreibung.startsWith('Teilrechnung 25 % gemäß'))
|
||
assert.equal((await lauf({ betragCents: 25000 })).summeCents, 25000)
|
||
await fruehereRechnung(ab, { id: 'in_f1', status: 'open', total: 40000 })
|
||
await fruehereRechnung(ab, { id: 'in_f2', status: 'void', total: 99000, nummer: 'WEBKLAR-0008' }) // storniert: zählt nicht
|
||
await fruehereRechnung(ab, { id: 'in_f3', status: 'draft', total: 5000 }) // Entwurf einer Anzahlung: zählt nicht
|
||
assert.equal((await lauf(undefined)).summeCents, 40000)
|
||
assert.equal((await getProjektRechnung('in_f2')).aktiv, false, 'die im Dashboard stornierte Rechnung wurde freigegeben')
|
||
await wirft(lauf({ betragCents: 40001 }), 400, `Die Anzahlung darf höchstens 400,00${NBSP}€ betragen (Summe der Auftragsbestätigung minus bereits berechnet).`)
|
||
await fruehereRechnung(ab, { id: 'in_f4', status: 'paid', total: 40000, nummer: 'WEBKLAR-0009' })
|
||
await wirft(lauf(undefined), 409, 'Nichts mehr offen.')
|
||
for (const kaputt of ['viel', 5, ['x']]) await wirft(lauf(kaputt), 400, 'Die Angaben zur Anzahlung müssen ein Objekt sein.')
|
||
})
|
||
|
||
test('baueRechnung anzahlung: ein Entwurf der Schlussrechnung zur AB blockiert weitere Anzahlungen (409)', async () => {
|
||
const ab = await legeAuftragAn()
|
||
await fruehereRechnung(ab, { id: 'in_s1', status: 'draft', art: 'schluss', total: 40000 })
|
||
await wirft(
|
||
baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id }),
|
||
409,
|
||
'Zu dieser Auftragsbestätigung gibt es schon einen Entwurf der Schlussrechnung. Erst den Entwurf senden oder löschen.'
|
||
)
|
||
})
|
||
|
||
test('baueRechnung anzahlung/schluss: Voraussetzungen (Schalter, Auftrag, Festpreis, Status, Kunde, Projekt)', async () => {
|
||
const ab = await legeAuftragAn()
|
||
for (const art of ['anzahlung', 'schluss']) {
|
||
await wirft(baueRechnung({ customerId: 'k1', art }), 400, 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.')
|
||
await wirft(baueRechnung({ customerId: 'k1', art, auftragId: 'gibtEsNicht' }), 404, 'Auftrag nicht gefunden.')
|
||
await wirft(baueRechnung({ customerId: 'k2', art, auftragId: ab.id }), 404, 'Auftrag nicht gefunden.')
|
||
await wirft(baueRechnung({ customerId: 'k1', art, auftragId: ab.id, projektId: 'p3' }), 400, 'Der Auftrag gehört nicht zu diesem Projekt.')
|
||
config.auftraege.mode = 'off'
|
||
try {
|
||
await wirft(baueRechnung({ customerId: 'k1', art, auftragId: ab.id }), 400, SCHALTER_AUS)
|
||
await wirft(baueRechnung({ customerId: 'k1', art }), 400, SCHALTER_AUS)
|
||
} finally {
|
||
config.auftraege.mode = 'on'
|
||
}
|
||
}
|
||
const aufwand = await legeAuftragAn({ art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000 }, { nummer: 'AB-2026-0006' })
|
||
await wirft(baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: aufwand.id }), 400, 'Anzahlung und Schlussrechnung gehen nur mit einer Festpreis-Auftragsbestätigung.')
|
||
for (const [i, status] of ['entwurf', 'storniert', 'ersetzt', 'widerrufen', 'abgerechnet'].entries()) {
|
||
const x = await legeAuftragAn({}, { nummer: `AB-2026-010${i}`, status })
|
||
for (const art of ['anzahlung', 'schluss']) await wirft(baueRechnung({ customerId: 'k1', art, auftragId: x.id }), 400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.')
|
||
}
|
||
const bestaetigt = await legeAuftragAn({}, { nummer: 'AB-2026-0007', status: 'bestaetigt', bestaetigtAm: '2026-10-07T09:00:00.000Z' })
|
||
assert.equal((await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: bestaetigt.id })).summeCents, 40000)
|
||
})
|
||
|
||
test('baueRechnung schluss: AB-Positionen, Zusatz und eine Minuszeile je finalisierter, nicht stornierter früherer Rechnung', async () => {
|
||
const ab = await legeAuftragAn()
|
||
await fruehereRechnung(ab, { id: 'in_f1', status: 'open', total: 40000, nummer: 'WEBKLAR-0007', finalisiert: '2026-10-08T10:00:00.000Z' })
|
||
await fruehereRechnung(ab, { id: 'in_f2', status: 'void', total: 20000, nummer: 'WEBKLAR-0008' })
|
||
const e = await baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id, items: [{ description: 'Domain-Registrierung', quantity: 1, unitAmountCents: 1500 }] })
|
||
assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.betragCents]), [['ab-1', 60000], ['ab-2', 20000], ['zusatz-1', 1500], ['abzug-in_f1', -40000]])
|
||
assert.equal(e.positionen.at(-1).beschreibung, 'abzüglich Anzahlung, Rechnung WEBKLAR-0007 vom 08.10.2026')
|
||
assert.equal(e.summeCents, 41500)
|
||
assert.deepEqual(e.customFields.map((f) => f.name), ['Projekt', 'Auftrag'])
|
||
assert.equal(e.projektRechnung.art, 'schluss')
|
||
assert.equal(e.projektRechnung.betragCents, 41500)
|
||
assert.equal(e.projektRechnung.auftragId, ab.id)
|
||
})
|
||
|
||
test('baueRechnung schluss: offener Entwurf zur AB blockiert (409), Rechnungen fremder ABs und bei Stripe gelöschte zählen nicht, nichts mehr offen -> 409', async () => {
|
||
const ab = await legeAuftragAn()
|
||
const andere = await legeAuftragAn({}, { nummer: 'AB-2026-0003' })
|
||
await fruehereRechnung(andere, { id: 'in_g1', status: 'draft', total: 11111 })
|
||
await fruehereRechnung(andere, { id: 'in_g2', status: 'paid', total: 22222, nummer: 'WEBKLAR-0005' })
|
||
const lauf = () => baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id })
|
||
assert.equal((await lauf()).summeCents, 80000)
|
||
await fruehereRechnung(ab, { id: 'in_h1', status: 'draft', total: 40000 })
|
||
await wirft(lauf(), 409, `Erst den Entwurf über 400,00${NBSP}€ senden oder löschen.`)
|
||
await updateProjektRechnung('in_h1', { aktiv: false })
|
||
assert.equal((await lauf()).summeCents, 80000)
|
||
await fruehereRechnung(ab, { id: 'in_h2', status: 'paid', total: 80000, nummer: 'WEBKLAR-0008' })
|
||
await wirft(lauf(), 409, 'Nichts mehr offen.')
|
||
await updateProjektRechnung('in_h2', { aktiv: false })
|
||
// aktiver Projektdatensatz, aber die Rechnung gibt es bei Stripe nicht mehr (im Dashboard gelöscht): zählt nicht
|
||
await fruehereRechnung(ab, { id: 'in_weg', ohneStripe: true, total: 5000, docNummer: 'X' })
|
||
const e = await lauf()
|
||
assert.equal(e.summeCents, 80000)
|
||
assert.equal(e.positionen.length, 2)
|
||
assert.equal((await getProjektRechnung('in_weg')).aktiv, false, 'der Datensatz der gelöschten Rechnung wurde freigegeben')
|
||
})
|
||
|
||
test('baueRechnung anzahlung: AB ohne Projekt oder mit gelöschtem Projekt -> Zusatzfeld Projekt zeigt den AB-Titel; ein genanntes fremdes Projekt 404', async () => {
|
||
const ohne = await legeAuftragAn({ projektId: '', titel: 'Logo-Entwurf' }, { nummer: 'AB-2026-0012' })
|
||
const e = await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ohne.id })
|
||
assert.equal(e.projekt, null)
|
||
assert.deepEqual(e.customFields[0], { name: 'Projekt', value: 'Logo-Entwurf' })
|
||
assert.equal(e.metadata.projektId, undefined)
|
||
assert.equal(e.projektRechnung.projektId, '')
|
||
const weg = await legeAuftragAn({ projektId: 'pWeg' }, { nummer: 'AB-2026-0013' })
|
||
const w = await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: weg.id })
|
||
assert.equal(w.projekt, null)
|
||
assert.deepEqual([w.metadata.projektId, w.projektRechnung.projektId, w.customFields[0].value], ['pWeg', 'pWeg', 'Website anelia-wolf.com'])
|
||
assert.equal((await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ohne.id, projektId: 'p1' })).projekt.id, 'p1')
|
||
await wirft(baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ohne.id, projektId: 'p2' }), 404, 'Projekt nicht gefunden.')
|
||
})
|
||
|
||
test('baueRechnung schluss: eine im Dashboard stornierte Schlussrechnung gibt die AB wieder frei (Selbstheilung), eine offene hält sie', async () => {
|
||
const ab = await legeAuftragAn({}, { status: 'abgerechnet', bestaetigtAm: '2026-10-07T09:00:00.000Z' })
|
||
await fruehereRechnung(ab, { id: 'in_sl1', art: 'schluss', status: 'open', total: 80000 })
|
||
await wirft(baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id }), 400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.')
|
||
assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet')
|
||
st.invoices.set('in_sl1', { ...st.invoices.get('in_sl1'), status: 'void' }) // im Dashboard storniert
|
||
const e = await baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id })
|
||
assert.equal(e.summeCents, 80000)
|
||
assert.equal((await getAuftrag(ab.id)).status, 'bestaetigt')
|
||
assert.equal((await getProjektRechnung('in_sl1')).aktiv, false)
|
||
})
|
||
|
||
test('baueRechnung schluss: bis zu 40 Zeilen (20 AB-Positionen, 10 Nachträge, 10 Abzüge); mehr Abzüge oder Nachträge -> 400', async () => {
|
||
const positionen = Array.from({ length: 20 }, (_, i) => ({ beschreibung: `Pos ${i + 1}`, menge: 1, einzelpreisCents: 1000 }))
|
||
const ab = await legeAuftragAn({ positionen, summeCents: 20000 }, { nummer: 'AB-2026-0014' })
|
||
const nachtraege = (n) => Array.from({ length: n }, (_, i) => ({ description: `Nachtrag ${i + 1}`, quantity: 1, unitAmountCents: 500 }))
|
||
const lauf = (items) => baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id, items })
|
||
const abzug = (i) => fruehereRechnung(ab, { id: `in_z${String(i).padStart(2, '0')}`, status: 'paid', total: 100, nummer: `WK-${String(i).padStart(4, '0')}` })
|
||
assert.equal((await lauf(undefined)).positionen.length, 20)
|
||
await abzug(1)
|
||
const eins = await lauf(undefined)
|
||
assert.deepEqual([eins.positionen.length, eins.summeCents], [21, 19900], '20 AB-Positionen und ein Abzug sind gültig')
|
||
for (let i = 2; i <= 10; i += 1) await abzug(i)
|
||
const voll = await lauf(nachtraege(10))
|
||
assert.deepEqual([voll.positionen.length, voll.summeCents, voll.projektRechnung.betragCents], [40, 24000, 24000])
|
||
await wirft(lauf(nachtraege(11)), 400, 'Eine Schlussrechnung erlaubt höchstens 10 Zusatzpositionen.')
|
||
await abzug(11)
|
||
await wirft(lauf(undefined), 400, 'Die Schlussrechnung hätte 11 Abzüge für frühere Rechnungen; erlaubt sind höchstens 10.')
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Lauf, erwartet FAIL**
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeRechnungsbau.test.js`
|
||
Expected: FAIL (die neuen Tests scheitern mit Status 501 `Rechnungsart noch nicht umgesetzt.`).
|
||
|
||
- [ ] **Step 3: Implementieren** die Zweige `anzahlung` und `schluss` in `rechnungsbau.js`:
|
||
1. **Schalter** gleich nach dem Laden des Kunden (Schritt 2 aus Task 6, vor jedem Zweig): `AB_RECHNUNGS_ARTEN.includes(art)` und `!auftraegeAktiv(kunde)` → 400 `SCHALTER_AUS_MELDUNG`.
|
||
2. **Auftrag:** `eingabe.auftragId` leer oder kein String → 400 `Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.`; `auftrag = await ladeAuftrag(kunde.$id, auftragId)` (404); `auftrag.art !== 'festpreis'` → 400 `Anzahlung und Schlussrechnung gehen nur mit einer Festpreis-Auftragsbestätigung.`; Status `abgerechnet` (Selbstheilung): zuerst `await ladeFruehere(auftrag.id)` (gibt eine im Dashboard stornierte oder gelöschte Schlussrechnung frei und setzt die AB zurück), dann `auftrag = await ladeAuftrag(kunde.$id, auftragId)` erneut; Status danach weder `versendet` noch `bestaetigt` → 400 `Die Auftragsbestätigung muss versendet oder bestätigt sein.` (jeder andere Status sofort).
|
||
3. **Projekt:** Nennt die Eingabe eine nicht leere `projektId`: `ladeProjekt` (404 wie üblich); hat der Auftrag ein anderes Projekt (`auftrag.projektId` nicht leer und verschieden) → 400 `Der Auftrag gehört nicht zu diesem Projekt.` Nennt sie keine, hat der Auftrag aber ein Projekt: `ladeProjekt(kunde.$id, auftrag.projektId, { weich: true })`; fehlt das Projekt oder gehört es einem anderen, ergibt das `null` statt eines Fehlers. Sonst `projekt = null`. `ladeProjekt` bekommt dafür den dritten Parameter `{ weich = false }` (bei `weich` statt jedes `AuftragError` `null`; die Meldung „Projekt nötig“ gibt es dann nicht).
|
||
4. **Frühere Rechnungen** `ladeFruehere(auftragId)` (intern): `listProjektRechnungen({ auftragId })` (nur aktive); je Datensatz `getStripe().invoices.retrieve(invoiceId)`; bei einem Fehler mit `statusCode === 404` oder `code === 'resource_missing'` (wie in `readInvoice`) und beim Status `void` → `buche('Rechnung freigeben', () => gibProjektRechnungFrei(invoiceId))` (Selbstheilung) und überspringen; steht eine finalisierte Rechnung ohne Nummer im Datensatz, `buche('Rechnungsnummer nachtragen', () => updateProjektRechnung(invoiceId, { rechnungsnummer }))`; andere Fehler weiterreichen (die Route macht daraus 409 oder 502). Ergebnis je Rechnung `{ invoiceId, art /* aus dem Datensatz */, status /* Stripe: draft, open, paid, uncollectible, void */, rechnungsnummer: raw.number || '', datum: berlinDate(new Date((raw.status_transitions?.finalized_at ?? raw.created) * 1000)), betragCents: raw.total ?? 0 }`. `finalisiert` = Status `open`, `paid` oder `uncollectible`; `void` zählt nirgends.
|
||
5. **Entwürfe:** Bei `schluss` blockiert jeder Eintrag mit Status `draft` → `AuftragError(409, …)` mit der Meldung `Erst den Entwurf über <euroDe(betragCents)> senden oder löschen.` Bei `anzahlung` blockiert nur ein Entwurf der Art `schluss` → 409 `Zu dieser Auftragsbestätigung gibt es schon einen Entwurf der Schlussrechnung. Erst den Entwurf senden oder löschen.`
|
||
6. **Positionen:** `anzahlung`: `eingabe.anzahlung` `undefined`/`null` → `{}`, sonst ein Objekt (kein Array) oder 400 `Die Angaben zur Anzahlung müssen ein Objekt sein.`; `positionen = [anzahlungPosition(auftrag, angaben, Summe der betragCents aller finalisierten)]`. `schluss`: `positionen = schlussPositionen(auftrag, finalisierte.map(({ invoiceId, rechnungsnummer, datum, betragCents, art }) => ({ invoiceId, rechnungsnummer, datum, betragCents, art })), eingabe.items)`. Die Gruppengrenzen der Schlussrechnung (20 AB-Positionen, 10 Zusatzpositionen, 10 Abzüge) prüft `schlussPositionen`; die Zeilenzahl-Prüfung im Abschluss (Task 6) läuft für `schluss` mit der Obergrenze 40, für `anzahlung` mit 20.
|
||
7. Abschluss mit `projekt` (oder `null`), `auftrag`, leeren `arbeitsblattIds`.
|
||
|
||
- [ ] **Step 4: Lauf, erwartet PASS**
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeRechnungsbau.test.js`
|
||
Expected: PASS, `# fail 0`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2
|
||
git add server/services/auftraege/rechnungsbau.js test/auftraegeRechnungsbau.test.js
|
||
git commit -m "Rechnungen: Anzahlung und Schlussrechnung bauen (Minuszeilen, Entwurf-Sperre, Schalter)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: `mapInvoice` – Projektfelder
|
||
|
||
**Files:**
|
||
- Modify: `server/services/finance/stripeFinance.js`
|
||
- Modify: `test/financeStripeRead.test.js`
|
||
- Create: `test/auftraegeStripe.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `RECHNUNGS_ARTEN` (`../auftraege/konstanten.js`).
|
||
- Produces: `mapInvoice(raw, { today })` liefert zusätzlich `projektId`, `projektName`, `art`, `auftragId`, `auftragNummer` (V4): `art` ist `metadata.art`, wenn es in `RECHNUNGS_ARTEN` steht, sonst `'abo'` bei Abo-Rechnungen, sonst `'frei'`; die anderen Felder `metadata.<Feld> || ''`. Damit trägt `GET /api/admin/finanzen/rechnungen` die Felder.
|
||
|
||
- [ ] **Step 1: Failing Test** – `test/auftraegeStripe.test.js` mit dem gemeinsamen Aufbau der folgenden Tasks und dem ersten Test anlegen:
|
||
|
||
```js
|
||
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 { schemaKeys } from './support/schema.js'
|
||
import { useTestEnv } from './support/testEnv.js'
|
||
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
|
||
|
||
const aw = await startAppwriteMock({
|
||
schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') },
|
||
datetimes: AUFTRAEGE_DATETIMES,
|
||
defaultLimit: 25,
|
||
})
|
||
const st = await startFakeStripe()
|
||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||
const { config } = await import('../server/config.js')
|
||
const { seedRechnungsDaten, legeAuftragAn, leereRechnungsDaten } = await import('./auftraegeRechnungenHilfe.js')
|
||
const {
|
||
mapInvoice, createProjectInvoice, rechnungsVorschau, sendInvoiceNow, voidInvoice, deleteDraft, invalidateFinanceCache, resetInvoiceCreateGuardForTests,
|
||
} = await import('../server/services/finance/stripeFinance.js')
|
||
const { getProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js')
|
||
const { getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||
const { sperreAuftrag } = await import('../server/services/auftraege/konstanten.js')
|
||
const { withLocks } = await import('../server/services/keyedLock.js')
|
||
const { berlinDate, formatDe } = await import('../server/services/finance/dates.js')
|
||
|
||
after(() => Promise.all([aw.close(), st.close()]))
|
||
seedRechnungsDaten(aw)
|
||
config.auftraege.mode = 'on'
|
||
beforeEach(async () => {
|
||
mock.restoreAll()
|
||
await leereRechnungsDaten(aw, st)
|
||
invalidateFinanceCache()
|
||
resetInvoiceCreateGuardForTests()
|
||
})
|
||
|
||
const NBSP = String.fromCharCode(160) // geschütztes Leerzeichen U+00A0, wie euroDe es vor € setzt
|
||
const POSTEN = [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }]
|
||
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 wirft = (promise, status, message) =>
|
||
assert.rejects(promise, (err) => {
|
||
assert.equal(err.status, status, `Status ${err.status}: ${err.message}`)
|
||
assert.equal(err.message, message)
|
||
return true
|
||
})
|
||
|
||
test('mapInvoice: Projektfelder aus den Metadaten, art mit Rückfall abo oder frei', () => {
|
||
const roh = (over = {}) => ({ id: 'in_1', object: 'invoice', status: 'open', customer: 'cus_1', created: 1_790_000_000, total: 1000, metadata: {}, lines: { object: 'list', data: [] }, ...over })
|
||
const felder = (r) => [r.projektId, r.projektName, r.art, r.auftragId, r.auftragNummer]
|
||
const heute = { today: '2026-10-06' }
|
||
assert.deepEqual(felder(mapInvoice(roh(), heute)), ['', '', 'frei', '', ''])
|
||
assert.deepEqual(felder(mapInvoice(roh({ subscription: 'sub_1' }), heute)), ['', '', 'abo', '', ''])
|
||
const metadata = { projektId: 'p1', projektName: 'Website Wolf', art: 'schluss', auftragId: 'a1', auftragNummer: 'AB-2026-0002' }
|
||
assert.deepEqual(felder(mapInvoice(roh({ metadata }), heute)), ['p1', 'Website Wolf', 'schluss', 'a1', 'AB-2026-0002'])
|
||
for (const art of ['quatsch', 'abo', '']) assert.equal(mapInvoice(roh({ metadata: { art } }), heute).art, 'frei', `art "${art}"`)
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Lauf, erwartet FAIL**
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeStripe.test.js`
|
||
Expected: FAIL (`mapInvoice` liefert die Felder nicht: `[undefined, …]` statt `['', '', 'frei', '', '']`).
|
||
|
||
- [ ] **Step 3: Implementieren** in `server/services/finance/stripeFinance.js`: `import { RECHNUNGS_ARTEN } from '../auftraege/konstanten.js'`; in `mapInvoice` neben den bisherigen Feldern ergänzen: `projektId: metadata.projektId || ''`, `projektName: metadata.projektName || ''`, `art: RECHNUNGS_ARTEN.includes(metadata.art) ? metadata.art : subscriptionIdOfInvoice(raw) ? 'abo' : 'frei'`, `auftragId: metadata.auftragId || ''`, `auftragNummer: metadata.auftragNummer || ''`. Danach den bisherigen Test `mapInvoice: Felder, Betraege in Cent und Berliner Kalendertage` in `test/financeStripeRead.test.js` nachziehen (Vertragsfrage 1; die Datei gehört laut Übersicht, Nachträge, zu L2, und das ist die einzige Änderung an ihr): im erwarteten Objekt hinter `bankTransactionId: '',` die fünf Zeilen `projektId: '',`, `projektName: '',`, `art: 'frei',`, `auftragId: '',`, `auftragNummer: '',` ergänzen. Nach der Änderung an `mapInvoice` und vor dieser Ergänzung scheitert genau dieser eine Test (`deepEqual` auf den exakten Schlüsselsatz), das ist erwartet.
|
||
|
||
- [ ] **Step 4: Lauf, erwartet PASS**
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && for f in auftraegeStripe financeStripeRead financeAssign financeOverview; do echo "== $f"; timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/$f.test.js 2>&1 | command grep -a -E "^# (pass|fail)"; done`
|
||
Expected: je Datei `# fail 0`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2
|
||
git add server/services/finance/stripeFinance.js test/financeStripeRead.test.js test/auftraegeStripe.test.js
|
||
git commit -m "Rechnungen: mapInvoice liefert Projekt, Auftrag und Art
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: `createProjectInvoice` – Zusatzfelder, A4, Projektdatensatz, Vorschau
|
||
|
||
**Files:**
|
||
- Modify: `server/services/finance/stripeFinance.js`
|
||
- Modify: `test/auftraegeStripe.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `baueRechnung`, `ladeFestenKunden` (`../auftraege/rechnungsbau.js`); `createProjektRechnung(invoiceId, daten)` (`../auftraege/projektRechnungenStore.js`); die bestehenden Helfer in `stripeFinance.js` (`claimCreate`, `releaseCreate`, `syncStripeCustomer`, `assertAddressForSend`, `finalizeAndSend`, `readInvoice`).
|
||
- Produces:
|
||
- `createProjectInvoice({ customerId, art = 'frei', projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, daysUntilDue, memo, senden, idempotencyKey, createdBy }): Promise<Rechnung>` (V4; Rechnung = `mapInvoice`-Form).
|
||
- `rechnungsVorschau(eingabe): Promise<{ positionen, summeCents, leistungszeitraum, customFields }>` mit `eingabe` wie bei `baueRechnung` (ohne `createdBy`), ohne Stripe-Schreibzugriff.
|
||
|
||
- [ ] **Step 1: Failing Test** – am Ende von `test/auftraegeStripe.test.js` anhängen:
|
||
|
||
```js
|
||
test('createProjectInvoice frei: A4 für jede Rechnung, Art in den Metadaten, keine Zusatzfelder, kein Projektdatensatz', async () => {
|
||
const rechnung = await createProjectInvoice({ customerId: 'k1', items: POSTEN, createdBy: 'kenso@webklar.com' })
|
||
const [create] = callsTo('POST', '/v1/invoices')
|
||
assert.equal(create.body['rendering[pdf][page_size]'], 'a4')
|
||
assert.equal(create.body['metadata[art]'], 'frei')
|
||
assert.equal(create.body['metadata[webklarCustomerId]'], 'k1')
|
||
assert.ok(!Object.keys(create.body).some((k) => k.startsWith('custom_fields')))
|
||
assert.ok(!Object.keys(create.body).some((k) => /^metadata\[(projektId|projektName|auftragId|auftragNummer)\]$/.test(k)))
|
||
assert.deepEqual([rechnung.art, rechnung.status, rechnung.totalCents, rechnung.projektId], ['frei', 'entwurf', 1200, ''])
|
||
assert.equal(aw.docs(config.collections.projektRechnungen).size, 0)
|
||
})
|
||
|
||
test('createProjectInvoice festbetrag: Zusatzfeld Projekt, Metadaten und ein Projektdatensatz mit der Rechnungs-ID', async () => {
|
||
const items = [{ description: 'Erstellung der Website anelia-wolf.com, Pauschalpreis laut Absprache', quantity: 1, unitAmountCents: 50000 }]
|
||
const rechnung = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items, createdBy: 'kenso@webklar.com' })
|
||
const [create] = callsTo('POST', '/v1/invoices')
|
||
assert.equal(create.body['custom_fields[0][name]'], 'Projekt')
|
||
assert.equal(create.body['custom_fields[0][value]'], 'Website anelia-wolf.com')
|
||
assert.equal(create.body['custom_fields[1][name]'], undefined)
|
||
assert.equal(create.body['rendering[pdf][page_size]'], 'a4')
|
||
assert.deepEqual([create.body['metadata[art]'], create.body['metadata[projektId]'], create.body['metadata[projektName]']], ['festbetrag', 'p1', 'Website anelia-wolf.com'])
|
||
assert.deepEqual([rechnung.art, rechnung.projektId, rechnung.projektName, rechnung.totalCents], ['festbetrag', 'p1', 'Website anelia-wolf.com', 50000])
|
||
const doc = await getProjektRechnung(rechnung.id)
|
||
assert.deepEqual(
|
||
[doc.invoiceId, doc.kundenId, doc.projektId, doc.auftragId, doc.art, doc.betragCents, doc.rechnungsnummer, doc.aktiv, doc.erstelltVon],
|
||
[rechnung.id, 'k1', 'p1', '', 'festbetrag', 50000, '', true, 'kenso@webklar.com']
|
||
)
|
||
})
|
||
|
||
test('createProjectInvoice aufwand: eine Zeile je Tag bei Stripe, Blätter im Projektdatensatz, Zeitraum als Zusatzfeld', async () => {
|
||
const rechnung = await createProjectInvoice({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws2', 'ws3'], createdBy: 'k' })
|
||
const items = callsTo('POST', '/v1/invoiceitems')
|
||
assert.deepEqual(items.map((c) => [c.body.quantity, c.body.unit_amount_decimal, c.body.amount]), [['1', '22500', undefined], ['1', '10000', undefined]])
|
||
assert.ok(items[0].body.description.startsWith('06.10.2026: '))
|
||
const [create] = callsTo('POST', '/v1/invoices')
|
||
assert.deepEqual([0, 1].map((i) => create.body[`custom_fields[${i}][name]`]), ['Projekt', 'Leistungszeitraum'])
|
||
assert.equal(create.body['custom_fields[1][value]'], '06.10.–07.10.2026')
|
||
assert.equal(rechnung.totalCents, 32500)
|
||
assert.deepEqual((await getProjektRechnung(rechnung.id)).arbeitsblattIds, ['ws1', 'ws2', 'ws3'])
|
||
})
|
||
|
||
test('createProjectInvoice schluss: positive Zeilen mit Menge und Einzelpreis, die Minuszeile nur mit amount (ohne quantity)', async () => {
|
||
const ab = await legeAuftragAn()
|
||
const anzahlung = await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, senden: true, createdBy: 'k' })
|
||
assert.deepEqual([anzahlung.totalCents, anzahlung.art, anzahlung.auftragNummer, anzahlung.status], [40000, 'anzahlung', 'AB-2026-0002', 'offen'])
|
||
st.calls.length = 0
|
||
const schluss = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, createdBy: 'k' })
|
||
const [create] = callsTo('POST', '/v1/invoices')
|
||
assert.deepEqual(
|
||
[0, 1].map((i) => [create.body[`custom_fields[${i}][name]`], create.body[`custom_fields[${i}][value]`]]),
|
||
[['Projekt', 'Website anelia-wolf.com'], ['Auftrag', 'AB-2026-0002 vom 06.10.2026']]
|
||
)
|
||
const items = callsTo('POST', '/v1/invoiceitems')
|
||
const kunde = items[0].body.customer
|
||
assert.deepEqual(items.map((c) => c.body), [
|
||
{ customer: kunde, invoice: schluss.id, description: 'Website', quantity: '1', unit_amount_decimal: '60000', currency: 'eur' },
|
||
{ customer: kunde, invoice: schluss.id, description: 'Texte', quantity: '2', unit_amount_decimal: '10000', currency: 'eur' },
|
||
{ customer: kunde, invoice: schluss.id, description: `abzüglich Anzahlung, Rechnung ${anzahlung.number} vom ${formatDe(berlinDate())}`, amount: '-40000', currency: 'eur' },
|
||
])
|
||
assert.deepEqual([schluss.totalCents, schluss.art, schluss.status], [40000, 'schluss', 'entwurf'])
|
||
})
|
||
|
||
test('createProjectInvoice: scheitert der Projektdatensatz, wird der Entwurf bei Stripe gelöscht und nichts bleibt zurück', async () => {
|
||
const eingabe = { customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, createdBy: 'k' }
|
||
aw.failNext({ collection: config.collections.projektRechnungen, method: 'POST', status: 500 })
|
||
await assert.rejects(createProjectInvoice(eingabe))
|
||
assert.equal(st.invoices.size, 0)
|
||
assert.equal(callsTo('DELETE', /^\/v1\/invoices\/in_/).length, 1)
|
||
assert.equal(aw.docs(config.collections.projektRechnungen).size, 0)
|
||
// der Schutz vor doppeltem Anlegen ist wieder frei
|
||
assert.equal((await createProjectInvoice(eingabe)).status, 'entwurf')
|
||
})
|
||
|
||
test('createProjectInvoice: der Fingerabdruck unterscheidet Art, Projekt, Auftrag und Anzahlung, gleicher Inhalt bleibt 30 Sekunden gesperrt', async () => {
|
||
const basis = { customerId: 'k1', items: POSTEN }
|
||
const GESPERRT = 'Diese Rechnung wird gerade schon erstellt.'
|
||
await createProjectInvoice(basis)
|
||
await createProjectInvoice({ ...basis, art: 'festbetrag', projektId: 'p1' })
|
||
await createProjectInvoice({ ...basis, art: 'festbetrag', projektId: 'p3' })
|
||
await wirft(createProjectInvoice({ ...basis, art: 'festbetrag', projektId: 'p3' }), 409, GESPERRT)
|
||
const ab = await legeAuftragAn()
|
||
await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 50 } })
|
||
await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 25 } })
|
||
await wirft(createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 25 } }), 409, GESPERRT)
|
||
assert.equal(st.invoices.size, 5)
|
||
})
|
||
|
||
test('rechnungsVorschau: Positionen, Summe, Zeitraum und Zusatzfelder ohne Stripe-Schreibzugriff und ohne Projektdatensatz', async () => {
|
||
const v = await rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p1' })
|
||
assert.deepEqual(Object.keys(v).sort(), ['customFields', 'leistungszeitraum', 'positionen', 'summeCents'])
|
||
assert.equal(v.summeCents, 32500)
|
||
assert.deepEqual(v.positionen.map((p) => p.schluessel), ['2026-10-06', '2026-10-07'])
|
||
assert.equal(v.leistungszeitraum, '06.10.–07.10.2026')
|
||
assert.deepEqual(v.customFields.map((f) => f.name), ['Projekt', 'Leistungszeitraum'])
|
||
assert.equal(writes().length, 0)
|
||
assert.equal(aw.docs(config.collections.projektRechnungen).size, 0)
|
||
})
|
||
|
||
test('rechnungsVorschau: dieselben Prüfungen wie das Anlegen; Anzahlung ohne Angaben zeigt den Standardwert', async () => {
|
||
await createProjectInvoice({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] })
|
||
await wirft(rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws3'] }), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
|
||
await wirft(rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p2' }), 404, 'Projekt nicht gefunden.')
|
||
const ab = await legeAuftragAn()
|
||
const v = await rechnungsVorschau({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id })
|
||
assert.deepEqual([v.summeCents, v.positionen[0].schluessel], [40000, 'anzahlung'])
|
||
assert.equal(aw.docs(config.collections.projektRechnungen).size, 1, 'nur der Entwurf von oben')
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Lauf, erwartet FAIL**
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeStripe.test.js`
|
||
Expected: FAIL (`createProjectInvoice` kennt `art` nicht und schreibt weder Zusatzfelder noch A4; `rechnungsVorschau` ist nicht definiert). Der `mapInvoice`-Test aus Task 9 bleibt grün.
|
||
|
||
- [ ] **Step 3: Implementieren** in `server/services/finance/stripeFinance.js`:
|
||
- **Importe:** `withLocks` aus `../keyedLock.js` kommt erst in Task 11; hier `baueRechnung`, `ladeFestenKunden` aus `../auftraege/rechnungsbau.js` und `createProjektRechnung` aus `../auftraege/projektRechnungenStore.js`. Entfallen: `validateItems`, `loadBillableCustomer` und die dadurch ungenutzten Konstanten (`MAX_ITEMS`, `MAX_DESCRIPTION`, `MAX_QUANTITY`, `MAX_UNIT_CENTS`, `MAX_TOTAL_CENTS`) sowie der Import `getDocument`, falls er danach ungenutzt ist. `sendInvoiceNow` ruft statt `loadBillableCustomer` jetzt `ladeFestenKunden` (Meldungen und Status bleiben gleich, die Fehlerklasse ist nun `AuftragError`).
|
||
- **`createProjectInvoice`** (Signatur siehe Interfaces; `daysUntilDue = 14`, `memo = ''`, `senden = false`): Zahlungsziel (ganze Zahl 0 bis 60), Vermerk (höchstens 1000 Zeichen) und Wiederholungsschlüssel wie bisher zuerst prüfen (gleiche Meldungen, Status 400). Fingerabdruck: SHA-256 über `JSON.stringify([customerId, art, projektId ?? '', auftragId ?? '', items ?? null, arbeitsblattIds (Kopie, sortiert) ?? null, anzahlung ?? null, zeilentexte ?? null, leistungszeitraum ?? '', note, daysUntilDue, senden === true])` (erweitert den bisherigen um Art, Projekt, Auftrag, Arbeitsblätter, Anzahlung, Zeilentexte und Zeitraum). Der Fingerabdruck darf bei falschen Typen nie werfen: ein Array wird sortiert kopiert, jeder andere Wert bleibt unverändert; die Typfehler meldet `baueRechnung` mit 400. `claimCreate`, `releaseCreate` und die 30-Sekunden-Regel bleiben; die Positionen werden jetzt in `baueRechnung` geprüft, scheitert das, wird der Schutz wieder freigegeben.
|
||
- **`createInvoice`:** `entwurf = await baueRechnung({ customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, createdBy })`; die E-Mail-Prüfung (`NO_EMAIL_MESSAGE`) und die Adress-Sperre über 250 € benutzen `entwurf.kunde` und `entwurf.summeCents`; danach unverändert Einstellungen, Stripe-Kunde und Abgleich (`syncStripeCustomer`). `stripe.invoices.create` bekommt zusätzlich `metadata: entwurf.metadata` (ersetzt das bisherige Objekt, es enthält dieselben drei Schlüssel plus die neuen), `custom_fields: entwurf.customFields` (nur wenn nicht leer, sonst weglassen) und `rendering: { pdf: { page_size: 'a4' } }`. Posten der Reihe nach: Position mit `einzelpreisCents > 0` genau wie bisher (`customer`, `invoice`, `description`, `quantity: menge`, `unit_amount_decimal: String(einzelpreisCents)`, `currency: 'eur'`); Minuszeile (`einzelpreisCents < 0`) nur mit `customer`, `invoice`, `description`, `amount: betragCents` (negativ) und `currency: 'eur'`, ohne `quantity`. Gibt es `entwurf.projektRechnung`, danach `await createProjektRechnung(invoiceId, entwurf.projektRechnung)`. Das Ganze steht im selben `try` wie bisher, dessen `catch` den halbfertigen Entwurf löscht (`stripe.invoices.del(invoiceId).catch(() => {})`, `invalidateFinanceCache()`, Fehler weiterreichen). Finalisieren, Senden, Lesen der Rechnung und die 502-Fehler mit `invoiceId` bleiben unverändert.
|
||
- **`export async function rechnungsVorschau(eingabe)`:** `entwurf = await baueRechnung(eingabe)`; Rückgabe genau `{ positionen, summeCents, leistungszeitraum, customFields }`; kein Schutz vor doppeltem Anlegen, keine Sperre, kein Schreibzugriff.
|
||
|
||
- [ ] **Step 4: Lauf, erwartet PASS** – neue Datei und die bestehenden Schreibtests (der Fingerabdruck, die Adress-Sperre und die 502-Fälle müssen unverändert funktionieren):
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && for f in auftraegeStripe financeStripeWrite financeSettings financeOverview financeAssign; do echo "== $f"; timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/$f.test.js 2>&1 | command grep -a -E "^# (pass|fail)"; done`
|
||
Expected: je Datei `# fail 0`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2
|
||
git add server/services/finance/stripeFinance.js test/auftraegeStripe.test.js
|
||
git commit -m "Rechnungen: Projektrechnungen bei Stripe (Zusatzfelder, A4, Minuszeilen, Projektdatensatz) und Vorschau
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Sperre gegen Doppelabrechnung
|
||
|
||
**Files:**
|
||
- Modify: `server/services/finance/stripeFinance.js`
|
||
- Modify: `test/auftraegeStripe.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `withLocks(keys, fn)` (`../keyedLock.js`), `sperreRechnung(kundenId)` (`../auftraege/konstanten.js`, die Schlüssel legt V2 fest).
|
||
- Produces: `createProjectInvoice` führt für `art` `aufwand`, `anzahlung` und `schluss` die ganze Anlage unter `withLocks(sperreRechnung(customerId), …)` aus; `frei` und `festbetrag` laufen ohne Sperre.
|
||
|
||
- [ ] **Step 1: Failing Test** – am Ende von `test/auftraegeStripe.test.js` anhängen (der Nachbau verzögert die erste Anlage bei Stripe, damit ohne Sperre beide Aufrufe gleichzeitig prüfen würden):
|
||
|
||
```js
|
||
test('Aufwand: zwei gleichzeitige Rechnungen mit denselben Blättern -> genau eine entsteht, die andere bekommt 409', async () => {
|
||
st.delayNext({ method: 'POST', path: '/v1/invoices', ms: 150 })
|
||
const eingabe = (memo) => ({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws2'], memo, createdBy: 'k' })
|
||
const ergebnisse = await Promise.allSettled([createProjectInvoice(eingabe('Admin A')), createProjectInvoice(eingabe('Admin B'))])
|
||
assert.deepEqual(ergebnisse.map((e) => e.status), ['fulfilled', 'rejected'])
|
||
assert.equal(ergebnisse[1].reason.status, 409)
|
||
assert.equal(ergebnisse[1].reason.message, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
|
||
assert.equal(st.invoices.size, 1)
|
||
assert.equal(callsTo('POST', '/v1/invoices').length, 1)
|
||
assert.equal(aw.docs(config.collections.projektRechnungen).size, 1)
|
||
})
|
||
|
||
test('Aufwand: eine nur teilweise überschneidende Auswahl wird abgewiesen, die übrigen Blätter bleiben frei', async () => {
|
||
const eingabe = (ids, memo) => ({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ids, memo })
|
||
await createProjectInvoice(eingabe(['ws1', 'ws2'], 'A'))
|
||
await wirft(createProjectInvoice(eingabe(['ws2', 'ws3'], 'B')), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
|
||
const rest = await createProjectInvoice(eingabe(['ws3'], 'C'))
|
||
assert.equal(rest.totalCents, 10000)
|
||
assert.equal(st.invoices.size, 2)
|
||
})
|
||
|
||
test('Doppelklick (gleicher Inhalt gleichzeitig): der Schutz vor doppeltem Anlegen greift, es entsteht eine Rechnung', async () => {
|
||
const eingabe = { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] }
|
||
const ergebnisse = await Promise.allSettled([createProjectInvoice(eingabe), createProjectInvoice(eingabe)])
|
||
assert.deepEqual(ergebnisse.map((e) => e.status), ['fulfilled', 'rejected'])
|
||
assert.equal(ergebnisse[1].reason.message, 'Diese Rechnung wird gerade schon erstellt.')
|
||
assert.equal(st.invoices.size, 1)
|
||
})
|
||
|
||
test('Schluss: zwei gleichzeitige Schlussrechnungen zur selben AB -> eine entsteht, die andere stößt auf den Entwurf (409)', async () => {
|
||
const ab = await legeAuftragAn()
|
||
st.delayNext({ method: 'POST', path: '/v1/invoices', ms: 150 })
|
||
const eingabe = (memo) => ({ customerId: 'k1', art: 'schluss', auftragId: ab.id, memo })
|
||
const ergebnisse = await Promise.allSettled([createProjectInvoice(eingabe('A')), createProjectInvoice(eingabe('B'))])
|
||
assert.deepEqual(ergebnisse.map((e) => e.status), ['fulfilled', 'rejected'])
|
||
assert.equal(ergebnisse[1].reason.status, 409)
|
||
assert.equal(ergebnisse[1].reason.message, `Erst den Entwurf über 800,00${NBSP}€ senden oder löschen.`)
|
||
assert.equal(st.invoices.size, 1)
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Lauf, erwartet FAIL**
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeStripe.test.js`
|
||
Expected: FAIL in genau den beiden Rennen-Tests (Aufwand und Schluss: ohne Sperre prüfen beide Aufrufe, bevor der erste seinen Datensatz schreibt, und es entstehen zwei Rechnungen); der Teilüberschneidungs- und der Doppelklick-Test sind schon grün.
|
||
|
||
- [ ] **Step 3: Implementieren** in `createProjectInvoice`: den Aufruf der Anlage (`createInvoice(…)`) für `art` in `['aufwand', 'anzahlung', 'schluss']` in `withLocks(sperreRechnung(customerId), () => createInvoice(…))` einwickeln (Importe `withLocks` aus `../keyedLock.js`, `sperreRechnung` aus `../auftraege/konstanten.js`); für alle anderen Arten ohne Sperre. `claimCreate` (der schnelle 409 gegen den Doppelklick) bleibt davor, außerhalb der Sperre; die Sperre umschließt Prüfung, Entwurf, Posten, Projektdatensatz und Senden. Die Schlüssel kommen unverändert aus `sperreRechnung`.
|
||
|
||
- [ ] **Step 4: Lauf, erwartet PASS**
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeStripe.test.js`
|
||
Expected: PASS, `# fail 0`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2
|
||
git add server/services/finance/stripeFinance.js test/auftraegeStripe.test.js
|
||
git commit -m "Rechnungen: Aufwand, Anzahlung und Schluss unter der Kundensperre (keine Doppelabrechnung)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Buchführung – Finalisieren, Storno, Entwurf löschen
|
||
|
||
**Files:**
|
||
- Modify: `server/services/finance/stripeFinance.js`
|
||
- Modify: `test/auftraegeStripe.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `updateProjektRechnung(invoiceId, patch): Promise<ProjektRechnung>` (`../auftraege/projektRechnungenStore.js`); `buche(was, fn)`, `gibProjektRechnungFrei(invoiceId)` (`../auftraege/rechnungsbau.js`, Task 7); `getAuftrag(id)`, `updateAuftrag(id, patch)` (`../auftraege/auftragStore.js`); `withLocks(keys, fn)`, `sperreAuftrag(auftragId)`; `RECHNUNGS_ARTEN`.
|
||
- Produces: Finalisieren (`createProjectInvoice` mit `senden`, `sendInvoiceNow`) setzt `projektRechnungen.rechnungsnummer` und bei `schluss` die AB auf `abgerechnet` (nur aus `versendet` oder `bestaetigt`); `voidInvoice` und `deleteDraft` setzen `aktiv: false` und nehmen bei `schluss` `abgerechnet` zurück (Ziel `bestaetigt`, wenn `bestaetigtAm` gesetzt, sonst `versendet`). Fehler dieser Buchungen werden protokolliert, nie geworfen. Die AB ändert L2 nur unter `withLocks(sperreAuftrag(auftragId), …)` (neuer Export `setzeAbAbgerechnet(auftragId)` in `rechnungsbau.js`). Erneutes Senden einer offenen oder überfälligen Projektrechnung trägt eine fehlende Rechnungsnummer im Datensatz nach.
|
||
|
||
- [ ] **Step 1: Failing Test** – am Ende von `test/auftraegeStripe.test.js` anhängen:
|
||
|
||
```js
|
||
test('Finalisieren trägt die Rechnungsnummer in den Projektdatensatz ein (senden und sendInvoiceNow); freie Rechnungen haben keinen Datensatz', async () => {
|
||
const gesendet = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, senden: true })
|
||
assert.match(gesendet.number, /^WK-\d{4}$/)
|
||
assert.equal((await getProjektRechnung(gesendet.id)).rechnungsnummer, gesendet.number)
|
||
const entwurf = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, memo: 'später' })
|
||
assert.equal((await getProjektRechnung(entwurf.id)).rechnungsnummer, '')
|
||
const nachher = await sendInvoiceNow(entwurf.id)
|
||
assert.equal((await getProjektRechnung(entwurf.id)).rechnungsnummer, nachher.number)
|
||
const frei = await createProjectInvoice({ customerId: 'k1', items: POSTEN, memo: 'frei', senden: true })
|
||
assert.equal(await getProjektRechnung(frei.id), null)
|
||
})
|
||
|
||
test('Schlussrechnung: nach dem Finalisieren steht die AB auf abgerechnet; Anzahlung und Entwurf ändern den Status nicht', async () => {
|
||
const ab = await legeAuftragAn()
|
||
await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, senden: true })
|
||
assert.equal((await getAuftrag(ab.id)).status, 'versendet')
|
||
const entwurf = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id })
|
||
assert.equal((await getAuftrag(ab.id)).status, 'versendet')
|
||
const gesendet = await sendInvoiceNow(entwurf.id)
|
||
assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet')
|
||
assert.equal((await getProjektRechnung(entwurf.id)).rechnungsnummer, gesendet.number)
|
||
await wirft(createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, memo: 'noch eine' }), 400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.')
|
||
})
|
||
|
||
test('Storno der Schlussrechnung: Datensatz inaktiv, AB zurück auf bestaetigt (mit bestaetigtAm) bzw. versendet; Storno einer Anzahlung ändert die AB nie', async () => {
|
||
for (const [patch, erwartet] of [[{}, 'versendet'], [{ status: 'bestaetigt', bestaetigtAm: '2026-10-07T09:00:00.000Z' }, 'bestaetigt']]) {
|
||
await leereRechnungsDaten(aw, st)
|
||
resetInvoiceCreateGuardForTests()
|
||
const ab = await legeAuftragAn({}, patch)
|
||
const anzahlung = await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, senden: true })
|
||
await voidInvoice(anzahlung.id)
|
||
assert.equal((await getProjektRechnung(anzahlung.id)).aktiv, false)
|
||
assert.equal((await getAuftrag(ab.id)).status, patch.status ?? 'versendet')
|
||
const schluss = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, senden: true })
|
||
assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet')
|
||
await voidInvoice(schluss.id)
|
||
assert.equal((await getProjektRechnung(schluss.id)).aktiv, false)
|
||
assert.equal((await getAuftrag(ab.id)).status, erwartet)
|
||
// die stornierten Rechnungen zählen nicht: eine neue Schlussrechnung geht über die volle Summe
|
||
assert.equal((await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, memo: 'neu' })).totalCents, 80000)
|
||
}
|
||
})
|
||
|
||
test('Entwurf löschen und Storno geben die Arbeitsblätter frei (Datensatz inaktiv)', async () => {
|
||
const args = { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'], createdBy: 'k' }
|
||
const entwurf = await createProjectInvoice(args)
|
||
await wirft(createProjectInvoice({ ...args, memo: 'nochmal' }), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
|
||
await deleteDraft(entwurf.id)
|
||
assert.equal((await getProjektRechnung(entwurf.id)).aktiv, false)
|
||
const offen = await createProjectInvoice({ ...args, memo: 'nochmal', senden: true })
|
||
assert.equal(offen.status, 'offen')
|
||
await wirft(createProjectInvoice({ ...args, memo: 'drittes Mal' }), 409, `Arbeitsblatt bereits abgerechnet (Rechnung ${offen.number}).`)
|
||
await voidInvoice(offen.id)
|
||
assert.equal((await getProjektRechnung(offen.id)).aktiv, false)
|
||
assert.equal((await createProjectInvoice({ ...args, memo: 'viertes Mal' })).status, 'entwurf')
|
||
})
|
||
|
||
test('Scheitert das Buchen nach dem Finalisieren, wird die Rechnung trotzdem gesendet (nur Protokoll); erneutes Senden trägt die Nummer nach', async () => {
|
||
const log = mock.method(console, 'error', () => {})
|
||
aw.failNext({ collection: config.collections.projektRechnungen, method: 'PATCH', status: 500 })
|
||
const rechnung = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, senden: true })
|
||
assert.equal(rechnung.status, 'offen')
|
||
assert.equal(callsTo('POST', /\/send$/).length, 1)
|
||
assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes('Rechnungsnummer buchen')))
|
||
assert.equal((await getProjektRechnung(rechnung.id)).rechnungsnummer, '')
|
||
const erneut = await sendInvoiceNow(rechnung.id)
|
||
assert.equal(erneut.status, 'offen')
|
||
assert.equal((await getProjektRechnung(rechnung.id)).rechnungsnummer, rechnung.number)
|
||
})
|
||
|
||
test('Die AB ändert L2 nur unter der AB-Sperre: eine laufende Statusänderung eines anderen Strangs wird nicht überholt', async () => {
|
||
const ab = await legeAuftragAn()
|
||
const entwurf = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id })
|
||
let freigeben
|
||
const gehalten = withLocks(sperreAuftrag(ab.id), () => new Promise((resolve) => { freigeben = resolve }))
|
||
const senden = sendInvoiceNow(entwurf.id)
|
||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||
assert.equal((await getAuftrag(ab.id)).status, 'versendet', 'L2 wartet auf die Sperre der AB')
|
||
freigeben()
|
||
await Promise.all([gehalten, senden])
|
||
assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet')
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Lauf, erwartet FAIL**
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeStripe.test.js`
|
||
Expected: FAIL (`rechnungsnummer` bleibt leer, die AB wird nicht `abgerechnet`, die Datensätze bleiben aktiv).
|
||
|
||
- [ ] **Step 3: Implementieren** in `server/services/auftraege/rechnungsbau.js` und `server/services/finance/stripeFinance.js`:
|
||
- `rechnungsbau.js`: neuer Export `setzeAbAbgerechnet(auftragId)`: unter `withLocks(sperreAuftrag(auftragId), …)`: `ab = await getAuftrag(auftragId)`; steht der Status auf `versendet` oder `bestaetigt`, `updateAuftrag(ab.id, { status: 'abgerechnet' })`. (`buche`, `setzeAbZurueck` und `gibProjektRechnungFrei` gibt es seit Task 7.)
|
||
- `stripeFinance.js`: `PROJEKT_ARTEN = RECHNUNGS_ARTEN.filter((art) => art !== 'frei')`; Importe `buche`, `gibProjektRechnungFrei`, `setzeAbAbgerechnet` aus `../auftraege/rechnungsbau.js` und `updateProjektRechnung` aus `../auftraege/projektRechnungenStore.js`.
|
||
- `bucheNummer(invoiceId, number, art)`: nur für `PROJEKT_ARTEN`; `buche('Rechnungsnummer buchen', () => updateProjektRechnung(invoiceId, { rechnungsnummer: number }))`.
|
||
- `bucheFinalisiert(invoiceId, number, art)`: nur für `PROJEKT_ARTEN`; `buche('Rechnungsnummer buchen', …)` mit `doc = await updateProjektRechnung(invoiceId, { rechnungsnummer: number })` und, bei `art === 'schluss'` und gesetztem `doc.auftragId`, `await setzeAbAbgerechnet(doc.auftragId)`.
|
||
- `bucheStorniert(invoiceId, art)`: nur für `PROJEKT_ARTEN`; `buche('Rechnung freigeben', () => gibProjektRechnungFrei(invoiceId))` (setzt `aktiv: false` und nimmt bei einer Schlussrechnung `abgerechnet` zurück, Ziel `bestaetigt`, wenn `bestaetigtAm` gesetzt, sonst `versendet`).
|
||
- `finalizeAndSend(stripe, invoiceId, art = 'frei')`: nach `finalizeInvoice`, vor `sendInvoice`: `await bucheFinalisiert(invoiceId, number, art)`. Aufrufer: `createInvoice` mit `entwurf.art`, `sendInvoiceNow` (Entwurf) mit `invoice.art`.
|
||
- `sendInvoiceNow`: wird eine offene oder überfällige Projektrechnung erneut gesendet, vorher `await bucheNummer(invoice.id, invoice.number, invoice.art)` (Selbstheilung nach einem ausgefallenen Buchen; der Status der AB bleibt dabei unberührt).
|
||
- `voidInvoice` und `deleteDraft`: nach der Stripe-Aktion `await bucheStorniert(invoice.id, invoice.art)` (Stripe zuerst: ein ausgefallenes Appwrite lässt die Blätter eher belegt als frei, das ist die sichere Richtung; Vertragsfrage 8).
|
||
|
||
- [ ] **Step 4: Lauf, erwartet PASS** – neue Datei und die bestehenden Tests, die Senden, Storno und Löschen prüfen:
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && for f in auftraegeStripe financeStripeWrite financeAssign; do echo "== $f"; timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/$f.test.js 2>&1 | command grep -a -E "^# (pass|fail)"; done`
|
||
Expected: je Datei `# fail 0`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2
|
||
git add server/services/finance/stripeFinance.js test/auftraegeStripe.test.js
|
||
git commit -m "Rechnungen: Rechnungsnummer buchen, AB abgerechnet, Storno und Löschen geben frei
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Routen – `POST /rechnungen` erweitert, `POST /rechnungen/vorschau`
|
||
|
||
**Files:**
|
||
- Modify: `server/routes/admin/finanzen.js`
|
||
- Create: `test/auftraegeRoutesRechnungen.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `createProjectInvoice`, `rechnungsVorschau` (`stripeFinance.js`); `AuftragError` (`../../services/auftraege/fehler.js`).
|
||
- Produces (V5): `POST /api/admin/finanzen/rechnungen` mit Körper `{ customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung: { prozent, betragCents, bezeichnung }, zeilentexte, leistungszeitraum, daysUntilDue, memo, senden, idempotencyKey }` → `{ rechnung }`; `POST /api/admin/finanzen/rechnungen/vorschau` mit demselben Körper ohne `senden`, `idempotencyKey`, `daysUntilDue`, `memo` → `{ positionen, summeCents, leistungszeitraum, customFields }`; `GET /api/admin/finanzen/rechnungen` trägt je Rechnung `projektId`, `projektName`, `art`, `auftragId`, `auftragNummer`. Fehler immer `{ error }` mit 400, 404, 409 oder 502.
|
||
|
||
- [ ] **Step 1: Failing Test** – `test/auftraegeRoutesRechnungen.test.js` anlegen:
|
||
|
||
```js
|
||
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 { schemaKeys } from './support/schema.js'
|
||
import { useTestEnv } from './support/testEnv.js'
|
||
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
|
||
|
||
const aw = await startAppwriteMock({
|
||
schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') },
|
||
datetimes: AUFTRAEGE_DATETIMES,
|
||
defaultLimit: 25,
|
||
})
|
||
const st = await startFakeStripe()
|
||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||
const { config } = await import('../server/config.js')
|
||
const { seedRechnungsDaten, legeAuftragAn, leereRechnungsDaten } = await import('./auftraegeRechnungenHilfe.js')
|
||
const { invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js')
|
||
const { startTestApp, post, rawGet } = await import('./support/testApp.js')
|
||
const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js')
|
||
|
||
const app = await startTestApp({ '/api/admin/finanzen': financeRoutes })
|
||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
||
seedRechnungsDaten(aw)
|
||
config.auftraege.mode = 'on'
|
||
beforeEach(async () => {
|
||
await leereRechnungsDaten(aw, st)
|
||
invalidateFinanceCache()
|
||
resetInvoiceCreateGuardForTests()
|
||
})
|
||
|
||
const admin = { headers: { Authorization: 'Bearer jwt-admin' } }
|
||
const staff = { headers: { Authorization: 'Bearer jwt-staff' } }
|
||
const base = '/api/admin/finanzen'
|
||
const POSTEN = [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }]
|
||
const SCHALTER_AUS = 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.'
|
||
const schreibaufrufe = () => st.calls.filter((c) => c.method !== 'GET')
|
||
const erwarte = async (pfad, body, status, error) => {
|
||
const res = await post(`${base}${pfad}`, body, admin)
|
||
assert.equal(res.status, status, JSON.stringify(res.body))
|
||
assert.deepEqual(res.body, { error })
|
||
}
|
||
|
||
test('POST /rechnungen: der bisherige Körper (ohne art) legt eine freie A4-Rechnung an', async () => {
|
||
const res = await post(`${base}/rechnungen`, { customerId: 'k1', items: POSTEN, daysUntilDue: 14, memo: '', senden: false, idempotencyKey: 'form-1' }, admin)
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
assert.deepEqual([res.body.rechnung.art, res.body.rechnung.status, res.body.rechnung.totalCents], ['frei', 'entwurf', 1200])
|
||
assert.equal(st.calls.find((c) => c.method === 'POST' && c.path === '/v1/invoices').body['rendering[pdf][page_size]'], 'a4')
|
||
})
|
||
|
||
test('POST /rechnungen: Festbetrag, Aufwand, Anzahlung und Schluss tragen Projekt, Auftrag und Art in der Antwort', async () => {
|
||
const ab = await legeAuftragAn()
|
||
const antwort = async (body) => {
|
||
const res = await post(`${base}/rechnungen`, body, admin)
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
return res.body.rechnung
|
||
}
|
||
const fest = await antwort({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN })
|
||
assert.deepEqual([fest.art, fest.projektId, fest.projektName, fest.auftragId], ['festbetrag', 'p1', 'Website anelia-wolf.com', ''])
|
||
const aufwand = await antwort({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws2'] })
|
||
assert.deepEqual([aufwand.art, aufwand.totalCents], ['aufwand', 22500])
|
||
const anzahlung = await antwort({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 25 }, senden: 'true' })
|
||
assert.deepEqual([anzahlung.art, anzahlung.status, anzahlung.totalCents, anzahlung.auftragNummer], ['anzahlung', 'offen', 20000, 'AB-2026-0002'])
|
||
const schluss = await antwort({ customerId: 'k1', art: 'schluss', auftragId: ab.id })
|
||
assert.deepEqual([schluss.art, schluss.totalCents], ['schluss', 60000])
|
||
})
|
||
|
||
test('POST /rechnungen/vorschau: Positionen, Summe, Zeitraum und Zusatzfelder, ohne Stripe-Schreibzugriff und ohne Datensatz', async () => {
|
||
const res = await post(`${base}/rechnungen/vorschau`, { customerId: 'k1', art: 'aufwand', projektId: 'p1' }, admin)
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
assert.deepEqual(Object.keys(res.body).sort(), ['customFields', 'leistungszeitraum', 'positionen', 'summeCents'])
|
||
assert.equal(res.body.summeCents, 32500)
|
||
assert.deepEqual(res.body.positionen.map((p) => p.schluessel), ['2026-10-06', '2026-10-07'])
|
||
assert.equal(res.body.leistungszeitraum, '06.10.–07.10.2026')
|
||
assert.deepEqual(res.body.customFields.map((f) => f.name), ['Projekt', 'Leistungszeitraum'])
|
||
assert.equal(schreibaufrufe().length, 0)
|
||
assert.equal(aw.docs(config.collections.projektRechnungen).size, 0)
|
||
})
|
||
|
||
test('Fehler kommen mit Status und nur der Meldung an (400, 404, 409), Schalter aus 400; nichts bei Stripe', async () => {
|
||
const ab = await legeAuftragAn()
|
||
for (const pfad of ['/rechnungen', '/rechnungen/vorschau']) {
|
||
await erwarte(pfad, { customerId: 'k1', art: 'quatsch', items: POSTEN }, 400, 'Unbekannte Rechnungsart.')
|
||
await erwarte(pfad, { customerId: 'gibtEsNicht', items: POSTEN }, 400, 'Kunde nicht gefunden.')
|
||
await erwarte(pfad, { customerId: 'k1', art: 'festbetrag', projektId: 'p2', items: POSTEN }, 404, 'Projekt nicht gefunden.')
|
||
await erwarte(pfad, { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['wsFremd'] }, 400, 'Arbeitsblatt nicht gefunden.')
|
||
await erwarte(pfad, { customerId: 'k1', art: 'schluss', auftragId: 'gibtEsNicht' }, 404, 'Auftrag nicht gefunden.')
|
||
config.auftraege.mode = 'off'
|
||
try {
|
||
await erwarte(pfad, { customerId: 'k1', art: 'anzahlung', auftragId: ab.id }, 400, SCHALTER_AUS)
|
||
} finally {
|
||
config.auftraege.mode = 'on'
|
||
}
|
||
}
|
||
assert.equal(schreibaufrufe().length, 0)
|
||
})
|
||
|
||
test('Abgerechnete Arbeitsblätter: Anlegen und Vorschau antworten 409 mit der Meldung', async () => {
|
||
const res = await post(`${base}/rechnungen`, { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] }, admin)
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
for (const pfad of ['/rechnungen', '/rechnungen/vorschau']) {
|
||
await erwarte(pfad, { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws3'], memo: 'zweite' }, 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
|
||
}
|
||
})
|
||
|
||
test('Falsche Feldtypen im Körper -> 400 mit Meldung, nichts bei Stripe', async () => {
|
||
const ab = await legeAuftragAn()
|
||
await erwarte('/rechnungen', { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: 'ws1' }, 400, 'Die Arbeitsblätter müssen als Liste von Kennungen übergeben werden.')
|
||
await erwarte('/rechnungen', { customerId: 'k1', art: 'aufwand', projektId: 'p1', zeilentexte: ['x'] }, 400, 'Die Zeilentexte müssen ein Objekt mit Texten sein.')
|
||
await erwarte('/rechnungen', { customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: 'viel' }, 400, 'Die Angaben zur Anzahlung müssen ein Objekt sein.')
|
||
await erwarte('/rechnungen', { customerId: 'k1', items: 'x' }, 400, 'Eine Rechnung braucht 1 bis 20 Positionen.')
|
||
await erwarte('/rechnungen', { customerId: 'k1', art: {}, items: POSTEN }, 400, 'Unbekannte Rechnungsart.')
|
||
assert.equal(schreibaufrufe().length, 0)
|
||
})
|
||
|
||
test('Rechte: ohne Anmeldung 401, Mitarbeiter ohne Admin-Label 403; GET /rechnungen trägt die neuen Felder', async () => {
|
||
for (const pfad of ['/rechnungen', '/rechnungen/vorschau']) {
|
||
assert.equal((await post(`${base}${pfad}`, { customerId: 'k1', items: POSTEN })).status, 401)
|
||
assert.equal((await post(`${base}${pfad}`, { customerId: 'k1', items: POSTEN }, staff)).status, 403)
|
||
}
|
||
assert.equal(st.invoices.size, 0)
|
||
await post(`${base}/rechnungen`, { customerId: 'k1', items: POSTEN, memo: 'frei' }, admin)
|
||
await post(`${base}/rechnungen`, { customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN }, admin)
|
||
const liste = (await rawGet(`${base}/rechnungen`, admin)).body.rechnungen
|
||
const felder = (r) => [r.art, r.projektId, r.projektName, r.auftragId, r.auftragNummer]
|
||
assert.deepEqual(liste.map(felder).sort(), [['festbetrag', 'p1', 'Website anelia-wolf.com', '', ''], ['frei', '', '', '', '']])
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Lauf, erwartet FAIL**
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeRoutesRechnungen.test.js`
|
||
Expected: FAIL (die Route reicht `art` und die übrigen Felder nicht weiter, `/rechnungen/vorschau` gibt 404, `AuftragError` wird zu 500).
|
||
|
||
- [ ] **Step 3: Implementieren** in `server/routes/admin/finanzen.js`:
|
||
- Importe: `AuftragError` aus `../../services/auftraege/fehler.js`, `rechnungsVorschau` aus `../../services/finance/stripeFinance.js`.
|
||
- `sendFinanceError`: der Zweig für eigene Fehler gilt auch für `AuftragError` (`err instanceof AssignError || err instanceof InvoiceError || err instanceof AuftragError`): Status und `{ error: err.message }`, `invoiceId` und `invoiceNumber` nur, wenn der Fehler sie trägt; alles andere wie bisher (Stripe-Fehler 409 oder 502, sonst 500 `Interner Fehler.`).
|
||
- `POST /rechnungen`: `const { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, daysUntilDue, memo, senden, idempotencyKey } = req.body || {}`; alles an `createProjectInvoice` weiterreichen (`art` nicht vorbelegen, `daysUntilDue ?? 14`, `senden === true || senden === 'true'`, `createdBy: req.appwriteUser?.email`). Die Typen prüft `baueRechnung`; die Route reicht die Werte unverändert durch.
|
||
- `POST /rechnungen/vorschau` (direkt hinter `POST /rechnungen`): `const { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum } = req.body || {}`; `res.json(await rechnungsVorschau({ … }))`; Fehler über `sendFinanceError(res, 'Rechnungsvorschau', err)`.
|
||
- Kopfkommentar der Datei: Vorschau ergänzen. `GET /rechnungen` bleibt unverändert (die neuen Felder kommen aus `mapInvoice`).
|
||
|
||
- [ ] **Step 4: Lauf, erwartet PASS** – neue Datei und die bestehenden Routentests der Finanzen:
|
||
|
||
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && for f in auftraegeRoutesRechnungen financeOverview financeSettings financeAssign financeRoutesBank; do echo "== $f"; timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/$f.test.js 2>&1 | command grep -a -E "^# (pass|fail)"; done`
|
||
Expected: je Datei `# fail 0`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2
|
||
git add server/routes/admin/finanzen.js test/auftraegeRoutesRechnungen.test.js
|
||
git commit -m "Rechnungen: Routen für Projektrechnungen und Vorschau
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: Selbstprüfung
|
||
|
||
**Files:** keine Änderung (Korrekturen, die sich ergeben, gehen als eigener Commit in die betroffene Datei).
|
||
|
||
**Interfaces:**
|
||
- Consumes: alles aus den Tasks 1 bis 13.
|
||
- Produces: den geprüften Branch `auftraege/l2-rechnungen`, bereit zum Zusammenführen (Z1).
|
||
|
||
- [ ] **Step 1: Alle eigenen Tests grün**
|
||
|
||
```bash
|
||
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && for f in auftraegePositionen auftraegeFakeStripe auftraegeRechnungsbau auftraegeStripe auftraegeRoutesRechnungen; do echo "== $f"; timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/$f.test.js 2>&1 | command grep -a -E "^# (pass|fail)"; done
|
||
```
|
||
Erwartet: je Datei `# fail 0`.
|
||
|
||
- [ ] **Step 2: Bestehende Finanzen-Tests grün** (Schreib-, Lese- und Routentests, Nachbau-Nutzer)
|
||
|
||
```bash
|
||
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && for f in financeStripeWrite financeStripeRead financeSettings financeOverview financeAssign financeRoutesBank financeTodos financeMatching; do echo "== $f"; timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/$f.test.js 2>&1 | command grep -a -E "^# (pass|fail)"; done
|
||
```
|
||
Erwartet: je Datei `# fail 0`.
|
||
|
||
- [ ] **Step 3: Vertragsliste abhaken** (jede Zeile gegen den Code und den benannten Test prüfen):
|
||
- [ ] `festePositionen`, `aufwandPositionen`, `anzahlungPosition`, `schlussPositionen`, `stundenText`, `leistungszeitraumText` mit den Signaturen aus V4 (Tasks 2 bis 4)
|
||
- [ ] Tageszeile (Texte mehrerer Arbeitsblätter mit `; ` verbunden, V4) `06.10.2026: Startseite; Kontaktformular – 2,25 Std. à 100,00 €`, KW-Zeile `KW 41/2026: …`, Schlüssel `KW-2026-41`, Kürzen auf 200 mit `…`, `zeilentexte` ersetzen den Text vor dem Gedankenstrich (Task 3)
|
||
- [ ] Zeilengrenzen: alle Rechnungen 1 bis 20 Zeilen (Minuszeilen zählen mit), die Schlussrechnung bis 40 (AB-Positionen ≤ 20, Zusatzpositionen ≤ 10, Abzüge ≤ 10, je 400 mit eigener Meldung); 20 AB-Positionen plus 1 Abzug gültig, 11 Abzüge ungültig (Tasks 2, 4, 6, 8)
|
||
- [ ] `baueRechnung` liefert `{ kunde, art, projekt, auftrag, positionen, summeCents, leistungszeitraum, customFields, metadata, projektRechnung }` (Tasks 6 bis 8)
|
||
- [ ] Zusatzfelder `Projekt` (auf 140 gekürzt, nicht bei `frei`), `Auftrag` (`AB-2026-0002 vom 06.10.2026`, nur mit AB), `Leistungszeitraum`; höchstens 4, Name ≤ 40, Wert ≤ 140; Metadaten `projektId`, `projektName`, `auftragId`, `auftragNummer`, `art` zusätzlich zu den heutigen (Tasks 6 bis 8, 10)
|
||
- [ ] `createProjectInvoice` mit allen V4-Parametern; A4 für alle Arten; positive Zeile mit `quantity` und `unit_amount_decimal`, Minuszeile mit `amount`; `createProjektRechnung` nach dem Entwurf, nicht bei `frei`; Entwurf wird bei einem Fehler gelöscht (Task 10)
|
||
- [ ] Aufwand, Anzahlung und Schluss unter `withLocks(sperreRechnung(customerId))`; Doppelabrechnung 409 (Task 11); jede Statusänderung der AB durch L2 unter `withLocks(sperreAuftrag(auftragId))` (Tasks 7 und 12)
|
||
- [ ] `rechnungsVorschau` (Task 10) und `POST /rechnungen/vorschau` (Task 13) ohne Stripe-Schreibzugriff
|
||
- [ ] `mapInvoice` liefert `projektId`, `projektName`, `art`, `auftragId`, `auftragNummer`; `GET /rechnungen` trägt sie (Tasks 9 und 13)
|
||
- [ ] Finalisieren setzt `rechnungsnummer`, Schluss setzt die AB auf `abgerechnet`; `voidInvoice` und `deleteDraft` setzen `aktiv: false` und nehmen `abgerechnet` zurück (Ziel `bestaetigt`, wenn `bestaetigtAm` gesetzt, sonst `versendet`) (Task 12)
|
||
- [ ] Adress-Sperre über 250 €, Kunden-Synchronisierung, Schutz vor doppeltem Anlegen (Fingerabdruck um Art, Projekt, Auftrag, Arbeitsblätter erweitert) und die 502-Fehler mit `invoiceId` unverändert (Task 10 und die unveränderten Tests aus Step 2)
|
||
- [ ] 409 `Arbeitsblatt bereits abgerechnet (…)`, offener Entwurf zur AB, `Nichts mehr offen.`, Adresse; 400 `SCHALTER_AUS_MELDUNG` für `anzahlung` und `schluss` bei ausgeschaltetem Schalter (Tasks 4, 7, 8, 11, 13)
|
||
- [ ] Arbeitsblätter gehören zu Tickets des Kunden (`workorders.customerId`), sind abrechenbar und nicht abgerechnet (Task 7)
|
||
- [ ] Selbstheilung (Entscheidung des Orchestrators): eine im Dashboard stornierte oder gelöschte Rechnung hält Arbeitsblätter, frühere Rechnungen und die AB nicht fest, eine fehlende Nummer wird nachgetragen (Tasks 7, 8 und 12)
|
||
- [ ] Nachbau-Ergänzungen nur `custom_fields`, `rendering` und die `amount`-Regel (Task 5)
|
||
|
||
- [ ] **Step 4: Nur eigene Dateien geändert, Arbeitsbaum sauber**
|
||
|
||
```bash
|
||
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l2 && git diff --name-only feature/auftraege...HEAD | sort && git status --short
|
||
```
|
||
Erwartet: genau diese zwölf Dateien in der Diff-Liste,
|
||
`server/routes/admin/finanzen.js`, `server/services/auftraege/positionen.js`, `server/services/auftraege/rechnungsbau.js`, `server/services/finance/stripeFinance.js`, `test/auftraegeFakeStripe.test.js`, `test/auftraegePositionen.test.js`, `test/auftraegeRechnungenHilfe.js`, `test/auftraegeRechnungsbau.test.js`, `test/auftraegeRoutesRechnungen.test.js`, `test/auftraegeStripe.test.js`, `test/financeStripeRead.test.js`, `test/support/fakeStripe.js`,
|
||
und `git status` zeigt nichts oder nur `?? node_modules`. Weicht etwas ab: die fremde Änderung zurücknehmen (nicht committen), eigene offene Änderungen einzeln committen.
|
||
|
||
- [ ] **Step 5: Abschluss melden:** Branch `auftraege/l2-rechnungen`, Zahl der Commits (`git log --oneline feature/auftraege..HEAD | wc -l`, erwartet 12, je einer für die Tasks 2 bis 13), den Stand der Vertragsfragen (alle entschieden, siehe unten) an den Orchestrator. Kein Push.
|
||
|
||
---
|
||
|
||
## Vertragsfragen
|
||
|
||
Stand nach Durchsicht der Pläne der anderen Stränge (L0, L1, W1, Z1), der Entscheidungsdatei des Orchestrators (`vertragsfragen.md` im Scratchpad) und der Planrunde (Übersicht, Abschnitt „Nachträge nach der Planrunde“). Alle Fragen sind entschieden; der Plan setzt um, was unter A steht. Offen ist nichts.
|
||
|
||
**A. Entschieden (siehe Übersicht, Nachträge)**
|
||
|
||
1. **`mapInvoice`-Test:** entschieden (siehe Übersicht, Nachträge): `test/financeStripeRead.test.js` gehört zur Zuständigkeit von L2. V4 verlangt neue Felder an `mapInvoice`, und der Test `mapInvoice: Felder, Betraege in Cent und Berliner Kalendertage` vergleicht per `deepEqual` den **exakten** Schlüsselsatz. Umgesetzt (Task 9): L2 ergänzt dort ausschließlich fünf Zeilen im erwarteten Objekt (`projektId: ''`, `projektName: ''`, `art: 'frei'`, `auftragId: ''`, `auftragNummer: ''`).
|
||
2. **`createdBy` in `baueRechnung`:** entschieden (siehe Übersicht, Nachträge): `eingabe` enthält zusätzlich das optionale Feld `createdBy`, die E-Mail des Anlegers für `metadata.erstelltVon` und `projektRechnung.erstelltVon`.
|
||
3. **Selbstheilung, Umfang:** entschieden (siehe Übersicht, Nachträge): stößt eine Prüfung („bereits abgerechnet“, offener Entwurf zur AB) auf eine Projektrechnung, deren Stripe-Rechnung storniert (`void`) oder gelöscht (404) ist, wird sie `aktiv: false` und die Prüfung wiederholt. Umgesetzt dort, wo L2 ohnehin vor einem 409 oder 400 steht: (a) ausdrückliche Arbeitsblatt-Auswahl und leere Standardauswahl (Task 7), (b) frühere Rechnungen bei `anzahlung` und `schluss` und eine `abgerechnet`e AB (Task 8), jeweils samt Zurücknahme von `abgerechnet`, wenn die Schlussrechnung im Dashboard storniert wurde; dazu der Zusatz aus L1 (Frage 5): eine fehlende Rechnungsnummer wird nachgetragen, auch beim erneuten Senden (Task 12). Die Vorschau heilt ebenfalls (schreibt dann Datensätze in Appwrite, nie etwas bei Stripe). **Nicht** geheilt wird auf reinen Lesepfaden (Projektkonto, Abzeichen, Blattliste des Dialogs; L1 und W1 lesen nur): die Blätter einer im Dashboard stornierten Rechnung erscheinen dort erst wieder, nachdem ein Aufruf von L2 sie freigegeben hat. Hinweis für den Betrieb: Stornos besser über WOMS als im Dashboard.
|
||
4. **Reihenfolge der Sperren:** entschieden (siehe Übersicht, Nachträge): wer beide braucht, nimmt erst `sperreRechnung(kundenId)` und darin `sperreAuftrag(auftragId)` (L2 beim Finalisieren und bei der Selbstheilung); innerhalb von `sperreAuftrag` nie `sperreRechnung`, sonst droht eine Verklemmung. Mails (K32, T22) gehen erst nach Freigabe der Sperre.
|
||
5. **Anzahlung:** entschieden (siehe Übersicht, Nachträge): die Obergrenze zählt nur **finalisierte** Rechnungen als „bereits berechnet“ (wie „Berechnet“ in Spec Abschnitt 4), nicht Entwürfe; dafür blockiert ein Entwurf der Schlussrechnung weitere Anzahlungen (409), und `anzahlung` läuft wie `aufwand` und `schluss` unter der Kundensperre.
|
||
6. **Minuszeile:** entschieden (siehe Übersicht, Nachträge): `abzüglich Anzahlung …` für die Art `anzahlung`, sonst `abzüglich Teilrechnung …`. Die frei gewählte Bezeichnung einer Anzahlung („Teilrechnung“) wird nicht gespeichert, die Minuszeile nennt dann trotzdem „Anzahlung“.
|
||
7. **Grenzen:** entschieden (siehe Übersicht, Nachträge): höchstens 400 Arbeitsblätter je Rechnung (das Feld `arbeitsblattIdsJson` fasst 20000 Zeichen; `LIMITS` hat dafür keinen Eintrag, L2 führt die Zahl lokal). Eine Schlussrechnung darf serverseitig bis 40 Zeilen haben: AB-Positionen höchstens 20, Zusatzpositionen höchstens 10, Abzüge höchstens 10, jede Gruppe mit eigener 400-Meldung (Tasks 4 und 8; `SCHLUSS_GRENZEN` in `positionen.js`, auch dafür hat `LIMITS` keine Einträge); alle anderen Rechnungen bleiben bei 1 bis 20 Zeilen. Der frühere Vorschlag, die AB-Positionen auf höchstens 19 zu begrenzen, entfällt.
|
||
8. **Buchführung:** entschieden (siehe Übersicht, Nachträge): ein Fehler beim Buchen nach einem Stripe-Erfolg (Nummer, AB-Status, `aktiv: false`) wird nur protokolliert, nie geworfen. Bleibt dabei ein Datensatz aktiv, sind die Blätter weiter „abgerechnet“ (sichere Richtung); die Selbstheilung (Punkt 3) räumt es beim nächsten Konflikt auf.
|
||
9. **Späteres Senden:** entschieden (wie vorgeschlagen, siehe Übersicht, Nachträge): `sendInvoiceNow` prüft einen älteren Entwurf nicht erneut (Arbeitsblätter, Minuszeilen, Grenzen). Den üblichen Fall deckt Task 8 ab (ein Schluss-Entwurf blockiert weitere Anzahlungen, ein Anzahlungs-Entwurf blockiert die Schlussrechnung); Änderungen im Stripe-Dashboard zwischen Entwurf und Senden bleiben Handarbeit.
|
||
10. **Aufträge bei Aufwand und Festbetrag:** entschieden (siehe Übersicht, Nachträge): bei `aufwand` ohne `auftragId` verknüpft L2 die jüngste gültige Aufwands-AB des Projekts (Satz, Zeitquelle, Zusatzfeld `Auftrag`, Metadaten), aber nur bei eingeschaltetem Schalter; bei ausgeschaltetem Schalter gilt 100 € und kein Auftrag (passt zu L1: „Schalter aus: ABs werden nicht gelesen“ und zu Z1 D3). Bei `festbetrag` hat eine genannte `auftragId` keine Wirkung: die Rechnung hängt nicht an der AB und mindert deren „Offenes“ nicht.
|
||
11. **Schreibweisen:** entschieden (siehe Übersicht, Nachträge): Wochenzeilen `KW 05/2026` und Schlüssel `KW-2026-05` (Woche zweistellig, sortiert richtig; so erwartet es auch W1), Schlüssel weiterer Positionen `pos-<n>` (frei, Festbetrag), `anzahlung`, `ab-<n>`, `zusatz-<n>`, `abzug-<invoiceId>` (so erwartet es W1); `stundenText` ohne überflüssige Nullen (`2`, `6,5`, `2,25`), weil die Spec `6,5 Std.` zeigt; Datum der AB in `Auftrag`-Feld und Anzahlungstext ist der Berliner Tag von `gesendetAm` (so rechnet Z1). Die Texte mehrerer Arbeitsblätter eines Tages verbindet der Plan mit `; `; das Beispiel der Spec („Startseite, Kontaktformular“) entspricht einem einzelnen Arbeitsblatt, dessen Text selbst ein Komma enthält (Task 3 prüft beide Fälle).
|
||
|
||
**B. Erledigt durch Entscheidungen oder andere Pläne, zur Kenntnis**
|
||
|
||
- Kennungsmuster der Projektdatensätze: `/^in_\w{1,33}$/` (Orchestrator, L0 Task 8); der Stripe-Nachbau-Kennung `in_test_<n>` passt.
|
||
- Sperrschlüssel: zusammengesetzte Einzel-Schlüssel (Orchestrator, L0 Task 2); L2 ruft nur `sperreRechnung(kundenId)` und `sperreAuftrag(auftragId)` und kennt die Schlüssel nicht.
|
||
- Stripe-Metadaten beim Neufassen einer AB: entfallen (Orchestrator); maßgeblich ist `projektRechnungen.auftragId`.
|
||
- Importzyklus `fehler.js` ↔ `stripeFinance.js`: L0 benutzt `InvoiceError` nur innerhalb der Funktion `sendeFehler`; L2 beachtet dasselbe (Global Constraints). Reine Unit-Tests von `positionen.js` laden dadurch `config.js` und das Stripe-SDK mit, das ist harmlos.
|
||
- `schemaKeys('auftraege')`, `schemaKeys('projektRechnungen')` und `AUFTRAEGE_DATETIMES` liefert L0 (Task 3); die Tests von L2 benutzen sie wie die von L0.
|
||
- Text und Minuten der Arbeitsblätter (auch Abo-Zeit, Kommentare und 0 Minuten kommen mit Kennzeichen zurück) liefert der L0-Lader (L0 Task 9); `createProjektRechnung` setzt `erstelltAm` selbst und wirft bei ungültigen Werten `TypeError`.
|
||
- `zeilentexte` (Orchestrator, W1): ersetzt den gesamten Text vor dem letzten ` – ` (Datum bzw. KW-Kopf eingeschlossen); den Schluss ` – <Std.> Std. à <Satz>` hängt immer der Server an; unbekannte Schlüssel und Nicht-Texte bleiben ohne Wirkung (Task 3). V4 wird entsprechend präzisiert.
|
||
- Weitere Tickets (Orchestrator, W1): W1 schickt `arbeitsblattIds`, auch von anderen Tickets des Kunden; der Server prüft sie gegen die Tickets des Kunden (Task 7).
|
||
- Zeitwert im Projektkonto (Orchestrator, L1): Summe der je Tag gerundeten Werte; `aufwandPositionen` rundet je Tag mit `Math.round(minuten * satzCents / 60)` auf ganze Cent, L1 rechnet genauso.
|
||
- Statuswechsel der AB durch L2 (`abgerechnet` setzen und zurücknehmen) liegen unter `sperreAuftrag` (Orchestrator, L3); L5 sendet seine Mails erst nach Freigabe der Sperre.
|
||
- Kein Unicode-Escape mit vier Hexziffern in Code und Tests (Orchestrator, L4): das geschützte Leerzeichen heißt im Plan `String.fromCharCode(160)`.
|