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

158 KiB
Raw Blame History

Bestätigung und Mails – Umsetzungsplan (Strang L5)

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: Ein Kunde bestätigt eine Auftragsbestätigung (AB) per Link ohne Anmeldung oder im Portal; die Bestätigung wird einmalig protokolliert und löst K32 (Kunde) und T22 (Team) aus. K31 verschickt die AB mit PDF und AGB (der Bestätigungslink geht nur an den Kunden, das Team bekommt eine Kopie ohne Link); eine Privatperson kann einen Projektauftrag über die Widerrufsseite widerrufen.

Architecture: Drei neue Bausteine in server/services/auftraege/ (token.js, mails.js, bestaetigung.js), zwei gefüllte Router-Gerüste aus L0 (server/routes/auftrag.js öffentlich per Token, server/routes/auftraegeKunde.js für angemeldete Kunden) und eine Erweiterung der öffentlichen Widerrufsroute in server/routes/abo.js um order = 'projekt' (mit je einem neuen Block projekt in K04 und T09). Bestätigen und Widerrufen laufen unter der Sperre sperreAuftrag(id) aus L0 und lesen den Stand innerhalb der Sperre neu; die Mails folgen erst nach der Freigabe. Die Routen sind dünn (Token prüfen, Schalter, Status, Antwort).

Tech Stack: Node 20.19 (Container) und 24 (Host), Express 4, ES-Module, nodemailer über sendCustomerMail/sendAdminNotice mit HTML-Vorlagen (renderMail), Tests mit node --test und den Nachbauten aus test/support/.

Spec: docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md (Abschnitte 2 „Versand“, „Bestätigung“, „Widerruf“, 6, 7, 9). Übersicht/Verträge: docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md (V2, V3, V4 „L5“, V5, V6 und der Abschnitt „Nachträge nach der Planrunde“ gelten wörtlich). Beide Dateien liegen im Basis-Worktree /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege/ (die Spec ist committet, Pläne sind dort ggf. unversioniert); der Lane-Worktree ab Task 1 enthält sie evtl. nicht, dann von dort lesen.

