Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-06-auftraege-03-rechnungen.md
T
ClaudeandClaude Opus 5.5 9d82fc579f Umsetzungspläne Aufträge und Rechnungen: Übersicht und 12 Strang-Pläne
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>
2026-10-06 12:49:15 +00:00

176 KiB
Raw Blame History

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

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
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):

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
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
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:

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):

      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
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:

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:

      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
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:

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
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:

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):

        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) …:

        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
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):

// 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:

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
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:

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
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:

/** 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
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:

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
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:

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
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):

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
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:

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
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:

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
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

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)
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

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 abgerechnete 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).