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