Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-06-auftraege-01-fundament.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

152 KiB
Raw Blame History

Fundament – Umsetzungsplan (Strang L0)

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: Das gemeinsame Fundament für „Aufträge und Rechnungen“ im Portal-Server: Abhängigkeiten pdfkit und fontkit (samt .gitignore-Eintrag für den node_modules-Symlink der Lane-Worktrees), Appwrite-Schema und Setup-Skript, Konfiguration und Pilot-Schalter, Konstanten und Fehlerklasse, Speicher der Auftragsbestätigungen und Projektrechnungen, Arbeitsblatt-Zugriff, Sichten, PDF-Ablage, Einstellung „Steuernummer“, Router-Gerüste samt Einbindung – so dass die Stränge L1–L7 danach parallel darauf aufbauen können.

Architecture: Aller neue Server-Code liegt unter server/services/auftraege/ in kleinen Modulen mit den Signaturen aus der Übersicht (V1–V7). Appwrite-Zugriffe laufen über server/services/appwriteAdmin.js, Tests über die Nachbauten aus test/support/. Die fünf Router-Gerüste tragen nur den Zugangsschutz; die Handler füllen später L1, L3 und L5. L0 liegt auf dem kritischen Pfad und committet direkt auf feature/auftraege.

Tech Stack: Node 20.19 (Container) und 24 (Host), Express 4, ES-Module, Appwrite-REST über appwriteAdmin.js, pdfkit und fontkit (neu, hier nur als Abhängigkeiten), Tests mit node --test und test/support/*.

Spec: docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md (Stand auf feature/auftraege, inklusive „Nachträge aus der Planrunde“ am Ende)

Übersicht/Verträge: docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md (V1–V8 und der Abschnitt „Nachträge nach der Planrunde“ gelten wörtlich; wo dieser Plan davon abweicht, gilt die Übersicht; alle Vertragsfragen dieses Plans sind dort entschieden, siehe am Ende)

Worktree und Branch: /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege, Branch feature/auftraege

Global Constraints

Alle Punkte der Übersicht gelten. Zusätze für diesen Strang:

  • Nur die Dateien aus der „Dateiübersicht“ unten anfassen (dazu gehören package.json, package-lock.json und .gitignore). Bestehende Tests ändern: ausschließlich test/financeSettings.test.js (Task 12) und test/support/schema.js (Task 3).
  • Namen, Signaturen und Datenformen exakt wie Übersicht V1–V7 samt „Nachträge nach der Planrunde“ (dort für L0 festgelegt: sperreAuftrag und zusammengesetzte Sperrschlüssel, AUFTRAEGE_DATETIMES, entfernePdf, fontkit, .gitignore, Steuernummer mit Buchstaben, texte.kleinunternehmer, privat bei termine).
  • Wortlaute und Fassungen, die L7 ändert (texte.js, AGB_FASSUNG), werden in Tests nie wörtlich festgenagelt, nur Form und eingesetzte Werte.
  • Programmfehler (ungültige Kennungen oder Typen an Speicherfunktionen) sind TypeError (die Route macht daraus 500). AuftragError mit Status nur für Fälle, die ein Nutzer verstehen soll (404, 409, 400 bei zu langem Text).
  • Tests mit Appwrite: startAppwriteMock({ schema, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) und useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) (diese Tests brauchen kein Stripe). Config und Server-Module erst danach per await import(...) laden.
  • Tests, die Dateien brauchen, legen ein Verzeichnis per mkdtempSync(join(tmpdir(), '<präfix>-')) an; der Ausführende setzt TMPDIR auf sein Scratchpad, damit alles dort landet. Gelöscht wird nur dieses selbst angelegte Verzeichnis (Präfixprüfung vor rmSync). Dem Befehl in Task 11 dazu TMPDIR=<eigenes Scratchpad> voranstellen.
  • Router-Tests prüfen nur, was auch nach dem Füllen der Router durch L1, L3 und L5 gilt (Zugangsschutz, unbekannte Tiefenpfade), damit diese Tests nach den Merges grün bleiben.
  • Die Tasks stehen in Abhängigkeitsreihenfolge (Task 7 braucht 2, 3 und 4; Task 9 braucht 8; Task 10 braucht 6); nicht umstellen.
  • Jeder Testlauf: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/<datei>. Mehrere Dateien in einem Aufruf nur mit --test-concurrency=1. Die volle Suite läuft nicht in L0 (nur Z1).

Review Focus

  1. Arbeitsblatt-Datum: TT.MM.JJJJ, ISO-Zeitpunkt UTC kurz vor und nach Mitternacht Berlin, leeres Datum (Rückgriff auf $createdAt), unmögliches Datum („31.02.2026“): immer der richtige Berliner Kalendertag, nie ein Wurf. Test: Task 9, „Datum eines Arbeitsblatts“.
  2. Arbeitsblatt-Zeit: 10 Minuten werden 15, 80 Minuten plus eine unterstützende Person werden 135, 0 Minuten und totalTime: 'abc' ergeben 0; Abo-Zeit, Kommentare und 0 Minuten sind nie abrechenbar. Test: Task 9, „Minuten“ und „abrechenbar“.
  3. Abgerechnet nur aus aktiven Projektrechnungen: ein stornierter Eintrag gibt seine Blätter frei, ein Entwurf sperrt sie (Rechnungsnummer leer), fremde Kunden bleiben unberührt. Tests: Task 8 (aktiv-Filter, umhaengen), Task 9 (abgerechneteArbeitsblaetter, offeneArbeitsblaetter).
  4. Leere oder ungültige Schlüssel treffen nie „alles“ oder fremde Dateien: findAuftragByTokenHash(''), findAuftragByNummer('') (Entwürfe haben beides leer), Listenfilter mit leerem Kunden, Rechnungs-IDs wie ../x, PDF-Pfade wie c1/../geheim.pdf, Überschreiben einer vorhandenen AB-Datei. Tests: Task 7, Task 8, Task 11.
  5. Pilot-Schalter: * lässt alle durch, off schlägt *, Groß- und Kleinschreibung und Leerraum der E-Mail-Adresse, leere Liste lässt niemanden durch. Test: Task 4.

Dateiübersicht

Datei Aufgabe Task
package.json, package-lock.json, .gitignore pdfkit, fontkit, node_modules auch als Symlink ignorieren 1
server/services/auftraege/konstanten.js Konstanten (V2) 2
server/services/auftraege/fehler.js AuftragError, sendeFehler 2
server/services/auftraege/schema.js Collections und Zusatzfelder (V1) 3
test/support/schema.js schemaKeys kennt die neuen Collections und die Steuernummer 3
server/config.js collections.auftraege, collections.projektRechnungen, config.auftraege 4
server/services/auftraege/schalter.js auftraegeAktiv 4
scripts/auftraege-setup.mjs Setup der Collections und Zusatzfelder 5
server/services/auftraege/texte.js vorläufige Textbausteine (V7) 6
server/services/auftraege/auftragStore.js Speicher der ABs 7
server/services/auftraege/projektRechnungenStore.js Speicher der Projektrechnungen 8
server/services/auftraege/arbeitsblaetter.js Arbeitsblätter laden und abgleichen 9
server/services/auftraege/auftragSicht.js Sichten auf eine AB 10
server/services/auftraege/ablage.js PDF-Ablage 11
server/services/finance/settings.js, test/financeSettings.test.js Einstellung steuernummer 12
server/routes/admin/projektkonten.js, server/routes/admin/auftraege.js, server/routes/team/arbeitsblaetter.js, server/routes/auftrag.js, server/routes/auftraegeKunde.js, server/index.js, server/routes/team/index.js Router-Gerüste und Einbindung 13
test/auftraege<Thema>.test.js (elf Dateien, je Task genannt) Tests 2–13

Task 1: Arbeitsumgebung, pdfkit, fontkit und .gitignore

Files:

  • Modify: package.json, package-lock.json (nur pdfkit, fontkit und deren neue Abhängigkeiten kommen dazu), .gitignore (Zeile node_modules/ wird node_modules)
  • Anlegen, unversioniert (steht in .gitignore): node_modules/ als echtes Verzeichnis

Interfaces:

  • Produces: echtes node_modules im Worktree (die Lane-Worktrees verlinken es später mit ln -s ../dev-auftraege/node_modules node_modules); .gitignore ignoriert node_modules ohne Schrägstrich, also auch diesen Symlink (mit Schrägstrich träfe die Regel nur Verzeichnisse und der Symlink stünde in jedem git status); Abhängigkeit pdfkit (import PDFDocument from 'pdfkit' liefert eine Funktion, für L4); Abhängigkeit fontkit direkt in package.json, mit derselben Versionsangabe, die pdfkit für fontkit verlangt (L4 prüft damit die Glyphen und soll sich nicht auf eine mitinstallierte Abhängigkeit verlassen).

  • Step 1: Arbeitsspeicher prüfen

Run: free -m Expected: „available“ mindestens 1500 MB; darunter warten und erneut prüfen, kein npm ci vorher.

  • Step 2: node_modules anlegen
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock nice -n 10 env NODE_OPTIONS=--max-old-space-size=1024 npm ci --no-audit --no-fund

Expected: Exit 0. Danach cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && test -d node_modules && ! test -L node_modules && echo echt gibt echt aus, und git status --short zeigt nichts außer Plan- und Spezifikationsdateien unter docs/superpowers/ (unversioniert oder vom Orchestrator geändert; sie gehören nicht zu L0). Scheitert npm ci, nicht auf npm install ausweichen, sondern dem Orchestrator melden.

  • Step 3: Versionen bestimmen
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && npm view pdfkit version engines dependencies

Regel für pdfkit: Die ausgegebene version ist die neueste. Sie wird genommen, wenn engines.node fehlt oder Node 20.19 zulässt und die Abhängigkeiten reines JavaScript sind (kein node-gyp, keine nativen Pakete). Sonst die neueste ältere stabile Version mit dieser Eigenschaft wählen (npm view pdfkit versions --json) und für diese Version npm view pdfkit@<Version> dependencies lesen. Keine Vorabversionen. Die gewählte Version heißt unten <V>. Regel für fontkit: <F> ist die Versionsangabe, die in dependencies von pdfkit bei fontkit steht (z. B. ^2.0.4), unverändert. Fehlt fontkit dort, nichts raten, sondern dem Orchestrator melden.

  • Step 4: pdfkit und fontkit eintragen
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock nice -n 10 env NODE_OPTIONS=--max-old-space-size=1024 npm install pdfkit@<V> fontkit@<F> --save --no-audit --no-fund
  • Step 5: .gitignore ändern
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && sed -i 's#^node_modules/$#node_modules#' .gitignore && git diff .gitignore

Expected: genau eine Zeile geändert, -node_modules/ und +node_modules (Zeile 1; alle anderen Zeilen unverändert).

  • Step 6: Prüfen
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && node -e "import('pdfkit').then(m=>console.log(typeof m.default))"

Expected: function.

cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && node -e "import('fontkit').then(m=>console.log(typeof (m.create || m.default?.create)))"

Expected: function.

cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git check-ignore -v node_modules; echo "exit=$?"; git status --short; git diff --stat; git diff package.json; git diff --numstat package-lock.json

Expected: git check-ignore -v node_modules nennt .gitignore:1:node_modules und endet mit exit=0; git status --short zeigt node_modules nicht (nur Plan- und Spezifikationsdateien unter docs/superpowers/) und sonst nur die drei geänderten Dateien .gitignore, package.json, package-lock.json; in package.json genau zwei neue Zeilen in dependencies: "fontkit": "<F>" (bei einer exakten Version in pdfkit setzt npm ein ^ davor; zwischen feiertagejs und jose) und "pdfkit": "^<V>" (zwischen nodemailer und sharp); in package-lock.json steht in der zweiten Spalte der numstat 0 (keine entfernten Zeilen). Weicht der Diff ab, den Diff lesen: erlaubt sind nur Einträge von pdfkit, fontkit und deren neuen Abhängigkeiten; alles andere zurücknehmen (git checkout package-lock.json package.json, Schritt 4 wiederholen) und dem Orchestrator melden.

  • Step 7: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add package.json package-lock.json .gitignore && git commit -m "Aufträge: pdfkit und fontkit als Abhängigkeiten, node_modules auch als Symlink ignorieren" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 2: Konstanten und Fehlerklasse

Files:

  • Create: server/services/auftraege/konstanten.js, server/services/auftraege/fehler.js
  • Test: test/auftraegeKonstanten.test.js

Interfaces:

  • Consumes: InvoiceError aus server/services/finance/stripeFinance.js (nur in sendeFehler).

  • Produces: alle Exporte aus Übersicht V2 (RECHNUNGS_ARTEN, AB_RECHNUNGS_ARTEN, AUFTRAG_ARTEN, AUFTRAG_STATUS, VEREINBARTE_STATUS, AUFTRAG_WEGE, ZAHLUNGSPLAN_ARTEN, KUNDENARTEN, STUNDENSATZ_STANDARD_CENTS, VIERTELSTUNDE, TOKEN_GUELTIG_TAGE, AGB_FASSUNG, LIMITS, SPERRE_NUMMER = ['auftraege-nummer'], sperreRechnung(kundenId) → ['auftraege-rechnung:<kundenId>'], sperreAuftrag(auftragId) → ['auftraege-ab:<auftragId>'], SCHALTER_AUS_MELDUNG); jede Sperre ist ein Array mit genau einem zusammengesetzten Schlüssel, weil withLocks jedes Listenelement einzeln sperrt; class AuftragError extends Error { constructor(status, message) } (Eigenschaft status); sendeFehler(res, was, err).

  • Step 1: Failing Test schreiben

// test/auftraegeKonstanten.test.js
import { test, mock } from 'node:test'
import assert from 'node:assert/strict'
import * as K from '../server/services/auftraege/konstanten.js'
import { AuftragError, sendeFehler } from '../server/services/auftraege/fehler.js'
import { InvoiceError } from '../server/services/finance/stripeFinance.js'

test('Konstanten stehen wie in Übersicht V2', () => {
  assert.deepEqual(K.RECHNUNGS_ARTEN, ['frei', 'festbetrag', 'aufwand', 'anzahlung', 'schluss'])
  assert.deepEqual(K.AB_RECHNUNGS_ARTEN, ['anzahlung', 'schluss'])
  assert.deepEqual(K.AUFTRAG_ARTEN, ['festpreis', 'aufwand'])
  assert.deepEqual(K.AUFTRAG_STATUS, ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen'])
  assert.deepEqual(K.VEREINBARTE_STATUS, ['versendet', 'bestaetigt', 'abgerechnet'])
  assert.deepEqual(K.AUFTRAG_WEGE, { muendlich: 'mündlich', telefon: 'telefonisch', email: 'per E-Mail', vor_ort: 'vor Ort', chat: 'im Chat' })
  assert.deepEqual(K.ZAHLUNGSPLAN_ARTEN, ['nach_abnahme', 'anzahlung', 'monatlich', 'frei'])
  assert.deepEqual(K.KUNDENARTEN, ['unternehmen', 'privat'])
  assert.equal(K.STUNDENSATZ_STANDARD_CENTS, 10000)
  assert.equal(K.VIERTELSTUNDE, 15)
  assert.equal(K.TOKEN_GUELTIG_TAGE, 60)
  assert.match(K.AGB_FASSUNG, /^\d{4}-\d{2}-\d{2}$/) // den Wert legt L7 fest
  assert.deepEqual(K.LIMITS, {
    maxPositionen: 20, maxBeschreibung: 200, maxMenge: 999, maxEinzelpreisCents: 10_000_000,
    maxSummeCents: 99_999_999, maxTitel: 200, maxZahlungsplanText: 500, maxZahlungszielTage: 60,
    standardZahlungszielTage: 14, standardAnzahlungProzent: 50, maxZeitquellen: 10,
    maxSchaetzungMinuten: 99_960, maxLeistungszeitraum: 140,
  })
  // withLocks sperrt jedes Listenelement einzeln: je Sperre genau ein zusammengesetzter Schlüssel (verschiedene Kunden und ABs blockieren sich nicht)
  assert.deepEqual(K.SPERRE_NUMMER, ['auftraege-nummer'])
  assert.deepEqual(K.sperreRechnung('c1'), ['auftraege-rechnung:c1'])
  assert.deepEqual(K.sperreAuftrag('ab1'), ['auftraege-ab:ab1'])
  assert.equal(K.SCHALTER_AUS_MELDUNG, 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.')
  for (const status of K.VEREINBARTE_STATUS) assert.ok(K.AUFTRAG_STATUS.includes(status), status)
  for (const art of K.AB_RECHNUNGS_ARTEN) assert.ok(K.RECHNUNGS_ARTEN.includes(art), art)
})

const fakeRes = () => ({
  statusCode: 200,
  body: undefined,
  status(code) {
    this.statusCode = code
    return this
  },
  json(body) {
    this.body = body
    return this
  },
})

test('AuftragError trägt Status und Meldung; ein Status außerhalb 400 bis 599 ist ein Programmfehler', () => {
  const err = new AuftragError(409, 'Arbeitsblatt bereits abgerechnet (WEBKLAR-0009).')
  assert.ok(err instanceof Error)
  assert.equal(err.name, 'AuftragError')
  assert.equal(err.status, 409)
  assert.equal(err.message, 'Arbeitsblatt bereits abgerechnet (WEBKLAR-0009).')
  for (const status of [200, 399, 600, 404.5, '404', undefined, null]) {
    assert.throws(() => new AuftragError(status, 'x'), TypeError, String(status))
  }
})

test('sendeFehler gibt AuftragError und InvoiceError mit Status und Meldung weiter', () => {
  const a = fakeRes()
  sendeFehler(a, 'AB senden', new AuftragError(400, 'Kundenart fehlt.'))
  assert.deepEqual([a.statusCode, a.body], [400, { error: 'Kundenart fehlt.' }])
  const b = fakeRes()
  sendeFehler(b, 'Rechnung anlegen', new InvoiceError(502, 'Stripe ist gerade nicht erreichbar.'))
  assert.deepEqual([b.statusCode, b.body], [502, { error: 'Stripe ist gerade nicht erreichbar.' }])
})

test('sendeFehler: alles andere wird 500 „Interner Fehler.“, das Protokoll nennt nie die Meldung', () => {
  const log = mock.method(console, 'error', () => {})
  try {
    const appwrite = Object.assign(new Error('Appwrite: Key sk_live_geheim abgelehnt'), { status: 400, type: 'user_unauthorized' })
    for (const err of [appwrite, 'sk_live_geheim', undefined, null]) {
      const res = fakeRes()
      sendeFehler(res, 'Projektkonto laden', err)
      assert.deepEqual([res.statusCode, res.body], [500, { error: 'Interner Fehler.' }])
    }
    assert.equal(log.mock.calls.length, 4)
    const zeilen = JSON.stringify(log.mock.calls.map((call) => call.arguments))
    assert.ok(!zeilen.includes('geheim'), zeilen)
    assert.ok(zeilen.includes('Projektkonto laden') && zeilen.includes('user_unauthorized'), zeilen)
  } finally {
    log.mock.restore()
  }
})
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeKonstanten.test.js Expected: FAIL mit ERR_MODULE_NOT_FOUND für server/services/auftraege/konstanten.js.

  • Step 3: Implementieren

  • server/services/auftraege/konstanten.js (neu, ohne Importe): genau die Exporte des Blocks „V2 Konstanten“ der Übersicht, mit denselben Namen, Werten und Reihenfolge, wörtlich übernommen (einschließlich der Sperren SPERRE_NUMMER, sperreRechnung, sperreAuftrag mit je einem zusammengesetzten Schlüssel und deren Kommentaren). AGB_FASSUNG steht auf einer eigenen Zeile mit dem Kommentar // L7 darf diese eine Zeile anpassen. Kopfkommentar: gemeinsame Datei, nur L0 ändert sie (Ausnahme AGB_FASSUNG).

  • server/services/auftraege/fehler.js (neu):

    • export class AuftragError extends Error, constructor(status, message): status muss eine ganze Zahl von 400 bis 599 sein, sonst TypeError; setzt this.name = 'AuftragError' und this.status.
    • export function sendeFehler(res, was, err): ist err eine AuftragError oder InvoiceError (Import aus ../finance/stripeFinance.js, nur innerhalb der Funktion benutzen, nie beim Laden des Moduls; stripeFinance.js importiert ab L2 selbst Module dieses Verzeichnisses, so bleibt der Importzyklus harmlos), dann res.status(err.status).json({ error: err.message }). Alles andere, auch Fehler mit status (Appwrite, Stripe), wird mit console.error('[auftraege] <was> fehlgeschlagen:', err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '') protokolliert (nie mit der Meldung) und als res.status(500).json({ error: 'Interner Fehler.' }) beantwortet.
  • Step 4: Lauf, erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeKonstanten.test.js Expected: PASS (alle vier Tests, fail 0).

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/konstanten.js server/services/auftraege/fehler.js test/auftraegeKonstanten.test.js && git commit -m "Aufträge: Konstanten und Fehlerklasse (L0)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 3: Appwrite-Schema und Test-Support

Files:

  • Create: server/services/auftraege/schema.js
  • Modify: test/support/schema.js
  • Test: test/auftraegeSchema.test.js

Interfaces:

  • Produces: AUFTRAEGE_COLLECTIONS ({ auftraege, projektRechnungen }, je { attributes, indexes }) und AUFTRAEGE_ZUSATZ_ATTRIBUTE ({ customers: [kundenart], financeSettings: [steuernummer] }) wörtlich wie Übersicht V1; zusätzlich AUFTRAEGE_DATETIMES ({ collection: [datetime-Schlüssel] }, wie ZEITEN_DATETIMES, für startAppwriteMock({ datetimes })). schemaKeys('auftraege'), schemaKeys('projektRechnungen') und schemaKeys('financeSettings') (jetzt mit steuernummer) in test/support/schema.js.

  • Step 1: Failing Test schreiben

// test/auftraegeSchema.test.js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { AUFTRAEGE_COLLECTIONS, AUFTRAEGE_DATETIMES, AUFTRAEGE_ZUSATZ_ATTRIBUTE } from '../server/services/auftraege/schema.js'
import { schemaKeys } from './support/schema.js'

// Spalten aus Übersicht V1 in Kurzschrift: sN = Text mit Größe N, i = ganze Zahl, b = Wahrheitswert, d = Datum und Zeit
const SPALTEN = {
  auftraege: `nummer:s20 jahr:i laufnummer:i status:s20 kundenId:s64 kundeJson:s2000 projektId:s64 titel:s200
    zeitquelleJson:s1000 art:s20 positionenJson:s20000 stundensatzCents:i schaetzungMinuten:i summeCents:i
    zahlungsplanJson:s1000 zahlungszielTage:i auftragVom:s10 auftragWeg:s20 startAm:s10 fertigBis:s10
    texteFassung:s20 agbFassung:s20 pdfDatei:s300 pdfSha256:s64 gesendetAm:d gesendetAn:s255 mailStatus:s20
    mailFehler:s500 tokenHash:s64 tokenGueltigBis:d bestaetigtAm:d bestaetigtUeber:s10 bestaetigtPdfSha256:s64
    fruehbeginn:b fruehbeginnFassung:s20 ersetztId:s64 ersetztDurchId:s64 storniertAm:d widerrufenAm:d
    erstelltVon:s255 erstelltAm:d geaendertAm:d`,
  projektRechnungen: `invoiceId:s64 kundenId:s64 projektId:s64 auftragId:s64 art:s20 betragCents:i
    arbeitsblattIdsJson:s20000 rechnungsnummer:s64 aktiv:b erstelltVon:s255 erstelltAm:d`,
}
function attribut(kurz) {
  const [key, art] = kurz.split(':')
  if (art === 'i') return { key, type: 'integer', required: false }
  if (art === 'b') return { key, type: 'boolean', required: false }
  if (art === 'd') return { key, type: 'datetime', required: false }
  return { key, type: 'string', size: Number(art.slice(1)), required: false }
}
const erwartet = (name) => SPALTEN[name].trim().split(/\s+/).map(attribut)

test('zwei Collections mit den Spalten aus Übersicht V1', () => {
  assert.deepEqual(Object.keys(AUFTRAEGE_COLLECTIONS), ['auftraege', 'projektRechnungen'])
  assert.deepEqual(AUFTRAEGE_COLLECTIONS.auftraege.attributes, erwartet('auftraege'))
  assert.deepEqual(AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes, erwartet('projektRechnungen'))
  assert.equal(AUFTRAEGE_COLLECTIONS.auftraege.attributes.length, 42)
  assert.equal(AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes.length, 11)
})

test('Indizes und Zusatzfelder wie in Übersicht V1', () => {
  assert.deepEqual(AUFTRAEGE_COLLECTIONS.auftraege.indexes, [
    { key: 'idx_kunde', type: 'key', attributes: ['kundenId'] },
    { key: 'idx_projekt', type: 'key', attributes: ['projektId'] },
    { key: 'idx_jahr', type: 'key', attributes: ['jahr'] },
    { key: 'idx_token', type: 'key', attributes: ['tokenHash'] },
    { key: 'idx_nummer', type: 'key', attributes: ['nummer'] },
  ])
  assert.deepEqual(AUFTRAEGE_COLLECTIONS.projektRechnungen.indexes, [
    { key: 'idx_kunde', type: 'key', attributes: ['kundenId'] },
    { key: 'idx_projekt', type: 'key', attributes: ['projektId'] },
    { key: 'idx_auftrag', type: 'key', attributes: ['auftragId'] },
  ])
  assert.deepEqual(AUFTRAEGE_ZUSATZ_ATTRIBUTE, {
    customers: [{ key: 'kundenart', type: 'string', size: 20, required: false }],
    financeSettings: [{ key: 'steuernummer', type: 'string', size: 40, required: false }],
  })
})

test('AUFTRAEGE_DATETIMES nennt je Collection genau die Datumsfelder', () => {
  assert.deepEqual(AUFTRAEGE_DATETIMES, {
    auftraege: ['gesendetAm', 'tokenGueltigBis', 'bestaetigtAm', 'storniertAm', 'widerrufenAm', 'erstelltAm', 'geaendertAm'],
    projektRechnungen: ['erstelltAm'],
  })
})

test('schemaKeys kennt die neuen Collections und die Steuernummer; Bestehendes bleibt', () => {
  assert.deepEqual(schemaKeys('auftraege'), AUFTRAEGE_COLLECTIONS.auftraege.attributes.map((a) => a.key))
  assert.deepEqual(schemaKeys('projektRechnungen'), AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes.map((a) => a.key))
  const einstellungen = schemaKeys('financeSettings')
  for (const key of ['steuernummer', 'bankDetailsText', 'taxReservePercent']) assert.ok(einstellungen.includes(key), key)
  assert.ok(!schemaKeys('financeFixedCosts').includes('steuernummer'))
  assert.ok(schemaKeys('portalFeatures').includes('unlockedBy') && schemaKeys('termineConfig').includes('einstellung'))
  assert.throws(() => schemaKeys('gibtEsNicht'), /fehlt im Setup-Skript/)
})
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeSchema.test.js Expected: FAIL mit ERR_MODULE_NOT_FOUND für server/services/auftraege/schema.js.

  • Step 3: Implementieren

  • server/services/auftraege/schema.js (neu): reine Daten, kein Import von server/config.js (Begründung im Kopfkommentar wie in server/services/zeiten/schema.js: test/support/schema.js liest die Datei vor useTestEnv()). Modulprivate Helfer str(key, size), int(key), bool(key), dt(key), index(key, ...attrs) wie dort (alle Attribute required: false). Exporte AUFTRAEGE_COLLECTIONS und AUFTRAEGE_ZUSATZ_ATTRIBUTE wörtlich aus Übersicht V1. Dazu export const AUFTRAEGE_DATETIMES = Object.fromEntries(Object.entries(AUFTRAEGE_COLLECTIONS).map(([name, { attributes }]) => [name, attributes.filter((a) => a.type === 'datetime').map((a) => a.key)])). Kommentar an idx_nummer: bewusst kein Unique-Index, weil Entwürfe nummer: '' haben; die Eindeutigkeit stellt vergibNummer unter SPERRE_NUMMER her (L3).

  • test/support/schema.js (drei Änderungen): (1) Import von AUFTRAEGE_COLLECTIONS und AUFTRAEGE_ZUSATZ_ATTRIBUTE aus ../../server/services/auftraege/schema.js; (2) am Anfang von schemaKeys: if (Object.hasOwn(AUFTRAEGE_COLLECTIONS, collection)) return AUFTRAEGE_COLLECTIONS[collection].attributes.map((a) => a.key); (3) in der Schleife über SETUP_SCRIPTS für jede Collection außer portalFeatures die Schlüssel aus AUFTRAEGE_ZUSATZ_ATTRIBUTE anhängen (nur wenn Object.hasOwn(AUFTRAEGE_ZUSATZ_ATTRIBUTE, collection); das trifft financeSettings). Den Kommentar über der Funktion um die beiden Quellen ergänzen. schemaKeys('customers') bleibt ohne Schema (wie bisher wirft es): Tests mit customers starten den Nachbau ohne Schema für diese Collection.

  • Step 4: Lauf, erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/auftraegeSchema.test.js test/zeitenSetup.test.js test/financeSetup.test.js Expected: PASS (fail 0); die beiden bestehenden Dateien belegen, dass schemaKeys für Zeiten und Finanzen unverändert arbeitet.

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/schema.js test/support/schema.js test/auftraegeSchema.test.js && git commit -m "Aufträge: Appwrite-Schema der Collections auftraege und projektRechnungen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 4: Konfiguration und Pilot-Schalter

Files:

  • Modify: server/config.js
  • Create: server/services/auftraege/schalter.js
  • Test: test/auftraegeSchalter.test.js

Interfaces:

  • Produces: config.collections.auftraege (APPWRITE_COLLECTION_AUFTRAEGE, Vorgabe 'auftraege'), config.collections.projektRechnungen (APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN, Vorgabe 'projektRechnungen'), config.auftraege = { mode, pilotEmails, uploadDir }; auftraegeAktiv(kunde) → boolean (kunde: Kundendokument oder { email }).

  • Step 1: Failing Test schreiben

// test/auftraegeSchalter.test.js
import { test, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import path from 'node:path'
import { config } from '../server/config.js'
import { auftraegeAktiv } from '../server/services/auftraege/schalter.js'

const ENV = ['AUFTRAEGE_ENABLED', 'AUFTRAEGE_PILOT_EMAILS', 'AUFTRAEGE_UPLOAD_DIR', 'APPWRITE_COLLECTION_AUFTRAEGE', 'APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN']

let original
beforeEach(() => {
  original = { ...config.auftraege }
})
afterEach(() => {
  Object.assign(config.auftraege, original)
})

/** Frische config.js-Instanz mit genau diesen Umgebungsvariablen (alle anderen entfernt). */
let freshCounter = 0
async function freshConfig(env = {}) {
  const saved = Object.fromEntries(ENV.map((key) => [key, process.env[key]]))
  for (const key of ENV) delete process.env[key]
  Object.assign(process.env, env)
  try {
    freshCounter += 1
    return (await import(`../server/config.js?auftraege-test=${freshCounter}`)).config
  } finally {
    for (const key of ENV) {
      if (saved[key] === undefined) delete process.env[key]
      else process.env[key] = saved[key]
    }
  }
}

