134 KiB
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=%Dnenntkn-p5undkn/server;node_modulesexistiert (Symlink, niegit add -A);free -mzeigt 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.modeund.pilotEmailsdirekt (c1 =k@example.invalidPilot, c2 nicht) und stellen sie inafter()zurück. - Abo-Modell-Dokument:
istAboModell(doc)(aboModell.js, Task 5) verlangtunlockedByundeinrichtungAbrechnungaus['abo','projekt']. Alte Dokumente (kauf,team,aboohne 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:1047und die K34-Liste:1343laufen grün; die Entscheidungs-Mails inshop/anfrage.jsundunterkunft/anfrageFunktion.jssenden 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 vonserver/mail-templates/K16 Funktion bereit.htmlals 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 409abo_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 mitwarnung: 'abo_fehlt'(WOMS zeigt sie, P9). Der Tagesabgleich (Task 8, Zeiten Task 10) fasstprojekt-Dokumente in der Aufbauphase nicht an. Ab dem ersten Abo gilt die Abo-Regel fürprojektwie fürabo: 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>undgit 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.
- 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. - 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. - 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.
- 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;gescheitertundwiderrufenergebennull; 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. - 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 409abo_fehltmit Grund und nichts geschrieben (einzige Ausnahme:projektbei einem Kunden, der noch nie ein Abo hatte, ergibt 200 mitwarnung), 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(listPlanUsageZ. 37-74,computeLedgerZ. 77-129) - Test:
test/hourLedgerVorschuss.test.js(neu);test/hourLedger.test.jsbleibt 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, vorschussMinutesRegel für
computeLedger:- Enthält
usagekeinen Eintrag miteinrichtung === true, läuft die bisherige Schleife unverändert;vorschussMinutesist 0, jeder Eintrag bekommteinrichtung: falseundvorschussMinutes: 0. - 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 (nurexpiresAt > date), zuerst alle Einrichtungseinträge, dann die übrigen, jeweils in Datumsreihenfolge, älteste Gutschrift zuerst. - Der ungedeckte Rest ist bei Einrichtungseinträgen
vorschussMinutes, sonstoverageMinutes.active: falseändert die Zuordnung nicht, nurbalanceMinutesundcredits[].remainingwerden 0 wie heute.
- Enthält
-
Step 1: Failing test
test/hourLedgerVorschuss.test.js(Kopf wietest/hourLedger.test.js:1-12,startAppwriteMock()ohne Schema;computeLedger,listPlanUsage,getLedgerdynamisch 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:
listPlanUsageundcomputeLedgerä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 nachGET /: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(hinterrequireStaffJwtausroutes/team/index.js){ produkt: 'hosting'|'wartung'|null, ledger: { balanceMinutes, vorschussMinutes, overageMinutes, nextExpiry } | null }produktwie/:id/abo(Produkt des aktuellen Abos, unbekannt giltwartung, ohne Abonull);ledgernur beiwartung, auch bei beendetem Abo (getLedger(id, { active })mitactive = ACTIVE_STATUSES.has(status)), sonstnull. Fehler:500 { error }. -
Step 1: Failing test
test/teamStundenkonto.test.js(Kopf wietest/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. 11EXPECTEDaufids('K', 41)undids('T', 24); Z. 718TEAM_FOOTERum'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.“ K38projekt_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) K38anhang: „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(importiertloadTemplate,renderMail,templateIdsausserver/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.jsanpassen
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(k29MailZ. 100,k30MailZ. 113) - Test:
test/aboFunktionenMails.test.js(neu);test/zeitenMails.test.jsbleibt 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 }wiezeiten/mails.js;kundeist 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 Blockfunktionen_abo, im Kopfkommentar erklärt),server/routes/abo.js(hatAboFunktionenZ. 422, Blöcke Z. 502 und Z. 981) - Test:
test/publicForms.test.js(Tests am Ende anhängen, Bestand unberührt; nutztkuendigeMitK02,kuendigeMitK03,mitFunktion,WARTUNGaus 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_aboin K02 und K03 (K03 innerhalb vonabo), 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 mitistAboModellim Zustand ungleichoff). 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 voneinschalten) - 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 nieRegeln: K38 an die Adresse des Kunden (BCC Team wie
sendCustomerMail) fürnews|termine|shop|unterkunft, nicht fürzeiten(dort sendetholeZeitenNachK27 und bleibt). Anhänge übermeldungDeps.anhaenge(key); sind es keine (News) oder wirft der Aufruf, geht K38 ohne Anhang raus (Blockanhangaus) und T24 trägtanhangFehlt, wenn die Funktion Rechtsunterlagen hat und der Aufruf warf. T24 (alle fünf Schlüssel) nur beiby === 'kunde'. Blockvorschuss:abrechnung === 'abo',aboProduktistwartungund das Stundenkonto zeigtbalanceMinutes <= 0odervorschussMinutes > 0;kontostand = kontostandText(ledger). Kunde ohne Adresse: K38 entfällt (Log). Versandfehler: Log mit Art und Status, kein Wurf.einschaltenruft die Meldung genau dann, wenn es in diesem Aufruf den Zustand gesetzt oder das Ticket angelegt hat (Heilung zählt,bereits_eingeschaltetnicht; beizeitennur beichanged === true). -
Step 1: Failing test
test/funktionEinschaltenMeldung.test.js(Kopf wie P5test/funktionEinschalten.test.jsTask 9: Schema, Zeiten-Neubau-Stub, Wartung für c1, Projekte p1 und p2; dazu SchemahourCredits,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.jsund der Aufruf ineinschalten
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(adminActivateZ. 658,ticketNachlegenZ. 960),server/routes/admin/funktionenAdmin.js(sendErrorZ. 46,POST /:customerId/:keyZ. 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ßerincompleteundincomplete_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 einTypeError.abrechnung: nullist das heutige Verhalten, Zeichen für Zeichen (direktactive, kein Ticket). Mit'abo'|'projekt':- Dokument mit
unlockedByVon(doc) === 'kauf'(Bestand): wienull, einschließlichassertAboLaeuftFuerAktivierungbeim Terminplaner. - Sonst
aboDeckt(customerId, key); nichtok→bestellFehler(409, <Text aus EINSCHALTEN_TEXTE.abo[grund]>, 'abo_fehlt', { grund, wechsel: grund === 'falsches_abo' })vor jedem Schreiben. Das gilt auch fürprojekt(E21), mit einer Ausnahme (E9, Aufbauphase):abrechnung === 'projekt',grund === 'kein_abo'undohneAboBisher(customerId). Dann läuft die Aktivierung durch (Schritte 3 und 4), undadminActivatesetztrueckmeldung.warnung = { code: 'abo_fehlt', grund: 'kein_abo' }(rueckmeldungist 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);ensureFeatureDocläuft im Abo-Modell erst innerhalb voneinschalten, damit eine Ablehnung kein leeres Dokument hinterlässt. - Dokument
disabledmitdisabledReason === 'team': wie heute direktactive(activate). - Sonst
einschalten({ customerId, projectId, key, by: 'team', abrechnung, staff: { name: by, adminNote }, nachweis, now }).nachweisist beitextformein Aufruf vonspeichereZustimmungmit denselben Feldern wie der heutige Textform-Zweig; er läuft vor dem Zustand. Die Rückgabe ist das Dokument auseinschalten.siteFeatures.jslädtfunktionEinschalten.jsdynamisch (Importkreis).
- Route:
modell = action === 'activate' && (await aboModellFuerKunde(customerId)). Nur dann zähltreq.body.abrechnung(Vorgabe'abo', anderer Wert 400Unbekannte Abrechnung) und gilt der Weg überadminActivate(…, { abrechnung })für alle fünf Schlüssel, auchzeiten(stattzeitenAktivieren); der K16-Zweig (freigeschaltet,unterlagenAlsAnhang) entfällt dort, die Unterlagen gehen mit K38. Sonst wirdabrechnungignoriert und alles läuft wie heute.sendErrorgibt zusätzlicherr.body(z. B.code,grund,wechsel) in die Antwort, wenn es gesetzt ist. Die Route übergibtrueckmeldung = {}anadminActivateund ergänzt die 200-Antwort umwarnung: 'abo_fehlt'undgrund: 'kein_abo', wennrueckmeldung.warnunggesetzt ist (sonst fehlen beide Schlüssel). Der alte Wertwarnung: 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 insetupohneticketId(außerzeiten, das hatticketNachHolen) geht übereinschalten(Heilung) statt überticketNachZahlung; kein Stripe-Aufruf.
- Dokument mit
-
Step 1: Failing test
test/funktionenAdminAbo.test.js(Kopf wietest/teamRoutes.test.js:1-24mit Schema fürportalFeatures,featureConsents,subscriptions,contractEvents; Router/api/team;auth = { headers: { authorization: 'Bearer jwt-staff' } }; Projektep1,p2für c1,p3für c2; Wartung für c1 (subscriptionDoc('sub_h', wartungDoc)); Pilot['k@example.invalid']; Mail-Senke;beforeEachleertportalFeatures,workorders,featureConsents,subscriptionsundmails.sentund legt Wartung für c1 neu an;config.shop.modeund.pilotEmailswerden inafter()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 nieAblauf wie
termine/aboAbgleich.js: eine Listenabfrage (featureKeyinABGLEICH_KEYS,stateungleichoff), nuristAboModell, je Kunde das Abo einmal lesen (aboLaeuftFuer(customerId, key)). Geschrieben wird unter der Sperrefeature-admin:<projectId>nach frischem Lesen von Dokument und Abo; Mails und Marker danach. Lesefehler: Kunde überspringen,fehlerzählt (fail-closed).setup|activeund das Abo deckt die Funktion nicht:disable(id, { reason: 'abo_ende', adminNote: 'System: Abgeschaltet: Abo beendet' }); beiunterkunftdanachleereCache(siteKey)ausunterkunft/kontext.js.- Aufbauphase (E9, E21): Ein Dokument mit
unlockedBy === 'projekt'im Zustandsetup|activewird nicht abgeschaltet, solangeohneAboBisher(customerId)wahr ist (die Website ist noch im Bau, der Kunde hatte nie ein Abo). Ab dem ersten Abo gilt fürprojektdieselbe Regel wie fürabo(deckt es die Funktion nicht, endet sie mit K39).ohneAboBisherwird nur gelesen, wenn einprojekt-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). disabledmit Grundabo_endeund das Abo deckt wieder, und die Daten sind noch da (News: Dokument nochdisabled; Terminplaner:ladeEinstellung(id) !== null; Shop und Unterkunft:adminNoteohne „gelöscht am“): Rückkehr. MitactivatedAtwirdactivate(id, { adminNote: 'System: Wieder eingeschaltet: Abo läuft wieder' }), sonststateFields('setup')mit geleerten Abschaltfeldern. Keine Mail.- Abgeschaltet ohne Marker
aboEndeMailAt:meldeBeendet(K39).artisthosting, wennaboProdukt(customerId).produkt === 'hosting', sonstabo_ende.loeschdatum = disabledAt + loeschFristTage(key)Tage. Kein Kundenkonto oder keine Adresse: Marker setzen, nichts senden.
-
Step 1: Failing test
test/aboFunktionenAbgleich.test.js(SchemaportalFeatures,subscriptions,contractEvents; Mail-Senke;beforeEachleertportalFeatures,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:
gleicheAboFunktionenAbführt zwei weitere Schritte aus (Zählergeloescht):- News:
disabled/abo_ende, das Abo deckt nicht,disabledAt + 30 Tageerreicht (Berliner Kalendertag wietermine/frist.js): erst alle Beiträge des Projekts samt Bild löschen (listPosts(projectId):hangingundexpired), dann das Dokument aufoffmit geleerten FeldernticketId,activatedAt,aboEndeMailAt,disabledAt,disabledReason. Schlägt ein Löschen fehl, bleibt das Dokumentdisabledundfehlerzä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_endemit „Shopdaten gelöscht am“ bzw. „Buchungsdaten gelöscht am“ inadminNote(die bestehenden 90-Tage-Jobsshop/jobs.jsundunterkunft/aufbewahrung.jshaben gelöscht): Dokument aufoffzurücksetzen (Felder wie bei News), unabhängig vom Abo. Ohne Marker bleibt alles, die Daten löschen die bestehenden Jobs. Der Terminplaner setzttermine/jobs.jsselbst aufoff.
- News:
-
Step 1: Failing tests (in
test/aboFunktionenAbgleich.test.js; Schema umnewsPostsergänzen,beforeEachleert zusätzlichnewsPosts)
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(gleicheAboAbZ. 397 undendeTermineFuerKundeZ. 272: Abo-Modell-Dokumente überspringen),server/services/zeiten/aboAbgleich.js(istGekoppeltZ. 83, Aufrufe Z. 258 und Z. 267),server/services/zeiten/behalten.js(behaltenMoeglichZ. 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/k30Mailmitmodell(Task 4). -
Produces: Terminplaner: Abo-Modell-Dokumente erscheinen weder in
gleicheAboAbnoch in den Kandidaten vonendeTermineFuerKunde(Zählerausgenommenundgeprueftzählen sie nicht). Zeiten:istGekoppeltgilt fürunlockedByaboundprojekt; Aufbauphase (E9, wie Task 8):projekt-Dokumente im Zustandsetup|activebleiben unberührt, solange die Abo-Lagelaeuft: falsemeldet undohneAboBisher(customerId)wahr ist (Filter inbearbeiteKundevor dem Zählen vongeprueft; ein Lesefehler überspringt den Kunden wie beim Lesen der Abo-Lage);k29Mailundk30Mailbekommenmodell: istAboModell(doc);behaltenMoeglichist für Abo-Modell-Dokumentefalse(409NICHT_MOEGLICHausstarteBehalten). Gratis-Zeiten ohne Marker behalten K29, K30 und den Behalten-Weg. -
Step 1: Failing test
test/aboFunktionenKoexistenz.test.js(Kopf wietest/termineAboAbgleich.test.js:1-60undtest/zeitenAboAbgleich.test.jsfü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 vongleicheAboFunktionenAboder{ uebersprungen: true }, wenn noch ein Lauf läuft (Merker im Modul, imfinallygelöscht); wirft nie.server/index.jsstartet 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(switchToWartungZ. 347,performSwitchZ. 366, Ereignis Z. 425),server/routes/abo.js(POST /switchZ. 373,GET /Z. 167) - Test:
test/planSwitch.test.js(Tests am Ende anhängen; Kopf,resetHosting,previewInvoice,switchInvoice,customerCookiesind 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/switchliestbody.funktionnur beineueAnsicht(req). Gültig ist{ key, projectId }mitkeyaus den Schlüsseln, derenENTHALTEN_INnur['wartung']ist (news, termine, shop, unterkunft, zeiten; nichtanalyse) undprojectIdeines Projekts des Kunden (assertOwnProject). Alles andere wird verworfen, der Wechsel läuft trotzdem (die Bequemlichkeit darf den bezahlten Vertrag nie blockieren).details.funktionbleibt weg, wennJSON.stringify(details)damit über 2000 Zeichen käme (Schutz des Nachweises).GET /api/abo:subscription.wechselFunktionnur beineueAnsicht(req), sonst fehlt der Schlüssel. Wert: das jüngstewechsel-Ereignis des aktuellen Abos isterledigt, hatdetails.funktion, der Erledigungszeitpunkt (doneAt, sonstdetails.paidAt, sonstdetails.effectiveAt) liegt höchstens 14 Tage zurück, und das Funktionsdokument des Projekts fehlt oder istoff; sonstnull.completeSwitchundmarkSwitchFailedbleiben 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, ZweigwechselZ. 1088) - Test:
test/publicForms.test.js(Tests am Ende anhängen; nutztwithdraw(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 nieAlle Dokumente des Kunden mit
istAboModell, Zustandsetup|activeund einem Schlüssel, der nur im Wartungs-Abo steckt (ENTHALTEN_IN-Wert genau['wartung']: news, termine, shop, unterkunft, zeiten; nichtanalyse): je Dokument unterfeature-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). DanachmeldeBeendet(doc, { art: 'hosting' })für alle außerzeiten(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.withdrawOrderruft nach dem Setzen der Ereignisse aufwiderrufen:endeTermineFuerKunde(unverändert),beendeWartungsFunktionen, undendAnalyseForCustomernur, wennaboModellFuerKunde(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:
beendeWartungsFunktionenundwithdrawOrder
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(KONTINGENTZ. 38),server/services/termine/einbauanleitung.js(DATENSCHUTZ_REGELZ. 487,abschnittAbrechnungZ. 533,kurzSchritteZ. 663),server/services/shop/ticketDetails.js(Z. 57),server/services/unterkunft/ticketDetails.js(Z. 49) - Test:
test/einrichtungsTicketTexte.test.js(neu)
Interfaces:
-
Consumes:
doc.einrichtungAbrechnungausctx.docbzw.feature(P5 Task 8),ABRECHNUNGS_ZEILEN(ticketAdmin.js, P5; die Schlusszeile hängtcreateFeatureTicketan). -
Produces: Für Dokumente mit
einrichtungAbrechnungabooderprojektenthalten die drei Builder (termineTicketDetails,shopTicketDetails,unterkunftTicketDetails) und der Terminplaner-Generator (einbauanleitung(...).textund.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_REGELnennt statt „wird nicht auf das Stundenkontingent angerechnet“ beiabo„gehört zur Einrichtung und zählt mit zum Stundenkonto“, beiprojekt„gehört zur Einrichtung und ist im Projekt enthalten“ (DATENSCHUTZ_REGELwird dafür zu einer Funktion vonfeature, 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 beiabo|projektmit „Ticket erledigen und die Zeit im Arbeitsblatt buchen.“ Der Notfalltext des Terminplaners lässtKONTINGENTweg. Dokumente ohne Marker bleiben Zeichen für Zeichen wie heute. -
Step 1: Failing test
test/einrichtungsTicketTexte.test.js(Kopf mitstartAppwriteMock()unduseTestEnv; Builder undeinbauanleitungdynamisch 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(sendeEntscheidungZ. 283),server/services/unterkunft/anfrageFunktion.js(sendeEntscheidungZ. 272),test/mailTemplates.test.js(Z. 9 Kommentar um „K42, K43 Zusage-Mails ohne Preis (Kundenansicht P6)“; Z. 11EXPECTEDaufids('K', 43); weitere Listen-Pins, die der Lauf nennt, nur um K42 und K43 erweitern) - Test:
test/aboFunktionenVorlagen.test.jsundtest/aboFunktionenMails.test.js(aus Task 3 und 4 erweitern),test/shopTeam.test.jsundtest/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
preisundust_hinweis, gesetzt inshop/anfrage.js:295undunterkunft/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 Blockangenommenselbst; 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
angenommenentfällt, ebenso die Platzhalterpreisundust_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 ZusageWebshop einschalten →, bei AbsageZum 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 K34Zum 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:
einrichtungim Join auch für Git-Blätter,vorschussMinutesim Ergebnis und je Eintrag, spätere Gutschrift deckt zuerst den Vorschuss, Vertragsende lässt ihn stehen (Task 1);GET /api/team/customers/:id/stundenkontomit der Form aus V8 (Task 2) -
V10:
abrechnungmit Vorgabeabo, 409abo_fehltmitgrund(Ausnahmekauf; Ausnahme Aufbauphase:projektbei einem Kunden ohne jedes Abo ergibt 200 mitwarnung: 'abo_fehlt', Vertragsfrage 8),einschaltenmit Ticket,disabled/teamdirekt aktiv, Textform wie heute (Task 7) -
V11:
funktionindetails.funktion,wechselFunktionnur neue Ansicht mit 14 Tagen und Status (Task 12); Widerruf beendet Wartungs-Funktionen mitabo_ende, die Analyse bleibt (Task 13) -
V13: K38, K39, T24 (Task 3, 4, 6, 8);
EXPECTEDintest/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
aboundprojekt, 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
- 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, Blocktermine), 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 Blockfunktionen_abo. T16 bleibt unverändert und ungenutzt für das Abo-Modell. K38 geht nicht fürzeitenraus, 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 Blockangenommen, die Pins verlangen die alten Mails bytegleich. Vorschlag (so geplant, Task 15): Abo-Modell-Varianten K42 (Webshop) und K43 (Buchungskalender), gewählt insendeEntscheidungüberaboModellFuerKunde; 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 insendeEntscheidung“ ergänzen. - Team-Aktivierung nur für die neue Ansicht. V10 nennt
abrechnungmit 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,einschaltenmit Ticket) gilt nur, wennaboModellFuerKunde(customerId)wahr ist; sonst wirdabrechnungim Body ignoriert und alles läuft wie heute. Miton(P10) gilt es für alle. P9 sollteabrechnungimmer mitsenden. - Fremde Dateien, jeweils minimal. P5:
server/services/funktionEinschalten.js(ein Aufruf vonmeldeEingeschaltet; P5 Vertragsfrage 5a damit erledigt: K27 bleibt für Zeiten,ticketNachlegenund 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 Parametermodell),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.jsundunterkunft/anfrageFunktion.js(nur der Aufruf insendeEntscheidung; P5 ändert inshop/anfrage.jsalleinstelleAnfrage, die Zeilen liegen weit auseinander). Neu und nur von P6 genutzt:aboAufbauphase.js. Vorschlag: P6 bekommt genau diese Stellen zugewiesen. - Abo-Modell-Dokument (V11 sagt
unlockedBy === 'abo').unlockedByallein reicht nicht: die Gratis-Zeiten der alten Ansicht tragenaboohne Marker und behalten K29, K30 und den Behalten-Weg. Vorschlag (so geplant):istAboModellverlangt zusätzlicheinrichtungAbrechnungausabo|projekt, und der Widerruf des Wechsels beendet auchprojekt-Dokumente, denn sie laufen nach E21 nur mit dem passenden Abo. - 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).
- „Guthaben reicht nicht“ (T24, E8). Es gibt keine Stundenschätzung je Funktion. Vorschlag (so geplant): Der Block
vorschusserscheint, wenn das Stundenkonto beim EinschaltenbalanceMinutes <= 0oder schon einen Vorschuss zeigt. Eine feinere Regel (zum Beispiel Schätzwert je Funktion) wäre ein eigener Beschluss. - Behalten-Weg der Zeiten (30 €). Für Abo-Modell-Dokumente gesperrt (409). Die Route
POST /api/funktionen/zeiten/behaltenund die Texte K29/K30 räumt erst P10 für alle auf (Spec 5.5). - 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 mitwarnung: 'abo_fehlt'undgrund: '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) fassenprojekt-Funktionen in der Aufbauphase nicht an; ab dem ersten Abo gilt die Abo-Regel wie beiabo(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 nebenprojects; der alte booleschewarnung: truefü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ässtaboFehltTextfürkein_abogelten; (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.