Workers-Pläne abgeglichen: Ergänzungen in der Übersicht, Harvey-Ideen H1–H5, Graue-Worker-Regel, Plattform-Folgeantworten (P4-T34)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-17 19:52:13 +00:00
1 parent 96a9226435
commit 740089c997
7 files changed
+1551 -668

No files matched your search

@@ -12,17 +12,18 @@
**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änzung zur Übersicht (P5) – bitte in die Übersicht übernehmen
> - **E1 Dateipfade der Seite:** `public/uebernahme.html`, `public/uebernahme.js`, `public/uebernahme-ansicht.js`, `public/uebernahme.css`. Übersicht §8 nennt `public/js/uebernahme.js`; das Portal hat kein `public/js` (Muster `public/auftrag*.js`).
> - **E2 Webhook-Vertrag:** `handleWorkersCheckoutEvent(event, { aboWeg })`. `aboWeg` ist der normale Abo-Zweig (`handleAboEvent` in `routes/abo.js`); so entstehen Spiegel, `abschluss`-Ereignis, T02 und **K01 (Vertragsbestätigung, § 312f BGB)** ohne Doppelbau. K01 fehlt in der Reihenfolge von Übersicht §8 und kommt hinzu.
> - **E3 Neue Namen:** `config.collections.workersSettings` (liest `offerText`), `config.workers = { kaufEnabled, vorschauRoot, vorschauBaseUrl }`; Env `WORKERS_KAUF_ENABLED` (`off`|`on`, Standard `off`), `WORKERS_VORSCHAU_ROOT` (Standard `/workers-vorschau/vorschau`), `WORKERS_VORSCHAU_BASE` (Standard `https://entwurf.project.webklar.com`).
> - **E4 Betrieb (§9):** Der Portal-Container (`kundenbereich-server`) hängt das Volume `workers_data` lesend an `/workers-vorschau` (`- workers_data:/workers-vorschau:ro`).
> - **E5 Auftrag an P4:** Der Vorschau-Server liefert 404, sobald `workersPreviews.convertedProjectId` gesetzt ist (der geheime Link stirbt mit dem Deal). P4 setzt Angebote auf `abgelaufen`, wenn der Interessent auf die Sperrliste kommt (das Portal prüft `workersProspects.status === 'gesperrt'` zusätzlich). P4 lässt den Ordner `/data/vorschau/<token>/` einer übernommenen Vorschau mindestens 7 Tage liegen: Stripe wiederholt Webhooks bis zu 3 Tage, und jede Wiederholung liest die Dateien erneut.
> - **E6 Auftrag an P1/P2:** Das Ereignis `deal` (workerId `system`, ID `eventIdFor('deal', offerId)`) trägt `metaJson` `{ offerId, customerId, productKey, amountCents, kundenart }` und dient Spiel und Spur. Kennzahlen: `newAbosMonth` und `mrrNewEur` liest P1 schon aus `workersOffers` (`status 'bezahlt'`, `paidAt` im Monat); P5 setzt genau diese Felder. **`dealsMonth` muss P1 ebenso aus `workersOffers` zählen** (Anzahl `bezahlt` im Monat). P1 summiert bisher `workersDaily.deals`, und das erhöht das Portal nie, die Kennzahl bliebe 0.
> - **E7 Mail-Kennungen:** K44 „Website übernommen“ und T25 „[Workers] …“ (Team-Hinweis, Betreff-Präfix `[Workers]`; der Präfix-Test in `mailTemplates.test.js` wird erweitert).
> - **E8 `createWorkersCheckout(offer, consent, buyer, { token })`:** vierter, optionaler Parameter; der Token steht nur im Fragment der Abbruch-Adresse.
> - **E9 Rechtsrunde:** Neue Wortlaute (Kundenart-Auswahl, Stripe-Hinweistext, Zitat des Angebotssatzes) brauchen Kensos Freigabe vor `WORKERS_KAUF_ENABLED=on`. Der Wortlaut der Bestellung selbst ist Fassung v5 (`2026-09-30-v5`), Wort für Wort aus `public/app.js`.
> - **E10 Nur Hosting (Auftrag an P1/P4):** P5 verkauft ausschließlich `productKey: 'hosting'`; jedes andere Produkt ergibt die neutrale 404-Antwort. Grund: Die Stundengutschrift der ersten Wartungs-Rechnung (`invoice.paid`) trifft vor der Verknüpfung ein und ginge verloren. P1 beschränkt `offerProductKey` in den Einstellungen auf `hosting`, P4 legt keine Angebote mit anderem Produkt an, bis das nachgerüstet ist.
> ### Ergänzungen zur Übersicht (P5) – übernommen, siehe Übersicht §8, §11
> Alle zehn Ergänzungen stehen in der Übersicht (§11.1 `P5-E1` bis `P5-E10`, dazu §11.2 A11–A14); die Zeilen unten sind nur Merkzettel mit Verweis, im Zweifel gilt die Übersicht.
> - **E1 Dateipfade der Seite:** `public/uebernahme.{html,js,css}` und `public/uebernahme-ansicht.js` (das Portal hat kein `public/js`) – übernommen, siehe Übersicht §8, §11, P5-E1.
> - **E2 Webhook-Vertrag:** `handleWorkersCheckoutEvent(event, { aboWeg })`, `aboWeg` = `handleAboEvent`; K01 (Vertragsbestätigung, § 312f BGB) kommt über den normalen Abo-Weg – siehe Übersicht §8, §11, P5-E2.
> - **E3 Neue Namen:** `config.collections.workersSettings` (liest `offerText`), `config.workers = { kaufEnabled, vorschauRoot, vorschauBaseUrl }`, Env `WORKERS_KAUF_ENABLED` (Standard `off`), `WORKERS_VORSCHAU_ROOT`, `WORKERS_VORSCHAU_BASE` – siehe Übersicht §8, §11, P5-E3.
> - **E4 Betrieb:** Der Portal-Container hängt `workers_data:/workers-vorschau:ro` an – siehe Übersicht §9, §11, P5-E4.
> - **E5 Auftrag an P4 (A14):** Vorschau-Server 404 ab `convertedProjectId`; Angebote `abgelaufen` bei Sperre (das Portal prüft `workersProspects.status === 'gesperrt'` zusätzlich); Ordner einer übernommenen Vorschau mindestens 7 Tage (Stripe wiederholt Webhooks bis zu 3 Tage), Verzeichnisse 0755, Dateien 0644 – übernommen, siehe Übersicht §4.13, §11, P5-E5.
> - **E6 Auftrag an P1/P2 (A12, A13):** Ereignis `deal` (workerId `system`, ID `eventIdFor('deal', offerId)`, `metaJson` laut Übersicht §5); `newAbosMonth`, `mrrNewEur` **und** `dealsMonth` zählt P1 aus `workersOffers` (`status 'bezahlt'`, `paidAt` im Berliner Monat) – Auftrag an P1, nicht mehr offen; P5 setzt genau diese Felder, `paidAt` als ISO-UTC mit `Z` (P5-T12) – übernommen, siehe Übersicht §5, §11, P5-E6.
> - **E7 Mail-Kennungen:** K44 „Website übernommen“ und T25 „[Workers] …“ (Betreff-Präfix `[Workers]`, Präfix-Test in `mailTemplates.test.js` erweitert) – siehe Übersicht §8, §11, P5-E7.
> - **E8 `createWorkersCheckout(offer, consent, buyer, { token })`:** vierter, optionaler Parameter; der Token steht nur im Fragment der Abbruch-Adresse – siehe Übersicht §8, §11, P5-E8.
> - **E9 Rechtsrunde:** Neue Wortlaute (Kundenart-Auswahl, Stripe-Hinweistext, Zitat des Angebotssatzes) brauchen Kensos Freigabe vor `WORKERS_KAUF_ENABLED=on`; der Bestellwortlaut ist Fassung v5 (`2026-09-30-v5`), Wort für Wort aus `public/app.js` – siehe Übersicht §11, P5-E9.
> - **E10 Nur Hosting (A11):** P5 verkauft ausschließlich `productKey: 'hosting'`, jedes andere Produkt ergibt die neutrale 404-Antwort (die Stundengutschrift der ersten Wartungs-Rechnung, `invoice.paid`, träfe vor der Verknüpfung ein und ginge verloren). P1 und P4 setzen es um: P1 beschränkt `offerProductKey` auf `hosting`, P4 legt nur solche Angebote an – siehe Übersicht §4.12, §11, P5-E10.
## Global Constraints
@@ -278,13 +279,13 @@ git commit -m "feat(workers-kauf): Vorschau-Ordner sicher lesen" -m "Co-Authored
**Interfaces:**
- Consumes: `startAppwriteMock`, `startFakeStripe`, `useTestEnv`, `startTestApp`/`get`/`post`/`send`/`postWebhook`, `mailSink`, `startFakeGitea`, `neuerToken`.
- Produces: `WORKERS_PORTAL_COLLECTIONS: Record<string, string[]>` (Attribut-Schlüssel nach Übersicht §4.1, §4.2, §4.7, §4.12, §4.13; Kopf-Kommentar: Spiegel, Wahrheit ist `src/schema.js` des Dienstes); `schemaKeys(name)` kennt `workersOffers|workersProspects|workersPreviews|workersEvents|workersSettings`; `gitea.clear()` entfernt im Nachbau alle Repos, Dateien, Hooks und aufgezeichneten Aufrufe.
- Produces: `startWorkersUmgebung({ kaufAn = true } = {}): Promise<U>` mit `U = { aw, st, gitea, mails, vorschauRoot, config, http: { get, rawGet, post, send, postWebhook }, startApp(routers), seedAngebot(over?), seedEinstellungen(offerText), kaufBody(over?), lateStart(), mitschneiden(), reset(), stop() }`. `mitschneiden(): () => string[]` ersetzt `console.log/warn/error` bis zum nächsten `reset()` und liefert die gesammelten Zeilen (für die Tests „kein Token, keine E-Mail, kein Passwort im Log“). Konvention: **jeder Test beginnt mit `await u.reset()`** (löscht über die Datenbank-Schnittstelle alle Dokumente der beteiligten Sammlungen, leert Stripe-Maps und -Aufrufe, Mail-Senke, `gitea.clear()` und den Inhalt von `vorschauRoot`). `seedAngebot({ nr = 1, email='kaeufer@example.invalid', firma='Müller Bau GmbH', status='offen', tage=30, prospectStatus='angebot', productKey='hosting', previewUrl?, previewDateien={'index.html':'<h1>Müller Bau</h1>','css/site.css':'body{}'}|null }) → { token, offerId:'o<nr>', prospectId:'p<nr>', previewId:'v<nr>', vorschauToken, offer }` legt Interessent, Vorschau-Dokument (und den Ordner unter `vorschauRoot`, wenn `previewDateien` gesetzt) und Angebot an (`tage` negativ = abgelaufen); `kaufBody(over)` liefert einen gültigen POST-Body (`acceptTerms: true`, `termsVersion: config.abo.termsVersion`, `startChoice: 'sofort'`, `lateStartAt: ''`, `kundenart: 'unternehmen'`, `name: 'Max Müller'`, `email: 'kaeufer@example.invalid'`, `companyName: 'Müller Bau GmbH'`, `street: 'Hauptstr. 1'`, `postalCode: '67722'`, `city: 'Winnweiler'`, `country: 'Deutschland'`); `lateStart()` = `lateStartAt().toISOString()`.
- Produces: `WORKERS_PORTAL_COLLECTIONS: Record<string, string[]>` (Attribut-Schlüssel nach Übersicht §4.1, §4.2, §4.7, §4.12, §4.13; Kopf-Kommentar: Spiegel, Wahrheit ist `src/schema.js` des Dienstes; `workersSettings` führt alle Felder aus §4.1 samt der später von P3/P4 angelegten `privacyInteressentenLive`, `sendingHalted`, `sendingHaltedReason`); `schemaKeys(name)` kennt `workersOffers|workersProspects|workersPreviews|workersEvents|workersSettings`; `gitea.clear()` entfernt im Nachbau alle Repos, Dateien, Hooks und aufgezeichneten Aufrufe.
- Produces: `startWorkersUmgebung({ kaufAn = true } = {}): Promise<U>` mit `U = { aw, st, gitea, mails, vorschauRoot, config, http: { get, rawGet, post, send, postWebhook }, startApp(routers), seedAngebot(over?), seedEinstellungen(offerText, { ohneGrenze = false } = {}), kaufBody(over?), lateStart(), mitschneiden(), reset(), stop() }`. `mitschneiden(): () => string[]` ersetzt `console.log/warn/error` bis zum nächsten `reset()` und liefert die gesammelten Zeilen (für die Tests „kein Token, keine E-Mail, kein Passwort im Log“). Konvention: **jeder Test beginnt mit `await u.reset()`** (löscht über die Datenbank-Schnittstelle alle Dokumente der beteiligten Sammlungen, leert Stripe-Maps und -Aufrufe, Mail-Senke, `gitea.clear()` und den Inhalt von `vorschauRoot`). `seedAngebot({ nr = 1, email='kaeufer@example.invalid', firma='Müller Bau GmbH', status='offen', tage=30, prospectStatus='angebot', productKey='hosting', previewUrl?, previewDateien={'index.html':'<h1>Müller Bau</h1>','css/site.css':'body{}'}|null }) → { token, offerId:'o<nr>', prospectId:'p<nr>', previewId:'v<nr>', vorschauToken, offer }` legt Interessent, Vorschau-Dokument (und den Ordner unter `vorschauRoot`, wenn `previewDateien` gesetzt) und Angebot an (`tage` negativ = abgelaufen); `kaufBody(over)` liefert einen gültigen POST-Body (`acceptTerms: true`, `termsVersion: config.abo.termsVersion`, `startChoice: 'sofort'`, `lateStartAt: ''`, `kundenart: 'unternehmen'`, `name: 'Max Müller'`, `email: 'kaeufer@example.invalid'`, `companyName: 'Müller Bau GmbH'`, `street: 'Hauptstr. 1'`, `postalCode: '67722'`, `city: 'Winnweiler'`, `country: 'Deutschland'`); `lateStart()` = `lateStartAt().toISOString()`. `seedEinstellungen(offerText, { ohneGrenze })` (async) legt bzw. ersetzt `workersSettings/main` mit allen Feldern aus Übersicht §4.1 (Standardwerte, darunter `privacyInteressentenLive: false`, `sendingHalted: false`, `sendingHaltedReason: ''`) und dem gegebenen `offerText`; der Satz hat **höchstens 400 Zeichen** (Übersicht §4.1, P1 erzwingt das in den Einstellungen): bei mehr wirft die Hilfe `Error('offerText: höchstens 400 Zeichen (Übersicht §4.1)')`, außer mit `ohneGrenze: true` – das stellt nur im Schutz-Test von P5-T08 ein von Hand geändertes Dokument nach.
- [ ] **Step 1: Failing test `test/workersKaufUmgebung.test.js`**
- [ ] **Step 1: Failing test `test/workersKaufUmgebung.test.js`** (`schemaKeys` aus `./support/schema.js` importieren)
```js
test('Umgebung startet, sät ein Angebot, lehnt unbekannte Felder ab und räumt mit reset() auf', async () => {
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()
@@ -300,6 +301,16 @@ test('Umgebung startet, sät ein Angebot, lehnt unbekannte Felder ab und räumt
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() }
})
```
@@ -310,7 +321,7 @@ Run: `timeout 900 node --max-old-space-size=1024 --test test/workersKaufUmgebung
Erwartet: FAIL `Cannot find module './support/workersKaufUmgebung.js'`.
- [ ] **Step 3: Implementieren**
1. `test/support/workersSchema.js` mit den fünf Schlüssellisten (Feldnamen exakt aus Übersicht §4; keine Pflicht/Typ-Angaben nötig).
1. `test/support/workersSchema.js` mit den fünf Schlüssellisten (Feldnamen exakt aus Übersicht §4; keine Pflicht/Typ-Angaben nötig). `workersSettings` führt alle 30 Felder aus §4.1 auf, darunter die Schalter `privacyInteressentenLive`, `sendingHalted` und `sendingHaltedReason`; das Portal liest davon nur `offerText`, aber der Nachbau lehnt unbekannte Felder ab, und `seedEinstellungen` sät das ganze Dokument. `seedEinstellungen` prüft die 400-Zeichen-Grenze für `offerText` selbst (siehe Interfaces); der Test „über 500 Zeichen → 500“ in P5-T08 bleibt als Schutz und sät mit `ohneGrenze: true`.
2. `test/support/schema.js`: `schemaKeys` liefert für diese fünf Namen `WORKERS_PORTAL_COLLECTIONS[name]` (Zeile vor der `SETUP_SCRIPTS`-Schleife), Kommentar im Kopf ergänzen.
3. `startWorkersUmgebung`: Reihenfolge Nachbauten (`startAppwriteMock({ schema: { workers*…, featureConsents, contractEvents, subscriptions } })`, `startFakeStripe()`, `startFakeGitea()`), `mkdtemp` unter `os.tmpdir()` für `vorschauRoot`, `useTestEnv(...)`, dann `process.env` setzen: `WORKERS_KAUF_ENABLED` (`on`/`off`), `WORKERS_VORSCHAU_ROOT=<vorschauRoot>`, `GITEA_BASE_URL`, `GITEA_API_TOKEN=t`, `GITEA_DEFAULT_OWNER=WEBklar`, `GITEA_ORG=WEBklar`, `PREVIEW_DEPLOY_ROOT=''`, `TRAEFIK_DYNAMIC_DIR=''`, `CHAT_NOTIFY_EMAILS=team@example.invalid`, `SMTP_FROM=portal@example.invalid`, `ABO_TEST_EMAILS=test@example.invalid`; danach erst `config.js`, `testApp.js`, `mailer.js` importieren und `setMailTransportForTests(mailSink())`. `stop()` schließt alles, setzt den Mail-Transport zurück und entfernt nur den eigenen Scratch-Ordner. `reset()` löscht die Dokumente der Sammlungen `customers`, `customerPortalAccess`, `subscriptions`, `contractEvents`, `featureConsents`, `websiteProjects` und `workers*` über `deleteDocument` (der Nachbau hat keine Sammelfunktion), leert `st.customers/subscriptions/checkoutSessions/calls`, `mails.sent`, ruft `gitea.clear()` auf und entfernt die Unterordner von `vorschauRoot` (nicht den Ordner selbst). `gitea.clear()` kommt in `test/helpers/fakeGitea.js` neben `setFile` aus P5-T05 (gleiche Datei, eigene Funktion).
@@ -501,7 +512,7 @@ git commit -m "feat(workers-kauf): Angebot per Token finden und Ansicht bauen" -
**Interfaces:**
- Consumes: `getStripe`, `ensurePrice`, `ensureTestCoupon`, `stripeCustomerFields` (`stripeBilling.js`), `getProduct`, `updateDocument`, `config.abo`.
- Produces: `createWorkersCheckout(offer: object, consent: { termsVersion, termsAcceptedAt: string, startChoice: 'sofort'|'nach_frist', serviceStartAt: Date|null, earlyStartConsentAt: string, consentIp: string, consentUserAgent: string, kundenart: 'unternehmen'|'privat' }, buyer: { name, email, companyName, street, postalCode, city, country }, { token }: { token: string }): Promise<{ id: string, url: string }>` – läuft unter `withLocks(['workers-kauf:' + offer.$id])` (dieselbe Sperre wie der Webhook) und liest das Angebot darin frisch (der übergebene `offer` liefert nur die Kennung); so entstehen bei einem Doppelklick nie zwei offene Sitzungen. Stripe-Kunde: zuerst der `customer` der Vorgängersitzung (`offer.stripeSessionId`, die Stripe-Suche kennt neue Kunden erst nach einer Weile), sonst Suche `metadata['workersOfferId']`, sonst neu angelegt; ein wiederverwendeter Kunde bekommt E-Mail, Name und Anschrift der neuen Angaben (`customers.update`). Beendet eine noch offene Vorgängersitzung (`offer.stripeSessionId`), wirft 409 bei bereits abgeschlossener Vorgängersitzung (`Ihre Zahlung ist bereits eingegangen. Die Zugangsdaten kommen in Kürze per E-Mail.`), wirft 500 bei Angebotssatz > 500 Zeichen, legt die Sitzung an und setzt `workersOffers` auf `{ status: 'checkout', stripeSessionId }`.
- Produces: `createWorkersCheckout(offer: object, consent: { termsVersion, termsAcceptedAt: string, startChoice: 'sofort'|'nach_frist', serviceStartAt: Date|null, earlyStartConsentAt: string, consentIp: string, consentUserAgent: string, kundenart: 'unternehmen'|'privat' }, buyer: { name, email, companyName, street, postalCode, city, country }, { token }: { token: string }): Promise<{ id: string, url: string }>` – läuft unter `withLocks(['workers-kauf:' + offer.$id])` (dieselbe Sperre wie der Webhook) und liest das Angebot darin frisch (der übergebene `offer` liefert nur die Kennung); so entstehen bei einem Doppelklick nie zwei offene Sitzungen. Stripe-Kunde: zuerst der `customer` der Vorgängersitzung (`offer.stripeSessionId`, die Stripe-Suche kennt neue Kunden erst nach einer Weile), sonst Suche `metadata['workersOfferId']`, sonst neu angelegt; ein wiederverwendeter Kunde bekommt E-Mail, Name und Anschrift der neuen Angaben (`customers.update`). Beendet eine noch offene Vorgängersitzung (`offer.stripeSessionId`), wirft 409 bei bereits abgeschlossener Vorgängersitzung (`Ihre Zahlung ist bereits eingegangen. Die Zugangsdaten kommen in Kürze per E-Mail.`), wirft 500 bei Angebotssatz > 500 Zeichen (Stripe-Grenze für Metadaten-Werte; P1 begrenzt `offerText` auf 400, die 500 sind nur der Schutz bei einem von Hand geänderten Dokument), legt die Sitzung an und setzt `workersOffers` auf `{ status: 'checkout', stripeSessionId }`.
- [ ] **Step 1: Failing test `test/workersKaufCheckout.test.js`** (Harness aus P5-T04; jeder Test beginnt mit `await u.reset()` und sät das Angebot neu; `createWorkersCheckout` bekommt jedes Mal das frische Dokument aus der Datenbank)
@@ -563,7 +574,7 @@ test('SEPA nicht aktiv → zweiter Versuch nur mit Karte', async () => {
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', async () => { /* seedEinstellungen('x'.repeat(501)) → rejects mit status 500; kein POST /v1/checkout/sessions in u.st.calls */ })
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**
@@ -835,7 +846,7 @@ git commit -m "feat(workers-kauf): Webhook-Zweig legt Kunde an und verknüpft St
2. **Nachweis:** nur wenn für `customerId` noch keine Zeile `featureConsents` mit `featureKey: 'abo'` existiert (Abfrage `Query.equal('customerId', id)` mit `Query.limit(25)`, dann im Code nach `featureKey` filtern; nur `customerId` hat einen Index): `speichereNachweisFuerRoute({ customerId, art: 'abo', bezug: productKey, texte: uebernahmeNachweisTexte(...), fassungen: { zustimmung: termsVersion, quelle: 'workers-uebernahme' }, kundenart, req: { ip: consentIp, get: (n) => (n.toLowerCase() === 'user-agent' ? consentUserAgent : ''), session: { name: contactName, email } }, at: termsAcceptedAt, wortlaut: 'v5-workers' })`; Fehler → geloggt, T25 „Zustimmungsnachweis nicht gespeichert“ (Hinweis: Zustimmung steht in den Stripe-Abo-Metadaten), Ablauf geht weiter.
3. **Vorschau:** `uebernehmeVorschau({ offer, preview, customerId })`; `VorschauProblem` oder ein Fehler mit `status` 400 bis 499 außer 408 und 429 (Gitea lehnt dauerhaft ab: Token, Vorlage, Rechte) gilt als dauerhaft → T25 „Vorschau konnte nicht übernommen werden“, weiter ohne Vorschau; jeder andere Fehler (ohne `status`, 5xx, 408, 429) wird geworfen (Stripe wiederholt).
4. **K44:** wenn `customers.portalPassword` nicht leer: `sendCustomerMail(customer.email, { template: 'K44', bccTeam: false, values, blocks })` – der Empfänger ist die Adresse des Kundendokuments, nie ein Wert aus der Bestellung, damit ein von Hand verknüpfter Bestandskunde sein Passwort nie an eine fremde Adresse bekommt –, mit dem gespeicherten Passwort, danach `forgetStoredPortalPassword(customerId)`; Mail-Fehler werden geworfen.
5. **Abschluss:** `workersOffers` `{ status: 'bezahlt', paidAt, stripeSessionId, customerId }`; `workersProspects` `{ status: 'deal', customerId, updatedAt }`; `workersEvents`-Dokument mit ID `eventIdFor('deal', offerId)` (409 = schon da): `{ ts, workerId: 'system', kind: 'deal', prospectId, text: 'Deal: <Betrieb> hat die Website übernommen (Hosting-Abo)' (auf 300 Zeichen gekürzt), link: vorschau_url|'', metaJson: JSON.stringify({ offerId, customerId, productKey, amountCents, kundenart }) }`.
5. **Abschluss:** `workersOffers` `{ status: 'bezahlt', paidAt, stripeSessionId, customerId }` (`paidAt` = `new Date().toISOString()`, also UTC mit `Z`, denn P1 filtert damit die Berliner Monatsgrenze für `dealsMonth`, `newAbosMonth` und `mrrNewEur`, A12; ebenso `ts` des Ereignisses); `workersProspects` `{ status: 'deal', customerId, updatedAt }`; `workersEvents`-Dokument mit ID `eventIdFor('deal', offerId)` (409 = schon da): `{ ts, workerId: 'system', kind: 'deal', prospectId, text: 'Deal: <Betrieb> hat die Website übernommen (Hosting-Abo)' (auf 300 Zeichen gekürzt), link: vorschau_url|'', metaJson: JSON.stringify({ offerId, customerId, productKey, amountCents, kundenart }) }`.
- [ ] **Step 1: Failing tests** (in `workersKaufWebhook.test.js` ergänzen, Fehlerpfade in `workersKaufAbschluss.test.js`; jeder Test beginnt mit `await u.reset()`; `u.mitschneiden()` schneidet `console.*` mit)
@@ -861,9 +872,9 @@ test('Gesamtlauf: Seite → Bezahlen → Webhook: alles genau einmal und alles v
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
const o = u.aw.docs('workersOffers').get('o1'); assert.deepEqual([o.status, o.customerId, o.stripeSessionId], ['bezahlt', kunde.$id, sessionId]); assert.ok(o.paidAt)
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'])
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)) }
@@ -1089,13 +1100,13 @@ Vorher: P1 ist live (Container `webklar-workers` läuft, Sammlungen angelegt), u
```yaml
- workers_data:/workers-vorschau:ro
```
Das Volume `workers_data` steht seit P1 oben im Block `volumes:` der Datei. Prüfung: `docker compose config -q && echo gueltig`.
Das Volume `workers_data` steht seit P1 oben im Block `volumes:` der Datei. Prüfung: `docker compose config -q && echo gueltig`. Das Volume wird im Portal **nur lesend** gemountet (`:ro`); damit der Portal-Container die Dateien trotzdem lesen kann, legt P4 Verzeichnisse mit 0755 und Dateien mit 0644 an (Übersicht §9, A14). Prüfung nach dem Start (sobald ein Vorschau-Ordner `<T>` im Volume liegt, spätestens in Schritt 9.1): `docker exec webklar-kundenbereich-server ls -l /workers-vorschau/vorschau/<T>/` zeigt `-rw-r--r--` bei den Dateien (Unterordner `drwxr-xr-x`) und keine Meldung „Permission denied“.
5. **Portal-Container neu anlegen** (nur dieser Dienst): `docker compose up -d --no-deps kundenbereich-server`. Prüfung nach etwa einer Minute (der Start installiert Pakete): `docker exec webklar-kundenbereich-server sh -c 'printenv WORKERS_KAUF_ENABLED; ls /workers-vorschau'` zeigt `off` und den Inhalt des Volumes; `curl -s -o /dev/null -w '%{http_code}\n' https://project.webklar.com/api/health` zeigt 200.
6. **Voraussetzungen prüfen** (einmalig, vor dem Livegang): (a) Gitea kann mehrere Dateien in einem Commit: `curl -s https://git.webklar.com/swagger.v1.json | rg -c 'ChangeFilesOptions'` zeigt mindestens 1. (b) Der bestehende Stripe-Webhook-Endpunkt `https://project.webklar.com/api/abo/webhook` liefert `checkout.session.completed` und `customer.subscription.created|updated|deleted` (Stripe-Dashboard, Entwickler, Webhooks); es ist nichts umzustellen, der Kaufweg nutzt denselben Endpunkt.
7. **Code live bringen.** `cd /home/webklar/apps/kundenbereich.webklar.com/dev-workers-kauf && git fetch origin && git rebase origin/main && timeout 900 node --max-old-space-size=1024 --test test/aboRoutes.test.js` (grün), dann `git push origin feature/workers-kaufweg:main` (Deploy-Hook baut und startet das Portal neu). Prüfung: `curl -s -o /dev/null -w '%{http_code}\n' https://project.webklar.com/api/uebernahme/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA` zeigt 404; `curl -sI https://project.webklar.com/uebernahme.html | head -1` zeigt 200; Serverpuls bleibt grün.
8. **Rechtsrunde (E9).** Vor dem Schalter: die neuen Wortlaute freigeben (Kundenart-Auswahl, Stripe-Hinweis „… auf der Seite „Website übernehmen“ …“, Zitat des Angebotssatzes) und prüfen, dass `workersSettings/main.offerText` denselben Preis nennt wie der Katalog (20 € im Monat, 12 Monate). Ohne deine Freigabe bleibt der Schalter auf `off`.
9. **Manuelle Prüfung mit einem Testkonto** (Stripe live; die Adresse muss in `ABO_TEST_EMAILS` stehen, dann kostet das Abo 0,50 €; wähle „nach Ablauf der Widerrufsfrist“, dann wird heute nichts abgebucht):
1. Vorschau-Ordner im Volume anlegen: `T=$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=') && docker exec webklar-workers sh -c "mkdir -p /data/vorschau/$T && echo '<h1>Test</h1>' > /data/vorschau/$T/index.html" && echo $T`
1. Vorschau-Ordner im Volume anlegen: `T=$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=') && docker exec webklar-workers sh -c "mkdir -p /data/vorschau/$T && echo '<h1>Test</h1>' > /data/vorschau/$T/index.html" && echo $T`. Leserechte des Portals prüfen (Schritt 4): `docker exec webklar-kundenbereich-server ls -l /workers-vorschau/vorschau/$T/` zeigt `-rw-r--r-- … index.html`.
2. `.env`: `WORKERS_KAUF_ENABLED=on`, dann `docker compose up -d --no-deps kundenbereich-server`.
3. Testangebot: `docker exec -w /app webklar-kundenbereich-server timeout 60 node --max-old-space-size=256 scripts/workers-kauf-testangebot.mjs --email <testadresse> --firma "Testbetrieb GmbH" --vorschau $T` – der Befehl druckt den Link und die Angebots-ID.
4. Link auf Handy und am Desktop in einem privaten Fenster öffnen (eine offene Kundenansicht im Browser sperrt das Bestellen mit 403); Seite prüfen (Betrieb, Vorschau-Link, Angebotssatz, Bedingungen, keine waagerechte Scrollleiste bei 360 px Breite, Dunkelmodus lesbar), bestellen, bei Stripe bezahlen.