Global Constraints

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

  • Nur die Dateien aus „Dateien“ ändern. public/widerrufen.html, server/index.js, server/services/mailer.js, test/support/* und kundenansichtGuard.js bleiben unberührt; K04 und T09 bekommen nur den Block projekt (Task 11).
  • token.js importiert nur node:crypto und ./konstanten.js, nie server/config.js (die Testhilfe lädt es vor der Testumgebung).
  • Tests nach dem Rezept „kundenportal-testing“: Nachbauten starten, useTestEnv(), danach alle Server-Module dynamisch importieren. AUFTRAEGE_ENABLED=on und AUFTRAEGE_UPLOAD_DIR (Temp-Verzeichnis) werden vor dem ersten Import von config.js gesetzt; den Schalter im Test ändern über config.auftraege.mode und config.auftraege.pilotEmails; die Uhr der öffentlichen Routen über setzeUhrFuerTests; verschiedene IP-Adressen über den Kopf x-forwarded-for (die Test-App setzt trust proxy auf 1).
  • Schreibweise im Code: nie ein Unicode-Escape mit genau vier Hexziffern (das Schreibwerkzeug macht daraus das Zeichen selbst), stattdessen \u{a0} mit dem Flag u oder String.fromCodePoint.
  • Mailtexte der Kunden in Sie-Form. In den Vorlagen keine HTML-Entities außer &rarr;, &nbsp; und &#10003; (die Textfassung kennt nur diese); „ “ – € § als echte Zeichen. Beträge über euroDe, Tage über berlinDate + formatDe, Uhrzeiten in Berliner Zeit als TT.MM.JJJJ, HH:MM.
  • Öffentliche Routen antworten nur mit den festen Texten aus V5 (404, 410, 429) und eigenen deutschen Texten, nie mit Rohtexten von Fehlern; alle Antworten Cache-Control: no-store. Token und IP-Adressen stehen nie in Datenbank oder Protokoll (Token nur als Hash).
  • Der Bestätigungslink steht nur in der Mail an den Kunden: K31 geht ohne BCC (bccTeam: false) an den Kunden; das Team bekommt über sendAdminNotice eine Kopie aus derselben Vorlage mit dem Block teamkopie, ohne Knopf, ohne Link, ohne Token und ohne Anhänge. Der Link hat die Form <config.publicBaseUrl>/auftrag.html#t=<token> (Fragment, nie ?t=).
  • Bestätigen und Widerrufen: Sperre sperreAuftrag(id) aus konstanten.js (V2: ein zusammengesetzter Schlüssel, withLocks), Stand innerhalb der Sperre neu lesen; nie sperreRechnung innerhalb von sperreAuftrag. K32 und T22 (bzw. K04 und T09 beim Widerruf) erst nach Freigabe der Sperre. Mailfehler dort nie als Fehler an den Kunden weitergeben (nur console.error mit der AB-Nummer); sendeAuftragMail dagegen wirft (L3 setzt mailStatus), nur eine gescheiterte Teamkopie gilt nicht als Fehler.
  • Bestätigung und Widerruf berühren keine anderen Felder als die in V2/V3 genannten.
  • Shell: grep ist hier eine Funktion, die Dateien still auslässt; immer command grep -a benutzen. Nie pkill -f.

Review Focus

  1. Alte oder ungültige Links und Link-Hygiene (Erinnerung mit neuem Link, ersetzte/stornierte/widerrufene AB, abgelaufen, Müll im Token, Schalter aus; der Link steht nie in einer Mail ans Team): der alte Link bestätigt nie etwas; 404 „Dieser Link ist ungültig oder abgelaufen.“ bzw. 410 mit dem Grund. Tests: Task 7 (ungültige, unbekannte und abgelaufene Links, Erinnerung mit neuem Link, Schalter, ersetzte, stornierte und widerrufene AB), Task 8 (POST mit unbekanntem, falschem, altem oder abgelaufenem Link), Task 4 (K31 an den Kunden ohne BCC, Teamkopie), Task 12 (nach dem Widerruf bestätigt der Link nichts mehr).
  2. Doppelklick, Link und Portal gleichzeitig: genau eine Bestätigung zählt (die erste), K32 und T22 genau einmal, beide erst nach Freigabe der Sperre. Tests: Task 5 (zwei gleichzeitige Bestätigungen, die zweite Bestätigung ändert nichts, unter der Sperre), Task 6 (Bestätigen und Widerrufen gleichzeitig), Task 8 (zweite und gleichzeitige Bestätigungen).
  3. Ungültige Eingaben beim Bestätigen (bestaetigt fehlt oder ist nicht true, fruehbeginn als Text oder Zahl, Frühbeginn bei Unternehmen): 400 ohne Änderung; Frühbeginn zählt nur bei Privatpersonen und nur als true. Tests: Task 8 (ohne bestaetigt: true, fruehbeginn zählt nur bei Privatpersonen), Task 5 (Unternehmen – fruehbeginn wird ignoriert).
  4. Widerruf eines Projektauftrags ohne oder mit falscher Nummer, mit fremder E-Mail-Adresse, als Unternehmen, nach der Frist, doppelt oder bei Datenbankfehler: immer dieselbe Antwort { ok: true, receivedAt }, K04 und T09 gehen raus, die AB ändert sich nur bei Privatperson in der Frist; T09 nennt den Grund. Tests: Task 12, Task 6, Task 11.
  5. Fremde ABs und Entwürfe im Portal, Kundenansicht: ein Kunde sieht und bestätigt nur eigene gesendete ABs (sonst 404); in der Kundenansicht ist Bestätigen gesperrt, auch über den öffentlichen Link. Tests: Task 9 (nur eigene, nur gesendete ABs, kannBestaetigen wie in V3, Schalter aus, fremde, Entwurf, unbekannte AB 404), Task 10.

Dateien

Datei Aufgabe
server/services/auftraege/token.js (neu) neuerToken, hashToken
server/services/auftraege/mails.js (neu) K31/K32/T22 aufbauen und senden, Teamkopie, AGB-Anhang
server/services/auftraege/bestaetigung.js (neu) bestaetigeAuftrag, widerrufeAuftrag, Zustandsregeln
server/mail-templates/K31 Auftragsbestaetigung.html, K32 Auftrag bestaetigt.html, T22 Auftrag bestaetigt.html (neu) Vorlagen
server/mail-templates/K04 Eingang Widerruf.html, T09 Widerruf eingegangen.html (ändern) nur der Block projekt
server/routes/auftrag.js (Gerüst aus L0 füllen) öffentliche Link-Routen
server/routes/auftraegeKunde.js (Gerüst aus L0 füllen) Portal-Routen des Kunden
server/routes/abo.js (ändern) Widerruf order = 'projekt'
test/mailTemplates.test.js (ändern) Vorlagenliste, Betreff-Präfix Aufträge, Team-Fuß, Block-Listen von K04 und T09
test/auftraegeToken.test.js, auftraegeVorlagen.test.js, auftraegeMails.test.js, auftraegeBestaetigung.test.js, auftraegeTokenRoutes.test.js, auftraegeKundeRoutes.test.js, auftraegeKundenansicht.test.js, auftraegeWiderruf.test.js (neu) Tests
test/auftraegeBestaetigungHilfe.js (neu, kein .test.js) Testdaten: gesendete AB, Token, Schema des Nachbaus

Task 1: Worktree einrichten und L0 prüfen

Files: keine Repository-Dateien; legt den Worktree /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 an.

Interfaces:

  • Consumes: Branch feature/auftraege mit dem fertigen Strang L0 (Dateien und Namen aus V1–V4).

  • Produces: Worktree dev-auftraege-l5, Branch auftraege/l5-bestaetigung, Symlink node_modules.

  • Step 1: L0 ist im Branch

git -C /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege ls-tree -r --name-only feature/auftraege | command grep -a -E '^server/services/auftraege/|^server/routes/(auftrag|auftraegeKunde)\.js$' | sort

Erwartet: mindestens ablage.js, arbeitsblaetter.js, auftragSicht.js, auftragStore.js, fehler.js, konstanten.js, projektRechnungenStore.js, schalter.js, schema.js, texte.js unter server/services/auftraege/ sowie server/routes/auftrag.js und server/routes/auftraegeKunde.js. Fehlt etwas, den Strang anhalten und melden.

  • Step 2: Worktree anlegen
git -C /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege worktree add ../dev-auftraege-l5 -b auftraege/l5-bestaetigung feature/auftraege
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && ln -s ../dev-auftraege/node_modules node_modules && git status --short

Erwartet: nur ?? node_modules (der Symlink bleibt unversioniert; nie git add -A). Existiert ../dev-auftraege/node_modules noch nicht, fehlt L0 etwas: anhalten.

  • Step 3: Namen aus L0 sind da
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && node --max-old-space-size=512 --input-type=module -e "
const erwartet = {
  auftragStore: ['getAuftrag', 'listAuftraege', 'createAuftrag', 'updateAuftrag', 'findAuftragByTokenHash', 'findAuftragByNummer', 'docToAuftrag', 'auftragToDoc'],
  auftragSicht: ['oeffentlicheAnsicht', 'fruehesterBeginn'],
  ablage: ['speicherePdf', 'lesePdf'],
  schalter: ['auftraegeAktiv'],
  fehler: ['AuftragError', 'sendeFehler'],
  konstanten: ['TOKEN_GUELTIG_TAGE', 'AGB_FASSUNG', 'sperreAuftrag'],
  texte: ['FRUEHBEGINN_FASSUNG', 'FRUEHBEGINN_TEXT', 'BESTAETIGUNG_TEXT'],
  schema: ['AUFTRAEGE_COLLECTIONS'],
}
let fehlt = 0
for (const [datei, namen] of Object.entries(erwartet)) {
  const modul = await import('./server/services/auftraege/' + datei + '.js')
  for (const name of namen) if (!(name in modul)) { console.log('FEHLT', datei, name); fehlt += 1 }
}
const { config } = await import('./server/config.js')
if (!config.auftraege || !config.auftraege.uploadDir) { console.log('FEHLT config.auftraege'); fehlt += 1 }
const { sperreAuftrag } = await import('./server/services/auftraege/konstanten.js')
if (JSON.stringify(sperreAuftrag('x1')) !== JSON.stringify(['auftraege-ab:x1'])) { console.log('FALSCH sperreAuftrag (V2: ein zusammengesetzter Schlüssel)'); fehlt += 1 }
console.log(fehlt ? 'L0 UNVOLLSTÄNDIG' : 'L0 ok')
"

Erwartet: L0 ok.

  • Step 4: Reine Datenmodule und Einbindung
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5
command grep -a -n "^import" server/services/auftraege/konstanten.js server/services/auftraege/schema.js
command grep -a -n "auftrag" server/index.js

Erwartet: die erste Abfrage liefert keine Zeile (sonst lädt die Testhilfe config.js zu früh: anhalten und melden); die zweite zeigt u. a. app.use('/api/auftrag', …) und app.use('/api/auftraege', …).

  • Step 5: Ausgangslage grün

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/mailTemplates.test.js Expected: # fail 0.

Kein Commit (nichts zu committen).


Task 2: Bestätigungs-Token (token.js)

Files:

  • Create: server/services/auftraege/token.js
  • Test: test/auftraegeToken.test.js

Interfaces:

  • Consumes: TOKEN_GUELTIG_TAGE (60) aus server/services/auftraege/konstanten.js.

  • Produces: neuerToken(jetzt = new Date()) → { token: string, hash: string, gueltigBis: Date } und hashToken(token) → string | null (V4 „L5“).

  • Step 1: Failing Test test/auftraegeToken.test.js

import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { hashToken, neuerToken } from '../server/services/auftraege/token.js'

const TAG_MS = 24 * 60 * 60 * 1000

test('neuerToken: 32 Zufallsbytes als base64url (43 Zeichen), Hash = SHA-256 in Hex, gültig 60 Tage ab jetzt', () => {
  const jetzt = new Date('2026-10-06T10:00:00.000Z')
  const { token, hash, gueltigBis } = neuerToken(jetzt)
  assert.match(token, /^[A-Za-z0-9_-]{43}$/)
  assert.equal(hash, createHash('sha256').update(token).digest('hex'))
  assert.ok(gueltigBis instanceof Date)
  assert.equal(gueltigBis.toISOString(), '2026-12-05T10:00:00.000Z')
  assert.equal(jetzt.toISOString(), '2026-10-06T10:00:00.000Z', 'das übergebene Datum bleibt unverändert')
})

test('neuerToken: jedes Token ist neu; ohne Argument zählt die aktuelle Zeit; ein ungültiger Zeitpunkt wirft', () => {
  const vorher = Date.now()
  const a = neuerToken()
  const b = neuerToken()
  const nachher = Date.now()
  assert.notEqual(a.token, b.token)
  assert.notEqual(a.hash, b.hash)
  assert.ok(a.gueltigBis.getTime() >= vorher + 60 * TAG_MS && a.gueltigBis.getTime() <= nachher + 60 * TAG_MS)
  assert.throws(() => neuerToken(new Date('kein Datum')), TypeError)
  assert.throws(() => neuerToken('2026-10-06'), TypeError)
})

test('hashToken: gleicher Hash wie neuerToken, null bei falschem Format', () => {
  const { token, hash } = neuerToken()
  assert.equal(hashToken(token), hash)
  const falsch = [undefined, null, 42, {}, [], '', 'kurz', `${token}x`, token.slice(1), ` ${token.slice(0, 42)}`, `${token.slice(0, 42)}+`, `${token.slice(0, 42)}=`, `../${token.slice(3)}`, `${token.slice(0, 42)}\n`]
  for (const wert of falsch) assert.equal(hashToken(wert), null, JSON.stringify(wert))
})
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeToken.test.js Expected: FAIL mit ERR_MODULE_NOT_FOUND (token.js fehlt).

  • Step 3: Implementieren server/services/auftraege/token.js

Imports: nur createHash, randomBytes aus node:crypto und TOKEN_GUELTIG_TAGE aus ./konstanten.js.

  • neuerToken(jetzt = new Date()): jetzt muss ein Date mit endlichem Zeitwert sein, sonst TypeError. token = randomBytes(32).toString('base64url'), hash = SHA-256 des Tokentextes in Hex (64 Zeichen), gueltigBis = new Date(jetzt.getTime() + TOKEN_GUELTIG_TAGE * 86_400_000) (reine UTC-Millisekunden, kein Kalenderrechnen).

  • hashToken(token): nur ein Text, der genau ^[A-Za-z0-9_-]{43}$ entspricht, ergibt den Hash, alles andere null.

  • Step 4: Lauf, erwartet PASS

Run: wie Step 2. Expected: # pass 3, # fail 0.

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5
git add server/services/auftraege/token.js test/auftraegeToken.test.js
git commit -m "Aufträge L5: Bestätigungs-Token (32 Zufallsbytes, nur als Hash gespeichert, 60 Tage gültig)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 3: Mailvorlagen K31, K32, T22

Files:

  • Create: server/mail-templates/K31 Auftragsbestaetigung.html, server/mail-templates/K32 Auftrag bestaetigt.html, server/mail-templates/T22 Auftrag bestaetigt.html
  • Modify: test/mailTemplates.test.js (Vorlagenliste, Betreff-Präfix, Team-Fuß, Kommentar)
  • Test: test/auftraegeVorlagen.test.js

Interfaces:

  • Consumes: renderMail(id, values, blocks), loadTemplate(id) aus server/services/mailTemplates.js (unverändert).

  • Produces: Vorlagen K31 (Platzhalter name, nummer, titel, datum, betrag, link, gueltig_bis; Blöcke erinnerung, privat, festpreis, aufwand, teamkopie, kundenmail), K32 (name, nummer, titel, zeitpunkt, fruehester_beginn, fruehbeginn_text; Blöcke fruehbeginn, ohne_fruehbeginn), T22 (kunde, nummer, titel, zeitpunkt, ueber, woms_link, fruehester_beginn; Blöcke privat_fruehbeginn, privat_ohne_fruehbeginn), Betreffe wie V6. kundenmail ist der Gegenblock zu teamkopie: die Vorlagen kennen nur positive Blöcke, „statt Knopf“ braucht also einen Block für die Kundenmail (Vertragsfrage 12).

  • Step 1: Failing Tests

(a) test/auftraegeVorlagen.test.js:

import { test } from 'node:test'
import assert from 'node:assert/strict'
import { config } from '../server/config.js'
import { loadTemplate, renderMail } from '../server/services/mailTemplates.js'
import { euroDe } from '../server/services/finance/format.js'

// K31, K32, T22 (Aufträge und Rechnungen, Strang L5) und der Block projekt in K04 und T09 (Task 11). Die allgemeinen
// Vorlagenregeln (Kopf, Platzhalter, Blöcke, Logo, Text ohne Entities, Team-Fuß) prüft test/mailTemplates.test.js.
const eur = (cent) => euroDe(cent).replace(/\u{a0}/gu, ' ') // die Textfassung macht geschützte Leerzeichen zu normalen
const DU_FORM = /(^|[^\p{L}])(du|dein\p{L}*|dir|dich)(?![\p{L}-])/iu
const SIE_FORM = /(^|[^\p{L}])(Sie|Ihr\p{L}*|Ihnen)(?![\p{L}-])/u
const alle = (id) => Object.fromEntries(loadTemplate(id).blocks.map((name) => [name, true]))
const pruefeZeilen = (mail, erwartet) => {
  const zeilen = mail.text.split('\n')
  for (const zeile of erwartet) assert.ok(zeilen.includes(zeile), `Zeile fehlt: ${zeile}\n${mail.text}`)
}

const K31 = { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', datum: '06.10.2026', betrag: eur(80000), link: 'https://project.webklar.com/auftrag.html#t=TOKEN', gueltig_bis: '05.12.2026' }
const K32 = { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', fruehester_beginn: '21.10.2026', fruehbeginn_text: 'Ich verlange ausdrücklich den Beginn vor Ablauf der Widerrufsfrist.' }
const T22 = { kunde: 'Muster GmbH', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', ueber: 'Bestätigungslink', woms_link: 'https://ticket.webklar.com/customers/c1?tab=invoices', fruehester_beginn: '21.10.2026' }

test('K31, K32, T22: Datei, Betreff, Platzhalter und Blöcke wie im Vertrag V6 (K31 mit dem Gegenblock kundenmail)', () => {
  const erwartet = {
    K31: { file: 'K31 Auftragsbestaetigung.html', subject: 'Ihre Auftragsbestätigung {{nummer}} – {{titel}}', placeholders: ['name', 'nummer', 'titel', 'datum', 'betrag', 'link', 'gueltig_bis'], blocks: ['erinnerung', 'privat', 'festpreis', 'aufwand', 'teamkopie', 'kundenmail'] },
    K32: { file: 'K32 Auftrag bestaetigt.html', subject: 'Ihre Bestätigung zu {{nummer}} ist eingegangen', placeholders: ['name', 'nummer', 'titel', 'zeitpunkt', 'fruehester_beginn', 'fruehbeginn_text'], blocks: ['fruehbeginn', 'ohne_fruehbeginn'] },
    T22: { file: 'T22 Auftrag bestaetigt.html', subject: '[Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}}', placeholders: ['kunde', 'nummer', 'titel', 'zeitpunkt', 'ueber', 'woms_link', 'fruehester_beginn'], blocks: ['privat_fruehbeginn', 'privat_ohne_fruehbeginn'] },
  }
  for (const [id, soll] of Object.entries(erwartet)) {
    const vorlage = loadTemplate(id)
    assert.equal(vorlage.file, soll.file, id)
    assert.equal(vorlage.subject, soll.subject, id)
    assert.deepEqual(vorlage.placeholders, soll.placeholders, id)
    assert.deepEqual(vorlage.blocks, soll.blocks, id)
  }
})

test('K31 Festpreis an eine Privatperson: Angaben, Knopf mit Link im Fragment, Anhänge, Widerrufshinweis; keine Erinnerung, kein Teamhinweis', () => {
  const mail = renderMail('K31', K31, { festpreis: true, privat: true, kundenmail: true })
  assert.deepEqual(mail.problems, [])
  assert.equal(mail.subject, 'Ihre Auftragsbestätigung AB-2026-0001 – Website für Musterbäckerei')
  pruefeZeilen(mail, [
    'Guten Tag Erika Muster,',
    'Auftrag: AB-2026-0001 vom 06.10.2026',
    'Projekt: Website für Musterbäckerei',
    'Gesamtpreis: 800,00 € (Festpreis, gemäß § 19 UStG ohne Umsatzsteuer)',
    'Bestätigung möglich bis: 05.12.2026',
    'Auftrag bestätigen: https://project.webklar.com/auftrag.html#t=TOKEN',
    'Viele Grüße',
    'Ihr WEBklar-Team',
  ])
  assert.ok(mail.text.includes('Die Auftragsbestätigung (AB-2026-0001.pdf) und unsere Allgemeinen Geschäftsbedingungen hängen als PDF an dieser E-Mail.'))
  assert.ok(mail.text.includes('Sie sind Verbraucher: Ihr Widerrufsrecht und das Muster-Widerrufsformular finden Sie in der angehängten Auftragsbestätigung;'))
  assert.ok(mail.text.includes(`Widerrufsformular auf unserer Website (${config.publicBaseUrl}/widerrufen.html)`))
  assert.doesNotMatch(mail.text, /Erinnerung|Vergütung:|Kopie für das Team/)
})

test('K31 Aufwand an ein Unternehmen als Erinnerung: Stundensatz, Hinweis auf den früheren Link, kein Widerrufshinweis', () => {
  const mail = renderMail('K31', { ...K31, betrag: `${eur(10000)} pro Stunde` }, { aufwand: true, erinnerung: true, kundenmail: true })
  assert.deepEqual(mail.problems, [])
  pruefeZeilen(mail, ['Vergütung: 100,00 € pro Stunde (nach Aufwand in Schritten von 15 Minuten, gemäß § 19 UStG ohne Umsatzsteuer)', 'Auftrag bestätigen: https://project.webklar.com/auftrag.html#t=TOKEN'])
  assert.ok(mail.text.includes('Wir haben Ihnen diese Auftragsbestätigung am 06.10.2026 schon einmal geschickt und noch keine Bestätigung von Ihnen erhalten. Der Link in der früheren E-Mail gilt nicht mehr.'))
  assert.doesNotMatch(mail.text, /Gesamtpreis|Widerruf|Verbraucher/)
})

test('K31 als Kopie für das Team: Hinweis statt Knopf, kein Link, keine Anhänge, kein Widerrufshinweis; die Erinnerung bleibt erkennbar', () => {
  const mail = renderMail('K31', { ...K31, link: '' }, { aufwand: true, erinnerung: true, teamkopie: true })
  assert.deepEqual(mail.problems, [])
  pruefeZeilen(mail, ['Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.', 'Auftrag: AB-2026-0001 vom 06.10.2026', 'Bestätigung möglich bis: 05.12.2026'])
  assert.ok(mail.text.includes('Der Link in der früheren E-Mail gilt nicht mehr.'))
  assert.doesNotMatch(mail.text, /Auftrag bestätigen|auftrag\.html|hängen als PDF|Anhänge|Verbraucher/)
  assert.doesNotMatch(mail.html, /auftrag\.html|#t=|class="btn"/)
})

test('K32: Eingangsbestätigung mit Datum und Uhrzeit; Frühbeginn ja (wörtliche Erklärung), nein und Unternehmen', () => {
  const ja = renderMail('K32', K32, { fruehbeginn: true })
  assert.deepEqual(ja.problems, [])
  assert.equal(ja.subject, 'Ihre Bestätigung zu AB-2026-0001 ist eingegangen')
  pruefeZeilen(ja, ['Guten Tag Erika Muster,', 'Auftrag: AB-2026-0001', 'Projekt: Website für Musterbäckerei', 'Bestätigt am: 07.10.2026, 10:30 Uhr', 'Ihre Erklärung zum früheren Beginn', K32.fruehbeginn_text, 'Viele Grüße', 'Ihr WEBklar-Team'])
  assert.ok(ja.text.includes('wir haben Ihre Bestätigung zu AB-2026-0001 am 07.10.2026, 10:30 Uhr erhalten.'))
  assert.doesNotMatch(ja.text, /21\.10\.2026|nicht verlangt|Widerrufsrecht bleibt/)
  const nein = renderMail('K32', K32, { ohne_fruehbeginn: true })
  assert.ok(nein.text.includes('Sie haben nicht verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Wir beginnen daher frühestens am 21.10.2026 mit der Arbeit.'))
  assert.doesNotMatch(nein.text, /Erklärung zum früheren Beginn/)
  assert.doesNotMatch(renderMail('K32', K32, {}).text, /Beginn der Arbeiten|21\.10\.2026|Widerruf|Erklärung/)
})

test('T22: Betreff mit Präfix, Angaben, Knopf ins Ticketsystem; Hinweise nur bei Privatpersonen', () => {
  const ja = renderMail('T22', T22, { privat_fruehbeginn: true })
  assert.deepEqual(ja.problems, [])
  assert.equal(ja.subject, '[Aufträge] Auftrag AB-2026-0001 bestätigt: Muster GmbH')
  pruefeZeilen(ja, ['Kunde: Muster GmbH', 'Auftrag: AB-2026-0001', 'Projekt: Website für Musterbäckerei', 'Bestätigt am: 07.10.2026, 10:30 Uhr', 'Weg: Bestätigungslink', 'Im Ticketsystem öffnen: https://ticket.webklar.com/customers/c1?tab=invoices'])
  assert.ok(ja.text.includes('Der Kunde hat ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Die Arbeit darf sofort starten. Bei einem Widerruf ist Wertersatz zu prüfen (§ 357a Abs. 2 BGB).'))
  assert.doesNotMatch(ja.text, /keinen früheren Beginn|21\.10\.2026/)
  const nein = renderMail('T22', T22, { privat_ohne_fruehbeginn: true })
  assert.ok(nein.text.includes('Der Kunde hat keinen früheren Beginn verlangt. Arbeitsbeginn frühestens am 21.10.2026, dem Tag nach Ablauf der Widerrufsfrist.'))
  assert.doesNotMatch(renderMail('T22', T22, {}).text, /Privatperson|Wertersatz|frühestens/)
})

test('K31 und K32 stehen in der Sie-Form; Werte werden HTML-sicher eingesetzt', () => {
  for (const [id, werte] of [['K31', K31], ['K32', K32]]) {
    const mail = renderMail(id, werte, alle(id))
    assert.doesNotMatch(mail.text, DU_FORM, `${id}: Du-Form\n${mail.text}`)
    assert.match(mail.text, SIE_FORM, `${id}: spricht den Kunden mit Sie an`)
  }
  const mail = renderMail('K31', { ...K31, titel: 'Café <b>&"' }, { festpreis: true })
  assert.match(mail.html, /Café &lt;b&gt;&amp;&quot;/)
  assert.doesNotMatch(mail.html, /<b>&/)
})

(b) test/mailTemplates.test.js, vier Änderungen (mit dem Edit-Werkzeug; Zeilen nach den Texten suchen):

  1. Den langen Kommentar über const ids = … am Ende um ; K31, K32 und T22 Auftragsbestätigung (Aufträge und Rechnungen, Strang L5; K31 und K32 in Sie-Form, T22 mit [Aufträge]) ergänzen.
  2. const EXPECTED = [...ids('G', 15), ...ids('K', 30), ...ids('T', 21)] → const EXPECTED = [...ids('G', 15), ...ids('K', 32), ...ids('T', 22)].
  3. In der Prüfung „Kopfkommentar passt zur Vorlage“ die Präfix-Gruppe (Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner|Webshop) um |Aufträge erweitern und im Meldungstext „oder [Webshop]“ durch „, [Webshop] oder [Aufträge]“ ersetzen. Keine Ausnahme für T22: die Vorlage trägt das Präfix.
  4. const TEAM_FOOTER = [… 'T19', 'T20', 'T21'] → … 'T19', 'T20', 'T21', 'T22'].
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeVorlagen.test.js test/mailTemplates.test.js Expected: FAIL (Mail-Vorlage K31 fehlt; alle Vorlagen sind da meldet K31, K32, T22 als fehlend).

  • Step 3: Vorlagen schreiben

Ausgangsdateien kopieren und nur das unten Genannte ändern: cp "server/mail-templates/K16 Funktion bereit.html" "server/mail-templates/K31 Auftragsbestaetigung.html", ebenso K32; cp "server/mail-templates/T16 Funktion bestellt.html" "server/mail-templates/T22 Auftrag bestaetigt.html". In jeder Datei ersetzen: (a) den Kopfkommentar durch den unten genannten (Format wie bei K15: nach <!-- ein Zeilenumbruch, dann K31 · …), (b) <title> (muss dem Betreff im Kopf gleichen), (c) den Text des unsichtbaren Vorschau-Spans (die Zeichenfolge &#8199;&#847;&#8199;&#847;&#8199;&#847; am Ende bleibt), (d) das Wort rechts oben in der Logo-Zeile, (e) alle <tr>-Zeilen der inneren Tabelle der Karte (von der Pill-Zeile bis zur letzten Zeile: bei K31/K32 die Gruß-Zeile, bei T22 die Leerzeile mit &nbsp;; die äußeren <tr><td class="card" …> und </table> bleiben) durch die unten genannte Folge. Fußzeilen, Logo, Dark-Mode-Stile und die einzige Outlook-Bedingung <!--[if mso]> bleiben. Neben den WENN/ENDE-Kommentaren und dem Kopf darf kein weiterer HTML-Kommentar stehen. Blockmarken ganzer Zeilen stehen wie in K15 auf eigenen Zeilen; Blöcke innerhalb der Tabelle stehen inline (<!-- WENN festpreis --><tr>…</tr><!-- ENDE festpreis -->).

Zeilenmuster (jeweils aus den vorhandenen Dateien übernehmen, nur Texte ändern):

  • Pill grün = erste Zeile der Karte in K16; Pill grau = erste Zeile der Karte in T16.
  • Überschrift = zweite Zeile in K16; Absatz = dritte Zeile in K16 (Hallo …,<br>… wird zu Guten Tag {{name}},<br>…).
  • Tabelle = vierte Zeile in K16; Tabellenzeilen wie dort (Rahmenlinie unten), die letzte Zeile ohne border-bottom.
  • Kasten = die Zeile „Wie geht es weiter?“ in K15 (Titel in div.ink, Text in div.mut).
  • Knopf = Knopfzeile in K16 (href und Beschriftung ersetzen; die Beschriftung endet mit &rarr;).
  • Gruß = letzte Zeile der Karte in K04 (Sie-Form).

K31 · Kopfkommentar:

<!--
  K31 · Auftragsbestätigung (Projektauftrag; Variante Erinnerung, Variante Privatperson, Kopie für das Team)
  Betreff:    Ihre Auftragsbestätigung {{nummer}} – {{titel}}
  Absender:   „WEBklar“ <Portal-Postfach>; die Kopie für das Team kommt als „WEBklar Kundenportal“
  Empfänger:  Kunde (E-Mail der Auftragsbestätigung), ohne BCC · Kopie ohne Link an kenso@webklar.com, andrej@webklar.com
  Platzhalter: {{name}}, {{nummer}}, {{titel}}, {{datum}}, {{betrag}}, {{link}}, {{gueltig_bis}}
  Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): erinnerung, privat, festpreis, aufwand, teamkopie, kundenmail
  nummer = Nummer der Auftragsbestätigung (AB-JJJJ-NNNN), titel = Projekt bzw. Titel des Auftrags, datum = Tag des Sendens (Berliner Datum, TT.MM.JJJJ).
  betrag = Gesamtpreis bei Festpreis (zum Beispiel 800,00 €) bzw. Stundensatz bei Aufwand (zum Beispiel 100,00 € pro Stunde); genau einer der Blöcke festpreis und aufwand ist an.
  link = Bestätigungsseite mit dem persönlichen Link (…/auftrag.html#t=…); er steht nur in der Mail an den Kunden und ist in der Kopie für das Team leer. gueltig_bis = letzter Tag, an dem der Link gilt (TT.MM.JJJJ).
  erinnerung = Erinnerung des Teams: dieselbe Auftragsbestätigung mit neuem Link, der frühere Link gilt nicht mehr.
  privat = der Kunde ist Privatperson: Hinweis auf Widerrufsrecht, Muster-Widerrufsformular (in der angehängten Auftragsbestätigung) und den Beginn der Arbeiten; nicht in der Kopie für das Team.
  kundenmail = die Mail an den Kunden: Knopf mit dem Link und Hinweis auf die Anhänge (die Auftragsbestätigung als PDF, <Nummer>.pdf, und die Allgemeinen Geschäftsbedingungen als PDF, WEBklar-AGB-<Fassung>.pdf).
  teamkopie = die Kopie für das Team: statt des Knopfs der Hinweis, dass der Bestätigungslink nur in der Mail an den Kunden steht (ohne Link, ohne Anhänge). Genau einer der Blöcke kundenmail und teamkopie ist an.
-->

<title> = Betreff. Vorschautext: Bitte prüfen und bestätigen Sie Ihren Auftrag {{nummer}}. Wort rechts oben: Auftrag. Karte, in dieser Reihenfolge:

  1. Pill grau: Auftragsbestätigung
  2. Überschrift: Ihre Auftragsbestätigung {{nummer}}
  3. Absatz: Guten Tag {{name}},<br>vielen Dank für Ihren Auftrag. Anbei erhalten Sie die Auftragsbestätigung {{nummer}} zum Projekt „{{titel}}“. Bitte prüfen Sie sie und bestätigen Sie den Auftrag; eine Anmeldung ist nicht nötig.
  4. Block erinnerung: Kasten, Titel Erinnerung, Text Wir haben Ihnen diese Auftragsbestätigung am {{datum}} schon einmal geschickt und noch keine Bestätigung von Ihnen erhalten. Der Link in der früheren E-Mail gilt nicht mehr.
  5. Tabelle mit den Zeilen: Auftrag | {{nummer}} vom {{datum}}; Projekt | {{titel}}; Block festpreis: Gesamtpreis | <strong>{{betrag}}</strong> <span class="mut" style="color:#6b6964;">(Festpreis, gemäß § 19 UStG ohne Umsatzsteuer)</span>; Block aufwand: Vergütung | <strong>{{betrag}}</strong> <span class="mut" style="color:#6b6964;">(nach Aufwand in Schritten von 15 Minuten, gemäß § 19 UStG ohne Umsatzsteuer)</span>; letzte Zeile Bestätigung möglich bis | {{gueltig_bis}}.
  6. Block kundenmail: Knopf, href="{{link}}", Beschriftung Auftrag bestätigen &rarr;
  7. Block teamkopie: eine Zeile im Muster Absatz (aber mit padding:28px 40px 0 40px), Text Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.
  8. Block kundenmail: Kasten, Titel Anhänge, Text Die Auftragsbestätigung ({{nummer}}.pdf) und unsere Allgemeinen Geschäftsbedingungen hängen als PDF an dieser E-Mail. Bitte bewahren Sie beide auf. Den Auftrag können Sie auch in Ihrem Kundenportal ansehen und bestätigen.
  9. Block privat: Kasten, Titel Ihr Widerrufsrecht, Text Sie sind Verbraucher: Ihr Widerrufsrecht und das Muster-Widerrufsformular finden Sie in der angehängten Auftragsbestätigung; widerrufen können Sie auch über das <a href="https://project.webklar.com/widerrufen.html" style="color:#111111;">Widerrufsformular auf unserer Website</a>. Mit der Arbeit beginnen wir erst nach Ablauf der Widerrufsfrist, es sei denn, Sie verlangen beim Bestätigen ausdrücklich einen früheren Beginn.
  10. Gruß: Bei Fragen antworten Sie einfach auf diese E-Mail.<br>Viele Grüße<br><strong>Ihr WEBklar-Team</strong>

K32 · Kopfkommentar:

<!--
  K32 · Auftrag bestätigt (Eingangsbestätigung der Bestätigung; Variante Privatperson mit oder ohne früheren Beginn)
  Betreff:    Ihre Bestätigung zu {{nummer}} ist eingegangen
  Absender:   „WEBklar“ <Portal-Postfach>
  Empfänger:  Kunde (E-Mail der Auftragsbestätigung) · BCC intern
  Platzhalter: {{name}}, {{nummer}}, {{titel}}, {{zeitpunkt}}, {{fruehester_beginn}}, {{fruehbeginn_text}}
  Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): fruehbeginn, ohne_fruehbeginn
  zeitpunkt = Zeitpunkt der Bestätigung (TT.MM.JJJJ, HH:MM, Berliner Zeit). fruehester_beginn = frühester Arbeitsbeginn ohne ausdrücklichen Wunsch (TT.MM.JJJJ, Tag nach Ablauf der Widerrufsfrist), nur im Block ohne_fruehbeginn.
  fruehbeginn = die Privatperson hat ausdrücklich verlangt, dass die Arbeit schon vor Ablauf der Widerrufsfrist beginnt; fruehbeginn_text = Wortlaut ihrer Erklärung (FRUEHBEGINN_TEXT aus server/services/auftraege/texte.js), nur in diesem Block, ohne weitere Sätze. ohne_fruehbeginn = sie hat das nicht verlangt. Bei Unternehmen ist keiner der beiden Blöcke an.
-->

Vorschautext: Wir haben Ihre Bestätigung zu {{nummer}} erhalten. Wort rechts oben: Bestätigung. Karte:

  1. Pill grün: &#10003;&nbsp; Auftrag bestätigt
  2. Überschrift: Vielen Dank für Ihre Bestätigung
  3. Absatz: Guten Tag {{name}},<br>wir haben Ihre Bestätigung zu {{nummer}} am {{zeitpunkt}} Uhr erhalten. Bitte bewahren Sie diese E-Mail auf.
  4. Tabelle: Auftrag | {{nummer}}; Projekt | {{titel}}; letzte Zeile Bestätigt am | {{zeitpunkt}} Uhr
  5. Block fruehbeginn: Kasten, Titel Ihre Erklärung zum früheren Beginn, Text {{fruehbeginn_text}} (nichts sonst: keine Einleitung, kein Satz zum Widerrufsrecht)
  6. Block ohne_fruehbeginn: Kasten, Titel Beginn der Arbeiten, Text Sie haben nicht verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Wir beginnen daher frühestens am {{fruehester_beginn}} mit der Arbeit.
  7. Gruß: Viele Grüße<br><strong>Ihr WEBklar-Team</strong>

T22 · Kopfkommentar:

<!--
  T22 · Auftrag bestätigt (Hinweis ans Team)
  Betreff:    [Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}}
  Absender:   „WEBklar Kundenportal“ <Portal-Postfach>
  Empfänger:  kenso@webklar.com, andrej@webklar.com
  Platzhalter: {{kunde}}, {{nummer}}, {{titel}}, {{zeitpunkt}}, {{ueber}}, {{woms_link}}, {{fruehester_beginn}}
  Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): privat_fruehbeginn, privat_ohne_fruehbeginn
  kunde = Firma, sonst Name des Kunden. ueber = Weg der Bestätigung (Bestätigungslink oder Kundenportal). zeitpunkt = TT.MM.JJJJ, HH:MM, Berliner Zeit. woms_link = Aufträge und Rechnungen des Kunden im Ticketsystem.
  privat_fruehbeginn = Privatperson, früherer Beginn ausdrücklich verlangt (die Arbeit darf sofort starten). privat_ohne_fruehbeginn = Privatperson ohne diesen Wunsch (Arbeitsbeginn erst ab fruehester_beginn, TT.MM.JJJJ). Bei Unternehmen ist keiner der beiden Blöcke an.
-->

<title> = Betreff. Vorschautext: {{kunde}}: {{nummer}} bestätigt. Wort rechts oben: Intern · Kundenportal. Karte:

  1. Pill grau: Aufträge
  2. Überschrift: Auftrag bestätigt
  3. Tabelle: Kunde | {{kunde}}; Auftrag | {{nummer}}; Projekt | {{titel}}; Bestätigt am | {{zeitpunkt}} Uhr; letzte Zeile Weg | {{ueber}}
  4. Block privat_fruehbeginn: Kasten, Titel Privatperson: früher Beginn verlangt, Text Der Kunde hat ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Die Arbeit darf sofort starten. Bei einem Widerruf ist Wertersatz zu prüfen (§ 357a Abs. 2 BGB).
  5. Block privat_ohne_fruehbeginn: Kasten, Titel Privatperson: kein früher Beginn, Text Der Kunde hat keinen früheren Beginn verlangt. Arbeitsbeginn frühestens am {{fruehester_beginn}}, dem Tag nach Ablauf der Widerrufsfrist.
  6. Knopf: href="{{woms_link}}", Beschriftung Im Ticketsystem öffnen &rarr;, danach die Leerzeile <tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr> wie am Ende von T16 (statt der Gruß-Zeile).
  • Step 4: Lauf, erwartet PASS

Run: wie Step 2. Expected: # fail 0 (alle Vorlagentests inklusive der neuen Ids K31, K32, T22).

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5
git add "server/mail-templates/K31 Auftragsbestaetigung.html" "server/mail-templates/K32 Auftrag bestaetigt.html" "server/mail-templates/T22 Auftrag bestaetigt.html" test/auftraegeVorlagen.test.js test/mailTemplates.test.js
git commit -m "Aufträge L5: Mailvorlagen K31 Auftragsbestätigung (mit Teamkopie), K32 Auftrag bestätigt, T22 Hinweis ans Team" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 4: Mails (mails.js)

Files:

  • Create: server/services/auftraege/mails.js, test/auftraegeBestaetigungHilfe.js
  • Test: test/auftraegeMails.test.js

Interfaces:

  • Consumes: sendCustomerMail(to, { template, values, blocks, attachments, bccTeam }) und sendAdminNotice({ template, values, blocks }) → Promise<{ sent, reason? }> (wirft nie; server/services/mailer.js, unverändert); L0: fruehesterBeginn(auftrag) → 'YYYY-MM-DD' | null (auftragSicht.js), lesePdf(pdfDatei) → Promise<Buffer> (ablage.js), AGB_FASSUNG (konstanten.js), FRUEHBEGINN_TEXT (texte.js); hashToken (Task 2); euroDe (finance/format.js), berlinDate, formatDe (finance/dates.js); config.publicBaseUrl, config.womsBaseUrl. Die auftrag-Objekte haben die Form „Auftrag“ aus V3; L3 übergibt das gespeicherte Dokument (mit nummer, gesendetAm, gesendetAn, tokenGueltigBis, agbFassung).

  • Produces: sendeAuftragMail(auftrag, { pdf, token, erinnerung = false }) → Promise<void> (wirft bei Fehler der Kundenmail) und sendeBestaetigtMails(auftrag) → Promise<void> (wirft nie), beide laut V4; dazu k31Mail(auftrag, { token, erinnerung = false, teamkopie = false }), k32Mail(auftrag), t22Mail(auftrag) → { template, values, blocks }, agbPdfPfad(fassung = AGB_FASSUNG) → string, setzeAgbVerzeichnisFuerTests(verzeichnis | null).

  • Step 1: Failing Tests

(a) Testhilfe test/auftraegeBestaetigungHilfe.js (reine Daten, kein config.js, wird von npm test nicht als Test gestartet):

import { AUFTRAEGE_COLLECTIONS } from '../server/services/auftraege/schema.js'
import { hashToken } from '../server/services/auftraege/token.js'

// Testdaten der Strang-L5-Tests: gesendete AB, Token, Schema und Datumsfelder für den Appwrite-Nachbau.
// Importiert nie server/config.js: die Tests setzen die Umgebung erst danach (useTestEnv).
const attribute = AUFTRAEGE_COLLECTIONS.auftraege.attributes
export const AUFTRAEGE_SCHEMA = { auftraege: attribute.map((a) => a.key) }
export const AUFTRAEGE_DATETIMES = { auftraege: attribute.filter((a) => a.type === 'datetime').map((a) => a.key) }

export const TOKEN = 'T'.repeat(43)
export const TOKEN_NEU = 'U'.repeat(43)
export const SHA = 'a'.repeat(64)
export const UNGUELTIG = 'Dieser Link ist ungültig oder abgelaufen.'

export const kunde = (kundenart = 'privat', extra = {}) => ({
  name: 'Erika Muster',
  firma: kundenart === 'unternehmen' ? 'Muster GmbH' : '',
  strasse: 'Hauptstr. 1',
  plz: '67722',
  ort: 'Winnweiler',
  land: 'DE',
  email: 'k@example.invalid',
  kundenart,
  kundennummer: 'K-100',
  ...extra,
})

/** Gesendete Festpreis-AB AB-2026-0001 des Kunden c1 (Form „Auftrag“ aus V3), gesendet am 06.10.2026 (Berlin 12:00), Link bis 05.12.2026. */
export const abDokument = (over = {}) => ({
  id: 'ab1', nummer: 'AB-2026-0001', jahr: 2026, laufnummer: 1, status: 'versendet',
  kundenId: 'c1', kunde: kunde(), projektId: 'p1', titel: 'Website für Musterbäckerei', zeitquelle: [],
  art: 'festpreis', positionen: [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 80000 }],
  stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
  zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' }, zahlungszielTage: 14,
  auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '', fertigBis: '',
  ersetztId: '', ersetztDurchId: '', texteFassung: '2026-10-06', agbFassung: '2026-09-30',
  pdfDatei: 'c1/ab1.pdf', pdfSha256: SHA,
  gesendetAm: '2026-10-06T10:00:00.000Z', gesendetAn: 'k@example.invalid', mailStatus: 'gesendet', mailFehler: '',
  tokenHash: hashToken(TOKEN), 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-06T09:00:00.000Z', geaendertAm: '2026-10-06T10:00:00.000Z',
  ...over,
})