test('Vorgabe: aus, keine Pilotadressen, Ablage unter upload-data/auftraege, Collection-IDs wie die Namen', async () => {
  const fresh = await freshConfig()
  assert.deepEqual(fresh.auftraege, { mode: 'off', pilotEmails: [], uploadDir: path.join(process.cwd(), 'upload-data', 'auftraege') })
  assert.equal(fresh.collections.auftraege, 'auftraege')
  assert.equal(fresh.collections.projektRechnungen, 'projektRechnungen')
})

test('AUFTRAEGE_ENABLED: leer und Unbekanntes sind off, " ON " ist on, " Pilot " ist pilot mit bereinigter Liste', async () => {
  for (const value of ['', '  ', 'banane', 'true', '1']) {
    assert.equal((await freshConfig({ AUFTRAEGE_ENABLED: value })).auftraege.mode, 'off', JSON.stringify(value))
  }
  assert.equal((await freshConfig({ AUFTRAEGE_ENABLED: ' ON ' })).auftraege.mode, 'on')
  const fresh = await freshConfig({ AUFTRAEGE_ENABLED: ' Pilot ', AUFTRAEGE_PILOT_EMAILS: ' Kenso.Gri@Gmail.com, ,justin@webklar.com ' })
  assert.equal(fresh.auftraege.mode, 'pilot')
  assert.deepEqual(fresh.auftraege.pilotEmails, ['kenso.gri@gmail.com', 'justin@webklar.com'])
})

test('Ablage und Collection-IDs lassen sich per Umgebung ändern', async () => {
  const fresh = await freshConfig({
    AUFTRAEGE_UPLOAD_DIR: '/tmp/ab-test',
    APPWRITE_COLLECTION_AUFTRAEGE: 'abX',
    APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN: 'prX',
  })
  assert.equal(fresh.auftraege.uploadDir, '/tmp/ab-test')
  assert.deepEqual([fresh.collections.auftraege, fresh.collections.projektRechnungen], ['abX', 'prX'])
})

test('off sperrt alle, auch mit Stern in der Liste; ein unbekannter Modus gilt als off', () => {
  config.auftraege.mode = 'off'
  config.auftraege.pilotEmails = ['*', 'kenso.gri@gmail.com']
  assert.equal(auftraegeAktiv({ email: 'kenso.gri@gmail.com' }), false)
  assert.equal(auftraegeAktiv({ email: 'jemand@example.com' }), false)
  for (const value of [undefined, null, '', 'banane', 'PILOT']) {
    config.auftraege.mode = value
    assert.equal(auftraegeAktiv({ email: 'kenso.gri@gmail.com' }), false, String(value))
  }
})

test('pilot: nur Adressen der Liste, ohne Beachtung von Groß- und Kleinschreibung und Leerraum', () => {
  config.auftraege.mode = 'pilot'
  config.auftraege.pilotEmails = ['a@b.de']
  assert.equal(auftraegeAktiv({ email: 'A@b.de' }), true)
  assert.equal(auftraegeAktiv({ email: ' a@b.de ' }), true)
  assert.equal(auftraegeAktiv({ email: 'x@y.de' }), false)
  assert.equal(auftraegeAktiv({ email: 'a@b.de.evil.de' }), false)
  for (const kunde of [undefined, null, {}, { email: '' }, { email: '   ' }]) assert.equal(auftraegeAktiv(kunde), false)
})

test('pilot mit Stern lässt alle durch, auch ohne Adresse; eine leere Liste niemanden', () => {
  config.auftraege.mode = 'pilot'
  config.auftraege.pilotEmails = ['*']
  assert.equal(auftraegeAktiv({ email: 'x@y.de' }), true)
  assert.equal(auftraegeAktiv(null), true)
  config.auftraege.pilotEmails = []
  assert.equal(auftraegeAktiv({ email: 'a@b.de' }), false)
})

test('on gilt für alle; ein Kundendokument zählt wie { email }', () => {
  config.auftraege.mode = 'on'
  config.auftraege.pilotEmails = []
  assert.equal(auftraegeAktiv({ email: 'x@y.de' }), true)
  assert.equal(auftraegeAktiv(undefined), true)
  config.auftraege.mode = 'pilot'
  config.auftraege.pilotEmails = ['k@example.invalid']
  assert.equal(auftraegeAktiv({ $id: 'c1', email: 'k@example.invalid', name: 'Kunde Eins' }), true)
})
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeSchalter.test.js Expected: FAIL mit ERR_MODULE_NOT_FOUND für server/services/auftraege/schalter.js.

  • Step 3: Implementieren

  • server/config.js, drei Einfügungen (neben den Zeiten-Gegenstücken):

    1. nach const zeitenEnabled = ...: const auftraegeEnabled = (process.env.AUFTRAEGE_ENABLED || '').trim().toLowerCase()
    2. in collections nach zeitenErinnerungen, mit Kommentar „Aufträge und Rechnungen (Auftragsbestätigungen, Projektrechnungen; scripts/auftraege-setup.mjs)“:
      auftraege: process.env.APPWRITE_COLLECTION_AUFTRAEGE || 'auftraege',
      projektRechnungen: process.env.APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN || 'projektRechnungen',
      
    3. nach dem Block zeiten: { ... }, und vor invoiceNinja, mit Kommentar „Aufträge und Rechnungen: Schalter nur für die AB-Funktionen und die Rechnungsarten anzahlung/schluss; wird bei jedem Aufruf gelesen, siehe server/services/auftraege/schalter.js“:
      auftraege: {
        // off (Vorgabe) | pilot (nur AUFTRAEGE_PILOT_EMAILS, '*' = alle) | on
        mode: ['pilot', 'on'].includes(auftraegeEnabled) ? auftraegeEnabled : 'off',
        pilotEmails: commaList(process.env.AUFTRAEGE_PILOT_EMAILS, []),
        // Gesendete AB-PDFs: <uploadDir>/<kundenId>/<auftragId>.pdf
        uploadDir: process.env.AUFTRAEGE_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'auftraege'),
      },
      
  • server/services/auftraege/schalter.js (neu): importiert config aus ../../config.js, exportiert nur auftraegeAktiv(kunde). Regeln wie zeitenFuerKunde in server/services/zeiten/schalter.js, gelesen wird config.auftraege bei jedem Aufruf: Modus on → true; Modus pilot → true, wenn die Liste '*' enthält, sonst wenn die kleingeschriebene, getrimmte E-Mail-Adresse (kunde?.email) nicht leer ist und in der Liste steht; jeder andere Modus (auch ein unbekannter Wert) → false. Der Kopfkommentar nennt: betrifft nur die AB-Funktionen und die Rechnungsarten anzahlung/schluss.

  • Step 4: Lauf, erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/auftraegeSchalter.test.js test/zeitenSchalter.test.js Expected: PASS (fail 0); zeitenSchalter belegt, dass die Änderung an config.js nichts Bestehendes verschiebt.

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/config.js server/services/auftraege/schalter.js test/auftraegeSchalter.test.js && git commit -m "Aufträge: Konfiguration und Pilot-Schalter AUFTRAEGE_ENABLED" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 5: Setup-Skript

Files:

  • Create: scripts/auftraege-setup.mjs
  • Test: test/auftraegeSetup.test.js

Interfaces:

  • Consumes: AUFTRAEGE_COLLECTIONS, AUFTRAEGE_ZUSATZ_ATTRIBUTE (Task 3); config.collections.{auftraege, projektRechnungen, customers, financeSettings} (Task 4); createAppwriteClient aus scripts/analyse-setup.mjs.

  • Produces: main({ check = false, client, log = console.log } = {}) → Promise<{ missing, missingCollections, missingIndexes }> mit Eintragsformen wie in scripts/zeiten-setup.mjs.

  • Step 1: Failing Test schreiben

// test/auftraegeSetup.test.js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { config } from '../server/config.js'
import { AUFTRAEGE_COLLECTIONS } from '../server/services/auftraege/schema.js'
import { main } from '../scripts/auftraege-setup.mjs'

// Geprüft wird nur mit dem Client mit Zustand unten (Muster: test/zeitenSetup.test.js), nie gegen Appwrite.
const NAMES = ['auftraege', 'projektRechnungen']
const ids = NAMES.map((name) => config.collections[name])
const customersId = config.collections.customers
const settingsId = config.collections.financeSettings
const keysOf = (name) => AUFTRAEGE_COLLECTIONS[name].attributes.map((a) => a.key)
const quiet = () => {}
const leer = () => ({ collections: [], attrs: [], indexes: [] })

/** Client mit Zustand: Attribute gibt es nur in vorhandenen Collections, Indizes nur auf vorhandenen Attributen. */
function fakeClient(initial = {}) {
  const start = {
    [customersId]: { attrs: ['name', 'email'] },
    [settingsId]: { attrs: ['minBufferCents', 'bankDetailsText'] },
    ...initial,
  }
  const state = new Map(
    Object.entries(start).map(([id, c]) => [id, { attrs: new Map((c.attrs || []).map((key) => [key, null])), indexes: new Set(c.indexes || []) }]),
  )
  const calls = leer()
  const need = (id) => {
    if (!state.has(id)) throw Object.assign(new Error(`Collection ${id} fehlt`), { status: 404 })
    return state.get(id)
  }
  return {
    state,
    calls,
    async collectionExists(id) {
      return state.has(id)
    },
    async ensureCollection(id, name) {
      if (state.has(id)) return false
      state.set(id, { attrs: new Map(), indexes: new Set() })
      calls.collections.push({ id, name })
      return true
    },
    async listAttributes(id) {
      return [...need(id).attrs.keys()].map((key) => ({ key }))
    },
    async createAttribute(id, attr) {
      const collection = need(id)
      if (collection.attrs.has(attr.key)) throw new Error(`Attribut ${id}.${attr.key} existiert schon`)
      collection.attrs.set(attr.key, attr)
      calls.attrs.push(`${id}.${attr.key}`)
    },
    async waitForAttribute(id, key) {
      assert.ok(need(id).attrs.has(key), `wartet auf nie angelegtes Attribut ${id}.${key}`)
    },
    async listIndexes(id) {
      return [...need(id).indexes].map((key) => ({ key }))
    },
    async createIndex(id, index) {
      const collection = need(id)
      for (const key of index.attributes) {
        if (!collection.attrs.has(key)) throw new Error(`Index ${index.key}: Attribut ${key} fehlt in ${id}`)
      }
      collection.indexes.add(index.key)
      calls.indexes.push(`${id}.${index.key}`)
    },
  }
}

const expectedAttrs = [
  ...NAMES.flatMap((name) => keysOf(name).map((key) => `${config.collections[name]}.${key}`)),
  `${customersId}.kundenart`,
  `${settingsId}.steuernummer`,
]
const expectedIndexes = NAMES.flatMap((name) => AUFTRAEGE_COLLECTIONS[name].indexes.map((i) => `${config.collections[name]}.${i.key}`))

