Hustler: Name in der Oberfläche, Budget höchstens 150 $/Monat (6 $/Tag) mit hartem Deckel

Kenso, 17.10.: "150 € im Monat max", "nenne es Hustler". Technische
Namen (workers*) bleiben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-17 20:26:42 +00:00
1 parent 740089c997
commit 11e4280a92
8 files changed
+184 -156

No files matched your search

@@ -2,9 +2,9 @@
> **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 in Workers.
**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 Workers – 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.
**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`.
@@ -20,7 +20,7 @@
> - **E4 Betrieb:** Der Portal-Container hängt `workers_data:/workers-vorschau:ro` an – siehe Übersicht §9, §11, P5-E4.
> - **E5 Auftrag an P4 (A14):** Vorschau-Server 404 ab `convertedProjectId`; Angebote `abgelaufen` bei Sperre (das Portal prüft `workersProspects.status === 'gesperrt'` zusätzlich); Ordner einer übernommenen Vorschau mindestens 7 Tage (Stripe wiederholt Webhooks bis zu 3 Tage), Verzeichnisse 0755, Dateien 0644 – übernommen, siehe Übersicht §4.13, §11, P5-E5.
> - **E6 Auftrag an P1/P2 (A12, A13):** Ereignis `deal` (workerId `system`, ID `eventIdFor('deal', offerId)`, `metaJson` laut Übersicht §5); `newAbosMonth`, `mrrNewEur` **und** `dealsMonth` zählt P1 aus `workersOffers` (`status 'bezahlt'`, `paidAt` im Berliner Monat) – Auftrag an P1, nicht mehr offen; P5 setzt genau diese Felder, `paidAt` als ISO-UTC mit `Z` (P5-T12) – übernommen, siehe Übersicht §5, §11, P5-E6.
> - **E7 Mail-Kennungen:** K44 „Website übernommen“ und T25 „[Workers] …“ (Betreff-Präfix `[Workers]`, Präfix-Test in `mailTemplates.test.js` erweitert) – siehe Übersicht §8, §11, P5-E7.
> - **E7 Mail-Kennungen:** K44 „Website übernommen“ und T25 „[Hustler] …“ (Betreff-Präfix `[Hustler]`, Präfix-Test in `mailTemplates.test.js` erweitert; der Dateiname `T25 Workers Hinweis.html` bleibt als technischer Name) – siehe Übersicht §8, §11, P5-E7.
> - **E8 `createWorkersCheckout(offer, consent, buyer, { token })`:** vierter, optionaler Parameter; der Token steht nur im Fragment der Abbruch-Adresse – siehe Übersicht §8, §11, P5-E8.
> - **E9 Rechtsrunde:** Neue Wortlaute (Kundenart-Auswahl, Stripe-Hinweistext, Zitat des Angebotssatzes) brauchen Kensos Freigabe vor `WORKERS_KAUF_ENABLED=on`; der Bestellwortlaut ist Fassung v5 (`2026-09-30-v5`), Wort für Wort aus `public/app.js` – siehe Übersicht §11, P5-E9.
> - **E10 Nur Hosting (A11):** P5 verkauft ausschließlich `productKey: 'hosting'`, jedes andere Produkt ergibt die neutrale 404-Antwort (die Stundengutschrift der ersten Wartungs-Rechnung, `invoice.paid`, träfe vor der Verknüpfung ein und ginge verloren). P1 und P4 setzen es um: P1 beschränkt `offerProductKey` auf `hosting`, P4 legt nur solche Angebote an – siehe Übersicht §4.12, §11, P5-E10.
@@ -41,7 +41,8 @@
- Reservierte Subdomain `entwurf` (Übersicht §9): kein Projekt darf sie tragen oder erben.
- Vorschau-Dateien nur aus `config.workers.vorschauRoot/<token>`: höchstens 300 Dateien, 5 MB je Datei, 25 MB gesamt, `index.html` Pflicht, keine Symlinks, nie `.webklar-preview.json` überschreiben.
- Texte an Käufer in Sie-Form, mit echten Umlauten; Mail-Kennungen K44 und T25 (vor dem Anlegen in P5-T10 gegen **alle** Refs prüfen).
- Nicht in P5: Widerruf oder Kündigung eines Workers-Kunden laufen über den bestehenden Weg (`contractEvents`, K03/K04, T08/T09); `workersOffers.status = 'widerrufen'` setzt später, wer es braucht.
- **Name [Kenso, 17.10.: Name „Hustler“]:** Was Mitarbeiter sehen, heißt „Hustler“: Betreff-Präfix der Team-Hinweise `[Hustler]` (T25), die Tag-Zeile „Intern · Hustler“, der in WOMS angezeigte Name beim Anlegen des Projekts (`staffName: 'Hustler'`). Texte an Käufer (Übernahme-Seite, Stripe-Hinweis, K44, Nachweis-Wortlaut) nennen weder „Workers“ noch „Hustler“ (Pin-Tests in P5-T02, P5-T08, P5-T10, P5-T13, P5-T14); sie sprechen als „WEBklar“. Technische Bezeichner bleiben unverändert: Sammlungen `workers*`, Dienst/Container `webklar-workers`, Dateien und Funktionen (`workersKauf.js`, `createWorkersCheckout` …), Metadaten `workersOfferId`, `workersAngebotssatz`, Env `WORKERS_*`, Volume `workers_data`, Mail-Dateiname `T25 Workers Hinweis.html`, Ereignisart `deal`. „Workers“ in Commit-Präfixen und Plan-Dateinamen ist das Projekt-Kürzel.
- Nicht in P5: Widerruf oder Kündigung eines Hustler-Kunden laufen über den bestehenden Weg (`contractEvents`, K03/K04, T08/T09); `workersOffers.status = 'widerrufen'` setzt später, wer es braucht.
## Review Focus
@@ -98,7 +99,7 @@ Erwartet: `git status --short` leer (`node_modules` steht in `.gitignore`), letz
- [ ] **Step 2: Failing test `test/workersReserviert.test.js`** (Muster `test/giteaAdmin.test.js`: `startFakeAppwrite` aus `./helpers/fakeAppwrite.js`, Env setzen, dann dynamisch importieren; Appwrite-Dokument `{ $id: 'wp1', repoFullName: 'WEBklar/x', projectName: 'X', subdomain: 'x', projectType: 'preview' }` in `websiteProjects`; `GITEA_BASE_URL=http://127.0.0.1:1`, `GITEA_API_TOKEN=t`, `PREVIEW_DEPLOY_ROOT=''`, `TRAEFIK_DYNAMIC_DIR=''`)
```js
test('config: Sammlungen und Workers-Block mit Standardwerten', () => {
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' })
})
@@ -177,6 +178,7 @@ test('Werte eingesetzt: Betrag, Datum, Laufzeit', () => {
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.')
@@ -427,7 +429,7 @@ 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: 'Workers' })`; 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 (Workers)' })` (bei Wiederholung ohne Änderung kein Commit).
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**
@@ -534,7 +536,7 @@ test('Sitzung ohne WOMS-Kunden: Metadaten, Adressen, Texte', async () => {
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.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])
@@ -691,14 +693,14 @@ git commit -m "feat(workers-kauf): öffentliche Routen /api/uebernahme" -m "Co-A
**Interfaces:**
- Produces: `renderMail('K44', values, blocks)` – Betreff `Willkommen bei WEBklar – Ihre Zugangsdaten`; Platzhalter `{{name}}, {{firma}}, {{email}}, {{passwort}}, {{portal_url}}, {{vorschau_url}}, {{leistungsbeginn}}`; Blöcke `vorschau`, `sofortbeginn`, `beginn_nach_widerrufsfrist`; Sie-Form; Layout wie K16.
- Produces: `renderMail('T25', values)` – Betreff `[Workers] {{vorfall}}`; Platzhalter `{{vorfall}}, {{details}}, {{zeit}}`; keine Blöcke; Layout wie T20 (Fuß der Team-Mails).
- 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**
```bash
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 `Workers` erweitern; `TEAM_FOOTER` um `'T25'`; Kommentarzeile 9 um „K44 Website übernommen und T25 Workers-Hinweis (Workers P5)“ ergänzen. Neue Tests:
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:
```js
test('K44: Zugangsdaten, Sie-Form, Blöcke je Beginn und Vorschau', () => {
@@ -713,8 +715,9 @@ test('K44: Zugangsdaten, Sie-Form, Blöcke je Beginn und Vorschau', () => {
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 [Workers], drei Platzhalter, Zeilenumbrüche in details bleiben', () => { /* wie der T20-Test, Datei 'T25 Workers Hinweis.html' */ })
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**
@@ -724,7 +727,7 @@ 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 und die Tag-Zeile „Intern · Workers“ ändern.
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**
@@ -784,19 +787,19 @@ test('doppelte Zustellung (nacheinander und gleichzeitig): ein Kunde, ein Abschl
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('[Workers]')), 'eine doppelte Zustellung ist kein Konflikt')
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 [Workers]-Mail */ })
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('[Workers] Zahlung ohne Angebot')))
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('[Workers] E-Mail-Adresse gehört schon einem Kunden')); assert.ok(t25)
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'))
})
@@ -871,22 +874,22 @@ test('Gesamtlauf: Seite → Bezahlen → Webhook: alles genau einmal und alles v
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 Workers
// 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('[Workers]')), 'im Normalfall kein Team-Hinweis')
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 "[Workers] Doppelte Zahlung für ein Angebot" */ })
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 "[Workers] 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 "[Workers] 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 "[Workers] Zustimmungsnachweis nicht gespeichert", eine K44, Angebot bezahlt */ })
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' */ })
```
@@ -911,7 +914,7 @@ Erwartet: alle PASS.
```bash
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 Workers" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
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>"
```
---
@@ -938,6 +941,7 @@ test('HTML: noindex, kein Referrer, nur externes Modul-Skript, keine Fremdadress
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') })
@@ -989,7 +993,7 @@ test('Angebot wird angezeigt; Daten aus dem Server bleiben Text, nie HTML', asyn
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.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"]'))
@@ -1093,7 +1097,7 @@ Vorher: P1 ist live (Container `webklar-workers` läuft, Sammlungen angelegt), u
3. **`.env`.** Zeile `WORKERS_KAUF_ENABLED=off` ergänzen (Schalter zunächst aus). Prüfung: `rg -n '^WORKERS_KAUF_ENABLED' .env` zeigt genau eine Zeile.
4. **Compose, Dienst `kundenbereich-server`.** Unter `environment:` zwei Zeilen, unter `volumes:` eine Zeile ergänzen:
```yaml
# Workers-Kaufweg (2026-10-17): Seite "Website uebernehmen" zuerst aus; Vorschau-Dateien lesend aus dem Workers-Volume
# Hustler-Kaufweg (2026-10-17): Seite "Website uebernehmen" zuerst aus; Vorschau-Dateien lesend aus dem Volume workers_data
- WORKERS_KAUF_ENABLED=${WORKERS_KAUF_ENABLED:-off}
- WORKERS_VORSCHAU_ROOT=/workers-vorschau/vorschau
```
@@ -1110,7 +1114,7 @@ Vorher: P1 ist live (Container `webklar-workers` läuft, Sammlungen angelegt), u
2. `.env`: `WORKERS_KAUF_ENABLED=on`, dann `docker compose up -d --no-deps kundenbereich-server`.
3. Testangebot: `docker exec -w /app webklar-kundenbereich-server timeout 60 node --max-old-space-size=256 scripts/workers-kauf-testangebot.mjs --email <testadresse> --firma "Testbetrieb GmbH" --vorschau $T` – der Befehl druckt den Link und die Angebots-ID.
4. Link auf Handy und am Desktop in einem privaten Fenster öffnen (eine offene Kundenansicht im Browser sperrt das Bestellen mit 403); Seite prüfen (Betrieb, Vorschau-Link, Angebotssatz, Bedingungen, keine waagerechte Scrollleiste bei 360 px Breite, Dunkelmodus lesbar), bestellen, bei Stripe bezahlen.
5. Prüfung: Rücksprung auf „Vielen Dank“; in Stripe tragen Kunde und Abo `webklarCustomerId`; im WOMS gibt es den Kunden (Status `customer`) mit Projekt „Testbetrieb GmbH“ (Vorschau erreichbar nach Anmeldung); Postfach der Testadresse: K44 (mit Passwort) und K01; Team-Postfach: T02; `workersOffers.status = bezahlt`; Anmeldung mit den K44-Daten klappt; Passwort im WOMS-Kundenblatt leer.
5. Prüfung: Rücksprung auf „Vielen Dank“; in Stripe tragen Kunde und Abo `webklarCustomerId`; im WOMS gibt es den Kunden (Status `customer`) mit Projekt „Testbetrieb GmbH“ (Vorschau erreichbar nach Anmeldung); Postfach der Testadresse: K44 (mit Passwort) und K01; Team-Postfach: T02 und kein Hinweis mit Betreff „[Hustler] …“; `workersOffers.status = bezahlt`; Anmeldung mit den K44-Daten klappt; Passwort im WOMS-Kundenblatt leer.
6. Aufräumen: Abo bei Stripe beenden bzw. widerrufen; Kunde im WOMS löschen; `docker exec -w /app webklar-kundenbereich-server timeout 60 node --max-old-space-size=256 scripts/workers-kauf-testangebot.mjs --loeschen <angebotsId>` (löscht nur Angebote, deren ID mit `t-` beginnt); Test-Repo `WEBklar/testbetrieb-gmbh` im WOMS-Projekt archivieren; Ordner `docker exec webklar-workers rm -r -- /data/vorschau/$T`.
10. **Handfälle** (kommen per T25-Mail): *E-Mail gehört schon einem Kunden* – im Stripe-Dashboard am Abo und am Kunden `webklarCustomerId=<ID des bestehenden Kunden>` setzen, dann das Ereignis `checkout.session.completed` erneut senden: der Handler setzt am Kunden fort (Nachweis, Projekt, Abschluss). Hat dieser Kunde im WOMS noch ein gespeichertes Anfangspasswort, geht es per K44 an die im Kundendokument hinterlegte Adresse (nicht an die Käufer-Adresse); sonst entfällt K44. Prüfe vorher, dass der Kunde wirklich dieselbe Person ist. *Doppelte Zahlung* – die zweite Zahlung bei Stripe erstatten und das zweite Abo beenden. *Vorschau nicht übernommen* – Dateien prüfen, Ereignis erneut senden.
10. **Handfälle** (kommen per T25-Mail, Betreff „[Hustler] …“): *E-Mail gehört schon einem Kunden* – im Stripe-Dashboard am Abo und am Kunden `webklarCustomerId=<ID des bestehenden Kunden>` setzen, dann das Ereignis `checkout.session.completed` erneut senden: der Handler setzt am Kunden fort (Nachweis, Projekt, Abschluss). Hat dieser Kunde im WOMS noch ein gespeichertes Anfangspasswort, geht es per K44 an die im Kundendokument hinterlegte Adresse (nicht an die Käufer-Adresse); sonst entfällt K44. Prüfe vorher, dass der Kunde wirklich dieselbe Person ist. *Doppelte Zahlung* – die zweite Zahlung bei Stripe erstatten und das zweite Abo beenden. *Vorschau nicht übernommen* – Dateien prüfen, Ereignis erneut senden.
11. **Zurücknehmen.** `WORKERS_KAUF_ENABLED=off` in `.env`, `docker compose up -d --no-deps kundenbereich-server`; die Links antworten sofort 404. Der Webhook-Zweig bleibt wirksam (bezahlte Sitzungen werden weiter verarbeitet).