Files
tickte-system/docs/superpowers/plans/2026-10-17-workers-p5-portal-kaufweg.md
T
ClaudeandClaude Opus 5.5 11e4280a92 Hustler: Name in der Oberfläche, Budget höchstens 150 $/Monat (6 $/Tag) mit hartem Deckel
Kenso, 17.10.: "150 € im Monat max", "nenne es Hustler". Technische
Namen (workers*) bleiben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:26:42 +00:00

117 KiB
Raw Blame History

Workers P5 – Kaufweg im Portal – Umsetzungsplan

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 Interessent bezahlt über einen geheimen „Website übernehmen“-Link ohne Login ein Hosting-Abo; erst danach entstehen WOMS-Kunde, Portal-Zugang, Zustimmungsnachweis, Kundenprojekt mit der Vorschau und der Deal im Hustler.

Architecture: Öffentliche Seite uebernahme.html plus Routen /api/uebernahme/:token (Muster routes/auftrag.js) lesen das Angebot (workersOffers, vom Dienst geschrieben) und erzeugen eine Stripe-Checkout-Sitzung ohne WOMS-Kunden (workersOfferId statt webklarCustomerId). Im Stripe-Webhook (routes/abo.js) fängt der neue Zweig isWorkersCheckoutEvent die Sitzung ab, legt den Kunden an, verknüpft Stripe, lässt den normalen Abo-Weg laufen (Spiegel, Abschluss-Ereignis, T02, K01) und erledigt danach Nachweis, Vorschau-Übernahme, K44 und Rückmeldung an den Hustler – jeder Schritt wiederholbar. Portal und Dienst teilen dieselbe Appwrite-Datenbank; eine eigene Dienst-Route (Spec §7, dritter Portal-Punkt) ist für den Kaufweg deshalb nicht nötig.

Tech Stack: Node 20/ESM (Host-Tests Node 24), Express 4, stripe 19, Appwrite-REST (appwriteAdmin.js), HTML-Mailvorlagen (mailTemplates.js), Gitea-API (giteaAdmin.js), node:test mit den Nachbauten appwriteMock, fakeStripe, fakeGitea, mailSink, fakeDom; Vanilla-JS-Seite im Stil von auftrag.html.

Spec: /home/webklar/apps/ticket.webklar.com/dev-workers/docs/superpowers/specs/2026-10-17-workers-design.md · Schnittstellen: /home/webklar/apps/ticket.webklar.com/dev-workers/docs/superpowers/plans/2026-10-17-workers-00-uebersicht.md (verbindlich)

Arbeitsort: Portal-Worktree /home/webklar/apps/kundenbereich.webklar.com/dev-workers-kauf (Branch feature/workers-kaufweg, entsteht in P5-T01). Alle Befehle unten laufen dort; Pfade sind relativ dazu.

Ergänzungen zur Übersicht (P5) – übernommen, siehe Übersicht §8, §11

Alle zehn Ergänzungen stehen in der Übersicht (§11.1 P5-E1 bis P5-E10, dazu §11.2 A11–A14); die Zeilen unten sind nur Merkzettel mit Verweis, im Zweifel gilt die Übersicht.

  • E1 Dateipfade der Seite: public/uebernahme.{html,js,css} und public/uebernahme-ansicht.js (das Portal hat kein public/js) – übernommen, siehe Übersicht §8, §11, P5-E1.
  • E2 Webhook-Vertrag: handleWorkersCheckoutEvent(event, { aboWeg }), aboWeg = handleAboEvent; K01 (Vertragsbestätigung, § 312f BGB) kommt über den normalen Abo-Weg – siehe Übersicht §8, §11, P5-E2.
  • E3 Neue Namen: config.collections.workersSettings (liest offerText), config.workers = { kaufEnabled, vorschauRoot, vorschauBaseUrl }, Env WORKERS_KAUF_ENABLED (Standard off), WORKERS_VORSCHAU_ROOT, WORKERS_VORSCHAU_BASE – siehe Übersicht §8, §11, P5-E3.
  • E4 Betrieb: Der Portal-Container hängt workers_data:/workers-vorschau:ro an – siehe Übersicht §9, §11, P5-E4.
  • E5 Auftrag an P4 (A14): Vorschau-Server 404 ab convertedProjectId; Angebote abgelaufen bei Sperre (das Portal prüft workersProspects.status === 'gesperrt' zusätzlich); Ordner einer übernommenen Vorschau mindestens 7 Tage (Stripe wiederholt Webhooks bis zu 3 Tage), Verzeichnisse 0755, Dateien 0644 – übernommen, siehe Übersicht §4.13, §11, P5-E5.
  • E6 Auftrag an P1/P2 (A12, A13): Ereignis deal (workerId system, ID eventIdFor('deal', offerId), metaJson laut Übersicht §5); newAbosMonth, mrrNewEur und dealsMonth zählt P1 aus workersOffers (status 'bezahlt', paidAt im Berliner Monat) – Auftrag an P1, nicht mehr offen; P5 setzt genau diese Felder, paidAt als ISO-UTC mit Z (P5-T12) – übernommen, siehe Übersicht §5, §11, P5-E6.
  • E7 Mail-Kennungen: K44 „Website übernommen“ und T25 „[Hustler] …“ (Betreff-Präfix [Hustler], Präfix-Test in mailTemplates.test.js erweitert; der Dateiname T25 Workers Hinweis.html bleibt als technischer Name) – siehe Übersicht §8, §11, P5-E7.
  • E8 createWorkersCheckout(offer, consent, buyer, { token }): vierter, optionaler Parameter; der Token steht nur im Fragment der Abbruch-Adresse – siehe Übersicht §8, §11, P5-E8.
  • E9 Rechtsrunde: Neue Wortlaute (Kundenart-Auswahl, Stripe-Hinweistext, Zitat des Angebotssatzes) brauchen Kensos Freigabe vor WORKERS_KAUF_ENABLED=on; der Bestellwortlaut ist Fassung v5 (2026-09-30-v5), Wort für Wort aus public/app.js – siehe Übersicht §11, P5-E9.
  • E10 Nur Hosting (A11): P5 verkauft ausschließlich productKey: 'hosting', jedes andere Produkt ergibt die neutrale 404-Antwort (die Stundengutschrift der ersten Wartungs-Rechnung, invoice.paid, träfe vor der Verknüpfung ein und ginge verloren). P1 und P4 setzen es um: P1 beschränkt offerProductKey auf hosting, P4 legt nur solche Angebote an – siehe Übersicht §4.12, §11, P5-E10.

Global Constraints

  • Alle Regeln aus Übersicht §2 gelten.
  • Portal-Tests: timeout 900 node --max-old-space-size=1024 --test test/<datei>.test.js; reine Tests ohne Server timeout 300 …. Nachbauten zuerst starten, dann useTestEnv(), dann erst server/config.js importieren (Muster test/aboRoutes.test.js). Keine echten Netzaufrufe; Vorschau-Dateien liegen in Tests in einem eigenen Ordner unter os.tmpdir().
  • Branch feature/workers-kaufweg von origin/main im Worktree dev-workers-kauf; Agenten pushen nie.
  • Der Kunde entsteht erst im Webhook nach checkout.session.completed. Vorher schreibt P5 nichts in customers, customerPortalAccess, websiteProjects, featureConsents und legt keinen Appwrite-Benutzer an (Übersicht §2.13).
  • Token: 43 Zeichen base64url (neuerToken()/hashToken() aus services/auftraege/token.js), in workersOffers.tokenHash nur der sha256-Hex. Der Token steht in keiner Logzeile und in keiner Stripe-Adresse außer im Fragment der cancel_url.
  • Ungültiger, abgelaufener, bezahlter, widerrufener Link, Interessent gesperrt, Schalter aus, Produkt ungleich hosting (E10): überall dieselbe Antwort 404 { error: 'Dieser Link ist nicht mehr gültig.' }.
  • Header jeder Antwort: Cache-Control: no-store, X-Robots-Tag: noindex, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer. Grenzen im Speicher: GET 120/h und POST 10/h je IP, zusätzlich POST 10/h je Token-Hash.
  • Schalter WORKERS_KAUF_ENABLED gilt nur für die Routen; der Webhook-Zweig arbeitet immer (bezahlte Sitzungen werden nie liegen gelassen). ABO_PILOT_EMAILS gilt hier nicht.
  • Bedingungen: config.abo.termsVersion = 2026-09-30-v5; Hosting config.products.hosting (amountCents 2000, minTermMonths 12, § 19 UStG, ohne MwSt.). Eine abweichende termsVersion oder ein abweichendes lateStartAt in der Bestellung ergibt 409.
  • Stripe: Sitzung mode: 'subscription', Session-metadata { workersOfferId, productKey }; subscription_data.metadata trägt die Zustimmungsfelder des normalen Checkouts ohne webklarCustomerId, ohne offerId/offerText (sonst greift activateOfferFromCheckout), plus workersOfferId, kundenart, Käuferangaben und Angebotssatz (Werte ≤ 500 Zeichen, Schlüssel ≤ 40).
  • Kunde: createCustomerWithPortalAccess({ name, email, password, kundenart }), danach customerStatus: 'customer'. Passwort 16 Zeichen aus abcdefghjkmnpqrstuvwxyz23456789, gezogen mit crypto.randomInt (nie Math.random). K44 geht ohne BCC an die E-Mail-Adresse des Kundendokuments (bccTeam: false), danach forgetStoredPortalPassword. Passwort und Token nie in Logs.
  • Reservierte Subdomain entwurf (Übersicht §9): kein Projekt darf sie tragen oder erben.
  • Vorschau-Dateien nur aus config.workers.vorschauRoot/<token>: höchstens 300 Dateien, 5 MB je Datei, 25 MB gesamt, index.html Pflicht, keine Symlinks, nie .webklar-preview.json überschreiben.
  • Texte an Käufer in Sie-Form, mit echten Umlauten; Mail-Kennungen K44 und T25 (vor dem Anlegen in P5-T10 gegen alle Refs prüfen).
  • Name [Kenso, 17.10.: Name „Hustler“]: Was Mitarbeiter sehen, heißt „Hustler“: Betreff-Präfix der Team-Hinweise [Hustler] (T25), die Tag-Zeile „Intern · Hustler“, der in WOMS angezeigte Name beim Anlegen des Projekts (staffName: 'Hustler'). Texte an Käufer (Übernahme-Seite, Stripe-Hinweis, K44, Nachweis-Wortlaut) nennen weder „Workers“ noch „Hustler“ (Pin-Tests in P5-T02, P5-T08, P5-T10, P5-T13, P5-T14); sie sprechen als „WEBklar“. Technische Bezeichner bleiben unverändert: Sammlungen workers*, Dienst/Container webklar-workers, Dateien und Funktionen (workersKauf.js, createWorkersCheckout …), Metadaten workersOfferId, workersAngebotssatz, Env WORKERS_*, Volume workers_data, Mail-Dateiname T25 Workers Hinweis.html, Ereignisart deal. „Workers“ in Commit-Präfixen und Plan-Dateinamen ist das Projekt-Kürzel.
  • Nicht in P5: Widerruf oder Kündigung eines Hustler-Kunden laufen über den bestehenden Weg (contractEvents, K03/K04, T08/T09); workersOffers.status = 'widerrufen' setzt später, wer es braucht.