test('main legt auf leerer Datenbank 2 Collections mit allen Attributen und Indizes an und ergänzt customers und financeSettings', async () => {
  const client = fakeClient()
  const result = await main({ client, log: quiet })
  assert.deepEqual(client.calls.collections, ids.map((id) => ({ id, name: id })))
  assert.deepEqual([...client.calls.attrs].sort(), [...expectedAttrs].sort())
  assert.deepEqual([...client.calls.indexes].sort(), [...expectedIndexes].sort())
  assert.equal(expectedAttrs.length, 55)
  assert.equal(expectedIndexes.length, 8)
  assert.equal(result.missing.length, 55)
  assert.deepEqual(result.missingCollections, ids)
  assert.equal(result.missingIndexes.length, 8)
  // customers und financeSettings gibt es schon: sie werden nie angelegt, bekommen nur ihr neues Feld
  assert.ok(!client.calls.collections.some((c) => [customersId, settingsId].includes(c.id)))
  assert.deepEqual(client.state.get(customersId).attrs.get('kundenart'), { key: 'kundenart', type: 'string', size: 20, required: false })
  assert.deepEqual(client.state.get(settingsId).attrs.get('steuernummer'), { key: 'steuernummer', type: 'string', size: 40, required: false })
})

test('main: zweiter Lauf legt nichts mehr an', async () => {
  const client = fakeClient()
  await main({ client, log: quiet })
  client.calls.collections.length = client.calls.attrs.length = client.calls.indexes.length = 0
  const result = await main({ client, log: quiet })
  assert.deepEqual(client.calls, leer())
  assert.deepEqual(result, { missing: [], missingCollections: [], missingIndexes: [] })
})

test('main legt nur Fehlendes an, auch in einer schon vorhandenen Collection', async () => {
  const pr = config.collections.projektRechnungen
  const client = fakeClient({
    [pr]: { attrs: ['invoiceId', 'kundenId'], indexes: ['idx_kunde'] },
    [customersId]: { attrs: ['name', 'kundenart'] },
  })
  await main({ client, log: quiet })
  assert.deepEqual(client.calls.collections, [{ id: config.collections.auftraege, name: config.collections.auftraege }])
  assert.deepEqual(
    client.calls.attrs.filter((k) => k.startsWith(`${pr}.`)),
    keysOf('projektRechnungen').filter((k) => !['invoiceId', 'kundenId'].includes(k)).map((k) => `${pr}.${k}`),
  )
  assert.deepEqual(client.calls.indexes.filter((k) => k.startsWith(`${pr}.`)), [`${pr}.idx_projekt`, `${pr}.idx_auftrag`])
  assert.ok(!client.calls.attrs.includes(`${customersId}.kundenart`))
  assert.ok(client.calls.attrs.includes(`${settingsId}.steuernummer`))
})

test('--check meldet Fehlendes und schreibt nichts', async () => {
  const client = fakeClient()
  const lines = []
  const result = await main({ check: true, client, log: (line) => lines.push(line) })
  assert.deepEqual(client.calls, leer())
  assert.equal(client.state.size, 2, 'nur customers und financeSettings, nichts angelegt')
  assert.deepEqual([...client.state.get(settingsId).attrs.keys()], ['minBufferCents', 'bankDetailsText'])
  assert.deepEqual(result.missingCollections, ids)
  assert.equal(result.missing.length, 55)
  assert.ok(lines.some((l) => l.includes(config.collections.auftraege) && l.includes('FEHLT')), lines.join('\n'))
  assert.ok(lines.some((l) => l.includes(`${settingsId}.steuernummer FEHLT`)), lines.join('\n'))
  assert.ok(lines.some((l) => l.includes(`Index ${config.collections.auftraege}.idx_nummer FEHLT`)), lines.join('\n'))
})

test('--check bei vollständigem Stand: nichts fehlt; ein einzelnes fehlendes Feld wird genau genannt', async () => {
  const client = fakeClient()
  await main({ client, log: quiet })
  assert.deepEqual(await main({ check: true, client, log: quiet }), { missing: [], missingCollections: [], missingIndexes: [] })
  client.state.get(customersId).attrs.delete('kundenart')
  const result = await main({ check: true, client, log: quiet })
  assert.deepEqual(result.missing.map((a) => `${a.collection}.${a.key}`), [`${customersId}.kundenart`])
  assert.deepEqual(result.missingCollections, [])
})

test('fehlt customers oder financeSettings, bricht main mit klarer Meldung ab, ohne etwas anzulegen', async () => {
  for (const [fehlt, hinweis] of [[settingsId, /setup-finanzen/], [customersId, /Ticketsystem/]]) {
    const client = fakeClient()
    client.state.delete(fehlt)
    await assert.rejects(
      () => main({ client, log: quiet }),
      (err) => err.message.includes(fehlt) && err.message.includes('fehlt') && hinweis.test(err.message),
      fehlt,
    )
    assert.deepEqual(client.calls, leer())
  }
})
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeSetup.test.js Expected: FAIL mit ERR_MODULE_NOT_FOUND für scripts/auftraege-setup.mjs.

  • Step 3: Implementieren

scripts/auftraege-setup.mjs (neu). Aufbau, Ablauf und Logzeilen 1:1 wie scripts/zeiten-setup.mjs (Datei lesen und übernehmen, der Test hängt an den Logzeilen - <id> FEHLT, - <id>.<key> FEHLT, - Index <id>.<key> FEHLT): erst alles prüfen (ohne --check auch dann zuerst alles prüfen, damit ein Abbruch vor jeder Änderung passiert), dann Collections anlegen (ensureCollection(id, id)), Attribute anlegen, auf jedes Attribut warten, Indizes anlegen. Unterschiede zu zeiten-setup:

  • Ziele: die Collections aus AUFTRAEGE_COLLECTIONS (je config.collections[name], own: true) sowie zwei vorhandene Collections nur mit zusätzlichen Attributen und ohne Indizes: config.collections.customers mit AUFTRAEGE_ZUSATZ_ATTRIBUTE.customers und config.collections.financeSettings mit AUFTRAEGE_ZUSATZ_ATTRIBUTE.financeSettings (own: false).

  • Fehlt eine nicht eigene Collection, wirft das Skript vor jeder Änderung Error('Collection <id> fehlt – <Hinweis>'); Hinweis für financeSettings: zuerst scripts/setup-finanzen.mjs ausfuehren; für customers: die Kunden-Collection gehört dem Ticketsystem (WOMS).

  • Kopfkommentar: wozu das Skript dient, dass es idempotent ist, vor dem Code-Deploy und vor AUFTRAEGE_ENABLED ausgeführt wird (docker exec -w /app webklar-kundenbereich-server node scripts/auftraege-setup.mjs), Usage mit --check.

  • Der Block invokedDirectly am Ende wie in zeiten-setup (Exit 1 bei --check mit Fehlendem).

  • Step 4: Lauf, erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeSetup.test.js Expected: PASS (sechs Tests, fail 0).

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add scripts/auftraege-setup.mjs test/auftraegeSetup.test.js && git commit -m "Aufträge: Setup-Skript für Collections und Zusatzfelder" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 6: Textbausteine (vorläufig)

Files:

  • Create: server/services/auftraege/texte.js
  • Test: test/auftraegeTextBausteine.test.js

Interfaces:

  • Produces: alle Exporte aus Übersicht V7 (TEXTE_FASSUNG, ABSENDER, AB_TEXTE, BESTAETIGUNG_TEXT, FRUEHBEGINN_FASSUNG, FRUEHBEGINN_TEXT, WIDERRUFSBELEHRUNG_PROJEKT, WIDERRUFSFORMULAR). Parametertypen der Textfunktionen (in V7 offen, hier festgelegt): datum, start, fertig, fassung sind fertig formatierte Texte TT.MM.JJJJ; weg ist das deutsche Wort aus AUFTRAG_WEGE (z. B. telefonisch) oder leer; satz, anzahlung, rest sind Beträge wie von euroDe(...); prozent und ziel (Tage) sind Zahlen; schaetzung ist ein Stundentext ('10', '2,25') oder null; privat (nur bei termine) ist ein optionaler Boolean, Vorgabe false: für Privatpersonen kommt der Satz zum Beginn nach Ablauf der Widerrufsfrist dazu (dann nie null, auch ohne Termine). Von L4 und L5 genau so aufzurufen.

Die Wortlaute sind vorläufig; die endgültigen macht Strang L7 (nur Wortlaute, Exportnamen und Formen bleiben).

  • Step 1: Failing Test schreiben
// test/auftraegeTextBausteine.test.js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import * as T from '../server/services/auftraege/texte.js'

// Geprüft werden Form und eingesetzte Werte, nie ganze Sätze: die Wortlaute ändert L7.
const alleTexte = (wert) =>
  typeof wert === 'string'
    ? [wert]
    : Array.isArray(wert)
      ? wert.flatMap(alleTexte)
      : wert && typeof wert === 'object'
        ? Object.values(wert).flatMap(alleTexte)
        : []

