Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-16-kundenansicht-07-abo-modell-betrieb.md

134 KiB
Raw Permalink Blame History

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 <Dateien einzeln> und git commit -m "<Betreff>" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>".

Review Focus

Die fünf wahrscheinlichsten Fehlerfälle, 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):

    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)

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
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 <noreply@anthropic.com>"

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)

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

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
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 <noreply@anthropic.com>"

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)

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

    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

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

    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)

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

    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)

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

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

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

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

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

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

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

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

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

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

    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)

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

    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)

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

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

  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']],
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):

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

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

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