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

157 KiB
Raw Permalink Blame History

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)
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
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:
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)
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
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):
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)
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
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)

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
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
{ 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)
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
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)

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
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)

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
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)

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
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)

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):

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
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:
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)
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
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)

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
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)
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
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)
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
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)
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
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:
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)
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
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)

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
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)
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
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
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).