test('Exportnamen und feste Angaben nach Übersicht V7', () => {
  assert.match(T.TEXTE_FASSUNG, /^\d{4}-\d{2}-\d{2}$/)
  assert.match(T.FRUEHBEGINN_FASSUNG, /^\d{4}-\d{2}-\d{2}$/)
  assert.deepEqual(T.ABSENDER, {
    name: 'WEBklar GbR', strasse: 'Am Schwimmbad 10', plzOrt: '67722 Winnweiler', telefon: '0176 23726355',
    email: 'support@webklar.com', web: 'webklar.com', vertretenDurch: 'Kenso Grimm und Justin Klein',
  })
  assert.equal(T.AB_TEXTE.titel, 'Auftragsbestätigung')
  assert.equal(T.AB_TEXTE.kleinunternehmer, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.')
  assert.equal(T.BESTAETIGUNG_TEXT, 'Ich bestätige den Auftrag zu diesen Bedingungen.')
  assert.deepEqual(Object.keys(T.AB_TEXTE).sort(), ['agb', 'aufwand', 'einleitung', 'ersetzt', 'gruss', 'kleinunternehmer', 'mitwirkung', 'termine', 'titel', 'zahlungsplan'])
  assert.deepEqual(Object.keys(T.AB_TEXTE.zahlungsplan).sort(), ['anzahlung', 'frei', 'monatlich', 'nach_abnahme'])
  for (const name of ['mitwirkung', 'gruss']) assert.ok(typeof T.AB_TEXTE[name] === 'string' && T.AB_TEXTE[name].length > 5, name)
  assert.ok(typeof T.FRUEHBEGINN_TEXT === 'string' && T.FRUEHBEGINN_TEXT.length > 50 && /Widerruf/.test(T.FRUEHBEGINN_TEXT))
})

test('Einleitung, AGB-Satz, Ersetzt-Hinweis und Termine setzen ihre Werte ein', () => {
  assert.match(T.AB_TEXTE.einleitung({ datum: '05.10.2026', weg: 'telefonisch' }), /05\.10\.2026.*telefonisch/)
  assert.doesNotMatch(T.AB_TEXTE.einleitung({ datum: '05.10.2026', weg: '' }), /\(\s*\)/, 'ohne Weg keine leere Klammer')
  assert.ok(T.AB_TEXTE.agb({ fassung: '30.09.2026' }).includes('30.09.2026'))
  const ersetzt = T.AB_TEXTE.ersetzt({ nummer: 'AB-2026-0001', datum: '01.10.2026' })
  assert.ok(ersetzt.includes('AB-2026-0001') && ersetzt.includes('01.10.2026'))
  assert.equal(T.AB_TEXTE.termine({ start: null, fertig: null }), null)
  assert.equal(T.AB_TEXTE.termine({}), null)
  assert.ok(T.AB_TEXTE.termine({ start: '01.11.2026', fertig: null }).includes('01.11.2026'))
  const beide = T.AB_TEXTE.termine({ start: '01.11.2026', fertig: '15.12.2026' })
  assert.ok(beide.includes('01.11.2026') && beide.includes('15.12.2026'))
  assert.ok(!T.AB_TEXTE.termine({ start: null, fertig: '15.12.2026' }).includes('null'))
})

test('Termine: für Privatpersonen kommt der Satz zum Beginn nach Ablauf der Widerrufsfrist dazu, auch ohne Termine', () => {
  const ohne = T.AB_TEXTE.termine({ start: '01.11.2026', fertig: '15.12.2026' })
  assert.equal(T.AB_TEXTE.termine({ start: '01.11.2026', fertig: '15.12.2026', privat: false }), ohne)
  assert.doesNotMatch(ohne, /Widerrufsfrist/)
  const privat = T.AB_TEXTE.termine({ start: '01.11.2026', fertig: '15.12.2026', privat: true })
  assert.ok(privat.startsWith(ohne) && privat.length > ohne.length && /Widerrufsfrist/.test(privat), privat)
  const nurSatz = T.AB_TEXTE.termine({ start: null, fertig: null, privat: true })
  assert.ok(typeof nurSatz === 'string' && /Widerrufsfrist/.test(nurSatz) && !nurSatz.includes('null'), String(nurSatz))
  assert.equal(T.AB_TEXTE.termine({ privat: true }), nurSatz)
})

test('Aufwandsregel und Zahlungsplan: Werte, Schätzung nur wenn gegeben, Ziel 0 heißt sofort', () => {
  const mit = T.AB_TEXTE.aufwand({ satz: '100,00 €', schaetzung: '10' })
  assert.ok(mit.includes('100,00 €') && /\b15\b/.test(mit) && /\b9\b/.test(mit) && /\b10\b/.test(mit), mit)
  const ohne = T.AB_TEXTE.aufwand({ satz: '100,00 €', schaetzung: null })
  assert.ok(!/\b10\b/.test(ohne) && ohne.length < mit.length, ohne)
  assert.match(T.AB_TEXTE.aufwand({ satz: '100,00 €', schaetzung: '1' }), /\b1 Stunde(?!n)/)

  const z = T.AB_TEXTE.zahlungsplan
  const anzahlung = z.anzahlung({ prozent: 50, anzahlung: '400,00 €', rest: '400,00 €', ziel: 14 })
  assert.ok(/50\s?%/.test(anzahlung) && anzahlung.split('400,00 €').length === 3 && /\b14\b/.test(anzahlung), anzahlung)
  for (const art of ['nach_abnahme', 'monatlich']) {
    assert.ok(/\b14\b/.test(z[art]({ ziel: 14 })), art)
    assert.ok(!/\b0 Tag/.test(z[art]({ ziel: 0 })), `${art}: Ziel 0`)
  }
  const frei = z.frei({ text: 'Zahlung in zwei Raten.', ziel: 14 })
  assert.ok(frei.includes('Zahlung in zwei Raten') && !frei.includes('Raten..') && /\b14\b/.test(frei), frei)
})

test('Frühbeginn-Erklärung, Widerrufsbelehrung und Formular haben die Form aus V7 und nennen keine Funktionen-Einrichtung mehr', () => {
  assert.equal(T.WIDERRUFSBELEHRUNG_PROJEKT.titel, 'Widerrufsbelehrung')
  assert.ok(T.WIDERRUFSBELEHRUNG_PROJEKT.abschnitte.length >= 2)
  for (const abschnitt of T.WIDERRUFSBELEHRUNG_PROJEKT.abschnitte) {
    assert.ok(typeof abschnitt.ueberschrift === 'string' && abschnitt.ueberschrift.length > 0)
    assert.ok(Array.isArray(abschnitt.absaetze) && abschnitt.absaetze.length > 0)
    for (const absatz of abschnitt.absaetze) assert.ok(typeof absatz === 'string' && absatz.trim() !== '')
  }
  assert.equal(T.WIDERRUFSFORMULAR.titel, 'Muster-Widerrufsformular')
  assert.ok(typeof T.WIDERRUFSFORMULAR.einleitung === 'string' && T.WIDERRUFSFORMULAR.einleitung.length > 0)
  assert.ok(Array.isArray(T.WIDERRUFSFORMULAR.zeilen) && T.WIDERRUFSFORMULAR.zeilen.length >= 6)
  const text = alleTexte([T.WIDERRUFSBELEHRUNG_PROJEKT, T.WIDERRUFSFORMULAR, T.FRUEHBEGINN_TEXT]).join('\n')
  assert.doesNotMatch(text, /Einrichtung|Terminplaner|Wartungs-Abo|Funktionen/)
  assert.match(text, /vierzehn Tage/)
  assert.match(text, /Projektauftrag/)
  assert.match(text, /widerrufen\.html/)
  assert.match(text, /support@webklar\.com/)
})
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeTextBausteine.test.js Expected: FAIL mit ERR_MODULE_NOT_FOUND für server/services/auftraege/texte.js.

  • Step 3: Implementieren

server/services/auftraege/texte.js (neu, ohne Importe), mit genau diesem Inhalt. Die Widerrufsbelehrung ist der Wortlaut aus docs/rechtliches/widerrufsbelehrung-funktionen.md (Fassung 2026-10-05), bezogen auf den Projektauftrag; das Formular ist deren Muster-Formular unverändert. FRUEHBEGINN_TEXT ist der Wortlaut des Nutzers.

/**
 * Textbausteine der Auftragsbestätigung (Spec 2026-10-06, Abschnitt 2 „Inhalt des PDFs“, Übersicht V7).
 * Reine Daten und Textfunktionen ohne Importe. VORLÄUFIGER Stand (angelegt von Strang L0): die endgültigen Wortlaute
 * macht Strang L7 nach der Rechtsrunde, dabei ändern sich nur Wortlaute, nie Exportnamen oder Formen.
 * Kundentexte stehen in der Sie-Form. Werte kommen fertig formatiert herein: Daten als 'TT.MM.JJJJ', Beträge wie von
 * euroDe(...), Tage und Prozent als Zahl, Stunden als Text ('10', '2,25'), `weg` als deutsches Wort ('telefonisch'),
 * `privat` (nur bei termine) als Boolean.
 */

export const TEXTE_FASSUNG = '2026-10-06'

export const ABSENDER = {
  name: 'WEBklar GbR',
  strasse: 'Am Schwimmbad 10',
  plzOrt: '67722 Winnweiler',
  telefon: '0176 23726355',
  email: 'support@webklar.com',
  web: 'webklar.com',
  vertretenDurch: 'Kenso Grimm und Justin Klein',
}

/** Zahlungsziel als Satzteil: 0 Tage heißt sofort, sonst „innerhalb von N Tagen nach Rechnung“. */
const zielText = (ziel) => (ziel === 0 ? 'sofort nach Rechnungserhalt' : `innerhalb von ${ziel} ${ziel === 1 ? 'Tag' : 'Tagen'} nach Rechnung`)

export const AB_TEXTE = {
  titel: 'Auftragsbestätigung',
  einleitung: ({ datum, weg }) => `Vielen Dank für Ihren Auftrag vom ${datum}${weg ? ` (${weg})` : ''}. Wir bestätigen ihn wie folgt:`,
  aufwand: ({ satz, schaetzung }) =>
    `Die Abrechnung erfolgt nach Aufwand: ${satz} pro Stunde in Schritten von 15 Minuten. Unterstützt eine weitere Person, berechnen wir für sie die Hälfte der Arbeitszeit (AGB Nr. 9).` +
    (schaetzung ? ` Geschätzter Umfang: etwa ${schaetzung} ${schaetzung === '1' ? 'Stunde' : 'Stunden'} (unverbindlich).` : '') +
    ' Zeichnet sich eine deutliche Überschreitung ab, melden wir uns vorher.',
  kleinunternehmer: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.',
  zahlungsplan: {
    nach_abnahme: ({ ziel }) => `Die Rechnung stellen wir nach Abnahme der Leistung; zahlbar ${zielText(ziel)}.`,
    anzahlung: ({ prozent, anzahlung, rest, ziel }) =>
      `Anzahlung ${prozent} % (${anzahlung}) nach Auftragsbestätigung, Rest (${rest}) nach Abnahme; zahlbar jeweils ${zielText(ziel)}.`,
    monatlich: ({ ziel }) => `Wir rechnen monatlich nach dem entstandenen Aufwand ab; zahlbar ${zielText(ziel)}.`,
    frei: ({ text, ziel }) => `${text.trim().replace(/[\s.;:,]+$/, '')}; zahlbar ${zielText(ziel)}.`,
  },
  termine: ({ start, fertig, privat = false } = {}) => {
    const teile = []
    if (start && fertig) teile.push(`Beginn: ${start}; voraussichtliche Fertigstellung: ${fertig}.`)
    else if (start) teile.push(`Beginn: ${start}.`)
    else if (fertig) teile.push(`Voraussichtliche Fertigstellung: ${fertig}.`)
    if (privat) teile.push('Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.')
    return teile.length ? teile.join(' ') : null
  },
  mitwirkung: 'Mitwirkungspflichten und Abnahme richten sich nach Nr. 7 und Nr. 8 unserer Allgemeinen Geschäftsbedingungen.',
  agb: ({ fassung }) => `Es gelten unsere Allgemeinen Geschäftsbedingungen in der Fassung vom ${fassung}, die dieser Bestätigung beiliegen.`,
  ersetzt: ({ nummer, datum }) => `Diese Auftragsbestätigung ersetzt ${nummer} vom ${datum}.`,
  gruss: 'Mit freundlichen Grüßen',
}

export const BESTAETIGUNG_TEXT = 'Ich bestätige den Auftrag zu diesen Bedingungen.'

export const FRUEHBEGINN_FASSUNG = '2026-10-06'
export const FRUEHBEGINN_TEXT =
  'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Arbeit an diesem Auftrag beginnt. Mir ist bekannt, dass ich bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachten Leistungen zahle (§ 357a Abs. 2 BGB).'

export const WIDERRUFSBELEHRUNG_PROJEKT = {
  titel: 'Widerrufsbelehrung',
  abschnitte: [
    {
      ueberschrift: 'Widerrufsrecht',
      absaetze: [
        'Diese Belehrung gilt für den Projektauftrag, den wir Ihnen mit dieser Auftragsbestätigung bestätigen.',
        'Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.',
        'Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.',
        'Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, Telefon: 0176 23726355, E-Mail: support@webklar.com) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.',
        'Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben; wählen Sie dort die Vertragsart „Projektauftrag (Auftragsbestätigung)“ und tragen Sie die Nummer Ihrer Auftragsbestätigung ein. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.',
        'Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.',
      ],
    },
    {
      ueberschrift: 'Folgen des Widerrufs',
      absaetze: [
        'Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.',
        'Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht.',
      ],
    },
  ],
}

export const WIDERRUFSFORMULAR = {
  titel: 'Muster-Widerrufsformular',
  einleitung: '(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)',
  zeilen: [
    '– An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:',
    '– Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)',
    '– Bestellt am (*)/erhalten am (*)',
    '– Name des/der Verbraucher(s)',
    '– Anschrift des/der Verbraucher(s)',
    '– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)',
    '– Datum',
    '(*) Unzutreffendes streichen.',
  ],
}
  • Step 4: Lauf, erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeTextBausteine.test.js Expected: PASS (fünf Tests, fail 0).

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/texte.js test/auftraegeTextBausteine.test.js && git commit -m "Aufträge: vorläufige Textbausteine der Auftragsbestätigung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 7: Auftragsspeicher

Files:

  • Create: server/services/auftraege/auftragStore.js
  • Test: test/auftraegeAuftragStore.test.js

Interfaces:

  • Consumes: config.collections.auftraege (Task 4); AUFTRAEGE_COLLECTIONS (Task 3, für die Spaltengrößen); LIMITS, ZAHLUNGSPLAN_ARTEN (Task 2); AuftragError (Task 2); aus server/services/appwriteAdmin.js: createDocument, getDocument, updateDocument, deleteDocument, listAllDocuments, listDocuments, Query.

  • Produces (Übersicht V4): docToAuftrag(doc) → Auftrag, auftragToDoc(auftrag) → object, getAuftrag(id) → Auftrag|null, listAuftraege({ kundenId, projektId, jahr } = {}) → Promise<Auftrag[]> (neueste zuerst), createAuftrag(daten) → Promise<Auftrag>, updateAuftrag(id, patch) → Promise<Auftrag>, deleteAuftrag(id) → Promise<void>, findAuftragByTokenHash(hash) → Promise<Auftrag|null>, findAuftragByNummer(nummer) → Promise<Auftrag|null>. Form von Auftrag: Übersicht V3 (43 Felder).

  • Step 1: Failing Test schreiben

// test/auftraegeAuftragStore.test.js
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'

// Speicher der Auftragsbestätigungen. Nachbau von Appwrite mit dem Schema aus schema.js, Datumsfeldern wie in Appwrite
// ("+00:00") und dem Standard-Limit 25.
const aw = await startAppwriteMock({ schema: { auftraege: schemaKeys('auftraege') }, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })

const { config } = await import('../server/config.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { docToAuftrag, auftragToDoc, getAuftrag, listAuftraege, createAuftrag, updateAuftrag, deleteAuftrag, findAuftragByTokenHash, findAuftragByNummer } =
  await import('../server/services/auftraege/auftragStore.js')
const { AuftragError } = await import('../server/services/auftraege/fehler.js')

const COL = config.collections.auftraege
beforeEach(async () => {
  for (const id of [...aw.docs(COL).keys()]) await deleteDocument(COL, id)
})

const ENTWURF = {
  kundenId: 'c1', projektId: 'p1', titel: 'Website Bäckerei', zeitquelle: ['t1'], art: 'festpreis',
  positionen: [{ beschreibung: 'Startseite', menge: 1, einzelpreisCents: 50000 }],
  summeCents: 50000, zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14,
  auftragVom: '2026-10-05', auftragWeg: 'telefon', erstelltVon: 'kenso@webklar.com',
}
const FELDER = 'id nummer jahr laufnummer status kundenId kunde projektId titel zeitquelle art positionen stundensatzCents schaetzungMinuten summeCents zahlungsplan zahlungszielTage auftragVom auftragWeg startAm fertigBis ersetztId ersetztDurchId texteFassung agbFassung pdfDatei pdfSha256 gesendetAm gesendetAn mailStatus mailFehler tokenHash tokenGueltigBis bestaetigtAm bestaetigtUeber bestaetigtPdfSha256 fruehbeginn fruehbeginnFassung storniertAm widerrufenAm erstelltVon erstelltAm geaendertAm'
const HASH = 'a'.repeat(64)
const seed = (id, felder = {}) => aw.seed(COL, { $id: id, kundenId: 'c1', status: 'entwurf', ...felder })

test('createAuftrag legt einen Entwurf an: Status erzwungen, Zeitpunkte gesetzt, JSON-Felder serialisiert', async () => {
  const vor = Date.now()
  const auftrag = await createAuftrag({ ...ENTWURF, status: 'versendet' })
  assert.deepEqual(Object.keys(auftrag).sort(), FELDER.split(' ').sort(), 'genau die 43 Felder aus V3')
  assert.match(auftrag.id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
  assert.equal(auftrag.status, 'entwurf')
  assert.deepEqual(
    { nummer: auftrag.nummer, jahr: auftrag.jahr, laufnummer: auftrag.laufnummer, kunde: auftrag.kunde, gesendetAm: auftrag.gesendetAm, mailStatus: auftrag.mailStatus, fruehbeginn: auftrag.fruehbeginn },
    { nummer: '', jahr: null, laufnummer: null, kunde: null, gesendetAm: null, mailStatus: '', fruehbeginn: false },
  )
  assert.deepEqual(auftrag.positionen, ENTWURF.positionen)
  assert.deepEqual(auftrag.zahlungsplan, ENTWURF.zahlungsplan)
  assert.deepEqual(auftrag.zeitquelle, ['t1'])
  assert.deepEqual([auftrag.stundensatzCents, auftrag.schaetzungMinuten], [null, null])
  assert.match(auftrag.erstelltAm, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
  assert.equal(auftrag.geaendertAm, auftrag.erstelltAm)
  assert.ok(Date.parse(auftrag.erstelltAm) >= vor)
  const doc = aw.docs(COL).get(auftrag.id)
  assert.equal(doc.status, 'entwurf')
  assert.equal(doc.positionenJson, JSON.stringify(ENTWURF.positionen))
  assert.equal(doc.zeitquelleJson, '["t1"]')
  assert.deepEqual(await getAuftrag(auftrag.id), auftrag)
})

test('docToAuftrag: fehlende Felder bekommen Vorgaben, kaputtes JSON wirft nie, Zeitpunkte werden zu ISO mit Z', () => {
  const auftrag = docToAuftrag({
    $id: 'x1', kundenId: 'c1', status: 'versendet', nummer: 'AB-2026-0003', jahr: 2026, laufnummer: 3,
    kundeJson: '', positionenJson: '{kaputt', zeitquelleJson: '["t1","",5," t2 "]', zahlungsplanJson: '[1]',
    gesendetAm: '2026-10-06T10:00:00.000+00:00', tokenGueltigBis: null, fruehbeginn: true, summeCents: 1000,
    $createdAt: '2026-10-06T08:00:00.000+00:00', $updatedAt: '2026-10-06T09:00:00.000+00:00',
  })
  assert.equal(auftrag.id, 'x1')
  assert.deepEqual([auftrag.nummer, auftrag.jahr, auftrag.laufnummer, auftrag.summeCents], ['AB-2026-0003', 2026, 3, 1000])
  assert.deepEqual([auftrag.kunde, auftrag.positionen, auftrag.zeitquelle], [null, [], ['t1', 't2']])
  assert.deepEqual(auftrag.zahlungsplan, { art: 'nach_abnahme', prozent: null, text: '' })
  assert.equal(auftrag.zahlungszielTage, 14)
  assert.deepEqual([auftrag.gesendetAm, auftrag.tokenGueltigBis, auftrag.bestaetigtAm], ['2026-10-06T10:00:00.000Z', null, null])
  assert.equal(auftrag.fruehbeginn, true)
  assert.deepEqual([auftrag.erstelltAm, auftrag.geaendertAm], ['2026-10-06T08:00:00.000Z', '2026-10-06T09:00:00.000Z'])
})

test('auftragToDoc: nur Felder aus V1, JSON serialisiert, undefined fehlt, null löscht', () => {
  const doc = auftragToDoc({
    id: 'x1', $id: 'x1', fremd: 1, status: 'versendet', kunde: { name: 'Anna Beispiel', kundenart: 'privat' },
    zeitquelle: ['t1'], positionen: [], zahlungsplan: { art: 'frei', prozent: null, text: 'x' },
    gesendetAm: '2026-10-06T10:00:00.000Z', tokenGueltigBis: null, titel: undefined, tokenHash: null,
  })
  assert.deepEqual(doc, {
    status: 'versendet', kundeJson: '{"name":"Anna Beispiel","kundenart":"privat"}', zeitquelleJson: '["t1"]', positionenJson: '[]',
    zahlungsplanJson: '{"art":"frei","prozent":null,"text":"x"}', gesendetAm: '2026-10-06T10:00:00.000Z', tokenGueltigBis: null, tokenHash: '',
  })
  assert.equal(auftragToDoc({ kunde: null }).kundeJson, '')
})

test('auftragToDoc: ein Text über der Spaltengröße ist ein 400, ein falscher Typ ein Programmfehler', () => {
  assert.throws(() => auftragToDoc({ titel: 'x'.repeat(201) }), (err) => err instanceof AuftragError && err.status === 400 && /titel/.test(err.message))
  assert.throws(() => auftragToDoc({ kunde: { name: 'x'.repeat(2001) } }), (err) => err instanceof AuftragError && err.status === 400 && /kunde/.test(err.message))
  assert.doesNotThrow(() => auftragToDoc({ titel: 'x'.repeat(200) }))
  for (const falsch of [{ titel: 5 }, { summeCents: 1.5 }, { jahr: '2026' }, { fruehbeginn: 'ja' }, { gesendetAm: 'gestern' }, { gesendetAm: '' }]) {
    assert.throws(() => auftragToDoc(falsch), TypeError, JSON.stringify(falsch))
  }
})

test('getAuftrag, updateAuftrag, deleteAuftrag: unbekannte und unmögliche IDs sind null, 404 bzw. folgenlos', async () => {
  seed('u1', { titel: 'Alt', summeCents: 1000, erstelltAm: '2020-01-01T00:00:00.000Z', geaendertAm: '2020-01-01T00:00:00.000Z' })
  assert.equal((await getAuftrag('u1')).titel, 'Alt')
  for (const id of ['gibtsnicht', '', '../x', 'a/b', undefined, null, 42, 'x'.repeat(37)]) assert.equal(await getAuftrag(id), null, String(id))

  const auftrag = await updateAuftrag('u1', { titel: 'Neu', positionen: [{ beschreibung: 'A', menge: 2, einzelpreisCents: 500 }], status: 'versendet' })
  assert.deepEqual([auftrag.titel, auftrag.summeCents, auftrag.status], ['Neu', 1000, 'versendet'])
  assert.equal(auftrag.positionen[0].menge, 2)
  assert.equal(auftrag.erstelltAm, '2020-01-01T00:00:00.000Z')
  assert.ok(auftrag.geaendertAm > '2020-01-01T00:00:00.000Z')
  assert.equal(aw.docs(COL).get('u1').titel, 'Neu')
  for (const id of ['gibtsnicht', '../x']) {
    await assert.rejects(() => updateAuftrag(id, { titel: 'x' }), (err) => err instanceof AuftragError && err.status === 404, id)
  }

  await deleteAuftrag('u1')
  assert.equal(aw.docs(COL).size, 0)
  await deleteAuftrag('u1')
  await deleteAuftrag('../x')
})

test('listAuftraege: Filter, neueste zuerst, mehr als 25 Treffer', async () => {
  seed('a1', { projektId: 'p1', jahr: 2026, erstelltAm: '2026-10-01T10:00:00.000Z' })
  seed('a2', { projektId: 'p2', jahr: 2026, erstelltAm: '2026-10-03T10:00:00.000Z' })
  seed('a3', { projektId: 'p1', jahr: 2025, erstelltAm: '2026-10-02T10:00:00.000Z' })
  seed('b1', { kundenId: 'c2', projektId: 'p1', jahr: 2026, erstelltAm: '2026-10-04T10:00:00.000Z' })
  const ids = async (filter) => (await listAuftraege(filter)).map((a) => a.id)
  assert.deepEqual(await ids({ kundenId: 'c1' }), ['a2', 'a3', 'a1'])
  assert.deepEqual(await ids({ kundenId: 'c1', projektId: 'p1' }), ['a3', 'a1'])
  assert.deepEqual(await ids({ jahr: 2026 }), ['b1', 'a2', 'a1'])
  assert.deepEqual(await ids(), ['b1', 'a2', 'a3', 'a1'])
  assert.deepEqual(await ids({ kundenId: 'gibtsnicht' }), [])
  for (let i = 0; i < 30; i += 1) seed(`m${String(i).padStart(2, '0')}`, { kundenId: 'c9', erstelltAm: `2026-09-${String(1 + (i % 28)).padStart(2, '0')}T10:00:00.000Z` })
  assert.equal((await listAuftraege({ kundenId: 'c9' })).length, 30)
})

test('listAuftraege: ein angegebener, aber leerer oder ungültiger Filter ist ein Fehler, nie „alle“', async () => {
  seed('a1')
  for (const filter of [{ kundenId: '' }, { kundenId: null }, { kundenId: 5 }, { projektId: '' }, { jahr: '2026' }, { jahr: 2026.5 }, { jahr: null }]) {
    await assert.rejects(() => listAuftraege(filter), TypeError, JSON.stringify(filter))
  }
})

test('findAuftragByTokenHash und findAuftragByNummer: genau eine AB, leere und ungültige Schlüssel nie', async () => {
  seed('e1', { nummer: '', tokenHash: '' })
  seed('e2')
  seed('v1', { status: 'versendet', nummer: 'AB-2026-0001', tokenHash: HASH })
  assert.equal((await findAuftragByTokenHash(HASH)).id, 'v1')
  assert.equal((await findAuftragByNummer('AB-2026-0001')).id, 'v1')
  assert.equal(await findAuftragByTokenHash('b'.repeat(64)), null)
  assert.equal(await findAuftragByNummer('AB-2026-0002'), null)
  for (const hash of ['', ' ', 'abc', 'A'.repeat(64), `${HASH}0`, undefined, null, 5]) assert.equal(await findAuftragByTokenHash(hash), null, String(hash))
  for (const nummer of ['', 'Entwurf', 'AB-2026-1', 'ab-2026-0001', 'AB-2026-0001 ', undefined, null]) assert.equal(await findAuftragByNummer(nummer), null, String(nummer))
})
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAuftragStore.test.js Expected: FAIL mit ERR_MODULE_NOT_FOUND für server/services/auftraege/auftragStore.js.

  • Step 3: Implementieren

server/services/auftraege/auftragStore.js (neu). Feldlisten nicht von Hand schreiben, sondern aus AUFTRAEGE_COLLECTIONS.auftraege.attributes ableiten (Typ und Größe); nur vier Spalten werden auf Objektfelder abgebildet: kundeJson ↔ kunde, zeitquelleJson ↔ zeitquelle, positionenJson ↔ positionen, zahlungsplanJson ↔ zahlungsplan. Alle anderen Felder heißen im Auftrag wie die Spalte; dazu id = $id.

  • docToAuftrag(doc): Vorgaben je Feld:

    • Textspalten: der Wert, wenn er ein Text ist, sonst ''.
    • jahr, laufnummer, stundensatzCents, schaetzungMinuten, summeCents: ganze Zahl, sonst null. zahlungszielTage: ganze Zahl, sonst LIMITS.standardZahlungszielTage. fruehbeginn: doc.fruehbeginn === true.
    • Datumsfelder (gesendetAm, tokenGueltigBis, bestaetigtAm, storniertAm, widerrufenAm): gültiger Zeitpunkt → new Date(wert).toISOString() (Appwrite liefert +00:00), sonst null. erstelltAm und geaendertAm ebenso, mit Rückgriff auf $createdAt bzw. $updatedAt.
    • kunde: JSON-Objekt, sonst null. zeitquelle: JSON-Liste, nur nicht leere Texte (getrimmt), sonst []. positionen: JSON-Liste, nur Objekte, sonst []. zahlungsplan: JSON-Objekt, daraus { art, prozent, text } mit art aus ZAHLUNGSPLAN_ARTEN (sonst 'nach_abnahme'), prozent Zahl oder null, text Text; ist es kein Objekt: { art: 'nach_abnahme', prozent: null, text: '' }. Kaputtes JSON wirft nie.
  • auftragToDoc(auftrag): wandelt nur die in auftrag vorhandenen Schlüssel um (auch teilweise Objekte, für updateAuftrag); id, $… und fremde Schlüssel bleiben weg, undefined fehlt. Regeln je Art: Text → String; null → ''; anderer Typ → TypeError. Zahl → ganze Zahl (Number.isSafeInteger) oder null, sonst TypeError. fruehbeginn → Wahrheitswert, sonst TypeError. Datumsfeld → null bleibt null, ein Date oder gültiger Zeitpunkt-Text wird toISOString(), alles andere (auch '') ist TypeError (Appwrite weist '' als Datum ab). JSON-Felder → JSON.stringify(wert) (null → ''). Ist ein Text oder JSON länger als die Spalte (size), dann AuftragError(400, 'Das Feld <Auftragsfeld> ist zu lang.') (Auftragsfeld = kunde für kundeJson usw.).

  • ID-Format für getAuftrag/updateAuftrag/deleteAuftrag: ein Text, der /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ erfüllt (Appwrite-Regel). getAuftrag: ungültige ID oder 404 → null (ohne Abfrage bei ungültiger ID); andere Fehler weiterreichen. updateAuftrag: ungültige ID oder 404 → AuftragError(404, 'Auftragsbestätigung nicht gefunden.'); setzt immer geaendertAm auf jetzt. deleteAuftrag: ungültige ID oder 404 sind folgenlos.

  • createAuftrag(daten): jetzt = new Date().toISOString(); Dokument = auftragToDoc({ ...daten, status: 'entwurf', erstelltAm: jetzt, geaendertAm: jetzt }), angelegt mit createDocument(config.collections.auftraege, doc) (ID vergibt ID.unique() als Vorgabe); Rückgabe docToAuftrag(Antwort).

  • listAuftraege(filter = {}): je Schlüssel kundenId, projektId (nicht leerer Text) und jahr (ganze Zahl), der nicht undefined ist, ein Query.equal; ist ein angegebener Wert ungültig (leerer Text, null, falscher Typ), TypeError (nie stillschweigend „alle“). Abfrage mit listAllDocuments (Seiten à 500, Appwrite liefert sonst nur 25), danach docToAuftrag, sortiert nach erstelltAm absteigend, dann id absteigend.

  • findAuftragByTokenHash(hash): nur ein Text aus genau 64 Zeichen [0-9a-f] führt zu einer Abfrage (Query.equal('tokenHash', hash), Query.limit(1)), sonst null ohne Abfrage (ein leerer Hash würde sonst jeden Entwurf treffen). findAuftragByNummer(nummer): nur /^AB-\d{4}-\d{4}$/, sonst null.

  • Step 4: Lauf, erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAuftragStore.test.js Expected: PASS (acht Tests, fail 0).

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/auftragStore.js test/auftraegeAuftragStore.test.js && git commit -m "Aufträge: Speicher der Auftragsbestätigungen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 8: Projektrechnungen-Speicher

Files:

  • Create: server/services/auftraege/projektRechnungenStore.js
  • Test: test/auftraegeProjektRechnungenStore.test.js

Interfaces:

  • Consumes: config.collections.projektRechnungen (Task 4); AuftragError (Task 2); createDocument, getDocument, updateDocument, listAllDocuments, Query aus appwriteAdmin.js.

  • Produces (Übersicht V4): getProjektRechnung(invoiceId) → Promise<ProjektRechnung|null>, listProjektRechnungen({ kundenId, projektId, auftragId, nurAktiv = true } = {}) → Promise<ProjektRechnung[]>, createProjektRechnung(invoiceId, daten) → Promise<ProjektRechnung>, updateProjektRechnung(invoiceId, patch) → Promise<ProjektRechnung>, umhaengen(auftragIdAlt, auftragIdNeu) → Promise<number>. Form von ProjektRechnung (V3): { invoiceId, kundenId, projektId, auftragId, art, betragCents, arbeitsblattIds, rechnungsnummer, aktiv, erstelltVon, erstelltAm }.

  • Step 1: Failing Test schreiben

// test/auftraegeProjektRechnungenStore.test.js
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'

// Speicher der Projektrechnungen (Dokument-ID = Stripe-Rechnungs-ID). Nachbau von Appwrite mit Schema und Standard-Limit 25.
const aw = await startAppwriteMock({ schema: { projektRechnungen: schemaKeys('projektRechnungen') }, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })

const { config } = await import('../server/config.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { getProjektRechnung, listProjektRechnungen, createProjektRechnung, updateProjektRechnung, umhaengen } =
  await import('../server/services/auftraege/projektRechnungenStore.js')
const { AuftragError } = await import('../server/services/auftraege/fehler.js')

const COL = config.collections.projektRechnungen
beforeEach(async () => {
  for (const id of [...aw.docs(COL).keys()]) await deleteDocument(COL, id)
})

const DATEN = { kundenId: 'c1', projektId: 'p1', auftragId: 'ab1', art: 'aufwand', betragCents: 22500, arbeitsblattIds: ['w1', 'w2'], erstelltVon: 'kenso@webklar.com' }
const seed = (id, felder = {}) =>
  aw.seed(COL, {
    $id: id, invoiceId: id, kundenId: 'c1', projektId: 'p1', auftragId: '', art: 'festbetrag', betragCents: 100,
    arbeitsblattIdsJson: '[]', rechnungsnummer: '', aktiv: true, erstelltAm: '2026-10-01T10:00:00.000Z', ...felder,
  })

test('createProjektRechnung: Dokument-ID ist die Rechnungs-ID, Standardwerte, Rückgabe in der Form aus V3', async () => {
  const rechnung = await createProjektRechnung('in_1SD6vPRxAbCdEf', DATEN)
  assert.deepEqual(
    { ...rechnung, erstelltAm: undefined },
    {
      invoiceId: 'in_1SD6vPRxAbCdEf', kundenId: 'c1', projektId: 'p1', auftragId: 'ab1', art: 'aufwand', betragCents: 22500,
      arbeitsblattIds: ['w1', 'w2'], rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com', erstelltAm: undefined,
    },
  )
  assert.match(rechnung.erstelltAm, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
  const doc = aw.docs(COL).get('in_1SD6vPRxAbCdEf')
  assert.equal(doc.invoiceId, 'in_1SD6vPRxAbCdEf')
  assert.equal(doc.arbeitsblattIdsJson, '["w1","w2"]')
  assert.equal(doc.aktiv, true)
  assert.deepEqual(await getProjektRechnung('in_1SD6vPRxAbCdEf'), rechnung)
  const minimal = await createProjektRechnung('in_test_1', { kundenId: 'c1', art: 'festbetrag', betragCents: 5000 })
  assert.deepEqual([minimal.projektId, minimal.auftragId, minimal.arbeitsblattIds, minimal.rechnungsnummer, minimal.aktiv], ['', '', [], '', true])
})

test('Rechnungs-IDs wie Stripe sie vergibt (auch in_test_1 des Nachbaus); alles andere ist ein Programmfehler und legt nichts an', async () => {
  for (const id of ['in_test_1', 'in_1SD6vPRxAbCdEf', `in_${'a'.repeat(33)}`]) assert.equal((await createProjektRechnung(id, DATEN)).invoiceId, id)
  await Promise.all([...aw.docs(COL).keys()].map((id) => deleteDocument(COL, id)))
  for (const id of ['', 'x', 'in_', 'in_a/b', 'in_../x', 'in_a-b', 'in_a.b', 'IN_abc', `in_${'a'.repeat(34)}`, undefined, 7]) {
    await assert.rejects(() => createProjektRechnung(id, DATEN), TypeError, String(id))
    assert.equal(await getProjektRechnung(id), null, String(id))
  }
  const falsch = [{ kundenId: '' }, { art: 'frei' }, { art: 'xyz' }, { betragCents: 1.5 }, { betragCents: -1 }, { arbeitsblattIds: 'w1' }, { arbeitsblattIds: [''] }, { aktiv: 'ja' }]
  for (const abweichung of falsch) {
    await assert.rejects(() => createProjektRechnung('in_abc', { ...DATEN, ...abweichung }), TypeError, JSON.stringify(abweichung))
  }
  assert.equal(aw.docs(COL).size, 0)
})

test('createProjektRechnung: eine doppelte Rechnungs-ID ist 409', async () => {
  await createProjektRechnung('in_abc', DATEN)
  await assert.rejects(() => createProjektRechnung('in_abc', DATEN), (err) => err instanceof AuftragError && err.status === 409)
})

test('updateProjektRechnung ändert genannte Felder; eine unbekannte Rechnung ist 404', async () => {
  await createProjektRechnung('in_abc', DATEN)
  const gesendet = await updateProjektRechnung('in_abc', { rechnungsnummer: 'WEBKLAR-0009' })
  assert.deepEqual([gesendet.rechnungsnummer, gesendet.aktiv, gesendet.betragCents], ['WEBKLAR-0009', true, 22500])
  const storniert = await updateProjektRechnung('in_abc', { aktiv: false, arbeitsblattIds: ['w1'] })
  assert.deepEqual([storniert.aktiv, storniert.arbeitsblattIds, storniert.rechnungsnummer], [false, ['w1'], 'WEBKLAR-0009'])
  assert.equal(aw.docs(COL).get('in_abc').aktiv, false)
  for (const id of ['in_gibtsnicht', '../x', '']) {
    await assert.rejects(() => updateProjektRechnung(id, { aktiv: false }), (err) => err instanceof AuftragError && err.status === 404, id)
  }
})

test('listProjektRechnungen: nur aktive, Filter, nach erstelltAm aufsteigend, mehr als 25 Treffer', async () => {
  seed('in_a', { erstelltAm: '2026-10-03T10:00:00.000Z', auftragId: 'ab1' })
  seed('in_b', { erstelltAm: '2026-10-01T10:00:00.000Z', auftragId: 'ab1' })
  seed('in_c', { aktiv: false, erstelltAm: '2026-10-02T10:00:00.000Z', auftragId: 'ab1' })
  seed('in_d', { kundenId: 'c2', projektId: 'p2', erstelltAm: '2026-10-04T10:00:00.000Z' })
  const ids = async (filter) => (await listProjektRechnungen(filter)).map((r) => r.invoiceId)
  assert.deepEqual(await ids({ kundenId: 'c1' }), ['in_b', 'in_a'])
  assert.deepEqual(await ids({ kundenId: 'c1', nurAktiv: false }), ['in_b', 'in_c', 'in_a'])
  assert.deepEqual(await ids({ auftragId: 'ab1' }), ['in_b', 'in_a'])
  assert.deepEqual(await ids({ projektId: 'p2' }), ['in_d'])
  assert.deepEqual(await ids(), ['in_b', 'in_a', 'in_d'])
  for (let i = 0; i < 30; i += 1) seed(`in_m${i}`, { kundenId: 'c9' })
  assert.equal((await listProjektRechnungen({ kundenId: 'c9' })).length, 30)
})

test('listProjektRechnungen: ein angegebener, aber leerer oder ungültiger Filter ist ein Fehler, nie „alle“', async () => {
  seed('in_a')
  for (const filter of [{ kundenId: '' }, { projektId: null }, { auftragId: 7 }, { nurAktiv: 'ja' }]) {
    await assert.rejects(() => listProjektRechnungen(filter), TypeError, JSON.stringify(filter))
  }
})

test('umhaengen hängt alle Dokumente einer AB (auch stornierte) an die neue AB, ist wiederholbar und lässt andere unberührt', async () => {
  seed('in_a', { auftragId: 'ab1' })
  seed('in_b', { auftragId: 'ab1', aktiv: false })
  seed('in_c', { auftragId: 'ab2' })
  assert.equal(await umhaengen('ab1', 'ab3'), 2)
  assert.deepEqual([...aw.docs(COL).values()].map((d) => [d.invoiceId, d.auftragId]).sort(), [['in_a', 'ab3'], ['in_b', 'ab3'], ['in_c', 'ab2']])
  assert.equal(await umhaengen('ab1', 'ab3'), 0)
  assert.equal(await umhaengen('ab3', 'ab3'), 0)
  for (const [alt, neu] of [['', 'ab3'], ['ab1', ''], [undefined, 'ab3'], ['ab1', null]]) {
    await assert.rejects(() => umhaengen(alt, neu), TypeError, JSON.stringify([alt, neu]))
  }
})
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeProjektRechnungenStore.test.js Expected: FAIL mit ERR_MODULE_NOT_FOUND für server/services/auftraege/projektRechnungenStore.js.

  • Step 3: Implementieren

server/services/auftraege/projektRechnungenStore.js (neu):

  • Rechnungs-ID-Format: /^in_\w{1,33}$/ (wie STRIPE_INVOICE_ID = /^in_\w+$/ im Bestand, zusätzlich höchstens 36 Zeichen, die Grenze der Appwrite-Dokument-ID; der Stripe-Nachbau vergibt in_test_<n>, V1 und Nachträge).

  • Dokument → Objekt: invoiceId = $id; arbeitsblattIds aus arbeitsblattIdsJson (nur nicht leere Texte, kaputtes JSON → []); aktiv = doc.aktiv === true; kundenId, projektId, auftragId, art, rechnungsnummer, erstelltVon: Text, sonst ''; betragCents: ganze Zahl, sonst 0; erstelltAm: ISO mit Z (new Date(...).toISOString()), Rückgriff $createdAt.

  • getProjektRechnung(invoiceId): ungültige ID → null ohne Abfrage; 404 → null; andere Fehler weiterreichen.

  • createProjektRechnung(invoiceId, daten): Prüfungen, jede Verletzung ist ein TypeError: ID passt zum Format; kundenId nicht leerer Text; art ∈ ['festbetrag', 'aufwand', 'anzahlung', 'schluss'] (frei hat nie ein Dokument); betragCents ganze Zahl ≥ 0; arbeitsblattIds (Vorgabe []) Liste nicht leerer Texte; aktiv (Vorgabe true) Wahrheitswert; projektId, auftragId, rechnungsnummer, erstelltVon Text (Vorgabe ''). Das Dokument bekommt die Rechnungs-ID als Dokument-ID und als Feld invoiceId, arbeitsblattIdsJson = JSON.stringify(...) (länger als 20000 Zeichen → AuftragError(400, 'Zu viele Arbeitsblätter in einer Rechnung.')), erstelltAm = jetzt. Appwrite-Antwort 409 → AuftragError(409, 'Zu dieser Rechnung gibt es schon einen Eintrag.'). Rückgabe: das Objekt.

  • updateProjektRechnung(invoiceId, patch): ungültige oder unbekannte ID → AuftragError(404, 'Projektrechnung nicht gefunden.'); änderbar sind kundenId, projektId, auftragId, art, betragCents, arbeitsblattIds, rechnungsnummer, aktiv, erstelltVon mit denselben Prüfungen wie beim Anlegen (nur für die genannten Schlüssel); invoiceId, erstelltAm und fremde Schlüssel werden ignoriert. Rückgabe: das Objekt nach der Änderung.

  • listProjektRechnungen(filter = {}): je Schlüssel kundenId, projektId, auftragId, der nicht undefined ist, ein Query.equal (nicht leerer Text, sonst TypeError); nurAktiv (Vorgabe true, muss ein Wahrheitswert sein, sonst TypeError) fügt Query.equal('aktiv', true) hinzu. Abfrage mit listAllDocuments, sortiert nach erstelltAm aufsteigend, dann invoiceId.

  • umhaengen(auftragIdAlt, auftragIdNeu): beide nicht leerer Text, sonst TypeError; sind sie gleich → 0. Sonst alle Dokumente mit auftragId === auftragIdAlt (listProjektRechnungen({ auftragId: auftragIdAlt, nurAktiv: false })) auf auftragIdNeu setzen; Rückgabe: Anzahl. Bei einem Fehler bleibt ein Teilstand, ein erneuter Aufruf setzt fort.

  • Step 4: Lauf, erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeProjektRechnungenStore.test.js Expected: PASS (sieben Tests, fail 0).

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/projektRechnungenStore.js test/auftraegeProjektRechnungenStore.test.js && git commit -m "Aufträge: Speicher der Projektrechnungen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 9: Arbeitsblätter

Files:

  • Create: server/services/auftraege/arbeitsblaetter.js
  • Test: test/auftraegeArbeitsblaetter.test.js

Interfaces:

  • Consumes: listProjektRechnungen (Task 8); VIERTELSTUNDE, VEREINBARTE_STATUS (Task 2); config.collections.worksheets; listAllDocuments, Query aus appwriteAdmin.js; customerWorkEntries aus server/services/customerProjectView.js; worksheetMinutes aus server/services/worksheetMinutes.js; berlinDate aus server/services/finance/dates.js.

  • Produces (Übersicht V3/V4): ladeArbeitsblaetter(ticketIds) → Promise<Arbeitsblatt[]>, ladeArbeitsblaetterNachIds(ids) → Promise<Arbeitsblatt[]> (liefert nur gefundene Blätter, der Aufrufer vergleicht die Anzahl), abrechenbar(blatt) → boolean, abgerechneteArbeitsblaetter(kundenId) → Promise<Map<worksheetId, { invoiceId, rechnungsnummer }>> (rechnungsnummer ist bei einem Entwurf ''; die Team-Route zeigt dafür „Entwurf“), offeneArbeitsblaetter(ticketIds, kundenId) → Promise<Arbeitsblatt[]>, zeitquelleFuerProjekt(projekt, auftraege) → string[]. Form Arbeitsblatt: { id, ticketId, woid, datum, minuten, text, aboZeit, kommentar }.

  • Step 1: Failing Test schreiben

// test/auftraegeArbeitsblaetter.test.js
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'

// worksheets gehört dem Ticketsystem und hat im Nachbau kein Schema (jedes Feld ist erlaubt).
const aw = await startAppwriteMock({ schema: { projektRechnungen: schemaKeys('projektRechnungen') }, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })

const { config } = await import('../server/config.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { createProjektRechnung, updateProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js')
const A = await import('../server/services/auftraege/arbeitsblaetter.js')

const WS = config.collections.worksheets
const PR = config.collections.projektRechnungen
beforeEach(async () => {
  for (const col of [WS, PR]) for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id)
  aw.resetListCalls()
})

const UNTERSTUETZUNG = JSON.stringify([{ employeeId: 'e2', name: 'Justin Klein', task: 'Review' }])
const ZWEI_UNTERSTUETZER = JSON.stringify([
  { employeeId: 'e2', name: 'Justin Klein', task: 'Review' },
  { employeeId: 'e3', name: 'Nico', task: 'Test' },
])
const blatt = (id, felder = {}) =>
  aw.seed(WS, {
    $id: id, workorderId: 't1', woid: '100101', wsid: id, startDate: '06.10.2026', endDate: '06.10.2026', totalTime: 60,
    details: 'Startseite überarbeitet', serviceType: 'REMOTE', employeeName: 'Kenso Grimm', countsToPlan: false, isComment: false, supporters: '', ...felder,
  })
const nachId = async (ticketIds = ['t1']) => Object.fromEntries((await A.ladeArbeitsblaetter(ticketIds)).map((b) => [b.id, b]))

test('ladeArbeitsblaetter liefert die normierte Form je Blatt', async () => {
  blatt('w1')
  assert.deepEqual(await A.ladeArbeitsblaetter(['t1']), [
    { id: 'w1', ticketId: 't1', woid: '100101', datum: '2026-10-06', minuten: 60, text: 'Startseite überarbeitet', aboZeit: false, kommentar: false },
  ])
})

test('Datum eines Arbeitsblatts: TT.MM.JJJJ, ISO als Berliner Tag, leer fällt auf $createdAt, ein unmögliches Datum auch', async () => {
  const faelle = {
    d1: [{ startDate: '06.10.2026' }, '2026-10-06'],
    d2: [{ startDate: '', endDate: '07.10.2026' }, '2026-10-07'],
    d3: [{ startDate: '2026-10-06T21:59:59.000Z' }, '2026-10-06'], // 23:59:59 in Berlin
    d4: [{ startDate: '2026-10-06T22:30:00.000Z' }, '2026-10-07'], // 00:30 in Berlin am nächsten Tag
    d5: [{ startDate: '2026-10-06' }, '2026-10-06'],
    d6: [{ startDate: '', $createdAt: '2026-10-08T21:30:00.000+00:00' }, '2026-10-08'],
    d7: [{ startDate: '', $createdAt: '2026-10-08T22:00:00.000+00:00' }, '2026-10-09'],
    d8: [{ startDate: '31.02.2026', $createdAt: '2026-10-01T10:00:00.000+00:00' }, '2026-10-01'],
    d9: [{ startDate: 'gestern', $createdAt: '2026-10-02T10:00:00.000+00:00' }, '2026-10-02'],
    d10: [{ startDate: '12.13.2026', $createdAt: '2026-10-03T10:00:00.000+00:00' }, '2026-10-03'], // nie als Monatsangabe der US-Schreibweise lesen
  }
  for (const [id, [felder]] of Object.entries(faelle)) blatt(id, { endDate: '', ...felder })
  const datum = Object.fromEntries(Object.values(await nachId()).map((b) => [b.id, b.datum]))
  assert.deepEqual(datum, Object.fromEntries(Object.entries(faelle).map(([id, [, tag]]) => [id, tag])))
})

test('Minuten: je Blatt inklusive Unterstützung auf 15 Minuten aufgerundet', async () => {
  const faelle = [
    ['m1', { totalTime: 10 }, 15],
    ['m2', { totalTime: 15 }, 15],
    ['m3', { totalTime: 16 }, 30],
    ['m4', { totalTime: 0 }, 0],
    ['m5', { totalTime: 'abc' }, 0],
    ['m6', { totalTime: 80, supporters: UNTERSTUETZUNG }, 135], // 80 + 45 Unterstützung = 125
    ['m7', { totalTime: 20, supporters: UNTERSTUETZUNG }, 45], // 20 + 15 = 35
    ['m8', { totalTime: 60, supporters: ZWEI_UNTERSTUETZER }, 120], // 60 + 30 + 30
  ]
  for (const [id, felder] of faelle) blatt(id, felder)
  const minuten = Object.fromEntries(Object.values(await nachId()).map((b) => [b.id, b.minuten]))
  assert.deepEqual(minuten, Object.fromEntries(faelle.map(([id, , erwartet]) => [id, erwartet])))
})

test('Abo-Zeit und Kommentar werden erkannt und sind nie abrechenbar, ebenso 0 Minuten', async () => {
  blatt('f1')
  blatt('f2', { countsToPlan: true })
  blatt('f3', { isComment: true, totalTime: 0 })
  blatt('f4', { totalTime: 0 })
  const b = await nachId()
  assert.deepEqual([b.f1.aboZeit, b.f1.kommentar, A.abrechenbar(b.f1)], [false, false, true])
  assert.deepEqual([b.f2.aboZeit, A.abrechenbar(b.f2)], [true, false])
  assert.deepEqual([b.f3.kommentar, A.abrechenbar(b.f3)], [true, false])
  assert.equal(A.abrechenbar(b.f4), false)
  const basis = { aboZeit: false, kommentar: false, minuten: 15 }
  assert.equal(A.abrechenbar(basis), true)
  assert.equal(A.abrechenbar({ ...basis, aboZeit: true }), false)
  assert.equal(A.abrechenbar({ ...basis, kommentar: true }), false)
  assert.equal(A.abrechenbar({ ...basis, minuten: 0 }), false)
})

test('Text: wie in der Kundenansicht, eine Zeile, höchstens 200 Zeichen, Git ohne Kürzel, Link und Namen', async () => {
  blatt('x1', { details: '  Startseite\n\nKontaktformular  überarbeitet  ' })
  blatt('x2', { details: 'ä'.repeat(300) })
  blatt('x3', {
    serviceType: 'GIT',
    totalTime: 45,
    details: [
      'Git Push auf `knso/x` (Branch: main)',
      'Commit: 88d3c37',
      '',
      '- [88d3c37](https://git.webklar.com/knso/x/commit/88d3c37ab7b15337772cf59a5a9294ec0cc8cca4): feat: Kontaktformular repariert (Kenso Grimm)',
      '- [1a2b3c4](https://git.webklar.com/knso/x/commit/1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b): fix: Footer',
      '',
    ].join('\n'),
  })
  blatt('x4', { isComment: true, details: 'nur intern' })
  blatt('x5', { details: '' })
  const b = await nachId()
  assert.equal(b.x1.text, 'Startseite Kontaktformular überarbeitet')
  assert.equal(b.x2.text.length, 200)
  assert.ok(b.x2.text.endsWith('…'))
  assert.equal(b.x3.text, 'Kontaktformular repariert; Footer')
  assert.doesNotMatch(b.x3.text, /88d3c37|git\.webklar|Kenso/)
  assert.equal(b.x4.text, '')
  assert.equal(b.x5.text, '')
})

test('ladeArbeitsblaetter: nur die genannten Tickets, nach Datum und ID sortiert, in Blöcken zu 100 Tickets', async () => {
  blatt('w3', { startDate: '07.10.2026' })
  blatt('w2', { startDate: '06.10.2026' })
  blatt('w1', { startDate: '06.10.2026' })
  blatt('w9', { workorderId: 't2', startDate: '05.10.2026' })
  blatt('w8', { workorderId: 't3' })
  assert.deepEqual((await A.ladeArbeitsblaetter(['t1', 't2'])).map((b) => b.id), ['w9', 'w1', 'w2', 'w3'])
  assert.deepEqual((await A.ladeArbeitsblaetter(['t1', 't1', '', null, 5])).map((b) => b.id), ['w1', 'w2', 'w3'])
  aw.resetListCalls()
  for (const leer of [[], undefined, null, [''], 'kein-array']) assert.deepEqual(await A.ladeArbeitsblaetter(leer), [], String(leer))
  assert.equal(aw.listCalls(WS).length, 0, 'ohne Ticket keine Abfrage')
  const viele = Array.from({ length: 250 }, (_, i) => `t_${i}`)
  viele[7] = 't1'
  assert.deepEqual((await A.ladeArbeitsblaetter(viele)).map((b) => b.id), ['w1', 'w2', 'w3'])
  const aufrufe = aw.listCalls(WS)
  assert.equal(aufrufe.length, 3)
  for (const aufruf of aufrufe) {
    assert.ok(aufruf.queries.find((q) => q.method === 'equal' && q.attribute === 'workorderId').values.length <= 100)
  }
})

test('ladeArbeitsblaetterNachIds liefert nur vorhandene Blätter, sortiert wie ladeArbeitsblaetter', async () => {
  blatt('w1', { startDate: '07.10.2026' })
  blatt('w2', { workorderId: 't2', startDate: '06.10.2026' })
  assert.deepEqual((await A.ladeArbeitsblaetterNachIds(['w1', 'w2', 'gibtsnicht', 'w1'])).map((b) => [b.id, b.ticketId]), [['w2', 't2'], ['w1', 't1']])
  assert.deepEqual(await A.ladeArbeitsblaetterNachIds([]), [])
})

test('abgerechneteArbeitsblaetter: nur aktive Projektrechnungen dieses Kunden; ein Entwurf hat die Nummer leer', async () => {
  const daten = (extra) => ({ kundenId: 'c1', projektId: 'p1', art: 'aufwand', betragCents: 1000, ...extra })
  await createProjektRechnung('in_gesendet', daten({ arbeitsblattIds: ['w1', 'w2'] }))
  await updateProjektRechnung('in_gesendet', { rechnungsnummer: 'WEBKLAR-0009' })
  await createProjektRechnung('in_entwurf', daten({ arbeitsblattIds: ['w5'] }))
  await createProjektRechnung('in_storniert', daten({ arbeitsblattIds: ['w3'] }))
  await updateProjektRechnung('in_storniert', { aktiv: false })
  await createProjektRechnung('in_fremd', daten({ kundenId: 'c2', arbeitsblattIds: ['w4'] }))
  const karte = await A.abgerechneteArbeitsblaetter('c1')
  assert.ok(karte instanceof Map)
  assert.deepEqual([...karte.entries()].sort(), [
    ['w1', { invoiceId: 'in_gesendet', rechnungsnummer: 'WEBKLAR-0009' }],
    ['w2', { invoiceId: 'in_gesendet', rechnungsnummer: 'WEBKLAR-0009' }],
    ['w5', { invoiceId: 'in_entwurf', rechnungsnummer: '' }],
  ])
  await assert.rejects(() => A.abgerechneteArbeitsblaetter(''), TypeError)
})

test('offeneArbeitsblaetter: abrechenbar und noch nicht abgerechnet; ein stornierter Eintrag gibt das Blatt frei', async () => {
  blatt('w1') // abgerechnet (siehe unten)
  blatt('w6', { totalTime: 30 }) // offen
  blatt('w7', { countsToPlan: true }) // Abo-Zeit
  blatt('w8', { isComment: true, totalTime: 0 }) // Kommentar
  blatt('w9', { totalTime: 0 }) // 0 Minuten
  blatt('w10', { workorderId: 't2' }) // anderes Ticket
  await createProjektRechnung('in_a', { kundenId: 'c1', projektId: 'p1', art: 'aufwand', betragCents: 1000, arbeitsblattIds: ['w1'] })
  assert.deepEqual((await A.offeneArbeitsblaetter(['t1'], 'c1')).map((b) => b.id), ['w6'])
  await updateProjektRechnung('in_a', { aktiv: false })
  assert.deepEqual((await A.offeneArbeitsblaetter(['t1'], 'c1')).map((b) => b.id), ['w1', 'w6'])
})

test('zeitquelleFuerProjekt: Vereinigung der vereinbarten ABs, sonst das Ticket des Projekts', () => {
  const ab = (status, zeitquelle) => ({ status, zeitquelle })
  const projekt = { $id: 'p1', ticketId: 't0' }
  const alle = [
    ab('versendet', ['t1', 't2']), ab('bestaetigt', ['t2', 't3']), ab('abgerechnet', ['t4']),
    ab('entwurf', ['t9']), ab('storniert', ['t8']), ab('ersetzt', ['t7']), ab('widerrufen', ['t6']),
  ]
  assert.deepEqual(A.zeitquelleFuerProjekt(projekt, alle), ['t1', 't2', 't3', 't4'])
  assert.deepEqual(A.zeitquelleFuerProjekt(projekt, []), ['t0'])
  assert.deepEqual(A.zeitquelleFuerProjekt(projekt, [ab('entwurf', ['t9'])]), ['t0'])
  assert.deepEqual(A.zeitquelleFuerProjekt(projekt, [ab('versendet', [])]), ['t0'])
  assert.deepEqual(A.zeitquelleFuerProjekt({ $id: 'p1' }, []), [])
  assert.deepEqual(A.zeitquelleFuerProjekt({ ticketId: '' }, undefined), [])
  assert.deepEqual(A.zeitquelleFuerProjekt(null, []), [])
})
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeArbeitsblaetter.test.js Expected: FAIL mit ERR_MODULE_NOT_FOUND für server/services/auftraege/arbeitsblaetter.js.

  • Step 3: Implementieren

server/services/auftraege/arbeitsblaetter.js (neu). Je Dokument aus worksheets entsteht ein Arbeitsblatt:

  • id = $id; ticketId = String(workorderId ?? ''); woid = String(woid ?? ''); aboZeit = countsToPlan === true; kommentar = isComment === true (nur echte Wahrheitswerte zählen).

  • minuten = Math.ceil(worksheetMinutes(doc) / VIERTELSTUNDE) * VIERTELSTUNDE (worksheetMinutes enthält die Unterstützung, ein Wert, der keine Zahl ist, zählt als 0).

  • datum (Berliner Kalendertag YYYY-MM-DD, Regel wie parseSheetDate in server/services/hourLedger.js, das nicht exportiert wird und unverändert bleibt): Rohwert = startDate || endDate || ''. Hat er die Form ^(\d{2})\.(\d{2})\.(\d{4})$, entscheidet allein diese Form: ein echter Kalendertag ergibt unmittelbar JJJJ-MM-TT, ein unmöglicher (31.02.) wird übersprungen (nie new Date darauf anwenden: V8 läse „12.13.2026“ als Dezember). Sonst zählen als Kandidaten der Reihe nach der Rohwert (nur wenn nicht leer und nicht von der Form TT.MM.JJJJ), createdAt und $createdAt; der erste, den new Date(...) als gültigen Zeitpunkt liest, ergibt berlinDate(date). Ist keiner gültig, ''.

  • text: customerWorkEntries([doc], { includeUpdates: true }) (aus ../customerProjectView.js) liefert eine Liste mit höchstens einem Eintrag; ohne Eintrag (Kommentar) → ''; hat der erste Eintrag changes (Git-Push) → changes.join('; '); sonst entry.text. Danach Leerraum zusammenfassen (/\s+/g → ' ', trim) und bei mehr als 200 Zeichen auf 199 Zeichen kürzen (trimEnd()) plus ….

  • Sortierung der Ergebnisliste: nach datum, dann nach id, jeweils einfacher Textvergleich.

  • Laden: Ticket-IDs bzw. Blatt-IDs entdoppeln, nur nicht leere Texte; ein Argument, das keine Liste ist (auch ein Text), gilt als leere Liste; ohne gültigen Eintrag [] ohne Abfrage; in Blöcken zu je 100 Werten mit listAllDocuments(config.collections.worksheets, [Query.equal('workorderId' | '$id', block)]) (Appwrite begrenzt die Werteliste je equal-Abfrage).

  • abrechenbar(blatt) = !blatt.aboZeit && !blatt.kommentar && blatt.minuten > 0.

  • abgerechneteArbeitsblaetter(kundenId): listProjektRechnungen({ kundenId }) (nur aktive; ungültiger kundenId → dessen TypeError), je Blatt-ID der erste Eintrag (älteste Rechnung) gewinnt: { invoiceId, rechnungsnummer }.

  • offeneArbeitsblaetter(ticketIds, kundenId): ladeArbeitsblaetter(ticketIds) gefiltert auf abrechenbar(b) und nicht in der Abgerechnet-Karte.

  • zeitquelleFuerProjekt(projekt, auftraege): Vereinigung der zeitquelle-Listen aller Aufträge mit status in VEREINBARTE_STATUS (Reihenfolge des Auftretens, ohne Doppelte und Leereinträge); ist sie leer, [projekt.ticketId] ohne Leereintrag; projekt und auftraege dürfen fehlen (null/undefined). Es wird nicht nach Projekt gefiltert: der Aufrufer übergibt nur die Aufträge dieses Projekts.

  • Step 4: Lauf, erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeArbeitsblaetter.test.js Expected: PASS (zehn Tests, fail 0).

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/arbeitsblaetter.js test/auftraegeArbeitsblaetter.test.js && git commit -m "Aufträge: Arbeitsblätter laden, normieren und als abgerechnet erkennen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 10: Sichten auf die Auftragsbestätigung

Files:

  • Create: server/services/auftraege/auftragSicht.js
  • Test: test/auftraegeAuftragSicht.test.js

Interfaces:

  • Consumes: AB_TEXTE, BESTAETIGUNG_TEXT, FRUEHBEGINN_TEXT (Task 6); AGB_FASSUNG, KUNDENARTEN, LIMITS, STUNDENSATZ_STANDARD_CENTS (Task 2); withdrawalDeadline aus server/services/deadlines.js (liefert { lastDay: 'YYYY-MM-DD', endsAt: Date }); euroDe aus server/services/finance/format.js; addDays, berlinDate aus server/services/finance/dates.js.

  • Produces (Übersicht V3/V4): kundeSnapshot(customerDoc) → KundeSnapshot, fruehesterBeginn(auftrag) → 'YYYY-MM-DD'|null, zahlungsplanText(auftrag) → string, aufwandText(auftrag) → string|null, auftragFuerTeam(auftrag) → AuftragTeam, oeffentlicheAnsicht(auftrag) → AuftragOeffentlich. Alles rein (kein Appwrite, keine Konfiguration).

  • Step 1: Failing Test schreiben

// test/auftraegeAuftragSicht.test.js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { kundeSnapshot, fruehesterBeginn, zahlungsplanText, aufwandText, auftragFuerTeam, oeffentlicheAnsicht } from '../server/services/auftraege/auftragSicht.js'
import { AGB_FASSUNG } from '../server/services/auftraege/konstanten.js'
import { AB_TEXTE, BESTAETIGUNG_TEXT, FRUEHBEGINN_TEXT } from '../server/services/auftraege/texte.js'
import { euroDe } from '../server/services/finance/format.js'

const KUNDE = { name: 'Anna Beispiel', firma: '', strasse: 'Hauptstr. 1', plz: '67722', ort: 'Winnweiler', land: '', email: 'anna@example.invalid', kundenart: 'privat', kundennummer: 'K-1001' }
const festpreis = (felder = {}) => ({
  id: 'ab1', nummer: 'AB-2026-0002', jahr: 2026, laufnummer: 2, status: 'versendet', kundenId: 'c1', kunde: KUNDE, projektId: 'p1', titel: 'Website Bäckerei',
  zeitquelle: ['t1'], art: 'festpreis',
  positionen: [{ beschreibung: 'Startseite', menge: 2, einzelpreisCents: 30000 }, { beschreibung: 'Kontaktformular', menge: 1, einzelpreisCents: 20000 }],
  stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000, zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14,
  auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '2026-11-01', fertigBis: '', ersetztId: '', ersetztDurchId: '', texteFassung: '2026-10-06', agbFassung: '2026-09-30',
  pdfDatei: 'c1/ab1.pdf', pdfSha256: 'a'.repeat(64), gesendetAm: '2026-10-06T10:00:00.000Z', gesendetAn: 'anna@example.invalid', mailStatus: 'gesendet', mailFehler: '',
  tokenHash: 'b'.repeat(64), tokenGueltigBis: '2026-12-05T10:00:00.000Z', bestaetigtAm: null, bestaetigtUeber: '', bestaetigtPdfSha256: '', fruehbeginn: false, fruehbeginnFassung: '',
  storniertAm: null, widerrufenAm: null, erstelltVon: 'kenso@webklar.com', erstelltAm: '2026-10-05T09:00:00.000Z', geaendertAm: '2026-10-06T10:00:00.000Z', ...felder,
})
const aufwand = (felder = {}) =>
  festpreis({ art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' }, ...felder })
const LEER = { name: '', firma: '', strasse: '', plz: '', ort: '', land: '', email: '', kundenart: '', kundennummer: '' }

test('kundeSnapshot übernimmt die Kundendaten getrimmt; eine unbekannte Kundenart wird leer; null und {} sind leer', () => {
  const doc = { $id: 'c1', name: ' Anna Beispiel ', companyName: '', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler', country: '', email: 'anna@example.invalid', kundenart: 'privat', code: 'K-1001', phone: '123', customerStatus: 'active' }
  assert.deepEqual(kundeSnapshot(doc), KUNDE)
  assert.deepEqual(kundeSnapshot({ ...doc, companyName: ' Bäckerei Beispiel GmbH ', kundenart: 'unternehmen' }), { ...KUNDE, firma: 'Bäckerei Beispiel GmbH', kundenart: 'unternehmen' })
  assert.equal(kundeSnapshot({ name: 'X', kundenart: 'firma' }).kundenart, '')
  assert.deepEqual(kundeSnapshot({}), LEER)
  assert.deepEqual(kundeSnapshot(null), LEER)
})

test('kundeSnapshot kürzt lange Werte, so dass die Spalte kundeJson (2000 Zeichen) reicht', () => {
  const lang = 'x'.repeat(500)
  const snapshot = kundeSnapshot({ name: lang, companyName: lang, street: lang, postalCode: lang, city: lang, country: lang, email: lang, kundenart: 'privat', code: lang })
  assert.ok(JSON.stringify(snapshot).length <= 2000)
  assert.equal(snapshot.name.length, 120)
})

test('fruehesterBeginn: Tag nach Ablauf der Widerrufsfrist, nur für Privatpersonen ohne Frühbeginn', () => {
  const faelle = [
    ['2026-10-06T10:00:00.000Z', '2026-10-21'],
    ['2026-10-06T22:30:00.000Z', '2026-10-22'], // in Berlin schon der 07.10.
    ['2026-10-10T08:00:00.000Z', '2026-10-27'], // Fristende fiele auf Samstag, 26.10. ist der Montag
    ['2026-10-17T08:00:00.000Z', '2026-11-03'], // 31.10. (Samstag, Feiertag) und 01.11. (Sonntag), Fristende Montag 02.11.
  ]
  for (const [gesendetAm, erwartet] of faelle) assert.equal(fruehesterBeginn(festpreis({ gesendetAm })), erwartet, gesendetAm)
  assert.equal(fruehesterBeginn(festpreis({ fruehbeginn: true })), null)
  for (const kundenart of ['unternehmen', '']) assert.equal(fruehesterBeginn(festpreis({ kunde: { ...KUNDE, kundenart } })), null, kundenart)
  assert.equal(fruehesterBeginn(festpreis({ kunde: null })), null)
  assert.equal(fruehesterBeginn(festpreis({ gesendetAm: null, status: 'entwurf' })), null)
  assert.equal(fruehesterBeginn(festpreis({ gesendetAm: 'kaputt' })), null)
})

test('zahlungsplanText: Anzahlung nach Prozent gerundet, Rest als Differenz', () => {
  const text = zahlungsplanText(festpreis())
  assert.equal(text.split(euroDe(40000)).length, 3, text) // Anzahlung und Rest je 400,00 €
  assert.match(text, /50\s?%/)
  assert.match(text, /\b14\b/)
  // Math.round(10001 × 33 / 100) = 3300, Rest 6701
  const ungerade = zahlungsplanText(festpreis({ summeCents: 10001, zahlungsplan: { art: 'anzahlung', prozent: 33, text: '' } }))
  assert.ok(ungerade.includes(euroDe(3300)) && ungerade.includes(euroDe(6701)), ungerade)
})

test('zahlungsplanText: nach Abnahme, monatlich, frei; Anzahlung ohne Summe fällt auf nach Abnahme zurück', () => {
  assert.match(zahlungsplanText(festpreis({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })), /\b14\b/)
  assert.match(zahlungsplanText(aufwand({ zahlungszielTage: 7 })), /\b7\b/)
  assert.ok(zahlungsplanText(festpreis({ zahlungsplan: { art: 'frei', prozent: null, text: 'Zahlung in zwei Raten.' } })).includes('Zahlung in zwei Raten'))
  const ohneSumme = aufwand({ zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' } })
  assert.equal(zahlungsplanText(ohneSumme), zahlungsplanText({ ...ohneSumme, zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } }))
})

test('aufwandText: nur bei Aufwand, mit Stundensatz und Schätzung in Stunden', () => {
  const text = aufwandText(aufwand())
  assert.ok(text.includes(euroDe(10000)))
  assert.match(text, /\b10\b/)
  assert.doesNotMatch(aufwandText(aufwand({ schaetzungMinuten: null })), /\b10\b/)
  assert.match(aufwandText(aufwand({ schaetzungMinuten: 135 })), /2,25/)
  assert.match(aufwandText(aufwand({ schaetzungMinuten: 90 })), /1,5\b/)
  assert.ok(aufwandText(aufwand({ stundensatzCents: null })).includes(euroDe(10000)), 'ohne Satz gilt der Standard')
  assert.equal(aufwandText(festpreis()), null)
})

test('auftragFuerTeam: ohne Token-Hash, PDF-Pfad und Bestätigungs-Prüfsumme, mit den abgeleiteten Feldern', () => {
  const auftrag = festpreis()
  const kopie = structuredClone(auftrag)
  const sicht = auftragFuerTeam(auftrag)
  assert.deepEqual(auftrag, kopie, 'verändert die AB nicht')
  const entfernt = ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256']
  for (const feld of entfernt) assert.equal(Object.hasOwn(sicht, feld), false, feld)
  assert.equal(sicht.pdfSha256, 'a'.repeat(64))
  assert.equal(sicht.pdfVorhanden, true)
  assert.equal(sicht.fruehesterBeginn, '2026-10-21')
  assert.equal(sicht.zahlungsplanText, zahlungsplanText(auftrag))
  assert.equal(sicht.aufwandText, null)
  assert.deepEqual(
    Object.keys(sicht).sort(),
    [...Object.keys(auftrag).filter((k) => !entfernt.includes(k)), 'pdfVorhanden', 'fruehesterBeginn', 'zahlungsplanText', 'aufwandText'].sort(),
  )
  assert.equal(auftragFuerTeam(festpreis({ pdfDatei: '' })).pdfVorhanden, false)
})

test('oeffentlicheAnsicht: genau die Felder aus V3, Beträge je Position, Berliner Datum, nichts Internes', () => {
  const ansicht = oeffentlicheAnsicht(festpreis())
  assert.deepEqual(
    Object.keys(ansicht).sort(),
    ['agbUrl', 'aufwandText', 'art', 'bestaetigtAm', 'datum', 'fertigBis', 'fruehbeginn', 'fruehesterBeginn', 'kundenart', 'nummer', 'positionen', 'schaetzungMinuten', 'startAm', 'status', 'stundensatzCents', 'summeCents', 'texte', 'titel', 'zahlungsplanText'].sort(),
  )
  assert.deepEqual(ansicht.positionen, [
    { beschreibung: 'Startseite', menge: 2, einzelpreisCents: 30000, summeCents: 60000 },
    { beschreibung: 'Kontaktformular', menge: 1, einzelpreisCents: 20000, summeCents: 20000 },
  ])
  assert.deepEqual(
    [ansicht.nummer, ansicht.datum, ansicht.titel, ansicht.art, ansicht.summeCents, ansicht.startAm, ansicht.kundenart, ansicht.status],
    ['AB-2026-0002', '2026-10-06', 'Website Bäckerei', 'festpreis', 80000, '2026-11-01', 'privat', 'versendet'],
  )
  assert.equal(ansicht.fruehesterBeginn, '2026-10-21')
  assert.deepEqual(ansicht.texte, { bestaetigung: BESTAETIGUNG_TEXT, fruehbeginn: FRUEHBEGINN_TEXT, kleinunternehmer: AB_TEXTE.kleinunternehmer })
  assert.equal(ansicht.texte.kleinunternehmer, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.')
  assert.equal(ansicht.agbUrl, '/rechtliches/agb-2026-09-30.pdf')
  const text = JSON.stringify(ansicht)
  for (const geheim of ['b'.repeat(64), 'c1/ab1.pdf', 'anna@example.invalid', 'Hauptstr. 1', 'kenso@webklar.com']) assert.ok(!text.includes(geheim), geheim)
})

test('oeffentlicheAnsicht: Unternehmen ohne Frühbeginn-Text, Datum nach Berliner Zeit, AGB-Adresse ohne gespeicherte Fassung', () => {
  const firma = oeffentlicheAnsicht(festpreis({ kunde: { ...KUNDE, kundenart: 'unternehmen' }, gesendetAm: '2026-10-06T22:30:00.000Z', agbFassung: '' }))
  assert.equal(firma.texte.fruehbeginn, null)
  assert.equal(firma.texte.kleinunternehmer, AB_TEXTE.kleinunternehmer)
  assert.equal(firma.fruehesterBeginn, null)
  assert.equal(firma.datum, '2026-10-07')
  assert.equal(firma.agbUrl, `/rechtliches/agb-${AGB_FASSUNG}.pdf`)
  const aufwandAnsicht = oeffentlicheAnsicht(aufwand())
  assert.deepEqual([aufwandAnsicht.positionen, aufwandAnsicht.stundensatzCents, aufwandAnsicht.schaetzungMinuten], [[], 10000, 600])
  assert.equal(aufwandAnsicht.aufwandText, aufwandText(aufwand()))
})
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAuftragSicht.test.js Expected: FAIL mit ERR_MODULE_NOT_FOUND für server/services/auftraege/auftragSicht.js.

  • Step 3: Implementieren

server/services/auftraege/auftragSicht.js (neu):

  • kundeSnapshot(customerDoc): aus dem Kundendokument (null/{} ergeben lauter ''): name ← name, firma ← companyName, strasse ← street, plz ← postalCode, ort ← city, land ← country (so gespeichert, kann '', DE oder Deutschland sein), email ← email, kundenart ← kundenart nur wenn in KUNDENARTEN, sonst '', kundennummer ← code. Alle Werte getrimmt und auf diese Längen gekürzt: name 120, firma 120, strasse 120, plz 20, ort 80, land 60, email 200, kundennummer 40 (gekürzte Summe plus JSON passt in 2000 Zeichen).

  • fruehesterBeginn(auftrag): nur wenn auftrag.kunde?.kundenart === 'privat', !auftrag.fruehbeginn und gesendetAm ein gültiger Zeitpunkt ist, sonst null: addDays(withdrawalDeadline(new Date(auftrag.gesendetAm)).lastDay, 1).

  • zahlungsplanText(auftrag): ziel = zahlungszielTage (ganze Zahl, sonst LIMITS.standardZahlungszielTage). Je zahlungsplan.art der Baustein aus AB_TEXTE.zahlungsplan (nach_abnahme({ ziel }), monatlich({ ziel }), frei({ text, ziel }), anzahlung({ prozent, anzahlung, rest, ziel })). Bei anzahlung: anzahlung = Math.round(summeCents * prozent / 100), rest = summeCents - anzahlung, beide mit euroDe; fehlt summeCents (nicht ganze Zahl) oder prozent (keine Zahl), gilt der Text von nach_abnahme. Ein fehlendes zahlungsplan gilt als nach_abnahme.

  • aufwandText(auftrag): null, wenn art !== 'aufwand'; sonst AB_TEXTE.aufwand({ satz: euroDe(stundensatzCents ?? STUNDENSATZ_STANDARD_CENTS), schaetzung }) mit schaetzung = Stunden als Text aus schaetzungMinuten (Math.round(minuten / 60 * 100) / 100, Dezimalkomma, ohne überflüssige Nullen: 600 → '10', 135 → '2,25', 90 → '1,5'), null wenn leer oder ≤ 0.

  • auftragFuerTeam(auftrag): flache Kopie ohne tokenHash, pdfDatei, bestaetigtPdfSha256, plus pdfVorhanden: Boolean(auftrag.pdfDatei), fruehesterBeginn, zahlungsplanText, aufwandText. Verändert das Argument nicht.

  • oeffentlicheAnsicht(auftrag): genau die 19 Felder aus V3 (AuftragOeffentlich): datum = berlinDate(new Date(gesendetAm)) oder null ohne gesendetAm; positionen mit zusätzlichem summeCents = menge * einzelpreisCents; kundenart = auftrag.kunde?.kundenart || ''; agbUrl = /rechtliches/agb-<agbFassung || AGB_FASSUNG>.pdf (die bei der AB gespeicherte Fassung, V3); texte = { bestaetigung: BESTAETIGUNG_TEXT, fruehbeginn: kundenart === 'privat' ? FRUEHBEGINN_TEXT : null, kleinunternehmer: AB_TEXTE.kleinunternehmer } (V3); fruehesterBeginn und die beiden Texte wie oben. Keine Kundendaten, kein Token, keine Pfade.

  • Step 4: Lauf, erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAuftragSicht.test.js Expected: PASS (neun Tests, fail 0).

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/auftragSicht.js test/auftraegeAuftragSicht.test.js && git commit -m "Aufträge: Sichten auf die Auftragsbestätigung (Team, öffentlich, Frühbeginn)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 11: PDF-Ablage

Files:

  • Create: server/services/auftraege/ablage.js
  • Test: test/auftraegeAblage.test.js

Interfaces:

  • Consumes: config.auftraege.uploadDir (Task 4); AuftragError (Task 2).

  • Produces (Übersicht V4): speicherePdf(kundenId, auftragId, buffer) → Promise<{ pdfDatei: '<kundenId>/<auftragId>.pdf', pdfSha256 }> (legt Verzeichnis 0700 und Datei 0600 an, überschreibt nie), lesePdf(pdfDatei) → Promise<Buffer> (nur ^[A-Za-z0-9_-]{1,64}/[A-Za-z0-9_-]{1,64}\.pdf$, sonst AuftragError(404, 'PDF nicht gefunden.')); zusätzlich entfernePdf(pdfDatei) → Promise<void> (Nachträge L0: räumt ein abgelegtes PDF weg, wenn danach etwas scheitert; L3 nutzt genau diesen Namen).

  • Step 1: Failing Test schreiben

// test/auftraegeAblage.test.js
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'

// Die Ablage liest ihren Ordner aus config.auftraege.uploadDir: AUFTRAEGE_UPLOAD_DIR muss vor dem ersten Import von config.js stehen.
// Die Ablage liegt in einem Unterordner der Wurzel, damit „Köder“-Dateien in der Wurzel einen Ausbruch aus der Ablage (../) sichtbar machen.
const PRAEFIX = path.join(os.tmpdir(), 'auftraege-ablage-')
const wurzel = mkdtempSync(PRAEFIX)
const basis = path.join(wurzel, 'ablage')
mkdirSync(basis, { mode: 0o700 })
process.env.AUFTRAEGE_UPLOAD_DIR = basis
after(() => {
  if (wurzel.startsWith(PRAEFIX)) rmSync(wurzel, { recursive: true, force: true })
})

const { speicherePdf, lesePdf, entfernePdf } = await import('../server/services/auftraege/ablage.js')
const { AuftragError } = await import('../server/services/auftraege/fehler.js')

const PDF = Buffer.from('%PDF-1.7\n% Auftragsbestätigung\n%%EOF\n')
const sha = (buffer) => createHash('sha256').update(buffer).digest('hex')
const nichtGefunden = (err) => err instanceof AuftragError && err.status === 404 && err.message === 'PDF nicht gefunden.'

test('speicherePdf legt <kundenId>/<auftragId>.pdf an: Prüfsumme, Rechte 0700 und 0600, Inhalt unverändert', async () => {
  const ergebnis = await speicherePdf('c1', 'ab1', PDF)
  assert.deepEqual(ergebnis, { pdfDatei: 'c1/ab1.pdf', pdfSha256: sha(PDF) })
  assert.match(ergebnis.pdfSha256, /^[0-9a-f]{64}$/)
  const datei = path.join(basis, 'c1', 'ab1.pdf')
  assert.deepEqual(readFileSync(datei), PDF)
  assert.equal(statSync(datei).mode & 0o777, 0o600)
  assert.equal(statSync(path.join(basis, 'c1')).mode & 0o777, 0o700)
  assert.deepEqual(await lesePdf('c1/ab1.pdf'), PDF)
})

test('speicherePdf überschreibt nie: die vorhandene Datei bleibt, der zweite Aufruf ist 409', async () => {
  await speicherePdf('c2', 'ab1', PDF)
  await assert.rejects(
    () => speicherePdf('c2', 'ab1', Buffer.from('%PDF-1.7\nanderer Inhalt\n')),
    (err) => err instanceof AuftragError && err.status === 409,
  )
  assert.deepEqual(readFileSync(path.join(basis, 'c2', 'ab1.pdf')), PDF)
  assert.equal((await speicherePdf('c3', 'ab1', PDF)).pdfDatei, 'c3/ab1.pdf', 'dieselbe AB-ID bei einem anderen Kunden ist eine andere Datei')
})

test('speicherePdf: ungültige Kennungen und Puffer sind Programmfehler und legen nichts an', async () => {
  const vorher = readdirSync(basis).sort()
  const kennungen = [['', 'ab1'], ['c1', ''], ['../x', 'ab1'], ['c1', '../ab1'], ['c1/x', 'ab1'], ['c1', 'a.b'], ['c1', 'a'.repeat(65)], [undefined, 'ab1'], ['c1', 5]]
  for (const [kunde, ab] of kennungen) await assert.rejects(() => speicherePdf(kunde, ab, PDF), TypeError, JSON.stringify([kunde, ab]))
  for (const buffer of [Buffer.alloc(0), 'text', undefined, null, new Uint8Array(3)]) {
    await assert.rejects(() => speicherePdf('c9', 'ab9', buffer), TypeError, String(buffer))
  }
  assert.deepEqual(readdirSync(basis).sort(), vorher)
})

test('lesePdf: Pfadtricks, fremde Zeichen und falsche Form sind 404; erlaubt sind 64 Zeichen aus A–Z, a–z, 0–9, _ und -', async () => {
  await speicherePdf('c4', 'ab1', PDF)
  writeFileSync(path.join(wurzel, 'geheim.pdf'), 'GEHEIM') // neben der Ablage: ein Ausbruch per ../ würde sie lesen
  const falsch = [
    'c4/../geheim.pdf', '../geheim.pdf', 'c4/ab1.pdf/../../geheim.pdf', '/c4/ab1.pdf', `${basis}/c4/ab1.pdf`, 'c4\\ab1.pdf',
    'c4/ab1.PDF', 'c4/ab1.pdf\0', 'c4/ab1.pdf.exe', 'c4/ab 1.pdf', 'c4/ab1', 'ab1.pdf', 'c4/x/ab1.pdf', '', ' ',
    `${'a'.repeat(65)}/ab1.pdf`, `c4/${'a'.repeat(65)}.pdf`, 'c4/ä.pdf', undefined, null, 42, {},
  ]
  for (const pdfDatei of falsch) await assert.rejects(() => lesePdf(pdfDatei), nichtGefunden, JSON.stringify(pdfDatei))
  await assert.rejects(() => lesePdf('c4/gibtsnicht.pdf'), nichtGefunden)
  const lang = `${'A'.repeat(32)}_${'b'.repeat(31)}`
  await speicherePdf('K_1-x', lang, PDF)
  assert.deepEqual(await lesePdf(`K_1-x/${lang}.pdf`), PDF)
})

test('entfernePdf entfernt eine gespeicherte Datei und prüft den Pfad wie lesePdf; danach lässt sich dieselbe AB erneut speichern', async () => {
  await speicherePdf('c5', 'ab1', PDF)
  await entfernePdf('c5/ab1.pdf')
  assert.equal(existsSync(path.join(basis, 'c5', 'ab1.pdf')), false)
  await entfernePdf('c5/ab1.pdf')
  await assert.rejects(() => lesePdf('c5/ab1.pdf'), nichtGefunden)
  writeFileSync(path.join(wurzel, 'geheim2.pdf'), 'x')
  for (const pfad of ['../geheim2.pdf', 'c5/../geheim2.pdf']) await assert.rejects(() => entfernePdf(pfad), nichtGefunden, pfad)
  assert.ok(existsSync(path.join(wurzel, 'geheim2.pdf')))
  assert.equal((await speicherePdf('c5', 'ab1', PDF)).pdfDatei, 'c5/ab1.pdf')
})
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAblage.test.js Expected: FAIL mit ERR_MODULE_NOT_FOUND für server/services/auftraege/ablage.js.

  • Step 3: Implementieren

server/services/auftraege/ablage.js (neu, Verzeichnis config.auftraege.uploadDir wird bei jedem Aufruf gelesen):

  • Kennungen müssen Texte im Format ^[A-Za-z0-9_-]{1,64}$ sein (eine Zahl oder undefined ist ungültig, auch wenn ihre Textform passen würde); Dateiname immer <kundenId>/<auftragId>.pdf; Pfadformat für Lesen und Löschen ^[A-Za-z0-9_-]{1,64}/[A-Za-z0-9_-]{1,64}\.pdf$ (kein Punkt außer vor pdf, damit kein .. möglich ist; typeof pdfDatei !== 'string' passt nie).

  • speicherePdf(kundenId, auftragId, buffer): prüft zuerst beide Kennungen und Buffer.isBuffer(buffer) && buffer.length > 0 (TypeError, nichts wird angelegt). Dann mkdir(<uploadDir>/<kundenId>, { recursive: true, mode: 0o700 }), Datei mit writeFile(pfad, buffer, { flag: 'wx', mode: 0o600 }) (existiert sie schon: AuftragError(409, 'Das PDF dieser Auftragsbestätigung ist schon gespeichert.'), die vorhandene Datei bleibt unberührt; scheitert das Schreiben nach dem Anlegen, die Teildatei entfernen und den Fehler weiterreichen). Rückgabe { pdfDatei, pdfSha256 } mit pdfSha256 = SHA-256 des Puffers als Kleinbuchstaben-Hex.

  • lesePdf(pdfDatei): Pfadformat prüfen, sonst AuftragError(404, 'PDF nicht gefunden.'); Datei lesen; ENOENT → dieselbe 404; andere Fehler weiterreichen.

  • entfernePdf(pdfDatei): Pfadformat wie lesePdf (sonst dieselbe 404); unlink, ENOENT ist folgenlos. Das Kundenverzeichnis bleibt stehen.

  • Step 4: Lauf, erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAblage.test.js Expected: PASS (fünf Tests, fail 0).

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/ablage.js test/auftraegeAblage.test.js && git commit -m "Aufträge: PDF-Ablage mit Pfadprüfung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 12: Steuernummer in den Finanz-Einstellungen

Files:

  • Modify: server/services/finance/settings.js, test/financeSettings.test.js

Interfaces:

  • Consumes: SettingsError, SETTINGS_RULES, DEFAULT_SETTINGS in settings.js (bestehend); schemaKeys('financeSettings') mit steuernummer (Task 3).

  • Produces: Einstellung steuernummer (Vorgabe '', getrimmt, höchstens 40 Zeichen; erlaubt sind Buchstaben A–Z und a–z, Ziffern, Leerzeichen, /, - und ., also auch eine USt-IdNr. „DE…“; '' löscht den Wert; sonst SettingsError(400, 'Steuernummer ungültig.')) in getSettings(), patchSettings() und damit in GET/PATCH /api/admin/finanzen/einstellungen.

  • Step 1: Failing Test schreiben

In test/financeSettings.test.js drei Änderungen (die übrigen Tests der Datei bleiben unverändert):

  1. Zeile mit const DEFAULTS = ... (kurz vor den Abschnitt „Zugriff“): steuernummer: '' ergänzen:
const DEFAULTS = { minBufferCents: 0, taxReservePercent: 30, previousYearRevenueCents: null, estPrepaymentCents: 0, bankDetailsText: '', steuernummer: '' }
  1. Den Titel des Tests GET /einstellungen: nur die fuenf Felder, nie Appwrite-Metafelder in ... nur die sechs Felder, nie Appwrite-Metafelder ändern.

  2. Direkt nach dem Test PATCH: der Bankverbindungstext wird ohne Rand-Leerraum gespeichert einfügen:

test('PATCH steuernummer: Vorgabe leer, getrimmt gespeichert; Buchstaben, Ziffern, Leerzeichen, / - und . bis 40 Zeichen, sonst 400', async () => {
  assert.equal((await getSettings()).einstellungen.steuernummer, '')
  const getrimmt = await patchSettings({ steuernummer: '  12/345/67890  ' })
  assert.equal(getrimmt.status, 200, JSON.stringify(getrimmt.body))
  assert.equal(getrimmt.body.einstellungen.steuernummer, '12/345/67890')
  assert.equal(storedSettings().steuernummer, '12/345/67890')
  assert.equal((await getSettings()).einstellungen.steuernummer, '12/345/67890')
  for (const wert of ['DE123456789', 'de 123.456.789', '12 345 67890', '12-345-678', 'A'.repeat(40), `  ${'1'.repeat(40)}  `, '   ', '']) {
    const res = await patchSettings({ steuernummer: wert })
    assert.equal(res.status, 200, JSON.stringify(wert))
    assert.equal(res.body.einstellungen.steuernummer, wert.trim())
    assert.equal(storedSettings().steuernummer, wert.trim())
  }
  assert.equal((await patchSettings({ steuernummer: 'DE123456789' })).status, 200)
  for (const wert of ['<script>', '1'.repeat(41), 'A'.repeat(41), '123_456', '12;34', 'Müller 12', '12\n34', '12\t34', null, 123, ['1'], true]) {
    const res = await patchSettings({ steuernummer: wert })
    assert.equal(res.status, 400, JSON.stringify(wert))
    assert.equal(res.body.error, 'Steuernummer ungültig.')
  }
  assert.equal(storedSettings().steuernummer, 'DE123456789', 'ungültige Werte ändern nichts')
})
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/financeSettings.test.js Expected: FAIL (u. a. der neue Test und der Vergleich mit DEFAULTS: steuernummer fehlt in den Antworten, PATCH meldet „Unbekanntes Feld: steuernummer.“).

  • Step 3: Implementieren

In server/services/finance/settings.js:

  • Konstante const MAX_STEUERNUMMER = 40 neben den übrigen MAX_....

  • DEFAULT_SETTINGS: steuernummer: '' als letzten Eintrag.

  • SETTINGS_RULES.steuernummer: ein Text; nach trim() höchstens MAX_STEUERNUMMER Zeichen und passend zu /^[A-Za-z0-9\/ .-]*$/ (Buchstaben nur A–Z und a–z, keine Umlaute; als Leerzeichen nur U+0020, kein Tabulator oder Zeilenumbruch), sonst throw new SettingsError(400, 'Steuernummer ungültig.') (genau diese Meldung, ohne Feldnamen); kein Text (auch null) ebenso. Rückgabe: der getrimmte Text ('' ist erlaubt und löscht den Wert).

  • Kommentar von patchSettings um steuernummer ergänzen (höchstens 40 Zeichen aus Buchstaben, Ziffern, Leerzeichen, /, - und ., getrimmt gespeichert; '' löscht).

  • Step 4: Lauf, erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/financeSettings.test.js test/financeOverview.test.js test/financeStripeWrite.test.js Expected: PASS (fail 0); financeOverview und financeStripeWrite lesen dieselben Einstellungen und belegen, dass das neue Feld nichts verschiebt.

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/finance/settings.js test/financeSettings.test.js && git commit -m "Finanzen: Einstellung Steuernummer für die Auftragsbestätigung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 13: Router-Gerüste und Einbindung

Files:

  • Create: server/routes/admin/projektkonten.js, server/routes/admin/auftraege.js, server/routes/team/arbeitsblaetter.js, server/routes/auftrag.js, server/routes/auftraegeKunde.js
  • Modify: server/index.js, server/routes/team/index.js
  • Test: test/auftraegeGeruest.test.js

Interfaces:

  • Consumes: requireAppwriteStaff aus server/middleware/appwriteStaff.js; requireSession aus server/middleware/session.js.

  • Produces: fünf leere Express-Router (jeweils export default router) mit dem Zugangsschutz, den die füllenden Stränge brauchen: projektkonten.js und auftraege.js (admin) mit router.use(requireAppwriteStaff) (L1 bzw. L3 füllen), team/arbeitsblaetter.js ohne eigenen Schutz (hängt hinter requireStaffJwt, L1 füllt), auftrag.js ohne Schutz (öffentlich per Token, L5 füllt samt Ratenbegrenzung), auftraegeKunde.js mit router.use(requireSession) (L5 füllt). Einbindung wie Übersicht V5: Mounts /api/admin/finanzen/projektkonten und /api/admin/finanzen/auftraege vor /api/admin/finanzen, /api/auftrag, /api/auftraege, /api/team/arbeitsblaetter.

  • Step 1: Failing Test schreiben

// test/auftraegeGeruest.test.js
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'

// Gerüste der Router (L0): geprüft wird nur, was auch nach dem Füllen durch L1, L3 und L5 gilt: Zugangsschutz, unbekannte
// Tiefenpfade (…/a/b/c/d) und die Reihenfolge der Einbindung im Quelltext.
const aw = await startAppwriteMock()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { startTestApp, rawGet } = await import('./support/testApp.js')
const { seedFixtures, customerCookie } = await import('./support/fixtures.js')
const { default: projektkonten } = await import('../server/routes/admin/projektkonten.js')
const { default: auftraegeAdmin } = await import('../server/routes/admin/auftraege.js')
const { default: auftragOeffentlich } = await import('../server/routes/auftrag.js')
const { default: auftraegeKunde } = await import('../server/routes/auftraegeKunde.js')
const { default: teamRoutes } = await import('../server/routes/team/index.js')

const app = await startTestApp({
  '/api/admin/finanzen/projektkonten': projektkonten,
  '/api/admin/finanzen/auftraege': auftraegeAdmin,
  '/api/team': teamRoutes,
  '/api/auftrag': auftragOeffentlich,
  '/api/auftraege': auftraegeKunde,
})
after(() => Promise.all([app.close(), aw.close()]))
seedFixtures(aw)

const bearer = (jwt) => ({ headers: { authorization: `Bearer ${jwt}` } })
const quelle = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')

test('Admin-Router: ohne JWT 401, Mitarbeiter ohne Admin-Label und Kunden 403, Admin kommt durch', async () => {
  for (const pfad of ['/api/admin/finanzen/projektkonten/a/b/c/d', '/api/admin/finanzen/auftraege/a/b/c/d']) {
    assert.equal((await rawGet(pfad)).status, 401, pfad)
    assert.equal((await rawGet(pfad, bearer('jwt-staff'))).status, 403, `${pfad} jwt-staff`)
    assert.equal((await rawGet(pfad, bearer('jwt-kunde'))).status, 403, `${pfad} jwt-kunde`)
    assert.equal((await rawGet(pfad, bearer('jwt-admin'))).status, 404, `${pfad} jwt-admin: unbekannter Pfad`)
  }
})

test('Team-Router: /arbeitsblaetter liegt hinter requireStaffJwt (jeder Mitarbeiter darf, Kunden nicht)', async () => {
  const pfad = '/api/team/arbeitsblaetter/a/b/c/d'
  assert.equal((await rawGet(pfad)).status, 401)
  assert.equal((await rawGet(pfad, bearer('jwt-kunde'))).status, 403)
  assert.equal((await rawGet(pfad, bearer('jwt-staff'))).status, 404, 'Mitarbeiter ohne Admin-Label kommen durch')
  assert.equal((await rawGet('/api/team/arbeitsblaetter/abgerechnet?ticketId=t1')).status, 401)
})

test('Kunden-Router verlangt eine Anmeldung, der öffentliche Router nicht', async () => {
  const ohne = await rawGet('/api/auftraege/a/b/c/d')
  assert.equal(ohne.status, 401)
  assert.deepEqual(ohne.body, { error: 'Nicht angemeldet' })
  assert.equal((await rawGet('/api/auftraege/a/b/c/d', { cookie: customerCookie('c1') })).status, 404)
  for (const pfad of ['/api/auftrag/a/b/c/d', '/api/auftrag/a/b/c/d/pdf']) assert.equal((await rawGet(pfad)).status, 404, pfad)
})

test('server/index.js: Admin-Router vor dem Finanzen-Router, Kunden- und öffentlicher Router hinter Wächter und Parser', () => {
  const index = quelle('server/index.js')
  const stelle = (zeile) => {
    const pos = index.indexOf(zeile)
    assert.ok(pos >= 0, `${zeile} fehlt`)
    assert.equal(index.indexOf(zeile, pos + 1), -1, `${zeile} steht mehrfach`)
    return pos
  }
  const projekt = stelle("app.use('/api/admin/finanzen/projektkonten', adminProjektkontenRoutes)")
  const auftraege = stelle("app.use('/api/admin/finanzen/auftraege', adminAuftraegeRoutes)")
  const finanzen = stelle("app.use('/api/admin/finanzen', adminFinanzenRoutes)")
  assert.ok(projekt < finanzen && auftraege < finanzen, 'sonst prüft der Finanzen-Router zuerst')
  const waechter = stelle('app.use(kundenansichtGuard)')
  const parser = stelle("app.use(express.json({ limit: '2mb'")
  const oeffentlich = stelle("app.use('/api/auftrag', publicAuftragRoutes)")
  const kunde = stelle("app.use('/api/auftraege', kundeAuftraegeRoutes)")
  assert.ok(waechter < oeffentlich && waechter < kunde && parser < oeffentlich && parser < kunde)
  for (const [name, datei] of [
    ['adminProjektkontenRoutes', 'admin/projektkonten'],
    ['adminAuftraegeRoutes', 'admin/auftraege'],
    ['publicAuftragRoutes', 'auftrag'],
    ['kundeAuftraegeRoutes', 'auftraegeKunde'],
  ]) {
    assert.ok(index.includes(`import ${name} from './routes/${datei}.js'`), name)
  }
})

test('server/routes/team/index.js: /arbeitsblaetter hängt hinter requireStaffJwt', () => {
  const team = quelle('server/routes/team/index.js')
  const jwt = team.indexOf('router.use(requireStaffJwt)')
  const mount = team.indexOf("router.use('/arbeitsblaetter', arbeitsblaetterTeamRoutes)")
  assert.ok(jwt >= 0 && mount > jwt)
  assert.equal(team.indexOf("router.use('/arbeitsblaetter'", mount + 1), -1)
  assert.ok(team.includes("import arbeitsblaetterTeamRoutes from './arbeitsblaetter.js'"))
})
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeGeruest.test.js Expected: FAIL mit ERR_MODULE_NOT_FOUND für server/routes/admin/projektkonten.js.

  • Step 3: Implementieren

Die fünf Router-Dateien (neu), je import { Router } from 'express', const router = Router() und export default router, mit Kopfkommentar „Gerüst von Strang L0; <wer füllt, welche Routen>“:

  • server/routes/admin/projektkonten.js: router.use(requireAppwriteStaff) (Import aus ../../middleware/appwriteStaff.js); Kommentar: Mount /api/admin/finanzen/projektkonten, L1 ergänzt GET / (?kunde=) und GET /:projektId.
  • server/routes/admin/auftraege.js: router.use(requireAppwriteStaff); Mount /api/admin/finanzen/auftraege, L3 füllt (Liste, Anlegen, Ändern, PDF, Senden, Erinnern, Neue Fassung, Stornieren).
  • server/routes/team/arbeitsblaetter.js: ohne Schutz (hängt in team/index.js hinter requireStaffJwt); L1 ergänzt GET /abgerechnet.
  • server/routes/auftrag.js: ohne Schutz (öffentlich, Token in der Adresse); Kommentar: kein Sitzungs- oder Wächter-Eintrag, L5 ergänzt Ratenbegrenzung im Arbeitsspeicher und GET /:token, GET /:token/pdf, POST /:token/bestaetigen.
  • server/routes/auftraegeKunde.js: router.use(requireSession) (Import aus ../middleware/session.js; fail-closed für jede später ergänzte Route); L5 ergänzt GET /, GET /:id/pdf, POST /:id/bestaetigen (das Bestätigen sperrt der Wächter der Kundenansicht von selbst, es kommt kein Eintrag in KUNDENANSICHT_ALLOWED).

Einbindung:

  • server/routes/team/index.js: import arbeitsblaetterTeamRoutes from './arbeitsblaetter.js' (alphabetisch vor customersRoutes) und am Ende nach dem /shop-Mount, mit Kommentar „Abgerechnete Arbeitsblätter (Abzeichen am Ticket, alle Mitarbeiter)“: router.use('/arbeitsblaetter', arbeitsblaetterTeamRoutes).

  • server/index.js, Importe: nach import zeitenRoutes from './routes/zeiten.js' die Zeilen import publicAuftragRoutes from './routes/auftrag.js' und import kundeAuftraegeRoutes from './routes/auftraegeKunde.js'; nach import adminFinanzenRoutes from './routes/admin/finanzen.js' die Zeilen import adminProjektkontenRoutes from './routes/admin/projektkonten.js' und import adminAuftraegeRoutes from './routes/admin/auftraege.js'.

  • server/index.js, Mounts: direkt nach app.use('/api/zeiten', zeitenRoutes)

    // Auftragsbestaetigung: oeffentliche Bestaetigung per Token (ohne Sitzung) und die Auftraege des angemeldeten Kunden
    app.use('/api/auftrag', publicAuftragRoutes)
    app.use('/api/auftraege', kundeAuftraegeRoutes)
    

    und direkt vor app.use('/api/admin/finanzen', adminFinanzenRoutes)

    // Projektkonten und Auftraege (Admin): vor dem Finanzen-Router, dessen requireAppwriteStaff sonst jede Anfrage darunter zuerst prueft
    app.use('/api/admin/finanzen/projektkonten', adminProjektkontenRoutes)
    app.use('/api/admin/finanzen/auftraege', adminAuftraegeRoutes)
    
  • Step 4: Lauf, erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/auftraegeGeruest.test.js test/teamRoutes.test.js test/kundenansichtGuard.test.js Expected: PASS (fail 0); Zusätzlich cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && node --check server/index.js ohne Ausgabe (nur Syntaxprüfung, der Server startet nicht).

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/routes/admin/projektkonten.js server/routes/admin/auftraege.js server/routes/team/arbeitsblaetter.js server/routes/auftrag.js server/routes/auftraegeKunde.js server/index.js server/routes/team/index.js test/auftraegeGeruest.test.js && git commit -m "Aufträge: Router-Gerüste und Einbindung in server/index.js und Team-Router" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 14: Selbstprüfung

Files: keine Änderungen vorgesehen (nur Korrekturen, falls ein Schritt etwas findet).

  • Step 1: Nur eigene Dateien geändert, Arbeitsbaum sauber
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && BASIS=$(git rev-parse "$(git log --format=%H --grep='^Aufträge: pdfkit und fontkit als Abhängigkeiten' -1)^") && echo "Basis (Stand vor dem ersten L0-Commit): $BASIS" && git status --short && git diff --name-only $BASIS..HEAD | LC_ALL=C sort

Expected: git status --short zeigt höchstens Plan- und Spezifikationsdateien unter docs/superpowers/ (unversioniert oder vom Orchestrator geändert; sie gehören nicht zu L0) und sonst nichts. Die Basis ist der Stand unmittelbar vor dem ersten L0-Commit, wie er beim Start war (nicht fest verdrahtet, weil der Orchestrator zuvor Pläne und Spezifikation committet). Die Dateiliste ist genau diese 36 Dateien (in der Reihenfolge von LC_ALL=C sort):

.gitignore
package-lock.json
package.json
scripts/auftraege-setup.mjs
server/config.js
server/index.js
server/routes/admin/auftraege.js
server/routes/admin/projektkonten.js
server/routes/auftraegeKunde.js
server/routes/auftrag.js
server/routes/team/arbeitsblaetter.js
server/routes/team/index.js
server/services/auftraege/ablage.js
server/services/auftraege/arbeitsblaetter.js
server/services/auftraege/auftragSicht.js
server/services/auftraege/auftragStore.js
server/services/auftraege/fehler.js
server/services/auftraege/konstanten.js
server/services/auftraege/projektRechnungenStore.js
server/services/auftraege/schalter.js
server/services/auftraege/schema.js
server/services/auftraege/texte.js
server/services/finance/settings.js
test/auftraegeAblage.test.js
test/auftraegeArbeitsblaetter.test.js
test/auftraegeAuftragSicht.test.js
test/auftraegeAuftragStore.test.js
test/auftraegeGeruest.test.js
test/auftraegeKonstanten.test.js
test/auftraegeProjektRechnungenStore.test.js
test/auftraegeSchalter.test.js
test/auftraegeSchema.test.js
test/auftraegeSetup.test.js
test/auftraegeTextBausteine.test.js
test/financeSettings.test.js
test/support/schema.js

Weicht die Liste ab (fremde Datei geändert, Datei fehlt), korrigieren und dazu committen; Dateien unter docs/superpowers/ gehören dem Orchestrator und zählen nicht. Zusätzlich: git ls-files | command grep -a -c '^node_modules/' gibt 0 aus.

  • Step 2: Alle eigenen Tests und die berührten bestehenden Tests
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/auftraegeKonstanten.test.js test/auftraegeSchema.test.js test/auftraegeSchalter.test.js test/auftraegeSetup.test.js test/auftraegeTextBausteine.test.js test/auftraegeAuftragStore.test.js test/auftraegeProjektRechnungenStore.test.js test/auftraegeArbeitsblaetter.test.js test/auftraegeAuftragSicht.test.js test/auftraegeAblage.test.js test/auftraegeGeruest.test.js test/financeSettings.test.js test/financeOverview.test.js test/financeStripeWrite.test.js test/financeSetup.test.js test/zeitenSetup.test.js test/zeitenSchalter.test.js test/teamRoutes.test.js test/kundenansichtGuard.test.js

Expected: fail 0, Exit-Code 0. Die volle Suite (npm test) läuft hier nicht (nur Z1). Zusätzlich Syntaxprüfung ohne Start: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && node --check scripts/auftraege-setup.mjs && node --check server/index.js ohne Ausgabe.

  • Step 3: Vertragsliste des Strangs abhaken

Jeder Punkt hat Datei und Beleg; ein offener Punkt ist ein Fehler dieses Strangs:

  • V1 Schema und Zusatzfelder, config.collections, config.auftraege: schema.js, config.js (auftraegeSchema, auftraegeSchalter, auftraegeSetup).

  • V2 Konstanten samt sperreAuftrag (jede Sperre ein zusammengesetzter Schlüssel), AuftragError, sendeFehler: konstanten.js, fehler.js (auftraegeKonstanten).

  • V3 Datenformen Auftrag (43 Felder), KundeSnapshot, ProjektRechnung, Arbeitsblatt, AuftragTeam, AuftragOeffentlich: Tests der Tasks 7 bis 10 nageln die Schlüssellisten fest.

  • V4 L0: auftraegeAktiv, docToAuftrag, auftragToDoc, getAuftrag, listAuftraege, createAuftrag, updateAuftrag, deleteAuftrag, findAuftragByTokenHash, findAuftragByNummer, getProjektRechnung, listProjektRechnungen, createProjektRechnung, updateProjektRechnung, umhaengen, kundeSnapshot, fruehesterBeginn, zahlungsplanText, aufwandText, auftragFuerTeam, oeffentlicheAnsicht, ladeArbeitsblaetter, ladeArbeitsblaetterNachIds, abrechenbar, abgerechneteArbeitsblaetter, offeneArbeitsblaetter, zeitquelleFuerProjekt, speicherePdf, lesePdf (und der Zusatz entfernePdf).

  • V4 Finanzen steuernummer (mit Buchstaben): settings.js (Task 12) samt Zeile „L0 GET/PATCH …/einstellungen zusätzlich steuernummer“ in V5.

  • V5 Einbindung der fünf Router: Task 13.

  • V7 Texte: texte.js (Task 6, vorläufig; termine mit privat); texte.kleinunternehmer und agbUrl in oeffentlicheAnsicht (Task 10, V3).

  • Setup-Skript: scripts/auftraege-setup.mjs (Task 5).

  • Nachträge für L0: pdfkit, fontkit und .gitignore (node_modules ohne Schrägstrich) in Task 1 (import('pdfkit') und import('fontkit') liefern function); entfernePdf (Task 11); AUFTRAEGE_DATETIMES und schemaKeys für financeSettings (Task 3).

  • Step 4: Ergebnis melden

An den Orchestrator: letzter Commit-Hash (git log -1 --format=%h), Anzahl der L0-Commits (git rev-list --count <Basis>..HEAD mit der Basis aus Step 1, erwartet 13), die Laufzeit-Zusammenfassung von Step 2 und den Hinweis, dass alle „Vertragsfragen“ unten entschieden und umgesetzt sind. Lane-Worktrees entstehen erst danach (git worktree add … -b auftraege/lN-… feature/auftraege, plus Symlink auf node_modules).

  • Step 5: Commit (nur bei Korrekturen)

Hat einer der Schritte 1 bis 3 eine Korrektur nötig gemacht, die betroffenen Dateien einzeln mit git add <datei> … und einer deutschen Nachricht mit der Schlusszeile Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> committieren und Schritt 2 wiederholen. Sonst entsteht kein Commit.


Vertragsfragen

Alle Vertragsfragen, auf die dieser Plan beim Schreiben gestoßen ist (eigene Funde und Wünsche der Pläne von L1, L3, L4, L5, L6 und Z1), sind entschieden. Die Entscheidungen stehen in der Übersicht (V1 bis V7 und „Nachträge nach der Planrunde“) und sind in diesem Plan umgesetzt; wo die Übersicht und ein früherer Stand dieses Plans auseinanderliefen, gilt die Übersicht. Offen bleibt nichts. Hinter jedem Punkt steht der Task, der ihn umsetzt.

  1. Rechnungs-ID-Format der projektRechnungen (die frühere Regex des V1-Kommentars lehnte in_test_<n> des Stripe-Nachbaus ab): entschieden (siehe Übersicht, V1 und Nachträge): /^in_\w{1,33}$/. Task 8.
  2. sperreAuftrag und die Sperrschlüssel: entschieden (siehe Übersicht, V2 und Nachträge „Sperren“): SPERRE_NUMMER = ['auftraege-nummer'], sperreRechnung(kundenId) = ['auftraege-rechnung:<kundenId>'], sperreAuftrag(id) = ['auftraege-ab:<id>'], je Sperre genau ein zusammengesetzter Schlüssel, weil withLocks jedes Listenelement einzeln sperrt. Task 2.
  3. entfernePdf(pdfDatei) in ablage.js (räumt ein abgelegtes PDF weg, wenn danach etwas scheitert): entschieden (siehe Übersicht, Nachträge L0; L3 nutzt genau diesen Namen). Task 11.
  4. AUFTRAEGE_DATETIMES und schemaKeys für auftraege, projektRechnungen und financeSettings: entschieden (siehe Übersicht, Nachträge L0). Task 3.
  5. Parametertypen der Textfunktionen (V7) und termine mit privat: entschieden (siehe Übersicht, V7 und Nachträge L4, L7): datum, start, fertig, fassung als TT.MM.JJJJ; weg als deutsches Wort aus AUFTRAG_WEGE oder leer; satz, anzahlung, rest mit euroDe; prozent, ziel als Zahl; schaetzung als Stundentext oder null; termine liefert null, wenn weder Termine noch privat vorliegen, und hängt bei privat: true den Satz „Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.“ an (vorläufig, L7 formuliert endgültig); jede Zeile von WIDERRUFSFORMULAR.zeilen trägt ihr führendes „–“ selbst. Task 6.
  6. agbUrl und texte.kleinunternehmer der öffentlichen Ansicht: entschieden (siehe Übersicht, V3): agbUrl nach auftrag.agbFassung || AGB_FASSUNG, texte mit bestaetigung, fruehbeginn und kleinunternehmer (AB_TEXTE.kleinunternehmer). Task 10.
  7. nummer unique (Spec § 5) gegen den normalen Index idx_nummer: entschieden (siehe Übersicht, V1: nicht eindeutig, weil Entwürfe nummer: '' haben; die Eindeutigkeit sichern SPERRE_NUMMER und vergibNummer). Task 3.
  8. fontkit als direkte Abhängigkeit (L4): entschieden (siehe Übersicht, Nachträge „Zusätzliche Dateien“, L0): Version wie die fontkit-Angabe in pdfkit. Task 1.
  9. .gitignore und der Symlink node_modules (L4): entschieden (siehe Übersicht, Nachträge „Zusätzliche Dateien“, L0): Eintrag node_modules ohne Schrägstrich. Task 1.
  10. Steuernummer mit Buchstaben (USt-IdNr. „DE…“): entschieden (siehe Übersicht, V4): Buchstaben, Ziffern, Leerzeichen, /, - und ., höchstens 40 Zeichen, '' löscht. Task 12.
  11. Stripe-Metadaten beim Neufassen (Spec § 2): entschieden (siehe Übersicht, Nachträge L3): sie wandern nicht mit, nur projektRechnungen.auftragId (umhaengen). Betrifft L0 nicht weiter.

Festlegungen dieses Plans, die V4 offen ließ und auf die sich die Pläne der anderen Stränge stützen:

  • Listenfunktionen: listAuftraege neueste zuerst, listProjektRechnungen älteste zuerst (nach erstelltAm), beide ohne Begrenzung auf 25. Ein Filterschlüssel mit undefined gilt als nicht angegeben; ein angegebener leerer oder ungültiger Wert ist ein TypeError, nie „alle“.
  • createAuftrag füllt fehlende Felder mit den Leerwerten aus V3 (Rückgabe gleich getAuftrag), updateAuftrag nimmt Teil-Patches und lässt andere Felder unberührt. createProjektRechnung setzt erstelltAm immer selbst (ein mitgegebener Wert wird ignoriert).
  • Fehlerarten der Speicher: unbekannte ID bei updateAuftrag und updateProjektRechnung → AuftragError(404); doppelte Rechnungs-ID → AuftragError(409); Programmfehler (falsche Typen, ungültige Kennungen) → TypeError; zu langer Text → AuftragError(400).
  • Zeitpunkte aus den Speichern sind ISO-UTC mit Z (toISOString()), null wenn nicht gesetzt; datum in AuftragOeffentlich ist YYYY-MM-DD.
  • abgerechneteArbeitsblaetter: bei einem Entwurf ist rechnungsnummer ''; die Route GET /api/team/arbeitsblaetter/abgerechnet (L1) zeigt dafür 'Entwurf'.
  • zeitquelleFuerProjekt filtert die Aufträge nicht nach Projekt; der Aufrufer übergibt nur die Aufträge dieses Projekts.
  • kundeSnapshot kürzt Felder (Summe passt in kundeJson); land wird unverändert übernommen und kann '', DE oder Deutschland sein (L4 entscheidet, wann es gedruckt wird).
  • auftraegeKunde.js trägt router.use(requireSession), die beiden Admin-Gerüste router.use(requireAppwriteStaff); schemaKeys('customers') bleibt ohne Schema (Tests mit customers starten den Appwrite-Nachbau ohne Schema für diese Collection).