(b) test/auftraegeMails.test.js:

import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
import { TOKEN, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'

// mails.js: K31 (mit Teamkopie), K32, T22 aufbauen und über die Senke verschicken. Kein Datenbankzugriff: jede versehentliche Anfrage scheitert sofort.
process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-mails-'))
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1/v1', stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { loadTemplate, renderMail } = await import('../server/services/mailTemplates.js')
const { euroDe } = await import('../server/services/finance/format.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { AGB_FASSUNG } = await import('../server/services/auftraege/konstanten.js')
const { FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js')
const { speicherePdf } = await import('../server/services/auftraege/ablage.js')
const { agbPdfPfad, k31Mail, k32Mail, sendeAuftragMail, sendeBestaetigtMails, setzeAgbVerzeichnisFuerTests, t22Mail } = await import('../server/services/auftraege/mails.js')

const agbVerzeichnis = mkdtempSync(path.join(os.tmpdir(), 'auftraege-agb-'))
const AB_PDF = Buffer.from('%PDF-1.4 AB-Test')
const AGB_PDF = Buffer.from('%PDF-1.4 AGB-Test')
writeFileSync(path.join(agbVerzeichnis, 'agb-2026-09-30.pdf'), AGB_PDF)
setzeAgbVerzeichnisFuerTests(agbVerzeichnis)
after(() => {
  setzeAgbVerzeichnisFuerTests(null)
  setMailTransportForTests(null)
})

let sink
let protokoll
beforeEach(() => {
  sink = mailSink()
  setMailTransportForTests(sink)
  protokoll = mock.method(console, 'error', () => {})
})
afterEach(() => mock.restoreAll())
const adressen = (liste) => liste.map((a) => a.address)

const AB = abDokument()
const AUFWAND = { art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, kunde: kunde('unternehmen') }
const BESTAETIGT = (over = {}) => abDokument({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', ...over })
const K31_BETREFF = 'Ihre Auftragsbestätigung AB-2026-0001 – Website für Musterbäckerei'
const T22_BETREFF = '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster'
const WERTE_K31 = { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', datum: '06.10.2026', betrag: euroDe(80000), gueltig_bis: '05.12.2026' }

test('agbPdfPfad: Vorgabe ist public/rechtliches/agb-<Fassung>.pdf; eine ungültige Fassung wirft', () => {
  setzeAgbVerzeichnisFuerTests(null)
  try {
    const rechtliches = fileURLToPath(new URL('../public/rechtliches/', import.meta.url))
    assert.equal(agbPdfPfad(), path.join(rechtliches, `agb-${AGB_FASSUNG}.pdf`))
    assert.equal(agbPdfPfad('2025-01-01'), path.join(rechtliches, 'agb-2025-01-01.pdf'))
    for (const fassung of ['', '../x', 'a/b', 'x'.repeat(21)]) assert.throws(() => agbPdfPfad(fassung), TypeError, fassung)
  } finally {
    setzeAgbVerzeichnisFuerTests(agbVerzeichnis)
  }
})

test('k31Mail: Link im Fragment; als Teamkopie ohne Link und ohne Kundenblöcke; Aufwand als Erinnerung (Berliner Tag des Sendens)', () => {
  assert.deepEqual(k31Mail(AB, { token: TOKEN }), {
    template: 'K31',
    values: { ...WERTE_K31, link: `${config.publicBaseUrl}/auftrag.html#t=${TOKEN}` },
    blocks: { erinnerung: false, privat: true, festpreis: true, aufwand: false, teamkopie: false, kundenmail: true },
  })
  // auch wenn man das Token mitgibt: die Teamkopie enthält es nie
  assert.deepEqual(k31Mail(AB, { token: TOKEN, teamkopie: true }), {
    template: 'K31',
    values: { ...WERTE_K31, link: '' },
    blocks: { erinnerung: false, privat: false, festpreis: true, aufwand: false, teamkopie: true, kundenmail: false },
  })
  const nachts = k31Mail(abDokument({ ...AUFWAND, gesendetAm: '2026-10-06T22:30:00.000Z' }), { token: TOKEN, erinnerung: true })
  assert.equal(nachts.values.datum, '07.10.2026')
  assert.equal(nachts.values.betrag, `${euroDe(10000)} pro Stunde`)
  assert.deepEqual(nachts.blocks, { erinnerung: true, privat: false, festpreis: false, aufwand: true, teamkopie: false, kundenmail: true })
})

test('k32Mail und t22Mail: Zeitpunkt in Berliner Zeit, Frühbeginn-Erklärung wörtlich, Blöcke je Kundenart und Wunsch', () => {
  const ja = BESTAETIGT({ fruehbeginn: true })
  assert.deepEqual(k32Mail(ja), {
    template: 'K32',
    values: { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', fruehester_beginn: '', fruehbeginn_text: FRUEHBEGINN_TEXT },
    blocks: { fruehbeginn: true, ohne_fruehbeginn: false },
  })
  const nein = BESTAETIGT({ bestaetigtUeber: 'portal' })
  assert.equal(k32Mail(nein).values.fruehester_beginn, '21.10.2026')
  assert.equal(k32Mail(nein).values.fruehbeginn_text, '')
  assert.deepEqual(k32Mail(nein).blocks, { fruehbeginn: false, ohne_fruehbeginn: true })
  const firma = BESTAETIGT({ kunde: kunde('unternehmen') })
  assert.deepEqual(k32Mail(firma).blocks, { fruehbeginn: false, ohne_fruehbeginn: false })
  assert.deepEqual([k32Mail(firma).values.fruehester_beginn, k32Mail(firma).values.fruehbeginn_text], ['', ''])
  assert.deepEqual(t22Mail(nein), {
    template: 'T22',
    values: { kunde: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', ueber: 'Kundenportal', woms_link: `${config.womsBaseUrl}/customers/c1?tab=invoices`, fruehester_beginn: '21.10.2026' },
    blocks: { privat_fruehbeginn: false, privat_ohne_fruehbeginn: true },
  })
  assert.equal(t22Mail(firma).values.kunde, 'Muster GmbH')
  assert.equal(t22Mail(ja).values.ueber, 'Bestätigungslink')
  assert.deepEqual(t22Mail(ja).blocks, { privat_fruehbeginn: true, privat_ohne_fruehbeginn: false })
})

test('Builder und Vorlagen passen zusammen: alle Platzhalter und Blöcke belegt, keine Unstimmigkeiten beim Rendern', () => {
  const nachrichten = [
    k31Mail(AB, { token: TOKEN }),
    k31Mail(AB, { token: TOKEN, teamkopie: true }),
    k31Mail(abDokument(AUFWAND), { token: TOKEN, erinnerung: true }),
    k31Mail(abDokument(AUFWAND), { erinnerung: true, teamkopie: true }),
    k32Mail(BESTAETIGT({ fruehbeginn: true })),
    k32Mail(BESTAETIGT()),
    t22Mail(BESTAETIGT({ fruehbeginn: true })),
    t22Mail(BESTAETIGT({ kunde: kunde('unternehmen') })),
  ]
  for (const { template, values, blocks } of nachrichten) {
    const vorlage = loadTemplate(template)
    assert.deepEqual(Object.keys(values).sort(), [...vorlage.placeholders].sort(), template)
    assert.deepEqual(Object.keys(blocks).sort(), [...vorlage.blocks].sort(), template)
    assert.deepEqual(renderMail(template, values, blocks).problems, [], template)
  }
})

test('sendeAuftragMail: K31 an den Kunden ohne BCC (Link im Fragment, AB-PDF und AGB), danach die Teamkopie ohne Link, Token und Anhänge', async () => {
  await sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN })
  assert.equal(sink.sent.length, 2)
  const [kundenmail, teamkopie] = sink.sent
  assert.deepEqual(adressen(kundenmail.to), ['k@example.invalid'])
  assert.ok(!kundenmail.bcc || kundenmail.bcc.length === 0, 'kein BCC ans Team: die Mail trägt den Bestätigungslink')
  assert.equal(kundenmail.subject, K31_BETREFF)
  assert.ok(kundenmail.text.includes(`Auftrag bestätigen: ${config.publicBaseUrl}/auftrag.html#t=${TOKEN}`))
  assert.deepEqual(kundenmail.attachments.map((a) => [a.filename, a.contentType]), [['AB-2026-0001.pdf', 'application/pdf'], ['WEBklar-AGB-2026-09-30.pdf', 'application/pdf']])
  assert.ok(Buffer.from(kundenmail.attachments[0].content, 'base64').equals(AB_PDF))
  assert.ok(Buffer.from(kundenmail.attachments[1].content, 'base64').equals(AGB_PDF))
  assert.deepEqual(adressen(teamkopie.to), config.chatNotify.emails)
  assert.equal(teamkopie.subject, K31_BETREFF)
  assert.ok(teamkopie.text.split('\n').includes('Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.'))
  assert.ok(!teamkopie.attachments || teamkopie.attachments.length === 0, 'die AB-PDF liegt im Ticketsystem')
  const geheim = JSON.stringify(teamkopie)
  for (const spur of [TOKEN, 'auftrag.html#t=', 'auftrag.html?t=']) assert.ok(!geheim.includes(spur), `die Teamkopie enthält ${spur}`)
})

test('sendeAuftragMail: die Erinnerung geht auch als Teamkopie raus; ohne pdf wird die gespeicherte Datei aus der Ablage gelesen', async () => {
  const { pdfDatei } = await speicherePdf('c1', 'ab-erinnerung', AB_PDF)
  await sendeAuftragMail(abDokument({ ...AUFWAND, pdfDatei }), { token: TOKEN, erinnerung: true })
  assert.equal(sink.sent.length, 2)
  for (const mail of sink.sent) assert.ok(mail.text.includes('Wir haben Ihnen diese Auftragsbestätigung am 06.10.2026 schon einmal geschickt'))
  assert.ok(Buffer.from(sink.sent[0].attachments[0].content, 'base64').equals(AB_PDF))
  assert.ok(!JSON.stringify(sink.sent[1]).includes(TOKEN))
})

test('sendeAuftragMail: wirft und verschickt nichts bei fehlender AGB-Datei, falschem Token oder fehlender Adresse; scheitert die Kundenmail, geht keine Teamkopie raus', async () => {
  await assert.rejects(sendeAuftragMail(abDokument({ agbFassung: '1999-01-01' }), { pdf: AB_PDF, token: TOKEN }), /AGB-PDF 1999-01-01 nicht lesbar/)
  await assert.rejects(sendeAuftragMail(AB, { pdf: AB_PDF, token: 'kurz' }), TypeError)
  await assert.rejects(sendeAuftragMail(abDokument({ gesendetAn: '', kunde: kunde('privat', { email: '' }) }), { pdf: AB_PDF, token: TOKEN }), /E-Mail-Adresse/)
  assert.equal(sink.sent.length, 0)
  let versuche = 0
  setMailTransportForTests({ async sendMail() { versuche += 1; throw new Error('SMTP 451') } })
  await assert.rejects(sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN }), /SMTP 451/)
  assert.equal(versuche, 1, 'nach dem Fehler der Kundenmail keine Teamkopie')
})

test('sendeAuftragMail: scheitert nur die Teamkopie, gilt der Versand als erfolgreich (nur Protokoll mit der Nummer)', async () => {
  let aufrufe = 0
  setMailTransportForTests({
    async sendMail(mail) {
      aufrufe += 1
      if (aufrufe === 2) throw new Error('SMTP 451')
      return sink.sendMail(mail)
    },
  })
  await sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN })
  assert.equal(aufrufe, 2)
  assert.deepEqual(sink.sent.map((m) => adressen(m.to)), [['k@example.invalid']])
  assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('Teamkopie') && c.arguments.join(' ').includes('AB-2026-0001')))
})

test('sendeBestaetigtMails: K32 an den Kunden (Kopie ans Team), danach T22 an das Team', async () => {
  await sendeBestaetigtMails(BESTAETIGT({ fruehbeginn: true }))
  assert.deepEqual(sink.sent.map((m) => m.subject), ['Ihre Bestätigung zu AB-2026-0001 ist eingegangen', T22_BETREFF])
  const [k32, t22] = sink.sent
  assert.deepEqual(adressen(k32.to), ['k@example.invalid'])
  assert.deepEqual(adressen(k32.bcc), config.chatNotify.emails)
  assert.deepEqual(adressen(t22.to), config.chatNotify.emails)
  assert.ok(k32.text.includes('Bestätigt am: 07.10.2026, 10:30 Uhr'))
  assert.ok(t22.text.includes(`Im Ticketsystem öffnen: ${config.womsBaseUrl}/customers/c1?tab=invoices`))
})

test('sendeBestaetigtMails wirft nie: scheitert K32, geht T22 trotzdem raus; ohne Adresse entfällt K32; Fehler stehen mit der Nummer im Protokoll', async () => {
  let aufrufe = 0
  setMailTransportForTests({
    async sendMail(mail) {
      aufrufe += 1
      if (aufrufe === 1) throw new Error('SMTP 451')
      return sink.sendMail(mail)
    },
  })
  await sendeBestaetigtMails(BESTAETIGT())
  assert.deepEqual(sink.sent.map((m) => m.subject), [T22_BETREFF])
  assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('K32') && c.arguments.join(' ').includes('AB-2026-0001')))
  sink.sent.length = 0
  setMailTransportForTests(sink)
  await sendeBestaetigtMails(BESTAETIGT({ gesendetAn: '', kunde: kunde('privat', { email: '' }) }))
  assert.deepEqual(sink.sent.map((m) => m.subject), [T22_BETREFF])
})
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeMails.test.js Expected: FAIL mit ERR_MODULE_NOT_FOUND (mails.js fehlt).

  • Step 3: Implementieren server/services/auftraege/mails.js

Nicht exportierte Hilfen:

  • tagText(iso): formatDe(berlinDate(new Date(iso))); fehlt der Zeitpunkt oder ist er ungültig, TypeError. zeitpunktText(iso): <tagText>, HH:MM mit Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).
  • preisText(auftrag): Festpreis euroDe(summeCents), Aufwand `${euroDe(stundensatzCents)} pro Stunde`.
  • empfaenger(auftrag): String(auftrag.gesendetAn || auftrag.kunde?.email || '').trim().

Builder (rein; die Werte und Blöcke genau wie im Test; kunde = auftrag.kunde ?? {}, privat = kunde.kundenart === 'privat'):

  • k31Mail(auftrag, { token, erinnerung = false, teamkopie = false }): name = kunde.name, nummer, titel, datum = tagText(gesendetAm), betrag = preisText, link = teamkopie ? '' : ${config.publicBaseUrl}/auftrag.html#t=${token}`` (das Token wird bei der Teamkopie nie benutzt), gueltig_bis = tagText(tokenGueltigBis); Blöcke erinnerung, privat: privat && !teamkopie, festpreis/aufwand (nach art), teamkopie, kundenmail: !teamkopie.
  • k32Mail: zeitpunkt = zeitpunktText(bestaetigtAm); fruehester_beginn = fruehesterBeginn(auftrag) als formatDe, sonst ''; fruehbeginn = privat && auftrag.fruehbeginn, ohne_fruehbeginn = privat && !auftrag.fruehbeginn; fruehbeginn_text = fruehbeginn ? FRUEHBEGINN_TEXT : '' (aus ./texte.js).
  • t22Mail: Wert kunde = Firma, sonst Name, sonst E-Mail des Kunden-Snapshots; ueber = { link: 'Bestätigungslink', portal: 'Kundenportal' }[auftrag.bestaetigtUeber] ?? ''; woms_link = ${config.womsBaseUrl}/customers/${auftrag.kundenId}?tab=invoices; übrige Werte und Blöcke wie K32 (privat_fruehbeginn, privat_ohne_fruehbeginn).

AGB-Pfad: Modul-Variable für das Verzeichnis (Vorgabe <Repo>/public/rechtliches, ermittelt aus import.meta.url mit drei Ebenen nach oben: server/services/auftraege/ → Repo); setzeAgbVerzeichnisFuerTests(verzeichnis | null); agbPdfPfad(fassung = AGB_FASSUNG) wirft TypeError, wenn fassung nicht ^[A-Za-z0-9._-]{1,20}$ entspricht, sonst path.join(verzeichnis, agb-${fassung}.pdf).

sendeAuftragMail(auftrag, { pdf, token, erinnerung = false }), in dieser Reihenfolge; die Schritte 1 bis 4 werfen vor jedem Versand:

  1. Adresse = empfaenger(auftrag), leer → Error('Keine E-Mail-Adresse für die Auftragsbestätigung').
  2. hashToken(token) === null → TypeError('sendeAuftragMail: token fehlt oder hat ein falsches Format').
  3. AB-PDF = pdf, wenn es ein Buffer ist, sonst await lesePdf(auftrag.pdfDatei) (Rückfall; der Fehler von lesePdf geht durch).
  4. Fassung = auftrag.agbFassung || AGB_FASSUNG; AGB-PDF aus agbPdfPfad(fassung) lesen, Fehler als Error(AGB-PDF ${fassung} nicht lesbar: ${err.code || err.message}).
  5. Kundenmail: await sendCustomerMail(adresse, { ...k31Mail(auftrag, { token, erinnerung }), attachments: [{ filename: ${auftrag.nummer}.pdf, content: abPdf, contentType: 'application/pdf' }, { filename: WEBklar-AGB-${fassung}.pdf, content: agbPdf, contentType: 'application/pdf' }], bccTeam: false }). Scheitert sie, wirft die Funktion und es geht keine Teamkopie raus.
  6. Teamkopie: const kopie = await sendAdminNotice(k31Mail(auftrag, { erinnerung, teamkopie: true })) (ohne Token, ohne Anhänge; sendAdminNotice wirft nie). Ist kopie.sent falsch, nur console.error mit „Teamkopie“, der AB-Nummer und kopie.reason; der Versand gilt trotzdem als erfolgreich.