Review Focus

  • Toter Link (abgelaufen, schon bezahlt, Interessent gesperrt, Schalter aus, Token verstümmelt, Mailprogramm hat #t= abgeschnitten) → überall dieselbe neutrale 404-Antwort bzw. Seite „Dieser Link ist nicht mehr gültig“ mit Kontakt, nie ein Hinweis auf den Grund. Tests: P5-T07, P5-T09, P5-T14.
  • Doppelklick (gleichzeitige Anfragen), zweiter Tab, Zurück-Knopf bei Stripe → höchstens eine offene Sitzung und ein Stripe-Kunde (Sperre je Angebot); ist die alte schon bezahlt, 409 statt zweiter Sitzung; eine zweite bezahlte Sitzung legt keinen zweiten Kunden an und meldet das Team. Tests: P5-T08, P5-T12.
  • Webhook doppelt, gleichzeitig, verspätet (subscription.created zuerst) oder mitten im Ablauf abgebrochen → genau ein Kunde, ein Nachweis, eine K44, ein Projekt; die Wiederholung macht dort weiter, wo es hing. Tests: P5-T11, P5-T12.
  • E-Mail gehört schon einem Kunden → vor dem Bezahlen 409 mit freundlichem Satz und ohne Stripe-Sitzung; nach dem Bezahlen (Wettlauf) kein zweites Konto, kein Absturz, kein Passwort an Fremde, T25 ans Team, Antwort 200. Tests: P5-T09, P5-T11.
  • Vorschau-Dateien fehlen, sind zu groß oder unsicher (Token mit ../, Symlink, kein index.html, Volume nicht eingehängt) oder Gitea ist weg → die Vertragsbestätigung (K01) kommt immer an; bei dauerhaftem Fehler (Ordner fehlt, zu groß, unsicher, Gitea-Antwort 4xx) gehen T25 und die Zugangsdaten (K44) trotzdem raus und die Antwort ist 200; bei vorübergehendem (Gitea nicht erreichbar, 5xx) antwortet der Webhook 500 und K44 kommt mit der Wiederholung; nichts außerhalb der Vorschau-Wurzel wird gelesen. Tests: P5-T03, P5-T06, P5-T12.

File Structure

Datei Verantwortung
server/config.js (ändern) Sammlungen workers*, Block workers
server/services/reserviert.js RESERVIERTE_SUBDOMAINS, istReserviert
server/services/giteaAdmin.js (ändern) ensureSubdomainAvailable/updateWebsiteProject lehnen entwurf ab; neu replaceRepoFiles, gitBlobSha
server/services/previewDeploy.js (ändern) foreignSubdomainOwner kennt reservierte Namen
server/services/workersKaufTexte.js Wortlaut Fassung v5 für die Seite, Nachweis-Texte (rein, ohne Imports außer bestelltexte.js)
server/services/workersVorschau.js Vorschau-Ordner sicher lesen (leseVorschau, VorschauProblem)
server/services/workersKaufProjekt.js Vorschau → Kundenprojekt (Namenswahl, anlegen, Dateien committen, Marker)
server/services/workersKauf.js findOfferByToken, uebernahmeAnsicht, pruefeBestellung, createWorkersCheckout; re-exportiert die Webhook-Funktionen
server/services/workersKaufAbschluss.js isWorkersCheckoutEvent, handleWorkersCheckoutEvent, neuesPasswort, Team-Hinweise
server/routes/uebernahme.js öffentliche Routen GET/POST
server/routes/abo.js, server/index.js (ändern) Webhook-Zweig, Mount /api/uebernahme
server/services/vertragsNachweis.js (ändern) optionaler Parameter wortlaut
server/services/customerAdmin.js (ändern) Rückbau des Kundendokuments, wenn das Anlegen des Zugangs scheitert
server/mail-templates/K44 Website übernommen.html, T25 Workers Hinweis.html Zugangsdaten-Mail, Team-Hinweis
public/uebernahme.html, .css, uebernahme-ansicht.js, uebernahme.js Seite ohne Login
scripts/workers-kauf-testangebot.mjs Testangebot für die manuelle Prüfung (und Aufräumen)
test/support/workersSchema.js, test/support/workersKaufUmgebung.js (neu), test/support/schema.js, test/support/fakeStripe.js, test/helpers/fakeGitea.js, test/mailTemplates.test.js (ändern) Testrahmen
test/workers*.test.js Tests je Task

P5-T01: Worktree, Konfiguration, reservierte Subdomain entwurf

Files:

  • Create (Git): Worktree /home/webklar/apps/kundenbereich.webklar.com/dev-workers-kauf, Branch feature/workers-kaufweg
  • Create: server/services/reserviert.js
  • Modify: server/config.js (Block collections nach projektRechnungen; neuer Block workers nach auftraege), server/services/giteaAdmin.js (ensureSubdomainAvailable, Konfliktprüfung in updateWebsiteProject), server/services/previewDeploy.js (foreignSubdomainOwner)
  • Test: test/workersReserviert.test.js

Interfaces:

  • Produces: config.collections.{workersOffers, workersProspects, workersPreviews, workersEvents, workersSettings} (Env APPWRITE_COLLECTION_WORKERS_OFFERS|PROSPECTS|PREVIEWS|EVENTS|SETTINGS, Standard = Sammlungs-ID); config.workers = { kaufEnabled: boolean, vorschauRoot: string, vorschauBaseUrl: string }; RESERVIERTE_SUBDOMAINS: readonly string[] (['entwurf']); istReserviert(subdomain: unknown): boolean (trimmt, Kleinschreibung).

  • Step 1: Worktree anlegen

cd /home/webklar/apps/kundenbereich.webklar.com/build && git fetch origin && git worktree add ../dev-workers-kauf -b feature/workers-kaufweg origin/main
cd ../dev-workers-kauf && ln -s ../build/node_modules node_modules && git status --short && git log --oneline -1

Erwartet: git status --short leer (node_modules steht in .gitignore), letzter Commit = origin/main.

  • Step 2: Failing test test/workersReserviert.test.js (Muster test/giteaAdmin.test.js: startFakeAppwrite aus ./helpers/fakeAppwrite.js, Env setzen, dann dynamisch importieren; Appwrite-Dokument { $id: 'wp1', repoFullName: 'WEBklar/x', projectName: 'X', subdomain: 'x', projectType: 'preview' } in websiteProjects; GITEA_BASE_URL=http://127.0.0.1:1, GITEA_API_TOKEN=t, PREVIEW_DEPLOY_ROOT='', TRAEFIK_DYNAMIC_DIR='')
test('config: Sammlungen und Block `workers` mit Standardwerten', () => {
  for (const n of ['workersOffers', 'workersProspects', 'workersPreviews', 'workersEvents', 'workersSettings']) assert.equal(config.collections[n], n)
  assert.deepEqual(config.workers, { kaufEnabled: false, vorschauRoot: '/workers-vorschau/vorschau', vorschauBaseUrl: 'https://entwurf.project.webklar.com' })
})
test('istReserviert: entwurf in jeder Schreibweise, sonst nichts', () => {
  assert.deepEqual(RESERVIERTE_SUBDOMAINS, ['entwurf'])
  assert.deepEqual(['entwurf', ' Entwurf ', 'ENTWURF'].map(istReserviert), [true, true, true])
  assert.deepEqual(['entwurf-2', 'mueller', '', null, undefined].map(istReserviert), [false, false, false, false, false])
})
test('ensureSubdomainAvailable("entwurf") → 409 reserviert; freie Namen bleiben frei', async () => {
  await assert.rejects(ensureSubdomainAvailable('entwurf'), (e) => e.status === 409 && /reserviert/.test(e.message))
  await assert.doesNotReject(ensureSubdomainAvailable('frei-und-neu'))
})
test('updateWebsiteProject: Subdomain "Entwurf" → 409 vor jedem Gitea-Aufruf', async () => {
  await assert.rejects(updateWebsiteProject('wp1', { subdomain: 'Entwurf' }), (e) => e.status === 409 && /reserviert/.test(e.message))
})
test('foreignSubdomainOwner: entwurf gehört dem System, ohne Datenbankzugriff', async () => {
  assert.equal(await foreignSubdomainOwner('WEBklar/neu', 'entwurf'), 'System (reserviert)')
})
  • Step 3: Test laufen lassen, Fehlschlag sehen

Run: timeout 900 node --max-old-space-size=1024 --test test/workersReserviert.test.js Erwartet: FAIL mit Cannot find module '../server/services/reserviert.js'.

  • Step 4: Implementieren

    1. server/services/reserviert.js: die beiden Exporte oben; Meldung der Aufrufer Subdomain "<name>" ist reserviert (Status 409).
    2. server/config.js: fünf Sammlungen im Stil der übrigen (process.env.APPWRITE_COLLECTION_WORKERS_OFFERS || 'workersOffers' usw.); Block workers: { kaufEnabled: (process.env.WORKERS_KAUF_ENABLED || '').trim().toLowerCase() === 'on', vorschauRoot: process.env.WORKERS_VORSCHAU_ROOT || '/workers-vorschau/vorschau', vorschauBaseUrl: (process.env.WORKERS_VORSCHAU_BASE || 'https://entwurf.project.webklar.com').replace(/\/$/, '') }.
    3. ensureSubdomainAvailable und die Konfliktprüfung in updateWebsiteProject (vor der listDocuments-Abfrage, nur wenn nextSubdomain gesetzt und geändert): bei istReserviert Fehler 409 Subdomain "<name>" ist reserviert.
    4. foreignSubdomainOwner (previewDeploy.js): als erste Zeile nach dem Leer-Check if (istReserviert(subdomain)) return 'System (reserviert)'.
  • Step 5: Test laufen lassen, bestehende Sperr-Tests prüfen

Run: timeout 900 node --max-old-space-size=1024 --test test/workersReserviert.test.js && timeout 900 node --max-old-space-size=1024 --test test/subdomainSperre.test.js && timeout 900 node --max-old-space-size=1024 --test test/giteaAdmin.test.js Erwartet: alle PASS.

  • Step 6: Commit
git add server/services/reserviert.js server/config.js server/services/giteaAdmin.js server/services/previewDeploy.js test/workersReserviert.test.js
git commit -m "feat(workers-kauf): Konfiguration, Sammlungen und reservierte Subdomain entwurf" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P5-T02: Wortlaut Fassung v5 und Nachweis-Texte

Files:

  • Create: server/services/workersKaufTexte.js
  • Test: test/workersKaufTexte.test.js

Interfaces:

  • Consumes: KNOPF, klartext aus server/services/bestelltexte.js.

  • Produces:

    • texteFuerClient({ produktName: string, leistung: string, amountCents: number, minTermMonths: number, lateStartAt: string /* ISO */ }): { zustimmung: {text, urls}, start: { frage, sofort: {titel, text}, nachFrist: {titel, text}, hinweisFirmenkunden }, kundenart: { frage, unternehmen, privat }, zeilen: { leistung, preis, abrechnung, laufzeit, zahlung, anbieter, faellig: {ohneWahl, sofort, nachFrist}, beginn: {ohneWahl, sofort, nachFrist} }, maengel: {text, urls}, knopf, danach } – alle Sätze fertig mit eingesetztem Datum (dd.mm.yyyy, Berlin) und Betrag (20,00 € mit normalem Leerzeichen).
    • angebotssatzFallback({ amountCents: number, minTermMonths: number }): string
    • uebernahmeNachweisTexte({ kundenart: 'unternehmen'|'privat', startChoice: 'sofort'|'nach_frist', texte: ReturnType<typeof texteFuerClient>, bestellung: string, angebotssatz: string }): Array<{ id: string, text: string, haken: boolean }> in der Reihenfolge angebot, bestellung, zustimmung (haken), beginn (haken), knopf, kundenart.
  • Step 1: Failing test test/workersKaufTexte.test.js (rein, kein Server)

const appJs = readFileSync(new URL('../public/app.js', import.meta.url), 'utf8')
const T = texteFuerClient({ produktName: 'WEBklar Hosting-Abo', leistung: 'Bereitstellung Ihrer statischen Website, 1 Domain inklusive', amountCents: 2000, minTermMonths: 12, lateStartAt: '2026-11-02T05:00:00.000Z' })
const teile = (s) => s.split(/[\[\]]/).map((x) => x.trim()).filter(Boolean)

test('Wortlaut: Sätze der Bestellseite stehen wörtlich in public/app.js (Fassung v5)', () => {
  const sätze = [T.zustimmung.text, T.start.frage, T.start.sofort.titel, T.start.sofort.text, T.start.nachFrist.titel, T.start.hinweisFirmenkunden, T.maengel.text, T.zeilen.abrechnung, T.zeilen.zahlung, T.zeilen.anbieter, T.danach]
  for (const satz of sätze) for (const t of teile(satz)) assert.ok(appJs.includes(t), `fehlt in app.js: ${t}`)
  // Sätze mit eingesetzten Werten: die festen Bruchstücke
  for (const f of ['Heute fällig: ', ' (gemäß § 19 UStG ohne Umsatzsteuer)', 'Erste Abbuchung am ', '; heute wird nichts abgebucht', 'sofort, auf Ihren ausdrücklichen Wunsch vor Ablauf der Widerrufsfrist', '; bis dahin keine Leistung und keine Abbuchung', 'Bis dahin erbringt WEBklar keine Leistungen und bucht nichts ab.', 'Bitte oben wählen: sofort oder nach Ablauf der 14-tägigen Widerrufsfrist', 'kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach unbefristet und jederzeit zum Ende des Abrechnungsmonats kündbar']) assert.ok(appJs.includes(f), f)
})
test('Werte eingesetzt: Betrag, Datum, Laufzeit', () => {
  assert.equal(T.zeilen.faellig.sofort, 'Heute fällig: 20,00 € (gemäß § 19 UStG ohne Umsatzsteuer)')
  assert.equal(T.zeilen.faellig.nachFrist, 'Erste Abbuchung am 02.11.2026: 20,00 € (gemäß § 19 UStG ohne Umsatzsteuer); heute wird nichts abgebucht')
  assert.equal(T.zeilen.beginn.nachFrist, 'nach Ablauf der 14-tägigen Widerrufsfrist (am 02.11.2026); bis dahin keine Leistung und keine Abbuchung')
  assert.match(T.zeilen.laufzeit, /^Mindestlaufzeit 12 Monate ab Leistungsbeginn/)
  assert.equal(T.zeilen.preis, '20,00 € pro Monat (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten')
  assert.equal(T.knopf, 'Zahlungspflichtig bestellen')
  assert.doesNotMatch(JSON.stringify(T), /Workers|Hustler/)   // Käufertexte nennen den internen Namen nie
})
test('Angebotssatz-Fallback ist Kensos Satz aus Spec §11.2', () => {
  assert.equal(angebotssatzFallback({ amountCents: 2000, minTermMonths: 12 }), 'Gratis-Vorschau, unverbindlich. Wenn sie gefällt, ist die Website im Hosting-Abo enthalten: 20 € im Monat, 12 Monate Mindestlaufzeit, ohne MwSt. nach § 19 UStG.')
})
test('Nachweis-Texte: Reihenfolge, Häkchen, Klartext', () => {
  const n = uebernahmeNachweisTexte({ kundenart: 'privat', startChoice: 'nach_frist', texte: T, bestellung: 'B', angebotssatz: 'A' })
  assert.deepEqual(n.map((t) => [t.id, t.haken]), [['angebot', false], ['bestellung', false], ['zustimmung', true], ['beginn', true], ['knopf', false], ['kundenart', false]])
  assert.doesNotMatch(n[2].text, /[\[\]]/)
  assert.match(n[3].text, /^Nach Ablauf der 14-tägigen Widerrufsfrist/)
  assert.equal(n[5].text, T.kundenart.privat)
  assert.notEqual(T.kundenart.privat, T.kundenart.unternehmen)
})
  • Step 2: Test laufen lassen, Fehlschlag sehen

Run: timeout 300 node --max-old-space-size=1024 --test test/workersKaufTexte.test.js Erwartet: FAIL Cannot find module '../server/services/workersKaufTexte.js'.

  • Step 3: Implementieren (server/services/workersKaufTexte.js)

Die Sätze wörtlich aus public/app.js:961-1004 übernehmen (961 Zustimmung mit den zwei Links /abo-bedingungen.html und /abo-bedingungen.html#widerruf; 964-969 Startwahl samt Firmenkunden-Hinweis; 972-984 Tabellenzeilen; 985 Mängelhaftung mit /abo-bedingungen.html und /datenschutz.html; 989-990 Knopf und Nachtext; 993-1004 die beiden Varianten für „Fällig“ und „Leistungsbeginn“); Link-Texte stehen in eckigen Klammern, urls in derselben Reihenfolge (Muster bestelltexte.js). Der Pin-Test oben schlägt bei jeder Abweichung an. Neu und deshalb von Hand formuliert (Rechtsrunde, E9): kundenart.frage = „Bestellen Sie als Unternehmen oder privat?“, unternehmen = „Ich bestelle als Unternehmen oder Selbstständige(r).“, privat = „Ich bestelle als Privatperson.“. uebernahmeNachweisTexte setzt beginn aus ${titel} ${text} der gewählten Startvariante und zustimmung über klartext(); knopf ist KNOPF aus bestelltexte.js. angebotssatzFallback rechnet den Betrag ohne Nachkommastellen (20 €).

  • Step 4: Test laufen lassen

Run: timeout 300 node --max-old-space-size=1024 --test test/workersKaufTexte.test.js Erwartet: PASS (4 Tests).

  • Step 5: Commit
git add server/services/workersKaufTexte.js test/workersKaufTexte.test.js
git commit -m "feat(workers-kauf): Wortlaut Fassung v5 und Nachweis-Texte" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P5-T03: Vorschau-Ordner sicher lesen

Files:

  • Create: server/services/workersVorschau.js
  • Test: test/workersVorschau.test.js

Interfaces:

  • Produces: class VorschauProblem extends Error { permanent: true }; GRENZEN = { dateien: 300, dateiBytes: 5 * 1024 * 1024, gesamtBytes: 25 * 1024 * 1024, tiefe: 8 }; leseVorschau(token: string, { root: string }): Promise<Array<{ path: string /* POSIX, relativ */, content: Buffer }>> (sortiert nach path).

  • Step 1: Failing test test/workersVorschau.test.js (eigener Scratch-Ordner mkdtemp(path.join(os.tmpdir(), 'workers-vorschau-')), am Ende nur diesen Ordner entfernen; Hilfe vorschau(dateien, token = 'A'.repeat(43)) legt <wurzel>/<token>/<pfad> an)

test('liest alle Dateien sortiert, mit Unterordnern; .webklar-preview.json und .git bleiben draußen', async () => {
  await vorschau({ 'index.html': '<h1>Hi</h1>', 'css/site.css': 'b{}', '.webklar-preview.json': '{}', '.git/config': 'x' })
  const d = await leseVorschau(TOKEN, { root: wurzel })
  assert.deepEqual(d.map((f) => f.path), ['css/site.css', 'index.html'])
  assert.equal(d[1].content.toString(), '<h1>Hi</h1>')
})
test('Token-Fälle: zu kurz, mit ../, mit Schrägstrich, Ordner fehlt, Ordner ohne index.html → VorschauProblem', async () => {
  for (const t of ['kurz', `../${'A'.repeat(40)}`, `${'A'.repeat(42)}/`, 'B'.repeat(43)]) await assert.rejects(leseVorschau(t, { root: wurzel }), (e) => e instanceof VorschauProblem && e.permanent === true)
  await vorschau({ 'seite.html': 'x' }, 'C'.repeat(43))
  await assert.rejects(leseVorschau('C'.repeat(43), { root: wurzel }), /index\.html/)
})
test('Symlink in der Vorschau und Symlink als Token-Ordner → VorschauProblem, nichts gelesen', async () => {
  await writeFile(path.join(wurzel, 'geheim.txt'), 'nicht lesen')
  await vorschau({ 'index.html': 'x' }); await symlink(path.join(wurzel, 'geheim.txt'), path.join(wurzel, TOKEN, 'x.txt'))
  await assert.rejects(leseVorschau(TOKEN, { root: wurzel }), VorschauProblem)
  await symlink(path.join(wurzel, TOKEN), path.join(wurzel, 'D'.repeat(43)))
  await assert.rejects(leseVorschau('D'.repeat(43), { root: wurzel }), VorschauProblem)
})
test('Grenzen: 301 Dateien, 5 MB + 1 je Datei, 6 × 5 MB gesamt (Sparse-Dateien, geprüft per Größe vor dem Lesen)', async () => { /* je ein vorschau()-Aufbau, truncate() für die großen; erwartet VorschauProblem mit den Meldungen /mehr als 300 Dateien/, /größer als 5 MB/ und /mehr als 25 MB/ */ })
  • Step 2: Test laufen lassen, Fehlschlag sehen

Run: timeout 300 node --max-old-space-size=1024 --test test/workersVorschau.test.js Erwartet: FAIL Cannot find module '../server/services/workersVorschau.js'.

  • Step 3: Implementieren (server/services/workersVorschau.js)

Token gegen /^[A-Za-z0-9_-]{43}$/; path.join(root, token) nur danach; lstat des Ordners (kein Symlink, Verzeichnis); rekursiv mit readdir({ withFileTypes }), jeder Eintrag per lstat: Symlink, Gerät oder Socket → VorschauProblem; .git und .webklar-preview.json im Wurzelordner überspringen; Tiefe, Anzahl und Größen aus lstat summieren, bevor gelesen wird; erst dann readFile. Meldungen deutsch, ohne Pfade außerhalb der Vorschau: „mehr als 300 Dateien“, „Datei größer als 5 MB“, „Vorschau insgesamt mehr als 25 MB“.

  • Step 4: Test laufen lassen

Run: timeout 300 node --max-old-space-size=1024 --test test/workersVorschau.test.js Erwartet: PASS.

  • Step 5: Commit
git add server/services/workersVorschau.js test/workersVorschau.test.js
git commit -m "feat(workers-kauf): Vorschau-Ordner sicher lesen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P5-T04: Testumgebung (Schema-Spiegel und Harness)

Files:

  • Create: test/support/workersSchema.js, test/support/workersKaufUmgebung.js
  • Modify: test/support/schema.js (schemaKeys), test/helpers/fakeGitea.js (neue Hilfe clear())
  • Test: test/workersKaufUmgebung.test.js

Interfaces:

  • Consumes: startAppwriteMock, startFakeStripe, useTestEnv, startTestApp/get/post/send/postWebhook, mailSink, startFakeGitea, neuerToken.

  • Produces: WORKERS_PORTAL_COLLECTIONS: Record<string, string[]> (Attribut-Schlüssel nach Übersicht §4.1, §4.2, §4.7, §4.12, §4.13; Kopf-Kommentar: Spiegel, Wahrheit ist src/schema.js des Dienstes; workersSettings führt alle Felder aus §4.1 samt der später von P3/P4 angelegten privacyInteressentenLive, sendingHalted, sendingHaltedReason); schemaKeys(name) kennt workersOffers|workersProspects|workersPreviews|workersEvents|workersSettings; gitea.clear() entfernt im Nachbau alle Repos, Dateien, Hooks und aufgezeichneten Aufrufe.

  • Produces: startWorkersUmgebung({ kaufAn = true } = {}): Promise<U> mit U = { aw, st, gitea, mails, vorschauRoot, config, http: { get, rawGet, post, send, postWebhook }, startApp(routers), seedAngebot(over?), seedEinstellungen(offerText, { ohneGrenze = false } = {}), kaufBody(over?), lateStart(), mitschneiden(), reset(), stop() }. mitschneiden(): () => string[] ersetzt console.log/warn/error bis zum nächsten reset() und liefert die gesammelten Zeilen (für die Tests „kein Token, keine E-Mail, kein Passwort im Log“). Konvention: jeder Test beginnt mit await u.reset() (löscht über die Datenbank-Schnittstelle alle Dokumente der beteiligten Sammlungen, leert Stripe-Maps und -Aufrufe, Mail-Senke, gitea.clear() und den Inhalt von vorschauRoot). seedAngebot({ nr = 1, email='kaeufer@example.invalid', firma='Müller Bau GmbH', status='offen', tage=30, prospectStatus='angebot', productKey='hosting', previewUrl?, previewDateien={'index.html':'<h1>Müller Bau</h1>','css/site.css':'body{}'}|null }) → { token, offerId:'o<nr>', prospectId:'p<nr>', previewId:'v<nr>', vorschauToken, offer } legt Interessent, Vorschau-Dokument (und den Ordner unter vorschauRoot, wenn previewDateien gesetzt) und Angebot an (tage negativ = abgelaufen); kaufBody(over) liefert einen gültigen POST-Body (acceptTerms: true, termsVersion: config.abo.termsVersion, startChoice: 'sofort', lateStartAt: '', kundenart: 'unternehmen', name: 'Max Müller', email: 'kaeufer@example.invalid', companyName: 'Müller Bau GmbH', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler', country: 'Deutschland'); lateStart() = lateStartAt().toISOString(). seedEinstellungen(offerText, { ohneGrenze }) (async) legt bzw. ersetzt workersSettings/main mit allen Feldern aus Übersicht §4.1 (Standardwerte, darunter privacyInteressentenLive: false, sendingHalted: false, sendingHaltedReason: '') und dem gegebenen offerText; der Satz hat höchstens 400 Zeichen (Übersicht §4.1, P1 erzwingt das in den Einstellungen): bei mehr wirft die Hilfe Error('offerText: höchstens 400 Zeichen (Übersicht §4.1)'), außer mit ohneGrenze: true – das stellt nur im Schutz-Test von P5-T08 ein von Hand geändertes Dokument nach.

  • Step 1: Failing test test/workersKaufUmgebung.test.js (schemaKeys aus ./support/schema.js importieren)

test('Umgebung startet, sät ein Angebot, lehnt unbekannte Felder ab, räumt mit reset() auf und sät Einstellungen (Spiegel, 400-Zeichen-Grenze)', async () => {
  const u = await startWorkersUmgebung()
  try {
    await u.reset()
    const a = await u.seedAngebot()
    assert.equal(a.token.length, 43)
    const doc = u.aw.docs('workersOffers').get('o1')
    assert.equal(doc.tokenHash.length, 64); assert.equal(doc.businessName, 'Müller Bau GmbH'); assert.equal(doc.status, 'offen')
    assert.match(doc.previewUrl, /^https:\/\/entwurf\.project\.webklar\.com\/[A-Za-z0-9_-]{43}\/$/)
    assert.ok((await readdir(path.join(u.vorschauRoot, a.vorschauToken))).includes('index.html'))
    const { createDocument } = await import('../server/services/appwriteAdmin.js')
    await assert.rejects(createDocument('workersOffers', { unbekannt: 1 }), (e) => e.status === 400)
    assert.equal(u.config.workers.kaufEnabled, true)
    u.gitea.addRepo({ full_name: 'WEBklar/x' }); u.mails.sent.push({})
    await u.reset()
    assert.deepEqual([u.aw.docs('workersOffers').size, u.aw.docs('workersProspects').size, u.gitea.repo('WEBklar/x'), u.mails.sent.length, (await readdir(u.vorschauRoot)).length], [0, 0, undefined, 0, 0])
    // Schema-Spiegel von workersSettings (Übersicht §4.1) und die 400-Zeichen-Grenze von seedEinstellungen
    const keys = schemaKeys('workersSettings')
    for (const k of ['offerText', 'offerProductKey', 'privacyInteressentenLive', 'sendingHalted', 'sendingHaltedReason']) assert.ok(keys.includes(k), k)
    await u.seedEinstellungen('x'.repeat(400))
    const s = u.aw.docs('workersSettings').get('main')
    assert.deepEqual([s.offerText.length, s.privacyInteressentenLive, s.sendingHalted, s.sendingHaltedReason], [400, false, false, ''])
    assert.deepEqual(Object.keys(s).filter((k) => !k.startsWith('$') && !keys.includes(k)), [], 'jedes gesäte Feld steht im Spiegel')
    await assert.rejects(u.seedEinstellungen('x'.repeat(401)), /höchstens 400 Zeichen/)
    await u.seedEinstellungen('x'.repeat(501), { ohneGrenze: true })
    assert.equal(u.aw.docs('workersSettings').get('main').offerText.length, 501)
  } finally { await u.stop() }
})
  • Step 2: Test laufen lassen, Fehlschlag sehen

Run: timeout 900 node --max-old-space-size=1024 --test test/workersKaufUmgebung.test.js Erwartet: FAIL Cannot find module './support/workersKaufUmgebung.js'.

  • Step 3: Implementieren

    1. test/support/workersSchema.js mit den fünf Schlüssellisten (Feldnamen exakt aus Übersicht §4; keine Pflicht/Typ-Angaben nötig). workersSettings führt alle 30 Felder aus §4.1 auf, darunter die Schalter privacyInteressentenLive, sendingHalted und sendingHaltedReason; das Portal liest davon nur offerText, aber der Nachbau lehnt unbekannte Felder ab, und seedEinstellungen sät das ganze Dokument. seedEinstellungen prüft die 400-Zeichen-Grenze für offerText selbst (siehe Interfaces); der Test „über 500 Zeichen → 500“ in P5-T08 bleibt als Schutz und sät mit ohneGrenze: true.
    2. test/support/schema.js: schemaKeys liefert für diese fünf Namen WORKERS_PORTAL_COLLECTIONS[name] (Zeile vor der SETUP_SCRIPTS-Schleife), Kommentar im Kopf ergänzen.
    3. startWorkersUmgebung: Reihenfolge Nachbauten (startAppwriteMock({ schema: { workers*…, featureConsents, contractEvents, subscriptions } }), startFakeStripe(), startFakeGitea()), mkdtemp unter os.tmpdir() für vorschauRoot, useTestEnv(...), dann process.env setzen: WORKERS_KAUF_ENABLED (on/off), WORKERS_VORSCHAU_ROOT=<vorschauRoot>, GITEA_BASE_URL, GITEA_API_TOKEN=t, GITEA_DEFAULT_OWNER=WEBklar, GITEA_ORG=WEBklar, PREVIEW_DEPLOY_ROOT='', TRAEFIK_DYNAMIC_DIR='', CHAT_NOTIFY_EMAILS=team@example.invalid, SMTP_FROM=portal@example.invalid, ABO_TEST_EMAILS=test@example.invalid; danach erst config.js, testApp.js, mailer.js importieren und setMailTransportForTests(mailSink()). stop() schließt alles, setzt den Mail-Transport zurück und entfernt nur den eigenen Scratch-Ordner. reset() löscht die Dokumente der Sammlungen customers, customerPortalAccess, subscriptions, contractEvents, featureConsents, websiteProjects und workers* über deleteDocument (der Nachbau hat keine Sammelfunktion), leert st.customers/subscriptions/checkoutSessions/calls, mails.sent, ruft gitea.clear() auf und entfernt die Unterordner von vorschauRoot (nicht den Ordner selbst). gitea.clear() kommt in test/helpers/fakeGitea.js neben setFile aus P5-T05 (gleiche Datei, eigene Funktion).
  • Step 4: Test laufen lassen

Run: timeout 900 node --max-old-space-size=1024 --test test/workersKaufUmgebung.test.js Erwartet: PASS.

  • Step 5: Commit
git add test/support/workersSchema.js test/support/workersKaufUmgebung.js test/support/schema.js test/helpers/fakeGitea.js test/workersKaufUmgebung.test.js
git commit -m "test(workers-kauf): Schema-Spiegel und Testumgebung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P5-T05: Gitea: Dateien eines Repos in einem Commit ersetzen

Files:

  • Modify: server/services/giteaAdmin.js (neu replaceRepoFiles, gitBlobSha), test/helpers/fakeGitea.js (Baum-Abfrage, Sammel-Commit, Hilfen)
  • Test: test/workersGiteaDateien.test.js

Interfaces:

  • Consumes: giteaFetch (vorhanden).

  • Produces: gitBlobSha(content: Buffer): string (sha1 über blob <länge>\0<inhalt>); replaceRepoFiles(repoFullName: string, branch: string, files: Array<{ path: string, content: Buffer }>, { message: string, behalten?: string[] /* Standard ['.webklar-preview.json'] */ }): Promise<{ geaendert: number, commit: boolean }>.

  • Produces im Nachbau: GET /api/v1/repos/:o/:r/git/trees/:ref?recursive=true (Einträge { path, type: 'blob', sha /* Git-Blob-SHA */ }), POST /api/v1/repos/:o/:r/contents (Gitea ChangeFiles: Body { message, branch, files: [{ operation: 'create'|'update'|'delete', path, content?, sha? }] }; update/delete brauchen den passenden sha, create einer vorhandenen Datei → 422), Hilfen gitea.setFile(fullName, path, text) und gitea.filesOf(fullName): Record<string, string> (clear() gehört zu P5-T04 und liegt in derselben Datei).

  • Step 1: Failing test test/workersGiteaDateien.test.js (startFakeGitea(), GITEA_BASE_URL/GITEA_API_TOKEN setzen, dann giteaAdmin.js importieren)

test('legt an, aktualisiert und löscht Reste – in genau einem Commit', async () => {
  gitea.addRepo({ full_name: 'WEBklar/r1' })
  for (const [p, t] of Object.entries({ '.webklar-preview.json': '{}', 'index.html': 'alt', 'README.md': 'r', 'alt.html': 'x' })) gitea.setFile('WEBklar/r1', p, t)
  const r = await replaceRepoFiles('WEBklar/r1', 'main', [{ path: 'index.html', content: Buffer.from('neu') }, { path: 'css/site.css', content: Buffer.from('b{}') }], { message: 'Vorschau übernommen' })
  assert.deepEqual(r, { geaendert: 4, commit: true })
  assert.deepEqual(gitea.filesOf('WEBklar/r1'), { '.webklar-preview.json': '{}', 'index.html': 'neu', 'css/site.css': 'b{}' })
  assert.equal(gitea.calls.filter((c) => c.method === 'POST' && c.path === '/api/v1/repos/WEBklar/r1/contents').length, 1)
})
test('zweiter Lauf mit gleichem Inhalt: kein Commit, kein POST', async () => { /* gleiche Dateien nochmals → { geaendert: 0, commit: false }, POST-Zahl unverändert */ })
test('gitBlobSha: bekannte Git-Werte', () => {   // Gegenprobe: `printf 'hello world\n' | git hash-object --stdin`
  assert.equal(gitBlobSha(Buffer.alloc(0)), 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391')
  assert.equal(gitBlobSha(Buffer.from('hello world\n')), '3b18e512dba79e4c8300dd08aeb37f8e728b8dad')
})
test('Gitea-Fehler (setDown) wirft ohne Status – Aufrufer behandelt das als vorübergehend', async () => { gitea.setDown(true); await assert.rejects(replaceRepoFiles('WEBklar/r1', 'main', [{ path: 'a', content: Buffer.from('a') }], { message: 'm' }), (e) => e.status === undefined); gitea.setDown(false) })
  • Step 2: Test laufen lassen, Fehlschlag sehen

Run: timeout 900 node --max-old-space-size=1024 --test test/workersGiteaDateien.test.js Erwartet: FAIL (replaceRepoFiles is not a function bzw. 404 vom Nachbau).

  • Step 3: Implementieren

    1. gitBlobSha und replaceRepoFiles in giteaAdmin.js: Baum holen (/repos/<o>/<r>/git/trees/<branch>?recursive=true&per_page=1000), Bestand = Blob-Pfade → SHA; je Datei gleicher SHA = überspringen, sonst update (mit SHA) oder create; Bestand ohne Gegenstück und nicht in behalten = delete (mit SHA); keine Operation = { geaendert: 0, commit: false }; sonst ein POST /repos/<o>/<r>/contents mit { message, branch, files } (Inhalt base64). Ein Commit = ein Push-Webhook = ein Deploy.
    2. fakeGitea.js: Baum-Route und Sammel-Commit nach der Beschreibung oben (SHA im Baum = Git-Blob-SHA des gespeicherten Textes, UTF-8), Fehlerfälle als 422; die beiden Hilfen exportieren und im Kopfkommentar der Datei dokumentieren.
  • Step 4: Test laufen lassen, Regressionen der Gitea-Tests prüfen

Run: timeout 900 node --max-old-space-size=1024 --test test/workersGiteaDateien.test.js && timeout 900 node --max-old-space-size=1024 --test test/giteaAdmin.test.js Erwartet: PASS.

  • Step 5: Commit
git add server/services/giteaAdmin.js test/helpers/fakeGitea.js test/workersGiteaDateien.test.js
git commit -m "feat(workers-kauf): Repo-Dateien in einem Commit ersetzen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P5-T06: Vorschau wird Kundenprojekt

Files:

  • Create: server/services/workersKaufProjekt.js
  • Test: test/workersKaufProjekt.test.js

Interfaces:

  • Consumes: createProjectFromTemplate, replaceRepoFiles, getRepoInfo (giteaAdmin.js); leseVorschau, VorschauProblem; slugify; updateDocument, getDocument; config.workers.vorschauRoot, config.gitea.org.

  • Produces: projektKandidaten(businessName: string): string[] (slugify(name) auf höchstens 40 Zeichen, leer → kundenseite; danach -2 … -5); uebernehmeVorschau({ offer, preview, customerId, root = config.workers.vorschauRoot }): Promise<{ projectId: string, repoFullName: string, previewUrl: string, subdomain: string, dateien: number }>.

  • Step 1: Failing test test/workersKaufProjekt.test.js (Harness aus P5-T04, seedAngebot() liefert Angebot o1 und Vorschau v1; jeder Test beginnt mit await u.reset())

test('Happy Path: Repo in Org WEBklar, Projekt mit customerId, Dateien committet, Marker gesetzt', async () => {
  const a = await u.seedAngebot(); const preview = u.aw.docs('workersPreviews').get('v1')
  const r = await uebernehmeVorschau({ offer: a.offer, preview, customerId: 'c9' })
  assert.equal(r.repoFullName, 'WEBklar/mueller-bau-gmbh'); assert.equal(r.subdomain, 'mueller-bau-gmbh'); assert.equal(r.previewUrl, 'https://mueller-bau-gmbh.project.webklar.com'); assert.equal(r.dateien, 2)
  const p = u.aw.docs('websiteProjects').get(r.projectId)
  assert.deepEqual([p.customerId, p.projectType, p.kind, p.repoFullName], ['c9', 'preview', 'website', 'WEBklar/mueller-bau-gmbh'])
  assert.deepEqual(Object.keys(u.gitea.filesOf('WEBklar/mueller-bau-gmbh')).sort(), ['.webklar-preview.json', 'css/site.css', 'index.html'])
  assert.equal(u.aw.docs('workersPreviews').get('v1').convertedProjectId, r.projectId)
})
test('Namenskonflikt → -2; "Entwurf" ist reserviert → entwurf-2', async () => { /* websiteProjects mit subdomain mueller-bau-gmbh vorab säen → mueller-bau-gmbh-2; Firma "Entwurf" → entwurf-2 */ })
test('Wiederholung nach convertedProjectId: kein zweites Repo, kein weiterer Commit – auch wenn der Ordner inzwischen fehlt', async () => { /* zweiter Aufruf: Repo-Zahl und POST-.../contents-Zahl unverändert, gleiche projectId; danach den Ordner unter vorschauRoot entfernen und ein dritter Aufruf wirft nicht (kein VorschauProblem), gleiche projectId */ })
test('Vorschau-Ordner fehlt → VorschauProblem, und es entsteht weder Repo noch Projekt noch Marker', async () => { /* seedAngebot({ previewDateien: null }) */ })
test('Gitea weg → Fehler ohne permanent (vorübergehend), Marker bleibt leer', async () => { /* setDown(true) vor dem Aufruf: rejects, e.permanent !== true, workersPreviews.convertedProjectId leer */ })
  • Step 2: Test laufen lassen, Fehlschlag sehen

Run: timeout 900 node --max-old-space-size=1024 --test test/workersKaufProjekt.test.js Erwartet: FAIL Cannot find module '../server/services/workersKaufProjekt.js'.

  • Step 3: Implementieren (server/services/workersKaufProjekt.js)

Ablauf (Reihenfolge ist Teil des Vertrags): (1) convertedProjectId gesetzt (Wiederholung): Projektdokument lesen, die Dateien mit leseVorschau holen; wirft das ein VorschauProblem, stehen Projekt und Commit schon von einem früheren Lauf, also ohne Commit mit dem Projekt zurückkehren; sonst weiter bei (4). (2) Sonst zuerst leseVorschau(preview.token, { root }) – scheitert das, entsteht nichts; (3) Kandidaten der Reihe nach mit createProjectFromTemplate({ repoName: k, displayName: offer.businessName, subdomain: k, customerId, owner: config.gitea.org, kind: 'website', staffName: 'Hustler' }) (staffName landet als kindSetBy im Projekt und ist in WOMS sichtbar); Status 409 → nächster, fünf Fehlschläge → VorschauProblem('Kein freier Projektname'), jeder andere Fehler wird weitergereicht; direkt danach updateDocument(workersPreviews, preview.$id, { convertedProjectId: projectId }); (4) Branch über getRepoInfo, replaceRepoFiles(repoFullName, branch, dateien, { message: 'Vorschau übernommen (Hustler)' }) (bei Wiederholung ohne Änderung kein Commit).

  • Step 4: Test laufen lassen

Run: timeout 900 node --max-old-space-size=1024 --test test/workersKaufProjekt.test.js Erwartet: PASS.

  • Step 5: Commit
git add server/services/workersKaufProjekt.js test/workersKaufProjekt.test.js
git commit -m "feat(workers-kauf): Vorschau als Kundenprojekt übernehmen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P5-T07: Angebot finden und Ansicht

Files:

  • Create: server/services/workersKauf.js
  • Test: test/workersKaufAngebot.test.js

Interfaces:

  • Consumes: hashToken (services/auftraege/token.js), listDocuments, getDocument, Query, productView, getProduct, lateStartAt (deadlines.js), texteFuerClient, angebotssatzFallback.

  • Produces:

    • workersKaufAktiv(): boolean (liest config.workers.kaufEnabled bei jedem Aufruf).
    • findOfferByToken(token: string, { now?: Date }): Promise<object | null> – null bei Format-Fehler, unbekanntem Hash, status nicht offen/checkout, expiresAt ≤ jetzt, productKey ungleich 'hosting' (E10), Interessent nicht gefunden (404 beim Lesen) oder gesperrt.
    • uebernahmeAnsicht(offer: object, { now?: Date }): Promise<{ firma, email, vorschauUrl, angebotssatz, produkt: { key, name, amountCents, minTermMonths }, termsVersion, lateStartAt, gueltigBis, texte }> – ohne tokenHash, prospectId, customerId, stripeSessionId; vorschauUrl nur, wenn sie mit config.workers.vorschauBaseUrl + '/' beginnt, sonst ''; angebotssatz aus workersSettings/main.offerText, bei fehlendem Dokument oder leerem Text angebotssatzFallback.
  • Step 1: Failing test test/workersKaufAngebot.test.js (Harness aus P5-T04; jeder Test beginnt mit await u.reset())

test('findOfferByToken: gültiger Link', async () => { await u.reset(); const a = await u.seedAngebot(); assert.equal((await findOfferByToken(a.token)).$id, 'o1') })
for (const [name, seed, tokenOf] of [
  ['verstümmelt', {}, (t) => t.slice(0, 42)],
  ['unbekannt', {}, () => 'x'.repeat(43)],
  ['abgelaufen', { tage: -1 }, (t) => t],
  ['bezahlt', { status: 'bezahlt' }, (t) => t], ['abgelaufen-Status', { status: 'abgelaufen' }, (t) => t], ['widerrufen', { status: 'widerrufen' }, (t) => t],
  ['Interessent gesperrt', { prospectStatus: 'gesperrt' }, (t) => t],
  ['unbekanntes Produkt', { productKey: 'gold' }, (t) => t], ['Wartung (E10)', { productKey: 'wartung' }, (t) => t],
]) test(`findOfferByToken: ${name} → null`, async () => { await u.reset(); const a = await u.seedAngebot(seed); assert.equal(await findOfferByToken(tokenOf(a.token)), null) })
test('Status checkout bleibt gültig', async () => { /* seedAngebot({ status: 'checkout' }) → findOfferByToken liefert das Dokument */ })
test('uebernahmeAnsicht: Felder, Fallback-Satz, kein Durchsickern interner Felder', async () => {
  await u.reset(); const a = await u.seedAngebot(); const v = await uebernahmeAnsicht(a.offer)
  assert.deepEqual(Object.keys(v).sort(), ['angebotssatz', 'email', 'firma', 'gueltigBis', 'lateStartAt', 'produkt', 'termsVersion', 'texte', 'vorschauUrl'])
  assert.equal(v.firma, 'Müller Bau GmbH'); assert.deepEqual(v.produkt, { key: 'hosting', name: 'WEBklar Hosting-Abo', amountCents: 2000, minTermMonths: 12 })
  assert.equal(v.termsVersion, '2026-09-30-v5'); assert.equal(v.lateStartAt, u.lateStart())
  assert.equal(v.angebotssatz, 'Gratis-Vorschau, unverbindlich. Wenn sie gefällt, ist die Website im Hosting-Abo enthalten: 20 € im Monat, 12 Monate Mindestlaufzeit, ohne MwSt. nach § 19 UStG.')
  assert.ok(!JSON.stringify(v).includes(a.offer.tokenHash))
  await u.seedEinstellungen('Mein Satz'); assert.equal((await uebernahmeAnsicht(a.offer)).angebotssatz, 'Mein Satz')
})
test('vorschauUrl: javascript:/fremde Adresse wird nicht ausgeliefert', async () => { await u.reset(); for (const [i, url] of ['javascript:alert(1)', 'https://fremd.example/x/'].entries()) { const a = await u.seedAngebot({ previewUrl: url, nr: i + 1 }); assert.equal((await uebernahmeAnsicht(a.offer)).vorschauUrl, '') } })
  • Step 2: Test laufen lassen, Fehlschlag sehen

Run: timeout 900 node --max-old-space-size=1024 --test test/workersKaufAngebot.test.js Erwartet: FAIL Cannot find module '../server/services/workersKauf.js'.

  • Step 3: Implementieren (server/services/workersKauf.js)

findOfferByToken: hashToken → listDocuments(workersOffers, [Query.equal('tokenHash', hash), Query.limit(1)]) → Prüfungen der Reihe nach; Lesefehler der Datenbank werden weitergereicht (die Route macht daraus 500, nie 404). uebernahmeAnsicht: productView(offer.productKey) plus texteFuerClient({ produktName, leistung: Titel der Leistungen kommagetrennt, amountCents, minTermMonths, lateStartAt }); gueltigBis = offer.expiresAt. seedAngebot aus P5-T04 bekommt hier, falls nötig, die Optionen previewUrl und tage (negativ = abgelaufen).

  • Step 4: Test laufen lassen

Run: timeout 900 node --max-old-space-size=1024 --test test/workersKaufAngebot.test.js Erwartet: PASS.

  • Step 5: Commit
git add server/services/workersKauf.js test/workersKaufAngebot.test.js test/support/workersKaufUmgebung.js
git commit -m "feat(workers-kauf): Angebot per Token finden und Ansicht bauen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P5-T08: Stripe-Checkout ohne Kunden

Files:

  • Modify: server/services/workersKauf.js (neu createWorkersCheckout), test/support/fakeStripe.js (Plattform-Route POST /v1/checkout/sessions/:id/expire: nur open → expired, sonst 400 wie Stripe)
  • Test: test/workersKaufCheckout.test.js

Interfaces:

  • Consumes: getStripe, ensurePrice, ensureTestCoupon, stripeCustomerFields (stripeBilling.js), getProduct, updateDocument, config.abo.

  • Produces: createWorkersCheckout(offer: object, consent: { termsVersion, termsAcceptedAt: string, startChoice: 'sofort'|'nach_frist', serviceStartAt: Date|null, earlyStartConsentAt: string, consentIp: string, consentUserAgent: string, kundenart: 'unternehmen'|'privat' }, buyer: { name, email, companyName, street, postalCode, city, country }, { token }: { token: string }): Promise<{ id: string, url: string }> – läuft unter withLocks(['workers-kauf:' + offer.$id]) (dieselbe Sperre wie der Webhook) und liest das Angebot darin frisch (der übergebene offer liefert nur die Kennung); so entstehen bei einem Doppelklick nie zwei offene Sitzungen. Stripe-Kunde: zuerst der customer der Vorgängersitzung (offer.stripeSessionId, die Stripe-Suche kennt neue Kunden erst nach einer Weile), sonst Suche metadata['workersOfferId'], sonst neu angelegt; ein wiederverwendeter Kunde bekommt E-Mail, Name und Anschrift der neuen Angaben (customers.update). Beendet eine noch offene Vorgängersitzung (offer.stripeSessionId), wirft 409 bei bereits abgeschlossener Vorgängersitzung (Ihre Zahlung ist bereits eingegangen. Die Zugangsdaten kommen in Kürze per E-Mail.), wirft 500 bei Angebotssatz > 500 Zeichen (Stripe-Grenze für Metadaten-Werte; P1 begrenzt offerText auf 400, die 500 sind nur der Schutz bei einem von Hand geänderten Dokument), legt die Sitzung an und setzt workersOffers auf { status: 'checkout', stripeSessionId }.

  • Step 1: Failing test test/workersKaufCheckout.test.js (Harness aus P5-T04; jeder Test beginnt mit await u.reset() und sät das Angebot neu; createWorkersCheckout bekommt jedes Mal das frische Dokument aus der Datenbank)

const TOKEN = 'T'.repeat(43)
const angebot = () => u.aw.docs('workersOffers').get('o1')
const consent = (over = {}) => ({ termsVersion: '2026-09-30-v5', termsAcceptedAt: '2026-10-20T10:00:00.000Z', startChoice: 'sofort', serviceStartAt: null, earlyStartConsentAt: '2026-10-20T10:00:00.000Z', consentIp: '203.0.113.7', consentUserAgent: 'UA/1.0', kundenart: 'unternehmen', ...over })
const buyer = { name: 'Max Müller', email: 'kaeufer@example.invalid', companyName: 'Müller Bau GmbH', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler', country: 'Deutschland' }
const aufruf = () => u.st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions').at(-1)

test('Sitzung ohne WOMS-Kunden: Metadaten, Adressen, Texte', async () => {
  await u.reset(); await u.seedAngebot()
  const s = await createWorkersCheckout(angebot(), consent(), buyer, { token: TOKEN })
  const b = aufruf().body
  assert.deepEqual([b.mode, b['metadata[workersOfferId]'], b['metadata[productKey]'], b.client_reference_id], ['subscription', 'o1', 'hosting', 'o1'])
  assert.equal(b['subscription_data[metadata][termsVersion]'], '2026-09-30-v5')
  for (const k of ['consentTextVersion', 'termsAcceptedAt', 'startChoice', 'earlyStartConsentAt', 'consentIp', 'consentUserAgent', 'kundenart', 'email', 'contactName', 'companyName', 'street', 'postalCode', 'city', 'country', 'workersAngebotssatz', 'workersOfferId']) assert.ok(`subscription_data[metadata][${k}]` in b, k)
  assert.deepEqual(Object.keys(b).filter((k) => /webklarCustomerId|\[offerId\]|\[offerText\]/.test(k)), [])
  assert.equal(b.success_url, 'https://project.webklar.com/uebernahme.html?danke=1')
  assert.equal(b.cancel_url, `https://project.webklar.com/uebernahme.html#t=${TOKEN}`)
  assert.equal(b.billing_address_collection, 'required')
  assert.match(b['custom_text[submit][message]'], /^Ihre Bestellung haben Sie auf der Seite „Website übernehmen“ abgegeben\./); assert.doesNotMatch(b['custom_text[submit][message]'], /Workers|Hustler/)
  assert.equal(u.aw.docs('customers').size, 0)
  const cus = [...u.st.customers.values()].at(-1)
  assert.deepEqual([cus.email, cus.name, cus.metadata.workersOfferId, cus.metadata.webklarCustomerId], ['kaeufer@example.invalid', 'Müller Bau GmbH', 'o1', undefined])
  assert.deepEqual([u.aw.docs('workersOffers').get('o1').status, u.aw.docs('workersOffers').get('o1').stripeSessionId], ['checkout', s.id])
})
test('nach_frist: trial_end = Leistungsbeginn, Hinweistext nennt die Frist', async () => {
  await u.reset(); await u.seedAngebot()
  await createWorkersCheckout(angebot(), consent({ startChoice: 'nach_frist', serviceStartAt: new Date('2026-11-02T05:00:00.000Z'), earlyStartConsentAt: '' }), buyer, { token: TOKEN })
  const b = aufruf().body
  assert.equal(b['subscription_data[trial_end]'], '1793595600'); assert.match(b['custom_text[submit][message]'], /Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist/)
})
test('Testkonto (ABO_TEST_EMAILS): Test-Rabatt wie im normalen Checkout', async () => {
  await u.reset(); await u.seedAngebot()
  await createWorkersCheckout(angebot(), consent(), { ...buyer, email: 'test@example.invalid' }, { token: TOKEN })
  assert.equal(aufruf().body['discounts[0][coupon]'], 'WEBKLAR-TEST-050-HOSTING')
})
test('zweiter Aufruf: Stripe-Kunde wiederverwendet, alte offene Sitzung beendet', async () => {
  await u.reset(); await u.seedAngebot()
  const a = await createWorkersCheckout(angebot(), consent(), buyer, { token: TOKEN }); const b = await createWorkersCheckout(angebot(), consent(), buyer, { token: TOKEN })
  assert.notEqual(a.id, b.id); assert.equal(u.st.customers.size, 1); assert.equal(angebot().stripeSessionId, b.id)
  assert.ok(u.st.calls.some((c) => c.method === 'POST' && c.path === `/v1/checkout/sessions/${a.id}/expire`))
})
test('Doppelklick: zwei gleichzeitige Aufrufe → genau eine offene Sitzung und ein Stripe-Kunde', async () => {
  await u.reset(); await u.seedAngebot()
  await Promise.all([createWorkersCheckout(angebot(), consent(), buyer, { token: TOKEN }), createWorkersCheckout(angebot(), consent(), buyer, { token: TOKEN })])
  const offen = [...u.st.checkoutSessions.values()].filter((s) => s.status === 'open')
  assert.deepEqual([offen.length, u.st.customers.size, angebot().stripeSessionId], [1, 1, offen[0].id])
})
test('alte Sitzung schon abgeschlossen → 409', async () => {
  await u.reset(); await u.seedAngebot()
  const a = await createWorkersCheckout(angebot(), consent(), buyer, { token: TOKEN }); u.st.checkoutSessions.get(a.id).status = 'complete'
  await assert.rejects(createWorkersCheckout(angebot(), consent(), buyer, { token: TOKEN }), (e) => e.status === 409 && /Ihre Zahlung ist bereits eingegangen/.test(e.message))
})
test('SEPA nicht aktiv → zweiter Versuch nur mit Karte', async () => {
  await u.reset(); await u.seedAngebot(); u.st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'The payment method type "sepa_debit" is invalid' })
  await createWorkersCheckout(angebot(), consent(), buyer, { token: TOKEN })
  const calls = u.st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions')
  assert.deepEqual([calls.length, calls.at(-1).body['payment_method_types[0]'], calls.at(-1).body['payment_method_types[1]']], [2, 'card', undefined])
})
test('Angebotssatz über 500 Zeichen → 500, keine Sitzung (Schutz bei von Hand geändertem Dokument)', async () => { /* u.seedEinstellungen('x'.repeat(501), { ohneGrenze: true }) – im Betrieb hält P1 offerText auf höchstens 400 Zeichen (Übersicht §4.1), ein Satz > 500 kann nur durch Handarbeit entstehen → rejects mit status 500; kein POST /v1/checkout/sessions in u.st.calls */ })
  • Step 2: Test laufen lassen, Fehlschlag sehen

Run: timeout 900 node --max-old-space-size=1024 --test test/workersKaufCheckout.test.js Erwartet: FAIL createWorkersCheckout is not a function.

  • Step 3: Implementieren

    1. fakeStripe.js: Route POST /v1/checkout/sessions/:id/expire für die Plattform (die Connect-Variante bleibt unberührt).
    2. createWorkersCheckout in workersKauf.js nach dem Muster createAboCheckout (services/stripeBilling.js:581; withLocks aus services/keyedLock.js): Stripe-Kunde über stripeCustomerFields({ email, companyName, name, street, postalCode, city, country }, { forCreate: true }) plus preferred_locales: ['de'], invoice_settings.footer, metadata { workersOfferId, kundenart }; subscription_data.metadata und trial_end wie in den Global Constraints; SEPA-Rückfall auf Karte wie dort; Hinweistext wie checkoutMessage (stripeBilling.js:561), aber „… auf der Seite „Website übernehmen“ abgegeben …“ statt „im Kundenportal“; workersAngebotssatz aus workersSettings/main.offerText oder dem Fallback.
  • Step 4: Test laufen lassen

Run: timeout 900 node --max-old-space-size=1024 --test test/workersKaufCheckout.test.js Erwartet: PASS.

  • Step 5: Commit
git add server/services/workersKauf.js test/support/fakeStripe.js test/workersKaufCheckout.test.js
git commit -m "feat(workers-kauf): Stripe-Checkout ohne WOMS-Kunden" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P5-T09: Öffentliche Routen /api/uebernahme

Files:

  • Create: server/routes/uebernahme.js
  • Modify: server/index.js (Import und app.use('/api/uebernahme', publicUebernahmeRoutes) direkt nach /api/auftrag), server/services/workersKauf.js (neu pruefeBestellung)
  • Test: test/workersKaufRoutes.test.js

Interfaces:

  • Consumes: findOfferByToken, uebernahmeAnsicht, createWorkersCheckout, workersKaufAktiv, createWriteLimiter, hashToken, getCustomerByEmail, isStripeConfigured, lateStartAt.
  • Produces: Router (default) mit GET /:token → 200 { angebot: <uebernahmeAnsicht> } und POST /:token/checkout → 200 { url }; Exporte setzeUhrFuerTests(fn|null), resetUebernahmeRoutesForTests(); in workersKauf.js: pruefeBestellung(body: object, { now?: Date }): { ok: true, startChoice, kundenart, buyer: { name, email /* klein */, companyName, street, postalCode, city, country } } | { ok: false, status: number, error: string }.

Fehlermeldungen von pruefeBestellung (Reihenfolge): acceptTerms !== true → 400 „Bitte stimmen Sie den Vertragsbedingungen zu, um zu bestellen.“; startChoice ungültig → 400 „Bitte wählen Sie, wann wir mit der Leistung beginnen sollen.“; termsVersion !== config.abo.termsVersion → 409 „Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.“; nach_frist und lateStartAt !== lateStartAt().toISOString() → 409 „Der Leistungsbeginn hat sich durch den Datumswechsel verschoben. Bitte laden Sie die Seite neu.“; kundenart ungültig → 400 „Bitte wählen Sie, ob Sie als Unternehmen oder privat bestellen.“; Name < 2 oder > 120 Zeichen → 400 „Bitte geben Sie Ihren Namen an.“; E-Mail ohne gültiges Format/> 254 → 400 „Bitte geben Sie eine gültige E-Mail-Adresse an.“; Straße, PLZ (5 Ziffern bei Deutschland), Ort fehlen → 400 „Bitte geben Sie Ihre Anschrift an.“; unternehmen ohne companyName → 400 „Bitte geben Sie den Namen Ihres Betriebs an.“. Zum Ergebnis buyer: country ist immer 'Deutschland' (die Seite hat kein Länderfeld; Stripe fragt die Rechnungsadresse ohnehin ab); bei privat wird companyName auf '' gesetzt, auch wenn die Seite den vorbelegten Betriebsnamen mitschickt (sonst steht er auf der Rechnung einer Privatperson).

  • Step 1: Failing test test/workersKaufRoutes.test.js (Harness + startApp({ '/api/uebernahme': router }); jeder Test beginnt mit await u.reset() und resetUebernahmeRoutesForTests())
const FAELLE = [
  [{ acceptTerms: false }, 400, 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu bestellen.'],
  [{ startChoice: 'irgendwann' }, 400, 'Bitte wählen Sie, wann wir mit der Leistung beginnen sollen.'],
  [{ termsVersion: '2026-01-01-v1' }, 409, 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.'],
  [{ startChoice: 'nach_frist', lateStartAt: '2020-01-01T00:00:00.000Z' }, 409, 'Der Leistungsbeginn hat sich durch den Datumswechsel verschoben. Bitte laden Sie die Seite neu.'],
  [{ kundenart: '' }, 400, 'Bitte wählen Sie, ob Sie als Unternehmen oder privat bestellen.'],
  [{ name: 'M' }, 400, 'Bitte geben Sie Ihren Namen an.'],
  [{ email: 'a@b' }, 400, 'Bitte geben Sie eine gültige E-Mail-Adresse an.'],
  [{ postalCode: '1234' }, 400, 'Bitte geben Sie Ihre Anschrift an.'],
  [{ companyName: '' }, 400, 'Bitte geben Sie den Namen Ihres Betriebs an.'],
]
test('GET: Ansicht, Header, nichts Internes', async () => {
  const a = await u.seedAngebot(); const r = await u.http.rawGet(`/api/uebernahme/${a.token}`)
  assert.equal(r.status, 200); assert.equal(r.body.angebot.firma, 'Müller Bau GmbH')
  assert.deepEqual([r.headers.get('x-robots-tag'), r.headers.get('cache-control'), r.headers.get('referrer-policy'), r.headers.get('x-content-type-options')], ['noindex', 'no-store', 'no-referrer', 'nosniff'])
  assert.ok(!JSON.stringify(r.body).includes(a.offer.tokenHash))
})
test('GET: jeder tote Link antwortet gleich 404 – auch bei ausgeschaltetem Schalter', async () => {
  const tot = []
  const seeds = [{ tage: -1 }, { status: 'bezahlt' }, { prospectStatus: 'gesperrt' }]
  for (const [i, seed] of seeds.entries()) tot.push(await u.http.rawGet(`/api/uebernahme/${(await u.seedAngebot({ ...seed, nr: i + 1 })).token}`))
  tot.push(await u.http.rawGet(`/api/uebernahme/${'x'.repeat(43)}`), await u.http.rawGet('/api/uebernahme/kurz'))
  const guter = await u.seedAngebot({ nr: 9 }); u.config.workers.kaufEnabled = false; tot.push(await u.http.rawGet(`/api/uebernahme/${guter.token}`)); u.config.workers.kaufEnabled = true
  for (const r of tot) { assert.equal(r.status, 404); assert.deepEqual(r.body, { error: 'Dieser Link ist nicht mehr gültig.' }) }
})
test('GET: 121. Aufruf je IP → 429', async () => { /* 120 GET auf einen toten Link (404), der 121. antwortet 429 { error: 'Zu viele Anfragen. Bitte später erneut versuchen.' } */ })
test('pruefeBestellung: Tabelle der Fehler (Status und Text)', () => { for (const [over, status, error] of FAELLE) { const r = pruefeBestellung(u.kaufBody(over)); assert.deepEqual([r.ok, r.status, r.error], [false, status, error]) } })
test('pruefeBestellung: privat verwirft den Betriebsnamen, das Land ist immer Deutschland', () => {
  const r = pruefeBestellung(u.kaufBody({ kundenart: 'privat', companyName: 'Müller Bau GmbH', country: 'Frankreich' }))
  assert.deepEqual([r.ok, r.buyer.companyName, r.buyer.country], [true, '', 'Deutschland'])
})
test('POST checkout: URL, Angebot "checkout", und noch NICHTS bei den Kunden', async () => {
  const a = await u.seedAngebot(); const r = await u.http.post(`/api/uebernahme/${a.token}/checkout`, u.kaufBody())
  assert.equal(r.status, 200); assert.match(r.body.url, /^https:\/\/checkout\.stripe\.test\/c\/pay\//)
  for (const c of ['customers', 'customerPortalAccess', 'websiteProjects', 'featureConsents', 'subscriptions']) assert.equal(u.aw.docs(c).size, 0, c)
  assert.equal(u.aw.docs('workersOffers').get('o1').status, 'checkout')
})
test('POST: E-Mail gehört schon einem Kunden → 409, keine Stripe-Sitzung', async () => { u.aw.seed('customers', { $id: 'cx', email: 'kaeufer@example.invalid', name: 'Alt' }); /* erwartet 409, Text "Zu dieser E-Mail-Adresse gibt es schon ein Kundenkonto …", st.calls ohne POST /v1/checkout/sessions */ })
test('POST: toter Link und Schalter aus → 404; Stripe nicht konfiguriert → 503; 11. Versuch je IP → 429', async () => { /* toter Link (tage: -1) und kaufEnabled=false: 404 { error: 'Dieser Link ist nicht mehr gültig.' }; u.config.stripe.secretKey = '' → 503 'Die Online-Bestellung ist noch nicht freigeschaltet.' (danach zurücksetzen); 10 gültige POSTs, der 11. → 429 */ })
test('Logs: weder Token noch E-Mail erscheinen in console.*', async () => { /* u.mitschneiden(); Stripe-Ausfall (u.st.failNext 500 auf POST /v1/checkout/sessions, message 'Invalid email address: kaeufer@example.invalid') und toten Link auslösen; keine Zeile enthält Token oder kaeufer@example.invalid */ })
test('server/index.js: Mount nach /api/auftrag (Quelltext-Pin)', () => { const idx = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8'); assert.match(idx, /app\.use\('\/api\/auftrag', publicAuftragRoutes\)\napp\.use\('\/api\/uebernahme', publicUebernahmeRoutes\)/) })
  • Step 2: Test laufen lassen, Fehlschlag sehen

Run: timeout 900 node --max-old-space-size=1024 --test test/workersKaufRoutes.test.js Erwartet: FAIL Cannot find module '../server/routes/uebernahme.js'.

  • Step 3: Implementieren

    1. pruefeBestellung in workersKauf.js nach der Meldungstabelle oben.
    2. server/routes/uebernahme.js im Aufbau von routes/auftrag.js (Header-Middleware, createWriteLimiter GET 120 / POST 10 je IP, zusätzlich POST 10 je Token-Hash, ladeNachToken mit der einen 404-Antwort und workersKaufAktiv() als erster Prüfung). POST: pruefeBestellung → getCustomerByEmail(email) (Treffer: 409 „Zu dieser E-Mail-Adresse gibt es schon ein Kundenkonto. Bitte melden Sie sich im Kundenbereich an oder schreiben Sie uns.“) → isStripeConfigured() (sonst 503 „Die Online-Bestellung ist noch nicht freigeschaltet.“) → createWorkersCheckout(offer, consent, buyer, { token }) mit consent.termsAcceptedAt = jetzt, earlyStartConsentAt = startChoice === 'sofort' ? jetzt : '', serviceStartAt = nach_frist ? lateStartAt() : null, IP und User-Agent aus der Anfrage. Fehler: err.status und deutscher Text; Stripe-/Appwrite-Fehler ohne Status → 500 „Das hat gerade nicht geklappt. Bitte versuchen Sie es gleich noch einmal.“; geloggt werden nur err.status, err.code und err.type, nie err.message (Stripe-Texte können die Adresse enthalten) und nie Token oder Angebotsdaten.
    3. server/index.js: Import und Mount.
  • Step 4: Test laufen lassen

Run: timeout 900 node --max-old-space-size=1024 --test test/workersKaufRoutes.test.js Erwartet: PASS.

  • Step 5: Commit
git add server/routes/uebernahme.js server/index.js server/services/workersKauf.js test/workersKaufRoutes.test.js
git commit -m "feat(workers-kauf): öffentliche Routen /api/uebernahme" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P5-T10: Mailvorlagen K44 und T25

Files:

  • Create: server/mail-templates/K44 Website übernommen.html, server/mail-templates/T25 Workers Hinweis.html
  • Modify: test/mailTemplates.test.js (EXPECTED, Präfix-Regel, TEAM_FOOTER, zwei neue Tests)

Interfaces:

  • Produces: renderMail('K44', values, blocks) – Betreff Willkommen bei WEBklar – Ihre Zugangsdaten; Platzhalter {{name}}, {{firma}}, {{email}}, {{passwort}}, {{portal_url}}, {{vorschau_url}}, {{leistungsbeginn}}; Blöcke vorschau, sofortbeginn, beginn_nach_widerrufsfrist; Sie-Form; Layout wie K16.

  • Produces: renderMail('T25', values) – Betreff [Hustler] {{vorfall}}; Platzhalter {{vorfall}}, {{details}}, {{zeit}}; keine Blöcke; Layout wie T20 (Fuß der Team-Mails).

  • Step 1: Nummern gegen alle Refs prüfen, dann failing test

for r in $(git for-each-ref --format='%(refname)' refs/heads refs/remotes); do git ls-tree -r --name-only $r -- server/mail-templates; done | sed 's#server/mail-templates/##' | rg '^(K4[4-9]|T2[5-9])' | sort -u

Erwartet: keine Zeile. Zusätzlich gegen die noch nicht gebauten Pläne: rg -n '\b(K44|T25)\b' /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht/docs/superpowers/plans – „frei“ ist in Ordnung, eine Belegung durch eine andere Vorlage nicht. Ist K44 oder T25 belegt, nimmt der Plan die nächste freie Nummer und ersetzt sie in diesem Plan überall (K44 steht in P5-T10, T11, T12; T25 in T10 bis T12). In test/mailTemplates.test.js: EXPECTED = [...ids('G', 22), ...ids('K', 44), ...ids('T', 25)]; im Kopfkommentar-Test die Präfix-Liste um Hustler erweitern; TEAM_FOOTER um 'T25'; Kommentarzeile 9 um „K44 Website übernommen und T25 Hustler-Hinweis (Workers P5)“ ergänzen. Neue Tests:

test('K44: Zugangsdaten, Sie-Form, Blöcke je Beginn und Vorschau', () => {
  const tpl = loadTemplate('K44')
  assert.equal(tpl.file, 'K44 Website übernommen.html'); assert.equal(tpl.subject, 'Willkommen bei WEBklar – Ihre Zugangsdaten')
  assert.deepEqual(tpl.placeholders, ['name', 'firma', 'email', 'passwort', 'portal_url', 'vorschau_url', 'leistungsbeginn'])
  assert.deepEqual([...tpl.blocks].sort(), ['beginn_nach_widerrufsfrist', 'sofortbeginn', 'vorschau'])
  const v = { name: 'Max Müller', firma: 'Müller Bau GmbH', email: 'max@example.invalid', passwort: 'abcd2345efgh6789', portal_url: 'https://project.webklar.com/login.html', vorschau_url: 'https://mueller.project.webklar.com', leistungsbeginn: '02.11.2026' }
  const mit = renderMail('K44', v, { vorschau: true, beginn_nach_widerrufsfrist: true })
  assert.deepEqual(mit.problems, [])
  for (const t of ['max@example.invalid', 'abcd2345efgh6789', 'https://mueller.project.webklar.com', '02.11.2026', 'separaten E-Mail']) assert.ok(mit.text.includes(t), t)
  const ohne = renderMail('K44', v, { sofortbeginn: true })
  assert.ok(!ohne.text.includes('mueller.project.webklar.com') && !ohne.text.includes('02.11.2026'))
  assert.doesNotMatch(mit.text, DU_FORM); assert.match(mit.text, SIE_FORM)
  assert.doesNotMatch(mit.text + ohne.text + tpl.subject, /Workers|Hustler/)   // Käufer-Mail nennt den internen Namen nie
})
test('T25: Betreff [Hustler], drei Platzhalter, Zeilenumbrüche in details bleiben, Tag-Zeile „Intern · Hustler“', () => { /* wie der T20-Test, Datei 'T25 Workers Hinweis.html' (technischer Dateiname), Betreff beginnt mit "[Hustler] ", die Mail enthält "Intern · Hustler" und nicht "Workers" */ })
  • Step 2: Test laufen lassen, Fehlschlag sehen

Run: timeout 300 node --max-old-space-size=1024 --test test/mailTemplates.test.js Erwartet: FAIL (alle Vorlagen sind da: K44/T25 fehlen; Mail-Vorlage K44 fehlt).

  • Step 3: Vorlagen anlegen

K44 aus K16 Funktion bereit.html kopieren (Kopfkommentar im Format K44 · Website übernommen mit Betreff:, Absender:, Empfänger: Käufer (E-Mail aus der Bestellung) · ohne BCC, enthält das Anfangspasswort, Platzhalter:, Optionale Blöcke (…):), Mitte ersetzen: Überschrift „Willkommen bei WEBklar“, Satz „vielen Dank für Ihre Bestellung für {{firma}}. Wir haben für Sie ein Konto im Kundenportal eingerichtet.“, Tabelle Adresse {{portal_url}} / Benutzername {{email}} / Passwort {{passwort}} mit Satz „Bitte ändern Sie das Passwort nach der ersten Anmeldung im Kundenportal.“, Block vorschau („Ihre Website-Vorschau finden Sie nach der Anmeldung unter „Projekte“ oder direkt unter {{vorschau_url}}. Der neueste Stand erscheint dort in wenigen Minuten.“), Block sofortbeginn („Wie von Ihnen gewünscht, beginnen wir sofort mit der Leistung.“), Block beginn_nach_widerrufsfrist („Wie von Ihnen gewählt, beginnen wir am {{leistungsbeginn}} nach Ablauf der Widerrufsfrist; bis dahin erbringen wir keine Leistungen und buchen nichts ab.“), Satz „Die Vertragsbestätigung mit den Vertragsunterlagen erhalten Sie in einer separaten E-Mail.“, Gruß „Viele Grüße, Ihr WEBklar-Team“. T25 aus T20 Terminplaner Betriebsmeldung.html kopieren, nur Kopf, <title>, Betreff ([Hustler] {{vorfall}}) und die Tag-Zeile „Intern · Hustler“ ändern.

  • Step 4: Test laufen lassen

Run: timeout 300 node --max-old-space-size=1024 --test test/mailTemplates.test.js Erwartet: PASS (alle Vorlagen, auch die allgemeinen Render-Tests für K44/T25).

  • Step 5: Commit
git add "server/mail-templates/K44 Website übernommen.html" "server/mail-templates/T25 Workers Hinweis.html" test/mailTemplates.test.js
git commit -m "feat(workers-kauf): Mailvorlagen K44 (Zugangsdaten) und T25 (Team-Hinweis)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P5-T11: Webhook-Zweig I – erkennen, Kunde anlegen, verknüpfen, Abo-Weg

Files:

  • Create: server/services/workersKaufAbschluss.js
  • Modify: server/services/workersKauf.js (Re-Export), server/routes/abo.js (Dispatcher), server/services/customerAdmin.js (Rückbau in createCustomerWithPortalAccess), test/support/workersKaufUmgebung.js (Hilfen bezahle, kauf)
  • Test: test/workersKaufWebhook.test.js

Interfaces:

  • Consumes: createCustomerWithPortalAccess, getStripe, withLocks, getDocument, updateDocument, sendAdminNotice, createDocument, eventIdFor.

  • Produces:

    • isWorkersCheckoutEvent(event): boolean – wahr für checkout.session.completed mit mode === 'subscription' und metadata.workersOfferId; wahr für customer.subscription.* mit metadata.workersOfferId und ohne metadata.webklarCustomerId; sonst falsch.
    • handleWorkersCheckoutEvent(event, { aboWeg }: { aboWeg: (event) => Promise<void> }): Promise<void> – offerId = metadata.workersOfferId der Sitzung bzw. des Abos. Der gesamte Handler (Teil 1 und Teil 2) läuft unter withLocks(['workers-kauf:' + offerId]), und das Angebot wird erst in der Sperre gelesen: Läse man es davor, hielte eine gleichzeitige zweite Zustellung den Stand ohne customerId, liefe in den E-Mail-Konflikt und meldete ihn fälschlich als T25. Fehlt das Angebot, ist das bei checkout.session.completed ein T25 „Zahlung ohne Angebot“ (dann fertig), bei Lebenszyklus-Ereignissen nichts. Lebenszyklus-Ereignisse (customer.subscription.*): hat das Angebot noch keine customerId, werden sie ignoriert (200); sonst (veralteter Stand eines schon verknüpften Abos) gehen sie an aboWeg(event). Teil 1, bei checkout.session.completed: Kunde aus offer.customerId oder sub.metadata.webklarCustomerId oder neu angelegt (Status customer, companyName/Anschrift aus den Abo-Metadaten, workersOffers.customerId sofort gespeichert; Konflikt-409 → T25 „E-Mail-Adresse gehört schon einem Kunden“ + Ereignis fehler, Antwort ohne Fehler), customers.update/subscriptions.update mit metadata.webklarCustomerId, dann aboWeg(event), wenn das Abo-Dokument noch kein confirmationSentAt hat. Teil 2 folgt in P5-T12.
    • neuesPasswort(): string (16 Zeichen, crypto.randomInt je Zeichen).
    • Im Harness: bezahle(sessionId, { subId = 'sub_1WorkersTestAbo00000001' }) → { sessionId, subId, event(type = 'checkout.session.completed') } baut aus der von createWorkersCheckout angelegten Sitzung (st.checkoutSessions) das Stripe-Abo (stripeSubscription(...) aus fixtures.js, metadata = subscription_data.metadata, webklarCustomerId entfernt, customer = Sitzungskunde, trial_end aus subscription_data), setzt die Sitzung auf complete/paid mit subscription und customer_details und liefert das signierbare Ereignis. kauf(over = {}) → { token, sessionId, subId, event } = seedAngebot() + POST /api/uebernahme/<token>/checkout mit kaufBody(over) + bezahle(sessionId) (die Routen müssen per startApp gemountet sein).
  • routes/abo.js: handleStripeEvent bleibt Dispatcher (Feature-Zweig bleibt dort); der bisherige switch zieht in handleAboEvent(event) um (Verhalten unverändert); nach dem Feature-Zweig if (isWorkersCheckoutEvent(event)) return handleWorkersCheckoutEvent(event, { aboWeg: handleAboEvent }), sonst return handleAboEvent(event).

  • customerAdmin.js: scheitert in createCustomerWithPortalAccess das Anlegen des Zugangs nach dem Kundendokument, wird neben dem Appwrite-Benutzer auch das Kundendokument wieder gelöscht (bisher blieb es als Geisterkunde stehen und die Wiederholung lief in 409).

  • Step 1: Failing test test/workersKaufWebhook.test.js (Harness mit startApp({ '/api/uebernahme': uebernahme, '/api/abo': abo }); jeder Test beginnt mit await u.reset() und resetUebernahmeRoutesForTests(); kauf aus dem Harness)

test('isWorkersCheckoutEvent: Tabelle', () => { /* completed+workersOfferId → true; payment-Modus → false; subscription.created mit workersOfferId ohne webklarCustomerId → true, mit → false; invoice.paid → false; anderes Metadatum → false */ })
test('subscription.created vor checkout.completed: 200, nichts angelegt', async () => {
  const { event } = await kauf(); const r = await u.http.postWebhook(event('customer.subscription.created'))
  assert.equal(r.status, 200)
  for (const c of ['customers', 'subscriptions', 'contractEvents']) assert.equal(u.aw.docs(c).size, 0, c); assert.equal(u.mails.sent.length, 0)
})
test('checkout.completed: Kunde, Zugang, Stripe-Verknüpfung, Spiegel, Abschluss-Ereignis, T02 und K01', async () => {
  const { subId, event } = await kauf({ kundenart: 'unternehmen' }); const r = await u.http.postWebhook(event())
  assert.equal(r.status, 200, JSON.stringify(r.body))
  const [kunde] = [...u.aw.docs('customers').values()]
  assert.deepEqual([kunde.name, kunde.email, kunde.companyName, kunde.street, kunde.postalCode, kunde.city, kunde.kundenart, kunde.customerStatus, kunde.portalAccessEnabled], ['Max Müller', 'kaeufer@example.invalid', 'Müller Bau GmbH', 'Hauptstr. 1', '67722', 'Winnweiler', 'unternehmen', 'customer', true])
  assert.equal(u.aw.docs('customerPortalAccess').size, 1)
  assert.equal(u.st.subscriptions.get(subId).metadata.webklarCustomerId, kunde.$id)
  assert.equal([...u.st.customers.values()].at(-1).metadata.webklarCustomerId, kunde.$id)
  assert.equal(u.aw.docs('subscriptions').get(subId).customerId, kunde.$id)
  assert.equal([...u.aw.docs('contractEvents').values()].filter((e) => e.type === 'abschluss').length, 1)
  assert.equal(u.aw.docs('workersOffers').get('o1').customerId, kunde.$id)
  assert.equal(u.mails.sent.filter((m) => /Vertragsbestätigung/.test(m.subject)).length, 1)   // K01
  assert.equal(u.mails.sent.filter((m) => /^\[Abo\] Neues Hosting-Abo/.test(m.subject)).length, 1)   // T02
})
test('doppelte Zustellung (nacheinander und gleichzeitig): ein Kunde, ein Abschluss, eine K01, ein T02', async () => {
  const { event } = await kauf(); await Promise.all([u.http.postWebhook(event()), u.http.postWebhook(event())]); await u.http.postWebhook(event())
  assert.equal(u.aw.docs('customers').size, 1); assert.equal([...u.aw.docs('contractEvents').values()].filter((e) => e.type === 'abschluss').length, 1)
  assert.equal(u.mails.sent.filter((m) => /Vertragsbestätigung|^\[Abo\] Neues/.test(m.subject)).length, 2)
  assert.ok(!u.mails.sent.some((m) => m.subject.startsWith('[Hustler]')), 'eine doppelte Zustellung ist kein Konflikt')
})
test('veralteter subscription.updated nach der Verknüpfung: wird wie ein normales Abo-Ereignis gespiegelt', async () => { /* nach der Zustellung von checkout.completed ein customer.subscription.updated mit workersOfferId ohne webklarCustomerId senden: Status 200, subscriptions-Dokument bleibt eines, kein [Hustler]-Mail */ })
test('Zahlung ohne Angebot (Angebot gelöscht): 200, T25 "Zahlung ohne Angebot", nichts angelegt', async () => {
  const { event } = await kauf(); await deleteDocument('workersOffers', 'o1')
  assert.equal((await u.http.postWebhook(event())).status, 200); assert.equal(u.aw.docs('customers').size, 0)
  assert.ok(u.mails.sent.some((m) => m.subject.startsWith('[Hustler] Zahlung ohne Angebot')))
})
test('E-Mail gehört schon einem Kunden (Wettlauf nach dem Bezahlen): 200, kein zweites Konto, T25, Ereignis fehler', async () => {
  const { event } = await kauf(); u.aw.seed('customers', { $id: 'cx', email: 'kaeufer@example.invalid', name: 'Alt', customerStatus: 'active' })
  assert.equal((await u.http.postWebhook(event())).status, 200)
  assert.equal(u.aw.docs('customers').size, 1); assert.equal(u.aw.docs('workersOffers').get('o1').customerId || '', '')
  const t25 = u.mails.sent.find((m) => m.subject.startsWith('[Hustler] E-Mail-Adresse gehört schon einem Kunden')); assert.ok(t25)
  assert.ok(!u.mails.sent.some((m) => /Zugangsdaten/.test(m.subject)))
  assert.ok([...u.aw.docs('workersEvents').values()].some((e) => e.kind === 'fehler' && e.workerId === 'system'))
})
test('Abbruch mitten im Anlegen (customerPortalAccess scheitert): 500, später genau ein Kunde', async () => {
  const { event } = await kauf(); u.aw.failNext({ collection: 'customerPortalAccess', method: 'POST', status: 500 })
  assert.equal((await u.http.postWebhook(event())).status, 500); assert.equal(u.aw.docs('customers').size, 0)
  assert.equal((await u.http.postWebhook(event())).status, 200); assert.equal(u.aw.docs('customers').size, 1)
})
test('neuesPasswort: 16 Zeichen aus dem Alphabet, nie zweimal gleich', () => { const a = neuesPasswort(); assert.match(a, /^[abcdefghjkmnpqrstuvwxyz23456789]{16}$/); assert.notEqual(a, neuesPasswort()) })
  • Step 2: Test laufen lassen, Fehlschlag sehen

Run: timeout 900 node --max-old-space-size=1024 --test test/workersKaufWebhook.test.js Erwartet: FAIL Cannot find module '../server/services/workersKaufAbschluss.js'.

  • Step 3: Implementieren

    1. workersKaufAbschluss.js nach den Interfaces (importiert nichts aus workersKauf.js; der Re-Export geht nur in diese Richtung); Abo-Metadaten lesen über getStripe().subscriptions.retrieve(session.subscription); Stripe-Kunden nicht abrufen (Name/Anschrift stehen in den Abo-Metadaten contactName, companyName, street, postalCode, city, country, email). Team-Hinweis als Hilfe meldeKaufProblem({ vorfall, details, offer }): sendAdminNotice({ template: 'T25', values: { vorfall, details, zeit } }) plus workersEvents-Dokument { ts, workerId: 'system', kind: 'fehler', prospectId, text: <Vorfall>, metaJson } (Fehler beim Hinweis werden geloggt, nie geworfen). zeit = TT.MM.JJJJ, HH:MM Berliner Zeit. Alle 409 aus createCustomerWithPortalAccess (auch Appwrite-Benutzer vorhanden) laufen in den Konfliktzweig; alle anderen Fehler werden geworfen.
    2. routes/abo.js: Import, Dispatcher, handleAboEvent (reiner Umzug des switch).
    3. customerAdmin.js: im catch von createCustomerWithPortalAccess zusätzlich deleteDocument(config.collections.customers, customer.$id) (best effort, wie beim Benutzer).
    4. Harness: bezahle und kauf.
  • Step 4: Tests laufen lassen, Abo-Regression prüfen

Run: timeout 900 node --max-old-space-size=1024 --test test/workersKaufWebhook.test.js && timeout 900 node --max-old-space-size=1024 --test test/aboRoutes.test.js Erwartet: PASS.

  • Step 5: Commit
git add server/services/workersKaufAbschluss.js server/services/workersKauf.js server/routes/abo.js server/services/customerAdmin.js test/support/workersKaufUmgebung.js test/workersKaufWebhook.test.js
git commit -m "feat(workers-kauf): Webhook-Zweig legt Kunde an und verknüpft Stripe" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P5-T12: Webhook-Zweig II – Nachweis, Vorschau-Projekt, K44, Abschluss, Rückmeldung

Files:

  • Modify: server/services/workersKaufAbschluss.js, server/services/vertragsNachweis.js (optionaler Parameter wortlaut, Standard WORTLAUT_STATUS, geht in fassungenJson; auch in speichereNachweisFuerRoute durchreichen)
  • Test: test/workersKaufWebhook.test.js (erweitern), test/workersKaufAbschluss.test.js (neu, Rückmeldung und Fehlerpfade)

Interfaces:

  • Consumes: speichereNachweisFuerRoute, uebernahmeNachweisTexte, texteFuerClient, bestellZeile (bestelltexte.js), uebernehmeVorschau, VorschauProblem, forgetStoredPortalPassword, sendCustomerMail, listDocuments, eventIdFor.

  • Produces (Teil 2 in handleWorkersCheckoutEvent, nach dem Abo-Weg, jeder Schritt wiederholbar):

    1. Schon erledigt? offer.status === 'bezahlt' und gleiche stripeSessionId → Rückkehr ohne Wirkung; andere stripeSessionId → T25 „Doppelte Zahlung für ein Angebot“, kein zweiter Kunde (diese Prüfung steht in der Sperre direkt nach dem Lesen des Angebots, vor Teil 1).
    2. Nachweis: nur wenn für customerId noch keine Zeile featureConsents mit featureKey: 'abo' existiert (Abfrage Query.equal('customerId', id) mit Query.limit(25), dann im Code nach featureKey filtern; nur customerId hat einen Index): speichereNachweisFuerRoute({ customerId, art: 'abo', bezug: productKey, texte: uebernahmeNachweisTexte(...), fassungen: { zustimmung: termsVersion, quelle: 'workers-uebernahme' }, kundenart, req: { ip: consentIp, get: (n) => (n.toLowerCase() === 'user-agent' ? consentUserAgent : ''), session: { name: contactName, email } }, at: termsAcceptedAt, wortlaut: 'v5-workers' }); Fehler → geloggt, T25 „Zustimmungsnachweis nicht gespeichert“ (Hinweis: Zustimmung steht in den Stripe-Abo-Metadaten), Ablauf geht weiter.
    3. Vorschau: uebernehmeVorschau({ offer, preview, customerId }); VorschauProblem oder ein Fehler mit status 400 bis 499 außer 408 und 429 (Gitea lehnt dauerhaft ab: Token, Vorlage, Rechte) gilt als dauerhaft → T25 „Vorschau konnte nicht übernommen werden“, weiter ohne Vorschau; jeder andere Fehler (ohne status, 5xx, 408, 429) wird geworfen (Stripe wiederholt).
    4. K44: wenn customers.portalPassword nicht leer: sendCustomerMail(customer.email, { template: 'K44', bccTeam: false, values, blocks }) – der Empfänger ist die Adresse des Kundendokuments, nie ein Wert aus der Bestellung, damit ein von Hand verknüpfter Bestandskunde sein Passwort nie an eine fremde Adresse bekommt –, mit dem gespeicherten Passwort, danach forgetStoredPortalPassword(customerId); Mail-Fehler werden geworfen.
    5. Abschluss: workersOffers { status: 'bezahlt', paidAt, stripeSessionId, customerId } (paidAt = new Date().toISOString(), also UTC mit Z, denn P1 filtert damit die Berliner Monatsgrenze für dealsMonth, newAbosMonth und mrrNewEur, A12; ebenso ts des Ereignisses); workersProspects { status: 'deal', customerId, updatedAt }; workersEvents-Dokument mit ID eventIdFor('deal', offerId) (409 = schon da): { ts, workerId: 'system', kind: 'deal', prospectId, text: 'Deal: <Betrieb> hat die Website übernommen (Hosting-Abo)' (auf 300 Zeichen gekürzt), link: vorschau_url|'', metaJson: JSON.stringify({ offerId, customerId, productKey, amountCents, kundenart }) }.
  • Step 1: Failing tests (in workersKaufWebhook.test.js ergänzen, Fehlerpfade in workersKaufAbschluss.test.js; jeder Test beginnt mit await u.reset(); u.mitschneiden() schneidet console.* mit)

test('Gesamtlauf: Seite → Bezahlen → Webhook: alles genau einmal und alles verknüpft', async () => {
  await u.reset()
  const { token, sessionId, subId, event } = await kauf({ startChoice: 'nach_frist', lateStartAt: u.lateStart(), kundenart: 'privat' })
  const logs = u.mitschneiden(); assert.equal((await u.http.postWebhook(event())).status, 200)
  const kunde = [...u.aw.docs('customers').values()][0]
  // Nachweis
  const nachweise = [...u.aw.docs('featureConsents').values()]; assert.equal(nachweise.length, 1)
  const n = nachweise[0]; assert.deepEqual([n.customerId, n.featureKey, n.kundenart, n.stripeSessionId], [kunde.$id, 'abo', 'privat', ''])
  assert.deepEqual(JSON.parse(n.texte).map((t) => t.id), ['angebot', 'bestellung', 'zustimmung', 'beginn', 'knopf', 'kundenart'])
  assert.deepEqual([JSON.parse(n.fassungen).zustimmung, JSON.parse(n.fassungen).wortlaut, n.acceptedAt], ['2026-09-30-v5', 'v5-workers', u.st.subscriptions.get(subId).metadata.termsAcceptedAt])
  assert.equal(n.ip, u.st.subscriptions.get(subId).metadata.consentIp); assert.match(n.konto, /^Max Müller <kaeufer@example\.invalid>$/)
  // Projekt
  const vorschau = u.aw.docs('workersPreviews').get('v1'); const projekt = u.aw.docs('websiteProjects').get(vorschau.convertedProjectId)
  assert.deepEqual([projekt.customerId, projekt.projectType], [kunde.$id, 'preview']); assert.ok(u.gitea.filesOf(projekt.repoFullName)['index.html'])
  // K44: genau eine, nur an den Käufer, Passwort steht drin und danach nicht mehr in der Datenbank
  const k44 = u.mails.sent.filter((m) => m.subject === 'Willkommen bei WEBklar – Ihre Zugangsdaten'); assert.equal(k44.length, 1)
  assert.deepEqual(k44[0].to.map((t) => t.address), ['kaeufer@example.invalid']); assert.ok(!k44[0].bcc)
  const passwort = k44[0].text.match(/Passwort[\s:]*([abcdefghjkmnpqrstuvwxyz23456789]{16})\b/)[1]
  assert.equal(u.aw.docs('customers').get(kunde.$id).portalPassword, ''); assert.ok(u.aw.user(kunde.appwriteUserId))
  assert.ok(k44[0].text.includes(projekt.previewUrl) && k44[0].text.includes('nach Ablauf der Widerrufsfrist'))
  // Rückmeldung an den Hustler
  const o = u.aw.docs('workersOffers').get('o1'); assert.deepEqual([o.status, o.customerId, o.stripeSessionId], ['bezahlt', kunde.$id, sessionId]); assert.ok(o.paidAt); assert.match(o.paidAt, /Z$/)   // ISO-UTC: P1 filtert paidAt für die Berliner Monatsgrenze (A12)
  const p = u.aw.docs('workersProspects').get('p1'); assert.deepEqual([p.status, p.customerId], ['deal', kunde.$id])
  const deal = u.aw.docs('workersEvents').get(eventIdFor('deal', 'o1')); assert.deepEqual([deal.kind, deal.workerId], ['deal', 'system']); assert.match(deal.ts, /Z$/)
  assert.deepEqual(JSON.parse(deal.metaJson), { offerId: 'o1', customerId: kunde.$id, productKey: 'hosting', amountCents: 2000, kundenart: 'privat' })
  // Geheimnisse nie im Log
  for (const zeile of logs()) { assert.ok(!zeile.includes(passwort)); assert.ok(!zeile.includes(token)) }
  assert.ok(!u.mails.sent.some((m) => m.subject.startsWith('[Hustler]')), 'im Normalfall kein Team-Hinweis')
})
test('Wiederholung nach vollständigem Lauf: nichts Neues', async () => { /* dasselbe Ereignis nochmals: Status 200; Zahl von customers, featureConsents, websiteProjects, workersEvents, K44-Mails und T02-Mails unverändert */ })
test('zweite bezahlte Sitzung zum selben Angebot', async () => { /* nach dem Gesamtlauf die Sitzung klonen (neue ID in u.st.checkoutSessions, andere subId) und als Ereignis senden: Status 200, customers bleibt 1, Mail "[Hustler] Doppelte Zahlung für ein Angebot" */ })
test('K44 scheitert (Transport wirft): 500, Passwort bleibt; Wiederholung sendet K44 einmal und löscht das Passwort', async () => { /* sendMail wirft nur für Betreff "Willkommen bei WEBklar …": Status 500, customers.portalPassword nicht leer; Transport frei, erneute Zustellung: Status 200, genau eine K44, portalPassword '' */ })
test('Gitea weg: 500 ohne K44; später genau ein Projekt und eine K44', async () => { /* u.gitea.setDown(true): Status 500, keine K44; setDown(false) + erneute Zustellung: Status 200, ein websiteProjects-Dokument, eine K44 */ })
test('Gitea lehnt dauerhaft ab (404 auf die Vorlage): 200, T25, K44 ohne Vorschau, Angebot bezahlt', async () => { /* u.gitea.failNext({ method: 'POST', pathIncludes: '/generate', status: 404 }): Status 200, Mail "[Hustler] Vorschau konnte nicht übernommen werden", genau eine K44 ohne Vorschauadresse, workersOffers.status 'bezahlt', websiteProjects leer */ })
test('Vorschau-Ordner fehlt: 200, T25, K44 ohne Vorschau, Angebot trotzdem bezahlt', async () => { /* seedAngebot({ previewDateien: null }) (im Aufruf von kauf über u.seedAngebot vorab): Mail "[Hustler] Vorschau konnte nicht übernommen werden", K44 ohne "project.webklar.com"-Vorschauadresse, workersOffers.status 'bezahlt', websiteProjects leer */ })
test('Nachweis scheitert: 200, T25, K44 geht trotzdem raus', async () => { /* u.aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500 }): Status 200, Mail "[Hustler] Zustimmungsnachweis nicht gespeichert", eine K44, Angebot bezahlt */ })
test('vertragsNachweis: ohne wortlaut-Parameter bleibt WORTLAUT_STATUS', async () => { /* speichereVertragsNachweis wie bisher aufgerufen: JSON.parse(fassungen).wortlaut === 'entwurf' */ })
  • Step 2: Tests laufen lassen, Fehlschlag sehen

Run: timeout 900 node --max-old-space-size=1024 --test test/workersKaufWebhook.test.js test/workersKaufAbschluss.test.js Erwartet: FAIL (kein Nachweis, keine K44, Angebot bleibt checkout).

  • Step 3: Implementieren

vertragsNachweis.js: Parameter wortlaut = WORTLAUT_STATUS an speichereVertragsNachweis und speichereNachweisFuerRoute (Test: Standardverhalten der bestehenden Aufrufer unverändert, test/knVertragsNachweis.test.js bleibt grün). workersKaufAbschluss.js: Teil 2 in der Reihenfolge der Interfaces; Werte für K44: name = contactName, firma = companyName oder contactName, portal_url = ${config.publicBaseUrl}/login.html, vorschau_url = previewUrl des Projekts oder '', leistungsbeginn = TT.MM.JJJJ aus trial_end; Blöcke vorschau, sofortbeginn (Abo startChoice === 'sofort'), beginn_nach_widerrufsfrist. Nachweis-Texte: texteFuerClient({ produktName, leistung, amountCents, minTermMonths, lateStartAt }) mit denselben Katalogwerten wie die Seite (lateStartAt = trial_end als ISO, bei Sofortbeginn lateStartAt().toISOString(), es wird dann nur für die nicht gewählte Variante gebraucht); bestellung = bestellZeile('abo', { produkt: productLabel(productKey), monatCents, mindestMonate, beginn: startChoice, beginnAm }); angebotssatz = workersAngebotssatz aus den Abo-Metadaten.

  • Step 4: Tests laufen lassen, Regressionen prüfen

Run:

for f in test/workersKaufWebhook.test.js test/workersKaufAbschluss.test.js test/aboRoutes.test.js test/knVertragsNachweis.test.js test/mailTemplates.test.js; do timeout 900 node --max-old-space-size=1024 --test "$f" || break; done

Erwartet: alle PASS.

  • Step 5: Commit
git add server/services/workersKaufAbschluss.js server/services/vertragsNachweis.js test/workersKaufWebhook.test.js test/workersKaufAbschluss.test.js
git commit -m "feat(workers-kauf): Nachweis, Vorschau-Projekt, K44 und Rückmeldung an den Hustler" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P5-T13: Seite I – HTML und Ansichtslogik

Files:

  • Create: public/uebernahme.html, public/uebernahme.css, public/uebernahme-ansicht.js
  • Test: test/workersUebernahmeAnsicht.test.js

Interfaces:

  • Consumes: tokenAusAdresse, KONTAKT aus public/auftrag-ansicht.js.

  • Produces (uebernahme-ansicht.js, ohne DOM, ohne fetch, ohne Importe außer dem Obigen):

    • TEXTE – Seitentexte (Sie-Form): laedt, ungueltig: { titel: 'Dieser Link ist nicht mehr gültig', text }, limit, fehler, danke: { titel, text }, knopfLaden, Feldnamen und Fehlertexte je Feld.
    • zustandAusAntwort(status: number, body: object): { zustand: 'ungueltig'|'limit'|'fehler', titel: string, text: string } (404 → ungueltig, 429 → limit, alles andere inkl. 0 → fehler).
    • formularWerte(felder: Record<string,string|boolean>): object (POST-Body: getrimmt, acceptTerms als Boolean, termsVersion und lateStartAt kommen aus dem Angebot).
    • pruefeFormular(werte: object): { ok: boolean, fehler: Record<string, string> } (Schlüssel = Feldnamen) mit denselben Regeln und Texten wie pruefeBestellung (P5-T09).
    • istDanke(suche: string): boolean (?danke=1).
  • Step 1: Failing test test/workersUebernahmeAnsicht.test.js

test('HTML: noindex, kein Referrer, nur externes Modul-Skript, keine Fremdadressen', () => {
  const h = readFileSync(new URL('../public/uebernahme.html', import.meta.url), 'utf8')
  for (const t of ['<meta name="robots" content="noindex, nofollow">', '<meta name="referrer" content="no-referrer">', '<body class="ab-seite">', 'id="ue-wurzel"', '<script type="module" src="/uebernahme.js"></script>', 'href="/auftrag.css"', 'href="/uebernahme.css"']) assert.ok(h.includes(t), t)
  assert.equal([...h.matchAll(/<script\b(?![^>]*\bsrc=)/g)].length, 0, 'kein Inline-Skript')
  for (const datei of ['uebernahme.html', 'uebernahme.css', 'uebernahme-ansicht.js']) assert.doesNotMatch(readFileSync(new URL(`../public/${datei}`, import.meta.url), 'utf8'), /Workers|Hustler/, datei)   // die Seite für Käufer nennt den internen Namen nie (uebernahme.js: P5-T14)
  assert.deepEqual([...h.matchAll(/(?:src|href)="(https?:)?\/\/[^"]+"/g)].map((m) => m[0]).filter((u) => !u.includes('webklar.com')), [])
})
test('zustandAusAntwort: 404/429/Rest', () => { assert.equal(zustandAusAntwort(404, {}).zustand, 'ungueltig'); assert.match(zustandAusAntwort(404, {}).titel, /nicht mehr gültig/); assert.equal(zustandAusAntwort(429, {}).zustand, 'limit'); for (const s of [0, 500, 503, 200]) assert.equal(zustandAusAntwort(s, {}).zustand, 'fehler') })
test('pruefeFormular: dieselben Regeln wie der Server', () => { /* leer → alle Pflichtfelder; PLZ "1234" → plz; E-Mail "a@b" → email; unternehmen ohne companyName → companyName; gültig → ok */ })
test('formularWerte: trimmt, acceptTerms als Boolean', () => { /* Leerzeichen am Rand weg; acceptTerms true/'on' → true, sonst false; Schlüssel wie der POST-Body aus P5-T09 */ })
test('Token aus der Adresse: #t=, Rückfall ?t=, verstümmelt → ""', () => { assert.equal(tokenAusAdresse('', `#t=${T}`), T); assert.equal(tokenAusAdresse(`?t=${T}`, ''), T); assert.equal(tokenAusAdresse('', '#t=kurz'), '') })
  • Step 2: Test laufen lassen, Fehlschlag sehen

Run: timeout 300 node --max-old-space-size=1024 --test test/workersUebernahmeAnsicht.test.js Erwartet: FAIL (Dateien fehlen).

  • Step 3: Implementieren

uebernahme.html im Aufbau von public/auftrag.html (Titel „Website übernehmen – WEBklar“, <body class="ab-seite">, Kopf mit Logo, <main> mit <div class="ab-wurzel" id="ue-wurzel" data-zustand="laedt" aria-busy="true">, noscript-Satz, aria-live-Bereich #ue-ansage, Fuß mit Datenschutz und Impressum, <script type="module" src="/uebernahme.js">). uebernahme.css: alle Regeln unter body.ab-seite mit Präfix .ue- (Formularfelder, Optionsgruppen, Zusammenfassungstabelle, Fehlermeldung), Farben über die Variablen --ab-* aus auftrag.css, 16 px Seitenrand, keine horizontale Scrollleiste bei 360 px. uebernahme-ansicht.js nach den Interfaces; die Texte der Meldungen entsprechen pruefeBestellung.

  • Step 4: Test laufen lassen

Run: timeout 300 node --max-old-space-size=1024 --test test/workersUebernahmeAnsicht.test.js Erwartet: PASS.

  • Step 5: Commit
git add public/uebernahme.html public/uebernahme.css public/uebernahme-ansicht.js test/workersUebernahmeAnsicht.test.js
git commit -m "feat(workers-kauf): Seite Website übernehmen – HTML und Ansichtslogik" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P5-T14: Seite II – Anzeige, Formular, Absenden

Files:

  • Create: public/uebernahme.js
  • Test: test/workersUebernahmeSeite.test.js

Interfaces:

  • Consumes: h aus public/auftrag-dom.js; TEXTE, zustandAusAntwort, formularWerte, pruefeFormular, istDanke, tokenAusAdresse, KONTAKT (P5-T13); Server-Antworten GET /api/uebernahme/:token → { angebot } und POST …/checkout → { url } (P5-T09).

  • Produces: initUebernahmeseite({ root, ansage, suche = '', hash = '' }): Promise<void> (Selbststart wie auftrag.js, wenn #ue-wurzel existiert). Vertrag der Oberfläche: root.dataset.zustand ∈ laedt|offen|ungueltig|limit|fehler|danke; Formular #ue-form; Felder name="kundenart" (Radio unternehmen/privat), name, email (vorbelegt mit angebot.email), companyName (vorbelegt mit angebot.firma, nur bei unternehmen sichtbar/Pflicht), street, postalCode, city, Haken name="acceptTerms", Radio name="startChoice" (sofort/nach_frist), Knopf #ue-kaufen; Fehler je Feld in [data-fehler="<feld>"]; Zusammenfassungszellen [data-zeile="faellig"] und [data-zeile="beginn"] zeigen bis zur Wahl …ohneWahl, danach die Variante der Wahl; Vorschau-Link a[data-vorschau] (target="_blank", rel="noopener noreferrer").

  • Step 1: Failing test test/workersUebernahmeSeite.test.js (installFakeDom({ antworten }) aus test/support/fakeDom.js; TOKEN = 'T'.repeat(43); ANGEBOT hat die Form von uebernahmeAnsicht mit firma: '<img src=x onerror=alert(1)> GmbH', vorschauUrl auf https://entwurf.project.webklar.com/<43 Zeichen>/ und texte: texteFuerClient(...) aus server/services/workersKaufTexte.js)

test('Angebot wird angezeigt; Daten aus dem Server bleiben Text, nie HTML', async () => {
  const fx = installFakeDom({ antworten: [{ status: 200, body: { angebot: ANGEBOT } }] })
  try {
    const root = fx.document.createElement('div'); fx.document.body.append(root)
    await initUebernahmeseite({ root, ansage: fx.document.createElement('div'), hash: `#t=${TOKEN}` })
    assert.equal(root.dataset.zustand, 'offen'); assert.deepEqual(fx.fetchLog[0], { method: 'GET', url: `/api/uebernahme/${TOKEN}`, body: undefined })
    assert.ok(fx.text(root).includes('<img src=x onerror=alert(1)> GmbH')); assert.equal(fx.finde(root, 'img'), null)
    assert.ok(fx.text(root).includes(ANGEBOT.angebotssatz)); assert.doesNotMatch(fx.text(root), /Workers|Hustler/)   // die Seite nennt den internen Namen nie
    const a = fx.finde(root, 'a[data-vorschau]'); assert.equal(a.getAttribute('rel'), 'noopener noreferrer'); assert.equal(a.getAttribute('target'), '_blank')
    assert.equal(fx.text(fx.finde(root, '[data-zeile="faellig"]')), ANGEBOT.texte.zeilen.faellig.ohneWahl)
    await fx.klick(fx.finde(root, 'input[name="startChoice"][value="nach_frist"]'))
    assert.equal(fx.text(fx.finde(root, '[data-zeile="faellig"]')), ANGEBOT.texte.zeilen.faellig.nachFrist)
  } finally { fx.restore() }
})
test('Absenden ohne Pflichtangaben: kein Netzaufruf, Fehler am Feld, Knopf bleibt frei', async () => { /* Formular leer abschicken: fx.fetchLog enthält nur den GET; [data-fehler="email"] u. a. gefüllt; document.activeElement ist das erste fehlerhafte Feld; #ue-kaufen nicht gesperrt */ })
test('Absenden: Body ohne Token, credentials omit, Knopf gesperrt, Weiterleitung auf Stripe-URL', async () => {
  /* globalThis.fetch umwickeln, um options.credentials zu sehen; Antworten: GET 200, POST { status: 200, body: { url: 'https://checkout.stripe.test/c/pay/cs_1' } };
     erwartet: POST-Body enthält acceptTerms true, termsVersion/lateStartAt aus dem Angebot, kundenart, Käuferfelder und keinen Token; credentials === 'omit'; fx.window.location.href === URL */ })
test('Serverfehler: 409 zeigt den Servertext und entsperrt den Knopf; 429 → limit; Netzfehler → fehler', async () => { /* POST-Antwort 409 { error: 'Zu dieser E-Mail-Adresse gibt es schon ein Kundenkonto. …' }: Text im Formular, #ue-kaufen frei; Antwort 429 → data-zustand "limit"; fetch wirft → "fehler" */ })
test('Toter Link und fehlendes Token: Seite "nicht mehr gültig" mit Kontakt, kein Formular', async () => { /* GET 404 bzw. leere Adresse (kein Netzaufruf): data-zustand "ungueltig", Text enthält support@webklar.com und 0176 23726355, kein #ue-form */ })
test('?danke=1: Dankeseite ohne Netzaufruf', async () => { /* suche '?danke=1': data-zustand "danke", fx.fetchLog leer, Text nennt E-Mail und Spam-Ordner */ })
  • Step 2: Test laufen lassen, Fehlschlag sehen

Run: timeout 300 node --max-old-space-size=1024 --test test/workersUebernahmeSeite.test.js Erwartet: FAIL Cannot find module '../public/uebernahme.js'.

  • Step 3: Implementieren (public/uebernahme.js)

Aufbau wie public/auftrag.js: ein anfrage(token, aktion, { method, body }) mit credentials: 'omit', jeder Netzfehler = Status 0; zeige(zustand, …) setzt data-zustand/aria-busy; alle Texte nur über h()/textContent. Absenden: pruefeFormular → bei Fehlern Texte in [data-fehler] und Fokus auf das erste Feld; sonst Knopf sperren, POST, bei { url } window.location.href = url, sonst Fehlertext (409/400 aus body.error, 429 → TEXTE.limit), Knopf wieder frei. Der Token steht nur im Pfad der Anfrage, nie im Body, im Text oder im Verlauf (history.replaceState nicht nötig).

  • Step 4: Tests laufen lassen

Run: timeout 300 node --max-old-space-size=1024 --test test/workersUebernahmeSeite.test.js test/workersUebernahmeAnsicht.test.js Erwartet: PASS.

  • Step 5: Commit
git add public/uebernahme.js test/workersUebernahmeSeite.test.js
git commit -m "feat(workers-kauf): Seite Website übernehmen – Anzeige, Formular, Absenden" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P5-T15: Testangebot-Skript für die manuelle Prüfung

Files:

  • Create: scripts/workers-kauf-testangebot.mjs
  • Test: test/workersKaufTestangebot.test.js

Interfaces:

  • Consumes: neuerToken, hashToken, createDocument, deleteDocument, config, schemaKeys (Test).

  • Produces: baueTestangebot({ jetzt: Date, email: string, firma: string, vorschauToken: string }): { token: string, ids: { prospect: string, preview: string, offer: string }, prospect: object, preview: object, offer: object } (reine Funktion, exportiert; die Dokumente tragen kein $id, die IDs stehen in ids); CLI node scripts/workers-kauf-testangebot.mjs --email <adresse> --firma <name> --vorschau <43-Zeichen-Token> (legt Interessent, Vorschau, Angebot an, druckt https://project.webklar.com/uebernahme.html#t=<token> und die drei Dokument-IDs) und --loeschen <angebotsId> (löscht die drei Dokumente, aber nur wenn darfLoeschen(offer, prospect) – ebenfalls exportiert – wahr ist: Angebots-ID beginnt mit t- und der Interessent trägt source: 'test-kauf'; sonst Exit 1 ohne etwas zu löschen, damit ein falsch eingetipptes echtes Angebot nie samt Interessent und Vorschau-Dokument verschwindet); --help. Das Skript schreibt nur in workersProspects, workersPreviews, workersOffers; Aufruf mit unvollständigen Argumenten endet mit Exit 1 und Nutzungshinweis.

  • Step 1: Failing test test/workersKaufTestangebot.test.js

test('baueTestangebot: Dokumente passen zum Schema, Hash passt zum Token, Frist 30 Tage', () => {
  const t = baueTestangebot({ jetzt: new Date('2026-10-20T10:00:00Z'), email: 'test@example.invalid', firma: 'Testbetrieb GmbH', vorschauToken: 'V'.repeat(43) })
  assert.equal(t.token.length, 43); assert.equal(t.offer.tokenHash, hashToken(t.token))
  for (const [name, doc] of [['workersOffers', t.offer], ['workersProspects', t.prospect], ['workersPreviews', t.preview]]) assert.deepEqual(Object.keys(doc).filter((k) => !schemaKeys(name).includes(k)), [], name)
  assert.equal(t.offer.previewUrl, `https://entwurf.project.webklar.com/${'V'.repeat(43)}/`); assert.equal(t.offer.expiresAt, '2026-11-19T10:00:00.000Z')
  assert.deepEqual([t.offer.status, t.offer.productKey, t.prospect.status, t.prospect.source], ['offen', 'hosting', 'angebot', 'test-kauf'])
  for (const pflicht of ['entrance', 'room', 'foundBy', 'source', 'foundAt', 'name', 'status', 'updatedAt']) assert.ok(t.prospect[pflicht], pflicht)
})
test('darfLoeschen: nur Testangebote', () => {
  const t = baueTestangebot({ jetzt: new Date('2026-10-20T10:00:00Z'), email: 'test@example.invalid', firma: 'Testbetrieb GmbH', vorschauToken: 'V'.repeat(43) })
  assert.deepEqual([darfLoeschen({ $id: t.ids.offer }, t.prospect), darfLoeschen({ $id: 'o1' }, t.prospect), darfLoeschen({ $id: t.ids.offer }, { source: 'gutefrage' })], [true, false, false])
})
test('Token-Format der Vorschau wird geprüft', () => assert.throws(() => baueTestangebot({ jetzt: new Date(), email: 'a@b.de', firma: 'X', vorschauToken: 'kurz' }), /43/))
  • Step 2: Test laufen lassen, Fehlschlag sehen

Run: timeout 900 node --max-old-space-size=1024 --test test/workersKaufTestangebot.test.js Erwartet: FAIL baueTestangebot is not a function.

  • Step 3: Implementieren

Reine Funktion und CLI in einer Datei; CLI nur, wenn die Datei direkt gestartet wird (process.argv[1] === fileURLToPath(import.meta.url)). Feste IDs t-<8 Hex> je Dokument (ids.offer beginnt mit t-, sonst greift darfLoeschen nie); source: 'test-kauf', room: 'netz', foundBy: 'rita', entrance: 'anfrage', name: firma, email. Ein Test-Interessent trägt purgeAfter (+ 7 Tage).

  • Step 4: Test laufen lassen, danach den ganzen P5-Satz

Run:

for f in test/workersReserviert.test.js test/workersKaufTexte.test.js test/workersVorschau.test.js test/workersKaufUmgebung.test.js test/workersGiteaDateien.test.js test/workersKaufProjekt.test.js test/workersKaufAngebot.test.js test/workersKaufCheckout.test.js test/workersKaufRoutes.test.js test/workersKaufWebhook.test.js test/workersKaufAbschluss.test.js test/workersUebernahmeAnsicht.test.js test/workersUebernahmeSeite.test.js test/workersKaufTestangebot.test.js test/mailTemplates.test.js test/aboRoutes.test.js test/subdomainSperre.test.js test/giteaAdmin.test.js; do timeout 900 node --max-old-space-size=1024 --test "$f" || { echo "FEHLER in $f"; break; }; done

Erwartet: alle PASS, keine Zeile „FEHLER“.

  • Step 5: Commit
git add scripts/workers-kauf-testangebot.mjs test/workersKaufTestangebot.test.js
git commit -m "feat(workers-kauf): Testangebot-Skript für die manuelle Prüfung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Betriebsanleitung (Kenso)

Vorher: P1 ist live (Container webklar-workers läuft, Sammlungen angelegt), und der Branch feature/workers-kaufweg ist geprüft. Agenten pushen nicht und starten keine Container; die Schritte führst du aus. Immer up -d --no-deps <dienst>, nie ein nacktes up -d, nie preview-portal neu anlegen.

  1. Sammlungen prüfen. Die fünf Sammlungen müssen existieren (Befehl und Dienst-Pfad laut P1-Betriebsanleitung): docker exec webklar-workers timeout 120 node --max-old-space-size=256 scripts/setup-collections.mjs --check Prüfung: Exit-Code 0, Ausgabe nennt workersOffers, workersProspects, workersPreviews, workersEvents, workersSettings als vorhanden.
  2. Backups. cd /home/webklar/apps && cp .env .env.bak-$(date +%F)-workers && cp docker-compose.yml docker-compose.yml.bak-$(date +%F)-workers. Prüfung: beide *.bak-…-workers per ls -l vorhanden.
  3. .env. Zeile WORKERS_KAUF_ENABLED=off ergänzen (Schalter zunächst aus). Prüfung: rg -n '^WORKERS_KAUF_ENABLED' .env zeigt genau eine Zeile.
  4. Compose, Dienst kundenbereich-server. Unter environment: zwei Zeilen, unter volumes: eine Zeile ergänzen:
          # Hustler-Kaufweg (2026-10-17): Seite "Website uebernehmen" zuerst aus; Vorschau-Dateien lesend aus dem Volume workers_data
          - WORKERS_KAUF_ENABLED=${WORKERS_KAUF_ENABLED:-off}
          - WORKERS_VORSCHAU_ROOT=/workers-vorschau/vorschau
    
          - workers_data:/workers-vorschau:ro
    
    Das Volume workers_data steht seit P1 oben im Block volumes: der Datei. Prüfung: docker compose config -q && echo gueltig. Das Volume wird im Portal nur lesend gemountet (:ro); damit der Portal-Container die Dateien trotzdem lesen kann, legt P4 Verzeichnisse mit 0755 und Dateien mit 0644 an (Übersicht §9, A14). Prüfung nach dem Start (sobald ein Vorschau-Ordner <T> im Volume liegt, spätestens in Schritt 9.1): docker exec webklar-kundenbereich-server ls -l /workers-vorschau/vorschau/<T>/ zeigt -rw-r--r-- bei den Dateien (Unterordner drwxr-xr-x) und keine Meldung „Permission denied“.
  5. Portal-Container neu anlegen (nur dieser Dienst): docker compose up -d --no-deps kundenbereich-server. Prüfung nach etwa einer Minute (der Start installiert Pakete): docker exec webklar-kundenbereich-server sh -c 'printenv WORKERS_KAUF_ENABLED; ls /workers-vorschau' zeigt off und den Inhalt des Volumes; curl -s -o /dev/null -w '%{http_code}\n' https://project.webklar.com/api/health zeigt 200.
  6. Voraussetzungen prüfen (einmalig, vor dem Livegang): (a) Gitea kann mehrere Dateien in einem Commit: curl -s https://git.webklar.com/swagger.v1.json | rg -c 'ChangeFilesOptions' zeigt mindestens 1. (b) Der bestehende Stripe-Webhook-Endpunkt https://project.webklar.com/api/abo/webhook liefert checkout.session.completed und customer.subscription.created|updated|deleted (Stripe-Dashboard, Entwickler, Webhooks); es ist nichts umzustellen, der Kaufweg nutzt denselben Endpunkt.
  7. Code live bringen. cd /home/webklar/apps/kundenbereich.webklar.com/dev-workers-kauf && git fetch origin && git rebase origin/main && timeout 900 node --max-old-space-size=1024 --test test/aboRoutes.test.js (grün), dann git push origin feature/workers-kaufweg:main (Deploy-Hook baut und startet das Portal neu). Prüfung: curl -s -o /dev/null -w '%{http_code}\n' https://project.webklar.com/api/uebernahme/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA zeigt 404; curl -sI https://project.webklar.com/uebernahme.html | head -1 zeigt 200; Serverpuls bleibt grün.
  8. Rechtsrunde (E9). Vor dem Schalter: die neuen Wortlaute freigeben (Kundenart-Auswahl, Stripe-Hinweis „… auf der Seite „Website übernehmen“ …“, Zitat des Angebotssatzes) und prüfen, dass workersSettings/main.offerText denselben Preis nennt wie der Katalog (20 € im Monat, 12 Monate). Ohne deine Freigabe bleibt der Schalter auf off.
  9. Manuelle Prüfung mit einem Testkonto (Stripe live; die Adresse muss in ABO_TEST_EMAILS stehen, dann kostet das Abo 0,50 €; wähle „nach Ablauf der Widerrufsfrist“, dann wird heute nichts abgebucht):
    1. Vorschau-Ordner im Volume anlegen: T=$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=') && docker exec webklar-workers sh -c "mkdir -p /data/vorschau/$T && echo '<h1>Test</h1>' > /data/vorschau/$T/index.html" && echo $T. Leserechte des Portals prüfen (Schritt 4): docker exec webklar-kundenbereich-server ls -l /workers-vorschau/vorschau/$T/ zeigt -rw-r--r-- … index.html.
    2. .env: WORKERS_KAUF_ENABLED=on, dann docker compose up -d --no-deps kundenbereich-server.
    3. Testangebot: docker exec -w /app webklar-kundenbereich-server timeout 60 node --max-old-space-size=256 scripts/workers-kauf-testangebot.mjs --email <testadresse> --firma "Testbetrieb GmbH" --vorschau $T – der Befehl druckt den Link und die Angebots-ID.
    4. Link auf Handy und am Desktop in einem privaten Fenster öffnen (eine offene Kundenansicht im Browser sperrt das Bestellen mit 403); Seite prüfen (Betrieb, Vorschau-Link, Angebotssatz, Bedingungen, keine waagerechte Scrollleiste bei 360 px Breite, Dunkelmodus lesbar), bestellen, bei Stripe bezahlen.
    5. Prüfung: Rücksprung auf „Vielen Dank“; in Stripe tragen Kunde und Abo webklarCustomerId; im WOMS gibt es den Kunden (Status customer) mit Projekt „Testbetrieb GmbH“ (Vorschau erreichbar nach Anmeldung); Postfach der Testadresse: K44 (mit Passwort) und K01; Team-Postfach: T02 und kein Hinweis mit Betreff „[Hustler] …“; workersOffers.status = bezahlt; Anmeldung mit den K44-Daten klappt; Passwort im WOMS-Kundenblatt leer.
    6. Aufräumen: Abo bei Stripe beenden bzw. widerrufen; Kunde im WOMS löschen; docker exec -w /app webklar-kundenbereich-server timeout 60 node --max-old-space-size=256 scripts/workers-kauf-testangebot.mjs --loeschen <angebotsId> (löscht nur Angebote, deren ID mit t- beginnt); Test-Repo WEBklar/testbetrieb-gmbh im WOMS-Projekt archivieren; Ordner docker exec webklar-workers rm -r -- /data/vorschau/$T.
  10. Handfälle (kommen per T25-Mail, Betreff „[Hustler] …“): E-Mail gehört schon einem Kunden – im Stripe-Dashboard am Abo und am Kunden webklarCustomerId=<ID des bestehenden Kunden> setzen, dann das Ereignis checkout.session.completed erneut senden: der Handler setzt am Kunden fort (Nachweis, Projekt, Abschluss). Hat dieser Kunde im WOMS noch ein gespeichertes Anfangspasswort, geht es per K44 an die im Kundendokument hinterlegte Adresse (nicht an die Käufer-Adresse); sonst entfällt K44. Prüfe vorher, dass der Kunde wirklich dieselbe Person ist. Doppelte Zahlung – die zweite Zahlung bei Stripe erstatten und das zweite Abo beenden. Vorschau nicht übernommen – Dateien prüfen, Ereignis erneut senden.
  11. Zurücknehmen. WORKERS_KAUF_ENABLED=off in .env, docker compose up -d --no-deps kundenbereich-server; die Links antworten sofort 404. Der Webhook-Zweig bleibt wirksam (bezahlte Sitzungen werden weiter verarbeitet).