Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-16-kundenansicht-06-abo-modell-kern.md
T

1501 lines
147 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Kundenansicht neu, Paket P5: Abo-Modell Kern (Server) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Der Server der Kundenansicht kennt das Abo-Modell der Funktionen: Kundenart, Katalog mit Abo-Zuordnung, Einschalten ohne Zahlung, gesammelte Widerrufsfristen und die Einrichtungs-Marker am Ticket. Alles nur für Sitzungen mit neuer Ansicht oder rein additiv.
**Architecture:** Vier neue Dienste (`kundenart.js`, `aboFunktionen.js`, `fristen.js`, `funktionEinschalten.js`) und zwei Helfer (`aboKatalog.js` für den Katalog, `aboUnterlagen.js` als einzige Quelle der verlinkten Unterlagen). Bestehende Routen bekommen Zweige hinter `neueAnsicht(req)`. Das Einschalten folgt dem Muster `einschaltenZeiten` (Sperre `feature-order`, Nachweis vor Zustand, Ticket danach, Heilung statt Doppelticket). Neue Datenbankfelder legt nur `scripts/kundenansicht-setup.mjs` an; der Code schreibt sie nur, wenn ein Aufrufer die zugehörigen Parameter übergibt. Die Wortlaute und Unterlagen der Fassung 6 (`WORTLAUT_V6`, `FUNKTIONEN_UNTERLAGEN_V6` aus P8) verdrahtet Task 15, sobald `kn/recht` im Hauptzweig ist; bis dahin gelten die Entwurfstexte in `config.aboFunktionen` und die Alt-Unterlagen des Registers.
**Tech Stack:** Node 20 (Host 24), Express 4, Appwrite-REST, `node --test` mit den Nachbauten aus `test/support/` (`appwriteMock`, `fakeStripe`, `testApp`, `fixtures`, `mailSink`).
**Spec:** `docs/superpowers/specs/2026-10-15-kundenansicht-neu-design.md` (E3, E4, E6, E8, E9, E10, E14, E17, E21, 5.4, 5.5, 5.6, C1, C3, C4, C5, C6, C9, C10, C13, C15)
**Übersicht/Verträge:** `/home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht/docs/superpowers/plans/2026-10-16-kundenansicht-00-uebersicht.md` (gilt wörtlich; dieses Paket liefert V4, V5, V6, V7 komplett)
**Worktree und Branch:** `/home/webklar/apps/kundenbereich.webklar.com/dev-kn-server`, Branch `kn/server` (von Tag `kn-p0`). Nach Task 14 Tag `kn-p5`. Alle Pfade unten sind relativ zu diesem Worktree. **Ausnahme Task 15** (Fassung 6 verdrahten): läuft erst nach dem Merge von `kn/recht` im Hauptzweig `feature/kundenansicht-neu` (Worktree `/home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht`) als erste Aufgabe von P7 („P7 Task 0“), Tag `kn-p5-v6`.
## Global Constraints
Es gelten **alle** Global Constraints der Übersicht wörtlich (Schalter, Aussehen, du-Anrede, keine Funktionspreise, Hashes, Kundenart nur vom Server, Nachweise, Testbefehle, Commits, Sicherheitsblock, Prod-Daten). Paketeigene Zusätze:
- **Vorbereitung (einmalig, kein Commit):** `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server log -1 --format=%D` nennt `kn-p0` und `kn/server`; `ls server/services/kundenansicht/schalter.js node_modules` existiert (Symlink `ln -s ../build/node_modules node_modules`, nie `git add -A`); `command grep -a -c neueAnsichtFuerKunde server/services/kundenansicht/schalter.js` zeigt mindestens 1 (P0-Erweiterung von V1 für Jobs ohne Cookie, Task 13 braucht sie; fehlt sie, den Controller melden und nicht selbst in `schalter.js` ergänzen); `free -m` zeigt mindestens 2000 MB verfügbar.
- **Alte Ansicht bytegleich:** `oeffentlicheAnsicht(key)` (Pins `test/funktionenRoutes.test.js:101` und `test/zeitenEinschalten.test.js:588`) und die Listenantwort ohne neue Ansicht bleiben unverändert. Einzige additive Ausnahme: `featureView` liefert `unlockedBy` für alle Funktionen (V7).
- **Schema:** Neue Attribute stehen nur in `scripts/kundenansicht-setup.mjs` (Flag `--pruefen`, Alias `--check`). `setup-abo-collections.mjs`, `feature-setup.mjs`, `zeiten-setup.mjs` bleiben unberührt. Das Skript läuft in diesem Plan nie gegen Produktion, nur gegen Nachbauten.
- **Testkopf** (neue Dateien mit Server-Zugriff), danach `config` und Module dynamisch importieren. `useTestEnv` setzt den Rest; Pilot über die Umgebung, E-Mail-Liste absichtlich mit Großschreibung und Leerzeichen:
```js
const aw = await startAppwriteMock({ schema: { /* schemaKeys(...) je Collection */ } })
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
Object.assign(process.env, { KUNDENANSICHT_NEU: 'pilot', KUNDENANSICHT_NEU_PILOT_EMAILS: 'k@example.invalid, TEST@example.invalid' })
```
c1 (`k@example.invalid`) und c4 (`test@example.invalid`) sind Pilot, c2 (`k2@example.invalid`) nicht. Tests, die den Modus wechseln, setzen `config.kundenansichtNeu.mode` und `.pilotEmails` direkt und stellen sie in `after()` zurück.
- **Uhr:** Routen nehmen `einschaltenDeps.jetzt()` (aus `server/services/zeiten/einschalten.js`). Tests setzen `JETZT = new Date('2026-10-17T08:00:00.000Z')` (ein Samstag).
- **Fehlerform neuer Routen:** `{ error: <Text in du-Form>, code, ...extra }` über `fail()` aus `server/routes/funktionen.js`; Rohtexte von Appwrite oder Stripe nie. Neue Antworten und Fehlertexte enthalten kein `€`.
- **Abgrenzung:** keine Mails, kein Ledger (`hourLedger.js`), keine Team-Aktivierung (`adminActivate`, `funktionenAdmin.js`). Das ist P6.
- **Commit:** `git add <Dateien einzeln>` und `git commit -m "<Betreff>" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`.
## Review Focus
Die fünf wahrscheinlichsten Fehlerfälle dieses Pakets, häufigster zuerst. Jede Zeile nennt den Task, dessen Tests sie festnageln.
1. **Kundenart leer, unbekannt oder gefälscht** (Übersicht-Review-Focus 1): `{}`, `'Unternehmen'`, `'kaputt'` gelten als `privat`; ein Body-Feld `kundenart` oder `unternehmer: true` vom Client ändert nichts; der Nachweis speichert die Kundenart des Dokuments. Die Kinder-unter-16-Aussage der Analyse ist im Abo-Modell serverseitig erzwungen: ohne `audienceConfirmed: true` 400 `zielgruppe_fehlt` und nichts geschrieben, mit Haken ein Nachweis mit Linkzeile, Zielgruppen-Zeile, Knopf und Kundenart. Tests: Task 2, 3, 10, 12, 13.
2. **Pilot-Schalter an den Rändern** (Übersicht-Review-Focus 2): `off` mit `*`, leere Liste, E-Mail mit Großbuchstaben und Leerzeichen, Mitarbeiter-Kundenansicht, unbekannter Schlüssel, `analyse`. Ohne neue Ansicht antwortet das Einschalten 404 und schreibt nichts. Die Kundenart-Pflicht beim Anlegen gilt nur ab `pilot` (bei `off` bleibt die WOMS-Anlage wie heute), Jobs ohne Cookie fragen den Schalter über `neueAnsichtFuerKunde`. Tests: Task 3, Task 10 („Einschalten nur in neuer Ansicht“), Task 11, Task 13.
3. **Doppeltes oder widersprüchliches Einschalten** (Übersicht-Review-Focus 4): zwei Taps nacheinander und gleichzeitig, schon aktiv, zweite Website, Zustand `setup` ohne Ticket (Heilung statt zweitem Ticket), Ticket scheitert beim Anlegen. Ergebnis: höchstens ein Ticket, ein Nachweis, klare 409-Codes. Tests: Task 9, 10.
4. **Abo- und Frist-Randfälle:** Hosting-Kunde bei Wartungs-Funktion (`wechsel: true`), gekündigt, `past_due`, widerrufener Wechsel, Abo endet zwischen Vorprüfung und Sperre; Privatkunde mit Abo „Beginn nach Fristende“ (Stripe `trialing`): `beginnt_spaeter` mit Datum statt Zahlungsmeldung; Fristende über Wochenende und Feiertage (Samstag + 14 Tage endet erst am Montag), Fristanzeige nur bei privat. Tests: Task 4, 5, 9, 10.
5. **Alte Ansicht bei nicht gelaufenem Setup-Skript und neuen Registerfeldern:** ohne `kundenart` oder `abrechnung` schreibt der Code kein neues Attribut; `oeffentlicheAnsicht` und die Liste ohne neue Ansicht bleiben unverändert. Tests: Task 6, 7, 8, 11.
## Dateiübersicht
| Datei | Aufgabe | Task |
|---|---|---|
| `scripts/kundenansicht-setup.mjs` (neu) | legt `featureConsents.kundenart`, `workorders.einrichtungFuer`, `workorders.einrichtungAbrechnung`, `portalFeatures.einrichtungAbrechnung` (und `unlockedBy`, falls fehlend) an; `--pruefen` | 1 |
| `test/support/schema.js` (geteilt, nur Quellen) | liest das Skript für `featureConsents` und `portalFeatures` | 1 |
| `server/services/kundenart.js` (neu) | V4: `kundenartGespeichert`, `kundenartWirksam` | 2 |
| `server/routes/auth.js` (nur `sanitizeCustomer`) | `kundenart` und `kundenartWirksam` in `/api/auth/me` und Login | 2 |
| `server/services/customerAdmin.js` | Kundenart beim Anlegen Pflicht (nur bei Schalter `pilot` oder `on`), beim Ändern optional | 3 |
| `server/services/aboFunktionen.js` (neu) | V5: `ENTHALTEN_IN`, `aboProdukt`, `aboDeckt` (auch `beginnt_spaeter`); dazu `aboLaeuftFuer`, `aboModellFuerKunde` | 4, 13 |
| `server/services/fristen.js`, `server/routes/fristen.js` (neu) | V6: `sammleFristen`, `GET /api/fristen` | 5 |
| `server/config.js` (Abschnitt `aboFunktionen`, Registerfelder in `features`) | Katalogfelder und Entwurfstexte (`beginn`, `hinweis`, Analyse-Zeilen); Task 15 stellt die Texte auf Fassung 6 | 6, 15 |
| `server/services/featureRegistry.js` (Katalogfelder) | `katalogReihenfolge`, `katalogFelder`, `consentAbo` (mit Item `hinweis`), `consentAboAktiv`, `aboweg`, `consentAboAnalyse`, `abowegAnalyse` | 6, 15 |
| `server/services/aboUnterlagen.js` (neu) | `unterlagenFuer`, `analyseUnterlagen`, `pdfAnhaenge`; einzige Quelle der verlinkten Unterlagen (Katalog, Nachweis, Analyse) | 6, 15 |
| `server/services/featureConsents.js` (Attribut `kundenart`) | `kundenart` im Nachweis | 7 |
| `server/services/funktionEinschalten.js` (neu) | V7: `einschalten` | 9 |
| `server/services/aboKatalog.js` (neu) | `katalogFuerKunde`, `anfrageStatusJeProjekt` | 11 |
| `server/routes/funktionen.js` (Katalog, Einschalten) | `POST /:key/einschalten`, Weiche an `/zeiten/einschalten`, Listenfelder, Analyse-Route (Zielgruppe, Nachweis, Fassung) | 10, 11, 13 |
| `server/services/shop/anfrage.js`, `server/routes/shop.js` (nur Anfrage) | Unternehmer aus Kundenart, `nur_betrieb` | 12 |
| `test/aboFassung6Verdrahtung.test.js` (neu) | Katalog, Nachweis, Analyse und K18 mit den Unterlagen und Wortlauten der Fassung 6 | 15 |
| Fremde Dateien, jeweils minimal (siehe Vertragsfragen 4): `server/index.js` (1 Import, 1 Mount), `server/routes/admin/customers.js` (`code` durchreichen), `server/services/ticketAdmin.js` (`abrechnung`), `server/services/siteFeatures.js` (`featureView`, `legeTicketAn`), `server/services/zeiten/einschalten.js` (`abrechnung`, `vorpruefung`), `server/services/analyse.js` (Hosting im Abo-Modell, `nachweis`-Rückruf, K18 mit Fassung 6) | | 3, 5, 8, 9, 13, 15 |
| Angepasster Pin: `test/featureSetup.test.js:191-192` | `schemaKeys('featureConsents')` kennt jetzt `kundenart` | 1 |
---
### Task 1: Setup-Skript und Schema-Quelle
**Files:**
- Create: `scripts/kundenansicht-setup.mjs`
- Modify: `test/support/schema.js` (nur Quellen), `test/featureSetup.test.js:191-192` (Pin)
- Test: `test/kundenansichtSetup.test.js`
**Interfaces:**
- Consumes: `createAppwriteClient()` aus `scripts/analyse-setup.mjs` (`collectionExists`, `listAttributes`, `createAttribute`, `waitForAttribute`), `ZEITEN_FEATURE_ATTRIBUTES` aus `server/services/zeiten/schema.js`.
- Produces (`config.js` erst in `main()` laden, wie `scripts/feature-setup.mjs`):
```js
export const KUNDENANSICHT_ATTRIBUTE = {
featureConsents: [{ key: 'kundenart', type: 'string', size: 12, required: false }],
workorders: [{ key: 'einrichtungFuer', type: 'string', size: 16, required: false }, { key: 'einrichtungAbrechnung', type: 'string', size: 8, required: false }],
portalFeatures: [/* unlockedBy (aus ZEITEN_FEATURE_ATTRIBUTES), */ { key: 'einrichtungAbrechnung', type: 'string', size: 8, required: false }],
}
export async function main({ pruefen = false, client, log = console.log } = {}) // → { missing: [{ collection, ...attr }] }
```
- [ ] **Step 1: Failing test `test/kundenansichtSetup.test.js` schreiben**
```js
import { KUNDENANSICHT_ATTRIBUTE, main } from '../scripts/kundenansicht-setup.mjs'
// Kopf: startAppwriteMock(), useTestEnv(), dann const { config } = await import('../server/config.js')
const client = (vorhanden = {}) => {
const calls = { attrs: [], waits: [] }
return { calls, collectionExists: async () => true, listAttributes: async (id) => (vorhanden[id] ?? []).map((key) => ({ key })),
createAttribute: async (id, a) => calls.attrs.push(`${id}.${a.key}`), waitForAttribute: async (id, k) => calls.waits.push(`${id}.${k}`) }
}
const ID = () => config.collections
const ALLE = () => [`${ID().featureConsents}.kundenart`, `${ID().portalFeatures}.einrichtungAbrechnung`, `${ID().portalFeatures}.unlockedBy`, `${ID().workorders}.einrichtungAbrechnung`, `${ID().workorders}.einrichtungFuer`]
test('Schema: Schlüssel, Größen, nur optionale Strings; längste Werte passen hinein', () => {
const kurz = Object.fromEntries(Object.entries(KUNDENANSICHT_ATTRIBUTE).map(([c, l]) => [c, l.map((a) => `${a.key}:${a.size}`)]))
assert.deepEqual(kurz, { featureConsents: ['kundenart:12'], workorders: ['einrichtungFuer:16', 'einrichtungAbrechnung:8'], portalFeatures: ['unlockedBy:8', 'einrichtungAbrechnung:8'] })
assert.ok(Object.values(KUNDENANSICHT_ATTRIBUTE).flat().every((a) => a.type === 'string' && a.required === false))
assert.ok('unternehmen'.length <= 12 && 'projekt'.length <= 8 && 'unterkunft'.length <= 16)
})
test('--pruefen meldet alle fünf Lücken und legt nichts an', async () => {
const c = client(); const log = []
const r = await main({ pruefen: true, client: c, log: (z) => log.push(z) })
assert.deepEqual(r.missing.map((a) => `${a.collection}.${a.key}`).sort(), ALLE())
assert.deepEqual(c.calls, { attrs: [], waits: [] })
assert.ok(log.some((z) => z === `- ${ID().featureConsents}.kundenart FEHLT`))
})
test('ohne --pruefen: legt an und wartet; der zweite Lauf ist idempotent', async () => {
const c = client(); await main({ client: c, log: () => {} })
assert.deepEqual([...c.calls.attrs].sort(), ALLE()); assert.deepEqual([...c.calls.waits].sort(), ALLE())
const voll = client({ [ID().featureConsents]: ['kundenart'], [ID().workorders]: ['einrichtungFuer', 'einrichtungAbrechnung'], [ID().portalFeatures]: ['unlockedBy', 'einrichtungAbrechnung'] })
assert.deepEqual((await main({ client: voll, log: () => {} })).missing, []); assert.deepEqual(voll.calls.attrs, [])
})
test('schemaKeys liest das Skript; das Skript löscht nichts', () => {
assert.equal(schemaKeys('featureConsents').at(-1), 'kundenart')
const f = schemaKeys('portalFeatures'); assert.ok(f.includes('einrichtungAbrechnung')); assert.equal(new Set(f).size, f.length)
assert.doesNotMatch(readFileSync(new URL('../scripts/kundenansicht-setup.mjs', import.meta.url), 'utf8'), /delete/i)
})
```
- [ ] **Step 2: Rot**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenansichtSetup.test.js`
Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `scripts/kundenansicht-setup.mjs`.
- [ ] **Step 3: `scripts/kundenansicht-setup.mjs` anlegen**
Aufbau wie `scripts/feature-setup.mjs`: je Collection `listAttributes`, Fehlendes in `missing` (mit `collection`) sammeln; im Prüfmodus nur `- <collection>.<key> FEHLT` loggen und `Alles vorhanden.` bzw. `N Eintrag/Eintraege fehlen.`; sonst `createAttribute`, danach `waitForAttribute`, Log `+ Attribut …` und `Fertig.`. Vorhandenes loggt `= <collection>.<key> (existiert)`. CLI mit `--pruefen`/`--check`, Exit 1 bei Lücken nur im Prüfmodus. Kein `delete`.
- [ ] **Step 4: `test/support/schema.js` und Pin `test/featureSetup.test.js:191-192`**
`schemaKeys('featureConsents')` hängt die Schlüssel von `KUNDENANSICHT_ATTRIBUTE.featureConsents` an; im `portalFeatures`-Zweig kommen die von `KUNDENANSICHT_ATTRIBUTE.portalFeatures` dazu, die Liste wird mit `[...new Set(...)]` entdoppelt (`unlockedBy` steht schon in den Zeiten-Feldern). Import des Skripts wie der von `feature-setup.mjs`. Der Pin erwartet jetzt `[...CONSENT_KEYS, 'kundenart']` bzw. `[...FEATURE_CONSENT_ATTRIBUTES.map((a) => a.key), 'kundenart']`.
- [ ] **Step 5: Grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenansichtSetup.test.js test/featureSetup.test.js test/zeitenSetup.test.js test/support.test.js test/auftraegeSchema.test.js`
Expected: PASS, 0 fail.
- [ ] **Step 6: Commit**
```bash
git add scripts/kundenansicht-setup.mjs test/support/schema.js test/featureSetup.test.js test/kundenansichtSetup.test.js
git commit -m "Kundenansicht P5: Setup-Skript für Kundenart und Einrichtungs-Marker" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 2: Kundenart-Hilfsfunktionen und `/api/auth/me`
**Files:**
- Create: `server/services/kundenart.js`
- Modify: `server/routes/auth.js` (`sanitizeCustomer`, Zeile 47)
- Test: `test/kundenart.test.js`
**Interfaces:**
- Consumes: `KUNDENARTEN` aus `server/services/auftraege/konstanten.js`.
- Produces (V4):
```js
export { KUNDENARTEN } // ['unternehmen', 'privat']
export function kundenartGespeichert(customerDoc) // 'unternehmen' | 'privat' | ''
export function kundenartWirksam(customerDoc) // 'unternehmen' | 'privat'; leer/unbekannt → 'privat'
export const NUR_BETRIEB_TEXT = 'Diese Funktion gibt es nur für Betriebe. Schreib uns im Chat, wenn du Fragen dazu hast.'
```
`sanitizeCustomer` in `auth.js` liefert zusätzlich `kundenart: kundenartGespeichert(doc)` und `kundenartWirksam: kundenartWirksam(doc)` (Login, Kunde, Kundenansicht).
- [ ] **Step 1: Failing test `test/kundenart.test.js`**
```js
test('nur exakte Werte zählen, alles andere ist leer bzw. privat', () => {
for (const doc of [undefined, null, {}, { kundenart: '' }, { kundenart: 'Unternehmen' }, { kundenart: ' privat' }, { kundenart: 'firma' }, { kundenart: 7 }, 'unternehmen']) {
assert.equal(kundenartGespeichert(doc), ''); assert.equal(kundenartWirksam(doc), 'privat')
}
assert.deepEqual([kundenartGespeichert({ kundenart: 'unternehmen' }), kundenartWirksam({ kundenart: 'unternehmen' })], ['unternehmen', 'unternehmen'])
assert.deepEqual([kundenartGespeichert({ kundenart: 'privat' }), kundenartWirksam({ kundenart: 'privat' })], ['privat', 'privat'])
assert.deepEqual(KUNDENARTEN, ['unternehmen', 'privat'])
})
test('/api/auth/me und Login liefern kundenart und kundenartWirksam in allen drei Zweigen', async () => {
// Konten wie test/staffLogin.test.js:14-30: aw.addUser u1/u2 mit Passwort, customers c1 (kundenart 'unternehmen'), c2 (ohne Feld), customerPortalAccess pa1/pa2
const login = await post('/api/auth/login', { email: 'k@example.invalid', password: 'passwort-eins' })
assert.deepEqual([login.body.customer.kundenart, login.body.customer.kundenartWirksam], ['unternehmen', 'unternehmen'])
const cookie = login.headers.getSetCookie().find((c) => c.startsWith(`${config.cookieName}=`)).split(';')[0] // echter Cookie des Logins
const me = await rawGet('/api/auth/me', { cookie })
assert.deepEqual([me.body.customer.kundenart, me.body.customer.kundenartWirksam], ['unternehmen', 'unternehmen'])
const leer = await rawGet('/api/auth/me', { cookie: kundenansichtCookie({ customerId: 'c2' }) }) // Kundenansicht-Zweig, c2 ohne Feld
assert.deepEqual([leer.body.customer.kundenart, leer.body.customer.kundenartWirksam], ['', 'privat'])
})
```
- [ ] **Step 2: Rot**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenart.test.js`
Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `kundenart.js`.
- [ ] **Step 3: `server/services/kundenart.js` implementieren**
Exakter Vergleich mit `KUNDENARTEN.includes(doc?.kundenart)`, kein Trimmen, kein Kleinschreiben. Nur Funktionen und Konstanten, keine Imports außer `./auftraege/konstanten.js`.
- [ ] **Step 4: `sanitizeCustomer` in `server/routes/auth.js` ergänzen**
Beide Felder anhängen; kein anderer Teil der Datei ändert sich.
- [ ] **Step 5: Grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenart.test.js test/staffLogin.test.js test/kundenansichtEnde.test.js test/kundenansichtGuard.test.js`
Expected: PASS, 0 fail.
- [ ] **Step 6: Commit**
```bash
git add server/services/kundenart.js server/routes/auth.js test/kundenart.test.js
git commit -m "Kundenansicht P5: Kundenart-Helfer und Felder in /api/auth/me" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 3: Kundenart in der Kundenverwaltung (`customerAdmin.js`)
**Files:**
- Modify: `server/services/customerAdmin.js` (`sanitizeCustomer` Z. 30, `createCustomerWithPortalAccess` Z. 68, `updateCustomerWithPortalAccess` Z. 138), `server/routes/admin/customers.js` (`code` durchreichen, Z. 28-33 und 38-43)
- Test: `test/kundenartAdmin.test.js`
**Interfaces:**
- Consumes: `KUNDENARTEN`, `kundenartGespeichert` (Task 2), `neueAnsichtModus` (V1, `server/services/kundenansicht/schalter.js`).
- Produces (V4, mit einer Einschränkung gegenüber dem Wortlaut der Übersicht): `createCustomerWithPortalAccess(payload)` nimmt `payload.kundenart`. **Pflicht nur, wenn `neueAnsichtModus() !== 'off'`**: Bei `off` legt das WOMS Kunden wie heute ohne Kundenart an (Global Constraint „Alte Ansicht unverändert“, Phase E „Schalter off = für Kunden unsichtbar“), auch wenn das Portal vor dem WOMS gepusht wird. Als „nicht mitgeschickt“ gelten `undefined`, `null` und `''`. Fehlt der Wert bei `pilot|on`, oder ist ein mitgeschickter Wert in irgendeinem Modus ungültig: `Error` mit `status: 400`, `code: 'kundenart_fehlt'`, Text `Kundenart ist erforderlich (unternehmen oder privat).`, **vor** jedem Schreibzugriff (kein Appwrite-Konto, kein Dokument). `updateCustomerWithPortalAccess(id, payload)` nimmt `kundenart` optional (nur bei `payload.kundenart !== undefined` prüfen und schreiben, ungültig wie oben). Die Route antwortet dann `{ error, code }`. `sanitizeCustomer` liefert `kundenart: kundenartGespeichert(doc)`.
- [ ] **Step 1: Failing test `test/kundenartAdmin.test.js`** (Router `/api/admin/customers`, Header `Authorization: Bearer jwt-admin`; Kopf: `const modus = (m) => { config.kundenansichtNeu.mode = m }`, `beforeEach(() => modus('pilot'))`, `after()` stellt den ursprünglichen Modus zurück)
```js
const admin = { headers: { Authorization: 'Bearer jwt-admin' } }
const neu = (extra = {}) => ({ name: 'Bäckerei Müller', email: 'mueller@example.invalid', password: 'geheim-1234', ...extra })
test('pilot und on: Anlegen ohne oder mit ungültiger Kundenart: 400 kundenart_fehlt, nichts geschrieben', async () => {
for (const mode of ['pilot', 'on']) {
modus(mode)
for (const kundenart of [undefined, '', 'Unternehmen', 'firma', 5, null]) {
const res = await post('/api/admin/customers', neu({ kundenart }), admin)
assert.deepEqual([res.status, res.body], [400, { error: 'Kundenart ist erforderlich (unternehmen oder privat).', code: 'kundenart_fehlt' }], `${mode} ${String(kundenart)}`)
}
}
assert.equal(aw.docs('customers').size, 0)
})
test('Anlegen mit unternehmen oder privat; Antwort und Dokument tragen die Kundenart', async () => {
const a = await post('/api/admin/customers', neu({ kundenart: 'unternehmen' }), admin)
assert.deepEqual([a.status, a.body.customer.kundenart], [201, 'unternehmen'])
assert.equal(aw.docs('customers').get(a.body.customer.$id).kundenart, 'unternehmen')
const b = await post('/api/admin/customers', neu({ email: 'frau@example.invalid', kundenart: 'privat' }), admin)
assert.equal(b.body.customer.kundenart, 'privat')
})
test('Ändern: ohne Feld bleibt die Kundenart, gültig ändert sie, ungültig ist 400 ohne Änderung', async () => {
const id = [...aw.docs('customers').keys()][0]
assert.equal((await send('PATCH', `/api/admin/customers/${id}`, { body: { name: 'Neu' }, ...admin })).body.customer.kundenart, 'unternehmen')
assert.equal((await send('PATCH', `/api/admin/customers/${id}`, { body: { kundenart: 'privat' }, ...admin })).body.customer.kundenart, 'privat')
const bad = await send('PATCH', `/api/admin/customers/${id}`, { body: { kundenart: 'x' }, ...admin })
assert.deepEqual([bad.status, bad.body.code], [400, 'kundenart_fehlt']); assert.equal(aw.docs('customers').get(id).kundenart, 'privat')
})
test('off (Schalter aus): Anlegen ohne Kundenart wie heute, kein Feld im Dokument; ungültig mitgeschickt bleibt 400', async () => { // als letzter Test der Datei: legt Kunden an, die die Tests davor nicht sehen sollen
modus('off')
const a = await post('/api/admin/customers', neu({ email: 'off1@example.invalid' }), admin)
assert.deepEqual([a.status, a.body.customer.kundenart], [201, ''])
assert.equal(Object.hasOwn(aw.docs('customers').get(a.body.customer.$id), 'kundenart'), false)
assert.equal((await post('/api/admin/customers', neu({ email: 'off2@example.invalid', kundenart: '' }), admin)).status, 201)
assert.equal((await post('/api/admin/customers', neu({ email: 'off3@example.invalid', kundenart: 'privat' }), admin)).status, 201)
const bad = await post('/api/admin/customers', neu({ email: 'off4@example.invalid', kundenart: 'firma' }), admin)
assert.deepEqual([bad.status, bad.body.code], [400, 'kundenart_fehlt'])
})
```
- [ ] **Step 2: Rot**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenartAdmin.test.js`
Expected: FAIL (`201 !== 400` beim Anlegen ohne Kundenart).
- [ ] **Step 3: `customerAdmin.js` ändern**
Prüfung `KUNDENARTEN.includes(payload.kundenart)` zusammen mit den bestehenden Eingabeprüfungen (Name, E-Mail, Passwort), also vor `createAppwriteUser`: fehlender Wert nur bei `neueAnsichtModus() !== 'off'` ein Fehler, ein mitgeschickter ungültiger Wert immer; ein gültiger Wert wird auch bei `off` ins Dokument geschrieben, ein fehlender nie. Beim Ändern nur prüfen, wenn `payload.kundenart !== undefined`, und nur dann ins Update schreiben. Gemeinsamer Fehlerbauer `kundenartFehler()`.
- [ ] **Step 4: `routes/admin/customers.js` gibt `code` durch**
In den `catch`-Zweigen von POST und PATCH: `res.status(status).json({ error: …, ...(err.code ? { code: err.code } : {}) })`.
- [ ] **Step 5: Grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenartAdmin.test.js test/rechtePrivatArchiv.test.js test/storageTasks.test.js`
Expected: PASS, 0 fail.
- [ ] **Step 6: Commit**
```bash
git add server/services/customerAdmin.js server/routes/admin/customers.js test/kundenartAdmin.test.js
git commit -m "Kundenansicht P5: Kundenart beim Anlegen Pflicht (ab Schalter pilot), beim Ändern optional" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 4: Abo-Zuordnung (`aboFunktionen.js`)
**Files:**
- Create: `server/services/aboFunktionen.js`
- Test: `test/aboFunktionen.test.js`
**Interfaces:**
- Consumes: `getCurrentSubscription`, `ACTIVE_STATUSES` (`stripeBilling.js`), `listContractEvents`, `openWithdrawals` (`contractEvents.js`).
- Produces (V5, mit zwei rückwärtskompatiblen Erweiterungen: Grund `beginnt_spaeter` und Parameter `now` bei `aboDeckt`):
```js
export const ENTHALTEN_IN = Object.freeze({ analyse: ['hosting', 'wartung'], news: ['wartung'], termine: ['wartung'], shop: ['wartung'], unterkunft: ['wartung'], zeiten: ['wartung'] })
export async function aboProdukt(customerId, { now = new Date() } = {})
// → { produkt: 'hosting'|'wartung'|null, laeuft, gekuendigt, widerrufsfristBis: string|null } (genau diese vier Schlüssel)
export async function aboDeckt(customerId, key, { now = new Date() } = {})
// → { ok: true } | { ok: false, grund: 'kein_abo'|'falsches_abo'|'gekuendigt'|'nicht_aktiv' } | { ok: false, grund: 'beginnt_spaeter', ab: string /*ISO*/ }
export async function aboLaeuftFuer(customerId, key) // → boolean: läuft und enthält key; gekündigt und past_due zählen noch (für Abgleichsjobs)
```
Regeln: `laeuft` = Status in `ACTIVE_STATUSES`; `produkt` ist nur dann nicht `null`, wenn `laeuft`; ohne `productKey` gilt Wartung (Altbestand); ist das jüngste `wechsel`-Ereignis dieses Abos `widerrufen`, gilt `hosting`. `gekuendigt` = `cancelAtPeriodEnd` oder `cancelAt`. `aboDeckt` prüft in der Reihenfolge `kein_abo`, `falsches_abo`, `gekuendigt`, `beginnt_spaeter`, `nicht_aktiv` (Status ≠ `active`). **`beginnt_spaeter`:** Status `trialing` mit `trialEnd` als gültigem Zeitpunkt nach `now`. So steht ein Privatkunde, der das Abo ohne Häkchen bestellt hat (`startChoice: 'nach_frist'`, Stripe „Probezeit ohne Leistung“, `stripeBilling.js:575-629`), bis zum Leistungsbeginn; `ab` ist `trialEnd` als ISO (derselbe Zeitpunkt wie `serviceStartsAt` in `GET /api/abo`). `trialing` ohne gültiges oder mit abgelaufenem `trialEnd` bleibt `nicht_aktiv`. Welche Lösung der Controller für diese Kunden will (Sperre mit Datum, wie gebaut, oder `trialing` als deckend), steht in Vertragsfrage 9. `widerrufsfristBis` = spätestes Ende der offenen Fristen von `abschluss` und `wechsel` (Speicher zählt nicht) als ISO, sonst `null`. Unbekannter Schlüssel: `TypeError`. Lesefehler werden weitergereicht (fail-closed).
- [ ] **Step 1: Failing test `test/aboFunktionen.test.js`** (Schema: `subscriptions`, `contractEvents`)
```js
const JETZT = new Date('2026-10-17T08:00:00.000Z')
const abo = (doc) => aw.seed('subscriptions', subscriptionDoc('sub_h', doc))
const ereignis = (id, type, contractAt, over = {}) => aw.seed('contractEvents', { $id: id, customerId: 'c1', subscriptionId: 'sub_h', type, productKey: 'wartung', status: 'erledigt', consentAt: contractAt, details: JSON.stringify({ contractAt }), ...over })
const FAELLE = [ // [Name, Abo-Dokument|null, aboProdukt-Teil, aboDeckt(termine), aboDeckt(analyse)]
['kein Abo', null, { produkt: null, laeuft: false, gekuendigt: false }, 'kein_abo', 'kein_abo'],
['Wartung aktiv', wartungDoc, { produkt: 'wartung', laeuft: true, gekuendigt: false }, null, null],
['Altbestand ohne productKey', { ...wartungDoc, productKey: undefined }, { produkt: 'wartung', laeuft: true, gekuendigt: false }, null, null],
['Hosting aktiv', hostingDoc, { produkt: 'hosting', laeuft: true, gekuendigt: false }, 'falsches_abo', null],
['Wartung zum Periodenende gekündigt', { ...wartungDoc, cancelAtPeriodEnd: true }, { produkt: 'wartung', laeuft: true, gekuendigt: true }, 'gekuendigt', 'gekuendigt'],
['Wartung mit cancelAt', { ...wartungDoc, cancelAt: '2026-12-01T00:00:00.000Z' }, { produkt: 'wartung', laeuft: true, gekuendigt: true }, 'gekuendigt', 'gekuendigt'],
['Wartung past_due', { ...wartungDoc, status: 'past_due' }, { produkt: 'wartung', laeuft: true, gekuendigt: false }, 'nicht_aktiv', 'nicht_aktiv'],
['Wartung trialing ohne Probezeit-Ende', { ...wartungDoc, status: 'trialing' }, { produkt: 'wartung', laeuft: true, gekuendigt: false }, 'nicht_aktiv', 'nicht_aktiv'],
['Wartung beendet', { ...wartungDoc, status: 'canceled' }, { produkt: null, laeuft: false, gekuendigt: false }, 'kein_abo', 'kein_abo'],
]
for (const [name, doc, teil, termine, analyse] of FAELLE) test(`Abo-Lage: ${name}`, async () => {
if (doc) abo(doc)
assert.deepEqual(await aboProdukt('c1', { now: JETZT }), { ...teil, widerrufsfristBis: null })
assert.deepEqual(await aboDeckt('c1', 'termine'), termine ? { ok: false, grund: termine } : { ok: true })
assert.deepEqual(await aboDeckt('c1', 'analyse'), analyse ? { ok: false, grund: analyse } : { ok: true })
})
test('Probezeit (Privatkunde bestellte ohne Häkchen, nach_frist): beginnt_spaeter mit Datum, nicht nicht_aktiv', async () => {
const ENDE = '2026-11-03T05:00:00.000Z' // 06:00 Berlin am Tag nach dem Fristende
const probe = { ...wartungDoc, status: 'trialing', startChoice: 'nach_frist', trialEnd: ENDE }
abo(probe)
assert.deepEqual(await aboProdukt('c1', { now: JETZT }), { produkt: 'wartung', laeuft: true, gekuendigt: false, widerrufsfristBis: null })
for (const key of ['termine', 'analyse']) assert.deepEqual(await aboDeckt('c1', key, { now: JETZT }), { ok: false, grund: 'beginnt_spaeter', ab: ENDE }, key)
for (const trialEnd of [null, '', 'kaputt', '2026-10-17T07:59:59.000Z']) { // kein oder abgelaufenes Ende: wie bisher nicht_aktiv
abo({ ...probe, trialEnd }); assert.deepEqual(await aboDeckt('c1', 'termine', { now: JETZT }), { ok: false, grund: 'nicht_aktiv' }, String(trialEnd))
}
abo({ ...probe, cancelAtPeriodEnd: true }); assert.equal((await aboDeckt('c1', 'termine', { now: JETZT })).grund, 'gekuendigt')
abo({ ...hostingDoc, status: 'trialing', trialEnd: ENDE }); assert.equal((await aboDeckt('c1', 'termine', { now: JETZT })).grund, 'falsches_abo') // Reihenfolge: Abo vor Beginn
assert.deepEqual(await aboDeckt('c1', 'analyse', { now: JETZT }), { ok: false, grund: 'beginnt_spaeter', ab: ENDE })
})
test('aboLaeuftFuer: gekündigt und past_due zählen noch, falsches Abo und beendet nicht', async () => {
abo({ ...wartungDoc, cancelAtPeriodEnd: true }); assert.equal(await aboLaeuftFuer('c1', 'termine'), true)
abo({ ...hostingDoc }); assert.deepEqual([await aboLaeuftFuer('c1', 'termine'), await aboLaeuftFuer('c1', 'analyse')], [false, true])
abo({ ...wartungDoc, status: 'canceled' }); assert.equal(await aboLaeuftFuer('c1', 'news'), false)
})
test('widerrufener Wechsel: Spiegel sagt Wartung, es gilt Hosting', async () => {
abo(wartungDoc); ereignis('ev_w', 'wechsel', '2026-10-10T09:00:00.000Z', { status: 'widerrufen' })
assert.equal((await aboProdukt('c1')).produkt, 'hosting'); assert.deepEqual(await aboDeckt('c1', 'shop'), { ok: false, grund: 'falsches_abo' })
})
test('widerrufsfristBis: spätestes offenes Ende von Abschluss und Wechsel; Speicher, fremde und abgelaufene zählen nicht', async () => {
abo(wartungDoc)
ereignis('ev_a', 'abschluss', '2026-10-17T07:00:00.000Z') // Sa + 14 Tage = Sa 31.10. (Feiertag), So 01.11. (Feiertag) → Mo 02.11.
ereignis('ev_w', 'wechsel', '2026-10-10T09:00:00.000Z') // → Mo 26.10., 23:59:59.999 MEZ
ereignis('ev_s', 'speicher', '2026-10-16T09:00:00.000Z', { details: JSON.stringify({ contractAt: '2026-10-16T09:00:00.000Z', fromUnits: 0, toUnits: 1 }) })
ereignis('ev_f', 'wechsel', '2026-10-15T09:00:00.000Z', { subscriptionId: 'sub_alt', status: 'widerrufen' })
assert.equal((await aboProdukt('c1', { now: JETZT })).widerrufsfristBis, '2026-11-02T22:59:59.999Z')
aw.docs('contractEvents').delete('ev_a'); assert.equal((await aboProdukt('c1', { now: JETZT })).widerrufsfristBis, '2026-10-26T22:59:59.999Z')
ereignis('ev_alt', 'abschluss', '2026-09-20T09:00:00.000Z'); aw.docs('contractEvents').delete('ev_w') // Frist endete Mo 05.10.
assert.equal((await aboProdukt('c1', { now: JETZT })).widerrufsfristBis, null)
})
test('Form: ENTHALTEN_IN eingefroren und genau die Schlüssel von V5; unbekannte Funktion ist ein Programmfehler', async () => {
assert.ok(Object.isFrozen(ENTHALTEN_IN)); assert.deepEqual(Object.keys(ENTHALTEN_IN), ['analyse', 'news', 'termine', 'shop', 'unterkunft', 'zeiten'])
assert.deepEqual(Object.keys(await aboProdukt('c1')).sort(), ['gekuendigt', 'laeuft', 'produkt', 'widerrufsfristBis'])
await assert.rejects(() => aboDeckt('c1', 'gibtEsNicht'), TypeError)
})
```
(`beforeEach` leert `subscriptions` und `contractEvents`.)
- [ ] **Step 2: Rot**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/aboFunktionen.test.js`
Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `aboFunktionen.js`.
- [ ] **Step 3: `server/services/aboFunktionen.js` implementieren**
Ein privater Leser `lies(customerId, now)` liefert Abo-Dokument, Produkt, Status und offene Fristen; `aboProdukt`, `aboDeckt`, `aboLaeuftFuer` bauen darauf auf (ein Lesepfad wie in `aboStatus.js`). Wechsel-Regel wie `aboStatus.js` (`lage`): jüngstes `wechsel`-Ereignis **desselben Abos** `widerrufen` → `hosting`. Fristen: `openWithdrawals(events, now)`, nur Typen `abschluss` und `wechsel`, Maximum von `until`. `beginnt_spaeter`: `trialing` und `Date.parse(trialEnd) > now.getTime()`, `ab = new Date(trialEnd).toISOString()`.
- [ ] **Step 4: Grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/aboFunktionen.test.js test/aboStatus.test.js test/contractEvents.test.js`
Expected: PASS, 0 fail.
- [ ] **Step 5: Commit**
```bash
git add server/services/aboFunktionen.js test/aboFunktionen.test.js
git commit -m "Kundenansicht P5: Abo-Zuordnung der Funktionen (aboProdukt, aboDeckt)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 5: Gesammelte Widerrufsfristen (`fristen.js`, `GET /api/fristen`)
**Files:**
- Create: `server/services/fristen.js`, `server/routes/fristen.js`
- Modify: `server/index.js` (1 Import, 1 Mount nach `app.use('/api/funktionen', funktionenRoutes)`)
- Test: `test/fristen.test.js`
**Interfaces:**
- Consumes: `listContractEvents`, `openWithdrawals`, `withdrawalDeadline` (`deadlines.js`), `paidFeatureOrders`, `FEATURE_LABELS` (`siteFeatures.js`), `listAuftraege` (`auftraege/auftragStore.js`), `auftraegeAktiv` (`auftraege/schalter.js`), `kundenartWirksam` (Task 2).
- Produces (V6):
```js
export async function sammleFristen(customerId, { now = new Date() } = {})
// → { fristen: [{ quelle, bezeichnung, bis, featureKey?, projektName? }] (aufsteigend nach bis), fruehestesEnde: string|null, anzeigen: boolean }
```
`quelle` und `bezeichnung`: `abschluss` „Abo-Abschluss“, `wechsel` „Wechsel ins Wartungs-Abo“, `speicher` „Zusatzspeicher“, `funktion` = `FEATURE_LABELS[key].name` (mit `featureKey`, `projektName`), `projekt` = `Auftrag <Nummer>` (nur bei `auftraegeAktiv(kunde)` und Snapshot-Kundenart `privat`, Status `versendet|bestaetigt`, Frist ab `gesendetAm`). `bis` ISO mit `Z`. `featureKey`/`projektName` fehlen als Schlüssel, wo sie nicht gelten. `anzeigen` = `kundenartWirksam(kunde) === 'privat' && fristen.length > 0`. Lesefehler werden weitergereicht. `GET /api/fristen`: `requireSession`, `Cache-Control: no-store`, in der Kundenansicht erlaubt (lesend), Antwort = Rückgabe von `sammleFristen`.
- [ ] **Step 1: Failing test `test/fristen.test.js`** (Schema: `subscriptions`, `contractEvents`, `portalFeatures`, `featureConsents`, `auftraege` mit `datetimes: AUFTRAEGE_DATETIMES`; `beforeEach` leert `contractEvents`, `portalFeatures`, `auftraege`, legt Wartung und Projekt p1 „Bäckerei“ an)
```js
const JETZT = new Date('2026-10-17T08:00:00.000Z')
const ereignis = (id, type, contractAt, over = {}) => aw.seed('contractEvents', { $id: id, customerId: 'c1', subscriptionId: 'sub_h', type, productKey: 'wartung', status: 'erledigt', consentAt: contractAt, details: JSON.stringify({ contractAt }), ...over })
const aufbau = () => { // Ende je Quelle: wechsel Mo 26.10., funktion Do 29.10., speicher Fr 30.10., abschluss Mo 02.11.
ereignis('ev_a', 'abschluss', '2026-10-17T07:00:00.000Z'); ereignis('ev_w', 'wechsel', '2026-10-10T09:00:00.000Z')
ereignis('ev_s', 'speicher', '2026-10-16T09:00:00.000Z', { details: JSON.stringify({ contractAt: '2026-10-16T09:00:00.000Z', fromUnits: 0, toUnits: 1 }) })
ereignis('ev_m', 'speicher', '2026-10-16T10:00:00.000Z', { details: JSON.stringify({ contractAt: '2026-10-16T10:00:00.000Z', fromUnits: 2, toUnits: 1 }) }) // Minus: keine Frist
aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', state: 'setup', enabled: false, unlockedByPurchase: true, orderedAt: '2026-10-15T09:00:00.000Z' })
}
test('mehrere Quellen, aufsteigend nach Ende; Speicher-Plus zählt, Minus nicht', async () => {
aufbau(); const r = await sammleFristen('c1', { now: JETZT })
assert.deepEqual(r.fristen, [
{ quelle: 'wechsel', bezeichnung: 'Wechsel ins Wartungs-Abo', bis: '2026-10-26T22:59:59.999Z' },
{ quelle: 'funktion', bezeichnung: 'News-Pinnwand', bis: '2026-10-29T22:59:59.999Z', featureKey: 'news', projektName: 'Bäckerei' },
{ quelle: 'speicher', bezeichnung: 'Zusatzspeicher', bis: '2026-10-30T22:59:59.999Z' },
{ quelle: 'abschluss', bezeichnung: 'Abo-Abschluss', bis: '2026-11-02T22:59:59.999Z' },
])
assert.deepEqual([r.fruehestesEnde, r.anzeigen], ['2026-10-26T22:59:59.999Z', true])
})
test('Kundenart: Betrieb hat Fristen, aber anzeigen false; privat, leer und unbekannt zeigen', async () => {
aufbau()
for (const [kundenart, anzeigen] of [['unternehmen', false], ['privat', true], ['', true], ['kaputt', true]]) {
aw.seed('customers', { ...customers.c1, kundenart }); const r = await sammleFristen('c1', { now: JETZT })
assert.deepEqual([r.fristen.length, r.anzeigen], [4, anzeigen], kundenart)
}
})
test('abgelaufene oder fehlende Fristen: leer, fruehestesEnde null, anzeigen false', async () => {
ereignis('ev_alt', 'abschluss', '2026-09-20T09:00:00.000Z') // Ende Mo 05.10.
assert.deepEqual(await sammleFristen('c1', { now: JETZT }), { fristen: [], fruehestesEnde: null, anzeigen: false })
})
test('Aufträge zählen nur mit Schalter und Snapshot-Kundenart privat', async () => {
const ab = (kundenart) => aw.seed('auftraege', { $id: 'ab1', nummer: 'AB-2026-0007', kundenId: 'c1', status: 'versendet', gesendetAm: '2026-10-16T09:00:00.000Z', kundeJson: JSON.stringify({ kundenart }) })
ab('privat'); config.auftraege.mode = 'off'; assert.deepEqual((await sammleFristen('c1', { now: JETZT })).fristen, [])
config.auftraege.mode = 'on'
assert.deepEqual((await sammleFristen('c1', { now: JETZT })).fristen, [{ quelle: 'projekt', bezeichnung: 'Auftrag AB-2026-0007', bis: '2026-10-30T22:59:59.999Z' }])
ab('unternehmen'); assert.deepEqual((await sammleFristen('c1', { now: JETZT })).fristen, [])
})
test('GET /api/fristen: 401 ohne Anmeldung, Form, no-store, Kundenansicht erlaubt; Mount in server/index.js', async () => {
assert.equal((await rawGet('/api/fristen')).status, 401)
const res = await rawGet('/api/fristen', { cookie: customerCookie('c1') })
assert.deepEqual([res.status, Object.keys(res.body).sort(), res.headers.get('cache-control')], [200, ['anzeigen', 'fristen', 'fruehestesEnde'], 'no-store'])
assert.equal((await rawGet('/api/fristen', { cookie: kundenansichtCookie({ customerId: 'c1' }) })).status, 200)
const src = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8')
assert.ok(src.indexOf("app.use('/api/fristen', fristenRoutes)") > src.indexOf('app.use(sessionMiddleware())'))
assert.ok(src.indexOf("app.use('/api/fristen', fristenRoutes)") < src.indexOf('app.use(express.static(publicDir))'))
})
```
- [ ] **Step 2: Rot**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/fristen.test.js`
Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `fristen.js`.
- [ ] **Step 3: `server/services/fristen.js` implementieren**
Kundendokument einmal lesen (`getDocument(config.collections.customers, …)`, 404 → `{}`). Quellen unabhängig sammeln, offen ist, was `now <= endsAt` erfüllt (`openWithdrawals` filtert selbst; Funktionen und Aufträge mit `withdrawalDeadline(...).endsAt`). Ereignis-Typ `speicher` kommt aus `openWithdrawals` (nur Plus).
- [ ] **Step 4: Route und Mount**
`server/routes/fristen.js`: `router.get('/', requireSession, …)` mit `getSessionCustomerId(req)`, Fehler als `500 { error: 'Die Fristen konnten gerade nicht geladen werden.' }`. In `server/index.js` Import `fristenRoutes` und `app.use('/api/fristen', fristenRoutes)` direkt nach der Zeile `/api/funktionen`.
- [ ] **Step 5: Grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/fristen.test.js`
Expected: PASS, 0 fail.
- [ ] **Step 6: Commit**
```bash
git add server/services/fristen.js server/routes/fristen.js server/index.js test/fristen.test.js
git commit -m "Kundenansicht P5: gesammelte Widerrufsfristen (sammleFristen, GET /api/fristen)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 6: Katalogfelder, `consentAbo` und Unterlagen-Quelle im Register
**Files:**
- Create: `server/services/aboUnterlagen.js`
- Modify: `server/config.js` (Eintrag `features.<key>` ergänzen, Abschnitt `aboFunktionen` neu), `server/services/featureRegistry.js`
- Test: `test/aboKatalogRegister.test.js`
**Interfaces:**
- Consumes: bestehende Registereinträge (`label.name`, `leistungsbeschreibung`, `avv`), `legalPaths` (`analyseLegal.js`).
- Produces:
```js
// server/config.js
config.aboFunktionen = { fassung: '2026-10-16', reihenfolge: ['news','termine','shop','unterkunft','zeiten','analyse'],
texte: { beginn: <Entwurf unten>, hinweis: { shop, unterkunft, termine } /* Entwurf unten */, zielgruppe: <Analyse, unten>, analyseKnopf: 'Analyse einschalten' },
analyse: { name: 'Website-Analyse', beschreibung, bild, ablauf, einrichtung, nurBetrieb } }
config.features.<news|termine|shop|unterkunft|zeiten>.{ beschreibung, bild, ablauf, einrichtung, nurBetrieb }
// server/services/aboUnterlagen.js (Blattmodul: importiert nur config und analyseLegal.js; Task 15 stellt die Quelle auf FUNKTIONEN_UNTERLAGEN_V6)
export function unterlagenFuer(key) // → { leistungsbeschreibung: { version, url } | null, avv: { version, url } | null }; Register-Dokumente, 'analyse' aus legalPaths(config.analyse.avvVersion); unbekannt: TypeError
export function analyseUnterlagen() // → { avvVersion, legal: { lb, avv } } (URLs), aus unterlagenFuer('analyse')
// server/services/featureRegistry.js
export function katalogReihenfolge() // config.aboFunktionen.reihenfolge (Kopie)
export function katalogFelder(key) // { beschreibung, bild, ablauf, einrichtung, nurBetrieb } | null (auch 'analyse')
export function consentAbo(key) // { version, knopf: 'Einschalten', items: [{ id, text, haken, nurPrivatInFrist? }] } | null (analyse, unbekannt); baut die Items bei jedem Aufruf aus unterlagenFuer(key) und config.aboFunktionen.texte
export function consentAboAktiv(key, { beginn = false } = {}) // wie angezeigt: 'einschalten-zeile' (haken false), falls vorhanden 'hinweis' (haken false), mit beginn zusätzlich 'beginn' (haken true)
export function aboweg(feature, { beginn = false } = {}) // { ...feature, consent: consentAboAktiv(feature.key, { beginn }), leistungsbeschreibung, avv (aus unterlagenFuer), widerruf: null }
export function consentAboAnalyse() // { version, avvVersion, knopf: 'Analyse einschalten', items: [{ id: 'einschalten-zeile', text, haken: false }, { id: 'zielgruppe', text, haken: true }] }
export function abowegAnalyse() // { key: 'analyse', consent: consentAboAnalyse(), leistungsbeschreibung: { version: avvVersion }, avv: { version: avvVersion }, widerruf: null } (für speichereNachweisAusAnfrage)
```
`config.js` importiert keine Dienste; es hält nur Daten und Entwurfstexte. Die Items stehen in Anzeigereihenfolge (`einschalten-zeile`, `hinweis`, `beginn`); der Nachweis (Task 7) speichert sie in dieser Reihenfolge. Das Item `hinweis` (`haken: false`) trägt für `shop`, `unterkunft` und `termine` die Pflicht aus den alten Häkchen `verantwortung`, `stripe` und `gesundheit` (Spec 5.6 Zeile F: „dazu ein Satz auf der Einschalt-Seite“); `news` und `zeiten` haben keins. Es ist die einzige Quelle: `katalog[].consent.items` enthält es dadurch automatisch, und `katalog[].hinweis` (Task 11) ist nur sein Text.
Feste Werte (Reihenfolge `news, termine, shop, unterkunft, zeiten, analyse`):
| key | ablauf | einrichtung | nurBetrieb | beschreibung |
|---|---|---|---|---|
| news | direkt | meist in 1–2 Wochen | false | Zeig Neuigkeiten und Aktionen direkt auf deiner Website. Du schreibst sie im Portal, sie erscheinen sofort. |
| termine | fragebogen | meist in 1–2 Wochen | false | Deine Kunden buchen Termine online, rund um die Uhr. Zeiten, Team und Buchungen verwaltest du im Portal. |
| shop | anfrage | meist in 2–3 Wochen | true | Verkaufe Artikel direkt über deine Website. Die Zahlung läuft über dein eigenes Stripe-Konto. |
| unterkunft | anfrage | meist in 1–2 Wochen | false | Gäste fragen deine Unterkunft online an, du gibst frei und behältst Belegung und Preise im Blick. Den Kalender baut WEBklar in deine Website ein. |
| zeiten | direkt | meist in wenigen Tagen | false | Deine Öffnungszeiten stehen live auf deiner Website und sind immer aktuell. Du änderst sie im Portal, auch für Feiertage und Urlaub. |
| analyse | direkt | meist in wenigen Tagen | false | Sieh, wie viele Besucher auf deine Website kommen und welche Seiten sie ansehen. Die Auswertung findest du direkt im Portal. |
`bild` = `/neu/bilder/<key>.webp`. `einschalten-zeile`: mit AVV (termine, shop, unterkunft) `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung <lb.version>).`, ohne AVV (news, zeiten) `Es gilt die Leistungsbeschreibung (Fassung <lb.version>).` (`<lb.version>` = `unterlagenFuer(key).leistungsbeschreibung.version`). `beginn` (`haken: true`, `nurPrivatInFrist: true`), **Entwurf bis zur Verdrahtung der Fassung 6 (Task 15)**: `Bitte beginnt sofort mit der Einrichtung. Mir ist klar: Mit der vollständigen Einrichtung erlischt mein Widerrufsrecht, und wenn ich vorher widerrufe, zahle ich den bis dahin erbrachten Teil.` (liegt in `config.aboFunktionen.texte.beginn`, wörtlich wie `WORTLAUT_V6.beginn.einrichtung` aus P8).
`hinweis` (nur diese drei Funktionen, Entwurf wörtlich wie `WORTLAUT_V6.hinweis` aus P8, in `config.aboFunktionen.texte.hinweis`):
- shop: `Du verkaufst in eigenem Namen und bist für deine Produkte, Preise und Rechtstexte verantwortlich. Die Zahlungen deiner Käufer laufen über dein eigenes Stripe-Konto.`
- unterkunft: `Du vermietest in eigenem Namen und bist für Preise, Stornobedingungen, Rechnungen und Meldepflichten verantwortlich. Online-Zahlungen deiner Gäste laufen über dein eigenes Stripe-Konto.`
- termine: `Der Terminplaner ist nicht für den Gesundheitsbereich gedacht. Das fragen wir im Fragebogen ab.`
Analyse (`consentAboAnalyse`): `version` = `config.aboFunktionen.fassung`, `avvVersion` = `analyseUnterlagen().avvVersion`, Linkzeile `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung <avvVersion>).` (derselbe Wortlaut wie A1 in P4), `zielgruppe` (`haken: true`, Text in `config.aboFunktionen.texte.zielgruppe`) `Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.` (A2 in P4), Knopf `Analyse einschalten`.
- [ ] **Step 1: Failing test `test/aboKatalogRegister.test.js`** (Kopf wie `test/featureRegistry.test.js:1-16`, dazu `import { unterlagenFuer, analyseUnterlagen } from '../server/services/aboUnterlagen.js'`)
```js
const KEYS = ['news', 'termine', 'shop', 'unterkunft', 'zeiten']
test('Reihenfolge und Felder je Funktion', () => {
assert.deepEqual(reg.katalogReihenfolge(), [...KEYS, 'analyse'])
const ablauf = { news: 'direkt', termine: 'fragebogen', shop: 'anfrage', unterkunft: 'anfrage', zeiten: 'direkt', analyse: 'direkt' }
for (const key of [...KEYS, 'analyse']) {
const f = reg.katalogFelder(key)
assert.deepEqual(Object.keys(f).sort(), ['ablauf', 'beschreibung', 'bild', 'einrichtung', 'nurBetrieb'])
assert.deepEqual([f.bild, f.ablauf, f.nurBetrieb], [`/neu/bilder/${key}.webp`, ablauf[key], key === 'shop'], key)
assert.ok(f.beschreibung.split(/(?<=[.!?])\s+/).length <= 2, 'höchstens zwei Sätze')
assert.doesNotMatch(f.beschreibung + f.einrichtung, /€|\bSie\b|\bIhre?[nmrs]?\b/, 'kein Preis, keine Sie-Form')
}
assert.equal(reg.katalogFelder('shop').einrichtung, 'meist in 2–3 Wochen'); assert.equal(reg.katalogFelder('gibtEsNicht'), null)
})
const HINWEIS = { // Pflichten der alten Häkchen verantwortung, stripe, gesundheit; wörtlich wie WORTLAUT_V6.hinweis (P8)
termine: 'Der Terminplaner ist nicht für den Gesundheitsbereich gedacht. Das fragen wir im Fragebogen ab.',
shop: 'Du verkaufst in eigenem Namen und bist für deine Produkte, Preise und Rechtstexte verantwortlich. Die Zahlungen deiner Käufer laufen über dein eigenes Stripe-Konto.',
unterkunft: 'Du vermietest in eigenem Namen und bist für Preise, Stornobedingungen, Rechnungen und Meldepflichten verantwortlich. Online-Zahlungen deiner Gäste laufen über dein eigenes Stripe-Konto.',
}
test('consentAbo: Linkzeile mit Fassung, Hinweis (termine, shop, unterkunft), beginn als Entwurf, kein Häkchen gesundheit', () => {
const V = config.aboFunktionen.fassung
for (const key of KEYS) {
const c = reg.consentAbo(key)
const ids = key in HINWEIS ? ['einschalten-zeile', 'hinweis', 'beginn'] : ['einschalten-zeile', 'beginn']
assert.deepEqual([c.version, c.knopf, c.items.map((i) => i.id)], [V, 'Einschalten', ids], key)
assert.deepEqual(c.items.map((i) => i.haken), ids.map((id) => id === 'beginn'), `${key}: nur beginn ist ein Häkchen`)
assert.deepEqual([c.items.at(-1).text, c.items.at(-1).nurPrivatInFrist], [config.aboFunktionen.texte.beginn, true], key)
if (key in HINWEIS) assert.equal(c.items[1].text, HINWEIS[key], key)
}
assert.equal(reg.consentAbo('termine').items[0].text, `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung ${config.termine.avvVersion}).`)
assert.equal(reg.consentAbo('zeiten').items[0].text, 'Es gilt die Leistungsbeschreibung (Fassung 2026-10-04).')
assert.ok(!reg.consentAbo('termine').items.some((i) => i.id === 'gesundheit'), 'das Häkchen gesundheit entfällt, der Satz steht als Hinweis')
assert.doesNotMatch(JSON.stringify(Object.values(HINWEIS)), /€|\bSie\b|\bIhre?[nmrs]?\b/)
assert.equal(reg.consentAbo('analyse'), null)
})
test('consentAboAktiv und aboweg zeigen nur, was verlangt ist; das alte Register bleibt unverändert', () => {
assert.deepEqual(reg.consentAboAktiv('shop').items.map((i) => i.id), ['einschalten-zeile', 'hinweis'])
assert.deepEqual(reg.consentAboAktiv('news').items.map((i) => i.id), ['einschalten-zeile'])
assert.deepEqual(reg.consentAboAktiv('shop', { beginn: true }).items.map((i) => [i.id, i.haken]), [['einschalten-zeile', false], ['hinweis', false], ['beginn', true]])
const w = reg.aboweg(reg.featureConfig('termine'), { beginn: true })
assert.deepEqual([w.consent.version, w.widerruf, w.avv.version, w.leistungsbeschreibung.version, w.key], [config.aboFunktionen.fassung, null, config.termine.avvVersion, config.termine.avvVersion, 'termine'])
assert.deepEqual(reg.featureConfig('termine').consent.items.map((i) => i.id), ['unterlagen', 'gesundheit', 'beginn', 'information'])
assert.equal(Object.keys(reg.oeffentlicheAnsicht('termine')).length, 11, 'oeffentlicheAnsicht ohne neue Felder')
})
test('unterlagenFuer und analyseUnterlagen: eine Quelle für Katalog, Nachweis und Analyse', () => {
const t = unterlagenFuer('termine')
assert.deepEqual([t.leistungsbeschreibung, t.avv], [{ version: config.termine.avvVersion, url: config.features.termine.leistungsbeschreibung.url }, { version: config.termine.avvVersion, url: config.features.termine.avv.url }])
assert.deepEqual([unterlagenFuer('news').avv, unterlagenFuer('zeiten').avv], [null, null])
assert.ok(unterlagenFuer('news').leistungsbeschreibung.version)
const a = analyseUnterlagen()
assert.deepEqual(a, { avvVersion: config.analyse.avvVersion, legal: { lb: `/rechtliches/leistungsbeschreibung-website-analyse-${config.analyse.avvVersion}.html`, avv: `/rechtliches/avv-website-analyse-${config.analyse.avvVersion}.html` } })
assert.throws(() => unterlagenFuer('gibtEsNicht'), TypeError)
})
test('consentAboAnalyse und abowegAnalyse: Linkzeile, Zielgruppen-Häkchen und Knopf wörtlich wie in P4', () => {
const c = reg.consentAboAnalyse()
assert.deepEqual([c.version, c.avvVersion, c.knopf], [config.aboFunktionen.fassung, config.analyse.avvVersion, 'Analyse einschalten'])
assert.deepEqual(c.items, [
{ id: 'einschalten-zeile', text: `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung ${config.analyse.avvVersion}).`, haken: false },
{ id: 'zielgruppe', text: 'Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.', haken: true },
])
const w = reg.abowegAnalyse()
assert.deepEqual([w.key, w.leistungsbeschreibung.version, w.avv.version, w.widerruf, w.consent.knopf], ['analyse', config.analyse.avvVersion, config.analyse.avvVersion, null, 'Analyse einschalten'])
})
```
- [ ] **Step 2: Rot**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/aboKatalogRegister.test.js`
Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `aboUnterlagen.js`.
- [ ] **Step 3: `server/config.js`**
Abschnitt `aboFunktionen` (Werte aus der Tabelle, `texte.beginn`, `texte.hinweis`, `texte.zielgruppe`, `texte.analyseKnopf`) als Eigenschaft von `config`; je Registereintrag die fünf Felder. Kein Getter `consentAbo` und kein Dienstimport in `config.js`. `consent`, `consentFrei` und alles Bestehende bleiben unangetastet.
- [ ] **Step 4: `server/services/aboUnterlagen.js` und `server/services/featureRegistry.js`**
`aboUnterlagen.js` nach „Produces“ (`unterlagenFuer` liest `config.features[key].leistungsbeschreibung` und `.avv` bei jedem Aufruf, `analyse` aus `legalPaths(config.analyse.avvVersion)` mit den Schlüsseln `lb` und `avv`). In `featureRegistry.js` die Funktionen aus „Produces“: `consentAbo(key)` baut die Items nach den festen Werten oben (`null` für `analyse` und unbekannte Schlüssel), `katalogFelder('analyse')` liest `config.aboFunktionen.analyse`, `aboweg` überschreibt `leistungsbeschreibung` und `avv` aus `unterlagenFuer` (so ändert Task 15 nur die Quelle). `aboUnterlagen.js` ist ein Blattmodul; der statische Import in der Registry ist deshalb zulässig, der Kommentar im Dateikopf von `featureRegistry.js` („importiert keine Dienste statisch“) nennt diese Ausnahme. `oeffentlicheAnsicht` bleibt unverändert.
- [ ] **Step 5: Grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/aboKatalogRegister.test.js test/featureRegistry.test.js test/featureConsentTexte.test.js test/legalDocs.test.js`
Expected: PASS, 0 fail (`TEXTSTAND` und die Registerpins unverändert).
- [ ] **Step 6: Commit**
```bash
git add server/config.js server/services/aboUnterlagen.js server/services/featureRegistry.js test/aboKatalogRegister.test.js
git commit -m "Kundenansicht P5: Katalogfelder, consentAbo mit Hinweis und Unterlagen-Quelle im Funktionen-Register" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 7: Kundenart im Zustimmungsnachweis
**Files:**
- Modify: `server/services/featureConsents.js` (`speichereZustimmung` Z. 79, `speichereNachweisAusAnfrage`)
- Test: `test/featureConsentsKundenart.test.js`
**Interfaces:**
- Consumes: `KUNDENARTEN` (Task 2), `aboweg` (Task 6).
- Produces: `speichereZustimmung({ …, kundenart })` und `speichereNachweisAusAnfrage(req, { …, kundenart })` mit optionalem `kundenart: 'unternehmen'|'privat'`. Das Feld wird **nur geschrieben, wenn es übergeben wurde**; ein anderer Wert ist `Error` mit `status: 400` (`Zustimmungsnachweis: Kundenart ungültig`) ohne Schreibzugriff.
- [ ] **Step 1: Failing test `test/featureConsentsKundenart.test.js`**
```js
// Kopf: const schema = { featureConsents: schemaKeys('featureConsents') }; const aw = await startAppwriteMock({ schema }) (schema bleibt änderbar wie test/featureSetup.test.js:193)
const base = { customerId: 'c1', projectId: 'p1', featureKey: 'termine', stripeSessionId: '', fassungen: { zustimmung: 'x' }, texte: [{ id: 'knopf', text: 'Einschalten', haken: false }], ip: '203.0.113.9', userAgent: 'UA/1', konto: 'k', at: '2026-10-17T08:00:00.000Z' }
test('kundenart wird gespeichert; ohne Angabe fehlt der Schlüssel, auch bei altem Schema', async () => {
assert.equal((await speichereZustimmung({ ...base, kundenart: 'privat' })).kundenart, 'privat')
schema.featureConsents = schemaKeys('featureConsents').filter((k) => k !== 'kundenart') // Setup-Skript noch nicht gelaufen
assert.equal(Object.hasOwn(await speichereZustimmung(base), 'kundenart'), false)
await assert.rejects(() => speichereZustimmung({ ...base, kundenart: 'privat' }), /Unknown attribute/)
schema.featureConsents = schemaKeys('featureConsents')
})
test('ungültige Kundenart: 400, nichts geschrieben', async () => {
const vorher = aw.docs('featureConsents').size
for (const kundenart of ['firma', '', 'Privat', 3, null]) await assert.rejects(() => speichereZustimmung({ ...base, kundenart }), (e) => e.status === 400)
assert.equal(aw.docs('featureConsents').size, vorher)
})
test('Nachweis aus der Anfrage im Abo-Modell: Zeile, Hinweis, optional Häkchen, Knopf; Fassungen ohne widerruf', async () => {
const req = { ip: '203.0.113.9', get: (h) => (h === 'user-agent' ? 'UA/1' : ''), session: { name: 'Kunde Eins', email: 'k@example.invalid' } }
const feature = reg.featureConfig('termine')
await speichereNachweisAusAnfrage(req, { feature: reg.aboweg(feature), customerId: 'c1', projectId: 'p1', klickAt: '2026-10-17T08:00:00.000Z', kundenart: 'unternehmen' })
await speichereNachweisAusAnfrage(req, { feature: reg.aboweg(feature, { beginn: true }), customerId: 'c1', projectId: 'p2', klickAt: '2026-10-17T08:00:00.000Z', kundenart: 'privat' })
const [a, b] = [...aw.docs('featureConsents').values()].filter((d) => d.featureKey === 'termine' && d.projectId.startsWith('p')).slice(-2)
assert.deepEqual(JSON.parse(a.texte).map((t) => t.id), ['einschalten-zeile', 'hinweis', 'knopf']) // termine trägt den Hinweis (Pflichten der alten Häkchen)
assert.equal(JSON.parse(a.texte)[1].text, reg.consentAbo('termine').items[1].text)
assert.deepEqual(JSON.parse(b.texte).map((t) => [t.id, t.haken]), [['einschalten-zeile', false], ['hinweis', false], ['beginn', true], ['knopf', false]])
assert.deepEqual(Object.keys(JSON.parse(a.fassungen)).sort(), ['avv', 'leistungsbeschreibung', 'zustimmung'])
assert.deepEqual([a.kundenart, b.kundenart, a.konto, a.stripeSessionId], ['unternehmen', 'privat', 'Kunde Eins <k@example.invalid>', ''])
})
```
- [ ] **Step 2: Rot**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/featureConsentsKundenart.test.js`
Expected: FAIL (`undefined !== 'privat'`).
- [ ] **Step 3: `featureConsents.js` ändern**
In `speichereZustimmung`: `kundenart` nur prüfen und aufnehmen, wenn `kundenart !== undefined` (dann muss es in `KUNDENARTEN` stehen, sonst 400 vor `createDocument`). `speichereNachweisAusAnfrage` reicht `kundenart` unverändert durch (nur wenn definiert).
- [ ] **Step 4: Grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/featureConsentsKundenart.test.js test/featureConsents.test.js test/funktionenCheckout.test.js test/featurePaid.test.js`
Expected: PASS, 0 fail.
- [ ] **Step 5: Commit**
```bash
git add server/services/featureConsents.js test/featureConsentsKundenart.test.js
git commit -m "Kundenansicht P5: Kundenart im Zustimmungsnachweis" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 8: Einrichtungs-Ticket mit Abrechnung und `featureView.unlockedBy`
**Files:**
- Modify: `server/services/ticketAdmin.js` (`createFeatureTicket` Z. 82), `server/services/siteFeatures.js` (`featureView` Z. 151, `legeTicketAn`)
- Test: `test/einrichtungsTicket.test.js`
**Interfaces:**
- Consumes: Setup-Attribute aus Task 1 (im Nachbau ist `workorders` ohne Schema).
- Produces (V7):
```js
// ticketAdmin.js
export const ABRECHNUNGS_ZEILEN = { abo: 'Zeit im Arbeitsblatt erfassen und aufs Abo-Stundenkonto anrechnen (Einrichtung aus dem Abo, Vorschuss möglich).',
projekt: 'Zeit im Arbeitsblatt erfassen, nicht aufs Abo anrechnen (im Projekt enthalten).' }
createFeatureTicket({ customerId, project, featureKey, siteKey, topic, details, fallbackDetails, abrechnung = '' }) // abrechnung: ''|'abo'|'projekt'
```
Mit `abrechnung` setzt das Ticket `einrichtungFuer: featureKey` und `einrichtungAbrechnung: abrechnung`; die Zeile steht als **letzte Zeile** der Details (nach der Kürzung auf 7000 Zeichen, auch im Notfallweg). Ohne `abrechnung` werden weder Felder noch Zeile geschrieben. Ungültiger Wert: `TypeError` vor jedem Schreibzugriff. `legeTicketAn(doc, …)` übergibt `doc.einrichtungAbrechnung`, wenn gesetzt. `featureView(doc, key)` liefert `unlockedBy` für **alle** Funktionen (Regel wie bisher bei `aboInklusive`: `null` im Zustand `off`); `aboEndeAt` und `einbau` bleiben bei `aboInklusive`.
- [ ] **Step 1: Failing test `test/einrichtungsTicket.test.js`**
```js
const ALT = ['createdAt', 'customerId', 'customerLocation', 'customerName', 'details', 'priority', 'serviceType', 'status', 'systemType', 'topic', 'type', 'woid']
const base = { customerId: 'c1', project: { $id: 'p1', projectName: 'Bäckerei' }, featureKey: 'termine', siteKey: 'abcdefghijklmnop', topic: 'T', details: 'Zeile 1' }
const nutzer = (doc) => Object.keys(doc).filter((k) => !k.startsWith('$')).sort()
test('abrechnung abo und projekt: Felder und letzte Zeile', async () => {
const { ticketId } = await createFeatureTicket({ ...base, abrechnung: 'abo' }); const t = aw.docs('workorders').get(ticketId)
assert.deepEqual([t.einrichtungFuer, t.einrichtungAbrechnung], ['termine', 'abo'])
assert.equal(t.details, `Zeile 1\n${ABRECHNUNGS_ZEILEN.abo}`)
assert.ok(aw.docs('workorders').get((await createFeatureTicket({ ...base, abrechnung: 'projekt' })).ticketId).details.endsWith('\nZeit im Arbeitsblatt erfassen, nicht aufs Abo anrechnen (im Projekt enthalten).'))
})
test('ohne abrechnung: keine neuen Felder, Details unverändert (alte Ansicht)', async () => {
for (const abrechnung of [undefined, '']) { const t = aw.docs('workorders').get((await createFeatureTicket({ ...base, abrechnung })).ticketId); assert.deepEqual(nutzer(t), ALT); assert.equal(t.details, 'Zeile 1') }
})
test('ungültiger Wert: TypeError, kein Ticket', async () => {
const n = aw.docs('workorders').size; await assert.rejects(() => createFeatureTicket({ ...base, abrechnung: 'frei' }), TypeError); assert.equal(aw.docs('workorders').size, n)
})
test('lange Details: gekürzt auf 7000, die Schlusszeile bleibt; auch im Notfallweg nach 400', async () => {
const lang = Array.from({ length: 400 }, (_, i) => `Zeile ${i} ${'x'.repeat(30)}`).join('\n')
const t = aw.docs('workorders').get((await createFeatureTicket({ ...base, details: lang, abrechnung: 'abo' })).ticketId)
assert.ok(t.details.length <= 7000 && t.details.endsWith(ABRECHNUNGS_ZEILEN.abo))
aw.failNext({ collection: 'workorders', method: 'POST', status: 400, message: 'zu lang' })
const n = aw.docs('workorders').get((await createFeatureTicket({ ...base, details: lang, fallbackDetails: 'Notfall', abrechnung: 'projekt' })).ticketId)
assert.ok(n.details.startsWith('Notfall') && n.details.endsWith(ABRECHNUNGS_ZEILEN.projekt))
})
test('featureView: unlockedBy für alle Funktionen, aboEndeAt/einbau nur bei Zeiten', async () => {
assert.equal((await featureView({ state: 'setup', unlockedBy: 'abo' }, 'news')).unlockedBy, 'abo')
assert.equal((await featureView({ state: 'setup', unlockedBy: 'projekt' }, 'shop')).unlockedBy, 'projekt')
assert.equal((await featureView({ state: 'active', unlockedByPurchase: true }, 'termine')).unlockedBy, 'kauf')
assert.equal((await featureView(undefined, 'news')).unlockedBy, null)
assert.equal(Object.hasOwn(await featureView({ state: 'setup', unlockedBy: 'abo' }, 'news'), 'einbau'), false)
})
test('ersetztes Ticket trägt die Abrechnung des Dokuments', async () => {
// portalFeatures f_p1_news: state 'setup', unlockedBy 'abo', einrichtungAbrechnung 'abo', ticketId 'weg' (Ticket gelöscht), websiteProjects p1, customers c1
const { doc } = await ticketNachlegen({ customerId: 'c1', projectId: 'p1', key: 'news', by: 'Kenso' })
assert.deepEqual([aw.docs('workorders').get(doc.ticketId).einrichtungAbrechnung, aw.docs('workorders').get(doc.ticketId).einrichtungFuer], ['abo', 'news'])
})
```
- [ ] **Step 2: Rot**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/einrichtungsTicket.test.js`
Expected: FAIL (`undefined !== 'termine'`).
- [ ] **Step 3: `ticketAdmin.js` ändern**
Parameter `abrechnung` prüfen (`''|'abo'|'projekt'`, sonst `TypeError`); lokale Funktion `mitAbrechnung(text)` kürzt den Text auf `7000 − Zeilenlänge − 1` (gleiche `kuerze`-Regeln) und hängt `\n<Zeile>` an, für Normal- und Notfallweg; Felder nur mit `abrechnung` ergänzen. `ABRECHNUNGS_ZEILEN` exportieren.
- [ ] **Step 4: `siteFeatures.js` ändern**
`featureView`: `unlockedBy` aus dem `mitAbo`-Zweig nehmen und für alle Schlüssel liefern; `aboEndeAt`/`einbau` bleiben im `mitAbo`-Zweig. `legeTicketAn`: `abrechnung: doc.einrichtungAbrechnung` mitgeben, wenn gesetzt. Nichts sonst in der Datei ändern (`adminActivate` gehört P6).
- [ ] **Step 5: Grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/einrichtungsTicket.test.js test/ticketAdmin.test.js test/featureTicket.test.js test/siteFeatures.test.js test/termineTicket.test.js test/zeitenTicket.test.js`
Expected: PASS, 0 fail.
- [ ] **Step 6: Commit**
```bash
git add server/services/ticketAdmin.js server/services/siteFeatures.js test/einrichtungsTicket.test.js
git commit -m "Kundenansicht P5: Einrichtungs-Ticket mit Abrechnung, unlockedBy für alle Funktionen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 9: Generisches Einschalten (`funktionEinschalten.js`)
**Files:**
- Create: `server/services/funktionEinschalten.js`
- Modify: `server/services/zeiten/einschalten.js` (`einschaltenZeiten`, `holeZeitenNach`)
- Test: `test/funktionEinschalten.test.js`
**Interfaces:**
- Consumes: `aboDeckt` (Task 4), `ticketTexte`, `featureConfig` (Registry), `createFeatureTicket({ abrechnung })` (Task 8), aus `siteFeatures.js`: `assertOwnProject`, `ensureFeatureDoc`, `getFeatureDoc`, `customerOf`, `stateFields`, `ticketWoid`, `unlockedByVon`; `withLocks`; `bestellFehler` (`featureOrder.js`).
- Produces (V7):
```js
export const ABRECHNUNGEN = Object.freeze(['abo', 'projekt'])
export const EINSCHALTEN_TEXTE = Object.freeze({ bereits: 'Diese Funktion ist für dieses Projekt schon eingeschaltet.',
laeuft: 'Für diese Funktion läuft schon eine Bestellung.', abgeschaltet: 'Diese Funktion wurde für dieses Projekt abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.',
ticket: 'Die Einrichtung konnte gerade nicht angelegt werden. Bitte versuch es gleich noch einmal.',
abo: { kein_abo: 'Für diese Funktion brauchst du ein laufendes Abo.', falsches_abo: 'Diese Funktion ist im Wartungs-Abo enthalten.',
gekuendigt: 'Dein Abo ist gekündigt. Zum Einschalten brauchst du ein laufendes Abo.',
nicht_aktiv: 'Dein Abo ist gerade nicht aktiv, zum Beispiel wegen einer offenen Zahlung. Sobald es wieder aktiv ist, kannst du die Funktion einschalten.',
beginnt_spaeter: 'Dein Abo beginnt am {datum}. Ab dann kannst du einschalten.' },
nichtPruefbar: 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.' })
export function aboFehlerText(grund, ab = null) // EINSCHALTEN_TEXTE.abo[grund]; bei 'beginnt_spaeter' wird {datum} durch den Berliner Tag von ab (berlinDay aus deadlines.js) als TT.MM.JJJJ ersetzt, z. B. 'Dein Abo beginnt am 03.11.2026. Ab dann kannst du einschalten.'
export async function einschalten({ customerId, projectId, key, by, abrechnung, nachweis = null, staff = null, now = new Date() }) // → { doc, ticketWoid }
// einschaltenZeiten({ …, abrechnung = '', vorpruefung = null }): abrechnung überschreibt unlockedBy ('projekt'|'abo') und setzt einrichtungAbrechnung; vorpruefung ersetzt pruefeAboFuerEinschalten unter der Sperre (nur by 'kunde')
```
Fehler (`bestellFehler`, mit `code` und `body`): 409 `bereits_eingeschaltet`, `bestellung_laeuft` (Zustand `ordered`), `abgeschaltet` (Zustand `disabled`), `abo_fehlt` (`body.grund`, `body.wechsel`, bei `beginnt_spaeter` zusätzlich `body.ab`; Text über `aboFehlerText`); 503 bei nicht lesbarem Abo oder gescheitertem Ticket; `assertOwnProject`-Fehler (403, 409 archiviert); `TypeError` für `key` außerhalb des Registers (auch `analyse`), `by`, `abrechnung`.
Ablauf für `news|termine|shop|unterkunft` unter der Sperre `feature-order:${customerId}:${projectId}`: (1) `by === 'kunde'`: `aboDeckt(customerId, key, { now })` frisch, sonst `abo_fehlt` (`wechsel` = `grund === 'falsches_abo'`, bei `beginnt_spaeter` `body.ab`); (2) Zustand lesen: `setup` ohne `ticketId` mit `unlockedBy` `abo|projekt` ist **Heilung** (nur Ticket anlegen, kein Nachweis, kein neuer Zustand), jeder andere `setup|active` ist `bereits_eingeschaltet`, `ordered` und `disabled` siehe oben; (3) `nachweis()` (falls übergeben) **vor** dem Zustand; (4) Dokument auf `setup` mit `featureKey`, `unlockedBy` (`projekt` oder `abo`), `unlockedByPurchase: false`, `einrichtungAbrechnung`, `disabledAt/disabledReason/aboEndeMailAt: null`, bei `by === 'team'` Notiz `staff.adminNote` oder `<ISO> <staff.name>: Eingeschaltet (<abrechnung>)`; (5) Ticket (`ticketTexte` mit dem neuen Dokument, `createFeatureTicket` mit `abrechnung`), `ticketId` speichern. Scheitert (5), bleibt `setup` ohne Ticket und der Aufruf wirft 503 `EINSCHALTEN_TEXTE.ticket`; der nächste Aufruf heilt. `zeiten` geht über `einschaltenZeiten` (Sperre, Nachweis, Ticket dort), das Ergebnis wird auf `{ doc, ticketWoid }` abgebildet.
- [ ] **Step 1: Failing test `test/funktionEinschalten.test.js`** (Schema und Zeiten-Neubau-Stub wie `test/zeitenEinschalten.test.js:11-24, 41-103`, dazu `contractEvents`; `beforeEach` setzt `aufrufe = []`, leert `portalFeatures`, `workorders`, `featureConsents` und legt Wartung für c1 an, Projekte `Projekt p1`, `Projekt p2`, `Projekt p3` für c1 und `p_fremd` für c2 wie `projekt()` in dieser Datei)
```js
const E = (over = {}) => einschalten({ customerId: 'c1', projectId: 'p1', key: 'news', by: 'kunde', abrechnung: 'abo', nachweis, now: JETZT, ...over })
let aufrufe; const nachweis = async () => { aufrufe.push(aw.docs('portalFeatures').get('f_p1_news')?.state ?? 'kein Dokument') }
const spuren = () => ({ docs: aw.docs('portalFeatures').size, tickets: aw.docs('workorders').size })
test('Erfolg im Abo: setup, unlockedBy abo, Marker, genau ein Ticket, Nachweis vor dem Zustand', async () => {
const { doc, ticketWoid } = await E()
assert.deepEqual([doc.state, doc.enabled, doc.unlockedBy, doc.unlockedByPurchase, doc.einrichtungAbrechnung], ['setup', false, 'abo', false, 'abo'])
assert.equal(aufrufe.length, 1); assert.ok(['kein Dokument', 'off'].includes(aufrufe[0]), 'Nachweis lief vor dem Zustand')
const [t] = [...aw.docs('workorders').values()]
assert.deepEqual([aw.docs('workorders').size, t.topic, t.einrichtungFuer, t.einrichtungAbrechnung, t.woid], [1, 'News-Pinnwand einbauen – Projekt p1', 'news', 'abo', ticketWoid])
assert.ok(t.details.endsWith(ABRECHNUNGS_ZEILEN.abo)); assert.equal(doc.ticketId, t.$id)
})
test('Team im Projekt: ohne Abo-Prüfung, unlockedBy projekt, Notiz und Ticketzeile', async () => {
aw.docs('subscriptions').clear()
const { doc } = await E({ by: 'team', abrechnung: 'projekt', staff: { name: 'Andrej' }, nachweis: null })
assert.deepEqual([doc.unlockedBy, doc.einrichtungAbrechnung], ['projekt', 'projekt']); assert.match(doc.adminNote, /Andrej: Eingeschaltet \(projekt\)$/)
assert.ok([...aw.docs('workorders').values()][0].details.endsWith(ABRECHNUNGS_ZEILEN.projekt))
})
test('Konflikte mit klaren Codes, ohne zweites Ticket oder zweiten Nachweis', async () => {
await E(); const s = spuren(); const n = aufrufe.length
await assert.rejects(E(), (e) => e.status === 409 && e.code === 'bereits_eingeschaltet' && e.body.code === 'bereits_eingeschaltet')
aw.docs('portalFeatures').get('f_p1_news').state = 'ordered'; await assert.rejects(E(), (e) => e.code === 'bestellung_laeuft')
aw.docs('portalFeatures').get('f_p1_news').state = 'disabled'; await assert.rejects(E(), (e) => e.code === 'abgeschaltet')
assert.deepEqual([spuren(), aufrufe.length], [s, n])
})
test('Heilung: setup ohne Ticket legt nur das Ticket an; gekauft (kauf) wird nicht geheilt', async () => {
aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', siteKey: 'abcdefghijklmnop', state: 'setup', enabled: false, unlockedBy: 'abo', unlockedByPurchase: false, einrichtungAbrechnung: 'abo' })
const { doc } = await E(); assert.deepEqual([aufrufe.length, aw.docs('workorders').size, doc.state, Boolean(doc.ticketId)], [0, 1, 'setup', true])
await assert.rejects(E(), (e) => e.code === 'bereits_eingeschaltet')
aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', state: 'setup', unlockedBy: 'kauf', unlockedByPurchase: true })
await assert.rejects(E(), (e) => e.code === 'bereits_eingeschaltet')
})
test('Ticket scheitert: 503, setup bleibt, der nächste Aufruf heilt ohne zweites Ticket', async () => {
aw.failNext({ collection: 'workorders', method: 'POST', status: 500 })
await assert.rejects(E(), (e) => e.status === 503 && e.message === EINSCHALTEN_TEXTE.ticket)
assert.deepEqual([aw.docs('portalFeatures').get('f_p1_news').state, aw.docs('workorders').size], ['setup', 0])
await E(); assert.deepEqual([aw.docs('workorders').size, aufrufe.length], [1, 1])
})
test('gleichzeitig: ein Erfolg, ein 409, ein Ticket, ein Nachweis', async () => {
const r = await Promise.allSettled([E(), E()])
assert.deepEqual(r.map((x) => x.status).sort(), ['fulfilled', 'rejected']); assert.equal(r.find((x) => x.status === 'rejected').reason.code, 'bereits_eingeschaltet')
assert.deepEqual([aw.docs('workorders').size, aufrufe.length], [1, 1])
})
test('Abo fehlt unter der Sperre: abo_fehlt mit Grund und wechsel, nichts geschrieben', async () => {
aw.seed('subscriptions', subscriptionDoc('sub_h', hostingDoc)) // Hosting läuft, news ist Wartung
await assert.rejects(E(), (e) => e.status === 409 && e.code === 'abo_fehlt' && e.body.grund === 'falsches_abo' && e.body.wechsel === true && e.message === EINSCHALTEN_TEXTE.abo.falsches_abo)
aw.docs('subscriptions').clear(); await assert.rejects(E(), (e) => e.body.grund === 'kein_abo' && e.body.wechsel === false)
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, status: 'trialing', startChoice: 'nach_frist', trialEnd: '2026-11-03T05:00:00.000Z' })) // Probezeit: Abo beginnt später
await assert.rejects(E(), (e) => e.status === 409 && e.code === 'abo_fehlt' && e.body.grund === 'beginnt_spaeter' && e.body.wechsel === false && e.body.ab === '2026-11-03T05:00:00.000Z' && e.message === 'Dein Abo beginnt am 03.11.2026. Ab dann kannst du einschalten.')
assert.equal(aboFehlerText('beginnt_spaeter', '2026-11-03T05:00:00.000Z'), 'Dein Abo beginnt am 03.11.2026. Ab dann kannst du einschalten.'); assert.equal(aboFehlerText('kein_abo'), EINSCHALTEN_TEXTE.abo.kein_abo)
assert.deepEqual([spuren(), aufrufe.length], [{ docs: 0, tickets: 0 }, 0])
})
test('Öffnungszeiten über den bestehenden Weg: Marker, unlockedBy je Abrechnung, kein Preistext im Abo-Fehler', async () => {
const { doc } = await E({ key: 'zeiten', nachweis: async () => {} }); assert.deepEqual([doc.unlockedBy, doc.einrichtungAbrechnung, doc.state], ['abo', 'abo', 'setup'])
assert.equal(aw.docs('workorders').get(doc.ticketId).einrichtungAbrechnung, 'abo')
aw.docs('subscriptions').clear(); await assert.rejects(E({ key: 'zeiten', projectId: 'p2' }), (e) => e.code === 'abo_fehlt' && !/€/.test(e.message))
const t = await E({ key: 'zeiten', projectId: 'p3', by: 'team', abrechnung: 'projekt', staff: { name: 'Andrej' } }); assert.equal(t.doc.unlockedBy, 'projekt')
})
test('Parameter und Projekt', async () => {
for (const bad of [{ key: 'analyse' }, { key: 'foo' }, { by: 'x' }, { abrechnung: 'frei' }]) await assert.rejects(E(bad), TypeError)
await assert.rejects(E({ projectId: 'p_fremd' }), (e) => e.status === 403)
assert.deepEqual(spuren(), { docs: 0, tickets: 0 })
})
```
- [ ] **Step 2: Rot**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/funktionEinschalten.test.js`
Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `funktionEinschalten.js`.
- [ ] **Step 3: `zeiten/einschalten.js` erweitern** (Standardverhalten unverändert)
`einschaltenZeiten`: neue Parameter `abrechnung = ''` und `vorpruefung = null`; Schritt 1 ruft `vorpruefung` statt `pruefeAboFuerEinschalten`, wenn übergeben; `unlockedBy` = `gekauft ? 'kauf' : abrechnung === 'projekt' ? 'projekt' : abrechnung === 'abo' ? 'abo' : (bisherige Regel)`; `einrichtungAbrechnung` nur mit `abrechnung` mitschreiben. `holeZeitenNach` gibt `frisch.einrichtungAbrechnung` (falls gesetzt) als `abrechnung` an `createFeatureTicket`. Keine Mails ändern.
- [ ] **Step 4: `server/services/funktionEinschalten.js` implementieren**
Nach dem Ablauf oben. `vorpruefung` für `zeiten` ist dieselbe frische `aboDeckt`-Prüfung wie bei den anderen Funktionen (liefert `abo_fehlt`, nie den 30-€-Text). Lesefehler des Abos: 503 `nichtPruefbar` (fail-closed). `ticketWoid` über `ticketWoid(doc.ticketId)`.
- [ ] **Step 5: Grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/funktionEinschalten.test.js test/zeitenEinschalten.test.js test/zeitenActivate.test.js test/zeitenAboAbgleich.test.js test/zeitenTicket.test.js`
Expected: PASS, 0 fail (die vorhandenen Zeiten-Tests unverändert grün).
- [ ] **Step 6: Commit**
```bash
git add server/services/funktionEinschalten.js server/services/zeiten/einschalten.js test/funktionEinschalten.test.js
git commit -m "Kundenansicht P5: generisches Einschalten im Abo (einschalten, Heilung, Ticket-Marker)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 10: Route `POST /api/funktionen/:key/einschalten`
**Files:**
- Modify: `server/routes/funktionen.js` (neue Route nach `/zeiten/einschalten`, Weiche an `/zeiten/einschalten` Z. ~393)
- Test: `test/funktionenEinschaltenRoute.test.js`
**Interfaces:**
- Consumes: `neueAnsicht` (V1, `server/services/kundenansicht/schalter.js`), `einschalten`, `EINSCHALTEN_TEXTE`, `aboFehlerText` (Task 9), `aboProdukt`, `aboDeckt` (Task 4), `kundenartWirksam`, `NUR_BETRIEB_TEXT` (Task 2), `consentAboAktiv`, `consentAbo`, `aboweg` (nimmt das Item `hinweis` in den Nachweis auf), `katalogFelder` (Task 6), `speichereNachweisAusAnfrage` (Task 7), `featureView`, `featureSperre`, `featureHaken`, `pruefeUnterlagen`, `einschaltenDeps.jetzt`, `berlinDay`.
- Produces (V5): `POST /api/funktionen/:key/einschalten`, Body `{ projectId, zustimmung: { version, haken: [] | ['beginn'] } }`, Antwort 200 `{ feature: featureView(doc, key), ticketWoid }`. `neueAnsicht(req)` falsch → 404 `{ error: 'Nicht gefunden' }` (nach `requireSession`, also unangemeldet 401). Reihenfolge der Prüfungen:
1. `req.session.viewAs` → 403 `In der Kundenansicht kann nicht eingeschaltet werden.`
2. Schlüssel nicht im Register (auch `analyse`, `__proto__`) → 404 `Unbekannte Funktion`
3. 400: `projectId` fehlt (`Bitte wähle ein Projekt.`); `zustimmung` fehlt, `version` kein Text, `haken` keine Liste oder nicht Teilmenge von `['beginn']` → `zustimmung_fehlt` (`Bitte bestätige die Erklärung, um einzuschalten.`)
4. `assertOwnProject(forNewOrder)` (403, 409 archiviert)
5. 409 in V5-Reihenfolge: `nicht_verfuegbar` (`featureSperre`; danach 503 `unterlagen_fehlen` wie `pruefeUnterlagen`), `abo_fehlt` (Texte über `aboFehlerText`, `body.grund`, `body.wechsel`, bei Grund `beginnt_spaeter` zusätzlich `body.ab`; das ist der Privatkunde, der das Abo ohne Häkchen bestellt hat und dessen Leistung erst nach der Widerrufsfrist beginnt; Lesefehler 503), `nur_betrieb` (`katalogFelder(key).nurBetrieb` und privat; `NUR_BETRIEB_TEXT`), `bestellung_offen` (Hook `bestellpruefung`, `body.offen`; Text `Es fehlen noch Angaben, bevor du einschalten kannst.`), `beginn_noetig` (privat, `widerrufsfristBis > jetzt`, `haken` ohne `beginn`; `body.ab` = ISO von Fristende + 1 ms; Text `Für den sofortigen Beginn brauchst du das Häkchen. Ohne Häkchen kannst du ab dem <TT.MM.JJJJ> einschalten.`), `bereits_eingeschaltet` (nur `setup|active`, **außer** `setup` ohne Ticket mit `unlockedBy` `abo|projekt`: das heilt der Dienst), `bestellung_laeuft`, `abgeschaltet`, `fassung_veraltet` (`version !== consentAbo(key).version`; `body.consent` = `consentAboAktiv(key, { beginn })`; Text `Die Erklärung wurde inzwischen geändert. Bitte lies sie noch einmal und schalte dann ein.`)
6. `einschalten({ by: 'kunde', abrechnung: 'abo', nachweis: () => speichereNachweisAusAnfrage(req, { feature: aboweg(feature, { beginn: gesetzt }), customerId, projectId, klickAt, kundenart }) })`. `kundenart` kommt nur aus dem Kundendokument. `gesetzt` = privat und `haken` enthält `beginn`; bei Betrieben wird ein mitgeschicktes `beginn` ignoriert und nicht gespeichert. Der Nachweis enthält in Anzeigereihenfolge `einschalten-zeile`, bei `shop`, `unterkunft`, `termine` den `hinweis` (`haken: false`), bei Bedarf `beginn` und den Knopf.
Weiche: `router.post('/zeiten/einschalten', requireSession, alteAnsichtNur, …)` mit `alteAnsichtNur = (req, _res, next) => (neueAnsicht(req) ? next('route') : next())`; in der neuen Ansicht trifft `zeiten` die neue Route.
- [ ] **Step 1: Failing test `test/funktionenEinschaltenRoute.test.js`** (Schema und Zeiten-Stub wie Task 9 plus `shopConfig`; `beforeEach`: Sammlungen leeren, `seedFixtures`, Wartung für c1, Projekte `p1`, `p1b`, `p1c`, `p_archiv` (`archived: true`) für c1 und `p_fremd` für c2, `config.kundenansichtNeu.mode = 'pilot'` mit `pilotEmails = ['k@example.invalid', 'test@example.invalid']`, `config.termine.mode = config.shop.mode = 'on'`, `config.zeiten.mode = 'pilot'` mit `['*']`, `config.features[key].unterlagenBereit = async () => true`, `einschaltenDeps.jetzt = () => JETZT`, `setMailTransportForTests(sink)`; `c1 = customerCookie('c1')`)
```js
const Z = (key, haken = []) => ({ version: consentAbo(key).version, haken }) // consentAbo aus featureRegistry.js
const go = (key, body = {}, cookie = c1) => post(`/api/funktionen/${key}/einschalten`, { projectId: 'p1', zustimmung: Z(key), ...body }, { cookie })
const abschluss = (contractAt = JETZT.toISOString()) => aw.seed('contractEvents', { $id: 'ev_a', customerId: 'c1', subscriptionId: 'sub_h', type: 'abschluss', productKey: 'wartung', status: 'erledigt', consentAt: contractAt, details: JSON.stringify({ contractAt }) })
const nichts = () => assert.deepEqual([aw.docs('portalFeatures').size, aw.docs('workorders').size, aw.docs('featureConsents').size], [0, 0, 0])
test('Erfolg (Betrieb, Wartung): Form, Zustand, Ticket, Nachweis mit Kundenart, keine Mail, kein Preis', async () => {
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' }); const r = await go('news')
assert.deepEqual([r.status, Object.keys(r.body).sort(), r.body.feature.state, r.body.feature.unlockedBy], [200, ['feature', 'ticketWoid'], 'setup', 'abo'])
assert.equal(r.body.ticketWoid, r.body.feature.ticketWoid); assert.equal(aw.docs('workorders').size, 1); assert.deepEqual([sink.sent.length, st.calls.length], [0, 0], 'keine Mail, kein Stripe')
const n = [...aw.docs('featureConsents').values()][0]; assert.deepEqual([JSON.parse(n.texte).map((t) => t.id), n.kundenart, n.stripeSessionId], [['einschalten-zeile', 'knopf'], 'unternehmen', ''])
assert.doesNotMatch(JSON.stringify(r.body), /€/)
})
test('Einschalten nur in neuer Ansicht (Pilot-Ränder)', async () => {
const faelle = [['Kunde nicht in der Liste', 'pilot', ['k2@example.invalid']], ['off schlägt *', 'off', ['*']], ['Pilot mit leerer Liste', 'pilot', []]]
for (const [name, mode, liste] of faelle) { config.kundenansichtNeu.mode = mode; config.kundenansichtNeu.pilotEmails = liste; assert.equal((await go('news')).status, 404, name); nichts() }
config.kundenansichtNeu.mode = 'pilot'; config.kundenansichtNeu.pilotEmails = ['*']; assert.equal((await go('news', { projectId: 'p1b' })).status, 200) // * lässt jeden Kunden durch
config.kundenansichtNeu.pilotEmails = ['test@example.invalid'] // Session-E-Mail mit Großbuchstaben und Leerzeichen trifft die Liste (nicht 404)
const schreibweise = sessionCookie({ customerId: 'c1', appwriteUserId: 'u1', name: 'Kunde Eins', email: ' TEST@Example.INVALID ' })
assert.notEqual((await go('news', { projectId: 'p1c' }, schreibweise)).status, 404)
assert.equal((await go('news', {}, kundenansichtCookie({ customerId: 'c1' }))).status, 403) // Mitarbeiter-Kundenansicht im Pilot: gesperrt, nicht 404
assert.equal((await post('/api/funktionen/news/einschalten', {})).status, 401)
for (const key of ['analyse', 'foo', '__proto__']) assert.equal((await go(key)).status, 404, key)
})
test('Weiche an /zeiten/einschalten: alte Ansicht alter Weg, neue Ansicht neuer Weg', async () => {
config.kundenansichtNeu.mode = 'off'; const alt = await post('/api/funktionen/zeiten/einschalten', { projectId: 'p1', zustimmung: { version: config.features.zeiten.consentFrei.version, haken: ['leistung'] } }, { cookie: c1 })
assert.deepEqual([alt.status, Object.keys(alt.body).sort()], [200, ['einbau', 'feature']]); aw.docs('portalFeatures').clear()
config.kundenansichtNeu.mode = 'pilot'; const neu = await go('zeiten'); assert.deepEqual([neu.status, Object.keys(neu.body).sort()], [200, ['feature', 'ticketWoid']])
})
test('Kundenart (leer, unbekannt, gefälscht) und Frist: beginn_noetig mit ab, Häkchen, Betrieb ohne Häkchen', async () => {
abschluss() // Samstag 17.10. + 14 Tage → Frist bis Mo 02.11., ab = Di 03.11. 00:00 Berlin
for (const kundenart of [undefined, '', 'kaputt', 'Unternehmen']) { aw.seed('customers', { ...customers.c1, ...(kundenart === undefined ? {} : { kundenart }) })
const r = await go('news', { kundenart: 'unternehmen', unternehmer: true }); assert.deepEqual([r.status, r.body.code, r.body.ab], [409, 'beginn_noetig', '2026-11-02T23:00:00.000Z'], String(kundenart)); assert.match(r.body.error, /03\.11\.2026/); nichts() }
assert.equal((await go('news', { zustimmung: Z('news', ['beginn']) })).status, 200)
const p = JSON.parse([...aw.docs('featureConsents').values()][0].texte); assert.deepEqual(p.map((t) => [t.id, t.haken]), [['einschalten-zeile', false], ['beginn', true], ['knopf', false]])
aw.docs('portalFeatures').clear(); aw.docs('workorders').clear(); aw.docs('featureConsents').clear(); aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' })
assert.equal((await go('news', { zustimmung: Z('news', ['beginn']) })).status, 200) // Betrieb: beginn wird ignoriert
assert.deepEqual(JSON.parse([...aw.docs('featureConsents').values()][0].texte).map((t) => t.id), ['einschalten-zeile', 'knopf'])
aw.docs('portalFeatures').clear(); aw.seed('customers', { ...customers.c1 }); abschluss('2026-09-01T09:00:00.000Z') // privat, Frist vorbei: kein Häkchen nötig
assert.equal((await go('news')).status, 200)
})
test('Prüfreihenfolge der 409-Codes', async () => {
config.termine.mode = 'off'; aw.docs('subscriptions').clear(); assert.equal((await go('termine')).body.code, 'nicht_verfuegbar')
config.termine.mode = 'on'; assert.equal((await go('termine')).body.code, 'abo_fehlt') // vor nur_betrieb
aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc)); assert.equal((await go('shop')).body.code, 'nur_betrieb') // privat, vor bestellung_offen
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' }); const o = await go('shop'); assert.deepEqual([o.body.code, o.body.offen], ['bestellung_offen', ['Deine Webshop-Anfrage ist noch nicht freigegeben.']])
await go('news'); assert.equal((await go('news', { zustimmung: { version: 'alt', haken: [] } })).body.code, 'bereits_eingeschaltet') // vor fassung_veraltet
const alt = await go('zeiten', { zustimmung: { version: 'alt', haken: [] } }); assert.deepEqual([alt.status, alt.body.code, alt.body.consent.version], [409, 'fassung_veraltet', config.aboFunktionen.fassung])
})
test('Hinweis (Pflichten der alten Häkchen): der Satz der Funktion steht im Nachweis; news hat keinen', async () => {
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' })
const alt = ['termine', 'shop'].map((k) => config.features[k].bestellpruefung)
for (const k of ['termine', 'shop']) config.features[k].bestellpruefung = async () => [] // Fragebogen und Freigabe sind hier nicht das Thema
try {
for (const key of ['termine', 'shop']) {
const r = await go(key); assert.equal(r.status, 200, `${key} ${JSON.stringify(r.body)}`)
const t = JSON.parse([...aw.docs('featureConsents').values()].at(-1).texte)
assert.deepEqual(t.map((x) => x.id), ['einschalten-zeile', 'hinweis', 'knopf'], key)
assert.equal(t[1].text, consentAbo(key).items[1].text, key); assert.equal(t[1].haken, false)
}
} finally { ['termine', 'shop'].forEach((k, i) => { if (alt[i] === undefined) delete config.features[k].bestellpruefung; else config.features[k].bestellpruefung = alt[i] }) }
assert.match(consentAbo('shop').items[1].text, /eigenem Namen.*eigenes Stripe-Konto/); assert.match(consentAbo('termine').items[1].text, /Gesundheitsbereich/)
aw.docs('featureConsents').clear(); assert.equal((await go('news')).status, 200)
assert.deepEqual(JSON.parse([...aw.docs('featureConsents').values()][0].texte).map((x) => x.id), ['einschalten-zeile', 'knopf'])
})
test('Probezeit (Privatkunde bestellte das Abo ohne Häkchen): abo_fehlt beginnt_spaeter mit Datum statt Zahlungsmeldung', async () => {
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, status: 'trialing', startChoice: 'nach_frist', trialEnd: '2026-11-03T05:00:00.000Z' }))
const r = await go('news')
assert.deepEqual([r.status, r.body.code, r.body.grund, r.body.wechsel, r.body.ab], [409, 'abo_fehlt', 'beginnt_spaeter', false, '2026-11-03T05:00:00.000Z'])
assert.equal(r.body.error, 'Dein Abo beginnt am 03.11.2026. Ab dann kannst du einschalten.'); assert.doesNotMatch(r.body.error, /Zahlung/); nichts()
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, status: 'trialing', startChoice: 'nach_frist', trialEnd: '2026-10-01T05:00:00.000Z' })) // Probezeit vorbei, Stripe noch nicht nachgezogen
assert.equal((await go('news')).body.grund, 'nicht_aktiv')
})
test('Doppelt und widersprüchlich: nacheinander, gleichzeitig, zweite Website, Zustände', async () => {
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' })
assert.deepEqual([(await go('news')).status, (await go('news')).body.code], [200, 'bereits_eingeschaltet']); assert.deepEqual([aw.docs('workorders').size, aw.docs('featureConsents').size], [1, 1])
assert.equal((await go('news', { projectId: 'p1b' })).status, 200); assert.equal(aw.docs('workorders').size, 2) // zweite Website, eigenes Ticket
const gl = await Promise.all([go('news', { projectId: 'p1c' }), go('news', { projectId: 'p1c' })]); assert.deepEqual(gl.map((x) => x.status).sort(), [200, 409]); assert.equal(aw.docs('workorders').size, 3)
aw.docs('portalFeatures').get('f_p1_news').state = 'active'; assert.equal((await go('news')).body.code, 'bereits_eingeschaltet')
aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', siteKey: 'abcdefghijklmnop', state: 'setup', unlockedBy: 'abo', unlockedByPurchase: false })
assert.equal((await go('news')).status, 200) // setup ohne Ticket: Heilung, kein 409
})
test('Abo-Grenzfälle', async () => {
const faelle = [['Hosting + Wartungs-Funktion', hostingDoc, 'falsches_abo', true], ['gekündigt', { ...wartungDoc, cancelAtPeriodEnd: true }, 'gekuendigt', false], ['past_due', { ...wartungDoc, status: 'past_due' }, 'nicht_aktiv', false], ['beendet', { ...wartungDoc, status: 'canceled' }, 'kein_abo', false]]
for (const [name, doc, grund, wechsel] of faelle) { aw.seed('subscriptions', subscriptionDoc('sub_h', doc)); const r = await go('termine'); assert.deepEqual([r.status, r.body.code, r.body.grund, r.body.wechsel], [409, 'abo_fehlt', grund, wechsel], name) }
aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc))
config.features.news.bestellpruefung = async () => { aw.docs('subscriptions').clear(); return [] } // Abo endet nach der Vorprüfung, vor der Sperre
const spaet = await go('news'); assert.deepEqual([spaet.status, spaet.body.code], [409, 'abo_fehlt']); nichts(); delete config.features.news.bestellpruefung
})
test('Eingabe und Archiv', async () => {
for (const [body, status] of [[{ projectId: '' }, 400], [{ zustimmung: undefined }, 400], [{ zustimmung: { version: 'x', haken: 'beginn' } }, 400], [{ zustimmung: Z('news', ['x']) }, 400], [{ projectId: 'p_fremd' }, 403], [{ projectId: 'p_archiv' }, 409]]) assert.equal((await go('news', body)).status, status, JSON.stringify(body))
assert.equal((await go('news', { zustimmung: undefined })).body.code, 'zustimmung_fehlt'); nichts()
})
```
- [ ] **Step 2: Rot**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/funktionenEinschaltenRoute.test.js`
Expected: FAIL (`404` statt `200`, die Route fehlt).
- [ ] **Step 3: Route und Weiche in `server/routes/funktionen.js`**
Wie oben; Uhr `einschaltenDeps.jetzt()`; `fail(res, err, 'Die Funktion konnte gerade nicht eingeschaltet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.')`; `beginn_noetig`-Datum mit `berlinDay(ab)` umgedreht zu `TT.MM.JJJJ`. Das Kundendokument einmal lesen (`getDocument(config.collections.customers, customerId)`, 404 → `{}`).
- [ ] **Step 4: Grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/funktionenEinschaltenRoute.test.js test/funktionenRoutes.test.js test/zeitenEinschalten.test.js test/funktionenCheckout.test.js test/kundenansichtGuard.test.js`
Expected: PASS, 0 fail.
- [ ] **Step 5: Commit**
```bash
git add server/routes/funktionen.js test/funktionenEinschaltenRoute.test.js
git commit -m "Kundenansicht P5: POST /api/funktionen/:key/einschalten (nur neue Ansicht)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 11: Katalog und Anfragestatus in `GET /api/funktionen`
**Files:**
- Create: `server/services/aboKatalog.js`
- Modify: `server/routes/funktionen.js` (`router.get('/')`)
- Test: `test/aboFunktionenListe.test.js`
**Interfaces:**
- Consumes: `katalogReihenfolge`, `katalogFelder`, `consentAbo`, `featureConfig`, `featureSperre` (Registry), `unterlagenFuer` (Task 6), `ENTHALTEN_IN`, `aboProdukt` (Task 4), `kundenartWirksam` (Task 2), `leseShopConfig` (`shop/anfrage.js`), `ladeEinstellungen` (`unterkunft/speicher.js`), `config.analyse`.
- Produces (V5):
```js
export async function katalogFuerKunde({ email }) // → katalog[] in der Reihenfolge von katalogReihenfolge(); analyse nur bei config.analyse.enabled
export async function anfrageStatusJeProjekt({ docs, projects }) // → Map<projectId, { shop: string, unterkunft: string }> ('' ohne Dokument)
// Eintrag: { key, name, beschreibung, bild, enthaltenIn, verfuegbar, ablauf, einrichtung, unterlagen: { leistungsbeschreibung, avv }, nurBetrieb, consent, hinweis }
```
`consent` ist die öffentliche Sicht von `consentAbo(key)` (`null` bei `analyse`), siehe Vertragsfrage 2. `hinweis` (V5-Erweiterung, vom Server geliefert, nie vom Client) ist der Text des Items `hinweis` aus `consent.items` oder `null` (news, zeiten, analyse): die Pflicht aus den alten Häkchen `verantwortung`, `stripe`, `gesundheit`, die die Einschalt-Seite über dem Knopf zeigt. `unterlagen` kommt aus `unterlagenFuer(key)`. Mit neuer Ansicht bekommt die Listenantwort zusätzlich `modell: 'abo'`, `kundenart: kundenartWirksam(kunde)`, `abo: await aboProdukt(customerId, { now })`, `katalog`, und `projects[i].features.shop.anfrageStatus` und `.unterkunft.anfrageStatus`. Ohne neue Ansicht ändert sich nichts (außer `unlockedBy`, Task 8). Der Katalog enthält kein Preisfeld.
- [ ] **Step 1: Failing test `test/aboFunktionenListe.test.js`** (Kopf und `beforeEach` wie Task 10, dazu Projekte `p1` und `p2` für c1, `config.analyse.enabled = false`; `abschluss()` wie dort)
```js
const OLD_KEYS = ['consentText', 'features', 'legal', 'projects', 'setupEstimate', 'shop', 'termine', 'unterkunft', 'ustHinweis', 'zeiten']
test('ohne neue Ansicht: Top-Level-Schlüssel unverändert, keine Anfragestatus', async () => {
config.kundenansichtNeu.mode = 'off'; const b = await get('/api/funktionen', { cookie: c1 })
assert.deepEqual(Object.keys(b).sort(), OLD_KEYS); assert.equal(Object.hasOwn(b.projects[0].features.shop, 'anfrageStatus'), false)
})
test('mit neuer Ansicht: Modell, Kundenart, Abo, Katalog in fester Reihenfolge', async () => {
abschluss() // offener Abschluss vom 17.10.
const b = await get('/api/funktionen', { cookie: c1 })
assert.deepEqual([b.modell, b.kundenart, b.abo], ['abo', 'privat', { produkt: 'wartung', laeuft: true, gekuendigt: false, widerrufsfristBis: '2026-11-02T22:59:59.999Z' }])
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' }) // Kundenart aus dem Dokument, auch in der Mitarbeiter-Kundenansicht
assert.equal((await get('/api/funktionen', { cookie: kundenansichtCookie({ customerId: 'c1' }) })).kundenart, 'unternehmen'); aw.seed('customers', customers.c1)
assert.deepEqual(b.katalog.map((e) => e.key), ['news', 'termine', 'shop', 'unterkunft', 'zeiten']) // analyse nur bei ANALYSE_ENABLED
config.analyse.enabled = true; const mit = await get('/api/funktionen', { cookie: c1 }); assert.equal(mit.katalog.at(-1).key, 'analyse'); assert.equal(mit.katalog.at(-1).enthaltenIn, 'hosting')
const t = b.katalog.find((e) => e.key === 'termine')
assert.deepEqual(Object.keys(t).sort(), ['ablauf', 'beschreibung', 'bild', 'consent', 'einrichtung', 'enthaltenIn', 'hinweis', 'key', 'name', 'nurBetrieb', 'unterlagen', 'verfuegbar'])
assert.deepEqual([t.name, t.enthaltenIn, t.ablauf, t.bild, t.nurBetrieb, t.consent.items.map((i) => i.id)], ['Terminplaner', 'wartung', 'fragebogen', '/neu/bilder/termine.webp', false, ['einschalten-zeile', 'hinweis', 'beginn']])
const hinweise = Object.fromEntries(b.katalog.map((e) => [e.key, e.hinweis])) // Pflichten der alten Häkchen: Server liefert den Satz, aus demselben Item wie der Nachweis
assert.deepEqual(Object.keys(hinweise).filter((k) => hinweise[k] !== null), ['termine', 'shop', 'unterkunft'])
assert.deepEqual([hinweise.shop, hinweise.termine], [b.katalog.find((e) => e.key === 'shop').consent.items[1].text, t.consent.items[1].text]); assert.match(hinweise.shop, /eigenem Namen.*eigenes Stripe-Konto/)
assert.deepEqual([hinweise.news, hinweise.zeiten, mit.katalog.at(-1).hinweis], [null, null, null])
assert.equal(b.katalog.find((e) => e.key === 'shop').nurBetrieb, true); assert.doesNotMatch(JSON.stringify(b.katalog), /€|priceLabel/)
assert.deepEqual(t.unterlagen.leistungsbeschreibung, { version: config.termine.avvVersion, url: config.features.termine.leistungsbeschreibung.url })
assert.equal(mit.katalog.at(-1).unterlagen.avv.url, `/rechtliches/avv-website-analyse-${config.analyse.avvVersion}.html`); config.analyse.enabled = false
})
test('verfuegbar folgt dem Schalter je Funktion; nicht verfügbare stehen trotzdem im Katalog', async () => {
config.termine.mode = 'off'; config.shop.mode = 'pilot'; config.shop.pilotEmails = ['k2@example.invalid']
const v = Object.fromEntries((await get('/api/funktionen', { cookie: c1 })).katalog.map((e) => [e.key, e.verfuegbar])); assert.deepEqual([v.news, v.termine, v.shop], [true, false, false])
})
test('anfrageStatus je Projekt für Shop und Buchungskalender', async () => {
// portalFeatures f_p1_shop + shopConfig f_p1_shop (anfrageStatus 'offen'); f_p1_unterkunft + ukEinstellungen f_p1_unterkunft (anfrageStatus 'angenommen'); Projekt p2 ohne Dokumente
const b = await get('/api/funktionen', { cookie: c1 }); const f = (id) => b.projects.find((p) => p.projectId === id).features
assert.deepEqual([f('p1').shop.anfrageStatus, f('p1').unterkunft.anfrageStatus, f('p2').shop.anfrageStatus, f('p2').unterkunft.anfrageStatus], ['offen', 'angenommen', '', ''])
assert.equal(Object.hasOwn(f('p1').news, 'anfrageStatus'), false)
})
```
- [ ] **Step 2: Rot**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/aboFunktionenListe.test.js`
Expected: FAIL (`undefined !== 'abo'`).
- [ ] **Step 3: `server/services/aboKatalog.js`**
`name`: `featureConfig(key).label.name`, bei `analyse` `config.aboFunktionen.analyse.name`. `unterlagen`: `unterlagenFuer(key)` (Task 6). `hinweis`: Text des Items mit `id === 'hinweis'` oder `null`. `verfuegbar`: `featureSperre(key, email) === null`, bei `analyse` `true`. `anfrageStatusJeProjekt` liest je Projekt mit vorhandenem Funktionsdokument genau eine Konfiguration (`leseShopConfig(doc.$id)?.anfrageStatus`, `ladeEinstellungen(doc.$id)?.anfrageStatus`), sonst `''`; Lesefehler einer Konfiguration ergibt `''` und einen Logeintrag mit Art und Status.
- [ ] **Step 4: `router.get('/')` ergänzen**
Nur wenn `neueAnsicht(req)`: nach dem Aufbau der Antwort die vier Felder und die Anfragestatus anhängen; Kundendokument einmal lesen. Der bestehende `res.json`-Aufbau bleibt zeichengleich.
- [ ] **Step 5: Grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/aboFunktionenListe.test.js test/funktionenRoutes.test.js test/zeitenEinschalten.test.js test/funktionenCheckout.test.js`
Expected: PASS, 0 fail.
- [ ] **Step 6: Commit**
```bash
git add server/services/aboKatalog.js server/routes/funktionen.js test/aboFunktionenListe.test.js
git commit -m "Kundenansicht P5: Katalog, Abo-Stand und Anfragestatus in GET /api/funktionen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 12: Shop-Anfrage im Abo-Modell (Unternehmer aus Kundenart)
**Files:**
- Modify: `server/services/shop/anfrage.js` (`stelleAnfrage` Z. 171), `server/routes/shop.js` (`POST /anfrage` Z. 213)
- Test: `test/aboAnfragen.test.js`
**Interfaces:**
- Consumes: `neueAnsicht` (V1), `kundenartWirksam`, `NUR_BETRIEB_TEXT` (Task 2).
- Produces: `stelleAnfrage({ customerId, projectId, eingabe, person, now, kundenart })` mit optionalem `kundenart`. Ohne Angabe verhält sich alles wie bisher (`unternehmer` Pflicht-`true` vom Client). Mit Angabe: `'privat'` → 409 mit `body.code: 'nur_betrieb'` und `NUR_BETRIEB_TEXT` **nach** Projekt- und Schalterprüfung, **vor** der Eingabe; `'unternehmen'` → `unternehmer` wird serverseitig auf `true` gesetzt (Client-Wert ignoriert), `keineAusgeschlossenen: true` bleibt Pflicht, die gespeicherte Anfrage trägt zusätzlich `kundenart: 'unternehmen'`. Die Route übergibt `kundenart: kundenartWirksam(kundendokument)` nur, wenn `neueAnsicht(req)`. Der Buchungskalender (`unterkunft/anfrageFunktion.js`) bleibt unverändert: seine Eingabe kennt kein `unternehmer`, und `nurBetrieb` ist dort `false`.
- [ ] **Step 1: Failing test `test/aboAnfragen.test.js`** (Router `/api/shop` und `/api/unterkunft`, Schema `shopConfig`, `ukEinstellungen`, `portalFeatures`; `mailSink`; `beforeEach`: Pilot wie Task 10, `config.shop.mode = config.unterkunft.mode = 'on'`, Projekt p1 für c1, Sammlungen leeren)
```js
const anfrage = (extra = {}) => ({ warenart: 'waren', beschreibung: 'Backwaren aus eigener Herstellung', artikelzahl: 'bis20', versand: false, abholung: true, keineAusgeschlossenen: true, ...extra })
const senden = (a, cookie = c1) => post('/api/shop/anfrage', { projectId: 'p1', anfrage: a }, { cookie })
test('privat (Kundenart leer): 409 nur_betrieb, auch mit gefälschtem unternehmer: true; nichts geschrieben, keine Mail', async () => {
const r = await senden(anfrage({ unternehmer: true }))
assert.deepEqual([r.status, r.body.code, r.body.error], [409, 'nur_betrieb', NUR_BETRIEB_TEXT]); assert.deepEqual([aw.docs('shopConfig').size, sink.sent.length], [0, 0])
})
test('Betrieb: unternehmer kommt vom Server, Anfrage speichert die Kundenart', async () => {
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' })
const r = await senden(anfrage()); assert.deepEqual([r.status, r.body.anfrage.status], [200, 'offen'])
assert.deepEqual([r.body.anfrage.anfrage.unternehmer, r.body.anfrage.anfrage.kundenart], [true, 'unternehmen'])
aw.docs('shopConfig').clear(); aw.docs('portalFeatures').clear()
const ohne = await senden(anfrage({ keineAusgeschlossenen: false })); assert.deepEqual([ohne.status, Object.keys(ohne.body.fehler)], [400, ['keineAusgeschlossenen']])
})
test('alte Ansicht unverändert: unternehmer bleibt Pflicht, privat darf anfragen', async () => {
config.kundenansichtNeu.mode = 'off'
const r = await senden(anfrage()); assert.deepEqual([r.status, Object.keys(r.body.fehler)], [400, ['unternehmer']])
assert.equal((await senden(anfrage({ unternehmer: true }))).status, 200)
})
test('Buchungskalender: privat darf in der neuen Ansicht anfragen (nurBetrieb false)', async () => {
const r = await post('/api/unterkunft/anfrage-funktion', { projectId: 'p1', art: 'ferienwohnung', einheiten: 2, portale: ['booking'] }, { cookie: c1 })
assert.equal(r.status, 200); assert.equal(r.body.anfrage.status, 'offen')
})
```
- [ ] **Step 2: Rot**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/aboAnfragen.test.js`
Expected: FAIL (`200 !== 409`, die privat-Anfrage wird angenommen).
- [ ] **Step 3: `shop/anfrage.js` und `routes/shop.js` ändern**
In `stelleAnfrage` nach `pruefeZugang` die Kundenart-Weiche (nur wenn `kundenart !== undefined`), `unternehmer: true` in die Eingabe setzen, nach `normalisiereAnfrage` `kundenart` an das gespeicherte Objekt hängen. Die Route liest das Kundendokument nur bei `neueAnsicht(req)`.
- [ ] **Step 4: Grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/aboAnfragen.test.js test/shopAnfrage.test.js test/unterkunftAnfrageFunktion.test.js test/funktionenShopTextform.test.js`
Expected: PASS, 0 fail.
- [ ] **Step 5: Commit**
```bash
git add server/services/shop/anfrage.js server/routes/shop.js test/aboAnfragen.test.js
git commit -m "Kundenansicht P5: Shop-Anfrage im Abo-Modell (Unternehmer aus Kundenart)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 13: Analyse im Abo-Modell (hosting-fähig, Zielgruppe erzwungen, Nachweis)
**Files:**
- Modify: `server/services/analyse.js` (`analyseEligibility` Z. 57, `activateAnalyse` Z. 281, `runAboCheck`), `server/services/aboFunktionen.js` (`aboModellFuerKunde`), `server/routes/funktionen.js` (`analyseView`, `/analyse/activate`)
- Test: `test/analyseAboModell.test.js` (Aufbau wie `test/analyseRoutes.test.js:9-60`: Fakes für `analyseDeps.client`, `rebuild`, Mail, `config.analyse.enabled = true`; Schema zusätzlich `featureConsents`)
**Interfaces:**
- Consumes: `aboDeckt`, `aboLaeuftFuer` (Task 4), `neueAnsichtFuerKunde({ customerId, email })` (P0, V1-Erweiterung für Jobs ohne Sitzung: `off` → false, `on` → true, `pilot` → `'*'` oder getrimmte, klein geschriebene E-Mail in der Liste; die Mitarbeiter-Kundenansicht gibt es dort nicht), `consentAboAnalyse`, `abowegAnalyse` (Task 6), `analyseUnterlagen` (Task 6), `speichereNachweisAusAnfrage` (Task 7), `kundenartWirksam` (Task 2), `bestellFehler` (`featureOrder.js`).
- Produces:
```js
// aboFunktionen.js
export async function aboModellFuerKunde(customerId) // → boolean: liest das Kundendokument (404 → false) und ruft neueAnsichtFuerKunde({ customerId, email: doc.email }); für Jobs ohne Cookie. NICHT neueAnsichtFuerSession: eine Sitzung braucht dort auch appwriteUserId, die ein Job nicht hat
// analyse.js
analyseEligibility(customerId, projectId, { aboModell = false } = {}) // aboModell: aboDeckt(customerId, 'analyse').ok statt „Wartung läuft“; sonst Grund 'kein_abo'
activateAnalyse({ …, aboModell = false, nachweis = null })
// aboModell: erwartete AVV-Fassung = consentAboAnalyse().avvVersion statt config.analyse.avvVersion;
// nachweis: async () => void, läuft im selben try wie recordConsent, nach dem Dokument und vor recordConsent; ein Fehler nimmt alles zurück (kein „on“ ohne Nachweis)
export const ELIGIBILITY_MESSAGES_ABO = { kein_abo: 'Die Website-Analyse gibt es nur mit einem laufenden Hosting- oder Wartungs-Abo.' }
export const eligibilityMessage = (reason, { aboModell = false } = {})
```
`analyseView(customerId, projectId, { aboModell })` liefert im Abo-Modell `avvVersion` und `legal` aus `analyseUnterlagen()` (die Oberfläche zeigt und sendet damit dieselbe Fassung, die der Server prüft und speichert), sonst wie bisher. `runAboCheck` überspringt eine aktive Analyse, wenn `aboModellFuerKunde(customerId)` und `aboLaeuftFuer(customerId, 'analyse')` gelten (gekündigt zählt bis zum Abo-Ende); ohne neue Ansicht bleibt der Lauf bytegleich (Wartung-Regel, K19).
**Route `POST /analyse/activate`, nur wenn `neueAnsicht(req)`** (alte Ansicht unverändert, ohne Nachweis in `featureConsents`):
1. nach der `viewAs`-Sperre und vor jedem Schreiben: `req.body.audienceConfirmed !== true` → `bestellFehler(400, 'Bitte bestätige, dass sich deine Website nicht gezielt an Kinder unter 16 Jahren richtet.', 'zielgruppe_fehlt')`. Die Aussage ist damit serverseitig erzwungen und nicht mehr nur ein Häkchen im Browser (Spec 4, 5.5, C3).
2. `activateAnalyse({ …, aboModell: true, nachweis })` mit `nachweis = () => speichereNachweisAusAnfrage(req, { feature: abowegAnalyse(), customerId, projectId, klickAt: einschaltenDeps.jetzt().toISOString(), kundenart })`. `kundenart` = `kundenartWirksam(Kundendokument)`, nie aus dem Body. Gespeichert wird in `featureConsents` (`featureKey: 'analyse'`) der Wortlaut in Anzeigereihenfolge: Linkzeile (A1), Zielgruppen-Zeile (A2, `haken: true`), Knopf `Analyse einschalten`; dazu die Fassungen (`zustimmung`, `leistungsbeschreibung`, `avv`) und die Kundenart.
3. Der Nachweis entsteht nur bei einer neuen Aktivierung (Zustand ≠ `active`); wer nur die Farbe einer aktiven Analyse ändert, bekommt keinen zweiten.
- [ ] **Step 1: Failing test `test/analyseAboModell.test.js`**
```js
// Helfer: abo(doc) = aw.seed('subscriptions', subscriptionDoc('sub_h', doc)); aktiveAnalyse(c, p) = aw.seed('portalFeatures', { $id: `f_${p}_analyse`, customerId: c, projectId: p, featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop' })
// consent() = { accepted: true, avvVersion: analyseUnterlagen().avvVersion }; putSiteAufrufe = die Aufrufe des Fakes analyseDeps.client.putSite als { active, … }; beforeEach leert putSiteAufrufe, portalFeatures, featureConsents und stellt den Modus 'pilot' mit c1 in der Liste her
// Importe: analyseUnterlagen (aboUnterlagen.js), aboModellFuerKunde (aboFunktionen.js)
test('Eignung: Hosting zählt nur im Abo-Modell; gekündigt und kein Abo nicht', async () => {
abo(hostingDoc)
assert.deepEqual(await an.analyseEligibility('c1', 'p1'), { ok: false, reason: 'kein_abo' }) // alte Regel
const neu = await an.analyseEligibility('c1', 'p1', { aboModell: true }); assert.deepEqual([neu.ok, neu.project.$id], [true, 'p1'])
abo({ ...hostingDoc, cancelAtPeriodEnd: true }); assert.deepEqual(await an.analyseEligibility('c1', 'p1', { aboModell: true }), { ok: false, reason: 'kein_abo' })
aw.docs('subscriptions').clear(); assert.equal((await an.analyseEligibility('c1', 'p1', { aboModell: true })).reason, 'kein_abo')
assert.equal(an.eligibilityMessage('kein_abo', { aboModell: true }), 'Die Website-Analyse gibt es nur mit einem laufenden Hosting- oder Wartungs-Abo.')
assert.equal(an.eligibilityMessage('kein_abo'), 'Die Website-Analyse gibt es nur mit einem laufenden Wartungs-Abo.')
})
const aktivieren = (extra = {}, cookie = c1) => post('/api/funktionen/analyse/activate', { projectId: 'p1', consent: consent(), audienceConfirmed: true, ...extra }, { cookie })
const keinSchreiben = () => assert.deepEqual([aw.docs('featureConsents').size, putSiteAufrufe.length, aw.docs('portalFeatures').get('f_p1_analyse')?.state ?? 'off'], [0, 0, 'off']) // putSiteAufrufe: Fake von analyseDeps.client.putSite
test('Liste und Aktivieren über die Routen: neue Ansicht mit Hosting ja, alte Ansicht nein', async () => {
abo(hostingDoc)
const b = await get('/api/funktionen', { cookie: c1 }); const a = b.projects.find((p) => p.projectId === 'p1').analyse
assert.deepEqual([a.eligible, a.reason, a.avvVersion, a.legal], [true, null, analyseUnterlagen().avvVersion, analyseUnterlagen().legal]) // Ansicht zeigt die Fassung, die der Server prüft
assert.equal((await aktivieren()).status, 200)
config.kundenansichtNeu.mode = 'off'
const alt = (await get('/api/funktionen', { cookie: c1 })).projects.find((p) => p.projectId === 'p1').analyse; assert.deepEqual([alt.eligible, alt.reasonText], [false, 'Die Website-Analyse gibt es nur mit einem laufenden Wartungs-Abo.'])
})
test('Zielgruppe serverseitig erzwungen: ohne audienceConfirmed true 400 zielgruppe_fehlt, nichts geschrieben', async () => {
abo(hostingDoc)
for (const audienceConfirmed of [undefined, false, 'true', 1, null]) {
const r = await aktivieren({ audienceConfirmed }); assert.deepEqual([r.status, r.body], [400, { error: 'Bitte bestätige, dass sich deine Website nicht gezielt an Kinder unter 16 Jahren richtet.', code: 'zielgruppe_fehlt' }], String(audienceConfirmed)); keinSchreiben()
}
assert.equal((await aktivieren({ consent: { accepted: true, avvVersion: '1999-01-01' } })).status, 400) // falsche Fassung: weiter abgelehnt, nichts geschrieben
keinSchreiben()
})
test('Nachweis der Aktivierung: Linkzeile, Zielgruppen-Zeile, Knopf, Fassungen und die Kundenart des Dokuments', async () => {
abo(hostingDoc)
for (const [kundenart, soll] of [['unternehmen', 'unternehmen'], ['privat', 'privat'], [undefined, 'privat'], ['kaputt', 'privat']]) {
aw.docs('portalFeatures').clear(); aw.docs('featureConsents').clear(); aw.seed('customers', { ...customers.c1, ...(kundenart === undefined ? {} : { kundenart }) })
const r = await aktivieren({ kundenart: 'unternehmen', unternehmer: true }); assert.equal(r.status, 200, String(kundenart)) // Body-Werte zur Kundenart zählen nicht
const [n] = [...aw.docs('featureConsents').values()]; assert.equal(aw.docs('featureConsents').size, 1)
assert.deepEqual([n.featureKey, n.projectId, n.customerId, n.kundenart, n.stripeSessionId], ['analyse', 'p1', 'c1', soll, ''], String(kundenart))
assert.deepEqual(JSON.parse(n.texte), [
{ id: 'einschalten-zeile', text: `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung ${analyseUnterlagen().avvVersion}).`, haken: false },
{ id: 'zielgruppe', text: 'Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.', haken: true },
{ id: 'knopf', text: 'Analyse einschalten', haken: false },
])
assert.deepEqual(JSON.parse(n.fassungen), { zustimmung: config.aboFunktionen.fassung, leistungsbeschreibung: analyseUnterlagen().avvVersion, avv: analyseUnterlagen().avvVersion })
assert.equal(aw.docs('portalFeatures').get('f_p1_analyse').audienceConfirmed, true)
}
assert.equal((await aktivieren({ accentColor: '#222222' })).status, 200); assert.equal(aw.docs('featureConsents').size, 1) // nur die Farbe einer aktiven Analyse: kein zweiter Nachweis
})
test('Nachweis scheitert: alles zurückgenommen (kein on ohne Nachweis), der nächste Versuch klappt', async () => {
abo(hostingDoc); aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500 })
const r = await aktivieren(); assert.equal(r.status, 500)
assert.deepEqual([aw.docs('featureConsents').size, aw.docs('portalFeatures').get('f_p1_analyse').state, putSiteAufrufe.map((a) => a.active)], [0, 'off', [true, false]]) // Messdienst angemeldet und wieder zurückgenommen
assert.equal((await aktivieren()).status, 200); assert.equal(aw.docs('featureConsents').size, 1)
})
test('alte Ansicht unverändert: ohne audienceConfirmed 200, kein Eintrag in featureConsents', async () => {
config.kundenansichtNeu.mode = 'off'; abo({ ...wartungDoc })
assert.equal((await aktivieren({ audienceConfirmed: undefined })).status, 200); assert.equal(aw.docs('featureConsents').size, 0)
})
test('Tagesprüfung: Hosting-Analyse bleibt für Kunden der neuen Ansicht, sonst endet sie wie bisher (K19)', async () => {
const stelle = (mode, pilotEmails = []) => Object.assign(config.kundenansichtNeu, { mode, pilotEmails })
const lauf = async () => { aw.docs('portalFeatures').clear(); aktiveAnalyse('c1', 'p1'); return (await an.runAboCheck({ now: JETZT })).deactivated.length } // Kunde c1: k@example.invalid
abo(hostingDoc)
for (const [mode, liste, deaktiviert] of [['on', [], 0], ['pilot', ['k@example.invalid'], 0], ['pilot', ['*'], 0], ['pilot', ['k2@example.invalid'], 1], ['pilot', [], 1], ['off', ['*'], 1]]) { stelle(mode, liste); assert.equal(await lauf(), deaktiviert, `${mode} ${liste}`) }
aw.seed('customers', { ...customers.c1, email: ' K@Example.Invalid ' }); stelle('pilot', ['k@example.invalid']); assert.equal(await lauf(), 0, 'E-Mail des Kunden mit Großbuchstaben und Leerzeichen trifft die Liste')
aw.seed('customers', customers.c1)
stelle('on'); abo({ ...wartungDoc, cancelAtPeriodEnd: true }); assert.equal(await lauf(), 0, 'gekündigt, aber noch laufend')
abo({ ...wartungDoc, status: 'canceled' }); assert.equal(await lauf(), 1, 'beendet')
assert.equal(await aboModellFuerKunde('gibtEsNicht'), false) // kein Kundendokument: kein Abo-Modell
})
```
- [ ] **Step 2: Rot**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/analyseAboModell.test.js`
Expected: FAIL (`{ ok: false, reason: 'kein_abo' }` statt `ok: true` bei `aboModell: true`).
- [ ] **Step 3: `analyse.js`, `aboFunktionen.js`, Route ändern**
`aboModellFuerKunde` liest das Kundendokument (404 → `false`) und ruft `neueAnsichtFuerKunde({ customerId, email: doc.email })`. `analyseEligibility` und `activateAnalyse` bekommen die Parameter, der Standard bleibt die bisherige strenge Wartungs-Regel und die Fassung `config.analyse.avvVersion`. In `activateAnalyse` ruft `await nachweis?.()` direkt vor `analyseDeps.recordConsent(entry)` im selben `try` (ein Fehler dort löst die vorhandene Rücknahme aus; ein Nachweis ohne Aktivierung ist harmloser als ein „on“-Eintrag ohne Nachweis, deshalb diese Reihenfolge). `audienceTextVersion` und `avvVersion` des Eintrags und des Dokuments sind im Abo-Modell `consentAboAnalyse().avvVersion`. `analyseView` bekommt `{ aboModell }` und liefert dann `avvVersion` und `legal` aus `analyseUnterlagen()`. Die Route prüft `audienceConfirmed` und baut `nachweis` wie oben beschrieben; das Kundendokument wird einmal gelesen (`getDocument(config.collections.customers, customerId)`, 404 → `{}`). In `runAboCheck` vor `deactivateAnalyse`: `if (await aboModellFuerKunde(doc.customerId) && (await aboLaeuftFuer(doc.customerId, 'analyse'))) continue` (im selben `try`, Lesefehler beenden nie).
- [ ] **Step 4: Grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/analyseAboModell.test.js test/analyse.test.js test/analyseRoutes.test.js test/analyseLegal.test.js test/funktionenRoutes.test.js`
Expected: PASS, 0 fail (die vorhandenen Analyse-Tests, auch zur alten Ansicht, unverändert grün).
- [ ] **Step 5: Commit**
```bash
git add server/services/analyse.js server/services/aboFunktionen.js server/routes/funktionen.js test/analyseAboModell.test.js
git commit -m "Kundenansicht P5: Analyse im Abo-Modell (Hosting, Zielgruppe erzwungen, Nachweis, Tagesprüfung)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 14: Selbstprüfung
**Files:** keine Änderungen (Korrekturen nur als eigener Fix-Commit auf den betroffenen Task).
- [ ] **Step 1: Speicher prüfen**
Run: `free -m`
Expected: mindestens 2000 MB verfügbar, sonst warten und den Controller melden.
- [ ] **Step 2: Alle neuen Testdateien grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenansichtSetup.test.js test/kundenart.test.js test/kundenartAdmin.test.js test/aboFunktionen.test.js test/fristen.test.js test/aboKatalogRegister.test.js test/featureConsentsKundenart.test.js test/einrichtungsTicket.test.js test/funktionEinschalten.test.js test/funktionenEinschaltenRoute.test.js test/aboFunktionenListe.test.js test/aboAnfragen.test.js test/analyseAboModell.test.js`
Expected: PASS, 0 fail, 0 skipped.
- [ ] **Step 3: Berührte bestehende Tests grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/featureSetup.test.js test/zeitenSetup.test.js test/support.test.js test/auftraegeSchema.test.js test/staffLogin.test.js test/kundenansichtEnde.test.js test/kundenansichtGuard.test.js test/rechtePrivatArchiv.test.js test/storageTasks.test.js test/aboStatus.test.js test/contractEvents.test.js test/featureRegistry.test.js test/featureConsentTexte.test.js test/legalDocs.test.js test/featureConsents.test.js test/funktionenCheckout.test.js test/featurePaid.test.js test/ticketAdmin.test.js test/featureTicket.test.js test/siteFeatures.test.js test/termineTicket.test.js test/zeitenTicket.test.js test/zeitenEinschalten.test.js test/zeitenActivate.test.js test/zeitenAboAbgleich.test.js test/funktionenRoutes.test.js test/shopAnfrage.test.js test/unterkunftAnfrageFunktion.test.js test/funktionenShopTextform.test.js test/analyse.test.js test/analyseRoutes.test.js`
Expected: PASS, 0 fail.
- [ ] **Step 4: Verträge abhaken**
- [ ] V4: `kundenart.js` (Task 2), `/api/auth/me` und Login in allen drei Zweigen (Task 2), Anlegen Pflicht ab Schalter `pilot` (bei `off` wie heute) und Ändern optional mit `kundenart_fehlt` (Task 3), `featureConsents.kundenart` aus dem Setup-Skript, von `schema.js` gelesen (Task 1, 7)
- [ ] V5: `ENTHALTEN_IN`, `aboProdukt`, `aboDeckt` samt Grund `beginnt_spaeter` mit `ab` (Task 4); Listenfelder `modell`, `kundenart`, `abo`, `katalog` samt `hinweis`, `anfrageStatus` (Task 11); `POST /:key/einschalten` mit allen Codes in der Reihenfolge, 404 ohne neue Ansicht, 403 in der Kundenansicht, 404 für `analyse` (Task 10); `consentAbo` ohne `gesundheit`, mit Item `hinweis` für `shop`, `unterkunft`, `termine`, im Nachweis gespeichert (Task 6, 7, 10); Shop-Unternehmer aus Kundenart und `nur_betrieb` (Task 12); Analyse im Abo-Modell mit `audienceConfirmed` (400 `zielgruppe_fehlt`) und Nachweis in `featureConsents` (Task 13)
- [ ] V6: `sammleFristen` und `GET /api/fristen` (Task 5)
- [ ] V7: `einschalten` mit `unlockedBy`, `unlockedByPurchase: false`, Sperre, `createFeatureTicket(abrechnung)`, Zeiten über den bestehenden Weg (Task 8, 9); Ticket-Attribute im Setup-Skript (Task 1); `featureView.unlockedBy` für alle (Task 8)
- [ ] Review Focus 1, 2, 4 der Übersicht: Tests in Task 2, 3, 10, 12, 13 (1), Task 3, 10, 11, 13 (2), Task 9 und 10 (4)
- [ ] Task 15 (Fassung 6 verdrahten) ist hier **nicht** erledigt: Er läuft erst nach dem Merge von `kn/recht` (siehe Task 15). Die letzte Nachricht nennt ihn als offen.
- [ ] **Step 5: Git sauber, nur eigene Dateien, Fremdes unberührt**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && git status --short && git diff --stat kn-p0..HEAD && git diff --stat kn-p0..HEAD -- scripts/setup-abo-collections.mjs scripts/feature-setup.mjs scripts/zeiten-setup.mjs public build ; ls -l /dev/null`
Expected: `git status` zeigt höchstens `?? node_modules`; `--stat` listet nur Dateien aus der Dateiübersicht; die zweite `--stat`-Abfrage bleibt leer; `/dev/null` zeigt `crw-rw-rw-`.
- [ ] **Step 6: Tag setzen**
```bash
git tag kn-p5
```
---
### Task 15: Fassung 6 verdrahten (Nachlauf, erst nach dem Merge von `kn/recht`)
**Ort und Voraussetzung.** Dieser Task läuft **nicht** in `dev-kn-server`, sondern im Hauptzweig `feature/kundenansicht-neu` (`/home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht`), nachdem `kn/server` (P5, P6) und `kn/recht` (P8) gemerged sind. Der Controller führt ihn als erste Aufgabe von P7 aus („P7 Task 0“). Warum ein eigener Task: P8 liefert nur die Konstanten (`WORTLAUT_V6`, `FUNKTIONEN_UNTERLAGEN_V6` in `products.js`), und P5 verlinkt und speichert bis hierher die Alt-Unterlagen mit Preisen und Kaufbedingungen und die Entwurfstexte. Erst dieser Task macht die Linkzeile „Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung …)“ und den Nachweis in `featureConsents` deckungsgleich mit den Dokumenten, die E6 und 5.9 ablösen. Vorab prüfen: `command grep -a -c FUNKTIONEN_UNTERLAGEN_V6 server/services/products.js` zeigt mindestens 1 und `ls server/services/aboUnterlagen.js` existiert; sonst anhalten und den Controller melden.
**Files:**
- Modify: `server/services/aboUnterlagen.js` (Quelle `FUNKTIONEN_UNTERLAGEN_V6`, neu `pdfAnhaenge`), `server/services/featureRegistry.js` (`consentAbo`, `consentAboAnalyse`), `server/config.js` (`aboFunktionen.texte.hinweis` entfällt), `server/services/analyse.js` (`k18Parts`, `sendK18`, `analyseDeps.legalPdfsV6`)
- Test: `test/aboFassung6Verdrahtung.test.js` (neu; Kopf und Helfer wie `test/funktionenEinschaltenRoute.test.js` und `test/analyseAboModell.test.js`: Schema, Fakes, `go`, `aktivieren`, `abo`, `sink`; dazu die Importe `mkdtemp`, `mkdir`, `writeFile` aus `node:fs/promises`, `join`, `tmpdir`, `pdfAnhaenge`, `analyseDeps`, `reg`; `analyseDeps.legalPdfs` und `.legalPdfsV6` werden in `after()` auf die Originale zurückgesetzt). Angepasst werden nur die Alt-Erwartungen in `test/aboKatalogRegister.test.js` (Linkzeilen mit `config.termine.avvVersion` und `2026-10-04`, `unterlagenFuer`, `analyseUnterlagen`, `consentAboAnalyse`, `aboweg`-Versionen) und `test/aboFunktionenListe.test.js` (`unterlagen`, analyse-URL); die Tests aus Task 10 und 13 lesen die Fassung dynamisch und bleiben unverändert.
**Interfaces:**
- Consumes (P8, `server/services/products.js`):
```js
FUNKTIONEN_FASSUNG_V6 // '2026-10-16'
WORTLAUT_V6 // .linkzeile.{ einschaltenMitAvv, einschaltenOhneAvv } mit {fassung}; .beginn.einrichtung; .hinweis.{ shop, unterkunft, termine }
mitFassung(text, fassung)
FUNKTIONEN_UNTERLAGEN_V6 // { [key]: { lb: Dok, avv: Dok | null } }, Dok = { version, url, pdfFile, anhangName }; Schlüssel news, termine, shop, unterkunft, zeiten, analyse
```
- Produces: dieselben Signaturen wie in Task 6, 11 und 13; nur die Quelle wechselt. Neu:
```js
// aboUnterlagen.js
export async function pdfAnhaenge(dokumente, { basis = <public/> } = {}) // dokumente: [{ pdfFile, anhangName }] → [{ filename: anhangName, content, contentType: 'application/pdf' }]; wirft, wenn eine Datei fehlt, leer ist oder nicht mit %PDF- beginnt
// analyse.js
analyseDeps.legalPdfsV6 // = () => pdfAnhaenge([V6.analyse.avv, V6.analyse.lb]) (Reihenfolge wie legalPdfAttachments('analyse'): avv, lb)
```
- `unterlagenFuer(key)` = `{ leistungsbeschreibung: { version, url }, avv: { version, url } | null }` aus `FUNKTIONEN_UNTERLAGEN_V6[key]`; `analyseUnterlagen()` daraus (`avvVersion` = `avv.version`, `legal.lb`/`legal.avv` = die `url`).
- `consentAbo(key)`: `version` = `FUNKTIONEN_FASSUNG_V6`; Linkzeile `mitFassung(WORTLAUT_V6.linkzeile.einschaltenMitAvv, FUNKTIONEN_FASSUNG_V6)` bei `termine`, `shop`, `unterkunft` und `…einschaltenOhneAvv` bei `news`, `zeiten`; Item `beginn` = `WORTLAUT_V6.beginn.einrichtung`; Item `hinweis` = `WORTLAUT_V6.hinweis[key]`. `consentAboAnalyse()`: Linkzeile `mitFassung(WORTLAUT_V6.linkzeile.einschaltenMitAvv, <analyse avv.version>)`.
- `config.aboFunktionen.fassung` und `texte.beginn` bleiben als Spiegel-Literale (`config.js` importiert keine Dienste; `test/aboWortlautV6.test.js` aus P8 pinnt beide und läuft mit diesem Task grün statt rot).
- K18 (`k18Parts`, `sendK18`): Gilt `doc.avvVersion === FUNKTIONEN_UNTERLAGEN_V6.analyse.avv.version`, kommen `avv_url` und `lb_url` aus den V6-URLs (absolut) und die Anhänge aus `analyseDeps.legalPdfsV6()`; sonst wie bisher `legalPaths` und `analyseDeps.legalPdfs(version)`. Ohne diese Weiche fände `legalPaths('analyse', '2026-10-16')` die Dateien nicht (sie liegen unter `/neu/rechtliches/`), und die K18 ginge für jede neue Aktivierung nie hinaus.
- [ ] **Step 1: Failing test `test/aboFassung6Verdrahtung.test.js`**
```js
const { WORTLAUT_V6, FUNKTIONEN_FASSUNG_V6: F6, FUNKTIONEN_UNTERLAGEN_V6: U6, mitFassung } = await import('../server/services/products.js')
const MIT_AVV = ['termine', 'shop', 'unterkunft']
test('Katalog: unterlagen zeigen auf die Dokumente der Fassung 6, nie auf Alt-Dokumente', async () => {
config.analyse.enabled = true; const b = await get('/api/funktionen', { cookie: c1 })
assert.deepEqual(b.katalog.map((e) => e.key), Object.keys(U6))
for (const e of b.katalog) {
const u = U6[e.key]
assert.deepEqual(e.unterlagen, { leistungsbeschreibung: { version: u.lb.version, url: u.lb.url }, avv: u.avv ? { version: u.avv.version, url: u.avv.url } : null }, e.key)
assert.match(e.unterlagen.leistungsbeschreibung.url, /^\/neu\/rechtliches\/[a-z-]+-2026-10-16\.html$/, e.key)
}
assert.doesNotMatch(JSON.stringify(b.katalog), /"\/(rechtliches|vertragsunterlagen)\//, 'kein Alt-Dokument mit Preisen'); config.analyse.enabled = false
})
test('consentAbo: Version, Linkzeile, beginn und Hinweis wörtlich aus P8; Spiegel in config', () => {
for (const key of ['news', ...MIT_AVV, 'zeiten']) {
const c = reg.consentAbo(key)
assert.equal(c.version, F6, key)
assert.equal(c.items[0].text, mitFassung(MIT_AVV.includes(key) ? WORTLAUT_V6.linkzeile.einschaltenMitAvv : WORTLAUT_V6.linkzeile.einschaltenOhneAvv, F6), key)
assert.equal(c.items.at(-1).text, WORTLAUT_V6.beginn.einrichtung, key)
assert.equal(c.items.find((i) => i.id === 'hinweis')?.text, WORTLAUT_V6.hinweis[key], key) // bei news und zeiten beide undefined
}
assert.deepEqual([config.aboFunktionen.fassung, config.aboFunktionen.texte.beginn], [F6, WORTLAUT_V6.beginn.einrichtung])
assert.equal(reg.aboweg(reg.featureConfig('shop')).avv.url, U6.shop.avv.url)
})
test('Einschalten: der Nachweis trägt Wortlaut und Fassungen der Fassung 6, auch den Hinweis', async () => {
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' })
const alt = config.features.termine.bestellpruefung; config.features.termine.bestellpruefung = async () => []
try {
assert.equal((await go('news')).status, 200); assert.equal((await go('termine')).status, 200)
} finally { if (alt === undefined) delete config.features.termine.bestellpruefung; else config.features.termine.bestellpruefung = alt }
const [n, t] = [...aw.docs('featureConsents').values()]
assert.equal(JSON.parse(n.texte)[0].text, mitFassung(WORTLAUT_V6.linkzeile.einschaltenOhneAvv, F6))
assert.deepEqual(JSON.parse(n.fassungen), { zustimmung: F6, leistungsbeschreibung: F6 })
assert.deepEqual(JSON.parse(t.texte).map((x) => x.text).slice(0, 2), [mitFassung(WORTLAUT_V6.linkzeile.einschaltenMitAvv, F6), WORTLAUT_V6.hinweis.termine])
assert.deepEqual(JSON.parse(t.fassungen), { zustimmung: F6, leistungsbeschreibung: F6, avv: F6 })
})
test('Analyse im Abo-Modell: Ansicht, Prüfung und Nachweis mit der Fassung 6', async () => {
abo(hostingDoc); const V = U6.analyse
const a = (await get('/api/funktionen', { cookie: c1 })).projects.find((p) => p.projectId === 'p1').analyse
assert.deepEqual([a.avvVersion, a.legal], [V.avv.version, { lb: V.lb.url, avv: V.avv.url }])
assert.equal((await aktivieren({ consent: { accepted: true, avvVersion: config.analyse.avvVersion } })).status, 400) // die alte Fassung wird im Abo-Modell nicht mehr angenommen
assert.equal(aw.docs('featureConsents').size, 0)
assert.equal((await aktivieren({ consent: { accepted: true, avvVersion: V.avv.version } })).status, 200)
const n = [...aw.docs('featureConsents').values()][0]
assert.equal(JSON.parse(n.texte)[0].text, mitFassung(WORTLAUT_V6.linkzeile.einschaltenMitAvv, V.avv.version))
assert.deepEqual(JSON.parse(n.fassungen), { zustimmung: F6, leistungsbeschreibung: V.lb.version, avv: V.avv.version })
assert.equal(aw.docs('portalFeatures').get('f_p1_analyse').avvVersion, V.avv.version)
})
test('K18 zu einer Analyse der Fassung 6 verlinkt und hängt die Dokumente der Fassung 6 an; alte Fassungen gehen den alten Weg', async () => {
const V = U6.analyse; const gefragt = []; const pdf = (filename) => ({ filename, content: Buffer.from('%PDF-1.4'), contentType: 'application/pdf' })
analyseDeps.legalPdfsV6 = async () => { gefragt.push('v6'); return [pdf(V.avv.anhangName), pdf(V.lb.anhangName)] }
analyseDeps.legalPdfs = async (v) => { gefragt.push(`alt ${v}`); return [pdf('alt.pdf')] }
abo(hostingDoc); assert.equal((await aktivieren({ consent: { accepted: true, avvVersion: V.avv.version } })).status, 200)
const roh = JSON.stringify(sink.sent.at(-1))
for (const erwartet of [V.avv.url, V.lb.url, V.avv.anhangName, V.lb.anhangName, V.avv.version]) assert.ok(roh.includes(erwartet), erwartet)
assert.deepEqual(gefragt, ['v6'])
aw.docs('portalFeatures').clear(); config.kundenansichtNeu.mode = 'off'; abo(wartungDoc); gefragt.length = 0 // alte Ansicht: alte Fassung, alter Weg
assert.equal((await aktivieren({ consent: { accepted: true, avvVersion: config.analyse.avvVersion } })).status, 200); assert.deepEqual(gefragt, [`alt ${config.analyse.avvVersion}`])
})
test('pdfAnhaenge: liest die PDFs; fehlende, leere und Nicht-PDF-Dateien werfen', async () => {
const dir = await mkdtemp(join(process.env.TMPDIR ?? tmpdir(), 'kn-pdf-')) // im eigenen Scratch-Ordner, wird nicht gelöscht
await mkdir(join(dir, 'neu/rechtliches'), { recursive: true })
const dok = (name) => ({ pdfFile: `neu/rechtliches/${name}.pdf`, anhangName: `${name}-2026-10-16.pdf` })
await writeFile(join(dir, dok('ok').pdfFile), '%PDF-1.4 x'); await writeFile(join(dir, dok('leer').pdfFile), ''); await writeFile(join(dir, dok('text').pdfFile), 'hallo')
assert.deepEqual((await pdfAnhaenge([dok('ok')], { basis: dir })).map((x) => [x.filename, x.contentType]), [['ok-2026-10-16.pdf', 'application/pdf']])
for (const name of ['fehlt', 'leer', 'text']) await assert.rejects(() => pdfAnhaenge([dok(name)], { basis: dir }), name)
})
```
- [ ] **Step 2: Rot**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/aboFassung6Verdrahtung.test.js`
Expected: FAIL (Alt-Dokumente unter `/rechtliches/`, `pdfAnhaenge is not a function`).
- [ ] **Step 3: Umsetzen**
`aboUnterlagen.js`: `unterlagenFuer` und `analyseUnterlagen` aus `FUNKTIONEN_UNTERLAGEN_V6`, `pdfAnhaenge` nach dem Muster von `legalPdfAttachments` (`legalDocs.js`: `readFile`, Prüfung auf `%PDF-`, nie ein leerer oder falscher Anhang). `featureRegistry.js`: `consentAbo` und `consentAboAnalyse` lesen `FUNKTIONEN_FASSUNG_V6`, `WORTLAUT_V6` und `mitFassung` (statischer Import aus `products.js`, ein Blattmodul), nicht mehr `config.aboFunktionen.texte`; in `config.js` entfällt `texte.hinweis`. `analyse.js`: die Weiche in `k18Parts` und `sendK18` nach „Produces“, `analyseDeps.legalPdfsV6` als Standardabhängigkeit. Die Alt-Erwartungen in den beiden genannten Testdateien auf die Werte der Fassung 6 umstellen.
- [ ] **Step 4: Grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/aboFassung6Verdrahtung.test.js test/aboWortlautV6.test.js test/aboKatalogRegister.test.js test/aboFunktionenListe.test.js test/funktionenEinschaltenRoute.test.js test/analyseAboModell.test.js test/analyse.test.js test/analyseRoutes.test.js test/legalDocs.test.js`
Expected: PASS, 0 fail; der Verdrahtungstest in `aboWortlautV6.test.js` läuft und ist grün (nicht übersprungen).
- [ ] **Step 5: Commit und Tag**
```bash
git add server/services/aboUnterlagen.js server/services/featureRegistry.js server/config.js server/services/analyse.js test/aboFassung6Verdrahtung.test.js test/aboKatalogRegister.test.js test/aboFunktionenListe.test.js
git commit -m "Kundenansicht P5: Fassung 6 verdrahtet (Katalog, Einschalt-Nachweis, Analyse, K18)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
git status --short; ls -l /dev/null; git tag kn-p5-v6
```
Expected: `git status --short` leer (außer `?? node_modules`), `/dev/null` zeigt `crw-rw-rw-`.
---
## Vertragsfragen an den Controller
1. **Kundenart-Pflicht und Push-Reihenfolge (V4), entschieden und gebaut.** `createCustomerWithPortalAccess` verlangt die Kundenart nur bei `neueAnsichtModus() !== 'off'` (Task 3). Damit ändert sich bei `off` nichts, auch wenn das Portal vor dem WOMS gepusht wird (Global Constraint „Alte Ansicht unverändert“, Phase E „Schalter off = für Kunden unsichtbar“). Die Push-Reihenfolge gilt trotzdem verbindlich und steht als Eintrag für die Übersicht in Frage 10: WOMS-Tag `kn-p9-kundenart` vor dem Portal und in jedem Fall vor dem Wechsel auf `pilot`; `kn-p9` zusammen mit P6. P9 Vertragsfrage 3 ist damit geschlossen.
2. **`katalog[].consent` und `katalog[].hinweis` fehlen in V5.** Die Oberfläche braucht Fassung, Linkzeile, Hinweis, Häkchen-Wortlaut und Knopftext, die der Server später als Nachweis speichert (nie Texte aus dem Client). Gebaut: jeder Katalogeintrag trägt `consent: { version, knopf: 'Einschalten', items: [{ id, text, haken, nurPrivatInFrist? }] }` (`null` bei `analyse`) in Anzeigereihenfolge `einschalten-zeile`, `hinweis`, `beginn`, dazu `hinweis: string | null`, der Text des Items `hinweis`. Das Item gibt es nur bei `shop`, `unterkunft` und `termine` und trägt die Pflichten der alten Häkchen `verantwortung`, `stripe` und `gesundheit` (Spec 5.6 Zeile F, „dazu ein Satz auf der Einschalt-Seite“); es steht in `featureConsents.texte` (Task 7, 10). Der Wortlaut ist zunächst der Entwurf `config.aboFunktionen.texte.hinweis`, wörtlich wie `WORTLAUT_V6.hinweis` aus P8; Task 15 stellt auf die Konstante um. Folge für P3: `renderEinschalten` (P3 Task 3) zeichnet `katalog[].hinweis` als `p.kn-fn-hinweis` direkt über dem Knopf, nach der Linkzeile und vor dem Häkchen `beginn`, damit Bildschirm und Nachweis dieselbe Reihenfolge haben (Test dort: Webshop-Einschalt-Seite zeigt den Händler-Satz).
3. **Zusätzliche Antwortcodes der Einschalt-Route.** Außer den sieben Codes aus V5 kommen vor: `bestellung_laeuft` (Zustand `ordered`), `abgeschaltet` (Zustand `disabled`), 400 `zustimmung_fehlt`, 503 `unterlagen_fehlen` (wie die Bestellseiten) und 503 bei nicht lesbarem Abo. `abo_fehlt` bekommt den Grund `beginnt_spaeter` mit `body.ab` (Frage 9). Die Analyse-Aktivierung im Abo-Modell bekommt 400 `zielgruppe_fehlt` (Task 13). Vorschlag: alles in V5 aufnehmen.
4. **Fremde Dateien mit minimalen Änderungen.** `server/index.js` (1 Import, 1 Mount), `server/routes/admin/customers.js` (`code` durchreichen), `server/services/ticketAdmin.js` (V7: `abrechnung`), `server/services/siteFeatures.js` (nur `featureView` und `legeTicketAn`; `adminActivate` bleibt P6), `server/services/zeiten/einschalten.js` (Parameter `abrechnung`, `vorpruefung`; Standard unverändert), `server/services/analyse.js` (Hosting im Abo-Modell, `nachweis`-Rückruf, K18-Weiche für die Fassung 6). Vorschlag: P5 bekommt diese Stellen zugewiesen; P6 vermeidet die genannten Funktionen.
5. **Zusatzattribut `portalFeatures.einrichtungAbrechnung`** (intern, im Setup-Skript). Es hält die Abrechnung am Funktionsdokument, damit jedes Nachlegen oder Ersetzen eines Tickets (`ticketNachlegen`, `holeZeitenNach`) den Marker setzt. P6 nutzt es, um (a) K27 bei Zeiten im neuen Weg zu unterdrücken, sobald K38 sendet, (b) `ticketNachlegen` für Dokumente mit `unlockedBy` `abo|projekt` ohne Stripe-Sitzung auf `einschalten` (Heilung) statt `ticketNachZahlung` zu lenken, (c) die alten „nicht anrechnen“-Zeilen in `termine/ticketDetails.js:38`, `shop/ticketDetails.js:57`, `unterkunft/ticketDetails.js:49` bei gesetztem Marker wegzulassen (P5 hängt nur die neue Schlusszeile an).
6. **(gestrichen)** Die frühere Annahme zu `neueAnsichtFuerSession` für Jobs entfällt: Eine Sitzung gilt nach V1 nur mit `appwriteUserId`, ein Job hat keine. Task 13 liest stattdessen das Kundendokument und ruft `neueAnsichtFuerKunde({ customerId, email })` aus P0 (Vorbereitung prüft, dass sie existiert).
7. **Verdrahtung der Fassung 6 steht in Task 15** (Ausführung als „P7 Task 0“ im Hauptzweig nach dem Merge von `kn/recht`). Die frühere Annahme, P8 ändere `config.aboFunktionen.texte.beginn` und `.fassung`, entfällt: P8 darf laut Zuständigkeitstabelle nur `abo.termsVersionNeu` ändern und hat `config.aboFunktionen` in `kn/recht` gar nicht. P8 liefert die Konstanten, P5 (Task 15) liest sie. Bis dahin gelten Entwurfstexte (`beginn`, `hinweis`) und die Alt-Unterlagen des Registers; Livegang nur mit Task 15, sonst verlinken und speichern Katalog, Nachweis und Analyse die Alt-Dokumente mit Preisen.
8. **(erledigt in Task 13)** Analyse-Zielgruppenzeile (Spec 5.5, letzter Punkt): In der neuen Ansicht verlangt `POST /api/funktionen/analyse/activate` `audienceConfirmed: true` (400 `zielgruppe_fehlt`) und schreibt einen Nachweis in `featureConsents` (Linkzeile A1, Zielgruppen-Zeile A2, Knopf `Analyse einschalten`, Kundenart, Fassungen). P4 Vertragsfrage 4 ist damit erledigt; P4 sendet `audienceConfirmed: true` bereits. Für P6 (C5, Widerruf eines Wechsels) gilt: Die Analyse eines Hosting-Kunden bleibt; `aboLaeuftFuer(customerId, 'analyse')` aus Task 4 ist die Abfrage.
9. **Privatkunde in der Probezeit („Beginn nach Fristende“, Stripe `trialing`).** Spec 5.5 Schritt 5 und C4 wollen für Privatkunden in der Frist das Häkchen oder, ohne Häkchen, einen Knopf mit dem Fristende-Datum. Wer das Abo ohne Häkchen bestellt hat, steht aber auf `trialing` (`stripeBilling.js:575-629`), und `aboDeckt` verlangt `active`. Gebaut (Vorschlag A): eigener Grund `beginnt_spaeter` mit `ab` (= `trialEnd`) und Text „Dein Abo beginnt am TT.MM.JJJJ. Ab dann kannst du einschalten.“ statt der Zahlungsmeldung `nicht_aktiv` (Task 4, 9, 10). Alternative B: `trialing` zählt als deckend, und das Häkchen-Verlangen läuft über `beginn_noetig`; das schaltet eine Funktion ein, bevor die Leistung des Abos beginnt, und ist eine Vertragsfrage, die der Controller entscheidet. Folgen von A: P3 `fehlerAktion` und `TEXTE` zeigen den Text und das Datum im Knopf; P6 (`adminActivate`) und P9 (WOMS zeigt den Grund) kennen den neuen Grund; die Analyse-Eignung meldet in der Probezeit weiter `kein_abo` (eigener Grund erst bei Entscheidung für A auch dort).
10. **Einträge für die Übersicht (der Controller trägt sie ein; dieser Plan darf die Übersicht nicht ändern).**
- *Zuständigkeitstabelle, Zeile P5:* zusätzlich `server/services/aboUnterlagen.js` (neu), `server/services/featureRegistry.js` (auch `consentAbo` mit Hinweis, `consentAboAnalyse`, `abowegAnalyse`) und die Verdrahtung der Fassung 6 (Task 15: Katalog-`unterlagen`, `consentAbo`, Analyse-Katalog und K18 lesen `FUNKTIONEN_UNTERLAGEN_V6` und `WORTLAUT_V6`). Zeile P8: liefert die Konstanten in `products.js`, verdrahtet nicht. Ablaufhinweise: Task 15 läuft als „P7 Task 0“.
- *Ablaufhinweise, Push-Reihenfolge verbindlich:* WOMS-Tag `kn-p9-kundenart` vor dem Portal (spätestens vor dem Wechsel auf `pilot`), `kn-p9` zusammen mit P6. Schließt P9 Vertragsfrage 3 und Frage 1 hier.
- *V4:* `createCustomerWithPortalAccess` verlangt die Kundenart nur bei `neueAnsichtModus() !== 'off'`.
- *V1:* `neueAnsichtFuerKunde({ customerId, email })` für Jobs ohne Sitzung (P0).
- *V5:* `katalog[].hinweis` (Server liefert, nie der Client); Item `hinweis` in `consentAbo`; `aboDeckt` mit Grund `beginnt_spaeter` und `ab`, `abo_fehlt` mit `body.ab`; `POST /analyse/activate` in der neuen Ansicht mit `audienceConfirmed` (400 `zielgruppe_fehlt`) und Nachweis; Analyse-`legal` und `avvVersion` kommen im Abo-Modell aus der Fassung-6-Tabelle.
11. **Livegang-Voraussetzung für K18.** Die Analyse-K18 der Fassung 6 hängt PDFs aus `public/neu/rechtliches/` an (P8 Task 12, erst nach der Freigabe). Fehlt eine Datei, geht die K18 nicht hinaus und wird vom Tagesabgleich erneut versucht, ohne Fehlermeldung für den Kunden. Bitte in die Übergabe-Notiz: PDFs liegen vor dem Wechsel auf `pilot` im Deploy.