sendeBestaetigtMails(auftrag): K32 und T22 je einzeln in try/catch; Fehler → console.error mit Vorlage und AB-Nummer, nie mit Adresse. Ohne Adresse entfällt K32 (Protokolleintrag), T22 geht immer. Reihenfolge K32 (sendCustomerMail, Kopie ans Team wie üblich: die Mail trägt keinen Link), dann T22 (sendAdminNotice, das selbst nie wirft; kein BCC).

  • Step 4: Lauf, erwartet PASS

Run: wie Step 2. Expected: # fail 0.

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5
git add server/services/auftraege/mails.js test/auftraegeBestaetigungHilfe.js test/auftraegeMails.test.js
git commit -m "Aufträge L5: Mails zur Auftragsbestätigung (K31 ohne BCC mit Teamkopie ohne Link, K32, T22)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 5: Auftrag bestätigen (bestaetigung.js)

Files:

  • Create: server/services/auftraege/bestaetigung.js
  • Test: test/auftraegeBestaetigung.test.js

Interfaces:

  • Consumes: L0 getAuftrag(id) → Auftrag | null, updateAuftrag(id, patch) → Auftrag (auftragStore.js), AuftragError(status, message) (fehler.js), sperreAuftrag(id) (konstanten.js, V2: ein zusammengesetzter Schlüssel ['auftraege-ab:<id>']), FRUEHBEGINN_FASSUNG (texte.js); withLocks(keys, fn) (server/services/keyedLock.js); sendeBestaetigtMails (Task 4).

  • Produces: bestaetigeAuftrag(auftrag, { ueber, fruehbeginn, jetzt = new Date() }) → Promise<Auftrag> (V4); dazu bestaetigungsLage(auftrag) → 'offen' | 'bestaetigt' | 'gesperrt', GESPERRT_MELDUNG ({ ersetzt, storniert, widerrufen } → Meldungstext).

  • Step 1: Failing Tests test/auftraegeBestaetigung.test.js

import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'

// bestaetigung.js: Bestätigen (idempotent, unter Sperre) und Widerrufen einer AB; Mails über die Senke
const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
const { FRUEHBEGINN_FASSUNG, FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js')
const { sperreAuftrag } = await import('../server/services/auftraege/konstanten.js')
const { withLocks } = await import('../server/services/keyedLock.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { bestaetigeAuftrag, bestaetigungsLage } = await import('../server/services/auftraege/bestaetigung.js')
after(() => {
  setMailTransportForTests(null)
  return aw.close()
})

const JETZT = new Date('2026-10-07T08:30:00.000Z') // 10:30 Uhr in Berlin (MESZ)
const ms = (iso) => new Date(iso).getTime()
let sink
let protokoll
beforeEach(() => {
  sink = mailSink()
  setMailTransportForTests(sink)
  protokoll = mock.method(console, 'error', () => {})
})
afterEach(() => mock.restoreAll())
const seed = (over = {}) => {
  const ab = abDokument(over)
  aw.seed('auftraege', { $id: ab.id, ...auftragToDoc(ab) })
  return ab
}
const betreffe = () => sink.sent.map((mail) => mail.subject)
const K32 = 'Ihre Bestätigung zu AB-2026-0001 ist eingegangen'
const T22 = '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster'
const norm = (text) => text.replace(/\s+/g, ' ').trim()

test('bestaetigungsLage: offen, bestaetigt oder gesperrt', () => {
  const lage = (over) => bestaetigungsLage(abDokument(over))
  assert.equal(lage({}), 'offen')
  assert.equal(lage({ status: 'abgerechnet' }), 'offen')
  assert.equal(lage({ status: 'abgerechnet', bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'bestaetigt')
  assert.equal(lage({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'bestaetigt')
  for (const status of ['entwurf', 'ersetzt', 'storniert', 'widerrufen']) assert.equal(lage({ status, bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'gesperrt', status)
})

test('bestaetigeAuftrag: Privatperson über den Link mit Frühbeginn – Protokoll am Dokument, K32 und T22', async () => {
  const ab = seed()
  const neu = await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: true, jetzt: JETZT })
  assert.equal(neu.status, 'bestaetigt')
  assert.equal(ms(neu.bestaetigtAm), JETZT.getTime())
  assert.equal(neu.bestaetigtUeber, 'link')
  assert.equal(neu.bestaetigtPdfSha256, ab.pdfSha256)
  assert.equal(neu.fruehbeginn, true)
  assert.equal(neu.fruehbeginnFassung, FRUEHBEGINN_FASSUNG)
  const doc = await getAuftrag('ab1')
  assert.deepEqual([doc.status, doc.bestaetigtUeber, doc.fruehbeginn, ms(doc.bestaetigtAm)], ['bestaetigt', 'link', true, JETZT.getTime()])
  assert.deepEqual(betreffe(), [K32, T22])
  assert.ok(norm(sink.sent[0].text).includes(norm(FRUEHBEGINN_TEXT)), 'K32 gibt die Erklärung wörtlich wieder')
  assert.ok(sink.sent[1].text.includes(`${config.womsBaseUrl}/customers/c1?tab=invoices`))
})

test('bestaetigeAuftrag: Privatperson über das Portal ohne Frühbeginn – frühester Beginn nach der Widerrufsfrist', async () => {
  const neu = await bestaetigeAuftrag(seed(), { ueber: 'portal', fruehbeginn: false, jetzt: JETZT })
  assert.equal(neu.bestaetigtUeber, 'portal')
  assert.equal(neu.fruehbeginn, false)
  assert.equal(neu.fruehbeginnFassung, FRUEHBEGINN_FASSUNG, 'die angezeigte Fassung der Erklärung wird auch ohne Haken festgehalten')
  assert.ok(sink.sent[0].text.includes('Wir beginnen daher frühestens am 21.10.2026 mit der Arbeit.'))
  assert.ok(sink.sent[1].text.includes('Arbeitsbeginn frühestens am 21.10.2026'))
  assert.ok(sink.sent[1].text.includes('Weg: Kundenportal'))
})

test('bestaetigeAuftrag: Unternehmen – fruehbeginn wird ignoriert, keine Beginn-Hinweise in K32 und T22', async () => {
  const neu = await bestaetigeAuftrag(seed({ kunde: kunde('unternehmen') }), { ueber: 'link', fruehbeginn: true, jetzt: JETZT })
  assert.equal(neu.fruehbeginn, false)
  assert.equal(neu.fruehbeginnFassung, '')
  for (const mail of sink.sent) assert.doesNotMatch(mail.text, /Beginn der Arbeiten|frühestens|Erklärung zum früheren Beginn|Die Arbeit darf sofort/)
  assert.equal(sink.sent[1].subject, '[Aufträge] Auftrag AB-2026-0001 bestätigt: Muster GmbH')
})

test('bestaetigeAuftrag: abgerechnete AB ohne frühere Bestätigung – Status bleibt abgerechnet', async () => {
  const neu = await bestaetigeAuftrag(seed({ status: 'abgerechnet' }), { ueber: 'portal', fruehbeginn: false, jetzt: JETZT })
  assert.equal(neu.status, 'abgerechnet')
  assert.equal(ms(neu.bestaetigtAm), JETZT.getTime())
  assert.equal((await getAuftrag('ab1')).status, 'abgerechnet')
  assert.equal(sink.sent.length, 2)
})

test('bestaetigeAuftrag: die zweite Bestätigung ändert nichts und verschickt nichts (die erste zählt, auch bei veraltetem Eingabeobjekt)', async () => {
  const ab = seed()
  const erste = await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: true, jetzt: JETZT })
  const zweite = await bestaetigeAuftrag(ab, { ueber: 'portal', fruehbeginn: false, jetzt: new Date(JETZT.getTime() + 3_600_000) })
  assert.equal(ms(zweite.bestaetigtAm), ms(erste.bestaetigtAm))
  assert.equal(zweite.bestaetigtUeber, 'link')
  assert.equal(zweite.fruehbeginn, true)
  assert.equal(sink.sent.length, 2, 'nur die Mails der ersten Bestätigung')
})

test('bestaetigeAuftrag: zwei gleichzeitige Bestätigungen (Doppelklick, Link und Portal) – eine zählt, genau zwei Mails', async () => {
  const ab = seed()
  const [a, b] = await Promise.all([
    bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }),
    bestaetigeAuftrag(ab, { ueber: 'portal', fruehbeginn: false, jetzt: new Date(JETZT.getTime() + 1000) }),
  ])
  assert.equal(ms(a.bestaetigtAm), ms(b.bestaetigtAm))
  assert.equal(a.bestaetigtUeber, 'link', 'der zuerst gestartete Aufruf gewinnt')
  assert.equal(b.bestaetigtUeber, 'link')
  assert.equal(sink.sent.length, 2)
})

test('bestaetigeAuftrag: läuft unter der Sperre sperreAuftrag(id); K32 und T22 gehen erst nach ihrer Freigabe raus', async () => {
  const ab = seed()
  let freigeben
  const gehalten = new Promise((resolve) => {
    freigeben = resolve
  })
  const halter = withLocks(sperreAuftrag('ab1'), () => gehalten)
  let fertig = false
  const bestaetigt = bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }).then((neu) => {
    fertig = true
    return neu
  })
  await new Promise((resolve) => setTimeout(resolve, 100))
  assert.equal(fertig, false, 'wartet, solange ein anderer die Sperre der AB hält')
  assert.equal((await getAuftrag('ab1')).status, 'versendet')
  freigeben()
  await halter
  assert.equal((await bestaetigt).status, 'bestaetigt')
  // Mails erst nach der Freigabe: im Versand ist die Sperre der AB wieder frei (sonst käme das Rennen nach 300 ms mit false zurück)
  seed()
  sink.sent.length = 0
  const frei = []
  setMailTransportForTests({
    async sendMail(mail) {
      frei.push(await Promise.race([withLocks(sperreAuftrag('ab1'), async () => true), new Promise((resolve) => setTimeout(() => resolve(false), 300))]))
      return sink.sendMail(mail)
    },
  })
  await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT })
  assert.deepEqual(frei, [true, true])
  assert.equal(sink.sent.length, 2)
})

test('bestaetigeAuftrag: Entwurf, ersetzt, storniert und widerrufen sind nicht bestätigbar (409), nichts ändert sich, keine Mail', async () => {
  const faelle = [['entwurf', 'Diese Auftragsbestätigung kann nicht bestätigt werden.'], ['ersetzt', 'Diese Auftragsbestätigung wurde ersetzt.'], ['storniert', 'Diese Auftragsbestätigung wurde storniert.'], ['widerrufen', 'Diese Auftragsbestätigung wurde widerrufen.']]
  for (const [status, meldung] of faelle) {
    const ab = seed({ status })
    await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 409 && err.message === meldung, status)
    const doc = await getAuftrag('ab1')
    assert.deepEqual([doc.status, doc.bestaetigtAm], [status, null], status)
  }
  assert.equal(sink.sent.length, 0)
})

test('bestaetigeAuftrag: scheitert der Mailversand, bleibt die Bestätigung bestehen (Fehler nur im Protokoll)', async () => {
  setMailTransportForTests({ async sendMail() { throw new Error('SMTP 451') } })
  const neu = await bestaetigeAuftrag(seed(), { ueber: 'link', fruehbeginn: false, jetzt: JETZT })
  assert.equal(neu.status, 'bestaetigt')
  assert.equal((await getAuftrag('ab1')).status, 'bestaetigt')
  assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('AB-2026-0001')), 'Fehler mit Nummer im Protokoll')
})

test('bestaetigeAuftrag: ungültiger Weg oder Zeitpunkt wirft TypeError, unbekannte AB 404', async () => {
  const ab = seed()
  await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'telefon', fruehbeginn: false, jetzt: JETZT }), TypeError)
  await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: 'gestern' }), TypeError)
  await assert.rejects(bestaetigeAuftrag(abDokument({ id: 'gibtsnicht' }), { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 404)
  assert.equal(sink.sent.length, 0)
})
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeBestaetigung.test.js Expected: FAIL mit ERR_MODULE_NOT_FOUND (bestaetigung.js fehlt).

  • Step 3: Implementieren server/services/auftraege/bestaetigung.js

Zustandsregeln (V2):

  • GESPERRT_MELDUNG = { ersetzt: 'Diese Auftragsbestätigung wurde ersetzt.', storniert: 'Diese Auftragsbestätigung wurde storniert.', widerrufen: 'Diese Auftragsbestätigung wurde widerrufen.' }.
  • bestaetigungsLage(a): Status entwurf, ersetzt, storniert, widerrufen → 'gesperrt'; sonst 'bestaetigt', wenn a.bestaetigtAm gesetzt oder a.status === 'bestaetigt'; sonst 'offen' (also versendet und abgerechnet ohne bestaetigtAm).

bestaetigeAuftrag(auftrag, { ueber, fruehbeginn, jetzt = new Date() }) (async; die Prüfungen 1 und 2 laufen synchron vor der Sperre, damit der zuerst gestartete Aufruf die Sperre zuerst bekommt):

  1. ueber nicht 'link' oder 'portal' → TypeError.
  2. jetzt kein Date mit gültiger Zeit → TypeError.
  3. withLocks(sperreAuftrag(auftrag.id), …) mit sperreAuftrag aus ./konstanten.js (nicht neu definieren): frisch = await getAuftrag(auftrag.id); null → AuftragError(404, 'Auftragsbestätigung nicht gefunden.').
  4. Lage gesperrt → AuftragError(409, GESPERRT_MELDUNG[frisch.status] ?? 'Diese Auftragsbestätigung kann nicht bestätigt werden.'). Lage bestaetigt → frisch unverändert zurück, keine Mails.
  5. Lage offen: privat = frisch.kunde?.kundenart === 'privat'; updateAuftrag(frisch.id, { bestaetigtAm: jetzt.toISOString(), bestaetigtUeber: ueber, bestaetigtPdfSha256: frisch.pdfSha256, fruehbeginn: privat && fruehbeginn === true, fruehbeginnFassung: privat ? FRUEHBEGINN_FASSUNG : '' }), dazu status: 'bestaetigt' nur wenn frisch.status === 'versendet' (bei abgerechnet bleibt der Status). Ergebnis merken.
  6. Erst nach der Freigabe der Sperre (der Test oben hält das fest) und nur, wenn dieser Aufruf bestätigt hat: await sendeBestaetigtMails(neu). Rückgabe: das gespeicherte Dokument.
  • Step 4: Lauf, erwartet PASS

Run: wie Step 2. Expected: # fail 0.

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5
git add server/services/auftraege/bestaetigung.js test/auftraegeBestaetigung.test.js
git commit -m "Aufträge L5: Auftrag bestätigen (idempotent, unter Sperre, Frühbeginn nur bei Privatpersonen)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 6: Auftrag widerrufen (widerrufeAuftrag)

Files:

  • Modify: server/services/auftraege/bestaetigung.js
  • Test: test/auftraegeBestaetigung.test.js (ergänzen)

Interfaces:

  • Consumes: wie Task 5 (insbesondere sperreAuftrag(id) aus konstanten.js, V2: ein zusammengesetzter Schlüssel, und withLocks); withdrawalDeadline(contractAt) → { lastDay, endsAt } (server/services/deadlines.js).

  • Produces: widerrufeAuftrag(auftrag, { jetzt = new Date() }) → Promise<{ auftrag, ergebnis, fristEnde }> mit ergebnis ∈ 'widerrufen' | 'bereits_widerrufen' | 'kein_widerrufsrecht' | 'nach_frist' | 'nicht_widerrufbar' und fristEnde: Date | null (= withdrawalDeadline(new Date(gesendetAm)).endsAt). Genutzt von abo.js (Task 12); V2: versendet|bestaetigt → widerrufen, nur Kundenart privat. Die Funktion verschickt selbst keine Mails: K04 und T09 schickt abo.js nach der Rückkehr, also nach Freigabe der Sperre.

  • Step 1: Failing Tests

In test/auftraegeBestaetigung.test.js die Importzeile const { bestaetigeAuftrag, bestaetigungsLage } = … zu const { bestaetigeAuftrag, bestaetigungsLage, widerrufeAuftrag } = … ändern und anhängen:

// gesendet am 06.10.2026 12:00 Berlin: die Frist endet am 20.10.2026 um 24 Uhr (= 21:59:59,999 UTC)
const FRIST_ENDE = new Date('2026-10-20T21:59:59.999Z')

test('widerrufeAuftrag: Privatperson – am letzten Tag bis 24 Uhr (eingeschlossen) widerrufen, eine Millisekunde später nach_frist', async () => {
  const ab = seed()
  const ok = await widerrufeAuftrag(ab, { jetzt: FRIST_ENDE })
  assert.equal(ok.ergebnis, 'widerrufen')
  assert.equal(ok.auftrag.status, 'widerrufen')
  assert.equal(ms(ok.auftrag.widerrufenAm), FRIST_ENDE.getTime())
  assert.equal(ok.fristEnde.getTime(), FRIST_ENDE.getTime())
  assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
  seed()
  const spaet = await widerrufeAuftrag(ab, { jetzt: new Date(FRIST_ENDE.getTime() + 1) })
  assert.equal(spaet.ergebnis, 'nach_frist')
  assert.equal(spaet.auftrag.status, 'versendet')
  assert.equal((await getAuftrag('ab1')).widerrufenAm, null)
})

test('widerrufeAuftrag: bestätigte AB wird widerrufen, bestaetigtAm bleibt als Beleg; Unternehmen: kein_widerrufsrecht', async () => {
  const bestaetigt = seed({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', fruehbeginn: true })
  const r = await widerrufeAuftrag(bestaetigt, { jetzt: JETZT })
  assert.equal(r.ergebnis, 'widerrufen')
  assert.equal(r.auftrag.fruehbeginn, true)
  assert.equal(ms((await getAuftrag('ab1')).bestaetigtAm), JETZT.getTime())
  const firma = seed({ kunde: kunde('unternehmen') })
  const k = await widerrufeAuftrag(firma, { jetzt: JETZT })
  assert.equal(k.ergebnis, 'kein_widerrufsrecht')
  assert.equal((await getAuftrag('ab1')).status, 'versendet')
})

test('widerrufeAuftrag: bereits widerrufen, abgerechnet, ersetzt, storniert, Entwurf – nichts ändert sich', async () => {
  const faelle = [['widerrufen', 'bereits_widerrufen'], ['abgerechnet', 'nicht_widerrufbar'], ['ersetzt', 'nicht_widerrufbar'], ['storniert', 'nicht_widerrufbar'], ['entwurf', 'nicht_widerrufbar']]
  for (const [status, ergebnis] of faelle) {
    const ab = seed({ status, widerrufenAm: status === 'widerrufen' ? '2026-10-08T08:00:00.000Z' : null })
    const r = await widerrufeAuftrag(ab, { jetzt: JETZT })
    assert.equal(r.ergebnis, ergebnis, status)
    const doc = await getAuftrag('ab1')
    assert.equal(doc.status, status, status)
    assert.equal(doc.widerrufenAm === null ? null : ms(doc.widerrufenAm), status === 'widerrufen' ? new Date('2026-10-08T08:00:00.000Z').getTime() : null, status)
  }
})

test('widerrufeAuftrag: wartet auf die Sperre sperreAuftrag(id) und liest den Stand erst darin (veraltetes Eingabeobjekt)', async () => {
  const veraltet = seed()
  let freigeben
  const gehalten = new Promise((resolve) => {
    freigeben = resolve
  })
  const halter = withLocks(sperreAuftrag('ab1'), () => gehalten)
  let fertig = false
  const widerruf = widerrufeAuftrag(veraltet, { jetzt: JETZT }).then((r) => {
    fertig = true
    return r
  })
  await new Promise((resolve) => setTimeout(resolve, 100))
  assert.equal(fertig, false, 'wartet, solange ein anderer die Sperre der AB hält')
  seed({ status: 'storniert' }) // währenddessen von Hand storniert (L3)
  freigeben()
  await halter
  const r = await widerruf
  assert.equal(r.ergebnis, 'nicht_widerrufbar', 'maßgeblich ist der Stand innerhalb der Sperre')
  assert.equal((await getAuftrag('ab1')).status, 'storniert')
  assert.equal(sink.sent.length, 0, 'widerrufeAuftrag verschickt keine Mails')
})

test('Bestätigen und Widerrufen gleichzeitig: der Widerruf geht nie verloren, in jeder Reihenfolge', async () => {
  const ab = seed()
  const [bestaetigt, widerrufen] = await Promise.all([
    bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }),
    widerrufeAuftrag(ab, { jetzt: JETZT }),
  ])
  assert.equal(bestaetigt.status, 'bestaetigt')
  assert.equal(widerrufen.ergebnis, 'widerrufen')
  assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
  seed()
  const umgekehrt = await Promise.allSettled([
    widerrufeAuftrag(ab, { jetzt: JETZT }),
    bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }),
  ])
  assert.equal(umgekehrt[0].value.ergebnis, 'widerrufen')
  assert.ok(umgekehrt[1].reason instanceof AuftragError && umgekehrt[1].reason.status === 409)
  assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
})
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeBestaetigung.test.js Expected: FAIL (widerrufeAuftrag is not a function).

  • Step 3: Implementieren widerrufeAuftrag in server/services/auftraege/bestaetigung.js

