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

147 KiB
Raw Blame History

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

    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

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

    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

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

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

    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)

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

    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)

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

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

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

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

    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)

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

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

    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)

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

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

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

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

    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:

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

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