166 KiB
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-E1bisP5-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}undpublic/uebernahme-ansicht.js(das Portal hat keinpublic/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(liestofferText),config.workers = { kaufEnabled, vorschauRoot, vorschauBaseUrl }, EnvWORKERS_KAUF_ENABLED(Standardoff),WORKERS_VORSCHAU_ROOT,WORKERS_VORSCHAU_BASE– siehe Übersicht §8, §11, P5-E3.- E4 Betrieb: Der Portal-Container hängt
workers_data:/workers-vorschau:roan – siehe Übersicht §9, §11, P5-E4.- E5 Auftrag an P4 (A14): Vorschau-Server 404 ab
convertedProjectId; Angeboteabgelaufenbei Sperre (das Portal prüftworkersProspects.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(workerIdsystem, IDeventIdFor('deal', offerId),metaJsonlaut Übersicht §5);newAbosMonth,mrrNewEurunddealsMonthzählt P1 ausworkersOffers(status 'bezahlt',paidAtim Berliner Monat) – Auftrag an P1, nicht mehr offen; P5 setzt genau diese Felder,paidAtals ISO-UTC mitZ(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 inmailTemplates.test.jserweitert; der DateinameT25 Workers Hinweis.htmlbleibt 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 auspublic/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änktofferProductKeyaufhosting, 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 Servertimeout 300 …. Nachbauten zuerst starten, dannuseTestEnv(), dann erstserver/config.jsimportieren (Mustertest/aboRoutes.test.js). Keine echten Netzaufrufe; Vorschau-Dateien liegen in Tests in einem eigenen Ordner unteros.tmpdir(). - Branch
feature/workers-kaufwegvonorigin/mainim Worktreedev-workers-kauf; Agenten pushen nie. - Der Kunde entsteht erst im Webhook nach
checkout.session.completed. Vorher schreibt P5 nichts incustomers,customerPortalAccess,websiteProjects,featureConsentsund legt keinen Appwrite-Benutzer an (Übersicht §2.13). - Token: 43 Zeichen base64url (
neuerToken()/hashToken()ausservices/auftraege/token.js), inworkersOffers.tokenHashnur der sha256-Hex. Der Token steht in keiner Logzeile und in keiner Stripe-Adresse außer im Fragment dercancel_url. - Ungültiger, abgelaufener, bezahlter, widerrufener Link, Interessent
gesperrt, Schalter aus, Produkt ungleichhosting(E10): überall dieselbe Antwort404 { 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_ENABLEDgilt nur für die Routen; der Webhook-Zweig arbeitet immer (bezahlte Sitzungen werden nie liegen gelassen).ABO_PILOT_EMAILSgilt hier nicht. - Bedingungen:
config.abo.termsVersion=2026-09-30-v5; Hostingconfig.products.hosting(amountCents2000,minTermMonths12, § 19 UStG, ohne MwSt.). Eine abweichendetermsVersionoder ein abweichendeslateStartAtin der Bestellung ergibt 409. - Stripe: Sitzung
mode: 'subscription', Session-metadata{ workersOfferId, productKey };subscription_data.metadataträgt die Zustimmungsfelder des normalen Checkouts ohnewebklarCustomerId, ohneofferId/offerText(sonst greiftactivateOfferFromCheckout), plusworkersOfferId,kundenart, Käuferangaben und Angebotssatz (Werte ≤ 500 Zeichen, Schlüssel ≤ 40). - Kunde:
createCustomerWithPortalAccess({ name, email, password, kundenart }), danachcustomerStatus: 'customer'. Passwort 16 Zeichen ausabcdefghjkmnpqrstuvwxyz23456789, gezogen mitcrypto.randomInt(nieMath.random). K44 geht ohne BCC an die E-Mail-Adresse des Kundendokuments (bccTeam: false), danachforgetStoredPortalPassword. 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.htmlPflicht, 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: Sammlungenworkers*, Dienst/Containerwebklar-workers, Dateien und Funktionen (workersKauf.js,createWorkersCheckout…), MetadatenworkersOfferId,workersAngebotssatz, EnvWORKERS_*, Volumeworkers_data, Mail-DateinameT25 Workers Hinweis.html, Ereignisartdeal. „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.createdzuerst) 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, keinindex.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, Branchfeature/workers-kaufweg - Create:
server/services/reserviert.js - Modify:
server/config.js(BlockcollectionsnachprojektRechnungen; neuer Blockworkersnachauftraege),server/services/giteaAdmin.js(ensureSubdomainAvailable, Konfliktprüfung inupdateWebsiteProject),server/services/previewDeploy.js(foreignSubdomainOwner) - Test:
test/workersReserviert.test.js
Interfaces:
-
Produces:
config.collections.{workersOffers, workersProspects, workersPreviews, workersEvents, workersSettings}(EnvAPPWRITE_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(Mustertest/giteaAdmin.test.js:startFakeAppwriteaus./helpers/fakeAppwrite.js, Env setzen, dann dynamisch importieren; Appwrite-Dokument{ $id: 'wp1', repoFullName: 'WEBklar/x', projectName: 'X', subdomain: 'x', projectType: 'preview' }inwebsiteProjects;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
server/services/reserviert.js: die beiden Exporte oben; Meldung der AufruferSubdomain "<name>" ist reserviert(Status 409).server/config.js: fünf Sammlungen im Stil der übrigen (process.env.APPWRITE_COLLECTION_WORKERS_OFFERS || 'workersOffers'usw.); Blockworkers: { 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(/\/$/, '') }.ensureSubdomainAvailableund die Konfliktprüfung inupdateWebsiteProject(vor derlistDocuments-Abfrage, nur wennnextSubdomaingesetzt und geändert): beiistReserviertFehler 409Subdomain "<name>" ist reserviert.foreignSubdomainOwner(previewDeploy.js): als erste Zeile nach dem Leer-Checkif (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,klartextausserver/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 }): stringuebernahmeNachweisTexte({ kundenart: 'unternehmen'|'privat', startChoice: 'sofort'|'nach_frist', texte: ReturnType<typeof texteFuerClient>, bestellung: string, angebotssatz: string }): Array<{ id: string, text: string, haken: boolean }>in der Reihenfolgeangebot,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 nachpath). -
Step 1: Failing test
test/workersVorschau.test.js(eigener Scratch-Ordnermkdtemp(path.join(os.tmpdir(), 'workers-vorschau-')), am Ende nur diesen Ordner entfernen; Hilfevorschau(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 Hilfeclear()) - 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 istsrc/schema.jsdes Dienstes;workersSettingsführt alle Felder aus §4.1 samt der später von P3/P4 angelegtenprivacyInteressentenLive,sendingHalted,sendingHaltedReason);schemaKeys(name)kenntworkersOffers|workersProspects|workersPreviews|workersEvents|workersSettings;gitea.clear()entfernt im Nachbau alle Repos, Dateien, Hooks und aufgezeichneten Aufrufe. -
Produces:
startWorkersUmgebung({ kaufAn = true } = {}): Promise<U>mitU = { 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[]ersetztconsole.log/warn/errorbis zum nächstenreset()und liefert die gesammelten Zeilen (für die Tests „kein Token, keine E-Mail, kein Passwort im Log“). Konvention: jeder Test beginnt mitawait 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 vonvorschauRoot).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 untervorschauRoot, wennpreviewDateiengesetzt) und Angebot an (tagenegativ = 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. ersetztworkersSettings/mainmit allen Feldern aus Übersicht §4.1 (Standardwerte, darunterprivacyInteressentenLive: false,sendingHalted: false,sendingHaltedReason: '') und dem gegebenenofferText; der Satz hat höchstens 400 Zeichen (Übersicht §4.1, P1 erzwingt das in den Einstellungen): bei mehr wirft die HilfeError('offerText: höchstens 400 Zeichen (Übersicht §4.1)'), außer mitohneGrenze: 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(schemaKeysaus./support/schema.jsimportieren)
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
test/support/workersSchema.jsmit den fünf Schlüssellisten (Feldnamen exakt aus Übersicht §4; keine Pflicht/Typ-Angaben nötig).workersSettingsführt alle 30 Felder aus §4.1 auf, darunter die SchalterprivacyInteressentenLive,sendingHaltedundsendingHaltedReason; das Portal liest davon nurofferText, aber der Nachbau lehnt unbekannte Felder ab, undseedEinstellungensät das ganze Dokument.seedEinstellungenprüft die 400-Zeichen-Grenze fürofferTextselbst (siehe Interfaces); der Test „über 500 Zeichen → 500“ in P5-T08 bleibt als Schutz und sät mitohneGrenze: true.test/support/schema.js:schemaKeysliefert für diese fünf NamenWORKERS_PORTAL_COLLECTIONS[name](Zeile vor derSETUP_SCRIPTS-Schleife), Kommentar im Kopf ergänzen.startWorkersUmgebung: Reihenfolge Nachbauten (startAppwriteMock({ schema: { workers*…, featureConsents, contractEvents, subscriptions } }),startFakeStripe(),startFakeGitea()),mkdtempunteros.tmpdir()fürvorschauRoot,useTestEnv(...), dannprocess.envsetzen: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 erstconfig.js,testApp.js,mailer.jsimportieren undsetMailTransportForTests(mailSink()).stop()schließt alles, setzt den Mail-Transport zurück und entfernt nur den eigenen Scratch-Ordner.reset()löscht die Dokumente der Sammlungencustomers,customerPortalAccess,subscriptions,contractEvents,featureConsents,websiteProjectsundworkers*überdeleteDocument(der Nachbau hat keine Sammelfunktion), leertst.customers/subscriptions/checkoutSessions/calls,mails.sent, ruftgitea.clear()auf und entfernt die Unterordner vonvorschauRoot(nicht den Ordner selbst).gitea.clear()kommt intest/helpers/fakeGitea.jsnebensetFileaus 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(neureplaceRepoFiles,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 überblob <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/deletebrauchen den passendensha,createeiner vorhandenen Datei → 422), Hilfengitea.setFile(fullName, path, text)undgitea.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_TOKENsetzen, danngiteaAdmin.jsimportieren)
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
gitBlobShaundreplaceRepoFilesingiteaAdmin.js: Baum holen (/repos/<o>/<r>/git/trees/<branch>?recursive=true&per_page=1000), Bestand = Blob-Pfade → SHA; je Datei gleicher SHA = überspringen, sonstupdate(mit SHA) odercreate; Bestand ohne Gegenstück und nicht inbehalten=delete(mit SHA); keine Operation ={ geaendert: 0, commit: false }; sonst einPOST /repos/<o>/<r>/contentsmit{ message, branch, files }(Inhalt base64). Ein Commit = ein Push-Webhook = ein Deploy.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 Angeboto1und Vorschauv1; jeder Test beginnt mitawait 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(liestconfig.workers.kaufEnabledbei jedem Aufruf).findOfferByToken(token: string, { now?: Date }): Promise<object | null>–nullbei Format-Fehler, unbekanntem Hash,statusnichtoffen/checkout,expiresAt≤ jetzt,productKeyungleich'hosting'(E10), Interessent nicht gefunden (404 beim Lesen) odergesperrt.uebernahmeAnsicht(offer: object, { now?: Date }): Promise<{ firma, email, vorschauUrl, angebotssatz, produkt: { key, name, amountCents, minTermMonths }, termsVersion, lateStartAt, gueltigBis, texte }>– ohnetokenHash,prospectId,customerId,stripeSessionId;vorschauUrlnur, wenn sie mitconfig.workers.vorschauBaseUrl + '/'beginnt, sonst'';angebotssatzausworkersSettings/main.offerText, bei fehlendem Dokument oder leerem TextangebotssatzFallback.
-
Step 1: Failing test
test/workersKaufAngebot.test.js(Harness aus P5-T04; jeder Test beginnt mitawait 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(neucreateWorkersCheckout),test/support/fakeStripe.js(Plattform-RoutePOST /v1/checkout/sessions/:id/expire: nuropen→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 unterwithLocks(['workers-kauf:' + offer.$id])(dieselbe Sperre wie der Webhook) und liest das Angebot darin frisch (der übergebeneofferliefert nur die Kennung); so entstehen bei einem Doppelklick nie zwei offene Sitzungen. Stripe-Kunde: zuerst dercustomerder Vorgängersitzung (offer.stripeSessionId, die Stripe-Suche kennt neue Kunden erst nach einer Weile), sonst Suchemetadata['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 begrenztofferTextauf 400, die 500 sind nur der Schutz bei einem von Hand geänderten Dokument), legt die Sitzung an und setztworkersOffersauf{ status: 'checkout', stripeSessionId }. -
Step 1: Failing test
test/workersKaufCheckout.test.js(Harness aus P5-T04; jeder Test beginnt mitawait u.reset()und sät das Angebot neu;createWorkersCheckoutbekommt 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
fakeStripe.js: RoutePOST /v1/checkout/sessions/:id/expirefür die Plattform (die Connect-Variante bleibt unberührt).createWorkersCheckoutinworkersKauf.jsnach dem MustercreateAboCheckout(services/stripeBilling.js:581;withLocksausservices/keyedLock.js): Stripe-Kunde überstripeCustomerFields({ email, companyName, name, street, postalCode, city, country }, { forCreate: true })pluspreferred_locales: ['de'],invoice_settings.footer,metadata { workersOfferId, kundenart };subscription_data.metadataundtrial_endwie in den Global Constraints; SEPA-Rückfall auf Karte wie dort; Hinweistext wiecheckoutMessage(stripeBilling.js:561), aber „… auf der Seite „Website übernehmen“ abgegeben …“ statt „im Kundenportal“;workersAngebotssatzausworkersSettings/main.offerTextoder 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 undapp.use('/api/uebernahme', publicUebernahmeRoutes)direkt nach/api/auftrag),server/services/workersKauf.js(neupruefeBestellung) - 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> }undPOST /:token/checkout→200 { url }; ExportesetzeUhrFuerTests(fn|null),resetUebernahmeRoutesForTests(); inworkersKauf.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 mitawait u.reset()undresetUebernahmeRoutesForTests())
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
pruefeBestellunginworkersKauf.jsnach der Meldungstabelle oben.server/routes/uebernahme.jsim Aufbau vonroutes/auftrag.js(Header-Middleware,createWriteLimiterGET 120 / POST 10 je IP, zusätzlich POST 10 je Token-Hash,ladeNachTokenmit der einen 404-Antwort undworkersKaufAktiv()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 })mitconsent.termsAcceptedAt = jetzt,earlyStartConsentAt = startChoice === 'sofort' ? jetzt : '',serviceStartAt = nach_frist ? lateStartAt() : null, IP und User-Agent aus der Anfrage. Fehler:err.statusund deutscher Text; Stripe-/Appwrite-Fehler ohne Status → 500 „Das hat gerade nicht geklappt. Bitte versuchen Sie es gleich noch einmal.“; geloggt werden nurerr.status,err.codeunderr.type, nieerr.message(Stripe-Texte können die Adresse enthalten) und nie Token oder Angebotsdaten.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)– BetreffWillkommen bei WEBklar – Ihre Zugangsdaten; Platzhalter{{name}}, {{firma}}, {{email}}, {{passwort}}, {{portal_url}}, {{vorschau_url}}, {{leistungsbeginn}}; Blöckevorschau,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 increateCustomerWithPortalAccess),test/support/workersKaufUmgebung.js(Hilfenbezahle,kauf) - Test:
test/workersKaufWebhook.test.js
Interfaces:
-
Consumes:
createCustomerWithPortalAccess,getStripe,withLocks,getDocument,updateDocument,sendAdminNotice,createDocument,eventIdFor. -
Produces:
isWorkersCheckoutEvent(event): boolean– wahr fürcheckout.session.completedmitmode === 'subscription'undmetadata.workersOfferId; wahr fürcustomer.subscription.*mitmetadata.workersOfferIdund ohnemetadata.webklarCustomerId; sonst falsch.handleWorkersCheckoutEvent(event, { aboWeg }: { aboWeg: (event) => Promise<void> }): Promise<void>–offerId=metadata.workersOfferIdder Sitzung bzw. des Abos. Der gesamte Handler (Teil 1 und Teil 2) läuft unterwithLocks(['workers-kauf:' + offerId]), und das Angebot wird erst in der Sperre gelesen: Läse man es davor, hielte eine gleichzeitige zweite Zustellung den Stand ohnecustomerId, liefe in den E-Mail-Konflikt und meldete ihn fälschlich als T25. Fehlt das Angebot, ist das beicheckout.session.completedein T25 „Zahlung ohne Angebot“ (dann fertig), bei Lebenszyklus-Ereignissen nichts. Lebenszyklus-Ereignisse (customer.subscription.*): hat das Angebot noch keinecustomerId, werden sie ignoriert (200); sonst (veralteter Stand eines schon verknüpften Abos) gehen sie anaboWeg(event). Teil 1, beicheckout.session.completed: Kunde ausoffer.customerIdodersub.metadata.webklarCustomerIdoder neu angelegt (Statuscustomer,companyName/Anschrift aus den Abo-Metadaten,workersOffers.customerIdsofort gespeichert; Konflikt-409 → T25 „E-Mail-Adresse gehört schon einem Kunden“ + Ereignisfehler, Antwort ohne Fehler),customers.update/subscriptions.updatemitmetadata.webklarCustomerId, dannaboWeg(event), wenn das Abo-Dokument noch keinconfirmationSentAthat. Teil 2 folgt in P5-T12.neuesPasswort(): string(16 Zeichen,crypto.randomIntje Zeichen).- Im Harness:
bezahle(sessionId, { subId = 'sub_1WorkersTestAbo00000001' }) → { sessionId, subId, event(type = 'checkout.session.completed') }baut aus der voncreateWorkersCheckoutangelegten Sitzung (st.checkoutSessions) das Stripe-Abo (stripeSubscription(...)ausfixtures.js,metadata=subscription_data.metadata,webklarCustomerIdentfernt,customer= Sitzungskunde,trial_endaussubscription_data), setzt die Sitzung aufcomplete/paidmitsubscriptionundcustomer_detailsund liefert das signierbare Ereignis.kauf(over = {}) → { token, sessionId, subId, event }=seedAngebot()+POST /api/uebernahme/<token>/checkoutmitkaufBody(over)+bezahle(sessionId)(die Routen müssen perstartAppgemountet sein).
-
routes/abo.js:handleStripeEventbleibt Dispatcher (Feature-Zweig bleibt dort); der bisherigeswitchzieht inhandleAboEvent(event)um (Verhalten unverändert); nach dem Feature-Zweigif (isWorkersCheckoutEvent(event)) return handleWorkersCheckoutEvent(event, { aboWeg: handleAboEvent }), sonstreturn handleAboEvent(event). -
customerAdmin.js: scheitert increateCustomerWithPortalAccessdas 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 mitstartApp({ '/api/uebernahme': uebernahme, '/api/abo': abo }); jeder Test beginnt mitawait u.reset()undresetUebernahmeRoutesForTests();kaufaus 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
workersKaufAbschluss.jsnach den Interfaces (importiert nichts ausworkersKauf.js; der Re-Export geht nur in diese Richtung); Abo-Metadaten lesen übergetStripe().subscriptions.retrieve(session.subscription); Stripe-Kunden nicht abrufen (Name/Anschrift stehen in den Abo-MetadatencontactName,companyName,street,postalCode,city,country,email). Team-Hinweis als HilfemeldeKaufProblem({ vorfall, details, offer }):sendAdminNotice({ template: 'T25', values: { vorfall, details, zeit } })plusworkersEvents-Dokument{ ts, workerId: 'system', kind: 'fehler', prospectId, text: <Vorfall>, metaJson }(Fehler beim Hinweis werden geloggt, nie geworfen).zeit=TT.MM.JJJJ, HH:MMBerliner Zeit. Alle409auscreateCustomerWithPortalAccess(auch Appwrite-Benutzer vorhanden) laufen in den Konfliktzweig; alle anderen Fehler werden geworfen.routes/abo.js: Import, Dispatcher,handleAboEvent(reiner Umzug desswitch).customerAdmin.js: imcatchvoncreateCustomerWithPortalAccesszusätzlichdeleteDocument(config.collections.customers, customer.$id)(best effort, wie beim Benutzer).- Harness:
bezahleundkauf.
-
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 Parameterwortlaut, StandardWORTLAUT_STATUS, geht infassungenJson; auch inspeichereNachweisFuerRoutedurchreichen) - 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):- Schon erledigt?
offer.status === 'bezahlt'und gleichestripeSessionId→ Rückkehr ohne Wirkung; anderestripeSessionId→ 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). - Nachweis: nur wenn für
customerIdnoch keine ZeilefeatureConsentsmitfeatureKey: 'abo'existiert (AbfrageQuery.equal('customerId', id)mitQuery.limit(25), dann im Code nachfeatureKeyfiltern; nurcustomerIdhat 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. - Vorschau:
uebernehmeVorschau({ offer, preview, customerId });VorschauProblemoder ein Fehler mitstatus400 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 (ohnestatus, 5xx, 408, 429) wird geworfen (Stripe wiederholt). - K44: wenn
customers.portalPasswordnicht 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, danachforgetStoredPortalPassword(customerId); Mail-Fehler werden geworfen. - Abschluss:
workersOffers{ status: 'bezahlt', paidAt, stripeSessionId, customerId }(paidAt=new Date().toISOString(), also UTC mitZ, denn P1 filtert damit die Berliner Monatsgrenze fürdealsMonth,newAbosMonthundmrrNewEur, A12; ebensotsdes Ereignisses);workersProspects{ status: 'deal', customerId, updatedAt };workersEvents-Dokument mit IDeventIdFor('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 }) }.
- Schon erledigt?
-
Step 1: Failing tests (in
workersKaufWebhook.test.jsergänzen, Fehlerpfade inworkersKaufAbschluss.test.js; jeder Test beginnt mitawait u.reset();u.mitschneiden()schneidetconsole.*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,KONTAKTauspublic/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,acceptTermsals Boolean,termsVersionundlateStartAtkommen aus dem Angebot).pruefeFormular(werte: object): { ok: boolean, fehler: Record<string, string> }(Schlüssel = Feldnamen) mit denselben Regeln und Texten wiepruefeBestellung(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:
hauspublic/auftrag-dom.js;TEXTE,zustandAusAntwort,formularWerte,pruefeFormular,istDanke,tokenAusAdresse,KONTAKT(P5-T13); Server-AntwortenGET /api/uebernahme/:token→{ angebot }undPOST …/checkout→{ url }(P5-T09). -
Produces:
initUebernahmeseite({ root, ansage, suche = '', hash = '' }): Promise<void>(Selbststart wieauftrag.js, wenn#ue-wurzelexistiert). Vertrag der Oberfläche:root.dataset.zustand∈laedt|offen|ungueltig|limit|fehler|danke; Formular#ue-form; Feldername="kundenart"(Radiounternehmen/privat),name,email(vorbelegt mitangebot.email),companyName(vorbelegt mitangebot.firma, nur beiunternehmensichtbar/Pflicht),street,postalCode,city, Hakenname="acceptTerms", Radioname="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-Linka[data-vorschau](target="_blank",rel="noopener noreferrer"). -
Step 1: Failing test
test/workersUebernahmeSeite.test.js(installFakeDom({ antworten })austest/support/fakeDom.js;TOKEN = 'T'.repeat(43);ANGEBOThat die Form vonuebernahmeAnsichtmitfirma: '<img src=x onerror=alert(1)> GmbH',vorschauUrlaufhttps://entwurf.project.webklar.com/<43 Zeichen>/undtexte: texteFuerClient(...)ausserver/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 inids); CLInode scripts/workers-kauf-testangebot.mjs --email <adresse> --firma <name> --vorschau <43-Zeichen-Token>(legt Interessent, Vorschau, Angebot an, druckthttps://project.webklar.com/uebernahme.html#t=<token>und die drei Dokument-IDs) und--loeschen <angebotsId>(löscht die drei Dokumente, aber nur wenndarfLoeschen(offer, prospect)– ebenfalls exportiert – wahr ist: Angebots-ID beginnt mitt-und der Interessent trägtsource: '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 inworkersProspects,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)
Reihenfolge einhalten: Block A bis J. [Claude] macht Claude für dich, alles andere tippst du selbst auf dem Server (jeder Befehl in seinem Kasten; $T, $T2 und $T3 aus E3, E12 und E10a gelten nur in derselben Shell). Zugangsdaten tippst du nur selbst ein (read -rs oder direkt in .env), nie in einen Chat und nie in einen Befehl, der sie ausgibt; die .env zeigst du nie an, die Befehle unten zählen nur Zeilen. Nach jeder Änderung an .env oder docker-compose.yml startest du den betroffenen Dienst mit cd /home/webklar/apps && docker compose up -d --no-deps <dienst> neu (nie docker restart: das liest .env nicht neu ein; nie ein nacktes docker compose up -d; nie preview-portal neu anlegen). Wird ein git push abgelehnt: nie --force, Claude Bescheid. Agenten führen von diesen Befehlen nichts aus. Der Start des Portal-Containers installiert Pakete und dauert 1 bis 2 Minuten (das gilt auch nach jedem Deploy): in der Zeit ist das Portal nicht erreichbar, lege Neustarts also in ruhige Zeiten.
Was P5 tut: Der Hustler schickt einem Betrieb einen Link auf https://project.webklar.com/uebernahme.html#t=<Token>. Dort bestellt der Betrieb ohne Anmeldung das Hosting-Abo (Kundenart, Anschrift, Startwahl) und zahlt bei Stripe. Im Webhook entstehen dann sein Kundenkonto, der Zustimmungsnachweis und sein Projekt aus der Vorschau (Repo mit dem einen Commit „Erste Version der Website“); er bekommt K01 (Vertragsbestätigung) und K44 (Zugangsdaten), und im Hustler wird der Interessent zum Deal. Achtung: Der Kaufweg kennt keinen Pilotkreis (ABO_PILOT_EMAILS gilt hier nicht): Sobald WORKERS_KAUF_ENABLED=on steht und der Hustler einen Link verschickt hat, kauft jeder mit gültigem Link. Darum diese Reihenfolge: Schalter aus deployen (C), Stripe vorbereiten (D), Testbestellung (E), Rechtsfreigabe (F), erst dann einschalten (G). Die Testbestellungen laufen gegen Stripe live: Lauf 2 bucht 0,50 € ab, Lauf 1 erst nach der Widerrufsfrist, und bis dahin kündigst du ihn (E13).
A. Voraussetzungen prüfen
A1. P1, P3 und P4 laufen.
curl -s https://ticket.webklar.com/api/workers/health
Erwartet: {"ok":true,"version":"0.1.0"}. Wenn nicht: erst die Betriebsanleitungen von P1, P3 und P4 abarbeiten (bis zu ihrem „Fertig, wenn“).
A2. Der Dienst hat den Stand mit dem Angebotssatz je Angebot (Dienst auf 558ce24 oder neuer; im Container, nicht nur im Checkout).
docker exec webklar-workers grep -c offerText /app/src/gespraeche/theo.js
Erwartet: eine Zahl über 0 (heute 13). Wenn nicht: P4 B7 und B8 (Push, Pull, Neubau) mit dem aktuellen Stand von p4-gespraeche nachholen; Claude nennt dir die endgültige Nummer. Dazu muss der Kauf-Link noch aus sein:
docker exec webklar-workers printenv WORKERS_KAUFLINK
Erwartet: nein. Wenn nicht: ja heißt, ein Kauf-Link zeigt auf eine Seite, die es noch nicht gibt: sofort Block I1.
A3. Schema stimmt (inklusive workersOffers.offerText aus dem P4-Schritt; hast du P4 B6 eben erst mit dem Stand 558ce24 oder neuer von workers-dev gemacht und dort Alles vorhanden. gesehen, überspringst du A3; lief P4 B6 mit einem älteren Stand, fehlt offerText womöglich, und Alles vorhanden. von damals beweist nichts). Der Laufzeit-Schlüssel des Dienstes darf das nicht: Lege in der Appwrite-Konsole (Projekt 6a1058610003c5a13a05 → Integrationen → API-Schlüssel) einen Schlüssel workers-setup an (Ablauf 1 Tag, Bereiche databases.read, collections.read/write, attributes.read/write, indexes.read/write, nie databases.write, nicht in .env).
cd /home/webklar/apps/workers-dev && ( read -rs -p 'Appwrite-Key workers-setup: ' APPWRITE_API_KEY; echo; export APPWRITE_API_KEY; timeout 300 node --max-old-space-size=1024 scripts/setup-collections.mjs --check; echo "Exit $?" )
Erwartet: 13 Zeilen = workers…: N Attribute, M Indizes vorhanden (darunter = workersOffers: 14 Attribute, 2 Indizes vorhanden), dann Alles vorhanden. und Exit 0. Danach den Schlüssel in der Appwrite-Konsole löschen. Wenn nicht: steht dort - workersOffers.offerText FEHLT, hol P4 B6 nach (nicht hier); 401 oder „missing scope“ = falscher Schlüssel; NICHT BEREIT = ein paar Minuten warten und wiederholen.
A4. Das Abo ist freigegeben (der Kaufweg verkauft das Hosting-Abo in der Fassung 2026-09-30-v5; seine Bedingungen müssen online stehen). In WOMS → Rechtliches sind die Dokumente dieser Fassung hochgeladen (K01 hängt sie als PDF an). Dann:
curl -s -o /dev/null -w '%{http_code}\n' https://project.webklar.com/abo-bedingungen.html
Erwartet: 200. Wenn nicht: nicht weitermachen; die Abo-Freigabe (Rechtliches hochladen) zuerst.
A5. Compose-Grundlagen. Der Dienst kundenbereich-server lässt Kunden mit Status customer an (sonst klappt die Anmeldung mit den K44-Daten nicht), und das Volume workers_data aus P1 steht in der Datei.
cd /home/webklar/apps && grep -c '^ - ALLOWED_CUSTOMER_STATUSES=.*customer' docker-compose.yml
Erwartet: 1. Dann:
cd /home/webklar/apps && docker compose config --volumes | grep -x workers_data
Erwartet: workers_data. Wenn nicht: beim ersten Befehl 0 = customer in der Zeile ALLOWED_CUSTOMER_STATUSES ergänzen (heute active,customer,lead), Claude Bescheid; beim zweiten keine Ausgabe = P1 (Schritt 10) steht nicht in der Datei: abbrechen.
A6. Traefik protokolliert nicht, und „entwurf“ ist nicht belegt. Der Token steht im Pfad /api/uebernahme/<Token>; ein Zugriffsprotokoll würde ihn speichern.
docker inspect webklar-traefik --format '{{json .Args}}' | grep -ci accesslog
Erwartet: 0. Die Vorschau-Adresse entwurf.project.webklar.com darf mit keinem Projekt oder Repo kollidieren (die Sperre in server/services/reserviert.js verhindert nur neue Zuordnungen):
docker exec -w /app webklar-kundenbereich-server node --max-old-space-size=256 --input-type=module -e "import {listDocuments,Query} from './server/services/appwriteAdmin.js'; import {config} from './server/config.js'; const d=await listDocuments(config.collections.websiteProjects,[Query.equal('subdomain','entwurf'),Query.limit(5)]); console.log('Projekte mit Subdomain entwurf:', d.length)"
Erwartet: Projekte mit Subdomain entwurf: 0. Dann:
ls /var/www/previews | grep -ix entwurf; ls /home/webklar/apps/traefik/dynamic | grep -i entwurf
Erwartet: die erste Abfrage ohne Treffer, die zweite höchstens workers-entwurf.yml (aus P4 C1). Dazu in Gitea unter https://git.webklar.com/WEBklar die Suche „entwurf“: kein Repo. Wenn nicht: bei accesslog über 0 den Zugriffslog aus Traefik nehmen (Claude), bei einem Treffer „entwurf“ das Projekt oder Repo umbenennen lassen (Claude), vorher nicht weitermachen.
A7. Gitea kann mehrere Dateien in einem Commit (der Commit „Erste Version der Website“ ersetzt die Vorlagendateien auf einmal).
curl -s https://git.webklar.com/swagger.v1.json | grep -c ChangeFilesOptions
Erwartet: mindestens 1. Wenn nicht: Claude Bescheid, Gitea ist zu alt.
A8. Andere Portal-Pushes abstimmen. Kein Befehl. Warten noch andere Portal-Zweige auf den Push (etwa feature/workers-datenschutz, auftraege/l7-rechtstexte, feature/uk-final-ui), klärt ihr vor C, wer zuerst pusht; P5 wird in C1 ohnehin mit dem dann aktuellen origin/main zusammengeführt (Claude sieht dort, was auf origin/main neu ist). Wenn nicht: sag Claude, was schon gelandet ist.
B. Sicherung und Konfiguration (Schalter bleibt aus)
B1. Sicherungen (mit Suffix -workers-p5, damit die von P1 und P4 bleiben; ein zweiter Anlauf am selben Tag nimmt -workers-p5-2 (-workers-p5b und -workers-p5c gehören G3 und I1)).
cd /home/webklar/apps && cp -p .env .env.bak-$(date +%F)-workers-p5 && cp -p docker-compose.yml docker-compose.yml.bak-$(date +%F)-workers-p5 && ls -l .env.bak-$(date +%F)-workers-p5 docker-compose.yml.bak-$(date +%F)-workers-p5
Erwartet: zwei Dateien von heute, beide größer als 0. Wenn nicht: nichts ändern, bis beide da sind.
B2. .env: Schalter WORKERS_KAUF_ENABLED=off (nur der Wert on schaltet ein; ein zweiter Lauf legt nichts doppelt an). WORKERS_VORSCHAU_ROOT und WORKERS_VORSCHAU_BASE bekommen keine Zeile: die Vorgaben des Portals (/workers-vorschau/vorschau und https://entwurf.project.webklar.com) passen zum Mount aus B3 und zum Dienst.
cd /home/webklar/apps && [ "$(grep -c '^WORKERS_KAUF_ENABLED=' .env)" = 0 ] && printf '\nWORKERS_KAUF_ENABLED=off\n' >> .env; grep -c '^WORKERS_KAUF_ENABLED=off$' .env
Erwartet: 1. Wenn nicht: 0 = die Zeile gab es schon mit anderem Wert: sed -i 's/^WORKERS_KAUF_ENABLED=.*/WORKERS_KAUF_ENABLED=off/' .env und die Zählung wiederholen.
B3. Compose: drei neue Zeilen im Dienst kundenbereich-server (ein Kommentar, die Umgebungsvariable und der Mount workers_data nur lesend). Der Befehl tut nichts, wenn WORKERS_KAUF_ENABLED schon in der Datei steht oder ein Anker nicht genau einmal vorkommt.
cd /home/webklar/apps && [ "$(grep -c WORKERS_KAUF_ENABLED docker-compose.yml)" = 0 ] && [ "$(grep -c '^ - ANALYTICS_URL=http://webklar-analytics:8091$' docker-compose.yml)" = 1 ] && [ "$(grep -c '^ - ./traefik/scripts/sync-preview-tls.sh:/sync-preview-tls.sh:ro$' docker-compose.yml)" = 1 ] && sed -i '/^ kundenbereich-server:$/,/^ [a-z0-9_-]*:$/{
\|^ - ANALYTICS_URL=http://webklar-analytics:8091$|a\
# 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}
\|^ - ./traefik/scripts/sync-preview-tls.sh:/sync-preview-tls.sh:ro$|a\
- workers_data:/workers-vorschau:ro
}' docker-compose.yml
Prüfen:
cd /home/webklar/apps && docker compose config -q && diff docker-compose.yml.bak-$(date +%F)-workers-p5 docker-compose.yml | grep -c '^>'
Erwartet: keine Fehlermeldung von config, dann 3. Wenn nicht: keine Änderung und Exit 1 beim ersten Befehl = schon eingefügt oder ein Anker fehlt (die Datei wurde umgebaut): nichts erzwingen, Claude Bescheid; eine andere Zahl oder ein Fehler von config = cp -p docker-compose.yml.bak-<Datum>-workers-p5 docker-compose.yml und Claude Bescheid.
B4. Portal-Container neu anlegen (nur dieser Dienst; 1 bis 2 Minuten bis zur Antwort).
cd /home/webklar/apps && docker compose up -d --no-deps kundenbereich-server
Erwartet: Recreated/Started für kundenbereich-server. Wenn nicht: Fehlermeldung an Claude, die Sicherungen aus B1 liegen bereit.
B5. Umgebung prüfen (nach 1 bis 2 Minuten; zeigt nur unkritische Werte).
docker exec webklar-kundenbereich-server sh -c 'printenv WORKERS_KAUF_ENABLED ALLOWED_CUSTOMER_STATUSES; ls /workers-vorschau; node -v'
Erwartet: off, eine Liste mit customer, ein Eintrag vorschau (und imap-state.json), v20.…. Dann:
curl -s -o /dev/null -w '%{http_code}\n' https://project.webklar.com/api/health
Erwartet: 200. Wenn nicht: ls: /workers-vorschau: No such file = der Mount fehlt (B3); WORKERS_KAUF_ENABLED leer = B2 oder die Compose-Zeile fehlt; kein 200 nach 3 Minuten = docker logs --tail 30 webklar-kundenbereich-server an Claude.
C. Code live (Schalter weiter aus)
C1. [Claude] Zweig mit origin/main zusammenführen und prüfen. Sag Claude: „Kaufweg-Zweig mit origin/main zusammenführen und prüfen.“ Claude arbeitet im Ordner /home/webklar/apps/kundenbereich.webklar.com/dev-workers-kauf (Zweig feature/workers-kaufweg): git fetch origin, git log --oneline HEAD..origin/main, dann git merge origin/main (nie --force; Konflikte sind in server/config.js, server/index.js, server/routes/abo.js, test/support/schema.js und in der Liste von test/mailTemplates.test.js zu erwarten, falls neue Mail-Nummern dazukamen). Er prüft, dass K44 Website übernommen.html und T25 Workers Hinweis.html auf keinem anderen Zweig belegt sind und dass git diff dd062b1 origin/main --stat -- public/app.js public/abo-bedingungen.html server/services/products.js server/services/bestelltexte.js server/config.js leer ist (sonst hat sich der Wortlaut der Abo-Seite geändert und die Fixture test/support/wortlautAboV5.json muss neu geprüft werden). Er trägt außerdem im Ordner /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht/docs/superpowers/plans ein: in 2026-10-16-kundenansicht-00-uebersicht.md (Zeile zu P6 „K44 und T25 frei (N29)“, den Absatz zu den Mailkennungen mit „K44 und T25 bleiben frei“ und die Stelle „K40, K41 und T25 bleiben frei für P6“), dass K44 und T25 durch Workers P5 belegt sind; in 2026-10-16-kundenansicht-11-umschalten-aufraeumen.md, Teil C „Fassung 6 festschreiben“, eine Kaufweg-Zeile: erst sperren (WORKERS_KAUF_ENABLED=off, WORKERS_KAUFLINK=nein), dann server/services/workersKaufTexte.js und test/support/wortlautAboV5.json auf Fassung 6 umstellen. Dann der Gesamtlauf bei free -m „available“ über 2500: timeout 1800 node --max-old-space-size=1024 --test --test-concurrency=2 test/*.test.js. Erwartet: Merge ohne offenen Konflikt, # fail 0, höchstens der eine bekannte Skip („PDFs der Fassung 6“; Stand vor dem Merge: 7696 Tests, 7695 grün). Fällt previewDevRouter.test.js allein, wird sie einzeln wiederholt (bekannter Flake). Wenn nicht: du pushst nichts, bevor alles grün ist.
C2. Pushen (Deploy-Hook baut und startet das Portal neu; wieder 1 bis 2 Minuten ohne Portal).
git -C /home/webklar/apps/kundenbereich.webklar.com/dev-workers-kauf push origin feature/workers-kaufweg:main
Erwartet: Push angenommen. Wenn nicht: „non-fast-forward“ = main hat neue Commits: nie erzwingen, C1 wiederholen.
C3. Die Route gibt es und ist neutral (Schalter aus; geprüft wird Body und Kopf, denn auch eine alte Fassung antwortet 404, nur mit der HTML-Seite „Cannot GET“).
curl -si "https://project.webklar.com/api/uebernahme/$(printf 'A%.0s' $(seq 43))"
Erwartet: HTTP/2 404, Kopfzeilen cache-control: no-store, x-robots-tag: noindex, x-content-type-options: nosniff, referrer-policy: no-referrer, Body {"error":"Dieser Link ist nicht mehr gültig."}. Dann dasselbe als Bestellung:
curl -s -X POST -H 'Content-Type: application/json' -d '{}' "https://project.webklar.com/api/uebernahme/$(printf 'A%.0s' $(seq 43))/checkout"
Erwartet: derselbe Body. Wenn nicht: HTML „Cannot GET“ = der Deploy ist noch nicht durch oder fehlgeschlagen (3 Minuten warten, dann docker logs --tail 30 webklar-kundenbereich-server); Serverpuls muss grün bleiben.
C4. Seite und Dateien sind da, ohne interne Namen.
for f in uebernahme.html uebernahme.css uebernahme.js uebernahme-ansicht.js auftrag.css auftrag-ansicht.js auftrag-dom.js; do curl -s -o /dev/null -w "$f %{http_code} %{content_type}\n" https://project.webklar.com/$f; done
Erwartet: sieben Mal 200, die .js mit einem JavaScript-Typ (Modulskripte brauchen ihn). Dann:
curl -s https://project.webklar.com/uebernahme.html https://project.webklar.com/uebernahme.js https://project.webklar.com/uebernahme-ansicht.js | grep -ciE 'hustler|workers'
Erwartet: 2 (beide Treffer sind Dateinamen in einem Code-Kommentar von uebernahme-ansicht.js, kein Text, den ein Käufer sieht). Wenn nicht: ein Treffer mehr, der sichtbarer Text ist = Claude Bescheid; der Käufer darf „Hustler“ und „Workers“ nie lesen.
C5. Node 20 lehnt Symlinks beim Lesen ab (die Vorschau-Dateien werden mit O_NOFOLLOW gelesen; getestet war das bisher nur auf Node 24).
docker exec -w /app webklar-kundenbereich-server timeout 120 node --max-old-space-size=256 --test test/workersVorschau.test.js
Erwartet: # fail 0 (darunter der Test mit dem Code ELOOP). Wenn nicht: Ausgabe an Claude, der Kaufweg bleibt aus.
C6. Die Begrenzung hängt an der echten Adresse (einmal; verbraucht die 10 Bestellversuche pro Stunde deiner Adresse, also aus einem anderen Netz als die Testbestellung oder eine Stunde Abstand; jeder Versuch nimmt einen anderen Token, damit nur die Adressbegrenzung wirkt).
for i in $(seq 1 12); do curl -s -o /dev/null -w '%{http_code} ' -X POST -H "X-Forwarded-For: 198.51.100.$i" -H 'Content-Type: application/json' -d '{}' "https://project.webklar.com/api/uebernahme/$(head -c 48 /dev/urandom | base64 -w0 | tr -d '+/=' | head -c 43)/checkout"; done; echo
Erwartet: neun Mal 404, dann 429 429 429 (der POST aus C3 zählt schon zu den 10 Versuchen deiner Adresse; liegt C3 mehr als eine Stunde zurück oder kam aus einer anderen Adresse, sind es zehn Mal 404, dann 429 429). Wenn nicht: zwölf Mal 404 heißt, die Anwendung sieht eine fälschbare Adresse und die Begrenzung bremst nichts: Schalter bleibt aus, Claude Bescheid.
C7. Felder in der echten Datenbank (die Tests laufen gegen einen Nachbau, der customers, customerPortalAccess und websiteProjects nicht prüft; fehlt ein Feld, scheitert der erste echte Kauf erst nach der Zahlung).
docker exec -w /app webklar-kundenbereich-server node --max-old-space-size=256 scripts/kundenansicht-setup.mjs --pruefen
Erwartet: Alles vorhanden., Exit 0 (featureConsents.kundenart besteht). Sieh außerdem in der Appwrite-Konsole nach: customers hat street, postalCode, city, country, companyName, kundenart, customerStatus und portalPassword; featureConsents hat stripeSessionId mit dem Index idx_session. Wenn nicht: FEHLT-Zeilen oder ein fehlendes Feld: nichts selbst anlegen, Claude Bescheid; bei 401 darf der Schlüssel des Portals das Schema nicht lesen, dann nur in der Konsole prüfen.
D. Stripe vorbereiten
D1. Ereignis checkout.session.expired freischalten (Stripe-Dashboard → Entwickler → Webhooks → Endpunkt https://project.webklar.com/api/abo/webhook → Ereignisse bearbeiten; die bisherigen bleiben). Kein Befehl. Erwartet: in der Liste stehen jetzt auch checkout.session.expired neben checkout.session.completed, customer.subscription.*, invoice.paid und invoice.payment_failed. Wenn nicht: ohne dieses Ereignis bleibt eine abgebrochene Bestellung im Status checkout (der Link bleibt bis expiresAt benutzbar), bis das nächtliche Aufräumen des Dienstes sie auf abgelaufen setzt; das geschieht frühestens expiresAt plus 3 Tage und nur, wenn die Vorschau selbst fällig ist. Der Stripe-Kunde mit Name und Anschrift des Käufers bleibt bis zum Tagesjob (D3) stehen, der ihn nach 30 Tagen löscht.
D2. Schlüsseltyp des Portals (zeigt nur die ersten 8 Zeichen, den Typ, nie den Schlüssel).
docker exec webklar-kundenbereich-server sh -c 'printenv STRIPE_SECRET_KEY | cut -c1-8'
Erwartet: sk_live_ (voller Zugriff, nichts weiter zu tun). Wenn nicht: rk_live_ = eingeschränkter Schlüssel: Stripe-Dashboard → Entwickler → API-Schlüssel → Schlüssel bearbeiten, dort Kunden auf „Schreiben“ (das Löschen gehört dazu) und Abos auf „Lesen“; der Tagesjob, der abgebrochene Bestellungen aufräumt, braucht beides, sonst zählt er nur Fehler. Eine Zeile mit _test_ = das Portal läuft gegen den Stripe-Testmodus: Claude Bescheid, dann stimmen die Preise und Schritte in E nicht.
D3. Aufräum-Skript einmal im Trockenlauf. Das Portal löscht täglich (230 Sekunden nach jedem Start, danach alle 24 Stunden, unabhängig vom Schalter) die Stripe-Kunden abgebrochener Kaufweg-Bestellungen, die älter als 30 Tage sind, nie ein Abo hatten und zu keinem bezahlten Angebot gehören (höchstens 50 je Lauf). Das Skript zeigt, was gelöscht würde, und ändert ohne --ausfuehren nichts.
docker exec -w /app webklar-kundenbereich-server timeout 120 node --max-old-space-size=256 scripts/workers-kauf-stripe-aufraeumen.mjs
Erwartet: Trockenlauf: es wird nichts gelöscht., dann gelesen=N kandidaten=0 gelöscht=0 würde-löschen=0 behalten=0 schon-weg=0 fehler=0, Exit 0 (vor der ersten Bestellung gibt es keine Kandidaten). Wenn nicht: Stripe ist nicht eingerichtet = STRIPE_SECRET_KEY fehlt; fehler= über 0 oder Fehler: … status=403 = der Schlüssel darf nicht lesen oder löschen (D2).
E. Testbestellung (zweimal; der Schalter ist dafür kurz an)
Lauf 1: Kundenart Unternehmen, Start „nach Ablauf der Widerrufsfrist“ (heute wird nichts abgebucht), mit der „Zurück“-Probe. Lauf 2: Kundenart privat, Start „sofort“ (0,50 € werden wirklich abgebucht). Die beiden Testadressen sind Aliase deiner eigenen Adresse; sie dürfen keinem Kunden und keinem Mitarbeiter in WOMS gehören (sonst antwortet die Seite mit 409).
E1. Testadressen für den Testpreis eintragen (nur Adressen aus ABO_TEST_EMAILS zahlen 0,50 € statt 20 €; der Befehl ist wiederholbar und hängt nur an, was fehlt).
cd /home/webklar/apps && for A in kenso.gri+kauftest1@gmail.com kenso.gri+kauftest2@gmail.com; do if grep -q "^ABO_TEST_EMAILS=.*$A" .env; then :; elif [ "$(grep -c '^ABO_TEST_EMAILS=' .env)" = 0 ]; then printf '\nABO_TEST_EMAILS=%s\n' "$A" >> .env; else sed -i "s/^ABO_TEST_EMAILS=\(.*\)\$/ABO_TEST_EMAILS=\1,$A/" .env; fi; done; grep -c '^ABO_TEST_EMAILS=.*kauftest1.*kauftest2' .env
Erwartet: 1. Wenn nicht: steht die Zeile in Anführungszeichen, ergänzt du die beiden Adressen im Editor (kleingeschrieben, durch Komma getrennt).
E2. Schalter an (nur für den Test) und Neustart.
cd /home/webklar/apps && sed -i 's/^WORKERS_KAUF_ENABLED=.*/WORKERS_KAUF_ENABLED=on/' .env && grep -c '^WORKERS_KAUF_ENABLED=on$' .env
Erwartet: 1. Dann:
cd /home/webklar/apps && docker compose up -d --no-deps kundenbereich-server
Nach 1 bis 2 Minuten docker exec webklar-kundenbereich-server printenv WORKERS_KAUF_ENABLED (Erwartet: on). Der Kauf-Link des Dienstes bleibt nein: es gibt keinen echten Link, nur die Testangebote aus E5 und E12.
E3. Vorschau-Ordner im Dienst anlegen. Der Token ist 43 Zeichen lang und beginnt nie mit - oder _ (das Skript lehnt solche ab); der Ordner liegt im Dienst unter /data/vorschau/<Token> und im Portal unter /workers-vorschau/vorschau/<Token>. Schreib den Token auf (Lauf 2 nimmt T2 genauso).
T=$(head -c 48 /dev/urandom | base64 -w0 | tr -d '+/=' | head -c 43); echo "$T"; printf %s "$T" | wc -c
Erwartet: ein Token und 43. Dann:
docker exec webklar-workers sh -c "mkdir -p /data/vorschau/$T && echo '<h1>Test</h1>' > /data/vorschau/$T/index.html"
Dann prüfst du, dass das Portal die Datei lesen kann:
docker exec webklar-kundenbereich-server ls -l /workers-vorschau/vorschau/$T/
Erwartet: -rw-r--r-- … index.html. Wenn nicht: No such file = der Ordner liegt nicht im Volume (E3 wiederholen) oder der Mount fehlt (B3); Permission denied = Claude Bescheid.
E4. Trockenlauf ohne Geld: Testangebot anlegen und wieder löschen. Das Skript legt dieselben drei Dokumente an wie der Dienst (Interessent, Vorschau, Angebot) und nimmt den Angebotssatz aus den Einstellungen des Hustlers (WOMS → Hustler → Hochhaus → Einstellungen); es legt nichts an, wenn die Adresse nicht in ABO_TEST_EMAILS steht.
docker exec -w /app webklar-kundenbereich-server timeout 60 node --max-old-space-size=256 scripts/workers-kauf-testangebot.mjs --email kenso.gri+kauftest1@gmail.com --firma "Testbetrieb GmbH" --vorschau "$T"
Erwartet: Testangebot angelegt., Kauf-Link: https://project.webklar.com/uebernahme.html#t=<43 Zeichen>, Angebot: t-<8 Zeichen>, Angebotssatz (Einstellungen): … (oder (Voreinstellung)), Exit 0. Öffne den Link in einem privaten Fenster: die Seite lädt mit „Testbetrieb GmbH“. Dann löschst du das Angebot (t-XXXXXXXX durch die Kennung aus der Zeile Angebot: ersetzen):
docker exec -w /app webklar-kundenbereich-server timeout 60 node --max-old-space-size=256 scripts/workers-kauf-testangebot.mjs --loeschen t-XXXXXXXX
Erwartet: Zeilen gelöscht: workersOffers t-…, gelöscht: workersPreviews …, gelöscht: workersProspects t-…, Exit 0; der Link zeigt jetzt „Dieser Link ist nicht mehr gültig.“. Das prüft echte Appwrite-Kennungen und Löschrechte ohne Stripe. Wenn nicht: steht nicht in ABO_TEST_EMAILS … Nichts angelegt. = E1 und E2 (inklusive Neustart); Zu diesem Vorschau-Token gibt es schon ein Vorschau-Dokument = frischen Token nehmen; Fehler: … (Status 400) mit „Unknown attribute“ = A3; meldet --loeschen bei einem frisch angelegten Angebot „kein Testangebot dieses Skripts“, hat das Skript einen Fehler: Claude Bescheid.
E5. Echtes Testangebot für Lauf 1. Derselbe Befehl wie in E4 (Ordner und Token bleiben gültig). Schreib die Zeile Angebot: auf. Erwartet: wie in E4.
E6. Bestellen (Lauf 1). Öffne den Link in einem privaten Fenster (eine geöffnete Kundenansicht des WOMS im selben Browser sperrt das Bestellen mit 403), am Handy und am Desktop.
- Sieh dir die Seite an: Betrieb „Testbetrieb GmbH“, Link zur Vorschau, Angebotssatz, Leistungsbeschreibung, Tabelle „Ihre Bestellung“ (die Zeilen „Fällig“ und „Leistungsbeginn“ wechseln mit der Startwahl), der Knopf „Zahlungspflichtig bestellen“ direkt unter Tabelle und Mängelhaftungs-Hinweis. Bei 360 Pixel Breite keine waagerechte Leiste, im Dunkelmodus lesbar; die Links der Zustimmung öffnen
/abo-bedingungen.html,/abo-bedingungen.html#widerrufund/datenschutz.htmlin neuem Tab. - Lass ein Pflichtfeld leer und klicke bestellen: das erste fehlerhafte Feld wird markiert und angesprungen.
- Konto-Probe: Fülle das Formular sonst vollständig aus (Haken und Startwahl gesetzt) und trage einmal die E-Mail-Adresse eines Teammitglieds (aus WOMS) und einmal die eines bestehenden Kunden in anderer Groß-/Kleinschreibung ein. Erwartet: jeweils der rote Satz „Zu dieser E-Mail-Adresse gibt es schon ein Kundenkonto …“ über dem Knopf, das Formular bleibt gefüllt, der Knopf ist frei, und in Stripe entsteht kein neuer Kunde. Wenn nicht: der Satz „Das hat gerade nicht geklappt“ =
docker logs --since 10m webklar-kundenbereich-server 2>&1 | grep '\[uebernahme\]'an Claude (Appwrite 1.8.1 verlangt dann womöglich einen Index aufemployees.email). - Tastatur: Gehe nur mit Tab, Pfeiltasten und Leertaste durch das Formular. Erwartet: die Reihenfolge Kundenart, Name, E-Mail, Betrieb (nur bei Unternehmen), Straße, PLZ, Ort, Haken (danach die Links der Zustimmung), Startwahl, Knopf; bei einem Fehler sagt ein Screenreader „Bitte prüfen Sie die markierten Angaben.“ an (einmal mit VoiceOver oder TalkBack probieren). Wenn nicht: eine andere Reihenfolge oder ein Feld, das sich nicht erreichen lässt = Claude Bescheid.
- Dann Kundenart Unternehmen, Name, Adresse
kenso.gri+kauftest1@gmail.com, Anschrift, Betrieb „Testbetrieb GmbH“ lassen, Haken, Start nach Ablauf der Widerrufsfrist, bestellen. Erwartet: Weiterleitung zur Stripe-Kasse mit dem Hinweis über dem Knopf („Ihre Bestellung haben Sie auf der Seite „Website übernehmen“ abgegeben …“) und dem Testpreis 0,50 € statt 20 € im Monat. - Zurück-Probe: klicke bei Stripe im Browser zurück. Erwartet: die Seite zeigt das Formular wieder (mit deinen Eingaben, wenn der Browser die Seite zwischengespeichert hat, sonst leer), der Bestellknopf ist wieder frei, der Link ist derselbe.
- Bestelle noch einmal und bezahle mit einer echten Karte (heute wird nichts abgebucht). Erwartet: Rücksprung auf die Seite mit „Vielen Dank“ (Adresse
…/uebernahme.html?danke=1), die zwei E-Mails ankündigt.
Wenn nicht: „Die Online-Bestellung ist noch nicht freigeschaltet.“ = Stripe im Portal nicht eingerichtet (D2); ein roter Satz am Feld oder über dem Knopf nennt den Grund; 409 = die Adresse gehört einem Kunden oder Mitarbeiter, nimm einen anderen Alias; alles andere an Claude.
E7. Postfächer und Anmeldung. Kein Befehl. Erwartet im Postfach der Testadresse: zuerst K01 (Vertragsbestätigung mit den PDF-Anhängen, Fassung 2026-09-30-v5), dann K44 („Website übernommen“: Absender WEBklar, Passwort mit 16 Zeichen, Projektadresse, der Block zum Leistungsbeginn passt zur Wahl „nach Ablauf der Widerrufsfrist“ mit dem Datum, nirgends „Hustler“ oder „Workers“); sieh beide in Gmail hell und dunkel am Handy an. Im Team-Postfach: T02 („Neues Abo“), keine Kopie von K44 (kein Passwort im Team-Postfach) und keine Mail mit „[Hustler]“ im Betreff. Melde dich unter https://project.webklar.com/login.html mit den K44-Daten an: Erwartet: das Projekt „Testbetrieb GmbH“ erscheint, unter „Änderungen“ steht nur „Erste Version der Website“ (nirgends „Hustler“); nach ein paar Minuten zeigt https://testbetrieb-gmbh.project.webklar.com nach der Anmeldung die Seite „Test“. Wenn nicht: keine K44 nach 5 Minuten oder eine Mail „[Hustler] …“ = H3 (die Mail nennt den Grund); Anmeldung scheitert trotz richtigem Passwort = customer fehlt in ALLOWED_CUSTOMER_STATUSES (A5).
E8. Daten in Appwrite ansehen (Appwrite-Konsole → Datenbank woms-database; das ist die einzige Prüfung gegen die echten Sammlungen, die Tests laufen gegen einen Nachbau). Erwartet: customers: ein Dokument mit customerStatus = customer, street, postalCode, city, country = Deutschland, companyName = Testbetrieb GmbH, kundenart = unternehmen und leerem portalPassword; dazu ein Dokument in customerPortalAccess und eines in websiteProjects (Name „Testbetrieb GmbH“, Repo WEBklar/testbetrieb-gmbh, customerId des Kunden). featureConsents: genau ein Dokument mit stripeSessionId = der bezahlten Sitzung (cs_…), kundenart, sechs Einträgen in texte (der erste ist der Angebotssatz), fassungen mit zustimmung = 2026-09-30-v5, quelle = workers-uebernahme, und einer IP, die deine öffentliche Adresse ist (keine 172.x). workersOffers: status = bezahlt, paidAt mit Z am Ende, customerId, stripeSessionId. workersProspects: status = deal. workersEvents (die neuesten Einträge): ein Ereignis mit Id deal-… („Deal: Testbetrieb GmbH hat die Website übernommen (Hosting-Abo)“), keines mit fehler-…. Wenn nicht: ein fehlendes oder falsch benanntes Feld (etwa featureConsents.kundenart, customers.street) macht sich als Hinweis „[Hustler] …“ bemerkbar (H3): C7 wiederholen und Claude Bescheid sagen.
E9. Stripe und Zustellung ansehen (Dashboard). Erwartet: genau ein Kunde mit der Testadresse; Kunde und Abo tragen webklarCustomerId (die Kunden-Id aus E8), der Kunde auch workersOfferId; die erste Sitzung der Zurück-Probe steht auf „abgelaufen“. Die Checkout-Sitzung trägt in den Metadaten nur workersOfferId und productKey; success_url enthält keinen Token, cancel_url den Token nur nach # (Entwickler → Protokolle, Aufruf POST /v1/checkout/sessions). Unter Entwickler → Webhooks → Endpunkt: alle Zustellungen (checkout.session.completed, checkout.session.expired der ersten Sitzung, customer.subscription.created, invoice.paid) mit Antwort 200, keine 500, und checkout.session.completed mit einem Zustellversuch. Notiere den Abstand zwischen der Zahlung und der Uhrzeit der Mail K44: das ist ungefähr die Dauer des Handlers (Repo anlegen, Dateien, zwei Mails laufen darin). Dauert er länger, als Stripe wartet, steht in der Liste ein zweiter Zustellversuch; das ist abgefangen (eine Sperre je Angebot: kein zweites Konto, keine zweite Mail), aber der Hinweis „Doppelte Zahlung für ein Angebot“ darf dann nicht kommen. Wenn nicht: eine 500 an einer dieser Zustellungen oder mehrere Kunden = Claude Bescheid, bevor es weitergeht.
E10. Logs sind sauber (gleich nach dem Test; kein Token, keine Adresse, keine Fehlerzeile der Bestellung).
docker logs --since 1h webklar-kundenbereich-server 2>&1 | grep -c -e "$(printf %s "$T" | cut -c1-12)" -e 'kauftest' -e 'Bestellung fehlgeschlagen'
Erwartet: 0. Wenn nicht: melde die Zeile Claude und gib sie sonst niemandem weiter (sie kann Daten enthalten).
E10a. Voller Preis ohne Rabatt (optional, einmal, ohne zu zahlen). Der Testpreis hängt nur an der Adresse des Angebots, nie an der Eingabe des Käufers. Neuer Token T3 und Ordner wie in E3:
T3=$(head -c 48 /dev/urandom | base64 -w0 | tr -d '+/=' | head -c 43); echo "$T3"; docker exec webklar-workers sh -c "mkdir -p /data/vorschau/$T3 && echo '<h1>Test</h1>' > /data/vorschau/$T3/index.html"
Dann das Angebot mit einer Adresse, die nicht in ABO_TEST_EMAILS steht (und keinem Kunden gehört):
docker exec -w /app webklar-kundenbereich-server timeout 60 node --max-old-space-size=256 scripts/workers-kauf-testangebot.mjs --email kenso.gri+voll@gmail.com --firma "Testbetrieb GmbH" --vorschau "$T3" --voller-preis
Öffne den Link, bestelle mit Start „sofort“ und brich bei Stripe ab. Erwartet: 20,00 € pro Monat ohne Rabattzeile (das Skript meldet beim Anlegen „Achtung: … steht nicht in ABO_TEST_EMAILS …“). Räum danach auf: den Stripe-Kunden dieser Adresse im Dashboard löschen (nach dem Löschen des Angebots findet das Ablauf-Ereignis nichts mehr, der Kunde bliebe bis zum Tagesjob nach 30 Tagen stehen), das Angebot mit --loeschen und seiner Kennung aus der Zeile Angebot: wie in E13 (3), dann den Ordner:
[ "${#T3}" = 43 ] && docker exec webklar-workers rm -r -- "/data/vorschau/$T3"
Wenn nicht: ein Rabatt oder 0,50 € = Schalter bleibt aus, Claude Bescheid (der Rabatt dürfte hier nicht erscheinen).
E11. [Claude] Repo und Nebenwirkungen prüfen. Sag Claude: „Repo WEBklar/testbetrieb-gmbh prüfen.“ Claude liest per Gitea-Werkzeug nur lesend. Erwartet: der Dateibaum enthält genau index.html und .webklar-preview.json, der neueste Commit heißt „Erste Version der Website“ (darunter „Update preview config from ticket system“ und „Initial commit“); in WOMS erscheint dazu kein Auto-Ticket „Website …“, kein Arbeitsblatt und keine Push-Zeitmail, und es ging keine Mail T17 („Repo zuordnen“) raus. Die alte Vorschau-Adresse ist tot: curl -s -o /dev/null -w '%{http_code}\n' https://entwurf.project.webklar.com/$T/ zeigt 404. Wenn nicht: weitere Dateien im Repo oder ein anderer Commit-Titel = die Annahmen zu Gitea stimmen nicht; T25 „Vorschau konnte nicht übernommen werden“ mit Code zu_viele_loeschungen, marker_fehlt, baum_abgeschnitten oder fremde_organisation = ebenfalls Claude.
E12. Lauf 2 (Kundenart privat, Start sofort; zahlt 0,50 € wirklich). Neuer Token und neues Testangebot, dann wie E6 bis E10 mit Alias 2:
T2=$(head -c 48 /dev/urandom | base64 -w0 | tr -d '+/=' | head -c 43); echo "$T2"; docker exec webklar-workers sh -c "mkdir -p /data/vorschau/$T2 && echo '<h1>Test</h1>' > /data/vorschau/$T2/index.html"
docker exec -w /app webklar-kundenbereich-server timeout 60 node --max-old-space-size=256 scripts/workers-kauf-testangebot.mjs --email kenso.gri+kauftest2@gmail.com --firma "Testbetrieb GmbH" --vorschau "$T2"
Schreib die Zeile Angebot: auf. Fehlerprobe zuerst (sie probt H3 und H4 gegen echtes Stripe, Gitea und Appwrite, bevor es ernst wird): Benenne den Ordner um, dann kann der Kauf die Vorschau nicht lesen.
docker exec webklar-workers mv /data/vorschau/$T2 /data/vorschau/$T2.weg
Bestelle als Privatperson (ein Name wie „Max Kauftest“) und wähle „sofort“. Erwartet: das Betriebsfeld ist ausgeblendet; Danke-Seite, K01 und K44 kommen (K44 ohne Projektadresse, mit dem Absatz zum Sofortbeginn statt des Datums nach der Widerrufsfrist), im Team-Postfach die Mail [Hustler] Vorschau konnte nicht übernommen werden (hier Absicht), das Angebot steht auf bezahlt, ein Projekt gibt es noch nicht. Dann stellst du den Ordner zurück:
docker exec webklar-workers mv /data/vorschau/$T2.weg /data/vorschau/$T2
Wähle im Stripe-Dashboard bei checkout.session.completed dieser Sitzung „Erneut senden“ (H4). Erwartet: Zustellung 200; das Projekt entsteht und heißt wie der Käufer („Max Kauftest“, Repo nach dem Muster WEBklar/max-kauftest), nicht „Testbetrieb GmbH“; keine zweite K01 oder K44 und kein neuer Hinweis. Danach laufen die Prüfungen E7 bis E11 für Lauf 2 (das Projekt gibt es erst jetzt; bei checkout.session.completed steht dein Erneut-Senden als zweite Zustellung in der Liste, das ist hier richtig): in featureConsents beginnt der beginn-Text mit „Sofort. Ich verlange ausdrücklich …“ und kundenart ist privat; in Stripe sind bei Sofortbeginn auch invoice.paid und customer.subscription.created mit 200 zugestellt (keine 500, auch wenn invoice.paid vor checkout.session.completed ankommt). Wenn nicht: kommt nach dem Erneut-Senden kein Projekt oder derselbe Hinweis noch einmal, ist die Wiederholung (H4) nicht wirksam: Claude Bescheid, die Schalter bleiben aus; ein Ordner $T2.weg, der stehen bleibt, wird von E13 (5) nicht entfernt (docker exec webklar-workers rm -r -- "/data/vorschau/$T2.weg").
E13. Aufräumen (in dieser Reihenfolge; das Skript sagt am Ende selbst, was von Hand bleibt).
(1) Stripe: die beiden Abos sofort beenden, danach die Stripe-Kunden löschen (Lauf 2 hat 0,50 € bezahlt: erstatten, wenn du magst; ohne Beenden würde Lauf 1 nach der Widerrufsfrist 0,50 € abbuchen).
(2) WOMS: die beiden Testkunden löschen (das entfernt Portal-Zugang und Appwrite-Benutzer), die Projekte archivieren, die Repos WEBklar/testbetrieb-gmbh und WEBklar/max-kauftest archivieren oder löschen.
(3) Pro Angebot, mit den Kennungen aus E5 und E12 (t-XXXXXXXX ersetzen):
docker exec -w /app webklar-kundenbereich-server timeout 60 node --max-old-space-size=256 scripts/workers-kauf-testangebot.mjs --loeschen t-XXXXXXXX
Erwartet: gelöscht: …-Zeilen für Angebot, Vorschau, die Ereignisse deal und fehler (soweit vorhanden) und Interessent, Exit 0.
(4) Von Hand in der Appwrite-Konsole nach der customerId der Testkunden filtern und löschen: featureConsents, contractEvents, subscriptions, websiteProjects.
(5) Die Ordner im Dienst (das nächtliche Aufräumen des Dienstes entfernt diese Ordner nie):
[ "${#T}" = 43 ] && [ "${#T2}" = 43 ] && docker exec webklar-workers rm -r -- "/data/vorschau/$T" "/data/vorschau/$T2"
(6) .env: die Testadressen entfernen, den Schalter wieder auf off, neu starten (die Rechtsrunde läuft ohne eingeschaltete Seite):
cd /home/webklar/apps && sed -i 's/,\{0,1\}kenso\.gri+kauftest[12]@gmail\.com//g; s/^WORKERS_KAUF_ENABLED=.*/WORKERS_KAUF_ENABLED=off/' .env && grep -c 'kauftest' .env; grep -c '^WORKERS_KAUF_ENABLED=off$' .env
Erwartet: 0, dann 1. Dann cd /home/webklar/apps && docker compose up -d --no-deps kundenbereich-server und nach 1 bis 2 Minuten docker exec webklar-kundenbereich-server printenv WORKERS_KAUF_ENABLED (off). Wenn nicht: bei [ "${#T}" = 43 ] ohne Ausgabe ist eine der Shell-Variablen leer: dann nichts löschen, die Ordnernamen von Hand eintippen.
F. Rechtsrunde (nach der Testbestellung, vor dem Einschalten)
Du gibst die neuen Käufer-Texte mit der echten Seite aus E vor Augen frei. Ohne deine Freigabe bleibt WORKERS_KAUF_ENABLED=off und WORKERS_KAUFLINK=nein.
F1. [Claude] Wortlaute zusammenstellen. Sag Claude: „Alle neuen Käufer-Texte des Kaufwegs für die Rechtsrunde.“ Claude liest sie aus dem Code und legt sie dir im Wortlaut vor (Seite: server/services/workersKaufTexte.js und public/uebernahme*.js; Stripe-Hinweis: checkoutMessage in server/services/stripeBilling.js; Mail: server/mail-templates/K44 Website übernommen.html; Fehlersätze: server/routes/uebernahme.js und server/services/workersKauf.js). Du gibst frei oder korrigierst:
- die Frage zur Kundenart und beide Antworten („Bestellen Sie als Unternehmen oder privat?“, „Ich bestelle als Unternehmen oder Selbstständige(r).“, „Ich bestelle als Privatperson.“);
- den Stripe-Hinweis über dem Zahlen-Knopf („Ihre Bestellung haben Sie auf der Seite „Website übernehmen“ abgegeben …“);
- den Angebotssatz auf der Seite und im Zustimmungsnachweis (F2);
- die Sätze zum Leistungsbeginn (Seite, Tabelle „Fällig“ und „Leistungsbeginn“, K44) für „sofort“ und „nach Ablauf der Widerrufsfrist“ und den Hinweis an Firmenkunden („Firmenkunden haben kein Widerrufsrecht und wählen am besten „Sofort“.“);
- die Leistungsbeschreibung vor „Ihre Bestellung“ (dieselbe Liste wie die Bestellseite im Portal; dort steht außerdem ein Hinweis auf die Übernahmegutschrift, hier nicht: bewusst?);
- die Danke-Seite (sie verspricht zwei E-Mails; bei einem Konflikt der Adresse nach der Zahlung gibt es weder K01 noch K44), den Satz „Zu dieser E-Mail-Adresse gibt es schon ein Kundenkonto …“ und die Seite für einen toten Link („Dieser Link ist nicht mehr gültig“ mit „Bitte melden Sie sich bei uns. Wir helfen Ihnen gern weiter.“ und der Kontaktzeile; der Käufer erfährt nie den Grund);
- dass der Link-Token im Verlauf und in den Lesezeichen des Käufers steht (bewusst: Neuladen und „Zurück“ von Stripe brauchen ihn).
F2. Der Angebotssatz stimmt überall überein. Vergleiche wortgleich: den Satz der Testbestellung (Ausgabe in E4, Seite, featureConsents.texte, erster Eintrag) mit workersSettings/main.offerText (WOMS → Hustler → Hochhaus → Einstellungen). Er muss denselben Preis nennen wie der Katalog: 20 € im Monat, 12 Monate Mindestlaufzeit, ohne MwSt. nach § 19 UStG; der Kaufweg verkauft nur den Katalogpreis. Neue Angebote übernehmen den Satz der Einstellungen zum Zeitpunkt des Anlegens (Schnappschuss, bestehende behalten ihren); jede spätere Änderung von offerText braucht darum eine neue Freigabe. Erwartet: alle Stellen wortgleich. Wenn nicht: Satz in den Einstellungen korrigieren und E4 mit einem neuen Testangebot wiederholen.
F3. Datenschutzerklärung. Prüfe in public/datenschutz.html und in der PDF-Fassung, dass dieser Ablauf gedeckt ist: Bestellung ohne Anmeldung mit Name, Anschrift, E-Mail (bei Unternehmen auch Betriebsname); der Stripe-Kunde entsteht schon beim Klick auf „Zahlungspflichtig bestellen“, also vor der Zahlung; IP-Adresse und Browser stehen im Zustimmungsnachweis; Löschfrist: der Stripe-Kunde einer nicht abgeschlossenen Bestellung wird gelöscht, wenn die Sitzung abläuft (bei Stripe standardmäßig nach 24 Stunden), spätestens nach 30 Tagen vom Tagesjob; die Checkout-Sitzungsobjekte selbst bleiben bei Stripe bestehen. Der Abschnitt „Interessenten“ ist bisher der einzige Text zu Interessenten. Fehlt ein Satz, schreibt Claude ihn, du gibst ihn frei, Claude ergänzt Seite und PDF (eigener Push wie C2) und lädt die neue Fassung in WOMS → Rechtliches hoch.
F4. Entscheidung. Freigabe = weiter mit G. Änderungswünsche: Claude ändert, Push wie C2, der Schalter bleibt aus; änderst du Texte der Seite oder der Mails, siehst du sie in einer kurzen Wiederholung von E1 bis E8 (neues Testangebot) noch einmal an und räumst wieder auf (E13).
G. Einschalten
G1. Voraussetzungen (alle müssen stimmen): A4 (Abo freigegeben), C3 bis C7 grün, D1 und D3 erledigt, E komplett aufgeräumt (E13), F freigegeben. Der Kaufweg steht nach G2 für jeden mit gültigem Link offen.
G2. Schalter an und Neustart.
cd /home/webklar/apps && sed -i 's/^WORKERS_KAUF_ENABLED=.*/WORKERS_KAUF_ENABLED=on/' .env && grep -c '^WORKERS_KAUF_ENABLED=on$' .env
Erwartet: 1. Dann:
cd /home/webklar/apps && docker compose up -d --no-deps kundenbereich-server
Nach 1 bis 2 Minuten docker exec webklar-kundenbereich-server printenv WORKERS_KAUF_ENABLED (Erwartet: on) und die Prüfung C3 noch einmal: Erwartet: dieselbe neutrale 404-Antwort mit Body und Kopfzeilen (ein unbekannter Link verrät nichts).
G3. Kauf-Link im Dienst einschalten (erst jetzt; im Hochhaus darf gerade kein Bau laufen, Bob, Bianca, Ben und Paul stehen nicht auf „arbeitet“, denn ein Neustart verwirft einen laufenden Bau, siehe P4 I3).
cd /home/webklar/apps && cp -p .env .env.bak-$(date +%F)-workers-p5b && if grep -q '^WORKERS_KAUFLINK=' .env; then sed -i 's/^WORKERS_KAUFLINK=.*/WORKERS_KAUFLINK=ja/' .env; else printf '\nWORKERS_KAUFLINK=ja\n' >> .env; fi; grep -c '^WORKERS_KAUFLINK=ja$' .env
Erwartet: 1. Dann:
cd /home/webklar/apps && docker compose up -d --no-deps workers
Danach docker exec webklar-workers printenv WORKERS_KAUFLINK (Erwartet: ja). Ab jetzt legt Theo bei einem Kaufwunsch nach einer gesendeten Vorschau einen Kauf-Link an und schickt ihn mit dem Angebotssatz. Wenn nicht: nein = Zeile in .env prüfen; zurück: I1.
G4. Erster echter Link. Sobald der erste Kaufwunsch kam: Appwrite-Konsole → workersOffers, das neue Angebot: offerText gesetzt (derselbe Satz wie in der Nachricht), status = offen, previewUrl beginnt mit https://entwurf.project.webklar.com/, expiresAt in rund 30 Tagen. Öffne den Link aus der Nachricht in einem privaten Fenster (Seite zeigt das Angebot; ein Link ohne #t=… zeigt „nicht mehr gültig“), auch aus Gmail, Outlook (Safe Links) und iOS Mail: der Teil nach #t= muss erhalten bleiben. Wenn nicht: offerText fehlt = A2 und A3; previewUrl mit anderer Adresse = die Vorschau-Adresse von Dienst und Portal stimmen nicht überein (Claude). Der erste echte Kauf liest zum ersten Mal eine vom Dienst gebaute Vorschau (mehrere Dateien, .css/.svg, Rechte vom Dienst), nicht den Testordner. Kommt danach der Hinweis [Hustler] Vorschau konnte nicht übernommen werden, ist die Ursache in der Mail genannt (H3); sonst prüfst du im neuen Projekt, dass alle Dateien der Vorschau im Repo liegen.
G5. Nach dem ersten echten Kauf. Erwartet: kein Hinweis „[Hustler] …“; im Hustler ist der Interessent deal, und die Kennzahlen für neue Abos und Deals im Monat steigen um 1 (neuer Monatsumsatz: 20 €); der Dienst schickt dem Interessenten keine weitere Mail. Achte auf den Projektnamen: bestellt ein Käufer als privat, heißt das Projekt nach ihm, nicht nach dem Betrieb. „Bezahlt“ heißt „Abo abgeschlossen“, nicht „Geld da“: schlägt eine SEPA-Lastschrift später fehl, gilt der normale Abo-Weg (Mail T04, invoice.payment_failed). Am nächsten Morgen (nach 03:20) prüfst du, dass das workersPreviews-Dokument des Kaufs seinen Status behalten hat (convertedProjectId gesetzt), der Ordner im Dienst noch steht und das Angebot bezahlt bleibt; das Aufräumen darf übernommene Vorschauen nie anfassen (ihr Löschtermin deleteAfter liegt bei der Voreinstellung 60 Tage nach dem Bau, die Prüfung zeigt am nächsten Morgen also nur, dass nichts schiefging).
H. Betrieb und Handfälle
H1. Was der Käufer als Fehler sieht. Kein Befehl. „Dieser Link ist nicht mehr gültig.“ (404) kommt bei jedem Grund gleich: Angebot abgelaufen, schon bezahlt, Interessent gesperrt/verloren, Vorschau übernommen oder verworfen, Schalter aus; den Grund liest du im workersOffers-Dokument. „Zu dieser E-Mail-Adresse gibt es schon ein Kundenkonto …“ (409) = die Adresse gehört einem Kunden oder Mitarbeiter: kein Kauf, das Team klärt es von Hand. „Zu viele Anfragen. Bitte später erneut versuchen.“ (429) = höchstens 10 Bestellversuche je Stunde und Adresse (und je Link), 120 Seitenaufrufe je Stunde und Adresse: eine Stunde warten. „Die Online-Bestellung ist noch nicht freigeschaltet.“ (503) = Stripe im Portal nicht eingerichtet. „Das hat gerade nicht geklappt. Bitte versuchen Sie es in einigen Minuten noch einmal.“ (500): docker logs --since 1h webklar-kundenbereich-server 2>&1 | grep '\[uebernahme\]' zeigt nur Art, Status und Code, nie Token oder Adressen. Die Seite nimmt nur JSON bis 16 KB (größer: 413).
H2. Täglich: nichts zu tun. Der Tagesjob des Portals (Stripe-Kunden abgebrochener Bestellungen, D3) und das nächtliche Aufräumen des Dienstes um 03:20 (hängende Kaufversuche nach Ablauf auf „abgelaufen“, Vorschauen übernommener Käufe bleiben stehen; P4 I4) laufen von selbst. Im Log steht nur dann etwas, wenn etwas gelöscht wurde oder schiefging:
docker logs --since 24h webklar-kundenbereich-server 2>&1 | grep -E '\[workers-kauf\]|\[uebernahme\]'
Erwartet: keine Zeile oder Aufräumen Stripe-Kunden: … mit Zahlen. Wenn nicht: Zeilen [workers-kauf] … status=… code=… an Claude; sie enthalten nie Adressen oder Token.
H3. Hinweise [Hustler] … (T25). Ein Hinweis des Kaufwegs kommt als Mail an das Team (Betreff [Hustler] <Titel>) und steht als Ereignis „fehler“ von Theo im Hustler. Jeder Titel heißt: der Käufer hat gezahlt und etwas ist nicht ganz durchgelaufen. Die Mail nennt Angebot, Stripe-Sitzung (cs_…) und Abo (sub_…). Der Käufer hat in manchen Fällen noch kein K01/K44: seine Vertragsbestätigung (§ 312f BGB) muss am selben Tag raus, also zuerst die Ursache klären. „Ereignis erneut senden“ ist in H4 erklärt.
- E-Mail-Adresse gehört schon einem Kunden: kein Konto angelegt, kein Passwort, weder K01 noch K44. Prüfe, ob es dieselbe Person ist und ob der Bestandskunde schon ein laufendes Abo hat: hat er eins, erstattest du und beendest das neue Abo (sonst entstünde ein zweites). Hat er keins: in Stripe am Abo und am Stripe-Kunden
webklarCustomerId= Kunden-Id aus WOMS setzen, dann das Ereignis erneut senden; K01 geht raus, K44 nur, wenn der Kunde noch ein gespeichertes Anfangspasswort hat (an die Adresse im Kundendokument, nicht an die der Bestellung). Gehört die Adresse nur einem Appwrite-Benutzer ohne Kunden: erstatten (den Benutzer löschst du erst, wenn du sicher bist, dass er niemandem gehört). - Doppelte Zahlung für ein Angebot: die zweite Zahlung erstatten und das zweite Abo beenden; es entstand kein zweiter Kunde.
- Zahlung ohne Angebot: das Angebot gibt es nicht mehr (zum Beispiel mit
--loeschenentfernt); nichts wurde angelegt: erstatten oder den Kunden von Hand anlegen. - Bezahlte Sitzung ohne Abo: Zahlung bei Stripe prüfen; es wurde nichts angelegt.
- Kunde zum Angebot fehlt: Angebot oder Abo verweist auf einen Kunden, den es nicht gibt: Kunden von Hand prüfen; ist die Ursache behoben, Ereignis erneut senden, sonst erstatten.
- Bezahlt, Konto fehlt: das Konto ist nicht vollständig entstanden (Schritt und Fehler stehen in der Mail, oft ein fehlendes Feld in
customers); Ursache beheben, Ereignis erneut senden, sonst erstatten. - Zustimmungsnachweis nicht gespeichert: der Kauf ist abgeschlossen; die Zustimmung steht in den Stripe-Metadaten des Abos (
termsVersion,termsAcceptedAt,consentIp,consentUserAgent,workersAngebotssatz). Ereignis erneut senden holt den Nachweis nach, oder du legst ihn infeatureConsentsvon Hand an. - Vorschau konnte nicht übernommen werden: der Kauf lief ohne Vorschau weiter (K44 ging ohne Projektadresse raus). Die Mail nennt die Ursache: Im Ordner der Vorschau im Dienst (
/data/vorschau/<Token>) sind höchstens 6 flache Dateien.html/.css/.svgerlaubt, zusammen höchstens 200 KB, mitindex.html, ohne Unterordner; fehlt der Ordner, holt auch ein erneut gesendetes Ereignis nichts nach: stelle ihn aus einer Sicherung wieder her oder lege die Website von Hand ins Repo des Kunden. Steht der Ordner wieder da, Ereignis erneut senden: es holt Projekt, Dateien und Nachweis nach, ohne Doppeltes und ohne neue Mail an den Käufer; die Adresse des Projekts gibst du ihm von Hand weiter. Der Hinweis kann sich bei jedem Versuch wiederholen, solange die Ursache besteht. - Zugangsdaten nicht zustellbar: der Mailserver hat die Adresse im Kundendokument abgelehnt (vertippt?). Korrigiere sie im WOMS in der Kundenakte; K44 geht bei der nächsten Wiederholung von Stripe (bis zu drei Tage) oder per „Ereignis erneut senden“ an die Adresse im Kundendokument. Das Anfangspasswort bleibt bis dahin gespeichert.
- Zugangsdaten nicht zugestellt: das Kundendokument war nicht lesbar, K44 ging nicht raus: Zugangsdaten von Hand schicken (Kundenakte im WOMS), Kundendokument prüfen.
- Passwort im Kundendokument nicht gelöscht: K44 ist angekommen, aber das Feld
portalPasswordsteht noch: in der Kundenakte von Hand leeren (sonst schickt ein erneut gesendetes Ereignis K44 noch einmal). - Rückmeldung an den Hustler nicht gespeichert: Kunde und Abo stehen; im Hustler fehlt „deal“ oder „bezahlt“. Ursache beheben (Schritt steht in der Mail), Ereignis erneut senden.
H4. „Ereignis erneut senden“ (Stripe-Dashboard → Entwickler → Ereignisse): das Ereignis checkout.session.completed der Sitzung aus dem Hinweis öffnen und beim Endpunkt https://project.webklar.com/api/abo/webhook „Erneut senden“ wählen (Stripe hält Ereignisse 30 Tage vor). Der Handler prüft zuerst, ob alles schon fertig ist (Projekt, Nachweis, erster Commit); ist es fertig, liest er nur und schreibt und mailt nichts. Sonst holt er nach, was fehlt, ohne Doppeltes und ohne neue Mail an den Käufer. Erwartet: Zustellung 200, der Hinweis kommt nicht wieder. Wenn nicht: kommt derselbe Hinweis noch einmal, besteht die Ursache weiter.
H5. Waisen-Repos. Bricht ein Lauf zwischen dem Anlegen des Repos und dem ersten Projektdokument ab, bleibt ein Repo ohne Projekt, und die Wiederholung nimmt den Namen mit -2 (<name> neben <name>-2). Schau nach dem Test und nach den ersten echten Käufen in der Gitea-Organisation WEBklar (oder in WOMS unter „Art fehlt“/unzugeordnet) nach solchen Paaren und lösche das Repo ohne Projekt von Hand.
I. Rückweg
I1. Kauf-Link zuerst aus (kein Bau darf laufen, siehe G3).
cd /home/webklar/apps && cp -p .env .env.bak-$(date +%F)-workers-p5c && sed -i 's/^WORKERS_KAUFLINK=.*/WORKERS_KAUFLINK=nein/' .env && grep -c '^WORKERS_KAUFLINK=nein$' .env
Erwartet: 1. Dann cd /home/webklar/apps && docker compose up -d --no-deps workers und docker exec webklar-workers printenv WORKERS_KAUFLINK (nein). Danach legt Theo keine Kauf-Links mehr an, Kaufwünsche landen wieder als Pult-Eintrag „Kaufwunsch – Kauf-Link noch nicht aktiv“.
I2. Dann die Seite aus.
cd /home/webklar/apps && sed -i 's/^WORKERS_KAUF_ENABLED=.*/WORKERS_KAUF_ENABLED=off/' .env && grep -c '^WORKERS_KAUF_ENABLED=off$' .env
Erwartet: 1. Dann cd /home/webklar/apps && docker compose up -d --no-deps kundenbereich-server und nach 1 bis 2 Minuten die Prüfung C3. Schon verschickte Links zeigen jetzt „Dieser Link ist nicht mehr gültig.“. Der Webhook-Zweig bleibt wirksam: schon bezahlte Sitzungen werden weiter verarbeitet, und der Tagesjob läuft weiter.
I3. Den Code nie zurückrollen, solange workersOffers im Status checkout stehen oder in den letzten 3 Tagen jemand bezahlt hat (Appwrite-Konsole, Sammlung workersOffers, nach status und paidAt sortieren): die alte Fassung kennt workersOfferId nicht, und bezahlte Sitzungen liefen ohne Kunden durch den normalen Abo-Weg. Für den Rückweg genügen die beiden Schalter. Was bleibt: angelegte Kunden, Projekte, Nachweise und Stripe-Abos; die Vorschau-Ordner im Volume; die .env-Zeilen.
J. Übergabe-Hinweise
J1. Umschalten auf Fassung 6 (Kundenansicht P10/P11). Der Kaufweg zeigt die eingefrorene Fassung 2026-09-30-v5. Stellt jemand config.abo.termsVersion auf v6, bevor die Kaufweg-Texte umgestellt sind, nennt der Nachweis v6 für einen Wortlaut v5. Darum zuerst I1 und I2 (Link und Seite aus), dann server/services/workersKaufTexte.js und die Fixture test/support/wortlautAboV5.json auf den Wortlaut v6 umstellen (der Test test/workersKaufTexte.test.js meldet bis dahin „Kaufweg auf Fassung 6 umstellen (Kundenansicht P10)“), neue Testbestellung (E) und Rechtsrunde (F), erst dann wieder G. Dieser Punkt gehört in die Checkliste der Fassung-6-Umschaltung (Kundenansicht, Teil C „Fassung 6 festschreiben“); Claude trägt ihn dort schon vor dem Push in C1 ein, zusammen mit dem Vermerk, dass K44 und T25 belegt sind.
J2. Nummern. Mail K44 („Website übernommen“) und Team-Hinweis T25 („Workers Hinweis“) sind durch P5 belegt; die nächsten freien Nummern sind K45 und T26.
J3. Übernommene Vorschau trägt noindex. Jede Vorschau-Seite enthält ein noindex-Meta (die Prüfung des Dienstes verlangt es). Bevor die Kundendomain eines übernommenen Projekts live geht, muss es raus, sonst taucht die Seite nie in Suchmaschinen auf. Sag dem Team Bescheid.
J4. Projektnamen. Ein Unternehmen heißt im Projekt wie sein Betrieb, eine Privatperson wie sie selbst; gibt es den Namen schon, hängt das System -2 an (H5).
Fertig, wenn: B5, C3 bis C7, D1 und D3, E4 bis E12 (Lauf 1, Fehlerprobe und Lauf 2) ihre „Erwartet“-Zeilen erfüllen, E13 aufgeräumt ist und F freigegeben hat. Das Einschalten (G) ist eine eigene Entscheidung.