Die Prüfung von jetzt (wie in Task 5) läuft synchron vor der Sperre, damit die Reihenfolge der Aufrufe die Reihenfolge an der Sperre bestimmt. Alles Weitere läuft unter withLocks(sperreAuftrag(auftrag.id), …) (sperreAuftrag aus ./konstanten.js, nicht neu definieren; nie sperreRechnung darin): den Stand frisch lesen (null → AuftragError(404, 'Auftragsbestätigung nicht gefunden.')). fristEnde = a.gesendetAm ? withdrawalDeadline(new Date(a.gesendetAm)).endsAt : null. Ergebnis in dieser Reihenfolge:

  1. a.status === 'widerrufen' → 'bereits_widerrufen'.
  2. a.kunde?.kundenart !== 'privat' → 'kein_widerrufsrecht'.
  3. Status nicht versendet oder bestaetigt, oder fristEnde === null → 'nicht_widerrufbar'.
  4. jetzt.getTime() > fristEnde.getTime() → 'nach_frist'.
  5. sonst updateAuftrag(a.id, { status: 'widerrufen', widerrufenAm: jetzt.toISOString() }) → 'widerrufen'. Nur Fall 5 schreibt; Rückgabe immer { auftrag: <aktueller Stand>, ergebnis, fristEnde }. Mails verschickt die Funktion nicht (das tut abo.js in Task 12 nach der Freigabe der Sperre).
  • Step 4: Lauf, erwartet PASS

Run: wie Step 2. Expected: # fail 0.

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5
git add server/services/auftraege/bestaetigung.js test/auftraegeBestaetigung.test.js
git commit -m "Aufträge L5: Auftrag widerrufen (nur Privatpersonen innerhalb der Frist, unter Sperre)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Files:

  • Modify: server/routes/auftrag.js (Gerüst aus L0 ersetzen, export default router bleibt)
  • Test: test/auftraegeTokenRoutes.test.js (neu)

Interfaces:

  • Consumes: Task 2 hashToken; L0 findAuftragByTokenHash(hash), oeffentlicheAnsicht(auftrag), auftraegeAktiv(kunde), lesePdf(pdfDatei), AuftragError, sendeFehler(res, was, err); Task 5 GESPERRT_MELDUNG; createWriteLimiter({ max, now }) (server/services/writeLimiter.js).

  • Produces: GET /api/auftrag/:token → { auftrag: AuftragOeffentlich }; GET /api/auftrag/:token/pdf → application/pdf; Fehler 404/410/429 wie V5; Exporte setzeUhrFuerTests(fn | null) und resetAuftragRoutesForTests() (leert die Zähler, Uhr zurück auf die echte).

  • Step 1: Failing Tests test/auftraegeTokenRoutes.test.js

import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
import { hashToken } from '../server/services/auftraege/token.js'
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, SHA, TOKEN, TOKEN_NEU, UNGUELTIG, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'

// Öffentliche Link-Routen /api/auftrag/:token (V5): ansehen, PDF, bestätigen; Ratenbegrenzung, Schalter, Ablauf, Status
process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-token-'))
process.env.AUFTRAEGE_ENABLED = 'on'
const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { startTestApp, send } = await import('./support/testApp.js')
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { speicherePdf } = await import('../server/services/auftraege/ablage.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { default: tokenRoutes, resetAuftragRoutesForTests, setzeUhrFuerTests } = await import('../server/routes/auftrag.js')
const app = await startTestApp({ '/api/auftrag': tokenRoutes })
after(() => {
  setMailTransportForTests(null)
  return Promise.all([app.close(), aw.close()])
})

const PDF = '%PDF-1.4 Test-AB'
await speicherePdf('c1', 'ab1', Buffer.from(PDF))
const JETZT = new Date('2026-10-10T09:00:00.000Z')
const GRUND = { ersetzt: 'Diese Auftragsbestätigung wurde ersetzt.', storniert: 'Diese Auftragsbestätigung wurde storniert.', widerrufen: 'Diese Auftragsbestätigung wurde widerrufen.' }
const ZU_VIELE = { error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }

let sink
const seed = (over = {}) => aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument(over)) })
beforeEach(() => {
  resetAuftragRoutesForTests()
  setzeUhrFuerTests(() => JETZT)
  config.auftraege.mode = 'on'
  config.auftraege.pilotEmails = []
  sink = mailSink()
  setMailTransportForTests(sink)
  seed()
})
const get = (token = TOKEN, rest = '', opts = {}) => send('GET', `/api/auftrag/${token}${rest}`, opts)
const post = (body, token = TOKEN, opts = {}) => send('POST', `/api/auftrag/${token}/bestaetigen`, { ...opts, body })
const ip = (adresse) => ({ headers: { 'x-forwarded-for': adresse } })

test('GET /api/auftrag/:token: öffentliche Ansicht der AB ohne interne Felder, nie zwischengespeichert', async () => {
  const res = await get()
  assert.equal(res.status, 200)
  assert.deepEqual(Object.keys(res.body), ['auftrag'])
  const a = res.body.auftrag
  assert.deepEqual([a.nummer, a.status, a.titel, a.summeCents, a.kundenart], ['AB-2026-0001', 'versendet', 'Website für Musterbäckerei', 80000, 'privat'])
  assert.deepEqual(a.positionen, [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 80000, summeCents: 80000 }])
  assert.match(a.agbUrl, /^\/rechtliches\/agb-.+\.pdf$/)
  for (const intern of ['id', 'kundenId', 'kunde', 'tokenHash', 'tokenGueltigBis', 'pdfDatei', 'pdfSha256', 'bestaetigtPdfSha256', 'gesendetAn', 'zeitquelle', 'erstelltVon']) assert.ok(!(intern in a), `${intern} darf nicht öffentlich sein`)
  assert.equal(res.headers.get('cache-control'), 'no-store')
  assert.equal(res.headers.get('x-robots-tag'), 'noindex')
})

test('ungültige, unbekannte und abgelaufene Links: 404 mit immer derselben Meldung; am letzten Moment gilt der Link noch', async () => {
  for (const token of [TOKEN_NEU, 'kurz', `${TOKEN}x`, `${'T'.repeat(42)}%2B`, `${'T'.repeat(40)}%2e%2e%2f`]) {
    for (const res of [await get(token), await get(token, '/pdf')]) assert.deepEqual([res.status, res.body], [404, { error: UNGUELTIG }], token)
  }
  seed({ tokenGueltigBis: new Date(JETZT.getTime() - 1).toISOString() })
  for (const res of [await get(), await get(TOKEN, '/pdf')]) assert.deepEqual([res.status, res.body], [404, { error: UNGUELTIG }])
  seed({ tokenGueltigBis: JETZT.toISOString() })
  assert.equal((await get()).status, 200)
})

test('Erinnerung mit neuem Link: der alte Link gilt nicht mehr (404), der neue schon', async () => {
  seed({ tokenHash: hashToken(TOKEN_NEU) })
  assert.deepEqual([(await get(TOKEN)).status, (await get(TOKEN_NEU)).status], [404, 200])
})

test('Schalter: bei aus 404 wie bei unbekanntem Link; im Pilot nur für Adressen der Liste', async () => {
  config.auftraege.mode = 'off'
  for (const res of [await get(), await get(TOKEN, '/pdf')]) assert.deepEqual([res.status, res.body], [404, { error: UNGUELTIG }])
  config.auftraege.mode = 'pilot'
  config.auftraege.pilotEmails = ['andere@example.invalid']
  assert.equal((await get()).status, 404)
  config.auftraege.pilotEmails = ['k@example.invalid']
  assert.equal((await get()).status, 200)
})

test('ersetzte, stornierte und widerrufene AB: 410 mit dem Grund – für Ansicht und PDF, auch bei abgelaufenem Link; Entwurf 404', async () => {
  for (const [status, meldung] of Object.entries(GRUND)) {
    seed({ status, tokenGueltigBis: '2026-01-01T00:00:00.000Z' })
    for (const res of [await get(), await get(TOKEN, '/pdf')]) assert.deepEqual([res.status, res.body], [410, { error: meldung }], status)
  }
  seed({ status: 'entwurf' })
  assert.equal((await get()).status, 404)
})

test('GET …/pdf liefert die gespeicherte Datei unverändert und inline; fehlt die Datei, 404', async () => {
  const res = await get(TOKEN, '/pdf')
  assert.equal(res.status, 200)
  assert.equal(res.headers.get('content-type'), 'application/pdf')
  assert.equal(res.headers.get('content-disposition'), 'inline; filename="AB-2026-0001.pdf"')
  assert.equal(res.headers.get('cache-control'), 'no-store')
  assert.equal(res.body, PDF)
  seed({ pdfDatei: 'c1/fehlt.pdf' })
  const fehlt = await get(TOKEN, '/pdf')
  assert.deepEqual([fehlt.status, fehlt.body], [404, { error: 'PDF nicht gefunden.' }])
})

test('Ratenbegrenzung: 120 Aufrufe (GET) je Stunde und IP, danach 429; eine andere IP ist nicht betroffen', async () => {
  for (let i = 1; i <= 120; i += 1) assert.equal((await get(TOKEN, '', ip('203.0.113.9'))).status, 200, `Anfrage ${i}`)
  const zuViele = await get(TOKEN, '', ip('203.0.113.9'))
  assert.deepEqual([zuViele.status, zuViele.body], [429, ZU_VIELE])
  assert.equal((await get(TOKEN, '', ip('203.0.113.10'))).status, 200)
})
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeTokenRoutes.test.js Expected: FAIL (das Gerüst antwortet nicht: 404 statt 200, resetAuftragRoutesForTests is not a function).

  • Step 3: Implementieren server/routes/auftrag.js

Konstanten: UNGUELTIG = 'Dieser Link ist ungültig oder abgelaufen.', ZU_VIELE = 'Zu viele Anfragen. Bitte später erneut versuchen.'. Uhr der Routen: Modul-Variable (Vorgabe () => new Date()), setzeUhrFuerTests(fn) setzt sie (kein fn = zurück auf die echte), resetAuftragRoutesForTests() leert beide Begrenzer und setzt die Uhr zurück. Begrenzer: createWriteLimiter({ max: 120, now }) für GET, createWriteLimiter({ max: 10, now }) für POST (now liest die Modul-Uhr), Schlüssel req.ip, Meldung ZU_VIELE, nur im Speicher. router.use setzt für alle Antworten Cache-Control: no-store, X-Robots-Tag: noindex, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer. Fehler laufen über sendeFehler(res, 'auftrag', err) (Handler in try/catch, Express 4 fängt async-Fehler nicht).

Gemeinsamer Vorlauf ladeNachToken(req, res) → Auftrag | null (bei null ist die Antwort schon gesendet), Reihenfolge fest:

  1. hash = hashToken(req.params.token); null → 404 { error: UNGUELTIG }.
  2. a = await findAuftragByTokenHash(hash); null → 404.
  3. Schalter: !auftraegeAktiv({ email: a.kunde?.email || a.gesendetAn }) → 404 (kein Hinweis auf den Grund).
  4. a.status === 'entwurf' → 404.
  5. GESPERRT_MELDUNG[a.status] vorhanden → 410 { error: <Meldung> } (geht dem Ablauf vor).
  6. tokenGueltigBis fehlt oder uhr().getTime() > Date.parse(tokenGueltigBis) → 404 (am Ende des letzten Moments gilt der Link noch).

GET /:token (Begrenzer GET): Vorlauf, dann res.json({ auftrag: oeffentlicheAnsicht(a) }). GET /:token/pdf (Begrenzer GET): Vorlauf, dann lesePdf(a.pdfDatei); scheitert das mit etwas anderem als einem AuftragError, console.error mit der Nummer und AuftragError(404, 'PDF nicht gefunden.'). Antwort mit res.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Disposition': inline; filename="${nummer}.pdf" }) und res.end(buffer); nummer vorher auf [A-Za-z0-9-] beschränken. Der POST-Pfad folgt in Task 8; die Datei exportiert schon jetzt export default router, setzeUhrFuerTests und resetAuftragRoutesForTests, und beide Begrenzer (der POST-Begrenzer wird in Task 8 benutzt) werden hier angelegt.

  • Step 4: Lauf, erwartet PASS

Run: wie Step 2. Expected: # fail 0.

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5
git add server/routes/auftrag.js test/auftraegeTokenRoutes.test.js
git commit -m "Aufträge L5: öffentliche Link-Routen (ansehen, PDF) mit Ratenbegrenzung, Schalter und Statusprüfung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 8: Öffentliche Bestätigung per Link (POST /api/auftrag/:token/bestaetigen)

Files:

  • Modify: server/routes/auftrag.js
  • Test: test/auftraegeTokenRoutes.test.js (ergänzen)

