Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-16-kundenansicht-08-bestellseiten.md

1580 lines
157 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Kundenansicht neu P7: Bestellseiten schlank Implementation Plan
> **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:** Abo bestellen, Wechsel, Zusatzspeicher-Plus und der Gutschrift-Antrag der neuen Ansicht folgen dem schlanken Muster der Spec 5.6 in Fassung 6: Block „Was du bestellst“ direkt über dem Knopf, eine Linkzeile, ein Häkchen nur für Privatkunden, der Knopf „Zahlungspflichtig bestellen“, darunter die Kundenart-Zeile. Der Server entscheidet die Kundenart selbst und speichert den angezeigten Wortlaut als Nachweis. Dazu sprechen in der neuen Ansicht auch die Fehlertexte der Dienste und die öffentlichen Formularseiten Kündigen und Widerrufen „du“ (Spec 5.7, E15, C14). Task 0 verbindet den Wortlaut von P8 (`WORTLAUT_V6`) mit P5 und diesem Paket.
**Architecture:** Die Sätze der Fassung 6 für Linkzeile und Häkchen stammen aus P8 (`WORTLAUT_V6`, `FUNKTIONEN_UNTERLAGEN_V6` in `products.js`). Task 0 stellt P5 (Einschalten, Katalog) darauf um. Die Sätze der Bestellseiten (Linkzeile, Häkchen, Sperrzeile, Knopf, Kundenart-Zeile) stehen einmal importfrei in `server/services/bestelltexte.js`, Gleichheitstests binden Linkzeile und Häkchen an `WORTLAUT_V6`. `GET /api/abo` liefert sie der neuen Ansicht, der Server baut daraus den Nachweis (`vertragsNachweis.js`, Collection `featureConsents`), nie aus Texten des Clients. Die Routen in `server/routes/abo.js` (und der Antrag in `takeoverCredits.js`) verzweigen nur, wenn der Body die Fassung 6 trägt und `neueAnsicht(req)` gilt (`fassungDerAnfrage`). Alle anderen Pfade bleiben bytegleich. Die vier Seiten in `public/neu/` bauen ihren Kopf aus gemeinsamen Bausteinen (`bestell-bausteine.js`) und melden ihren Titel mit `meldeTitel`, sie haben keine eigene `h1`. `duFehler.js` übersetzt bekannte Fehlertexte der Dienste für die neue Ansicht (Task 14), die zwei Formularseiten haben du-Texte hinter dem Attribut `data-anrede="du"` (Task 15).
**Tech Stack:** Node 20 (Host 24), Express 4, Appwrite-REST, Stripe SDK 19; `node --test` mit den Nachbauten aus `test/support/` (`appwriteMock`, `fakeStripe`, `testApp`, `fixtures`, `mailSink`, `fakeDom`); Vanilla-ES-Module unter `public/neu/` ohne Build; Sichtprüfung nur über das Werkzeug aus P0.
**Spec:** `docs/superpowers/specs/2026-10-15-kundenansicht-neu-design.md` (5.6 Bestätigungen schlank, 5.7 Sprache, 4 „Rechtlich fest“, C4, C14, E3, E4, E15)
**Übersicht/Verträge:** /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht/docs/superpowers/plans/2026-10-16-kundenansicht-00-uebersicht.md (gilt wörtlich; dieses Paket liefert V12 komplett)
**Worktree und Branch:** `/home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht`, Branch `feature/kundenansicht-neu` (Hauptzweig, nach dem Merge von Server P5/P6, Recht P8 und UI P1 bis P4). Alle Pfade dieses Plans sind relativ zu diesem Worktree, soweit sie nicht mit `/` beginnen. `KW` = `/root/reports/kundenansicht-neu-2026-10-15/werkzeug` (eigenes Git). Tag am Ende: `kn-p7`.
## Global Constraints
Alle Global Constraints der Übersicht gelten (alte Ansicht unverändert, Schalter, Aussehen mit allen Verboten, Anrede „du“, keine Preise für Funktionen, Hashes, rechtlich fest, Kundenart nur vom Server, Nachweise, Testbefehle mit Speicherbremse, Commits mit `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`, Sicherheitsblock, kein Push, nichts in `build/`). Die Abo-Preise der Bestellseiten sind keine Funktionspreise und bleiben. Paketeigene Zusätze:
- **Weiche:** Fassung 6 gilt nur bei `fassungDerAnfrage(req, body.termsVersion) === 'neu'`. Fehlt `config.abo.termsVersionNeu` oder `termsVersion`, ist das Ergebnis nie `'neu'`. Alle anderen Pfade verhalten sich wie heute (Prüfung: `aboRoutes`, `planSwitch`, `mailStorage`, `takeoverCredits`, `mailTemplates` bleiben grün).
- **Kundenart nie vom Client:** Die Routen lesen weder `kundenart` noch `unternehmer` noch (in Fassung 6) `startChoice` aus dem Body. Quelle ist allein `kundenartWirksam(customerDoc)` (V4) aus dem geladenen Kunden. Ein Lesefehler ist ein 500, nie ein Rückfall auf „Betrieb“.
- **Nachweis zuerst:** Der Nachweis wird vor der unumkehrbaren Handlung geschrieben (Stripe-Sitzung, Wechsel, Speicheränderung, Antrag). Scheitert er: 500 mit `FEHLER_NACHWEIS`, die Handlung unterbleibt. Die Texte kommen nur aus `bestelltexte.js`.
- **Wortlaut ist Entwurf** bis zur Freigabe durch den Inhaber (E20). Quelle von Linkzeile und Häkchen ist `WORTLAUT_V6` aus P8 (`products.js`). Die Fassungsangabe darin ist die Versionszeichenfolge selbst (`Fassung 2026-10-16-v6`, so setzt `mitFassung` sie ein), keine eigene Datumsform. `bestelltexte.js` bleibt importfrei und hält diese Sätze als Kopie; `test/knBestelltexte.test.js` und `test/knVerdrahtung.test.js` pinnen die Gleichheit mit `WORTLAUT_V6`/`mitFassung`, nicht Literale. Ändert P8 oder der Inhaber den Wortlaut, werden diese Tests rot, und `bestelltexte.js` zieht nach. Die übrigen Sätze (Sperrzeile, Warnzeile, Gutschrift-Satz, Kundenart-Zeile, Fehlertexte) liegen nur hier und in `docs/rechtliches/kundenansicht-entscheidungen.md`.
- **Knopf wörtlich** `Zahlungspflichtig bestellen` (Antrag: `Antrag absenden`). Block, Linkzeile, Häkchen und Knopf liegen ohne `details`, `dialog`, Akkordeon oder Scroll-Container hintereinander.
- **Oberflächen-Quelltext:** wie P2 (relative Importe, kein `innerHTML`, `insertAdjacentHTML`, `outerHTML`; Kundentext nur über `h()`; Adressen aus Linkzeilen nur über `sicherLink`). Anrede „du“ in allen vier Seiten, einzige Ausnahme der Block `TAKEOVER_CONDITIONS` (Rechtstext, hash-gepinnt).
- **Keine eigene `h1`** in den vier Ansichten und in `bestell-bausteine.js` (P2 „Keine eigene `h1`“, P1 VF11: sonst stehen in Etage 2 zwei Titel). Den Titel meldet die Ansicht mit `meldeTitel(wurzel, text)` aus `./ansicht-hilfen.js` (Ereignis `kn:titel`): Produktname ohne `WEBklar ` beim Bestellen, `Wechsel ins Wartungs-Abo`, `Bedingungen der Übernahmegutschrift`. Abschnittsüberschriften sind `h2`.
- **Dienste-Fehlertexte** (`planSwitch.js`, `mailStorage.js`, `stripeBilling.js` und die Antworten der Abo-Routen) bleiben im Quelltext Sie-Form, damit die alte Ansicht unverändert bleibt. In Sitzungen mit neuer Ansicht übersetzt `duFehler.js` die bekannten Sätze vor der Antwort in „du“ (Task 14). Neue Prüfungen dieses Pakets sprechen „du“.
- **Testkopf Server** wie `test/aboRoutes.test.js:1-45`: Nachbauten starten, `useTestEnv`, dann `Object.assign(process.env, { KUNDENANSICHT_NEU: 'pilot', KUNDENANSICHT_NEU_PILOT_EMAILS: 'K@example.invalid, k3@example.invalid' })`, danach `config` dynamisch importieren. c1 (`k@`) und c3 (`k3@`) sind Pilot, c2 (`k2@`) nicht. Schema mindestens `subscriptions`, `contractEvents`, `aboOffers`, `hourCredits`, `featureConsents` (alle über `schemaKeys`). Die Fassung 6 heißt in Tests und Fixtures immer `config.abo.termsVersionNeu` und ihre Adresse `termsDocFor(config.abo.termsVersionNeu).url` (`products.js`, P8), nie ein Literal. Einzige Ausnahme ist die gebündelte Werkzeug-Fixture, die als Kopie ein Test an den Server bindet (Task 16). Hilfe `setzeKundenart(id, wert)` = `aw.seed('customers', { ...customers[id], ...(wert === undefined ? {} : { kundenart: wert }) })`.
- **Testcode in diesem Plan** zeigt Namen und Assertions. Importe, Hilfsfunktionen und Aufräumen (`after`, Rücksetzen von `config`-Werten) schreibt der Implementierer dazu.
## Review Focus
1. **Server nimmt Kundenart oder Beginn vom Client** (Übersicht-Review-Focus 1): Ein Betrieb sendet `kundenart: 'privat'`, `startChoice: 'nach_frist'`, `earlyStart: true`; ein Privatkunde sendet `kundenart: 'unternehmen'`. Erwartet: Ergebnis und Nachweis folgen allein dem Kundendokument, leer und unbekannt gelten als privat. Tests: Task 4 „Bestellung Fassung 6“ (Checkout), 5 (Wechsel), 7 (Speicher), 8 (Quelltext-Pin und Gutschrift).
2. **Falsche Fassung nimmt den neuen Weg oder den alten verändert:** Fassung 6 aus der alten Ansicht oder von Nicht-Pilot-Kunden, Fassung 5 aus dem Pilot, fehlende oder unbekannte `termsVersion`, fehlendes `config.abo.termsVersionNeu`. Erwartet: 409 ohne Schreibzugriff bzw. der alte Weg wie heute, `GET /api/abo` der alten Ansicht mit denselben neun Schlüsseln. Tests: Task 2, 3, 4, 5, 7, 8.
3. **Nachweis fehlt, lügt, kommt aus dem Client oder weicht vom Wortlaut der Fassung 6 ab:** Der Nachweis enthält genau die angezeigten Konstanten in Anzeigereihenfolge samt Kundenart zum Klickzeitpunkt, das Beginn-Häkchen nur, wenn es gezeigt und gesetzt war. Er wird vor der Handlung geschrieben; scheitert er, passiert nichts. Linkzeile und Häkchen der Bestellseiten sind Wort für Wort der Satz aus `WORTLAUT_V6`, und P5 zeigt und speichert beim Einschalten dieselbe Fassung und die Unterlagen der Fassung 6 statt der Alt-Unterlagen mit Preisen. Tests: Task 0, 1, 2, 4, 5, 7, 8.
4. **Betrieb muss ein Häkchen setzen oder Privat kommt ohne durch:** Betrieb: Wechsel ohne Zeitstempel, Grund `kundenart` im Ereignis, kein Häkchen in der Oberfläche. Privat und leer: Wechsel ohne Häkchen ist 400 und der Knopf gesperrt, Abo ohne Häkchen beginnt nach der Frist. Die Bestätigungsmails behaupten für Betriebe kein „ausdrücklich verlangt“. Tests: Task 4, 5, 6, 10, 11.
5. **Kerninfo und Knopf getrennt, Knopftext verändert, fremde Adresse in der Linkzeile:** Klappe, Sheet oder Scrollbereich zwischen Block und Knopf, abweichender Knopftext, `javascript:` oder `//host` in einer Linkzeile, fehlender Wortlaut (`bestelltexte` fehlt) mit sichtbarem Knopf. Erwartet: keine Klappe, Knopf wörtlich, nur sichere Adressen, ohne Wortlaut nur der Satz „wird gerade eingerichtet“. Tests: Task 9, 10, 11, 12, 13 und Werkzeug-Gate in Task 16.
6. **Sie-Text in der du-Oberfläche und doppelter Titel:** Fehlertexte von Bestellung, Wechsel und Speicher („Bitte laden Sie die Seite neu“), die öffentlichen Seiten Kündigen und Widerrufen samt Meldung bei falscher Auftragsnummer und bei falscher Paketzahl, eine eigene `h1` neben dem Titel der Hülle. Erwartet: In der neuen Ansicht steht kein „Sie“ und genau ein Titel, in der alten bleibt jeder Text wie heute. Tests: Task 10, 11, 13 (Titel), 14, 15.
## Festlegungen dieses Plans
Diese Punkte legen weder Spec noch Übersicht fest.
**Beginn-Regeln** (Server, nur Fassung 6; `earlyStart` ist nur dann gültig, wenn es exakt `true` ist):
| Kundenart (Server) | `earlyStart` im Body | Checkout: `startChoice` / `earlyStartConsentAt` | Wechsel |
|---|---|---|---|
| `unternehmen` | egal, wird ignoriert | `sofort` / `''` | ohne Zeitstempel (`''`), `details.kundenart: 'unternehmen'` im Ereignis |
| `privat`, leer, unbekannt | `true` | `sofort` / jetzt | Zeitstempel jetzt |
| `privat`, leer, unbekannt | fehlt, `false` oder kein Boolean | `nach_frist` / `''` | 400 `WECHSEL_GESPERRT`, nichts geschrieben |
**Zuordnung Spec 5.6 (Tabelle der heutigen Häkchen):**
| Zeile | Neu | Server | Oberfläche |
|---|---|---|---|
| A1 Abo „gelesen und stimme zu …“ | Linkzeile; der Klick auf den Knopf sendet `acceptTerms: true` | Task 4 | Task 10 |
| A2 Abo Sofort / nach Frist | Betrieb: sofort ohne Auswahl. Privat/leer: ein Häkchen, ohne Häkchen `nach_frist` | Task 4 | Task 10 |
| B1 Wechsel AGB/AVV | Linkzeile | Task 5 | Task 11 |
| B2 Wechsel Sofortbeginn | nur Privat/leer, Pflicht; Knopf gesperrt mit kurzer Zeile; Betrieb ohne Zeitstempel | Task 5, 6 | Task 11 |
| C1 Zusatzspeicher Regeln + Belehrung | Linkzeile, kein Häkchen; der Plus-Klick sendet `acceptTerms: true` | Task 7 | Task 12 |
| C3 Warnbox beim Verkleinern | eine Zeile `#storage-warnung` | | Task 12 |
| D2 Gutschrift „Ich versichere …“ | Satz über dem Knopf mit Datenschutz-Link; Bedingungen als Link auf `#gutschrift?bedingungen=1` (neuer Tab) statt Klappe | Task 8 | Task 13 |
| 5.6 Punkt 5 | `Du bestellst als Betrieb · Stimmt nicht? Schreib uns im Chat` unter dem Knopf | | Task 9 bis 12 |
| E1/E2 Kündigen und Widerrufen | gleicher Ablauf, Texte in du | | Task 15 |
| 5.7 Fehlertexte der Dienste | in der neuen Ansicht „du“ | Task 14 | zeigen Task 10 bis 13 |
**Weitere Festlegungen:**
- **Block „Was du bestellst“** (Zeilen in dieser Reihenfolge, nur diese): Leistung, Preis (mit „gemäß § 19 UStG ohne Umsatzsteuer“), Fällig, Beginn, Laufzeit (Mindestlaufzeit und Kündigung), Zahlung. Die Zeile „Anbieter“ und die Zeile „Abrechnung“ entfallen (Spec 5.6 nennt sie nicht; Vertragsfrage VF7). Beim Wechsel stehen zusätzlich Zusatzspeicher, Gutschrift und Neuer Abbuchungstag, beim Speicher-Plus die Zeilen der heutigen Zusammenfassung.
- **Kerninfo auf einem Bildschirm:** Vom Anfang des Blocks bis zum Ende des Knopfs liegen höchstens `innerHeight` Pixel (Werkzeug-Prüfung bei 390 x 844). Reicht es nicht, werden Zeilen zusammengefasst, nicht Pflichtinhalte gestrichen.
- **Nachweis-Zeile `bestellung`:** eine vom Server gebaute Zusammenfassung aus Werten, die der Server selbst kennt oder geprüft hat (`bestellZeile`). `bezug` und `wortlaut` stehen in `fassungen` (Vertragsfrage VF4). `stripeSessionId` bleibt leer (VF5).
- **Gutschrift-Bedingungen:** Der Block `TAKEOVER_CONDITIONS` bleibt Zeichen für Zeichen in `ansicht-gutschrift.js`, `config.takeover.conditionsVersion` bleibt `2026-09-30`, der Hash-Pin `test/legalTexts.test.js:404-419` bleibt gültig. Nur die Darstellung wechselt (eigene Ansicht statt Klappe).
- **Fassungsangabe:** Alle Sätze nennen die Fassung als Versionszeichenfolge, wie P8 sie mit `mitFassung(…, config.abo.termsVersionNeu)` einsetzt (heute `Fassung 2026-10-16-v6`). Die frühere Form `Fassung 6 vom 30.10.2026` und die Funktion `fassungLabel` entfallen. Das Datum der Fassung setzt P8, bei der Freigabe ändert es sich; Code und Tests lesen es aus `config`.
- **Kündigen und Widerrufen (C14 geklärt):** Die zwei Seiten speichern keinen Wortlaut und keine `termsVersion`. Die Vertragsereignisse `kuendigung` und `widerruf` tragen nur Codes (`contract`, `kind`, `order`, `effectiveAt`), der Nachweis sind die Mails K03/K04, und die bleiben Sie-Form (Mail-Runde, Spec 5.7). Die Umstellung auf du braucht deshalb keine neue `termsVersion`. Sie kommt mit Fassung 6 in diesem Paket (Task 15), über das Attribut `data-anrede="du"` am Formular. Die alten Seiten ohne Attribut verhalten sich byteweise wie heute.
- **Fehlertexte der Dienste:** `duFehler.js` übersetzt nur exakte, bekannte Sätze (Tabelle plus ein Muster für die Paketzahl). Unbekannte Texte bleiben unverändert; ein Test sucht in den drei Diensten nach Sie-Sätzen ohne Übersetzung. `offers.js` enthält keine Sie-Sätze (Prüfung mit `command grep -an "Sie\b\|Ihr" server/services/offers.js`).
## Dateiübersicht
Quelle: `Tabelle` = Zuständigkeitstabelle der Übersicht, `P2-VF` = vorgeschlagene Übernahme laut der P2-Vertragsfrage „Fassung 6 für Speicher und Gutschrift“, `VFn` = Vertragsfrage unten.
| Datei | Quelle | Aufgabe | Task |
|---|---|---|---|
| `server/config.js` (nur `aboFunktionen.fassung`, `.texte.beginn`), `server/services/featureRegistry.js` (`consentAbo`), `server/services/aboKatalog.js` (`unterlagen`, `hinweis`), `public/neu/funktion-einschalten.js` (nur `sichereAdresse`), `test/knVerdrahtung.test.js` (neu) | VF2, VF12 | P5 mit dem Wortlaut und den Unterlagen aus P8 verdrahten | 0 |
| `server/services/bestelltexte.js` (neu) | VF1 | Wortlaut-Entwurf, Nachweistexte, Nutzlast für den Client | 1 |
| `server/services/vertragsNachweis.js` (neu) | Tabelle | `fassungDerAnfrage`, `speichereVertragsNachweis` | 2 |
| `server/routes/abo.js` (nur Pfade Fassung 6: `GET /`, `POST /checkout`, `/switch`, `/storage`, `recordAbschluss`, `sendContractConfirmation`) | Tabelle | Server-Regeln V12 | 3 bis 7 |
| `server/services/planSwitch.js` (`switchToWartung` Z. 347-351, 388, 483; `performSwitch` Ereignis-`details`, K12-Aufruf) | Tabelle | Betrieb ohne Zeitstempel | 5, 6 |
| `server/services/stripeBilling.js` (`createAboCheckout` Z. 606-607) | VF2 | Fassung aus `consent.termsVersion` | 4 |
| `server/mail-templates/K12 Wechsel Wartungs-Abo.html` | VF2 | Block `sofortverlangen` | 6 |
| `server/routes/takeoverCredits.js` (`POST /`, Z. 74-93) | VF2 | Nachweis des Antrags | 8 |
| `public/neu/bestell-bausteine.js` (neu), `public/neu/ansichten.css` (Anhang), `test/support/knBestellDaten.js` (neu) | VF1 | gemeinsame Bausteine der vier Seiten | 9 |
| `public/neu/ansicht-bestellen.js`, `ansicht-wechsel.js` | Tabelle | schlankes Muster | 10, 11 |
| `public/neu/ansicht-speicher.js`, `ansicht-gutschrift.js` | P2-VF | schlankes Muster | 12, 13 |
| `server/services/duFehler.js` (neu), `server/routes/abo.js` und `server/routes/takeoverCredits.js` (je eine Zeile `router.use`) | VF1, VF2 | bekannte Fehlertexte der Dienste in der neuen Ansicht in du | 14 |
| `public/neu/oeffentlich/kuendigen.html`, `widerrufen.html`, `public/legal-form.js`, `public/auftrag-widerruf.js` (nur der Zweig `data-anrede="du"`) | VF2, VF13 | Kündigen und Widerrufen in du | 15 |
| `KW/` `views.mjs`, `gates.mjs`, `fixtures/endpunkte-p7.mjs`, `fixtures/faelle-p7.mjs`, `vertrag-endpunkte.mjs`, `test/bestelltexte.test.mjs` | Tabelle (P0 legt an) | Ansichten, Gate `p7`, Fälle | 16 |
| `test/kn*.test.js` (17 Dateien: 13 neu, 4 ersetzt, siehe Tasks) | VF1 | Tests dieses Pakets | 0 bis 15 |
**Pins und Tests, die dieses Paket anpasst** (Fundstellen der P2-Dateien per `command grep -an` ermitteln, sie entstehen erst mit dem Merge von P2):
- `test/mailTemplates.test.js:379`: Eintrag `'Sie haben ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit dem Wartungs-Abo beginnen'` im K12-Test wandert in eine Prüfung mit `{ sofortverlangen: true }` (Task 6).
- P5-Tests `test/aboKatalogRegister.test.js` (Linkzeile `Fassung <lb.version>`), `test/aboFunktionenListe.test.js` (Schlüsselliste um `hinweis`, `unterlagen` aus `FUNKTIONEN_UNTERLAGEN_V6`) und jeder weitere Treffer von `command grep -aln "Es gelten Leistungsbeschreibung\|Es gilt die Leistungsbeschreibung" test/*.test.js`: Aussagen bleiben, nur die Quelle der Werte wechselt (Task 0). Dazu `test/knEinschaltenLogik.test.js` (P3): die Gut-Liste von `sichereAdresse` bekommt eine Adresse unter `/neu/rechtliches/`.
- `test/knAnsichtOeffentlich.test.js` (P2 Task 17): für `kuendigen.html` und `widerrufen.html` entfällt der Vergleich des sichtbaren Texts mit der alten Seite; Feldattribute und Skripte werden weiter verglichen, der Text steht in `test/knOeffentlichDu.test.js` (Task 15).
- `test/knAnsichtBestellen.test.js`, `knAnsichtWechsel.test.js`, `knAnsichtSpeicher.test.js`, `knAnsichtGutschrift.test.js` (P2): werden ersetzt; die Orakel-Vergleiche gegen `app.js` entfallen, die übrigen Fälle bleiben (Tasks 10 bis 13). `knAnsichtOrakel.test.js` bleibt unverändert.
- `test/knAnsichtQuelltext.test.js` (P2): die Liste `WORTLAUT` wird leer, ausgenommen bleibt nur der Block `TAKEOVER_CONDITIONS` (Task 13).
- `KW/test/backend.test.mjs` (Test „Fälle in fester Reihenfolge“): zwei Fälle werden angehängt (Task 16). P2 hat keine Fälle ergänzt (P2 VF7), die Pin-Änderung gehört P7 allein.
**Bestehende Tests, die grün bleiben müssen** (nur Lauf): `test/aboRoutes.test.js:143-153` (Sitzungs-Metadaten ohne Zustimmungsdaten), `test/planSwitch.test.js:238`, `:254`, `:494-498` (400 ohne Zeitstempel), `:588`, `test/mailStorage.test.js:1057-1070`, `test/legalTexts.test.js:404-419`, `test/products.test.js`, `test/contractEvents.test.js`, `test/aboWortlautV6.test.js` (P8; ab Task 0 ohne übersprungenen Verdrahtungstest), `test/publicForms.test.js:1439-1465`, `test/auftraegeKundenportalWiderruf.test.js`, `test/auftraegeAblauf.test.js:288` (lesen die alten Seiten und Skripte, die unverändert bleiben), `test/auftraegeKundenportalAnsicht.test.js:188` (`TEXTE_DU` unverändert).
---
### Task 0: Verdrahtung P5 ↔ P8 ↔ P7
P5 zeigt beim Einschalten heute einen Entwurf des Häkchens und die Alt-Unterlagen mit Preisen (`config.features[key].leistungsbeschreibung`), P8 liefert die Fassung 6 (`WORTLAUT_V6`, `FUNKTIONEN_UNTERLAGEN_V6`), und der Verdrahtungstest in `test/aboWortlautV6.test.js` wird nach dem Merge von P5 rot (P8 Vertragsfrage 2, P5 Vertragsfrage 7). Dieser Task verbindet die drei, bevor die Bestellseiten gebaut werden.
**Files:**
- Modify: `server/config.js` (nur Abschnitt `aboFunktionen`: `fassung`, `texte.beginn`), `server/services/featureRegistry.js` (`consentAbo`), `server/services/aboKatalog.js` (`katalogFuerKunde`: `unterlagen`, neues Feld `hinweis`), `public/neu/funktion-einschalten.js` (nur `sichereAdresse`, falls nötig)
- Test: `test/knVerdrahtung.test.js` (neu); angepasst: `test/aboKatalogRegister.test.js`, `test/aboFunktionenListe.test.js` und jeder weitere Treffer aus Step 2, `test/knEinschaltenLogik.test.js` (P3, Gut-Liste von `sichereAdresse`)
**Interfaces:**
- Consumes: `WORTLAUT_V6`, `FUNKTIONEN_FASSUNG_V6`, `FUNKTIONEN_UNTERLAGEN_V6`, `mitFassung` (P8 Task 3, `server/services/products.js`); `consentAbo`, `consentAboAktiv` (`featureRegistry.js`), `katalogFuerKunde` (`aboKatalog.js`), `config.aboFunktionen` (P5 Task 6 und 11).
- Produces:
- `config.aboFunktionen.fassung === FUNKTIONEN_FASSUNG_V6` und `config.aboFunktionen.texte.beginn === WORTLAUT_V6.beginn.einrichtung`, beide als Literale (`config.js` importiert keine Dienste; die Tests binden die Gleichheit).
- `consentAbo(key).items[0].text` = `mitFassung(WORTLAUT_V6.linkzeile[FUNKTIONEN_UNTERLAGEN_V6[key].avv ? 'einschaltenMitAvv' : 'einschaltenOhneAvv'], config.aboFunktionen.fassung)`. Alles andere an `consentAbo` bleibt (`null` für `analyse` und unbekannte Schlüssel, `version`, `knopf`, Häkchen). Derselbe Text geht über `consentAboAktiv` in den Nachweis und über `katalog[].consent` an die Oberfläche.
- `katalog[].unterlagen` = `{ leistungsbeschreibung: { version: FUNKTIONEN_FASSUNG_V6, url }, avv: { version: FUNKTIONEN_FASSUNG_V6, url } | null }` aus `FUNKTIONEN_UNTERLAGEN_V6[key].lb|avv` für news, termine, shop, unterkunft, zeiten. **`analyse` bleibt bei `legalPaths(config.analyse.avvVersion)`** (Alt-Unterlagen), bis der Inhaber die Funktionsunterlagen freigibt (Vertragsfrage VF12).
- `katalog[].hinweis` = `WORTLAUT_V6.hinweis[key]` (shop, unterkunft, termine), sonst `null`.
- [ ] **Step 1: Voraussetzungen des Pakets prüfen und Bezugspunkt setzen**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && git status --short && git tag --list 'kn-p*' && ls server/services/kundenansicht/schalter.js server/services/kundenart.js server/services/aboKatalog.js server/services/featureRegistry.js public/neu/ansicht-bestellen.js public/neu/ansicht-wechsel.js public/neu/ansicht-speicher.js public/neu/ansicht-gutschrift.js public/neu/ansicht-hilfen.js public/neu/funktion-einschalten.js test/support/knDaten.js docs/rechtliches/kundenansicht-entscheidungen.md && command grep -an "termsVersionNeu" server/config.js && command grep -an "aboFunktionen" server/config.js | head -3 && command grep -an "export function aktuelleAboFassung\|export const FASSUNGEN\|export const WORTLAUT_V6\|export const FUNKTIONEN_UNTERLAGEN_V6\|export function mitFassung" server/services/products.js && command grep -an "kundenart" scripts/kundenansicht-setup.mjs | head -2 && free -m`
Expected: `git status` leer; die Tag-Liste nennt `kn-p0` und die `-haupt`-Tags von P1 bis P6 und P8; alle Dateien existieren; `termsVersionNeu` und `aboFunktionen` stehen in `config.js`; alle fünf Exporte in `products.js` sind da; `free -m` zeigt mindestens 2000 MB verfügbar. Fehlt etwas, anhalten und dem Controller melden. Danach `git tag kn-p7-start` (Bezugspunkt für Task 17).
- [ ] **Step 2: Rot sehen und Alt-Pins finden**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/aboWortlautV6.test.js; command grep -aln "Es gelten Leistungsbeschreibung\|Es gilt die Leistungsbeschreibung\|unterlagen" test/*.test.js`
Expected: FAIL im Test „Verdrahtung mit P5“ (er läuft jetzt, `config.aboFunktionen` existiert; die Linkzeile trägt `Fassung <lb.version>` statt `Fassung 2026-10-16`). Die Liste nennt die P5-Tests, die Alt-Texte oder Alt-Unterlagen pinnen, mindestens `aboKatalogRegister` und `aboFunktionenListe`; Step 5 passt sie an.
- [ ] **Step 3: Failing tests schreiben** (`test/knVerdrahtung.test.js`; Kopf wie `test/aboKatalogRegister.test.js`, dazu `readFileSync`, `WORTLAUT_V6`, `FUNKTIONEN_FASSUNG_V6`, `FUNKTIONEN_UNTERLAGEN_V6`, `mitFassung` aus `products.js`, `katalogFuerKunde` aus `aboKatalog.js`, `sichereAdresse` aus `public/neu/funktion-einschalten.js`)
```js
const KEYS = ['news', 'termine', 'shop', 'unterkunft', 'zeiten']
const unterlage = (d) => (d ? { version: FUNKTIONEN_FASSUNG_V6, url: d.url } : null)
test('config.aboFunktionen trägt die Literale aus P8, config.js importiert keine Dienste', () => {
assert.deepEqual([config.aboFunktionen.fassung, config.aboFunktionen.texte.beginn], [FUNKTIONEN_FASSUNG_V6, WORTLAUT_V6.beginn.einrichtung])
assert.doesNotMatch(readFileSync(new URL('../server/config.js', import.meta.url), 'utf8'), /from '\.\/services\//)
})
test('consentAbo: Linkzeile mit und ohne AVV aus WORTLAUT_V6, Häkchen aus WORTLAUT_V6.beginn, Nachweis gleich', () => {
for (const key of KEYS) {
const mitAvv = FUNKTIONEN_UNTERLAGEN_V6[key].avv !== null
const zeile = mitFassung(WORTLAUT_V6.linkzeile[mitAvv ? 'einschaltenMitAvv' : 'einschaltenOhneAvv'], FUNKTIONEN_FASSUNG_V6)
assert.equal(reg.consentAbo(key).items[0].text, zeile, key)
assert.deepEqual(reg.consentAboAktiv(key, { beginn: true }).items.map((i) => i.text), [zeile, WORTLAUT_V6.beginn.einrichtung], key)
}
assert.equal(reg.consentAbo('analyse'), null)
})
test('Katalog: unterlagen aus FUNKTIONEN_UNTERLAGEN_V6, hinweis aus WORTLAUT_V6, Analyse bleibt bei den Alt-Unterlagen', async () => {
const vorher = config.analyse.enabled; config.analyse.enabled = true
try {
const katalog = await katalogFuerKunde({ email: 'k@example.invalid' })
for (const key of KEYS) {
const e = katalog.find((x) => x.key === key)
assert.deepEqual(e.unterlagen, { leistungsbeschreibung: unterlage(FUNKTIONEN_UNTERLAGEN_V6[key].lb), avv: unterlage(FUNKTIONEN_UNTERLAGEN_V6[key].avv) }, key)
assert.equal(e.hinweis, WORTLAUT_V6.hinweis[key] ?? null, key)
}
const a = katalog.find((x) => x.key === 'analyse')
assert.deepEqual([a.unterlagen.avv.version, a.unterlagen.avv.url, a.hinweis], [config.analyse.avvVersion, `/rechtliches/avv-website-analyse-${config.analyse.avvVersion}.html`, null])
} finally { config.analyse.enabled = vorher }
})
test('sichereAdresse (P3) lässt jede Adresse der Funktionsunterlagen durch', () => {
for (const u of Object.values(FUNKTIONEN_UNTERLAGEN_V6).flatMap((x) => [x.lb, x.avv]).filter(Boolean)) assert.equal(sichereAdresse(u.url), u.url, u.url)
})
```
- [ ] **Step 4: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knVerdrahtung.test.js`
Expected: FAIL (Linkzeile mit `lb.version`, `hinweis` fehlt, Unterlagen unter `/rechtliches/`, `sichereAdresse` weist `/neu/rechtliches/…` ab).
- [ ] **Step 5: Verdrahten**
- `server/config.js`: `aboFunktionen.fassung` und `aboFunktionen.texte.beginn` als Literale, der zweite wörtlich aus `WORTLAUT_V6.beginn.einrichtung` (P5 hat denselben Entwurf). Kein Import.
- `server/services/featureRegistry.js`: `consentAbo(key)` ersetzt im Ergebnis des Getters `items[0].text` wie oben beschrieben. Der Import von `products.js` ist zyklenfrei (`products.js` importiert nur `config`).
- `server/services/aboKatalog.js`: `unterlagen` für die fünf Funktionen aus `FUNKTIONEN_UNTERLAGEN_V6` (nur `version` und `url`), `analyse` unverändert; neues Feld `hinweis`.
- `sichereAdresse` in `public/neu/funktion-einschalten.js`: den Pfadpräfix `/neu/rechtliches/` zusätzlich zu `/rechtliches/` und `/vertragsunterlagen/` zulassen, mit denselben Ausschlüssen (`..`, `%2e`, Leerraum, Steuerzeichen, Rückwärtsschrägstrich). Lässt P3 ihn schon zu, entfällt das; sonst die Gut-Liste in `test/knEinschaltenLogik.test.js` um `/neu/rechtliches/leistungsbeschreibung-terminplaner-2026-10-16.html` erweitern. Ohne diesen Präfix würde die Oberfläche die Links der Linkzeile stillschweigend weglassen.
- P5-Pins aus Step 2 anpassen: In `test/aboKatalogRegister.test.js` die Erwartungen für `consentAbo('termine')` und `consentAbo('zeiten')` auf `mitFassung(WORTLAUT_V6.linkzeile.einschaltenMitAvv|einschaltenOhneAvv, FUNKTIONEN_FASSUNG_V6)`; in `test/aboFunktionenListe.test.js` die Schlüsselliste des Katalogeintrags um `hinweis` und `unterlagen.leistungsbeschreibung` auf `{ version: FUNKTIONEN_FASSUNG_V6, url: FUNKTIONEN_UNTERLAGEN_V6.termine.lb.url }`, die Analyse-Erwartung bleibt. Weitere Treffer ebenso; keine Aussage wird abgeschwächt.
- [ ] **Step 6: Lauf grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knVerdrahtung.test.js test/aboWortlautV6.test.js test/aboKatalogRegister.test.js test/aboFunktionenListe.test.js test/featureRegistry.test.js test/funktionEinschalten.test.js test/funktionenEinschaltenRoute.test.js test/knEinschaltenLogik.test.js`
Expected: PASS, 0 fail; in `aboWortlautV6.test.js` läuft der Verdrahtungstest (nicht übersprungen).
- [ ] **Step 7: Commit**
```bash
git add server/config.js server/services/featureRegistry.js server/services/aboKatalog.js public/neu/funktion-einschalten.js test/knVerdrahtung.test.js test/aboKatalogRegister.test.js test/aboFunktionenListe.test.js test/knEinschaltenLogik.test.js
git commit -m "Kundenansicht neu P7: Einschalten zeigt Linkzeile, Häkchen und Unterlagen der Fassung 6 aus P8" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
(Weitere in Step 5 geänderte Test-Dateien einzeln dazunehmen.)
---
### Task 1: Wortlaut und Nachweistexte (`bestelltexte.js`)
**Files:**
- Create: `server/services/bestelltexte.js`
- Test: `test/knBestelltexte.test.js`
**Interfaces:**
- Consumes: nichts. Die Datei hat keine Imports, damit Server, Tests und Werkzeug sie ohne Umgebung laden.
- Produces:
```js
export const WORTLAUT_STATUS = 'entwurf' // 'entwurf' | 'freigegeben'; stellt nur der Inhaber/P8 um
export const KNOPF = 'Zahlungspflichtig bestellen'
export const WECHSEL_GESPERRT, FEHLER_NACHWEIS, FEHLER_BEDINGUNGEN, FEHLER_UNVOLLSTAENDIG // Texte im Test
export function klartext(vorlage) // 'Es gilt [A] und [B].' → 'Es gilt A und B.'
export function bestelltexteFuerClient({ fassung, termsUrl }) // Nutzlast für GET /api/abo (Form im Test); fassung = Versionszeichenfolge (config.abo.termsVersionNeu), leer oder kein Text wirft einen Error mit dem Wort „Fassung“
export function bestellZeile(art, fakten) // art 'abo' | 'wechsel' | 'speicher' → eine Zeile Klartext
export function nachweisTexte(art, { kundenart, fassung, bestellung = '', beginnGehakt = false }) // fassung wie oben → [{ id, text, haken }] in Anzeigereihenfolge
```
- [ ] **Step 1: Voraussetzungen**
Erledigt in Task 0 Step 1. Prüfe nur `git tag --list kn-p7-start` (der Tag muss existieren) und `free -m` (mindestens 2000 MB verfügbar).
- [ ] **Step 2: Wortlaut abgleichen**
Lies in `server/services/products.js` `WORTLAUT_V6` (P8 Task 3) und in `docs/rechtliches/kundenansicht-entscheidungen.md` (R6 und die Abschnitte zu Abo, Wechsel, Zusatzspeicher und Gutschrift). Linkzeile und Häkchen (`linkzeile.bestellen`, `beginn.abo`, `beginn.wechsel`) gelten wörtlich aus `WORTLAUT_V6`: Die Tests lesen sie dort, in `bestelltexte.js` steht eine Kopie. Die übrigen Sätze (Sperrzeile, Warnzeile, Gutschrift-Satz, Kundenart-Zeile) stammen aus dem Entscheidungsdokument, sonst gelten die Literale in Step 3. `WORTLAUT_STATUS` bleibt `'entwurf'`. Fehlt `WORTLAUT_V6`, anhalten und dem Controller melden (Task 0 setzt P8 voraus).
- [ ] **Step 3: Failing tests schreiben** (`test/knBestelltexte.test.js`)
```js
const { config } = await import('../server/config.js')
const { WORTLAUT_V6, mitFassung, termsDocFor } = await import('../server/services/products.js')
const FASSUNG = config.abo.termsVersionNeu
const URL_V6 = termsDocFor(FASSUNG).url
const BESTELLEN = mitFassung(WORTLAUT_V6.linkzeile.bestellen, FASSUNG) // der Satz aus P8, Fassungsangabe = Versionszeichenfolge
const T = bestelltexteFuerClient({ fassung: FASSUNG, termsUrl: URL_V6 })
test('Status, Knopf und feste Fehlertexte', () => {
assert.deepEqual([WORTLAUT_STATUS, KNOPF], ['entwurf', 'Zahlungspflichtig bestellen'])
assert.equal(WECHSEL_GESPERRT, 'Der Wechsel gilt sofort. Ohne dein Häkchen können wir ihn nicht ausführen.')
assert.equal(FEHLER_NACHWEIS, 'Das hat gerade nicht geklappt. Bitte versuche es gleich noch einmal.')
assert.equal(FEHLER_BEDINGUNGEN, 'Die Bedingungen wurden aktualisiert. Bitte lade die Seite neu.')
assert.equal(FEHLER_UNVOLLSTAENDIG, 'Die Bestellung ist unvollständig. Bitte lade die Seite neu.')
})
test('klartext und Fassungsangabe', () => {
assert.equal(klartext('Es gilt [A] und [B].'), 'Es gilt A und B.')
for (const leer of ['', undefined, null, 6]) assert.throws(() => bestelltexteFuerClient({ fassung: leer, termsUrl: URL_V6 }), /Fassung/)
})
test('Nutzlast: Linkzeile und Häkchen sind der Wortlaut aus P8 (WORTLAUT_V6), dazu Sperrzeile, Warnzeile, Satz, Kundenart-Zeile (Entwurf)', () => {
assert.deepEqual([T.status, T.fassung, T.knopf], ['entwurf', FASSUNG, 'Zahlungspflichtig bestellen'])
assert.equal(klartext(T.abo.linkzeile.text), `${BESTELLEN} Datenschutz`)
assert.deepEqual(T.abo.linkzeile.text.match(/\[[^\]]+\]/g), ['[Vertragsbedingungen]', '[Datenschutz]'])
assert.deepEqual(T.abo.linkzeile.urls, [URL_V6, '/datenschutz.html'])
assert.equal(T.abo.haken, WORTLAUT_V6.beginn.abo)
assert.deepEqual(T.wechsel.linkzeile, T.abo.linkzeile)
assert.equal(T.wechsel.haken, WORTLAUT_V6.beginn.wechsel)
assert.equal(T.wechsel.gesperrt, WECHSEL_GESPERRT)
assert.equal(klartext(T.speicher.linkzeile.text), `${BESTELLEN} Datenschutz`)
assert.deepEqual(T.speicher.linkzeile.urls, [`${URL_V6}#zusatzspeicher`, '/datenschutz.html'])
assert.equal(T.speicher.warnung.text, 'Wichtig: Passen die gespeicherten E-Mails nach der Verkleinerung nicht mehr in den verbleibenden Speicher, nehmen die Postfächer keine neuen E-Mails mehr an. Bitte räume vorher auf. Mehr dazu in den [Regeln zum Zusatzspeicher].')
assert.deepEqual(T.speicher.warnung.urls, [`${URL_V6}#zusatzspeicher`])
assert.equal(T.gutschrift.satz.text, 'Mit dem Absenden bestätigst du, dass deine Angaben stimmen und die Rechnung auf dich bzw. dein Unternehmen ausgestellt ist. Es gelten die [Bedingungen der Übernahmegutschrift]. Angaben und Nachweise verwendet WEBklar zur Prüfung und Abwicklung des Antrags ([Datenschutz]).')
assert.deepEqual(T.gutschrift.satz.urls, ['#gutschrift?bedingungen=1', '/datenschutz.html#uebernahmegutschrift'])
assert.equal(T.gutschrift.knopf, 'Antrag absenden')
assert.deepEqual(T.kundenart, { unternehmen: 'Du bestellst als Betrieb', privat: 'Du bestellst privat', korrektur: { text: 'Stimmt nicht? [Schreib uns im Chat]', urls: ['#chat'] } })
})
test('bestellZeile: feste Zeilen aus geprüften Werten', () => {
assert.equal(bestellZeile('abo', { produkt: 'Wartungs-Abo', monatCents: 5000, mindestMonate: 12, beginn: 'sofort' }), 'Wartungs-Abo: 50,00 € pro Monat (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), Beginn sofort, Mindestlaufzeit 12 Monate ab Leistungsbeginn')
assert.equal(bestellZeile('abo', { produkt: 'Hosting-Abo', monatCents: 2000, mindestMonate: 12, beginn: 'nach_frist', beginnAm: '2026-10-30T05:00:00.000Z', angebot: 'Erste 2 Monate je 10,00 €' }), 'Hosting-Abo: 20,00 € pro Monat (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), individuelles Angebot: Erste 2 Monate je 10,00 €, Beginn nach Ablauf der Widerrufsfrist am 30.10.2026, Mindestlaufzeit 12 Monate ab Leistungsbeginn')
assert.equal(bestellZeile('wechsel', { heuteCents: 4100, monatCents: 5000, mindestMonate: 12 }), 'Wechsel ins Wartungs-Abo, Beginn sofort. Heute fällig: 41,00 € (gemäß § 19 UStG ohne Umsatzsteuer), danach 50,00 € pro Monat zuzüglich gebuchtem Zusatzspeicher, Mindestlaufzeit 12 Monate ab dem Wechsel')
assert.equal(bestellZeile('speicher', { units: 2, gb: 10, monatCents: 200 }), 'Zusatzspeicher: 2 Pakete (+10 GB), 2,00 € pro Monat (gemäß § 19 UStG ohne Umsatzsteuer), monatlich kündbar')
assert.equal(bestellZeile('speicher', { units: 1, gb: 5, monatCents: 100 }), 'Zusatzspeicher: 1 Paket (+5 GB), 1,00 € pro Monat (gemäß § 19 UStG ohne Umsatzsteuer), monatlich kündbar')
assert.throws(() => bestellZeile('gutschrift', {}))
})
test('nachweisTexte: Reihenfolge, Häkchen nur wenn gezeigt und gesetzt, Klartext', () => {
const fassung = FASSUNG
const betrieb = nachweisTexte('abo', { kundenart: 'unternehmen', fassung, bestellung: 'Zeile' })
assert.deepEqual(betrieb.map((t) => t.id), ['bestellung', 'linkzeile', 'knopf', 'kundenart'])
assert.equal(betrieb[1].text, `${BESTELLEN} Datenschutz`)
assert.deepEqual(betrieb[3], { id: 'kundenart', text: 'Du bestellst als Betrieb · Stimmt nicht? Schreib uns im Chat', haken: false })
const privat = nachweisTexte('abo', { kundenart: 'privat', fassung, bestellung: 'Zeile', beginnGehakt: true })
assert.deepEqual(privat.map((t) => [t.id, t.haken]), [['bestellung', false], ['linkzeile', false], ['beginn', true], ['knopf', false], ['kundenart', false]])
assert.equal(privat[2].text, WORTLAUT_V6.beginn.abo)
assert.ok(!nachweisTexte('abo', { kundenart: 'privat', fassung, bestellung: 'Zeile' }).some((t) => t.id === 'beginn'))
assert.ok(!nachweisTexte('wechsel', { kundenart: 'unternehmen', fassung, bestellung: 'Zeile', beginnGehakt: true }).some((t) => t.id === 'beginn'))
assert.equal(nachweisTexte('wechsel', { kundenart: 'privat', fassung, bestellung: 'Z', beginnGehakt: true })[2].text, WORTLAUT_V6.beginn.wechsel)
assert.deepEqual(nachweisTexte('speicher', { kundenart: 'privat', fassung, bestellung: 'Z', beginnGehakt: true }).map((t) => t.id), ['bestellung', 'linkzeile', 'knopf', 'kundenart'])
assert.deepEqual(nachweisTexte('gutschrift', {}).map((t) => [t.id, t.text]), [['satz', klartext(T.gutschrift.satz.text)], ['knopf', 'Antrag absenden']])
assert.throws(() => nachweisTexte('unbekannt', {}))
})
test('Quelle: keine Imports, du-Form, als Entwurf gekennzeichnet', () => {
const q = readFileSync(new URL('../server/services/bestelltexte.js', import.meta.url), 'utf8')
assert.doesNotMatch(q, /^import /m); assert.doesNotMatch(q, /\b(Sie|Ihr|Ihre|Ihnen)\b/); assert.match(q, /ENTWURF/)
})
```
- [ ] **Step 4: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knBestelltexte.test.js`
Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `server/services/bestelltexte.js`.
- [ ] **Step 5: Implementieren** (Signaturen und Literale oben)
Kopfkommentar mit dem Wort `ENTWURF`, Stand 16.10.2026, Quelle (`WORTLAUT_V6` in `products.js` für Linkzeile und Häkchen; hier steht eine Kopie, weil die Datei importfrei bleibt, und die Tests binden sie) und dem Hinweis „bei Freigabe `WORTLAUT_STATUS` umstellen“. Linkzeile und Häkchen als Literale; die Linkzeile trägt den Platzhalter `{fassung}` (lokale Ersetzung, kein Import) und die Markierungen `[Vertragsbedingungen]` und `[Datenschutz]`. Euro als `(cents / 100).toFixed(2).replace('.', ',') + ' €'` mit normalem Leerzeichen (stabiler Hash), Datum als `TT.MM.JJJJ` in `Europe/Berlin`. `bestelltexteFuerClient` setzt die Versionszeichenfolge `fassung` ein (kein Text oder leer wirft) und gibt je Text `{ text, urls }` zurück, `urls` in der Reihenfolge der `[…]`-Markierungen; die Adresse der Speicher-Regeln ist `termsUrl + '#zusatzspeicher'`. `nachweisTexte`: Reihenfolge `bestellung` (nur wenn nichtleer), `linkzeile`, `beginn` (nur bei `art` abo oder wechsel, `kundenart === 'privat'` und `beginnGehakt === true`, `haken: true`), `knopf`, `kundenart`; Gutschrift `satz`, `knopf`. Eine unbekannte `art` wirft.
- [ ] **Step 6: Lauf grün**
Run: wie Step 4. Expected: PASS, 6 Tests.
- [ ] **Step 7: Commit**
```bash
git add server/services/bestelltexte.js test/knBestelltexte.test.js
git commit -m "Kundenansicht neu P7: Wortlaut-Entwurf und Nachweistexte der Bestellseiten" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 2: Fassungsweiche und Nachweis (`vertragsNachweis.js`)
**Files:**
- Create: `server/services/vertragsNachweis.js`
- Test: `test/knVertragsNachweis.test.js`
**Interfaces:**
- Consumes: `neueAnsicht(req)` aus `server/services/kundenansicht/schalter.js` (V1), `KUNDENARTEN` aus `server/services/kundenart.js` (V4), `texteHash`, `actingPerson` aus `server/services/featureConsents.js`, `createDocument` aus `server/services/appwriteAdmin.js`, `WORTLAUT_STATUS`, `FEHLER_NACHWEIS` (Task 1), `config`.
- Produces (V12):
```js
export function fassungDerAnfrage(req, termsVersion) // 'alt' | 'neu' | null
// 'alt': termsVersion === config.abo.termsVersion. 'neu': nichtleer, === config.abo.termsVersionNeu UND neueAnsicht(req). Sonst null.
export function istNeueFassung(termsVersion) // nichtleer und === config.abo.termsVersionNeu (ohne Sitzungsbezug; für Mails und Ereignisse)
export async function speichereVertragsNachweis({ customerId, art, bezug = '', texte, fassungen, kundenart, req, at }) // → angelegtes Dokument
```
`speichereVertragsNachweis` schreibt in `featureConsents`: `featureKey = art` (`'abo' | 'wechsel' | 'speicher' | 'gutschrift'`), `projectId = ''`, `stripeSessionId = ''`, `acceptedAt = at`, `fassungen = JSON { ...fassungen, bezug, wortlaut: WORTLAUT_STATUS }`, `texte = JSON texte`, `textHash = texteHash(texte)`, `ip = req.ip`, `userAgent = req.get('user-agent')`, `konto = 'Name <E-Mail>'` aus `actingPerson(req.session)`, `kundenart`. Prüfungen vor dem Schreiben mit Status 400: bekannte `art`, `kundenart` aus `KUNDENARTEN` (kein Rückfall auf privat), nichtleere `texte` mit `{ id, text }` (nichtleere Strings, `haken` optional Boolean), JSON höchstens 20000 (`texte`) und 1000 (`fassungen`) Zeichen, nichtleere `customerId`. Jeder Fehler beim Schreiben wird zu `Error(FEHLER_NACHWEIS)` mit Status 500; die Ursache steht nur im Log.
- [ ] **Step 1: Failing tests schreiben** (`test/knVertragsNachweis.test.js`; Kopf: `startAppwriteMock({ schema: { featureConsents: schemaKeys('featureConsents') } })`, `useTestEnv`, `Object.assign(process.env, { KUNDENANSICHT_NEU: 'pilot', KUNDENANSICHT_NEU_PILOT_EMAILS: 'k3@example.invalid' })`, `req(email)` = `{ ip: '203.0.113.9', get: (h) => (h === 'user-agent' ? 'UA/1' : ''), session: { customerId: 'c3', appwriteUserId: 'u3', name: 'Kunde Drei', email } }`; ohne `appwriteUserId` gilt die Sitzung laut P0 nicht als Kunden-Sitzung)
```js
test('fassungDerAnfrage: alt, neu, null; fehlende Neu-Fassung ist nie neu', () => {
const NEU = config.abo.termsVersionNeu; const modus = config.kundenansichtNeu.mode // P8 setzt die Neu-Fassung, der Test liest sie nur
try {
assert.equal(fassungDerAnfrage(req('fremd@example.invalid'), config.abo.termsVersion), 'alt')
assert.equal(fassungDerAnfrage(req('k3@example.invalid'), NEU), 'neu')
assert.equal(fassungDerAnfrage(req('fremd@example.invalid'), NEU), null) // nicht Pilot
assert.equal(fassungDerAnfrage(req('k3@example.invalid'), '2026-09-25-v3'), null)
for (const v of [undefined, null, '', 6, {}]) assert.equal(fassungDerAnfrage(req('k3@example.invalid'), v), null)
config.abo.termsVersionNeu = undefined
assert.equal(fassungDerAnfrage(req('k3@example.invalid'), undefined), null)
config.abo.termsVersionNeu = NEU; config.kundenansichtNeu.mode = 'off'
assert.equal(fassungDerAnfrage(req('k3@example.invalid'), NEU), null) // off schlägt die Liste
assert.deepEqual([istNeueFassung(NEU), istNeueFassung(''), istNeueFassung(undefined), istNeueFassung(config.abo.termsVersion)], [true, false, false, false])
} finally { config.abo.termsVersionNeu = NEU; config.kundenansichtNeu.mode = modus }
})
test('speichereVertragsNachweis schreibt eine Zeile ohne Projekt, mit Kundenart, Hash und Konto', async () => {
const FASSUNG = config.abo.termsVersionNeu
const texte = nachweisTexte('abo', { kundenart: 'privat', fassung: FASSUNG, bestellung: 'Zeile', beginnGehakt: true })
const d = await speichereVertragsNachweis({ customerId: 'c3', art: 'abo', bezug: 'wartung', texte, fassungen: { zustimmung: FASSUNG }, kundenart: 'privat', req: req('k3@example.invalid'), at: '2026-10-17T08:00:00.000Z' })
assert.deepEqual([d.featureKey, d.projectId, d.stripeSessionId, d.kundenart, d.konto, d.ip, d.userAgent], ['abo', '', '', 'privat', 'Kunde Drei <k3@example.invalid>', '203.0.113.9', 'UA/1'])
assert.deepEqual(JSON.parse(d.fassungen), { zustimmung: FASSUNG, bezug: 'wartung', wortlaut: 'entwurf' })
assert.deepEqual(JSON.parse(d.texte), texte); assert.equal(d.textHash, texteHash(texte))
})
test('Ungültiges wird vor dem Schreiben mit 400 abgelehnt, nie als privat ergänzt', async () => {
const vorher = aw.docs('featureConsents').size
const gut = { customerId: 'c3', art: 'abo', texte: [{ id: 'knopf', text: 'Zahlungspflichtig bestellen', haken: false }], fassungen: {}, kundenart: 'privat', req: req(), at: '2026-10-17T08:00:00.000Z' }
for (const schlecht of [{ art: 'feature' }, { kundenart: '' }, { kundenart: 'firma' }, { kundenart: undefined }, { texte: [] }, { texte: [{ id: 'x', text: '' }] }, { texte: [{ id: 'x', text: 'a'.repeat(20001) }] }, { customerId: '' }]) {
await assert.rejects(speichereVertragsNachweis({ ...gut, ...schlecht }), (e) => e.status === 400, JSON.stringify(Object.keys(schlecht)))
}
assert.equal(aw.docs('featureConsents').size, vorher)
})
test('Appwrite-Fehler: 500 mit dem festen Text, keine Ursache nach außen', async () => {
aw.failNext({ collection: config.collections.featureConsents, method: 'POST', status: 500, message: 'geheimer Appwrite-Text' })
await assert.rejects(speichereVertragsNachweis({ customerId: 'c3', art: 'gutschrift', texte: nachweisTexte('gutschrift', {}), fassungen: {}, kundenart: 'unternehmen', req: req(), at: '2026-10-17T08:00:00.000Z' }),
(e) => e.status === 500 && e.message === FEHLER_NACHWEIS)
})
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knVertragsNachweis.test.js`
Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `vertragsNachweis.js`.
- [ ] **Step 3: Implementieren** (Signaturen und Regeln oben). `featureConsents.js` bleibt unberührt: `speichereZustimmung` lehnt eine leere `projectId` ab, deshalb schreibt dieses Modul selbst mit `createDocument` (Vertragsfrage VF3).
- [ ] **Step 4: Lauf grün**
Run: wie Step 2. Expected: PASS, 4 Tests.
- [ ] **Step 5: Commit**
```bash
git add server/services/vertragsNachweis.js test/knVertragsNachweis.test.js
git commit -m "Kundenansicht neu P7: Fassungsweiche und Vertragsnachweis (V12)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 3: `GET /api/abo` mit Fassung 6 und Wortlaut
**Files:**
- Modify: `server/routes/abo.js` (`router.get('/')`, Z. 167-214, darin `termsVersion` Z. 201), Imports Z. 1-35
- Test: `test/knAboFassung6.test.js`
**Interfaces:**
- Consumes: `neueAnsicht(req)` (V1), `bestelltexteFuerClient` (Task 1), `termsDocFor` aus `server/services/products.js` (Tabelle von P8), `config.abo.termsVersionNeu`.
- Produces: In der neuen Ansicht (`neueAnsicht(req)` und nichtleeres `config.abo.termsVersionNeu`) trägt die Antwort `termsVersion: config.abo.termsVersionNeu` und das zusätzliche Feld `bestelltexte = bestelltexteFuerClient({ fassung: config.abo.termsVersionNeu, termsUrl: termsDocFor(config.abo.termsVersionNeu).url })`. Sonst bleibt die Antwort bytegleich (neun Schlüssel, `termsVersion = config.abo.termsVersion`).
- [ ] **Step 1: Failing tests schreiben** (`test/knAboFassung6.test.js`, Testkopf und Hilfen wie in den Global Constraints; Router `/api/abo`)
```js
test('Pilot-Kunde bekommt Fassung 6 und den Wortlaut', async () => {
const neu = await get('/api/abo', { cookie: customerCookie('c1') })
assert.equal(neu.termsVersion, config.abo.termsVersionNeu)
assert.deepEqual([neu.bestelltexte.status, neu.bestelltexte.fassung, neu.bestelltexte.knopf], ['entwurf', config.abo.termsVersionNeu, 'Zahlungspflichtig bestellen'])
assert.equal(neu.bestelltexte.abo.linkzeile.urls[0], termsDocFor(config.abo.termsVersionNeu).url)
})
test('Kundenansicht der Mitarbeiter sieht dasselbe, Nicht-Pilot die alte Antwort', async () => {
assert.ok((await get('/api/abo', { cookie: kundenansichtCookie() })).bestelltexte)
const alt = await get('/api/abo', { cookie: customerCookie('c2') })
assert.equal(alt.termsVersion, config.abo.termsVersion)
assert.deepEqual(Object.keys(alt).sort(), ['available', 'lateStartAt', 'ledger', 'mailStorage', 'offers', 'products', 'subscription', 'termsVersion', 'testAccount'])
})
test('Ohne Neu-Fassung in der Konfiguration bleibt auch der Pilot bei der alten Antwort', async () => {
const gemerkt = config.abo.termsVersionNeu; config.abo.termsVersionNeu = ''
try {
const r = await get('/api/abo', { cookie: customerCookie('c1') })
assert.equal('bestelltexte' in r, false); assert.equal(r.termsVersion, config.abo.termsVersion)
} finally { config.abo.termsVersionNeu = gemerkt }
})
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAboFassung6.test.js`
Expected: FAIL (`bestelltexte` fehlt, `termsVersion` ist Fassung 5).
- [ ] **Step 3: Implementieren** in `router.get('/')`: `const neu = neueAnsicht(req) && Boolean(config.abo.termsVersionNeu)`; `termsVersion` und `bestelltexte` nur bei `neu`. Sonst nichts ändern.
- [ ] **Step 4: Lauf grün, dazu die alten Abo-Tests**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knAboFassung6.test.js test/aboRoutes.test.js`
Expected: PASS, 0 fail.
- [ ] **Step 5: Commit**
```bash
git add server/routes/abo.js test/knAboFassung6.test.js
git commit -m "Kundenansicht neu P7: GET /api/abo liefert Fassung 6 und Wortlaut nur der neuen Ansicht" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 4: Bestellung Fassung 6 (Checkout)
**Files:**
- Modify: `server/routes/abo.js` (`POST /checkout` Z. 221-282, `recordAbschluss` Z. 520-543), `server/services/stripeBilling.js` (`createAboCheckout` Z. 580-607)
- Test: `test/knCheckoutFassung6.test.js`
**Interfaces:**
- Consumes: `fassungDerAnfrage`, `istNeueFassung`, `speichereVertragsNachweis` (Task 2), `nachweisTexte`, `bestellZeile`, `FEHLER_BEDINGUNGEN`, `FEHLER_UNVOLLSTAENDIG` (Task 1), `kundenartWirksam` (V4), `neueAnsicht` (V1).
- Produces (V12): Body der Fassung 6
```js
{ productKey, acceptTerms: true, earlyStart?: boolean, termsVersion: config.abo.termsVersionNeu, offerId, lateStartAt } // Antwort { url } wie heute
```
`createAboCheckout(customer, consent, productKey, offer)` nimmt zusätzlich `consent.termsVersion` (Vorgabe `config.abo.termsVersion`) und schreibt sie in `termsVersion` und `consentTextVersion` der Abo-Metadaten (Z. 606-607).
- [ ] **Step 1: Failing tests schreiben** (`test/knCheckoutFassung6.test.js`; Testkopf wie Global Constraints; Hilfen `unix`, `checkout(body, { cookie })` mit Rückgabe `{ res, call }`, `priceIdFor` wie `test/aboRoutes.test.js:44-70`; Käufer ist c3 ohne Abo)
```js
const NEU = () => config.abo.termsVersionNeu
const body = (productKey, extra = {}) => ({ productKey, acceptTerms: true, termsVersion: NEU(), offerId: '', ...extra })
const meta = (call, key) => call.body[`subscription_data[metadata][${key}]`]
const zeilen = () => [...aw.docs('featureConsents').values()].filter((d) => d.featureKey === 'abo')
const ids = (z) => JSON.parse(z.texte).map((t) => t.id)
test('Betrieb: sofort ohne Häkchen, Fassung 6 in den Metadaten, Nachweis ohne Beginn-Häkchen', async () => {
setzeKundenart('c3', 'unternehmen'); const n0 = zeilen().length
const { res, call } = await checkout(body('wartung'))
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(meta(call, 'startChoice'), 'sofort'); assert.ok(!meta(call, 'earlyStartConsentAt'))
assert.deepEqual([meta(call, 'termsVersion'), meta(call, 'consentTextVersion')], [NEU(), NEU()])
assert.equal(call.body['subscription_data[trial_end]'], undefined)
const [z] = zeilen().slice(n0)
assert.deepEqual([z.customerId, z.projectId, z.kundenart, z.stripeSessionId], ['c3', '', 'unternehmen', ''])
assert.deepEqual(ids(z), ['bestellung', 'linkzeile', 'knopf', 'kundenart'])
assert.deepEqual(JSON.parse(z.fassungen), { zustimmung: NEU(), bezug: 'wartung', wortlaut: 'entwurf' })
assert.match(JSON.parse(z.texte)[0].text, /^Wartungs-Abo: 50,00 € pro Monat .*Beginn sofort/)
})
test('Privat und leer: ohne earlyStart nach der Frist, mit earlyStart sofort mit Zeitstempel und Häkchen im Nachweis', async () => {
for (const art of ['privat', undefined]) {
setzeKundenart('c3', art); const n0 = zeilen().length
const { lateStartAt } = await get('/api/abo', { cookie: customerCookie('c3') })
const frist = await checkout(body('hosting', { lateStartAt }))
assert.equal(frist.res.status, 200, JSON.stringify(frist.res.body))
assert.equal(meta(frist.call, 'startChoice'), 'nach_frist'); assert.equal(frist.call.body['subscription_data[trial_end]'], String(unix(lateStartAt)))
const sofort = await checkout(body('hosting', { earlyStart: true }))
assert.equal(meta(sofort.call, 'startChoice'), 'sofort'); assert.match(meta(sofort.call, 'earlyStartConsentAt'), /^\d{4}-\d\d-\d\dT/)
const neu = zeilen().slice(n0)
assert.deepEqual(neu.map(ids), [['bestellung', 'linkzeile', 'knopf', 'kundenart'], ['bestellung', 'linkzeile', 'beginn', 'knopf', 'kundenart']])
assert.deepEqual(neu.map((z) => z.kundenart), ['privat', 'privat'])
}
})
test('earlyStart zählt nur als Boolean true; veralteter Leistungsbeginn ist 409 ohne Nachweis und Stripe-Aufruf', async () => {
setzeKundenart('c3', 'privat'); const n0 = zeilen().length
const { lateStartAt } = await get('/api/abo', { cookie: customerCookie('c3') })
for (const wert of ['ja', 1, 'true', null]) assert.equal(meta((await checkout(body('hosting', { earlyStart: wert, lateStartAt }))).call, 'startChoice'), 'nach_frist', String(wert))
const alt = await checkout(body('hosting', { lateStartAt: '2026-01-02T05:00:00.000Z' }))
assert.equal(alt.res.status, 409); assert.equal(alt.call, null); assert.equal(zeilen().length, n0 + 4)
})
test('Kundenart und Beginn kommen nie vom Client (Review Focus 1)', async () => {
const { lateStartAt } = await get('/api/abo', { cookie: customerCookie('c3') })
setzeKundenart('c3', 'unternehmen')
const b = await checkout(body('wartung', { kundenart: 'privat', unternehmer: false, startChoice: 'nach_frist', earlyStart: true, lateStartAt }))
assert.equal(meta(b.call, 'startChoice'), 'sofort'); assert.ok(!meta(b.call, 'earlyStartConsentAt'))
assert.equal(zeilen().at(-1).kundenart, 'unternehmen'); assert.ok(!ids(zeilen().at(-1)).includes('beginn'))
for (const wert of ['privat', '', 'firma', 'Unternehmen']) {
setzeKundenart('c3', wert === '' ? undefined : wert)
const p = await checkout(body('wartung', { kundenart: 'unternehmen', startChoice: 'sofort', lateStartAt }))
assert.equal(meta(p.call, 'startChoice'), 'nach_frist', wert); assert.equal(zeilen().at(-1).kundenart, 'privat', wert)
}
})
test('Fassungsweiche: Fassung 6 ohne Pilot 409, Fassung 5 aus dem Pilot wie früher, fehlende Version nie neu', async () => {
setzeKundenart('c3', 'unternehmen'); const n0 = zeilen().length
const fremd = await checkout(body('wartung'), { cookie: customerCookie('c2') })
assert.equal(fremd.res.status, 409); assert.match(fremd.res.body.error, /Bedingungen/); assert.equal(fremd.call, null)
const v3 = await checkout(body('wartung', { termsVersion: '2026-09-25-v3' }))
assert.equal(v3.res.status, 409); assert.equal(v3.res.body.error, 'Die Bedingungen wurden aktualisiert. Bitte lade die Seite neu.')
const alt = await checkout({ productKey: 'wartung', acceptTerms: true, startChoice: 'sofort', termsVersion: config.abo.termsVersion, offerId: '' })
assert.equal(alt.res.status, 200); assert.equal(meta(alt.call, 'termsVersion'), config.abo.termsVersion)
assert.equal((await checkout({ productKey: 'wartung', acceptTerms: true, termsVersion: config.abo.termsVersion, offerId: '' })).res.status, 400) // alter Weg verlangt startChoice
const { termsVersion: _t, ...ohne } = body('wartung'); const gemerkt = config.abo.termsVersionNeu
for (const neuWert of [gemerkt, undefined]) { config.abo.termsVersionNeu = neuWert; assert.equal((await checkout(ohne)).res.status, 409) }
config.abo.termsVersionNeu = gemerkt
assert.equal(zeilen().length, n0) // weder Fassung 5 noch Fehlerfälle schreiben einen Nachweis
})
test('Nachweis nicht speicherbar: 500, keine Stripe-Sitzung; Ansichtsmodus 403 ohne Schreiben', async () => {
setzeKundenart('c3', 'unternehmen'); const n0 = zeilen().length
aw.failNext({ collection: config.collections.featureConsents, method: 'POST' })
const f = await checkout(body('wartung'))
assert.deepEqual([f.res.status, f.res.body.error, f.call], [500, 'Das hat gerade nicht geklappt. Bitte versuche es gleich noch einmal.', null])
const v = await checkout(body('wartung'), { cookie: kundenansichtCookie({ customerId: 'c3' }) })
assert.deepEqual([v.res.status, v.call, zeilen().length], [403, null, n0])
})
test('Webhook: Ereignis abschluss nennt den Grund kundenart nur bei Fassung 6 ohne Zeitstempel', async () => {
const lauf = async (metadata) => { // SUB_NEW, checkoutCompleted, cleanupWebhook aus test/aboRoutes.test.js:346-370 in die neue Datei übernehmen
st.subscriptions.set(SUB_NEW, stripeSubscription({ id: SUB_NEW, customerId: 'c3', productKey: 'wartung', metadata }))
try { assert.equal((await postWebhook(checkoutCompleted(SUB_NEW, { productKey: 'wartung' }))).status, 200); return (await listContractEvents('c3'))[0].details } finally { await cleanupWebhook(SUB_NEW) }
}
assert.equal((await lauf({ termsVersion: NEU(), consentTextVersion: NEU(), startChoice: 'sofort', earlyStartConsentAt: '' })).kundenart, 'unternehmen')
assert.equal('kundenart' in (await lauf({ termsVersion: NEU(), startChoice: 'sofort', earlyStartConsentAt: new Date().toISOString() })), false)
assert.equal('kundenart' in (await lauf({ termsVersion: config.abo.termsVersion, startChoice: 'sofort', earlyStartConsentAt: '' })), false)
})
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knCheckoutFassung6.test.js`
Expected: FAIL (Fassung 6 wird mit 409 abgelehnt).
- [ ] **Step 3: Implementieren**
`POST /checkout` in `server/routes/abo.js`:
- Zusätzlich `earlyStart` aus dem Body lesen; `const fassung = fassungDerAnfrage(req, termsVersion)`. Die Reihenfolge der Prüfungen bleibt: Ansichtsmodus 403, Produkt 400, `acceptTerms` 400, danach nur im alten Pfad die Prüfung von `startChoice`, dann die Fassung.
- `fassung === null`: 409 mit `FEHLER_BEDINGUNGEN`, wenn `neueAnsicht(req)`, sonst mit dem alten Satz. `fassung === 'alt'`: der Code bleibt unverändert, ohne Nachweis.
- `fassung === 'neu'`: `acceptTerms !== true` gibt 400 `FEHLER_UNVOLLSTAENDIG`. `startChoice`, `kundenart` und `unternehmer` aus dem Body werden nie gelesen. Die Kundenart ist `kundenartWirksam(customer)` des geladenen Kunden. Beginn nach der Tabelle „Beginn-Regeln“; für `nach_frist` bleibt die Prüfung von `lateStartAt` (409) wie heute.
- Angebots-, Stripe- und Pilot-Prüfungen bleiben wie heute. Direkt vor `createAboCheckout` schreibt `speichereVertragsNachweis` den Nachweis: `art: 'abo'`, `bezug: productKey`, `texte: nachweisTexte('abo', { kundenart, fassung: termsVersion, bestellung: bestellZeile('abo', { produkt: productLabel(productKey), monatCents: product.amountCents, mindestMonate: product.minTermMonths, beginn, beginnAm: serviceStartAt?.toISOString(), angebot: offer ? offerText(offer) : undefined }), beginnGehakt })`, `fassungen: { zustimmung: termsVersion }`, `at: now`. Scheitert der Nachweis: 500, kein Stripe-Aufruf.
- Der Aufruf `createAboCheckout` bekommt `termsVersion` im `consent`-Objekt (nur im neuen Pfad).
`createAboCheckout`: `termsVersion` und `consentTextVersion` der Metadaten aus `consent.termsVersion || config.abo.termsVersion`.
`recordAbschluss`: `details` bekommt `kundenart: 'unternehmen'`, wenn `istNeueFassung(sub.termsVersion)`, `sub.startChoice === 'sofort'` und `sub.earlyStartConsentAt` leer ist (in Fassung 6 geht das nur für Betriebe).
- [ ] **Step 4: Lauf grün, dazu die alten Abo-Tests**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knCheckoutFassung6.test.js test/knAboFassung6.test.js test/aboRoutes.test.js test/stripeBilling.test.js`
Expected: PASS, 0 fail.
- [ ] **Step 5: Commit**
```bash
git add server/routes/abo.js server/services/stripeBilling.js test/knCheckoutFassung6.test.js
git commit -m "Kundenansicht neu P7: Bestellung Fassung 6 mit Kundenart vom Server und Wortlaut-Nachweis" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 5: Wechsel Fassung 6 (`planSwitch.js:351/388/483`)
**Files:**
- Modify: `server/routes/abo.js` (`POST /switch` Z. 373-405), `server/services/planSwitch.js` (`switchToWartung` Z. 347-351, Metadaten Z. 388, Ereignis-Zustimmung Z. 483, `details` des Ereignisses in `performSwitch`)
- Test: `test/knWechselFassung6.test.js`
Nach dem Merge von P6 (`details.funktion`, `completeSwitch`, `markSwitchFailed`) sind die Zeilen verschoben. Die Stellen sind an den Zeichenfolgen `if (!consent.earlyStartConsentAt)` und `earlyStartConsentAt: consent.earlyStartConsentAt,` (zweimal: `metadata` und `consent` des Ereignisses) zu finden. Die Änderungen von P6 an derselben Funktion bleiben unberührt.
**Interfaces:**
- Consumes: wie Task 4, dazu `WECHSEL_GESPERRT`.
- Produces (V12): Body `{ acceptTerms: true, earlyStart?: true, termsVersion: config.abo.termsVersionNeu, prorationDate, amountDueCents, funktion? }`. `switchToWartung(customerId, consent, prorationDate, { expectedAmountCents, funktion })` (die Option `funktion` kommt von P6) nimmt `consent.kundenart` (`'unternehmen' | 'privat'`, optional). Ohne `kundenart` verhält sich die Funktion wie heute.
- [ ] **Step 1: Failing tests schreiben** (`test/knWechselFassung6.test.js`; Hilfen `price`, `resetHosting`, `line`, `previewInvoice`, `switchInvoice`, `nowUnix` wie `test/planSwitch.test.js:25-110`; Wechsler ist c1)
```js
const NEU = () => config.abo.termsVersionNeu
const wb = (extra = {}) => ({ acceptTerms: true, termsVersion: NEU(), prorationDate: nowUnix(), amountDueCents: 4100, ...extra })
const zeilen = () => [...aw.docs('featureConsents').values()].filter((d) => d.featureKey === 'wechsel')
async function wechsel(b, cookie = customerCookie()) {
await resetHosting(); const vor = st.calls.length
st.invoicePreview = previewInvoice(line('price_hosting', -900, { proration: true, periodStart: nowUnix() }), line('price_wartung', 5000))
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() }
const res = await post('/api/abo/switch', b, { cookie })
return { res, call: st.calls.slice(vor).find((c) => c.path === '/v1/subscriptions/sub_h' && c.method === 'POST') || null }
}
test('Betrieb: Wechsel ohne Häkchen, ohne Zeitstempel, Grund im Ereignis, Nachweis ohne Beginn-Häkchen', async () => {
setzeKundenart('c1', 'unternehmen'); const n0 = zeilen().length
const { res, call } = await wechsel(wb())
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual([call.body['metadata[termsVersion]'], call.body['metadata[earlyStartConsentAt]']], [NEU(), ''])
const [ev] = await listContractEvents('c1')
assert.deepEqual([ev.termsVersion, ev.earlyStartConsentAt, ev.details.kundenart], [NEU(), null, 'unternehmen'])
const [z] = zeilen().slice(n0)
assert.deepEqual([z.kundenart, z.projectId, JSON.parse(z.fassungen).bezug], ['unternehmen', '', 'hosting>wartung'])
assert.deepEqual(JSON.parse(z.texte).map((t) => t.id), ['bestellung', 'linkzeile', 'knopf', 'kundenart'])
assert.match(JSON.parse(z.texte)[0].text, /^Wechsel ins Wartungs-Abo, Beginn sofort\. Heute fällig: 41,00 € /)
})
test('Privat und leer: ohne Häkchen 400 mit Sperrtext und ohne Schreiben, mit Häkchen Zeitstempel und Häkchen im Nachweis', async () => {
for (const art of ['privat', undefined]) {
setzeKundenart('c1', art); const n0 = zeilen().length
for (const earlyStart of [undefined, false, 'ja', 1]) {
const { res, call } = await wechsel(wb(earlyStart === undefined ? {} : { earlyStart }))
assert.deepEqual([res.status, res.body.error, call], [400, WECHSEL_GESPERRT, null])
}
assert.equal(zeilen().length, n0)
const ok = await wechsel(wb({ earlyStart: true }))
assert.equal(ok.res.status, 200, JSON.stringify(ok.res.body)); assert.match(ok.call.body['metadata[earlyStartConsentAt]'], /^\d{4}-\d\d-\d\dT/)
const [ev] = await listContractEvents('c1'); assert.equal(ev.earlyStartConsentAt, ev.consentAt); assert.ok(!('kundenart' in ev.details))
assert.deepEqual(JSON.parse(zeilen().at(-1).texte).map((t) => t.id), ['bestellung', 'linkzeile', 'beginn', 'knopf', 'kundenart'])
}
})
test('Kundenart kommt nie vom Client (Review Focus 1)', async () => {
setzeKundenart('c1', 'unternehmen')
const b = await wechsel(wb({ kundenart: 'privat', earlyStart: true }))
assert.equal(b.call.body['metadata[earlyStartConsentAt]'], ''); assert.equal(zeilen().at(-1).kundenart, 'unternehmen')
setzeKundenart('c1', 'privat')
assert.equal((await wechsel(wb({ kundenart: 'unternehmen' }))).res.status, 400)
})
test('Die gemerkte Funktion aus P6 läuft im Fassung-6-Wechsel weiter', async () => {
setzeKundenart('c1', 'unternehmen'); aw.seed('websiteProjects', { $id: 'wf_p1', customerId: 'c1', projectName: 'Bäckerei' })
const { res } = await wechsel(wb({ funktion: { key: 'termine', projectId: 'wf_p1' } }))
assert.equal(res.status, 200, JSON.stringify(res.body)); assert.deepEqual((await listContractEvents('c1'))[0].details.funktion, { key: 'termine', projectId: 'wf_p1' })
})
test('Fassungsweiche und Nachweisfehler', async () => {
setzeKundenart('c1', 'unternehmen'); const n0 = zeilen().length
const fremd = await wechsel(wb(), customerCookie('c2')); assert.equal(fremd.res.status, 409); assert.equal(fremd.call, null)
const alt = await wechsel({ acceptTerms: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix(), amountDueCents: 4100 })
assert.equal(alt.res.status, 400) // alter Weg verlangt earlyStart: true, auch von einem Betrieb
assert.equal(zeilen().length, n0)
await resetHosting(); aw.failNext({ collection: config.collections.featureConsents, method: 'POST' })
const vor = st.calls.length; const f = await post('/api/abo/switch', wb(), { cookie: customerCookie() })
assert.deepEqual([f.status, f.body.error], [500, 'Das hat gerade nicht geklappt. Bitte versuche es gleich noch einmal.'])
assert.equal(st.calls.slice(vor).some((c) => c.path === '/v1/subscriptions/sub_h' && c.method === 'POST'), false)
})
test('switchToWartung: ohne Zeitstempel nur mit kundenart unternehmen', async () => {
await resetHosting(); const { earlyStartConsentAt: _x, ...ohne } = consent
for (const c of [ohne, { ...ohne, kundenart: 'privat' }, { ...ohne, kundenart: 'firma' }]) await assert.rejects(switchToWartung('c1', c, nowUnix()), (e) => e.status === 400)
st.invoicePreview = previewInvoice(line('price_hosting', -900, { proration: true, periodStart: nowUnix() }), line('price_wartung', 5000))
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() }
assert.ok((await switchToWartung('c1', { ...ohne, kundenart: 'unternehmen' }, nowUnix(), { expectedAmountCents: 4100 })).event)
})
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knWechselFassung6.test.js`
Expected: FAIL (Betrieb-Wechsel ohne Häkchen wird abgelehnt).
- [ ] **Step 3: Implementieren**
`planSwitch.js`: Der Guard (Z. 351) lautet `!consent.earlyStartConsentAt && consent.kundenart !== 'unternehmen'` mit demselben Fehlertext. Die Metadaten (Z. 388) und die Zustimmung des Ereignisses (Z. 483) schreiben `consent.earlyStartConsentAt || ''`, damit ein Betrieb den Zeitstempel des Hosting-Abschlusses nicht erbt (Stripe löscht den Schlüssel bei leerem Wert; `previousMetadata` hält den alten Stand). Die `details` des Ereignisses bekommen `kundenart: 'unternehmen'`, wenn `consent.kundenart === 'unternehmen'`.
`POST /switch` in `abo.js`: `fassung = fassungDerAnfrage(req, termsVersion)` gleich nach dem Auslesen des Bodys. `'neu'`: Reihenfolge Ansichtsmodus 403, `acceptTerms !== true` 400 und unvollständige Zahlen 400 (beide `FEHLER_UNVOLLSTAENDIG`), Kunde laden (Lesefehler 500), `kundenartWirksam`; ist sie nicht `'unternehmen'` und `earlyStart !== true`: 400 `WECHSEL_GESPERRT`. `consent = { termsVersion, consentAt: now, earlyStartConsentAt: privat ? now : '', kundenart, consentIp, consentUserAgent }`. Nachweis vor `switchToWartung`: `art: 'wechsel'`, `bezug: 'hosting>wartung'`, `bestellZeile('wechsel', { heuteCents: amountDueCents, monatCents: config.products.wartung.amountCents, mindestMonate: 12 })`, `beginnGehakt: privat`. `fassung === null`: 409 wie in Task 4. `'alt'`: Code unverändert. Die Behandlung von `funktion` aus P6 (Prüfung nur bei `neueAnsicht(req)`, Weitergabe als Option `funktion` an `switchToWartung`) gilt unverändert in beiden Zweigen.
- [ ] **Step 4: Lauf grün, dazu die alten Wechsel-Tests**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knWechselFassung6.test.js test/planSwitch.test.js`
Expected: PASS, 0 fail.
- [ ] **Step 5: Commit**
```bash
git add server/routes/abo.js server/services/planSwitch.js test/knWechselFassung6.test.js
git commit -m "Kundenansicht neu P7: Wechsel Fassung 6, Betrieb ohne Sofortbeginn-Zeitstempel" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 6: Bestätigungsmails ohne erfundenes Verlangen (K01, K12)
Ein Betrieb hat kein Sofortbeginn-Verlangen erklärt. K01 (`sofortbeginn`) und K12 (zweiter Absatz) behaupten es sonst.
**Files:**
- Modify: `server/routes/abo.js` (`sendContractConfirmation` Z. 448, Block `sofortbeginn` Z. 477), `server/services/planSwitch.js` (`performSwitch`, Aufruf `sendSwitchConfirmation` Z. 456 mit den Blöcken), `server/mail-templates/K12 Wechsel Wartungs-Abo.html` (Kopfkommentar Z. 11 und Absatz in Z. 50), `test/mailTemplates.test.js:379` (Pin)
- Test: `test/knBestaetigungsmails.test.js`
**Interfaces:**
- Consumes: `istNeueFassung` (Task 2).
- Produces: K01-Block `sofortbeginn` gilt für `!lateStart`, außer bei Fassung 6 ohne `earlyStartConsentAt`. K12 bekommt den Block `sofortverlangen` um den Satz „Sie haben ausdrücklich verlangt …“ samt Wertersatz-Erklärung (das einleitende `<br><br>` gehört in den Block); `sendSwitchConfirmation` übergibt `sofortverlangen: Boolean(consent.earlyStartConsentAt)`.
- [ ] **Step 1: Failing tests schreiben** (`test/knBestaetigungsmails.test.js`; Kopf wie `test/planSwitch.test.js` mit Mail-Senke, Hilfen wie Task 5; K01 über den Webhook wie `test/aboRoutes.test.js:412-440`)
```js
test('Vorlage K12: der Absatz „ausdrücklich verlangt“ hängt am Block sofortverlangen', () => {
const tpl = loadTemplate('K12'); const werte = sampleValues(tpl)
assert.ok(tpl.blocks.includes('sofortverlangen'))
assert.match(renderMail('K12', werte, { sofortverlangen: true }).text, /Sie haben ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit dem Wartungs-Abo beginnen/)
assert.doesNotMatch(renderMail('K12', werte, {}).text, /ausdrücklich verlangt|Wertersatz/)
})
test('K12 im Betrieb-Wechsel ohne den Satz, im Privat-Wechsel und in der alten Ansicht mit dem Satz', async () => {
const text = async (consentExtra) => {
await resetHosting(); mails.sent.length = 0
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() }
const { earlyStartConsentAt: _x, ...ohne } = consent
await switchToWartung('c1', { ...ohne, ...consentExtra }, nowUnix())
return mails.sent.find((m) => m.subject === K12_SUBJECT).text
}
assert.doesNotMatch(await text({ kundenart: 'unternehmen' }), /ausdrücklich verlangt/)
assert.match(await text({ kundenart: 'privat', earlyStartConsentAt: consent.consentAt }), /Sie haben ausdrücklich verlangt/)
assert.match(await text({ earlyStartConsentAt: consent.consentAt }), /Sie haben ausdrücklich verlangt/)
})
test('K01: Fassung 6 ohne Zeitstempel (Betrieb) ohne „ausdrücklich verlangt“, mit Zeitstempel und in Fassung 5 mit dem Satz', async () => {
const k01 = async (metadata) => { // Webhook wie test/aboRoutes.test.js:346-410
st.subscriptions.set(SUB_NEW, stripeSubscription({ id: SUB_NEW, customerId: 'c3', productKey: 'wartung', metadata })); mails.sent.length = 0
try { await postWebhook(checkoutCompleted(SUB_NEW, { productKey: 'wartung' })); return mails.sent.find((m) => m.subject.includes('Vertragsbestätigung')).text } finally { await cleanupWebhook(SUB_NEW) }
}
assert.doesNotMatch(await k01({ termsVersion: config.abo.termsVersionNeu, startChoice: 'sofort', earlyStartConsentAt: '' }), /ausdrücklich verlangt/)
assert.match(await k01({ termsVersion: config.abo.termsVersionNeu, startChoice: 'sofort', earlyStartConsentAt: new Date().toISOString() }), /ausdrücklich verlangt/)
assert.match(await k01({ termsVersion: config.abo.termsVersion, startChoice: 'sofort', earlyStartConsentAt: '' }), /ausdrücklich verlangt/)
})
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knBestaetigungsmails.test.js`
Expected: FAIL (`sofortverlangen` unbekannt, K01 nennt den Satz immer).
- [ ] **Step 3: Implementieren**
K12-Vorlage: Absatz in `<!-- WENN sofortverlangen -->` … `<!-- ENDE sofortverlangen -->` fassen und `sofortverlangen` in der Zeile „Optionale Blöcke“ des Kopfkommentars ergänzen. `sendSwitchConfirmation`-Aufruf in `performSwitch` ergänzt den Block. `sendContractConfirmation`: `sofortbeginn: !lateStart && !(istNeueFassung(subDoc.termsVersion) && !subDoc.earlyStartConsentAt)`. Der Pin `test/mailTemplates.test.js:379` verliert den Eintrag `'Sie haben ausdrücklich verlangt, …'` (er steht jetzt im ersten Test dieses Tasks).
- [ ] **Step 4: Lauf grün, dazu die Mail-Tests**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knBestaetigungsmails.test.js test/mailTemplates.test.js test/planSwitch.test.js test/aboRoutes.test.js`
Expected: PASS, 0 fail.
- [ ] **Step 5: Commit**
```bash
git add server/routes/abo.js server/services/planSwitch.js "server/mail-templates/K12 Wechsel Wartungs-Abo.html" test/mailTemplates.test.js test/knBestaetigungsmails.test.js
git commit -m "Kundenansicht neu P7: K01 und K12 behaupten für Betriebe kein Sofortbeginn-Verlangen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 7: Zusatzspeicher-Plus Fassung 6
**Files:**
- Modify: `server/routes/abo.js` (`POST /storage` Z. 319-348)
- Test: `test/knSpeicherFassung6.test.js`
**Interfaces:**
- Consumes: wie Task 4.
- Produces (V12, „unverändert“ bis auf Fassung und Nachweis): Body `{ units, acceptTerms: true, termsVersion }` nur beim Plus. Mit `acceptTerms === true` und Fassung 6 schreibt der Server vor `changeStorage` den Nachweis (`art: 'speicher'`, `bezug: 'units=<n>'`, ohne Beginn-Häkchen). Ein Minus (kein `acceptTerms`) bleibt ohne Nachweis.
- [ ] **Step 1: Failing tests schreiben** (`test/knSpeicherFassung6.test.js`; Hilfen `resetSubH`, `price`, Preise im Stripe-Nachbau wie `test/mailStorage.test.js:42-80`, Muster der Route-Aufrufe `:1057-1070`; Besitzer ist c1 mit `sub_h`)
```js
const NEU = () => config.abo.termsVersionNeu
const zeilen = () => [...aw.docs('featureConsents').values()].filter((d) => d.featureKey === 'speicher')
test('Plus in Fassung 6: Nachweis mit Kundenart aus dem Dokument, Ereignis mit der neuen Fassung', async () => {
setzeKundenart('c1', 'unternehmen'); await resetSubH({ units: 0 }); const n0 = zeilen().length
const res = await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: NEU(), kundenart: 'privat' }, { cookie: customerCookie() })
assert.equal(res.status, 200, JSON.stringify(res.body))
const [z] = zeilen().slice(n0)
assert.deepEqual([z.kundenart, JSON.parse(z.fassungen).bezug], ['unternehmen', 'units=2'])
assert.deepEqual(JSON.parse(z.texte).map((t) => t.id), ['bestellung', 'linkzeile', 'knopf', 'kundenart'])
assert.equal(JSON.parse(z.texte)[0].text, 'Zusatzspeicher: 2 Pakete (+10 GB), 2,00 € pro Monat (gemäß § 19 UStG ohne Umsatzsteuer), monatlich kündbar')
assert.equal((await listContractEvents('c1'))[0].termsVersion, NEU())
})
test('Privat und leer: Nachweis mit privat, nie mit dem Wert aus dem Body', async () => {
for (const art of ['privat', undefined, 'firma']) {
setzeKundenart('c1', art); await resetSubH({ units: 0 })
await post('/api/abo/storage', { units: 1, acceptTerms: true, termsVersion: NEU(), kundenart: 'unternehmen' }, { cookie: customerCookie() })
assert.equal(zeilen().at(-1).kundenart, 'privat', String(art))
}
})
test('Minus, Fassung 5, falsche Fassung, ungültige Menge und Nachweisfehler', async () => {
setzeKundenart('c1', 'unternehmen'); await resetSubH({ units: 2 }); const n0 = zeilen().length
assert.equal((await post('/api/abo/storage', { units: 1 }, { cookie: customerCookie() })).status, 200); assert.equal(zeilen().length, n0)
await resetSubH({ units: 0 })
assert.equal((await post('/api/abo/storage', { units: 1, acceptTerms: true, termsVersion: config.abo.termsVersion }, { cookie: customerCookie() })).status, 200)
assert.equal(zeilen().length, n0) // Fassung 5: alter Weg ohne Nachweis
const fremd = await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: NEU() }, { cookie: customerCookie('c2') })
assert.equal(fremd.status, 409)
assert.equal((await post('/api/abo/storage', { units: 11, acceptTerms: true, termsVersion: NEU() }, { cookie: customerCookie() })).status, 400); assert.equal(zeilen().length, n0)
await resetSubH({ units: 0 }); aw.failNext({ collection: config.collections.featureConsents, method: 'POST' }); const vor = st.calls.length
const f = await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: NEU() }, { cookie: customerCookie() })
assert.deepEqual([f.status, st.calls.slice(vor).some((c) => c.path === '/v1/subscriptions/sub_h' && c.method === 'POST')], [500, false])
})
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knSpeicherFassung6.test.js`
Expected: FAIL (Fassung 6 wird mit 409 abgelehnt).
- [ ] **Step 3: Implementieren**
In `POST /storage`: Nur im Zweig `acceptTerms === true` gilt `fassungDerAnfrage(req, termsVersion)`; `null` ist 409 (`FEHLER_BEDINGUNGEN` für `neueAnsicht(req)`, sonst der alte Satz), `'alt'` unverändert. `'neu'`: Kunde laden, `kundenartWirksam`, `consent` wie bisher mit `termsVersion` der Anfrage; ist `units` eine ganze Zahl von 0 bis `config.mailStorage.maxUnits`, schreibt `speichereVertragsNachweis` den Nachweis (`bestellZeile('speicher', { units, gb: units * config.mailStorage.gbPerUnit, monatCents: units * config.mailStorage.amountCents })`) vor `changeStorage`. Eine ungültige Menge überlässt die Ablehnung wie heute `changeStorage` (400).
- [ ] **Step 4: Lauf grün, dazu die alten Speicher-Tests**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knSpeicherFassung6.test.js test/mailStorage.test.js`
Expected: PASS, 0 fail.
- [ ] **Step 5: Commit**
```bash
git add server/routes/abo.js test/knSpeicherFassung6.test.js
git commit -m "Kundenansicht neu P7: Zusatzspeicher-Plus in Fassung 6 mit Wortlaut-Nachweis" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 8: Gutschrift-Antrag Fassung 6 und Quelltext-Pin Kundenart
**Files:**
- Modify: `server/routes/takeoverCredits.js` (`POST /` Z. 74-93)
- Test: `test/knGutschriftFassung6.test.js`, `test/knBestellQuelltext.test.js`
**Interfaces:**
- Consumes: wie Task 4; `createTakeoverRequest`, `validateRequestInput`, `parseUploads` unverändert.
- Produces: Body wie heute plus `termsVersion` (Fassung 6) und `consent: true`. Mit Fassung 6 schreibt die Route nach der Validierung und vor `createTakeoverRequest` den Nachweis (`art: 'gutschrift'`, `bezug: ''`, `fassungen: { zustimmung: config.takeover.conditionsVersion }`, `nachweisTexte('gutschrift', {})`, `kundenart` aus dem Kundendokument). Ohne `termsVersion` oder mit Fassung 5 bleibt alles wie heute.
- [ ] **Step 1: Failing tests schreiben** (`test/knGutschriftFassung6.test.js`; Router `/api/abo/takeover-credits` aus `server/routes/takeoverCredits.js`; `TAKEOVER_UPLOAD_DIR` vor dem Import von `config` auf ein `mkdtemp`-Verzeichnis unter `os.tmpdir()` setzen (laut Sicherheitsblock zeigt `TMPDIR` in den Scratch-Ordner) und am Ende mit `fs.rm(dir, { recursive: true })` nur genau dieses Verzeichnis entfernen; Mail-Senke wie `aboRoutes`; c1 hat das Hosting-Abo `sub_h`)
```js
const antrag = (extra = {}) => ({ services: ['hosting'], provider: 'IONOS', domain: 'beispiel.invalid', invoiceAmount: '119.88', billingMonths: 12,
paidUntil: new Date(Date.now() + 200 * 86400000).toISOString().slice(0, 10), message: 'Der Vertrag dort läuft noch bis Ende März.', email: 'k@example.invalid', consent: true,
files: [{ name: 'rechnung.pdf', data: Buffer.from('%PDF-1.4 test').toString('base64') }], ...extra })
const zeilen = () => [...aw.docs('featureConsents').values()].filter((d) => d.featureKey === 'gutschrift')
test('Fassung 6: Nachweis mit dem Satz und dem Knopf, Kundenart aus dem Dokument, Bedingungsversion unverändert', async () => {
setzeKundenart('c1', 'unternehmen'); const n0 = zeilen().length
const res = await post('/api/abo/takeover-credits', antrag({ termsVersion: config.abo.termsVersionNeu, kundenart: 'privat' }), { cookie: customerCookie() })
assert.equal(res.status, 201, JSON.stringify(res.body))
const [z] = zeilen().slice(n0)
assert.deepEqual([z.kundenart, z.projectId], ['unternehmen', ''])
assert.deepEqual(JSON.parse(z.texte), nachweisTexte('gutschrift', {}))
assert.deepEqual(JSON.parse(z.fassungen), { zustimmung: '2026-09-30', bezug: '', wortlaut: 'entwurf' })
assert.equal(aw.docs('takeoverCredits').get(res.body.request.id).conditionsVersion, '2026-09-30')
})
test('Ohne Fassung 6 kein Nachweis; ungültige Eingabe und Nachweisfehler legen keinen Antrag an', async () => {
const n0 = zeilen().length; const anzahl = () => aw.docs('takeoverCredits').size
assert.equal((await post('/api/abo/takeover-credits', antrag(), { cookie: customerCookie() })).status, 201); assert.equal(zeilen().length, n0)
assert.equal((await post('/api/abo/takeover-credits', antrag({ termsVersion: config.abo.termsVersion }), { cookie: customerCookie() })).status, 201); assert.equal(zeilen().length, n0)
const a1 = anzahl()
assert.equal((await post('/api/abo/takeover-credits', antrag({ termsVersion: config.abo.termsVersionNeu, provider: '' }), { cookie: customerCookie() })).status, 400)
aw.failNext({ collection: config.collections.featureConsents, method: 'POST' })
const f = await post('/api/abo/takeover-credits', antrag({ termsVersion: config.abo.termsVersionNeu }), { cookie: customerCookie() })
assert.deepEqual([f.status, f.body.error, anzahl(), zeilen().length], [500, 'Das hat gerade nicht geklappt. Bitte versuche es gleich noch einmal.', a1, n0])
const fremd = await post('/api/abo/takeover-credits', antrag({ termsVersion: config.abo.termsVersionNeu }), { cookie: customerCookie('c2') })
assert.equal(fremd.status, 201) // c2 ist kein Pilot: Fassung 6 zählt nicht, der Antrag läuft auf dem alten Weg
assert.equal(zeilen().length, n0)
})
```
`test/knBestellQuelltext.test.js` (Review Focus 1 als Quelltext-Pin):
```js
test('Server-Routen lesen die Kundenart nie aus dem Body', () => {
for (const datei of ['server/routes/abo.js', 'server/routes/takeoverCredits.js']) {
const q = readFileSync(new URL(`../${datei}`, import.meta.url), 'utf8')
assert.doesNotMatch(q, /\b(?:req\.)?body\??\.(kundenart|unternehmer)\b/, datei)
assert.doesNotMatch(q, /\{[^}]*\b(kundenart|unternehmer)\b[^}]*\}\s*=\s*req\.body/, datei)
}
})
test('Kundenart stammt aus kundenartWirksam, nicht aus Antwort-Daten des Clients', () => {
const q = readFileSync(new URL('../server/routes/abo.js', import.meta.url), 'utf8')
assert.match(q, /kundenartWirksam\(/)
})
```
Hinweis zum zweiten Fall: Eine Anfrage von c2 (kein Pilot) mit Fassung 6 geht beim Antrag nicht auf 409, sondern auf den alten Weg, weil dort `termsVersion` bisher nie geprüft wurde. Das ist gewollt (Vertragsfrage VF2).
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knGutschriftFassung6.test.js test/knBestellQuelltext.test.js`
Expected: FAIL (kein Nachweis geschrieben); `knBestellQuelltext.test.js` ist schon grün, weil Task 4 bis 7 `kundenartWirksam(` nutzen.
- [ ] **Step 3: Implementieren**
In `POST /` von `takeoverCredits.js` nach `validateRequestInput` und `parseUploads` und nach dem Laden des Kunden: `if (fassungDerAnfrage(req, req.body.termsVersion) === 'neu')` Nachweis schreiben (Fehler wird zum 500 mit `FEHLER_NACHWEIS`, `sendError` gibt ihn weiter), dann `createTakeoverRequest` wie heute. `fassungDerAnfrage` mit `null` bleibt hier ohne Folge (der Antrag hat keine Versionsprüfung).
- [ ] **Step 4: Lauf grün, dazu die alten Tests**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knGutschriftFassung6.test.js test/knBestellQuelltext.test.js test/takeoverCredits.test.js test/legalTexts.test.js`
Expected: PASS, 0 fail.
- [ ] **Step 5: Commit**
```bash
git add server/routes/takeoverCredits.js test/knGutschriftFassung6.test.js test/knBestellQuelltext.test.js
git commit -m "Kundenansicht neu P7: Gutschrift-Antrag in Fassung 6 mit Nachweis, Pin gegen Kundenart aus dem Body" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 9: Oberflächen-Bausteine der Bestellseiten
**Files:**
- Create: `public/neu/bestell-bausteine.js`, `test/support/knBestellDaten.js`
- Modify: `public/neu/ansichten.css` (Abschnitt am Ende anhängen)
- Test: `test/knBestellBausteine.test.js`
**Interfaces:**
- Consumes: `h` aus `./dom.js`, `K` und `safeUrl` aus `./ansicht-hilfen.js` (P2); im Test `bestelltexteFuerClient` (Task 1), `mitDom`, `me`, `aboAntwort` aus `test/support/knDaten.js` (P2). Die Bausteine zeichnen keine `h1`.
- Produces `public/neu/bestell-bausteine.js`:
```js
export function kundenartVon(me, daten = null) // 'unternehmen' | 'privat': nur exakt 'unternehmen' in me.customer.kundenartWirksam (sonst daten.kundenart) ist Betrieb; alles andere privat (E4)
export function sicherLink(url) // url bei '/pfad' (nicht '//…'), '#name…' oder http(s)://, sonst ''
export function linkTeile(text, urls) // 'Es gilt [A].' + ['/a'] → [{ text: 'Es gilt ' }, { text: 'A', href: '/a' }, { text: '.' }]; unsichere oder fehlende Adresse → Teil ohne href; offene Klammer bleibt Text
export function baueLinkzeile({ text, urls }, { id }) // <p id>; Links nur aus sicherLink; '/…' und http(s) mit target="_blank" rel="noopener", '#…' ohne target
export function baueKundenartZeile(kundenart, texte, { id }) // <p id>: texte.unternehmen | texte.privat, ' · ', texte.korrektur als Linkzeile
export function baueBlock(zeilen, { id }) // zeilen [{ id?, label, text }] → <dl id class="kn-block"> mit dt/dd; Text nur als Text
export function hatWortlaut(daten) // true, wenn daten.bestelltexte status, knopf und abo.linkzeile trägt
```
`test/support/knBestellDaten.js`: `NEU = config.abo.termsVersionNeu` und `TERMS_URL = termsDocFor(NEU).url` (beide per Import aus `server/config.js` und `server/services/products.js`, exportiert, kein Literal); `meMit(kundenartWirksam, over = {})` (= `me(over)` mit `customer.kundenart` und `customer.kundenartWirksam`, bei `''` bleibt die gespeicherte Angabe leer); `aboNeu(over = {})` (= `aboAntwort({ termsVersion: NEU, bestelltexte: bestelltexteFuerClient({ fassung: NEU, termsUrl: TERMS_URL }), ...over })`).
- [ ] **Step 1: Failing tests schreiben** (`test/knBestellBausteine.test.js`)
```js
test('kundenartVon: nur exakt unternehmen ist Betrieb (E4)', () => {
for (const m of [undefined, null, {}, { customer: {} }, meMit(''), meMit('privat'), { customer: { kundenartWirksam: 'Unternehmen' } }, { customer: { kundenart: 'unternehmen' } }]) assert.equal(kundenartVon(m), 'privat')
assert.equal(kundenartVon(meMit('unternehmen')), 'unternehmen'); assert.equal(kundenartVon({}, { kundenart: 'unternehmen' }), 'unternehmen')
})
test('sicherLink', () => {
for (const ok of ['/datenschutz.html', `${TERMS_URL}#zusatzspeicher`, '#chat', '#gutschrift?bedingungen=1', 'https://example.invalid/x']) assert.equal(sicherLink(ok), ok)
for (const schlecht of ['javascript:alert(1)', '//evil.example/x', 'data:text/html,x', 'http:/x', 'abo.html', '', undefined]) assert.equal(sicherLink(schlecht), '')
})
test('linkTeile: Markierungen werden der Reihe nach Links, Unsicheres bleibt Text', () => {
assert.deepEqual(linkTeile('Es gilt [A] und [B].', ['/a', '/b']), [{ text: 'Es gilt ' }, { text: 'A', href: '/a' }, { text: ' und ' }, { text: 'B', href: '/b' }, { text: '.' }])
assert.deepEqual(linkTeile('Siehe [A].', ['javascript:x']), [{ text: 'Siehe ' }, { text: 'A' }, { text: '.' }])
assert.deepEqual(linkTeile('Nur Text', []), [{ text: 'Nur Text' }]); assert.deepEqual(linkTeile('Offen [A', ['/a']), [{ text: 'Offen [A' }])
})
test('baueLinkzeile, baueKundenartZeile, baueBlock', mitDom((fx) => {
const p = baueLinkzeile({ text: 'Siehe [A] und [B].', urls: ['/a', '#chat'] }, { id: 'x' }); const [a, b] = fx.alle(p, 'a')
assert.deepEqual([p.id, fx.text(p), a.getAttribute('target'), a.getAttribute('rel'), b.getAttribute('target')], ['x', 'Siehe A und B.', '_blank', 'noopener', null])
const T = aboNeu().bestelltexte.kundenart
assert.equal(fx.text(baueKundenartZeile('unternehmen', T, { id: 'k' })), 'Du bestellst als Betrieb · Stimmt nicht? Schreib uns im Chat')
assert.equal(fx.finde(baueKundenartZeile('privat', T, { id: 'k' }), 'a').getAttribute('href'), '#chat')
const dl = baueBlock([{ id: 'r1', label: 'Preis', text: '<b>50</b>' }], { id: 'blk' })
assert.deepEqual([fx.alle(dl, 'dt').length, fx.finde(dl, 'dd').textContent, fx.finde(dl, 'b'), fx.finde(dl, '#r1').textContent], [1, '<b>50</b>', null, '<b>50</b>'])
}))
test('hatWortlaut', () => { assert.equal(hatWortlaut(aboNeu()), true); for (const d of [null, {}, { bestelltexte: {} }]) assert.equal(hatWortlaut(d), false) })
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knBestellBausteine.test.js`
Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `bestell-bausteine.js`.
- [ ] **Step 3: Implementieren**
Module nach den Signaturen, Elemente nur mit `h()`, Klassen aus `K` und `kn-block`, `kn-linkzeile`, `kn-kundenart-zeile`. In `ansichten.css` einen Abschnitt `/* P7 Bestellseiten */` anhängen: `.kn-block` als Raster Beschriftung/Wert mit Haarlinien, `.kn-linkzeile` und `.kn-kundenart-zeile` in Zweittext-Größe, das Häkchen mit 44-px-Tap-Fläche. Nur Tokens aus `tokens.css` (Namen dort nachsehen), keine Farbwerte, keine Karten, Verläufe oder Lila. `test/knAnsichtStil.test.js` (P2) muss grün bleiben.
- [ ] **Step 4: Lauf grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knBestellBausteine.test.js test/knAnsichtStil.test.js`
Expected: PASS, 0 fail.
- [ ] **Step 5: Commit**
```bash
git add public/neu/bestell-bausteine.js public/neu/ansichten.css test/support/knBestellDaten.js test/knBestellBausteine.test.js
git commit -m "Kundenansicht neu P7: gemeinsame Bausteine der Bestellseiten (Block, Linkzeile, Kundenart-Zeile)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 10: Bestellseite schlank (A1, A2)
**Files:**
- Modify: `public/neu/ansicht-bestellen.js` (Umbau von der 1:1-Übernahme)
- Test: `test/knAnsichtBestellen.test.js` (ersetzt; die Orakel-Vergleiche gegen `app.js` entfallen)
**Interfaces:**
- Consumes: Task 9, `api`, `h`, `K`, `fmtEuro`, `fmtDatum`, `safeUrl`, `ladeAbo`, `meldeTitel` (P2). `GET /api/abo` mit `bestelltexte` (Task 3), `POST /api/abo/checkout` (Task 4).
- Produces (unverändert): `zeigeBestellen(wurzel, { route, me, nav, daten })`, `produktAusRoute(route, hash)`.
Titel: `meldeTitel(wurzel, <Produktname ohne 'WEBklar '>)`, keine eigene `h1` (P2, P1 VF11; der Titel der Hülle steht schon da). Aufbau in `section#abo-order` (nach dem Angebotsabschnitt, der Leistungsliste und dem Übernahmehinweis in du-Form): `h2` „Was du bestellst“, `dl#abo-block` (Zeilen `Leistung`, `Preis`, `Fällig` mit `#abo-due-summary`, `Beginn` mit `#abo-start-summary`, `Laufzeit`, `Zahlung`), `p#abo-linkzeile`, nur bei Privat und leer das Häkchen `label > input#abo-beginn + span#abo-beginn-text` (nicht vorausgewählt, Wortlaut `bestelltexte.abo.haken`), `p#abo-error`, `button#abo-buy`, `p#abo-kundenart`. Feste Zeilentexte (`{p}` = Preis in Euro, `{d}` = `fmtDatum(lateStartAt)`):
- Leistung: `{product.name}: {Titel der Leistungsliste, durch Komma getrennt}`.
- Preis: `{p} pro Monat (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten`. Mit Angebot: `{Angebotspreis} pro Monat für die ersten {n} Monate, danach {Regelpreis} pro Monat (individuelles Angebot: {offer.text}; Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten`.
- Betrieb und Privat mit Häkchen: Fällig `Heute fällig: {p} (gemäß § 19 UStG ohne Umsatzsteuer)`; Beginn `sofort` (Betrieb) bzw. `sofort, auf deinen ausdrücklichen Wunsch vor Ablauf der Widerrufsfrist` (Privat).
- Privat ohne Häkchen: Fällig `Erste Abbuchung am {d}: {p} (gemäß § 19 UStG ohne Umsatzsteuer); heute wird nichts abgebucht`; Beginn `nach Ablauf der 14-tägigen Widerrufsfrist (am {d}); bis dahin keine Leistung und keine Abbuchung`.
- Laufzeit: `Mindestlaufzeit {n} Monate ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach jederzeit zum Ende des Abrechnungsmonats kündbar`.
- Zahlung: `Kreditkarte oder SEPA-Lastschrift über Stripe; die Zahlungsart hinterlegst du im nächsten Schritt, bis zum Abschluss dort kannst du abbrechen`.
Der Knopf trägt `Zahlungspflichtig bestellen`, während des Aufrufs `Weiter zur Zahlung…` (gesperrt), nach einem Fehler wieder den Knopftext. Body: `{ productKey, acceptTerms: true, termsVersion: daten.termsVersion, offerId: offer?.id || '' }`, bei Privat und leer zusätzlich `earlyStart: <Häkchen>` und `lateStartAt: daten.lateStartAt`. Nie `kundenart` oder `startChoice`. Weiterleitung nur mit `safeUrl`. Ohne `hatWortlaut(daten)`, ohne Produkt oder mit `available: false` steht nur `Die Online-Bestellung wird gerade eingerichtet – schreib uns gern im Chat.` (kein Knopf). Ansichtsmodus: Knopf und Häkchen gesperrt, daneben `Im Ansichtsmodus nicht möglich.`. Testkonto-Hinweis in du: `Testkonto: Bei Stripe wird ein Test-Rabatt verrechnet, abgebucht werden nur 0,50 € pro Monat.`
- [ ] **Step 1: Failing tests schreiben** (`test/knAnsichtBestellen.test.js`; Hilfen `zeige(fx, daten, me, produkt = 'wartung')` (hängt vor dem Aufruf einen Hörer für `kn:titel` an die Wurzel und sammelt die Texte in `fx.titel`) und `reihe(fx, w)` = die Ids aus `['abo-block','abo-linkzeile','abo-beginn','abo-error','abo-buy','abo-kundenart']` in DOM-Reihenfolge unterhalb von `#abo-order`)
```js
test('Betrieb: Block, Linkzeile, Knopf, Kundenart-Zeile in dieser Reihenfolge, ohne Häkchen und ohne Klappe', mit({}, async (fx) => {
const w = await zeige(fx, aboNeu(), meMit('unternehmen'))
assert.deepEqual(reihe(fx, w), ['abo-block', 'abo-linkzeile', 'abo-error', 'abo-buy', 'abo-kundenart'])
assert.equal(fx.finde(w, '#abo-buy').textContent, 'Zahlungspflichtig bestellen')
assert.equal(fx.alle(w, 'details, dialog, input[type="radio"]').length, 0)
const t = fx.text(fx.finde(w, '#abo-block'))
for (const s of ['Leistung', 'Preis', 'Fällig', 'Beginn', 'Laufzeit', 'Zahlung', '50,00', 'gemäß § 19 UStG ohne Umsatzsteuer', 'Heute fällig: 50,00']) assert.ok(t.includes(s), s)
assert.ok(!t.includes('Anbieter') && !t.includes('Abrechnung'))
assert.equal(fx.text(fx.finde(w, '#abo-kundenart')), 'Du bestellst als Betrieb · Stimmt nicht? Schreib uns im Chat')
assert.equal(fx.finde(w, '#abo-linkzeile a').getAttribute('href'), TERMS_URL)
}))
test('Titel: Produktname als Ereignis, keine eigene h1 (P2, P1 VF11)', mit({}, async (fx) => {
const w = await zeige(fx, aboNeu(), meMit('unternehmen'))
assert.deepEqual(fx.titel, ['Wartungs-Abo']); assert.equal(fx.alle(w, 'h1').length, 0)
}))
test('Betrieb: Body ohne earlyStart, lateStartAt, startChoice und Kundenart', mit({ 'POST /api/abo/checkout': { url: 'https://checkout.stripe.test/x' } }, async (fx) => {
const w = await zeige(fx, aboNeu(), meMit('unternehmen')); await fx.klick(fx.finde(w, '#abo-buy')); await fx.ruhe()
assert.deepEqual(fx.fetchLog.at(-1), { method: 'POST', url: '/api/abo/checkout', body: { productKey: 'wartung', acceptTerms: true, termsVersion: NEU, offerId: '' } })
assert.equal(fx.window.location.href, 'https://checkout.stripe.test/x')
}))
test('Privat und leer: ein Häkchen aus dem Wortlaut, ungehakt nach der Frist, gehakt sofort', mit({ 'POST /api/abo/checkout': { url: 'https://checkout.stripe.test/x' } }, async (fx) => {
for (const m of [meMit('privat'), meMit('')]) {
const w = await zeige(fx, aboNeu(), m)
assert.deepEqual(reihe(fx, w), ['abo-block', 'abo-linkzeile', 'abo-beginn', 'abo-error', 'abo-buy', 'abo-kundenart'])
assert.equal(fx.finde(w, '#abo-beginn').checked, false); assert.equal(fx.text(fx.finde(w, '#abo-beginn-text')), aboNeu().bestelltexte.abo.haken)
assert.ok(fx.text(fx.finde(w, '#abo-start-summary')).startsWith('nach Ablauf der 14-tägigen Widerrufsfrist (am '))
assert.ok(fx.text(fx.finde(w, '#abo-kundenart')).startsWith('Du bestellst privat · '))
await fx.klick(fx.finde(w, '#abo-buy')); await fx.ruhe()
assert.deepEqual(fx.fetchLog.at(-1).body, { productKey: 'wartung', acceptTerms: true, earlyStart: false, termsVersion: NEU, offerId: '', lateStartAt: aboNeu().lateStartAt })
const v = await zeige(fx, aboNeu(), m); await fx.klick(fx.finde(v, '#abo-beginn'))
assert.ok(fx.text(fx.finde(v, '#abo-start-summary')).startsWith('sofort, auf deinen ausdrücklichen Wunsch')); assert.ok(fx.text(fx.finde(v, '#abo-due-summary')).startsWith('Heute fällig: '))
await fx.klick(fx.finde(v, '#abo-buy')); await fx.ruhe(); assert.equal(fx.fetchLog.at(-1).body.earlyStart, true)
}
}))
test('Angebot: Angebotsnummer im Body, Text in Abschnitt und Preiszeile', mit({}, async (fx) => {
const d = aboNeu({ offers: { hosting: null, wartung: angebot() } }); const w = await zeige(fx, d, meMit('unternehmen'))
assert.ok(fx.text(w).includes(angebot().text)); await fx.klick(fx.finde(w, '#abo-buy')); await fx.ruhe(); assert.equal(fx.fetchLog.at(-1).body.offerId, angebot().id)
}))
test('Ohne Wortlaut, nicht verfügbar oder unbekanntes Produkt: nur der Satz, kein Knopf; unsichere Linkadressen werden Text', mit({}, async (fx) => {
const satz = 'Die Online-Bestellung wird gerade eingerichtet – schreib uns gern im Chat.'
for (const d of [aboAntwort({ termsVersion: NEU }), aboNeu({ available: false })]) { const w = await zeige(fx, d, meMit('unternehmen')); assert.ok(fx.text(w).includes(satz)); assert.equal(fx.finde(w, '#abo-buy'), null) }
assert.equal(fx.finde(await zeige(fx, aboNeu(), meMit('unternehmen'), 'gibtsnicht'), '#abo-buy'), null)
const boese = aboNeu(); boese.bestelltexte.abo.linkzeile.urls = ['javascript:alert(1)', '//evil.example']
assert.equal(fx.alle(await zeige(fx, boese, meMit('unternehmen')), '#abo-linkzeile a[href]').length, 0)
}))
```
Aus der bisherigen Datei bleiben (auf die neuen Ids angepasst und in du): `produktAusRoute`; Fehler der Zahlungsseite zeigt den Text in `#abo-error` und stellt den Knopf zurück; eine unsichere Zahlungsadresse (`javascript:`) leitet nicht weiter; Ansichtsmodus sperrt Knopf und Häkchen mit `Im Ansichtsmodus nicht möglich.`; der Testkonto-Hinweis.
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtBestellen.test.js`
Expected: FAIL (`#abo-block` fehlt, die alte Seite hat `#abo-terms` und Radiofelder).
- [ ] **Step 3: Umbauen** nach dem Aufbau oben. Alle Sie-Sätze der Seite werden du, sonst bleibt der Wortlaut der Fassung 5 an den Stellen, die nicht in A1/A2 fallen (Leistungsliste, Angebotshinweis, Übernahmehinweis).
- [ ] **Step 4: Lauf grün**
Run: wie Step 2. Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add public/neu/ansicht-bestellen.js test/knAnsichtBestellen.test.js
git commit -m "Kundenansicht neu P7: Bestellseite schlank (Block, Linkzeile, Häkchen nur privat)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 11: Wechselseite schlank (B1, B2)
**Files:**
- Modify: `public/neu/ansicht-wechsel.js`
- Test: `test/knAnsichtWechsel.test.js` (ersetzt)
**Interfaces:**
- Consumes: Task 9, `POST /api/abo/switch/preview`, `POST /api/abo/switch` (Task 5), `gemerkteFunktion` und `merkeHinweis`, `navigiere`, `meldeTitel` (P2/V11).
- Produces (unverändert): `zeigeWechsel(wurzel, { route, me, nav, daten })`, `gemerkteFunktion()`.
Titel: `meldeTitel(wurzel, 'Wechsel ins Wartungs-Abo')`, keine eigene `h1` (P2, P1 VF11). Aufbau in `section#switch-order`: `h2` „Was du bestellst“, `dl#switch-block` mit den Zeilen der heutigen Übersicht in du (`Wartungs-Abo`, bei Zusatzspeicher `Zusatzspeicher`, bei Gutschrift `Gutschrift für nicht genutzte Tage`, `Heute fällig`, `Neuer Abbuchungstag`, `Laufzeit`, `Beginn`), vorher der Absatz „Zusätzlich zum Hosting: …“ als Zeile `Leistung`; `p#switch-linkzeile`; nur Privat/leer das Häkchen `#switch-beginn` (+ `#switch-beginn-text`, Wortlaut `bestelltexte.wechsel.haken`) und `p#switch-sperre` (Text `bestelltexte.wechsel.gesperrt`, sichtbar, solange das Häkchen fehlt); `p#switch-error`; `button#switch-buy`; `p#switch-kundenart`. Der Knopf ist frei, wenn die Vorschau da ist und (bei Privat/leer) das Häkchen gesetzt ist. Body: `{ acceptTerms: true, termsVersion: daten.termsVersion, prorationDate, amountDueCents }`, bei Privat/leer plus `earlyStart: true`, plus `funktion` aus `gemerkteFunktion()` (Schlüssel danach gelöscht). Die Zeile `Beginn` lautet in du: `Der Wechsel wird erst mit der Zahlung wirksam. Bei Kreditkarte, sobald die Zahlung erfolgreich ist (ggf. nach Bestätigung bei deiner Bank); bei SEPA-Lastschrift sofort mit der Bestellung, wird die Lastschrift nicht eingelöst, gilt der Wechsel als nicht erfolgt. Einen späteren Beginn gibt es nicht.` Weitere du-Texte: `Ein Wechsel ist derzeit nicht möglich. Details findest du unter Verträge.`, `Dein Wechsel wartet auf die Zahlung. Wir leiten dich zur Zahlung weiter.`, `Dein Wechsel ins Wartungs-Abo gilt ab sofort. Die Lastschrift wird noch eingezogen; wir bestätigen dir den Wechsel per E-Mail.`, `Dein Wechsel ins Wartungs-Abo ist abgeschlossen. Wir bestätigen ihn per E-Mail.`
- [ ] **Step 1: Failing tests schreiben** (Hilfen `HOSTING(o)` = `aboNeu({ subscription: abo({ produkt: 'hosting', canSwitch: true, storage: { units: 2, pendingSetup: false, pendingReduction: null } }), ...o })`, `SRV = { 'POST /api/abo/switch/preview': VORSCHAU, 'POST /api/abo/switch': () => ({ body: { paymentUrl: 'https://pay.stripe.test/x', subscription: abo() } }) }`, `reihe` mit den Ids `switch-block`, `switch-linkzeile`, `switch-beginn`, `switch-sperre`, `switch-error`, `switch-buy`, `switch-kundenart`; `zeige(fx, daten, me)` sammelt die Ereignisse `kn:titel` der Wurzel in `fx.titel`)
```js
test('Betrieb: Block, Linkzeile, Knopf, kein Häkchen, Knopf nach der Vorschau frei, Body ohne earlyStart', mit(SRV, async (fx) => {
const w = await zeige(fx, HOSTING(), meMit('unternehmen'))
assert.deepEqual(fx.titel, ['Wechsel ins Wartungs-Abo']); assert.equal(fx.alle(w, 'h1').length, 0) // Titel kommt als Ereignis, keine eigene h1
assert.deepEqual(reihe(fx, w), ['switch-block', 'switch-linkzeile', 'switch-error', 'switch-buy', 'switch-kundenart'])
const t = fx.text(fx.finde(w, '#switch-block'))
for (const s of ['Wartungs-Abo', 'Zusatzspeicher', '2 Pakete (+10 GB)', 'Gutschrift für nicht genutzte Tage', '−15,00', 'Heute fällig', '30,00', 'Neuer Abbuchungstag', 'Laufzeit', 'bei deiner Bank']) assert.ok(t.includes(s), s)
assert.equal(fx.finde(w, '#switch-buy').hasAttribute('disabled'), false)
await fx.klick(fx.finde(w, '#switch-buy')); await fx.ruhe()
assert.deepEqual(fx.fetchLog.find((a) => a.url === '/api/abo/switch').body, { acceptTerms: true, termsVersion: NEU, prorationDate: VORSCHAU.prorationDate, amountDueCents: VORSCHAU.amountDueCents })
assert.equal(fx.window.location.href, 'https://pay.stripe.test/x'); assert.match(fx.text(w), /Dein Wechsel wartet auf die Zahlung\. Wir leiten dich zur Zahlung weiter\./)
}))
test('Privat und leer: Knopf gesperrt mit Sperrzeile, nach dem Häkchen frei, Body mit earlyStart true', mit(SRV, async (fx) => {
for (const m of [meMit('privat'), meMit('')]) {
const w = await zeige(fx, HOSTING(), m)
assert.deepEqual(reihe(fx, w), ['switch-block', 'switch-linkzeile', 'switch-beginn', 'switch-sperre', 'switch-error', 'switch-buy', 'switch-kundenart'])
assert.equal(fx.finde(w, '#switch-buy').hasAttribute('disabled'), true); assert.equal(fx.text(fx.finde(w, '#switch-sperre')), HOSTING().bestelltexte.wechsel.gesperrt)
assert.equal(fx.text(fx.finde(w, '#switch-beginn-text')), HOSTING().bestelltexte.wechsel.haken)
await fx.klick(fx.finde(w, '#switch-beginn')); assert.equal(fx.finde(w, '#switch-buy').hasAttribute('disabled'), false); assert.equal(fx.finde(w, '#switch-sperre'), null)
await fx.klick(fx.finde(w, '#switch-buy')); await fx.ruhe(); assert.equal(fx.fetchLog.findLast((a) => a.url === '/api/abo/switch').body.earlyStart, true)
}
}))
```
Übernommen und angepasst (du-Texte, neue Ids): „Wechsel nicht möglich“ zeigt den Satz und navigiert mit `ersetzen` zu `#vertraege`; `gemerkteFunktion` (kaputte oder fremde Werte ergeben `null`) und der Body mit `funktion` samt gelöschtem Schlüssel `kn-wechsel-funktion`; 409 lädt die Vorschau neu und behält die Meldung; Ansichtsmodus sperrt Knopf und Häkchen; beim Neuzeichnen bleibt das Häkchen erhalten.
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtWechsel.test.js`
Expected: FAIL (`#switch-block` fehlt).
- [ ] **Step 3: Umbauen** nach dem Aufbau oben; `#switch-terms` und `#switch-early` entfallen.
- [ ] **Step 4: Lauf grün**
Run: wie Step 2. Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add public/neu/ansicht-wechsel.js test/knAnsichtWechsel.test.js
git commit -m "Kundenansicht neu P7: Wechselseite schlank, Sofortbeginn-Häkchen nur privat" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 12: E-Mail-Speicher schlank (C1, C3)
**Files:**
- Modify: `public/neu/ansicht-speicher.js`
- Test: `test/knAnsichtSpeicher.test.js` (ersetzt)
**Interfaces:**
- Consumes: Task 9, `POST /api/abo/storage/preview`, `POST /api/abo/storage` (Task 7), `setzeChatVorgabe`, `navigiere`, `meldeZaehlerNeu` (P2).
- Produces (unverändert): `zeigeSpeicher(wurzel, { route, me, nav, daten })`.
Plus (Zielmenge über der gebuchten): `dl#storage-block` mit den Zeilen der heutigen Zusammenfassung in du (Neue Gesamtgröße, Preis, Zusätzlich jetzt, Nächste Abbuchung, Einrichtung, Laufzeit), `p#storage-linkzeile` (`bestelltexte.speicher.linkzeile`), `p#storage-error`, `button#storage-submit` mit `Zahlungspflichtig bestellen`, `p#storage-kundenart`; kein Häkchen (`#storage-terms` entfällt). Minus: statt der Warnbox genau eine Zeile `p#storage-warnung` (`bestelltexte.speicher.warnung`) und der Knopf `Speichern`, ohne Linkzeile und ohne Kundenart-Zeile. Body Plus `{ units, acceptTerms: true, termsVersion: daten.termsVersion }`, Minus `{ units }`. du-Texte: `Danke, deine Bestellung ist eingegangen. Der Speicher wird in der Regel innerhalb eines Werktags eingerichtet.`, `Gespeichert. Es entstehen keine zusätzlichen Kosten, die Pakete waren schon bezahlt.`, `Gespeichert. Die Verkleinerung wirkt zum <TT.MM.JJJJ>.`, `Dein zusätzlicher Speicher wird eingerichtet. Das dauert in der Regel bis zum nächsten Werktag.`, 409-Hinweis `<Meldung> Bitte lade die Seite neu, falls sich der Stand geändert hat.`, gesperrt `Änderungen sind derzeit nicht möglich – <Grund>.` mit den Gründen `Wechsel wartet auf die Zahlung`, `Abo gekündigt`, `Zahlung offen`, `bei Fragen schreib uns gern im Chat`.
- [ ] **Step 1: Failing tests schreiben** (`speicher(o, sub)` = `aboNeu({ subscription: abo({ storage: { units: 2, pendingSetup: false, pendingReduction: null }, canChangeStorage: true, ...sub }), ...o })`; `'POST /api/abo/storage/preview'` liefert beim Minus `VORSCHAU_SPEICHER`, beim Plus `PLUS = { direction: 'up', units: 3, totalGb: 20, monthlyCents: 300, deltaMonthlyCents: 100, prorationCents: 40, nextChargeAt: '2026-11-16T00:00:00.000Z', effectiveAt: null }`; `zeigeNeu(fx, daten, m)` und `reihe` mit den Ids `storage-block`, `storage-linkzeile`, `storage-warnung`, `storage-error`, `storage-submit`, `storage-kundenart`)
```js
test('Plus: Block, Linkzeile, Knopf, Kundenart-Zeile, kein Häkchen, Body ohne Kundenart', mit({ 'POST /api/abo/storage/preview': PLUS, 'POST /api/abo/storage': { change: { task: true }, subscription: abo() }, 'GET /api/abo': speicher() }, async (fx) => {
const w = await zeigeNeu(fx, speicher(), meMit('unternehmen')); await fx.klick(fx.finde(w, '#storage-plus')); await fx.ruhe()
assert.deepEqual(reihe(fx, w), ['storage-block', 'storage-linkzeile', 'storage-error', 'storage-submit', 'storage-kundenart'])
assert.deepEqual([fx.finde(w, '#storage-terms'), fx.finde(w, '#storage-submit').textContent, fx.finde(w, '#storage-warnung')], [null, 'Zahlungspflichtig bestellen', null])
assert.equal(fx.finde(w, '#storage-linkzeile a').getAttribute('href'), `${TERMS_URL}#zusatzspeicher`)
await fx.klick(fx.finde(w, '#storage-submit')); await fx.ruhe()
assert.deepEqual(fx.fetchLog.find((a) => a.url === '/api/abo/storage').body, { units: 3, acceptTerms: true, termsVersion: NEU })
assert.match(fx.text(w), /Danke, deine Bestellung ist eingegangen\. Der Speicher wird in der Regel innerhalb eines Werktags eingerichtet\./)
}))
test('Minus: genau eine Warnzeile aus dem Wortlaut, Knopf „Speichern“, Body nur mit units', mit({ 'POST /api/abo/storage/preview': VORSCHAU_SPEICHER, 'POST /api/abo/storage': { change: { effectiveAt: '2026-11-01T00:00:00.000Z' }, subscription: abo() } }, async (fx) => {
const w = await zeigeNeu(fx, speicher(), meMit('privat')); await fx.klick(fx.finde(w, '#storage-minus')); await fx.ruhe()
assert.equal(fx.alle(w, '#storage-warnung').length, 1); assert.equal(fx.text(fx.finde(w, '#storage-warnung')), klartext(speicher().bestelltexte.speicher.warnung.text))
assert.deepEqual([fx.finde(w, '#storage-linkzeile'), fx.finde(w, '#storage-kundenart'), fx.finde(w, '#storage-submit').textContent], [null, null, 'Speichern'])
await fx.klick(fx.finde(w, '#storage-submit')); await fx.ruhe(); assert.deepEqual(fx.fetchLog.find((a) => a.url === '/api/abo/storage').body, { units: 1 })
}))
test('Privat und leer: Kundenart-Zeile nennt privat; Ansichtsmodus sperrt Stepper und Knöpfe', mit({ 'POST /api/abo/storage/preview': PLUS }, async (fx) => {
const w = await zeigeNeu(fx, speicher(), meMit('')); await fx.klick(fx.finde(w, '#storage-plus')); await fx.ruhe()
assert.ok(fx.text(fx.finde(w, '#storage-kundenart')).startsWith('Du bestellst privat · '))
const v = await zeigeNeu(fx, speicher(), meMit('unternehmen', { viewAs: true })); assert.ok(['#storage-minus', '#storage-plus'].every((s) => fx.finde(v, s).hasAttribute('disabled')))
}))
```
Übernommen und angepasst: Zustände „gesperrt“, „wird eingerichtet“, „Reduzierung geplant“ (du-Texte); 409 hängt den Neuladen-Hinweis an; `Postfach anfragen` setzt die Chat-Vorgabe `Bitte lege ein neues Postfach an: ` und navigiert zu `#chat`.
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtSpeicher.test.js`
Expected: FAIL (`#storage-linkzeile` fehlt, `#storage-terms` ist da).
- [ ] **Step 3: Umbauen** nach dem Aufbau oben.
- [ ] **Step 4: Lauf grün**
Run: wie Step 2. Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add public/neu/ansicht-speicher.js test/knAnsichtSpeicher.test.js
git commit -m "Kundenansicht neu P7: E-Mail-Speicher schlank, Plus mit Linkzeile, Warnung als eine Zeile" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 13: Gutschrift-Antrag schlank (D2) und Quelltext-Regeln
**Files:**
- Modify: `public/neu/ansicht-gutschrift.js`, `test/knAnsichtQuelltext.test.js` (P2-Regel)
- Test: `test/knAnsichtGutschrift.test.js` (ersetzt)
**Interfaces:**
- Consumes: Task 9, `GET /api/abo/takeover-credits`, `POST /api/abo/takeover-credits` (Task 8), `ladeAbo`, `sammleDateien`, `IO`, `meldeTitel` (P2). `route.query` trägt den Roh-String hinter `?` (V2).
- Produces (unverändert): `zeigeGutschrift(wurzel, { route, me, nav })`, `sammleDateien(...)`, `IO`.
Formular: Die Klappe `details.tk-conditions` und das Häkchen `#tk-consent` entfallen. Über dem Knopf steht `p#tk-satz` mit `bestelltexte.gutschrift.satz` (Link „Bedingungen der Übernahmegutschrift“ → `#gutschrift?bedingungen=1` mit `target="_blank"`, Link „Datenschutz“ → `/datenschutz.html#uebernahmegutschrift`). Der Absenden-Klick sendet die bisherigen Felder plus `consent: true` und `termsVersion: daten.termsVersion`. Bei `route.query === 'bedingungen=1'` zeigt die Seite stattdessen, mit `meldeTitel(wurzel, 'Bedingungen der Übernahmegutschrift')` als Titel (keine eigene `h1`, P2, P1 VF11), die fünf Sätze des Blocks `TAKEOVER_CONDITIONS` als nummerierte Liste (`ol > li`) und den Link „Zurück zum Antrag“ (`#gutschrift`). Der Block `TAKEOVER_CONDITIONS` bleibt unverändert (Hash-Pin, `conditionsVersion` `2026-09-30`). Alle übrigen Sie-Texte der Seite werden du; mindestens `Bitte lade die Rechnung deines bisherigen Anbieters hoch.`
- [ ] **Step 1: Failing tests schreiben** (Hilfen: `ABO = aboNeu({ subscription: abo() })`, `ANTRAEGE` wie in der bisherigen Datei, `SRV = { 'GET /api/abo': ABO, 'GET /api/abo/takeover-credits': ANTRAEGE }`, `zeigeNeu(fx, m = meMit('unternehmen'))` ruft `zeigeGutschrift`, `fuelleAntrag(fx, w)` füllt `services=hosting`, `#tk-provider`, `#tk-amount`, `#tk-months`, `#tk-paid-until`, `#tk-message`, `#tk-email` und setzt eine Datei an `#tk-files`; `IO.lies` wird ersetzt)
```js
test('Formular: Satz vor dem Knopf, keine Klappe, kein Häkchen, Links wie im Wortlaut', mit(SRV, async (fx) => {
const w = await zeigeNeu(fx); await fx.klick(fx.finde(w, '#tk-start'))
assert.deepEqual([fx.finde(w, '#tk-consent'), fx.alle(w, 'details').length, fx.finde(w, '#tk-submit').textContent], [null, 0, 'Antrag absenden'])
const ids = fx.alle(w, '[id]').map((e) => e.id); assert.ok(ids.indexOf('tk-satz') > -1 && ids.indexOf('tk-satz') < ids.indexOf('tk-submit'))
assert.equal(fx.text(fx.finde(w, '#tk-satz')), klartext(ABO.bestelltexte.gutschrift.satz.text))
const [bed, ds] = fx.alle(w, '#tk-satz a')
assert.deepEqual([bed.textContent, bed.getAttribute('href'), bed.getAttribute('target'), ds.getAttribute('href')], ['Bedingungen der Übernahmegutschrift', '#gutschrift?bedingungen=1', '_blank', '/datenschutz.html#uebernahmegutschrift'])
}))
test('Absenden sendet consent und die Fassung, nie eine Kundenart', mit({ ...SRV, 'POST /api/abo/takeover-credits': { request: ANTRAEGE.requests[0] } }, async (fx) => {
IO.lies = async () => 'UERGLTEuNA=='; const w = await zeigeNeu(fx); await fx.klick(fx.finde(w, '#tk-start'))
await fuelleAntrag(fx, w); await fx.klick(fx.finde(w, '#tk-submit')); await fx.ruhe()
const b = fx.fetchLog.find((a) => a.method === 'POST' && a.url === '/api/abo/takeover-credits').body
assert.deepEqual([b.consent, b.termsVersion, 'kundenart' in b], [true, NEU, false])
}))
test('Bedingungen-Ansicht: Titel als Ereignis, fünf Sätze aus dem Block, Rückweg, kein Formular', mit(SRV, async (fx) => {
const w = neueWurzel(fx); const titel = []; w.addEventListener('kn:titel', (e) => titel.push(e.detail.text))
await zeigeGutschrift(w, { route: { bereich: 'vertraege', etage: 'gutschrift', query: 'bedingungen=1' }, me: meMit('unternehmen'), nav: 'push' }); await fx.ruhe()
assert.deepEqual(titel, ['Bedingungen der Übernahmegutschrift']); assert.equal(fx.alle(w, 'h1').length, 0); assert.equal(fx.alle(w, 'ol > li').length, 5)
assert.equal(fx.finde(w, 'ol > li').textContent, 'Die Übernahmegutschrift gibt es für Leistungen, die Ihr Abo ersetzt (Domain, Hosting bzw. Betrieb der Website, E-Mail-Postfächer) und die Sie bei Ihrem bisherigen Anbieter bereits im Voraus bezahlt haben.')
assert.equal(fx.finde(w, 'a[href="#gutschrift"]').textContent, 'Zurück zum Antrag'); assert.equal(fx.finde(w, '#tk-form'), null)
}))
```
Übernommen (Sie-Texte zu du): der Hash-Pin des Blocks `TAKEOVER_CONDITIONS` (`2b7a745cb599c0ab28f20615a745bd86da6faa8d120391ca56beac35a0473e62`, Block unverändert), `sammleDateien` mit den Grenzen (erste Meldung jetzt `Bitte lade die Rechnung deines bisherigen Anbieters hoch.`), Statuspillen, Rückfrage-Antwort, Teilausfall, Ansichtsmodus (`Im Ansichtsmodus kann kein Antrag gestellt werden.`).
`test/knAnsichtQuelltext.test.js`: Die Liste `WORTLAUT` wird `[]`; die Du-Regel gilt für alle Module, in `ansicht-gutschrift.js` vorher den Block `const TAKEOVER_CONDITIONS = [ … ]` entfernen. Zusätzlich für `ansicht-bestellen.js`, `ansicht-wechsel.js`, `ansicht-speicher.js`, `ansicht-gutschrift.js`, `bestell-bausteine.js`: `assert.doesNotMatch(q, /body:\s*\{[^}]*kundenart/s)` und `assert.doesNotMatch(q, /h\(\s*['"]h1['"]/)` (keine eigene `h1`, P2).
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knAnsichtGutschrift.test.js test/knAnsichtQuelltext.test.js`
Expected: FAIL (`#tk-satz` fehlt, Sie-Texte in den vier Dateien).
- [ ] **Step 3: Umbauen** nach dem Aufbau oben und alle Sie-Sätze der vier Dateien (`ansicht-bestellen.js`, `-wechsel.js`, `-speicher.js`, `-gutschrift.js`) auf du prüfen.
- [ ] **Step 4: Lauf grün, dazu die Hash-Pins**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knAnsichtGutschrift.test.js test/knAnsichtQuelltext.test.js test/knAnsichtOrakel.test.js test/legalTexts.test.js test/adminPanelGone.test.js`
Expected: PASS, 0 fail.
- [ ] **Step 5: Commit**
```bash
git add public/neu/ansicht-gutschrift.js test/knAnsichtGutschrift.test.js test/knAnsichtQuelltext.test.js
git commit -m "Kundenansicht neu P7: Gutschrift-Antrag schlank, Bedingungen als eigene Ansicht statt Klappe" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 14: Fehlertexte der Dienste in der neuen Ansicht in du
`planSwitch.js`, `mailStorage.js` und `stripeBilling.js` werfen ihre Fehlertexte in Sie-Form („Bitte laden Sie die Seite neu“), und die Routen in `abo.js` antworten teils mit eigenen Sie-Sätzen. Die neue Ansicht zeigt sie unverändert an (`ApiFehler.message`), also stünde Sie in einer du-Oberfläche. Die Texte im Quelltext bleiben, damit die alte Ansicht unverändert bleibt. Dieser Task übersetzt bekannte Sätze vor der Antwort, nur für Sitzungen mit neuer Ansicht.
**Files:**
- Create: `server/services/duFehler.js`
- Modify: `server/routes/abo.js` und `server/routes/takeoverCredits.js` (je eine Zeile am Anfang des Routers: `router.use(duFehlerAntworten)`)
- Test: `test/knDuFehler.test.js`
**Interfaces:**
- Consumes: `neueAnsicht(req)` (V1).
- Produces:
```js
export const DU_FEHLER // Object.freeze({ [Sie-Satz]: du-Satz }); Quellen: planSwitch.js, mailStorage.js, stripeBilling.js, die Antworten in abo.js, zwei Sätze aus takeoverCredits.js (Tabelle im Test)
export function duText(message) // bekannter Satz → du-Satz; Muster 'Bitte wählen Sie zwischen 0 und <n> Paketen Zusatzspeicher.' → 'Bitte wähle zwischen 0 und <n> Paketen Zusatzspeicher.'; alles andere (auch kein Text) unverändert
export function duFehlerAntworten(req, res, next) // Express-Middleware: gilt neueAnsicht(req), ersetzt sie in res.json den String body.error durch duText(body.error); sonst reicht sie durch
```
- [ ] **Step 1: Failing tests schreiben** (`test/knDuFehler.test.js`; Testkopf Server wie in den Global Constraints, dazu `readFileSync`; die Routen-Hilfen `post`, `customerCookie` wie in `test/knCheckoutFassung6.test.js`)
```js
const SIE = /\b(Sie|Ihr|Ihre|Ihren|Ihrem|Ihrer|Ihres|Ihnen)\b/
const PAARE = [ // [Sie-Satz im Quelltext, du-Satz in der neuen Ansicht]
// planSwitch.js
['Der Wechsel ist erst nach Beginn Ihres Hosting-Abos möglich und nicht, solange eine Zahlung offen ist.', 'Der Wechsel ist erst nach Beginn deines Hosting-Abos möglich und nicht, solange eine Zahlung offen ist.'],
['Ihr Hosting-Abo ist gekündigt. Ein Wechsel ist deshalb nicht möglich.', 'Dein Hosting-Abo ist gekündigt. Ein Wechsel ist deshalb nicht möglich.'],
['Die Vorschau fehlt. Bitte laden Sie die Seite neu.', 'Die Vorschau fehlt. Bitte lade die Seite neu.'],
['Die Vorschau ist ungültig. Bitte laden Sie die Seite neu.', 'Die Vorschau ist ungültig. Bitte lade die Seite neu.'],
['Bitte stimmen Sie den Vertragsbedingungen zu, um zu wechseln.', 'Bitte stimme den Vertragsbedingungen zu, um zu wechseln.'],
['Bitte bestätigen Sie, dass wir sofort mit dem Wartungs-Abo beginnen sollen.', 'Bitte bestätige, dass wir sofort mit dem Wartungs-Abo beginnen sollen.'],
['Ihr Wechsel wird gerade verarbeitet.', 'Dein Wechsel wird gerade verarbeitet.'],
['Ihr Abrechnungsmonat hat seit der Vorschau neu begonnen. Bitte Seite neu laden.', 'Dein Abrechnungsmonat hat seit der Vorschau neu begonnen. Bitte Seite neu laden.'],
// mailStorage.js
['Ihr Speicherstand hat sich geändert. Bitte Seite neu laden und der Bestellung zustimmen.', 'Dein Speicherstand hat sich geändert. Bitte Seite neu laden und der Bestellung zustimmen.'],
['Bitte stimmen Sie den Regeln zum Zusatzspeicher zu.', 'Bitte stimme den Regeln zum Zusatzspeicher zu.'],
['Ihr Abo ist gekündigt. Der Zusatzspeicher endet mit dem Abo.', 'Dein Abo ist gekündigt. Der Zusatzspeicher endet mit dem Abo.'],
['Ihr Wechsel wartet noch auf die Zahlung. Bitte versuchen Sie es danach erneut.', 'Dein Wechsel wartet noch auf die Zahlung. Bitte versuche es danach erneut.'],
['Eine Änderung läuft bereits. Bitte warten Sie einen Moment und laden Sie die Seite neu.', 'Eine Änderung läuft bereits. Bitte warte einen Moment und lade die Seite neu.'],
['Die Änderung konnte bei Stripe nicht gespeichert werden. Es wurde nichts berechnet. Bitte versuchen Sie es später erneut.', 'Die Änderung konnte bei Stripe nicht gespeichert werden. Es wurde nichts berechnet. Bitte versuche es später erneut.'],
// stripeBilling.js
['Sie haben bereits ein laufendes Abo abgeschlossen.', 'Du hast bereits ein laufendes Abo abgeschlossen.'],
// abo.js
['Bitte wählen Sie ein Abo (Hosting-Abo oder Wartungs-Abo).', 'Bitte wähle ein Abo (Hosting-Abo oder Wartungs-Abo).'],
['Bitte stimmen Sie den Vertragsbedingungen zu, um zu bestellen.', 'Bitte stimme den Vertragsbedingungen zu, um zu bestellen.'],
['Bitte wählen Sie, wann wir mit der Leistung beginnen sollen.', 'Bitte wähle, wann wir mit der Leistung beginnen sollen.'],
['Ihr Angebot gilt für ein anderes Abo. Bitte laden Sie die Seite neu.', 'Dein Angebot gilt für ein anderes Abo. Bitte lade die Seite neu.'],
['Ihr Angebot hat sich geändert. Bitte laden Sie die Seite neu.', 'Dein Angebot hat sich geändert. Bitte lade die Seite neu.'],
['Der Leistungsbeginn hat sich durch den Datumswechsel verschoben. Bitte laden Sie die Seite neu.', 'Der Leistungsbeginn hat sich durch den Datumswechsel verschoben. Bitte lade die Seite neu.'],
['Die Bestellung ist unvollständig. Bitte laden Sie die Seite neu.', 'Die Bestellung ist unvollständig. Bitte lade die Seite neu.'],
// takeoverCredits.js (die zwei Sätze, die die Seite nicht vorab prüft)
['Die Übernahmegutschrift können Sie beantragen, sobald Ihr Abo läuft.', 'Die Übernahmegutschrift kannst du beantragen, sobald dein Abo läuft.'],
['Sie haben bereits mehrere offene Anträge. Bitte warten Sie unsere Antwort ab.', 'Du hast bereits mehrere offene Anträge. Bitte warte auf unsere Antwort.'],
]
test('duText: jeder bekannte Satz wird du, Unbekanntes bleibt', () => {
for (const [sie, du] of PAARE) { assert.equal(duText(sie), du); assert.doesNotMatch(du, SIE, du) }
assert.equal(duText('Bitte wählen Sie zwischen 0 und 10 Paketen Zusatzspeicher.'), 'Bitte wähle zwischen 0 und 10 Paketen Zusatzspeicher.')
for (const x of ['Kein Abo vorhanden.', 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.', '', undefined, 7]) assert.equal(duText(x), x)
})
test('Quelltext: jeder Sie-Satz der drei Dienste hat eine Übersetzung', () => {
const AUSNAHMEN = ['Die Zahlung der Wechselrechnung wurde nicht rechtzeitig abgeschlossen, zum Beispiel weil die von Ihrer Bank verlangte Bestätigung nicht erfolgt ist.'] // Grund in der Mail K14, keine Antwort der API
for (const datei of ['planSwitch.js', 'mailStorage.js', 'stripeBilling.js']) {
const q = readFileSync(new URL(`../server/services/${datei}`, import.meta.url), 'utf8').replace(/^\s*(?:\/\/|\*|\/\*).*$/gm, '')
for (const [, satz] of q.matchAll(/'([^'\n]*\b(?:Sie|Ihr|Ihre|Ihren|Ihrem|Ihrer|Ihres|Ihnen)\b[^'\n]*)'/g)) if (!AUSNAHMEN.includes(satz)) assert.notEqual(duText(satz), satz, `${datei}: ${satz}`)
}
})
test('Route: Pilot-Kunde bekommt du, Nicht-Pilot und Gast bleiben bei Sie', async () => {
const body = { productKey: 'gibtsnicht', acceptTerms: true }
const pilot = await post('/api/abo/checkout', body, { cookie: customerCookie('c1') })
const alt = await post('/api/abo/checkout', body, { cookie: customerCookie('c2') })
assert.deepEqual([pilot.status, pilot.body.error, alt.status, alt.body.error], [400, 'Bitte wähle ein Abo (Hosting-Abo oder Wartungs-Abo).', 400, 'Bitte wählen Sie ein Abo (Hosting-Abo oder Wartungs-Abo).'])
assert.equal((await post('/api/abo/checkout', body)).status, 401) // ohne Sitzung: keine Umschreibung, keine Antwort mit Text
})
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knDuFehler.test.js`
Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `server/services/duFehler.js`.
- [ ] **Step 3: Implementieren** (Signaturen oben). `DU_FEHLER` aus den Paaren des Tests, tief eingefroren; `duText` schlägt exakt nach und kennt das eine Muster der Paketzahl; `duFehlerAntworten` wickelt `res.json` ein, nur bei `neueAnsicht(req)` und `typeof body?.error === 'string'`. `router.use(duFehlerAntworten)` steht in `abo.js` und `takeoverCredits.js` als erste Zeile nach dem Anlegen des Routers. Die Texte in den Diensten und Routen bleiben unverändert. Die Sätze der Fassung-6-Zweige aus Task 4 bis 8 sind schon du und laufen unverändert durch.
- [ ] **Step 4: Lauf grün, dazu die alten Tests der Dienste**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knDuFehler.test.js test/aboRoutes.test.js test/planSwitch.test.js test/mailStorage.test.js test/takeoverCredits.test.js`
Expected: PASS, 0 fail.
- [ ] **Step 5: Commit**
```bash
git add server/services/duFehler.js server/routes/abo.js server/routes/takeoverCredits.js test/knDuFehler.test.js
git commit -m "Kundenansicht neu P7: bekannte Fehlertexte der Dienste in der neuen Ansicht in du" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 15: Kündigen und Widerrufen in du (E15, C14)
Spec 5.7 und E15 verlangen „du“ auch auf den öffentlichen Formularseiten, C14 bindet es an Fassung 6. P2 Task 17 hat die zwei Seiten mit dem Text der Fassung 5 übernommen (Sie, mit einem Vergleichstest gegen die alten Seiten). Bis zum Umschalten liefert der Server die alten Seiten aus; die neuen Dateien erreicht erst P10.
**Files:**
- Modify: `public/neu/oeffentlich/kuendigen.html`, `public/neu/oeffentlich/widerrufen.html` (sichtbare Texte, dazu das Attribut `data-anrede="du"` am `<form id="decl-form">`), `public/legal-form.js` (nur der Zweig mit `data-anrede="du"`), `public/auftrag-widerruf.js` (nur der Zweig mit `data-anrede="du"`), `test/knAnsichtOeffentlich.test.js` (P2: der Textvergleich entfällt für diese zwei Seiten)
- Test: `test/knOeffentlichDu.test.js` (neu)
**Interfaces:**
- Consumes: die Seiten und Skripte aus P2 Task 17; `initProjektWiderruf(form)` (`auftrag-widerruf.js`, Modul), `initLegalForm({ endpoint, fields })` (`legal-form.js`, klassisches Skript).
- Produces:
- Sichtbarer Text beider neuen Seiten ohne Sie-Wörter (Pins im Test). Knöpfe und Ablauf bleiben (`Jetzt kündigen`, `Widerruf bestätigen`, keine Halteangebote).
- `legal-form.js`: `function legalFormPaketeFehler(wert, max)` → `''` bei leerem (getrimmtem) Wert oder einer ganzen Zahl von 1 bis `max`, sonst `Bitte gib 1 bis <max> Pakete an oder lass das Feld leer (alle Pakete).` Der Absende-Zweig ruft sie nur bei `form.getAttribute('data-anrede') === 'du'` und `storageOnly()` auf (`max` aus dem Feld `packages`, Vorgabe 10), vor dem `fetch`, zeigt das Ergebnis in `#form-error` und sendet nichts. Der Sie-Satz des Servers für diese Eingabe erreicht die neue Seite damit nicht.
- `auftrag-widerruf.js`: Bei `form.getAttribute('data-anrede') === 'du'` lautet die Meldung für eine falsche Auftragsnummer `Die Nummer sieht anders aus als AB-2026-0001. Bitte prüfe die Nummer oder lass das Feld leer.`, sonst unverändert `texte().abNummerUngueltig`. `auftrag-ansicht.js` und `TEXTE_DU` bleiben unberührt (Pin `test/auftraegeKundenportalAnsicht.test.js:188`).
- Ohne das Attribut verhalten sich beide Skripte und die alten Seiten byteweise wie heute.
- [ ] **Step 1: Failing tests schreiben** (`test/knOeffentlichDu.test.js`)
```js
const lese = (p) => readFileSync(new URL(`../public/${p}`, import.meta.url), 'utf8')
const sichtbar = (html) => html.replace(/<!--[\s\S]*?-->|<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>/g, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ')
const SIE = /\b(Sie|Ihr|Ihre|Ihren|Ihrem|Ihrer|Ihres|Ihnen)\b/
test('Kündigen und Widerrufen (neu): kein Sie im sichtbaren Text, Attribut gesetzt; die alten Seiten bleiben', () => {
for (const n of ['kuendigen', 'widerrufen']) {
const neu = lese(`neu/oeffentlich/${n}.html`)
assert.doesNotMatch(sichtbar(neu), SIE, n); assert.match(neu, /<form id="decl-form"[^>]*data-anrede="du"/, n)
if (existsSync(new URL(`../public/${n}.html`, import.meta.url))) { // P10 löscht die alten Seiten, dann entfällt dieser Teil
assert.doesNotMatch(lese(`${n}.html`), /data-anrede/, `${n}: alte Seite unverändert`); assert.match(sichtbar(lese(`${n}.html`)), SIE, `${n}: alte Seite bleibt Sie`)
}
}
})
test('Text-Pins der du-Fassung', () => {
const k = sichtbar(lese('neu/oeffentlich/kuendigen.html')); const w = sichtbar(lese('neu/oeffentlich/widerrufen.html'))
for (const s of ['Zahl der Pakete, die du kündigst (je 5 GB; leer = alle gebuchten Pakete)', 'Das geht auch, wenn dein Abo schon gekündigt ist.', 'Deine Kündigung ist eingegangen',
'Eine Eingangsbestätigung mit diesen Angaben haben wir an deine E-Mail-Adresse geschickt. Den Zeitpunkt, zu dem die Kündigung wirkt, bestätigen wir dir per E-Mail.']) assert.ok(k.includes(s), s)
for (const s of ['Dein Widerruf ist bei uns eingegangen', 'Eine Eingangsbestätigung mit diesen Angaben haben wir an deine E-Mail-Adresse geschickt.']) assert.ok(w.includes(s), s)
})
test('legalFormPaketeFehler: leer und 1 bis max passen, alles andere bekommt den du-Satz', () => {
const { legalFormPaketeFehler } = new Function(`${lese('legal-form.js')}\nreturn { legalFormPaketeFehler }`)()
for (const ok of ['', ' ', '1', '10']) assert.equal(legalFormPaketeFehler(ok, 10), '', JSON.stringify(ok))
for (const schlecht of ['0', '11', '-1', '2,5', 'abc']) assert.equal(legalFormPaketeFehler(schlecht, 10), 'Bitte gib 1 bis 10 Pakete an oder lass das Feld leer (alle Pakete).', schlecht)
assert.match(lese('legal-form.js'), /getAttribute\('data-anrede'\) === 'du'/)
})
test('auftrag-widerruf.js: du-Meldung bei data-anrede, sonst der alte Satz', mit(async (fx) => { // Rahmen `mit`, `h`, `initProjektWiderruf` wie in test/auftraegeKundenportalWiderruf.test.js
const DU = 'Die Nummer sieht anders aus als AB-2026-0001. Bitte prüfe die Nummer oder lass das Feld leer.'
const ALT = 'Die Nummer sieht anders aus als AB-2026-0001. Bitte prüfen Sie die Nummer oder lassen Sie das Feld leer.'
for (const [attribute, erwartet] of [[{ 'data-anrede': 'du' }, DU], [{}, ALT]]) {
const nummer = h('input', { id: 'abNummer', name: 'abNummer' }); const fehler = h('p', { id: 'form-error', class: 'error hidden' }); const knopf = h('button', { type: 'submit' }, 'Widerruf bestätigen')
const form = h('form', { id: 'decl-form', ...attribute }, h('label', {}, h('input', { type: 'radio', name: 'order', value: 'projekt', checked: true })), h('div', { id: 'ab-nummer-wrap' }, nummer), fehler, knopf)
fx.document.body.append(form); initProjektWiderruf(form)
await fx.setze(nummer, 'irgendwas'); await fx.klick(knopf)
assert.equal(fehler.textContent, erwartet); assert.equal(fehler.classList.contains('hidden'), false)
}
}))
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knOeffentlichDu.test.js`
Expected: FAIL (Sie-Wörter in den neuen Seiten, `legalFormPaketeFehler` fehlt).
- [ ] **Step 3: Umsetzen**
- `kuendigen.html` (neu): `Zahl der Pakete, die du kündigst`, `wenn dein Abo schon gekündigt ist`, `Deine Kündigung ist eingegangen`, `… an deine E-Mail-Adresse geschickt. … bestätigen wir dir per E-Mail.` `widerrufen.html` (neu): `Dein Widerruf ist bei uns eingegangen`, `… an deine E-Mail-Adresse geschickt.` Alle übrigen Wörter, Felder, Werte und Skripte bleiben wie in P2 Task 17. Das Attribut `data-anrede="du"` steht am `<form id="decl-form">` beider Seiten.
- `legal-form.js` und `auftrag-widerruf.js` nach den Interfaces; sonst keine Zeile ändern.
- `test/knAnsichtOeffentlich.test.js`: in den Tests für `kuendigen.html` und `widerrufen.html` entfällt `assert.equal(sichtbarerText(lese(neu)), sichtbarerText(lese(alt)))`. Die Vergleiche der Feldattribute und der Skripte bleiben, für `login.html` und `offline.html` bleibt der Textvergleich.
- [ ] **Step 4: Lauf grün, dazu die Tests der alten Seiten und Skripte**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knOeffentlichDu.test.js test/knAnsichtOeffentlich.test.js test/publicForms.test.js test/auftraegeKundenportalWiderruf.test.js test/auftraegeAblauf.test.js test/auftraegeKundenportalAnsicht.test.js`
Expected: PASS, 0 fail.
- [ ] **Step 5: Commit**
```bash
git add public/neu/oeffentlich/kuendigen.html public/neu/oeffentlich/widerrufen.html public/legal-form.js public/auftrag-widerruf.js test/knOeffentlichDu.test.js test/knAnsichtOeffentlich.test.js
git commit -m "Kundenansicht neu P7: Kündigen und Widerrufen in du (Fassung 6, C14)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 16: Werkzeug: Fälle, Ansichten und Gate `p7`
**Files:** (alle in `KW`, eigenes Git; nichts im Portal-Worktree)
- Create: `fixtures/endpunkte-p7.mjs`, `fixtures/faelle-p7.mjs`, `test/bestelltexte.test.mjs`
- Modify: `fixtures/endpunkte.mjs` und `fixtures/faelle.mjs` (je ein Import; `FALL_NAMEN` um zwei Fälle anhängen), `views.mjs`, `gates.mjs`, `vertrag-endpunkte.mjs`, `test/backend.test.mjs` (der Pin „Fälle in fester Reihenfolge“ bekommt die zwei Namen am Ende, der Testname wird von „vierzehn“ zu „sechzehn Fälle“; P2 hat keinen Fall ergänzt, die Änderung gehört P7 allein)
**Interfaces:**
- Consumes: Werkzeug aus P0 (`ANSICHTEN`-Einträge `{ id, fall, adresse, geraete, warte, schritte?(seite) }`, `GATES.p<N> = { checks, ansichten }`, `ALWAYS_ON`, `SEITEN_PRUEFUNGEN`, Aufruf `shots.sh <ausgabe> [--only id,…] [--gate pN]`, Browser nur über `run-browser.sh`, Erweiterungsweg laut P0-Vertragsfrage 5). Weicht P0 ab, gilt P0.
- Produces: Die Antwort `abo` trägt in allen Fällen `termsVersion` und `bestelltexte` als Kopie der echten Werte: `termsVersion = config.abo.termsVersionNeu`, `bestelltexte = bestelltexteFuerClient({ fassung: config.abo.termsVersionNeu, termsUrl: termsDocFor(config.abo.termsVersionNeu).url })` (Stand P8: `2026-10-16-v6` und `/neu/rechtliches/abo-bedingungen.html`). Die Fixture ist gebündelt und kann `server/` nicht importieren; `test/bestelltexte.test.mjs` bindet die Kopie an den Server, ein Datumswechsel der Freigabe macht ihn rot, dann wird die Fixture nachgezogen. Neue Fälle (angehängt): `privat-ohne-abo` (wie `ohne-abo`, `customer.kundenart: 'privat'`, `kundenartWirksam: 'privat'`) und `hosting-privat` (wie `hosting`, privat). Für die Speicher-Ansichten gibt es keinen dritten Fall: Sie nehmen `betrieb-wartung` und überschreiben im Vorlauf `/api/abo` wie P2 `kn-p2-email-plus` (`seite.route('**/api/abo', …)` setzt `subscription.canChangeStorage = true` und `subscription.storage = { units: 2, pendingSetup: false, pendingReduction: null }`, danach `seite.reload()`). Gate `p7 = { checks: [...ALWAYS_ON, ...SEITEN_PRUEFUNGEN], ansichten: ['b7-*'] }`. Hilfsprüfung `kerninfo(seite, blockSel, knopfSel)` in `views.mjs`: wirft, wenn zwischen Block und Knopf ein Vorfahr `details`, `dialog` oder ein Element mit `overflow-y: auto|scroll` liegt oder `knopf.bottom - block.top > innerHeight` gilt.
Ansichten (Adresse `/dashboard.html` plus Hash; `schritte` wirft bei Abweichung):
| Ansicht | Hash | Fall | Geräte | Warten auf | Prüfung |
|---|---|---|---|---|---|
| `b7-bestellen-betrieb` | `#bestellen-wartung` | `ohne-abo` | Handy, Tablet, Desktop | `#abo-buy` | kein `#abo-beginn`; Knopftext wörtlich; `kerninfo('#abo-block', '#abo-buy')`; Zeile „Du bestellst als Betrieb“ |
| `b7-bestellen-privat` | `#bestellen-wartung` | `privat-ohne-abo` | Handy, Desktop | `#abo-buy` | `#abo-beginn` ungehakt, Beginn „nach Ablauf“; Tap auf das Häkchen ergibt „sofort“; `kerninfo`; „Du bestellst privat“ |
| `b7-bestellen-hosting` | `#bestellen-hosting` | `ohne-abo` | Handy | `#abo-buy` | wie Betrieb |
| `b7-wechsel-betrieb` | `#wechsel` | `hosting` | Handy, Desktop | `#switch-buy:not([disabled])` | kein `#switch-beginn`; `kerninfo('#switch-block', '#switch-buy')` |
| `b7-wechsel-privat` | `#wechsel` | `hosting-privat` | Handy | `#switch-sperre` | Knopf gesperrt; nach Tap auf `#switch-beginn` frei und Sperrzeile weg |
| `b7-speicher-plus` | `#speicher` | `betrieb-wartung` (`/api/abo` im Vorlauf überschrieben, siehe Produces) | Handy | `#storage-plus` | Tap Plus: `#storage-linkzeile` und `#storage-submit` da, kein `#storage-terms`, `kerninfo('#storage-block', '#storage-submit')` |
| `b7-speicher-minus` | `#speicher` | `betrieb-wartung` (wie oben) | Handy | `#storage-minus` | Tap Minus: genau eine `#storage-warnung`, Knopf „Speichern“ |
| `b7-gutschrift` | `#gutschrift` | `betrieb-wartung` | Handy | `#tk-start` | Tap: `#tk-satz` vor `#tk-submit`, kein `details` |
| `b7-gutschrift-bedingungen` | `#gutschrift?bedingungen=1` | `betrieb-wartung` | Handy | `ol` | fünf `li`, Link „Zurück zum Antrag“ |
- [ ] **Step 1: Failing tests schreiben** (`KW/test/bestelltexte.test.mjs`; `WT` aus `process.env.WT`, sonst der Vorgabewert aus `env.sh`)
```js
test('Die Fixture trägt dieselbe Fassung, Adresse und denselben Wortlaut wie der Server', async () => {
const { config } = await import(`${WT}/server/config.js`)
const { termsDocFor } = await import(`${WT}/server/services/products.js`)
const { bestelltexteFuerClient } = await import(`${WT}/server/services/bestelltexte.js`)
const NEU = config.abo.termsVersionNeu
for (const fall of ['ohne-abo', 'hosting', 'betrieb-wartung', 'privat-ohne-abo', 'hosting-privat']) {
const abo = ladeFall(fall).daten.abo
assert.equal(abo.termsVersion, NEU, fall)
assert.deepEqual(abo.bestelltexte, bestelltexteFuerClient({ fassung: NEU, termsUrl: termsDocFor(NEU).url }), fall)
}
})
test('Zwei neue Fälle sind privat und stehen am Ende; die Gates kennen alle b7-Ansichten', () => {
assert.deepEqual(FALL_NAMEN.slice(-2), ['privat-ohne-abo', 'hosting-privat'])
for (const f of ['privat-ohne-abo', 'hosting-privat']) assert.deepEqual([ladeFall(f).daten['auth.me'].customer.kundenart, ladeFall(f).daten['auth.me'].customer.kundenartWirksam], ['privat', 'privat'])
assert.doesNotThrow(() => pruefeGate(GATES.p7)); assert.equal(waehleAnsichten(GATES.p7.ansichten).length, 9)
})
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/bestelltexte.test.mjs`
Expected: FAIL (`bestelltexte` fehlt in `abo`, Fälle und Gate unbekannt).
- [ ] **Step 3: Implementieren** nach Interfaces und Tabelle. Der Vertragseintrag `abo` in `vertrag-endpunkte.mjs` bekommt `vorher`, das `config.kundenansichtNeu.mode` auf `'on'` setzt (damit die echte Antwort `bestelltexte` trägt), mit Rücksetzfunktion; die Fixture folgt der echten Antwort, Ausnahmen nur mit Grund.
- [ ] **Step 4: Tests ohne Browser grün**
Run: `cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/bestelltexte.test.mjs test/backend.test.mjs test/gates.test.mjs test/vertrag.test.mjs`
Expected: `fail 0`. Meldet `vertrag.test.mjs` überholte Ausnahmen aus V4 bis V8 (P5/P6 sind gemergt), streiche genau die genannten.
- [ ] **Step 5: Sichtprüfung über das Werkzeug**
Run: `free -m`, dann `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/shots.sh /root/reports/kundenansicht-neu-2026-10-15/werkzeug/shots/p7 --gate p7`
Expected: Exit 0, Bericht ohne Verstöße (`step-failed`, `external-request`, `console-error`, `unknown-api`, `backend-error`, `dark-surface`, `low-contrast`, `overflow-x`, `lila` zählen). Danach dieselben Ansichten aus P2 gegen die umgebauten Seiten: `shots.sh …/shots/p2-bestellen --only kn-p2-bestellen,kn-p2-bestellen-hosting,kn-p2-wechsel,kn-p2-email,kn-p2-email-plus,kn-p2-gutschrift,kn-p2-gutschrift-frage,kn-p2-kuendigen,kn-p2-widerrufen --gate p2` (die zwei letzten prüfen die du-Seiten aus Task 15). Prüft ein P2-Schritt Inhalte der Fassung 5 (zum Beispiel `#storage-terms`), passt nur dieser Schritt an. Zuletzt `ls -l /dev/null` (`crw-rw-rw-`).
- [ ] **Step 6: Aufnahmen ansehen**
Je Ansicht auf Handy, Tablet und Desktop: Block und Knopf stehen untereinander ohne Klappe, die Linkzeile und das Häkchen (nur Privat) sind lesbar, nichts läuft waagerecht über, keine Karten oder Kästen, Zeilen der Anbieter-Angabe fehlen bewusst.
- [ ] **Step 7: Commit im Werkzeug-Git**
```bash
KW=/root/reports/kundenansicht-neu-2026-10-15/werkzeug
git -C $KW add fixtures/endpunkte-p7.mjs fixtures/faelle-p7.mjs fixtures/endpunkte.mjs fixtures/faelle.mjs views.mjs gates.mjs vertrag-endpunkte.mjs test/bestelltexte.test.mjs test/backend.test.mjs
git -C $KW commit -m "Werkzeug P7: Bestellseiten-Ansichten, zwei private Fälle, Gate p7" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 17: Selbstprüfung
**Files:** keine Änderungen (Korrekturen nur als eigener Fix-Commit auf den betroffenen Task).
- [ ] **Step 1: Speicher prüfen**
Run: `free -m`
Expected: mindestens 2000 MB verfügbar, sonst warten und den Controller melden.
- [ ] **Step 2: Alle eigenen Testdateien grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knVerdrahtung.test.js test/knBestelltexte.test.js test/knVertragsNachweis.test.js test/knAboFassung6.test.js test/knCheckoutFassung6.test.js test/knWechselFassung6.test.js test/knBestaetigungsmails.test.js test/knSpeicherFassung6.test.js test/knGutschriftFassung6.test.js test/knBestellQuelltext.test.js test/knBestellBausteine.test.js test/knAnsichtBestellen.test.js test/knAnsichtWechsel.test.js test/knAnsichtSpeicher.test.js test/knAnsichtGutschrift.test.js test/knDuFehler.test.js test/knOeffentlichDu.test.js`
Expected: PASS, 0 fail (17 Dateien). Die Gleichheitstests `knBestelltexte` und `knVerdrahtung` und der Verdrahtungstest in `aboWortlautV6.test.js` müssen grün sein, bevor der Inhaber `WORTLAUT_STATUS` auf `'freigegeben'` setzen lässt (VF9).
- [ ] **Step 3: Bestehende Pins und Nachbartests grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/aboRoutes.test.js test/planSwitch.test.js test/mailStorage.test.js test/takeoverCredits.test.js test/legalTexts.test.js test/products.test.js test/contractEvents.test.js test/featureConsents.test.js test/mailTemplates.test.js test/kundenansichtGuard.test.js test/adminPanelGone.test.js test/aboWortlautV6.test.js test/aboKatalogRegister.test.js test/aboFunktionenListe.test.js test/publicForms.test.js test/auftraegeKundenportalWiderruf.test.js test/auftraegeAblauf.test.js test/auftraegeKundenportalAnsicht.test.js test/kn*.test.js`
Expected: PASS, 0 fail (die Datei-Glob `kn*` schließt die Tests von P1 bis P4 ein; `aboWortlautV6.test.js` ohne übersprungenen Test).
- [ ] **Step 4: Verträge und Spec abhaken**
- [ ] V12: Checkout (`acceptTerms`, `earlyStart`, Betrieb sofort ohne Zeitstempel, Privat mit/ohne Häkchen) Task 4; Wechsel inkl. `planSwitch.js` Z. 351, 388, 483 Task 5; Speicher mit `acceptTerms` Task 7; Nachweis `speichereVertragsNachweis` für `abo`, `wechsel`, `speicher`, `gutschrift` Task 2, 4, 5, 7, 8; `termsVersion` aus `GET /api/abo` Task 3
- [ ] V4 und Review Focus 1: Kundenart nur aus `kundenartWirksam`, Tests mit gefälschtem Body in Task 4, 5, 7, 8; Quelltext-Pin Task 8 und 13
- [ ] Spec 5.6: A1, A2 (Task 4, 10), B1, B2 (Task 5, 6, 11), C1, C3 (Task 7, 12), D2 (Task 8, 13), Punkt 5 (Task 9 bis 12)
- [ ] Wortlaut der Fassung 6 aus einer Quelle: Linkzeile und Häkchen gleich `WORTLAUT_V6` (Task 1), P5 zeigt Linkzeile, Häkchen und Unterlagen der Fassung 6 (Task 0)
- [ ] Spec 5.7, E15, C14: Kündigen und Widerrufen in du (Task 15), Fehlertexte der Dienste in der neuen Ansicht in du (Task 14), keine eigene `h1` in den vier Ansichten (Task 10, 11, 13)
- [ ] Rechtlich fest: Kerninfo und Knopf ohne Klappe, Knopftext wörtlich (Task 10 bis 12, Werkzeug-Prüfung `kerninfo` in Task 16)
- [ ] Alte Ansicht unverändert: Step 3 grün, `git diff` unten nennt nur die Zeilen mit `data-anrede`
- [ ] **Step 5: Nur eigene Dateien geändert, Altes unverändert**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && git diff --name-only kn-p7-start HEAD && git diff --stat kn-p7-start HEAD -- public/app.js public/dashboard.html public/login.html public/kuendigen.html public/widerrufen.html public/offline.html build scripts/setup-abo-collections.mjs && git diff -U0 kn-p7-start HEAD -- public/legal-form.js public/auftrag-widerruf.js | command grep -a '^[+-][^+-]'`
Expected: Die erste Liste enthält nur Pfade aus der Dateiübersicht (und `docs/superpowers/plans/…-08-bestellseiten.md`, falls der Controller sie committet hat). Der zweite Befehl gibt nichts aus. Der dritte nennt nur Zeilen der Zweige mit `data-anrede` in `legal-form.js` (Funktion `legalFormPaketeFehler` samt Aufruf) und `auftrag-widerruf.js` (du-Meldung); keine bestehende Zeile ist geändert oder entfernt.
- [ ] **Step 6: Werkzeug-Lauf wiederholen**
Run: `free -m`, dann `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/shots.sh /root/reports/kundenansicht-neu-2026-10-15/werkzeug/shots/p7 --gate p7`
Expected: Exit 0.
- [ ] **Step 7: Sicherheit und Sauberkeit**
Run: `ls -l /dev/null && git -C /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht status --short && git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug status --short`
Expected: `crw-rw-rw-`; beide `status` ohne Ausgabe (`node_modules`, `tmp/`, `shots/` sind ignoriert).
- [ ] **Step 8: Tags setzen**
```bash
git -C /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht tag kn-p7
git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug tag kn-p7
git -C /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht log --oneline kn-p7-start..kn-p7 | wc -l
```
Expected: Zahl der Commits dieses Pakets im Portal (mindestens 16: Task 0 bis 15), kein Push.
- [ ] **Step 9: Schlussmeldung an den Controller**
Tag mit Commit-Hash, Zahl der grünen Tests je Gruppe, Ergebnis des Gates `p7`, Stand von `WORTLAUT_STATUS` (`entwurf`), Ergebnis der Gleichheitstests mit `WORTLAUT_V6` (`knBestelltexte`, `knVerdrahtung`, `aboWortlautV6`; Linkzeile und Häkchen stammen aus P8, die übrigen Sätze aus dem Entscheidungsdokument oder diesem Plan), offene Vertragsfragen.
---
## Vertragsfragen an den Controller
Der Vorschlag gilt, bis der Controller anders entscheidet. Alle sind im Plan schon so umgesetzt.
- **VF1 Zusatzdateien und fremde Dateien.** Die Zuständigkeitstabelle nennt für P7 nur `ansicht-bestellen.js`, `ansicht-wechsel.js`, `abo.js`-Pfade, `planSwitch.js:351` und `vertragsNachweis.js`. Zusätzlich braucht P7: `server/services/bestelltexte.js`, `server/services/duFehler.js`, `public/neu/bestell-bausteine.js`, `test/support/knBestellDaten.js`, die Tests `test/kn*.test.js` dieses Plans, einen angehängten Abschnitt in `public/neu/ansichten.css` und den Umbau von `ansicht-speicher.js` und `ansicht-gutschrift.js` (Vorschlag „Fassung 6 für Speicher und Gutschrift“ im P2-Plan). Vorschlag: alle in die Zeile von P7 aufnehmen; P8 ändert den Wortlaut von Linkzeile und Häkchen in `products.js` (`WORTLAUT_V6`) und im Entscheidungsdokument, `bestelltexte.js` zieht nach (Task 1, die Gleichheitstests melden Abweichungen).
- **VF2 Weitere fremde Dateien, jeweils minimal.** `server/services/stripeBilling.js` (`createAboCheckout`: Fassung aus `consent.termsVersion`, Vorgabe unverändert), `server/routes/takeoverCredits.js` (`POST /`: Nachweis bei Fassung 6; dort gibt es keine Versionsprüfung, ein Nicht-Pilot mit Fassung 6 läuft deshalb auf dem alten Weg), `server/mail-templates/K12 Wechsel Wartungs-Abo.html` (Block `sofortverlangen`), `test/mailTemplates.test.js:379`. Beim Wechsel ändert auch P6 `switchToWartung` (`details.funktion`); P7 berührt nur die drei genannten Stellen und das `details`-Feld `kundenart`. Task 0 berührt zusätzlich P5-Stellen: `server/config.js` (nur `aboFunktionen.fassung` und `.texte.beginn`), `server/services/featureRegistry.js` (`consentAbo`), `server/services/aboKatalog.js` (`unterlagen`, `hinweis`) und P5-Tests, dazu `sichereAdresse` in `public/neu/funktion-einschalten.js` (P3). Task 14 setzt je eine Zeile `router.use` in `abo.js` und `takeoverCredits.js`. Task 15 ändert zwei P2-Seiten (`public/neu/oeffentlich/kuendigen.html`, `widerrufen.html`) und nur im Zweig `data-anrede="du"` die alten Skripte `public/legal-form.js` und `public/auftrag-widerruf.js` (Verhalten ohne Attribut unverändert). Vorschlag: P7 bekommt diese Stellen zugewiesen, P5, P6, P3 und P2 ändern sie nicht mehr.
- **VF3 `speichereZustimmung` lehnt `projectId = ''` ab,** V12 verlangt aber `projectId = ''`. Vorschlag (so gebaut): `vertragsNachweis.js` schreibt selbst mit `createDocument` und nutzt `texteHash` aus `featureConsents.js`. Alternative: P5 macht `projectId` dort optional, dann ruft P7 `speichereZustimmung`.
- **VF4 `bezug` hat in V12 kein Feld.** Vorschlag (so gebaut): `bezug` und `wortlaut` (`WORTLAUT_STATUS`) stehen als Schlüssel in `fassungen` (höchstens 1000 Zeichen), `texte` bleibt die reine Anzeigereihenfolge.
- **VF5 Nachweis vor der Handlung, `stripeSessionId` leer.** Der Nachweis wird vor Stripe-Sitzung, Wechsel, Speicheränderung und Antrag geschrieben (kein Zustand ohne Nachweis); die Stripe-Sitzung existiert dann noch nicht. Folge: Bei späteren 409 oder Stripe-Fehlern bleibt eine Zeile übrig, die den Klick belegt. Zuordnung über Zeit, Konto und `bezug`.
- **VF6 Fehlertexte der Dienste in der neuen Ansicht in du (so gebaut, Task 14).** `planSwitch.js`, `mailStorage.js`, `stripeBilling.js` und die Antworten der Abo-Routen bleiben im Quelltext Sie-Form (alte Ansicht unverändert); `duFehler.js` übersetzt sie für Sitzungen mit neuer Ansicht, ein Quelltext-Test meldet jeden neuen Sie-Satz ohne Übersetzung. `offers.js` hat keine Sie-Sätze. Offen bleiben bewusst: (a) die Validierungssätze von `takeoverCredits.js` bis auf die zwei Sätze der Tabelle (die Seite prüft die Pflichtfelder vorab in du; Team-Texte in derselben Datei sollen Sie bleiben), (b) der Text auf der Stripe-Zahlungsseite (`custom_text` in `createAboCheckout`, Sie), (c) die Mails. Vorschlag: (b) und (c) entscheidet die Mail-/Textrunde (Spec 5.7).
- **VF7 Block-Inhalt.** Die Zeilen „Anbieter“ und „Abrechnung“ entfallen aus dem Block (Spec 5.6 nennt sie nicht). Die Anbieter-Angabe steht im Impressum (im Konto erreichbar). Bitte in die Rechtsrunde (P8, Kanzlei-Frage) aufnehmen; kommt die Zeile zurück, ist es eine Zeile in `ansicht-bestellen.js` und der Test `Betrieb: Block …`.
- **VF8 Gutschrift-Bedingungen als eigene Ansicht** (`#gutschrift?bedingungen=1`, neuer Tab) statt Klappe, der Rechtstext bleibt unverändert (Hash-Pin, `conditionsVersion` `2026-09-30`). Soll daraus später eine eigene Rechtsseite werden, entscheidet P8.
- **VF9 Livegang.** P10 prüft vor `on`, dass `WORTLAUT_STATUS` auf `'freigegeben'` steht (Wortlaut abgenommen, E20) und dass `scripts/kundenansicht-setup.mjs` gelaufen ist. Ohne das Attribut `featureConsents.kundenart` scheitert jeder Nachweis mit 500, also jede Bestellung der neuen Ansicht. **Zusatz für P10 Task 9 (Step 1 und 4):** Er lässt vorher `test/knBestelltexte.test.js`, `test/knVerdrahtung.test.js` und `test/aboWortlautV6.test.js` laufen und verlangt grün. `WORTLAUT_STATUS` prüft nur die Marke, die Gleichheit mit dem freigegebenen `WORTLAUT_V6` beweisen erst diese Tests.
- **VF10 Werkzeug.** Zwei Fälle (`privat-ohne-abo`, `hosting-privat`) werden an `FALL_NAMEN` angehängt. Der Pin „Fälle in fester Reihenfolge“ in `KW/test/backend.test.mjs` wird von P7 allein erweitert: P2 hat keinen Fall ergänzt (P2 VF7, P0 pinnt `FALL_NAMEN`). Die Speicher-Ansichten brauchen keinen eigenen Fall (`betrieb-wartung` mit überschriebenem `/api/abo`, wie P2 `kn-p2-email-plus`). Die Fixture `abo` trägt `termsVersion` und `bestelltexte` als Kopie; `KW/test/bestelltexte.test.mjs` bindet sie an `config.abo.termsVersionNeu`, `termsDocFor(…).url` und `bestelltexteFuerClient`.
- **VF11 Mails.** K01 und K12 behaupten für Betriebe in Fassung 6 kein „ausdrücklich verlangt“ mehr (Task 6). Der übrige Mailtext, auch die PDF-Anlagen der Fassung 6 (`contractAttachmentFor` über die Fassungstabelle von P8), bleibt Sache von P6 und P8. Ob Betriebe einen eigenen Satz in K01 und K12 bekommen, entscheidet die Mail-Runde.
- **VF12 Verdrahtung P5 ↔ P8 ↔ P7 (Task 0).** Beantwortet P8 Vertragsfrage 2 und P5 Vertragsfrage 7: P7 setzt die Literale in `config.aboFunktionen` und stellt `consentAbo` und `aboKatalog` auf `WORTLAUT_V6` und `FUNKTIONEN_UNTERLAGEN_V6` um. Drei Punkte bleiben offen. (a) Beim Datumswechsel der Freigabe (P8 Task 11, Ablauf Schritt 2) muss `config.aboFunktionen.fassung` mit `FUNKTIONEN_FASSUNG_V6` und `texte.beginn` mit `WORTLAUT_V6.beginn.einrichtung` mitwandern; `aboWortlautV6.test.js` und `knVerdrahtung.test.js` zeigen es rot an. (b) Die Analyse bleibt bis zur Freigabe bei den Alt-Unterlagen (`legalPaths(config.analyse.avvVersion)`); mit der Freigabe stellt der Controller (oder P10) `config.analyse.avvVersion` und `legalPaths` auf die V6-Dokumente um. (c) `katalog[].hinweis` (Pflichten aus den alten Häkchen `verantwortung`, `stripe`, `gesundheit`, P8 Vertragsfrage 3) wird jetzt vom Server geliefert, aber P3 zeigt ihn noch nicht über dem Knopf und der Nachweis (`consentAboAktiv`) speichert ihn nicht. Ohne Folgetask fehlt der Satz aus Spec 5.6 Zeile F auf der Einschalt-Seite; Vorschlag: P3 zeigt ihn, P5 nimmt ihn als Nachweistext `hinweis` auf, entschieden vom Controller. Außerdem lässt `sichereAdresse` (P3) jetzt `/neu/rechtliches/` zu; nach P10 liegen die Funktionsunterlagen weiter dort.
- **VF13 Kündigen und Widerrufen in du (Task 15) und P10.** Geklärt: kein neuer Wortlaut-Nachweis, keine neue `termsVersion` (siehe Festlegungen). Für P10 folgt: Task 17 Step 1 friert den Text der alten Seiten in `test/support/oeffentlichStand.json` ein und vergleicht die neuen Seiten damit. Für `kuendigen.html` und `widerrufen.html` gilt das nur für Feldattribute und Skripte, nicht für den sichtbaren Text (der ist du, gepinnt in `test/knOeffentlichDu.test.js`). Dieselbe Ausnahme gilt für die Umstellung von `test/publicForms.test.js:1439-1450`, `test/auftraegeKundenportalWiderruf.test.js:9` und `test/auftraegeAblauf.test.js:288` auf die neuen Seiten: Deren Sie-Pins (zum Beispiel `bestätigen wir Ihnen per E-Mail`) werden zu den du-Sätzen aus Task 15. `legal-form.js` und `auftrag-widerruf.js` bleiben liegen (P10 löscht sie nicht). Die Mails K03 und K04 bleiben Sie (Mail-Runde).