Terminplaner TP2 Verkauf: Umsetzungsplan (Bloecke A, B, E, C, D, F und Livegang-Checkliste G)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-03 20:04:49 +00:00
1 parent 6ed0e3bcaf
commit a72380cc8b
1 file changed
+909
@@ -0,0 +1,909 @@
# Terminplaner TP2 Verkauf – Umsetzungsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Kunden mit laufendem Wartungs-Abo bestellen den Terminplaner selbst (Fragebogen → Zustimmungen → 149 € über Stripe → Ticket mit Einbauanleitung → Einbau → Freischaltung); endet das Abo, endet der Terminplaner (Löschung nach 30 Tagen); dazu Rechtstexte + Kanzlei-Paket; am Ende `TERMINE_ENABLED=on`.
**Architecture:** Der News-Bestellweg wird zu einem Register `config.features` verallgemeinert (Checkout, Mails, Ticket, Freischaltung je Funktion). Der Fragebogen schreibt in dasselbe `termineConfig`-Dokument wie TP1 (Entwurf = spätere Live-Einstellung) plus ein neues Attribut `fragebogen`. Ein gemeinsamer Abo-Baustein `aboStatus.js` dient Analyse und Terminplaner; ein täglicher Abgleich koppelt den Terminplaner an das Abo. Einbauanleitung als reine Funktion, im Ticket (kurz) und im WOMS (voll).
**Tech Stack:** wie TP1 (Node 20 Produktion / 24 Host, Express 4, Appwrite 1.8.1 REST, nodemailer 9, Stripe über `stripeBilling.js`, Playwright aus `/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs`), WOMS React/Vite.
**Spec:** `docs/superpowers/specs/2026-10-03-terminplaner-tp2-verkauf-design.md` (Abschnitte 1–11, Entscheidungen D1–D8). TP1-Spec `docs/superpowers/specs/2026-10-02-terminplaner-design.md`, TP1-Ledger `/root/reports/terminplaner-tp1/progress.md` (Rulings R1–R55), Erkundung `/root/reports/terminplaner-tp2/erkundung-2026-10-03.json`.
**Worktrees:** Portal `/home/webklar/apps/kundenbereich.webklar.com/dev-termine`, Branch `feature/terminplaner-tp2` (von `origin/main` 3a0b008; Spec-Commit 6ed0e3b; eigenes `node_modules`). WOMS `/home/webklar/apps/ticket.webklar.com/dev-termine`, Branch `feature/terminplaner-tp2` von `origin/test` 78bc99a (V1).
## Global Constraints
- Nichts pushen, nichts in Produktion schreiben, keine Live-Stripe-Aufrufe; das macht der Inhaber bzw. der Livegang mit Freigabe. Nie `git add -A`; Dateien explizit stagen. WOMS: jeder gepushte Branch wird ausgeliefert.
- Node-20-tauglich (keine APIs ab Node 21). `config.*` bei jedem Aufruf lesen (Tests ändern Werte zur Laufzeit).
- Tests: Portal `npm test` (`node --test test/*.test.js`), Mocks aus `test/support/` (`startAppwriteMock`, `startFakeStripe`, `mailSink`, `useTestEnv`, `startTestApp`, `sessionCookie`); nie echte Dienste. `test/previewDevRouter.test.js` ist ein bekannter, unabhängiger Wackel-Test (einzeln wiederholen). WOMS `npm test` + einmal `nice npx vite build`. Playwright nicht in `npm test`, schwere Läufe nacheinander, vorher `free -m`, mit `nice`.
- Schema nur über idempotente Skripte mit `--check`: `scripts/termine-setup.mjs` (Termine-Collections) und das neue `scripts/feature-setup.mjs` (`portalFeatures.disabledReason`, `aboEndeMailAt`, `zahlungMeldung`, neue Collection `featureConsents`); `scripts/setup-abo-collections.mjs` bleibt unverändert; `test/support/schema.js` zieht mit.
- News bleibt im Verhalten unverändert (bestehende Tests grün), **außer** den Rechtstext-Korrekturen in Block F. Analyse ändert ihr Verhalten nicht (nur gemeinsamer Abo-Baustein).
- Funktionsschlüssel `news`, `termine` im Register; `analyse` bleibt eigener Weg. Preise in Cent; Terminplaner-Einrichtung `14900`, Stripe-Katalogschlüssel `termineSetup`, Lookup-Key `webklar_termine_einrichtung_14900`. Preisanzeige „149 €“ mit Hinweis „ohne Umsatzsteuer gemäß § 19 UStG“ (gilt nach Block F auch für News).
- Zustände `portalFeatures.state`: `off` | `ordered` | `setup` | `active` | `disabled`. `disabledReason` ∈ `team` | `abo_ende` | `abo_ende_vor_einrichtung` | `kunde_geloescht`. `unlockedByPurchase === true` nur bei bezahlter Bestellung; Staff-/Pilot-Dokumente sind vom Abo-Abgleich ausgenommen.
- Mails neu: **K22** „Terminplaner beendet“ (Blöcke `abo_ende`, `hosting`, `vor_einrichtung`, `daten`), **K23** „Terminplaner läuft wieder“, **T19** „Erstattung fällig: Terminplaner-Einrichtung“ (Vorlage aus B9b; einziger Sender `meldeErstattung`, B10b), **T20** „Terminplaner Betriebsmeldung“ (A10a1). K15/K16/T16 mit `{{funktion}}`; T16 neue Blöcke `kein_abo`, `bereits_aktiv`, `anleitung`, `gesundheit` (`{{gesundheit_text}}`) und `anhang_fehlt` (F8); K02/K03 Block `funktionen`; K04/T09 mit `{{produkt}}` und `{{preis}}`. K/T in Du-Form; Layout nach den bestehenden Vorlagen; `test/mailTemplates.test.js`: Liste endet `…ids('K',23), …ids('T',19), 'T20'`, Kopfkommentar ergänzen.
- Kundentexte Du-Form, Gästetexte Sie/Du nach `anrede`, Rechtstexte für Verbraucher Sie-Form. Text nur über `textContent`/`esc()`; `href` nur geprüfte `https:`-URLs mit `rel="noopener noreferrer"`. Keine Gesundheitsangaben.
- Kundenansicht: `POST /api/funktionen/:key/checkout` und `PUT /api/termine/fragebogen` sind für `req.session.viewAs` gesperrt (403); die Wildcard `POST /^\/api\/termine\/.+$/` wird durch eine explizite Liste ersetzt (Block A).
- Protokolle ohne Gast-PII; Tagesbericht nur mit Zählungen.
- Ein Portal-Prozess (Sperren, Grenzen, Mailbudget im Speicher) – festhalten, nicht ändern.
## Review Focus
1. Zahlung trifft ein, nachdem sich der Zustand geändert hat (Abo beendet, Staff hat aktiviert, Sitzung abgelaufen, Dokument `disabled`): nie Geld ohne Spur – immer Ticket **oder** T16/T19 an das Team, nie doppelt. → Block B (onFeaturePaid-Fälle), Block E
2. Abo-Abgleich irrt: Lesefehler, `past_due`, Kündigung vorgemerkt, Pilot-/Staff-Dokument, Abo kommt innerhalb von 30 Tagen zurück ⇒ nie Abschalten bei Fehler, Pilot nie, Rückkehr schaltet wieder ein, Löschung prüft vorher frisch. → Block E
3. Entwurf ohne aktive Funktion: nie öffentlich sichtbar, nur der eigene Kunde schreibt, ab `ordered` nur lesbar (außer Staff in Kundenansicht), Kundenansicht bestätigt keine Fragebogen-Schritte. → Block C
4. Löschjobs mit vielen Betrieben: seitenweise, nie früher als die Frist, nie still für immer übersprungen (Überlauf läuft an Folgetagen weiter und wird gezählt). → Block A
5. Zustimmungsnachweis: genau die gezeigten Texte + Fassungen + Zeitpunkt sind gespeichert, **bevor** der Kunde zu Stripe geht (die Stripe-Sitzung wird zuerst angelegt, weil der Nachweis ihre ID trägt; die URL geht erst nach gespeichertem Nachweis an den Browser); Widerrufs-Anker = Zeit des Klicks. → Block B, F
## Schnittstellen über Blockgrenzen (verbindlich für alle Tasks)
- **Register** `config.features` (Block B, `server/config.js`): `{ news: {...}, termine: {...} }`, je Eintrag: `key`, `product` (Katalogschlüssel `newsSetup` | `termineSetup`), `label: { name, kurz, keine }`, `priceLabel` (z. B. „149 €“), `setupEstimate`, `consent: { version, knopf, items: [{ id, text, haken }] }`, `leistungsbeschreibung`, `avv`, `widerruf` (Fassungen + URLs), `returnHash` (`'news'` | `'termine'`), `ticket: { topic(project) ⇒ string, details(ctx) ⇒ Promise<string> }` mit `ctx = { feature, doc, project, customer }`, `mails: { funktion, knopf }`, `requiresWartungsAbo: boolean`, `ticketFreischaltung: boolean`, `disabledMessage`, `orderedConflictMessage`; dazu `config.ustHinweis`. Helfer in `server/services/featureRegistry.js`: `featureConfig(key) ⇒ Eintrag | null`, `featureHaken(key)` mit den Haken `verfuegbar`, `bestellpruefung`, `unterlagenBereit`, `belehrung`, `freischaltbar`, und `anleitungsLink`.
- **Checkout-Body** (Block B): `POST /api/funktionen/:key/checkout` mit `{ projectId, zustimmung: { version, haken } }` oder `{ projectId, fortsetzen: true }` (offene Sitzung fortsetzen).
- **Abo-Baustein** (`server/services/aboStatus.js`; Kernversion in B5 mit endgültiger Signatur, ausgebaut in E1): `laeuft(customerId, { altbestand = true } = {}) ⇒ Promise<boolean>` (Wartungs-Abo, `productKey || 'wartung'`, Status ∈ `ACTIVE_STATUSES`; wirft bei Lesefehler; B5 kennt nur `laeuft(customerId)`, E1 ergänzt `altbestand` und die Wechsel-Regel), `bestellbar(customerId) ⇒ Promise<{ ok: boolean, grund?: 'kein_abo' | 'gekuendigt' | 'nicht_aktiv' }>` (Gründe in dieser Prüfreihenfolge), `aboMemo() ⇒ { laeuft, bestellbar }` (je Lauf zwischengespeichert; E1 ergänzt `lage`) und **erst ab E1** `aboLage(customerId) ⇒ Promise<{ laeuft, art, endedAt }>` (gelesener Spiegel inkl. Wechsel-Regel; in B5 gibt es sie nicht). **`aboMeldung` gehört nicht in diese Datei:** `aboMeldung(feature, grund) ⇒ string` (Argumentreihenfolge: Register-Eintrag, dann Grund ∈ `kein_abo` | `gekuendigt` | `nicht_aktiv` | `unbekannt`) liegt in `server/services/featureOrder.js` (B6) und ist die einzige Quelle der Abo-Hinweistexte; C2, C4a, C4b, C10a und E importieren sie von dort. Andere Blöcke nutzen nur diese Funktionen.
- **siteFeatures**: `disable(docId, { by, adminNote, reason })` idempotent (ein bereits `disabled` Dokument behält `disabledAt`), setzt `disabledReason` (A11); `activate()` leert `disabledAt`, `disabledReason`, `aboEndeMailAt`, nicht `zahlungMeldung` (A11); `markPaid(...) ⇒ { doc, created, fall }` und Marker `zahlungMeldung` (B10a); `meldeErstattung(doc, { customer, sitzung, nachAboEnde })` ist der **einzige** T19-Sender (B10b); `activateClosedTickets({ now, customerId })` über alle Register-Schlüssel mit `ticketFreischaltung: true` und Haken `freischaltbar(doc) ⇒ Promise<boolean>` aus dem Register (Termine: Einstellung vorhanden **und** `laeuft`) (B11).
- **Zustimmungsnachweis** (Block B, `server/services/featureConsents.js`, Collection `featureConsents`): `speichereZustimmung({ customerId, projectId, featureKey, stripeSessionId, fassungen, texte, ip, userAgent, konto, at }) ⇒ Promise<doc>` (Hash über die Texte), `letzteZustimmung({ customerId, projectId, featureKey }) ⇒ Promise<doc | null>`, `zustimmungFuerSitzung(sessionId)`, `nachweisTexte(feature, { belehrung })`; Widerrufs-Anker `vertragAt` über `paidFeatureOrders`.
- **Fragebogen** (Block C, `server/services/termine/fragebogen.js`; Stub in B5): `bestellungAnsicht(...)` (async), `BRANCHEN` (Liste `{ key, name, vorlage | null, gesundheit: boolean }`), `SCHRITTE` (Schlüssel der 8 Schritte), `normalisiereFragebogen(eingabe) ⇒ { fragebogen } | { fehler }`, `bestellpruefung({ einstellung, fragebogen, feature, abo, mitAbo = true }) ⇒ Array<{ schritt, text }>` (leer = bestellbar; der Abo-Punkt (h) nur bei `mitAbo !== false`: der Checkout (B6, Standard-Haken) ruft mit `mitAbo: false` und ohne `abo`, weil dort die Abo-Stufe (7) die Codes `kein_abo` | `gekuendigt` | `nicht_aktiv` liefert; nur `GET /api/termine/einstellungen` (C4a) übergibt `abo` und zeigt (h) als offenen Punkt), `gesundheitsWarnungen(einstellung) ⇒ string[]`. Attribut `termineConfig.fragebogen` (JSON-String, ≤ 16 000 Zeichen). Route `PUT /api/termine/fragebogen { projectId, fragebogen }`.
- **Einbauanleitung** (Block D, `server/services/termine/einbauanleitung.js`): `einbauanleitung({ feature /* portalFeatures-Dokument */, project, einstellung, fragebogen, version, now }) ⇒ { stand: { version, updatedAt }, warnungen: string[], abschnitte: Array<{ id, titel, text, kopierbar }>, text, kurz }` (rein, nie `feedToken`); `einbauSnippet({ siteKey, apiBase, telefon, modus, knopfText })` (aus `routes/termine.js` hierher verschoben). Team-Route `GET /api/team/funktionen/:customerId/termine/anleitung?projectId=`.
- **Rechtstexte** (Blöcke D/F, `server/services/termine/rechtstexte.js`): `datenschutzAbsatz(einstellung, anrede) ⇒ string` (Quelle für Anleitung und Doku).
- **Status-Route** (Block A): `GET /api/team/termine/status ⇒ { budget, queue: { wartet, aeltesteMin }, pause, letzterTagesjob, aktiveBetriebe }`.
## Grundlagen der Blöcke (aus den Block-Einleitungen, verbindlich für alle Tasks)
Jeder Task bekommt Kopf und eigenen Text, nicht die Einleitung seines Blocks. Was ein Task aus einer Block-Einleitung braucht, steht deshalb hier.
- **Zeilenangaben:** Alle `Z. n` und `:n` im Plan gelten für Portal `3a0b008` (WOMS `78bc99a`) und verschieben sich durch frühere Tasks an denselben Dateien (besonders `test/termineJobs.test.js`, die A3a, A3b, A4, A6, A10a2, A10a3, A12, E7, E8 und C6 ändern; ebenso `server/services/termine/jobs.js`, `outbox.js`, `server/config.js`, `server/services/siteFeatures.js`, `server/routes/termine.js`): **per Test- oder Funktionsname suchen**, nicht nach Zeilennummer.
- **Neue Werte in `config.termine`** (Standardwerte, bei jedem Aufruf gelesen, Tests setzen sie zur Laufzeit; angelegt in A3a, A3b, A6, A7 und A9a): `mailsProFunktion: 40`, `mailsReserve: 0.25`, `mailListe: 100`, `loeschDeckel: 2000`, `loeschDeckelGesamt: 20000`, `zellenDeckel: 5000` (vorhanden: `mailsProStunde: 200`, `holdMinutes: 30`).
- **Testkonventionen für Register-, Checkout-, Abo- und Zustimmungstests** (Block B; gelten ebenso für die Tests in C, D, E und F, die diese Teile berühren): `startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), featureConsents: schemaKeys('featureConsents'), termineConfig: schemaKeys('termineConfig') } })` (`featureConsents` ab B3), `useTestEnv` vor den dynamischen Importen, `config.termine.mode = 'pilot'` und `config.termine.pilotEmails = ['*']` für Termine-Fälle, Mails über `setMailTransportForTests(mailSink())`. Der Nachbau prüft Schlüssel nur beim Anlegen/Ändern, nicht in `seed`: Felder, die erst Block A/E ins Schema bringen (`disabledReason`), dürfen Tests schon seeden (`schemaKeys('portalFeatures')` enthält seit A11 `disabledReason`, `aboEndeMailAt`, `zahlungMeldung`). **Pins auf Werte, die Block F ändert** (News-`consent.version`/-Text, `mails.preisHinweis`, Termine-Items und -Fassung, `leistungsbeschreibung`/`avv`) lesen `config.features.*` statt Literale; nur der Einfrier-Test `featureMails` (B9a, B9b) und die dort genannten Wortlaute sind Literale, die Block F2 gezielt anpasst.
- **Falle `seedFixtures`:** `seedFixtures(aw)` legt für `c1` nur ein **Hosting**-Abo `sub_h` an, `c2` hat ebenfalls nur Hosting, `c3` gar kein Abo. Jeder Test, der für `c1` ein **Wartungs**-Abo braucht (Termine-Bestellung, Abo-Baustein, Freischaltung, Abgleich), überschreibt es mit `aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc }))` (Hilfen in `test/support/fixtures.js`); sonst scheitert er an `kein_abo`.
- **Im Code geprüft (Block B):** Das Schema läuft über `scripts/feature-setup.mjs` (A11 legt es nach dem Muster `scripts/analyse-setup.mjs` an, B3 ergänzt `featureConsents`); `scripts/setup-abo-collections.mjs` kennt **kein** `--check`, ist ein Top-Level-Skript ohne Exporte und bleibt unangetastet. `scripts/stripe-products.mjs` exportiert nur `parseArgs` (Task B2 exportiert `KEYS` und `catalogEntry`). `aboStatus.js`, `featureRegistry.js`, `featureConsents.js` und `termine/fragebogen.js` gibt es noch nicht. `markPaid` übergibt weder `topic` noch `details` (`siteFeatures.js:261-266`), `createFeatureTicket` kürzt nichts (`ticketAdmin.js:85`). `teamNotice` und `unmatchedNotice` kennen kein `funktion` (T16-Betreff „…: News-Pinnwand“ ist in `test/funktionenRoutes.test.js` Z. 150, 339, 371 gepinnt und bleibt für News so). `seedFixtures(aw)` legt für `c1` ein **Hosting**-Abo `sub_h` an; Termine-Tests brauchen `aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc }))` (überschreibt es), `c2` hat nur Hosting, `c3` gar kein Abo.
- **Festlegungen des Blocks E** (gelten für E4–E10):
- Schreiben nur unter der Sperre `feature-admin:<projectId>` (wie `routes/admin/funktionenAdmin.js:87`) und nach **frischem** Lesen von Dokument **und** Abo (`aboLage` direkt, nie der Memo). Der Memo (`aboMemo()`) dient nur der Vorauswahl je Kunde im Listenlauf.
- Lesefehler (Appwrite, Abo, Ereignisse) ⇒ Kunde/Dokument überspringen, `fehler += 1`, Log ohne Namen, Adressen und IDs von Gästen; **nie** abschalten, wenn etwas nicht lesbar war.
- Marker erst nach Erfolg: `aboEndeMailAt` (K22) und `zahlungMeldung === 'gemeldet'` (T19 über `meldeErstattung`, B10b; `purchaseStatus` bleibt `paid`). Schlägt der Versand fehl, wiederholt ihn der nächste Lauf; `disabledAt` ändert sich dabei nie (sonst wüchse die Löschfrist).
- Abgleich nur für `disabledReason`-Werte aus dem Kopf (`team | abo_ende | abo_ende_vor_einrichtung | kunde_geloescht`); ein `disabled`-Dokument ohne Grund (Altbestand) wird wie `team` behandelt und nie angefasst. Die Spec nennt in Abschnitt 6 zusätzlich `frist`; maßgeblich ist die Liste aus dem Plankopf.
- **Fassungsdatum `<F>`** (gilt für F1 bis F15b und überall, wo `<F>` oder `<V>` steht): `<F>` ist der **Berliner Kalendertag des F1-Commits** (`JJJJ-MM-TT`, muss nach `2026-10-02` liegen, der eingefrorenen Altfassung der Gästetexte in F10a). F1 schreibt ihn als Wert von `config.termine.avvVersion`; alle späteren Tasks lesen die Konstante und tragen den Tag nie als Literal in Code oder Tests (Ausnahme: das Literal `RECHTSTEXTE_VERSION` in `termine/rechtstexte.js`, das F10b1 auf denselben Tag setzt; `<V>` = `<F>`, ein Test hält beide gleich). Drei Stände: `config.termine.avvVersion` (Leistungsbeschreibung, AVV, Belehrung, Bestelltexte, News-`consent.version`), `RECHTSTEXTE_VERSION` (Gästetexte) und „Stand“ der Datenschutzerklärung (Tag der Änderung). Eine **ausgelieferte** Fassung wird nie überschrieben: sobald die Texte an die Kanzlei gegangen oder live sind (Block G1/G7), heißt jede Textänderung neues Datum und neue Dateien; davor darf der Controller `<F>` mit einem eigenen Commit „Fassungsdatum anheben“ auf den Tag der Übergabe setzen. Beim Anheben entstehen neu: die F6-Dateien `*-<F>.html/.pdf` (alte, nie ausgelieferte Dateien werden gelöscht), das Archiv `termine-gaestetexte-<V>.md` (F10b1) samt Hash-Pin, die Fassungs- und Hash-Pins in den Tests von F2, F7, F10b1, F13a und F15a, `RECHTSTEXTE_VERSION` (F10b1) und News-`consent.version` (F2); die Datenschutzerklärung nur bei Textänderung.
---
## Vorab (Controller, kein Subagent)
- [ ] **V1 WOMS-Branch:** im Worktree `/home/webklar/apps/ticket.webklar.com/dev-termine` `git fetch origin && git checkout -b feature/terminplaner-tp2 origin/test`; `npm test` grün als Basis.
- [ ] **V2 Basis Portal:** im Portal-Worktree `npm ci` falls `node_modules` fehlt, `npm test` als Basis (Erkundung: 2235 Portal grün, WOMS 294); dazu der Node-20-Lauf der reinen Termine-Tests (`docker run --rm -v "$PWD":/w:ro -w /w node:20-alpine node --test test/termineZeit.test.js test/termineSlots.test.js test/termineEinstellungen.test.js test/termineIcs.test.js`).
- [ ] **V3 Ledger:** SDD-Workspace für diesen Plan anlegen; die Rulings R1–R55 aus TP1 gelten weiter, sofern dieser Plan nichts anderes sagt. Abweichungen aus der Planprüfung festhalten: der Entwurfszugriff (C4b) und der `kein_abo`-Hinweis (B10b) nutzen `laeuft`; der Tagesbericht zählt `ausgenommen`e Dokumente; der Abo-Abgleich hat keinen eigenen Beobachtungsschalter (er beobachtet über den Datenstand); der Checkout wendet den Abo-Punkt (h) des Bestell-Gates nicht an (`mitAbo: false`, B6/C2), die Abo-Stufe (7) in B6 liefert die Abo-Codes.
Reihenfolge der Blöcke: **A → B → E → C → D → F**, danach **Block G** (Livegang, am Ende von Block F). Was TP2 bewusst nicht enthält, steht in Spec Abschnitt 1 und 8 („SOLLTE“/„Kann warten“).
---
## Block A: Technik-Voraussetzungen aus TP1
Schuld aus TP1 (Spec Abschnitt 8, MUSS #1–#6; die Verallgemeinerung von `activateClosedTickets` baut Block B, Task B11) plus die billigen SOLLTE-Punkte am selben Code (Frühausstieg in `verarbeite`, Index `termineOutbox(bookingId)`, Minutenjob teilen). Löschjobs, Zellen-Sweep und CSV-Export lesen seitenweise (`limit` ≤ 100, Export 500), haben einen Deckel je Lauf und laufen an Folgetagen weiter; die Mail-Warteschlange bekommt Kontingent, Vorrang, Alarm und eine Status-Route. Neue Werte in `config.termine` (`server/config.js` Z. 378–394, bei jedem Aufruf gelesen, Tests setzen sie zur Laufzeit): `mailsProFunktion: 40`, `mailsReserve: 0.25`, `mailListe: 100`, `loeschDeckel: 2000`, `loeschDeckelGesamt: 20000`, `zellenDeckel: 5000`.
Reihenfolge: A2 vor A3a–A6 und A9a; A3a vor A3b, A4 und A6; A7 vor A9a; A9a vor A9b; A3a, A3b, A4, A6, A7 und A9b vor A10a1; A10a1 vor A10a2 vor A10a3; A10a3 vor A10b und A12; A11 steht für sich (vor Block B). Schema-Änderungen (Indizes, `portalFeatures.disabledReason`, `aboEndeMailAt`, `zahlungMeldung`) laufen **vor** dem Code-Deploy über die idempotenten Skripte `scripts/termine-setup.mjs` und das neue `scripts/feature-setup.mjs` (beide mit `--check`; Livegang Schritt 3): `activate()` schreibt die neuen Felder, ohne Schema käme `Unknown attribute`. `scripts/setup-abo-collections.mjs` bleibt unverändert (es hat kein `--check` und ist ein Top-Level-Skript ohne Exporte); `feature-setup.mjs` folgt dem Muster von `scripts/analyse-setup.mjs` (exportierte Attribut-Listen, `main({ check, client, log })`, Test mit Mock-Client).
**Bewusst nicht in TP2** (Spec Abschnitt 8 „SOLLTE“/„Kann warten“, kein Task; im Ledger als offen führen): Transport `pool: true` und Begrenzung der Parallelität der `einreihen`-Sendungen (`mailer.js:25`); Mailbudget nach einem Neustart aus der Outbox rekonstruieren; `/tage` und `/zeiten` außerhalb des Horizonts vor `imZeitraum` kurzschließen (`publicTermine.js`); Hetzner-Überlimit-Antworten als Pause statt als `fehler` einstufen (`outbox.js`); `mailSicher`-Fehlermarker, damit das Portal „nicht zugestellt“ zeigt; Tagesjob zu fester Nachtzeit (heute 180 s nach jedem Start); `TERMINE_TOKEN_SECRET`; `engines >=20`; axe-/Screenreader-Lauf von Widget und Gästeseite (deshalb in der Leistungsbeschreibung keine Konformitätszusage, F4). Lock-Wartezeiten, `halte` ohne `termine-cfg`-Lock, Fetch-Timeouts und der Widget-Monolith bleiben liegen.
### Task A1: Kundenansicht-Wildcard durch eine explizite Liste ersetzen
**Files:** Modify `server/middleware/kundenansichtGuard.js` (Z. 18–21), `test/kundenansichtGuard.test.js` (Z. 106–127 und neue Tests am Ende).
**Consumes:** –. **Produces:** `KUNDENANSICHT_ALLOWED` mit genau sechs Termine-Einträgen, jeweils mit `\/?$`: POST `/api/termine/einstellungen/vorlage`, POST `/api/termine/ressourcen/[^/]+/feed`, POST `/api/termine/buchungen`, POST `/api/termine/buchungen/[^/]+/aktion`, PATCH `/api/termine/einstellungen`, DELETE `/api/termine/buchungen/[^/]+`. Alles andere unter `/api/termine` (auch Block C `PUT /fragebogen`) bleibt in der Kundenansicht gesperrt, ohne dass es einen Eintrag braucht; Block B legt den Checkout unter `/api/funktionen` (dort nie erlaubt).
- [ ] Tests: (1) „Erlaubnisliste“ (Z. 106): `KUNDENANSICHT_ALLOWED.length === 8 + 6`; die bisherigen `ok`-Fälle bleiben erlaubt; neu **nicht** erlaubt: `POST /api/termine/zukunft`, `POST|PUT /api/termine/fragebogen`, `POST /api/termine/export.csv`, `POST /api/termine/buchungen/abc` (bisher durch die Wildcard erlaubt), `POST …/buchungen/abc/aktion/x`, `POST …/einstellungen/vorlage/x`, `POST …/ressourcen/abc/feed/x`, `POST /api/funktionen/termine/checkout`. (2) Neu „unbekanntes POST unter /api/termine ⇒ 403“: über HTTP mit `kv()` ⇒ 403 `view_blocked` für `POST /api/termine/zukunft`, `PUT /api/termine/fragebogen`, `POST /api/termine/buchungen/abc/aktion/x` und `POST /API/Termine/Zukunft` (der Wächter antwortet vor dem Router, auch wenn es die Route nicht gibt); dieselben Pfade mit `customerCookie('c1')` ⇒ nicht 403 (404, der Wächter fasst normale Kunden nicht an). (3) Neu „Router-Abgleich“: `writeRoutes([['/api/termine', termineRouter]])` gefiltert mit `allowed()` ergibt exakt die sechs Routen (`:id` ⇒ `x`); eine künftige Schreib-Route unter `/api/termine` fällt dadurch im Test „jede Schreib-Route ausserhalb der Erlaubnisliste ⇒ 403“ auf, nicht erst in Produktion. (4) Der Test „Kundenansicht darf PATCH, POST und DELETE (mit Header) und es wird protokolliert“ (`termineRoutes.test.js` Z. 1028: Vorlage, Feed, Buchung, Aktion, PATCH, DELETE) bleibt grün.
- [ ] FAIL → implementieren (die vier POST-Muster einzeln ausschreiben, kein `.+`) → PASS → `npm test` → Commit „Kundenansicht: Terminplaner-POSTs nur noch ueber eine explizite Liste“
### Task A2: Abfrage-Bausteine und Nachbau-Erweiterung
**Files:** Modify `server/services/appwriteAdmin.js` (`Query` Z. 77–94, neu `countDocuments`), `test/support/appwriteMock.js` (`matches` Z. 124, `supportedQueries` Z. 145, `list` Z. 147–169, Rückgabeobjekt Z. 388); Test `test/appwriteBulk.test.js` (dort stehen die Query- und Nachbau-Tests, ab Z. 37).
**Consumes:** –. **Produces:** `Query.select(attrs[])` ⇒ `{ method: 'select', values: attrs }`; `Query.notEqual(attr, wert | wert[])` ⇒ `{ method: 'notEqual', attribute, values: […] }` (Liste = „keiner von“); `countDocuments(collectionId, queries) ⇒ Promise<number | null>` (ein Aufruf mit `limit(1)`, liefert `total` der Antwort, `null`, wenn Appwrite keines liefert). Nachbau: `select` liefert nur die genannten Felder plus alle `$…`-Felder; `notEqual` (kein Wert gleich), `total` = Treffer vor `limit`/`cursorAfter` (heute: Länge nach `limit`), `aw.listCalls(collection?)` ⇒ `[{ collection, queries: [{ method, attribute, values }] }]` (nur Listen-GETs, geparst) und `aw.resetListCalls()`. `Query.offset` wird bewusst nicht gebaut (Löschseiten lesen ohne Cursor neu), ebenso `Query.isNull` (Block E filtert `aboEndeMailAt` im Code; sein Verhalten bei Datetime-Attributen auf Appwrite 1.8.1 ist ungemessen). Dass `notEqual` mit Liste und `total` auf der echten Appwrite 1.8.1 dasselbe bedeuten wie im Nachbau, ist ebenfalls ungemessen: Messprüfung in Block G (Livegang 5).
- [ ] Tests (`appwriteBulk.test.js`): `Query`-Helfer liefern die Formen oben; Nachbau: `select(['a'])` ⇒ Dokumente nur mit `a` und `$id`; `notEqual('f', ['x','y'])` schließt beide aus, `notEqual` mit einem Wert; `total` bei `limit(2)` über 5 Treffer ⇒ 5 und `countDocuments` ⇒ 5; `listCalls('termineClaims')` zeichnet Listenabfragen auf (keine POST/GET-by-ID), `resetListCalls` leert; „Nachbau ohne Optionen verhält sich wie bisher“ (Z. 196) bleibt grün.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Appwrite-Abfragen: select, notEqual, countDocuments und Nachbau dazu“
### Task A3a: Aufbewahrungs-Löschung seitenweise – Grundlagen und Schritte `verfallen`/`abgesagt`/`abgelehnt` (Review Focus 4)
**Files:** Modify `server/services/termine/buchungen.js` (`loeschKandidaten` Z. 183–192), `server/services/termine/jobs.js` (`loeschfristen` Z. 288–315, nur die Schritte `verfallen`, `abgesagt`, `abgelehnt`; neu `arbeiteSeiten`; `wendeLoeschfristenAn` Z. 400, `taeglicherJob` Z. 420), `server/config.js` (`loeschDeckel`, Standard 2000), `server/services/termine/schema.js` (termineBookings: `index('idx_status_ends', 'status', 'endsAt')` und `index('idx_status_updated', 'status', 'updatedAt')`; die Spec nennt nur das erste, `abgesagt`/`abgelehnt` laufen über `updatedAt` und brauchen das zweite); Tests `test/termineJobs.test.js` (Z. 672–751 anpassen, neue), `test/termineZellen.test.js` (Z. 933–958), `test/termineSetup.test.js` (Z. 143–155).
**Consumes:** A2 (`Query.select`, `aw.listCalls`). **Produces:** `loeschKandidaten(status, vor, { featureId = null, limit = 100 } = {}) ⇒ Promise<doc[]>` (höchstens `limit`, älteste Bezugszeit zuerst, `Query.select` ohne Gastfelder: `$id, featureId, status, endsAt, updatedAt, heldUntil`; TypeError für nicht löschbare Status wie bisher); `arbeiteSeiten(lauf, { holeSeite, bearbeite, deckel }) ⇒ { geloescht, gedeckelt, vollstaendig }` (für A3b, A4); `wendeLoeschfristenAn(now) ⇒ { verfallen, abgesagt, abgelehnt, erledigt, nichterschienen, gedeckelt }` (`gedeckelt` = Zahl der Schritte, die am Deckel endeten; die Schritte `erledigt`/`nichterschienen` laufen bis A3b unverändert über den alten Weg); `taeglicherJob` liefert zusätzlich `gedeckelt: { loeschfristen, abgeschaltet: 0, zellen: 0 }` (A4/A6 füllen die anderen) und trennt `gedeckelt` vom Löschzähler ab, damit die Summe im `tagesbericht` stimmt.
**Ansatz:** Die Frist steht in der Datenbankabfrage (`lessThan(Bezug, jetzt − Frist)`) und wird nie im Code nachgeholt. Seiten ohne Cursor: Seite lesen, jede Buchung wie bisher unter ihrer Sperre frisch prüfen und samt Zellen löschen (`loescheMitZellen`), Liste neu lesen (Gelöschtes fehlt). `arbeiteSeiten` endet bei leerer Seite, beim Deckel (zählt `lauf.gedeckelt`, Log-Warnung ohne IDs) oder bei einer Seite ohne einen Erfolg (Fehler stehen im Log; bereits versuchte IDs merkt eine Menge, kein Endlosdurchlauf). Durchgang (1): global `verfallen` (48 h), `abgesagt`, `abgelehnt` (30 Tage), Deckel `loeschDeckel` je Status. Die Prüfung am Kandidaten (`endsAt` < Frist) bleibt als zweiter Boden.
- [ ] Tests: (1) `loeschKandidaten` (termineZellen): 150 alte `erledigt` ⇒ Standard 100, `{ limit: 10 }` ⇒ 10, aufsteigend nach `endsAt`, `{ featureId }` filtert, Treffer ohne `gastName`/`gastEmail`, Bezugszeit je Status und TypeError für `bestaetigt`/`gehalten` wie bisher. (2) Seitenlauf: 250 `verfallen`/`abgesagt`/`abgelehnt` gemischt, davon 40 eine Millisekunde zu jung ⇒ ein Lauf löscht genau die 210 fälligen samt Zellen und keine junge; alle Listenabfragen an `termineBookings` haben `limit ≤ 100` und ein `select` ohne Gastfelder (`aw.listCalls`), kein `$id`-Seitenlauf mit 500; der Standard `loeschDeckel` (2000, je Status) liegt über den 210 fälligen, ohne dass der Test ihn setzt. (3) Deckel: `loeschDeckel = 7`, 20 fällige `verfallen` ⇒ Lauf 1 löscht 7 und meldet `gedeckelt: 1` (Warnung im Log ohne ID/Name), Lauf 2 weitere 7, Lauf 3 die letzten 6 mit `gedeckelt: 0`; nach jedem Lauf stimmen Rest und Zellen, an keinem wird eine junge Buchung gelöscht. (4) Fehler stoppt nicht für immer: alle DELETE auf `termineBookings` scheitern (fetch-Wrapper) ⇒ der Lauf endet nach höchstens einer Seite je Schritt (Anfragenzahl begrenzt), Fehler gezählt, die anderen Schritte laufen; nächster Lauf ohne Fehler löscht alles. (5) Angepasste TP1-Tests: Z. 672 erwartet `gedeckelt: 0`; der Race-Test (Z. 742) bleibt. (6) `termineSetup`: Index-Liste von `termineBookings` um `idx_status_ends` (status, endsAt) und `idx_status_updated` (status, updatedAt) ergänzen, `main` legt beide an.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Aufbewahrungs-Loeschung seitenweise (Grundlagen, verfallen/abgesagt/abgelehnt)“
### Task A3b: Aufbewahrungs-Löschung seitenweise – je Funktion, Sicherheitsnetz, Gesamtdeckel, Bericht (Review Focus 4)
**Files:** Modify `server/services/termine/jobs.js` (`loeschfristen` Z. 288–315, die Schritte `erledigt`/`nichterschienen`; `wendeLoeschfristenAn` Z. 400, `taeglicherJob` Z. 420, `tagesbericht` Z. 437), `server/config.js` (`loeschDeckelGesamt`, Standard 20000; `loeschDeckel` Standard 2000 kommt aus A3a); Tests `test/termineJobs.test.js` (Z. 672–751 anpassen, neue).
**Consumes:** A3a (`loeschKandidaten`, `arbeiteSeiten`, `loeschDeckel`, Indizes). **Produces:** `wendeLoeschfristenAn` und `taeglicherJob` behalten die Form aus A3a; `tagesbericht` bekommt das Segment „Löschläufe am Deckel: n, Rest folgt an den nächsten Tagen“ (nur bei `gedeckelt` > 0, direkt nach `Mails nicht zugestellt`).
**Ansatz:** Durchgang (2): je Funktion (`portalFeatures` mit `featureKey termine`, `state` active|disabled, `listAllDocuments` mit `Query.select(['$id'])`) `erledigt`/`nichterschienen` nach `aufbewahrungTage` der Funktion (`aufbewahrungTage()` wie bisher, `null` ⇒ Funktion auslassen), gemeinsamer Deckel `loeschDeckel` je Funktion und `loeschDeckelGesamt` je Lauf. Durchgang (3), Sicherheitsnetz: `erledigt`/`nichterschienen` mit `endsAt < jetzt − 365 Tage` ohne Funktionsfilter (die größte erlaubte Aufbewahrung; fängt Buchungen ohne Funktionsdokument und Funktionen mit unlesbarer Einstellung spätestens nach 365 Tagen). Die Prüfung am Kandidaten (`endsAt` < Frist) bleibt als zweiter Boden.
- [ ] Tests: (1) „viele Betriebe“: 60 Funktionen (`active`, `aufbewahrungTage` 14/90/365 gemischt), je 6 Buchungen (3 fällig, 3 eine Millisekunde zu jung, Status gemischt) ⇒ ein Lauf löscht genau die 180 fälligen samt Zellen und keine junge (die Standards `loeschDeckel` 2000 je Funktion und `loeschDeckelGesamt` 20000 je Lauf liegen darüber, der Test setzt sie nicht); alle Listenabfragen an `termineBookings` haben `limit ≤ 100` (`aw.listCalls`), kein `$id`-Seitenlauf mit 500. (2) Deckel je Funktion: `loeschDeckel = 7`, eine Funktion mit 20 fälligen ⇒ Lauf 1 löscht 7 und meldet `gedeckelt: 1` (Warnung im Log ohne ID/Name), Lauf 2 weitere 7, Lauf 3 die letzten 6 mit `gedeckelt: 0`; A 50 fällige, B 2 fällige, `loeschDeckel = 5` ⇒ A 5, B 2 im ersten Lauf; `loeschDeckelGesamt = 6` ⇒ insgesamt 6, Rest im nächsten Lauf; an keinem Lauf wird eine junge Buchung gelöscht. (3) Sicherheitsnetz: Buchung ohne Funktionsdokument, 366 Tage alt ⇒ gelöscht, 200 Tage alt ⇒ bleibt (dokumentierte Grenze, Waisen gibt es in Produktion nicht); Funktion mit unlesbarer Einstellung (`failNext` termineConfig GET): 100 Tage bleibt (nie früher), 366 Tage weg. (4) Angepasste TP1-Tests: Z. 692 legt für F2 und F4 `portalFeatures`-Dokumente an (F2 ohne Einstellung ⇒ 90, F4 kaputte Einstellung ⇒ 90). (5) `tagesbericht` unverändert ohne Deckel, mit `gedeckelt` > 0 zusätzlich das Segment oben (vollständige Zeile siehe E7).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Aufbewahrungs-Loeschung je Funktion mit Deckel, Sicherheitsnetz und Bericht“
### Task A4: Löschung abgeschalteter Funktionen seitenweise (Review Focus 4)
**Files:** Modify `server/services/termine/jobs.js` (`loescheAbgeschaltete` Z. 347–391, `nochAbgeschaltet` Z. 325, `loescheAbgeschalteteFunktionen` Z. 410), `server/services/termine/buchungen.js` (neu `ersteBuchungen`); Tests `test/termineJobs.test.js` (Z. 753–870).
**Consumes:** A3a (`arbeiteSeiten`, `loeschDeckel`, `gedeckelt` im Tagesjob). **Produces:** `ersteBuchungen(featureId, limit = 100) ⇒ doc[]` (`Query.equal('featureId')`, `limit`, `select(['$id'])`); `loescheAbgeschalteteFunktionen(now) ⇒ { funktionen, buchungen, gedeckelt }`. Block E hängt die frische Abo-Prüfung an `nochAbgeschaltet` und setzt das Funktionsdokument nach dem Löschen der Einstellung auf `off`.
**Ansatz:** Je abgeschaltete Funktion (Frist `disabledAt < jetzt − 30 Tage` bleibt in der Funktionsliste) Seiten zu 100 lesen und löschen, bis die Seite leer ist; vor jeder Seite nach dem Lesen und alle 50 Buchungen `nochAbgeschaltet` (frisch, wie bisher); Deckel `loeschDeckel` je Funktion. Die Einstellung (`termineConfig`) wird nur nach leerer Seite ohne Fehler und mit letzter Prüfung unter `termine-cfg:<id>` gelöscht; sonst bleibt die Funktion `disabled` mit altem `disabledAt` und der nächste Tag macht weiter (`gedeckelt` + Log-Warnung).
- [ ] Tests: (1) Funktion seit 31 Tagen abgeschaltet mit 230 Buchungen (mit Zellen), `loeschDeckel = 100`: Lauf 1 ⇒ `{ funktionen: 1, buchungen: 100, gedeckelt: 1 }`, `termineConfig` und `disabledAt` unverändert; Lauf 2 ⇒ weitere 100; Lauf 3 ⇒ 30 und Einstellung weg, `gedeckelt: 0`; alle Listenabfragen `limit ≤ 100`. (2) Deckel gilt je Funktion: A 150 Buchungen, B 10 ⇒ B im selben Lauf komplett samt Einstellung, A noch nicht. (3) Schaltet das Personal die Funktion nach der ersten Seite wieder ein (`mitEinwurf` auf die zweite Listenabfrage) ⇒ keine weitere Löschung, Einstellung bleibt, Log „inzwischen wieder aktiv“, kein Fehler. (4) Scheitert jedes Löschen (Wrapper) ⇒ Ende nach einer Seite, Einstellung bleibt, Anfragenzahl begrenzt, kein Wurf. (5) Die bestehenden Tests Z. 753–870 bleiben bis auf `gedeckelt: 0` in den `deepEqual`-Pins (`{ funktionen, buchungen, gedeckelt }`; Block E pinnt dieselbe Form) grün (Wiedereinschalten nach 50 Buchungen ⇒ genau 50 gelöscht; Neulesen scheitert ⇒ nichts gelöscht, ein Fehler im Log).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Loeschung abgeschalteter Funktionen seitenweise, Rest an Folgetagen“
### Task A5: CSV-Export seitenweise streamen (Review Focus 4)
**Files:** Modify `server/routes/termine.js` (`GET /export.csv` Z. 450–474, Import Z. 11), `server/services/termine/buchungen.js` (neu `exportSeite`); Tests `test/termineRoutes.test.js` (Z. 927–990).
**Consumes:** A2 (`aw.listCalls`). **Produces:** `exportSeite(featureId, { nach = null, limit = 500 } = {}) ⇒ doc[]` (`Query.equal('featureId')`, `orderAsc('startsAt')`, `limit`, `cursorAfter(nach)` ⇒ `idx_feature_start`).
**Ansatz:** Die erste Seite wird gelesen, **bevor** Header gesendet werden (Fehler ⇒ normales JSON-500). Dann BOM, Kopfzeile und je Seite die Zeilen per `res.write` (Sperren, `quelle sperre`, je Seite herausfiltern; Cursor = letzte Zeile der Rohseite). Ein Fehler nach dem ersten Byte ⇒ `res.destroy(err)`: ein abgebrochener Download statt einer still gekürzten Datei. Wird das Cursor-Dokument zwischen zwei Seiten vom Tagesjob gelöscht, bricht der Export ab und wird neu geladen (selten, dokumentieren). `imZeitraum` bleibt für die übrigen Aufrufer.
- [ ] Tests: (1) 1 200 Buchungen + 40 Sperren + fremde Funktion ⇒ 1 200 Datenzeilen nach Beginn sortiert, Zeilen byte-gleich zum bisherigen Format; genau drei Listenabfragen an `termineBookings` (500/500/200) mit `limit 500` und ohne `$id`-Sortierung. (2) 20 100 Buchungen (über dem alten Deckel von 20 000 in `listAllDocuments`) ⇒ 20 100 Zeilen, kein `list_limit_exceeded`. (3) `failNext` GET 500 bei der ersten Seite ⇒ 500 JSON ohne CSV-Anfang; bei der zweiten (`after: 1`) ⇒ der Antwortkörper bricht ab (`arrayBuffer()` wirft), kein vollständig aussehendes Ende; im Log kein Gastname/keine Adresse. (4) Die Tests Z. 934 (BOM, Kopf, Sortierung, Sperren, fremde Funktionen) und Z. 957 (Hochkomma-Schutz) bleiben unverändert grün; `Content-Disposition`, `no-store`, `nosniff` wie bisher.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: CSV-Export seitenweise gestreamt“
### Task A6: Zellen-Sweep seitenweise über `idx_bis` (Review Focus 4)
**Files:** Modify `server/services/termine/zellen.js` (`ALLE_ZELLEN_MAX` Z. 46, `ungueltigeZellen` Z. 92–99, `verwaisteAus`/`alleZellen`/`verwaisteZellen`/`raeumeVerwaisteZellen` Z. 215–270), `server/config.js` (`zellenDeckel`, Standard 5000), `server/services/termine/jobs.js` (`taeglicherJob`); Tests `test/termineZellen.test.js` (Z. 600–745), `test/termineJobs.test.js` (Tagesjob-Test Z. 867).
**Consumes:** A2 (`Query.select`, `listCalls`), A3a (`gedeckelt` im Tagesjob). **Produces:** `raeumeVerwaisteZellen(now, { deckel = config.termine.zellenDeckel } = {}) ⇒ Promise<{ geloescht, gedeckelt }>`; `verwaisteZellen`, `alleZellen`, `ALLE_ZELLEN_MAX` entfallen (nur in Tests benutzt); `ungueltigeZellen(zellen, nowMs)` prüft zuerst das Zeitfenster: eine Zelle mit `bis < jetzt − 1 Tag` ist ohne Buchungsabfrage ungültig, nur die übrigen werden gegen ihre Buchung geprüft (`raeumeBlockierer` in `belegeZellen` nutzt dieselbe Funktion).
**Ansatz:** Seiten über `idx_bis`: `lessThan('bis', jetzt − 1 Tag)`, `orderAsc('bis')`, `limit(100)`, `select(['$id','featureId','ressourceId','bookingId','bis'])`, ohne Cursor neu lesen. Gruppierung je Ressourcensperre mit `Map.get`/`push` (statt `[...alt, zelle]`, Z. 247, quadratisch); je Gruppe unter der Sperre erneut lesen (`leseNachIds`) und nur löschen, was dort noch dieselbe `bookingId` hat und alt ist; erster Fehler wird am Ende geworfen, eine Seite ohne Erfolg beendet den Lauf. **Bewusste Änderung:** Zellen mit künftigem `bis`, deren Buchung fehlt oder nichts mehr belegt (nur möglich, wenn `gibFreiSicher` scheiterte), findet der Sweep nicht mehr täglich; sie stören nicht (`belegeZellen` räumt Blockierer bei Bedarf, `raeumeBlockierer`) und fallen spätestens einen Tag nach `bis` in den Sweep.
- [ ] Tests: (1) Zellen mit `bis` genau `jetzt − 1 Tag` bleiben, eine Millisekunde älter ⇒ weg; der Sweep fragt `termineBookings` nicht ab (kein GET). (2) 260 alte Zellen auf zwei Ressourcen (Test Z. 687 angepasst; der Standard `zellenDeckel` 5000 liegt darüber) ⇒ `{ geloescht: 260, gedeckelt: false }`, alle Listenabfragen an `termineClaims` mit `limit ≤ 100`. (3) Deckel: 600 alte Zellen, `zellenDeckel = 250` ⇒ Lauf 1 `{ 250, true }`, Lauf 2 `{ 250, true }`, Lauf 3 `{ 100, false }`; `taeglicherJob` meldet `gedeckelt.zellen` und `zellen` als Zahl. (4) 3 000 alte Zellen einer einzigen Ressource (ein Sperrschlüssel, Deckel 5 000) ⇒ alle gelöscht (Regression des quadratischen Aufbaus). (5) Race (Tests Z. 697/711 angepasst auf alte Zellen): wird eine gelistete Zelle vor der Sperre durch eine andere `bookingId` und künftiges `bis` ersetzt (`mitEinwurf`), bleibt sie. (6) Waise mit künftigem `bis` (Buchung `abgesagt`) bleibt im Sweep stehen, blockiert aber keine Neubuchung (`belegeZellen` räumt sie, bestehender Test Z. 361/406 grün). (7) Ein DELETE-Fehler stoppt die übrigen Blöcke nicht und wird am Ende geworfen (Z. 736); danach kein Endlosdurchlauf. (8) **Angepasste/entfallende TP1-Tests (einzeln):** die beiden `verwaisteZellen`-Tests Z. 600 (Buchung fehlt, nicht mehr belegend, Zelle länger als einen Tag vorbei) und Z. 636 (liest alle Zellen, prüft in Blöcken) werden **gelöscht**, weil die Funktion entfällt; ihre Fälle gehen in (1), (2) und (6) auf. Der Test Z. 670 („löscht genau die verwaisten Zellen und zählt sie“) liest `.geloescht` statt der Zahl (Z. 681–683: `equal(…, 4)` und `equal(…, 0)`, dazu müssen die Zellen `bis` älter als einen Tag haben; die künftigen `c_ok`/`c_abgesagt`/`c_fehlt` bleiben bewusst liegen, vgl. (6)); Z. 687 (260 Zellen) laut (2); Z. 697 und Z. 711 laut (5); Z. 736 laut (7), dort `equal(…, 1)` (Z. 742) ebenfalls auf `.geloescht`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Zellen-Sweep seitenweise ueber idx_bis mit Deckel“
### Task A7: Mail-Fairness 1 – Kontingent je Funktion, Vorrang-Reserve, Haltungsgrenze
**Files:** Modify `server/services/termine/outbox.js` (Budgetzustand Z. 50–79, `sende` Z. 161–204), `server/config.js` (`mailsProFunktion`, Standard 40; `mailsReserve`, Standard 0.25), `server/services/termine/limits.js` (`HALTUNGEN_JE_SITE` Z. 44: 60 → 25); Tests `test/termineOutbox.test.js` (Z. 247–297, 466–493), `test/termineLimits.test.js` (Z. 139–155, 176–193), `test/publicTermine.test.js` (Z. 614–628).
**Consumes:** –. **Produces:** `MAIL_VORRANG = ['G01','G02','G08']`, `MAIL_NACHRANGIG = ['G05','K20','K21']`, `mailKlasse(art) ⇒ 'vorrang' | 'normal' | 'nachrangig'`; `pruefePlatz(nowMs, featureId, art) ⇒ null | 'pause' | 'budget' | 'reserve' | 'kontingent'`; `mailBudget(now) ⇒ { stunde: { benutzt, max }, nachrangig: { benutzt, max }, jeFunktionMax, spitzen: [{ featureId, benutzt }] (höchstens 3) }` (für A10a2 und A10b; nur Zahlen und Funktions-IDs, keine Adressen).
**Ansatz:** Das Stundenfenster hält Einträge `{ t, f, n }` (Zeit, Funktion, nachrangig) statt Zahlen; `gibPlatzFrei` entfernt per Objekt. Regeln in dieser Reihenfolge: Pause; Gesamtzahl im Fenster ≥ `mailsProStunde` ⇒ `budget`; nachrangige im Fenster ≥ `max(1, floor(mailsProStunde × (1 − mailsReserve)))` ⇒ `reserve` (so bleibt ein Viertel der Stunde Vorrang- und Normal-Mails vorbehalten); Mails der Funktion im Fenster ≥ `mailsProFunktion` ⇒ `kontingent`. `sende` liest den Eintrag (kennt dann `featureId` und `art`), belegt den Platz erst danach und lässt einen gebremsten Eintrag unverändert `wartet` (kein Versuch, `versuche` bleibt); der Sofortversand aus `einreihen` unterliegt denselben Regeln. Mit 25 Haltungen je Stunde und siteKey löst eine Funktion höchstens 25 G01 aus, unter dem Kontingent 40.
- [ ] Tests (`config.termine.mailsProFunktion`, `mailsReserve` je Test setzen, `beforeEach` stellt 200/40/0.25 zurück): (1) Kontingent: `mailsProFunktion = 3`, Funktion A reiht 5 G02 ein ⇒ 3 gesendet, 2 `wartet` (`versuche 0`, Inhalt erhalten); Funktion B reiht 2 ein ⇒ beide gehen raus; `verarbeite(+61 min)` ⇒ A's 2 gehen raus. (2) Reserve: `mailsProStunde = 8` (nachrangig höchstens 6): 10 G05 ⇒ 6 gesendet, danach ein G01 ⇒ geht raus (Plätze 7 und 8 gehören dem Vorrang), das siebte G05 bleibt `wartet`, obwohl noch ein Platz frei ist; `mailKlasse` für alle zehn Arten (G03/G04/G06/G07 ⇒ `normal`). (3) Gleitendes Fenster je Funktion: Plätze laufen 60 Minuten nach dem Versand ab (59 Min nein, 60 Min ja); 4xx-Fehlversuche und Anmeldepause belegen weder Gesamt- noch Funktionsplatz (Tests Z. 281/386 grün). (4) Gleichzeitig: `Promise.all` über 6 Einträge einer Funktion mit Kontingent 3 ⇒ genau 3 Transportaufrufe. (5) `mailBudget`: nach 5 Sendungen aus zwei Funktionen stimmen `benutzt`, `max`, `spitzen` (sortiert, höchstens 3), kein `@` im Ergebnis. (6) TP1-Pins: Z. 466 (`Stundenbudget gilt auch bei gleichzeitigen Versuchen`) nimmt `art: 'G02'`; Z. 247–263 bleibt außer der `faellig`-Zeile (A9a). (7) Limits: `darfHaltung` 25 je siteKey und Stunde (Test Z. 139: Titel, 65 Aufrufe ⇒ 25 gezählte; Test Z. 176: Schleife `i < 60` ⇒ 25), `publicTermine.test.js` Z. 614: Titel und Schleifen 60/59 ⇒ 25/24, die 25. Haltung geht noch, die 26. ⇒ 429.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Mailkontingent je Funktion, Vorrang-Reserve und kleinere Haltungsgrenze“
### Task A8: Empfänger-Kappe über Betriebe (G01, Freitext in G04/G06)
**Files:** Modify `server/services/termine/limits.js` (`darfG01` Z. 196, neu `darfEmpfaenger`), `server/services/termine/mails.js` (`anGast` Z. 472–494); `server/routes/publicTermine.js` Z. 535 bleibt unverändert (ruft `darfG01`); Tests `test/termineLimits.test.js` (Z. 162–174 neu geschrieben, Z. 176–193), `test/publicTermine.test.js` (Z. 630–647), `test/termineAblaufBetrieb.test.js` (G04 Z. 281, G06 Z. 323).
**Consumes:** –. **Produces:** `darfG01(featureId, adresse, now)` zählt zusätzlich über alle Funktionen (`zaehleAlle`, alles oder nichts): `empfaenger-h:<adresse>` 3 je Stunde und `empfaenger-d:<adresse>` 10 je Tag, neben dem bisherigen `g01:<funktion>:<adresse>` 5 je Tag; `darfEmpfaenger(adresse, now) ⇒ boolean` (dieselben zwei Zähler, ohne Funktionsregel). Die Adresse ist die normalisierte (klein, getrimmt).
**Ansatz:** Das Gast-Formular bleibt ein stiller Erfolg wie heute (keine Buchung, keine Mail, gleiche Antwort), weil `darfG01` vor `halte` prüft (kein verwaister Halte-Eintrag). Für G04 (`extra.nachricht`) und G06 vom Betrieb (`extra.grund`, `extra.nachricht`) mit Freitext prüft `anGast` `darfEmpfaenger(an)`: ist die Kappe erreicht, geht die Mail **ohne den Freitext** raus (der Hinweis auf Ablehnung/Absage bleibt, der Missbrauchswert fällt weg), Log-Warnung ohne Adresse; ohne Freitext zählt nichts, G05/K20/K21 sind nicht betroffen.
- [ ] Tests: (1) `darfG01` (Test Z. 162 wird neu geschrieben): dieselbe Adresse bei `f1` ⇒ `[true, true, true, false, false, false, false]`; `f2` mit derselben Adresse ⇒ `false` (Kappe über Betriebe, bisher `true`), andere Adresse ⇒ `true`; nach 61 Minuten wieder drei; Tageskappe: 3 + 3 + 3 + 1 über vier Stunden verteilt ja, die 11. nein; alles oder nichts (ein abgelehnter Aufruf erhöht keinen Zähler, auch nicht den der Funktion); `darfG08` zählt weiter getrennt. Test Z. 176 (Speicher): drei statt fünf Durchläufe, der vierte `false`. (2) `darfEmpfaenger` teilt die Zähler mit `darfG01` (zwei `darfEmpfaenger` + zwei `darfG01` ⇒ der vierte Aufruf `false`). (3) `publicTermine` Test Z. 630 wird „je Adresse 3 je Stunde“: sechs Anfragen derselben Adresse ⇒ alle 201 mit gleicher Antwort, drei Mails (statt 5), drei Buchungen, die 4. bis 6. ohne Haltung und ohne Mail; andere Adresse und „Fehler der Zeit kommen vor der Mailgrenze“ (409) wie bisher. (4) `anGast`: G04 mit Nachricht an eine Adresse über der Kappe ⇒ Mail geht raus und enthält den Text nicht, darunter mit Text; G06 `von: 'betrieb'` mit `grund` ebenso, G06 `von: 'gast'` zählt nicht; das Log enthält keine Adresse.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Empfaenger-Kappe ueber Betriebe (G01, Freitext in G04/G06)“
### Task A9a: Mail-Fairness 2a – Frühausstieg, begrenzte und nach Klasse geordnete Warteschlange
**Files:** Modify `server/services/termine/outbox.js` (`verarbeite` Z. 260–275, `sende` Z. 161–204: nur die Platzprüfung), `server/config.js` (`mailListe`, Standard 100); Tests `test/termineOutbox.test.js` (Z. 218–263, neue).
**Consumes:** A2 (`select`, `notEqual`, `listCalls`), A7 (`pruefePlatz`, `mailKlasse`, `MAIL_*`). **Produces:** `verarbeite(now) ⇒ { faellig, gesendet }` (Form unverändert; `faellig` = gelesene Einträge).
**Ansatz:** (1) Frühausstieg ohne Abfrage bei Pause oder leerem Gesamtbudget. (2) Je Klasse `vorrang` ⇒ `normal` ⇒ `nachrangig` (entfällt bei `reserve`) eine Abfrage: `status wartet`, `naechsterVersuch ≤ jetzt`, `art` in/nicht in der Klasse (`equal`/`notEqual`), `featureId notEqual blockierte` (nur wenn nicht leer, höchstens 90 Werte), `orderAsc('naechsterVersuch')`, `limit(mailListe)`, `select(['$id','featureId','art','naechsterVersuch'])` (keine Bodies). (3) Vor jedem Versand `pruefePlatz`: `budget`/`pause` ⇒ Ende; `kontingent` ⇒ Funktion in `blockiert`, Eintrag überspringen; ist die Seite voll und wurde etwas gesendet oder blockiert, höchstens 4 weitere Runden mit der neuen Ausschlussliste (keine Funktion verhungert hinter einer vollen).
- [ ] Tests: (1) Frühausstieg: Budget voll (`mailsProStunde = 2`, zwei gesendet) ⇒ `verarbeite` liefert `{ faellig: 0, gesendet: 0 }` ohne eine Listenabfrage an `termineOutbox` (`listCalls`); ebenso während der Anmeldepause; der TP1-Test Z. 247–263 erwartet in der Zeile `verarbeite(+59 min)` jetzt `{ faellig: 0, gesendet: 0 }`, `+61 min` weiter `{ faellig: 1, gesendet: 1 }`. (2) Begrenzt: 250 fällige G02 über 3 Funktionen ⇒ jede Abfrage `limit ≤ 100`, ohne `$id`-Seitenlauf, mit `select` (kein `html` im Abfragetext); gesendet wird höchstens bis zum Kontingent. (3) Reihenfolge: ein älteres G05, ein G03 und ein jüngeres G01 fällig, Budget 1 ⇒ G01; Budget 2 ⇒ G01 und G03; G05 zuletzt; innerhalb einer Klasse nach `naechsterVersuch` (TP1-Test Z. 218 erwartet „fälligste zuerst“ statt `createdAt`). (4) Kein Verhungern: Funktion A mit 150 fälligen G02 (Kontingent 40) vor einem einzelnen G02 von Funktion B ⇒ ein Lauf sendet 40 von A und die Mail von B, höchstens 5 Abfragen je Klasse. (5) `reserve` erreicht ⇒ keine nachrangige Abfrage mehr.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Mail-Warteschlange begrenzt und nach Vorrang geordnet“
### Task A9b: Mail-Fairness 2b – veraltete G01 verwerfen, Index `bookingId`
**Files:** Modify `server/services/termine/outbox.js` (`sende` Z. 161–204: G01-Prüfung, `raeumeAuf` Z. 304), `server/services/termine/schema.js` (termineOutbox: `index('idx_booking', 'bookingId')`; `zustellFehler` und `verwerfeWartende` fragen nach `bookingId`); Tests `test/termineOutbox.test.js` (neue), `test/termineSetup.test.js` (Z. 157).
**Consumes:** A9a. **Produces:** neuer Status `verworfen` (9 Zeichen, passt in `status`, kein Schema-Eingriff); `sendeEintrag` kann zusätzlich `'verworfen'` liefern; `verworfeneG01() ⇒ number` (Zähler seit Prozessstart, für A10b).
**Ansatz:** Veraltetes G01: in `sende` nach dem Lesen und nach dem Check auf `unverbucht`, vor dem Budget: `art 'G01'`, `bookingId !== '-'` (G01 `zu_viele` hat keinen Link), `nowMs − createdAt > (config.termine.holdMinutes + 1) · 60 s` ⇒ Status `verworfen`, `an/html/text/ics` geleert, `fehler 'Verworfen: Bestätigungslink abgelaufen'`, `naechsterVersuch = jetzt + 48 h`; `raeumeAuf` löscht `gesendet`, `fehler` und `verworfen`; `zustellFehler` zählt `verworfen` nicht (der Betrieb sieht keinen Zustellfehler für einen verfallenen Halte-Eintrag).
- [ ] Tests: (1) Veraltetes G01: `createdAt` = jetzt − 31 min − 1 s ⇒ `verworfen` ohne Transportaufruf, Felder geleert, `zustellFehler([id])` leer, `raeumeAuf(+48 h)` löscht ihn; genau 31 min ⇒ wird gesendet; G01 mit `bookingId '-'` (`zu_viele`) und ein gleich altes G02 werden gesendet; kein Budgetplatz belegt; `holdMinutes` wird bei jedem Aufruf gelesen. (2) Ein bereits gesendeter, nicht verbuchter Eintrag (`unverbucht`) wird nie verworfen und nie doppelt gesendet. (3) Schema: `indexes('termineOutbox')` = `[['idx_status_next','status','naechsterVersuch'], ['idx_booking','bookingId']]`, `main` legt ihn an.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: veraltete G01 verworfen, Index bookingId“
### Task A10a1: Sichtbarkeit 1a – Team-Mail-Vorlage T20
**Files:** Create `server/mail-templates/T20 Terminplaner Betriebsmeldung.html` (Layout von `T14 Loeschfristen.html`, Betreff `[Terminplaner] {{vorfall}}`, Platzhalter `vorfall`, `details`, `zeit`, ohne Blöcke); Modify `test/mailTemplates.test.js` (Z. 8 Kopfkommentar, Z. 11 Liste `[...ids('G', 8), ...ids('K', 21), ...ids('T', 18), 'T20']`, Z. 28 Präfix-Regel um `Terminplaner`). T19 „Erstattung fällig“ gehört **Block B (B9b)**; dort wird die Liste zu `[...ids('G', 8), ...ids('K', 21), ...ids('T', 19), 'T20']`, in E3 zu `ids('K', 23)`; `'T20'` bleibt stehen.
**Consumes:** –. **Produces:** die Vorlage T20 (Versand ab A10a2 über `sendAdminNotice`).
- [ ] Tests (`mailTemplates.test.js`): T20 rendert ohne Reste mit und ohne Blöcke, WEBklar-Logo vorhanden, Kopfkommentar passt zum `<title>`, Betreff beginnt mit `[Terminplaner]`, die Platzhalter `vorfall`, `details`, `zeit` sind deklariert und verwendet; Liste und Präfix-Regel wie oben.
- [ ] FAIL → Vorlage schreiben → PASS → `npm test` → Commit „Terminplaner: Team-Mail-Vorlage T20 Betriebsmeldung“
### Task A10a2: Sichtbarkeit 1b – Betriebsprüfung mit Abkühlzeit (`pruefeBetrieb`)
**Files:** Create `server/services/termine/betrieb.js`, `test/termineBetrieb.test.js`; Modify `server/services/termine/outbox.js` (neu `warteschlangeAlter`), `server/services/termine/jobs.js` (Betriebsprüfung am Ende von `minutenJob`).
**Consumes:** A2 (`countDocuments`), A7 (`mailBudget`), A10a1 (T20), `pausiertBis()` (bisher ungenutzt). **Produces:** `pruefeBetrieb(now) ⇒ Promise<string[]>` (aktive Vorfälle, wirft nie); der gemeinsame Melder `meldeVorfall(schluessel, text, now)` (Warnlog stündlich, Team-Mail mit Abkühlzeit je Schlüssel; A10a3 meldet über ihn die Vorfälle `tagesjob`, `deckel`, `betriebe`); `resetBetriebForTests()`.
**Ansatz:** Vorfälle dieses Tasks: `budget` (≥ 70 % von `mailsProStunde` im Fenster), `queue` (ältester fälliger `wartet`-Eintrag > 10 Min überfällig; `warteschlangeAlter` = `aeltesteMin` aus `status wartet`, `naechsterVersuch ≤ jetzt`, `orderAsc`, `limit(1)`), `pause` (`pausiertBis() > now`). Je Vorfall: Warnlog höchstens stündlich (`[termine-betrieb] …`, nur Zahlen), Team-Mail T20 über `sendAdminNotice` beim Beginn und danach frühestens nach 6 Stunden (Abkühlzeit je Schlüssel, auch wenn der Vorfall zwischendurch endete). Lesefehler ⇒ Werte `null` und ein Log, nie ein 500; Modus `off` ⇒ `minutenJob` ruft nichts auf. Fällt der SMTP aus, scheitert auch die Team-Mail; das Log bleibt der Rückfall.
- [ ] Tests (`termineBetrieb.test.js`, feste Uhr, `mailSink`, `config.chatNotify.emails` gesetzt): (1) Budget: 139 von 200 ⇒ kein Vorfall, 140 ⇒ Vorfall; Warnlog mit „Mailbudget 140/200“ genau einmal, 30 Min später kein zweites, 61 Min später wieder; T20 genau einmal, Betreff beginnt mit `[Terminplaner]`, weder Adresse noch Name im Text. (2) Queue: Eintrag 10 Min überfällig ⇒ kein Vorfall, 11 Min ⇒ Vorfall; ein Eintrag mit `naechsterVersuch` in der Zukunft (Wiederholung) zählt nicht. (3) Pause: EAUTH ⇒ Vorfall `pause` mit Endzeit im Log; endet nach Ablauf; zweite Pause innerhalb 6 Stunden ⇒ Log ja, Mail nein, nach 6 Stunden Mail ja. (4) `pruefeBetrieb` wirft nie: Appwrite 500 ⇒ ein Log, keine Mail-Schleife; Team-Mail scheitert ⇒ Log, Vorfall bleibt gemerkt. (5) `minutenJob` im Modus `off` ruft `pruefeBetrieb` nicht auf (0 Anfragen).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Betriebspruefung mit Abkuehlzeit fuer die Team-Mail“
### Task A10a3: Sichtbarkeit 1c – Tagesjob-Meldung, Tagesjob-Felder und Bericht
**Files:** Modify `server/services/termine/betrieb.js` (neu `meldeTagesjob`), `server/services/termine/jobs.js` (`meldeTagesjob` am Ende von `taeglicherJob` mit `aktiveBetriebe` und `geloescht` im Ergebnis, `tagesbericht`), `test/termineBetrieb.test.js`, `test/termineJobs.test.js` (Z. 867, gepinnte `tagesbericht`-Zeile).
**Consumes:** A10a2 (`betrieb.js`, `meldeVorfall`), A2 (`countDocuments`), A3a/A3b/A4/A6 (`gedeckelt`). **Produces:** `meldeTagesjob(ergebnis, now)` (liest `ergebnis.fehler`, `ergebnis.gedeckelt` = `{ loeschfristen, abgeschaltet, zellen }`, `ergebnis.geloescht` = Summe aller Löschzähler und `ergebnis.aktiveBetriebe` = Zahl oder `null`; merkt `letzterTagesjob = { at, fehler, gedeckelt, geloescht }` (nur Zählungen); prüft Fehler, Deckel, Betriebe; wirft nie); `taeglicherJob` liefert dafür zusätzlich `geloescht` und `aktiveBetriebe` (Zahl); der eingeschränkte Lauf aus E7 liefert dieselben Felder.
**Ansatz:** Vorfälle über den Melder aus A10a2: `tagesjob` (`fehler > 0` oder Abbruch), `deckel` (Löschdeckel an 3 Tagesläufen in Folge), `betriebe` (≥ 20 aktive Betriebe, D8); `aktiveBetriebe` = `countDocuments` der aktiven Termine-Dokumente.
- [ ] Tests (`termineBetrieb.test.js` und `termineJobs.test.js`, feste Uhr, `mailSink`, `config.chatNotify.emails` gesetzt): (1) `meldeTagesjob({ fehler: 2, … })` ⇒ Vorfall `tagesjob`; Deckel an 2 Tagen ⇒ nein, am 3. ⇒ `deckel`; `aktiveBetriebe` 19 ⇒ nein, 20 ⇒ `betriebe`; `letzterTagesjob` trägt `{ at, fehler, gedeckelt, geloescht }` als Zahlen; `meldeTagesjob` wirft nie (Appwrite 500, Team-Mail scheitert). (2) Die exakt gepinnte `tagesbericht`-Zeile (`termineJobs.test.js` Z. 867) bekommt am Ende „, n aktive Betriebe“; das Ergebnis von `taeglicherJob` trägt `aktiveBetriebe` (Zahl) und `geloescht`, die Zeile bleibt frei von IDs, Adressen und Namen.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Tagesjob-Meldung, Betriebe-Zaehlung und Bericht“
### Task A10b: Sichtbarkeit 2 – Status-Route
**Files:** Create `server/routes/team/termine.js`, `test/termineStatus.test.js`; Modify `server/services/termine/betrieb.js` (neu `statusDaten`), `server/routes/team/index.js` (`router.use('/termine', termineTeamRoutes)` hinter `requireStaffJwt`).
**Consumes:** A10a2 (`betrieb.js`, `warteschlangeAlter`), A10a3 (`letzterTagesjob`, `aktiveBetriebe`), A7 (`mailBudget`), A9b (`verworfeneG01`), `pausiertBis()`. **Produces:** `statusDaten(now) ⇒ Promise<{ budget, queue: { wartet, aeltesteMin }, pause: { aktiv, bis }, letzterTagesjob, aktiveBetriebe }>` (`budget` = `mailBudget(now)` plus `verworfeneG01`; `letzterTagesjob` = `null` oder `{ at, fehler, gedeckelt, geloescht }`, nur Zählungen, nach einem Neustart `null` bis zum Tagesjob; `aktiveBetriebe` = `countDocuments` der aktiven Termine-Dokumente); `GET /api/team/termine/status` liefert genau das, `Cache-Control: no-store`. Lesefehler ⇒ Werte `null` und ein Log, nie ein 500.
- [ ] Tests (`termineStatus.test.js`, Team-Eingang wie `teamAdminRoutes.test.js`): ohne JWT 401, `jwt-kunde` 403, `jwt-staff` 200; Form wie oben; `letzterTagesjob` vorher `null`, nach `taeglicherJob` gefüllt; `aktiveBetriebe` 2 bei zwei aktiven und einem abgeschalteten Dokument; `failNext` auf `termineOutbox` ⇒ 200 mit `queue` aus `null`-Werten; rekursiv keine Adresse und kein Gastname im Body; ein Kunden-Cookie erreicht die Route nicht.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Status-Route fuer das Team“
### Task A11: `disable()` idempotent, `disabledReason`, Schema-Skript `feature-setup.mjs`
**Files:** Modify `server/services/siteFeatures.js` (`activate` Z. 293–302, `disable` Z. 304–313, `disableCustomerFeatures` Z. 321–337), `server/services/customerAdmin.js` (Z. 289: `reason: 'kunde_geloescht'`), `test/support/schema.js` (`schemaKeys('portalFeatures')` hängt die Schlüssel aus `PORTAL_FEATURE_ATTRIBUTES` an, Import aus dem neuen Skript); Create `scripts/feature-setup.mjs`, `test/featureSetup.test.js`; Tests `test/siteFeatures.test.js` (Z. 216–265), `test/funktionenTermine.test.js` (Z. 74, 172–195). `scripts/setup-abo-collections.mjs`, `retention.js` und die WOMS-Route bleiben unverändert (Vorgabe-Grund `team`).
**Consumes:** –. **Produces (verbindlich laut Kopf):** `DISABLED_REASONS = ['team', 'abo_ende', 'abo_ende_vor_einrichtung', 'kunde_geloescht']`; `disable(docId, { by, adminNote, reason = 'team' })` unter `withLocks(['feature-disable:' + docId])`: Dokument lesen (404 wie bisher), steht es schon auf `disabled`, bleibt **alles** unverändert (`disabledAt`, `disabledReason`, `adminNote`, kein Schreibzugriff) und das Dokument wird zurückgegeben; **einzige Ausnahme Altbestand:** fehlt einem `disabled`-Dokument `disabledAt`, wird nur dieses Feld auf jetzt gesetzt (sonst fiele es nie in die 30-Tage-Löschung), Grund und Notiz bleiben; sonst `state disabled`, `disabledAt` jetzt, `disabledReason`, Notiz wie bisher; ein unbekannter Grund ⇒ 400 ohne Schreibzugriff. Die Analyse-Nacharbeit (`stopAnalyseCollecting`) läuft für `analyse`-Dokumente unverändert bei jedem Aufruf. `activate()` setzt zusätzlich `disabledReason: null` und `aboEndeMailAt: null` (`zahlungMeldung` fasst `activate()` bewusst **nicht** an: ein noch offener Zahlungsfall darf durch eine Aktivierung nicht verloren gehen; nur E8 setzt es beim Zurücksetzen auf `''`). `disableCustomerFeatures(customerId, key, { by, adminNote, reason })` reicht den Grund durch. `portalFeatures.consentText` bleibt unberührt. **Schema:** `scripts/feature-setup.mjs` (Muster `scripts/analyse-setup.mjs`, importiert dessen `createAppwriteClient`; Hauptlauf nur bei direktem Aufruf) exportiert `PORTAL_FEATURE_ATTRIBUTES = [{ type: 'string', key: 'disabledReason', size: 32 }, { type: 'datetime', key: 'aboEndeMailAt' }, { type: 'string', key: 'zahlungMeldung', size: 24 }]` (alle optional; `zahlungMeldung` ist der Marker aus B10a/B10b: `''` | `'bereits_aktiv'` | `'abgeschaltet'` | `'abo_ende'` | `'gemeldet'`) und `main({ check = false, client, log = console.log } = {})`: legt Fehlendes idempotent an, `--check` liest nur und meldet je Lücke `- portalFeatures.<key> FEHLT` (Exit 1 bei Lücken). Block B (B3) ergänzt `featureConsents` im selben Skript.
- [ ] Tests: (1) `disable` ohne Grund ⇒ `disabledReason 'team'`; mit `reason: 'abo_ende'` gespeichert; mit `'frist'` ⇒ 400 und nichts geschrieben. (2) Idempotenz: zweites `disable` (anderer Grund, andere Notiz) lässt `disabledAt`, `disabledReason`, `adminNote` und `$updatedAt` unverändert und liefert das Dokument; `Promise.all([disable, disable])` ⇒ ein Schreibzugriff, gleiches `disabledAt`; Altbestand `disabled` ohne `disabledAt` ⇒ `disabledAt` wird jetzt gesetzt, Grund und Notiz bleiben (zweiter Aufruf schreibt nichts mehr). (3) Die 30-Tage-Löschung wird nicht verschoben: Dokument mit `disabledAt` vor 31 Tagen, erneutes `disable` (Doppelklick im WOMS, Kundenlöschung) ⇒ `loescheAbgeschalteteFunktionen` löscht weiterhin. (4) `activate` leert `disabledAt`, `disabledReason` und `aboEndeMailAt` (Dokument vorher mit allen dreien gefüllt) und lässt `zahlungMeldung` unverändert. (5) `disableCustomerFeatures` mit `reason` ⇒ alle Dokumente tragen ihn, ein schon abgeschaltetes behält Grund und `disabledAt`; Kundenlöschung (Test Z. 172) ⇒ `kunde_geloescht`, `schonAus` behält `team`. (6) Analyse: `disable` einer `analyse`-Funktion ruft `analyseDisabled` wie vorher (auch beim zweiten Aufruf), kein Verhaltensunterschied. (7) `featureSetup.test.js` (Mock-Client wie `analyseSetup.test.js`): `PORTAL_FEATURE_ATTRIBUTES` mit Typ und Größe (`disabledReason` ≥ 24, längster Wert `abo_ende_vor_einrichtung`; `aboEndeMailAt` datetime; alle optional); `main` legt nur Fehlendes an und überspringt Vorhandenes; `main({ check: true })` legt nichts an und meldet; `schemaKeys('portalFeatures')` enthält die drei Schlüssel (der Analyse-Aufbau `[...schemaKeys('portalFeatures'), ...ANALYSE_KEYS]` bleibt fehlerfrei).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktionen: disable() idempotent, disabledReason, Schema-Skript feature-setup“
### Task A12: `minutenJob` in Verfall und Mailversand getrennt schützen
**Files:** Modify `server/services/termine/jobs.js` (`fuehreAus` Z. 91–108, `minutenJob` Z. 151–157); Tests `test/termineJobs.test.js` (Z. 300–361, 963–1005). `server/index.js` bleibt unverändert (der Pin-Test Z. 1007 prüft `minutenJob` in `index.js`).
**Consumes:** A10a2 (die Betriebsprüfung wandert in den Mail-Teil). **Produces:** `fuehreAus(job, now, arbeit, { teil = null } = {})` führt die Überlappungssperre je `job:teil` (Block E7 erweitert dasselbe Optionsobjekt um `trotzAus`; die Signatur ist `{ teil = null, trotzAus = null } = {}`); `minutenJob(now)` startet beide Teile mit `Promise.all` und liefert wie bisher `{ verfallen, faellig, gesendet, fehler }` (Summe der Fehler), `{ aus: true }` im Modus `off`, `{ uebersprungen: true }`, wenn **beide** Teile übersprungen werden; ist nur der Verfall übersprungen, kommt zusätzlich `verfallUebersprungen: true` (analog `mailUebersprungen`). Die Warnung bei Überlappung nennt den Teil (`minutenJob (Verfall)`), das Fehlerlog behält `minutenJob` als Jobnamen.
- [ ] Tests: (1) Hängt der Verfall (erste Appwrite-Anfrage blockiert), versendet der nächste Takt trotzdem die Warteschlange (`gesendet: 1`, `verfallUebersprungen: true`), die Überlappung steht einmal im Log; (2) hängt der Mailversand, verfällt im nächsten Takt eine abgelaufene Haltung trotzdem; (3) hängen beide ⇒ `{ uebersprungen: true }` wie im TP1-Test Z. 963 (Pin wird um die Teilnamen im Log ergänzt); (4) scheitern beide Teile (Z. 991) ⇒ `fehler: 2`, die Sperren sind danach frei; (5) Fehler im Verfall stoppt den Mailversand nicht (Z. 347 grün); (6) Modus `off` ⇒ `{ aus: true }` ohne Abfrage (Z. 277 grün); (7) der Pin `server/index.js: die drei Jobs …` (Z. 1007) bleibt unverändert grün.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Minutenjob in Verfall und Mailversand getrennt geschuetzt“
**Abschlussprüfung Block A (Controller):** `npm test` komplett grün (`test/previewDevRouter.test.js` bei Ausreißer einzeln wiederholen); Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): `docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)'` (`analyticsContract.test.js` importiert `../analytics/src` und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: `git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob'` bleibt leer. Schema-Check vor jedem Deploy: `node scripts/termine-setup.mjs --check` und `node scripts/feature-setup.mjs --check` (Livegang Schritt 3).
---
## Block B: Register, Checkout, Zustimmungsnachweis
Block B macht aus dem festverdrahteten News-Bestellweg ein Register `config.features` (Checkout, Mails, Ticket, Freischaltung je Funktion), legt den Stripe-Preis `termineSetup` an, baut `POST /api/funktionen/:key/checkout` mit der Prüfreihenfolge aus Spec 4.3 samt unveränderlichem Zustimmungsnachweis (`featureConsents`) und fängt jede Zahlung ab, die in einem anderen Zustand eintrifft (Review Focus #1 und #5). Reihenfolge im Gesamtplan A → B → E → C → D → F: Was Block C (`bestellpruefung`), E (`aboStatus`) und D (Ticket-Kurzfassung) liefern, steht hier als **Stub mit endgültiger Signatur** (Task B5, B8), den die genannten Tasks ersetzen. Alle Zeilenangaben beziehen sich auf Portal `3a0b008` plus Block A.
**Im Code geprüft, Testkonventionen für B und die Falle `seedFixtures`:** stehen im Kopf, Abschnitt „Grundlagen der Blöcke“ (jeder Task bekommt nur Kopf und eigenen Text).
Der Register-Eintrag (Tabelle der verbindlichen Form) steht in Task B1.
---
### Task B1: Register `config.features` und `featureRegistry.js`
**Files:** Modify `server/config.js` (neuer Block `features` vor `export const config`, `funktionen` Z. 193–197 wird Alias mit Gettern auf `features.news`; neu `config.ustHinweis = 'ohne Umsatzsteuer gemäß § 19 UStG'`, von Register und `mails.preisHinweis` genutzt), `server/services/siteFeatures.js` (`FEATURE_LABELS` Z. 11–14 und `DISABLED_MESSAGE` Z. 17–18 werden aus dem Register abgeleitet, Exporte und Werte bleiben). Create `server/services/featureRegistry.js`, `test/featureRegistry.test.js`.
**Consumes:** – (Block A fasst diese Stellen nicht an).
**Produces:** `config.features` (Tabelle unten in diesem Task); `featureConfig(key) ⇒ Eintrag | null` (nur eigene Schlüssel des Registers, `Object.hasOwn`; `'analyse'`, `'__proto__'`, `'constructor'`, `undefined` ⇒ `null`); `featureKeys() ⇒ ['news','termine']`; `featureHaken(key) ⇒ { verfuegbar?, bestellpruefung?, unterlagenBereit?, belehrung?, freischaltbar? }` (B1 liefert die leere Hülle samt Überschreib-Regel, B6, B7 und B11 füllen die Standards; Standard-Haken laden Dienste per dynamischem `import()`, weil `featureRegistry.js` von `siteFeatures.js` importiert wird); `oeffentlicheAnsicht(key) ⇒ null | { key, name, kurz, priceLabel, setupEstimate, consent: { version, knopf, items: [{ id, text, haken }] }, leistungsbeschreibung, avv, widerruf, returnHash, requiresWartungsAbo }` (JSON-sicher, ohne Funktionen und ohne `ticket`/`mails`).
**Register-Eintrag (verbindliche Form; der Plan-Kopf-Kern bleibt, alles Weitere ist additiv):**
| Feld | `news` (heutige Werte, wörtlich) | `termine` |
|---|---|---|
| `key`, `product` | `'news'`, `'newsSetup'` | `'termine'`, `'termineSetup'` |
| `label` | `{ name: 'News-Pinnwand', kurz: 'News', keine: 'keine News-Pinnwand' }` | `{ name: 'Terminplaner', kurz: 'Termine', keine: 'keinen Terminplaner' }` |
| `priceLabel`, `setupEstimate` | `'30 €'`, `'meist innerhalb von 1–2 Wochen'` | `'149 €'`, gleicher Text (Frage Q1) |
| `consent` | `{ version: '2026-09-30', knopf: 'Zahlungspflichtig bestellen · 30 €', items: [{ id: 'beginn', haken: true, text: <heutiger Satz aus config.js:195-196, wörtlich inkl. „§ 356 Abs. 4 BGB“> }] }` | `{ version: 'ENTWURF-2026-10-03', knopf: 'Zahlungspflichtig bestellen · 149 €', items }` mit vier Haken `unterlagen`, `gesundheit`, `beginn`, `information` (alle `haken: true`, ohne `url`; die IDs sind die endgültigen aus F7, Block F ersetzt nur Texte und Fassung); Texte nur Platzhalter „ENTWURF (Block F): …“ nach Spec 4.3 (1)–(4). Die Widerrufsbelehrung ist kein Haken-Item, sondern wird als eigener Nachweiseintrag `belehrung` gespeichert (B4/B7/F7) |
| `leistungsbeschreibung`, `avv`, `widerruf` | `{ version: '2026-09-30', url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' }`, `null`, `null` | `leistungsbeschreibung` und `avv` je `{ version: 'ENTWURF', url: '' }`, `widerruf` `null` (Block F1/F7 setzt `config.termine.avvVersion`-Fassung und versionierte Adressen) |
| `returnHash`, `returnProjekt` | `'news'`, `false` | `'termine'`, `true` (Rücksprung `#termine?bestellt=1&projekt=<id>`) |
| `ticket` | `topic(project)` = `` `News-Pinnwand einbauen – ${projectName}`.trim() ``; `details(ctx)` = die 6 Zeilen aus `ticketAdmin.js:95-102` | `topic` = `` `Terminplaner einbauen – ${projectName}` ``; `details(ctx)` ruft lazy `termine/ticketDetails.js` (Stub aus B8, Block D4 ersetzt den Inhalt) |
| `mails` | `funktion: 'News-Pinnwand'`; `formen: { dein: 'Deine News-Pinnwand', nom: 'die News-Pinnwand', akk: 'die News-Pinnwand', gen: 'der News-Pinnwand', pron: 'sie' }`; `knopf: 'Beitrag schreiben'`; `danach: 'Danach kannst Du Beiträge im Portal schreiben.'`; `bereit: 'Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite.'`; `preisHinweis: 'brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo'`; `anleitung: false` | `funktion: 'Terminplaner'`; `formen: { dein: 'Dein Terminplaner', nom: 'der Terminplaner', akk: 'den Terminplaner', gen: 'des Terminplaners', pron: 'ihn' }`; `knopf: 'Termine öffnen'`; `danach`, `bereit` als Platzhaltertexte für die Mail-Überarbeitung des Inhabers (Mailrunde des Inhabers, nicht Block F; vom `todo`-Test unten ausgenommen); `preisHinweis` = `` `einmalig, ${config.ustHinweis}; der Terminplaner läuft, solange Dein Wartungs-Abo läuft` ``; `anleitung: true` (die K15-Anhänge hängt F8 direkt in `onFeaturePaid` an, es gibt keinen Registerhaken dafür) |
| Schalter | `requiresWartungsAbo: false`, `ticketFreischaltung: true` | `requiresWartungsAbo: true`, `ticketFreischaltung: true` |
| Meldungen | `disabledMessage` = heutiger `DISABLED_MESSAGE`; `orderedConflictMessage: 'Die News-Pinnwand ist für dieses Projekt schon bestellt.'` | `disabledMessage: 'Der Terminplaner ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.'`; `orderedConflictMessage: 'Der Terminplaner ist für dieses Projekt schon bestellt.'`; `unavailableMessage: 'Der Terminplaner ist noch nicht verfügbar.'` (= `MELDUNG.nichtVerfuegbar`, `routes/termine.js:57`) |
| Haken (optional) | – | **nicht in `config.js`**, sondern als Standard-Haken in `featureRegistry.js` (`featureHaken(key)`): `verfuegbar(email)` = `termineFuerKunde` (B6), `bestellpruefung(ctx)` = `ladeEntwurf` + Block-C-Funktion (B6), `unterlagenBereit()` (B6, Standard `true`; F8 hängt `featureLegalReady` ein), `belehrung()` (B7, Standard: der Platzhaltertext `BELEHRUNG_PLATZHALTER` = „ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.“, exportiert aus `featureRegistry.js`; F7 liefert `widerrufsbelehrungFunktion()`; `news` hat keinen `belehrung`-Haken), `freischaltbar(doc)` (B11); wer `config.features.<key>.<haken>` setzt (Tests), überschreibt den Standard |
- [ ] Tests (`test/featureRegistry.test.js`, Testaufbau laut Kopf): „News-Eintrag entspricht den bisherigen Festwerten“ (`product 'newsSetup'`, `priceLabel '30 €'`, `setupEstimate`, Satz wörtlich, `returnHash 'news'`, `requiresWartungsAbo false`, `ticketFreischaltung true`, beide Meldungen wortgleich zu den heutigen, `topic({ projectName: 'Bäckerei' }) === 'News-Pinnwand einbauen – Bäckerei'`, `details` == die 6 Zeilen des heutigen Defaults mit `siteKey` und Domain `liveDomain || previewUrl || subdomain`); „Alias bleibt“ (`config.funktionen.setupEstimate`/`consentText` == News-Werte, Getter folgen einer Änderung an `config.features.news` zur Laufzeit); „`FEATURE_LABELS` unverändert“ (`{ news: { name, keine }, termine: { name, keine } }` exakt) und `FEATURE_KEYS` == `featureKeys()`; „Schlüsselschutz“ (`featureConfig` für `analyse`, `__proto__`, `toString`, `undefined`, `''` ⇒ `null`); „Haken überschreibbar“ (`config.features.termine.bestellpruefung = fn` gewinnt gegen den Standard, danach zurücksetzen); „Ansicht ohne Funktionen“ (`JSON.parse(JSON.stringify(ansicht))` gleich, kein `ticket`, kein `mails`); „Termine: Preis und Struktur“ (`product 'termineSetup'`, `priceLabel '149 €'`, `requiresWartungsAbo true`, `returnProjekt true`, Item-IDs `['unterlagen','gesundheit','beginn','information']` mit `haken` `[true,true,true,true]`, `config.ustHinweis` wörtlich, `mails.preisHinweis` enthält `config.ustHinweis`); **`{ todo: 'Block F ersetzt die Platzhaltertexte' }`-Test** „Bestelltexte ohne ENTWURF“ (Version, Items, `leistungsbeschreibung`, `avv`, `widerruf` frei von `ENTWURF`, ab B7 auch der Text von `await featureHaken('termine').belehrung()`; **ausgenommen** `mails.danach`/`mails.bereit`, die der Inhaber in seiner Mailrunde schreibt; schlägt bis Block F1/F7 fehl, ohne die Suite zu brechen).
- [ ] FAIL → implementieren (Einträge als reine Daten in `config.js`; `ticket.*` des News-Eintrags ohne Importe, `ticket.details` des Termine-Eintrags per dynamischem `import()` innerhalb der Funktion, weil `config.js` keine Dienste statisch importieren darf; das Ziel `termine/ticketDetails.js` entsteht erst in B8, aufgerufen wird es vorher nirgends) → PASS → `npm test` → Commit „Funktionen: Register config.features mit News- und Termine-Eintrag“
### Task B2: Stripe – Katalog `termineSetup` und generisches `createFeatureCheckout`
**Files:** Modify `server/config.js` (`termineSetup` neben `newsSetup` Z. 100–107, im `config` neben `newsSetup` Z. 190), `server/services/stripeBilling.js` (`PRODUCT_METADATA` Z. 54 + `termineSetup: 'termine-einrichtung'`, `catalogFor` Z. 57–62, `createFeatureCheckout` Z. 647–682), `scripts/stripe-products.mjs` (`KEYS` Z. 20 + `'termineSetup'`, `KEYS` und `catalogEntry` Z. 30–34 mit `export`), `test/stripeBilling.test.js` (neu neben `news_einrichtung_einmalpreis`, Z. 246–260). Create `test/featureCheckout.test.js`, `test/stripeProductsScript.test.js`.
**Consumes:** B1 `featureConfig`.
**Produces:** `config.termineSetup = { key: 'termineSetup', name: 'Terminplaner – Einrichtung', amountCents: 14900, lookupKey: 'webklar_termine_einrichtung_14900', oneTime: true, priceNickname: 'Terminplaner-Einrichtung 149 €' }`; `createFeatureCheckout(customer, { projectId, key = 'news', consentAt }) ⇒ { id, url }` (unbekannter `key` ⇒ Fehler `status 400`, **vor** jedem Stripe-Aufruf): Preis `ensurePrice(feature.product)`, `invoice_data: { description: <Katalogname>, footer, metadata: { featureKey: key, projectId } }`, `metadata` unverändert (`customerId, projectId, featureKey, consentAt`), `success_url` = `${publicBaseUrl}/dashboard.html#${returnHash}?bestellt=1` (+ `&projekt=<encodeURIComponent(projectId)>` bei `returnProjekt`), `cancel_url` = `…#${returnHash}` (+ `?projekt=<id>`); SEPA-Rückfall und 24-h-Ablauf unverändert. Skript: `KEYS` und `catalogEntry(key)` exportiert (Import startet `main()` nicht, `import.meta.url`-Wächter besteht).
- [ ] Tests: `stripeBilling.test.js` „termine_einrichtung_einmalpreis“ nach Vorbild News: ein `POST /v1/prices` mit `unit_amount '14900'`, `lookup_key 'webklar_termine_einrichtung_14900'`, ohne `recurring`, Produkt-Metadaten `webklar = 'termine-einrichtung'`, zweiter Aufruf legt nichts an. `featureCheckout.test.js` (Stripe-Nachbau, direkter Aufruf): News: Preis 3000, `success_url`/`cancel_url` **identisch zu heute** (`…#news?bestellt=1`, `…#news`), `invoice_creation[invoice_data][description] = 'News-Pinnwand – Einrichtung'`, Footer, `metadata[featureKey]='news'`, `expires_at` < jetzt + 24 h, `payment_method_types` Karte + SEPA, SEPA-Rückfall nur Karte; Termine: Preis 14900, `invoice_data[description] = 'Terminplaner – Einrichtung'`, `invoice_data[metadata][featureKey]='termine'` und `[projectId]`, `success_url …#termine?bestellt=1&projekt=p%2F1` (Sonderzeichen kodiert), `cancel_url …#termine?projekt=p%2F1`; unbekannter Schlüssel (`'analyse'`) ⇒ `rejects` mit `status 400` und `st.calls` unverändert. `stripeProductsScript.test.js`: `KEYS` deepEqual `['wartung','hosting','mailStorage','newsSetup','termineSetup']`, `catalogEntry('termineSetup') === config.termineSetup`, Import ohne Stripe-Aufruf und ohne `process.exit` (kein Live-Aufruf, `--anlegen` macht der Inhaber im Livegang-Schritt 4).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Stripe: Katalog Terminplaner-Einrichtung (149 €) und Checkout je Funktion aus dem Register“
### Task B3: Schema `featureConsents` in `scripts/feature-setup.mjs`
**Files:** Modify `scripts/feature-setup.mjs` (aus A11: neu `FEATURE_CONSENT_ATTRIBUTES`, `FEATURE_CONSENT_INDEXES`, `CONSENT_COLLECTION_NAME`; `main` legt die Collection samt Attributen und Indizes an bzw. meldet sie mit `--check`), `server/config.js` (`collections.featureConsents`, Env `APPWRITE_COLLECTION_FEATURE_CONSENTS`), `test/support/schema.js` (`schemaKeys('featureConsents')` liefert die Schlüssel aus `FEATURE_CONSENT_ATTRIBUTES`), `test/featureSetup.test.js` (aus A11, erweitert). `scripts/setup-abo-collections.mjs` bleibt unverändert (Top-Level-Skript ohne Exporte und ohne `--check`; ein nachträglich eingebauter Prüfmodus gefährdete das Live-Schema-Skript).
**Consumes:** A11 (`feature-setup.mjs`; `createAppwriteClient` aus `analyse-setup.mjs`). **Produces:** Collection `featureConsents` (nur Server-Key, wie `analyseConsents`; Anlegen über `ensureCollection` ohne Rechte): `customerId` 36, `projectId` 36, `featureKey` 16, `stripeSessionId` 128, `acceptedAt` datetime, `fassungen` 1000 (JSON), `texte` 20000 (JSON, Appwrite macht daraus eine TEXT-Spalte), `textHash` 64, `ip` 64, `userAgent` 300, `konto` 255; Indizes `idx_session` [`stripeSessionId`], `idx_project_feature` [`projectId`, `featureKey`, `acceptedAt`]. `node scripts/feature-setup.mjs --check` meldet je fehlende Collection/Attribut/Index eine Zeile `- <id>[.<key>] FEHLT`, schreibt nichts, Exit-Code 1 bei Lücken.
- [ ] Tests (`featureSetup.test.js`, Mock-Client wie `analyseSetup.test.js`): „Schema featureConsents“ (die 11 Schlüssel, Größen: `texte` ≥ 20000, `stripeSessionId` ≥ 128, `acceptedAt` datetime, alle optional; `schemaKeys('featureConsents')` == dieselben Schlüssel; beide Indizes mit den Attributen oben); `main` legt bei fehlender Collection genau `featureConsents` mit 11 Attributen und 2 Indizes an (und für `portalFeatures` nur Fehlendes, A11); `main` überspringt Vorhandenes (keine Anlege-Aufrufe); `main({ check: true })` schreibt nie (keine `createAttribute`/`ensureCollection`/`createIndex`-Aufrufe), meldet `featureConsents FEHLT` und liefert die Lücken im Ergebnis. Der Lauf gegen die echte Appwrite gehört in den Livegang-Schritt 3 (Block G), weil der Nachbau die Attribut-Wartezeiten nicht nachstellt.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Schema: Zustimmungsnachweis featureConsents im Skript feature-setup“
### Task B4: Zustimmungsnachweis `featureConsents.js` und Widerrufs-Anker
**Files:** Create `server/services/featureConsents.js`, `test/featureConsents.test.js`. Modify `server/services/siteFeatures.js` (`paidFeatureOrders` Z. 190–206), `server/routes/abo.js` (`withdrawFeature` Z. 1091: `o.vertragAt || o.orderedAt`), `test/publicForms.test.js` (neuer Test neben „Widerruf Funktion…“ Z. 515).
**Consumes:** B3 Schema, B1 `featureConfig`.
**Produces:** `speichereZustimmung({ customerId, projectId, featureKey, stripeSessionId, fassungen, texte, ip, userAgent, konto, at }) ⇒ Promise<doc>` (Plan-Kopf-Signatur; `fassungen` Objekt Name ⇒ Fassung, `texte` Array `{ id, text, haken }` genau in Anzeigereihenfolge; speichert beides als JSON-Text, `textHash` = SHA-256 (hex) über `JSON.stringify(texte)`, `acceptedAt = at`); `letzteZustimmung({ customerId, projectId, featureKey }) ⇒ Promise<doc | null>` (neueste nach `acceptedAt`); `zustimmungFuerSitzung(stripeSessionId) ⇒ Promise<doc | null>`; `leseZustimmung(doc) ⇒ { …doc, fassungen: object, texte: array }`; `texteHash(texte) ⇒ string`; `zustimmungsfassungen(feature) ⇒ { zustimmung, leistungsbeschreibung, avv?, widerruf? }` (jeweils die `version` des Registerfelds, falls vorhanden) und `nachweisTexte(feature, { belehrung = null } = {}) ⇒ [{ id, text, haken }]` (alle `consent.items` in Anzeigereihenfolge, dann – falls übergeben – `{ id: 'belehrung', text: belehrung, haken: false }` (der **gezeigte** Belehrungstext samt Muster-Formular, Review Focus 5; die Route holt ihn über `featureHaken(key).belehrung`), zuletzt `{ id: 'knopf', text: consent.knopf, haken: false }`). `speichereZustimmung` erlaubt leeres `stripeSessionId`, `ip` und `userAgent` sowie fehlendes `haken` (Nachweis der Staff-Aktivierung aus F13a); `zustimmungFuerSitzung('')` (leer/`undefined`) liefert `null` **ohne Abfrage** (sonst träfe es die F13-Zeilen über `idx_session`). `paidFeatureOrders` liefert zusätzlich `featureKey` und `vertragAt` (= `acceptedAt` der Zustimmung **zur bezahlten Sitzung**, sonst `doc.consentAt`, sonst `orderedAt`; Lesefehler ⇒ Rückfall auf `orderedAt` mit Log, die Widerrufsprüfung darf an der Nachweis-Collection nie scheitern). Das Modul exportiert bewusst nur diese Lese-/Anfügefunktionen (keine Änderung, kein Löschen).
- [ ] Tests (`featureConsents.test.js`): „speichert und liest“ (Rundlauf, `fassungen`/`texte` als Objekt/Array zurück, `textHash` == unabhängig berechneter SHA-256); „append-only“ (`Object.keys(import)` genau die sieben Exporte `speichereZustimmung`, `letzteZustimmung`, `zustimmungFuerSitzung`, `leseZustimmung`, `texteHash`, `zustimmungsfassungen`, `nachweisTexte`; kein `update`/`delete`/`remove`); „neueste Zustimmung“ (zwei Einträge, `at` verschieden, `letzteZustimmung` liefert den späteren, anderes Projekt/andere Funktion stört nicht); „`zustimmungFuerSitzung`“; „vollständig oder gar nicht“ (`texte` > 20 000 Zeichen ⇒ wirft 400, nichts gekürzt; fehlendes/ungültiges `at` ⇒ wirft; leere `texte` ⇒ wirft); „IP/UA/Konto gekürzt auf 64/300/255“; „Nachweistexte aus dem Register“ (`nachweisTexte(featureConfig('termine'))` hat fünf Einträge (vier Haken + `knopf`), letzter `knopf`; mit `{ belehrung: 'Text' }` sechs, `belehrung` direkt vor `knopf`, `haken false`); „Staff-Nachweis“ (`stripeSessionId: ''`, `ip: ''`, `userAgent: ''`, Text ohne `haken` ⇒ gespeichert; `zustimmungFuerSitzung('')` ⇒ `null` und **keine** Listenabfrage an `featureConsents`, `zustimmungFuerSitzung(undefined)` ebenso). `publicForms.test.js`: „Widerruf Funktion: Fristanker ist die Zeit des Klicks der bezahlten Sitzung“: Dokument `orderedAt` = vor 1 Tag (durch Neubestellung überschrieben), Zustimmung zur bezahlten Sitzung vor 13 Tagen ⇒ T09 nennt das Fristende von `withdrawalDeadline(vor 13 Tagen)` und `RECHTZEITIG`, mit Zustimmung vor 20 Tagen stattdessen `NACH Fristende` (je ein Testfall); eine **spätere, unbezahlte** Zustimmung desselben Kunden/Projekts (andere Sitzung) verschiebt den Anker nicht (Block F9 stützt sich auf `vertragAt`/`featureKey` aus diesem Task, nicht auf `letzteZustimmung`); Nachweis-Collection kaputt (`failNext` auf `featureConsents`) ⇒ Rückfall auf `orderedAt`, Antwort 200.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Zustimmungsnachweis je Funktion (featureConsents) und Fristanker aus dem Klick“
### Task B5: Schnittstellen-Stubs für Block E und C (`aboStatus`, `fragebogen`)
**Files:** Create `server/services/aboStatus.js`, `server/services/termine/fragebogen.js` (Stub), `test/aboStatus.test.js`, `test/termineFragebogenStub.test.js`.
**Consumes:** `getCurrentSubscription`, `ACTIVE_STATUSES` aus `stripeBilling.js` (Z. 24, 178).
**Produces (Plan-Kopf-Signaturen; Block E1 erweitert die Datei `aboStatus.js` (Modify, die Marke `KERNVERSION` entfällt) und zieht `hasWartungsAbo` aus `analyse.js:43-46` hinein; Block C ersetzt den Stub):**
- `aboStatus.js` (Kopfkommentar `// KERNVERSION (Block B) – Block E baut aus`; **fail-closed**, kein Stub im Sinn von „durchlassen“): `laeuft(customerId) ⇒ Promise<boolean>` (`productKey || 'wartung'` == `'wartung'` und Status ∈ `ACTIVE_STATUSES`; wirft bei Lesefehler), `bestellbar(customerId) ⇒ Promise<{ ok: boolean, grund?: 'kein_abo' | 'gekuendigt' | 'nicht_aktiv' }>` (**Reihenfolge der Gründe, einmal für alle Blöcke:** nicht `laeuft` ⇒ `kein_abo`; `cancelAtPeriodEnd || cancelAt` ⇒ `gekuendigt`; Status ≠ `active` ⇒ `nicht_aktiv`), `aboMemo() ⇒ { laeuft, bestellbar }` (Zwischenspeicher je Aufruf von `aboMemo()` und `customerId`).
- `termine/fragebogen.js` (Stub, Kopfkommentar `// STUB (Block B) – ersetzt Task C…`): `bestellpruefung({ einstellung, fragebogen, feature, abo, mitAbo = true }) ⇒ Array<{ schritt, text }>` gibt `[]` zurück (Signatur wie im Kopf, endgültig).
- [ ] Tests (`aboStatus.test.js`; Testaufbau laut Kopf: `seedFixtures` gibt `c1` nur ein Hosting-Abo, für Wartungsfälle überschreibt der Test es mit `aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc }))`): Wartungs-Abo `active` ⇒ `laeuft` true, `bestellbar { ok: true }`; Hosting-Abo ⇒ false / `kein_abo`; Dokument **ohne** `productKey` zählt als Wartung (wie `abo.js:68`); `canceled` ⇒ false / `kein_abo`; `past_due` und `trialing` ⇒ `laeuft` true, `bestellbar { ok: false, grund: 'nicht_aktiv' }`; `cancelAtPeriodEnd` und `cancelAt` ⇒ `gekuendigt`, auch bei `trialing` und `past_due` (Kündigung vor Status); kein Abo (`c3`) ⇒ `kein_abo`; **Lesefehler** (`aw.failNext({ collection: 'subscriptions', status: 500 })`) ⇒ beide Funktionen **werfen** (nie `false`/`ok:false`); Memo: zweiter Aufruf desselben Kunden aus demselben `aboMemo()` kommt aus dem Speicher (ein danach eingeplantes `failNext` trifft ihn nicht, ein frischer `laeuft('c1')` schon), anderer `aboMemo()` liest neu. `termineFragebogenStub.test.js`: Stub liefert `[]` und trägt die Marke (Block C löscht diese Datei mit dem Stub).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Abo-Baustein (Kernversion) und Platzhalter fuer die Bestellpruefung“
### Task B6: Checkout-Route, Teil 1 – Vorprüfungen in Spec-Reihenfolge
**Files:** Modify `server/routes/funktionen.js` (`POST /news/checkout` Z. 137–181 wird `POST /:key/checkout`; `fail` Z. 35–40 gibt zusätzlich `err.body` weiter), `server/services/featureRegistry.js` (`featureSperre`). Create `server/services/featureOrder.js`, `test/funktionenCheckout.test.js`; Modify `test/kundenansichtGuard.test.js` (Z. 118: `['POST','/api/funktionen/termine/checkout']` in die Negativliste).
**Consumes:** B1 `featureConfig`/`oeffentlicheAnsicht`/`featureHaken`, B5 `aboMemo`, `bestellpruefung`, `ladeEinstellung` (`termine/kontext.js:75`), `termineFuerKunde` (`schalter.js:22`), `assertOwnProject`.
**Produces:** Vertrag der Route `POST /api/funktionen/:key/checkout`, Body `{ projectId, zustimmung: { version, haken: [id, …] } }` (News zusätzlich wie heute `{ projectId, consent: true }`, gilt nur für Funktionen mit genau einem Haken-Item) **oder** `{ projectId, fortsetzen: true }` („Zahlung fortsetzen“ aus der Statuskarte: die Zustimmungsprüfung (3) entfällt **nur**, wenn das Dokument nach (4) `ordered` ist, sonst 400 `zustimmung_fehlt`; Antwort und Nachweisregel in B7; enthält der Body beides, gilt `zustimmung`); `featureOrder.js`: `pruefeZustimmung(feature, eingabe) ⇒ { akzeptiert: [id] }` (wirft 400 `zustimmung_fehlt`, 409 `fassung_veraltet` mit `err.body.consent = oeffentlicheAnsicht(key).consent`), `aboMeldung(feature, grund) ⇒ string` (exportiert aus `featureOrder.js`; **die** Quelle der Abo-Hinweistexte, Du-Form, Akkusativ aus `feature.mails.formen.akk`; in den Wortlauten steht „den Terminplaner“ für `formen.akk`: `kein_abo` „Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.“, `gekuendigt` „Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.“, `nicht_aktiv` „Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.“, `unbekannt` „Das Abo konnte gerade nicht geprüft werden.“; Block C und E definieren keine eigenen Wortlaute), `ladeEntwurf(featureId) ⇒ { einstellung, fragebogen }` (`ladeEinstellung` + tolerantes Parsen von `termineConfig.fragebogen`, fehlt/kaputt ⇒ `null`), `orderDeps = { aboMemo }` (Test-Einhängepunkt), `featureSperre(key, email) ⇒ string | null` (in `featureRegistry.js`; `featureHaken(key).verfuegbar`, bei `false` die `unavailableMessage`; Standard-Haken `verfuegbar`, `bestellpruefung` und `unterlagenBereit` (Standard `true`) werden hier gefüllt). **Prüfreihenfolge, jede Stufe ohne Seiteneffekt:** (1) `req.session.viewAs` ⇒ 403 „In der Kundenansicht kann nicht bestellt werden.“; (2) `featureConfig(key)` fehlt ⇒ 404 „Unbekannte Funktion“; (3) Zustimmungen vollständig und Fassung == Serverfassung (400/409 wie oben); bei `fortsetzen: true` ohne `zustimmung` übersprungen; (4) `projectId` Text ⇒ sonst 400 „Bitte wähle ein Projekt.“; `assertOwnProject` ⇒ sonst 403; bei `fortsetzen: true` danach: `getFeatureDoc` nicht `ordered` ⇒ 400 `zustimmung_fehlt`; (5) `featureSperre` ⇒ 409 `nicht_verfuegbar`; (5b) `featureHaken(key).unterlagenBereit()` falsch ⇒ 503 `unterlagen_fehlen` „Die Vertragsunterlagen sind gerade nicht abrufbar. Bitte versuch es später noch einmal oder schreib uns im Chat.“ (Standard immer bereit; F8 hängt `featureLegalReady` ein, vor jeder Stripe-Sitzung); (6) `featureHaken(key).bestellpruefung({ customerId, projectId, feature })` (der Kontext enthält **kein** `abo`) ⇒ nicht leer ⇒ 409 `bestellung_offen` mit `body.offen` (Standard für `termine`: `getFeatureDoc` → `ladeEntwurf(doc.$id)`, ohne Dokument `einstellung`/`fragebogen` = `null`, dann `bestellpruefung({ einstellung, fragebogen, feature, mitAbo: false })` aus `termine/fragebogen.js`, **ohne** `abo`); (7) `requiresWartungsAbo`: `aboMemo().bestellbar(customerId)` ⇒ nicht ok ⇒ 409 mit `code` und `body.grund` = `kein_abo` | `gekuendigt` | `nicht_aktiv` (ohne Präfix, derselbe Code wie in Block C) und Text aus `aboMeldung`; Lesefehler ⇒ 500 mit generischem Text (fail-closed). **Regel für den Abo-Punkt (h) des Gates (verbindlich für B6 und C2):** der Checkout wendet (h) nicht an; der Standard-Haken ruft mit `mitAbo: false` und ohne `abo`, und auch der überschreibbare Haken `config.features.termine.bestellpruefung` bekommt kein `abo`. Die Abo-Stufe (7) ist damit die **einzige** Quelle der Checkout-Codes `kein_abo` | `gekuendigt` | `nicht_aktiv` und bleibt erreichbar: ein Kunde ohne Abo mit vollständigem Fragebogen bekommt 409 `kein_abo`, nie `bestellung_offen` mit Schritt `bestellen`; mit unvollständigem Fragebogen kommt zuerst `bestellung_offen` (ohne Eintrag `bestellen`, die Abo-Stufe wird nicht gelesen). Nur die Vorschau (`GET /api/termine/einstellungen`, C4a) übergibt `abo` und zeigt (h) als offenen Punkt.
- [ ] Tests (`funktionenCheckout.test.js`, jede Ablehnung prüft zusätzlich: `st.checkoutSessions.size === 0`, kein `featureConsents`-Dokument, kein `portalFeatures`-Dokument): „Kundenansicht ⇒ 403“ (Cookie `kundenansichtCookie()`, Testanwendung ohne Wächter, gültiger Body); „Wächter sperrt zusätzlich“ (`kundenansichtGuard.test.js`: die vorhandene Schleife `writeRoutes(MOUNTS)` trifft `/api/funktionen/x/checkout` automatisch ⇒ 403 `view_blocked`; Negativliste um den Termine-Pfad ergänzt); „unbekannter Schlüssel“ (`analyse`, `__proto__`, `nope` ⇒ 404); „Zustimmung“: fehlt/leer ⇒ 400 `zustimmung_fehlt`; ein Pflicht-Haken fehlt ⇒ 400; unbekannte Haken-ID ⇒ 400; falsche Fassung ⇒ 409 `fassung_veraltet` mit `consent.version` == Serverfassung und allen Item-Texten; `consent: true` bei Termine (vier Haken) ⇒ 400, bei News ⇒ weiter; „Projekt“: ohne `projectId` 400, fremd (`p5` von `c2`) 403; „Schalter“: `mode 'off'` ⇒ 409 „Der Terminplaner ist noch nicht verfügbar.“, `pilot` mit fremder Adresse ⇒ 409, News unabhängig vom Modus; **„Reihenfolge“**: `config.features.termine.bestellpruefung` auf `async () => [{ schritt: 'zeiten', text: 'Zeiten bestätigen' }]` und `orderDeps.aboMemo` auf einen Zähler ⇒ 409 `bestellung_offen` mit `offen[0].schritt === 'zeiten'` und Zähler **0**; danach Pruefung `[]` ⇒ Zähler 1; der Haken erhält ein Objekt ohne `abo`-Eigenschaft (`'abo' in arg === false`); „Abo-Gründe“ (Testaufbau laut Kopf, `seedFixtures`-Falle): `c3` (kein Abo) und `c2` (Hosting) ⇒ `kein_abo`, Kündigung vorgemerkt ⇒ `gekuendigt`, `past_due` ohne Kündigung ⇒ `nicht_aktiv`, `past_due` mit Kündigung ⇒ `gekuendigt`, Lesefehler ⇒ 500 ohne Rohtext; `nicht_aktiv`-Text == der volle Wortlaut oben (endet auf „bestellen.“); mit leerer Prüfung (`[]`, Stub oder echtes Gate) und Kunde ohne Abo ⇒ 409 `kein_abo`, nicht `bestellung_offen`; „Unterlagen“: `config.features.termine.unterlagenBereit = async () => false` ⇒ 503 `unterlagen_fehlen`, Zähler 0, Reihenfolge (5) → (5b) → (6) im Reihenfolge-Test; „fortsetzen“: `{ projectId, fortsetzen: true }` bei `off` oder ohne Dokument ⇒ 400 `zustimmung_fehlt`, kein Dokument entstanden; bei fremdem Projekt ⇒ 403; „News braucht kein Abo“ (`c3` bestellt News weiter nach Teil 2).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Checkout je Funktion: Vorpruefungen in der Reihenfolge der Spezifikation“
### Task B7: Checkout-Route, Teil 2 – Sperre, Zustandsprüfung, Nachweis vor der Weiterleitung
**Files:** Modify `server/routes/funktionen.js` (Rest der Route, `openOrderSession` Z. 121–135 mit Meldung aus dem Register), `server/services/siteFeatures.js` (`markOrdered` Z. 208–226: Meldungen aus dem Register, `consentText` aus den Haken-Texten), `server/services/featureRegistry.js` (Standard-Haken `belehrung`, `BELEHRUNG_PLATZHALTER`), `test/funktionenRoutes.test.js` (Schema `featureConsents`, neue News-Fälle), `test/funktionenCheckout.test.js`.
**Consumes:** B2 `createFeatureCheckout`, B4 `speichereZustimmung`/`zustimmungFuerSitzung`/`zustimmungsfassungen`/`nachweisTexte`, B6.
**Produces:** Ablauf unter `withLocks(['feature-order:<customer>:<project>'])` (Eingangszeit `klickAt = new Date().toISOString()` **vor** der Sperre): (a) `getFeatureDoc`: `setup`/`active` ⇒ 409 `feature.orderedConflictMessage`, `disabled` ⇒ 409 `feature.disabledMessage`; (b) `ordered` ⇒ `openOrderSession` (offene Sitzung wiederverwenden, `expired` ⇒ neu, `complete` oder Stripe nicht erreichbar < 24 h ⇒ 409); bei Body `fortsetzen: true` (ohne `zustimmung`, „Zahlung fortsetzen“) wird eine offene Sitzung **nur** zurückgegeben, wenn `zustimmungFuerSitzung(session.id)` einen Nachweis kennt (dann weder zweite Stripe-Sitzung noch zweiter Nachweis); fehlt der Nachweis oder bräuchte es eine neue Sitzung (`expired`), antwortet die Route 409 `zustimmung_fehlt` ohne URL, und die Bestellseite zeigt das Formular, in dem der Kunde erneut zustimmt; (c) neue Sitzung: `createFeatureCheckout(customer, { projectId, key, consentAt: klickAt })`; (d) `zustimmungFuerSitzung(session.id)` leer ⇒ `speichereZustimmung({ …, stripeSessionId: session.id, fassungen: zustimmungsfassungen(feature), texte: nachweisTexte(feature, { belehrung: (await featureHaken(key).belehrung?.()) ?? null }), ip: req.ip, userAgent: req.get('user-agent'), konto: 'Name <E-Mail>' (max. 255), at: klickAt })` — **auch** bei Wiederverwendung einer offenen Sitzung, falls für sie noch kein Nachweis liegt; (e) erst danach `markOrdered({ customerId, projectId, key, sessionId, consent: { text, at: klickAt, ip, userAgent } })`; (f) Antwort `{ url }`. Gespeichert werden **die Serverfassungen und -texte**, nie Texte aus dem Client. Scheitert (d), gibt es **keine** URL und kein `ordered` (die offene, nicht erreichbare Stripe-Sitzung läuft nach 24 h ab; kein Stripe-Storno nötig). **Belehrungs-Haken:** B7 füllt den Standard-Haken `featureHaken('termine').belehrung()` in `featureRegistry.js` mit dem Platzhalter `BELEHRUNG_PLATZHALTER = 'ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.'` (exportiert; F7 ersetzt ihn durch `widerrufsbelehrungFunktion()`). Damit hat der Nachweis ab B7 immer einen Eintrag `belehrung` (`haken false`, direkt vor `knopf`) und die Bestellseite (C2, C8, C11) zeigt vor dem Knopf einen Belehrungstext; `news` hat keinen `belehrung`-Haken (kein solcher Eintrag, Verhalten unverändert).
- [ ] Tests, **Review Focus #5** (`funktionenCheckout.test.js`, Termine): „Nachweis steht vor der Weiterleitung“ – gültiger Aufruf ⇒ 200 `{ url }`; genau **ein** `featureConsents`-Dokument mit `stripeSessionId` == der Stripe-Sitzung, `texte` (geparst) deepEqual `nachweisTexte(featureConfig('termine'), { belehrung: (await featureHaken('termine').belehrung?.()) ?? null })` in Reihenfolge, `fassungen` == `zustimmungsfassungen(…)`, `textHash` == SHA-256 über die gespeicherten Texte, `ip`/`userAgent`/`konto` gesetzt; **ein Zeitpunkt**: `acceptedAt` == `metadata.consentAt` der Stripe-Sitzung == `portalFeatures.consentAt`, zwischen den vor/nach dem Aufruf genommenen Zeiten; „ohne Nachweis keine URL“ – `aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500 })` ⇒ 500, Antwort ohne `url`, Dokument nicht `ordered` (fehlt oder `off`), `orderedAt` leer; „Belehrung im Nachweis“ – `texte` endet auf `[…, { id: 'belehrung', text: BELEHRUNG_PLATZHALTER, haken: false }, { id: 'knopf', … }]`, der News-Nachweis hat keinen `belehrung`-Eintrag; „Client-Texte zählen nicht“ – Body mit zusätzlichem `texte: ['manipuliert']` ⇒ gespeichert werden die Registertexte; „Fassungswechsel“ – `config.features.termine.consent.version` zur Laufzeit ändern ⇒ alter Body 409, neuer Body 200 mit neuer Fassung im Nachweis; „Wiederverwendung“ – zweiter Aufruf bei offener Sitzung ⇒ gleiche URL, keine zweite Stripe-Sitzung, **kein** zweites Nachweis-Dokument; Nachweis der Sitzung per Test gelöscht ⇒ zweiter Aufruf legt einen neuen an, bevor die URL zurückkommt; „Zahlung fortsetzen“ (`{ projectId, fortsetzen: true }`, Review Focus 5) – Dokument `ordered` mit Nachweis zur offenen Sitzung ⇒ 200 mit **derselben** URL, keine zweite Stripe-Sitzung, kein zweites `featureConsents`-Dokument; Nachweis per Test gelöscht ⇒ 409 `zustimmung_fehlt`, keine URL, danach derselbe Aufruf mit `zustimmung` ⇒ 200, Nachweis wieder da (ein Dokument); abgelaufene Sitzung mit `fortsetzen` ⇒ 409 `zustimmung_fehlt`, mit `zustimmung` ⇒ neue Sitzung; „abgelaufene Sitzung“ ⇒ neue Sitzung und neuer Nachweis (zwei Dokumente, zwei Sitzungs-IDs); „parallele Klicks“ (`Promise.all` ×2) ⇒ eine Sitzung, ein Nachweis, beide 200 mit gleicher URL; Zustände: `setup`/`active` ⇒ 409 `orderedConflictMessage`, `disabled` ⇒ 409 `disabledMessage`, `complete` ⇒ 409, nirgends neue Sitzung; Datenbankfehler beim `markOrdered` (`failNext` PATCH `portalFeatures`) ⇒ 500 mit „Bestellung konnte gerade nicht gestartet werden“, ohne Rohtext. News (`funktionenRoutes.test.js`, Schema um `featureConsents` ergänzt, alle bisherigen Tests unverändert grün): zusätzlich „News schreibt auch einen Nachweis“ (`texte` == die `consent.items` von `config.features.news` + Knopf, `fassungen { zustimmung: config.features.news.consent.version, leistungsbeschreibung: config.features.news.leistungsbeschreibung.version }` – gegen `config` statt Literale, damit Block F2 die Fassung ohne Testbruch anhebt –, `portalFeatures.consentText` weiter == `config.funktionen.consentText`, Widerrufs-Anker über `paidFeatureOrders().vertragAt`).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Checkout je Funktion: Zustimmungsnachweis vor der Weiterleitung, Zustaende aus dem Register“
### Task B8: Ticket aus dem Register (Deckel, Notfall-Details) – alleinige Besitzerin von `createFeatureTicket`
**Files:** Modify `server/services/siteFeatures.js` (`markPaid` Z. 243–270), `server/services/ticketAdmin.js` (`createFeatureTicket` Z. 75–109), `server/services/featureRegistry.js` (`ticketTexte`, `minimaleTicketDetails`, `anleitungsLink`, `kuerze`, Konstanten `TICKET_TOPIC_MAX = 128`, `TICKET_DETAILS_MAX = 7000`, `LANGE_DETAILS = 1000`). Create `server/services/termine/ticketDetails.js` (Stub), `test/featureTicket.test.js`. Der bestehende Test `webhook_retry_setzt_ticketlose_einrichtung_fort` (`test/funktionenRoutes.test.js:321-345`) bleibt unverändert grün. Block D ändert `createFeatureTicket` **nicht** mehr (D5 entfällt).
**Consumes:** B1.
**Produces:** `ticketTexte(ctx) ⇒ Promise<{ topic, details, fallbackDetails }>` mit `ctx = { feature, doc, project, customer }` (`feature` = Register-Eintrag, `doc` = `portalFeatures`-Dokument, `project` = `websiteProjects`-Dokument, `customer` best effort oder `null`; `now` setzt Block D4 selbst im Rumpf): `topic` auf `TICKET_TOPIC_MAX` gekürzt, `details` auf `TICKET_DETAILS_MAX` (Schnitt an der letzten Zeilengrenze, eine einheitliche Schlusszeile „… gekürzt, vollständig im Ticketsystem unter Kunden → Funktionen“); wirft `feature.ticket.details` oder liefert es Leeres/Nicht-Text, gilt `fallbackDetails`. `kuerze(text, max, { anZeile = false, endzeile = '' })` kürzt **surrogatsicher** (nie ein halbes Zeichenpaar). `minimaleTicketDetails(ctx)` = Kopfzeilen (Funktion, Projekt, Domain = `liveDomain || previewUrl || subdomain`, Kunde, `siteKey`) + `Verweis: ${anleitungsLink(doc.customerId, doc.projectId)}`. `anleitungsLink(customerId, projectId)` = `${config.womsBaseUrl}/customers/<id>?tab=funktionen&anleitung=<encodeURIComponent(projectId)>` (der Reiter `funktionen` existiert in `W/src/lib/customerTabs.js`; das WOMS-Panel öffnet sich mit `anleitung=` automatisch, D10; B9b (T16 `anleitung_url`) und D3 nutzen dieselbe Funktion). `createFeatureTicket({ …, topic, details, fallbackDetails })`: `topic` und `details` bleiben **optional** mit den heutigen News-Festwerten als Rückfall (direkte Aufrufer und die Analyse bleiben wirkungsgleich; `markPaid` übergibt immer die Registertexte), kürzt beides selbst; **Wiederholung nur**, wenn `createDocument` mit `status 400` scheitert **und** `fallbackDetails` übergeben wurde **und** `details.length > LANGE_DETAILS` (dann **einmal** mit `fallbackDetails`, gleiche `woid`); kurze Details (News, Standardtext) und jeder andere Fehler (5xx, Netz: unklar, ob das Ticket entstand, sonst Doppel-Tickets) werden ohne Wiederholung weitergereicht; scheitert auch der Fallback, wird der **erste** Fehler geworfen (`markPaid` bleibt dann `setup` ohne `ticketId`, Stripe wiederholt – unverändert). `markPaid` lädt den Kunden (best effort) und übergibt Register-`topic`/`details`/`fallbackDetails`. `termine/ticketDetails.js`: `termineTicketDetails(ctx) ⇒ Promise<string>` = `minimaleTicketDetails` + Zeile „Einrichtung bezahlt (149 €), Zeit nicht auf das Stundenkontingent anrechnen.“ (Kopfkommentar `// STUB (Block B) – ersetzt Task D4`; **Block D4 ersetzt den Inhalt** durch `einbauanleitung(…).kurz`, Signatur und `ctx` bleiben).
- [ ] Tests (`featureTicket.test.js`; die bisherigen News-Tests in `siteFeatures.test.js`/`funktionenRoutes.test.js` bleiben unverändert grün): „News-Ticket byte-gleich“ (`topic === 'News-Pinnwand einbauen – Bäckerei'`, `details` == die 6 Zeilen des heutigen Defaults, einschließlich `Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)`); „Termine-Ticket aus dem Register“ (`topic` beginnt `Terminplaner einbauen – `, `details` enthält `siteKey` und `…?tab=funktionen&anleitung=<projectId>`); „Deckel“ (Projektname 255 Zeichen ⇒ `topic.length <= 128`; 100 × „😀“ als Projektname ⇒ gültige Zeichenkette ohne halbes Surrogat; `details`-Funktion liefert 20 000 Zeichen ⇒ gespeichert ≤ 7000, Schnitt an einer Zeilengrenze, endet auf der Schlusszeile; genau 7000 Zeichen unverändert); „Notfall“: `details`-Funktion wirft ⇒ Ticket entsteht mit `minimaleTicketDetails` (Geld ohne Spur ausgeschlossen); „Wiederholung nur bei langen Details“: Details mit 5000 Zeichen und `aw.failNext({ collection: 'workorders', method: 'POST', status: 400 })` einmal ⇒ zweiter Versuch mit `fallbackDetails` (Kopf + Link, ohne Snippet), genau ein Dokument in `workorders`, gleiche `woid`; zweimal 400 ⇒ `markPaid` wirft den **ersten** Fehler, Dokument `setup` ohne `ticketId`, eine spätere Wiederholung legt genau ein Ticket an; dieselbe Lage mit `status: 503` ⇒ wirft ohne Wiederholung, `workorders` unverändert (kein Doppel-Ticket); **kurze** Details (News/Standard) + einmal 400 ⇒ wirft, keine Wiederholung (das ist der bestehende Test `webhook_retry_setzt_ticketlose_einrichtung_fort`: Webhook 500, danach 200); „Termine über `markPaid`“ (lange Kurzfassung + 400 ⇒ Ticket mit Minimal-Details, `created: true`, K15/T16 gehen raus); „Analyse unberührt“ (`createFeatureTicket` mit eigenem `topic`/`details` ohne `fallbackDetails` ⇒ ein `createDocument`-Aufruf, Text unverändert, Deckel nur bei Überlänge); „Defaults“ (ohne `topic`/`details` ⇒ der heutige News-Text).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktions-Ticket: Text aus dem Register, Laengendeckel, Notfall-Details und gezielte Wiederholung“
### Task B9a: Mails K15 und K16 je Funktion (News-Wortlaut zuerst eingefroren)
**Files:** Modify `server/mail-templates/K15 Bestellung Funktion.html`, `K16 Funktion bereit.html`; Modify `server/services/siteFeatures.js` (K15-Versand Z. 530–546 in `onFeaturePaid`, `runActivation` Z. 578–593, `portalUrl` Z. 406); Modify `test/mailTemplates.test.js` (Platzhalter-/Blocklisten-Tests für K15 und K16). Create `test/featureMails.test.js` (K15/K16-Teil; B9b ergänzt T16 und T19).
**Consumes:** B1 (`mails.*`, `formen`, `featureConfig`).
**Produces:** Platzhalter/Blöcke (Kopfkommentar der Vorlagen, Reihenfolge wie hier): **K15** `kunde_name, projekt, funktion, funktion_gen, funktion_akk, funktion_nom, funktion_pron, preis, preis_hinweis, zeit, danach, portal_url, rechnung_url`, Block `rechnung`; Betreff „Deine Bestellung: {{funktion}}“; die News-Texte werden 1:1 aus den Platzhaltern erzeugt (z. B. „Wir richten {{funktion_akk}} jetzt für Dein Projekt ein“, „Sobald {{funktion_nom}} auf Deiner Website eingebaut ist, schalten wir {{funktion_pron}} im Kundenportal frei … {{danach}}“, Preiszeile „<strong>{{preis}}</strong> ({{preis_hinweis}})“, Knopf „Zum Kundenportal“ ⇒ `{{portal_url}}`); der Spec-Block „kein Abo“ entfällt zugunsten des Registerwerts `preisHinweis`. **K16** `kunde_name, projekt, funktion, funktion_dein, funktion_nom, bereit, knopf, portal_url`; Betreff „{{funktion_dein}} ist bereit“. Aufrufstellen: K15/K16 füllen alle Werte aus `featureConfig(doc.featureKey)`, `eur(config.newsSetup.amountCents)` wird `eur(catalog.amountCents)` je `feature.product`, `portal_url` = `${publicBaseUrl}/dashboard.html#${returnHash}`. **Anhänge:** K15 geht in B9a **ohne** Anhänge raus; die PDF-Anhänge der Termine-Bestellung hängt F8 direkt in `onFeaturePaid` an (`featureLegalAttachments(doc.featureKey)`, lazy importiert; es gibt keinen Registerhaken `mails.anhaenge`).
- [ ] **Zuerst Charakterisierung** (vor dem Umbau der Vorlagen): `renderMail` mit den heutigen News-Werten für K15 (mit/ohne `rechnung`) und K16 ausführen, `subject` und `text` als Literale in `test/featureMails.test.js` einfrieren („News-Mails bleiben wörtlich“, u. a. „Wir richten die News-Pinnwand jetzt für Dein Projekt ein.“, „(brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo)“, „Danach kannst Du Beiträge im Portal schreiben.“, „Deine News-Pinnwand ist bereit“, Knopf „Beitrag schreiben“, Link `…/dashboard.html#news`). Danach Vorlagen umbauen; derselbe Test bleibt grün. Weitere Tests: „Termine-Mails“ (K15/K16 mit Termine-Werten enthalten weder „News“ noch „Beitr“; Betreff „Deine Bestellung: Terminplaner“, „Dein Terminplaner ist bereit“; Preis „149,00 €“; K15-Link `…#termine`; Knopf „Termine öffnen“); „Platzhalter-/Blocklisten exakt“ (`loadTemplate('K15')` und `loadTemplate('K16')`: `.placeholders`/`.blocks` deepEqual den Listen oben, die Schleifen in `mailTemplates.test.js` decken Rendern mit/ohne Blöcke ab); „K15 ohne Anhänge“ (Termine-Bestellung in B9a ⇒ K15 trägt keinen Anhang, der Versand ruft keinen Registerhaken).
- [ ] FAIL → implementieren → PASS → `npm test` (alle vorhandenen Mail-Tests in `funktionenRoutes.test.js` Z. 150, 166, 199, 238, 339, 371 grün) → Commit „Mails K15/K16 je Funktion (News-Wortlaut eingefroren)“
### Task B9b: Mail T16 je Funktion und neue Vorlage T19 Erstattung fällig
**Files:** Modify `server/mail-templates/T16 Funktion bestellt.html`; Create `server/mail-templates/T19 Erstattung faellig.html` (Layout von T16, Footer wie T16); Modify `server/services/siteFeatures.js` (`teamNotice` Z. 456, `unmatchedNotice` Z. 472), `test/mailTemplates.test.js` (Kopfkommentar Z. 8–9: „K15, K16, T16 Funktionen“ um „T19 Erstattung fällig“ ergänzen; `EXPECTED` Z. 11: `[...ids('G', 8), ...ids('K', 21), ...ids('T', 19), 'T20']` (A10a1 hat `'T20'` angehängt, nicht überschreiben; E3 hebt nur die K-Zahl); `TEAM_FOOTER` Z. 489 + `'T19'`; Blockliste/Platzhalter-Tests für T16 und T19), `test/featureMails.test.js` (aus B9a: T16/T19-Teil dazu).
**Consumes:** B1 (`mails.*`, `featureConfig`), B8 (`anleitungsLink`), B9a (`featureMails.test.js`).
**Produces:** Platzhalter/Blöcke (Kopfkommentar der Vorlagen, Reihenfolge wie hier): **T16** `kunde_name, email, projekt, funktion, woid, sitekey, sitzung, zustand, anleitung_url, gesundheit_text`; Blöcke `zahlung_fehlgeschlagen, ungeklaert, kein_abo, bereits_aktiv, anleitung, gesundheit`; Betreff „Neue Funktion bestellt: {{funktion}}“; Text `kein_abo`: Zahlung da und Ticket angelegt, aber kein laufendes Wartungs-Abo, Freischaltung bleibt gesperrt, endet das Abo vor Fertigstellung ⇒ volle Erstattung (T19); Text `bereits_aktiv`: „Zahlung eingegangen, aber die Funktion war schon {{zustand}}. Es wurde kein Ticket angelegt und nichts eingerichtet. Bitte prüfen: Einbau nachholen oder erstatten. Sitzung: {{sitzung}}“; `anleitung` = Knopf „Im Ticketsystem öffnen“ ⇒ `{{anleitung_url}}` (= `anleitungsLink(doc.customerId, doc.projectId)` aus B8, also `…?tab=funktionen&anleitung=<projektId>`, nur bei `mails.anleitung`); `gesundheit` = Warnzeile „Gesundheitsbereich prüfen: {{gesundheit_text}}“ (Spec 3.5; B deklariert Platzhalter und Block, sie bleiben bis D4 aus; **Block D4 füllt sie** in `teamNotice` aus `gesundheitsWarnungen`). Der T16-Betreff „…: News-Pinnwand“ ist in `test/funktionenRoutes.test.js` Z. 150, 339, 371 gepinnt und bleibt für News so. **T19** (Betreff „[Abo] Erstattung fällig: {{funktion}}-Einrichtung“, passt zur Betreff-Regel für T-Vorlagen in `mailTemplates.test.js` Z. 28): `kunde_name, email, projekt, funktion, betrag, sitzung, woid, rechnung_url`, Blöcke `zahlung_nach_ende, ticket_offen, rechnung`; **B9b ist die einzige Besitzerin von T19** (Block E legt sie nicht noch einmal an; E5 und E10 senden sie über `meldeErstattung`, B10b). Inhalt: der Kunde hat gezahlt (Betrag, Sitzung, Rechnung), das Wartungs-Abo lief nicht mehr bzw. endete vor der Freischaltung ⇒ volle Erstattung von Hand in Stripe (keine automatische Rückzahlung); Ticket mit Vermerk schließen (die Freischaltung beim Schließen schaltet dann nicht frei, weil das Dokument `disabled` ist); `zahlung_nach_ende`: „Die Zahlung ist erst nach dem Abo-Ende eingegangen, es wurde kein Ticket angelegt“; `ticket_offen`: das Ticket `{{woid}}` ist noch offen. Aufrufstellen: T16 füllt alle Werte aus `featureConfig(doc.featureKey)` (`unmatchedNotice` ohne bekannten Schlüssel: `funktion = 'Funktion'`, Rest leer); `eur(...)` und `portal_url` wie in B9a. Exportiert wird `meldeErstattung` erst in B10b.
- [ ] **Zuerst Charakterisierung** (vor dem Umbau der Vorlage): `renderMail` mit den heutigen News-Werten für T16 (Normal, `zahlung_fehlgeschlagen`, `ungeklaert`) ausführen, `subject` und `text` als Literale in `test/featureMails.test.js` einfrieren („News-Mails bleiben wörtlich“: Betreff „Neue Funktion bestellt: News-Pinnwand“ und die Texte der drei Fälle). Danach die Vorlage umbauen; derselbe Test bleibt grün. Weitere Tests: „Termine-T16“ (enthält weder „News“ noch „Beitr“; Betreff „Neue Funktion bestellt: Terminplaner“); „Platzhalter-/Blocklisten exakt“ (`loadTemplate('T16')` und `loadTemplate('T19')`: `.placeholders`/`.blocks` deepEqual den Listen oben, `mailTemplates.test.js`-Schleifen decken Rendern mit/ohne Blöcke ab); „T19 rendert beide Fälle“ (nur `zahlung_nach_ende`, nur `ticket_offen`, mit Rechnungsknopf; `problems` leer; Footer „Ticketsystem“); „T16 gesundheit“ (Block aus ⇒ kein Warntext, Block an mit `gesundheit_text` ⇒ der Text steht in der Mail; `anleitung_url` trägt `&anleitung=<projektId>`).
- [ ] FAIL → implementieren → PASS → `npm test` (alle vorhandenen Mail-Tests in `funktionenRoutes.test.js` Z. 150, 166, 199, 238, 339, 371 grün) → Commit „Mail T16 je Funktion, neue Vorlage T19 Erstattung faellig“
### Task B10a: Zahlungseingang in jedem Zustand 1 – `markPaid` meldet den Fall, Meldemarker (Review Focus #1)
**Files:** Modify `server/services/siteFeatures.js` (`markPaid` Z. 243–270); Tests `test/siteFeatures.test.js` (neue `fall`-Tests).
**Consumes:** B1, B8 (Ticket), A11 (`portalFeatures.zahlungMeldung` im Schema; `activate()` lässt es unberührt, nur E8 setzt es beim Zurücksetzen zurück).
**Produces:** `markPaid(…) ⇒ { doc, created, fall }` mit `fall ∈ null | 'bereits_aktiv' | 'abgeschaltet' | 'abo_ende'`. Unter der vorhandenen Sperre `feature-paid:<sessionId>` gilt für ein per Sitzung gefundenes Dokument: `ordered` oder fortsetzbares `setup` ⇒ wie bisher (`created: true`, `fall: null`); `off` ⇒ unverändert `created: false`, `fall: null` (die Übernahme `adoptPaidSession` bleibt zuständig); **jeder andere Zustand** (`active`, `disabled`, `setup` mit Ticket): hat das Dokument `purchaseStatus !== 'paid'`, wird die Zahlung **einmalig verbucht** (`purchaseStatus: 'paid'`, `unlockedByPurchase: true`, `stripeInvoiceId`, `invoiceUrl`, `adminNote` neuester Eintrag `<ISO> System: Zahlung eingegangen (Sitzung …), nichts eingerichtet`, 300 Zeichen) und im **eigenen Feld `zahlungMeldung`** der noch zu meldende Fall vermerkt (`active` und `setup` mit Ticket ⇒ `'bereits_aktiv'`; `disabled` mit `disabledReason === 'abo_ende_vor_einrichtung'` ⇒ `'abo_ende'`; sonstiges `disabled` ⇒ `'abgeschaltet'`). `fall` ist der Wert von `zahlungMeldung`, solange er einer dieser drei ist; nach der erfolgreichen Meldung steht dort `'gemeldet'` (B10b) und `fall` ist `null`. Eine **Wiederholung** des Ereignisses (Stripe-Retry, `completed` + `async_payment_succeeded` bei SEPA) bucht deshalb nichts doppelt (`purchaseStatus` bleibt `'paid'`), liefert den Fall aber erneut, solange die Meldung noch nicht raus ist. Eine normal bezahlte Bestellung (Übergang aus `ordered`) hat `zahlungMeldung ''`: ihr Folgeereignis liefert `fall: null`, auch wenn das Dokument inzwischen `active` ist. `purchaseStatus` wird nie mit Meldezuständen überladen (andere Stellen, z. B. die Widerrufsprüfung, lesen es).
- [ ] Tests (`siteFeatures.test.js`, `markPaid` direkt): `fall` je Zustand (`active` ⇒ `bereits_aktiv`, `disabled`/`team` ⇒ `abgeschaltet`, `disabled`/`abo_ende_vor_einrichtung` ⇒ `abo_ende`, `setup` mit Ticket ⇒ `bereits_aktiv`), jeweils `created: false`, `purchaseStatus 'paid'`, `unlockedByPurchase true`, Rechnung gespeichert, `zahlungMeldung` == Fall; zweiter Aufruf ⇒ nichts doppelt verbucht (`adminNote` unverändert), `fall` bleibt gesetzt, bis `zahlungMeldung` auf `'gemeldet'` steht, danach `null`; normal bezahltes Dokument (`ordered` ⇒ `setup` ⇒ `active`), Folgeereignis ⇒ `fall: null` und `zahlungMeldung ''`; `off` ⇒ `created: false`, `fall: null`, Dokument unverändert; zwei parallele Aufrufe ⇒ einmal verbucht.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Zahlungseingang: markPaid meldet den Fall und merkt ihn im Dokument“
### Task B10b: Zahlungseingang in jedem Zustand 2 – Meldungen T16/T19, `meldeErstattung` (Review Focus #1)
**Files:** Modify `server/services/siteFeatures.js` (`onFeaturePaid` Z. 515–552, `teamNotice`, neu `meldeErstattung`, `meldeZahlungFall`, `zahlungsNotiz`; `customerOf` und `projectName` werden exportiert). Create `test/featurePaid.test.js`.
**Consumes:** B1, B5 `laeuft`, B8 (Ticket), B9a (K15), B9b (T16-Vorlage, T19), B10a.
**Produces:** `onFeaturePaid`: `created` ⇒ K15 + T16 wie bisher; zusätzlich `feature.requiresWartungsAbo` und `await laeuft(customerId)` **nach** der Verbuchung (Lesefehler ⇒ Log, kein Block, nie 500) falsch ⇒ T16-Block `kein_abo`. `fall` `'bereits_aktiv'`/`'abgeschaltet'` ⇒ `meldeZahlungFall(doc, fall, { customer })` = **nur** T16-Block `bereits_aktiv` (`zustand` „freigeschaltet“ / „in Einrichtung“ bzw. „abgeschaltet“, `woid` leer), `console.error` mit Sitzungs-ID, keine K15. `fall` `'abo_ende'` ⇒ **nur** `meldeErstattung(doc, { customer, nachAboEnde: true })`, kein Ticket, keine K15. `meldeErstattung(doc, { customer = null, sitzung = doc.stripeSessionId, nachAboEnde = false } = {}) ⇒ Promise<{ sent: boolean, schonGemeldet?: boolean }>` sendet T19 (`betrag` = `priceLabel`, `woid`, `rechnung_url`, Blöcke `zahlung_nach_ende`, wenn `nachAboEnde` gesetzt ist oder `doc.zahlungMeldung === 'abo_ende'` (Zahlung nach dem Abo-Ende, so auch wenn der Abgleich vor dem Webhook-Versand zuerst meldet), `ticket_offen`, wenn `doc.ticketId` gesetzt ist (kein Ticket und keine späte Zahlung ⇒ keiner der beiden Blöcke), `rechnung`) und ist **die einzige** Stelle für T19: **Block E5 ruft sie aus dem Abgleich** (`nachAboEnde: false`), `erstattungMelden` o. Ä. gibt es nicht. **Idempotenz (`meldeErstattung` und `meldeZahlungFall`):** unter `withLocks(['feature-meldung:' + doc.$id])` (nie innerhalb einer `feature-paid:*`-Sperre aufrufen, `withLocks` ist nicht wiederbetretbar) das Dokument **frisch lesen**; `zahlungMeldung === 'gemeldet'` ⇒ `{ sent: false, schonGemeldet: true }` ohne Versand; sonst senden und **erst bei `sent === true`** `zahlungMeldung: 'gemeldet'` schreiben (auch wenn dort vorher ein offener Fall stand: die Wiederholung sendet kein zweites T19). `sendAdminNotice` wirft nie; die Funktionen werfen deshalb nicht selbst. **`onFeaturePaid` wirft** bei den Fällen `bereits_aktiv`/`abgeschaltet`/`abo_ende`, wenn weder `sent` noch `schonGemeldet` zutrifft: der Webhook antwortet 500, Stripe wiederholt, `markPaid` liefert den noch offenen Fall erneut (B10a) und der nächste Versuch sendet genau einmal. Die Mailfehler der Normalpfade (K15, T16 beim `created`) bleiben wie bisher gefangen. Annahme für E: der Abgleich Schritt 1b meldet T19 nur für **bezahlte** (`setup`) Dokumente; ein unbezahltes `ordered` wird nur abgeschaltet, die spätere Zahlung meldet dieser Task.
- [ ] Tests, **Review Focus #1** (Testaufbau laut Kopf; für „Abo läuft“ überschreibt der Test `sub_h` von `c1` mit `wartungDoc`; `featurePaid.test.js`, Termine, echter Bestellweg über die Route oder `markOrdered`, Webhook über `postWebhook`; Hilfsprüfung `spur()`: `Tickets + Team-Mails ≥ 1` und `Tickets ≤ 1` nach jedem Fall): (1) `ordered` + Abo läuft ⇒ `setup`, ein Ticket „Terminplaner einbauen – …“, K15 „Deine Bestellung: Terminplaner“ an den Kunden, T16 genau einmal **ohne** `kein_abo`-Text; (2) `ordered` + Abo beendet (`canceled`) ⇒ `setup`, Ticket, K15, T16 mit `kein_abo`-Text; (3) `laeuft` scheitert (`failNext` auf `subscriptions`) ⇒ Webhook 200, Ticket, K15, T16 ohne `kein_abo`; (4) Staff aktiviert die `ordered`-Bestellung (`adminActivate`), dann Zahlung ⇒ Dokument bleibt `active`, **kein** Ticket, **keine** K15, T16 mit `bereits_aktiv`/„freigeschaltet“ genau einmal, `purchaseStatus 'paid'`, `unlockedByPurchase true`, Rechnung gespeichert, `adminNote` beginnt mit der System-Zeile, `console.error` mit Sitzungs-ID, `zahlungMeldung 'gemeldet'`; zweite Zustellung desselben Ereignisses **und** zwei parallele Zustellungen (`Promise.all`) ⇒ weiterhin genau ein T16; (5) Team schaltet die `ordered`-Bestellung ab, dann Zahlung ⇒ `disabled` bleibt (`disabledAt` unverändert), T16 „abgeschaltet“ einmal, Wiederholung stumm; (6) `disabled` mit `disabledReason: 'abo_ende_vor_einrichtung'` (geseedet) + Zahlung ⇒ T19 „[Abo] Erstattung fällig: Terminplaner-Einrichtung“ einmal mit Block `zahlung_nach_ende`, **kein** T16, kein Ticket, keine K15, Wiederholung stumm (auch `async_payment_succeeded` nach `completed`); (7) Stripe-Sitzung läuft ab (`off`), Zahlung kommt doch ⇒ Übernahme, `setup`, ein Ticket, K15 + T16 je einmal; (8) Neubestellung nach Ablauf, die **alte** Sitzung zahlt ⇒ Übernahme in die laufende Bestellung, ein Ticket; die **neue** Sitzung zahlt danach ⇒ T16 „ungeklärt“ (bestehendes Verhalten), kein zweites Ticket; (9) SEPA: `completed` ohne `paid` ⇒ nichts, `async_payment_succeeded` ⇒ wie (1), `async_payment_failed` ⇒ T16 `zahlung_fehlgeschlagen` mit „Terminplaner“; (10) **Versandfehler in den Fallpfaden:** T16/T19 scheitert beim Versand (SMTP wirft) ⇒ Webhook **500** (Stripe wiederholt), die Zahlung ist gebucht (`purchaseStatus 'paid'`, `adminNote`-Spur), `zahlungMeldung` noch offen; die Wiederholung (Versand geht) ⇒ genau eine Mail, `zahlungMeldung 'gemeldet'`, weitere Wiederholungen stumm; K15/T16 beim normalen `created` scheitern weiterhin gefangen (Webhook 200); (11) News unverändert (alle bisherigen Fälle in `funktionenRoutes.test.js` grün, zusätzlich News + Staff-Aktivierung + Zahlung ⇒ T16 `bereits_aktiv`, die heute stille Lücke); (12) `meldeErstattung` direkt (der Aufruf aus Block E5): bezahltes `setup`-Dokument ⇒ ein T19 mit Block `ticket_offen`, `zahlungMeldung 'gemeldet'`, zweiter und paralleler Aufruf ⇒ `schonGemeldet`, nie zwei Mails; offener Fall `abo_ende` + Aufruf des Abgleichs + Webhook-Wiederholung ⇒ insgesamt genau ein T19; Mail scheitert ⇒ `{ sent: false }`, kein Marker, kein Wurf.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Zahlungseingang in jedem Zustand: nie Geld ohne Spur, nie doppelt (T16/T19)“
### Task B11: Freischaltung über das Register (`activateClosedTickets`, `freischaltbar`) – alleinige Besitzerin
**Files:** Modify `server/services/siteFeatures.js` (`activateClosedTickets` Z. 359–387; `findActiveBySiteKey` unverändert), `server/services/featureRegistry.js` (`ticketFreischaltungKeys`, Standard-Haken `freischaltbar` für `termine`, mit dynamischen Importen von `kontext.js` und `aboStatus.js`), `test/siteFeatures.test.js` (**Z. 235**, Test „activateClosedTickets bleibt bei News (TP2)…“: er pinnt heute, dass nur `news` geöffnet wird; er wird auf „Funktionen mit `ticketFreischaltung`; News verhält sich unverändert“ umgestellt). Create `test/featureFreischaltung.test.js`. Block D6 ändert `activateClosedTickets` **nicht** mehr.
**Consumes:** B1, B5 `laeuft`, B9a (K16 je Funktion), `ladeEinstellung`.
**Produces:** `ticketFreischaltungKeys() ⇒ string[]` (Register-Schlüssel mit `ticketFreischaltung: true`); `activateClosedTickets({ now, customerId })` filtert `Query.equal('featureKey', ticketFreischaltungKeys())` statt fest `news` und ruft **unter der Sperre `feature-activate:<id>` nach dem frischen Lesen** den optionalen Haken `featureHaken(key).freischaltbar(doc) ⇒ Promise<boolean>` (der Standard für `termine` darf einen zweiten Parameter `{ abo }` annehmen, D6); `false` oder Fehler ⇒ Dokument bleibt `setup` (Log; **einmal** ein `adminNote`-Eintrag „Freischaltung wartet: <Grund>“, **kein** Mailversand alle 5 Minuten – der T16-Hinweis `kein_abo` kam bei der Zahlung (B10b), T19 vom Abgleich (E5)). Termine-Standardhaken: Einstellung vorhanden (`ladeEinstellung(doc.$id)`) **und** `laeuft(doc.customerId)` (Spec 4.5: Aktivierung bleibt gesperrt, bis ein Abo läuft). Block D6 zieht diese Prüfung nach `termine/freischaltung.js` (`freischaltHindernis(doc, { abo })`) und der Standardhaken delegiert dorthin; die Tests dieses Tasks bleiben dabei unverändert grün. `adminActivate` (Staff-Knopf) bleibt Sache von Block D/E9/WOMS.
- [ ] Tests (Testaufbau laut Kopf, `seedFixtures`-Falle: Wartungs-Abo für `c1` durch Überschreiben von `sub_h`): „News unverändert“ (bisherige Tests grün, **Z. 235 wie oben angepasst**); „Termine: Ticket geschlossen + Einstellung + Abo ⇒ `active`, K16 „Dein Terminplaner ist bereit“ mit Knopf „Termine öffnen“ und Link `…#termine`“; „kein Abo ⇒ bleibt `setup`, `adminNote` enthält „Freischaltung wartet“, zweiter Lauf schreibt keinen zweiten Eintrag und sendet keine Mail; Abo kommt zurück ⇒ nächster Lauf aktiviert und sendet K16“; „keine Einstellung ⇒ bleibt `setup`“; „Abo-Lesefehler ⇒ bleibt `setup`, `runActivation` wirft nicht, andere Dokumente werden trotzdem bearbeitet“; „Abo endet zwischen Listenlesen und Sperre (der Haken meldet beim frischen Lesen `false`) ⇒ bleibt `setup`“; „Ticket `Cancelled` ⇒ bleibt `setup`“; „`customerId`-Filter“; „Hook gilt nur für seine Funktion“ (News-Dokument im selben Lauf wird aktiviert); „parallele Läufe“ (`runActivation()` und `runActivation({ customerId })` ⇒ ein K16); „Dokument anderer Funktion mit `setup` (z. B. `analyse`) bleibt unberührt“.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Freischaltung ueber das Register mit Pruefung von Einstellung und Abo“
### Task B12: Register-Ansicht im Kundenportal (`GET /api/funktionen`, News-Seite)
**Files:** Modify `server/routes/funktionen.js` (`GET /` Z. 88–114), `public/funktionen.js` (`PRICE` Z. 9, `newsTile` Z. 81–85, `initNews` Z. 220, `renderBooking` Z. 261–297), `test/funktionenRoutes.test.js` (`liste_zustaende` Z. 60ff.), `test/funktionenTiles.test.js`.
**Consumes:** B1 `oeffentlicheAnsicht`, B7 (Checkout-Vertrag).
**Produces:** `GET /api/funktionen` ⇒ wie bisher plus `features: { news: <Ansicht>, termine: <Ansicht> }` (`oeffentlicheAnsicht`, auch wenn `termine.verfuegbar` falsch ist: nur öffentliche Bestelltexte); `setupEstimate`/`consentText` bleiben als News-Alias. Zusätzlich `ustHinweis: config.ustHinweis` (die einzige Stelle für den Preishinweis auf Bestellseiten; F2/F8 ergänzen ihn nicht noch einmal). Die Bestellseite (Block C8) bekommt dieselben Registerwerte über `bestellungAnsicht()` (aus `oeffentlicheAnsicht`, C2) in `GET /api/termine/einstellungen → bestellung`, nicht aus dieser Liste, und schickt `zustimmung: { version, haken }` an den Checkout; „Zahlung fortsetzen“ schickt `{ projectId, fortsetzen: true }` (B6/B7: die Route liefert die offene Sitzung nur, wenn sie den Nachweis dazu kennt, sonst 409 `zustimmung_fehlt` und die Seite zeigt wieder das Formular). `public/funktionen.js`: Preis und Zustimmungstext der News-Seite aus `features.news` (`priceLabel`, `consent.items[0].text`, `consent.knopf`), Rückfall auf die bisherigen Konstanten, wenn `features` fehlt; News sendet weiter `{ projectId, consent: true }` (Alias B6), das Verhalten bleibt gleich; der Wortlaut „inklusive Mehrwertsteuer“ (Z. 272) bleibt hier stehen (Korrektur in Block F).
- [ ] Tests: `liste_zustaende` + „`features.news.priceLabel === '30 €'`, `features.termine.priceLabel === '149 €'`, `ustHinweis === config.ustHinweis`, Items vollständig, kein Feld mit Funktion/`ticket`/`mails`, `features.termine` auch bei `verfuegbar: false`“; `funktionenTiles.test.js`: „Rückfall ohne `features`“ (die bisherigen `render`-Hilfen liefern nur `{ projects }`, Tests unverändert grün) und „Preis aus dem Register“ (`features.news.priceLabel: '31 €'` ⇒ Kachel-Knopf „Einrichten lassen · 31 €“); statische Prüfung „News postet weiter `consent: true` an `/api/funktionen/news/checkout`“.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktionen-Seite: Preis und Bestelltexte aus dem Register“
---
**Abschlussprüfung Block B (Controller):** `npm test` komplett grün (`test/previewDevRouter.test.js` bei Ausreißer einzeln wiederholen); `command grep -rn "STUB (Block" server` zeigt genau zwei Treffer (`server/services/termine/fragebogen.js`, `server/services/termine/ticketDetails.js`), `aboStatus.js` trägt die Marke `// KERNVERSION (Block B) – Block E baut aus`; Blöcke C, D und E entfernen ihre Marken. Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): `docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)'` (`analyticsContract.test.js` importiert `../analytics/src` und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: `git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob'` bleibt leer. Festverdrahtetes News ist nur noch im Register: `command grep -rn "dashboard.html#news\|config.newsSetup\|DISABLED_MESSAGE" server` trifft ausschließlich die Definitionen in `server/config.js`, den Alias-Export in `server/services/siteFeatures.js` und den Katalog in `stripeBilling.js` (`catalogFor`); `config.funktionen` wird nur noch in `server/routes/funktionen.js` (GET-Alias) gelesen. Die Beschriftungen „News-Pinnwand“ in `routes/abo.js` (Widerruf, Block F/4.7), `retention.js` und `routes/admin/funktionenAdmin.js` sind **nicht** Teil von B.
**Übergaben an andere Blöcke (verbindlich für deren Tasks):**
- **A:** `disable()` idempotent mit `reason`, das Schema (`portalFeatures.disabledReason`, `aboEndeMailAt`, `zahlungMeldung`) im neuen Skript `feature-setup.mjs` (A11; B3 ergänzt dort `featureConsents`) und die explizite Liste statt der Wildcard liefert A; B berührt `activateClosedTickets` (B11), nicht `disable`/`activate`. B-Tests seeden `disabledReason` nur.
- **E:** erweitert `aboStatus.js` (Modify, Marke `KERNVERSION` entfällt; gleiche drei Funktionen, `aboMemo` je Lauf, dazu `aboLage` und `aboMemo().lage` aus E1; die Reihenfolge der Gründe `kein_abo` → `gekuendigt` → `nicht_aktiv` steht hier fest); `aboMeldung` bleibt in `featureOrder.js` (E definiert keine eigene); ruft `meldeErstattung(doc, { customer, nachAboEnde: false })` (B10b, einzige T19-Stelle) aus dem Abgleich 1b (nur für bezahlte `setup`-Dokumente) und schickt K22/K23; `unlockedByPurchase === true` ist nach B10a auch bei „Zahlung nach Staff-Aktivierung“ gesetzt (diese Dokumente unterliegen dann dem Abgleich); E8 setzt beim Zurücksetzen `zahlungMeldung` auf `''`.
- **C:** ersetzt den Stub `termine/fragebogen.js` (`bestellpruefung`, `BRANCHEN`, …) und darf `ladeEntwurf` in `featureOrder.js` durch seinen Lader ersetzen (Signatur `(featureId) ⇒ { einstellung, fragebogen }` bleibt); die Bestellseite `funktion-bestellen.js` liest `bestellung` aus `GET /api/termine/einstellungen` (Registerwerte über `oeffentlicheAnsicht`), schickt `{ projectId, zustimmung: { version, haken } }`, zeigt bei 409 `fassung_veraltet` die neue Fassung aus `body.consent` und bei `bestellung_offen` die Punkte aus `body.offen`; „Zahlung fortsetzen“ sendet `{ projectId, fortsetzen: true }` (409 `zustimmung_fehlt` ⇒ Formular); der Rücksprung liefert `#termine?bestellt=1&projekt=<id>`, die Abbruch-Adresse `#termine?projekt=<id>`; die Abo-Codes sind `kein_abo` | `gekuendigt` | `nicht_aktiv`. Der T16-Warnblock `gesundheit` (Spec 3.5) ist in B9b deklariert und wird von Block D4 gefüllt (nicht von C).
- **D:** ersetzt den Inhalt von `termine/ticketDetails.js` (`termineTicketDetails(ctx)`, `ctx = { feature, doc, project, customer }`, `now` im Rumpf), füllt den T16-Block `gesundheit` (D4), zieht die Freischalt-Prüfung nach `termine/freischaltung.js` (D6; B11 bleibt Besitzerin von `activateClosedTickets`), blendet im WOMS „Aktivieren (ohne Zahlung)“ für Termine in `ordered`/`setup` aus (der Fall `bereits_aktiv` aus B10b bleibt als Netz); `anleitungsLink` kommt aus `featureRegistry.js` (B8), T16 und Ticket verlinken also schon mit `&anleitung=<projektId>`; `createFeatureTicket` ändert D nicht (D5 entfällt).
- **F:** ersetzt alle `ENTWURF`-Platzhalter im Termine-Eintrag (`consent`-Texte und -Fassung bei bereits festen IDs, `leistungsbeschreibung`, `avv`, `widerruf`, `mails.danach/bereit` ausgenommen: das ist die Mailrunde des Inhabers; `mails.preisHinweis` bleibt aus `config.ustHinweis`; die K15-Anhänge hängt F8 direkt in `onFeaturePaid` an (`featureLegalAttachments(doc.featureKey)`), nicht über einen Registerhaken), korrigiert die News-Texte (§ 356 Abs. 5 Nr. 2, Wertersatz, § 19 UStG in `consent.items[0].text`, `mails.preisHinweis`, `public/funktionen.js:272`) und **hebt dabei `consent.version` an** (der Nachweis speichert die Fassung; die Pins in B1/B7/B9a/B9b lesen `config`, F2 passt nur den Einfrier-Test in `featureMails.test.js` an); Widerruf je Funktion (4.7: `withdrawFeature`-Frist je Funktion, K04/T09 `{{produkt}}`/`{{preis}}`, `widerrufen.html`) ist nicht in B, B4 liefert dafür `featureKey` und `vertragAt` aus `paidFeatureOrders` (F9 nutzt genau diese); der `todo`-Test aus B1 muss mit F1/F7 grün werden.
---
## Block E: Abo-Kopplung
Ein gemeinsamer Abo-Baustein (`aboStatus.js`) trägt Analyse und Terminplaner. Der tägliche Abgleich `gleicheAboAb` (Spec 6 und 4.6) schaltet den Terminplaner ab, wenn das Wartungs-Abo endet, meldet fällige Erstattungen (T19), holt K22 nach und schaltet innerhalb der Frist wieder ein (K23); die Löschung nach 30 Tagen prüft vorher frisch und setzt das Dokument auf `off` zurück. **Reihenfolge:** A → B → E → C → D → F. B bringt `aboStatus.js` als Kernversion (B5), A11 `disabledReason`/`disable()`; E1 erweitert die Kernversion, E3–E12 folgen in dieser Reihenfolge. E11 und E12 liegen vor C und D: C4a/C4b/C10b/D2 bzw. D9/D10 setzen auf deren Stand auf und erhalten ihn. Der Abgleich liest nur den Appwrite-Spiegel `subscriptions`, nie Stripe; nichts läuft gegen Produktion.
**Festlegungen des Blocks** (gelten für E4–E10): stehen im Kopf, Abschnitt „Grundlagen der Blöcke“.
---
### Task E1: Abo-Baustein `aboStatus.js` ausbauen, Analyse darauf umgestellt
**Files:** Modify `server/services/aboStatus.js` (B5-Kernversion; die Marke `// KERNVERSION (Block B) – Block E baut aus` entfällt), `test/aboStatus.test.js` (B5, **erweitern statt neu anlegen**); Modify `server/services/analyse.js` (Z. 11: Import `ACTIVE_STATUSES, getCurrentSubscription` entfällt, neu `laeuft` aus `./aboStatus.js`; Z. 43–46 `hasWartungsAbo` wird `(id) => laeuft(id, { altbestand: false })`), `test/analyse.test.js` (+1 Test; bestehende Zeilen bleiben unberührt).
**Consumes:** `getCurrentSubscription` (`stripeBilling.js:178`), `ACTIVE_STATUSES` (`:24`), `listContractEvents` (`contractEvents.js:120`).
**Produces** (Namen und Signaturen aus dem Plankopf unverändert, Zusätze mit „+“; ein privater Lader `lage(customerId, { altbestand })` liefert `{ laeuft, art, endedAt, abo }`, **alle** Prädikate bauen darauf, damit Bestell-Gate, Freischaltung, T16 `kein_abo` und Abgleich nie verschieden urteilen):
- `laeuft(customerId, { altbestand = true } = {}) ⇒ Promise<boolean>`: aktuelles Abo ist ein Wartungs-Abo mit Status ∈ `ACTIVE_STATUSES` (active, trialing, past_due; vorgemerkte Kündigung zählt noch als laufend). Wirft bei jedem Lesefehler, gibt bei einem Fehler nie `false`. **Zwei Regeln:** `altbestand: true` (Vorgabe, Terminplaner) ist die großzügige Regel: Produktschlüssel `productKey || 'wartung'` wie `routes/abo.js:68` **und** die Wechsel-Regel unten; `altbestand: false` (Analyse) ist die heutige Regel unverändert: `=== 'wartung'` (ein Spiegel ohne `productKey` zählt nicht), keine Ereignisabfrage (Spec: „`analyseEligibility` ändert sein Verhalten nicht“). Der Unterschied betrifft nur Spiegel ohne `productKey` und den Wechsel-Widerruf; `stripeBilling.js:246` schreibt `productKey` immer, nur Altbestand könnte es fehlen (Produktion ungeprüft ⇒ offene Frage).
- `bestellbar(customerId) ⇒ Promise<{ ok: boolean, grund?: 'kein_abo' | 'gekuendigt' | 'nicht_aktiv' }>`: **Reihenfolge der Gründe (einmal für alle Blöcke, wie B5):** nicht `laeuft` (großzügige Regel inkl. Wechsel-Regel) ⇒ `kein_abo`; `cancelAtPeriodEnd || cancelAt` (Muster `routes/abo.js:96`) ⇒ `gekuendigt`; Status ≠ `active` (trialing, past_due) ⇒ `nicht_aktiv`; sonst `{ ok: true }`. Wirft bei Lesefehler.
- `aboMemo() ⇒ { laeuft(id), bestellbar(id), lage(id) }`: je Aufruf von `aboMemo()` ein eigener Speicher (Promise je `customerId`, eine Ablehnung bleibt für diesen Lauf stehen). Nie für Prüfungen unter einer Sperre.
- + `aboLage(customerId) ⇒ Promise<{ laeuft: boolean, art: 'laeuft' | 'wechsel_widerrufen' | 'hosting' | 'beendet', endedAt: string | null }>` (= der private Lader mit `altbestand: true`, kein eigener Zweig): **Wechsel-Regel:** sagt der Spiegel „Wartung läuft“ und hat das jüngste `wechsel`-Ereignis dieses Abos Status `widerrufen` ⇒ `laeuft: false`, `art: 'wechsel_widerrufen'`. Grund: Der Widerruf eines Wechsels (Hosting→Wartung) setzt den Spiegel nicht zurück, das tut das Team von Hand (Kommentar `routes/abo.js:992`); ohne diese Regel schaltete der tägliche Abgleich einen per Sofortpfad (E9) beendeten Terminplaner am nächsten Tag wieder ein, **und** ein Kunde mit widerrufenem Wechsel dürfte bestellen (149 €) und freischalten, der Abgleich schaltete am Folgetag ab und löste T19 aus. Ereignisse werden nur gelesen, wenn der Spiegel „Wartung läuft“ sagt (und nur bei `altbestand: true`). Ohne laufendes Wartungs-Abo: `art: 'hosting'`, wenn das aktuelle Abo ein laufendes Hosting-Abo ist, sonst `'beendet'`; `endedAt` = `endedAt || canceledAt` des Abos oder `null`.
- `aboMeldung` gibt es hier **nicht** (die einzige Quelle der Abo-Hinweistexte ist `featureOrder.js`, B6).
- [ ] Tests `aboStatus.test.js` (Testaufbau und `seedFixtures`-Falle laut Kopf; die B5-Fälle bleiben; Mock: `subscriptions` und `contractEvents` mit `schemaKeys`, Dokumente aus `subscriptionDoc`/`wartungDoc`/`hosting2Doc` in `test/support/fixtures.js`):
- `laeuft`: Wartung `active`/`trialing`/`past_due` ⇒ true; `canceled`/`unpaid`/`incomplete_expired` ⇒ false; Hosting aktiv ⇒ false; kein Dokument ⇒ false; aktives Abo mit `cancelAtPeriodEnd: true` ⇒ **true**; zwei Dokumente (altes `canceled`, neues `active`) ⇒ true.
- **Altbestand-Unterschied:** Dokument ohne `productKey` ⇒ `laeuft(id)` true, `laeuft(id, { altbestand: false })` false.
- **Lesefehler:** `aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 })` ⇒ `laeuft`, `bestellbar`, `aboLage` lehnen ab (nie `false`/`{ ok: false }`).
- `bestellbar`: Tabelle (active ohne Kündigung ⇒ ok; `cancelAtPeriodEnd`, nur `cancelAt` in der Zukunft ⇒ `gekuendigt`; trialing, past_due ⇒ `nicht_aktiv`; canceled, Hosting, kein Abo ⇒ `kein_abo`; trialing **und** past_due **mit** Kündigung ⇒ `gekuendigt`, Kündigung vor Status).
- `aboLage`: `art` je Fall (`laeuft`, `hosting` mit aktivem Hosting-Abo, `beendet` mit `endedAt`, `wechsel_widerrufen` mit `wechsel`-Ereignis `widerrufen`; ein **neueres** `wechsel`-Ereignis `erledigt` hebt es auf ⇒ `laeuft`); bei `beendet` und `hosting` keine Ereignis-Abfrage (Anfragen zählen per Wrapper um `globalThis.fetch` wie `mitEinwurf` in `termineJobs.test.js:234`).
- **Wechsel-Regel in allen Prädikaten:** Spiegel Wartung `active` + jüngstes `wechsel`-Ereignis `widerrufen` ⇒ `laeuft(id)` false, `bestellbar(id)` `{ ok: false, grund: 'kein_abo' }`, `aboLage.art 'wechsel_widerrufen'`; `laeuft(id, { altbestand: false })` bleibt true und fragt **keine** Ereignisse ab. Integration: Checkout (B6) ⇒ 409 `kein_abo`, kein Dokument entstanden; `activateClosedTickets` (B11) mit Ticket `Closed` ⇒ Dokument bleibt `setup`; `adminActivate` (E9) ⇒ 409.
- `aboMemo`: drei Aufrufe für denselben Kunden ⇒ eine Abfrage auf `subscriptions`; zweiter Kunde ⇒ eine weitere; ein neuer `aboMemo()` liest wieder; eine Ablehnung wird im selben Memo nicht neu gelesen (keine zusätzliche Anfrage).
- `analyse.test.js` neuer Test „Altbestand ohne productKey: Analyse bleibt streng (`kein_abo`), `aboStatus.laeuft` großzügig“ (pinnt den Unterschied).
- [ ] Beweis „Analyse unverändert“: `node --test test/analyse.test.js test/analyseRoutes.test.js test/analyseView.test.js test/funktionenRoutes.test.js test/publicForms.test.js` vor der Umstellung grün, danach mit **keiner geänderten bestehenden Testzeile** grün (`git diff test/analyse*.test.js` zeigt nur den neuen Test); `grep -n "ACTIVE_STATUSES\|getCurrentSubscription" server/services/analyse.js` leer.
- [ ] FAIL → `aboStatus.js` ausbauen (ein privater Lader `lage(customerId, { altbestand })` über `getCurrentSubscription` und `listContractEvents`; die Prädikate darauf; Memo je Aufruf in einer `Map` von Promises) → PASS → `npm test` → Commit „Abo-Baustein aboStatus.js: Wechsel-Regel in laeuft/bestellbar, aboLage; Analyse nutzt ihn“
### Task E2: Anzeige von Abschaltgrund und Löschfrist (`featureView`, `frist.js`)
**Files:** Create `server/services/termine/frist.js`; Modify `server/services/siteFeatures.js` (`featureView` Z. 116); Tests `test/siteFeatures.test.js`, `test/funktionenTermine.test.js` (Z. 158). **Nicht Teil von E2** (liegt in A11): idempotentes `disable()`, `DISABLED_REASONS`, `activate()`-Reset, `disableCustomerFeatures(reason)`, `customerAdmin.js` `kunde_geloescht`, die Schema-Attribute (`feature-setup.mjs`) und deren Tests.
**Consumes:** A11 (`disabledReason`, `disabledAt`, `aboEndeMailAt`, `reason`).
**Produces:** `featureView` liefert zusätzlich `disabledReason`, `disabledAt` und `loeschungAm` (Berliner Tag `'JJJJ-MM-TT'`, nur bei `state === 'disabled'` und vorhandenem `disabledAt`, sonst `null`; bei anderen Zuständen alle drei `null`). `frist.js`: `LOESCHFRIST_TAGE = 30`, `loeschzeit(disabledAt) ⇒ Date | null` (`disabledAt` + 30 × 24 h, dieselbe Rechnung wie `jobs.js:50`), `loeschTag(disabledAt) ⇒ 'JJJJ-MM-TT' | null` (Berliner Kalendertag über `ausUtc` aus `zeit.js`); nur Importe aus `zeit.js`/`util.js` (kein Kreis mit `siteFeatures.js`). F4/F5 prüfen ihre Fristsätze gegen `LOESCHFRIST_TAGE`. Sperre und Mails kommen später (E4ff.).
- [ ] Tests: `featureView`/`GET /api/funktionen` (`funktionenTermine.test.js:158`): disabled+`abo_ende` mit `disabledAt: '2026-10-04T23:30:00.000Z'` ⇒ `loeschungAm === '2026-11-04'` (Berliner Tag, nicht der UTC-Tag `2026-11-03`); `team` ⇒ Grund `team` und ebenfalls ein Datum; active ⇒ alle drei Felder `null`; Altbestand ohne Grund ⇒ `disabledReason: null`. `frist.js`: `loeschTag('2026-10-04T23:30:00.000Z') === '2026-11-04'`; `loeschzeit` = exakt 30 × 24 h später; ungültig/leer ⇒ `null`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Abschaltgrund und Loeschfrist in der Funktionsansicht (featureView, frist.js)“
### Task E3: Mailvorlagen K22, K23 und Block in K02/K03
**Files:** Create `server/mail-templates/K22 Terminplaner beendet.html`, `K23 Terminplaner laeuft wieder.html` (Layout wörtlich aus `K19 Analyse beendet.html`, Kopfkommentar im Format der vorhandenen); **T19 legt B9b an**, nicht E; Modify `server/mail-templates/K02 Kuendigungsbestaetigung.html`, `K03 Eingang Kuendigung.html`, `server/routes/abo.js` (K02-Aufruf Z. 469: Block `funktionen: isWartung(sub)`; K03-Aufruf Z. 937: `funktionen: !storage && isWartung(sub)`), `test/mailTemplates.test.js` (Z. 8–11: nur `ids('K', 23)`; die T-Liste bleibt `ids('T', 19)` plus `'T20'` aus B9b/A10a1, nicht überschreiben; Kopfkommentar um „K22, K23 Terminplaner beendet/läuft wieder“ ergänzen).
**Consumes:** `renderMail`/`loadTemplate` (`mailTemplates.js`). Texte sind Entwurf für die Mail-Überarbeitung des Inhabers (Spec 2); nur Platzhalter und Blöcke sind verbindlich.
**Produces:**
- **K22** Betreff `Dein Terminplaner wurde beendet – {{projekt}}`; Platzhalter `kunde_name, projekt, enddatum, loeschdatum, preis, portal_url`; Blöcke `abo_ende` (Wartungs-Abo beendet), `hosting` (Hosting-Abo reicht nicht), `vor_einrichtung` (Abo endete vor der Einrichtung, Einrichtung wird voll erstattet, nennt `{{preis}}`), `daten` (Kasten „Was jetzt gilt“, nur für die ersten beiden: bis `{{loeschdatum}}` ansehen und CSV-Export im Portal, Gäste können ihre Termine weiter absagen, bestehende Termine bleiben eingetragen und Erinnerungen an Gäste laufen bis zur Löschung weiter, **bitte Gäste selbst informieren**, ab `{{loeschdatum}}` Löschung, **Rückkehr:** läuft bis dahin wieder ein Wartungs-Abo, schalten wir ihn automatisch und ohne Gebühr wieder ein; nach der Löschung braucht es neuen Fragebogen und neue `{{preis}}`). Du-Form.
- **K23** Betreff `Dein Terminplaner läuft wieder – {{projekt}}`; Platzhalter `kunde_name, projekt, portal_url`; keine Blöcke.
- **T19** gehört B9b (eine Vorlage: Platzhalter `kunde_name, email, projekt, funktion, betrag, sitzung, woid, rechnung_url`, Blöcke `zahlung_nach_ende, ticket_offen, rechnung`); E4/E5/E10 senden sie ausschließlich über `meldeErstattung` (B10b).
- **K02/K03:** neuer Block `funktionen` (im Kopfkommentar „Optionale Blöcke“ ergänzen; in K03 **innerhalb** von `abo`): „Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm. Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren.“ Sie-Form, weil K02/K03 selbst Sie-Form sind (Abweichung vom Du-Grundsatz, offene Frage).
- [ ] Tests (`mailTemplates.test.js`): `templateIds()` = G01–G08, K01–K23, T01–T20 (T19/T20 aus B9b/A10a1); die schleifenweisen Tests (Kopfkommentar = `<title>`, deklarierte = verwendete Platzhalter/Blöcke, Rendern ohne Reste mit allen Blöcken an/aus, WEBklar-Logo) laufen automatisch für die zwei neuen IDs K22 und K23. Neu: `K22` je Variante mit nur diesem Block an (`abo_ende`: „Wartungs-Abo“ und kein „erstatten“; `hosting`: „Hosting-Abo“; `vor_einrichtung`: enthält den Wert von `preis` und „erstatten“, **kein** Löschdatum-Kasten; `daten` zeigt `loeschdatum` und den Satz zu den Gästen); K22/K23 ohne `Sie`/`Ihr` (Du-Form); K23 nennt `projekt` und führt zu `portal_url`; `K02`/`K03`: `funktionen` an ⇒ Text enthält „Terminplaner“, aus ⇒ nicht; K03 mit `abo: false, funktionen: true` ⇒ Text fehlt (Speicher-Kündigung); bestehende K02-Hosting-Prüfung (`/Wartungs-Abo/` darf nicht vorkommen, Z. 388) bleibt grün.
- [ ] FAIL → Vorlagen schreiben, Aufrufstellen in `routes/abo.js` ergänzen → PASS → `npm test` → Commit „Mails K22, K23 und Hinweis auf das Ende der Funktionen in K02/K03“
### Task E4: `gleicheAboAb` Schritt 1 (aktive abschalten) und Schritt 2 (K22 nachholen)
**Files:** Create `server/services/termine/aboAbgleich.js`, `test/termineAboAbgleich.test.js`; Modify `server/services/siteFeatures.js` (`customerOf` und `projectName` aus Z. 420–435 exportieren statt ein drittes Mal zu kopieren; B10b hat sie schon exportiert, dann entfällt der Eintrag).
**Consumes:** E1 `aboMemo`, `aboLage`; A11 `disable({ reason })`; E2 `loeschzeit`, `loeschTag`; E3 K22; `listAllDocuments`, `withLocks`, `getDocument`/`updateDocument` (`appwriteAdmin.js`), `datumKurz` (`termine/zeit.js:120`), `config.features.termine.priceLabel` (Block B, „149 €“; Wert für `{{preis}}` in K22).
**Produces:** `gleicheAboAb(now) ⇒ Promise<{ geprueft, abgeschaltet, wiederEin, mails, ausgenommen, fehler }>` (wirft nie; nur Zählungen). `geprueft` = bewertete Dokumente (aktiv mit `unlockedByPurchase === true`, `ordered`/`setup`, `disabled` mit Grund `abo_ende*`); Pilot-/Kulanz-Dokumente zählen nicht dort, sondern in `ausgenommen` (aktive Dokumente mit `unlockedByPurchase !== true`: der Abgleich fasst sie nicht an, der Tagesbericht macht sie für den Inhaber sichtbar; Kanzleifrage Q7). Intern/exportiert für E5/E9: `meldeAboEnde(doc, { lage, now }) ⇒ Promise<boolean>` – **der** gemeinsame Melder für das Ende (Schritt 2 und der Sofortpfad E9 rufen nur ihn, nie `meldeErstattung` oder K22 einzeln). E4 baut ihn für den Grund `abo_ende` (K22 und Marker); E5 erweitert dieselbe Funktion um `abo_ende_vor_einrichtung` (T19, K22, Marker). Rückgabe `true` = nichts mehr offen (Marker gesetzt oder nichts zu melden), `false` = Versand gescheitert (der nächste Tag wiederholt).
Ablauf: **eine** `listAllDocuments(portalFeatures, [Query.equal('featureKey', 'termine'), Query.notEqual('state', 'off')])` (die vielen `off`-Entwürfe bleiben ungelesen; `notEqual` mit Einzelwert aus A2, der Rest der Filter im Code), gruppiert je `customerId`, ein `memo = aboMemo()`; je Kunde `lage = await memo.lage(id)` in `try/catch`. **Schritt 1:** Dokumente mit `state === 'active' && unlockedByPurchase === true` bei `!lage.laeuft`: `withLocks(['feature-admin:' + projectId])` ⇒ Dokument frisch lesen, noch `active` und `unlockedByPurchase === true`, **frische** `aboLage` noch `!laeuft` ⇒ `disable(id, { reason: 'abo_ende', adminNote: 'Abgeschaltet: Wartungs-Abo beendet' })`. **Schritt 2 (Nachlauf):** für jedes `disabled`-Dokument mit Grund `abo_ende` ohne `aboEndeMailAt` (auch die eben abgeschalteten) und weiter `!lage.laeuft`: `meldeAboEnde`: K22 an `customers.email` (Block `hosting`, wenn `lage.art` `hosting` oder `wechsel_widerrufen`, sonst `abo_ende`; `daten: true`), Werte `enddatum` = Berliner Tag von `disabledAt`, `loeschdatum` = `datumKurz(loeschzeit(disabledAt))`, `preis`, `portal_url` = `${publicBaseUrl}/dashboard.html#termine`; **erst nach Erfolg** `aboEndeMailAt = now`. Kunde fehlt oder ohne E-Mail ⇒ Marker setzen (kein Wiederholen), Log ohne Adresse. Läuft das Abo wieder, überspringt Schritt 2 (E6 übernimmt).
- [ ] Tests `termineAboAbgleich.test.js` (Aufbau wie `termineJobs.test.js`: Mock mit `portalFeatures`/`subscriptions`/`termineConfig`/`termineBookings`, `mailSink`, feste `JETZT`, Logs auf Gast-/Kundendaten prüfen, Umgebung per `useTestEnv({ appwriteUrl, stripeUrl: 'http://127.0.0.1:1' })`; Abo-Dokumente aus `test/support/fixtures.js` per dynamischem Import):
- **Abo beendet ⇒ abgeschaltet:** `status: 'canceled'` ⇒ Dokument `disabled`, `enabled false`, `disabledReason 'abo_ende'`, `disabledAt === JETZT`, Notiz „Abgeschaltet: Wartungs-Abo beendet“; Ergebnis `{ geprueft: 1, abgeschaltet: 1, wiederEin: 0, mails: 1, ausgenommen: 0, fehler: 0 }`; K22 an den Kunden (Betreff `Dein Terminplaner wurde beendet – <Projekt>`, Löschdatum = `disabledAt` + 30 Tage), `aboEndeMailAt === JETZT`. **Zweiter Lauf am Folgetag:** nichts mehr, `disabledAt` unverändert, keine zweite Mail (Frist wird nie verschoben).
- Kein Abo-Dokument ⇒ abgeschaltet; nur Hosting-Abo aktiv ⇒ abgeschaltet, K22 mit Block `hosting` („Hosting-Abo“).
- **Läuft weiter (nie abschalten):** `past_due`, `trialing`, `active` mit `cancelAtPeriodEnd: true` und `cancelAt` in 10 Tagen ⇒ Dokument bleibt `active`, 0 Mails; `unpaid`, `incomplete_expired`, `canceled` ⇒ abgeschaltet (Tabelle).
- **Lesefehler:** `failNext({ collection: 'subscriptions', method: 'GET', status: 500 })` ⇒ bleibt `active`, `fehler: 1`, keine Mail; derselbe Fehler erst bei der **frischen** Prüfung unter der Sperre (`after: 1`) ⇒ bleibt `active`; der folgende Lauf ohne Fehler schaltet ab. Fehler beim Lesen der Dokumentliste ⇒ `gleicheAboAb` wirft nicht (Ergebnis mit `fehler`), nichts geändert.
- **Pilot/Kulanz:** `unlockedByPurchase: false` und fehlend, Kunde ohne Abo ⇒ unverändert `active`, nicht in `geprueft`, `ausgenommen` zählt sie (2 bei beiden Fällen), keine Mail; `off`-Entwürfe werden nicht gelesen (`listCalls` zeigt `notEqual state off`).
- **Rennen:** zwischen Liste und Sperre kommt ein aktives Abo (Einwurf wie `mitEinwurf`, `termineJobs.test.js:234`) ⇒ frische Prüfung ⇒ bleibt `active`; zwischen Liste und Sperre schaltet das Team ab (`reason: 'team'`) ⇒ Grund bleibt `team`, `disabledAt` unverändert, kein K22; Sperre: hält der Test `feature-admin:<projectId>`, bleibt der Abgleich offen, bis sie frei ist.
- **Memo:** ein Kunde mit drei Projekten ⇒ genau eine Abfrage auf `subscriptions`, drei abgeschaltete Dokumente, drei K22 (je Projekt eine).
- **K22-Nachlauf:** Transport wirft ⇒ `disabled`, `aboEndeMailAt` leer, `fehler 1`, `mails 0`; nächster Tag ohne Fehler ⇒ K22 geht raus, Marker gesetzt, `disabledAt` und Löschdatum im Text unverändert; Kunde gelöscht/ohne E-Mail ⇒ kein Wurf, Marker gesetzt, am Folgetag nichts.
- Alt-`disabled` ohne Grund, `team`, `kunde_geloescht` ⇒ nie angefasst, nie K22.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Abo-Abgleich: aktive Terminplaner ohne Wartungs-Abo abschalten, K22 nachholen“
### Task E5: Schritt 1b (Abo endet vor der Einrichtung) und T19 über `meldeErstattung`
**Files:** Modify `server/services/termine/aboAbgleich.js`, `test/termineAboAbgleich.test.js`. (`siteFeatures.js` bleibt unberührt: es gibt **keine** eigene `erstattungMelden`.)
**Consumes:** E1–E4; B10b `meldeErstattung(doc, { customer, sitzung, nachAboEnde })` (**die** einzige T19-Stelle: Sperre `feature-meldung:<docId>`, Marker `zahlungMeldung === 'gemeldet'` erst nach `sent === true`, wirft nie); Plankopf/B11 `activateClosedTickets` nur als Prüfpunkt im Test.
**Produces:** Schritt 1b in `gleicheAboAb`: Dokumente mit `state ∈ {'ordered','setup'}` bei `!lage.laeuft` ⇒ unter `feature-admin:<projectId>` frisch lesen, noch `ordered`/`setup`, frische `aboLage` noch `!laeuft` ⇒ `disable(id, { reason: 'abo_ende_vor_einrichtung', adminNote: 'Abgeschaltet: Wartungs-Abo endete vor der Einrichtung' })`. Schritt 2 erweitert sich auf Grund `abo_ende_vor_einrichtung`; die Meldelogik liegt **in `meldeAboEnde`** (E4), nicht in der Schleife, damit der Sofortpfad E9 dasselbe tut. `meldeAboEnde` für `abo_ende_vor_einrichtung`: bei `unlockedByPurchase === true` **erst** `meldeErstattung(doc, { customer, nachAboEnde: false })` (T19 „[Abo] Erstattung fällig: Terminplaner-Einrichtung“; Aufruf **außerhalb** jeder `feature-admin`/`feature-paid`-Sperre), dann K22 mit Block `vor_einrichtung` (`daten: false`), dann Marker `aboEndeMailAt`; `sent === false` ohne `schonGemeldet` ⇒ Rückgabe `false`, `fehler += 1`, **weder K22 noch Marker** (der nächste Tag wiederholt T19 und K22 in dieser Reihenfolge). Ein **unbezahltes** `ordered`-Dokument (`unlockedByPurchase !== true`) wird nur abgeschaltet: **kein** T19, **kein** K22, **kein** Marker, kein Schreibzugriff (es ist nichts zu erstatten; kommt die Zahlung später, löst B10b/E10 T19 aus, und Schritt 2 sendet dann K22). Die offene Stripe-Sitzung wird nicht storniert (Spec 6 Schritt 1b). Das Ticket wird nicht angefasst.
- [ ] Tests:
- **Bezahlt, im Aufbau (`setup`, `unlockedByPurchase`, `stripeSessionId 'cs_test_1'`, Ticket vorhanden) und kein Abo ⇒** `disabled`, Grund `abo_ende_vor_einrichtung`; T19 **genau einmal** an `config.chatNotify.emails` (Betreff `[Abo] Erstattung fällig: Terminplaner-Einrichtung`, Text nennt Kunde, Projekt, `cs_test_1`, Rechnungslink, Ticketnummer), `zahlungMeldung === 'gemeldet'` und `purchaseStatus` weiter `paid`; K22 mit Block `vor_einrichtung` (Preis genannt, kein Löschkasten); das Ticket bleibt `Open` (Dokument in `workorders` unverändert); zweiter Lauf ⇒ weder T19 noch K22.
- **T19 schlägt fehl** (Transport wirft; `sendAdminNotice` fängt ab ⇒ `sent: false`) ⇒ `zahlungMeldung` bleibt leer, Dokument trotzdem `disabled`, `fehler ≥ 1`, weder K22 noch `aboEndeMailAt` bis dahin; nächster Lauf sendet T19 und danach K22 nach, danach nie wieder. Ein anschließendes Stripe-Ereignis zur selben Sitzung (`async_payment_succeeded`) ⇒ kein zweites T19.
- **Unbezahlt `ordered`** ohne Abo ⇒ `disabled`, **kein** T19, kein K22, `aboEndeMailAt` leer, `stripeSessionId` unverändert.
- `setup` ohne `ticketId` („Ticket fehlt“) ⇒ T19 ohne Block `ticket_offen`.
- Läuft das Abo (`past_due`) ⇒ `ordered`/`setup` bleiben unberührt; Lesefehler ⇒ unberührt.
- **Review Focus #1, „nie ohne Spur“:** ein `setup`-Dokument, dessen Abo endet, und das Ticket wird danach geschlossen (`workorders.status = 'Closed'`) ⇒ `activateClosedTickets` aktiviert das `disabled`-Dokument **nicht** (Zustand bleibt `disabled`).
- Rennen: Team aktiviert zwischen Liste und Sperre (Zustand `active`) ⇒ Schritt 1b greift nicht (frisch gelesen).
- **`meldeAboEnde` direkt** (der Aufruf, den E9 nutzt): bezahltes `setup`-Dokument mit Grund `abo_ende_vor_einrichtung` ⇒ ein T19, ein K22 `vor_einrichtung`, Marker gesetzt, Rückgabe `true`; unbezahltes `ordered`-Dokument ⇒ weder T19 noch K22 noch Marker, Rückgabe `true`, Dokument unverändert; zweiter Aufruf ⇒ nichts mehr.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Abo-Abgleich: Abo endet vor der Einrichtung ⇒ abgeschaltet, T19 und K22“
### Task E6: Schritt 3 Wiederkehr und K23
**Files:** Modify `server/services/termine/aboAbgleich.js`, `test/termineAboAbgleich.test.js`.
**Consumes:** E1–E5; `ladeEinstellung(featureId)` (`kontext.js:75`, prüft den Modus nicht und liefert `null`, wenn keine lesbare Einstellung da ist), `activate` (A11).
**Produces:** Schritt 3 in `gleicheAboAb`: Dokumente mit `state === 'disabled'`, Grund **`abo_ende`** und `lage.laeuft === true`: unter `feature-admin:<projectId>` Dokument frisch lesen (noch `disabled`/`abo_ende`), frische `aboLage` noch `laeuft`, `await ladeEinstellung(doc.$id)` ≠ `null` (Daten da), dann `activate(id, { adminNote: 'Wieder eingeschaltet: Wartungs-Abo läuft wieder' })` (Daten und Einstellungen bleiben, `activate` leert Grund/Marker/`disabledAt`) und K23 **nur, wenn K22 ging** (`aboEndeMailAt` vor dem `activate` gelesen); scheitert K23, bleibt die Reaktivierung bestehen (`fehler += 1`). Festlegung: Die Rückkehr gilt, solange die Einstellung noch da ist, auch wenn der Lauf erst nach Tag 30 kommt (Abgleich läuft vor der Löschung im selben Tagesjob; sonst blieben Löschung und Rückkehr gegenseitig blockiert). Nach der Löschung ist das Dokument `off` (E8) und wird nie automatisch aktiv. Nicht automatisch: Grund `abo_ende_vor_einrichtung` (Erstattung läuft; Wiedereinschalten entscheidet das Team per WOMS), `team`, `kunde_geloescht`, Altbestand ohne Grund.
- [ ] Tests:
- **Rückkehr innerhalb von 30 Tagen:** `disabled`/`abo_ende`, `disabledAt` = JETZT − 10 Tage, `aboEndeMailAt` gesetzt, Einstellung und eine künftige Buchung vorhanden, neues Wartungs-Abo `active` ⇒ Dokument `active`, `enabled true`, `activatedAt === JETZT`, `disabledAt`/`disabledReason`/`aboEndeMailAt` `null`; Einstellung (Version) und Buchung **unverändert**; K23 genau einmal (Betreff `Dein Terminplaner läuft wieder – <Projekt>`); zweiter Lauf ⇒ nichts; Ergebnis `wiederEin: 1`.
- K22 ging nie (Marker leer, Abo kam vorher zurück) ⇒ reaktiviert, **kein** K22, **kein** K23.
- **Nach der Löschung nie automatisch:** Dokument `off` mit laufendem Abo ⇒ unberührt; `disabled`/`abo_ende` ohne Einstellung (`termineConfig` fehlt) ⇒ bleibt `disabled`, kein K23.
- `team`, `kunde_geloescht`, ohne Grund, `abo_ende_vor_einrichtung` + laufendes Abo ⇒ unberührt (auch kein Wegschreiben von Feldern).
- Rennen: Abo endet wieder zwischen Liste und Sperre ⇒ frische Prüfung ⇒ bleibt `disabled`; Lesefehler ⇒ bleibt `disabled`, `fehler 1`.
- `disabledAt` = JETZT − 35 Tage, Einstellung noch da, Abo läuft ⇒ reaktiviert; derselbe Tagesjob löscht danach nichts (siehe E8).
- **Wechsel widerrufen (Spiegel noch Wartung):** `wechsel`-Ereignis `widerrufen` ⇒ ein aktives Dokument wird abgeschaltet (Schritt 1, K22 Block `hosting`) und ein `disabled`/`abo_ende`-Dokument bleibt `disabled` (kein Flattern); ein neueres, wirksames `wechsel`-Ereignis hebt das auf ⇒ Rückkehr.
- K23 schlägt fehl ⇒ Dokument bleibt `active`, `fehler 1`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Abo-Abgleich: Rückkehr innerhalb der Frist schaltet wieder ein (K23)“
### Task E7: Einbindung in den Tagesjob, geteilter Schalter-Guard, Bericht
**Files:** Modify `server/services/termine/jobs.js` (`fuehreAus` Z. 91, `taeglicherJob` Z. 420, `tagesbericht` Z. 437, Kopfkommentar Z. 20–37), `test/termineJobs.test.js` (Test „Modus off“ Z. 277, Tagesjob Z. 867, Berichtszeile Z. 905).
**Consumes:** E4–E6 `gleicheAboAb`; A12 (`fuehreAus(job, now, arbeit, { teil })` mit Sperre je `job:teil`), A3a/A3b/A10a3 (`gedeckelt`, `geloescht`, `aktiveBetriebe`, Berichtssegmente); `termineAus` (`schalter.js`). Block A hat Tagesjob und `tagesbericht` schon erweitert: E7 hängt nur sein Segment an und ordnet den Schritt ein.
**Produces:** `fuehreAus(job, now, arbeit, { teil = null, trotzAus = null } = {})` (erweitert das Optionsobjekt aus A12; `teil` und die Überlappungssperre je `job:teil` bleiben unverändert): bei Modus `off` läuft der Job nur, wenn `await trotzAus?.()` wahr ist; wirft `trotzAus`, wird geloggt und `{ aus: true }` geliefert. `taeglicherJob` übergibt `trotzAus` = eine Abfrage `listDocuments(portalFeatures, [equal('featureKey','termine'), equal('unlockedByPurchase', true), limit(1)])`. **Modus off mit bezahlten Dokumenten** (eingeschränkter Lauf): nur `gleicheAboAb` und `loescheAbgeschaltete`, Ergebnis `{ eingeschraenkt: true, aboAbgleich, abgeschaltet, gedeckelt: { loeschfristen: 0, abgeschaltet: <n>, zellen: 0 }, geloescht: <abgeschaltet.buchungen>, aktiveBetriebe: null, fehler }` (dieselben Felder, die `meldeTagesjob` (A10a3) und `letzterTagesjob` der Status-Route lesen, damit dort nichts `undefined` ist); alle übrigen Schritte (Löschfristen der Buchungen, Outbox, Zellen) bleiben aus (Annahme, offene Frage). **Sonst** (pilot/on) neuer Schritt `gleicheAboAb(now)` nach `loeschfristen` und **vor** `loescheAbgeschaltete`, in `schritt(lauf, 'Abo-Abgleich', …, ersatz)` (ein Fehler dort stoppt weder die Löschung noch den Rest; `lauf.fehler += aboAbgleich.fehler`); Ergebnis erhält `aboAbgleich`. `tagesbericht` hängt `Abo-Abgleich N geprüft / N abgeschaltet / N wieder ein / N Mails / N ausgenommen` vor `N Fehler` an (nur Zahlen, keine IDs). **Reihenfolge der Segmente (verbindlich für A3b, A10a3, C6, E7):** `…, N Mails nicht zugestellt, [Löschläufe am Deckel: n, Rest folgt an den nächsten Tagen, ][Entwürfe n gelöscht (m offen), ]Abo-Abgleich …, N Fehler, n aktive Betriebe`; die vollständige Zeile in E7 (Entwürfe fehlen hier noch, C6 fügt sie zwischen Deckel und Abo-Abgleich ein): `[termine] Tagesjob: 0 Buchungen gelöscht (verfallen 0, abgesagt 0, abgelehnt 0, erledigt 0, nichterschienen 0, abgeschaltete Funktionen 0), 0 Funktionen bereinigt, 0 Zellen, 0 Outbox, 0 Mails nicht zugestellt, Abo-Abgleich 0 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 0 ausgenommen, 0 Fehler, 0 aktive Betriebe`; für den eingeschränkten Lauf eine eigene Zeile `[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung): …`.
- [ ] Tests:
- Test „Modus off“ angepasst: `minutenJob`/`fuenfMinutenJob` ⇒ `{ aus: true }` und **0** Anfragen; `taeglicherJob` ohne bezahlte Dokumente ⇒ `{ aus: true }` und **genau 1** Anfrage (die Guard-Abfrage), sonst nichts; schlägt die Guard-Abfrage fehl ⇒ `{ aus: true }`, Warnung, keine Löschung.
- **Modus off, bezahltes Dokument:** abgeschaltet seit 31 Tagen mit Buchungen ⇒ Buchungen/Zellen/Einstellung gelöscht und Dokument `off` (E8); aktives bezahltes Dokument ohne Abo ⇒ abgeschaltet, K22 geht raus; verfallene Buchung eines anderen Betriebs bleibt (kein `loeschfristen`-Schritt); Ergebnis hat `eingeschraenkt: true`, `tagesbericht` liefert die eingeschränkte Zeile ohne IDs.
- Pilot-Modus: `taeglicherJob` ⇒ `erg.aboAbgleich` mit den vier Zählern; Reihenfolge (Abgleich vor Löschung) per Test: ein Dokument, das heute zurückkehrt (Abo läuft, `disabledAt` vor 40 Tagen, Einstellung da), wird **nicht** gelöscht; `erg.fehler` enthält die Fehler des Abgleichs; wirft der Abgleich (Liste nicht lesbar), läuft die Löschung weiter (Fehler gezählt, `ersatz` mit Nullen).
- Berichtszeile (Z. 905) gleicht gegen das Literal oben (zusätzlich eine Variante mit `gedeckelt` > 0 und dem Deckel-Segment), weiter ohne `b_`, `f_`, `@`; Z. 867 prüft zusätzlich `erg.aboAbgleich`; der eingeschränkte Lauf liefert `gedeckelt`, `geloescht` und `aktiveBetriebe: null`, und `meldeTagesjob`/`statusDaten` lesen daraus ohne `undefined`.
- Überlappung: der eingeschränkte Lauf nutzt dieselbe Sperre wie der volle Tagesjob (zweiter paralleler Aufruf ⇒ `{ uebersprungen: true }`); die Sperren der beiden `minutenJob`-Teile (`job:teil`, A12) bleiben davon **unabhängig** (hängender Tagesjob ⇒ Verfall und Mailversand laufen weiter, und umgekehrt).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Tagesjob: Abo-Abgleich vor der Löschung, auch bei TERMINE_ENABLED=off mit bezahlten Dokumenten“
### Task E8: Löschung nach 30 Tagen: frische Abo-Prüfung und Zurücksetzen auf `off`
**Files:** Modify `server/services/termine/jobs.js` (`ABGESCHALTET_BEHALTEN_MS` Z. 50 aus `LOESCHFRIST_TAGE`, `nochAbgeschaltet` Z. 325, `loescheAbgeschaltete` Z. 347), `test/termineJobs.test.js` (Z. 753–780, 822, 846).
**Consumes:** E1 `aboLage`, E2 `LOESCHFRIST_TAGE`, A11 `disabledReason`; Block A (seitenweise Löschung und Deckel: die beiden Änderungen hängen an `nochAbgeschaltet` und an der Stelle, wo die Einstellung gelöscht wird, nicht an der Seitenlogik).
**Produces:** `nochAbgeschaltet` prüft bei Grund `abo_ende` zusätzlich **frisch** `(await aboLage(frisch.customerId)).laeuft`; läuft das Abo wieder ⇒ `false` mit Log („Abo läuft wieder, Löschung abgebrochen“); ein Lesefehler dabei ⇒ `false` und `melde` (im Zweifel wird nichts gelöscht). Die Prüfung gilt vor jedem unumkehrbaren Schritt (Buchungsliste, alle 50 Buchungen, Einstellung); für `team`, `kunde_geloescht`, `abo_ende_vor_einrichtung`, Altbestand gibt es **keine** Abo-Abfrage. **Zurücksetzen:** sind alle Buchungen weg (`vollstaendig`) und die Einstellung gelöscht **oder schon nicht mehr da (404)**, setzt der Job unter `withLocks(['feature-admin:<projectId>', 'termine-cfg:<id>'])` nach nochmaligem Prüfen das Feature-Dokument zurück: `stateFields('off')`, `unlockedByPurchase false`, `purchaseStatus 'zurueckgesetzt'` (dauerhafte Marke „hier wurde nach dem Abo-Ende gelöscht“: Block C6 löscht ein so markiertes Funktionsdokument **nie**, nur seine Einstellung, damit der `siteKey` bestehen bleibt; weder `paid` noch Meldezustand), `zahlungMeldung ''`, `ticketId ''`, `activatedAt null`, `disabledAt null`, `disabledReason null`, `aboEndeMailAt null`, `adminNote 'Daten gelöscht (Löschfrist abgelaufen)'`; **`siteKey` bleibt** (das eingebettete Snippet bleibt gültig), Bestell-/Rechnungsfelder bleiben bis zur nächsten Bestellung stehen. Gilt für jeden Grund. Der Zähler hat die Form `{ funktionen, buchungen, gedeckelt }` (A4; ein reines Zurücksetzen ohne Löschung zählt nicht). Ist nur noch das Zurücksetzen offen (Einstellung weg, keine Buchungen), entfällt die Abo-Prüfung (nichts Unumkehrbares mehr).
- [ ] Tests (`termineJobs.test.js`, Abo-Dokumente über `subscriptions` im Mock-Schema ergänzen):
- Bestehender Test Z. 753: die Zeile „das Funktionsdokument selbst bleibt“ (Z. 776) wird zu: Dokument vorhanden mit `state 'off'`, `enabled false`, `unlockedByPurchase false`, `purchaseStatus 'zurueckgesetzt'`, `zahlungMeldung ''`, `ticketId ''`, `disabledAt null`, `siteKey 'abcdefghijklmnop'` unverändert; die Dokumente der anderen Funktionen (noch nicht abgelaufen, wieder aktiv, andere `featureKey`, ohne `disabledAt`) bleiben unverändert; zweiter Lauf `{ funktionen: 0, buchungen: 0, gedeckelt: 0 }` (die `deepEqual`-Pins der TP1-Tests tragen `gedeckelt: 0` seit A4) und das Dokument wird **nicht mehr gelistet** (keine tägliche Neuprüfung); Entwurfsjob (C6) nach 91 Tagen ⇒ das zurückgesetzte Dokument behält den `siteKey` (Test in C6).
- **Abo-Vorprüfung:** Grund `abo_ende`, Abo läuft **wieder**, `disabledAt` vor 31 Tagen ⇒ nichts gelöscht (Buchungen, Zellen, Einstellung da), Dokument bleibt `disabled`, Warnung im Log; dasselbe Dokument mit Grund `team` ⇒ gelöscht und **0** Abfragen auf `subscriptions`; Lesefehler auf `subscriptions` ⇒ nichts gelöscht, `fehler` gezählt; das Abo kommt erst nach 60 Buchungen zurück (Einwurf nach der 50. Löschung, Muster Z. 822) ⇒ Löschung endet dort, Einstellung bleibt.
- **Zurücksetzen:** scheitert eine Buchungslöschung (`failNext` DELETE `termineBookings`) ⇒ Dokument bleibt `disabled`, Einstellung bleibt; nächster Lauf löscht und setzt zurück. Scheitert das Zurücksetzen (`failNext` PATCH `portalFeatures`) ⇒ Einstellung ist weg, Dokument noch `disabled`; nächster Lauf setzt zurück, **auch wenn das Abo inzwischen läuft** (kein Unumkehrbares mehr offen). Wird das Dokument zwischen Löschen der Einstellung und Zurücksetzen vom Team aktiviert (Einwurf), bleibt es `active` (frisch gelesen, nicht überschrieben).
- Nach dem Zurücksetzen ist eine Neubestellung möglich: `markOrdered` (Block B) auf dem `off`-Dokument ⇒ `ordered` (Verweis auf B-Test, hier nur der Zustand `off`).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Löschung nach 30 Tagen: Abo-Vorprüfung, Funktionsdokument danach auf off“
### Task E9: Sofortpfad Widerruf Wechsel und Schutz der Team-Aktivierung
**Files:** Modify `server/services/termine/aboAbgleich.js` (+ `endeTermineFuerKunde`), `server/routes/abo.js` (Z. 1051–1058, nach dem Analyse-Block), `server/services/siteFeatures.js` (`adminActivate` Z. 351), Tests `test/publicForms.test.js` (neben Z. 422), `test/funktionenTermine.test.js`, `test/termineAboAbgleich.test.js`.
**Consumes:** E4–E6, E1 `aboLage`.
**Produces:** `endeTermineFuerKunde(customerId, { now = new Date() } = {}) ⇒ Promise<{ beendet: string[] }>` (wirft nie, wie `endAnalyseForCustomer`): alle `termine`-Dokumente des Kunden (`listCustomerFeatureDocs`), `active` mit `unlockedByPurchase === true` ⇒ `disable({ reason: 'abo_ende', adminNote: 'Abgeschaltet: Wechsel widerrufen' })`, `ordered`/`setup` ⇒ `abo_ende_vor_einrichtung`; je Dokument unter `feature-admin:<projectId>` mit frischem Lesen, **ohne** Abo-Prüfung (der Spiegel ist veraltet); danach sofort `meldeAboEnde` (E4/E5: `abo_ende` ⇒ K22; `abo_ende_vor_einrichtung` mit `unlockedByPurchase === true` ⇒ erst T19 über `meldeErstattung` (B10b), dann K22 `vor_einrichtung` und Marker; ein unbezahltes `ordered`-Dokument wird nur abgeschaltet und bekommt weder T19 noch K22 noch Marker), ein Fehler bei einem Dokument stoppt die anderen nicht. `routes/abo.js` ruft sie per dynamischem Import in einem eigenen `try/catch` nach dem Analyse-Block (`inTime && type === 'wechsel'`); der Widerruf antwortet auch bei einem Fehler dort 200. `adminActivate`: für `termine` und ein Dokument mit `unlockedByPurchase === true` ist das Aktivieren nur erlaubt, wenn `(await aboLage(customerId)).laeuft`; sonst 409 „Das Wartungs-Abo des Kunden läuft nicht. Der Terminplaner kann erst wieder freigeschaltet werden, wenn es läuft.“ (verhindert, dass der nächste Abgleichslauf die Kulanz zurücknimmt und K22 erneut sendet); Lesefehler ⇒ Fehler, nicht aktiviert. Nicht betroffen: `news`, Dokumente mit `unlockedByPurchase !== true` (Pilot/Kulanz ohne Kauf).
- [ ] Tests:
- `publicForms.test.js` (Muster Z. 401–440, Dokument `termine` statt `analyse`): Widerruf des Wechsels in der Frist ⇒ aktives bezahltes Termine-Dokument `disabled`/`abo_ende`, K22 in der Senke, Analyse-Verhalten unverändert; wirft die Termine-Abschaltung (Dokument-Update scheitert), bleibt der Widerruf 200; Widerruf nach Fristende oder Zusatzspeicher ⇒ Termine unverändert (`wf_feat`-Test erweitert).
- **Bezahlter Aufbau über den Sofortpfad (Review Focus #1):** Termine-Dokument `setup` (`unlockedByPurchase`, `stripeSessionId 'cs_test_1'`, Ticket vorhanden), Widerruf des Wechsels in der Frist ⇒ `disabled`/`abo_ende_vor_einrichtung`, **genau ein** T19 (Block `ticket_offen`), K22 mit Block `vor_einrichtung`, `aboEndeMailAt` gesetzt, `zahlungMeldung 'gemeldet'`; der folgende `gleicheAboAb`-Lauf sendet nichts mehr (weder T19 noch K22). Unbezahltes `ordered` ⇒ `disabled`/`abo_ende_vor_einrichtung`, **kein** T19, kein K22, kein Marker; kommt die Zahlung danach, meldet B10b T19 genau einmal und der Abgleich K22. T19-Versand scheitert ⇒ der Widerruf antwortet trotzdem 200, K22 und Marker fehlen, der Abgleich am Folgetag sendet T19 und K22 nach.
- **Kein Flattern:** nach dem Sofortpfad (Spiegel `sub_h` auf `productKey 'wartung'`, `active`) läuft `gleicheAboAb` ⇒ Dokument bleibt `disabled`, kein K23, kein zweites K22.
- `funktionenTermine.test.js`: Team-„Aktivieren“ bei bezahltem `disabled`-Dokument ohne laufendes Abo ⇒ 409 mit dem Text, Zustand unverändert; mit laufendem Abo ⇒ 200, `active`, Grund `null`; Dokument ohne Kauf (`unlockedByPurchase: false`) ohne Abo ⇒ 200; `news` ⇒ unberührt; Abo-Lesefehler ⇒ 5xx, nicht aktiviert.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Abo-Ende: Sofortpfad beim Widerruf des Wechsels, Team-Aktivierung nur mit laufendem Abo“
### Task E10: Zahlung nach dem Abo-Ende (T19 statt Ticket) – Zusatztests gegen B10
**Files:** Modify `test/termineAboAbgleich.test.js` (nur Tests). **Kein eigener Code** und kein eigener Zweig in `onFeaturePaid`: das Verhalten (kein Ticket, kein K15/T16, T19 genau einmal, Webhook 500 bei Versandfehler, Wiederholung sendet genau einmal) baut B10a/B10b.
**Consumes:** B10a (`markPaid`-Fall `abo_ende`, Marker `zahlungMeldung`), B10b (`meldeErstattung`, `onFeaturePaid`), E4–E6 (`gleicheAboAb`).
**Produces:** Tests für das Zusammenspiel von Zahlungs-Webhook und täglichem Abgleich (Review Focus #1: nie Geld ohne Spur, nie doppelt).
- [ ] Tests (`handleFeatureCheckoutEvent` direkt, Hilfen `event`/`paidSession` wie in `funktionenRoutes.test.js:51–58`, Dokument `termine` im Zustand `ordered`, danach `gleicheAboAb` ohne Abo):
- Zahlung nach Abschalten ⇒ genau ein T19 (Block `zahlung_nach_ende`, Sitzungs-ID), **kein** Eintrag in `workorders`, kein K15/T16, `unlockedByPurchase true`, `purchaseStatus 'paid'`, `zahlungMeldung 'gemeldet'`, Zustand bleibt `disabled`; dasselbe Event noch einmal und danach `async_payment_succeeded` ⇒ kein zweites T19 (nie doppelt).
- T19-Versand schlägt fehl ⇒ `handleFeatureCheckoutEvent` wirft (Webhook 500), `unlockedByPurchase` ist trotzdem `true`, `zahlungMeldung` offen; zweiter Aufruf mit funktionierendem Versand ⇒ genau ein T19.
- Folgetag-Abgleich ⇒ K22 `vor_einrichtung` an den Kunden, `aboEndeMailAt` gesetzt, **kein** zweites T19 (Marker `gemeldet`).
- Abgleich **vor** der verspäteten Zahlung-Wiederholung: Schritt 2 meldet T19 (das Dokument ist durch die erste Verbuchung bezahlt), die Wiederholung des Webhooks danach bleibt stumm; parallel (`Promise.all`) ⇒ insgesamt ein T19.
- Zahlung für ein Dokument mit Grund `team` bleibt beim Verhalten aus B10b (T16 „abgeschaltet“, kein T19); Zahlung bei `setup`/`ordered` (Abo läuft) unverändert (Ticket, K15, T16).
- Rennen Reihenfolge: Zahlung **vor** dem Abgleich (Dokument `setup`, Ticket da) und Abgleich danach ⇒ T16/Ticket **und** ein T19 (mindestens eine Spur, T19 höchstens einmal); anschließend Stripe-Retry desselben Ereignisses ⇒ stumm.
- [ ] FAIL → Tests schreiben (bei Rot: den Fehler in B10 beheben, nicht hier umgehen) → PASS → `npm test` → Commit „Zahlung nach Abo-Ende: Zusatztests Webhook und Abgleich (T19 nie doppelt)“
### Task E11: Meldungen und Banner im Portal nach Abschaltgrund
**Files:** Modify `server/routes/termine.js` (`MELDUNG.abgeschaltet` Z. 59 und Z. 152; `GET /einstellungen` Z. 201), `public/termine-view.js` (+ `abschaltText`), `public/termine.js` (`hinweisAbgeschaltet` Z. 66, Aufrufe Z. 96–98 und Z. 200); Tests `test/termineRoutes.test.js` (Z. 994), `test/termineView.test.js`, `test/ui/termine-portal.ui.mjs` (Z. 1024, Playwright, nicht in `npm test`).
**Consumes:** E2 `loeschTag`, `featureView`-Felder. **Eigentum:** E11 **besitzt** Banner, Meldungstexte und das Feld `abgeschaltet` (Reihenfolge A → B → E → C → D): Block C (C4 schreibt `MELDUNG`, `withTermine` und GET `/einstellungen` neu, C10b ändert `zeigeProjekt`) und Block D (D2 verschiebt den Snippet-Aufruf in GET `/einstellungen`) setzen auf diesen Stand auf und **erhalten** `MELDUNG.abgeschaltet`, das Feld `abgeschaltet`, `abschaltText` und `hinweisAbgeschaltet`; C baut keinen eigenen disabled-Banner und kein eigenes Löschdatum.
**Produces:** `GET /api/termine/einstellungen` liefert zusätzlich `abgeschaltet: { grund, seit, loeschungAm } | null` (nur bei `state === 'disabled'`; `grund` = `disabledReason` oder `null`, `seit` = `disabledAt`, `loeschungAm` = `'JJJJ-MM-TT'`). Die 409-Meldung bei Schreibversuchen (Code bleibt `abgeschaltet`) nennt je Grund das Löschdatum (`dd.mm.yyyy`): `abo_ende` „Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und exportieren. Ab dem 04.11.2026 löschen wir die Daten.“, `abo_ende_vor_einrichtung` „…Abo endete, bevor der Terminplaner eingerichtet war…“, sonst der bisherige Satz plus (mit Datum) „Ab dem … löschen wir die Daten.“. `abschaltText({ grund, loeschungAm }) ⇒ { davor, danach, mitExport }` in `termine-view.js` (rein, Du-Form, `datumKurz` für das Datum): `mitExport` falsch nur bei `abo_ende_vor_einrichtung`; `hinweisAbgeschaltet(projectId, abgeschaltet)` setzt `[davor, exportLink(projectId, 'exportieren'), danach]` (Linktext und genau ein Link bleiben, die UI-Prüfung und der Link-Wächter in `termineView.test.js` ~Z. 290 gelten weiter), Text nur über `h()`. Wortlaut ist Vorschlag für die Rechtsrunde (Block F).
- [ ] Tests:
- `termineRoutes.test.js` (Test Z. 994 erweitert): disabled ohne Grund/`disabledAt` ⇒ `abgeschaltet: { grund: null, seit: null, loeschungAm: null }` und der **bisherige** Satz unverändert; `abo_ende` mit `disabledAt: '2026-10-04T23:30:00.000Z'` ⇒ `loeschungAm '2026-11-04'`, jede Schreibroute 409 mit `code 'abgeschaltet'` und Text mit „Wartungs-Abo“ und „04.11.2026“; `abo_ende_vor_einrichtung` ⇒ Text mit „eingerichtet“; `active` ⇒ `abgeschaltet: null`; Lesen und Export bleiben 200.
- `termineView.test.js`: `abschaltText` je Grund (abo_ende, abo_ende_vor_einrichtung, team, kunde_geloescht, `null`) mit und ohne Datum; `mitExport` nur bei vor_einrichtung falsch; kein „Sie“/„Ihr“ (die Dateischleife Z. 277 bleibt grün, keine neue Datei).
- UI (`nice`, vorher `free -m`): Fixture `disabled` + `abo_ende` ⇒ Banner nennt „Wartungs-Abo“ und das Datum, der Export-Link bleibt der einzige Link (`.tm-box-warn a`, Text „exportieren“); Fixture ohne Grund ⇒ wie bisher (Z. 1039).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner-Banner und Meldung nach Abschaltgrund mit Löschdatum“
### Task E12: WOMS „Abgeschaltet (Abo beendet), Löschung am …“
**Files:** Modify `src/lib/funktionenView.js` (+ `abgeschaltetText`), `src/components/customer/CustomerFunktionen.jsx` (Statuszelle, Z. 96–98), `tests/unit/funktionenView.test.mjs`.
**Consumes:** E2 (`disabledReason`, `loeschungAm` in `features[key]` über `GET /api/team/funktionen/:customerId`; `adminView` reicht die Felder von `listCustomerFeatures` durch, kein Portal-Eingriff), `fmtYmd` (`src/lib/format.js:41`); Block D (D9/D10) ändert dieselbe Komponente **nach** E12 (Anleitung, `aktionen(state, key)`) und **erhält** die Zeile mit `abgeschaltetText` in der Statuszelle; E12 ergänzt nur diese Zeile und die reine Funktion.
**Produces:** `abgeschaltetText(feature) ⇒ string` (rein): `''`, wenn `state !== 'disabled'`; sonst `Abgeschaltet` + ` (Abo beendet)` bei `abo_ende`, ` (Abo endete vor Einrichtung, Erstattung prüfen)` bei `abo_ende_vor_einrichtung`, ` (Team)` bei `team`, ` (Kunde gelöscht)` bei `kunde_geloescht`, nichts ohne Grund; mit `loeschungAm` anschließend `, Löschung am ${fmtYmd(loeschungAm)}`. Die Komponente zeigt den Text als `area-muted`-Zeile unter dem Abzeichen (das Abzeichen bleibt `ZUSTAND.disabled`, der Test dort bleibt unverändert). Das Team-„Aktivieren“ meldet bei fehlendem Abo (E9) den Servertext im bestehenden Fehlerbereich.
- [ ] Tests (`funktionenView.test.mjs`): `abgeschaltetText({ state: 'disabled', disabledReason: 'abo_ende', loeschungAm: '2026-11-04' }) === 'Abgeschaltet (Abo beendet), Löschung am 04.11.2026'`; die drei anderen Gründe; ohne Grund und Datum `'Abgeschaltet'`; ohne Grund mit Datum `'Abgeschaltet, Löschung am …'`; `active`/`setup`/`off`/`undefined` ⇒ `''`; unbekannter Grund wird wie „ohne Grund“ behandelt; bestehende Tests (`ZUSTAND`, `aktionen`, `mergeFunktion`) unverändert grün. `npm test` (WOMS) und einmal `nice npx vite build`.
- [ ] FAIL → implementieren → PASS → WOMS `npm test` + `nice npx vite build` → Commit „Funktionen-Reiter: Abschaltgrund und Löschdatum des Terminplaners“
**Abschlussprüfung Block E (Controller):** `npm test` komplett grün (`test/previewDevRouter.test.js` bei Ausreißer einzeln wiederholen); Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): `docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)'` (`analyticsContract.test.js` importiert `../analytics/src` und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: `git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob'` bleibt leer. WOMS-Anteil (E12): `npm test` im WOMS-Worktree und einmal `nice npx vite build`. Check vor dem Livegang: der Abgleich läuft zunächst nur beobachtend (Block G: solange kein Dokument `unlockedByPurchase === true` hat, schaltet er nichts ab).
---
## Block C: Fragebogen
Block C macht aus dem TP1-Einstellungsdokument den Fragebogen: In `off` ist `termineConfig.einstellung` der Entwurf und zugleich die spätere Live-Einstellung, dazu das neue Attribut `termineConfig.fragebogen` (Schrittbestätigungen, Einbauwünsche), acht Schritte aus den TP1-Editoren und die Bestellseite „Prüfen und bestellen“. Der strikte Gate `bestellpruefung` ersetzt den Stub aus Block B. Reihenfolge: Server C1–C6 (C4a, C4b), Client C7a–C10b (C7a, C7b, C8, C9a, C9b, C10a, C10b), Playwright C11 (Voraussetzung: A, B, E sind fertig; C vor D und F).
Gelesen und geprüft (Portal-Worktree): `server/routes/termine.js` (`withTermine` :137-157, GET :201-216, Vorlage :230-252, PATCH :254-272), `server/services/termine/{kontext,einstellungen,vorlagen,schema,jobs}.js`, `public/termine*.js`, `public/funktionen.js` (Termine-Kachel :139-197), `server/routes/funktionen.js:109`, `server/services/customerAdmin.js:285-292`, UI-Testserver `test/ui/termineTestServer.mjs`. Abweichungen von der Spec: (1) `BRANCHEN` liegt nach der Schnittstellenliste in `fragebogen.js`, nicht in `vorlagen.js`; (2) das Attribut kommt in `server/services/termine/schema.js` (das Setup-Skript `scripts/termine-setup.mjs` und `test/support/schema.js` lesen von dort, beide bleiben unverändert); (3) `feiertage(t)` aus `termine-team-ausnahmen.js` wird nicht übernommen, Schritt 4 braucht ein Bundesland ohne Vorauswahl und ein Speichern je „Weiter“.
**Annahmen an andere Blöcke (abgestimmt, verbindlich):**
- **B Register:** `featureConfig('termine')` hat die Felder aus B1 (`consent: { version, knopf, items: [{ id, text, haken }] }`, `leistungsbeschreibung`, `avv`, `widerruf`, `priceLabel`, `setupEstimate`). Die Widerrufsbelehrung liefert der Haken `featureHaken('termine').belehrung` (F7); C erfindet keine Registerfelder `pflichtinfo` oder `widerruf{belehrung,muster}`. Nur `bestellungAnsicht()` (C2) bildet das Register für die Bestellseite ab.
- **B Checkout** `POST /api/funktionen/termine/checkout`: Body `{ projectId, zustimmung: { version, haken: [id, …] } }` (`haken` = angehakte `consent.items[].id` mit `haken: true`); „Zahlung fortsetzen“ in `ordered` sendet `{ projectId, fortsetzen: true }` und bekommt die offene Sitzung nur, wenn für sie ein Nachweis vorliegt, sonst 409 `zustimmung_fehlt` (die Seite zeigt wieder das Formular); Antwort `{ url }`. Bei nicht leerer `bestellpruefung` antwortet B mit 409 `bestellung_offen` und `body.offen` (Liste `{ schritt, text }`), bei veralteter Fassung mit 409 `fassung_veraltet` und `body.consent`, bei Abo-Mangel mit 409 und `code` `kein_abo` | `gekuendigt` | `nicht_aktiv` (Abo-Stufe (7) in B6, nicht über das Gate).
- **B (Checkout) und C4a (Vorschau)** rufen `bestellpruefung` mit `ladeEinstellung(featureId)` (C3 liefert dort jetzt auch `fragebogen`): B6 im Checkout als `bestellpruefung({ einstellung, fragebogen, feature: featureConfig('termine'), mitAbo: false })` (ohne `abo`, die Abo-Stufe (7) liegt in B6), C4a im GET als `bestellpruefung({ einstellung, fragebogen, feature: featureConfig('termine'), abo: await bestellbar(customerId) })` (bei Lesefehler ohne `abo` ⇒ Punkt (h) „unbekannt“). Block D liest die Einbauwünsche über denselben Weg. Abo-Hinweistexte kommen aus `aboMeldung(feature, grund)` (`featureOrder.js`, B6); C definiert keine eigenen Wortlaute.
- **E** liefert `aboStatus.laeuft`/`bestellbar` (E1) und **besitzt** Abschalt-Banner, Meldungstexte und das GET-Feld `abgeschaltet` (E11). C4a/C4b, C10b und D2 setzen darauf auf und erhalten `MELDUNG.abgeschaltet`, das Feld `abgeschaltet`, `abschaltText` und `hinweisAbgeschaltet`.
- **A** `kundenansichtGuard`: `PUT /api/termine/fragebogen` bleibt außerhalb der Erlaubnisliste; Einfügepunkt für C6 ist `taeglicherJob` nach `loescheAbgeschaltete` (auch A und E ändern diese Funktion und `tagesbericht`; Segmentreihenfolge siehe E7).
### Task C1: Fragebogen-Attribut, Branchen, Schritte, Normalisierung
**Files:** Create `server/services/termine/fragebogen.js` (Teil 1; hat Block B die Datei schon mit dem Stub angelegt, wird sie ergänzt), `test/termineFragebogen.test.js`. Modify `server/services/termine/schema.js` (`termineConfig`: `str('fragebogen', 20000)` hinter `updatedBy`), `test/termineSetup.test.js` (Attributliste in „Schema: Attribute je Collection“ um `fragebogen` am Ende; Größen-Test; neuer `--check`-Test).
**Consumes:** `text` aus `einstellungen.js`; `VORLAGEN` aus `vorlagen.js`.
**Produces:** `SCHRITTE = ['branche','wer','leistungen','zeiten','regeln','gastfelder','benachrichtigung','website']`; `BRANCHEN = [{ key, name, vorlage: string|null, gesundheit: boolean }]` (Reihenfolge von `vorlagenListe()`, danach der Eintrag `{ key: 'gesundheit', name: 'Gesundheitsbereich', vorlage: null, gesundheit: true }`; Schlüssel `gesundheit` und Name „Gesundheitsbereich“ gelten wörtlich für C2, C5, C9a und C11); `leerFragebogen()`; `normalisiereFragebogen(eingabe) ⇒ { fragebogen } | { fehler: { [pfad]: text } }`; `FRAGEBOGEN_MAX = 16000`. Form: `{ schema: 1, branche: <BRANCHEN-key>|null, bestaetigt: [Schrittschlüssel], bundeslandGewaehlt: boolean, preiseGeprueft: boolean, einbau: { modus: 'inline'|'knopf'|null, knopfText ≤ 40, stelle ≤ 300, gestaltung ≤ 1000, bemerkung ≤ 1000 } }`. `branche` ist gegenüber der Spec ergänzt, sonst kennt der Gate (f) die Branche nicht.
- [ ] Tests: `SCHRITTE` genau diese acht; `BRANCHEN`: jede `vorlage` steht in `VORLAGEN`, alle neun Vorlagen kommen genau einmal vor, genau ein Eintrag `gesundheit: true` ohne Vorlage, `sonstiges` bleibt wählbar (D6). `normalisiereFragebogen`: `{}` ⇒ `leerFragebogen()`; unbekannte Schlüssel und `__proto__` fliegen raus; `bestaetigt: ['website','branche','branche','quatsch']` ⇒ `['branche','website']` (Reihenfolge der Schritte, ohne Doppelte und Fremde); `knopfText` mit 41 Zeichen wird auf 40 gekürzt; Steuerzeichen (`\u0000`, `‮`) in `gestaltung` verschwinden, Absätze bleiben; `modus: 'popup'` ⇒ `fehler['einbau.modus']`; `bundeslandGewaehlt: 'ja'` ⇒ Fehler; Nicht-Objekt ⇒ `fehler._`; idempotent (`normalisiere(normalisiere(x).fragebogen)` gleich); größte gültige Eingabe bleibt unter `FRAGEBOGEN_MAX`. Setup: `keysOf('termineConfig')` endet auf `fragebogen`; Größe 20000 (Inhalt ≤ 16 000 erzwingt der Server, die größere Spalte wird in Appwrite eine Textspalte und zählt nicht zur Zeilenbreite; Annahme, vor Produktion `termine-setup.mjs --check` und Anlegen gegen eine Kopie); `main({ check: true })` meldet in einer vorhandenen Collection ohne `fragebogen` „termineConfig.fragebogen FEHLT“, der Lauf ohne `--check` legt genau dieses Attribut an, der zweite nichts.
- [ ] FAIL → implementieren (`normalisiereFragebogen` baut ein neues Objekt Feld für Feld, Texte über `text()`; `stelle`/`gestaltung`/`bemerkung` mehrzeilig) → PASS → `npm test` → Commit „Terminplaner TP2: Fragebogen-Attribut, Branchen, Schritte, Normalisierung“
### Task C2: Bestell-Gate und Gesundheits-Stichwortprüfung
**Files:** Modify `server/services/termine/fragebogen.js` (Teil 2, ersetzt den Stub aus B; die Marke `STUB` entfällt), `server/services/termine/einstellungen.js` (`sichtbareLeistungen` :568 und `MELDUNG_PFLICHT` :562 exportieren, sonst unverändert), `test/termineFragebogen.test.js`. **Delete** `test/termineFragebogenStub.test.js` (B5; sie prüft den Stub und bliebe rot). Create `test/support/fragebogenStand.js`. B-Tests, die nur mit dem Stub (`[]`) bestellbar waren (`grep -l checkout test/*.test.js`), bekommen `bestellbarerStand()`.
**Produces:** `bestellpruefung({ einstellung, fragebogen, feature, abo, mitAbo = true }) ⇒ Array<{ schritt, text }>` (`schritt` ∈ `SCHRITTE` ∪ `'bestellen'`, sortiert nach Schrittfolge; `feature` = Register-Eintrag, nur für `label.name` und `aboMeldung` (aus `featureOrder.js`, B6, Argumente `(feature, grund)`); `abo` = Ergebnis von `bestellbar`, gelesen nur bei `mitAbo !== false`); `SCHRITT_TITEL`; `gesundheitsWarnungen(einstellung) ⇒ string[]`; `bestellungAnsicht(feature) ⇒ Promise<{ preis, preisHinweis: config.ustHinweis, einrichtung, consent: { version, knopf, items: [{ id, text, haken }] }, dokumente: [{ id, titel, version, url }], belehrung } | null>` (reine Abbildung von `oeffentlicheAnsicht(feature.key)` (B1) für die Bestellseite: `consent` unverändert **inklusive `knopf` und `haken`-Flag** (der Knopf zeigt genau diesen Text, B speichert genau diesen Text), `dokumente` aus den Registerfeldern `leistungsbeschreibung`/`avv`/`widerruf` mit gesetzter `url`, `belehrung` = Ergebnis von `featureHaken(feature.key).belehrung()` (derselbe Text, den B7 im Nachweis speichert; bis F7 der Platzhalter `BELEHRUNG_PLATZHALTER` aus `featureRegistry.js` (B7), „ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.“); keine Felder `pflichtinfo`/`widerruf`). Testhelfer: `bestellbarerStand({ now }) ⇒ { einstellung, fragebogen }` (Vorlage `salon`, Telefon, Anschrift, Impressum, Datenschutz, alle Schritte bestätigt, `bundeslandGewaehlt`, `preiseGeprueft`, `einbau.modus: 'inline'`, `branche: 'salon'`) und `entwurfDoc(featureId, { customerId, projectId, stand })` (Seed für `termineConfig`).
Gate (a)–(h), jeder Punkt ein eigener Eintrag: (a) `fehlendePflichtangaben` (Telefon ⇒ Schritt `website`, Anschrift ⇒ `branche`, Text aus `MELDUNG_PFLICHT`); (b) keine `sichtbareLeistungen` ⇒ `leistungen`; (c) je unbestätigtem Schritt ein Punkt „Bitte bestätige den Schritt „…“.“, dazu `leistungen`, wenn eine sichtbare Leistung einen Preis hat und `preiseGeprueft` fehlt; (d) `bundeslandGewaehlt !== true` ⇒ `zeiten` (der Serverstandard `RP` zählt nicht); (e) `einbau.modus` fehlt, oder `knopf` ohne `knopfText` ⇒ `website`; (f) `branche` fehlt oder Gesundheit ⇒ `branche`; (g) `impressumUrl` oder `datenschutzUrl` fehlt ⇒ `website`; (h) nur bei `mitAbo !== false` (der Checkout in B6 ruft mit `mitAbo: false` und ohne `abo`; dort liefert die Abo-Stufe (7) die Codes): `abo.ok !== true` ⇒ `bestellen` mit `aboMeldung(feature, abo.grund)`, `abo` fehlt (Lesefehler) ⇒ derselbe Schritt mit `aboMeldung(feature, 'unbekannt')` (im Zweifel nicht bestellbar). `einstellung: null` ⇒ ein Punkt `branche`.
Stichwortprüfung (Regel: Treffer nur am **Wortanfang**, vor dem Treffer steht kein Buchstabe `\p{L}`; „Gesichtsbehandlung“ ist keine Behandlung). Gruppe 1, Stamm mit beliebiger Fortsetzung bis zum Wortende (Zusammensetzungen zählen): `physio`, `therap` (Therapie, Therapeut/in), `arzt`, `ärzt`, `zahnarzt`, `zahnärzt`, `heilpraktik`, `krankengymnast`, `osteopath`, `diagnos`. Gruppe 2, Stamm plus höchstens eine Beugungsendung aus `e`, `en`, `n`, `s`, `es` und dann Wortende: `rezept`, `beschwerde`, `behandlung` (so trifft „Rezepte“, „Beschwerden“, „Behandlungen“, aber **nicht** „Rezeption“ oder „Rezeptur“: `rezept` + `ion`/`ur` ist keine erlaubte Endung); geprüft werden Leistungsnamen, Beschreibungen, Zusatzfeld-Beschriftungen und -Auswahlen, ergänzt um den Betriebsnamen; höchstens 10 Meldungen, ohne Doppelte, ohne Gästedaten. Nur Warnblock für das Team (T16-Block `gesundheit` und Ticket-Kurzfassung, beide füllt Block D4), dem Kunden zeigt Block C nichts.
- [ ] Tests: `bestellbarerStand()` mit `abo: { ok: true }` ⇒ `[]`; je Kriterium (a)–(h) ein Fall mit genau einem Punkt und dem erwarteten `schritt` (Tabelle im Test), u. a. Telefon leer ⇒ `{ schritt: 'website' }`, Anschrift leer ⇒ `'branche'`, `bestaetigt: []` ⇒ acht Punkte in Schrittfolge, `bundeslandGewaehlt: false` bei `einstellung.bundesland === 'RP'` ⇒ trotzdem Punkt, alle Preise `keiner` ⇒ kein Preis-Punkt, `branche: 'gesundheit'` ⇒ Punkt in `branche`, `abo: { ok: false, grund }` für die drei Gründe ⇒ Text == `aboMeldung(feature, grund)`, `abo: undefined` ⇒ `aboMeldung(feature, 'unbekannt')`, `mitAbo: false` ⇒ kein (h)-Punkt, auch bei `abo: undefined` und bei `abo: { ok: false, grund: 'kein_abo' }`; Reihenfolge stabil; alle Texte Du-Form (kein `\bSie\b`). `gesundheitsWarnungen`: je Stichwort ein Treffer (Name, Beschreibung, Zusatzfeld), Groß-/Kleinschreibung und Umlaute; **Regeltest** (Treffer: „Rezept“, „Rezepte“, „Behandlungen“, „Beschwerden“, „Physiotherapie“, „Therapiestunde“, „Zahnärztin“, „Krankengymnastik“, „Osteopathie“; kein Treffer: „Rezeption“, „Rezeptur“, „Gesichtsbehandlung“, „Beratung“, „Besichtigung“); **alle neun `ausVorlage`-Vorlagen ergeben `[]`** (schützt „Gesichtsbehandlung“, „Beratung“, „Besichtigung“); `null` ⇒ `[]`. `bestellungAnsicht`: gleicht `oeffentlicheAnsicht('termine')` (Items mit `haken`-Flag, `consent.knopf` wörtlich, `dokumente` nur mit `url`), `belehrung` == Ergebnis des Hakens, `null` für unbekannte Funktion, enthält nie interne Felder (`ticket`, `mails`). **Integration Checkout (B6, mit dem echten Gate):** Kunde ohne Abo mit `bestellbarerStand()` ⇒ 409 `kein_abo` (nicht `bestellung_offen`); Kunde ohne Abo mit offenem Fragebogen ⇒ 409 `bestellung_offen` ohne Eintrag `bestellen`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Bestell-Gate und Gesundheits-Stichwortpruefung“
### Task C3: Fragebogen in Kontext und Speicher
**Files:** Modify `server/services/termine/kontext.js` (`liesDokument` :58, `ladeEinstellung` :75, `baueKontext` :82, `speichereEinstellung` :156), `test/termineKontext.test.js`.
**Consumes:** C1 (`leerFragebogen`, `normalisiereFragebogen`).
**Produces:** `ctx.fragebogen` (normalisiert; fehlt oder kaputtes JSON ⇒ `leerFragebogen()`, kaputtes JSON mit einer Logzeile, ohne Wurf); `ladeEinstellung(featureId) ⇒ { einstellung, version, fragebogen } | null` (additiv, B und D nutzen es); `kontextFuerPortal(feature) ⇒ Promise<ctx>` (portalFeatures-Dokument in jedem Zustand, kein Modus-/Zustandscheck, den macht der Aufrufer; `kontextPerSiteKey` und `kontextPerFeature` bleiben unverändert); `speichereFragebogen(feature, aendere, { by, now }) ⇒ { fragebogen }` (unter `termine-cfg:<id>`; `aendere(bisher) ⇒ Eingabe`, wird normalisiert, Fehler tragen `err.fehler`; schreibt nur `fragebogen`, `updatedAt`, `updatedBy`, **ohne** `version`; fehlt das termineConfig-Dokument ⇒ 409 `code: 'keine_einstellung'`); neue Option `fragebogen` bei `speichereEinstellung(feature, …, { version, by, now, fragebogen })` schreibt Einstellung und Fragebogen in derselben Sperre (Neuanlage mit Branche, „Vorlage ersetzen“ mit zurückgesetzten Bestätigungen). Beide leeren den Zwischenspeicher.
- [ ] Tests: `speichereFragebogen` ändert `fragebogen`/`updatedAt`/`updatedBy`, `version` und `einstellung` bleiben bytegleich, `ladeEinstellung` liefert den neuen Stand; `speichereEinstellung` ohne Option lässt `fragebogen` stehen; mit Option auf leerer Collection entsteht ein Dokument mit beidem; gleichzeitig `speichereEinstellung` (Version 1) und `speichereFragebogen` ⇒ beide gelten, Version steigt genau einmal; ohne Dokument ⇒ `keine_einstellung`, nichts angelegt; kaputtes Fragebogen-JSON ⇒ leerer Fragebogen und gültige Einstellung; `kontextFuerPortal` liefert für `off`, `ordered`, `setup`, `active`, `disabled` einen Kontext, **`kontextPerSiteKey` für `off`, `ordered`, `setup`, `disabled` mit vollständiger Einstellung `null`** (Review-Focus 3: Entwurf nie öffentlich), nach `activate` den Kontext.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Fragebogen im Kontext und Speicher“
### Task C4a: Entwurfszugriff – lesen in jedem Zustand, GET ohne Dokument, GET-Felder
**Files:** Modify `server/routes/termine.js` (`MELDUNG`, `withTermine`, GET `/einstellungen`), `test/termineRoutes.test.js` (Test „Funktion aus, noch nicht bestellt oder ohne Dokument“ :247-264 wird ersetzt), Create `test/termineFragebogenRoutes.test.js` (Teil 1).
**Consumes:** C1–C3, `aboMemo` (`bestellbar`, E1), `aboMeldung(feature, grund)` aus `featureOrder.js` (B6), `featureConfig` (B), `betriebAusKonto` (bestehend), `analyseHosts`; **E11 bleibt erhalten:** `MELDUNG.abgeschaltet` (Funktion nach Grund), das GET-Feld `abgeschaltet` und `hinweisAbgeschaltet` werden von C4a **nicht** neu geschrieben, sondern übernommen (C4a ersetzt nur die Zweige für `off`/`ordered`/`setup`).
**Produces:** `withTermine({ schreiben, entwurf })`: ohne `entwurf` unverändert (`active|disabled`). Mit `entwurf: true` (nur GET/PATCH `/einstellungen`, POST `/einstellungen/vorlage`, PUT `/fragebogen`): **lesen** in jedem Zustand; Dokument fehlt ⇒ virtueller Kontext `off`, nichts wird angelegt (die Schreibregeln folgen in C4b). Alle anderen Routen behalten die Schranke `active|disabled`. GET liefert zusätzlich `branchen: [{ key, name, gesundheit }]`, `fragebogen`, `kundenansicht: boolean`, in `off`: `abo: { ok, grund? } | null`, `offen: bestellpruefung({ einstellung, fragebogen, feature, abo }) | null` (mit `abo`, also inklusive Punkt (h); bei Lesefehler ohne `abo` ⇒ (h) „unbekannt“), `bestellung: await bestellungAnsicht(…)`; ohne Einstellung `betriebVorschlag: { name, adresse, email, telefon, websiteUrl }` (Konto, Website nur aus `liveDomain`); `einbau.snippet` und `feeds` nur in `active`/`disabled`.
- [ ] Tests (Zustandsmatrix je Zeile Kunde/Kundenansicht, Cookie via `kv()` wie im vorhandenen Kundenansicht-Test): GET ohne Dokument ⇒ 200 `{ state: 'off', einstellung: null, version: 0 }` und `aw.docs('portalFeatures').size` unverändert; GET in `ordered`/`setup` 200, aber `/buchungen`, `/zeiten`, `/export.csv`, `POST /ressourcen/:id/feed`, `POST /buchungen` ⇒ 409 „Der Terminplaner ist für diese Website nicht aktiv.“; **Mandanten (Review-Focus 3): Kunde c2 mit Projekt von c1 ⇒ 403 auf GET**; Abo: ohne Abo GET 200 mit `abo.ok === false`/`grund 'kein_abo'`, `cancelAtPeriodEnd` ⇒ `gekuendigt`; `aw.failNext` auf `subscriptions` ⇒ GET `abo: null` mit offenem Punkt „nicht geprüft“ (Text aus `aboMeldung(…, 'unbekannt')`); `bestellung.consent.knopf` == Registertext; **E11 unverändert:** `disabled` ⇒ `abgeschaltet`-Feld und Meldung nach Grund wie in den E11-Tests (sie bleiben grün).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Entwurfszugriff lesen, GET ohne Dokument, GET-Felder“
### Task C4b: Entwurfszugriff – Schreibregeln, PATCH mit `fehlt`, nie öffentlich
**Files:** Modify `server/routes/termine.js` (`withTermine` Schreibregeln, PATCH `/einstellungen`), `test/termineRoutes.test.js` (PATCH-Antwort mit `fehlt` in :399, die übrigen `fehlt`-Prüfungen :282, :316, :357, :478 bleiben), `test/termineFragebogenRoutes.test.js` (Teil 1b).
**Consumes:** C4a, `laeuft` (E1), `aboMeldung(feature, grund)` aus `featureOrder.js` (B6).
**Produces:** Schreibregeln für `entwurf: true`: `off` und `active` ja, `ordered`/`setup` nur mit `req.session.viewAs` (sonst 409 `code: 'gesperrt'`: „Dein Terminplaner ist bestellt. Die Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.“), `disabled` 409 wie bisher (Text von E11); in `off` zusätzlich `laeuft(customerId)` – **nicht** `bestellbar`: eine Kündigung oder offene Zahlung sperrt nur die Bestellung (Gate (h)), nicht das Speichern des Entwurfs (bewusste Abweichung von Spec 6, im Plan-Kopf vermerkt) –, sonst 409 `code: 'kein_abo'` mit Text aus `aboMeldung`, bei Lesefehler 503 `abo_pruefung` (Entwurf entsteht nur für Kunden mit laufendem Wartungs-Abo). Der erste Schreibzugriff legt das Dokument über `ensureFeatureDoc(customerId, projectId, 'termine')` erst im Handler an, nach der Eingabeprüfung. PATCH ruft `normalisiereEinstellung(…, { entwurf: state !== 'active' })` und antwortet mit `fehlt: fehlendePflichtangaben(…)`.
- [ ] Tests (Zustandsmatrix wie C4a): PATCH `off` ⇒ 200, `active` ⇒ 200, `ordered`/`setup` Kunde ⇒ 409 `gesperrt` (Dokument unverändert), Kundenansicht ⇒ 200 und `termin_einstellungen` protokolliert, `disabled` ⇒ 409 auch in der Kundenansicht; **Mandanten (Review-Focus 3): Kunde c2 mit Projekt von c1 ⇒ 403 auf PATCH, `termineConfig` danach unverändert**; PATCH `off` ohne Telefon/Anschrift ⇒ 200 mit `fehlt: ['betrieb.telefon','betrieb.adresse']`, dieselbe Eingabe in `active` ⇒ 400, ungültige URL in `off` ⇒ weiter 400 (der Entwurf überspringt nur die Pflichtangaben); Abo: ohne Abo PATCH ⇒ 409 `kein_abo`, kein Dokument entstanden; `cancelAtPeriodEnd` oder `past_due` ⇒ PATCH weiter 200 (Entwurf bleibt speicherbar; die Bestellung sperrt Gate (h)); `aw.failNext` auf `subscriptions` ⇒ PATCH 503; öffentlich: für `off`, `ordered`, `setup` mit vollständiger Einstellung liefern `GET /api/public/termine/v1/:siteKey/config` und `POST …/buchungen` `{ aktiv: false }` und der Kalender-Link mit dem richtigen Token 404, nach Aktivierung geht es; **Einbauwünsche nie öffentlich:** `fragebogen.einbau.stelle = 'GEHEIMWUNSCH'` taucht in `JSON.stringify` der öffentlichen Config nicht auf, ebenso nicht `fragebogen`, `einbau`, `knopfText`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Entwurf schreiben, PATCH mit fehlt, nie oeffentlich“
### Task C5: Branche und Vorlage im Entwurf, `PUT /fragebogen`
**Files:** Modify `server/routes/termine.js` (POST `/einstellungen/vorlage` :230-252, neue Route `PUT /fragebogen`), `test/termineFragebogenRoutes.test.js` (Teil 2); die bestehenden Vorlage-Tests in `termineRoutes.test.js` müssen unverändert grün bleiben.
**Consumes:** C1–C4b, `ausVorlage` mit `betrieb`-Feldern (bestehend).
**Produces:** `POST /api/termine/einstellungen/vorlage { projectId, branche | vorlage, betrieb?: { name, adresse, telefon, email, websiteUrl }, ersetzen?: true, version?: int }`: `branche` ⇒ Vorlage laut `BRANCHEN`; unbekannt ⇒ 400; **`gesundheit` ⇒ 400 `code: 'gesundheitsbereich'`, es wird nichts angelegt** (D6, harter Ausschluss); `vorlage` ohne `branche` wie bisher (TP1). Die Betriebsfelder überschreiben nur nichtleere Kontowerte. Eine Einstellung ist nur mit `ersetzen: true`, `version` (muss stimmen, sonst 409 `EINSTELLUNG_VERALTET`) und Zustand `off` ersetzbar (neue IDs und Feed-Token, `fragebogen` neu mit gewählter `branche`, `bestaetigt: []`, `preiseGeprueft: false`, `bundeslandGewaehlt: false`, `einbau` bleibt), in einem Schreibvorgang über die Option `fragebogen` aus C3. Antwort 201 mit `einstellung` (ohne Feed-Token), `version`, `fehlt`, `fragebogen`. `PUT /api/termine/fragebogen { projectId, fragebogen }`: erst `req.session.viewAs` ⇒ 403 „In der Kundenansicht kann nichts bestätigt werden.“, dann `withTermine({ entwurf: true, schreiben: true })` (Abo-Gate in `off`, in `ordered`/`setup`/`disabled` 409), `limitWrites`, `normalisiereFragebogen` (400 mit `fehler`), `speichereFragebogen`; Antwort `{ fragebogen, offen }`; ohne Einstellung 409 `keine_einstellung`; kein Eintrag im Kundenansicht-Protokoll.
- [ ] Tests: Vorlage mit `branche: 'salon'` legt Feature-Dokument `off` (mit dauerhaftem siteKey) und `termineConfig` mit `fragebogen.branche === 'salon'` an; `betrieb.name` überschreibt das Kontowert, leeres Feld nicht; `branche: 'gesundheit'` ⇒ 400, `portalFeatures` und `termineConfig` unverändert (auch ohne vorher vorhandenes Dokument); Vorlage zweimal ⇒ 409; `ersetzen` ohne/mit falscher `version` ⇒ 400/409, in `active` ⇒ 409, in `off` ⇒ neue Leistungen, neue Feed-Token, `bestaetigt` leer, `einbau` erhalten; PUT: speichert, `version` der Einstellung bleibt, `updatedBy` = `kunde:<id>`; zwei Tabs mit offenem Editor (alte `version`) können danach weiter per PATCH speichern (kein 409); ungültiger Modus ⇒ 400 mit `fehler['einbau.modus']`; PUT in `ordered`, `setup`, `disabled` ⇒ 409; **PUT in der Kundenansicht ⇒ 403 `view_blocked` über den Wächter und 403 mit Text direkt am Router (zweite App ohne Wächter)**; Mandant c2 ⇒ 403; Schreibbudget: ein kompletter Durchlauf (1 Vorlage, 8 PATCH, 8 PUT, 5 Editor-Speicherungen) bleibt unter 120 je Stunde, der 121. Aufruf ist 429.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Branche und Vorlage im Entwurf, PUT /fragebogen“
### Task C6: Entwürfe aufbewahren und löschen
**Files:** Create `server/services/termine/entwuerfe.js`, `test/termineEntwuerfe.test.js`. Modify `server/services/termine/jobs.js` (`taeglicherJob` nach `loescheAbgeschaltete`; Rückgabe `entwuerfe`; `tagesbericht` bekommt das Segment `Entwürfe <n> gelöscht (<m> offen)` nach dem Deckel-Segment (A3b) und vor `Abo-Abgleich` (E7); vollständige Zeile: `[termine] Tagesjob: 0 Buchungen gelöscht (verfallen 0, abgesagt 0, abgelehnt 0, erledigt 0, nichterschienen 0, abgeschaltete Funktionen 0), 0 Funktionen bereinigt, 0 Zellen, 0 Outbox, 0 Mails nicht zugestellt, Entwürfe 0 gelöscht (0 offen), Abo-Abgleich 0 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 0 ausgenommen, 0 Fehler, 0 aktive Betriebe`), `server/services/customerAdmin.js` (nach `disableCustomerFeatures`, Fehler nur loggen), `test/termineJobs.test.js` (Test „taeglicherJob: Löschfristen, …“ :867-906 und die festgenagelte Berichtszeile), `test/funktionenTermine.test.js` (Test „Kunde loeschen …“ :173).
**Produces:** `loescheEntwuerfe(now) ⇒ { geprueft, geloescht, uebrig, fehler }` und `loescheEntwuerfeVonKunde(customerId) ⇒ { geloescht }`; Konstanten `ENTWURF_TAGE = 90`, `MAX_GEPRUEFT = 2000`, `MAX_GELOESCHT = 100`. Kandidaten: `portalFeatures` mit `featureKey 'termine'` und `state 'off'`, seitenweise (`limit 100`, `orderAsc('$id')`, `cursorAfter`). Frist: der jüngere Zeitpunkt aus `$updatedAt` des Funktionsdokuments und `termineConfig.updatedAt` ist älter als 90 Tage. Vor jedem Löschen unter `withLocks([feature-order:<kunde>:<projekt>, feature-admin:<projekt>, termine-cfg:<id>])` das Dokument frisch lesen (noch `off`, Frist noch überschritten, keine Buchung zur Funktion), dann `termineConfig` löschen (404 ist ok), das Funktionsdokument nur, wenn es nie in Betrieb oder bezahlt war (kein `activatedAt`, `ticketId`, `disabledAt`, kein `unlockedByPurchase`, `purchaseStatus` weder `paid` noch `zurueckgesetzt`; sonst bleibt es wegen des siteKeys). `zurueckgesetzt` ist die dauerhafte Marke, die E8 nach der Löschung bei Abo-Ende setzt (E8 leert `activatedAt`, `ticketId`, `disabledAt` und `unlockedByPurchase` wieder, damit das Dokument neu bestellbar ist, und lässt den `siteKey` stehen, damit das eingebettete Snippet gültig bleibt): ein so markiertes Dokument verliert höchstens seine Einstellung, **nie** das Funktionsdokument. Nie `ordered`/`setup`. Deckel: nach 100 Löschungen zählt der Rest der Seite als `uebrig`, nach 2000 geprüften Dokumenten endet der Lauf mit einer Logzeile (ohne IDs); gelöschte Kandidaten fehlen am nächsten Tag in der Liste, der Überlauf läuft also an Folgetagen weiter. Einzelfehler zählen in `fehler`, brechen den Lauf nicht ab. Kundenlöschung entfernt alle `off`-Entwürfe des Kunden vollständig (Funktionsdokument und Einstellung).
- [ ] Tests: 89 Tage alt bleibt, 91 Tage ⇒ `termineConfig` und Funktionsdokument weg; frisch bearbeiteter Entwurf (alte `$updatedAt`, neue `termineConfig.updatedAt`, nur `PUT /fragebogen`) bleibt; `ordered`, `setup`, `active`, `disabled` bleiben unabhängig vom Alter; Funktionsdokument mit `activatedAt` verliert nur die Einstellung; **von E8 zurückgesetztes Dokument** (`off`, `purchaseStatus 'zurueckgesetzt'`, sonst alle Spuren leer) behält nach 91 Tagen ohne Änderung sein Dokument und den `siteKey` unverändert (eine vorhandene Einstellung wird gelöscht); nach fehlgeschlagener Zahlung (`purchaseStatus: 'failed'`) ist der Entwurf nach 90 Tagen weg; **Rennen:** der Test hält `withLocks(['feature-order:<kunde>:<projekt>'])`, startet `loescheEntwuerfe`, schreibt das Dokument auf `ordered` und gibt die Sperre frei ⇒ der Lauf liest frisch und löscht nichts; Buchungen zur Funktion ⇒ nichts gelöscht; 130 Kandidaten ⇒ erster Lauf `geloescht 100, uebrig 30`, zweiter `30/0`; Einzelfehler (`aw.failNext` DELETE) ⇒ `fehler 1`, die übrigen werden gelöscht; Tagesjob enthält `entwuerfe`, die Berichtszeile hat keine IDs, kein `@`, keine Namen; bei `TERMINE_ENABLED=off` läuft der Schritt nicht (Entwürfe sind nie bezahlt; bei der Aufteilung von `fuehreAus` durch Block E gehört er zum stillstehenden Teil); Kunde löschen entfernt seine `off`-Entwürfe samt Einstellung, die eines anderen Kunden nicht, ein Fehler dabei hält das Löschen nicht auf (Logzeile).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Entwuerfe nach 90 Tagen und bei Kundenloeschung entfernen“
### Task C7a: Client-Bausteine 1 – reine Helfer in `termine-view.js`
**Files:** Modify `public/termine-view.js` (reine Helfer), `test/termineView.test.js` (Dateiliste, „Spiegel“-Tests), Create `test/termineFragebogenView.test.js`.
**Produces:** in `termine-view.js`: `FRAGEBOGEN_SCHRITTE = [[key, titel]]` (Spiegel von `SCHRITTE`), `schrittStatus(fragebogen) ⇒ [{ key, titel, erledigt }]`, `ersterOffenerSchritt(fragebogen) ⇒ key | 'bestellen'`, `ressourcenAusNamen({ bisher, typ, namen, neueId }) ⇒ { ressourcen, entfernt, neu }` (trimmt, entfernt Doppelte ohne Rücksicht auf Groß-/Kleinschreibung, höchstens `LIMITS.ressourcen`; gleiche Namen behalten ID und Plan, übrige alte Einträge werden in Reihenfolge umbenannt, weitere Namen kopieren den Plan des ersten, `typ` gilt für alle), `buchungsartFuerAlle(leistungen, art)`, `buchungsartLage(leistungen) ⇒ 'sofort'|'anfrage'|'gemischt'`, `zusammenfassung(einstellung, fragebogen, branchen) ⇒ [{ schritt, titel, zeilen: [string] }]` (acht Gruppen, nur Text). **Kein** `loeschDatum`: Löschdatum und Abschalt-Texte gehören E11 (`abschaltText`).
- [ ] Tests: `FRAGEBOGEN_SCHRITTE` gleich `SCHRITTE` des Servers (Spiegel-Test), `schrittStatus`/`ersterOffenerSchritt` für leer, teilweise, vollständig; `ressourcenAusNamen`: „Stylist/in“ → „Anna“ behält ID und Plan, zweiter Name `Ben` bekommt neue ID und den Plan von Anna, Entfernen liefert die IDs, Doppelte/Leere fallen weg, über 20 Namen werden gekürzt, `typ: 'platz'` gilt für alle, Ergebnis besteht `normalisiereEinstellung` (mit Leistungen, die allen zugeordnet sind); `buchungsartFuerAlle` ändert nur `buchungsart`; `zusammenfassung` für die Vorlage `salon` enthält Name, Leistungen mit Preis, Wochenplan, Bundesland nur bei `bundeslandGewaehlt`, nie `feedToken`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Fragebogen-Helfer im Client“
### Task C7b: Client-Bausteine 2 – Exporte der Reiter, `wochenplanFeld`, `fehlt`
**Files:** Modify `public/termine-tab-einstellungen.js` (Abschnitte exportieren), `public/termine-tab-team.js` (Wochenplan-Feld herauslösen), `public/termine-speichern.js:116`, `public/termine.js` (nur `ctx`-Bau herauslösen, Verhalten gleich), `test/termineView.test.js`, `test/termineFragebogenView.test.js` (statische Teile und Verhalten); Create `test/support/fakeDom.js`.
**Consumes:** C7a.
**Produces:** Exporte aus `termine-tab-einstellungen.js`: `betrieb(ctx, e, { nur, titel, hinweis })` (`nur` = Teilmenge von `name, email, telefon, adresse, kleinunternehmer, impressumUrl, datenschutzUrl, agbUrl, websiteUrl`, `lese` schreibt nur diese Felder; ohne `nur` wie bisher), `darstellung`, `regeln`, `gastFelder`, `erinnerung`, `storno`, `rechtliches`, `zahlung`. Aus `termine-tab-team.js`: `wochenplanFeld(r, pfad) ⇒ { el, lese() ⇒ { plan } | { fehler } }` (die Tagesfelder :81-86 und der Wochenplan-Block :108-111 aus `formular`, das ihn danach selbst nutzt). `termine-speichern.js` übernimmt `fehlt: antwort.fehlt ?? []` statt `[]`. Testhilfe `test/support/fakeDom.js`: `installFakeDom({ antworten = [] } = {}) ⇒ { document, fetchLog, finde(baum, vergleich), alle(baum, vergleich), text(baum), klick(el), setze(el, wert), restore() }` – ein Minimal-`document` auf `globalThis` (`createElement`, Elemente mit `setAttribute`, `append`, `addEventListener`, `dataset`, `className`, `value`, `checked`, `children`) und ein Fake-`fetch`, das jeden Aufruf als `{ method, url, body }` in `fetchLog` schreibt und die nächste Antwort aus `antworten` (`{ status, body }`, Standard 200 `{}`) liefert; `h` aus `termine-dom.js` liest `document` bei jedem Aufruf, deshalb braucht der Client keine Einhängepunkte. C8, C9a, C9b und C10b nutzen dieselbe Datei.
- [ ] Tests: statisch: `termine-speichern.js` setzt `fehlt` aus der Antwort; `termine-tab-einstellungen.js` exportiert die acht Abschnitte, `betrieb` ohne `nur` baut weiter alle neun Felder, `wochenplanFeld` wird von `termine-tab-team.js` selbst benutzt (gemeinsame bestehende UI-Skripte `termine-portal.ui.mjs` laufen unverändert grün). **Verhalten mit Fake-DOM** (`termineFragebogenView.test.js`): (a) `betrieb(ctx, e, { nur: ['email'] })` baut nur das Feld `email`, `lese()` liefert nur `{ email }` (Schlüsselmenge geprüft); ohne `nur` entstehen weiter alle neun Felder; (b) `wochenplanFeld(r, pfad)`: gültige Fenster in zwei Tagesfeldern ⇒ `lese()` liefert `{ plan }` mit genau diesen Fenstern, ein ungültiges Fenster ⇒ `{ fehler }` mit Text, ein leerer Tag ⇒ geschlossen; (c) `termine-speichern.js`: antwortet der Fake-`fetch` auf den PATCH mit `fehlt: ['betrieb.telefon']`, enthält das Ergebnis des Speicherns `fehlt: ['betrieb.telefon']` (ohne `fehlt` in der Antwort `[]`).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Reiter-Exporte und wochenplanFeld fuer den Fragebogen“
### Task C8: Bestellseite und Statuskarten (`funktion-bestellen.js`)
**Files:** Create `public/funktion-bestellen.js` (heißt bewusst nicht `termine*.js`: einzige Datei mit `href`-Links, wird relativ importiert, Regel R4 bleibt; **diese** Datei entsteht hier, F8 ändert sie nicht), `test/termineFragebogenView.test.js` (Teil), Modify `public/termine.css` (`tm-bestellen*`, `tm-rechtstext`, `tm-statuskarte`).
**Consumes:** C2 (`bestellung` aus `GET /api/termine/einstellungen`), C7a (`zusammenfassung`), B (`POST /api/funktionen/termine/checkout`), `h`, `request` aus `termine-dom.js`.
**Produces:** `sicherLink(url) ⇒ string` (nur `https:` oder Pfade unter `/rechtliches/`, sonst `''`); `bestellKoerper({ projectId, bestellung, gewaehlt }) ⇒ { projectId, zustimmung: { version, haken } }` (`version` = `bestellung.consent.version`, `haken` = die angehakten IDs der Items mit `haken: true`, nie Texte; das ist der Vertrag aus B6); `fortsetzenKoerper(projectId) ⇒ { projectId, fortsetzen: true }`; `renderBestellen(root, { ctx, daten, zumSchritt })` und `statusKarte({ projekt, bestellung, einrichtung, danke, kundenansicht }, { weiter })`. Es gibt **keine** Knopftext-Konstante im Client: der Knopf zeigt `bestellung.consent.knopf` (derselbe Text, den B7 als Eintrag `knopf` im Nachweis speichert; Review Focus 5: gezeigter Text = gespeicherter Text). Reihenfolge auf der Seite: Zusammenfassung (je Gruppe Knopf „Ändern“ ⇒ `zumSchritt(key)`), offene Punkte aus `daten.offen` (je Punkt „Zum Schritt“), Preis mit `bestellung.preisHinweis` und Einrichtungsdauer, die **vollständige Widerrufsbelehrung samt Muster-Formular** aus `bestellung.belehrung` als Text (scrollbarer Bereich, **nie eingeklappt, kein `<details>`**; der Text kommt vom Server: bis F7 der Platzhalter `BELEHRUNG_PLATZHALTER` (B7), ab F7 die Belehrung; F8 ändert diese Datei nicht), Links aus `bestellung.dokumente` (versionierte Adressen, nur über `sicherLink`), die Zustimmungs-Haken in Registerreihenfolge (Wortlaut aus `bestellung.consent.items`; Items mit `haken: true` sind Haken, andere reiner Text), zuletzt der Knopf. Knopf gesperrt, solange ein Haken fehlt, `offen` nicht leer ist oder `kundenansicht` (dann Hinweis „In der Kundenansicht kann nicht bestellt werden.“); ein Klick sperrt ihn, sendet `bestellKoerper` und setzt `window.location.href` nur auf eine `https:`-Adresse; bei Fehler Meldung am Knopf, bei 409 lädt die Seite `daten` neu und setzt die Haken zurück: `bestellung_offen` zeigt die Punkte aus `body.offen`, `fassung_veraltet` die neue Fassung aus `body.consent`, ein Abo-Code den Text aus der Antwort. `statusKarte`: `ordered` ⇒ Pille „Zahlung offen“, Bestelldatum, Knopf „Zahlung fortsetzen“ (POST `fortsetzenKoerper`, Navigation zur offenen Sitzung; antwortet die Route 409 `zustimmung_fehlt`, lädt die Seite neu und zeigt wieder das Bestellformular; Hinweis, dass SEPA einige Tage dauern kann), Chat-Knopf; `setup` ⇒ „In Einrichtung“, Ticketnummer (`ticketWoid`, ohne Ticket „Wir legen dein Ticket gerade an.“), Bestelldatum, Rechnungslink über `sicherLink`, Zeiteinschätzung, Hinweis „Deine Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.“; `danke` (Rückkehr von Stripe) zeigt „Danke! Sobald die Zahlung da ist, legen wir los.“
- [ ] Tests (Node, ohne DOM): `sicherLink` lässt `https://x.de/a` und `/rechtliches/avv-terminplaner-v1.html` durch, weist `javascript:`, `data:`, `http:`, `//evil.example` und Leerraum-Tricks ab; `bestellKoerper` liefert **genau** `{ projectId, zustimmung: { version, haken } }` mit nur den angehakten IDs der `haken: true`-Items (kein `consent`, keine Texte; die gesendeten IDs sind Teilmenge der Register-IDs, Querprüfung gegen `featureConfig('termine').consent.items` in `termineFragebogenView.test.js`); `fortsetzenKoerper` liefert `{ projectId, fortsetzen: true }` ohne `zustimmung`; statisch (wie die `termine*.js`-Regeln): kein `innerHTML`/`confirm`/`alert`, **kein Literal „Zahlungspflichtig“ im Client** (der Knopftext kommt aus `bestellung.consent.knopf`), Du-Form im Code (die Rechtstexte kommen vom Server), jedes `href` nur über `sicherLink(`, `location.href =` nur nach Prüfung auf `https:`, kein `localStorage`, kein `<details>` für die Belehrung; Datei ist in `termine*.js` nicht enthalten und wird von keinem absoluten Import geladen (`funktionen.js` behält genau `/analyse-view.js` und `/geraete.js`); Reihenfolge im erzeugten Baum (Fake-DOM aus C7b): Belehrungstext steht vor dem Knopf; Knopftext == `bestellung.consent.knopf` (Integration „Knopftext == Registertext == gespeicherter `knopf`-Eintrag“ in C11).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktionen: Bestellseite und Statuskarten“
### Task C9a: Fragebogen-Oberfläche 1 – Gerüst und Schritte 1 bis 3
**Files:** Create `public/termine-fragebogen.js`, Modify `public/termine.css` (`tm-schritte`, `tm-schritt`, Handy-Ansicht), `test/termineView.test.js` (Dateiliste: `termine-fragebogen.js` zwischen `termine-formulare.js` und `termine-gaestetexte.js`; neue Regeln unten), `test/termineFragebogenView.test.js` (Verhalten mit Fake-DOM aus C7b).
**Consumes:** C4a/C4b, C5 (Routen), C7a/C7b, C8, `neuerSpeicher` (einziger PATCH-Weg), `render` aus `termine-tab-leistungen.js`.
**Produces:** `renderFragebogen(root, ctx)` (ctx wie bei den Reitern, `hinweise` leer): Fortschrittsleiste `<ol>` mit `aria-current="step"` (Handy: „Schritt 3 von 8“ + Liste), Start beim ersten offenen Schritt (alles erledigt ⇒ Bestellseite), freie Navigation, ein `<form>` je Schritt, „Weiter“ = **ein** PATCH über den `neuerSpeicher` des Schritts (nur wenn der Schritt Felder hat) und danach `PUT /fragebogen` mit dem Schritt in `bestaetigt`; Fehler am Feld (`setzeFehler`), Fokus auf die Schrittüberschrift beim Wechsel, Hinweis „Wir speichern deinen Entwurf bis zu 90 Tage nach deiner letzten Änderung.“ Schritte 1 bis 3:
1 **Branche und Betrieb:** ohne Einstellung Radiogruppe aus `daten.branchen` plus `betrieb(…, { nur: ['name','adresse','kleinunternehmer'] })` mit `betriebVorschlag`, „Mit dieser Branche starten“ ⇒ `POST vorlage { branche, betrieb }` ⇒ PUT; **„Gesundheitsbereich“ ⇒ Erklärtext statt Weiter, es wird keine Anfrage gesendet**; mit Einstellung: „Branche wechseln“ mit Rückfrage in der Seite („ersetzt Leistungen, Zeiten und Regeln, alle Bestätigungen gehen zurück“) ⇒ `ersetzen: true` + `version`.
2 **Wer wird gebucht:** Einzelperson / Team / Plätze, Namen je Zeile (`ressourcenAusNamen`); neue Einträge werden im selben PATCH allen Leistungen zugeordnet, entfernte aus Leistungen und Abwesenheiten genommen; mindestens ein Name.
3 **Leistungen:** `render` aus `termine-tab-leistungen.js` unverändert (speichert selbst) plus Haken „Ich habe die Preise geprüft (die Preise der Vorlage sind Platzhalter)“ ⇒ `preiseGeprueft`; Weiter braucht eine sichtbare Leistung und bei Preisen den Haken; nur PUT.
`daten.kundenansicht === true`: PATCH bleibt möglich, „Weiter“ geht ohne PUT weiter, die Bestellseite ist gesperrt (Review-Focus 3, Kundenansicht bestätigt nichts). Nach Schritt 8 (C9b) folgt `renderBestellen`.
- [ ] Tests: statisch in `termineView.test.js`: Datei in der Liste, kein `innerHTML`, Du-Form, kein `href:` (der einzige bleibt `exportUrl`), `'PATCH'` weiter nur in `termine-speichern.js`, `'PUT'` nur in `termine-fragebogen.js`, kein Zugriff auf `localStorage`; Eintrag per ID statt Stelle (bestehende Regel gilt auch hier); `renderFragebogen` wird nur über `termine.js` geladen. **Verhalten in Node** (`test/termineFragebogenView.test.js`, Fake-DOM und Fake-`fetch` aus C7b): (a) Schritt 1, Auswahl „Gesundheitsbereich“: der Erklärtext steht im Baum, es gibt keinen Knopf „Mit dieser Branche starten“ und kein „Weiter“, `fetchLog` bleibt leer (kein einziger Aufruf); (b) „Weiter“ in einem Schritt mit Feldern (Schritt 7): `fetchLog` ist genau `[PATCH /api/termine/einstellungen, PUT /api/termine/fragebogen]` in dieser Reihenfolge, der PUT-Body enthält `projectId` und `fragebogen.bestaetigt` mit dem Schritt; antwortet der PATCH mit 400 und `fehler`, folgt **kein** PUT und der Fehler steht am Feld (`data-fehler`); (c) Schritt 3 mit Preisen ohne den Haken „Preise geprüft“ ⇒ „Weiter“ meldet den Fehler und ruft nichts auf, mit Haken ⇒ nur ein PUT (kein PATCH) mit `preiseGeprueft: true`; (d) `daten.kundenansicht === true`: „Weiter“ sendet den PATCH, aber **keinen** PUT und wechselt trotzdem zum nächsten Schritt; (e) Start beim ersten offenen Schritt (`aria-current="step"` am richtigen Listenpunkt); (f) Schritt 2 mit den Namen „Anna“ und „Ben“ ⇒ der PATCH-Body enthält beide Ressourcen und jede Leistung ist beiden zugeordnet. Browser-Layout und echter Server in C11 (Playwright), reine Teile in C7a.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Fragebogen-Geruest und Schritte 1 bis 3“
### Task C9b: Fragebogen-Oberfläche 2 – Schritte 4 bis 8 und Bestellseite
**Files:** Modify `public/termine-fragebogen.js`, `public/termine.css`, `test/termineView.test.js` (zusätzliche Importregeln), `test/termineFragebogenView.test.js` (Verhalten mit Fake-DOM aus C7b).
**Consumes:** C9a, C7b (`wochenplanFeld`, `betrieb`, `darstellung`, `regeln`, `gastFelder`, `erinnerung`, `storno`, `rechtliches`, `zahlung`), C8 (`renderBestellen`), `abwesenheiten`/`sonderoeffnungen` aus `termine-team-ausnahmen.js`.
**Produces:** Schritte 4 bis 8:
4 **Zeiten:** je Person/Platz `wochenplanFeld`, Bundesland **ohne Vorauswahl** (leere erste Option) und „An Feiertagen geschlossen“, im PATCH; danach PUT mit `bundeslandGewaehlt: true`; darunter `abwesenheiten(t)` und `sonderoeffnungen(t)` (speichern selbst, mit eigenem Container und eigenem `zeichne`, damit ungespeicherte Zeiten oben beim Neuzeichnen nicht verloren gehen).
5 **Regeln:** Sammelknopf „Alle sofort buchbar / Alle nur als Anfrage“ (`buchungsartFuerAlle`, Anzeige `buchungsartLage`), `regeln`, `storno(ctx, e, regeln.absage)` (das Muster folgt der Absagefrist), `rechtliches`, `zahlung`.
6 **Gastfelder:** `gastFelder`. 7 **Benachrichtigung:** `betrieb(…, { nur: ['email'] })` (einzige Adresse, auch Reply-To), `darstellung`, `erinnerung`. 8 **Website:** `betrieb(…, { nur: ['telefon','impressumUrl','datenschutzUrl','agbUrl','websiteUrl'] })` plus Einbauwünsche (Modus „direkt auf der Seite / hinter einem Knopf“, Knopftext bei Knopf, Stelle, Gestaltung, Bemerkung; Hinweis, dass ein zweites Widget, Knöpfe je Leistung und Sonderdesign nach Stunden gehen) im PUT. Danach `renderBestellen`.
- [ ] Tests: statisch in `termineView.test.js`: `termine-fragebogen.js` importiert `abwesenheiten`/`sonderoeffnungen` aus `termine-team-ausnahmen.js` und `wochenplanFeld` aus `termine-tab-team.js`, kein zweiter Zugriff auf `'PATCH'`/`'PUT'` außerhalb der erlaubten Dateien, Bundesland-Auswahl hat keine Vorauswahl (leere erste Option im Quelltext). **Verhalten in Node** (`test/termineFragebogenView.test.js`, Fake-DOM und Fake-`fetch`): (a) Schritt 4: die erste Option des Bundesland-`select` hat `value ''` und keine Option ist `selected`; „Weiter“ ohne Wahl ⇒ Fehler am Feld, `fetchLog` leer; mit Wahl `RP` ⇒ PATCH (`bundesland: 'RP'`, Wochenplan aus `wochenplanFeld`), danach PUT mit `bundeslandGewaehlt: true` und `bestaetigt` mit `zeiten`; (b) Schritt 7: der PATCH enthält aus `betrieb` nur `email` (Schlüsselmenge geprüft), Schritt 8 nur `telefon`, `impressumUrl`, `datenschutzUrl`, `agbUrl`, `websiteUrl`; der PUT enthält `einbau` mit `modus`, `knopfText`, `stelle`, `gestaltung`, `bemerkung`, bei Modus „hinter einem Knopf“ ohne Knopftext ⇒ Fehler am Feld und kein Aufruf; (c) nach Schritt 8 zeigt `renderBestellen` die Bestellseite (Knopfbeschriftung == `bestellung.consent.knopf`). Browser-Layout und echter Server in C11 (Playwright).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Fragebogen-Schritte 4 bis 8 und Bestellseite“
### Task C10a: Kachel mit Bestellweg und Abo-Felder in `GET /api/funktionen`
**Files:** Modify `public/funktionen.js` (`termineTile` :151-180, `pickTermineProject`), `server/routes/funktionen.js:109`, `test/funktionenTiles.test.js` (Tests ab :82 „Aus ⇒ …“ und „Abgeschaltet“), `test/funktionenTermine.test.js` (Test „GET /api/funktionen: termine.verfuegbar …“ :144), `test/ui/termine-portal.ui.mjs` (Prüfungen `fx_kachel_aus_aktiv_abgeschaltet` :306 und `fx_projektwahl` :377 pinnen die alten Texte).
**Consumes:** E1 (`bestellbar`, `aboMeldung` über `featureOrder.js`, B6), E11 (Texte der Abschaltung bleiben unberührt), B (`featureConfig('termine')`).
**Produces:** `GET /api/funktionen` ⇒ `termine: { verfuegbar: false }` unverändert; bei `true` zusätzlich `bestellbar: boolean|null`, `grund: 'kein_abo'|'gekuendigt'|'nicht_aktiv'|null`, `preis` (`priceLabel`), `einrichtung` (`setupEstimate`); ein Lesefehler im Abo ergibt `bestellbar: null` und kostet die Liste nichts. **Kachel** (Pille / Knopf): `off` mit Abo ⇒ „Aus“ / „Terminplaner einrichten · 149 €“, mit vorhandenem Entwurf (Funktionsdokument da, `siteKey` gesetzt) „Fragebogen fortsetzen“; `off` ohne Abo ⇒ kein Knopf, „Im Wartungs-Abo enthalten. Zu Verträge“ (`kein_abo`), „Dein Wartungs-Abo ist gekündigt …“, „… gerade nicht aktiv …“, bei `bestellbar === null` „Das Abo konnte gerade nicht geprüft werden.“; `ordered` ⇒ „Zahlung offen“ / „Status ansehen“; `setup` ⇒ „In Einrichtung“ / „Status ansehen“; `active` ⇒ „Aktiv“ / „Termine öffnen“; `disabled` ⇒ „Abgeschaltet“ / „Termine ansehen“. Zielprojekt: aktiv, dann `ordered`/`setup`, dann abgeschaltet, dann Entwurf, dann erstes.
- [ ] Tests: `funktionenTermine`: `{ verfuegbar: false }` exakt wie bisher, bei `true` die vier neuen Felder für Kunde mit Wartungs-Abo, ohne Abo (`kein_abo`), mit `cancelAtPeriodEnd` (`gekuendigt`), Abo-Lesefehler (`bestellbar: null`, Projekte kommen trotzdem); `funktionenTiles` (Node, `fetch`-Attrappe wie bisher): jede Zeile der Kacheltabelle mit Pillenklasse und Knopftext, `ordered`/`setup` ohne Zahl-Abfrage an `/api/termine`, Mehrprojekt-Auswahl, Entwurf ⇒ „Fragebogen fortsetzen“, kein `data-view="termine"` ohne Abo, Projektname maskiert; die E11-Tests zu `disabled` bleiben unverändert grün; die zwei Playwright-Prüfungen oben sind auf die neuen Kachel-Texte angepasst und grün.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Kachel mit Bestellweg, Abo-Felder in der Funktionen-Liste“
### Task C10b: Ansicht `#termine` nach Zustand und Rückkehr von Stripe
**Files:** Modify `public/termine.js` (`zeigeProjekt` :75-101, `initTermine`, Reiterliste), `test/funktionenTiles.test.js` (Rückkehr-Tests), `test/termineFragebogenView.test.js` (Ansicht je Zustand mit Fake-DOM aus C7b), `test/ui/termine-portal.ui.mjs` (Fixture um `abo`, `branchen` erweitern).
**Consumes:** C4a (GET-Felder), C8, C9a/C9b, C10a (Kachel führt in die Ansicht), E1 (`bestellbar`), E11 (Banner und Texte der Abschaltung bleiben unberührt), B (`featureConfig('termine')`).
**Produces:** **Ansicht** (`zeigeProjekt`): `off` ⇒ ohne `abo.ok` Hinweis wie in der Kachel (kein Fragebogen), sonst `renderFragebogen`; `active` ohne Einstellung weiter die TP1-Vorlagenauswahl; `ordered`/`setup` ⇒ `statusKarte` und darunter die Reiter Leistungen, Team & Plätze, Einstellungen schreibgeschützt (`nurLesen`, der Reiter „Termine“ entfällt, er bekäme 409); `disabled` ⇒ unverändert der Banner aus E11 (`hinweisAbgeschaltet`/`abschaltText`, Löschdatum aus `abgeschaltet.loeschungAm`); C10b baut **keine** eigenen Abschalttexte und kein eigenes Löschdatum. Rückkehr `#termine?bestellt=1&projekt=<id>` (B2 `success_url`) **und** Abbruch `#termine?projekt=<id>` (B2 `cancel_url`): der Parameter `projekt` wird in beiden Fällen ausgewertet (Projekt wählen), `danke` nur mit `bestellt=1`; Flag und Projekt einmal lesen, Hash per `history.replaceState` bereinigen (wie `initNews`).
- [ ] Tests: **Ansicht je Zustand** (Fake-DOM und Fake-`fetch` aus C7b, der Fake liefert die GET-Antwort von `/api/termine/einstellungen`): `off` mit Abo ⇒ `renderFragebogen` (Fortschrittsleiste im Baum), `off` ohne `abo.ok` ⇒ Hinweis und **kein** Fragebogen, `active` ohne Einstellung ⇒ TP1-Vorlagenauswahl, `ordered`/`setup` ⇒ `statusKarte` und die schreibgeschützten Reiter ohne Reiter „Termine“, `disabled` ⇒ Banner aus E11 (`hinweisAbgeschaltet`) mit dem Löschdatum aus `abgeschaltet.loeschungAm`; Rückkehr: `#termine?bestellt=1&projekt=p2` wählt p2 und zeigt `danke`, `#termine?projekt=p2` (Abbruch) wählt p2 **ohne** `danke`, in beiden Fällen ist der Hash bereinigt; die E11-Tests zu `disabled` bleiben unverändert grün; Playwright-Fixture-Skripte angepasst und grün.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Ansicht nach Zustand und Rueckkehr von Stripe“
### Task C11: UI-Testserver für Entwürfe und Playwright „Fragebogen bis Bestellklick“
**Files:** Modify `test/ui/termineTestServer.mjs` (Schema um `subscriptions`, `featureConsents`; `zuruecksetzen({ entwurf: true })`: kein Funktionsdokument, keine Einstellung, Wartungs-Abo `wartungDoc` für `c1`; Aufräumliste um beide Collections; `srv.abo(zustand)` und `srv.kundenansichtCookie()`), Create `test/ui/termine-fragebogen.ui.mjs`. Nicht Teil von `npm test`; vorher `free -m`, Aufruf `nice node test/ui/termine-fragebogen.ui.mjs`, Bilder nach `/root/reports/terminplaner-ui/fragebogen-*.png`.
**Consumes:** C4a–C10b, B (Collection `featureConsents` über `feature-setup.mjs`/`schemaKeys`, Checkout-Route), E (`aboStatus`), fake Stripe (`checkout.stripe.test`, wird per `page.route` abgefangen, kein Netz).
Prüfungen (je eine, nacheinander, `check()`-Rahmen wie in `termine-portal.ui.mjs`: fester Browser-Takt Montag 2026-10-05 10:00, Portal-Cookie `srv.portalCookie()`, Seitenfehler und Browser-Dialoge sind Fehler, Hosts außerhalb `127.0.0.1`/`localhost`/`checkout.stripe.test` sind Fehler):
- [ ] `ohne_abo`: Kachel „Im Wartungs-Abo enthalten“, Ansicht zeigt den Hinweis, es gibt keinen Fragebogen, `portalFeatures` bleibt leer.
- [ ] `gesundheit_blockt`: Kachel „Terminplaner einrichten · 149 €“ ⇒ Branche „Gesundheitsbereich“ ⇒ Erklärtext, kein „Weiter“, **kein einziger Schreibaufruf**, kein Dokument in Appwrite.
- [ ] `fragebogen_komplett_bis_bestellklick`: Branche „Friseur und Salon“ (Name/Anschrift aus dem Konto) ⇒ Start legt Funktionsdokument `off` und Einstellung an, Schritte 2 bis 8 ausfüllen (Team mit zwei Namen, Preise geprüft, Bundesland „Rheinland-Pfalz“ gewählt, „Alle sofort buchbar“, Telefon, Impressum, Datenschutz, Modus „hinter einem Knopf“ mit Text) und nach jedem „Weiter“ steht der Schritt in `fragebogen.bestaetigt` (aus `termineConfig` gelesen), `version` der Einstellung steigt nur durch PATCH; Bestellseite: offene Punkte leer, Knopf gesperrt, **Widerrufsbelehrung und Muster-Formular stehen im DOM vor dem Knopf** (`compareDocumentPosition`; bis F7 der Platzhaltertext `BELEHRUNG_PLATZHALTER`, B7), nach allen Haken aktiv; Klick ⇒ Navigation zu `https://checkout.stripe.test/c/pay/cs_test_…`; danach Funktionsdokument `ordered` mit `stripeSessionId`, genau ein `POST /v1/checkout/sessions` mit `metadata.featureKey 'termine'` und `projectId`, ein `featureConsents`-Dokument mit den gezeigten Texten (Haken, Belehrung, **Knopf**: gespeicherter `knopf`-Eintrag == `bestellung.consent.knopf` == Beschriftung des geklickten Knopfes), Fassungen und Zeit, gespeichert **bevor** die Navigation lief (Zeitstempel kleiner als der Aufruf der Stripe-Attrappe; die Sitzung selbst entsteht davor, B7).
- [ ] `offene_punkte_sperren`: Fragebogen mit fehlendem Impressum ⇒ Bestellseite listet den Punkt mit „Zum Schritt“, der Knopf bleibt gesperrt, Klick auf den Punkt öffnet Schritt 8; ohne Bundesland ⇒ Punkt im Schritt „Zeiten“, obwohl der Serverstandard `RP` gespeichert ist.
- [ ] `rueckkehr_und_zahlung_fortsetzen`: Seed `ordered` mit Stripe-Sitzung **und** dem `featureConsents`-Dokument dieser Sitzung; Seite `#termine?bestellt=1&projekt=p1` ⇒ Statuskarte „Zahlung offen“ mit „Danke!“, Hash bereinigt, Einstellungen schreibgeschützt (Felder gesperrt, Reiter ohne „Termine“); „Zahlung fortsetzen“ (Body `{ projectId, fortsetzen: true }`) ⇒ Navigation zur selben Sitzung, es gibt weiterhin genau eine Checkout-Sitzung und weiterhin genau ein `featureConsents`-Dokument; ohne das `featureConsents`-Dokument (Seed) ⇒ keine Navigation, die Seite zeigt wieder das Bestellformular (409 `zustimmung_fehlt`); Abbruch-Rückkehr `#termine?projekt=p1` ohne „Danke!“; ein PATCH direkt am Router ⇒ 409 `gesperrt`; Zustand `setup` per Seed ⇒ Karte mit Ticketnummer und Rechnungslink.
- [ ] `entwurf_bleibt_beim_neuladen`: nach Schritt 4 Seite neu laden ⇒ Fragebogen startet bei Schritt 5, Eingaben der Schritte 1 bis 4 stehen in den Feldern; Branche wechseln mit Rückfrage ⇒ Bestätigungen zurückgesetzt, `einbau` bleibt.
- [ ] `kundenansicht`: Portal mit Kundenansicht-Cookie (`srv.kundenansichtCookie()`, neu, baut auf `kundenansichtCookie` aus `test/support/fixtures.js`; der Wächter hängt im UI-Testserver nicht, geprüft wird die Absicherung am Router selbst) ⇒ Schritte lassen sich lesen und per „Weiter“ durchklicken, `fragebogen.bestaetigt` bleibt unverändert, Bestellknopf gesperrt mit Hinweis, `PUT /fragebogen` direkt ⇒ 403.
- [ ] `handy_390`: Fortschritt „Schritt n von 8“, keine seitliche Rollleiste (`scrollWidth <= innerWidth`) auf Schritt 1, 4, 8 und Bestellseite; Bilder.
- [ ] FAIL → implementieren (nur Testserver und Skript, keine Produktivcode-Änderung) → alle Prüfungen grün → vorhandene UI-Skripte `termine-portal.ui.mjs` und `funktionen.ui.mjs` einmal nacheinander mit durchlaufen lassen → Commit „Terminplaner TP2: UI-Testserver Entwurf und Playwright Fragebogen bis Bestellklick“
**Abschlussprüfung Block C (Controller):** `npm test` komplett grün (`test/previewDevRouter.test.js` bei Ausreißer einzeln wiederholen); Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): `docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)'` (`analyticsContract.test.js` importiert `../analytics/src` und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: `git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob'` bleibt leer. Playwright-Läufe (`termine-fragebogen.ui.mjs`, `termine-portal.ui.mjs`, `funktionen.ui.mjs`) nacheinander, vorher `free -m`, mit `nice`, nicht Teil von `npm test`.
---
## Block D: Ticket, Einbauanleitung, WOMS
Aus Einstellungen und Fragebogen entsteht eine reine, deterministische Einbauanleitung (`einbauanleitung.js`): vollständig im WOMS-Panel mit Kopierknöpfen, als Kurzfassung (≤ 7000 Zeichen) in `ticket.details`. Die Freischaltung über das Register verallgemeinert Block B (B11, Termine: Einstellung vorhanden **und** Abo läuft); D liefert die Hindernis-Codes (`termine/freischaltung.js`) und die Team-Aktionen „Freischalten (Abnahme)“ und „Ticket nachlegen“. Das Ticket selbst (`createFeatureTicket`: Deckel, gezielte Wiederholung, Notfall-Details) gehört Block B (B8); ein eigener D5 entfällt. Nie `feedToken` in Ausgaben, Kundentexte nur als Text, kein Termine-Modus-Gate und kein Zugriff aus der Kundenseite.
Vorbedingungen aus früheren Blöcken: B (`featureConfig`, `config.features.termine.{ priceLabel, ticket, ticketFreischaltung }`, B8 `ticketTexte`/`anleitungsLink`/`kuerze`/`TICKET_DETAILS_MAX` in `featureRegistry.js` und das Gerüst `termine/ticketDetails.js`, B9b T16 mit Block `gesundheit`, B9a K15/K16, B11 `activateClosedTickets` samt Standardhaken, `markPaid` ruft `ticket.topic/details`, K16/T16 mit `{{funktion}}`), C (`termineConfig.fragebogen`, `gesundheitsWarnungen`), E (`aboStatus.laeuft`, `aboMemo`). Reihenfolge D1 → D10 (D1–D8 Portal, D9–D10 WOMS; D3a vor D3b; D5 entfällt).
### Task D1: Datenschutz-Absatz als Rechtstext-Baustein
**Files:** Modify `server/services/termine/rechtstexte.js` (neu `datenschutzAbsatz`, `RECHTSTEXTE_ENTWURF`; Kopfkommentar „ohne config“ ersetzen durch „nur Hilfsfunktionen aus einstellungen.js“), `test/termineRechtstexte.test.js`. `docs/rechtliches/termine-datenschutz-absatz.md` (Fassung 2026-10-02) bleibt inhaltlich unverändert; den endgültigen Wortlaut setzt Block F in Markdown **und** Funktion gemeinsam.
**Consumes:** `zeigtWiderruf(einstellung)` aus `einstellungen.js` (Bedingung „Gäste Verbraucher“; deckt sich mit dem Widerrufsknopf im Widget), `RECHTSTEXTE_VERSION`. **Produces:** `datenschutzAbsatz(einstellung, anrede) ⇒ string` (reiner Text, Absätze durch Leerzeile, erste Zeile „Online-Terminbuchung“, nicht HTML-maskiert wie `widerrufsbelehrung`; `anrede === 'du'` ⇒ Du-Fassung, sonst Sie wie `formularHinweis`), `RECHTSTEXTE_ENTWURF = true` (Block F setzt `false`, sobald die ENTWURF-Köpfe fallen). Bedingte Sätze nach der Markdown-Quelle: Telefon-Feld (`gastFelder.telefon !== 'aus'`), Nachricht/Zusatzfelder (`gastFelder.nachricht !== 'aus' || gastFelder.zusatz.length > 0`), Erinnerung (`erinnerung.anbieten`), Widerruf (`zeigtWiderruf`), Telefon hinterlegt (`betrieb.telefon`). Platzhalter: `betrieb.name/adresse/email/telefon`, `aufbewahrungTage` (ungültig ⇒ 90 wie `formularHinweis`). Fehlen Name oder E-Mail ⇒ `TypeError` wie `betriebsdaten`.
- [ ] Tests: (a) Pin gegen die Quelle: aus „Fassung Sie“/„Fassung Du“ den Blockquote lesen (`> `, `**`, `###` entfernt, Leerraum normalisiert), `[[wenn …: X]]` bei „alle Bedingungen an“ durch X ersetzen, bei „alle aus“ streichen, `{{Firma}}`/`{{Anschrift}}`/`{{E-Mail}}`/`{{Telefon}}`/`{{Tage}}` durch Testwerte ersetzen; das Ergebnis ist gleich `datenschutzAbsatz(…)` (ebenfalls normalisiert) für beide Zustände und beide Anreden. (b) Jede Bedingung schaltet genau ihren Satz (z. B. `anbieten: false` ⇒ kein „Erinnerung“, `gaesteSind: 'unternehmer'` ⇒ kein „Vertrag widerrufen“, `telefon: 'aus'` ⇒ kein „Telefonnummer“). (c) `aufbewahrungTage: 365` ⇒ „365 Tage nach dem Termin“, `'viel'` ⇒ 90. (d) Sie-Fassung ohne Du-Wörter, Du-Fassung ohne Sie-Wörter (Regexe aus dem bestehenden Datenschutz-Absatz-Test wiederverwenden). (e) Ohne Name oder E-Mail ⇒ `TypeError`; leere Anschrift ⇒ „Verantwortlich ist X, E-Mail Y.“ ohne leeres Komma. (f) `RECHTSTEXTE_ENTWURF === true` (Block F10b2 setzt die Konstante auf `false` und ändert diese Assertion zusammen mit den Köpfen `KOPF` der Dokumente).
- [ ] FAIL → implementieren (Absätze als kleine Funktionen mit Bedingung, je Anrede eine Textliste; kein Lesen der Markdown-Datei zur Laufzeit) → PASS → `npm test` → Commit „Terminplaner: Datenschutz-Absatz als Rechtstext-Baustein“
### Task D2: Einbau-Snippet (verschoben, mit Knopf) und Farbvorschlag mit WCAG-Kontrast
**Files:** Create `server/services/termine/einbauanleitung.js` (zunächst nur diese Bausteine), `test/termineEinbauanleitung.test.js`; Modify `server/routes/termine.js` (`:182-194` lokales `esc` und `einbauSnippet(ctx)` entfernen, Aufrufstelle `:212` ⇒ `einbauSnippet({ siteKey: ctx.feature.siteKey, apiBase: config.publicBaseUrl, telefon: ctx.einstellung?.betrieb?.telefon })`; Modus bleibt `inline`, die Kundenansicht ändert sich nicht; Merge-Hinweis: Block C ändert dieselbe Datei an `withTermine` und GET `/einstellungen`), `server/services/termine/einstellungen.js` (`VORGABE_FARBE` exportieren), `test/termineEmbedStatic.test.js` (Farbparität).
**Produces:** `esc(text)`, `einbauSnippet({ siteKey, apiBase, telefon, modus = 'inline', knopfText })`, `luminanz(hex)`, `kontrast(hexA, hexB)`, `textFarbeAuf(hex) ⇒ '#ffffff' | '#111827' | '#000000'`, `cssVorschlag(farbe) ⇒ { css, akzent, akzentText, kontrastText, kontrastSeite, hinweise: string[] }`. Snippet: wie bisher (`<div data-webklar-termine=… data-api=… data-modus=…>`, Ausweichtext mit Telefon, `<script src="…/embed/termine.js" defer>`); Modus `knopf` ergänzt `data-knopf="…"` nur bei gesetztem Text (Widget-Vorgabe „Termin buchen“); Text ≤ 40 Zeichen, Zeilenumbrüche zu Leerzeichen, durch `esc` maskiert; unbekannter Modus ⇒ `inline`. `cssVorschlag`: nur gültige `#rrggbb` (sonst `VORGABE_FARBE`, nie Rohtext im CSS), Kommentar + `[data-webklar-termine]{ --wkt-akzent; --wkt-akzent-text }`; `hinweise` warnt, wenn der Akzent gegen Weiß unter 3 : 1 liegt (Rahmen freier Tage, `termine-einbau.md` Abschnitt 3).
- [ ] Tests: Snippet inline ist für `{ siteKey: 'abcdefghijklmnop', apiBase: 'https://project.webklar.com', telefon: '06301 1234' }` zeichengleich zu Abschnitt 1 von `docs/funktionen/termine-einbau.md`; `telefon: '06301 <12>'` ⇒ `&lt;12&gt;`; ohne Telefon „Termine telefonisch vereinbaren.“; Modus `knopf` mit `knopfText: 'Jetzt "buchen" & mehr'` ⇒ `data-modus="knopf" data-knopf="Jetzt &quot;buchen&quot; &amp; mehr"`; Knopf ohne Text ⇒ kein `data-knopf`; Text mit 60 Zeichen und `\n` ⇒ 40 Zeichen, keine Umbrüche; Modus `'dialog'` ⇒ `inline`. `luminanz('#ffffff') === 1`, `kontrast('#000000', '#ffffff') === 21`, `textFarbeAuf('#0f766e') === '#ffffff'`, `textFarbeAuf('#f5c542') === '#111827'`; für alle 4096 Farben mit Kanalwerten in 17er-Schritten ist `kontrast(farbe, textFarbeAuf(farbe)) >= 4.5`; **Parität**: dieselben 4096 Farben liefern in `public/embed/termine.js` (Marken `@farben-anfang`/`@farben-ende`, per `vm` wie `farbFunktionen()` im Bestandstest) und hier dasselbe `textFarbeAuf`. `cssVorschlag('#9d4b6f').css` enthält `--wkt-akzent:#9d4b6f;` und `--wkt-akzent-text:#ffffff;`; `'red'`, `'#fff'`, `undefined`, `'#9d4b6f;}</style>'` ⇒ `#7c3aed` und der Rohtext steht nirgends in der Ausgabe; `cssVorschlag('#f5c542').hinweise` nennt den Kontrast gegen Weiß. Bestehende `termineRoutes.test.js`-Assertions zum Snippet (`:283-305`) bleiben unverändert grün.
- [ ] FAIL → implementieren (Farbfunktionen 1:1 aus `public/embed/termine.js:192-216`, nicht aus dem Widget importieren) → PASS → `npm test` → Commit „Terminplaner: Einbau-Snippet mit Knopf und Farbvorschlag nach WCAG verschoben“
### Task D3a: Generator `einbauanleitung()` 1 – Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen
**Files:** Modify `server/services/termine/einbauanleitung.js`, `test/termineEinbauanleitung.test.js`.
**Consumes:** `einstellungen.js` (`preisText`, `zahlungText`, `zeigtWiderruf`, `hatPreis`), `zeit.js` (`heute`, `datumKurz`), B `config.features.termine.priceLabel` (hier „149 €“), `config.publicBaseUrl`. **Produces:** `einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt = null, kunde = null, entwurf = RECHTSTEXTE_ENTWURF }) ⇒ { stand: { version, updatedAt }, warnungen, abschnitte, text, kurz }` (`feature` = **`portalFeatures`-Dokument** (der Registereintrag heißt in B `feature`, der Aufrufer D4 übergibt `ctx.doc`), `project` = `websiteProjects`-Dokument, `fragebogen` = geparstes Objekt oder `null`, `kunde` = `{ name }`, die Anrede kommt aus `einstellung.anrede` (`'sie'` | `'du'`, kein eigener Parameter); **rein = kein I/O**, `config` wird beim Aufruf gelesen; `kurz` kommt in D4), hier die Abschnitte `modell`, `leistungen`, `ressourcen`, `regeln`, `gastfelder`, `benachrichtigungen` (feste Reihenfolge, `kopierbar: false`) und `stand`. Der Link ins WOMS ist `anleitungsLink(customerId, projectId)` aus `featureRegistry.js` (B8). **Feldweißliste:** gelesen werden ausdrücklich benannte Felder, nie Objekte kopiert (kein `feedToken`, keine Betriebs-E-Mail außer im Datenschutz-Absatz). Kundentexte (Leistungsnamen, Ressourcenbezeichnungen) nur als Text. Ablaufende Dinge (Abwesenheiten, Sonderöffnungen) nur ab `heute(now)`.
- [ ] Tests (Fixture `ausVorlage('salon', BETRIEB, NOW)` mit Farbe `#9d4b6f`, Telefon, Anschrift, Impressum-/Datenschutz-URL): die sechs Abschnitt-IDs in fester Reihenfolge, alle `kopierbar: false`; Leistungen mit Name, Dauer, `preisText`, Buchungsart, Personen, inaktive markiert; Ressourcen mit Wochenplan („Mo 09:00–12:30, 14:00–18:00“), nur künftige Abwesenheiten; Regeln mit Raster, Vorlauf, Horizont, Absagefrist, `maxOffeneJeEmail`, Storno-Text samt Fassung, `zahlungText`; **feedToken-Test:** jede Ressource trägt einen echten Token, `JSON.stringify(ergebnis)` enthält weder einen Token noch `feedToken`; **Weißlistentest:** Zusatzfelder `geheim: 'XYZ-GEHEIM'` an Betrieb, Ressource und Leistung (und `notiz` an der Leistung) tauchen nirgends auf. Determinismus: gleiche Eingabe und `now` ⇒ `deepEqual`; anderes `now` ändert nur die „erzeugt am“-Zeile und die Filterung vergangener Abwesenheiten. `stand` gleich `{ version, updatedAt }`.
- [ ] FAIL → implementieren (je Abschnitt eine kleine Funktion `(daten) ⇒ { id, titel, text, kopierbar }`; Hilfsfunktionen für Wochenplan-Text und Kürzen mit „und N weitere“) → PASS → `npm test` → Commit „Terminplaner: Einbauanleitung als reiner Generator (Modell bis Benachrichtigungen)“
### Task D3b: Generator `einbauanleitung()` 2 – Einbau, Snippet, CSS, Datenschutz, Checkliste, Abrechnung, Warnungen, Volltext
**Files:** Modify `server/services/termine/einbauanleitung.js`, `test/termineEinbauanleitung.test.js`.
**Consumes:** D3a, D1 `datenschutzAbsatz`, `RECHTSTEXTE_ENTWURF`; D2 Bausteine; `einstellungen.js` (`fehlendePflichtangaben`, `zeigtWiderruf`, `hatPreis`), C `gesundheitsWarnungen(einstellung) ⇒ string[]` aus `fragebogen.js`, `config.womsBaseUrl`. **Produces:** `ENTWURF_BANNER` = „ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen“; die restlichen Abschnitt-IDs in fester Reihenfolge hinter D3a: `einbau`, `snippet`, `css`, `datenschutz_sie`, `datenschutz_du`, `checkliste`, `abrechnung` (zusammen die 13 IDs `modell`, `leistungen`, `ressourcen`, `regeln`, `gastfelder`, `benachrichtigungen`, `einbau`, `snippet`, `css`, `datenschutz_sie`, `datenschutz_du`, `checkliste`, `abrechnung`); `kopierbar: true` nur für `snippet`, `css`, `datenschutz_sie`, `datenschutz_du`. `text` = alle Abschnitte mit Überschrift, aber nur die zur Anrede (`einstellung.anrede`, D3a) passende Datenschutz-Fassung (die Abschnitte `datenschutz_sie` und `datenschutz_du` rufen `datenschutzAbsatz(einstellung, 'sie')` bzw. `(einstellung, 'du')`). Kundentexte (Wünsche aus `fragebogen.einbau`: `stelle`, `gestaltung`, `bemerkung`, `knopfText`) nur als Text, gesammelt in einem Block „Kundenwunsch (Text des Kunden)“ im Abschnitt `einbau`, nie in `snippet`/`css`; Grenzen 40/300/1000/1000 erneut angewandt. Ohne Fragebogen: Modus `inline`. Abnahme-Checkliste bedingt: `zeigtWiderruf` ⇒ Widerrufsbelehrung aufklappbar, Haken für vorzeitigen Beginn bei Preis, Knopf „Vertrag widerrufen“; Modus `knopf` und `zeigtWiderruf` ⇒ Widerrufsknopf dauerhaft neben dem Buchungsknopf, Modus `knopf` ⇒ `--wkt-akzent` immer setzen; immer: Widget unsichtbar bis zur Freischaltung, Ausweichtext, Datenschutz-Absatz auf der Website, URLs von Impressum/Datenschutz, „Freischalten (Abnahme)“, danach Probebuchung bis „Fast geschafft“ und Stornierung (die öffentliche Schnittstelle antwortet erst bei `active`), Kontrast, 390 px, Tastatur (Punkte aus Abschnitt 7 von `termine-einbau.md`). Abrechnung: „Einrichtung bezahlt (149 €, Rechnung <URL> | Rechnung folgt im Portal), Zeit im Arbeitsblatt, nicht auf Abo-Stunden“; Leistungsumfang „ein Widget an einer Stelle, Gestaltung nach Fragebogen, Einbau; Mehrwünsche (zweites Widget, Knöpfe je Leistung, Sonderdesign) nach Stunden, vorher im Chat abstimmen“; bei `feature.unlockedByPurchase !== true` „ohne Zahlung freigeschaltet (Pilot/Kulanz), keine Abrechnung“. Datenschutz-Absatz in der Checkliste: bis Block F steht der Zusatz „liegt die Datenschutzerklärung nicht in unserem Repo: Absatz per Chat an den Kunden senden“; **F10b2 ersetzt ihn durch die Endregel aus F4** (WEBklar fügt den Absatz ein, wenn die Website von WEBklar betreut wird, sonst Textbaustein per Chat; Teil der Einrichtung, nicht auf das Stundenkontingent angerechnet).
**Warnungen** (Reihenfolge): `ENTWURF_BANNER` genau dann, wenn `entwurf`; fehlende Pflichtangaben (Telefon/Anschrift, Gäste können nicht buchen); fehlende Impressum-/Datenschutz-URL; `gesundheitsWarnungen`; Kontrasthinweise aus `cssVorschlag`; Datenschutz-Absatz nicht erzeugbar (kein Name/keine E-Mail ⇒ statt Wurf Hinweistext im Abschnitt + Warnung). Ohne Einstellung (`einstellung === null`): Warnung „Keine Einstellungen gespeichert“ und nur der Abschnitt `snippet` (mit Modus aus dem Fragebogen, falls vorhanden).
- [ ] Tests (Fixture wie D3a): Abschnitt-IDs (alle 13) und `kopierbar`-Flags exakt wie oben; `text` enthält genau eine Datenschutz-Fassung (Sie bei `einstellung.anrede === 'sie'`, Du bei `'du'`), `abschnitte` beide; Fragebogen `{ einbau: { modus: 'knopf', knopfText: 'Termin sichern', stelle: 'Startseite unter dem Hero', gestaltung: '…', bemerkung: '<script>alert(1)</script>' } }` ⇒ Snippet mit `data-modus="knopf" data-knopf="Termin sichern"`, die Wünsche stehen nur im Kundenwunsch-Block und das Skript-Tag erscheint nirgends in `snippet`/`css`; Texte über Grenze werden gekürzt. Warnungen: Banner genau bei `entwurf` (Standard und `entwurf: false` getrennt prüfen; der Standard hängt an `RECHTSTEXTE_ENTWURF`, F10b2 stellt die Standard-Assertion um), `betrieb.telefon: ''` ⇒ Pflichtangaben-Warnung, fehlende URL ⇒ Warnung, Leistung „Physiotherapie“ ⇒ Warnung mit „Physio“ (Liste gehört C). Checkliste: `gaesteSind: 'unternehmer'` ⇒ keine Widerrufspunkte; Verbraucher + Modus `knopf` ⇒ dauerhafter Widerrufsknopf, Verbraucher + `inline` ⇒ kein solcher Punkt; „Freischalten (Abnahme)“ und „stornieren“ immer. Abrechnung mit `invoiceUrl` ⇒ URL, „149 €“, „nicht auf Abo-Stunden“; ohne ⇒ „Rechnung folgt“; `unlockedByPurchase: false` ⇒ „keine Abrechnung“. `einstellung: null` ⇒ `abschnitte.map(a => a.id)` gleich `['snippet']` und Warnung „Keine Einstellungen gespeichert“. Fehlende E-Mail im Betrieb ⇒ kein Wurf, Abschnitt mit Hinweis.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Einbauanleitung als reiner Generator (Einbau bis Abrechnung, Warnungen)“
### Task D4: Kurzfassung für `ticket.details`, T16-Gesundheitsblock und Registry-Anbindung
**Files:** Modify `server/services/termine/einbauanleitung.js` (`kurz`, `ticketStand`, `driftWarnung`), `server/services/termine/kontext.js` (`ladeStand`), `server/services/termine/ticketDetails.js` (B8 hat den Stub angelegt: der Inhalt wird ersetzt; `server/config.js` bleibt unberührt, denn `features.termine.ticket.details` ruft die Datei schon lazy auf), `server/services/siteFeatures.js` (`teamNotice`: den von B9b deklarierten T16-Block `gesundheit` füllen), Create `test/termineTicket.test.js`; Tests `test/termineEinbauanleitung.test.js`, `test/termineKontext.test.js`, `test/featureMails.test.js` (T16-Fall).
**Consumes:** D3a/D3b; B8: `featureConfig('termine').ticket.details(ctx)` mit `ctx = { feature, doc, project, customer }` (`feature` = Register-Eintrag, `doc` = `portalFeatures`-Dokument, `customer` darf `null` sein; `now` setzt D4 im Rumpf), `TICKET_DETAILS_MAX`, `anleitungsLink`, `kuerze` aus `featureRegistry.js`; B9b (T16-Platzhalter `gesundheit_text`, Block `gesundheit`); C `gesundheitsWarnungen` und Attribut `termineConfig.fragebogen` (JSON-String, in `test/support/schema.js` über `schemaKeys('termineConfig')` enthalten). **Produces:** `kurz` (Klartext, ≤ `TICKET_DETAILS_MAX` aus B8; die Konstante wird hier nicht neu definiert), `ladeStand(featureId) ⇒ Promise<{ einstellung|null, version, updatedAt, fragebogen|null } | null>` (`null` nur ohne `termineConfig`-Dokument; unlesbares JSON ⇒ jeweiliges Feld `null`), `ticketStand(details) ⇒ number | null` (liest `^Konfigurationsstand: Version (\d{1,9})`), `driftWarnung(ticketVersion, jetzt) ⇒ string | null` („Einstellungen seit der Bestellung geändert (Ticket Version N, jetzt M).“ nur bei `jetzt > N`), `termineTicketDetails(ctx) ⇒ Promise<string>` (wirft **nie**: bei Lesefehlern Kopfzeilen + „Anleitung konnte nicht erzeugt werden, im WOMS öffnen“ + Link; setzt `now = new Date()` im Rumpf und ruft `einbauanleitung({ feature: ctx.doc, project: ctx.project, … })` auf – in D3 ist `feature` das `portalFeatures`-Dokument, den Registereintrag `ctx.feature` braucht der Generator nicht; `siteKey` und `$id` kommen aus `ctx.doc`). **T16-Block `gesundheit`:** `teamNotice` füllt `gesundheit_text` aus `gesundheitsWarnungen((await ladeEinstellung(doc.$id))?.einstellung)` (zusammengefügt, höchstens 500 Zeichen) und schaltet den Block ein, wenn der Text nicht leer ist; ein Lesefehler schaltet den Block aus, T16 geht trotzdem raus (nie Geld ohne Spur). Aufbau von `kurz` (Absätze durch Leerzeile, **erster Absatz = Kopf**): Kopf (`Funktion: Terminplaner`, Projekt, Domain = `liveDomain || previewUrl || subdomain` wie `createFeatureTicket`, Kunde, `siteKey`, `Konfigurationsstand: Version N, TT.MM.JJJJ` bzw. `Version 0 (noch keine Einstellungen gespeichert)`); Kundenwunsch-Block (Modus/Stelle/Gestaltung/Bemerkung); Zählungen mit ersten 8 Namen („12 Leistungen (11 aktiv): A, B, … und 4 weitere“, Ressourcen, Zusatzfelder); Warnungen (höchstens 5, ohne Banner-Text-Doppel); die Gesundheitswarnungen stehen in einem eigenen Absatz „Gesundheitsbereich prüfen“ und zählen nicht zu den 5; Snippet; 6 Schritte (WOMS-Panel öffnen, Code einfügen, Gestaltung/Kontrast, Datenschutz-Absatz, „Freischalten (Abnahme)“ + Probebuchung + Stornierung, Ticket erledigen und Zeit buchen, „Nicht auf das Stundenkontingent anrechnen“); Verweis `anleitungsLink`. Ohne Datenschutz-Absatz, ohne Tokens. Passt die Summe nicht, fallen erst Zählungen, dann die übrigen Warnungen, dann der Kundenwunsch-Block weg (Kopf, Snippet, Schritte, Verweis **und der Gesundheitsabsatz** bleiben immer; eine Gesundheitswarnung verschwindet nie unter Platzdruck).
- [ ] Tests: `kurz.length <= 7000` bei Maximalbefüllung (50 Leistungen mit 100 Zeichen, 20 Ressourcen, 5 Zusatzfelder, Wünsche 300/1000/1000, 5 Warnungen und 10 Gesundheitswarnungen); enthält Kopf, Snippet mit `siteKey`, Verweis, „Nicht auf das Stundenkontingent anrechnen“ und **nicht** „Online-Terminbuchung“ (Datenschutz-Absatz) oder einen `feedToken`; erster Absatz endet vor der ersten Leerzeile mit der `Konfigurationsstand`-Zeile; `ticketStand('…Konfigurationsstand: Version 7, 03.10.2026…') === 7`, ohne die Zeile oder mit `Version 99999999999` ⇒ `null`; `driftWarnung(7, 9)` Text wie oben, `driftWarnung(9, 9)`, `(9, 7)`, `(null, 9)` ⇒ `null`. `ladeStand`: ohne Dokument `null`, mit Dokument alle Felder, kaputtes `fragebogen`-JSON ⇒ `fragebogen: null` bei gültiger Einstellung. `termineTicketDetails`: mit Dokument ⇒ enthält `Konfigurationsstand: Version 1`; ohne `termineConfig` ⇒ String mit „noch keine Einstellungen“ und Snippet; `aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500 })` ⇒ löst auf (kein Wurf) mit Kopf und Link. Registry: `featureConfig('termine').ticket.details(ctx)` liefert denselben Text wie `termineTicketDetails`, mit `ctx` genau in der Form, die `markPaid` übergibt (`{ feature, doc, project, customer }`; `siteKey` aus `ctx.doc`, nicht aus `ctx.feature`). **Gesundheit:** Leistung „Physiotherapie“ + Maximalbefüllung ⇒ `kurz` enthält „Physio“ und bleibt ≤ 7000; T16 (`teamNotice`) für ein Termine-Dokument mit dieser Leistung enthält den Warnblock, ohne Treffer kein Block, `ladeEinstellung` scheitert (`failNext`) ⇒ T16 geht ohne Block raus. **Integration (Review Focus 1):** `markOrdered` + `markPaid` für `termine` ⇒ `workorders`-Dokument mit `details` ≤ 7000, enthält `data-webklar-termine="<siteKey>"`; derselbe Ablauf mit kaputtem `termineConfig`-Lesen (failNext) legt das Ticket trotzdem an und liefert `created: true` (Zahlung nie ohne Ticket oder T16).
- [ ] FAIL → implementieren (Budgetliste mit Prioritäten statt Abschneiden mitten im Satz) → PASS → `npm test` → Commit „Terminplaner: Kurzfassung der Einbauanleitung fuer das Einrichtungs-Ticket, T16-Gesundheitsblock“
### Task D5: entfällt (vollständig in B8 aufgegangen)
Die Härtung von `createFeatureTicket` (Titel auf 128 Zeichen, Details auf 7000 mit einheitlicher Schlusszeile, surrogatsicheres Kürzen, **gezielte** Wiederholung nur bei `status 400` und `details.length > 1000` mit den Minimal-Details, kein Doppel-Ticket bei 5xx) und ihre Tests liegen in **Task B8**; `webhook_retry_setzt_ticketlose_einrichtung_fort` (`funktionenRoutes.test.js:321-345`) bleibt dort grün. Die Konstanten `TICKET_TOPIC_MAX`, `TICKET_DETAILS_MAX`, `LANGE_DETAILS` und `kuerze` stehen in `featureRegistry.js` (B8); D4 und D3 nutzen sie. Block D ändert `ticketAdmin.js` nicht.
### Task D6: Freischalt-Hindernisse und Freigabe-Mail (aus B11 herausgelöst)
**Files:** Create `server/services/termine/freischaltung.js`; Modify `server/services/siteFeatures.js` (`runActivation` Z. 578–593: den K16-Versand in die neue Funktion `meldeFreigeschaltet` herauslösen), `server/services/featureRegistry.js` (der Standardhaken `freischaltbar` für `termine` delegiert an `freischaltHindernis`); Test neu `test/termineFreischaltung.test.js`. **`activateClosedTickets`, der Hook `freischaltbar`, die Behandlung einer blockierten Freischaltung (ein `adminNote`-Eintrag, kein Mailversand) und die Anpassung des Tests `siteFeatures.test.js:235` gehören Block B (B11)**; D6 ändert weder `config.js` noch `activateClosedTickets`.
**Consumes:** B11 (`activateClosedTickets`, `featureHaken`, Standardhaken mit der Prüfung „Einstellung vorhanden **und** `laeuft`“), B9a (K16 mit `{{funktion}}`/`{{portal_url}}`/Knopftext aus dem Register), E1 `aboMemo()`/`laeuft`, `ladeEinstellung(featureId)` (C3). **Produces:** `freischaltHindernis(doc, { abo = aboMemo() } = {}) ⇒ Promise<null | 'kein_abo' | 'keine_einstellung' | 'abo_unbekannt'>` (Reihenfolge: Abo zuerst, dann Einstellung vorhanden; `laeuft` wirft ⇒ `'abo_unbekannt'`, also nie freischalten bei Lesefehler); der B11-Standardhaken wird `freischaltbar(doc) = (await freischaltHindernis(doc)) === null` (Verhalten und Tests von B11 bleiben unverändert); `meldeFreigeschaltet(doc, { attachments = [] } = {})` (K16 an den Kunden, Fehler geloggt und nie geworfen; `attachments` nimmt die PDFs der Staff-Aktivierung aus F13a auf); `runActivation` ruft sie für jedes aktivierte Dokument.
- [ ] Tests (Review Focus 2): `freischaltHindernis`-Tabelle: Wartungs-Abo da + Einstellung da ⇒ `null`; kein Abo ⇒ `'kein_abo'` (auch ohne Einstellung: Abo zuerst); Abo da, keine Einstellung ⇒ `'keine_einstellung'`; Abo-Lesefehler (`failNext` auf `subscriptions`) ⇒ `'abo_unbekannt'`; Abo `past_due` zählt als laufend (`null`); Wechsel-Widerruf (E1) ⇒ `'kein_abo'`. `meldeFreigeschaltet`: K16 mit „Terminplaner“ im Betreff und Link `#termine`; mit `attachments` steht das PDF in der gesendeten Mail (`mailSink`); Versandfehler ⇒ kein Wurf, `console.error`. Delegation: **die Tests von B11 laufen unverändert grün**; `runActivation` sendet für ein aktiviertes Termine-Dokument genau ein K16.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktionen: Freischalt-Hindernisse und Freigabe-Mail ausgelagert“
### Task D7: Team-Aktionen „Freischalten (Abnahme)“ und „Ticket nachlegen“
**Files:** Modify `server/services/siteFeatures.js` (neu `freischaltenNachAbnahme`, `ticketNachlegen`), `server/routes/admin/funktionenAdmin.js` (POST `/:customerId/:key`, `:73-103`: erlaubte `action` ⇒ `activate | disable | abnahme | ticket`; bei `abnahme`/`ticket` zusätzlich `featureConfig(key)?.ticketFreischaltung` sonst 409; `adminNote` wie bisher über `composeAdminNote`), Test `test/funktionenTermine.test.js`.
**Consumes:** D6 (`freischaltHindernis`, `meldeFreigeschaltet`), B11 (`activateClosedTickets` bleibt unberührt), B (Ticketanlage mit Register-Text in `markPaid`: dort `createFeatureTicket({ …, topic, details })` in eine interne Funktion `legeTicketAn(doc, project, customer)` herauslösen, falls B das nicht schon getan hat), `handleFeatureCheckoutEvent(event)` und `retrieveCheckoutSession(id)`. **Produces:** `freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote, now }) ⇒ { doc, activated: boolean }`: nur aus `setup` (bereits `active` ⇒ no-op mit `activated: false`, Antwort 200; `off`/`ordered`/`disabled` ⇒ 409 „Freischalten (Abnahme) gibt es nur für Funktionen in Einrichtung.“; ohne Dokument 409 „Für dieses Projekt gibt es noch {keine}“), Hindernis ⇒ 409 mit Klartext (`kein_abo`: „Der Kunde hat kein laufendes Wartungs-Abo. Freischalten ist erst mit laufendem Abo möglich.“, `keine_einstellung`: „Es sind noch keine Einstellungen gespeichert.“, `abo_unbekannt` ⇒ 503 „Das Abo konnte gerade nicht geprüft werden.“); Schreiben unter `feature-activate:<id>` mit erneutem Lesen und Hook; setzt nur `active`, `activatedAt`, `adminNote` (**Ticket und `unlockedByPurchase` bleiben**, das Ticket wird nicht geschlossen), danach `meldeFreigeschaltet` (K16). `ticketNachlegen({ customerId, projectId, key, by, adminNote }) ⇒ { doc, created: boolean }`: nur in `setup`; (a) `ticketId` leer ⇒ Zahlung bei Stripe prüfen (`retrieveCheckoutSession(doc.stripeSessionId)`, `payment_status === 'paid'` sonst 409 „Die Zahlung ist noch nicht eingegangen.“, Stripe nicht erreichbar ⇒ 503) und **denselben Weg wie der Webhook** laufen lassen (`handleFeatureCheckoutEvent({ type: 'checkout.session.completed', data: { object: session } })`), damit Ticket, K15 und T16 genau einmal entstehen und ein späterer Stripe-Retry nichts doppelt sendet; (b) `ticketId` gesetzt, Ticket aber gelöscht ⇒ neues Ticket über `legeTicketAn`, `ticketId` ersetzen, **keine** Mails (K15/T16 gingen beim ersten Mal raus); (c) Ticket vorhanden ⇒ no-op `created: false`. Keine Sperre `feature-paid:<session>` um (a) halten (`withLocks` ist nicht wiederbetretbar, `markPaid` nimmt sie selbst); (b) läuft unter dieser Sperre.
- [ ] Tests: `abnahme` auf `setup` mit Einstellung und Abo ⇒ 200, `termine.state === 'active'`, `activatedAt` gesetzt, `ticketId` und Ticket-Status `Open` unverändert, `unlockedByPurchase === true`, `adminNote` passt auf `/Andrej: abnahme/`, K16 genau einmal; zweiter Klick ⇒ 200 ohne zweite Mail; zwei parallele Klicks ⇒ eine Mail; gleichzeitig `runActivation` mit geschlossenem Ticket ⇒ eine Mail insgesamt; `off`/`ordered`/`disabled` ⇒ 409; kein Abo ⇒ 409 und `setup` bleibt; keine Einstellung ⇒ 409; Abo-Lesefehler ⇒ 503; Projekt eines anderen Kunden ⇒ 403; Kundensitzung statt JWT ⇒ 401/403; `news` + `abnahme` funktioniert ohne Abo-Prüfung (Hook fehlt), `analyse` ⇒ 404 wie bisher. `ticket`: (a) `setup` ohne `ticketId`, Sitzung in `st.checkoutSessions` bezahlt ⇒ Ticket mit Kurzfassung (D4), `ticketId` gesetzt, K15 und T16 je einmal, zweiter Klick ⇒ `created: false`, keine weiteren Mails, ein folgender Webhook mit derselben Sitzung ⇒ ebenfalls nichts Neues (Review Focus 1: nie doppelt); unbezahlte Sitzung ⇒ 409; Stripe-Ausfall ⇒ 503; (b) gelöschtes Ticket ⇒ neues Ticket, neue `ticketId`, keine Mail; (c) vorhandenes ⇒ no-op; `ordered`/`active` ⇒ 409; zwei parallele Klicks ⇒ ein Ticket. Bisherige Tests dieser Datei (`Pruefungen wie bei News`: `action: 'loeschen'` ⇒ 400) bleiben grün.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktionen: Freischalten nach Abnahme und Ticket nachlegen (Team)“
### Task D8: Team-Route Anleitung
**Files:** Modify `server/routes/admin/funktionenAdmin.js` (neu `GET /:customerId/termine/anleitung`; `adminView` `:50-62` ergänzt `hindernis` über `freischaltHindernis` für `termine`-Dokumente in `setup`, ohne `portalFeatures` erneut zu lesen), `docs/funktionen/termine-einbau.md` (Einleitung um drei Sätze: Anleitung je Kunde im WOMS-Panel, Kurzfassung im Ticket; alle von `termineEmbedStatic.test.js:212-228` gepinnten Strings bleiben), Test `test/funktionenTermine.test.js`.
**Consumes:** D3a/D3b, D4 (`ladeStand`, `ticketStand`, `driftWarnung`), D6 (`freischaltHindernis`, `aboMemo` je Anfrage), `assertOwnProject`, `getFeatureDoc`, B-Meldungstext „Für dieses Projekt gibt es noch keinen Terminplaner“ (`FEATURE_LABELS.termine.keine`). **Produces:** `GET /api/team/funktionen/:customerId/termine/anleitung?projectId=` ⇒ `{ stand: { version, updatedAt }, warnungen, abschnitte, text, state, ticketWoid, ticketVersion, hindernis }` (ohne `kurz`; `hindernis` nur in `setup`, sonst `null`). Hinter `requireStaffJwt` (bestehender Mount), **kein** `TERMINE_ENABLED`-Gate, **kein** `customerViewLog`, kein Pfad unter `/api/funktionen` (Kundenrouter). 400 ohne `projectId`, 403 fremdes Projekt (`assertOwnProject`), 409 ohne Funktionsdokument (Text wie die bestehende Meldung), sonst 200 auch in `off`/`ordered`/`setup`/`active`/`disabled`; fehlt die Einstellung ⇒ 200 mit Warnung und nur Snippet. Drift: Ticket über `feature.ticketId` lesen (404 ⇒ keine Warnung, kein Fehler), `ticketStand(details)` gegen `stand.version`, Warnung am Ende von `warnungen`. Rechnung und Preis stammen live aus dem Dokument (`backfillInvoiceLinks` ergänzt den Link später, die Anleitung zeigt ihn dann auch bei altem Ticket).
- [ ] Tests: ohne JWT 401, mit Kundencookie 401/403, `GET /api/funktionen/termine/anleitung` (Kundenrouter) 404; fehlendes `projectId` 400; Projekt von c2 für c1 ⇒ 403; Projekt ohne Dokument ⇒ 409; `config.termine.mode = 'off'` ⇒ trotzdem 200; `customerViewLog` unverändert; Antwort-JSON enthält keinen der seeded `feedToken` und kein `kurz`; mit Einstellung ⇒ `abschnitte.map(a => a.id)` wie D3b, `stand.version === 1`; ohne Einstellung ⇒ nur `snippet` und die Warnung; Ticket-Details „Konfigurationsstand: Version 1“, Einstellung inzwischen Version 3 ⇒ Warnung „Einstellungen seit der Bestellung geändert (Ticket Version 1, jetzt 3).“; Ticket gelöscht oder ohne `ticketId` ⇒ keine Warnung; `setup` ohne Abo ⇒ `hindernis: 'kein_abo'`, mit Abo `null`, `active` ⇒ `null`; `GET /api/team/funktionen/:customerId` liefert `hindernis` je `setup`-Dokument und liest `portalFeatures` weiterhin einmal (Bestandstest „liest die Funktionsdokumente des Kunden einmal“ grün); Rechnungs-Link erscheint im Abschnitt `abrechnung`, sobald `invoiceUrl` im Dokument steht.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktionen: Team-Route fuer die Terminplaner-Einbauanleitung“
### Task D9: WOMS – Ansicht-Logik und API-Client
**Files:** Modify `src/lib/funktionenView.js`, `src/lib/teamApi.js`, `tests/unit/funktionenView.test.mjs` (Worktree `/home/webklar/apps/ticket.webklar.com/dev-termine`, Branch `feature/terminplaner-tp2`).
**Consumes:** D7/D8 (Routen und `action`-Werte `abnahme`, `ticket`; Feld `hindernis` in der Funktionsliste). **Produces:** `aktionen(state, key, { ticketMissing = false } = {})`: News unverändert (`off` `['activate']`, `ordered`/`setup` `['activate','disable']`, `active` `['disable']`, `disabled` `['activate']`; ohne `key` wie News), Termine `off` `['activate']`, `ordered` `['disable']` (kein „Aktivieren (ohne Zahlung)“, sonst verschluckt das Portal eine spätere Zahlung), `setup` `['abnahme','disable']` bzw. mit `ticketMissing` `['ticket','abnahme','disable']`, `active` `['disable']`, `disabled` `['activate']`; `FUNKTIONEN[termine].bestaetigen` um `abnahme` („Terminplaner freischalten? Der Kunde bekommt die Mail „bereit“ und Gäste können buchen.“) und `ticket` („Fehlendes Einrichtungs-Ticket neu anlegen?“) erweitert; `AKTION_LABEL = { activate: 'Aktivieren (ohne Zahlung)', disable: 'Abschalten', abnahme: 'Freischalten (Abnahme)', ticket: 'Ticket nachlegen' }`; `hindernisText(code)` (`kein_abo` „Kein laufendes Wartungs-Abo: Freischalten ist gesperrt.“, `keine_einstellung` „Noch keine Einstellungen gespeichert.“, `abo_unbekannt` „Abo gerade nicht prüfbar.“, sonst `''`); `anleitungKopien(antwort) ⇒ [{ id, label, text }]` (zuerst `alles` mit `antwort.text`, dann alle `kopierbar`-Abschnitte; feste Beschriftungen für `snippet` „Einbau-Code kopieren“, `css` „CSS-Variablen kopieren“, `datenschutz_sie` „Datenschutz-Absatz (Sie) kopieren“, `datenschutz_du` „… (Du) kopieren“, unbekannte kopierbare ⇒ „<Titel> kopieren“; `null` oder ohne Text ⇒ `[]`); `anleitungPfad(customerId, projectId)` ⇒ `/funktionen/<id>/termine/anleitung?projectId=<id>` (kodiert); `teamApi.getTermineAnleitung(customerId, projectId) = request(anleitungPfad(…))`; **`panelModell(antwort, feature) ⇒ { kopf, ticketPfad, warnungen, hinweis, kopien, abschnitte }`** (rein, die gesamte Entscheidungslogik des Panels aus D10): `kopf` = `Stand Version N, TT.MM.JJJJ` (aus `antwort.stand`, ohne Antwort `''`), `ticketPfad` = `/tickets?woid=<ticketWoid>` (ohne `antwort.ticketWoid` `null`), `warnungen` = `antwort.warnungen` (sonst `[]`), `hinweis` = `hindernisText(feature.hindernis)`, `kopien` = `anleitungKopien(antwort)`, `abschnitte` = `[{ id, titel, text }]` in Antwortreihenfolge; `null`-Antwort ⇒ alles leer; **`anleitungOffen(search, projectId) ⇒ boolean`** (`true`, wenn die Adresse `?anleitung=<projectId>` trägt, kodiert oder nicht, sonst `false`).
- [ ] Tests (WOMS `npm test`): `FUNKTIONEN`-Test an die neuen Texte angepasst; `aktionen` für alle Zustände × {`news`, `termine`} inkl. `ticketMissing`, ohne `key` und mit `undefined`-Zustand wie bisher (`funktionenView.test.mjs:48-55` anpassen, News-Zeilen unverändert); `AKTION_LABEL` exakt; `hindernisText` für alle Codes und `'x'`; `anleitungKopien` mit einer Beispielantwort (Reihenfolge, Beschriftungen, nicht kopierbare Abschnitte fehlen, `null` ⇒ `[]`); `anleitungPfad` kodiert `projectId` mit Sonderzeichen; `panelModell`: Beispielantwort ⇒ alle sechs Felder (Kopf `Stand Version 3, 03.10.2026`, Ticketpfad, Warnliste, `hinweis` bei `hindernis: 'kein_abo'`, Abschnitte in Reihenfolge, Kopien), `null` ⇒ leeres Modell, Antwort ohne `ticketWoid` ⇒ `ticketPfad: null`; `anleitungOffen('?tab=funktionen&anleitung=p%2F1', 'p/1') === true`, anderes Projekt, leere und fehlende Suche ⇒ `false`; `mergeFunktion`/`siteKeyOf`-Tests unverändert grün.
- [ ] FAIL → implementieren (`teamApi.js` bleibt dünn, weil es `appwrite` lädt und nicht unter Node testbar ist) → PASS → `npm test` → Commit „WOMS: Aktionen und Anleitungs-Logik fuer den Terminplaner“
### Task D10: WOMS – Anleitungs-Panel und Aktionsknöpfe
**Files:** Modify `src/components/customer/CustomerFunktionen.jsx` (Zeilen `:80-124`), `src/styles/admin-areas.css` (`.funktionen-panel*`).
**Consumes:** D9 (`aktionen`, `AKTION_LABEL`, `hindernisText`, `anleitungKopien`, `panelModell`, `anleitungOffen`, `getTermineAnleitung`), bestehend `copyText` (`CopyableCredential`), `Card/Badge/Button`. **Produces:** je Termine-Zeile mit Funktionsdokument (Zustand ≠ `off`) ein Knopf „Anleitung“, der ein Panel unter der Zeile aufklappt (Zeilen werden `Fragment` mit `key`, das Panel ist eine eigene Zeile außerhalb der Gitterspalten); Panel (alle Inhalte und Entscheidungen kommen aus `panelModell` und `anleitungOffen` in D9, die Komponente verdrahtet nur): Kopfzeile `modell.kopf` + Ticketlink `modell.ticketPfad`, `modell.warnungen` als Liste (`area-error`), `modell.hinweis` als Hinweis, Knopfreihe aus `modell.kopien` (Kopieren mit „Kopiert“-Rückmeldung wie `SiteKey`), darunter die Abschnitte als aufklappbare Blöcke (`<details>`, Text in `<pre>` mit `pre-wrap`, ausschließlich React-Textknoten, kein `dangerouslySetInnerHTML`, nie als Link oder HTML); Lade-/Fehlerzustand mit „Erneut laden“; nach jeder Aktion (`save`) für dieselbe Zeile neu laden, weil sich Version und Hindernis ändern; Panel öffnet automatisch, wenn `anleitungOffen(window.location.search, projectId)` wahr ist (T16 und Ticket verlinken so); `aktionen(feature.state, f.key, { ticketMissing: feature.ticketMissing })` und Beschriftung aus `AKTION_LABEL`; in `setup` neben dem Status `hindernisText(feature.hindernis)`.
- [ ] Prüfung: `npm test` grün (die Panel-Logik ist in D9 getestet: `panelModell`, `anleitungOffen`, `anleitungKopien`, `aktionen`, `hindernisText`; `CustomerFunktionen.jsx` enthält keine eigene Entscheidungslogik); einmal `nice npx vite build` (vorher `free -m`) ohne Fehler; keine neuen Abhängigkeiten. Sichtprüfung des Panels (390 px und Desktop, Kopieren, Warnungen, Aktionsknöpfe je Zustand) gehört zum Livegang-Test (Plan-Kopf Schritt 8), nicht in `npm test`.
- [ ] Implementieren → Build → Commit „WOMS: Einbauanleitung als Panel mit Kopierknoepfen, Abnahme-Knopf und Ticket nachlegen“
**Bewusst nicht in Block D (Spec 7.3, letzter Punkt):** Die Textform-Pflicht für `adminActivate` bei Termine ab Modus `on` (außer Pilot-Adressen, wie `activateAnalyse` mit `validateTextform` und Zielgruppen-Haken) ist keine kleine Ergänzung: sie braucht Eingabefelder (Datum, Kanal, Haken) in der Bestätigungszeile des WOMS, Modusweichen und eigene Tests. Der Einhängepunkt ist vorbereitet: Zweig `action === 'activate'` in `funktionenAdmin.js` (POST `/:customerId/:key`) und die Bestätigungszeile in `CustomerFunktionen.jsx` (`isConfirming`). Block F (oder ein eigener Task nach D10) setzt dort an; „Freischalten (Abnahme)“ ist davon nicht betroffen, weil die Zahlung samt Zustimmungsnachweis (`featureConsents`) vorliegt.
**Abschlussprüfung Block D (Controller):** `npm test` komplett grün (`test/previewDevRouter.test.js` bei Ausreißer einzeln wiederholen); Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): `docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)'` (`analyticsContract.test.js` importiert `../analytics/src` und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: `git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob'` bleibt leer. WOMS-Anteil (D9, D10): `npm test` im WOMS-Worktree und einmal `nice npx vite build` (vorher `free -m`); Sichtprüfung des Panels gehört zum Livegang-Test (Block G).
---
## Block F: Rechtstexte, Kanzlei-Paket, News-Korrektur
Block F liefert den Textsatz, ohne den `TERMINE_ENABLED=on` nicht zu verantworten ist: Leistungsbeschreibung (LB), AVV und Widerrufsbelehrung des Terminplaners (Markdown → HTML → PDF), Datenschutz 6c samt §6/§7, endgültige Gäste- und Bestelltexte, die Korrektur der News-Texte, den Widerruf je Funktion, die Textform-Sperre für die Staff-Aktivierung, die WOMS-Karten und das Kanzlei-Paket. Alle Texte sind „Endfassung zur Kanzlei-/Hausprüfung“, keine Rechtsberatung; jede Aussage, die die Kanzlei bestätigen muss, steht im Kanzlei-Paket (F15a, F15b) als `⚖ Q<n>` (in den veröffentlichten Dateien stehen keine Marker).
Fassungsdatum `<F>`: siehe Kopf, Abschnitt „Grundlagen der Blöcke“ (Berliner Kalendertag des F1-Commits, die drei Stände, was beim Anheben neu entsteht). Dateien einer ausgelieferten Fassung werden nie überschrieben: Änderung nach der Kanzlei = neues Datum + neue Dateien. Review-Fokus 5 (Zustimmungsnachweis: genau die gezeigten Texte + Fassungen + Zeitpunkt, gespeichert vor der Umleitung auf Stripe; Widerrufs-Anker = Klick) gilt für F7–F9; „nie Geld ohne Spur“ (Fokus 1) für den Ausfall der K15-Anhänge in F8. Reihenfolge: F1, F2 · F3–F6 (Dokumente) · F7–F9 (Verdrahtung, nach B) · F10a, F10b1, F10b2, F11, F12a, F12b · F13a (nach B/D), F13b · F14 · F15a, F15b zuletzt · Block G (Livegang-Checkliste, Controller) am Ende dieser Datei.
**Befunde beim Lesen des Codes (ergänzen oder korrigieren die Spec):**
- „inklusive Mehrwertsteuer“ steht nur in `public/funktionen.js:272`; die News-LB sagt „inklusive Umsatzsteuer“ (`funktionen-leistungsbeschreibung.html:49,60`), K15 „(brutto, einmalig; … kein Abo)“, `stripeBilling.js:642` und `funktionen-news-design.md:78` „brutto“. Alle fallen unter die Korrektur (F2). „§ 356 Abs. 4“ steht in `config.js:192,196`, LB `:51,73`, `funktionen-news-design.md:73`, Test `legalTexts.test.js:485`.
- Die Analyse-Rechtstexte liegen in `public/rechtliches/` (nicht unter `vertragsunterlagen/`); ihr HTML ist von Hand gebaut, eine Markdown-Bibliothek gibt es nicht (F6 baut einen kleinen Generator). `analyseLegal.js` kennt nur die Analyse; `build-legal-pdfs.sh` nur `all|analyse`.
- `ablauf.js:231` (`stornoNoetig`) hängt nur am Stornotext, nicht am Preis der Leistung; im Widget zeigen `embed/termine.js:1215-1219,1259,1279` Stornobox, Haken und Prüfung ebenso preisunabhängig.
- `paidFeatureOrders` liefert seit **B4** `featureKey` und `vertragAt` (Zeit der Zustimmung zur bezahlten Sitzung); `withdrawFeature` (`abo.js:1088`) nimmt aber noch das Maximum über alle Funktionen (F9 filtert je Funktion); `ORDERS`/`orderLabel` (`abo.js:707,782`) kennen nur `funktion`.
- Das Wort ENTWURF ist gepinnt in `termineRechtstexte.test.js:226` (Kopf der vier Dokumente), `termineEmbedStatic.test.js:219` (+ `docs/funktionen/termine-einbau.md:127`) und im Datenschutz-Wächter `termineRechtstexte.test.js:267`.
- WOMS `scripts/setup-legal.mjs` `DOCUMENTS` (Z. 89-96) hat sechs Karten; die Analyse-Karten (README nennt „datenschutz-website-analyse“) stehen nicht im Skript. Nicht Teil dieses Auftrags, dem Inhaber melden.
- Der gesetzliche Wertersatz (§ 357a Abs. 2 BGB, Anlage 1: Anteil am Gesamtpreis) ist nicht D3 „nach Stundenaufwand“; die Belehrung trägt den Gesetzeswortlaut, der Aufwand ist nur das Maß für den Anteil (⚖ Q5).
- `adminActivate` (`siteFeatures.js:350`) prüft weder Einwilligung noch AVV; `validateTextform` (`analyse.js:117`) ist exportiert und nimmt `minDate`.
---
### Task F1: Fassungskonstante und Dokument-Registry
**Files:** Create `server/services/legalDocs.js`, `test/legalDocs.test.js`; Modify `server/config.js` (`termine.avvVersion`; `features.termine.leistungsbeschreibung` und `.avv` als Getter `{ version: config.termine.avvVersion, url: '/rechtliches/leistungsbeschreibung-terminplaner-<version>.html' | '/rechtliches/avv-terminplaner-<version>.html' }`, sie ersetzen die ENTWURF-Platzhalter aus B1; `config.js` darf `legalDocs.js` nicht importieren, die Adresse wird dort aus Slug und Version gebaut, ein Test gleicht sie mit `legalPaths` ab; `ustHinweis` gibt es seit B1), `server/services/analyseLegal.js` (dünner Wrapper), `server/index.js` (`:41,114`: `app.use('/rechtliches', legalRouter())`).
**Consumes:** B1 (`features.termine`, `config.ustHinweis`). Der letzte Test nutzt B `featureConfig` aus `server/services/featureRegistry.js`.
**Produces:** `config.termine.avvVersion` (= `<F>`, der echte Berliner Kalendertag des F1-Commits laut Kopf, kein Platzhalter und nicht vor `2026-10-03`) und die Getter `features.termine.leistungsbeschreibung`/`.avv` (Version und Adresse folgen der Konstante auch zur Laufzeit). In `legalDocs.js`: Sätze `analyse` (avv, lb) und `termine` (lb, avv, widerruf; Slugs `leistungsbeschreibung-terminplaner`, `avv-terminplaner`, `widerrufsbelehrung-funktionen`; PDF-Namen `Leistungsbeschreibung-Terminplaner-<v>.pdf`, `AVV-Terminplaner-<v>.pdf`, `Widerrufsbelehrung-Muster-Widerrufsformular-<v>.pdf`); `legalPaths(satz, version) ⇒ { [doc]: '/rechtliches/<slug>-<v>.html' }`; `legalPdfFiles(satz, version) ⇒ { [doc]: { filename, path } }`; `legalPdfAttachments(satz, version, { ohne = [] } = {}) ⇒ Promise<Array<{ filename, content, contentType }>>` (Reihenfolge termine: lb, avv, widerruf; analyse: avv, lb; wirft bei fehlender Datei); `featureLegalAttachments(featureKey, opts) ⇒ Promise<Array>` (`termine` ⇒ Satz `termine` in aktueller Fassung, `news` ⇒ `[]`); `featureLegalReady(featureKey) ⇒ Promise<boolean>`; `legalRouter()` (Adressen ohne Fassung für beide Sätze, liefert die Fassung aus `config`; **nur** für Menschen und WOMS-Karten, keine Mail und kein Zustimmungstext darf sie verlinken).
`analyseLegal.js` behält `legalPaths(version) ⇒ { avv, lb }`, `legalPdfFiles`, `legalPdfAttachments`, `analyseLegalRouter` (rufen die Registry mit `'analyse'`), damit `analyseLegal.test.js` und `analyseRoutes.test.js:115` ohne Änderung grün bleiben. Versionsprüfung bleibt `^\d{4}-\d{2}-\d{2}$`.
- [ ] Tests (`legalDocs.test.js`): `legalPaths('termine', V)` nennt drei versionierte Adressen; `'../x'` und unbekannter Satz werfen; `legalPdfFiles` Dateinamen; `legalPdfAttachments('termine','1999-01-01')` rejected; `featureLegalAttachments('news')` = `[]`, `featureLegalReady('news')` = true; `config.termine.avvVersion` passt auf das Datumsmuster und liegt nach `'2026-10-02'` (der Altfassung der Gästetexte), `config.ustHinweis` wörtlich (aus B1, nur gelesen); Analyse-Tests unverändert grün; (nach B) `featureConfig('termine').leistungsbeschreibung` und `.avv` = `{ version: config.termine.avvVersion, url: legalPaths('termine', version).lb | .avv }` (Drift-Wächter gegen die in `config.js` gebaute Adresse; ändert ein Test `config.termine.avvVersion`, folgt der Getter); `featureLegalAttachments('news')` ist `[]` (die News-Rechtsdokumente `leistungsbeschreibung`/Fassung 2026-09-30 aus B1 bleiben unberührt und gehören F2).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Rechtstexte: Dokument-Registry und Fassungskonstante des Terminplaners“
### Task F2: News-Korrektur (Zitat, Wertersatz, Umsatzsteuer-Wortlaut)
**Files:** Modify `server/config.js` (`features.news.consent`, Alias `funktionen.consentText`, Kommentar `:192`), `public/vertragsunterlagen/funktionen-leistungsbeschreibung.html` (`:49,51,60,73`, „Stand“), `public/funktionen.js:272` (bzw. die Stelle, an die B den Preis verschoben hat), `server/services/stripeBilling.js:642` (Kommentar), `docs/superpowers/specs/2026-09-30-funktionen-news-design.md` (`:73,:78`, Korrekturvermerk); `features.news.mails.preisHinweis` steht in `config.js` (die K15-Vorlage ändert F2 **nicht**, B9a hat sie schon auf `{{preis_hinweis}}` umgestellt; `ustHinweis` im GET liefert B12). Tests `test/legalTexts.test.js:485`, `test/funktionenRoutes.test.js` (~153), neu `test/funktionenRecht.test.js`; **angepasste B-Pins:** `test/featureMails.test.js` (B9a: der eingefrorene News-Wortlaut „(brutto, einmalig; …)“ und die K15-Preiszeile bekommen den neuen Satz ohne „brutto“), `test/featureRegistry.test.js` (B1: der Wortlaut-Pin des News-Satzes und der Fassung; die übrigen Pins lesen `config.features.news`), `test/funktionenCheckout.test.js`/`test/funktionenRoutes.test.js` (B7: News-Nachweis liest Text und Fassung aus `config`).
**Consumes:** B-Register (`featureConfig('news').consent`), F1 `config.ustHinweis`.
**Produces:** News-Zustimmung (ein Haken, id `beginn`, `consent.version` = `<F>`): „Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung des Einbaus erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).“ (⚖ Q3; Aufbau wie der geprüfte Gästetext `ZUSTIMMUNG_VORZEITIG`). LB: Kopf-Tabelle und Abschnitt 2 „30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG)“, Abschnitt 6 „… erlischt nach § 356 Abs. 5 Nr. 2 BGB …“ plus Satz zum Wertersatz mit „(§ 357a Abs. 2 BGB)“; die LB bleibt „ENTWURF, Freigabe Nutzer ausstehend“ (Freigabe ist Sache des Inhabers). Bestellseite: „30 € · ohne Umsatzsteuer gemäß § 19 UStG“ aus `ustHinweis` (B12); K15: nur der Registerwert `features.news.mails.preisHinweis` verliert „brutto“ („einmalig, ohne Umsatzsteuer gemäß § 19 UStG; die Nutzung ist kostenlos, es läuft kein Abo“). Bestehende News-Bestellungen behalten Text und Fassung (keine Migration).
- [ ] Tests (`funktionenRecht.test.js`): `news_zustimmung_wortlaut` (Register = Alias = exakter Text, enthält `Abs. 5 Nr. 2` und `§ 357a Abs. 2`, nicht `Abs. 4`); `waechter_zitat_und_preistext` (über config.js, News-LB, funktionen.js bzw. Bestellseite, K15, stripeBilling.js kein Treffer auf `/§ 356 Abs\. 4/`, `/inklusive (Mehr|Umsatz)steuer/i`, `/\bbrutto\b/i`; LB nennt `ohne Umsatzsteuer gemäß § 19 UStG` zweimal); `bestellseite_hinweis` (`GET /api/funktionen` ⇒ `ustHinweis === config.ustHinweis` (B12), Client setzt ihn per `textContent`); `k15_news` (gerenderter Text: `30,00 €`, Hinweis, kein „brutto“); `alte_bestellung_unveraendert` (Dokument mit altem `consentText` bleibt beim Lesen unverändert, neue Bestellung speichert Register-Text und Fassung). `legalTexts.test.js:485` prüft `['30,00 €','§ 356 Abs. 5 Nr. 2 BGB','§ 357a Abs. 2 BGB','1–2 Wochen','keine laufenden Kosten']`; der ENTWURF-Pin der News-LB (`:481`) bleibt.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „News: Zitat § 356 Abs. 5 Nr. 2, Wertersatz-Satz und § 19 UStG statt brutto“
### Task F3: Widerrufsbelehrung der Funktionen (Text aus Code, Dokument gepinnt)
**Files:** Modify `server/services/termine/rechtstexte.js` (die Absatz-Bausteine nehmen die „Widerrufsfunktion“ als Parameter), Create `docs/rechtliches/widerrufsbelehrung-funktionen.md`; Test `test/termineRechtstexte.test.js` (erweitern, Hilfsfunktionen `musterZeilen`/`fristBaustein` unverändert nutzen).
**Produces:** `widerrufsbelehrungFunktion() ⇒ string` (Sie-Form, Text mit Zeilenumbrüchen wie `widerrufsbelehrung`): WEBklar als Unternehmer (`WEBKLAR_KONTAKT = { name: 'WEBklar GbR', adresse: 'Am Schwimmbad 10, 67722 Winnweiler', telefon: '0176 23726355', email: 'support@webklar.com' }`), Fristbeginn „des Vertragsabschlusses.“, Widerrufsfunktion im Satzrahmen der Anlage 1 mit der Adresse `https://project.webklar.com/widerrufen.html` (Wortlaut wie im Abo, `abo-bedingungen.html` Abschnitt „Widerrufsbelehrung“), Wertersatz für Dienstleistungen, Muster-Widerrufsformular mit „An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:“. Die Ausgabe von `widerrufsbelehrung(betrieb)` (Gäste) bleibt byte-gleich.
Dokument `widerrufsbelehrung-funktionen.md`: `# Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen)`, `**Fassung <F>**`, Hinweis „Das Widerrufsrecht gilt nur für Verbraucher“ (D1: Belehrung und Haken erhalten trotzdem alle Besteller, ⚖ Q1), Belehrung + Formular wörtlich aus `widerrufsbelehrungFunktion()`, dann „Hinweise zum Widerruf“ (nicht Teil der gesetzlichen Belehrung): 1. gilt je Bestellung einer Funktion und je Website-Projekt; 2. der Widerruf der Einrichtung beendet die Funktion, das Wartungs-Abo bleibt unberührt (⚖ Q7); 3. hat WEBklar auf ausdrücklichen Wunsch vor Fristende mit der Einrichtung begonnen, schuldet der Kunde einen angemessenen Betrag für die bis dahin erbrachte Leistung, bemessen als Anteil am Gesamtpreis; Maß ist der im Arbeitsblatt erfasste Aufwand (⚖ Q5); 4. Rückzahlung über das ursprüngliche Zahlungsmittel (Stripe).
- [ ] Tests: `belehrung_funktion_muster_woertlich` (jeder Absatz der Anlage 1 für Dienstleistungen steht wörtlich darin, nur die Lücken sind gefüllt); `belehrung_funktion_widerrufsfunktion` (Satzrahmen aus Anlage 1 wörtlich, Lücke = die Adresse); `belehrung_funktion_formular` (Anlage 2 wörtlich, „An“-Zeile mit WEBklar); `belehrung_gleich_abo` (die Absätze zwischen „Widerrufsrecht“ und „Ende der Widerrufsbelehrung“ in `public/abo-bedingungen.html` sind nach Normalisierung der Absatzgrenzen wortgleich zur Funktionsausgabe, wobei Kontaktklammer und „An“-Zeile vorher in beiden durch einen Platzhalter ersetzt werden, weil die Gäste-Bausteine „Telefon: …, E-Mail: …“ mit Doppelpunkt setzen; Drift-Wächter zwischen den beiden WEBklar-Belehrungen); `belehrung_dokument_gepinnt` (Textblock in der Datei = Funktionsausgabe, Kopf trägt `Fassung ${config.termine.avvVersion}`, kein `[`, `…`, `{{`); Gäste-Belehrung unverändert (bestehende Tests grün).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Rechtstexte: Widerrufsbelehrung der Funktionen aus dem Gesetzesmuster“
### Task F4: Leistungsbeschreibung Terminplaner (Markdown)
**Files:** Create `docs/rechtliches/leistungsbeschreibung-terminplaner.md`, `test/termineVertragsunterlagen.test.js`.
**Aufbau** (Nummern verbindlich: AVV, Paket und Mails verweisen darauf): `# Leistungsbeschreibung Terminplaner`, `**Fassung <F>** · Zusatzbedingungen zum Wartungs-Abo` und Abschnitte 1 Geltung · 2 Leistung · 3 Nicht enthalten · 4 Voraussetzungen und Pflichten des Kunden · 5 Einrichtung und Preis · 6 Verfügbarkeit und Störungen · 7 Haftung für verpasste Termine · 8 Ende der Leistung · 9 Änderungen und Einstellung · 10 Widerruf · 11 Hinweise. Dritte Person („der Kunde“) wie die Analyse-LB; Gerüst und Tonfall aus `leistungsbeschreibung-website-analyse.md`.
**Inhalt (Festlegungen):**
- 1: Zusatz zum Wartungs-Abo, **Hosting-Abo genügt nicht**; vereinbart mit der Bestellung und der Zustimmung zu LB, AVV Terminplaner und Belehrung (AVV Vorbemerkung regelt den Ablauf); Rangfolge: LB vor Abo-Bedingungen, in Datenschutzfragen der AVV; LB B Nr. 3 der Abo-Bedingungen („Rechtstexte nicht enthalten“) gilt nicht für Datenschutz-Absatz und Gästetexte.
- 2: je Website-Projekt ein Widget an einer Stelle (inline oder Knopf), Gestaltung nach Fragebogen; Leistungen, Personen/Plätze, Zeiten, Regeln; Portal-Ansicht, CSV-Export; E-Mails (Bestätigungslink, Bestätigung, Änderung, Absage, Erinnerung nur mit Haken, Eingang eines Widerrufs); geheimer Kalender-Link (ICS); Missbrauchsschutz; Löschfristen nach AVV Nr. 9; **nur Zahlung vor Ort**; Gästetexte, Datenschutz-Absatz und Belehrung für Gäste sind Mustertexte der Standardkonfiguration.
- 3: nicht enthalten: SMS, Online-Zahlung, Kalender-Sync (nur ICS-Abo), Konten der Gäste, Marketing-Mails, Gruppen, Mehrsprachigkeit, **Gesundheitsbereich**; Mehrwünsche (zweites Widget, Knöpfe je Leistung, Sonderdesign) nach Stunden (§ 4 Abo-Bedingungen).
- 4: laufendes Wartungs-Abo; Betriebsangaben samt Impressum- und Datenschutz-Adresse vor der Bestellung; kein Angebot im Gesundheitsbereich (WEBklar darf Einrichtung ablehnen oder abschalten, ⚖ Q11); kein Angebot gezielt an Kinder unter 16; Telefon-Ausweichlösung; Haken- und Hinweistexte unverändert lassen; Beschäftigte über Namen/Zeiten und Kalender-Link informieren; Pflichten gegenüber Gästen (Preisangaben, AGB/Stornobedingungen, Einstufung Freizeit/Verbraucher) trägt der Kunde. **Datenschutz-Absatz:** liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar ihn beim Einbau ein, Teil der Einrichtung und nicht auf das Stundenkontingent angerechnet; sonst Textbaustein, den der Kunde selbst einfügt (gleiche Regel wie Analyse-LB Nr. 4 Abs. 4, ⚖ Q18).
- 5: Einrichtung nach Fragebogen, Einbau, Datenschutz-Absatz, Abnahme, Freischaltung; **149 € einmalig je Website-Projekt, ohne Umsatzsteuer gemäß § 19 UStG**; die voraussichtliche Dauer nennt die Bestellseite (Einschätzung, keine Zusage); Zahlung über Stripe, Rechnung; Preis nicht auf Stunden anrechenbar.
- 6: Verfügbarkeitsziel (§ 5 Abo-Bedingungen) gilt für die Website, nicht für die Funktion; bei Ausfall zeigt das Widget den Ausweichtext mit Telefonnummer; Zeiten in Europe/Berlin; keine Gewähr für Zustellung und Lesen von E-Mails (Postfach des Empfängers, Mailgrenzen des Anbieters); gesetzliche Rechte, §§ 12, 13 Abo-Bedingungen bleiben.
- 7: (1) Hilfsmittel; Einhaltung der Termine bleibt Sache des Kunden. (2) keine Gewähr für Zustellung der E-Mails. (3) Entgangener Gewinn und Folgeschäden aus verpassten Terminen nur im Rahmen von § 13 Abs. 1 und 2 der Abo-Bedingungen. (4) Der Haftungsrahmen des Kunden gegenüber seinen Gästen bleibt unberührt (⚖ Q8).
- 8: (1) „Mit dem Ende des Wartungs-Abos endet der Terminplaner. WEBklar schaltet ihn spätestens am folgenden Tag ab und informiert den Kunden per E-Mail. Das gilt auch bei einem Wechsel ins Hosting-Abo und beim Widerruf des Abschlusses oder eines Wechsels ins Wartungs-Abo.“ (⚖ Q7). (2) Wirkung: keine neuen Buchungen, Ausweichtext; Termine bleiben im Portal sichtbar, CSV-Export; Gäste können über ihren Link absagen; bereits angeforderte Erinnerungen laufen weiter; der Kunde informiert seine Gäste. (3) „WEBklar löscht alle Termindaten und die Einstellungen 30 Tage nach dem Abschalten.“ (⚖ Q9). (4) Rückkehr (D2): „Beginnt innerhalb dieser 30 Tage wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein. Nach der Löschung ist eine neue Einrichtung nötig (neuer Fragebogen, neuer Preis nach Nr. 5).“ (5) Erstattung (D3): „Endet das Wartungs-Abo, bevor WEBklar den Terminplaner freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig.“ (⚖ Q6). (6) Auf Wunsch des Kunden in Textform schaltet WEBklar den Terminplaner jederzeit ab; eine Erstattung des Einrichtungspreises folgt daraus nicht (außer Widerruf, ⚖ Q6). Hinweis zur Kanzleifrage Q7: Dokumente, die WEBklar **ohne Bestellung** freigeschaltet hat (Pilot/Kulanz, auch F13a), nimmt der tägliche Abgleich aus und zählt sie im Tagesbericht als `ausgenommen`; sie bleiben bis zur Abschaltung durch WEBklar aktiv.
- 9: Technik verbessern, solange der Leistungsumfang nicht schlechter wird; Einstellung nur bei Rechtslage oder nicht behebbarer Sicherheitslücke mit Frist (Muster Analyse-LB Nr. 8 Abs. 2 und 3); **keine** Einstellung ohne Grund; Rechte nach § 327r BGB bleiben (Erstattung bei Einstellung durch WEBklar offen, ⚖ Q8).
- 10: Verbraucher widerrufen die Bestellung der Einrichtung nach der Belehrung (Anlage); Erlöschen und Wertersatz nach der Zustimmung bei der Bestellung (⚖ Q3, Q5); der Widerruf beendet die Funktion.
- 11: Keine Rechtsberatung: Mustertexte für die Standardkonfiguration, Einstufung (Verbraucher/Unternehmer, Freizeit) trägt der Kunde; **Barrierefreiheit:** „Das Widget ist für die Bedienung mit Tastatur und Hilfstechniken gestaltet (Beschriftungen, Fokusführung, Fehlermeldungen als Live-Region). Eine Konformität nach WCAG 2.1 AA oder EN 301 549 sagt WEBklar nicht zu. Ob der Kunde als Dienstleistungserbringer unter das BFSG fällt (Ausnahme für Kleinstunternehmen, § 3 Abs. 3 BFSG), entscheidet und prüft der Kunde.“ (⚖ Q22; kein axe-/Screenreader-Lauf in TP1, daher keine Konformitätszusage).
- [ ] Tests (`termineVertragsunterlagen.test.js`, LB-Teil): `lb_aufbau` (h2-Liste exakt, Kopf `Fassung ${config.termine.avvVersion}`); `lb_sauber` (kein `ENTWURF`, `Prüfpunkt`, `TODO`, `{{`, `<!--`); `lb_festwerte` (`149 €`, `§ 19 UStG`, „Hosting-Abo genügt nicht“-Satz, die Sätze von Nr. 8 Abs. 1, 3, 4, 5 wörtlich); `lb_preis_gleich_register` (`149 €` = `featureConfig('termine').priceLabel`); `lb_fristen_gleich_code` (30 Tage Abschalten = `LOESCHFRIST_TAGE` aus `termine/frist.js` (E2), kein Regex-Lesen von `jobs.js`); `lb_verweise` (jedes „Nr. N“ auf LB/AVV und „§ N Abo-Bedingungen“ existiert); `lb_ohne_du` (keine Du-Form); `lb_bfsg_ohne_konformitaetszusage` (kein „WCAG … entspricht“).
- [ ] FAIL → Text schreiben → PASS → `npm test` → Commit „Rechtstexte: Leistungsbeschreibung Terminplaner“
### Task F5: AVV Terminplaner (Markdown)
**Files:** Create `docs/rechtliches/avv-terminplaner.md`; Test `test/termineVertragsunterlagen.test.js` (AVV-Teil).
**Aufbau** wie `avv-website-analyse.md` (eigenes Geschwister, **kein Zusatz** zum AVV der Analyse, damit Analyse-Kunden nicht neu zustimmen): `# Auftragsverarbeitungsvertrag Terminplaner (Art. 28 DSGVO)`, `**Fassung <F>**`, Parteien, Vorbemerkung, Nr. 1–12, Anlage TOM.
**Inhalt (Festlegungen):**
- Vorbemerkung: Abschluss per Haken bei der Bestellung zusammen mit der LB; das Portal speichert Fassung, Zeitpunkt, Konto und Person (`featureConsents`); danach schickt WEBklar K15 mit den PDFs. Mitarbeiter dürfen die Funktion nur einschalten, wenn der Kunde vorher in Textform zugestimmt hat (F13a); der Kunde erhält die Unterlagen anschließend per E-Mail; Verhältnis zu Anlage 1 der Abo-Bedingungen: für den Terminplaner geht dieser Vertrag vor.
- Nr. 1 Dauer: ab Einschalten bis Abschalten, spätestens Ende des Wartungs-Abos (Abschalten spätestens am Folgetag); Pflichten bis zur Löschung. Nr. 2: Termine verwalten, E-Mails im Auftrag, Missbrauchsschutz; keine eigenen Zwecke, keine Profile.
- Nr. 3 Daten: Gäste (Name, E-Mail; optional Telefon, Nachricht, Zusatzfelder des Betriebs; Leistung, Person/Platz, Termin; Status mit Zeitpunkten; Nachweis der Zustimmungen als Zeitpunkt und Hashes der gezeigten Texte; Erinnerungswunsch; Zustellstatus der E-Mails) und Beschäftigte des Kunden (Namen/Bezeichnungen, Arbeitszeiten, Abwesenheiten, geheimer Kalender-Link). **Art. 9 DSGVO ausgeschlossen** (Gesundheitsbereich; Pflicht Nr. 11). IP-Adresse und Browser sieht der Server technisch, Mengenbegrenzung nur im Arbeitsspeicher, **mit der Buchung wird keine IP gespeichert**; Server-Protokolle 14 Tage (Anlage 2 der Abo-Bedingungen).
- Nr. 4 Weisungen (Einstellungen im Portal, sonst Textform), Nr. 5 Vertraulichkeit, Nr. 6 TOM (Anlage), Nr. 7 Unterauftragsverarbeiter: **Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen: Falkenstein (Server, Sicherung) und Nürnberg (Webhosting-SMTP für die Buchungsmails)**, keine Drittländer, Änderung mit vier Wochen Vorlauf und zwei Wochen Widerspruch (Muster Analyse-AVV Nr. 7), Stripe betrifft nur den Vertrag Kunde–WEBklar (⚖ Q10).
- Nr. 8 Betroffenenrechte: Anfragen von Gästen leitet WEBklar an den Kunden weiter; Unterstützung durch die Aktion „Gastdaten löschen“ (`anonymisieren`) und den CSV-Export; Datenpannen unverzüglich, möglichst binnen 24 Stunden; **Archiv der Fassungen der Gästetexte** (`docs/rechtliches/archiv/termine-gaestetexte-<V>.md`) bis drei Jahre nach Ende (Nachweis speichert nur Hashes, ⚖ Q26).
- **Nr. 9 Löschung genau wie gebaut:** nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten; abgesagte/abgelehnte Termine 30 Tage; bestätigte und erledigte Termine `aufbewahrungTage` (Vorgabe 90, einstellbar 14–365) nach Terminende; Versandwarteschlange: Empfängeradresse und Inhalt nach dem Versand geleert, Eintrag nach 7 Tagen gelöscht, nicht zustellbare nach 48 Stunden; **Abgeschaltet oder Ende des Abos: alle Termindaten und Einstellungen 30 Tage danach** (CSV-Export bis dahin, Rückkehr nach LB Nr. 8 Abs. 4); Entwürfe aus dem Fragebogen ohne Bestellung 90 Tage nach der letzten Änderung; Sicherungen: gelöschte Daten bleiben bis zu 7 Tage (§ 3 Abs. 5 Abo-Bedingungen, ⚖ Q9).
- Nr. 10 Nachweise/Kontrollen (wie Analyse), Nr. 11 Pflichten des Kunden (Verantwortlicher; Information der Gäste nach Art. 13 mit Datenschutz-Absatz und Hinweis am Formular; Beschäftigte informieren; kein Gesundheitsbereich; Erinnerungs-Haken nicht verändern), Nr. 12 Haftung (§ 13 Abo-Bedingungen, Art. 82), Änderungen mit sechs Wochen Vorlauf, im Übrigen § 16.
- Anlage TOM (Art. 32): TLS; **keine Cookies, nichts im Browser des Gastes** (gesichert durch `termineEmbedStatic.test.js:24`); Zugriff: Portal-Sitzung, Kunde sieht nur eigene Daten, öffentliche Schnittstelle nur über Site-Key, Kalender-Link mit geheimem Token (konstantzeitiger Vergleich); Missbrauchsschutz (Honeypot, Mindestzeit, Grenzen und Mailbudget nur im Arbeitsspeicher, `limits.js`); Datenminimierung; Trennung je Funktions-Dokument; Löschlauf täglich; Protokolle ohne Gast-Daten; Sicherung Hetzner (7 Tagessicherungen); Rechenzentrum ISO/IEC 27001; Organisation (Verpflichtung, Überprüfung jährlich). Jede Zeile der Anlage nennt in der Test-Tabelle ihre Fundstelle.
- [ ] Tests (AVV-Teil): `avv_aufbau` (Überschriften Vorbemerkung, 1–12, Anlage; Kopf mit Fassung); `avv_sauber` (wie LB); `avv_unterauftragsverarbeiter` (Nr. 7 nennt Hetzner mit Anschrift, `Falkenstein` **und** `Nürnberg`, kein Drittland); `avv_loeschfristen_gleich_code` (48 h, 30 Tage, Vorgabe 90 / 14 / 365, Outbox 7 Tage / 48 h; die 30 Tage Abschalten aus `LOESCHFRIST_TAGE` (`termine/frist.js`, E2), die übrigen Werte aus exportierten Konstanten bzw. per Regex aus `jobs.js`/`outbox.js`/`AUFBEWAHRUNG_*`, soweit nicht exportiert); `avv_keine_ip_in_buchung` (`TERMINE_COLLECTIONS.termineBookings.attributes` in `schema.js` enthält kein Attribut, dessen Name auf `/^(ip|ipAdresse|clientIp|userAgent)$/i` passt – ein loses `/ip/` träfe `stripeSessionId` und `stripePaymentIntent`; der `nachweis` einer über `halte` gehaltenen Buchung enthält keinen Schlüssel `ip`/`userAgent`; `baueNachweis` selbst ist nicht exportiert); `avv_verweise_auf_lb` (jedes „Nummer N der Leistungsbeschreibung“ existiert); `avv_ohne_analyse_bezug` (Analyse-Kunden brauchen keine neue Zustimmung: kein Verweis auf eine Änderung des Analyse-AVV).
- [ ] FAIL → Text schreiben → PASS → `npm test` → Commit „Rechtstexte: AVV Terminplaner“
### Task F6: HTML-Generator, PDF-Modus und ausgelieferte Dateien
**Files:** Create `scripts/build-legal-html.mjs`, `test/buildLegalHtml.test.js`, `public/rechtliches/{leistungsbeschreibung-terminplaner,avv-terminplaner,widerrufsbelehrung-funktionen}-<F>.{html,pdf}`; Modify `scripts/build-legal-pdfs.sh` (Modi `termine` und `datenschutz`, Variable `TERMINE_VERSION` mit Vorgabe `<F>`; die Zeile `pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf` bleibt in `all`), `docs/rechtliches/archiv/README.md` (Tabelle „Terminplaner“: Fassung, Dateien, „gültig ab“ offen bis Livegang).
**Consumes:** F1 `legalPaths`, F3–F5 Markdown.
**Produces:** `node scripts/build-legal-html.mjs termine [--check]` rendert die drei Markdown-Quellen deterministisch (keine Datumsangabe) in dieselbe Seitenhülle wie die Analyse-Seiten (Navigation zu den anderen Dokumenten, `@page`-Kopf „WEBklar · <Titel>“, Seite x von y, `meta` mit Fassung); Teilmenge von Markdown (h1–h3, Absätze, `- `-Listen, `**fett**`, `` `code` ``, Tabellen); jede andere Syntax (``` , `>`, Bilder, rohes HTML) bricht laut ab. `--check` vergleicht mit den eingecheckten Dateien (Exit 1). `render(md, meta) ⇒ html` wird exportiert. `build-legal-pdfs.sh termine` erzeugt die drei PDFs auf dem Host (vorher `free -m`, mit `nice`, Chrome aus `~/.cache/ms-playwright`); PDFs und HTML werden explizit gestaged.
- [ ] Tests (`buildLegalHtml.test.js`): `html_gleich_generator` (eingecheckte HTML = `render(md)` für alle drei); `html_text_gleich_markdown` (jeder Absatz der Quelle steht nach Tag-Entfernung und Normalisierung im HTML); `html_sauber` (kein `<!--`, `Entwurf`, `Prüfpunkt`, `<script`, `on…=`; `Fassung <F>` im Kopf; Links nur relativ oder `https:`); `generator_lehnt_unbekannte_syntax_ab`; `generator_escaped` (`<`, `&`, `"` in Text und Attributen); `pdf_dateien` (`%PDF`, > 20 KB je Datei, Dateinamen aus `legalPdfFiles`); `anhaenge_drei` (`legalPdfAttachments('termine', F)` liefert lb, avv, widerruf mit `application/pdf`); `router_alias` (`/rechtliches/avv-terminplaner.html` = Bytes der Fassungsdatei, unbekannte Fassung 404, `-1999-01-01.html` 404); `script_modus` (`build-legal-pdfs.sh` enthält die Modi `termine`, `datenschutz` und die unveränderte `all`-Zeile).
- [ ] FAIL → implementieren, Generator laufen lassen, PDFs bauen → PASS → `npm test` → Commit „Rechtstexte: HTML-Generator und PDFs des Terminplaners“
### Task F7: Bestelltexte (Zustimmungen), Dokumente und Belehrung im Register
**Files:** Modify `server/config.js` (`features.termine.consent`; `features.termine.widerruf` als Getter `{ version: config.termine.avvVersion, url: '/rechtliches/widerrufsbelehrung-funktionen-<version>.html' }` wie in F1), `server/services/featureRegistry.js` (Standardhaken `featureHaken('termine').belehrung` = `widerrufsbelehrungFunktion()`, F3); Test neu `test/featureConsentTexte.test.js`; **angepasste B-Tests** (gezielt, ihre Pins hängen an Texten und Fassung): `test/featureRegistry.test.js` (B1: Item-Texte und Fassung; der `todo`-Test „Bestelltexte ohne ENTWURF“ wird grün; die Item-IDs `unterlagen`/`gesundheit`/`beginn`/`information` stehen seit B1), `test/featureConsents.test.js` (B4: `nachweisTexte` mit `belehrung`, `zustimmungsfassungen` mit `widerruf`), `test/funktionenCheckout.test.js` (B6/B7: vier Haken, Nachweis mit `belehrung` und `knopf`).
**Consumes:** B-Register (`featureConfig`, `server/services/featureRegistry.js`: `consent: { version, knopf, items }`, `priceLabel`, `setupEstimate`, `leistungsbeschreibung`), B `featureConsents.js` und Checkout (Reihenfolge laut B7: Stripe-Sitzung erzeugen, Nachweis speichern, **dann** die URL zurückgeben), C2 (`bestellungAnsicht` liest `belehrung` aus demselben Haken), F1, F3.
**Produces:** `features.termine.consent = { version: config.termine.avvVersion, knopf: 'Zahlungspflichtig bestellen · 149 €', items }` mit vier Haken (alle Besteller, D1; alle `haken: true`, ohne `url`; die B-/C-Oberfläche rendert jedes Item mit `haken: true` als Haken, die ids sind für Tests und Nachweis verbindlich):
1. `unterlagen`: „Ich stimme der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner in der Fassung ${version} zu.“ (die versionierten Links stehen daneben: C8 rendert sie aus `bestellung.dokumente`, F1/F7 liefern die Adressen)
2. `gesundheit`: „Mein Angebot liegt nicht im Gesundheitsbereich (zum Beispiel keine Arzt-, Zahnarzt-, Psychotherapie-, Heilpraktiker- oder Physiotherapiepraxis).“
3. `beginn`: „Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Terminplaners beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).“ (⚖ Q3, Q5)
4. `information`: „Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Terminplaners für diese Website: ein Widget an einer Stelle, gestaltet nach dem Fragebogen, mit Einbau und Datenschutz-Absatz), Gesamtpreis ${priceLabel} einmalig (${ustHinweis}), voraussichtliche Dauer ${setupEstimate}, der Terminplaner endet mit dem Wartungs-Abo. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.“ (⚖ Q20)
Die dynamischen Teile setzt `config.js` einmal beim Laden ein, gespeichert wird immer der fertige Text. Knopf: „Zahlungspflichtig bestellen · 149 €“ (⚖ Q19). **Nachweis (Review Focus 5):** B7 speichert zusätzlich zu den Haken den Knopftext als Eintrag `{ id: 'knopf', text }` und die **gezeigte** Widerrufsbelehrung samt Muster-Formular (Hook `belehrung`) als Eintrag `{ id: 'belehrung', text }`; `zustimmungsfassungen` trägt `widerruf: version`. Die Bestellseite (C8) zeigt `bestellung.belehrung` aus demselben Hook, gezeigter Text und gespeicherter Text sind also dieselbe Zeichenkette.
- [ ] Tests (`featureConsentTexte.test.js`): `termine_haken_woertlich` (ids genau `['unterlagen','gesundheit','beginn','information']`, alle `haken true`, Texte exakt); `termine_version_gleich_dokumente` (= `config.termine.avvVersion`, `Fassung` in Haken 1); `information_nennt_register_werte` (`149 €`, `ohne Umsatzsteuer gemäß § 19 UStG`, `setupEstimate`); `keine_urls_im_text`; `textstand_pin` (SHA-256 über die verbundenen Texte beider Funktionen steht im Test, Kommentar „bei Textänderung neues Fassungsdatum **und** neuen Hash“: Textänderung ohne Fassungswechsel fällt auf); `nachweis_vor_umleitung` (Integration über B7: `POST /api/funktionen/termine/checkout` legt `featureConsents` mit `texte` = Register-Haken + `belehrung` + `knopf`, `fassungen` (mit `widerruf`) und Zeitpunkt an, `acceptedAt` == Klickzeit; das Dokument existiert, **bevor die Antwort mit `url` zurückkommt**, die Stripe-Sitzung entsteht davor (B7); schlägt das Speichern fehl ⇒ 500 ohne `url` und ohne `ordered`, die verwaiste Sitzung läuft nach 24 h ab); `belehrung_gezeigt_gleich_gespeichert` (gespeicherter `belehrung`-Text == `bestellung.belehrung` aus `GET /api/termine/einstellungen` == `widerrufsbelehrungFunktion()`); `knopf_gleich_register_und_nachweis` (`bestellung.consent.knopf` == Registertext == gespeicherter `knopf`-Eintrag); `alte_fassung_409` (Body mit alter Fassung ⇒ 409 mit neuer Fassung).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Bestelltexte des Terminplaners (vier Zustimmungen, Belehrung im Nachweis, Fassung)“
### Task F8: K15-Anhänge, Preisangabe und Unterlagen in der Bestellseite
**Files:** Modify `server/services/siteFeatures.js` (K15-Versand in `onFeaturePaid`: lazy `import('./legalDocs.js')` und `featureLegalAttachments(doc.featureKey)`; **kein** Registerhaken `mails.anhaenge`, B9a legt keinen an), `server/services/featureRegistry.js` (der Haken `unterlagenBereit` für `termine` = `featureLegalReady('termine')`), `server/routes/funktionen.js` (GET-Feld `legal`), `server/mail-templates/K15 Bestellung Funktion.html`, `server/mail-templates/T16 Funktion bestellt.html` (Block `anhang_fehlt`), `test/mailTemplates.test.js` (Platzhalter/Blöcke), Tests `test/funktionenRecht.test.js` (erweitern), `test/funktionenRoutes.test.js`, `test/featureMails.test.js` (**B9a/B9b-Pin „Platzhalter-/Blocklisten exakt“**: K15 um `fassung`, `lb_url`, `avv_url`, `widerruf_url`, `zustimmungen_text` und die Blöcke `anlagen`, `zustimmungen`, T16 um den Block `anhang_fehlt` ergänzen). Die Bestellseite `public/funktion-bestellen.js` (Darstellung der Belehrung und der Links) gehört C8; F8 ändert sie nicht.
**Consumes:** F1 `featureLegalAttachments`, `featureLegalReady`, `legalPaths`; F3 `widerrufsbelehrungFunktion`; B `onFeaturePaid`, `zustimmungFuerSitzung` (B4).
**Produces:** K15 trägt für `termine` drei PDF-Anhänge (LB, AVV, Belehrung mit Muster-Formular; dauerhafter Datenträger, § 312f BGB; Muster `legalPdfAttachments`/K18). **Mechanismus (einziger Weg):** `onFeaturePaid` ruft vor dem K15-Versand `await featureLegalAttachments(doc.featureKey)` (lazy `import()` aus `legalDocs.js`; `news` ⇒ `[]`) und übergibt das Ergebnis als `attachments`; ein Registerhaken `mails.anhaenge` besteht nicht. Neue K15-Werte (Texte in Du-Form wie die übrigen K-Vorlagen; `preis_hinweis` hat B9a schon deklariert und mit `mails.preisHinweis` gefüllt): `fassung`, `lb_url`, `avv_url`, `widerruf_url` (absolute, **versionierte** Adressen), `zustimmungen_text` (Wortlaut und Zeitpunkt der erteilten Zustimmungen aus `zustimmungFuerSitzung(session.id)`, B4: die Zustimmung zur **bezahlten** Sitzung); Blöcke `anlagen` und `zustimmungen` nur für Funktionen mit Rechtsdokumenten (⚖ Q20). Sind die PDFs beim Versand nicht lesbar, geht K15 **ohne** Anhänge raus (Bestätigung vor Anhang; die Zahlung hat Spur) und T16 meldet im Block `anhang_fehlt` „Unterlagen von Hand nachsenden“; Fehler loggen. Checkout: der B6-Haken `unterlagenBereit` ist für `termine` an `featureLegalReady('termine')` gehängt (Stufe 5b in B6: `false` ⇒ 503 `unterlagen_fehlen` „Die Vertragsunterlagen sind gerade nicht abrufbar. Bitte versuch es später noch einmal oder schreib uns im Chat.“, vor jeder Stripe-Sitzung; die Stufe steht im Reihenfolge-Test von B6). `GET /api/funktionen` ergänzt `legal: { termine: { version, links: { lb, avv, widerruf } (absolut, https), belehrung: widerrufsbelehrungFunktion() }, news: null }` (`ustHinweis` liefert schon B12). Dieselben Werte (die versionierten Adressen über `dokumente`, die Belehrung über den Haken aus F7) fließen in `bestellungAnsicht` (C2), aus der die Bestellseite (C8) rendert: Links mit `rel="noopener noreferrer"` und `target="_blank"`, die Belehrung samt Formular als Text im **nicht einklappbaren** Bereich über dem Knopf (Absätze per `textContent`, **kein `<details>`**).
- [ ] Tests: `k15_termine_anhaenge` (drei Anhänge in der Reihenfolge lb, avv, widerruf, Namen aus `legalPdfFiles`, alle `%PDF`; `Object.hasOwn(featureConfig('termine').mails, 'anhaenge') === false`); `k15_news_ohne_anhaenge` (News unverändert, kein Block `anlagen`); `k15_links_versioniert` (Text enthält `-${F}.html`, **keine** Adresse ohne Fassung); `k15_anhang_nicht_lesbar` (`legalPdfAttachments` wirft ⇒ K15 ohne Anhänge, T16 mit Block `anhang_fehlt`, Dokument trotzdem `setup`, Ticket angelegt, Webhook 200); `k15_zustimmungen_text` (Text der vier Haken und Zeit im Mail-Text); `checkout_503_ohne_unterlagen` (keine Stripe-Sitzung, kein `ordered`-Dokument); `liste_legal` (`legal.termine.links` alle `https://` und versioniert, `belehrung` = Funktionsausgabe); `bestellseite_statisch` (kein `innerHTML` für die Belehrung, kein `<details>`, `rel="noopener noreferrer"`); `mailTemplates.test.js`: deklarierte = verwendete Platzhalter/Blöcke für K15 und T16; `featureMails.test.js`: die exakten Listen aus B9a/B9b sind um die oben genannten Werte ergänzt (kein zweiter Test dafür).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktionen: Vertragsunterlagen als K15-Anhang, Belehrung vor dem Bestellknopf“
### Task F9: Widerruf je Funktion (Formular, Frist, K04/T09)
**Files:** Modify `public/widerrufen.html` (`:23`), `server/routes/abo.js` (`ORDERS :707`, `orderLabel :782`, `withdrawFeature :1088`, `:1118`, `:1143`, `:1166`), `server/mail-templates/K04 Eingang Widerruf.html`, `server/mail-templates/T09 Widerruf eingegangen.html`, `test/mailTemplates.test.js` (`:254-258`, Kopfkommentar), Tests `test/publicForms.test.js` (neben `:518`).
**Consumes:** B4 `paidFeatureOrders(customerId)` mit `featureKey` und `vertragAt` (Zeit der Zustimmung zur **bezahlten Sitzung** = Klick, Review-Fokus 5; B4 stellt `withdrawFeature` bereits auf `vertragAt || orderedAt` um), `featureConfig(key)` (`label.name`, `priceLabel`). F9 baut keinen zweiten Anker (kein `contractAt`, kein `letzteZustimmung`: eine spätere, unbezahlte Zustimmung zu einer neuen Sitzung würde den Anker verfälschen).
**Produces:** `widerrufen.html`: neuer Radioknopf `value="termine"` mit `data-text="Terminplaner (Einrichtung)"`, der bestehende `funktion` („Funktion (z. B. News-Pinnwand)“) bleibt unverändert. `ORDERS` + `termine`; `FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine' }`; `orderLabel('termine') = 'Terminplaner (Einrichtung)'`. `withdrawFeature(d, receivedAt, featureKey)` filtert die Bestellungen aus `paidFeatureOrders` auf die gewählte Funktion (`featureKey`) und rechnet die Frist **aus deren `vertragAt`** (spätestes Fristende je Funktion, Zeilen mit eigenem Fristende je Projekt). `produkt` = `featureConfig(key).label.name` (statt fest „News-Pinnwand“), neu `preis` = `featureConfig(key).priceLabel` in T09. K04 Block `funktion` (Sie-Form bleibt): Wertersatz-Satz für „begonnene“ statt „abgeschlossene“ Einrichtung, Verweis § 357a Abs. 2 BGB. T09 Block `funktion`: „Widerruf einer Funktion ({{produkt}}, {{preis}} einmalig) …“; Handgriffe: Erstattung in Stripe binnen 14 Tagen, ggf. abzüglich Wertersatz für den begonnenen Einbau (Aufwand im Arbeitsblatt, ⚖ Q5), Funktion im Reiter „Funktionen“ abschalten; bei Terminplaner: Wartungs-Abo bleibt unberührt.
- [ ] Tests: `widerruf_termine_frist_je_funktion` (News vor 20 Tagen, Terminplaner vor 3 Tagen bestellt, Widerruf `termine` ⇒ T09 `RECHTZEITIG`, Fristende des Terminplaners; umgekehrt Widerruf `funktion` ⇒ `NACH Fristende`; Regression des bisherigen Maximums); `widerruf_termine_mails` (K04 nennt „Terminplaner (Einrichtung)“, T09 „149 €“ und das Projekt; `Funktion (z. B. News-Pinnwand)`-Test bei `funktion` unverändert grün); `widerruf_anker_vertragAt` (`orderedAt` später überschrieben, Zustimmung zur bezahlten Sitzung früher ⇒ Frist aus `vertragAt`; ohne `featureConsents`-Eintrag Rückfall auf `orderedAt`; die Grundlage prüft der B4-Test, hier nur die Funktionsfilterung darauf); `widerruf_unbekannte_funktion` (kein bezahltes Dokument ⇒ „ungeprüft (keine bezahlte Funktion gefunden)“); `mailTemplates.test.js`: T09 deklariert `preis`, K04/T09 Blöcke unverändert; `widerrufen_html_radio` (Wert `termine`, Beschriftung wörtlich).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Widerruf: Terminplaner als eigene Auswahl, Frist und Mails je Funktion“
### Task F10a: Gästetexte – Archiv-Skript und eingefrorene Altfassung 2026-10-02
**Files:** Create `scripts/termine-texte-archiv.mjs`, `docs/rechtliches/archiv/termine-gaestetexte-2026-10-02.md` (Altfassung, aus den **unveränderten** Texten erzeugt, **eigener Commit vor jeder Textänderung**), `test/termineRechtstexteArchiv.test.js`; Modify `docs/rechtliches/archiv/README.md` (Tabelle „Terminplaner-Gästetexte“).
**Consumes:** `server/services/termine/rechtstexte.js` im Stand 2026-10-02 (noch ohne Änderung durch F10b1).
**Produces:** `termine-texte-archiv.mjs [--check]` schreibt `docs/rechtliches/archiv/termine-gaestetexte-<V>.md` aus `rechtstexte.js` mit Platzhalter-Betrieb: Fassung, Formular-Hinweis (Sie/Du), alle Haken, Gesundheitshinweis, Knopftexte (Tabelle), Belehrung (Platzhalter-Fassung), Hinweis „Der Nachweis speichert Hashes der Texte mit den Betriebsangaben zum Buchungszeitpunkt“ (⚖ Q26); `--check` vergleicht mit der eingecheckten Datei (Exit 1). Die README-Tabelle „Terminplaner-Gästetexte“ nennt die Fassung 2026-10-02.
- [ ] Tests: `archiv_aktuelle_fassung` (Skriptausgabe = eingecheckte Datei für die aktuelle `RECHTSTEXTE_VERSION`; Datei und README nennen die Fassung); `archiv_altfassung_eingefroren` (die Datei 2026-10-02 enthält den alten Erinnerungstext **ohne** den Widerrufssatz, ihr SHA-256 ist im Test festgenagelt).
- [ ] FAIL → implementieren, Altfassung erzeugen → PASS → `npm test` → Commit „Gaestetexte: Archiv-Skript und eingefrorene Fassung 2026-10-02“ (**vor** F10b1)
### Task F10b1: Gästetexte final 1 – Fassung <V>, Erinnerungs- und Abo-Ende-Satz, Archiv
**Files:** Modify `server/services/termine/rechtstexte.js` (`RECHTSTEXTE_VERSION`, `ZUSTIMMUNG_ERINNERUNG`, `datenschutzAbsatz` aus D1), `docs/rechtliches/termine-datenschutz-absatz.md` (Satz (c); Quelle der D1-Pins, Funktion und Markdown ändern gemeinsam), `docs/rechtliches/archiv/README.md`; Create `docs/rechtliches/archiv/termine-gaestetexte-<V>.md` (mit dem Skript aus F10a); Tests `test/termineRechtstexte.test.js` (Version, Sätze (b) und (c), D1-Pins), `test/publicTermine.test.js` (Nachweis-`fassung`), `test/termineRechtstexteArchiv.test.js`.
**Consumes:** F10a, D1 `datenschutzAbsatz(einstellung, anrede)`, F1 (`config.termine.avvVersion` = `<F>`).
**Produces:** `RECHTSTEXTE_VERSION = '<V>'` (= `<F>`, Literal in `rechtstexte.js`, nicht `2026-10-02`). Änderungen gegenüber `2026-10-02`: (b) `ZUSTIMMUNG_ERINNERUNG(n)` = „Ich möchte ${n} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.“ (Art. 7 Abs. 3 DSGVO, ⚖ Q17; der Hash im Nachweis deckt den Satz); (c) Datenschutz-Absatz Sie/Du: „Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht.“ (Quelle `datenschutzAbsatz()` und Markdown gemeinsam); `termine-texte-archiv.mjs` (F10a) erzeugt `docs/rechtliches/archiv/termine-gaestetexte-<V>.md`. Die README-Tabelle „Terminplaner-Gästetexte“ nennt beide Fassungen. Die ENTWURF-Köpfe und `RECHTSTEXTE_ENTWURF` bleiben bis F10b2 unverändert.
- [ ] Tests: `archiv_aktuelle_fassung` (aus F10a, jetzt für `<V>`: Skriptausgabe = eingecheckte Datei, `RECHTSTEXTE_VERSION` in Datei und README); `archiv_altfassung_eingefroren` (aus F10a, unverändert grün); `textstand_pin` (SHA-256 der aktuellen Archivdatei im Test: Textänderung ohne neues Datum fällt auf); `erinnerung_widerrufssatz` (Haken-Text, Dokument und `texte.erinnerung` der öffentlichen Konfiguration wörtlich gleich); `absatz_abo_ende` (Funktion und Markdown, Sie und Du, nennen „30 Tage nach dem Abschalten“; bestehende Regex-Pins bleiben grün); `version_gleich_register` (`RECHTSTEXTE_VERSION === config.termine.avvVersion` und ungleich `'2026-10-02'`); Nachweis-`fassung` einer neuen Buchung = `RECHTSTEXTE_VERSION`, Altbuchungen behalten `2026-10-02`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Gästetexte des Terminplaners: Fassung <V>, Erinnerungs- und Abo-Ende-Satz, Archiv je Fassung“
### Task F10b2: Gästetexte final 2 – Entwurf-Kennzeichnung entfernt
**Files:** Modify `server/services/termine/rechtstexte.js` (Kopf, `RECHTSTEXTE_ENTWURF = false` (D1)), `server/services/termine/einbauanleitung.js` (Checklisten-Text zum Datenschutz-Absatz, D3b), `docs/rechtliches/termine-hinweise.md`, `-datenschutz-absatz.md`, `-widerrufsbelehrung-muster.md`, Rename `termine-datenschutz-6c-entwurf.md` → `termine-datenschutz-6c.md` (hier nur Kopf entfernt, Inhalt in F12a), `docs/funktionen/termine-einbau.md` (`:127`); Tests `test/termineRechtstexte.test.js` (`:49`, Liste `DOKUMENTE :219` mit neuem 6c-Namen, `:226`, Pfad im Wächter `:267`, **D1-Test (f)**: `RECHTSTEXTE_ENTWURF === false`), `test/termineEinbauanleitung.test.js` (D3b: die Banner-Tests mit Standardwert drehen um, Checklisten-Text), `test/termineEmbedStatic.test.js:219`.
**Consumes:** F10a, F10b1 (Version, Sätze und Archiv stehen), D `RECHTSTEXTE_ENTWURF` und `einbauanleitung` (der Banner hängt nach D1/D3b an `RECHTSTEXTE_ENTWURF`; er fällt hier weg).
**Produces:** `RECHTSTEXTE_ENTWURF = false`. Änderungen: (a) ENTWURF-Köpfe aus allen vier Dokumenten und die „Für die Rechtsprüfung offen“-Listen aus den drei Gäste-Dokumenten entfernt (die offenen Punkte stehen als Fragen im Kanzlei-Paket); (d) `termine-einbau.md` Abschnitt 6 ohne die Pilot-Einschränkung; (e) der Checklisten-Text der Einbauanleitung nennt die Endregel aus F4 Nr. 4 (WEBklar fügt den Datenschutz-Absatz ein, wenn die Website von WEBklar betreut wird, sonst Textbaustein per Chat; Teil der Einrichtung, nicht auf das Stundenkontingent angerechnet) statt der Zwischenregel aus D3b.
- [ ] Tests: `kein_entwurf_mehr` (kein `ENTWURF` im Sinne von `/ENTWURF(?![_A-Z])/` in den vier Dokumenten (Köpfe), in den Texten von `rechtstexte.js` und in `docs/funktionen/termine-einbau.md`; der Bezeichner `RECHTSTEXTE_ENTWURF` ist erlaubt und steht auf `false`; das alte Kopf-Gebot `termineRechtstexte.test.js:226` wird zum Verbot); `einbau_ohne_banner` (nach D: die Anleitung im Standardaufruf enthält kein ENTWURF-Banner; D3b-Banner-Tests mit explizitem `entwurf: true` bleiben); `checkliste_datenschutz_endregel` (Text der Checkliste = Endregel, die Zwischenregel „liegt die Datenschutzerklärung nicht in unserem Repo“ ist weg); `termineEmbedStatic.test.js:219` prüft statt `'ENTWURF'` das Wort `'Fassung'`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Gästetexte des Terminplaners: Entwurf-Kennzeichnung entfernt“
### Task F11: Portal-Hinweistexte und Storno nur bei Preis
**Files:** Modify `public/termine-tab-einstellungen.js` (`WIDERRUF_HINWEIS`, `FREIZEIT_HINWEIS` `:16-23` exportieren; Storno-Abschnitt ~`:222`), `server/services/termine/ablauf.js` (`:231,:282`), `server/services/termine/vorlagen.js` (`ausVorlage` ~`:209`), `public/embed/termine.js` (`:1215-1219,:1259,:1279`); Tests `test/termineAblaufGast.test.js`, `test/termineEinstellungen.test.js`, `test/termineView.test.js`, `test/termineEmbedStatic.test.js`, (`test/ui/termine-widget.ui.mjs` von Hand).
**Produces:** `FREIZEIT_HINWEIS` (Du-Form): „Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend, ein Kurs oder ein Escape Room. Nach unserer Einschätzung gilt bei Friseur, Kosmetik, Nagel- und Fußpflege, Tattoo, Fotoshootings, Coaching und Beratung in der Regel ein Widerrufsrecht; bei Massage und Wellness ist die Einordnung unklar, im Zweifel gilt ein Widerrufsrecht. Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. Das ist keine Rechtsberatung: Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.“ (nimmt Massage und Weiterbildung aus der Liste „keine Freizeitleistung“, die die Recherche nicht trägt, ⚖ Q15). `WIDERRUF_HINWEIS` bleibt, erhält nach dem dritten Satz: „Hat ein Gast ausdrücklich verlangt, dass du vor Ablauf der Frist beginnst, und widerruft er danach, kann er für die bis dahin erbrachte Leistung zu einem angemessenen Betrag verpflichtet sein (Wertersatz, § 357a Abs. 2 BGB).“ Storno: `stornoNoetig` nur bei `hatPreis(leistung)`; bei kostenlosen Leistungen kein Pflichthaken, **kein** `nachweis.storno`, im Widget weder Stornobox noch Haken noch Prüfung (nach `l.preis` der gewählten Leistung); `ausVorlage` setzt `storno.text = ''`, wenn keine Leistung der Vorlage einen Preis hat; Abschnitt „Stornobedingungen“ ergänzt: „Die Stornobedingungen gelten nur für Leistungen mit Preis; bei kostenlosen Leistungen fragt der Terminplaner Gäste nicht danach.“ (⚖ Q16). **Muster-Kennzeichnung (Spec 7.3):** der vorgeschlagene Stornotext (Vorgabe 50 % / 24 h) wird im Editor als Muster gekennzeichnet, mit einem Hinweis neben dem Feld: „Das ist ein Muster, kein Rechtstext. Bitte prüfe es und passe es an deine Bedingungen an.“ (Du-Form, nur im Portal, nicht im Gästetext).
- [ ] Tests: `storno_haken_nur_bei_preis` (`termineAblaufGast`: Leistung mit Preis ohne Haken ⇒ 400 wie bisher; `S_GRATIS` ohne Haken ⇒ Buchung gehalten, `nachweis.storno` fehlt; mit `storno: true` bei Gratis ⇒ ebenfalls ohne `nachweis.storno`; Handeintrag unverändert); `vorlage_ohne_preis_ohne_storno` (Vorlage mit nur kostenlosen Leistungen ⇒ `storno.text === ''`, Vorlagen mit Preisen unverändert); `widget_storno_je_leistung` (`termineEmbedStatic`: Box, Haken und Prüfung hängen an `l.preis`); `freizeit_hinweis_text` (nennt weder „Massage“ noch „Weiterbildung“ als Beispiel für „keine Freizeitleistung“, enthält „keine Rechtsberatung“, Du-Form); `widerruf_hinweis_wertersatz`; `storno_abschnitt_text`; `storno_muster_gekennzeichnet` (Hinweis steht neben dem Stornofeld, wörtlich, Du-Form; er erscheint nicht in den Gästetexten und nicht im Hash des Nachweises); Playwright-Lauf des Widgets bleibt außerhalb von `npm test` (einmal von Hand, `nice`, nach `free -m`).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Hinweistexte nach Recherche, Storno nur bei Preis“
### Task F12a: Datenschutzerklärung (6c, §6, §7) und PDF
**Files:** Modify `public/datenschutz.html` (`Stand`; §6 `:82`; nach 6b `:93-95` neu `<h2 id="termine">`; §7 `:99`), `docs/WEBklar-Datenschutzerklaerung.pdf` (neu per `build-legal-pdfs.sh datenschutz`); `docs/rechtliches/termine-datenschutz-6c.md` (aus F10b2 umbenannt; Markdown-Quelle des Abschnitts, ohne „Einbau in TP2“, ohne „nicht eingebaut“, ohne „Für die Rechtsprüfung offen“); Tests `test/legalTexts.test.js` (`:494-` Kundenansicht-Pin, Stand-Pin), `test/termineRechtstexte.test.js` (`:267` Wächter entfernen).
**Consumes:** B (`config.collections.featureConsents`), A/C (Entwurfs-Löschung nach 90 Tagen), E (Abo-Ende, Rückkehr, K22), F10b2 (Umbenennung).
**Produces:** **6c final** (`<h2 id="termine">6c. Online-Terminbuchung auf Websites unserer Kunden</h2>` zwischen 6b und §7, kein Kommentar im HTML): Rolle (Auftragsverarbeiter für Gäste; Verantwortliche für Betriebsdaten, Fragebogen-Entwurf und Bestellung); Daten wie Entwurf; Einstellungen und Entwurf: nicht bestellte Entwürfe werden 90 Tage nach der letzten Änderung gelöscht (auch bei Kundenlöschung); E-Mail-Versand über Hetzner (Rechenzentrum Nürnberg, Abschnitt 8), Server in Falkenstein; „mit der Buchung speichern wir die IP-Adresse nicht“ (belegt durch `avv_keine_ip_in_buchung`); Aufruf: Skript und Texte beim Laden der Seite, Termine im Modus `inline` beim Durchklicken, im Modus `knopf` erst nach Klick (`termine-einbau.md` §6); Fristen wie AVV Nr. 9; **Ende des Abos:** „Endet das Wartungs-Abo oder schalten wir die Funktion ab, schalten wir den Terminplaner spätestens am folgenden Tag ab und löschen alle Termindaten und Einstellungen 30 Tage danach; beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schalten wir ihn mit den vorhandenen Daten wieder ein“; Sicherungen halten gelöschte Daten bis zu 7 Tage. **§6:** „… und dabei für Sie News-Beiträge anlegen, ändern oder löschen und, wenn Sie den Terminplaner nutzen, dessen Einstellungen und Termine ansehen und ändern.“ (Sperre für Bestellungen, Zahlungen, Fragebogen-Bestätigung bleibt). **§7:** neuer Absatz: „Bestellen Sie eine Funktion (News-Pinnwand, Terminplaner), speichern wir als Nachweis die Texte und Fassungen, denen Sie zugestimmt haben, den Zeitpunkt Ihres Klicks auf „Zahlungspflichtig bestellen“, die Stripe-Sitzung, IP-Adresse und Browserkennung sowie Ihr Konto und die angemeldete Person. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO; unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses und der Zustimmungen, insbesondere für den Widerruf. Diese Angaben löschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet. Den Entwurf Ihres Fragebogens speichern wir bis zu 90 Tage nach Ihrer letzten Änderung und löschen ihn danach, wenn Sie nichts bestellt haben.“ (⚖ Q23; Spec 3.7: 6c und §7 nennen die Entwurfsdaten und die Frist). `Stand` = Änderungstag.
- [ ] Tests: `legal_datenschutz_termine` (Reihenfolge `id="analyse"` < `id="termine"` < `<h2>7. `; Pflichtsätze: Auftragsverarbeiter, `Falkenstein`, `Nürnberg`, `48 Stunden`, `30 Tage`, `90 Tage`, `sieben Tage`, „nicht dauerhaft“; kein `<!--` im Block; Nummerierung 7–15 unverändert); die bestehenden Wächter bleiben grün, weil 6c ihre Verbote meidet: `legal_datenschutz_news` schneidet von 6a bis §7 (also auch 6c; verboten sind dort „Abschnitt 6 und 7“, „spätestens nach 24 Stunden“, „Endet die Zusammenarbeit, schalten wir“), `Datenschutz: Abos, produktneutral` verbietet im ganzen Dokument u. a. „Unterauftragsverarbeiter“ und „im Rahmen des Wartungs-Abos“; `datenschutz_6c_gleich_markdown` (HTML-Block = Markdown-Quelle); `legal_datenschutz_kundenansicht` angepasst (neuer Satz wörtlich, `Stand` per Regex `/Stand \d{2}\.\d{2}\.2026/` und ≥ 01.10.2026 und ≥ Datum aus `termsVersion`); `legal_datenschutz_funktionsbestellung` (§7-Absatz wörtlich, **einschließlich des Satzes zu den Entwurfsdaten und den 90 Tagen**); `wiederkehr_satz_gleich_lb` (Satz zur Rückkehr in 6c/LB/AVV/K22 enthält jeweils „30 Tage“ und „wieder ein“, K22 per Glob `server/mail-templates/K22 *.html`); der 6c-Wächter `termineRechtstexte.test.js:267` entfällt; `build-legal-pdfs.sh datenschutz` erzeugt das PDF (> 20 KB, `%PDF`).
- [ ] FAIL → implementieren, PDF bauen → PASS → `npm test` → Commit „Datenschutz: Abschnitt 6c Terminplaner, Kundenansicht und Nachweise bei Funktionsbestellung“
### Task F12b: Aufbewahrung der Zustimmungsnachweise (Retention)
**Files:** Modify `server/services/retention.js` (`findDueDeletions :74` samt Bedingung `if (!posts.length && !features.length) continue`, `applyDeletions :172`, `formatReport :244`); Test `test/retention.test.js`.
**Consumes:** B3 (`config.collections.featureConsents`), F12a (der §7-Satz „drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet“).
**Produces:** `findDueDeletions` plant die `featureConsents` fälliger Kunden mit (`plan.news[].consentIds`), `applyDeletions` löscht sie (`deleted.featureConsents`), `formatReport` nennt sie (damit der §7-Satz stimmt; Kunden ohne Abo, z. B. nur News, bleiben wie bisher außerhalb der Retention, siehe offene Fragen).
- [ ] Tests: `retention_loescht_featureConsents` (fälliger Kunde ⇒ Einträge geplant und gelöscht, nicht fälliger Kunde unberührt, Bericht nennt Anzahl; ein Kunde ohne Abo, z. B. nur News, bleibt außerhalb der Retention wie bisher).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Retention: Zustimmungsnachweise der Funktionsbestellung drei Jahre nach Abo-Ende loeschen“
### Task F13a: Staff-Aktivierung nur mit Textform-Nachweis (Modus `on`) – Portal
**Files:** Modify `server/services/termine/schalter.js`, `server/routes/admin/funktionenAdmin.js` (POST + GET), `server/services/siteFeatures.js` (`adminActivate`), Tests `test/termineSchalter.test.js`, `test/funktionenTermine.test.js`.
**Consumes:** B4 `speichereZustimmung` (erlaubt leeres `stripeSessionId`/`ip`/`userAgent` und Texte ohne `haken`), D6 `meldeFreigeschaltet(doc, { attachments })`; `validateTextform(textform, { minDate })` aus `analyse.js`; F1 `legalPaths`, `featureLegalAttachments`.
**Produces:** `termineTextformNoetig(email) ⇒ boolean` (`termineModus() === 'on'` **und** die Adresse steht nicht in `config.termine.pilotEmails`; `'*'` zählt nicht als Pilotadresse). `POST /api/team/funktionen/:customerId/termine` mit `action: 'activate'` verlangt dann `textformDate` (JJJJ-MM-TT, nicht vor `config.termine.avvVersion`, nicht in der Zukunft) und `textformChannel` (`chat` | `email`), sonst 400. Bei Erfolg: `speichereZustimmung({ customerId, projectId, featureKey: 'termine', stripeSessionId: '', fassungen: { leistungsbeschreibung: v, avv: v }, texte: [{ id: 'textform', text: 'Der Kunde hat der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner (Fassung <v>) am <TT.MM.JJJJ> per <Chat im Kundenportal | E-Mail> in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt.' }], ip: '', userAgent: '', konto: 'Mitarbeiter: <Name>', at })`, danach K16 über `meldeFreigeschaltet(doc, { attachments })` mit den beiden PDFs (LB, AVV; `featureLegalAttachments('termine', { ohne: ['widerruf'] })`), damit der Kunde die Unterlagen auf einem dauerhaften Datenträger erhält (⚖ Q21). `GET /api/team/funktionen/:customerId` ⇒ zusätzlich `termine: { textformNoetig, avvVersion, links }`. Hinweis: Dokumente, die so ohne Bestellung freigeschaltet werden (`unlockedByPurchase` bleibt falsch), nimmt der Abo-Abgleich aus und zählt sie im Tagesbericht als `ausgenommen` (Kanzleifrage Q7).
- [ ] Tests (Portal): `textform_pflicht_modus_on` (Kunde fremd ⇒ ohne Angaben 400, mit gültigen 200, `active`, `featureConsents` mit Datum, Weg, Fassung, Mitarbeitername, leerem `stripeSessionId`, K16 mit zwei `%PDF`-Anhängen in `mailSink`); `textform_entfaellt` (Modus `off`/`pilot`, Modus `on` mit Pilotadresse, News, Abschalten); `textform_datum` (vor Fassung, Zukunft, ungültiger Weg ⇒ 400, nichts angelegt); `get_admin_termine_feld`; `termineTextformNoetig` je Modus; `chat_vorlage_versioniert` (nur Adressen mit `-${v}.html`); `zustimmungFuerSitzung('')` findet den Staff-Nachweis nicht (B4).
- [ ] FAIL → implementieren → PASS → `npm test` (Portal) → Commit „Terminplaner: Staff-Aktivierung nur mit Zustimmung in Textform (Modus on), Portal“
### Task F13b: Staff-Aktivierung nur mit Textform-Nachweis (Modus `on`) – WOMS
**Files (WOMS, Worktree `dev-termine`):** Modify `src/lib/funktionenView.js`, `src/components/customer/CustomerFunktionen.jsx`, `src/lib/teamApi.js`; Tests `tests/unit/funktionenView.test.mjs`.
**Consumes:** F13a (Feld `termine.textformNoetig`, Body `textformDate`/`textformChannel`), D9/D10 (Bedienung „Aktivieren (ohne Zahlung)“ nur in `off`/`disabled`, Panel in `CustomerFunktionen.jsx`).
**Produces:** im Bestätigungsdialog „Terminplaner ohne Zahlung aktivieren?“ erscheinen bei `textformNoetig` Haken (Text wie in F13a), Datum und Weg (Wiederverwendung von `CHANNELS`/`canSubmitTextform` aus `analyseView.js`), `termineChatTemplate(version, links)` (Kopierknopf): „Damit wir den Terminplaner für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. Es gelten die Leistungsbeschreibung Terminplaner und der Auftragsverarbeitungsvertrag Terminplaner in der Fassung ${version}: ${lb} · ${avv}. Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Dein Angebot nicht im Gesundheitsbereich liegt.“ (versionierte Adressen); News und „Abschalten“ unverändert.
- [ ] Tests (WOMS): `termineChatTemplate` wörtlich, `canSubmitTextform` mit `minDate` = Fassung, Dialogtext, Body `{ …, textformDate, textformChannel }`; zusätzlich einmal `nice npx vite build`.
- [ ] FAIL → implementieren → PASS → `npm test` (WOMS) und `nice npx vite build` → Commit „Terminplaner: Staff-Aktivierung nur mit Zustimmung in Textform (Modus on), WOMS“
### Task F14: WOMS „Rechtliches“: Karten der Terminplaner-Unterlagen
**Files:** Modify `scripts/setup-legal.mjs` (`DOCUMENTS :89-96`) im WOMS-Worktree; Test `tests/setup-legal.test.mjs`.
**Produces:** drei neue Karten (sort 7–9): `leistungsbeschreibung-terminplaner` („Leistungsbeschreibung Terminplaner“, `liveUrl` `https://project.webklar.com/rechtliches/leistungsbeschreibung-terminplaner.html`), `avv-terminplaner` („AVV Terminplaner (Auftragsverarbeitung)“, `…/avv-terminplaner.html`), `widerrufsbelehrung-funktionen` („Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)“, `…/widerrufsbelehrung-funktionen.html`); die Adressen ohne Fassung sind die Alias-Seiten aus F1. Das Skript legt nur die Karten an (idempotent); **Ausführung mit API-Key und Upload der Versionen (PDF/HTML der Fassung) macht der Inhaber** (Livegang-Schritt 7; `md` ist im Bucket nicht erlaubt, das Datenschutz-PDF geht als neue Version der vorhandenen Karte `datenschutz`). `--dry-run` listet sie. Die Analyse-Karten werden hier nicht ergänzt.
- [ ] Tests: `documents_keys_eindeutig_und_sort_lueckenlos` (Schlüssel `^[a-z0-9-]{1,64}$`, `sort` 1..n), `terminplaner_karten_vorhanden` (drei Schlüssel, Titel, `liveUrl` beginnt mit `https://project.webklar.com/rechtliches/` und endet ohne Fassungsdatum), `bestehende_karten_unveraendert` (die sechs alten Einträge deepEqual), `dry_run_listet_karten` (Skript mit `--dry-run`, keine Netzaufrufe).
- [ ] FAIL → implementieren → PASS → `npm test` (WOMS) → Commit „Rechtliches: Karten für Leistungsbeschreibung, AVV und Widerrufsbelehrung des Terminplaners“
### Task F15a: Kanzlei-Paket 1 – Aufbau, Texte, Ablauf, Fundstellen (Abschnitte 1–3 und 5–7)
**Files:** Create `docs/rechtliches/termine-kanzlei-paket.md`, `test/termineKanzleiPaket.test.js`.
**Consumes:** alle Texte aus F2–F14 (Zitate), Register, Code-Fundstellen.
**Aufbau** (Überschriften verbindlich): `## 1. Zweck und Umfang` (Kernpaket nach D5: Widerrufsbelehrung + Muster-Formular der 149-€-Bestellung, Zustimmungssatz, AVV, Haftungs- und Ende-mit-Abo-Klauseln der LB, Gästetexte mit Gesetzlichkeitsfiktion; Hausrunde: 6c, Portal-Hinweise, Mails; keine Rechtsberatung durch WEBklar; Fassungen `<F>`/`<V>`) · `## 2. Produktablauf in Kurzform` (Abo-Prüfung → Fragebogen-Entwurf → Bestellung mit vier Haken → Nachweis → Stripe → Ticket/Einbau → Freischaltung → Betrieb; Gast: Haltezeit 30 Min., Bestätigungslink, Widerrufsfunktion; Ende des Abos: Abschalten spätestens am Folgetag, 30 Tage, Rückkehr, Erstattung vor Freischaltung; Zahlen 149 €, 14 Tage, 48 Stunden, 90 Tage, 7 Tage) · `## 3. Texte in Endfassung` (wörtlich als Zitatblöcke `> `: vier Bestell-Haken + Knopf, Widerrufsbelehrung + Formular der Funktionen, LB Nr. 5, 7, 8, 11, AVV Nr. 7 und 9, Gäste-Belehrung, Gäste-Haken, Datenschutz-Hinweis am Formular, Knopftexte; AVV und LB in voller Länge als PDF-Anlagen A/B; hinter jedem Zitat `⚖ Q<n>`) · `## 4. Fragen an die Kanzlei` (`### Q1` … `### Q26`, je: Frage, Fundstelle im Text, unsere Annahme, Alternative) · `## 5. Fundstellen im Code` (Tabelle Thema | Datei | Funktion/Konstante, ohne Zeilennummern) · `## 6. Nicht Teil der Kanzleiprüfung` (Hausrunde; Liste 7.4 „Später“) · `## 7. Checkliste für den Inhaber` (Hetzner-AV für Cloud **und** Webhosting, Anlage 1 mit „Terminbucher/Beschäftigte/Buchungsdaten“, Kontoadresse = Impressum, Backups an; Uploads in WOMS; Beauftragung der Kanzlei; Freigabe der Fassung vor dem ersten fremden Betrieb).
**Umfang dieses Tasks:** alle sieben Überschriften und die Abschnitte 1, 2, 3, 5, 6, 7 mit Inhalt; die Zitate in Abschnitt 3 tragen schon ihre `⚖ Q<n>`-Marker (die Nummern sind in F15b festgelegt). Abschnitt 4 besteht hier nur aus der Überschrift und der Zeile „Fragen Q1 bis Q26: Task F15b.“; F15b ersetzt sie.
- [ ] Tests (`termineKanzleiPaket.test.js`): `paket_aufbau` (sieben Überschriften exakt, Fassungen `config.termine.avvVersion` und `RECHTSTEXTE_VERSION` genannt); `paket_zitate_woertlich` (jeder `> `-Absatz steht nach Whitespace-Normalisierung in einer Quelle: Register-Haken, `rechtstexte.js`-Ausgaben, die drei Markdown-Dokumente, `config.ustHinweis`); `paket_fundstellen` (jede Datei der Tabelle existiert, der genannte Bezeichner steht darin); `paket_zahlen_gleich_code` (149 €, 14 Tage, 30 Minuten, 48 Stunden, 90 Tage, 7 Tage, 30 Tage aus Register bzw. den Konstanten); `paket_konsistenz_abo_ende` (LB Nr. 8, AVV Nr. 9, 6c, K22 nennen alle „30 Tage“ und die Rückkehr).
- [ ] FAIL → Paket schreiben → PASS → `npm test` → Commit „Kanzlei-Paket Terminplaner: Aufbau, Texte, Ablauf, Fundstellen“
### Task F15b: Kanzlei-Paket 2 – Fragen Q1 bis Q26 (Abschnitt 4)
**Files:** Modify `docs/rechtliches/termine-kanzlei-paket.md` (Abschnitt `## 4. Fragen an die Kanzlei`: `### Q1` … `### Q26`, je Frage, Fundstelle im Text, unsere Annahme, Alternative), `test/termineKanzleiPaket.test.js`.
**Consumes:** F15a (Aufbau und `⚖ Q<n>`-Marker in den Zitaten), alle Texte aus F2–F14.
**Fragen (feste Nummern):** Q1 Alle Besteller wie Verbraucher (D1): Haken und Belehrung für alle, Hinweis „gilt nur für Verbraucher“ ausreichend? · Q2 Fristbeginn: Klick auf „Zahlungspflichtig bestellen“ (unser Anker), Zahlungseingang oder K15? · Q3 Zustimmungssatz (Zählung § 356 Abs. 5 Nr. 2, Kenntnis-Bestätigung, Dauerhafter Datenträger durch K15) · Q4 **D7 News-Altkunden:** Bestellungen seit 2026-09-30 mit altem Text (Abs. 4, ohne Wertersatz-Hälfte, ohne Musterbelehrung): lief die Frist nach § 356 Abs. 3 BGB nicht an; Belehrung nachholen per Mail oder nur künftig; Anzahl der Fälle liefert der Inhaber · Q5 Wertersatz: gesetzlicher Anteil am Gesamtpreis (Anlage 1) vs. D3 „nach Stundenaufwand“ · Q6 volle Erstattung bei Ende des Abos vor Freischaltung (Klausel LB Nr. 8 Abs. 5, § 307 BGB) · Q7 Ende mit dem Abo, auch Wechsel und Widerruf; Verhältnis Widerruf des Abos zum Einrichtungsvertrag; Ausnahme: ohne Bestellung freigeschaltete Dokumente (Pilot/Kulanz, F13a) nimmt der Abgleich aus (`ausgenommen` im Tagesbericht): genügt „schaltet spätestens am folgenden Tag ab“ trotzdem, oder braucht es eine Kulanz-Klausel? · Q8 Haftung für verpasste Termine (LB Nr. 7, § 309 Nr. 7, Höchstbetrag gegenüber Unternehmern?), Erstattung bei Einstellung durch WEBklar · Q9 30 Tage: Anker `disabledAt`, Rückkehr gratis, Backup-Überhang 7 Tage; Alternative Modell der Analyse (Tag 61, Export bis Tag 46) · Q10 AVV: eigenes Geschwister statt Rahmen-AVV, Hetzner Falkenstein und Nürnberg, „keine Drittlandübermittlung“ trotz Kalender-Abo bei Google/Apple (Abonnement durch den Kunden) · Q11 Gesundheits-Ausschluss: Branchenfrage + Haken + Stichwort-Warnung + Ablehnungsrecht ausreichend (Art. 9)? · Q12 Gästebelehrung: Abweichungen vom Muster (Lieferkosten-Klammer, Widerrufsfunktion als „anderer geeigneter Hinweis“ ohne Adresse, „beginnen soll“ im Singular) und Gesetzlichkeitsfiktion · Q13 Gästetext vorzeitiger Beginn und Wertersatz-Satz · Q14 Anfrage mit Festpreis, Bestätigungslink und Seitenknopf „Zahlungspflichtig bestätigen“ als Bestellung (§ 312j Abs. 3) · Q15 Einstufung Freizeit/Massage/Fotograf/Coaching und der Portal-Hinweis · Q16 Storno-Muster 50 %/24 h (§ 307) und Wegfall bei kostenlosen Leistungen · Q17 Pflicht von Datenschutz-/Impressum-Link und „jederzeit widerrufen“-Satz am Erinnerungshaken · Q18 Datenschutz-Absatz auf der Kundenseite: Einfügen durch WEBklar im Preis, eine Regel für beide LB · Q19 Preisangabe „ohne Umsatzsteuer gemäß § 19 UStG“ (bei Fortbestehen des Kleinunternehmerstatus), „· 149 €“ am Knopf · Q20 Pflichtinformationen (Art. 246a § 1 EGBGB) als Haken und Wortlaut in K15 · Q21 Staff-Aktivierung mit Zustimmung in Textform und K16 mit PDFs · Q22 BFSG-Satz (keine Konformitätszusage) · Q23 Nachweis der Bestellung mit IP/Browser, Frist drei Jahre (Art. 6 Abs. 1 lit. f) · Q24 Rollenverteilung in 6c (Auftragsverarbeiter/Verantwortliche), Entwurfsaufbewahrung 90 Tage · Q25 Mindestlaufzeit des Wartungs-Abos und Terminplaner (kein Sonderkündigungsrecht) · Q26 Nachweis der Gäste: Hash + Archiv der Textfassung genügt (Betriebsdaten ändern sich später)?
- [ ] Tests (`termineKanzleiPaket.test.js`): `paket_marker_und_fragen` (jedes `⚖ Q<n>` hat `### Q<n>`, jede Frage ist mindestens einmal verwiesen, Nummern lückenlos 1–26, Q4 nennt „News“, „§ 356 Abs. 3“ und „Altkunden“).
- [ ] FAIL → Fragen schreiben → PASS → `npm test` → Commit „Kanzlei-Paket Terminplaner: Fragen Q1 bis Q26“
**Abschlussprüfung Block F (Controller):** `npm test` komplett grün (`test/previewDevRouter.test.js` bei Ausreißer einzeln wiederholen); Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): `docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)'` (`analyticsContract.test.js` importiert `../analytics/src` und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: `git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob'` bleibt leer. WOMS-Anteil (F13b, F14): `npm test` im WOMS-Worktree und einmal `nice npx vite build`. PDFs: `build-legal-pdfs.sh termine` und `datenschutz` laufen auf dem Host (vorher `free -m`, mit `nice`).
## Block G: Livegang-Checkliste (Controller, je Schritt Freigabe des Inhabers)
Kein Subagent-Block. Der Controller arbeitet die Schritte in dieser Reihenfolge ab (Spec Abschnitt 9) und holt vor jedem Schritt mit Schreibzugriff auf Produktion oder Stripe die Freigabe des Inhabers ein. Claude pusht nicht auf `main`; das macht der Inhaber. **Betriebsannahme:** genau ein Portal-Prozess (Sperren, Grenzen und das Mailbudget liegen im Speicher; jeder zweite Prozess bricht das; festhalten, nicht ändern). Die Schritte G1–G10 entsprechen 9.1–9.10 der Spec.
- [ ] **G1 Rechtsrunde (9.1):** Texte aus F2–F12 und das Kanzlei-Paket (F15a, F15b) fertigstellen; der Inhaber beauftragt die Kanzlei; Hausrunde für den Rest (Portal-Hinweise, Mails, 6c).
- [ ] **G2 Code in den Worktrees (9.2):** Blöcke A–F fertig, jede Abschlussprüfung grün (einschließlich Node-20-Lauf und Grep-Wächter), WOMS-Build ohne Fehler; die Test-Pins aus den Berichten sind mitgeändert (`funktionenRoutes`, `termineRoutes`, `termineSetup`, `termineView`, `kundenansichtGuard`, `legalTexts`, `mailTemplates`, `termineJobs`, dazu `featureRegistry`, `featureConsents`, `featureMails`, `funktionenCheckout`).
- [ ] **G3 Schema in der Produktions-Appwrite vor dem Code-Deploy (9.3, Freigabe):** `node scripts/termine-setup.mjs --check`, dann anwenden (`termineConfig.fragebogen`, Indizes `termineBookings(status,endsAt)` und `(status,updatedAt)`, `termineOutbox(bookingId)`); `node scripts/feature-setup.mjs --check`, dann anwenden (`portalFeatures.disabledReason`, `aboEndeMailAt`, `zahlungMeldung`, neue Collection `featureConsents` samt Indizes). `scripts/setup-abo-collections.mjs` wird **nicht** ausgeführt. Der Schritt liegt vor dem Deploy, weil `activate()` die neuen Felder schreibt (ohne Schema `Unknown attribute`); der Indexaufbau ist asynchron, die Abfragen funktionieren auch ohne.
- [ ] **G4 Stripe (9.4, Freigabe):** `node scripts/stripe-products.mjs --pruefen`, dann `--anlegen` für `termineSetup` (Lookup-Key `webklar_termine_einrichtung_14900`); der Inhaber prüft im Dashboard, dass der Live-Webhook `async_payment_succeeded`, `async_payment_failed` und `expired` abonniert hat (Annahme: ja, News läuft seit 2026-09-30).
- [ ] **G5 Messprüfungen (9.5, Spec 8 #7, Freigabe):** (1) tatsächliche Größe von `workorders.details` (trägt `TICKET_DETAILS_MAX = 7000`?); (2) Seiten-Listing über 500 Dokumente auf der Live-Appwrite 1.8.1 (die Seitenläufe aus A3a–A6 lesen mit `limit ≤ 100` ohne Cursor; im Nachbau bisher nur Spike); (3) `Query.notEqual` mit Liste und `total`/`countDocuments` (A2, A9a, A10b, E4) auf 1.8.1 mit derselben Bedeutung wie im Nachbau, `limit(1)`-Antwort mit `total` bis 5000; (4) tatsächliche Hetzner-Antwort bei mehr als 500 Mails je Stunde; (5) Hetzner-AV-Prüfung durch den Inhaber (Cloud **und** Webhosting, Anlage 1 mit „Terminbucher/Beschäftigte/Buchungsdaten“, Kontoadresse = Impressum, Backups an). Ergebnisse ins Ledger; weicht ein Wert ab, geht der betroffene Task an seinen Block zurück.
- [ ] **G6 Deploy Portal + WOMS (9.6, der Inhaber pusht):** Modus bleibt `pilot` mit leerem `TERMINE_PILOT_EMAILS` plus Pilotkunde „test“. Der Abo-Abgleich läuft zunächst nur beobachtend: das ist durch den Datenstand gegeben, denn er schaltet nur Dokumente mit `unlockedByPurchase === true` ab, und davon gibt es vor der ersten bezahlten Bestellung keines (Pilotkunde und Kulanzfreigaben stehen im Tagesbericht als `ausgenommen`); ein eigener Beobachtungsschalter ist nicht gebaut. Tagesbericht und `GET /api/team/termine/status` beobachten.
- [ ] **G7 Rechtstexte live (9.7):** PDFs bauen (`build-legal-pdfs.sh termine` und `datenschutz`), HTML/PDF unter `/rechtliches`, 6c + PDF der Datenschutzerklärung, WOMS-Karten (`scripts/setup-legal.mjs` mit API-Key, Freigabe) und Uploads der Fassung durch den Inhaber, Gästetexte final (`RECHTSTEXTE_VERSION`).
- [ ] **G8 Ende-zu-Ende-Test (9.8)** mit einem Testkunden und echtem Wartungs-Abo: Fragebogen → Bestellung (echte 149 €, Rückerstattung durch den Inhaber danach; Annahme: es gibt kein Test-Konto für diesen Preis) → Ticket + Anleitung → „Freischalten (Abnahme)“ → Probebuchung → Abo-Ende-Simulation (Testkunde) → K22 → Rückkehr K23 → Löschlauf auf Wegwerfdaten. Dazu die Sichtprüfung des WOMS-Panels (390 px und Desktop, Kopieren, Warnungen, Aktionsknöpfe je Zustand) und die Playwright-Läufe (`termine-fragebogen.ui.mjs`, `termine-portal.ui.mjs`, `funktionen.ui.mjs`, `termine-widget.ui.mjs`), nacheinander, vorher `free -m`, mit `nice`.
- [ ] **G9 Erster fremder Kunde im Pilot (9.9)**, erst nach dem Kanzleibefund (Entscheidung D5 der Spec): nur seine Adresse in `TERMINE_PILOT_EMAILS`; Beobachtung von Tagesbericht, Mailbudget und Status-Route (Freigabe der Adresse).
- [ ] **G10 `TERMINE_ENABLED=on` (9.10)** als **letzter** Schritt (Compose-Änderung, nur `kundenbereich-server` neu erstellen); danach eine Woche Beobachtung, News-Altfälle (D7) und der Entwurfs-Löschjob werden geprüft.
---