Interfaces:

  • Consumes: Task 5 bestaetigeAuftrag; Task 7 Vorlauf ladeNachToken (404/410 gehen dem 400 vor) und POST-Begrenzer.

  • Produces: POST /api/auftrag/:token/bestaetigen mit Rumpf { bestaetigt: true, fruehbeginn: boolean } → { auftrag: AuftragOeffentlich }; 400 { error: 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' } ohne bestaetigt === true; 404/410/429 wie Task 7.

  • Step 1: Failing Tests — an test/auftraegeTokenRoutes.test.js anhängen:

const HAEKCHEN = { error: 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' }

test('POST …/bestaetigen ohne bestaetigt: true → 400, nichts geändert, keine Mail', async () => {
  for (const body of [{}, { bestaetigt: false }, { bestaetigt: 'true' }, { bestaetigt: 1 }, { fruehbeginn: true }]) {
    const res = await post(body)
    assert.deepEqual([res.status, res.body], [400, HAEKCHEN], JSON.stringify(body))
  }
  const doc = await getAuftrag('ab1')
  assert.deepEqual([doc.status, doc.bestaetigtAm], ['versendet', null])
  assert.equal(sink.sent.length, 0)
  // die Prüfung des Links geht dem Häkchen vor: bei einem unbekannten Link 404, nie 400
  const unbekannt = await post({}, TOKEN_NEU)
  assert.deepEqual([unbekannt.status, unbekannt.body], [404, { error: UNGUELTIG }])
})

test('POST mit unbekanntem, falschem, altem oder abgelaufenem Link, bei ersetzter AB oder abgeschaltetem Schalter: 404 bzw. 410, nichts ändert sich', async () => {
  const pruefe = async (erwartet, token = TOKEN) => {
    const res = await post({ bestaetigt: true }, token)
    assert.deepEqual([res.status, res.body], erwartet)
    const doc = await getAuftrag('ab1')
    assert.deepEqual([doc.status, doc.bestaetigtAm, sink.sent.length], ['versendet', null, 0])
  }
  await pruefe([404, { error: UNGUELTIG }], TOKEN_NEU)
  await pruefe([404, { error: UNGUELTIG }], 'kurz')
  seed({ tokenHash: hashToken(TOKEN_NEU) }) // wie nach „Erinnerung senden“: nur der neue Link gilt
  await pruefe([404, { error: UNGUELTIG }], TOKEN)
  seed({ tokenGueltigBis: new Date(JETZT.getTime() - 1).toISOString() })
  await pruefe([404, { error: UNGUELTIG }])
  seed({ status: 'ersetzt' })
  const ersetzt = await post({ bestaetigt: true })
  assert.deepEqual([ersetzt.status, ersetzt.body], [410, { error: GRUND.ersetzt }])
  assert.deepEqual([(await getAuftrag('ab1')).bestaetigtAm, sink.sent.length], [null, 0])
  seed()
  config.auftraege.mode = 'off'
  await pruefe([404, { error: UNGUELTIG }])
})

test('POST bestätigt über den Link: Protokoll am Dokument, öffentliche Antwort, K32 und T22; keine IP-Adresse gespeichert', async () => {
  const res = await post({ bestaetigt: true, fruehbeginn: true }, TOKEN, ip('203.0.113.77'))
  assert.equal(res.status, 200)
  assert.deepEqual([res.body.auftrag.status, res.body.auftrag.fruehbeginn, new Date(res.body.auftrag.bestaetigtAm).getTime()], ['bestaetigt', true, JETZT.getTime()])
  assert.ok(!('tokenHash' in res.body.auftrag) && !('pdfSha256' in res.body.auftrag))
  const doc = await getAuftrag('ab1')
  assert.deepEqual([doc.bestaetigtUeber, doc.bestaetigtPdfSha256, doc.fruehbeginn], ['link', SHA, true])
  assert.deepEqual(sink.sent.map((m) => m.subject), ['Ihre Bestätigung zu AB-2026-0001 ist eingegangen', '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster'])
  assert.ok(!JSON.stringify([...aw.docs('auftraege').values()]).includes('203.0.113.77'), 'keine IP-Adresse im Dokument')
})

test('fruehbeginn zählt nur bei Privatpersonen und nur als true', async () => {
  for (const [kundenart, eingabe, erwartet] of [['privat', 'ja', false], ['privat', 1, false], ['privat', true, true], ['unternehmen', true, false]]) {
    seed({ kunde: kunde(kundenart) })
    const res = await post({ bestaetigt: true, fruehbeginn: eingabe })
    assert.equal(res.status, 200, `${kundenart} ${eingabe}`)
    assert.equal((await getAuftrag('ab1')).fruehbeginn, erwartet, `${kundenart} ${eingabe}`)
  }
})

test('zweite und gleichzeitige Bestätigungen: 200, die erste zählt, genau zwei Mails', async () => {
  const erste = await post({ bestaetigt: true, fruehbeginn: true })
  setzeUhrFuerTests(() => new Date(JETZT.getTime() + 3_600_000))
  const zweite = await post({ bestaetigt: true, fruehbeginn: false })
  assert.equal(zweite.status, 200)
  assert.deepEqual(zweite.body, erste.body)
  assert.equal(sink.sent.length, 2)
  seed()
  sink.sent.length = 0
  const [a, b] = await Promise.all([post({ bestaetigt: true }), post({ bestaetigt: true })])
  assert.deepEqual([a.status, b.status], [200, 200])
  assert.equal(a.body.auftrag.bestaetigtAm, b.body.auftrag.bestaetigtAm)
  assert.equal(sink.sent.length, 2)
})

test('abgerechnete AB ohne frühere Bestätigung: 200, der Status bleibt abgerechnet', async () => {
  seed({ status: 'abgerechnet' })
  const res = await post({ bestaetigt: true })
  assert.deepEqual([res.status, res.body.auftrag.status], [200, 'abgerechnet'])
  assert.notEqual((await getAuftrag('ab1')).bestaetigtAm, null)
})

test('Mailfehler: die Bestätigung gilt trotzdem (200), der Fehler steht nur im Protokoll', async () => {
  setMailTransportForTests({ async sendMail() { throw new Error('SMTP 451') } })
  const log = mock.method(console, 'error', () => {})
  const res = await post({ bestaetigt: true })
  assert.equal(res.status, 200)
  assert.equal((await getAuftrag('ab1')).status, 'bestaetigt')
  assert.ok(log.mock.callCount() > 0)
  mock.restoreAll()
})

test('Ratenbegrenzung: höchstens 10 POST je Stunde und IP, danach 429; GET und andere IP sind nicht betroffen', async () => {
  const kopf = ip('203.0.113.20')
  for (let i = 1; i <= 10; i += 1) assert.equal((await post({}, TOKEN, kopf)).status, 400, `Anfrage ${i}`)
  const zuViele = await post({ bestaetigt: true }, TOKEN, kopf)
  assert.deepEqual([zuViele.status, zuViele.body], [429, ZU_VIELE])
  assert.equal((await getAuftrag('ab1')).bestaetigtAm, null)
  assert.equal((await get(TOKEN, '', kopf)).status, 200)
  assert.equal((await post({ bestaetigt: true }, TOKEN, ip('203.0.113.21'))).status, 200)
})
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeTokenRoutes.test.js Expected: FAIL (die neuen POST-Tests: der Pfad existiert noch nicht, Antwort 404 statt 200 bzw. 400).

  • Step 3: Implementieren POST /:token/bestaetigen in server/routes/auftrag.js

Mit dem POST-Begrenzer (Task 7), danach der Vorlauf ladeNachToken; Rumpf body = req.body (ist es kein Objekt, {}); body.bestaetigt !== true → 400 { error: 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' }; sonst neu = await bestaetigeAuftrag(a, { ueber: 'link', fruehbeginn: body.fruehbeginn === true, jetzt: uhr() }) und res.json({ auftrag: oeffentlicheAnsicht(neu) }). Nichts von der Anfrage (IP, Kopfzeilen, Rumpf) wird gespeichert oder protokolliert.

  • Step 4: Lauf, erwartet PASS

Run: wie Step 2. Expected: # fail 0 (alle Tests der Datei).

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5
git add server/routes/auftrag.js test/auftraegeTokenRoutes.test.js
git commit -m "Aufträge L5: öffentliche Bestätigung per Link (idempotent, Frühbeginn nur bei Privatpersonen)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 9: Portal-Routen des Kunden (routes/auftraegeKunde.js)

Files:

  • Modify: server/routes/auftraegeKunde.js (Gerüst aus L0 ersetzen, export default router bleibt)
  • Test: test/auftraegeKundeRoutes.test.js (neu)

Interfaces:

  • Consumes: requireSession, getSessionCustomerId (server/middleware/session.js); isKundenansicht(session) (server/services/kundenansicht.js); getDocument(config.collections.customers, id) (appwriteAdmin.js); L0 listAuftraege({ kundenId }), getAuftrag(id), oeffentlicheAnsicht, auftraegeAktiv, lesePdf, AuftragError, sendeFehler; Task 5 bestaetigeAuftrag, GESPERRT_MELDUNG.

  • Produces: GET /api/auftraege → { aktiv, auftraege: (AuftragOeffentlich & { id, kannBestaetigen })[] } (V3/V5: kannBestaetigen = (status === 'versendet' || (status === 'abgerechnet' && !bestaetigtAm)) && nicht Kundenansicht; Entwürfe fehlen); GET /api/auftraege/:id/pdf → application/pdf; POST /api/auftraege/:id/bestaetigen ({ bestaetigt: true, fruehbeginn }) → { auftrag } (gleiche Form wie ein Listeneintrag); 400 mit dem Text der Token-Route, 404 fremd/Entwurf/unbekannt, 410 mit den Meldungen der Token-Route (ersetzt, storniert, widerrufen).

  • Step 1: Failing Tests test/auftraegeKundeRoutes.test.js

import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'

// Portal-Routen /api/auftraege (V5): nur eigene, gesendete ABs; Schalter; Bestätigen mit Portal-Sitzung
process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-kunde-'))
process.env.AUFTRAEGE_ENABLED = 'on'
const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { startTestApp, send } = await import('./support/testApp.js')
const { seedFixtures, customerCookie, adminCookie } = await import('./support/fixtures.js')
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { speicherePdf } = await import('../server/services/auftraege/ablage.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { default: kundeRoutes } = await import('../server/routes/auftraegeKunde.js')
const app = await startTestApp({ '/api/auftraege': kundeRoutes })
after(() => {
  setMailTransportForTests(null)
  return Promise.all([app.close(), aw.close()])
})
seedFixtures(aw)

const PDF = '%PDF-1.4 Kunden-AB'
for (const [kundenId, id] of [['c1', 'ab1'], ['c2', 'ab3'], ['c1', 'ab4']]) await speicherePdf(kundenId, id, Buffer.from(PDF))
const NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.'
const ENTWURF = { id: 'ab2', nummer: '', jahr: null, laufnummer: null, status: 'entwurf', gesendetAm: null, gesendetAn: '', tokenHash: '', tokenGueltigBis: null, pdfDatei: '', pdfSha256: '' }
const AB4 = { id: 'ab4', nummer: 'AB-2026-0003', status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', pdfDatei: 'c1/ab4.pdf', tokenHash: '' }
const AB3 = { id: 'ab3', nummer: 'AB-2026-0002', kundenId: 'c2', pdfDatei: 'c2/ab3.pdf', kunde: kunde('privat', { email: 'k2@example.invalid' }), tokenHash: '' }

let sink
const seed = (over = {}) => aw.seed('auftraege', { $id: over.id || 'ab1', ...auftragToDoc(abDokument(over)) })
beforeEach(() => {
  config.auftraege.mode = 'on'
  config.auftraege.pilotEmails = []
  sink = mailSink()
  setMailTransportForTests(sink)
  for (const over of [{}, ENTWURF, AB3, AB4]) seed(over)
})
const cookie = customerCookie('c1')
const get = (pfad, opts = { cookie }) => send('GET', `/api/auftraege${pfad}`, opts)
const post = (pfad, body, opts = { cookie }) => send('POST', `/api/auftraege${pfad}`, { ...opts, body })

test('GET /api/auftraege: nur eigene, nur gesendete ABs, mit id und kannBestaetigen, ohne interne Felder', async () => {
  const res = await get('')
  assert.equal(res.status, 200)
  assert.equal(res.body.aktiv, true)
  assert.deepEqual(res.body.auftraege.map((a) => a.id).sort(), ['ab1', 'ab4'])
  const ab1 = res.body.auftraege.find((a) => a.id === 'ab1')
  const ab4 = res.body.auftraege.find((a) => a.id === 'ab4')
  assert.deepEqual([ab1.nummer, ab1.status, ab1.kannBestaetigen, ab4.status, ab4.kannBestaetigen], ['AB-2026-0001', 'versendet', true, 'bestaetigt', false])
  for (const intern of ['kundenId', 'kunde', 'tokenHash', 'tokenGueltigBis', 'pdfDatei', 'pdfSha256', 'gesendetAn']) assert.ok(!(intern in ab1), intern)
  assert.equal(res.headers.get('cache-control'), 'no-store')
})

test('kannBestaetigen wie in V3: versendet und abgerechnet ohne frühere Bestätigung ja, sonst nein', async () => {
  const faelle = [
    [{}, true],
    [{ status: 'abgerechnet' }, true],
    [{ status: 'abgerechnet', bestaetigtAm: '2026-10-07T08:30:00.000Z' }, false],
    [{ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z' }, false],
    [{ status: 'ersetzt' }, false],
    [{ status: 'storniert' }, false],
    [{ status: 'widerrufen' }, false],
  ]
  for (const [over, erwartet] of faelle) {
    seed(over)
    const ab1 = (await get('')).body.auftraege.find((a) => a.id === 'ab1')
    assert.ok(ab1, `${JSON.stringify(over)}: auch ersetzte, stornierte und widerrufene ABs stehen in der Liste`)
    assert.equal(ab1.kannBestaetigen, erwartet, JSON.stringify(over))
  }
})

test('ohne Anmeldung und mit alter Mitarbeiter-Sitzung: 401, nichts ändert sich', async () => {
  for (const opts of [{}, { cookie: adminCookie() }]) {
    assert.equal((await get('', opts)).status, 401)
    assert.equal((await get('/ab1/pdf', opts)).status, 401)
    assert.equal((await post('/ab1/bestaetigen', { bestaetigt: true }, opts)).status, 401)
  }
  assert.equal((await getAuftrag('ab1')).bestaetigtAm, null)
})

test('Schalter aus: leere Liste, PDF und Bestätigen wie bei unbekannter AB; im Pilot nur für Adressen der Liste', async () => {
  config.auftraege.mode = 'off'
  assert.deepEqual((await get('')).body, { aktiv: false, auftraege: [] })
  for (const res of [await get('/ab1/pdf'), await post('/ab1/bestaetigen', { bestaetigt: true })]) assert.deepEqual([res.status, res.body], [404, { error: NICHT_GEFUNDEN }])
  assert.equal((await getAuftrag('ab1')).bestaetigtAm, null)
  config.auftraege.mode = 'pilot'
  config.auftraege.pilotEmails = ['k@example.invalid']
  assert.equal((await get('')).body.aktiv, true)
  assert.deepEqual((await get('', { cookie: customerCookie('c2') })).body, { aktiv: false, auftraege: [] })
})

test('GET /api/auftraege/:id/pdf: eigene AB (auch ersetzt), nie fremde, Entwürfe, unbekannte oder ungültige IDs', async () => {
  const eigene = await get('/ab1/pdf')
  assert.deepEqual([eigene.status, eigene.headers.get('content-type'), eigene.body], [200, 'application/pdf', PDF])
  assert.equal(eigene.headers.get('content-disposition'), 'inline; filename="AB-2026-0001.pdf"')
  seed({ status: 'ersetzt' })
  assert.equal((await get('/ab1/pdf')).status, 200)
  for (const id of ['ab3', 'ab2', 'gibtsnicht', '..%2F..%2Fx', 'a'.repeat(40)]) {
    const res = await get(`/${id}/pdf`)
    assert.deepEqual([res.status, res.body], [404, { error: NICHT_GEFUNDEN }], id)
  }
})

test('POST …/bestaetigen: bestätigt über das Portal (Protokoll, K32, T22); der zweite Aufruf ändert nichts', async () => {
  const vorher = Date.now()
  const res = await post('/ab1/bestaetigen', { bestaetigt: true, fruehbeginn: true })
  assert.equal(res.status, 200)
  assert.deepEqual([res.body.auftrag.id, res.body.auftrag.status, res.body.auftrag.kannBestaetigen], ['ab1', 'bestaetigt', false])
  const doc = await getAuftrag('ab1')
  assert.deepEqual([doc.bestaetigtUeber, doc.fruehbeginn], ['portal', true])
  assert.ok(new Date(doc.bestaetigtAm).getTime() >= vorher - 1000 && new Date(doc.bestaetigtAm).getTime() <= Date.now() + 1000)
  assert.equal(sink.sent.length, 2)
  const zweite = await post('/ab1/bestaetigen', { bestaetigt: true, fruehbeginn: false })
  assert.deepEqual([zweite.status, zweite.body], [200, res.body])
  assert.equal(sink.sent.length, 2)
})

test('POST …/bestaetigen: ohne bestaetigt: true 400; fremde, Entwurf, unbekannte AB 404; ersetzt, storniert, widerrufen 410 – nie eine Änderung', async () => {
  for (const body of [{}, { bestaetigt: 'ja' }, { bestaetigt: false }]) {
    const res = await post('/ab1/bestaetigen', body)
    assert.deepEqual([res.status, res.body], [400, { error: 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' }])
  }
  for (const id of ['ab3', 'ab2', 'gibtsnicht']) {
    const res = await post(`/${id}/bestaetigen`, { bestaetigt: true })
    assert.deepEqual([res.status, res.body], [404, { error: NICHT_GEFUNDEN }], id)
  }
  for (const [status, text] of [['ersetzt', 'ersetzt'], ['storniert', 'storniert'], ['widerrufen', 'widerrufen']]) {
    seed({ status })
    const res = await post('/ab1/bestaetigen', { bestaetigt: true })
    assert.deepEqual([res.status, res.body], [410, { error: `Diese Auftragsbestätigung wurde ${text}.` }], status)
    assert.equal((await getAuftrag('ab1')).bestaetigtAm, null, status)
  }
  assert.equal((await getAuftrag('ab3')).bestaetigtAm, null)
  assert.equal(sink.sent.length, 0)
})
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeKundeRoutes.test.js Expected: FAIL (Gerüst antwortet 404 statt 200 bzw. 401).

  • Step 3: Implementieren server/routes/auftraegeKunde.js

router.use(requireSession), danach Cache-Control: no-store für alle Antworten; Fehler über sendeFehler(res, 'auftraege', err). Konstanten: NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.', HAEKCHEN = 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.', ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.

  • ansicht(a, req) = { ...oeffentlicheAnsicht(a), id: a.id, kannBestaetigen: !isKundenansicht(req.session) && (a.status === 'versendet' || (a.status === 'abgerechnet' && !a.bestaetigtAm)) } (wörtlich die Formel aus V3; in der Kundenansicht ist Bestätigen nie angeboten).

  • ladeEigene(req, res) → Auftrag | null (bei null ist 404 { error: NICHT_GEFUNDEN } schon gesendet): ID_RE prüfen; Kunden-Dokument getDocument(config.collections.customers, getSessionCustomerId(req)); !auftraegeAktiv(kunde) → 404; a = await getAuftrag(req.params.id); null, a.kundenId !== customerId oder a.status === 'entwurf' → 404.

  • GET /: Kunden-Dokument laden; !auftraegeAktiv(kunde) → { aktiv: false, auftraege: [] }; sonst listAuftraege({ kundenId }), Entwürfe herausfiltern, { aktiv: true, auftraege: liste.map((a) => ansicht(a, req)) }.

  • GET /:id/pdf: ladeEigene, lesePdf wie in Task 7 (Fehler → 404 'PDF nicht gefunden.'), Antwort inline mit Content-Disposition: inline; filename="<Nummer>.pdf"; jeder Status außer Entwurf ist abrufbar.

  • POST /:id/bestaetigen: ladeEigene (404); GESPERRT_MELDUNG[a.status] → 410 { error } (wie in der Token-Route); body.bestaetigt !== true → 400 { error: HAEKCHEN }; sonst bestaetigeAuftrag(a, { ueber: 'portal', fruehbeginn: body.fruehbeginn === true }) und res.json({ auftrag: ansicht(neu, req) }).

  • Step 4: Lauf, erwartet PASS

Run: wie Step 2. Expected: # fail 0.

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5
git add server/routes/auftraegeKunde.js test/auftraegeKundeRoutes.test.js
git commit -m "Aufträge L5: Portal-Routen für Aufträge (eigene gesendete ABs, PDF, Bestätigen)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 10: Kundenansicht sperrt das Bestätigen

Files:

  • Test: test/auftraegeKundenansicht.test.js (neu); kein Produktivcode (der Wächter sperrt jede Schreib-Route ohne Erlaubnisliste-Eintrag).

Interfaces:

  • Consumes: kundenansichtGuard, KUNDENANSICHT_ALLOWED (server/middleware/kundenansichtGuard.js, unverändert); Router aus Task 7 und 9; kundenansichtCookie (test/support/fixtures.js).

  • Produces: Regressionstest: Bestätigen ist in der Kundenansicht nie möglich, weder im Portal noch über den öffentlichen Link.

  • Step 1: Test schreiben test/auftraegeKundenansicht.test.js

import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, TOKEN, abDokument } from './auftraegeBestaetigungHilfe.js'

// Kundenansicht (Mitarbeiter im Portal eines Kunden): Bestätigen ist eine Erklärung im Namen des Kunden und bleibt gesperrt
process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-kundenansicht-'))
process.env.AUFTRAEGE_ENABLED = 'on'
const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { startTestApp, send } = await import('./support/testApp.js')
const { seedFixtures, customerCookie, kundenansichtCookie } = await import('./support/fixtures.js')
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { speicherePdf } = await import('../server/services/auftraege/ablage.js')
const { kundenansichtGuard, KUNDENANSICHT_ALLOWED } = await import('../server/middleware/kundenansichtGuard.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { default: kundeRoutes } = await import('../server/routes/auftraegeKunde.js')
const { default: tokenRoutes } = await import('../server/routes/auftrag.js')
// Mount-Pfade und Reihenfolge wie in server/index.js: der Wächter steht vor den Routen
const app = await startTestApp({ '/': kundenansichtGuard, '/api/auftraege': kundeRoutes, '/api/auftrag': tokenRoutes })
after(() => {
  setMailTransportForTests(null)
  return Promise.all([app.close(), aw.close()])
})
seedFixtures(aw)
await speicherePdf('c1', 'ab1', Buffer.from('%PDF-1.4 Test'))

const VIEW = 'a'.repeat(32)
const kv = () => ({ cookie: kundenansichtCookie({ customerId: 'c1', viewId: VIEW }), headers: { 'x-kundenansicht': VIEW } })
let sink
beforeEach(() => {
  sink = mailSink()
  setMailTransportForTests(sink)
  aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument()) })
})
const unveraendert = async () => {
  const doc = await getAuftrag('ab1')
  assert.deepEqual([doc.status, doc.bestaetigtAm, sink.sent.length], ['versendet', null, 0])
}

test('kein Eintrag der Erlaubnisliste gilt für Aufträge', () => {
  for (const pfad of ['/api/auftraege', '/api/auftraege/ab1/bestaetigen', '/api/auftraege/ab1/pdf', `/api/auftrag/${TOKEN}`, `/api/auftrag/${TOKEN}/bestaetigen`]) {
    assert.ok(!KUNDENANSICHT_ALLOWED.some(([, muster]) => muster.test(pfad)), pfad)
  }
})

test('POST /api/auftraege/:id/bestaetigen in der Kundenansicht: 403 view_blocked, die AB bleibt unverändert', async () => {
  const res = await send('POST', '/api/auftraege/ab1/bestaetigen', { ...kv(), body: { bestaetigt: true, fruehbeginn: true } })
  assert.equal(res.status, 403)
  assert.equal(res.body.code, 'view_blocked')
  await unveraendert()
})

test('alle Schreib-Routen des Kunden-Routers sind in der Kundenansicht gesperrt (nur Bestätigen ist eine)', async () => {
  const schreibRouten = kundeRoutes.stack
    .filter((schicht) => schicht.route)
    .flatMap((schicht) => Object.keys(schicht.route.methods).filter((m) => !['get', 'head', 'options', '_all'].includes(m)).map((m) => [m.toUpperCase(), `/api/auftraege${String(schicht.route.path).replace(/:[A-Za-z]+/g, 'ab1')}`]))
  assert.deepEqual(schreibRouten, [['POST', '/api/auftraege/ab1/bestaetigen']])
  for (const [methode, pfad] of schreibRouten) {
    const res = await send(methode, pfad, { ...kv(), body: { bestaetigt: true } })
    assert.equal(res.status, 403, `${methode} ${pfad}`)
  }
  await unveraendert()
})

test('auch der öffentliche Link bestätigt nichts, wenn der Browser eine Kundenansicht-Sitzung trägt', async () => {
  const mitKennung = await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { ...kv(), body: { bestaetigt: true } })
  assert.deepEqual([mitKennung.status, mitKennung.body.code], [403, 'view_blocked'])
  const ohneKennung = await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { cookie: kv().cookie, body: { bestaetigt: true } })
  assert.deepEqual([ohneKennung.status, ohneKennung.body.code], [409, 'view_replaced'])
  await unveraendert()
})

test('Lesen bleibt erlaubt, kannBestaetigen ist in der Kundenansicht false; der Kunde selbst darf bestätigen', async () => {
  const liste = await send('GET', '/api/auftraege', kv())
  assert.equal(liste.status, 200)
  assert.deepEqual(liste.body.auftraege.map((a) => [a.id, a.kannBestaetigen]), [['ab1', false]])
  assert.equal((await send('GET', '/api/auftraege/ab1/pdf', kv())).status, 200)
  const selbst = await send('GET', '/api/auftraege', { cookie: customerCookie('c1') })
  assert.deepEqual(selbst.body.auftraege.map((a) => [a.id, a.kannBestaetigen]), [['ab1', true]])
  const res = await send('POST', '/api/auftraege/ab1/bestaetigen', { cookie: customerCookie('c1'), body: { bestaetigt: true } })
  assert.equal(res.status, 200)
  assert.equal((await getAuftrag('ab1')).status, 'bestaetigt')
})
  • Step 2: Gegenprobe, erwartet FAIL (der Test beißt wirklich)

Temporär (nicht committen) in server/middleware/kundenansichtGuard.js unter KUNDENANSICHT_ALLOWED die Zeile ['POST', /^\/api\/auftraege\/[^/]+\/bestaetigen\/?$/], einfügen und ausführen: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeKundenansicht.test.js Expected: FAIL (drei Tests: kein Eintrag der Erlaubnisliste gilt für Aufträge, POST /api/auftraege/:id/bestaetigen in der Kundenansicht … und alle Schreib-Routen des Kunden-Routers …). Danach sofort zurücknehmen: git checkout -- server/middleware/kundenansichtGuard.js.

  • Step 3: Implementierung

Keine: der Wächter und die Router aus Task 7 und 9 liefern das Verhalten. Besteht der Test ohne die Probe-Zeile nicht, ist ein Router falsch eingehängt oder liefert kannBestaetigen falsch: dort korrigieren, nicht den Wächter ändern.

  • Step 4: Lauf, erwartet PASS

Run: wie in Step 2, ohne Probe-Zeile (git diff --stat server/middleware/kundenansichtGuard.js muss leer sein). Expected: # fail 0.

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5
git add test/auftraegeKundenansicht.test.js
git commit -m "Aufträge L5: Test – in der Kundenansicht ist das Bestätigen von Aufträgen gesperrt" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 11: K04 und T09 – Block projekt

Files:

  • Modify: server/mail-templates/K04 Eingang Widerruf.html, server/mail-templates/T09 Widerruf eingegangen.html (je nur der Kopfkommentar und der neue Block), test/mailTemplates.test.js (die gepinnten Block-Listen)
  • Test: test/auftraegeVorlagen.test.js (ergänzen)

Interfaces:

  • Consumes: renderMail(id, values, blocks), loadTemplate(id) (unverändert); die Hilfen K04-/T09-Testwerte legt dieser Task in auftraegeVorlagen.test.js an.

  • Produces: K04 mit den zusätzlichen Blöcken projekt (Kasten „Wie es weitergeht“: „Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.“) und darin projekt_unternehmen (Zusatz „Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.“); T09 mit dem zusätzlichen Block projekt (roter Kasten „Zu tun“ mit den Zeilen Ergebnis:, Widerrufsfrist bis:, Eingang:). Keine neuen Platzhalter: die Vertragsart steht wie bei den anderen Bestellungen in {{bestellung}} („Projektauftrag (Auftragsbestätigung AB-JJJJ-NNNN)“, Task 12), T09 nutzt {{ereignisse}}, {{fristende}} und {{rechtzeitig}}. Der Kasten sagt nichts zu Erstattung oder Wertersatz (das ist Sache der Rechtsrunde in L7 und der Handarbeit des Teams). Der verschachtelte Block projekt_unternehmen ist nötig, weil die Vorlagen nur positive Blöcke kennen (Vertragsfrage 13).

  • Step 1: Failing Tests

(a) An test/auftraegeVorlagen.test.js anhängen (die Hilfen pruefeZeilen und die Importe stehen schon in der Datei):

const K04 = { name: 'Erika Muster', produkt: 'Projektauftrag', bestellung: 'Projektauftrag (Auftragsbestätigung AB-2026-0001)', eingang_zeitpunkt: '07.10.2026, 10:30', email: 'k@example.invalid', kundennummer: 'KD-9' }
const T09 = { email: 'k@example.invalid', name: 'Erika Muster', produkt: 'Projektauftrag', preis: '', kundennummer: 'KD-9', bestellung: 'Projektauftrag (Auftragsbestätigung AB-2026-0001)', stripe_abo_id: '', abo_beginn: '–', fristende: '20.10.2026, 24 Uhr', rechtzeitig: 'RECHTZEITIG', ereignisse: 'Auftragsbestätigung auf „widerrufen“ gesetzt', pruefhinweis: '' }

test('K04 und T09: Block projekt ist deklariert (K04 mit projekt_unternehmen), die Platzhalter bleiben, die anderen Blöcke auch', () => {
  assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen', 'projekt', 'projekt_unternehmen'])
  assert.deepEqual(loadTemplate('K04').placeholders, ['name', 'produkt', 'bestellung', 'eingang_zeitpunkt', 'email', 'kundennummer'])
  assert.deepEqual(loadTemplate('T09').blocks.slice(-2), ['bereits_widerrufen', 'projekt'])
  assert.deepEqual(loadTemplate('T09').placeholders, ['email', 'name', 'produkt', 'preis', 'kundennummer', 'bestellung', 'stripe_abo_id', 'abo_beginn', 'fristende', 'rechtzeitig', 'ereignisse', 'pruefhinweis'])
})

test('K04 Block projekt: Vertragsart, „wir prüfen“; bei Unternehmen zusätzlich der Hinweis ohne gesetzliches Widerrufsrecht; keine Zusagen', () => {
  const privat = renderMail('K04', K04, { kundennummer: true, projekt: true })
  assert.deepEqual(privat.problems, [])
  assert.equal(privat.subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Projektauftrag')
  pruefeZeilen(privat, [
    'wir haben Ihren Widerruf der Bestellung „Projektauftrag (Auftragsbestätigung AB-2026-0001)“ am 07.10.2026, 10:30 Uhr erhalten.',
    'Bestellung: Projektauftrag (Auftragsbestätigung AB-2026-0001)',
    'Kundennummer: KD-9',
    'Wie es weitergeht',
    'Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.',
  ])
  assert.doesNotMatch(privat.text, /Unternehmen|gesetzliches Widerrufsrecht|Abbuchungen|erstatt|Wertersatz|Widerrufsfrist läuft/)
  const firma = renderMail('K04', K04, { projekt: true, projekt_unternehmen: true })
  assert.deepEqual(firma.problems, [])
  pruefeZeilen(firma, ['Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen. Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.'])
  // der Zusatz gehört zum Block projekt: allein erscheint er nie, und ohne projekt gibt es keinen Kasten
  assert.doesNotMatch(renderMail('K04', K04, { projekt_unternehmen: true }).text, /Unternehmen|Wie es weitergeht/)
  assert.doesNotMatch(renderMail('K04', K04, {}).text, /Wie es weitergeht|Projektauftrags/)
})

test('T09 Block projekt: Ergebnis, Widerrufsfrist und Eingang als eigene Zeilen, Hinweis auf die Handarbeit; ohne den Block nichts davon', () => {
  const mail = renderMail('T09', T09, { kundennummer: true, projekt: true })
  assert.deepEqual(mail.problems, [])
  assert.equal(mail.subject, '[Abo] WIDERRUF eingegangen: k@example.invalid')
  pruefeZeilen(mail, [
    'Bestellung: Projektauftrag (Auftragsbestätigung AB-2026-0001)',
    'Abo: Projektauftrag',
    'Kundennummer: KD-9',
    'Zu tun',
    'Ergebnis: Auftragsbestätigung auf „widerrufen“ gesetzt',
    'Widerrufsfrist bis: 20.10.2026, 24 Uhr',
    'Eingang: RECHTZEITIG',
  ])
  assert.ok(mail.text.includes('Der Kunde hat „wir prüfen“ bekommen. Erstattung, Wertersatz und das Ende der Arbeit sind Handarbeit (Ticketsystem: Kunde → Aufträge und Rechnungen).'))
  assert.doesNotMatch(mail.text, /KEIN aktives Abo|Stripe-Abo|Abo in Stripe/)
  assert.doesNotMatch(renderMail('T09', T09, { kundennummer: true }).text, /Ergebnis:|Eingang:|Handarbeit|Zu tun/)
})

(b) test/mailTemplates.test.js, die gepinnten Block-Listen anpassen (Edit-Werkzeug; nach den Texten suchen, nicht nach Zeilennummern):

  1. Die Zeile assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen']) kommt zweimal vor (im Test „K03, K04, T08, T09: Vertrag bzw. Bestellung …“ und im Test „F9: T09 deklariert preis …“): beide Male 'pruefen'] zu 'pruefen', 'projekt', 'projekt_unternehmen'] ändern (replace_all).
  2. Im Test „F9: T09 deklariert preis …“ die Liste von loadTemplate('T09').blocks am Ende 'nicht_pruefbar', 'bereits_widerrufen', zu 'nicht_pruefbar', 'bereits_widerrufen', 'projekt', ändern und den Testtitel zu 'F9: T09 deklariert preis, Blöcke von K04 und T09 wie festgelegt (dazu je der Block projekt aus Strang L5)'.
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeVorlagen.test.js test/mailTemplates.test.js Expected: FAIL (die Blöcke projekt und projekt_unternehmen fehlen in K04, projekt in T09: die Pins und die neuen Tests schlagen fehl, renderMail meldet unbekannter Block "projekt").

  • Step 3: Vorlagen ergänzen (nur diese Änderungen, sonst bleiben beide Dateien byteweise gleich)

K04 (server/mail-templates/K04 Eingang Widerruf.html):

  1. Kopfkommentar: die Zeile …: kundennummer, abschluss, wechsel, zusatzspeicher, funktion, pruefen endet danach auf …, funktion, pruefen, projekt, projekt_unternehmen, und direkt darunter (vor der Zeile produkt = Katalogname …) steht neu: projekt = Widerruf eines Projektauftrags (Aufträge und Rechnungen): wir prüfen und melden uns, ohne Zusagen zu Erstattung oder Wertersatz; projekt_unternehmen (nur innerhalb von projekt) = die Auftragsbestätigung gehört zu einem Unternehmen, der Zusatz nennt, dass kein gesetzliches Widerrufsrecht besteht.
  2. Direkt unter der Zeile <!-- ENDE pruefen --> (vor der Gruß-Zeile Viele Grüße) diese drei Zeilen einfügen (Kasten wie bei pruefen, nur der Text ist neu):
<!-- WENN projekt -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.<!-- WENN projekt_unternehmen --> Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.<!-- ENDE projekt_unternehmen --></div></td></tr></table></td></tr>
<!-- ENDE projekt -->

T09 (server/mail-templates/T09 Widerruf eingegangen.html):

  1. Kopfkommentar: die Zeile Optionale Blöcke …: kundennummer, abo_gefunden, …, nicht_pruefbar, bereits_widerrufen endet danach auf …, nicht_pruefbar, bereits_widerrufen, projekt, und direkt darunter (vor -->) steht neu: projekt = Widerruf eines Projektauftrags (Aufträge und Rechnungen), ohne Abo und ohne Vertragsereignis: ereignisse = Ergebnis der Prüfung der Auftragsbestätigung, fristende = Ende der Widerrufsfrist (TT.MM.JJJJ, 24 Uhr), rechtzeitig = Eingang (RECHTZEITIG, NACH Fristende, ungeprüft …); die Zeile „Abo“ zeigt nur die Vertragsart.
  2. Direkt unter der Zeile <!-- ENDE funktion --> diese drei Zeilen einfügen (Kasten wie bei funktion, nur der Text ist neu):
<!-- WENN projekt -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Widerruf eines Projektauftrags. Geprüft wurde nur die Auftragsbestätigung (kein Abo, kein Vertragsereignis); die Zeile „Abo“ oben nennt nur die Vertragsart.<br>Ergebnis: {{ereignisse}}<br>Widerrufsfrist bis: {{fristende}}<br>Eingang: <strong>{{rechtzeitig}}</strong><br>Der Kunde hat „wir prüfen“ bekommen. Erstattung, Wertersatz und das Ende der Arbeit sind Handarbeit (Ticketsystem: Kunde → Aufträge und Rechnungen).</div></td></tr></table></td></tr>
<!-- ENDE projekt -->

Sonst nichts ändern: keine neuen Platzhalter, kein weiterer HTML-Kommentar, keine HTML-Entities (die Zeichen „ “ → – stehen als echte Zeichen).

  • Step 4: Lauf, erwartet PASS

Run: wie Step 2. Expected: # fail 0 (insbesondere die allgemeinen Prüfungen zu K04 und T09: deklarierte = verwendete Platzhalter und Blöcke, jeder Block geschlossen, rendert mit allen Blöcken an und aus ohne Reste).

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5
git add "server/mail-templates/K04 Eingang Widerruf.html" "server/mail-templates/T09 Widerruf eingegangen.html" test/auftraegeVorlagen.test.js test/mailTemplates.test.js
git commit -m "Aufträge L5: K04 und T09 mit Block projekt für den Widerruf eines Projektauftrags" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 12: Widerruf eines Projektauftrags (abo.js, order = 'projekt')

Files:

  • Modify: server/routes/abo.js (ORDERS, validDeclaration, orderLabel, neue Hilfen, router.post('/public/withdraw'))
  • Test: test/auftraegeWiderruf.test.js (neu)

Interfaces:

  • Consumes: Task 6 widerrufeAuftrag(auftrag, { jetzt }) → { auftrag, ergebnis, fristEnde }; L0 findAuftragByNummer(nummer) → Auftrag | null; Task 11 (K04 und T09 mit dem Block projekt, K04 zusätzlich projekt_unternehmen); bestehend sendCustomerMail, sendAdminNotice, Ratenbegrenzung und readDeclaration der Widerrufsseite.

  • Produces: POST /api/abo/public/withdraw nimmt zusätzlich order: 'projekt' und abNummer (optional, Text, wird getrimmt, groß geschrieben, auf 20 Zeichen gekürzt); Antwort immer { ok: true, receivedAt }. Text der Vertragsart: Projektauftrag (Auftragsbestätigung AB-JJJJ-NNNN) (ohne gültige Nummer Projektauftrag (Auftragsbestätigung)). Nur mit gültiger Nummer wird nachgeschlagen: passt die AB zu Nummer und E-Mail-Adresse und ist der Kunde eine Privatperson, wird sie widerrufen (mit widerrufenAm), wenn sie versendet oder bestaetigt ist und die Frist (withdrawalDeadline(gesendetAm)) nicht überschritten ist; sonst ändert sich nichts. Ohne oder mit ungültiger Nummer, bei fremder Adresse oder bei einem Datenbankfehler gehen nur die Mails raus, mit dem Vermerk „ungeprüft“. K04 trägt den Block projekt (bei einer Auftragsbestätigung eines Unternehmens zusätzlich projekt_unternehmen), T09 den Block projekt mit den Zeilen Ergebnis:, Widerrufsfrist bis: und Eingang:. Der Schalter AUFTRAEGE_ENABLED hat auf den Widerruf keinen Einfluss. Die Mails gehen nach der Rückkehr von widerrufeAuftrag raus, also nach Freigabe der Sperre.

  • Step 1: Failing Tests test/auftraegeWiderruf.test.js

import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, TOKEN, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'

// Widerruf eines Projektauftrags über /api/abo/public/withdraw (order = 'projekt', abNummer optional); K04 und T09 mit dem Block projekt
process.env.AUFTRAEGE_ENABLED = 'on'
const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, post, send } = await import('./support/testApp.js')
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { withdrawalDeadline } = await import('../server/services/deadlines.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { default: aboRoutes, resetPublicRateLimit } = await import('../server/routes/abo.js')
const { default: tokenRoutes, resetAuftragRoutesForTests } = await import('../server/routes/auftrag.js')
const app = await startTestApp({ '/api/abo': aboRoutes, '/api/auftrag': tokenRoutes })
const sink = mailSink()
setMailTransportForTests(sink)
after(() => {
  setMailTransportForTests(null)
  return Promise.all([app.close(), aw.close(), st.close()])
})

const vor = (tage) => new Date(Date.now() - tage * 86_400_000).toISOString()
const tag = (zeit) => new Date(zeit).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
const seed = (over = {}) => aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument(over)) })
let protokoll
beforeEach(() => {
  resetPublicRateLimit()
  resetAuftragRoutesForTests()
  sink.sent.length = 0
  config.auftraege.mode = 'on'
  protokoll = mock.method(console, 'error', () => {})
})
afterEach(() => mock.restoreAll())
const widerruf = (body = {}) => post('/api/abo/public/withdraw', { name: 'Erika Muster', email: 'k@example.invalid', order: 'projekt', abNummer: 'AB-2026-0001', ...body })
const k04 = () => sink.sent.find((mail) => mail.subject.startsWith('Eingangsbestätigung Ihres Widerrufs'))
const t09 = () => sink.sent.find((mail) => mail.subject.startsWith('[Abo] WIDERRUF eingegangen'))
const zeile = (mail, anfang) => mail.text.split('\n').find((z) => z.startsWith(anfang))
const LABEL = 'Projektauftrag (Auftragsbestätigung AB-2026-0001)'
const OHNE_NUMMER = 'Projektauftrag (Auftragsbestätigung)'
const UNTERNEHMEN_HINWEIS = 'Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.'
const KEINE_NUMMER = 'ungeprüft: keine gültige AB-Nummer angegeben, nichts geändert, bitte von Hand zuordnen'
const pruefeT09 = (ergebnis, frist, eingang) => {
  assert.equal(zeile(t09(), 'Ergebnis: '), `Ergebnis: ${ergebnis}`)
  assert.equal(zeile(t09(), 'Widerrufsfrist bis: '), `Widerrufsfrist bis: ${frist}`)
  assert.equal(zeile(t09(), 'Eingang: '), `Eingang: ${eingang}`)
}

test('Privatperson innerhalb der Frist: AB widerrufen, Antwort wie immer, K04 und T09 mit Block projekt; kein Stripe', async () => {
  const gesendetAm = vor(3)
  seed({ gesendetAm })
  const stripeVorher = st.calls.length
  const res = await widerruf({ customerNumber: 'KD-9' })
  assert.equal(res.status, 200, JSON.stringify(res.body))
  assert.deepEqual(Object.keys(res.body).sort(), ['ok', 'receivedAt'])
  assert.equal(st.calls.length, stripeVorher, 'kein Stripe-Aufruf')
  const ab = await getAuftrag('ab1')
  assert.equal(ab.status, 'widerrufen')
  assert.equal(new Date(ab.widerrufenAm).getTime(), new Date(res.body.receivedAt).getTime())
  // K04 an die Privatperson: Vertragsart, „wir prüfen“, keine Zusagen, kein Hinweis für Unternehmen
  assert.equal(k04().subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Projektauftrag')
  assert.deepEqual(k04().to.map((a) => a.address), ['k@example.invalid'])
  assert.ok(k04().text.includes(`Widerruf der Bestellung „${LABEL}“ am `), k04().text)
  assert.equal(zeile(k04(), 'Bestellung: '), `Bestellung: ${LABEL}`)
  assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.'))
  assert.ok(k04().text.includes('Kundennummer: KD-9'))
  assert.doesNotMatch(k04().text, /Unternehmen|Abbuchungen|Wir prüfen, ob für diese Bestellung/)
  // T09 ans Team: Vertragsart, Ergebnis, Frist, Eingang
  assert.equal(t09().subject, '[Abo] WIDERRUF eingegangen: k@example.invalid')
  assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${LABEL}`)
  assert.equal(zeile(t09(), 'Abo: '), 'Abo: Projektauftrag')
  pruefeT09('Auftragsbestätigung auf „widerrufen“ gesetzt', `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, 'RECHTZEITIG')
  assert.doesNotMatch(t09().text, /KEIN aktives Abo|Stripe-Abo/)
})

test('bestätigte AB mit verlangtem Frühbeginn: wird widerrufen, T09 mahnt die Prüfung des Wertersatzes an', async () => {
  seed({ gesendetAm: vor(5), status: 'bestaetigt', bestaetigtAm: vor(4), bestaetigtUeber: 'link', fruehbeginn: true })
  assert.equal((await widerruf()).status, 200)
  const ab = await getAuftrag('ab1')
  assert.equal(ab.status, 'widerrufen')
  assert.notEqual(ab.bestaetigtAm, null)
  assert.equal(zeile(t09(), 'Ergebnis: '), 'Ergebnis: Auftragsbestätigung auf „widerrufen“ gesetzt; Frühbeginn war verlangt: Wertersatz prüfen (§ 357a Abs. 2 BGB)')
})

test('Unternehmen: kein gesetzliches Widerrufsrecht – AB bleibt unverändert, K04 weist darauf hin, T09 sagt es dem Team', async () => {
  seed({ gesendetAm: vor(3), kunde: kunde('unternehmen') })
  assert.equal((await widerruf()).status, 200)
  assert.equal((await getAuftrag('ab1')).status, 'versendet')
  assert.ok(k04().text.includes(`Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen. ${UNTERNEHMEN_HINWEIS}`), k04().text)
  pruefeT09('Unternehmen: kein gesetzliches Widerrufsrecht, nichts geändert', 'entfällt (Unternehmen)', 'ungeprüft')
})

test('nach Fristende: nichts geändert, T09 nennt das Fristende und bittet um Prüfung; K04 ohne Unternehmen-Hinweis', async () => {
  const gesendetAm = vor(20)
  seed({ gesendetAm })
  assert.equal((await widerruf()).status, 200)
  assert.equal((await getAuftrag('ab1')).status, 'versendet')
  pruefeT09('nichts geändert', `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, 'NACH Fristende, vor einer Ablehnung bitte prüfen')
  assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags'))
  assert.ok(!k04().text.includes(UNTERNEHMEN_HINWEIS))
})

test('ohne, mit ungültiger oder falscher Nummer und mit fremder E-Mail-Adresse: dieselbe Antwort, nichts geändert, K04 und T09 gehen trotzdem raus (ungeprüft)', async () => {
  const faelle = [
    [{ abNummer: 'AB-2026-0002' }, 'Projektauftrag (Auftragsbestätigung AB-2026-0002)', 'ungeprüft: keine Auftragsbestätigung zu Nummer „AB-2026-0002“ und dieser E-Mail-Adresse gefunden, nichts geändert'],
    [{ email: 'fremd@example.invalid' }, LABEL, 'ungeprüft: keine Auftragsbestätigung zu Nummer „AB-2026-0001“ und dieser E-Mail-Adresse gefunden, nichts geändert'],
    [{ abNummer: 'AB-2026-1' }, OHNE_NUMMER, KEINE_NUMMER],
    [{ abNummer: '' }, OHNE_NUMMER, KEINE_NUMMER],
    [{ abNummer: undefined }, OHNE_NUMMER, KEINE_NUMMER],
    [{ abNummer: 'irgendwas' }, OHNE_NUMMER, KEINE_NUMMER],
  ]
  seed({ gesendetAm: vor(3) })
  for (const [body, label, ergebnis] of faelle) {
    sink.sent.length = 0
    resetPublicRateLimit()
    const res = await widerruf(body)
    const was = JSON.stringify(body)
    assert.equal(res.status, 200, was)
    assert.deepEqual(Object.keys(res.body).sort(), ['ok', 'receivedAt'], was)
    assert.equal((await getAuftrag('ab1')).status, 'versendet', was)
    assert.equal(zeile(k04(), 'Bestellung: '), `Bestellung: ${label}`, was)
    assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${label}`, was)
    pruefeT09(ergebnis, 'keine Frist gefunden', 'ungeprüft')
    assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.'), was)
    assert.ok(!k04().text.includes(UNTERNEHMEN_HINWEIS), was)
  }
})

test('Groß-/Kleinschreibung und Leerzeichen bei Nummer und E-Mail-Adresse spielen keine Rolle', async () => {
  seed({ gesendetAm: vor(3) })
  assert.equal((await widerruf({ abNummer: ' ab-2026-0001 ', email: 'K@Example.INVALID' })).status, 200)
  assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
  assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${LABEL}`)
})

test('bereits widerrufen, abgerechnet, ersetzt, storniert: nichts geändert, T09 sagt warum', async () => {
  const faelle = [
    ['widerrufen', 'war schon widerrufen, nichts geändert', 'bereits widerrufen'],
    ['abgerechnet', 'Status „abgerechnet“, nichts geändert, bitte prüfen', 'ungeprüft'],
    ['ersetzt', 'Status „ersetzt“, nichts geändert, bitte prüfen', 'ungeprüft'],
    ['storniert', 'Status „storniert“, nichts geändert, bitte prüfen', 'ungeprüft'],
  ]
  for (const [status, ergebnis, eingang] of faelle) {
    const gesendetAm = vor(3)
    seed({ gesendetAm, status, widerrufenAm: status === 'widerrufen' ? vor(1) : null })
    sink.sent.length = 0
    resetPublicRateLimit()
    assert.equal((await widerruf()).status, 200, status)
    pruefeT09(ergebnis, `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, eingang)
    assert.equal((await getAuftrag('ab1')).status, status, status)
  }
})

