# Kundenansicht neu, Paket P6: Abo-Modell Betrieb (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 betreibt das Abo-Modell der Funktionen: Einrichtungen laufen als Vorschuss aufs Stundenkonto, das Team aktiviert mit Abo-Prüfung und Abrechnung, ein Wechsel ins Wartungs-Abo merkt sich die gewünschte Funktion, und ein täglicher Abgleich beendet Funktionen mit dem Abo (mit Mails, Fristen und Rückkehr). **Architecture:** Ein `portalFeatures`-Dokument gehört zum Abo-Modell, wenn `unlockedBy` und `einrichtungAbrechnung` beide `abo` oder `projekt` sind (`aboModell.js`, Marker aus P5). Nur solche Dokumente fasst der neue Abgleich (`aboFunktionenAbgleich.js`) an. Die Zeiten behalten ihr bestehendes Modul (30 Tage Frist, Neubau, Ersatz-Ticket) und nehmen Abo-Modell-Dokumente dort mit auf. Neue Mails sind eigene Vorlagen (K38–K43, T24; K42 und K43 sind die Zusage-Mails der Webshop- und der Buchungskalender-Anfrage ohne Preis); alte Vorlagen und Builder bleiben bytegleich, K02 und K03 bekommen einen zusätzlichen Block. Die Team-Aktivierung „im Projekt“ (E9) warnt nur, solange der Kunde noch nie ein Abo hatte (Aufbauphase, `aboAufbauphase.js`); ab seinem ersten Abo gilt für alle Abo-Modell-Dokumente dieselbe Abo-Regel. Alles ist hinter dem Marker oder `neueAnsicht`/`aboModellFuerKunde` verborgen oder rein additiv. **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` (E7, E8, E9, E14, E17, E21, 5.5, C5, C6, C7, C12) **Ü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 V8, V10, V11, V13 komplett) **Worktree und Branch:** `/home/webklar/apps/kundenbereich.webklar.com/dev-kn-server`, Branch `kn/server` (läuft nach P5 im selben Worktree, Start ist Tag `kn-p5`). Am Ende Tag `kn-p6`. Alle Pfade unten sind relativ zu diesem Worktree. ## Global Constraints Es gelten **alle** Global Constraints der Übersicht wörtlich. Paketeigene Zusätze: - **Vorbereitung (einmalig, kein Commit):** `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server log -1 --format=%D` nennt `kn-p5` und `kn/server`; `node_modules` existiert (Symlink, nie `git add -A`); `free -m` zeigt mindestens 2000 MB verfügbar. - **Testkopf, Uhr, Pilot, Fehlerform:** wie in P5 (Global Constraints: Testkopf, Uhr, Fehlerform). Tests, die den Modus wechseln, setzen `config.kundenansichtNeu.mode` und `.pilotEmails` direkt (c1 = `k@example.invalid` Pilot, c2 nicht) und stellen sie in `after()` zurück. - **Abo-Modell-Dokument:** `istAboModell(doc)` (`aboModell.js`, Task 5) verlangt `unlockedBy` **und** `einrichtungAbrechnung` aus `['abo','projekt']`. Alte Dokumente (`kauf`, `team`, `abo` ohne Marker, wie die Gratis-Zeiten der alten Ansicht) berührt keine neue Logik. - **Alte Ansicht bytegleich:** Alte Mailbuilder und Vorlagen K22, K24, K29, K30, K34, T16 bleiben unverändert (Pins `test/zeitenMails.test.js`, `test/mailTemplates.test.js:136`, die K24-Tests ab `:1047` und die K34-Liste `:1343` laufen grün; die Entscheidungs-Mails in `shop/anfrage.js` und `unterkunft/anfrageFunktion.js` senden für Kunden ohne neue Ansicht Zeichen für Zeichen wie heute). Ticket-Texte des Kauf-Bestands bleiben wörtlich. Der Terminplaner-Abgleich und die Zeiten behandeln Dokumente ohne Marker wie bisher. - **Mails:** Neue Texte ohne `€`, neue K-Vorlagen in du-Form im Layout von K16 (Kopie von `server/mail-templates/K16 Funktion bereit.html` als Gerüst; K42 und K43 sind Kopien von K24 und K34, gleiches Layout), T24 im Layout von T19. Ein Kunde der neuen Ansicht bekommt für Funktionen des Abo-Modells nirgends eine Mail mit Preis oder mit dem Wort „bestellen“ (Zusage-Mails: K42, K43). Der Zusatz in K02 und K03 steht in Sie-Form wie die Vorlage. Das Log nennt weder Namen noch Adressen. - **Team-Aktivierung „im Projekt“ (E9, E21; hier festgeschrieben, Spec 5.5 lässt „prüft … oder warnt zumindest“ zu):** `abrechnung: 'abo'` braucht ein deckendes Abo, sonst 409 `abo_fehlt`. `abrechnung: 'projekt'` braucht es auch, **außer in der Aufbauphase**: Hat der Kunde noch nie ein Abo (kein Abo-Dokument außer abgebrochenen Bestellungen, `ohneAboBisher`, Task 7), weil die Website beim Projektstart noch im Bau ist, wird die Funktion eingebaut, und die Antwort ist 200 mit `warnung: 'abo_fehlt'` (WOMS zeigt sie, P9). Der Tagesabgleich (Task 8, Zeiten Task 10) fasst `projekt`-Dokumente in der Aufbauphase nicht an. Ab dem ersten Abo gilt die Abo-Regel für `projekt` wie für `abo`: deckt es die Funktion nicht (Hosting, beendet), endet sie mit K39. Hat der Kunde schon ein nicht deckendes Abo, bleibt es beim 409 auch im Projekt, weil der Abgleich die Funktion sonst in der Nacht abschaltete. Ablauf für das Team: Wartungs-Abo vor dem Livegang vereinbaren; die Warnung sagt das. - **Fremde Dateien nur minimal** (Dateiübersicht, Vertragsfragen 3). P5-Dateien ändert P6 nur an den dort genannten Stellen. - **Abgrenzung:** Die Analyse im Hosting-Abo (`runAboCheck`, `analyseEligibility`) ist P5 Task 13. P6 ergänzt dort nur den Widerruf des Wechsels (Task 13). Kein Rechtstext (P8), keine Oberfläche, kein WOMS-Code (P9). - **Commit:** `git add ` und `git commit -m "" -m "Co-Authored-By: Claude Opus 5.5 "`. ## Review Focus Die fünf wahrscheinlichsten Fehlerfälle, häufigster zuerst. Jede Zeile nennt den Task, dessen Tests sie festnageln. 1. **Doppelte Zuständigkeit alt/neu im Abo-Ende:** dasselbe Dokument wird von zwei Abgleichen angefasst (Abo-Modell-Terminplaner im alten Terminplaner-Abgleich, Abo-Modell-Zeiten mit K29/K30 und 30-€-Behalten, Gratis-Zeiten ohne Marker im neuen Abgleich). Erwartung: genau ein Abgleich und eine Mail je Dokument, bei Abo-Modell-Dokumenten nirgends ein Preis oder ein Behalten-Angebot, auch nicht in den Zusage-Mails K24 und K34 (dort kommen im Abo-Modell K42 und K43: „kannst du einschalten“, ohne `€` und ohne „bestellen“), der Bestand bytegleich. Tests: Task 8, 10, 15. 2. **Abo-Ende-Randfälle:** gekündigt oder `past_due` (läuft noch: nichts abschalten), Hosting statt Wartung, Abo nicht lesbar (nichts abschalten, nichts löschen), Rückkehr nach gelöschten Daten, Kunde ohne E-Mail, Mailfehler (nächster Lauf sendet nach, die Frist wächst nie), Aufbauphase (eine im Projekt eingebaute Funktion bleibt, solange der Kunde noch nie ein Abo hatte; mit dem ersten nicht deckenden Abo endet sie). Tests: Task 8, 9, 10. 3. **Vorschuss statt Mehraufwand** (Übersicht-Review-Focus 5): Einrichtungsminuten ohne Deckung nie als Mehraufwand, normale Arbeit bleibt Mehraufwand, eine spätere Gutschrift gleicht zuerst den Vorschuss aus, Git-Blätter zählen mit, nach Vertragsende bleibt der Vorschuss stehen. Tests: Task 1, 2. 4. **Wechsel mit gemerkter Funktion** (Übersicht-Review-Focus 4): eine ungültige Funktion blockiert nie den bezahlten Wechsel; sofort bezahlter Wechsel ohne `doneAt`; 14-Tage-Fenster; `gescheitert` und `widerrufen` ergeben `null`; der Widerruf beendet alle Wartungs-Funktionen des Abo-Modells, die Analyse bleibt, ein Fehler bei einer Funktion stoppt weder die anderen noch den Widerruf. Tests: Task 12, 13. 5. **Team-Aktivierung:** Kunde außerhalb der neuen Ansicht, Bestand (`kauf`), vom Team abgeschaltete Funktion, Hosting oder kein Abo, Projektstart ohne jedes Abo, Textform-Nachweis. Erwartung: Bestand unverändert, sonst 409 `abo_fehlt` mit Grund und nichts geschrieben (einzige Ausnahme: `projekt` bei einem Kunden, der noch nie ein Abo hatte, ergibt 200 mit `warnung`), Nachweis vor dem Zustand. Tests: Task 6, 7. ## Dateiübersicht | Datei | Aufgabe | Task | |---|---|---| | `server/services/hourLedger.js` | Einrichtungs-Join in `listPlanUsage`, Vorschuss in `computeLedger` | 1 | | `server/routes/team/customers.js` | `GET /:id/stundenkonto` | 2 | | `server/mail-templates/` (neu: K38, K39, K40, K41, T24, K42, K43; K02, K03 um Block `funktionen_abo`) | Vorlagen | 3, 5, 15 | | `test/mailTemplates.test.js:9,11,718` | Kommentar, `EXPECTED` (K bis 41, T bis 24; in Task 15 K bis 43), `TEAM_FOOTER` um T24 | 3, 15 | | `server/services/aboFunktionenMails.js` (neu) | Builder `k38Mail`, `k39Mail`, `t24Mail`, `loeschFristTage`, `kontostandText`; später `anfrageEntschiedenMail` (K42, K43) | 4, 15 | | `server/services/zeiten/mails.js:100,113` | `k29Mail`, `k30Mail` mit Parameter `modell` (K40, K41) | 4 | | `server/routes/abo.js:422,502,981` | `hatAboFunktionen`, Blöcke `funktionen_abo` in K02 und K03 | 5 | | `server/services/aboModell.js` (neu) | `ABRECHNUNGEN`, `istAboModell` (Blatt-Modul ohne Importe) | 5 | | `server/services/aboFunktionenMeldung.js` (neu) | `meldeEingeschaltet`, `meldeBeendet`, `meldungDeps` | 6, 8 | | `server/services/funktionEinschalten.js` (P5) | ein Aufruf von `meldeEingeschaltet` am Ende von `einschalten` | 6 | | `server/services/aboAufbauphase.js` (neu) | `ohneAboBisher` (Kunde hatte noch nie ein Abo), gemeinsame Quelle für Team-Aktivierung und beide Abgleiche | 7 | | `server/services/siteFeatures.js:658,960` | `adminActivate` mit Abo-Prüfung (Ausnahme Aufbauphase) und Abrechnung, `ticketNachlegen` für Abo-Modell | 7 | | `server/routes/admin/funktionenAdmin.js:46,339` | `abrechnung` im Body, Fehlerform mit `code` und `grund`, Antwort mit `warnung`, Weiche auf das neue Verhalten | 7 | | `server/services/aboFunktionenAbgleich.js` (neu) | Tagesabgleich (mit Aufbauphase für `projekt`), News-Löschschritt, Sofortpfad beim Widerruf | 8, 9, 11, 13 | | `server/services/termine/aboAbgleich.js:272,397` | beide Einstiege überspringen Abo-Modell-Dokumente | 10 | | `server/services/zeiten/aboAbgleich.js:83,258,267`, `zeiten/behalten.js:47` | Kopplung auch an `projekt` (nicht in der Aufbauphase), Mails K40 und K41, Behalten gesperrt | 10 | | `server/index.js` (1 Import, 1 Intervallblock nach Z. 236) | Tagesjob | 11 | | `server/services/planSwitch.js:347,366,425`, `server/routes/abo.js:167,373` | `details.funktion`, `wechselFunktion` | 12 | | `server/routes/abo.js:1088` | Widerruf des Wechsels: Sofortpfad, Analyse bleibt | 13 | | `server/services/termine/ticketDetails.js:38`, `termine/einbauanleitung.js:487,533,663`, `shop/ticketDetails.js:57`, `unterkunft/ticketDetails.js:49` | Stundenkonto-Texte je Abrechnung | 14 | | `server/services/shop/anfrage.js:283`, `server/services/unterkunft/anfrageFunktion.js:272` | `sendeEntscheidung` wählt im Abo-Modell K42 bzw. K43 statt K24 bzw. K34 (nur dieser Aufruf, sonst unberührt) | 15 | | Neue Tests | `test/hourLedgerVorschuss`, `teamStundenkonto`, `aboFunktionenVorlagen`, `aboFunktionenMails`, `funktionEinschaltenMeldung`, `aboAufbauphase`, `funktionenAdminAbo`, `aboFunktionenAbgleich`, `aboFunktionenKoexistenz`, `aboFunktionenTagesjob`, `einrichtungsTicketTexte` (je `.test.js`) | 1–15 | | Angehängte Tests (Bestand unberührt) | `test/publicForms.test.js` (Task 5 und 13), `test/planSwitch.test.js` (Task 12), `test/zeitenBehalten.test.js` (Task 10), `test/shopTeam.test.js` und `test/unterkunftAnfrageFunktion.test.js` (Task 15) | 5, 10, 12, 13, 15 | --- ### Task 1: Vorschuss im Stundenkonto **Files:** - Modify: `server/services/hourLedger.js` (`listPlanUsage` Z. 37-74, `computeLedger` Z. 77-129) - Test: `test/hourLedgerVorschuss.test.js` (neu); `test/hourLedger.test.js` bleibt unverändert grün **Interfaces:** - Consumes: Attribut `workorders.einrichtungAbrechnung` (`'abo'|'projekt'|''`, P5 Task 1 und 8). Fehlt es am Dokument, gilt `''`. - Produces (V8): ```js listPlanUsage(customerId) // jeder Eintrag zusätzlich: einrichtung: boolean (= Ticket.einrichtungAbrechnung === 'abo') computeLedger(credits, usage, { now, active }) // zusätzlich: vorschussMinutes; usage[i] zusätzlich: einrichtung, vorschussMinutes ``` Regel für `computeLedger`: 1. Enthält `usage` keinen Eintrag mit `einrichtung === true`, läuft die bisherige Schleife unverändert; `vorschussMinutes` ist 0, jeder Eintrag bekommt `einrichtung: false` und `vorschussMinutes: 0`. 2. Sonst zwei Durchgänge über die Einträge in Datumsreihenfolge. Durchgang 1 deckt jeden Eintrag nur aus Gutschriften, die zur Arbeitszeit schon gebucht (`grantedAt <= date`) und noch gültig (`expiresAt > date`) waren, älteste zuerst. Durchgang 2 deckt den Rest rückwirkend (nur `expiresAt > date`), zuerst alle Einrichtungseinträge, dann die übrigen, jeweils in Datumsreihenfolge, älteste Gutschrift zuerst. 3. Der ungedeckte Rest ist bei Einrichtungseinträgen `vorschussMinutes`, sonst `overageMinutes`. `active: false` ändert die Zuordnung nicht, nur `balanceMinutes` und `credits[].remaining` werden 0 wie heute. - [ ] **Step 1: Failing test `test/hourLedgerVorschuss.test.js`** (Kopf wie `test/hourLedger.test.js:1-12`, `startAppwriteMock()` ohne Schema; `computeLedger`, `listPlanUsage`, `getLedger` dynamisch importieren) ```js const K = (id, minutes, grantedAt) => ({ $id: id, minutes, grantedAt, expiresAt: '2099-01-01T00:00:00.000Z' }) const U = (id, tag, minutes, einrichtung = false) => ({ id, date: `2026-${tag}T12:00:00.000Z`, minutes, einrichtung }) const NOW = new Date('2026-12-01T00:00:00.000Z') const je = (l) => Object.fromEntries(l.usage.map((e) => [e.id, [e.coveredMinutes, e.overageMinutes, e.vorschussMinutes]])) // [gedeckt, Mehraufwand, Vorschuss] test('Einrichtung ohne volles Guthaben: Vorschuss, nie Mehraufwand; normale Arbeit bleibt Mehraufwand', () => { const k = [K('k1', 60, '2026-09-01T00:00:00.000Z')] const e = computeLedger(k, [U('e1', '09-10', 150, true)], { now: NOW }) assert.deepEqual(je(e), { e1: [60, 0, 90] }); assert.deepEqual([e.vorschussMinutes, e.overageMinutes, e.balanceMinutes, e.usedMinutes], [90, 0, 0, 60]) const n = computeLedger(k, [U('n1', '09-10', 90)], { now: NOW }) assert.deepEqual(je(n), { n1: [60, 30, 0] }); assert.deepEqual([n.vorschussMinutes, n.overageMinutes], [0, 30]) }) test('spätere Gutschrift gleicht zuerst den Vorschuss aus, vor jeder anderen Arbeit', () => { const use = [U('n1', '09-05', 60), U('n2', '09-06', 60), U('e1', '09-10', 60, true)] const [k1, k2, k3] = [K('k1', 60, '2026-09-01T00:00:00.000Z'), K('k2', 60, '2026-10-01T00:00:00.000Z'), K('k3', 60, '2026-11-01T00:00:00.000Z')] assert.deepEqual(je(computeLedger([k1], use, { now: NOW })), { n1: [60, 0, 0], n2: [0, 60, 0], e1: [0, 0, 60] }) // beides sichtbar assert.deepEqual(je(computeLedger([k1, k2], use, { now: NOW })), { n1: [60, 0, 0], n2: [0, 60, 0], e1: [60, 0, 0] }) // k2 deckt e1 vor n2 const voll = computeLedger([k1, k2, k3], use, { now: NOW }) assert.deepEqual(je(voll), { n1: [60, 0, 0], n2: [60, 0, 0], e1: [60, 0, 0] }); assert.deepEqual([voll.overageMinutes, voll.vorschussMinutes, voll.balanceMinutes], [0, 0, 0]) }) test('ohne Einrichtung bleibt die Zuordnung wie bisher (Eintrag für Eintrag, rückwirkend)', () => { const l = computeLedger([K('a', 30, '2026-01-01T00:00:00.000Z'), K('c', 60, '2026-01-15T00:00:00.000Z')], [U('n1', '01-10', 60), U('n2', '01-20', 60)], { now: NOW }) assert.deepEqual(je(l), { n1: [60, 0, 0], n2: [30, 30, 0] }); assert.deepEqual([l.overageMinutes, l.vorschussMinutes], [30, 0]) }) test('Vertragsende (active false): Vorschuss bleibt stehen, Guthaben 0', () => { const l = computeLedger([K('k1', 60, '2026-09-01T00:00:00.000Z')], [U('e1', '09-10', 150, true)], { now: NOW, active: false }) assert.deepEqual([l.vorschussMinutes, l.balanceMinutes, l.nextExpiry], [90, 0, null]) }) test('listPlanUsage und getLedger: Einrichtung nur bei einrichtungAbrechnung abo; Git-Blatt zählt mit; projekt und fehlendes Feld nicht', async () => { aw.seed('workorders', { $id: 'wo_e', customerId: 'c9', woid: '100301', topic: 'Einrichtung', einrichtungAbrechnung: 'abo' }) aw.seed('workorders', { $id: 'wo_p', customerId: 'c9', woid: '100302', topic: 'Projekt', einrichtungAbrechnung: 'projekt' }) aw.seed('workorders', { $id: 'wo_n', customerId: 'c9', woid: '100303', topic: 'Alt' }) const ws = (id, wo, tag, over = {}) => aw.seed('worksheets', { $id: id, workorderId: wo, wsid: id, countsToPlan: true, totalTime: 60, startDate: `${tag}.10.2026`, employeeName: 'Justin', details: 'x', ...over }) ws('s_e', 'wo_e', '05'); ws('s_git', 'wo_e', '06', { serviceType: 'GIT', isComment: true, totalTime: 30, employeeShort: 'GIT' }) ws('s_p', 'wo_p', '07'); ws('s_n', 'wo_n', '08'); ws('s_aus', 'wo_e', '09', { countsToPlan: false }) assert.deepEqual(Object.fromEntries((await listPlanUsage('c9')).map((x) => [x.id, x.einrichtung])), { s_e: true, s_git: true, s_p: false, s_n: false }) aw.seed('hourCredits', { ...K('h1', 60, '2026-09-01T00:00:00.000Z'), customerId: 'c9' }) const l = await getLedger('c9') // 60 min gedeckt (s_e), s_git 30 min Vorschuss, s_p und s_n je 60 min Mehraufwand assert.deepEqual([l.usedMinutes, l.vorschussMinutes, l.overageMinutes], [60, 30, 120]) }) ``` - [ ] **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/hourLedgerVorschuss.test.js` Expected: FAIL (`vorschussMinutes` fehlt, die Zuordnung weicht ab). - [ ] **Step 3: `listPlanUsage` und `computeLedger` ändern** (`server/services/hourLedger.js`) `listPlanUsage` liest `einrichtungAbrechnung` aus dem schon geladenen Ticket (`byId`) und setzt `einrichtung` je Eintrag (für Git-Blätter gleich, sie hängen am selben Ticket). `computeLedger` nach der Regel oben; der Zweig ohne Einrichtungseintrag ist die bisherige Schleife, unverändert. Kopfkommentar der Datei um die Vorschuss-Regel ergänzen (Vertragsende: Restminus wird nicht berechnet). - [ ] **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/hourLedgerVorschuss.test.js test/hourLedger.test.js test/hourCredits.test.js test/aboRoutes.test.js` Expected: PASS, 0 fail. - [ ] **Step 5: Commit** ```bash git add server/services/hourLedger.js test/hourLedgerVorschuss.test.js git commit -m "Kundenansicht P6: Vorschuss im Stundenkonto (Einrichtung statt Mehraufwand)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 2: Team-Endpunkt Stundenkonto **Files:** - Modify: `server/routes/team/customers.js` (neue Route nach `GET /:id/abo`, Z. 35) - Test: `test/teamStundenkonto.test.js` (neu) **Interfaces:** - Consumes: `getLedger` (Task 1), `getCurrentSubscription`, `ACTIVE_STATUSES`, `PRODUCT_KEYS`. - Produces (V8): `GET /api/team/customers/:id/stundenkonto` (hinter `requireStaffJwt` aus `routes/team/index.js`) ```js { produkt: 'hosting'|'wartung'|null, ledger: { balanceMinutes, vorschussMinutes, overageMinutes, nextExpiry } | null } ``` `produkt` wie `/:id/abo` (Produkt des aktuellen Abos, unbekannt gilt `wartung`, ohne Abo `null`); `ledger` nur bei `wartung`, auch bei beendetem Abo (`getLedger(id, { active })` mit `active = ACTIVE_STATUSES.has(status)`), sonst `null`. Fehler: `500 { error }`. - [ ] **Step 1: Failing test `test/teamStundenkonto.test.js`** (Kopf wie `test/teamRoutes.test.js:1-24`, `seedFixtures(aw)`, `auth = { headers: { authorization: 'Bearer jwt-staff' } }`) ```js test('Wartung: Guthaben, Vorschuss und Mehraufwand aus dem Stundenkonto', async () => { aw.seed('subscriptions', { $id: 'sub_t', customerId: 'c3', status: 'active', productKey: 'wartung', storageUnits: 0, startedAt: '2026-09-01T10:00:00.000Z' }) aw.seed('hourCredits', { $id: 'h1', customerId: 'c3', minutes: 60, grantedAt: '2026-09-01T00:00:00.000Z', expiresAt: '2099-01-01T00:00:00.000Z' }) aw.seed('workorders', { $id: 'wo_t', customerId: 'c3', woid: '100401', topic: 'Einrichtung', einrichtungAbrechnung: 'abo' }) aw.seed('worksheets', { $id: 'ws_t', workorderId: 'wo_t', wsid: 'ws_t', countsToPlan: true, totalTime: 90, startDate: '10.09.2026' }) const res = await rawGet('/api/team/customers/c3/stundenkonto', auth) assert.deepEqual([res.status, res.body], [200, { produkt: 'wartung', ledger: { balanceMinutes: 0, vorschussMinutes: 30, overageMinutes: 0, nextExpiry: null } }]) }) test('Hosting: kein Konto; ohne Abo null; beendetes Wartungs-Abo behält den Vorschuss, Guthaben 0; ohne JWT 401', async () => { assert.deepEqual((await rawGet('/api/team/customers/c1/stundenkonto', auth)).body, { produkt: 'hosting', ledger: null }) // c1: Hosting aus seedFixtures assert.deepEqual((await rawGet('/api/team/customers/c4/stundenkonto', auth)).body, { produkt: null, ledger: null }) aw.docs('subscriptions').get('sub_t').status = 'canceled' const ende = (await rawGet('/api/team/customers/c3/stundenkonto', auth)).body assert.deepEqual([ende.produkt, ende.ledger.vorschussMinutes, ende.ledger.balanceMinutes], ['wartung', 30, 0]) assert.equal((await rawGet('/api/team/customers/c3/stundenkonto')).status, 401) }) ``` - [ ] **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/teamStundenkonto.test.js` Expected: FAIL (404 statt 200). - [ ] **Step 3: Route in `server/routes/team/customers.js`** Aufbau und Kommentarstil wie `GET /:id/abo`; Ergebnis auf die vier Felder reduzieren (`nextExpiry` bleibt `{ minutes, at } | null`). - [ ] **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/teamStundenkonto.test.js test/teamRoutes.test.js test/teamAuth.test.js` Expected: PASS, 0 fail. - [ ] **Step 5: Commit** ```bash git add server/routes/team/customers.js test/teamStundenkonto.test.js git commit -m "Kundenansicht P6: Team-Endpunkt Stundenkonto mit Vorschuss" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 3: Mailvorlagen K38, K39, K40, K41 und T24 **Files:** - Create: `server/mail-templates/K38 Funktion wird eingerichtet.html`, `K39 Funktion beendet.html`, `K40 Zeiten Abo-Ende.html`, `K41 Zeiten abgeschaltet Abo.html`, `T24 Funktion eingeschaltet.html` - Modify: `test/mailTemplates.test.js` (Z. 9 Kommentar: K38–K41 und T24 ergänzen; Z. 11 `EXPECTED` auf `ids('K', 41)` und `ids('T', 24)`; Z. 718 `TEAM_FOOTER` um `'T24'`; weitere Listen-Pins, die der Lauf nennt, nur um die neuen IDs erweitern) - Test: `test/aboFunktionenVorlagen.test.js` (neu) **Interfaces:** - Produces (V13; die Belegung K40, K41 trägt dieser Plan nach, Vertragsfrage 1): | ID | Betreff | Platzhalter | Blöcke | |---|---|---|---| | K38 | `Deine Funktion wird eingerichtet: {{funktion}}` | `kunde_name, projekt, funktion, funktion_akk, einrichtung, ticket, fassung, portal_url` | `stundenkonto, projekt_kosten, anhang` | | K39 | `Deine Funktion wurde beendet: {{funktion}}` | `kunde_name, projekt, funktion, loeschtag, portal_url` | `abo_ende, hosting, termine, offene` | | K40 | `Dein Abo ist beendet – Öffnungszeiten live läuft noch bis {{frist}}` | `kunde_name, projekt, frist, portal_url` | `abo_ende, hosting, wechsel_widerrufen` | | K41 | `Öffnungszeiten live ist abgeschaltet` | `kunde_name, projekt, loeschdatum, portal_url` | – | | T24 | `[Abo] Funktion eingeschaltet: {{funktion}} – {{kunde_name}}` | `kunde_name, email, projekt, funktion, woid, kontostand, woms_url` | `vorschuss, anhang_fehlt` | Feste Sätze (Wortlaut verbindlich, Rest ist Entwurf der Mail-Runde): - K38 `stundenkonto`: „Die Einrichtung geht von deinem Stundenkonto ab, auch wenn das Guthaben noch nicht reicht. Extrakosten entstehen keine.“ K38 `projekt_kosten`: „Die Einrichtung ist Teil deines Projekts und wird über die Projektkosten abgerechnet. Von deinem Stundenkonto geht nichts ab. Die Funktion läuft, solange dein Wartungs-Abo läuft.“ (der letzte Satz nennt dem Kunden, der beim Projektstart noch kein Abo hat, die Bedingung der Aufbauphase, E21) K38 `anhang`: „Leistungsbeschreibung und Auftragsverarbeitung (Fassung {{fassung}}) hängen als PDF an.“ Das Wort „Stundenkonto“ steht in K38 nur in diesen zwei Blöcken. `{{ticket}}` steht direkt hinter dem Projektnamen („ … Projekt {{projekt}}{{ticket}} …“). - K39: `abo_ende` „Dein Abo ist beendet. {{funktion}} gehörte dazu und endet deshalb mit ihm.“, `hosting` „Dein Abo ist ein Hosting-Abo. {{funktion}} ist im Wartungs-Abo enthalten; ein Hosting-Abo reicht dafür nicht.“, immer „Deine Eingaben bleiben bis zum {{loeschtag}} gespeichert. Danach löschen wir sie. Läuft bis dahin wieder ein passendes Abo, schalten wir {{funktion}} automatisch und ohne Gebühr wieder ein.“, `termine` „Bis dahin kannst du deine Termine im Kundenportal ansehen und als CSV exportieren. Gäste können ihre Termine weiter absagen, und die Erinnerungen laufen bis zur Löschung weiter. Bitte informiere deine Gäste selbst.“, `offene` „Offene Bestellungen und Buchungen kannst du bis dahin weiter im Kundenportal bearbeiten. Neue gibt es nicht mehr.“ - K40 und K41: Struktur und Blöcke von K29 und K30, aber ohne Behalten und ohne Preis; K40 sagt, dass die Funktion bis `{{frist}}` unverändert läuft, danach abgeschaltet wird und die Eingaben 30 Tage gespeichert bleiben; K41 nennt `{{loeschdatum}}`. Beide enden mit „Läuft wieder ein Wartungs-Abo, schalten wir die Funktion ohne Gebühr wieder ein.“ - T24: „Der Kunde hat die Funktion aus seinem Abo eingeschaltet. Die Einrichtung geht aufs Stundenkonto.“ `vorschuss`: „Das Guthaben reicht voraussichtlich nicht ({{kontostand}}). Die Einrichtung läuft trotzdem; das Minus ist ein Vorschuss und wird mit den nächsten Monatsgutschriften verrechnet. Extrakosten entstehen keine.“ `anhang_fehlt`: Hinweis „Unterlagen von Hand nachsenden“ wie in T16. - [ ] **Step 1: Failing test `test/aboFunktionenVorlagen.test.js`** (importiert `loadTemplate`, `renderMail`, `templateIds` aus `server/services/mailTemplates.js`) ```js const SOLL = { // [Datei, Betreff, Platzhalter, Blöcke] wie die Tabelle oben K38: ['K38 Funktion wird eingerichtet.html', 'Deine Funktion wird eingerichtet: {{funktion}}', ['kunde_name', 'projekt', 'funktion', 'funktion_akk', 'einrichtung', 'ticket', 'fassung', 'portal_url'], ['stundenkonto', 'projekt_kosten', 'anhang']], K39: ['K39 Funktion beendet.html', 'Deine Funktion wurde beendet: {{funktion}}', ['kunde_name', 'projekt', 'funktion', 'loeschtag', 'portal_url'], ['abo_ende', 'hosting', 'termine', 'offene']], K40: ['K40 Zeiten Abo-Ende.html', 'Dein Abo ist beendet – Öffnungszeiten live läuft noch bis {{frist}}', ['kunde_name', 'projekt', 'frist', 'portal_url'], ['abo_ende', 'hosting', 'wechsel_widerrufen']], K41: ['K41 Zeiten abgeschaltet Abo.html', 'Öffnungszeiten live ist abgeschaltet', ['kunde_name', 'projekt', 'loeschdatum', 'portal_url'], []], T24: ['T24 Funktion eingeschaltet.html', '[Abo] Funktion eingeschaltet: {{funktion}} – {{kunde_name}}', ['kunde_name', 'email', 'projekt', 'funktion', 'woid', 'kontostand', 'woms_url'], ['vorschuss', 'anhang_fehlt']], } const mail = (id, on) => { const t = loadTemplate(id); return renderMail(id, Object.fromEntries(t.placeholders.map((p) => [p, `W-${p}`])), Object.fromEntries(t.blocks.map((b) => [b, on]))) } for (const [id, [file, subject, placeholders, blocks]] of Object.entries(SOLL)) test(`${id}: Datei, Betreff, Platzhalter, Blöcke; rendert ohne Reste; ohne € und ohne Sie-Form (K)`, () => { const t = loadTemplate(id); assert.deepEqual([t.file, t.subject, t.placeholders, t.blocks], [file, subject, placeholders, blocks]) for (const on of [true, false]) { const m = mail(id, on); assert.deepEqual(m.problems, []); assert.doesNotMatch(m.html + m.text, /€/); if (id.startsWith('K')) assert.doesNotMatch(m.text, /\bSie\b|\bIhr/) } }) test('Festtexte', () => { assert.match(mail('K38', true).text, /Die Einrichtung geht von deinem Stundenkonto ab, auch wenn das Guthaben noch nicht reicht\. Extrakosten entstehen keine\./) assert.match(mail('K38', true).text, /Von deinem Stundenkonto geht nichts ab\. Die Funktion läuft, solange dein Wartungs-Abo läuft\./) assert.doesNotMatch(mail('K38', false).text, /Stundenkonto|Wartungs-Abo läuft/) assert.match(mail('K39', true).text, /Läuft bis dahin wieder ein passendes Abo, schalten wir W-funktion automatisch und ohne Gebühr wieder ein\./) assert.match(mail('K39', true).text, /Bitte informiere deine Gäste selbst\./); assert.doesNotMatch(mail('K39', false).text, /CSV|Offene Bestellungen/) assert.match(mail('T24', true).text, /Das Guthaben reicht voraussichtlich nicht \(W-kontostand\)\./); assert.doesNotMatch(mail('T24', false).text, /Vorschuss/) }) test('templateIds enthält K38 bis K41 und T24', () => { for (const id of Object.keys(SOLL)) assert.ok(templateIds().includes(id)) }) ``` - [ ] **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/aboFunktionenVorlagen.test.js` Expected: FAIL mit `Mail-Vorlage K38 fehlt`. - [ ] **Step 3: Fünf Vorlagen anlegen, Pins in `test/mailTemplates.test.js` anpassen** Kopfkommentar je Vorlage im Format der Nachbarn (`K38 · …`, Betreff, Absender, Empfänger, Platzhalter, Blöcke mit Erklärung). K-Vorlagen als Kopie von K16 (Logo, Outlook-Kommentar, Knopf zu `{{portal_url}}`), T24 als Kopie von T19 (Team-Fuß). Der Titel im `` ist gleich dem Betreff. - [ ] **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/aboFunktionenVorlagen.test.js test/mailTemplates.test.js test/zeitenMails.test.js` Expected: PASS, 0 fail. - [ ] **Step 5: Commit** ```bash git add server/mail-templates/"K38 Funktion wird eingerichtet.html" server/mail-templates/"K39 Funktion beendet.html" server/mail-templates/"K40 Zeiten Abo-Ende.html" server/mail-templates/"K41 Zeiten abgeschaltet Abo.html" server/mail-templates/"T24 Funktion eingeschaltet.html" test/mailTemplates.test.js test/aboFunktionenVorlagen.test.js git commit -m "Kundenansicht P6: Mailvorlagen K38 bis K41 und T24 für das Abo-Modell" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 4: Mail-Builder und Zeiten-Varianten **Files:** - Create: `server/services/aboFunktionenMails.js` - Modify: `server/services/zeiten/mails.js` (`k29Mail` Z. 100, `k30Mail` Z. 113) - Test: `test/aboFunktionenMails.test.js` (neu); `test/zeitenMails.test.js` bleibt unverändert grün **Interfaces:** - Consumes: `featureConfig`, `katalogFelder` (P5 Task 6), `anleitungsLink` (`featureRegistry.js`), `datumKurz` (`termine/zeit.js`), `LOESCHFRIST_TAGE` (`termine/frist.js`), `config.shop.loeschTageNachAbschalten`, `config.unterkunft.loeschTageNachAbschalten`. - Produces (rein, kein Zugriff auf Appwrite, Form `{ template, values, blocks }` wie `zeiten/mails.js`; `kunde` ist Name oder Kundendokument): ```js export function k38Mail({ kunde, projekt, key, abrechnung, ticketNr = null, anhang = false }) // blocks { stundenkonto: abrechnung === 'abo', projekt_kosten: abrechnung === 'projekt', anhang } // values.funktion = featureConfig(key).mails.funktion, funktion_akk = mails.formen.akk, einrichtung = katalogFelder(key).einrichtung, // ticket = ' (Ticket #<Nr>)' oder '', fassung = leistungsbeschreibung.version oder '', portal_url = `${publicBaseUrl}/dashboard.html#${returnHash}` export function k39Mail({ kunde, projekt, key, loeschdatum, art }) // loeschdatum: Date; art 'hosting' | sonst abo_ende // blocks { abo_ende: art !== 'hosting', hosting: art === 'hosting', termine: key === 'termine', offene: key === 'shop' || key === 'unterkunft' }; values.loeschtag = datumKurz(loeschdatum) export function t24Mail({ kunde, email, projekt, key, customerId, projectId, woid, kontostand = '', anhangFehlt = false }) // blocks { vorschuss: kontostand !== '', anhang_fehlt: anhangFehlt }; values.woms_url = anleitungsLink(customerId, projectId) export const loeschFristTage = (key) // news 30, termine LOESCHFRIST_TAGE, shop und unterkunft config.<key>.loeschTageNachAbschalten; sonst TypeError export const kontostandText = ({ balanceMinutes = 0, vorschussMinutes = 0 }) => `Guthaben ${balanceMinutes} min, Vorschuss ${vorschussMinutes} min` // zeiten/mails.js k29Mail({ ..., modell = false }) // modell → template 'K40', values ohne preis, blocks unverändert k30Mail({ ..., modell = false }) // modell → template 'K41', values ohne preis ``` - [ ] **Step 1: Failing test `test/aboFunktionenMails.test.js`** ```js test('k38Mail: Werte und Blöcke je Abrechnung', () => { const m = k38Mail({ kunde: { name: 'Silke Müller' }, projekt: 'Bäckerei', key: 'termine', abrechnung: 'abo', ticketNr: '#100150', anhang: true }) assert.equal(m.template, 'K38'); assert.deepEqual(m.blocks, { stundenkonto: true, projekt_kosten: false, anhang: true }) assert.deepEqual(m.values, { kunde_name: 'Silke Müller', projekt: 'Bäckerei', funktion: 'Terminplaner', funktion_akk: 'den Terminplaner', einrichtung: 'meist in 1–2 Wochen', ticket: ' (Ticket #100150)', fassung: config.features.termine.leistungsbeschreibung.version, portal_url: `${config.publicBaseUrl}/dashboard.html#termine` }) assert.deepEqual(k38Mail({ kunde: 'K', projekt: 'P', key: 'shop', abrechnung: 'projekt' }).blocks, { stundenkonto: false, projekt_kosten: true, anhang: false }) assert.equal(k38Mail({ kunde: 'K', projekt: 'P', key: 'news', abrechnung: 'abo' }).values.ticket, '') }) test('k39Mail: Frist, Grund und Zusatzblöcke je Funktion', () => { const ende = new Date('2026-11-16T12:00:00.000Z') assert.deepEqual(k39Mail({ kunde: 'K', projekt: 'P', key: 'termine', loeschdatum: ende, art: 'beendet' }).blocks, { abo_ende: true, hosting: false, termine: true, offene: false }) assert.deepEqual(k39Mail({ kunde: 'K', projekt: 'P', key: 'shop', loeschdatum: ende, art: 'hosting' }).blocks, { abo_ende: false, hosting: true, termine: false, offene: true }) assert.equal(k39Mail({ kunde: 'K', projekt: 'P', key: 'news', loeschdatum: ende, art: 'x' }).values.loeschtag, '16.11.2026') assert.throws(() => k39Mail({ kunde: 'K', projekt: 'P', key: 'news', loeschdatum: 'morgen', art: 'x' }), TypeError) }) test('t24Mail, loeschFristTage, kontostandText', () => { const t = t24Mail({ kunde: 'Silke', email: 'k@example.invalid', projekt: 'P', key: 'news', customerId: 'c1', projectId: 'p1', woid: '100150', kontostand: kontostandText({ balanceMinutes: 0, vorschussMinutes: 90 }) }) assert.deepEqual([t.template, t.values.kontostand, t.blocks, t.values.woms_url], ['T24', 'Guthaben 0 min, Vorschuss 90 min', { vorschuss: true, anhang_fehlt: false }, anleitungsLink('c1', 'p1')]) assert.equal(t24Mail({ kunde: 'K', email: '', projekt: 'P', key: 'news', customerId: 'c1', projectId: 'p1', woid: '' }).blocks.vorschuss, false) assert.deepEqual(['news', 'termine', 'shop', 'unterkunft'].map(loeschFristTage), [30, 30, 90, 90]); assert.throws(() => loeschFristTage('zeiten'), TypeError) }) test('alle Builder-Ergebnisse rendern ohne Probleme und ohne €; Zeiten-Varianten', () => { const bau = [k38Mail({ kunde: 'K', projekt: 'P', key: 'unterkunft', abrechnung: 'abo' }), k39Mail({ kunde: 'K', projekt: 'P', key: 'termine', loeschdatum: new Date(), art: 'x' }), t24Mail({ kunde: 'K', email: 'e', projekt: 'P', key: 'shop', customerId: 'c', projectId: 'p', woid: '1' })] for (const m of bau) { const r = renderMail(m.template, m.values, m.blocks); assert.deepEqual(r.problems, []); assert.doesNotMatch(r.text, /€/) } const frist = new Date('2026-11-16T12:00:00.000Z') const k40 = k29Mail({ kunde: 'K', projekt: 'P', art: 'hosting', frist, preis: '30 €', modell: true }); assert.equal(k40.template, 'K40'); assert.deepEqual(Object.keys(k40.values).sort(), ['frist', 'kunde_name', 'portal_url', 'projekt']); assert.equal(k40.blocks.hosting, true) const k41 = k30Mail({ kunde: 'K', projekt: 'P', loeschdatum: frist, preis: '30 €', modell: true }); assert.equal(k41.template, 'K41'); assert.equal(renderMail('K41', k41.values, k41.blocks).problems.length, 0) assert.equal(k29Mail({ kunde: 'K', projekt: 'P', art: 'beendet', frist, preis: '30 €' }).template, 'K29') // Standard unverändert }) ``` (`config.features.termine.leistungsbeschreibung.version` ist ein Registerfeld; `config` und die Builder dynamisch importieren, Kopf wie `test/zeitenMails.test.js:1-30`.) - [ ] **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/aboFunktionenMails.test.js` Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `aboFunktionenMails.js`. - [ ] **Step 3: Builder und `modell`-Parameter** `aboFunktionenMails.js` nach der Signaturliste. `kundenName` und `datum` wie in `zeiten/mails.js` (Datum nur gültiges `Date`, sonst `TypeError`). In `k29Mail` und `k30Mail` den Parameter `modell = false` ergänzen: nur dann Vorlage K40 bzw. K41 und `preis` weglassen; ohne `modell` bleibt das Ergebnis Zeichen für Zeichen wie heute. - [ ] **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/aboFunktionenMails.test.js test/zeitenMails.test.js test/aboFunktionenVorlagen.test.js` Expected: PASS, 0 fail. - [ ] **Step 5: Commit** ```bash git add server/services/aboFunktionenMails.js server/services/zeiten/mails.js test/aboFunktionenMails.test.js git commit -m "Kundenansicht P6: Mail-Builder für K38, K39, T24 und die Zeiten-Varianten K40, K41" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 5: K02 und K03 nennen das Ende der Abo-Funktionen **Files:** - Create: `server/services/aboModell.js` - Modify: `server/mail-templates/K02 Kuendigungsbestaetigung.html`, `K03 Eingang Kuendigung.html` (neuer Block `funktionen_abo`, im Kopfkommentar erklärt), `server/routes/abo.js` (`hatAboFunktionen` Z. 422, Blöcke Z. 502 und Z. 981) - Test: `test/publicForms.test.js` (Tests am Ende anhängen, Bestand unberührt; nutzt `kuendigeMitK02`, `kuendigeMitK03`, `mitFunktion`, `WARTUNG` aus Z. 152-190) **Interfaces:** - Produces (Blatt-Modul ohne Importe, von Routen, Abgleichen und Mails gemeinsam genutzt): ```js export const ABRECHNUNGEN = Object.freeze(['abo', 'projekt']) export const istAboModell = (doc) => boolean // unlockedBy und einrichtungAbrechnung beide in ABRECHNUNGEN; doc null oder undefined → false ``` - Produces: Block `funktionen_abo` in K02 und K03 (K03 innerhalb von `abo`), Sie-Form, fester Satz: „Die Funktionen Ihrer Website, die in Ihrem Abo enthalten sind (zum Beispiel Terminplaner, Webshop, Buchungskalender, News-Pinnwand oder Öffnungszeiten live), enden mit dem Abo. Ihre Eingaben bleiben danach noch 30 Tage gespeichert (beim Webshop und beim Buchungskalender 90 Tage); mit einem neuen Abo in dieser Zeit schalten wir die Funktionen wieder ein.“ - `hatAboFunktionen(sub)` liefert `{ alt: boolean, modell: boolean }` (`modell` = mindestens ein Dokument mit `istAboModell` im Zustand ungleich `off`). Blöcke: `funktionen: isWartung(sub) && alt && !modell`, `funktionen_abo: isWartung(sub) && modell`. Lesefehler: `{ alt: true, modell: false }` wie bisher. Hosting-Abo: weiterhin kein Lesen der Funktionen (Pin Z. 243-260). - [ ] **Step 1: Failing tests** (am Ende von `test/publicForms.test.js`) ```js test('istAboModell: nur mit beiden Markern aus abo und projekt', () => { // import { istAboModell } from '../server/services/aboModell.js' for (const [doc, soll] of [[{ unlockedBy: 'abo', einrichtungAbrechnung: 'abo' }, true], [{ unlockedBy: 'projekt', einrichtungAbrechnung: 'projekt' }, true], [{ unlockedBy: 'abo', einrichtungAbrechnung: 'projekt' }, true], [{ unlockedBy: 'abo' }, false], [{ unlockedBy: 'kauf', einrichtungAbrechnung: 'abo' }, false], [{ unlockedBy: 'team', einrichtungAbrechnung: 'abo' }, false], [{ einrichtungAbrechnung: 'abo' }, false], [{ unlockedBy: 'abo', einrichtungAbrechnung: '' }, false], [null, false], [undefined, false]]) assert.equal(istAboModell(doc), soll, JSON.stringify(doc)) }) const MODELL_SATZ = 'enden mit dem Abo. Ihre Eingaben bleiben danach noch 30 Tage gespeichert' const MODELL_DOC = { featureKey: 'news', state: 'setup', unlockedBy: 'abo', einrichtungAbrechnung: 'abo' } for (const [name, doc, erwartet] of [['Abo-Modell-Dokument (news, setup)', MODELL_DOC, true], ['Abo-Modell, Projektkosten', { ...MODELL_DOC, unlockedBy: 'projekt', einrichtungAbrechnung: 'projekt' }, true], ['News ohne Marker (Altbestand)', { featureKey: 'news', state: 'active' }, false], ['Abo-Modell-Dokument im Zustand off', { ...MODELL_DOC, state: 'off' }, false]]) { test(`K03 und K02, Wartungs-Abo: ${name} => ${erwartet ? 'neuer Hinweis, nicht der alte' : 'kein neuer Hinweis'}`, async () => { for (const mail of [await mitFunktion(doc, () => kuendigeMitK03(WARTUNG)), await mitFunktion(doc, () => kuendigeMitK02(WARTUNG))]) { assert.equal(mail.text.includes(MODELL_SATZ), erwartet, mail.text); assert.equal(mail.html.includes('Terminplaner, Webshop'), erwartet) assert.equal(mail.text.includes(FUNKTIONEN_SATZ), false) } }) } test('K03: Abo-Modell-Dokument und alte Analyse zusammen: nur der neue Satz; Hosting-Abo: kein Hinweis und kein Lesen', async () => { await mitFunktion(MODELL_DOC, async () => { aw.seed('portalFeatures', { $id: 'fk_zweit', customerId: 'c1', projectId: 'fk_p2', featureKey: 'analyse', state: 'active', enabled: true }) const mail = await kuendigeMitK03(WARTUNG); assert.ok(mail.text.includes(MODELL_SATZ)); assert.equal(mail.text.includes(FUNKTIONEN_SATZ), false) await deleteDocument(config.collections.portalFeatures, 'fk_zweit').catch(() => {}) aw.resetListCalls(); assert.equal((await kuendigeMitK03()).text.includes(MODELL_SATZ), false); assert.equal(aw.listCalls('portalFeatures').length, 0) }) }) ``` (`mitFunktion` seedet mit Vorgaben `featureKey: 'analyse'`; `doc` überschreibt sie.) - [ ] **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/publicForms.test.js` Expected: FAIL (neuer Satz fehlt). - [ ] **Step 3: Vorlagen und `hatAboFunktionen`** Block in beide Vorlagen einfügen und im Kopf bei „Optionale Blöcke“ nennen. `hatAboFunktionen` und die zwei Aufrufstellen (Z. 502, Z. 981) anpassen; die alten Blöcke und Texte bleiben unverändert. - [ ] **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/publicForms.test.js test/mailTemplates.test.js test/aboRoutes.test.js` Expected: PASS, 0 fail. - [ ] **Step 5: Commit** ```bash git add server/services/aboModell.js server/mail-templates/"K02 Kuendigungsbestaetigung.html" server/mail-templates/"K03 Eingang Kuendigung.html" server/routes/abo.js test/publicForms.test.js git commit -m "Kundenansicht P6: K02 und K03 nennen das Ende der Abo-Funktionen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 6: Meldung beim Einschalten (K38, T24) **Files:** - Create: `server/services/aboFunktionenMeldung.js` - Modify: `server/services/funktionEinschalten.js` (P5, ein Aufruf am Ende von `einschalten`) - Test: `test/funktionEinschaltenMeldung.test.js` (neu) **Interfaces:** - Consumes: Builder aus Task 4, `featureLegalAttachments` (`legalDocs.js`), `getLedger`, `aboProdukt` (P5), `customerOf`, `projectName` (`siteFeatures.js`), `sendCustomerMail`, `sendAdminNotice`. - Produces: ```js export const meldungDeps = { anhaenge: (key) => featureLegalAttachments(key, { ohne: ['widerruf'] }) } export async function meldeEingeschaltet({ doc, ticketWoid, by, abrechnung }) // → { k38: boolean, t24: boolean }; wirft nie ``` Regeln: K38 an die Adresse des Kunden (BCC Team wie `sendCustomerMail`) für `news|termine|shop|unterkunft`, **nicht** für `zeiten` (dort sendet `holeZeitenNach` K27 und bleibt). Anhänge über `meldungDeps.anhaenge(key)`; sind es keine (News) oder wirft der Aufruf, geht K38 ohne Anhang raus (Block `anhang` aus) und T24 trägt `anhangFehlt`, wenn die Funktion Rechtsunterlagen hat und der Aufruf warf. T24 (alle fünf Schlüssel) nur bei `by === 'kunde'`. Block `vorschuss`: `abrechnung === 'abo'`, `aboProdukt` ist `wartung` und das Stundenkonto zeigt `balanceMinutes <= 0` oder `vorschussMinutes > 0`; `kontostand = kontostandText(ledger)`. Kunde ohne Adresse: K38 entfällt (Log). Versandfehler: Log mit Art und Status, kein Wurf. `einschalten` ruft die Meldung genau dann, wenn es in diesem Aufruf den Zustand gesetzt oder das Ticket angelegt hat (Heilung zählt, `bereits_eingeschaltet` nicht; bei `zeiten` nur bei `changed === true`). - [ ] **Step 1: Failing test `test/funktionEinschaltenMeldung.test.js`** (Kopf wie P5 `test/funktionEinschalten.test.js` Task 9: Schema, Zeiten-Neubau-Stub, Wartung für c1, Projekte p1 und p2; dazu Schema `hourCredits`, `mailSink`, `setMailTransportForTests`) ```js const kunde = (over = {}) => ({ doc: aw.docs('portalFeatures').get('f_p1_news'), ticketWoid: '100150', by: 'kunde', abrechnung: 'abo', ...over }) const betreff = (start) => mails.sent.filter((m) => m.subject.startsWith(start)) test('Kunde, Wartung ohne Guthaben: K38 mit Konto-Satz an den Kunden, T24 mit Vorschuss ans Team', async () => { await E() // einschalten wie P5 Task 9: ruft die Meldung selbst const [k38] = betreff('Deine Funktion wird eingerichtet: News-Pinnwand'); assert.deepEqual(k38.to.map((a) => a.address), ['k@example.invalid']) assert.match(k38.text, /geht von deinem Stundenkonto ab/); assert.match(k38.text, /\(Ticket #\w+\)/) const [t24] = betreff('[Abo] Funktion eingeschaltet: News-Pinnwand'); assert.match(t24.text, /Guthaben 0 min, Vorschuss 0 min/) assert.equal(betreff('[Abo] Funktion eingeschaltet').length, 1) }) test('Guthaben reicht (Gutschrift 300 min): T24 ohne Vorschuss-Satz; Hosting-Kunde: auch keiner', async () => { aw.seed('hourCredits', { $id: 'h1', customerId: 'c1', minutes: 300, grantedAt: '2026-10-01T00:00:00.000Z', expiresAt: '2099-01-01T00:00:00.000Z' }) await E(); assert.doesNotMatch(betreff('[Abo] Funktion eingeschaltet')[0].text, /reicht voraussichtlich nicht/) }) test('Terminplaner: K38 mit zwei PDF-Anhängen; PDFs nicht lesbar: K38 ohne Anhang, T24 mit Hinweis', async () => { await E({ key: 'termine' }); const k = betreff('Deine Funktion wird eingerichtet')[0] assert.deepEqual(k.attachments.map((a) => a.contentType), ['application/pdf', 'application/pdf']); assert.match(k.text, /hängen als PDF an/) mails.sent.length = 0; const echt = meldungDeps.anhaenge; meldungDeps.anhaenge = async () => { throw new Error('weg') } try { await E({ key: 'termine', projectId: 'p2' }) } finally { meldungDeps.anhaenge = echt } assert.equal(betreff('Deine Funktion wird eingerichtet')[0].attachments.length, 0); assert.match(betreff('[Abo]')[0].text, /von Hand nachsenden/) }) test('Team im Projekt: K38 mit Projektkosten-Satz, kein Konto-Satz, kein T24; Zeiten: nur T24 (K27 bleibt)', async () => { await E({ by: 'team', abrechnung: 'projekt', staff: { name: 'Andrej' }, nachweis: null }) const k = betreff('Deine Funktion wird eingerichtet')[0]; assert.match(k.text, /Von deinem Stundenkonto geht nichts ab/); assert.match(k.text, /Die Funktion läuft, solange dein Wartungs-Abo läuft/); assert.doesNotMatch(k.text, /geht von deinem Stundenkonto ab/); assert.equal(betreff('[Abo]').length, 0) mails.sent.length = 0; await E({ key: 'zeiten', projectId: 'p2' }) assert.deepEqual([betreff('Deine Funktion wird eingerichtet').length, betreff('[Abo] Funktion eingeschaltet').length], [0, 1]) }) test('Wiederholung und Heilung: höchstens eine Meldung je Einschalten; Versandfehler und Kunde ohne Adresse brechen nichts ab', async () => { await E(); await assert.rejects(E(), (e) => e.code === 'bereits_eingeschaltet'); assert.equal(betreff('Deine Funktion').length, 1) mails.sent.length = 0; aw.failNext({ collection: 'workorders', method: 'POST', status: 500 }) await assert.rejects(E({ projectId: 'p2' }), (e) => e.status === 503); assert.equal(mails.sent.length, 0) // Ticket scheitert: noch keine Mail await E({ projectId: 'p2' }); assert.equal(betreff('Deine Funktion').length, 1) // Heilung: genau eine setMailTransportForTests({ sendMail: async () => { throw new Error('smtp') } }) const r = await meldeEingeschaltet(kunde()); assert.deepEqual(r, { k38: false, t24: false }) setMailTransportForTests(mails); aw.seed('customers', { ...customers.c1, email: '' }); assert.equal((await meldeEingeschaltet(kunde())).k38, false) }) ``` (`E`, `JETZT`, `aw`, `customers` aus dem P5-Kopf; ein Test pro Fall mit `beforeEach`, das `portalFeatures`, `workorders`, `hourCredits` leert und `mails.sent` zurücksetzt.) - [ ] **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/funktionEinschaltenMeldung.test.js` Expected: FAIL (`ERR_MODULE_NOT_FOUND` für `aboFunktionenMeldung.js`). - [ ] **Step 3: `aboFunktionenMeldung.js` und der Aufruf in `einschalten`** Reihenfolge in `meldeEingeschaltet`: Kunde und Projektname lesen, Anhänge holen, K38 senden, Konto lesen, T24 senden; jeder Schritt in eigenem `try`. `einschalten` ruft die Meldung nach dem Speichern der `ticketId` mit `await` (die Meldung wirft 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/funktionEinschaltenMeldung.test.js test/funktionEinschalten.test.js test/funktionenEinschaltenRoute.test.js test/zeitenEinschalten.test.js` Expected: PASS, 0 fail. - [ ] **Step 5: Commit** ```bash git add server/services/aboFunktionenMeldung.js server/services/funktionEinschalten.js test/funktionEinschaltenMeldung.test.js git commit -m "Kundenansicht P6: K38 an den Kunden und T24 ans Team beim Einschalten" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 7: Team-Aktivierung mit Abo-Prüfung und Abrechnung **Files:** - Create: `server/services/aboAufbauphase.js` - Modify: `server/services/siteFeatures.js` (`adminActivate` Z. 658, `ticketNachlegen` Z. 960), `server/routes/admin/funktionenAdmin.js` (`sendError` Z. 46, `POST /:customerId/:key` Z. 339) - Test: `test/funktionenAdminAbo.test.js` (neu), `test/aboAufbauphase.test.js` (neu) **Interfaces:** - Consumes: `einschalten` (P5 Task 9), `aboDeckt`, `aboModellFuerKunde` (P5 Task 4 und 13), `unlockedByVon`, `speichereZustimmung`, `listSubscriptionsForCustomer` (`stripeBilling.js`). - Produces (V10, Ausnahme Aufbauphase siehe Global Constraints): ```js // aboAufbauphase.js (genutzt hier, in Task 8 und Task 10) export async function ohneAboBisher(customerId) // → boolean; true = der Kunde hatte noch nie ein Abo adminActivate(customerId, projectId, key, { by, adminNote, textform = null, abrechnung = null, rueckmeldung = null, now = new Date() } = {}) ``` `ohneAboBisher`: kein Abo-Dokument des Kunden (`listSubscriptionsForCustomer`) mit einem Status außer `incomplete` und `incomplete_expired` (abgebrochene Bestellungen zählen nicht; jeder andere Status zählt, auch ein beendetes Abo). Lesefehler werden weitergereicht (fail-closed); eine leere oder keine String-Kunden-ID ist ein `TypeError`. `abrechnung: null` ist das heutige Verhalten, Zeichen für Zeichen (direkt `active`, kein Ticket). Mit `'abo'|'projekt'`: 1. Dokument mit `unlockedByVon(doc) === 'kauf'` (Bestand): wie `null`, einschließlich `assertAboLaeuftFuerAktivierung` beim Terminplaner. 2. Sonst `aboDeckt(customerId, key)`; nicht `ok` → `bestellFehler(409, <Text aus EINSCHALTEN_TEXTE.abo[grund]>, 'abo_fehlt', { grund, wechsel: grund === 'falsches_abo' })` vor jedem Schreiben. Das gilt auch für `projekt` (E21), **mit einer Ausnahme (E9, Aufbauphase):** `abrechnung === 'projekt'`, `grund === 'kein_abo'` und `ohneAboBisher(customerId)`. Dann läuft die Aktivierung durch (Schritte 3 und 4), und `adminActivate` setzt `rueckmeldung.warnung = { code: 'abo_fehlt', grund: 'kein_abo' }` (`rueckmeldung` ist ein Objekt des Aufrufers; ohne Objekt entfällt das Setzen). Hat der Kunde schon ein nicht deckendes Abo (Hosting, beendet, gekündigt, `nicht_aktiv`), bleibt es beim 409, auch im Projekt. Das Dokument wird dafür nur gelesen (`getFeatureDoc`); `ensureFeatureDoc` läuft im Abo-Modell erst innerhalb von `einschalten`, damit eine Ablehnung kein leeres Dokument hinterlässt. 3. Dokument `disabled` mit `disabledReason === 'team'`: wie heute direkt `active` (`activate`). 4. Sonst `einschalten({ customerId, projectId, key, by: 'team', abrechnung, staff: { name: by, adminNote }, nachweis, now })`. `nachweis` ist bei `textform` ein Aufruf von `speichereZustimmung` mit denselben Feldern wie der heutige Textform-Zweig; er läuft vor dem Zustand. Die Rückgabe ist das Dokument aus `einschalten`. `siteFeatures.js` lädt `funktionEinschalten.js` dynamisch (Importkreis). - Route: `modell = action === 'activate' && (await aboModellFuerKunde(customerId))`. Nur dann zählt `req.body.abrechnung` (Vorgabe `'abo'`, anderer Wert 400 `Unbekannte Abrechnung`) und gilt der Weg über `adminActivate(…, { abrechnung })` für alle fünf Schlüssel, auch `zeiten` (statt `zeitenAktivieren`); der K16-Zweig (`freigeschaltet`, `unterlagenAlsAnhang`) entfällt dort, die Unterlagen gehen mit K38. Sonst wird `abrechnung` ignoriert und alles läuft wie heute. `sendError` gibt zusätzlich `err.body` (z. B. `code`, `grund`, `wechsel`) in die Antwort, wenn es gesetzt ist. Die Route übergibt `rueckmeldung = {}` an `adminActivate` und ergänzt die 200-Antwort um `warnung: 'abo_fehlt'` und `grund: 'kein_abo'`, wenn `rueckmeldung.warnung` gesetzt ist (sonst fehlen beide Schlüssel). Der alte Wert `warnung: true` („K16 ging nicht raus“) kommt im Abo-Modell nicht vor, weil K16 dort entfällt; WOMS prüft ihn mit `=== true` (`funktionenView.js:318`) und verwechselt ihn nicht mit dem String. - `ticketNachlegen`: ein Abo-Modell-Dokument in `setup` ohne `ticketId` (außer `zeiten`, das hat `ticketNachHolen`) geht über `einschalten` (Heilung) statt über `ticketNachZahlung`; kein Stripe-Aufruf. - [ ] **Step 1: Failing test `test/funktionenAdminAbo.test.js`** (Kopf wie `test/teamRoutes.test.js:1-24` mit Schema für `portalFeatures`, `featureConsents`, `subscriptions`, `contractEvents`; Router `/api/team`; `auth = { headers: { authorization: 'Bearer jwt-staff' } }`; Projekte `p1`, `p2` für c1, `p3` für c2; Wartung für c1 (`subscriptionDoc('sub_h', wartungDoc)`); Pilot `['k@example.invalid']`; Mail-Senke; `beforeEach` leert `portalFeatures`, `workorders`, `featureConsents`, `subscriptions` und `mails.sent` und legt Wartung für c1 neu an; `config.shop.mode` und `.pilotEmails` werden in `after()` zurückgesetzt) ```js const go = (cid, key, body = {}, pid = 'p1') => post(`/api/team/funktionen/${cid}/${key}`, { projectId: pid, action: 'activate', ...body }, auth) const feat = (key, pid = 'p1') => aw.docs('portalFeatures').get(`f_${pid}_${key}`) const nichts = () => assert.deepEqual([aw.docs('portalFeatures').size, aw.docs('workorders').size, aw.docs('featureConsents').size, mails.sent.length], [0, 0, 0, 0]) test('Pilotkunde mit Wartung: setup, unlockedBy abo, Ticket mit Abo-Zeile, K38, kein T24; im Projekt: projekt', async () => { const r = await go('c1', 'news'); assert.equal(r.status, 200, JSON.stringify(r.body)); assert.equal(r.body.warnung, undefined) // Abo deckt: keine Warnung const d = feat('news'); assert.deepEqual([d.state, d.unlockedBy, d.einrichtungAbrechnung, d.unlockedByPurchase], ['setup', 'abo', 'abo', false]) const t = aw.docs('workorders').get(d.ticketId); assert.deepEqual([t.einrichtungAbrechnung, t.details.endsWith(ABRECHNUNGS_ZEILEN.abo)], ['abo', true]) assert.deepEqual([mails.sent.filter((m) => /^Deine Funktion wird eingerichtet/.test(m.subject)).length, mails.sent.filter((m) => m.subject.startsWith('[Abo]')).length], [1, 0]) assert.equal((await go('c1', 'shop', { abrechnung: 'projekt' })).status, 200); const s = feat('shop'); assert.deepEqual([s.unlockedBy, s.einrichtungAbrechnung], ['projekt', 'projekt']) assert.ok(aw.docs('workorders').get(s.ticketId).details.endsWith(ABRECHNUNGS_ZEILEN.projekt)) }) test('kein deckendes Abo: 409 abo_fehlt mit Grund, nichts geschrieben (abo immer; projekt, sobald der Kunde ein Abo hat oder hatte); ungültige Abrechnung 400', async () => { for (const [abo, grund, wechsel] of [[hostingDoc, 'falsches_abo', true], [{ ...wartungDoc, cancelAtPeriodEnd: true }, 'gekuendigt', false], [{ ...wartungDoc, status: 'canceled' }, 'kein_abo', false]]) { aw.docs('subscriptions').clear(); aw.seed('subscriptions', subscriptionDoc('sub_h', abo)) for (const abrechnung of ['abo', 'projekt']) { const r = await go('c1', 'termine', { abrechnung }); assert.deepEqual([r.status, r.body.code, r.body.grund, r.body.wechsel, typeof r.body.error], [409, 'abo_fehlt', grund, wechsel, 'string'], `${grund} ${abrechnung}`) } nichts() } aw.docs('subscriptions').clear() // noch nie ein Abo: aus dem Abo ist das Stundenkonto nötig, also hart const ohne = await go('c1', 'termine'); assert.deepEqual([ohne.status, ohne.body.code, ohne.body.grund], [409, 'abo_fehlt', 'kein_abo']); nichts() assert.equal((await go('c1', 'news', { abrechnung: 'frei' })).status, 400); nichts() }) test('Projektstart ohne jedes Abo (E9, Aufbauphase): 200 mit warnung abo_fehlt, Funktion in setup mit Ticket; abgebrochene Bestellung zählt nicht; mit deckendem Abo keine Warnung', async () => { aw.docs('subscriptions').clear(); aw.seed('subscriptions', subscriptionDoc('sub_i', { ...wartungDoc, status: 'incomplete' })) const r = await go('c1', 'shop', { abrechnung: 'projekt' }); assert.equal(r.status, 200, JSON.stringify(r.body)); assert.deepEqual([r.body.warnung, r.body.grund], ['abo_fehlt', 'kein_abo']) const d = feat('shop'); assert.deepEqual([d.state, d.unlockedBy, d.einrichtungAbrechnung], ['setup', 'projekt', 'projekt']); assert.ok(aw.docs('workorders').get(d.ticketId)) assert.equal(mails.sent.filter((m) => /^Deine Funktion wird eingerichtet/.test(m.subject)).length, 1) // K38 mit dem Satz zur Bedingung (Task 3) aw.docs('subscriptions').clear(); const z = await go('c1', 'news', { abrechnung: 'projekt' }, 'p2'); assert.deepEqual([z.status, z.body.warnung], [200, 'abo_fehlt']) aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc)); const ok = await go('c1', 'news', { abrechnung: 'projekt' }); assert.deepEqual([ok.status, ok.body.warnung, ok.body.grund], [200, undefined, undefined]) }) test('Kunde außerhalb der neuen Ansicht: wie heute direkt aktiv, abrechnung im Body wird ignoriert', async () => { const r = await go('c2', 'news', { abrechnung: 'projekt' }, 'p3'); assert.equal(r.status, 200) const d = feat('news', 'p3'); assert.deepEqual([d.state, d.enabled, d.ticketId, d.unlockedBy, d.einrichtungAbrechnung], ['active', true, undefined, undefined, undefined]); assert.equal(aw.docs('workorders').size, 0) config.kundenansichtNeu.mode = 'off'; aw.docs('portalFeatures').clear(); assert.equal(feat('news'), undefined) assert.equal((await go('c1', 'news')).status, 200); assert.equal(feat('news').state, 'active'); config.kundenansichtNeu.mode = 'pilot' }) test('Bestand und vom Team abgeschaltet: kauf wie heute, disabled/team direkt aktiv (mit Abo), sonst 409', async () => { aw.seed('portalFeatures', { $id: 'f_p2_termine', customerId: 'c1', projectId: 'p2', featureKey: 'termine', state: 'disabled', disabledReason: 'abo_ende', unlockedBy: 'kauf', unlockedByPurchase: true }) aw.docs('subscriptions').clear(); const alt = await go('c1', 'termine', {}, 'p2'); assert.deepEqual([alt.status, alt.body.code, /Wartungs-Abo des Kunden läuft nicht/.test(alt.body.error)], [409, undefined, true]) aw.seed('portalFeatures', { $id: 'f_p1_shop', customerId: 'c1', projectId: 'p1', featureKey: 'shop', state: 'disabled', disabledReason: 'team', unlockedBy: 'abo', einrichtungAbrechnung: 'abo', activatedAt: '2026-10-01T09:00:00.000Z' }) assert.equal((await go('c1', 'shop')).body.code, 'abo_fehlt'); aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc)) assert.equal((await go('c1', 'shop')).status, 200); assert.deepEqual([feat('shop').state, aw.docs('workorders').size], ['active', 0]) }) test('Textform: Nachweis vor dem Zustand, K38 mit PDF-Anhängen, kein K16; Aktion ticket legt das Ticket ohne Stripe nach', async () => { config.shop.mode = 'on'; config.shop.pilotEmails = [] const text = { textformDate: '2026-10-10', textformChannel: 'whatsapp' } aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500 }) // Nachweis scheitert: Funktion bleibt aus, kein Ticket, keine Mail assert.ok((await go('c1', 'shop', text)).status >= 500); assert.deepEqual([feat('shop')?.state ?? 'off', aw.docs('workorders').size, mails.sent.length], ['off', 0, 0]) const r = await go('c1', 'shop', text); assert.equal(r.status, 200, JSON.stringify(r.body)) const n = [...aw.docs('featureConsents').values()].find((c) => c.featureKey === 'shop'); assert.match(n.konto, /^Mitarbeiter: /) assert.ok(mails.sent.find((m) => /^Deine Funktion wird eingerichtet/.test(m.subject)).attachments.length >= 2); assert.equal(mails.sent.filter((m) => /ist bereit/.test(m.subject)).length, 0) aw.docs('workorders').delete(feat('shop').ticketId); aw.docs('portalFeatures').get('f_p1_shop').ticketId = '' const calls = st.calls.length; const t = await go('c1', 'shop', { action: 'ticket' }); assert.equal(t.status, 200); assert.ok(feat('shop').ticketId); assert.equal(st.calls.length, calls) }) ``` Zweite Testdatei `test/aboAufbauphase.test.js` (Kopf wie P5 `test/aboFunktionen.test.js`: Schema `subscriptions`; `wartungDoc`, `hostingDoc`, `subscriptionDoc` aus den Fixtures; `beforeEach` leert `subscriptions`): ```js test('ohneAboBisher: wahr ohne Abo-Dokument und bei abgebrochener Bestellung, falsch bei jedem anderen Status (auch beendet), je Kunde', async () => { assert.equal(await ohneAboBisher('c1'), true) for (const status of ['incomplete', 'incomplete_expired']) { aw.docs('subscriptions').clear(); aw.seed('subscriptions', subscriptionDoc('sub_x', { ...wartungDoc, status })); assert.equal(await ohneAboBisher('c1'), true, status) } for (const status of ['active', 'trialing', 'past_due', 'canceled', 'unpaid', 'paused']) { aw.docs('subscriptions').clear(); aw.seed('subscriptions', subscriptionDoc('sub_x', { ...wartungDoc, status })); assert.equal(await ohneAboBisher('c1'), false, status) } aw.docs('subscriptions').clear(); aw.seed('subscriptions', subscriptionDoc('sub_h', hostingDoc)) // Hosting zählt als Abo; das Dokument gehört c1 assert.deepEqual([await ohneAboBisher('c1'), await ohneAboBisher('c2')], [false, true]) }) test('ohneAboBisher: Lesefehler werden weitergereicht, leere oder falsche Kunden-ID ist ein TypeError', async () => { aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 }); await assert.rejects(() => ohneAboBisher('c1')) for (const id of ['', undefined, null, 5]) await assert.rejects(() => ohneAboBisher(id), TypeError) }) ``` - [ ] **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-concurrency=1 test/aboAufbauphase.test.js test/funktionenAdminAbo.test.js` Expected: FAIL (`ERR_MODULE_NOT_FOUND` für `aboAufbauphase.js`; Dokument ist `active` statt `setup`). - [ ] **Step 3: `ohneAboBisher`, `adminActivate`, `ticketNachlegen`, Route** Nach den Regeln oben. `unlockedBy` und `einrichtungAbrechnung` setzt allein `einschalten`; `adminActivate` schreibt sie nie selbst. Die Prüfung `ohneAboBisher` läuft nur im Ausnahmefall (`projekt`, `kein_abo`), nie vor `aboDeckt`. - [ ] **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/aboAufbauphase.test.js test/funktionenAdminAbo.test.js test/teamAdminRoutes.test.js test/funktionenShopTextform.test.js test/funktionenUnterkunftTextform.test.js test/featureFreischaltung.test.js test/zeitenTeam.test.js` Expected: PASS, 0 fail. - [ ] **Step 5: Commit** ```bash git add server/services/aboAufbauphase.js server/services/siteFeatures.js server/routes/admin/funktionenAdmin.js test/aboAufbauphase.test.js test/funktionenAdminAbo.test.js git commit -m "Kundenansicht P6: Team-Aktivierung mit Abo-Prüfung und Abrechnung (V10), Projektstart ohne Abo nur mit Warnung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 8: Abo-Ende-Abgleich: abschalten, melden, zurückkehren **Files:** - Create: `server/services/aboFunktionenAbgleich.js` - Modify: `server/services/aboFunktionenMeldung.js` (`meldeBeendet`) - Test: `test/aboFunktionenAbgleich.test.js` (neu) **Interfaces:** - Consumes: `istAboModell` (Task 5), `ohneAboBisher` (Task 7), `aboLaeuftFuer`, `aboProdukt` (P5), `disable`, `activate`, `stateFields`, `notizVorn`, `customerOf`, `projectName` (`siteFeatures.js`), `withLocks`, `ladeEinstellung` (`termine/kontext.js`), Builder aus Task 4. - Produces: ```js // aboFunktionenAbgleich.js export const ABGLEICH_KEYS = Object.freeze(['news', 'termine', 'shop', 'unterkunft']) // zeiten: zeiten/aboAbgleich.js (Task 10) export async function gleicheAboFunktionenAb(now = new Date()) // → { geprueft, abgeschaltet, wiederEin, geloescht, mails, fehler }; wirft nie // aboFunktionenMeldung.js export async function meldeBeendet(doc, { art, now }) // → boolean (true: gesendet oder nichts zu senden, Marker aboEndeMailAt gesetzt; false: Versand gescheitert, Marker leer); wirft nie ``` Ablauf wie `termine/aboAbgleich.js`: eine Listenabfrage (`featureKey` in `ABGLEICH_KEYS`, `state` ungleich `off`), nur `istAboModell`, je Kunde das Abo einmal lesen (`aboLaeuftFuer(customerId, key)`). Geschrieben wird unter der Sperre `feature-admin:<projectId>` nach frischem Lesen von Dokument **und** Abo; Mails und Marker danach. Lesefehler: Kunde überspringen, `fehler` zählt (fail-closed). - `setup|active` und das Abo deckt die Funktion nicht: `disable(id, { reason: 'abo_ende', adminNote: 'System: Abgeschaltet: Abo beendet' })`; bei `unterkunft` danach `leereCache(siteKey)` aus `unterkunft/kontext.js`. - **Aufbauphase (E9, E21):** Ein Dokument mit `unlockedBy === 'projekt'` im Zustand `setup|active` wird nicht abgeschaltet, solange `ohneAboBisher(customerId)` wahr ist (die Website ist noch im Bau, der Kunde hatte nie ein Abo). Ab dem ersten Abo gilt für `projekt` dieselbe Regel wie für `abo` (deckt es die Funktion nicht, endet sie mit K39). `ohneAboBisher` wird nur gelesen, wenn ein `projekt`-Dokument vom Abo nicht gedeckt ist: in der Vorauswahl je Kunde höchstens einmal je Lauf, unter der Sperre noch einmal frisch vor dem Abschalten; ein Lesefehler überspringt den Kunden (`fehler`, nichts abgeschaltet). - `disabled` mit Grund `abo_ende` und das Abo deckt wieder, und die Daten sind noch da (News: Dokument noch `disabled`; Terminplaner: `ladeEinstellung(id) !== null`; Shop und Unterkunft: `adminNote` ohne „gelöscht am“): Rückkehr. Mit `activatedAt` wird `activate(id, { adminNote: 'System: Wieder eingeschaltet: Abo läuft wieder' })`, sonst `stateFields('setup')` mit geleerten Abschaltfeldern. Keine Mail. - Abgeschaltet ohne Marker `aboEndeMailAt`: `meldeBeendet` (K39). `art` ist `hosting`, wenn `aboProdukt(customerId).produkt === 'hosting'`, sonst `abo_ende`. `loeschdatum = disabledAt + loeschFristTage(key)` Tage. Kein Kundenkonto oder keine Adresse: Marker setzen, nichts senden. - [ ] **Step 1: Failing test `test/aboFunktionenAbgleich.test.js`** (Schema `portalFeatures`, `subscriptions`, `contractEvents`; Mail-Senke; `beforeEach` leert `portalFeatures`, `subscriptions`, `contractEvents`, `mails.sent`; `seedFixtures(aw)`, Projekt p1 „Bäckerei“ für c1) ```js const JETZT = new Date('2026-10-17T08:00:00.000Z'), TAG = 86400000, KEYS = ['news', 'termine', 'shop', 'unterkunft'] const NAMEN = { news: 'News-Pinnwand', termine: 'Terminplaner', shop: 'Webshop', unterkunft: 'Buchungskalender' } const seed = (key, over = {}) => aw.seed('portalFeatures', { $id: `f_p1_${key}`, customerId: 'c1', projectId: 'p1', featureKey: key, siteKey: 'abcdefghijklmnop', state: 'active', enabled: true, activatedAt: '2026-10-01T09:00:00.000Z', unlockedBy: 'abo', unlockedByPurchase: false, einrichtungAbrechnung: 'abo', ...over }) const f = (key) => aw.docs('portalFeatures').get(`f_p1_${key}`) const abo = (doc) => aw.seed('subscriptions', subscriptionDoc('sub_h', doc)) const k39 = (key) => mails.sent.find((m) => m.subject === `Deine Funktion wurde beendet: ${NAMEN[key]}`) for (const [name, doc] of [['aktiv', wartungDoc], ['zum Periodenende gekündigt', { ...wartungDoc, cancelAtPeriodEnd: true }], ['past_due', { ...wartungDoc, status: 'past_due' }]]) test(`Wartung ${name}: nichts wird abgeschaltet`, async () => { abo(doc); KEYS.forEach((k) => seed(k)); const r = await gleicheAboFunktionenAb(JETZT) assert.deepEqual([r.geprueft, r.abgeschaltet, r.mails, r.fehler, mails.sent.length], [4, 0, 0, 0, 0]); KEYS.forEach((k) => assert.equal(f(k).state, 'active')) }) test('Abo beendet: alle vier abgeschaltet (setup zählt wie active), K39 je Funktion mit eigener Frist, Marker; zweiter Lauf tut nichts', async () => { abo({ ...wartungDoc, status: 'canceled' }); KEYS.forEach((k) => seed(k)); seed('news', { state: 'setup', enabled: false, activatedAt: null }) const r = await gleicheAboFunktionenAb(JETZT); assert.deepEqual([r.abgeschaltet, r.mails, r.fehler], [4, 4, 0]) for (const k of KEYS) { const d = f(k); assert.deepEqual([d.state, d.enabled, d.disabledReason], ['disabled', false, 'abo_ende']); assert.ok(d.aboEndeMailAt && d.disabledAt) const tag = datumKurz(new Date(Date.parse(d.disabledAt) + (k === 'shop' || k === 'unterkunft' ? 90 : 30) * TAG)); assert.ok(k39(k).text.includes(tag), k) assert.equal(/CSV/.test(k39(k).text), k === 'termine'); assert.equal(/Offene Bestellungen/.test(k39(k).text), k === 'shop' || k === 'unterkunft') } const vorher = f('shop').disabledAt; const noch = await gleicheAboFunktionenAb(new Date(JETZT.getTime() + TAG)); assert.deepEqual([noch.abgeschaltet, noch.mails, mails.sent.length, f('shop').disabledAt], [0, 0, 4, vorher]) }) test('Hosting statt Wartung: K39 mit Hosting-Satz', async () => { abo(hostingDoc); seed('shop'); await gleicheAboFunktionenAb(JETZT); assert.match(k39('shop').text, /ein Hosting-Abo reicht dafür nicht/) }) test('Abo nicht lesbar: nichts abgeschaltet, fehler gezählt, Log ohne Adresse', async (t) => { abo({ ...wartungDoc, status: 'canceled' }); seed('news'); const log = t.mock.method(console, 'error', () => {}); aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'k@example.invalid kaputt' }) const r = await gleicheAboFunktionenAb(JETZT); assert.ok(r.fehler >= 1); assert.equal(f('news').state, 'active'); assert.equal(mails.sent.length, 0); assert.ok(log.mock.calls.every((c) => !String(c.arguments[0]).includes('@'))) }) test('Rückkehr: nur solange die Daten da sind; ohne activatedAt zurück auf setup; ohne Mail', async () => { abo(wartungDoc); const aus = { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: '2026-10-10T09:00:00.000Z', aboEndeMailAt: '2026-10-10T09:05:00.000Z' } seed('news', aus); seed('termine', { ...aus, activatedAt: null }); seed('shop', aus); seed('unterkunft', aus) const r = await gleicheAboFunktionenAb(JETZT); assert.deepEqual([r.wiederEin, mails.sent.length], [3, 0]) // termine: keine Einstellung gespeichert → bleibt assert.deepEqual([f('news').state, f('news').disabledReason, f('news').aboEndeMailAt, f('shop').state, f('unterkunft').state, f('termine').state], ['active', null, null, 'active', 'active', 'disabled']) aw.seed('termineConfig', { $id: 'f_p1_termine', einstellung: '{}', version: 1 }); await gleicheAboFunktionenAb(JETZT); assert.equal(f('termine').state, 'setup') // ohne activatedAt: setup, nicht active }) test('Alte Dokumente bleiben unberührt; Kunde ohne Adresse setzt nur den Marker; Mailfehler: Marker leer, nächster Lauf sendet, disabledAt bleibt', async () => { abo({ ...wartungDoc, status: 'canceled' }); seed('news', { unlockedBy: 'kauf', unlockedByPurchase: true, einrichtungAbrechnung: undefined }); seed('termine', { einrichtungAbrechnung: '' }); seed('shop', { unlockedBy: 'team' }) assert.deepEqual([(await gleicheAboFunktionenAb(JETZT)).geprueft, f('news').state, f('termine').state, f('shop').state], [0, 'active', 'active', 'active']) seed('unterkunft'); aw.seed('customers', { ...customers.c1, email: '' }); await gleicheAboFunktionenAb(JETZT); assert.deepEqual([f('unterkunft').state, Boolean(f('unterkunft').aboEndeMailAt), mails.sent.length], ['disabled', true, 0]) aw.seed('customers', customers.c1); seed('news'); setMailTransportForTests({ sendMail: async () => { throw new Error('smtp') } }) const bad = await gleicheAboFunktionenAb(JETZT); const am = f('news').disabledAt; assert.deepEqual([bad.fehler, Boolean(f('news').aboEndeMailAt)], [1, false]) setMailTransportForTests(mails); const ok = await gleicheAboFunktionenAb(JETZT); assert.deepEqual([ok.mails, f('news').disabledAt, Boolean(f('news').aboEndeMailAt)], [1, am, true]) }) test('Projektstart-Funktionen (projekt): ohne jedes Abo unberührt (Aufbauphase), mit deckendem Abo auch, mit dem ersten nicht deckenden Abo gilt die Abo-Regel', async () => { KEYS.forEach((k) => seed(k, { unlockedBy: 'projekt', einrichtungAbrechnung: 'projekt' })) const leer = await gleicheAboFunktionenAb(JETZT); assert.deepEqual([leer.abgeschaltet, leer.mails, leer.fehler, mails.sent.length], [0, 0, 0, 0]); KEYS.forEach((k) => assert.equal(f(k).state, 'active')) // kein Abo-Dokument abo({ ...wartungDoc, status: 'incomplete' }); assert.equal((await gleicheAboFunktionenAb(JETZT)).abgeschaltet, 0) // eine abgebrochene Bestellung ist kein Abo aw.docs('subscriptions').clear(); abo(wartungDoc); assert.equal((await gleicheAboFunktionenAb(JETZT)).abgeschaltet, 0) // deckendes Abo aw.docs('subscriptions').clear(); abo(hostingDoc); const h = await gleicheAboFunktionenAb(JETZT) // das erste Abo deckt nicht: Abo-Regel assert.deepEqual([h.abgeschaltet, h.mails], [4, 4]); KEYS.forEach((k) => assert.equal(f(k).disabledReason, 'abo_ende')); assert.match(k39('shop').text, /ein Hosting-Abo reicht dafür nicht/) }) test('abo-Dokumente kennen keine Aufbauphase: ohne jedes Abo-Dokument werden sie wie bisher abgeschaltet', async () => { seed('news'); const r = await gleicheAboFunktionenAb(JETZT); assert.deepEqual([r.abgeschaltet, f('news').state], [1, 'disabled']) }) ``` (`termineConfig` mit `einstellung` als JSON-Objekt ist das Format, das `ladeEinstellung` in `termine/kontext.js:102` liest; Schema `termineConfig: schemaKeys('termineConfig')` ergänzen.) - [ ] **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/aboFunktionenAbgleich.test.js` Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `aboFunktionenAbgleich.js`. - [ ] **Step 3: Module nach den Regeln oben** `meldeBeendet` hängt in `aboFunktionenMeldung.js` (Mailversand, Marker). Kopf von `aboFunktionenAbgleich.js` wie bei `termine/aboAbgleich.js` mit Ablauf, Sperren, Fail-closed und der Aufteilung zu den alten Modulen. - [ ] **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/aboFunktionenAbgleich.test.js test/aboFunktionenMails.test.js test/publicForms.test.js` Expected: PASS, 0 fail. - [ ] **Step 5: Commit** ```bash git add server/services/aboFunktionenAbgleich.js server/services/aboFunktionenMeldung.js test/aboFunktionenAbgleich.test.js git commit -m "Kundenansicht P6: Abo-Ende-Abgleich für News, Terminplaner, Webshop und Buchungskalender" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 9: News-Löschschritt und Zurücksetzen nach der Löschung **Files:** - Modify: `server/services/aboFunktionenAbgleich.js` - Test: `test/aboFunktionenAbgleich.test.js` (erweitern) **Interfaces:** - Consumes: `listPosts`, `deleteNewsImage` (`newsPosts.js`, `newsImages.js`), `deleteDocument`. - Produces: `gleicheAboFunktionenAb` führt zwei weitere Schritte aus (Zähler `geloescht`): - **News:** `disabled/abo_ende`, das Abo deckt **nicht**, `disabledAt + 30 Tage` erreicht (Berliner Kalendertag wie `termine/frist.js`): erst alle Beiträge des Projekts samt Bild löschen (`listPosts(projectId)`: `hanging` und `expired`), dann das Dokument auf `off` mit geleerten Feldern `ticketId`, `activatedAt`, `aboEndeMailAt`, `disabledAt`, `disabledReason`. Schlägt ein Löschen fehl, bleibt das Dokument `disabled` und `fehler` zählt (der nächste Lauf wiederholt). Deckt das Abo wieder, gilt die Rückkehr aus Task 8 vor der Löschung. - **Shop und Unterkunft:** `disabled/abo_ende` mit „Shopdaten gelöscht am“ bzw. „Buchungsdaten gelöscht am“ in `adminNote` (die bestehenden 90-Tage-Jobs `shop/jobs.js` und `unterkunft/aufbewahrung.js` haben gelöscht): Dokument auf `off` zurücksetzen (Felder wie bei News), unabhängig vom Abo. Ohne Marker bleibt alles, die Daten löschen die bestehenden Jobs. Der Terminplaner setzt `termine/jobs.js` selbst auf `off`. - [ ] **Step 1: Failing tests** (in `test/aboFunktionenAbgleich.test.js`; Schema um `newsPosts` ergänzen, `beforeEach` leert zusätzlich `newsPosts`) ```js test('News: nach 30 Tagen werden Beiträge samt Bild gelöscht und das Dokument ist off; vorher und mit laufendem Abo nicht', async () => { abo({ ...wartungDoc, status: 'canceled' }); const aus = { state: 'disabled', enabled: false, disabledReason: 'abo_ende', aboEndeMailAt: '2026-09-01T00:00:00.000Z' } seed('news', { ...aus, disabledAt: new Date(JETZT.getTime() - 29 * TAG).toISOString() }) aw.seed('newsPosts', { $id: 'np1', customerId: 'c1', projectId: 'p1', title: 'A', imageId: null, visibleFrom: '2026-09-01T00:00:00.000Z', visibleUntil: null }); aw.seed('newsPosts', { $id: 'np2', customerId: 'c1', projectId: 'p_anders', title: 'B', imageId: null, visibleFrom: '2026-09-01T00:00:00.000Z', visibleUntil: null }) assert.equal((await gleicheAboFunktionenAb(JETZT)).geloescht, 0); assert.equal(aw.docs('newsPosts').size, 2) aw.docs('portalFeatures').get('f_p1_news').disabledAt = new Date(JETZT.getTime() - 31 * TAG).toISOString() assert.equal((await gleicheAboFunktionenAb(JETZT)).geloescht, 1); assert.deepEqual([...aw.docs('newsPosts').keys()], ['np2']) assert.deepEqual([f('news').state, f('news').disabledAt, f('news').aboEndeMailAt], ['off', null, null]) seed('news', { ...aus, disabledAt: new Date(JETZT.getTime() - 40 * TAG).toISOString() }); aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc)) // Abo kehrt zurück: Rückkehr statt Löschung aw.seed('newsPosts', { $id: 'np3', customerId: 'c1', projectId: 'p1', title: 'C', imageId: null, visibleFrom: '2026-09-01T00:00:00.000Z', visibleUntil: null }) await gleicheAboFunktionenAb(JETZT); assert.deepEqual([f('news').state, aw.docs('newsPosts').has('np3')], ['active', true]) }) test('News: ein Fehler beim Löschen lässt das Dokument disabled und zählt fehler', async (t) => { abo({ ...wartungDoc, status: 'canceled' }); seed('news', { state: 'disabled', enabled: false, disabledReason: 'abo_ende', aboEndeMailAt: 'x', disabledAt: new Date(JETZT.getTime() - 31 * TAG).toISOString() }) aw.seed('newsPosts', { $id: 'np1', customerId: 'c1', projectId: 'p1', title: 'A', imageId: null, visibleFrom: '2026-09-01T00:00:00.000Z', visibleUntil: null }); t.mock.method(console, 'error', () => {}); aw.failNext({ collection: 'newsPosts', method: 'DELETE', status: 500 }) const r = await gleicheAboFunktionenAb(JETZT); assert.deepEqual([r.fehler, f('news').state], [1, 'disabled']); assert.equal((await gleicheAboFunktionenAb(JETZT)).geloescht, 1) }) test('Shop und Unterkunft: nach der Löschung durch die bestehenden Jobs wird das Dokument off (auch bei laufendem Abo); ohne Marker nichts', async () => { abo(wartungDoc); const aus = { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: '2026-06-01T00:00:00.000Z', aboEndeMailAt: '2026-06-01T00:05:00.000Z' } seed('shop', { ...aus, adminNote: 'Shopdaten gelöscht am 2026-09-01' }); seed('unterkunft', { ...aus, adminNote: 'Buchungsdaten gelöscht am 2026-09-01' }); seed('termine', { ...aus }) const r = await gleicheAboFunktionenAb(JETZT); assert.equal(r.geloescht, 2); assert.deepEqual([f('shop').state, f('unterkunft').state, f('shop').aboEndeMailAt], ['off', 'off', null]) }) ``` - [ ] **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/aboFunktionenAbgleich.test.js` Expected: FAIL (`geloescht` bleibt 0). - [ ] **Step 3: Schritte ergänzen** Beide laufen im selben Lauf nach Abschalten und Rückkehr, je Dokument unter der Sperre mit frischem Lesen. Der News-Löschschritt löscht zuerst das Bild, dann den Beitrag (wie `retention.js` `deleteDependents`). - [ ] **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/aboFunktionenAbgleich.test.js test/shopJobs.test.js test/unterkunftAufbewahrung.test.js` Expected: PASS, 0 fail. - [ ] **Step 5: Commit** ```bash git add server/services/aboFunktionenAbgleich.js test/aboFunktionenAbgleich.test.js git commit -m "Kundenansicht P6: News-Löschschritt und Zurücksetzen nach der Löschung im Abo-Ende-Abgleich" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 10: Koexistenz mit Terminplaner- und Zeiten-Abgleich **Files:** - Modify: `server/services/termine/aboAbgleich.js` (`gleicheAboAb` Z. 397 und `endeTermineFuerKunde` Z. 272: Abo-Modell-Dokumente überspringen), `server/services/zeiten/aboAbgleich.js` (`istGekoppelt` Z. 83, Aufrufe Z. 258 und Z. 267), `server/services/zeiten/behalten.js` (`behaltenMoeglich` Z. 47) - Test: `test/aboFunktionenKoexistenz.test.js` (neu); `test/zeitenBehalten.test.js` (ein Test am Ende angehängt, Bestand unberührt) **Interfaces:** - Consumes: `istAboModell` (Task 5), `ohneAboBisher` (Task 7), `k29Mail`/`k30Mail` mit `modell` (Task 4). - Produces: Terminplaner: Abo-Modell-Dokumente erscheinen weder in `gleicheAboAb` noch in den Kandidaten von `endeTermineFuerKunde` (Zähler `ausgenommen` und `geprueft` zählen sie nicht). Zeiten: `istGekoppelt` gilt für `unlockedBy` `abo` **und** `projekt`; **Aufbauphase (E9, wie Task 8):** `projekt`-Dokumente im Zustand `setup|active` bleiben unberührt, solange die Abo-Lage `laeuft: false` meldet und `ohneAboBisher(customerId)` wahr ist (Filter in `bearbeiteKunde` vor dem Zählen von `geprueft`; ein Lesefehler überspringt den Kunden wie beim Lesen der Abo-Lage); `k29Mail` und `k30Mail` bekommen `modell: istAboModell(doc)`; `behaltenMoeglich` ist für Abo-Modell-Dokumente `false` (409 `NICHT_MOEGLICH` aus `starteBehalten`). Gratis-Zeiten ohne Marker behalten K29, K30 und den Behalten-Weg. - [ ] **Step 1: Failing test `test/aboFunktionenKoexistenz.test.js`** (Kopf wie `test/termineAboAbgleich.test.js:1-60` und `test/zeitenAboAbgleich.test.js` für die Zeiten-Helfer; Mail-Senke) ```js const modell = { unlockedBy: 'abo', unlockedByPurchase: false, einrichtungAbrechnung: 'abo' } test('Terminplaner-Abgleich überspringt Abo-Modell-Dokumente in jedem Zustand, auch ohne Wartungs-Abo; Kauf-Dokumente werden weiter abgeschaltet', async () => { // kein Abo; Abo-Modell: active, setup, disabled/abo_ende (ohne Marker); dazu ein gekaufter, aktiver Terminplaner for (const [id, state, extra] of [['f_a', 'active', {}], ['f_s', 'setup', {}], ['f_d', 'disabled', { disabledReason: 'abo_ende', disabledAt: new Date().toISOString() }]]) aw.seed('portalFeatures', { $id: id, customerId: 'c1', projectId: `p_${id}`, featureKey: 'termine', state, enabled: state === 'active', ...modell, ...extra }) aw.seed('portalFeatures', { $id: 'f_k', customerId: 'c1', projectId: 'p_k', featureKey: 'termine', state: 'active', enabled: true, unlockedBy: 'kauf', unlockedByPurchase: true }) const r = await gleicheAboAb(new Date()); assert.deepEqual([r.abgeschaltet, r.ausgenommen], [1, 0]); assert.deepEqual(['f_a', 'f_s', 'f_d', 'f_k'].map((id) => aw.docs('portalFeatures').get(id).state), ['active', 'setup', 'disabled', 'disabled']) assert.deepEqual((await endeTermineFuerKunde('c1')).beendet, []); assert.equal(mails.sent.filter((m) => /^Dein Terminplaner wurde beendet/.test(m.subject)).length, 1) // nur der Kauf }) const T0 = new Date('2026-10-17T08:00:00.000Z'), TAG = 86400000 // Schritt 1 erkennt das Abo-Ende, Schritt 2 schaltet nach der 30-Tage-Frist ab (ohne gespeicherte Zeiten, also ohne Ersatz-Ticket) const zeit = (id, over) => aw.seed('portalFeatures', { $id: id, customerId: 'c1', projectId: `p_${id}`, featureKey: 'zeiten', siteKey: `zk${id}`.padEnd(16, '2').slice(0, 16), state: 'active', enabled: true, activatedAt: '2026-10-01T09:00:00.000Z', unlockedByPurchase: false, einbau: 'angestossen', ticketId: 't1', einschaltMailAt: '2026-10-01T09:05:00.000Z', ...over }) const zdoc = (id) => aw.docs('portalFeatures').get(id) const beendetesAbo = () => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, status: 'canceled' })) const k40Mails = () => mails.sent.filter((m) => m.subject.startsWith('Dein Abo ist beendet – Öffnungszeiten live läuft noch bis')) test('Zeiten: Abo-Modell (abo und projekt) bekommt K40 und K41 ohne Preis, Gratis-Zeiten ohne Marker weiter K29 und K30 mit 30 €', async () => { beendetesAbo() // das Wartungs-Abo des Kunden ist beendet (mit einem Abo-Dokument: sonst gälte für projekt die Aufbauphase, siehe nächster Test) zeit('z_abo', { unlockedBy: 'abo', einrichtungAbrechnung: 'abo' }); zeit('z_projekt', { unlockedBy: 'projekt', einrichtungAbrechnung: 'projekt' }); zeit('z_alt', { unlockedBy: 'abo' }) await gleicheZeitenAboAb(T0) const k40 = k40Mails(); const k29 = mails.sent.filter((m) => m.subject.startsWith('Dein Wartungs-Abo ist beendet')) assert.deepEqual([k40.length, k29.length], [2, 1]); assert.ok(k40.every((m) => !/€|behalten/i.test(m.text))); assert.match(k29[0].text, /30 €/) await gleicheZeitenAboAb(new Date(T0.getTime() + 31 * TAG)) assert.deepEqual(['z_abo', 'z_projekt', 'z_alt'].map((id) => zdoc(id).disabledReason), ['abo_ende', 'abo_ende', 'abo_ende']) const k41 = mails.sent.filter((m) => m.subject === 'Öffnungszeiten live ist abgeschaltet'); assert.equal(k41.length, 3); assert.equal(k41.filter((m) => /30 €/.test(m.text)).length, 1) // nur das Gratis-Dokument nennt noch den Preis }) test('Zeiten, Aufbauphase (E9): projekt ohne jedes Abo bleibt unberührt, abo nicht; mit dem ersten beendeten Abo gilt die Abo-Regel auch für projekt', async () => { zeit('z_projekt', { unlockedBy: 'projekt', einrichtungAbrechnung: 'projekt' }); zeit('z_abo', { unlockedBy: 'abo', einrichtungAbrechnung: 'abo' }) // kein Abo-Dokument const r = await gleicheZeitenAboAb(T0); assert.equal(r.fehler, 0) assert.deepEqual([Boolean(zdoc('z_projekt').aboEndeAt), Boolean(zdoc('z_abo').aboEndeAt), k40Mails().length], [false, true, 1]) // nur z_abo erkennt ein Abo-Ende await gleicheZeitenAboAb(new Date(T0.getTime() + 31 * TAG)); assert.deepEqual([zdoc('z_projekt').state, zdoc('z_projekt').disabledReason ?? null], ['active', null]) // auch nach 31 Tagen aw.seed('subscriptions', subscriptionDoc('sub_i', { ...wartungDoc, status: 'incomplete' })); await gleicheZeitenAboAb(T0); assert.equal(Boolean(zdoc('z_projekt').aboEndeAt), false) // abgebrochene Bestellung ist kein Abo aw.docs('subscriptions').clear(); beendetesAbo(); await gleicheZeitenAboAb(T0); assert.deepEqual([Boolean(zdoc('z_projekt').aboEndeAt), k40Mails().length], [true, 2]) }) ``` Behalten-Sperre (angehängt an `test/zeitenBehalten.test.js`, Helfer `seedeAbgeschaltet`, `mitZeiten`, `behalten`, `koerper`, `keineSpuren`, `NICHT_MOEGLICH` dort vorhanden): ```js test('Behalten (30 €) ist für Abo-Modell-Dokumente gesperrt (409 ohne Spur), für Gratis-Zeiten im selben Zustand möglich', async () => { seedeAbgeschaltet({ einrichtungAbrechnung: 'abo' }); await mitZeiten() const res = await behalten(koerper()); assert.deepEqual([res.status, res.body.error], [409, NICHT_MOEGLICH]); keineSpuren() zeitenDoc().einrichtungAbrechnung = ''; assert.equal((await behalten(koerper())).status, 200) }) ``` (Im Kopf von `test/aboFunktionenKoexistenz.test.js` Schema für `portalFeatures`, `subscriptions`, `contractEvents` setzen, `gleicheZeitenAboAb`, `gleicheAboAb`, `endeTermineFuerKunde` importieren, `seedFixtures(aw)` aufrufen und `subscriptionDoc`, `wartungDoc` aus den Fixtures holen; ein `beforeEach` leert `portalFeatures`, `subscriptions` und `mails.sent`. `T0`, `TAG`, `zeit`, `zdoc`, `beendetesAbo`, `k40Mails` stehen auf Modulebene, damit beide Zeiten-Tests sie nutzen.) - [ ] **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/aboFunktionenKoexistenz.test.js` Expected: FAIL (Abo-Modell-Terminplaner wird abgeschaltet, Zeiten senden K29). - [ ] **Step 3: Die fünf Stellen anpassen** Je Stelle eine Zeile bzw. ein Filter (in `zeiten/aboAbgleich.js` zusätzlich der Aufbauphasen-Filter nach dem Lesen der Abo-Lage); Kommentare der Datei-Köpfe um den Satz „Abo-Modell-Dokumente (Marker) gehören `aboFunktionenAbgleich.js`“ ergänzen (Terminplaner) bzw. „und `projekt`, außer in der Aufbauphase“ (Zeiten). - [ ] **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/aboFunktionenKoexistenz.test.js test/termineAboAbgleich.test.js test/zeitenAboAbgleich.test.js test/zeitenBehalten.test.js test/zeitenMails.test.js` Expected: PASS, 0 fail. - [ ] **Step 5: Commit** ```bash git add server/services/termine/aboAbgleich.js server/services/zeiten/aboAbgleich.js server/services/zeiten/behalten.js test/aboFunktionenKoexistenz.test.js test/zeitenBehalten.test.js git commit -m "Kundenansicht P6: alte Abgleiche lassen Abo-Modell-Dokumente dem neuen Abgleich, Zeiten ohne Behalten" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 11: Einbindung in den Tagesjob **Files:** - Modify: `server/services/aboFunktionenAbgleich.js` (`aboFunktionenTagesJob`), `server/index.js` (1 Import, 1 Intervallblock nach dem Analyse-Block, Z. 236) - Test: `test/aboFunktionenTagesjob.test.js` (neu) **Interfaces:** - Produces: `export async function aboFunktionenTagesJob(now)` → Ergebnis von `gleicheAboFunktionenAb` oder `{ uebersprungen: true }`, wenn noch ein Lauf läuft (Merker im Modul, im `finally` gelöscht); wirft nie. `server/index.js` startet den Job täglich, erstmals 150 s nach dem Start (vor dem Terminplaner-Tagesjob bei 180 s und den Löschjobs von Shop 200 s und Buchungskalender 210 s, damit ein zurückgekehrtes Abo nicht mehr gelöscht wird). Der Job läuft unabhängig von den Funktionsschaltern. Logzeile nur bei Ereignissen: `[funktionen-abo] N abgeschaltet, N wieder eingeschaltet, N gelöscht, N Mail(s), N Fehler`. - [ ] **Step 1: Failing test `test/aboFunktionenTagesjob.test.js`** (Kopf wie Task 8) ```js test('Überlappungsschutz: der zweite gleichzeitige Lauf wird übersprungen, danach läuft der Job wieder', async () => { abo({ ...wartungDoc, status: 'canceled' }); seed('news') const [a, b] = await Promise.all([aboFunktionenTagesJob(JETZT), aboFunktionenTagesJob(JETZT)]) assert.deepEqual([a, b].filter((r) => r.uebersprungen).length, 1); assert.equal([a, b].find((r) => !r.uebersprungen).abgeschaltet, 1) assert.equal((await aboFunktionenTagesJob(JETZT)).uebersprungen, undefined) }) test('Der Job wirft nie (Listenfehler → fehler 1) und index.js startet ihn vor den Löschjobs', async (t) => { t.mock.method(console, 'error', () => {}); aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500 }) assert.equal((await aboFunktionenTagesJob(JETZT)).fehler, 1) const src = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8') const start = src.indexOf('setTimeout(aboFunktionenAbgleich, 150 * 1000)'); assert.ok(start > src.indexOf('const analyseAboCheck')) assert.ok(start < src.indexOf('setTimeout(termineTagesJob, 180 * 1000)')); assert.match(src, /setInterval\(aboFunktionenAbgleich, 24 \* 60 \* 60 \* 1000\)/) }) ``` - [ ] **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/aboFunktionenTagesjob.test.js` Expected: FAIL (`aboFunktionenTagesJob is not a function`). - [ ] **Step 3: Job und Intervallblock** Block in `index.js` nach dem Muster des `zeitenAbo`-Blocks (Z. 309-318), mit Kommentar zu Reihenfolge und Unabhängigkeit vom Schalter. - [ ] **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/aboFunktionenTagesjob.test.js test/aboFunktionenAbgleich.test.js` Expected: PASS, 0 fail. - [ ] **Step 5: Commit** ```bash git add server/services/aboFunktionenAbgleich.js server/index.js test/aboFunktionenTagesjob.test.js git commit -m "Kundenansicht P6: Abo-Ende-Abgleich im Tagesjob" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 12: Wechsel mit Funktion **Files:** - Modify: `server/services/planSwitch.js` (`switchToWartung` Z. 347, `performSwitch` Z. 366, Ereignis Z. 425), `server/routes/abo.js` (`POST /switch` Z. 373, `GET /` Z. 167) - Test: `test/planSwitch.test.js` (Tests am Ende anhängen; Kopf, `resetHosting`, `previewInvoice`, `switchInvoice`, `customerCookie` sind vorhanden) **Interfaces:** - Consumes: `ENTHALTEN_IN` (P5), `assertOwnProject`, `getFeatureDoc`, `neueAnsicht` (V1). - Produces (V11): ```js switchToWartung(customerId, consent, prorationDate, { expectedAmountCents, funktion = null } = {}) // funktion: { key, projectId }, schon geprüft → event.details.funktion export async function wechselFunktionVon(customerId, events, { now = new Date() } = {}) // → { key, projectId } | null (planSwitch.js) ``` - `POST /api/abo/switch` liest `body.funktion` nur bei `neueAnsicht(req)`. Gültig ist `{ key, projectId }` mit `key` aus den Schlüsseln, deren `ENTHALTEN_IN` nur `['wartung']` ist (news, termine, shop, unterkunft, zeiten; nicht `analyse`) und `projectId` eines Projekts des Kunden (`assertOwnProject`). Alles andere wird **verworfen**, der Wechsel läuft trotzdem (die Bequemlichkeit darf den bezahlten Vertrag nie blockieren). `details.funktion` bleibt weg, wenn `JSON.stringify(details)` damit über 2000 Zeichen käme (Schutz des Nachweises). - `GET /api/abo`: `subscription.wechselFunktion` nur bei `neueAnsicht(req)`, sonst fehlt der Schlüssel. Wert: das jüngste `wechsel`-Ereignis des aktuellen Abos ist `erledigt`, hat `details.funktion`, der Erledigungszeitpunkt (`doneAt`, sonst `details.paidAt`, sonst `details.effectiveAt`) liegt höchstens 14 Tage zurück, und das Funktionsdokument des Projekts fehlt oder ist `off`; sonst `null`. `completeSwitch` und `markSwitchFailed` bleiben unverändert (der Wert folgt dem Status). - [ ] **Step 1: Failing tests** (am Ende von `test/planSwitch.test.js`) ```js const pilot = (an) => { config.kundenansichtNeu.mode = an ? 'pilot' : 'off'; config.kundenansichtNeu.pilotEmails = ['k@example.invalid'] } const F = { key: 'termine', projectId: 'wf_p1' } aw.seed('websiteProjects', { $id: 'wf_p1', customerId: 'c1', projectName: 'Bäckerei' }); aw.seed('websiteProjects', { $id: 'wf_fremd', customerId: 'c2', projectName: 'Fremd' }) const bestelle = async (extra = {}) => { await resetHosting(); st.invoicePreview = previewInvoice(line('price_hosting', -900, { proration: true, periodStart: nowUnix() }), line('price_wartung', 5000)); st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() } return post('/api/abo/switch', { acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix(), amountDueCents: 4100, ...extra }, { cookie: customerCookie() }) } const wechsel = async () => (await listContractEvents('c1')).find((e) => e.type === 'wechsel') test('Wechsel mit Funktion: nur in der neuen Ansicht gespeichert; Ungültiges wird verworfen, der Wechsel läuft trotzdem', async () => { pilot(true); assert.equal((await bestelle({ funktion: F })).status, 200); assert.deepEqual((await wechsel()).details.funktion, F); assert.ok(JSON.stringify((await wechsel()).details).length <= 2000) for (const funktion of [{ key: 'analyse', projectId: 'wf_p1' }, { key: 'foo', projectId: 'wf_p1' }, { key: 'termine', projectId: 'wf_fremd' }, { key: 'termine' }, 'termine', [], null]) { assert.equal((await bestelle({ funktion })).status, 200, JSON.stringify(funktion)); assert.equal(Object.hasOwn((await wechsel()).details, 'funktion'), false, JSON.stringify(funktion)) } pilot(false); assert.equal((await bestelle({ funktion: F })).status, 200); assert.equal(Object.hasOwn((await wechsel()).details, 'funktion'), false) }) test('GET /api/abo: wechselFunktion folgt dem jüngsten erledigten Wechsel, 14 Tage, Funktion off; alte Ansicht ohne den Schlüssel', async () => { const ts = (tage) => new Date(Date.now() - tage * 86400000).toISOString() const lege = async ({ status = 'erledigt', paidVor = 2, doneVor = null, funktion = F } = {}) => { const ev = await recordContractEvent({ customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', consent, status, details: { from: 'hosting', switchedAt: ts(paidVor), ...(status === 'erledigt' && doneVor === null ? { paidAt: ts(paidVor) } : {}), ...(funktion ? { funktion } : {}) } }) if (doneVor !== null) await updateContractEvent(ev.$id, { status: 'erledigt', doneAt: ts(doneVor) }) } const lies = async () => (await get('/api/abo', { cookie: customerCookie() })).body.subscription const neu = async (setup) => { await resetHosting({ patch: { productKey: 'wartung' } }); await lege(setup); return (await lies()).wechselFunktion } pilot(true) assert.deepEqual(await neu({}), F); assert.deepEqual(await neu({ paidVor: 20, doneVor: 13 }), F) // sofort bezahlt (paidAt) bzw. später erledigt (doneAt) for (const setup of [{ status: 'offen' }, { status: 'gescheitert' }, { status: 'widerrufen' }, { paidVor: 20, doneVor: 15 }, { funktion: null }]) assert.equal(await neu(setup), null, JSON.stringify(setup)) await neu({}); await lege({ funktion: null }); assert.equal((await lies()).wechselFunktion, null) // der jüngere Wechsel ohne Funktion gewinnt await neu({}); aw.seed('portalFeatures', { $id: 'f_wf_p1_termine', customerId: 'c1', projectId: 'wf_p1', featureKey: 'termine', state: 'setup' }); assert.equal((await lies()).wechselFunktion, null) aw.docs('portalFeatures').get('f_wf_p1_termine').state = 'off'; assert.deepEqual((await lies()).wechselFunktion, F) pilot(false); assert.equal(Object.hasOwn(await lies(), 'wechselFunktion'), 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/planSwitch.test.js` Expected: FAIL (`details.funktion` undefined). - [ ] **Step 3: Service und Routen** `performSwitch` übernimmt `funktion` aus den Optionen in `details`; `wechselFunktionVon` liest nur Ereignisse, die schon geladen sind (`events`), und je ein Funktionsdokument. Die Route prüft `funktion` vor `switchToWartung` und fängt `assertOwnProject`-Fehler ab (verwerfen). `GET /` setzt das Feld nach `publicSubscription`. - [ ] **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/planSwitch.test.js test/aboRoutes.test.js test/contractEvents.test.js` Expected: PASS, 0 fail. - [ ] **Step 5: Commit** ```bash git add server/services/planSwitch.js server/routes/abo.js test/planSwitch.test.js git commit -m "Kundenansicht P6: Wechsel merkt sich die gewünschte Funktion (details.funktion, wechselFunktion)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 13: Widerruf des Wechsels beendet Wartungs-Funktionen, die Analyse bleibt **Files:** - Modify: `server/services/aboFunktionenAbgleich.js` (`beendeWartungsFunktionen`), `server/routes/abo.js` (`withdrawOrder`, Zweig `wechsel` Z. 1088) - Test: `test/publicForms.test.js` (Tests am Ende anhängen; nutzt `withdraw` (Z. 85), `wartungSpiegel`, `wechselIn`, `withActiveAnalyse` (Z. 556-775)) **Interfaces:** - Consumes: `ENTHALTEN_IN`, `aboModellFuerKunde` (P5), `meldeBeendet` (Task 8). - Produces (V11): ```js export async function beendeWartungsFunktionen(customerId, { now = new Date() } = {}) // → { beendet: string[] } (Dokument-IDs); wirft nie ``` Alle Dokumente des Kunden mit `istAboModell`, Zustand `setup|active` und einem Schlüssel, der nur im Wartungs-Abo steckt (`ENTHALTEN_IN`-Wert genau `['wartung']`: news, termine, shop, unterkunft, zeiten; nicht `analyse`): je Dokument unter `feature-admin:<projectId>` frisch lesen, `disable(id, { reason: 'abo_ende', adminNote: 'Abgeschaltet: Wechsel widerrufen' })` ohne Abo-Prüfung (der Spiegel steht bis zur Handarbeit des Teams noch auf Wartung). Danach `meldeBeendet(doc, { art: 'hosting' })` für alle außer `zeiten` (dort sendet der Zeiten-Abgleich K41 samt Ersatz-Ticket). `projekt`-Dokumente zählen mit (E21, Vertragsfrage 4). Ein Fehler bei einem Dokument stoppt die übrigen nicht; was misslingt, holt der Tagesabgleich nach. - `withdrawOrder` ruft nach dem Setzen der Ereignisse auf `widerrufen`: `endeTermineFuerKunde` (unverändert), `beendeWartungsFunktionen`, und `endAnalyseForCustomer` **nur**, wenn `aboModellFuerKunde(sub.customerId)` falsch ist (neue Ansicht: Hosting enthält die Analyse). Fehler wie bisher nur ins Log. - [ ] **Step 1: Failing tests** (am Ende von `test/publicForms.test.js`) ```js const wm = (key, over = {}) => ({ $id: `wm_${key}`, customerId: 'c1', projectId: 'wf_pt', featureKey: key, siteKey: `wmkey${key}`.slice(0, 16).padEnd(16, '2'), state: 'active', enabled: true, activatedAt: daysAgo(10), unlockedBy: 'abo', unlockedByPurchase: false, einrichtungAbrechnung: 'abo', ...over }) const wdoc = (id) => aw.docs('portalFeatures').get(id) const k39 = () => mails.sent.filter((m) => m.subject.startsWith('Deine Funktion wurde beendet')) const mitModell = async (keys, fn) => { for (const k of keys) aw.seed('portalFeatures', wm(k)); try { return await fn() } finally { for (const k of keys) await deleteDocument(config.collections.portalFeatures, `wm_${k}`).catch(() => {}) } } test('Widerruf Wechsel, neue Ansicht: alle Wartungs-Funktionen des Abo-Modells enden (abo_ende, K39 Hosting), Bestand und Analyse bleiben', async () => { pilot(true); await wartungSpiegel(); await wechselIn(3) aw.seed('portalFeatures', wm('shop', { $id: 'wm_shop_p', projectId: 'wf_p2', unlockedBy: 'projekt', einrichtungAbrechnung: 'projekt' })); aw.seed('portalFeatures', wm('news', { $id: 'wm_kauf', projectId: 'wf_p3', unlockedBy: 'kauf', unlockedByPurchase: true, einrichtungAbrechnung: '' })) await mitModell(['news', 'termine', 'shop', 'unterkunft', 'zeiten'], () => withActiveAnalyse(async (stops) => { const res = await withdraw({ order: 'wechsel' }); assert.equal(res.status, 200, JSON.stringify(res.body)) for (const id of ['wm_news', 'wm_termine', 'wm_shop', 'wm_unterkunft', 'wm_zeiten', 'wm_shop_p']) assert.deepEqual([wdoc(id).state, wdoc(id).disabledReason, wdoc(id).adminNote], ['disabled', 'abo_ende', 'Abgeschaltet: Wechsel widerrufen'], id) assert.equal(wdoc('wm_kauf').state, 'active'); assert.deepEqual(stops, []); assert.equal(wdoc('wf_feat').state, 'active'); assert.equal(bySubject('Deine Website-Analyse wurde beendet'), null) assert.equal(k39().length, 5); assert.ok(k39().every((m) => /ein Hosting-Abo reicht dafür nicht/.test(m.text))); assert.ok(k04() && t09()) // zeiten: K41 kommt vom Zeiten-Abgleich })) for (const id of ['wm_shop_p', 'wm_kauf']) await deleteDocument(config.collections.portalFeatures, id).catch(() => {}) }) test('Widerruf Wechsel, alte Ansicht: Analyse endet wie bisher', async () => { pilot(false); await wartungSpiegel(); await wechselIn(3) await withActiveAnalyse(async (stops) => { assert.equal((await withdraw({ order: 'wechsel' })).status, 200); assert.deepEqual(stops, ['wfsitekey0123456789']); assert.equal(wdoc('wf_feat').state, 'off') }) }) test('Widerruf Wechsel: scheitert eine Abschaltung, bleibt der Widerruf 200 und die übrigen enden; der Tagesabgleich holt die letzte nach', async (t) => { t.mock.method(console, 'error', () => {}); pilot(true); await wartungSpiegel(); await wechselIn(3) await mitModell(['news', 'termine', 'shop'], async () => { aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' }) assert.equal((await withdraw({ order: 'wechsel' })).status, 200); assert.equal(['wm_news', 'wm_termine', 'wm_shop'].filter((id) => wdoc(id).state === 'active').length, 1); assert.equal((await events('wechsel'))[0].status, 'widerrufen') await gleicheAboFunktionenAb(new Date()); assert.equal(['wm_news', 'wm_termine', 'wm_shop'].filter((id) => wdoc(id).state === 'active').length, 0); assert.equal(k39().length, 3) }) }) ``` (`pilot(an)` und `daysAgo` wie in Task 12 bzw. vorhanden; Imports `gleicheAboFunktionenAb` ergänzen.) - [ ] **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/publicForms.test.js` Expected: FAIL (Abo-Modell-Dokumente bleiben `active`, die Analyse endet). - [ ] **Step 3: `beendeWartungsFunktionen` und `withdrawOrder`** Im Zweig `wechsel` nur die drei Aufrufe nach der Regel oben; die bestehenden `try/catch` mit Logzeilen bleiben, jeder Aufruf in eigenem `try`. - [ ] **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/publicForms.test.js test/aboFunktionenAbgleich.test.js test/termineAboAbgleich.test.js test/analyseRoutes.test.js` Expected: PASS, 0 fail. - [ ] **Step 5: Commit** ```bash git add server/services/aboFunktionenAbgleich.js server/routes/abo.js test/publicForms.test.js git commit -m "Kundenansicht P6: Widerruf des Wechsels beendet Wartungs-Funktionen, die Analyse bleibt" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 14: Ticket-Texte zum Stundenkonto **Files:** - Modify: `server/services/termine/ticketDetails.js` (`KONTINGENT` Z. 38), `server/services/termine/einbauanleitung.js` (`DATENSCHUTZ_REGEL` Z. 487, `abschnittAbrechnung` Z. 533, `kurzSchritte` Z. 663), `server/services/shop/ticketDetails.js` (Z. 57), `server/services/unterkunft/ticketDetails.js` (Z. 49) - Test: `test/einrichtungsTicketTexte.test.js` (neu) **Interfaces:** - Consumes: `doc.einrichtungAbrechnung` aus `ctx.doc` bzw. `feature` (P5 Task 8), `ABRECHNUNGS_ZEILEN` (`ticketAdmin.js`, P5; die Schlusszeile hängt `createFeatureTicket` an). - Produces: Für Dokumente mit `einrichtungAbrechnung` `abo` oder `projekt` enthalten die drei Builder (`termineTicketDetails`, `shopTicketDetails`, `unterkunftTicketDetails`) und der Terminplaner-Generator (`einbauanleitung(...).text` und `.kurz`) **keine** Aussage „nicht auf das Stundenkontingent / nicht auf Abo-Stunden anrechnen“ oder „angerechnet“; die Abrechnung steht dort allein in der Schlusszeile aus P5. Ersatztexte: `DATENSCHUTZ_REGEL` nennt statt „wird nicht auf das Stundenkontingent angerechnet“ bei `abo` „gehört zur Einrichtung und zählt mit zum Stundenkonto“, bei `projekt` „gehört zur Einrichtung und ist im Projekt enthalten“ (`DATENSCHUTZ_REGEL` wird dafür zu einer Funktion von `feature`, die Konstante bleibt für Dokumente ohne Marker wörtlich). Abschnitt „Abrechnung“: `abo` „Abrechnung: Einrichtung aus dem Abo, Zeit im Arbeitsblatt aufs Abo-Stundenkonto anrechnen (Vorschuss möglich).“, `projekt` „Abrechnung: im Projekt enthalten, Zeit im Arbeitsblatt, nicht aufs Abo anrechnen.“ Schritt 6 der Kurzfassung endet bei `abo|projekt` mit „Ticket erledigen und die Zeit im Arbeitsblatt buchen.“ Der Notfalltext des Terminplaners lässt `KONTINGENT` weg. Dokumente ohne Marker bleiben Zeichen für Zeichen wie heute. - [ ] **Step 1: Failing test `test/einrichtungsTicketTexte.test.js`** (Kopf mit `startAppwriteMock()` und `useTestEnv`; Builder und `einbauanleitung` dynamisch importieren) ```js const ALT = /nicht auf (das )?Stundenkontingent|nicht auf Abo-Stunden|angerechnet/i const ctx = (key, doc) => ({ feature: { key }, project: { projectName: 'Bäckerei', subdomain: 'baeckerei' }, customer: { name: 'Kunde' }, doc: { $id: `f_${key}`, siteKey: 'abcdefghijklmnop', customerId: 'c1', projectId: 'p1', featureKey: key, ...doc } }) const BAUER = { termine: termineTicketDetails, shop: shopTicketDetails, unterkunft: unterkunftTicketDetails } for (const [key, bau] of Object.entries(BAUER)) { test(`${key}: mit Abrechnung abo und projekt keine „nicht anrechnen“-Aussage im Ticket`, async () => { for (const einrichtungAbrechnung of ['abo', 'projekt']) assert.doesNotMatch(await bau(ctx(key, { einrichtungAbrechnung, unlockedBy: einrichtungAbrechnung })), ALT, `${key} ${einrichtungAbrechnung}`) }) test(`${key}: Kauf-Bestand und Altdokumente behalten ihren Wortlaut`, async () => { assert.match(await bau(ctx(key, key === 'termine' ? { unlockedByPurchase: true } : {})), ALT) }) } test('Terminplaner-Anleitung: Text und Kurzfassung je Abrechnung; Notfalltext ohne Kontingent-Satz', async () => { const gen = (feature) => einbauanleitung({ feature: { $id: 'f1', siteKey: 'abcdefghijklmnop', customerId: 'c1', projectId: 'p1', ...feature }, project: { projectName: 'B' }, einstellung: null, fragebogen: null, version: 0, updatedAt: null, kunde: { name: 'K' }, now: new Date('2026-10-17T08:00:00.000Z') }) for (const a of ['abo', 'projekt']) { const g = gen({ einrichtungAbrechnung: a, unlockedBy: a }); assert.doesNotMatch(g.text, ALT, a); assert.doesNotMatch(g.kurz, ALT, a) } assert.match(gen({ einrichtungAbrechnung: 'abo', unlockedBy: 'abo' }).text, /Einrichtung aus dem Abo, Zeit im Arbeitsblatt aufs Abo-Stundenkonto anrechnen \(Vorschuss möglich\)\./) assert.match(gen({ einrichtungAbrechnung: 'projekt', unlockedBy: 'projekt' }).text, /im Projekt enthalten, Zeit im Arbeitsblatt, nicht aufs Abo anrechnen\./) assert.match(gen({ unlockedByPurchase: true }).kurz, /Nicht auf das Stundenkontingent anrechnen/) }) ``` - [ ] **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/einrichtungsTicketTexte.test.js` Expected: FAIL (die Texte nennen „nicht auf Abo-Stunden“). - [ ] **Step 3: Texte nach Marker verzweigen** Je Stelle eine Verzweigung auf `doc?.einrichtungAbrechnung` (`abo`/`projekt`), sonst der bisherige Text unverändert. Die Pins der Bestandsdateien (`termineEinbauanleitung.test.js`, `termineTicket.test.js`, `featureTicket.test.js`, `shopKanzleiPaket.test.js`, `zeitenRecht.test.js`) bleiben unberührt. - [ ] **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/einrichtungsTicketTexte.test.js test/termineEinbauanleitung.test.js test/termineTicket.test.js test/featureTicket.test.js test/einrichtungsTicket.test.js test/shopKanzleiPaket.test.js test/zeitenRecht.test.js` Expected: PASS, 0 fail. - [ ] **Step 5: Commit** ```bash git add server/services/termine/ticketDetails.js server/services/termine/einbauanleitung.js server/services/shop/ticketDetails.js server/services/unterkunft/ticketDetails.js test/einrichtungsTicketTexte.test.js git commit -m "Kundenansicht P6: Ticket-Texte zum Stundenkonto je Abrechnung (Abo, Projekt)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 15: Zusage-Mails K42 und K43 ohne Preis (Webshop- und Buchungskalender-Anfrage) **Files:** - Create: `server/mail-templates/K42 Webshop-Anfrage Abo.html`, `server/mail-templates/K43 Anfrage Buchungskalender Abo.html` - Modify: `server/services/aboFunktionenMails.js` (`anfrageEntschiedenMail`), `server/services/shop/anfrage.js` (`sendeEntscheidung` Z. 283), `server/services/unterkunft/anfrageFunktion.js` (`sendeEntscheidung` Z. 272), `test/mailTemplates.test.js` (Z. 9 Kommentar um „K42, K43 Zusage-Mails ohne Preis (Kundenansicht P6)“; Z. 11 `EXPECTED` auf `ids('K', 43)`; weitere Listen-Pins, die der Lauf nennt, nur um K42 und K43 erweitern) - Test: `test/aboFunktionenVorlagen.test.js` und `test/aboFunktionenMails.test.js` (aus Task 3 und 4 erweitern), `test/shopTeam.test.js` und `test/unterkunftAnfrageFunktion.test.js` (je ein Test am Ende angehängt, Bestand unberührt) **Interfaces:** - Grund: K24 (Webshop) und K34 (Buchungskalender) melden dem Kunden die Zusage des Teams mit „einmalig 299 €“ (Zeile „Preis“, Platzhalter `preis` und `ust_hinweis`, gesetzt in `shop/anfrage.js:295` und `unterkunft/anfrageFunktion.js:284`) und „kannst … jetzt bestellen“. Im Abo-Modell gibt es weder Preis noch Bestellung (E6; Spec Nicht-Ziele: Mailtexte mit Preisen für Funktionen werden inhaltlich angepasst). Preiszeile und „bestellen“ stehen im Block `angenommen` selbst; sie auszublenden bräuchte neue Blöcke und Platzhalter und änderte die Pins der alten Vorlagen (`tpl.blocks`, `tpl.placeholders`, `test/mailTemplates.test.js:1050-1078, :1343`). Darum eigene Vorlagen wie bei K40 und K41; K24 und K34 bleiben bytegleich. - Consumes: `aboModellFuerKunde` (P5 Task 13), `config.features.shop.returnHash` (`webshop`), `config.features.unterkunft.returnHash` (`buchungen`), `config.publicBaseUrl`. - Produces (V13, Nachtrag in Vertragsfrage 1): | ID | Betreff | Platzhalter | Blöcke | |---|---|---|---| | K42 | `Deine Webshop-Anfrage für {{projekt}}` | `kunde_name, projekt, begruendung, portal_url` | `angenommen, abgelehnt` | | K43 | `Deine Anfrage zum Buchungskalender für {{projekt}}` | `kunde_name, projekt, begruendung, portal_url` | `angenommen, abgelehnt` | Gerüst: Kopie von K24 bzw. K34 (gleiches Layout wie K16). Gegenüber dem Vorbild ändert sich nur das Folgende, alles andere (Überschriften, Vorschautext, Pille, Absage-Text, Zeile „Begründung“, Fuß) bleibt Zeichen für Zeichen: - Die Zeile „Preis“ im Block `angenommen` entfällt, ebenso die Platzhalter `preis` und `ust_hinweis`. - K42 `angenommen`: „wir haben Deine Webshop-Anfrage für {{projekt}} angenommen. Du kannst den Webshop jetzt im Kundenportal einschalten; danach richten wir ihn ein.“ Knopf bei Zusage `Webshop einschalten →`, bei Absage `Zum Kundenportal →` (wie K24). - K43 `angenommen`: „wir haben Deine Anfrage zum Buchungskalender für {{projekt}} angenommen. Du kannst ihn jetzt im Kundenportal einschalten; danach richten wir ihn ein.“ Knopf wie K34 `Zum Kundenportal →`. - In Text, Vorschautext und Knopf steht kein `€`, kein „UStG“ und kein Wort mit „bestell“. ```js // aboFunktionenMails.js (rein, Form { template, values, blocks }) export function anfrageEntschiedenMail({ kunde, projekt, key, projectId, angenommen, begruendung = '' }) // key 'shop' → template 'K42', 'unterkunft' → 'K43', sonst TypeError // values { kunde_name, projekt, begruendung, portal_url }; kunde_name = getrimmter Name (String oder Kundendokument), leer → 'Kunde' (wie K24/K34 heute); // portal_url = `${config.publicBaseUrl}/dashboard.html#${returnHash}?projekt=${encodeURIComponent(projectId)}` (wie heute in sendeEntscheidung) // blocks { angenommen: Boolean(angenommen), abgelehnt: !angenommen } ``` `sendeEntscheidung` (beide Dateien): Im bestehenden `try`, nach `customerOf` und der Adressprüfung, `const modell = await aboModellFuerKunde(customerId)`. Bei `modell` kommen Vorlage, Werte und Blöcke aus `anfrageEntschiedenMail({ kunde, projekt: project?.projectName || projectId, key: 'shop' bzw. 'unterkunft', projectId, angenommen, begruendung })`; sonst bleibt der bisherige Aufruf mit K24 bzw. K34 (Werte `preis`, `ust_hinweis`, Blöcke) unverändert. Versandweg, Log und Rückgabe (`true`/`false`) bleiben. Wirft `aboModellFuerKunde`, greift das bestehende `catch` (Log, `false`, die Antwort trägt `mailFehler`): lieber keine Mail als eine mit Preis. - [ ] **Step 1: Failing tests** `test/aboFunktionenVorlagen.test.js`: in `SOLL` zwei Einträge ergänzen und einen Test anhängen. ```js K42: ['K42 Webshop-Anfrage Abo.html', 'Deine Webshop-Anfrage für {{projekt}}', ['kunde_name', 'projekt', 'begruendung', 'portal_url'], ['angenommen', 'abgelehnt']], K43: ['K43 Anfrage Buchungskalender Abo.html', 'Deine Anfrage zum Buchungskalender für {{projekt}}', ['kunde_name', 'projekt', 'begruendung', 'portal_url'], ['angenommen', 'abgelehnt']], ``` ```js test('K42, K43: Zusage ohne Preis und ohne „bestellen“, Absage mit Begründung wie K24 und K34', () => { const werte = { kunde_name: 'Erika', projekt: 'Töpferei', begruendung: 'Wir verkaufen keine <Waffen>.', portal_url: 'https://portal.example/dashboard.html#x?projekt=p1' } for (const [id, satz, knopf] of [['K42', 'Du kannst den Webshop jetzt im Kundenportal einschalten; danach richten wir ihn ein.', 'Webshop einschalten'], ['K43', 'Du kannst ihn jetzt im Kundenportal einschalten; danach richten wir ihn ein.', 'Zum Kundenportal']]) { const zu = renderMail(id, werte, { angenommen: true, abgelehnt: false }); assert.deepEqual(zu.problems, []) assert.doesNotMatch(zu.html + zu.text, /€|UStG|bestell|Preis|Waffen/i); assert.ok(zu.text.includes(satz), id); assert.ok(zu.text.includes(knopf), id) const ab = renderMail(id, werte, { angenommen: false, abgelehnt: true }); assert.deepEqual(ab.problems, []) assert.match(ab.text, /leider/); assert.match(ab.text, /Wir verkaufen keine <Waffen>\./); assert.doesNotMatch(ab.text + ab.html, /€|UStG|bestell/i) } }) ``` Der Test „templateIds enthält K38 bis K41 und T24“ heißt danach „… K38 bis K43 und T24“. `test/aboFunktionenMails.test.js` (anhängen): ```js test('anfrageEntschiedenMail: K42 und K43, Werte, Blöcke, Adresse; ohne Preis', () => { const m = anfrageEntschiedenMail({ kunde: { name: 'Erika' }, projekt: 'Töpferei', key: 'shop', projectId: 'pt1', angenommen: true }) assert.equal(m.template, 'K42'); assert.deepEqual(m.blocks, { angenommen: true, abgelehnt: false }) assert.deepEqual(m.values, { kunde_name: 'Erika', projekt: 'Töpferei', begruendung: '', portal_url: `${config.publicBaseUrl}/dashboard.html#webshop?projekt=pt1` }) const u = anfrageEntschiedenMail({ kunde: { name: ' ' }, projekt: 'Donau', key: 'unterkunft', projectId: 'a b', angenommen: false, begruendung: 'Passt nicht.' }) assert.deepEqual([u.template, u.blocks, u.values.kunde_name, u.values.begruendung, u.values.portal_url], ['K43', { angenommen: false, abgelehnt: true }, 'Kunde', 'Passt nicht.', `${config.publicBaseUrl}/dashboard.html#buchungen?projekt=a%20b`]) assert.throws(() => anfrageEntschiedenMail({ kunde: 'K', projekt: 'P', key: 'termine', projectId: 'p', angenommen: true }), TypeError) for (const x of [m, u]) assert.deepEqual(renderMail(x.template, x.values, x.blocks).problems, []) }) ``` `test/shopTeam.test.js` (angehängt; Helfer `stelle`, `entscheide`, `k24`, `leereAlles`, `customers`, `config` stehen im Kopf; `config.kundenansichtNeu` stellt `after()` zurück): ```js const ansichtAlt = { ...config.kundenansichtNeu }; after(() => Object.assign(config.kundenansichtNeu, ansichtAlt)) const neueAnsicht = (an) => { config.kundenansichtNeu.mode = an ? 'pilot' : 'off'; config.kundenansichtNeu.pilotEmails = [customers.c1.email] } test('Abo-Modell: Zusage als K42 ohne Preis und ohne „bestellen“, Absage mit Begründung; alte Ansicht bekommt weiter K24 mit 299 €', async () => { for (const an of [true, false]) { neueAnsicht(an); await leereAlles(); await stelle(); mails.sent.length = 0 assert.equal((await entscheide({ entscheidung: 'annehmen' })).status, 200) const m = k24()[0]; assert.equal(m.subject, 'Deine Webshop-Anfrage für Töpferei'); assert.match(m.html, /dashboard\.html#webshop\?projekt=pt1"/) if (an) { assert.doesNotMatch(m.text + m.html, /€|UStG|bestell/i); assert.match(m.text, /jetzt im Kundenportal einschalten/) } else { assert.match(m.text, /299 €/); assert.match(m.text, /bestellen/) } } neueAnsicht(true); await leereAlles(); await stelle(); mails.sent.length = 0 assert.equal((await entscheide({ entscheidung: 'ablehnen', begruendung: 'Passt nicht.' })).status, 200) assert.match(k24()[0].text, /leider[\s\S]*Passt nicht\./); assert.doesNotMatch(k24()[0].text, /299 €/) }) ``` `test/unterkunftAnfrageFunktion.test.js` (angehängt; Helfer `stelle`, `entscheide`, `aufrufe`, `P`, `FEATURE`, `config`, `customers`, `renderMail` stehen im Kopf; das Löschen von `ukEinstellungen` setzt die Anfrage zurück, denn nach einer Zusage nimmt `stelle` keine neue an): ```js const ansichtAlt = { ...config.kundenansichtNeu }; after(() => Object.assign(config.kundenansichtNeu, ansichtAlt)) const neueAnsicht = (an) => { config.kundenansichtNeu.mode = an ? 'pilot' : 'off'; config.kundenansichtNeu.pilotEmails = [customers.c1.email] } test('Abo-Modell: Zusage als K43 ohne Preis und ohne „bestellen“, Absage unverändert; alte Ansicht bekommt weiter K34 mit 199 €', async () => { for (const an of [true, false]) { neueAnsicht(an); aufrufe.kunde.length = 0; aw.docs('ukEinstellungen').delete(FEATURE); assert.equal((await stelle()).status, 200); assert.equal((await entscheide({ entscheidung: 'annehmen' })).status, 200) const m = aufrufe.kunde[0]; const r = renderMail(m.template, m.values, m.blocks); assert.deepEqual(r.problems, []) assert.deepEqual([m.template, 'preis' in m.values, 'ust_hinweis' in m.values], an ? ['K43', false, false] : ['K34', true, true]) assert.equal(m.values.portal_url, `${config.publicBaseUrl}/dashboard.html#buchungen?projekt=${P}`) if (an) assert.doesNotMatch(r.text + r.html, /€|UStG|bestell/i); else { assert.match(r.text, /199 €/); assert.match(r.text, /bestellen/) } } neueAnsicht(true); aufrufe.kunde.length = 0; aw.docs('ukEinstellungen').delete(FEATURE); await stelle(); assert.equal((await entscheide({ entscheidung: 'ablehnen', begruendung: 'Passt nicht.' })).status, 200) assert.deepEqual([aufrufe.kunde[0].template, aufrufe.kunde[0].values.begruendung, aufrufe.kunde[0].blocks], ['K43', 'Passt nicht.', { angenommen: false, abgelehnt: true }]) }) ``` - [ ] **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-concurrency=1 test/aboFunktionenVorlagen.test.js test/aboFunktionenMails.test.js test/shopTeam.test.js test/unterkunftAnfrageFunktion.test.js` Expected: FAIL (`Mail-Vorlage K42 fehlt`, `anfrageEntschiedenMail` fehlt, die Zusage-Mail nennt noch 299 € und „bestellen“). - [ ] **Step 3: Vorlagen, Builder und `sendeEntscheidung`** Kopfkommentar je Vorlage im Format der Nachbarn (`K42 · …`, Betreff, Absender, Empfänger, Platzhalter, Blöcke; der Kommentar nennt K24 bzw. K34 als alte Fassung und sagt, dass diese Fassung im Abo-Modell gilt und keinen Preis nennt). `anfrageEntschiedenMail` nach der Signatur oben, Aufrufstellen nach der Regel oben; der alte Zweig bleibt wörtlich stehen. `test/mailTemplates.test.js` nachziehen. - [ ] **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/aboFunktionenVorlagen.test.js test/aboFunktionenMails.test.js test/mailTemplates.test.js test/shopTeam.test.js test/unterkunftAnfrageFunktion.test.js test/aboAnfragen.test.js` Expected: PASS, 0 fail. Die unveränderten Bestandstests (K24-Mail in `shopTeam.test.js`: „annehmen“ und „ablehnen“; K34-Mail in `unterkunftAnfrageFunktion.test.js`: „Team: Uebersicht … mit K34“ und „T23 und K34 … passen zu den Vorlagen“) laufen mit Schalter `off` und pinnen die alte Mail Zeichen für Zeichen. - [ ] **Step 5: Commit** ```bash git add server/mail-templates/"K42 Webshop-Anfrage Abo.html" server/mail-templates/"K43 Anfrage Buchungskalender Abo.html" server/services/aboFunktionenMails.js server/services/shop/anfrage.js server/services/unterkunft/anfrageFunktion.js test/mailTemplates.test.js test/aboFunktionenVorlagen.test.js test/aboFunktionenMails.test.js test/shopTeam.test.js test/unterkunftAnfrageFunktion.test.js git commit -m "Kundenansicht P6: Zusage-Mails K42 und K43 für Webshop- und Buchungskalender-Anfrage ohne Preis" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 16: 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/hourLedgerVorschuss.test.js test/teamStundenkonto.test.js test/aboFunktionenVorlagen.test.js test/aboFunktionenMails.test.js test/funktionEinschaltenMeldung.test.js test/aboAufbauphase.test.js test/funktionenAdminAbo.test.js test/aboFunktionenAbgleich.test.js test/aboFunktionenKoexistenz.test.js test/aboFunktionenTagesjob.test.js test/einrichtungsTicketTexte.test.js` Expected: PASS, 0 fail, 0 skipped. - [ ] **Step 3: Berührte und angehängte Bestandstests 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/hourLedger.test.js test/hourCredits.test.js test/worksheetMinutes.test.js test/mailTemplates.test.js test/zeitenMails.test.js test/zeitenAboAbgleich.test.js test/zeitenBehalten.test.js test/zeitenEinschalten.test.js test/termineAboAbgleich.test.js test/termineJobs.test.js test/termineEinbauanleitung.test.js test/termineTicket.test.js test/featureTicket.test.js test/shopKanzleiPaket.test.js test/zeitenRecht.test.js test/planSwitch.test.js test/publicForms.test.js test/aboRoutes.test.js test/teamRoutes.test.js test/teamAdminRoutes.test.js test/funktionenRoutes.test.js test/funktionEinschalten.test.js test/funktionenEinschaltenRoute.test.js test/analyseAboModell.test.js test/shopJobs.test.js test/unterkunftAufbewahrung.test.js test/einrichtungsTicket.test.js test/shopTeam.test.js test/unterkunftAnfrageFunktion.test.js test/aboAnfragen.test.js` Expected: PASS, 0 fail. - [ ] **Step 4: Verträge abhaken** - [ ] V8: `einrichtung` im Join auch für Git-Blätter, `vorschussMinutes` im Ergebnis und je Eintrag, spätere Gutschrift deckt zuerst den Vorschuss, Vertragsende lässt ihn stehen (Task 1); `GET /api/team/customers/:id/stundenkonto` mit der Form aus V8 (Task 2) - [ ] V10: `abrechnung` mit Vorgabe `abo`, 409 `abo_fehlt` mit `grund` (Ausnahme `kauf`; Ausnahme Aufbauphase: `projekt` bei einem Kunden ohne jedes Abo ergibt 200 mit `warnung: 'abo_fehlt'`, Vertragsfrage 8), `einschalten` mit Ticket, `disabled/team` direkt aktiv, Textform wie heute (Task 7) - [ ] V11: `funktion` in `details.funktion`, `wechselFunktion` nur neue Ansicht mit 14 Tagen und Status (Task 12); Widerruf beendet Wartungs-Funktionen mit `abo_ende`, die Analyse bleibt (Task 13) - [ ] V13: K38, K39, T24 (Task 3, 4, 6, 8); `EXPECTED` in `test/mailTemplates.test.js:11` (Task 3, in Task 15 auf K43); K02 und K03 nur für das Abo-Modell (Task 5); K22, K29, K30, T16 unverändert, Abo-Modell-Varianten über K39, K40, K41, T24 (Vertragsfrage 1); K24 und K34 unverändert, Zusage-Mails im Abo-Modell über K42 und K43 ohne Preis und ohne „bestellen“ (Task 15, Vertragsfrage 1) - [ ] C12: Abo-Ende-Abgleich für News, Terminplaner, Webshop, Buchungskalender (Task 8, 9, 11), Zeiten über das bestehende Modul (Task 10), Rückkehr, Löschfristen 30 und 90 Tage, Analyse-Hosting aus P5 Task 13 plus Widerruf (Task 13); Aufbauphase für `projekt`-Funktionen in beiden Abgleichen (Task 8, 10) - [ ] Ticket-Texte zum Stundenkonto bei `abo` und `projekt`, Kauf-Bestand unverändert (Task 14) - [ ] Review Focus 1 bis 5 dieses Plans und 4 und 5 der Übersicht: Tests in Task 8, 10 und 15 (1), Task 8, 9 und 10 (2), Task 1 und 2 (3), Task 12 und 13 (4), Task 6 und 7 (5) - [ ] **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-p5..HEAD && git diff --stat kn-p5..HEAD -- scripts public build server/services/analyse.js server/routes/funktionen.js server/services/featureConsents.js test/support ; ls -l /dev/null` Expected: `git status` zeigt höchstens `?? node_modules`; das erste `--stat` listet nur Dateien aus der Dateiübersicht; das zweite bleibt leer; `/dev/null` zeigt `crw-rw-rw-`. - [ ] **Step 6: Tag setzen** ```bash git tag kn-p6 ``` --- ## Vertragsfragen an den Controller 1. **Mailvorlagen (V13).** K22, K29, K30 und T16 lassen sich für das Abo-Modell nicht „nur ergänzen“: ihr Preis- und Behalten-Text steht in den festen Absätzen, die Engine kennt kein „Block aus“, und die Pins (`test/zeitenMails.test.js:65-68,248`, `test/mailTemplates.test.js:136`) verlangen bytegleiche alte Mails. Vorschlag (so geplant): Abo-Modell-Varianten über **K39** (gilt neben News, Webshop, Buchungskalender auch für den Terminplaner, Block `termine`), **K40** (Zeiten: Abo-Ende, Funktion läuft noch bis zur Frist) und **K41** (Zeiten abgeschaltet), dazu **T24** ans Team; K02 und K03 bekommen den zusätzlichen Block `funktionen_abo`. T16 bleibt unverändert und ungenutzt für das Abo-Modell. K38 geht nicht für `zeiten` raus, dort bleibt K27 die Einschalt-Mail (T24 geht trotzdem). Dasselbe gilt für K24 und K34 (Zusage der Webshop- bzw. Buchungskalender-Anfrage, heute mit „einmalig 299 €“ und „bestellen“): Preiszeile und „bestellen“ stehen im Block `angenommen`, die Pins verlangen die alten Mails bytegleich. Vorschlag (so geplant, Task 15): Abo-Modell-Varianten **K42** (Webshop) und **K43** (Buchungskalender), gewählt in `sendeEntscheidung` über `aboModellFuerKunde`; K24 und K34 bleiben unverändert. Bitte V13 so nachtragen: K40, K41, K42, K43 belegt, T25 frei; und in der Zuständigkeitstabelle die P6-Zeile um „Varianten der Mails K24/K34 (K42, K43) und der Aufruf in `sendeEntscheidung`“ ergänzen. 2. **Team-Aktivierung nur für die neue Ansicht.** V10 nennt `abrechnung` mit Vorgabe `'abo'` ohne Einschränkung, die Global Constraints verbieten aber Verhaltensänderungen für Kunden ohne neue Ansicht. Vorschlag (so geplant): Das neue Verhalten (Abo-Prüfung, `einschalten` mit Ticket) gilt nur, wenn `aboModellFuerKunde(customerId)` wahr ist; sonst wird `abrechnung` im Body ignoriert und alles läuft wie heute. Mit `on` (P10) gilt es für alle. P9 sollte `abrechnung` immer mitsenden. 3. **Fremde Dateien, jeweils minimal.** P5: `server/services/funktionEinschalten.js` (ein Aufruf von `meldeEingeschaltet`; P5 Vertragsfrage 5a damit erledigt: K27 bleibt für Zeiten, `ticketNachlegen` und Ticket-Zeilen wie P5 5b und 5c). Alt: `termine/aboAbgleich.js` (zwei Filter), `zeiten/aboAbgleich.js`, `zeiten/mails.js`, `zeiten/behalten.js` (je eine Zeile bzw. der Parameter `modell`), `server/index.js` (1 Import, 1 Intervallblock), `routes/abo.js` (K02/K03, `POST /switch`, `GET /`, `withdrawOrder`; P7 ändert dieselbe Datei später an anderen Stellen), vier Ticket-Text-Dateien, `shop/anfrage.js` und `unterkunft/anfrageFunktion.js` (nur der Aufruf in `sendeEntscheidung`; P5 ändert in `shop/anfrage.js` allein `stelleAnfrage`, die Zeilen liegen weit auseinander). Neu und nur von P6 genutzt: `aboAufbauphase.js`. Vorschlag: P6 bekommt genau diese Stellen zugewiesen. 4. **Abo-Modell-Dokument (V11 sagt `unlockedBy === 'abo'`).** `unlockedBy` allein reicht nicht: die Gratis-Zeiten der alten Ansicht tragen `abo` ohne Marker und behalten K29, K30 und den Behalten-Weg. Vorschlag (so geplant): `istAboModell` verlangt zusätzlich `einrichtungAbrechnung` aus `abo|projekt`, und der Widerruf des Wechsels beendet auch `projekt`-Dokumente, denn sie laufen nach E21 nur mit dem passenden Abo. 5. **Rückkehr ohne Mail.** Bei News, Terminplaner, Webshop und Buchungskalender kehrt die Funktion still zurück (K39 kündigt es an: „schalten wir automatisch wieder ein“). Eine Rückkehr-Mail (wie K23 oder K27 „wieder“) gibt es nur bei den Zeiten im bestehenden Modul. Soll es sie für die übrigen geben, braucht es eine weitere Vorlage (K44; K42 und K43 sind die Zusage-Mails, Task 15). 6. **„Guthaben reicht nicht“ (T24, E8).** Es gibt keine Stundenschätzung je Funktion. Vorschlag (so geplant): Der Block `vorschuss` erscheint, wenn das Stundenkonto beim Einschalten `balanceMinutes <= 0` oder schon einen Vorschuss zeigt. Eine feinere Regel (zum Beispiel Schätzwert je Funktion) wäre ein eigener Beschluss. 7. **Behalten-Weg der Zeiten (30 €).** Für Abo-Modell-Dokumente gesperrt (409). Die Route `POST /api/funktionen/zeiten/behalten` und die Texte K29/K30 räumt erst P10 für alle auf (Spec 5.5). 8. **Team-Aktivierung „im Projekt“ ohne Abo (V10, E9, E21).** V10 verlangt bei fehlendem Abo immer 409. Beim Projektstart hat der Kunde aber meist noch kein Abo; ein harter Block machte E9 praktisch unmöglich, und die Spec (5.5) lässt „prüft … oder warnt zumindest“ zu. Entscheidung in diesem Plan (Global Constraints, Task 7, 8, 10): `abrechnung: 'abo'` bleibt hart. `abrechnung: 'projekt'` warnt nur (200 mit `warnung: 'abo_fehlt'` und `grund: 'kein_abo'`), und nur, solange der Kunde **noch nie ein Abo** hatte (Aufbauphase, Website im Bau). Hat er schon ein nicht deckendes Abo (Hosting, beendet, gekündigt), bleibt es beim 409, sonst schaltete der Tagesabgleich die frisch gebaute Funktion in der Nacht wieder ab. Beide Abgleiche (Task 8, Zeiten Task 10) fassen `projekt`-Funktionen in der Aufbauphase nicht an; ab dem ersten Abo gilt die Abo-Regel wie bei `abo` (Hosting als erstes Abo beendet die Funktion mit K39; das ist nach E21 und R4 „läuft nur, solange das Wartungs-Abo läuft“ gewollt). Die Ausnahme ist bewusst enger als „solange nie ein deckendes Abo bestand“: Ein Hosting-Kunde behielte die Funktion sonst dauerhaft ohne Wartungs-Abo. Bitte: (a) V10 um diese Ausnahme und die Antwortform ergänzen (`warnung: 'abo_fehlt'`, `grund`, flach neben `projects`; der alte boolesche `warnung: true` für ein fehlendes K16 bleibt für die alte Ansicht); (b) P9 (Task 10, 11) zeigt die Warnung, zum Beispiel „Der Kunde hat noch kein Abo. Die Funktion läuft erst mit seinem Wartungs-Abo; mit einem Hosting-Abo endet sie.“, und lässt `aboFehltText` für `kein_abo` gelten; (c) P8 prüft, ob der Satz „Auch dann läuft die Funktion nur, solange das Wartungs-Abo läuft“ (Leistungsbeschreibung, Baustein „projekt“, R4) die Zeit vor dem ersten Abo mit abdeckt; (d) Spec E21 trägt den Ablauf nach: Wartungs-Abo spätestens zum Livegang vereinbaren.