test('nach dem Widerruf bestätigt der Link nichts mehr: 410 mit Grund', async () => {
  seed({ gesendetAm: vor(3) })
  assert.equal((await widerruf()).status, 200)
  const lesen = await send('GET', `/api/auftrag/${TOKEN}`)
  assert.deepEqual([lesen.status, lesen.body], [410, { error: 'Diese Auftragsbestätigung wurde widerrufen.' }])
  assert.equal((await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { body: { bestaetigt: true } })).status, 410)
  assert.equal((await getAuftrag('ab1')).bestaetigtAm, null)
})

test('Schalter aus: der Widerruf wird trotzdem verarbeitet (Verbraucherrecht), der Link antwortet wie bei unbekannter AB', async () => {
  seed({ gesendetAm: vor(3) })
  config.auftraege.mode = 'off'
  assert.equal((await widerruf()).status, 200)
  assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
  assert.equal((await send('GET', `/api/auftrag/${TOKEN}`)).status, 404)
})

test('Datenbankfehler bei der Prüfung: Antwort 200, nichts geändert, T09 meldet die fehlgeschlagene Prüfung, K04 geht raus', async () => {
  seed({ gesendetAm: vor(3) })
  aw.failNext({ collection: 'auftraege', method: 'GET', status: 503 })
  assert.equal((await widerruf()).status, 200)
  assert.equal((await getAuftrag('ab1')).status, 'versendet')
  pruefeT09('ungeprüft: Prüfung fehlgeschlagen (Datenbank), bitte von Hand prüfen', 'keine Frist gefunden', 'ungeprüft')
  assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags'))
  assert.ok(protokoll.mock.callCount() > 0)
})
  • Step 2: Lauf, erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeWiderruf.test.js Expected: FAIL (order: 'projekt' fällt auf abschluss zurück: K04 trägt „Abschluss Ihres Abos“, die AB bleibt unverändert).

  • Step 3: Implementieren in server/routes/abo.js (nach den Namen suchen, nicht nach Zeilennummern)
  1. Importe oben ergänzen:
import { findAuftragByNummer } from '../services/auftraege/auftragStore.js'
import { widerrufeAuftrag } from '../services/auftraege/bestaetigung.js'
  1. const ORDERS = [..., 'zeiten'] → [..., 'zeiten', 'projekt'] (den Kommentar mit der Aufzählung der order-Werte über validDeclaration um 'projekt' ergänzen); direkt unter ORDERS: const AB_NUMMER = /^AB-\d{4}-\d{4}$/.
  2. validDeclaration: im zurückgegebenen Objekt zusätzlich abNummer: String(body?.abNummer || '').trim().toUpperCase().slice(0, 20),.
  3. orderLabel(order, sub, { customer = false } = {}) → Parameter { customer = false, abNummer = '' } = {} und als erste Anweisung der Funktion:
  if (order === 'projekt') return AB_NUMMER.test(abNummer) ? `Projektauftrag (Auftragsbestätigung ${abNummer})` : 'Projektauftrag (Auftragsbestätigung)'
  1. Neue Hilfen vor describeWithdrawn:
/** Projektauftrag (Aufträge und Rechnungen, Spec 2026-10-06 Abschnitt 2): Nummer und E-Mail-Adresse müssen zur gesendeten AB passen. Ohne gültige Nummer wird nichts nachgeschlagen. Wirft bei Datenbankfehlern. */
async function withdrawProjekt(d, receivedAt) {
  const auftrag = AB_NUMMER.test(d.abNummer) ? await findAuftragByNummer(d.abNummer) : null
  const adressen = [auftrag?.gesendetAn, auftrag?.kunde?.email].map((adresse) => String(adresse || '').trim().toLowerCase())
  if (!auftrag || !adressen.includes(d.email.toLowerCase())) return { auftrag: null, ergebnis: 'ungeprueft', fristEnde: null }
  return widerrufeAuftrag(auftrag, { jetzt: receivedAt })
}

const PROJEKT_EINGANG = { widerrufen: 'RECHTZEITIG', nach_frist: 'NACH Fristende, vor einer Ablehnung bitte prüfen', bereits_widerrufen: 'bereits widerrufen' }

/** Werte für die Zeilen „Ergebnis“, „Widerrufsfrist bis“ und „Eingang“ im Block projekt von T09 (nur das Team sieht sie). */
function projektWerte({ ergebnis, auftrag, fristEnde }, abNummer) {
  const ergebnisText = {
    widerrufen: () => `Auftragsbestätigung auf „widerrufen“ gesetzt${auftrag.fruehbeginn ? '; Frühbeginn war verlangt: Wertersatz prüfen (§ 357a Abs. 2 BGB)' : ''}`,
    bereits_widerrufen: () => 'war schon widerrufen, nichts geändert',
    kein_widerrufsrecht: () => 'Unternehmen: kein gesetzliches Widerrufsrecht, nichts geändert',
    nach_frist: () => 'nichts geändert',
    nicht_widerrufbar: () => `Status „${auftrag.status}“, nichts geändert, bitte prüfen`,
    fehler: () => 'ungeprüft: Prüfung fehlgeschlagen (Datenbank), bitte von Hand prüfen',
  }[ergebnis]
  return {
    ereignisse: ergebnisText
      ? ergebnisText()
      : AB_NUMMER.test(abNummer)
        ? `ungeprüft: keine Auftragsbestätigung zu Nummer „${abNummer}“ und dieser E-Mail-Adresse gefunden, nichts geändert`
        : 'ungeprüft: keine gültige AB-Nummer angegeben, nichts geändert, bitte von Hand zuordnen',
    fristende: ergebnis === 'kein_widerrufsrecht' ? 'entfällt (Unternehmen)' : fristEnde ? `${fmtDate(fristEnde)}, 24 Uhr` : 'keine Frist gefunden',
    rechtzeitig: PROJEKT_EINGANG[ergebnis] ?? 'ungeprüft',
  }
}

(ergebnis ist 'ungeprueft' ohne Treffer; es gibt dort keinen Eintrag in ergebnisText. Die Nummer wird nur im gültigen Format ausgegeben, nie freier Text des Absenders.) 6. In router.post('/public/withdraw', …):

  • Nach const feature = featureKey !== null: const projekt = d.order === 'projekt' und let pr = { auftrag: null, ergebnis: 'ungeprueft', fristEnde: null }.

  • Im try: if (feature) { → if (projekt) {\n pr = await withdrawProjekt(d, receivedAt)\n } else if (feature) { (für projekt gibt es weder Abo-Suche noch Stripe: sub bleibt null); im catch nach dem console.error ergänzen: if (projekt) pr = { auftrag: null, ergebnis: 'fehler', fristEnde: null }.

  • Nach const { inTime } = result: const projektAngaben = projekt ? projektWerte(pr, d.abNummer) : null und const produktText = projekt ? 'Projektauftrag' : feature ? funktion.label.name : productLabelOf(sub); den Ausdruck produkt: feature ? funktion.label.name : productLabelOf(sub), an beiden Stellen (K04 und T09) durch produkt: produktText, ersetzen.

  • K04: bestellung: orderLabel(d.order, sub, { customer: true }), → bestellung: orderLabel(d.order, sub, { customer: true, abNummer: d.abNummer }),; in den Blöcken pruefen: !inTime && !feature, → pruefen: !inTime && !feature && !projekt, und danach projekt, sowie projekt_unternehmen: projekt && pr.ergebnis === 'kein_widerrufsrecht',.

  • T09: bestellung: orderLabel(d.order, sub), → bestellung: orderLabel(d.order, sub, { abNummer: d.abNummer }),; fristende: result.deadline ? … → fristende: projekt ? projektAngaben.fristende : result.deadline ? …; dem Ausdruck rechtzeitig: feature ? … die Bedingung voranstellen: rechtzeitig: projekt ? projektAngaben.rechtzeitig : feature ? …; ereignisse: feature ? fn.lines : describeWithdrawn(result.withdrawn), → ereignisse: projekt ? projektAngaben.ereignisse : feature ? fn.lines : describeWithdrawn(result.withdrawn),; in den Blöcken kein_abo_gefunden: !sub && !feature, → kein_abo_gefunden: !sub && !feature && !projekt, und projekt, ergänzen.

  • Alles Übrige (Antwort { ok: true, receivedAt }, Reihenfolge K04 vor T09, Ratenbegrenzung, Mailfehler nur ins Protokoll) bleibt.

  • Step 4: Lauf, erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/auftraegeWiderruf.test.js test/publicForms.test.js test/aboRoutes.test.js Expected: # fail 0 (die bestehenden Widerrufstests der anderen Bestellungen bleiben grün).

  • Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5
git add server/routes/abo.js test/auftraegeWiderruf.test.js
git commit -m "Aufträge L5: Widerruf eines Projektauftrags auf der Widerrufsseite (nur Privatpersonen in der Frist, Nummer optional)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 13: Selbstprüfung

Files: keine Änderungen vorgesehen (Korrekturen nur in eigenen Dateien, mit eigenem Commit).

  • Step 1: Freier Speicher

Run: free -m. Unter 1500 MB „available“: warten (Memory „server-speicher“).

  • Step 2: Eigene Tests
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/auftraegeToken.test.js test/auftraegeVorlagen.test.js test/auftraegeMails.test.js test/auftraegeBestaetigung.test.js test/auftraegeTokenRoutes.test.js test/auftraegeKundeRoutes.test.js test/auftraegeKundenansicht.test.js test/auftraegeWiderruf.test.js

Expected: # fail 0.

  • Step 3: Berührte bestehende Tests (Mailvorlagen, Kundenansicht-Wächter, öffentlicher Widerruf, Abo)
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/mailTemplates.test.js test/kundenansichtGuard.test.js test/publicForms.test.js test/aboRoutes.test.js

Expected: # fail 0.

  • Step 4: Nur eigene Dateien geändert, Arbeitsbaum sauber
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5
git status --short
diff <(git diff --name-only feature/auftraege...HEAD | LC_ALL=C sort) - <<'EOF' && echo "nur eigene Dateien"
server/mail-templates/K04 Eingang Widerruf.html
server/mail-templates/K31 Auftragsbestaetigung.html
server/mail-templates/K32 Auftrag bestaetigt.html
server/mail-templates/T09 Widerruf eingegangen.html
server/mail-templates/T22 Auftrag bestaetigt.html
server/routes/abo.js
server/routes/auftraegeKunde.js
server/routes/auftrag.js
server/services/auftraege/bestaetigung.js
server/services/auftraege/mails.js
server/services/auftraege/token.js
test/auftraegeBestaetigung.test.js
test/auftraegeBestaetigungHilfe.js
test/auftraegeKundeRoutes.test.js
test/auftraegeKundenansicht.test.js
test/auftraegeMails.test.js
test/auftraegeToken.test.js
test/auftraegeTokenRoutes.test.js
test/auftraegeVorlagen.test.js
test/auftraegeWiderruf.test.js
test/mailTemplates.test.js
EOF

Expected: git status --short zeigt nur ?? node_modules (der Symlink); diff schweigt und gibt nur eigene Dateien aus. Nichts gepusht, kein --force, kein git add -A. Vom Mailer (server/services/mailer.js), von server/index.js, public/widerrufen.html und dem Wächter kundenansichtGuard.js steht nichts in der Liste.

  • Step 5: Keine Reste
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5
DATEIEN="server/services/auftraege/mails.js server/services/auftraege/bestaetigung.js server/services/auftraege/token.js server/routes/auftrag.js server/routes/auftraegeKunde.js test/auftraegeBestaetigung.test.js test/auftraegeBestaetigungHilfe.js test/auftraegeKundeRoutes.test.js test/auftraegeKundenansicht.test.js test/auftraegeMails.test.js test/auftraegeToken.test.js test/auftraegeTokenRoutes.test.js test/auftraegeVorlagen.test.js test/auftraegeWiderruf.test.js"
command grep -a -n -E "html\?t=|\['auftraege-ab'|const sperreAuftrag|bccTeam: true" server/services/auftraege/mails.js server/services/auftraege/bestaetigung.js server/services/auftraege/token.js server/routes/auftrag.js server/routes/auftraegeKunde.js || echo "keine alten Link-Formen, Sperren oder BCC-Reste"
LC_ALL=C command grep -a -n -P '\xC2\xA0' $DATEIEN || echo "kein unsichtbares geschütztes Leerzeichen im Code"

Erwartet: beide Male die Meldung nach dem || (kein Treffer). Ein Treffer der zweiten Abfrage heißt: irgendwo wurde ein Unicode-Escape mit vier Hexziffern zum Zeichen selbst; dort \u{a0} mit dem Flag u bzw. String.fromCodePoint schreiben.

  • Step 6: Vertragsliste abhaken (jede Zeile gegen den Code prüfen)

  • V4 L5 neuerToken(jetzt = new Date()) → { token, hash, gueltigBis }, hashToken(token) → string | null: 43 Zeichen base64url, SHA-256 hex, 60 Tage; token.js importiert nur node:crypto und ./konstanten.js.

  • V4 L5 bestaetigeAuftrag(auftrag, { ueber, fruehbeginn, jetzt = new Date() }): V2-Regeln, idempotent, unter sperreAuftrag(id) aus konstanten.js (kein eigener Sperrname), Stand innerhalb der Sperre neu gelesen, fruehbeginn nur bei privat, fruehbeginnFassung bei Privatpersonen immer gespeichert und bei Unternehmen leer, bestaetigtPdfSha256 = pdfSha256, K32 und T22 erst nach Freigabe der Sperre (Fehler nur im Protokoll).

  • V4 L5 sendeAuftragMail(auftrag, { pdf, token, erinnerung = false }) wirft bei Fehler der Kundenmail: K31 an den Kunden mit bccTeam: false, danach die Teamkopie über sendAdminNotice ohne Token, ohne Link und ohne Anhänge (Fehler der Kopie nur im Protokoll, der Versand gilt als erfolgreich); sendeBestaetigtMails(auftrag) wirft nie.

  • V6 Vorlagen K31/K32/T22: Dateinamen, Betreffe (T22 mit Präfix [Aufträge], keine Testausnahme), Platzhalter, Blöcke (K31 mit teamkopie und dem Gegenblock kundenmail), K32 mit fruehbeginn_text (wörtlich FRUEHBEGINN_TEXT, kein Satz zum Widerrufsrecht), woms_link = <womsBaseUrl>/customers/<kundenId>?tab=invoices, Link <publicBaseUrl>/auftrag.html#t=<token> (Fragment, nie ?t=), Anhänge <Nummer>.pdf und WEBklar-AGB-<Fassung>.pdf nur in der Kundenmail, BCC Team nur für K32.

  • K04 und T09: nur der Block projekt (K04 mit projekt_unternehmen) ist neu, keine neuen Platzhalter, die gepinnten Block-Listen in test/mailTemplates.test.js stimmen; der K04-Kasten sagt nichts zu Erstattung oder Wertersatz.

  • V5 GET /api/auftrag/:token, …/pdf, POST …/bestaetigen: 404 „Dieser Link ist ungültig oder abgelaufen.“, 410 ersetzt/storniert/widerrufen, 429 „Zu viele Anfragen. Bitte später erneut versuchen.“ (GET 120/h, POST 10/h je IP im Speicher), 400 „Bitte bestätigen Sie den Auftrag mit dem Häkchen.“ ohne bestaetigt: true (404/410 gehen dem 400 vor).

  • V5/V3 GET /api/auftraege, GET /api/auftraege/:id/pdf, POST /api/auftraege/:id/bestaetigen: nur eigene, keine Entwürfe, kannBestaetigen = (status === 'versendet' || (status === 'abgerechnet' && !bestaetigtAm)) && nicht Kundenansicht, 400 mit demselben Text wie die Token-Route, 404 fremd/Entwurf/unbekannt, 410 wie die Token-Routen, 401 ohne Sitzung; Kundenansicht gesperrt, kein Eintrag in KUNDENANSICHT_ALLOWED.

  • V5 POST /api/abo/public/withdraw mit order: 'projekt', abNummer optional: nur privat mit passender Nummer und E-Mail-Adresse → widerrufen mit widerrufenAm (Fristprüfung über withdrawalDeadline(gesendetAm)), sonst nur Mails (ohne oder mit ungültiger Nummer, fremder Adresse oder Datenbankfehler mit dem Vermerk „ungeprüft“); Unternehmen: K04 mit Hinweis ohne gesetzliches Widerrufsrecht; Text „Projektauftrag (Auftragsbestätigung )“; Antwort immer { ok: true, receivedAt }.

  • Schalter: aus → Token-Routen 404, Kundenrouten { aktiv: false, auftraege: [] } bzw. 404; Widerruf unabhängig vom Schalter.

  • Sperren: bestaetigeAuftrag und widerrufeAuftrag laufen unter sperreAuftrag(id) (ein zusammengesetzter Schlüssel), nie sperreRechnung darin; K32/T22 (K04/T09) erst nach der Freigabe.

  • Review Focus 1–5 je durch einen Test belegt (Verweise oben), keine IP und kein Token im Dokument oder Protokoll, der Link nie in einer Mail ans Team.

Kein Commit nötig, wenn nichts geändert wurde; Korrekturen als eigener Commit mit derselben Schlusszeile.


Vertragsfragen

Stand nach der Planrunde: Die Fragen 1 bis 11 sind entschieden (siehe Übersicht, Nachträge); dieser Plan folgt den Entscheidungen. Die Fragen 12 bis 14 sind neu, jeweils mit Vorschlag „so lassen“.

  1. K04/T09 und die Dateitabelle. Entschieden (siehe Übersicht, Nachträge: L5 bekommt K04 und T09, nur Block projekt). Umgesetzt in Task 11 (K04 projekt mit Vertragsart und, bei Unternehmen, Hinweis ohne gesetzliches Widerrufsrecht; T09 projekt); die frühere Notlösung über den Block pruefen entfällt.
  2. T22-Betreff. Entschieden (siehe Übersicht, Nachträge und V6). [Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}}; das Präfix Aufträge kommt in die Präfix-Gruppe von test/mailTemplates.test.js, keine Ausnahme für T22 (Task 3).
  3. Kunden-POST für ersetzt/storniert/widerrufen und Text des 400. Entschieden (siehe Übersicht, Nachträge und V5). 400 „Bitte bestätigen Sie den Auftrag mit dem Häkchen.“ für Token- und Kundenroute, 410 wie die Token-Routen, 404 sonst (Task 8 und 9).
  4. kannBestaetigen. Entschieden (siehe Übersicht, V3). (status === 'versendet' || (status === 'abgerechnet' && !bestaetigtAm)) && nicht Kundenansicht (Task 9, mit Test über alle Status).
  5. AGB-Anhang. Entschieden (siehe Übersicht, V3 und Nachträge L7: alte AGB-PDF-Fassungen bleiben liegen). Es zählt auftrag.agbFassung (Rückfall AGB_FASSUNG). Fehlt die Datei, wirft sendeAuftragMail (L3 setzt mailStatus: 'fehler'). Für Z1: nach dem Merge von L7 prüfen, dass public/rechtliches/agb-<AGB_FASSUNG>.pdf existiert, sonst scheitert jedes Senden.
  6. Gemeinsame Sperre je AB. Entschieden (siehe Übersicht, V2 und Nachträge „Sperren“). sperreAuftrag(id) aus konstanten.js (L0), ein zusammengesetzter Schlüssel; L5 legt sie um Bestätigen und Widerrufen und sperrt nie sperreRechnung darin. K32/T22 (K04/T09) gehen erst nach der Freigabe raus.
  7. Widerruf und Schalter. Entschieden (siehe Übersicht, Nachträge und V5). Der Widerruf eines Projektauftrags wird auch bei AUFTRAEGE_ENABLED=off verarbeitet; die AB-Nummer ist optional (ohne oder ungültig: nur Mails mit dem Vermerk „ungeprüft“).
  8. Zusätzliche Exporte. Zur Kenntnis, kein Vertragsthema. Nur L5-intern oder für Tests: widerrufeAuftrag, bestaetigungsLage, GESPERRT_MELDUNG (bestaetigung.js); k31Mail, k32Mail, t22Mail, agbPdfPfad, setzeAgbVerzeichnisFuerTests (mails.js); setzeUhrFuerTests, resetAuftragRoutesForTests (routes/auftrag.js). sendeAuftragMail liest bei fehlendem pdf die Datei über lesePdf (Superset von V4).
  9. L3 übergibt das gespeicherte Dokument an sendeAuftragMail. Entschieden (siehe Übersicht, Nachträge L3: Mail mit dem gespeicherten Dokument). Mit nummer, gesendetAm, gesendetAn, tokenGueltigBis, agbFassung, kunde.
  10. Frühbeginn-Fassung. Entschieden (siehe Übersicht, Nachträge L5). Bei Privatpersonen wird fruehbeginnFassung = FRUEHBEGINN_FASSUNG auch ohne Haken gespeichert, bei Unternehmen '' (Task 5).
  11. Testhilfe ohne .test.js. Entschieden (siehe Übersicht, Nachträge: Testhilfen gehören dem anlegenden Strang). test/auftraegeBestaetigungHilfe.js gehört L5 und wird von npm test nicht als Test gestartet. Die Tests erwarten für AuftragOeffentlich mindestens nummer, status, titel, summeCents, kundenart, positionen (mit summeCents) und agbUrl wie in V3; weicht L0 ab, fällt das in Task 7 auf.
  12. K31 braucht einen sechsten Block kundenmail. V6 nennt für K31 die Blöcke erinnerung, privat, festpreis, aufwand, teamkopie. Die Vorlagen kennen nur positive Blöcke (WENN/ENDE); „statt Knopf und Link der Hinweis“ heißt aber, dass Knopf, Anhangs-Kasten und Verbraucherhinweis in der Teamkopie fehlen müssen. Plan: zusätzlicher Block kundenmail als Gegenblock zu teamkopie (genau einer von beiden ist an; privat ist in der Teamkopie aus). Vorschlag: V6 um kundenmail ergänzen. Kein anderer Strang liest die K31-Blöcke (L3 ruft nur sendeAuftragMail auf).
  13. K04 und T09: zweiter Blockname, keine neuen Platzhalter. K04 braucht für den Zusatz „Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.“ einen verschachtelten Block projekt_unternehmen (nur positive Blöcke). T09 nutzt {{ereignisse}}, {{fristende}} und {{rechtzeitig}} für Ergebnis, Frist und Eingang; die Zeile „Abo“ zeigt dabei „Projektauftrag“ (die Haupttabelle bleibt unverändert, der Kasten sagt es dem Team). Vorschlag: so lassen; ein eigener Platzhalter oder eine geänderte Tabelle wäre mehr als „nur Block projekt“.
  14. Teamkopie von K31. Sie geht als eine Mail über sendAdminNotice an alle CHAT_NOTIFY_EMAILS, ohne Anhänge (die AB-PDF liegt im Ticketsystem) und erst, nachdem die Kundenmail rausging; sie kommt als „WEBklar Kundenportal“ (wie die T-Mails), nicht als „WEBklar“. Scheitert nur die Kopie, gilt der Versand als erfolgreich (Protokoll mit der AB-Nummer, mailStatus bleibt gesendet). Vorschlag: so lassen.