# Plan 2 – Sichtbarer Teil: Verträge, Hosting-Abo, Zusatzspeicher, Wechsel (mit Vertragsbedingungen Version 4) > **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. **Ziel:** Kunden sehen den Bereich „Verträge“, können das Hosting-Abo (15 €) bestellen, bekommen im Wartungs-Abo beliebig viele Postfächer, buchen Zusatzspeicher per Plus/Minus und wechseln jederzeit vom Hosting- ins Wartungs-Abo. All das geht zusammen mit den Vertragsbedingungen Version 4 live. **Aufbau:** - **Grundlage aus Plan 1:** Produktkatalog in `server/config.js` und `server/services/products.js`. - **Drei neue Dienste:** - `contractEvents.js` für Nachweise, Widerrufsfristen und Speicher-Aufgaben - `mailStorage.js` für Plus und Minus - `planSwitch.js` für den Wechsel - **Abrechnung:** Stripe bleibt die Wahrheit. Dieselbe Subscription bekommt eine zweite Position (Zusatzspeicher) bzw. einen neuen Preis (Wechsel). - **Oberfläche:** Vanilla JS in `public/app.js` und `public/dashboard.html`. - **Texte:** Rechtstexte als HTML mit PDF, Mails aus `server/mail-templates/`. - **Andere Repos:** Website und Ticketsystem liegen je in einem eigenen Repo und bekommen einen eigenen Worktree. **Technik:** - Node 20, ESM, Express, stripe-node 19.3.0 (API `2025-10-29.clover`) - Appwrite-REST, `node --test` und die Nachbauten aus Plan 1 (`test/support/`) - Playwright-Prüfungen im Scratchpad - Website: React/Vite mit vitest - Ticketsystem: React/Vite **Spec:** `docs/superpowers/specs/2026-09-27-vertraege-hosting-abo-design.md`, Abschnitte 1–14 und Anhang (Abschnitt 14 = Entscheidungen der Rechtsrunde, geht vor). ## Global Constraints **Preise und Laufzeiten** - Hosting 15,00 €, Wartung 50,00 €, Zusatzspeicher 1,00 € je 5 GB und Monat. Überall, wo ein Preis steht, steht auch „Gemäß § 19 UStG ohne Umsatzsteuer“. - Zusatzspeicher: 0 bis 10 Pakete. Plus und Minus gibt es nur bei Stripe-Status `active` und ohne Kündigung (`cancelAtPeriodEnd` oder `cancelAt` gesetzt heißt gekündigt). Über „Verträge hier kündigen“ (Task 10) sind Verringerungen jederzeit möglich, auch bei gekündigtem Abo oder offener Zahlung. - **Wechsel:** - Nur ein Hosting-Abo mit Status `active` kann wechseln, und nur ohne Kündigung. - Nur mit Sofortbeginn, das Pflicht-Häkchen ist gesetzt. - Neue Mindestlaufzeit 12 Monate ab Wechseltag (`termStartAt`). Der Abbuchungstag wird der Wechseltag. - Der Rückwechsel läuft nur von Hand und hat keinen Knopf. **Wörtliche Beschriftungen und Adressen** - Knopfbeschriftungen: „Zahlungspflichtig bestellen“ (Neuabschluss, Wechsel, Speicher-Plus), „Speichern“ (Speicher-Minus), „Zum Wartungs-Abo wechseln“, „Postfach anfragen“, „Mein Vertrag“, „Abo abschließen“. Unverändert bleiben „Verträge hier kündigen“, „Vertrag widerrufen“ und „Abo verwalten & Rechnungen“. - Hashes: - `#vertraege` - `#stunden` - `#bestellen-hosting` und `#bestellen-wartung` - `#wechsel` Die alten `#wartung` und `#abo` führen zu `#vertraege`. `#gutschrift` bleibt. **Rechtstexte** - **Adresse und PDF:** Die Bedingungen bleiben unter `/abo-bedingungen.html`. Das neue PDF heißt `public/WEBklar-Abo-Vertragsunterlagen.pdf`. - **Archiv Version 3:** `public/vertragsunterlagen/v3/abo-bedingungen.html` und `public/vertragsunterlagen/v3/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf`. `public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf` bleibt als Fassung 3 bestehen. - **Versionsstand:** `config.abo.termsVersion` hat das Format `JJJJ-MM-TT-v4` und gilt für beide Abos. Das Datum ist der Tag der Freigabe durch den Nutzer. Das Dokument zeigt „Version 4 · Stand TT.MM.JJJJ“ mit demselben Datum. - **Widerrufsbelehrung:** Das gesetzliche Muster bleibt wortgleich mit Version 3. **Namen im Code** - Mailvorlagen: neu sind K12 (Wechsel), K13 (Zusatzspeicher), T12 (Wechsel, intern) und T13 (Speicher-Aufgabe, intern). K11 ist schon vergeben. Nach der Rechtsrunde kommen K14 (Wechsel nicht zustande gekommen) und T14 (Löschfristen, intern) dazu. - `contractEvents.type` ist `abschluss`, `wechsel`, `speicher`, `kuendigung` oder `widerruf`. `status` ist `offen`, `erledigt`, `widerrufen` oder `gescheitert` (Wechsel ohne Zahlung, nach der Rechtsrunde). - Stripe-Metadaten am Abo: `webklarCustomerId`, `email`, `productKey`, `termStartAt`, `termsVersion`, `termsAcceptedAt`, `earlyStartConsentAt`, `consentIp`, `consentUserAgent`, `switchedFrom`, `switchedAt`. - Testrabatte: `WEBKLAR-TEST-050` (Wartung, amount_off 4950, besteht schon) und `WEBKLAR-TEST-050-HOSTING` (Hosting, amount_off 1450). - lookup_keys werden in Stripe nie übertragen (`transfer_lookup_key`). Ein neuer Preis bekommt einen neuen lookup_key im Katalog, denn Zuordnung und Stundenregel hängen am lookup_key (Plan 1). **Deploy und Produktion** - **Kundenportal:** Push nach `main` nur mit Freigabe des Nutzers. - **Website:** Push nach `main` von `JUSN/Webklar` nur mit Freigabe des Nutzers. - **Ticketsystem:** Der Nutzer pusht den Worktree auf `test`. **Niemals einen Feature-Branch pushen**, denn das Ticketsystem deployt jeden gepushten Branch. - Lesen oder Schreiben in Produktion (Appwrite, Stripe live) nur mit ausdrücklicher Freigabe. **Tests:** gegen die Nachbauten, nie gegen Produktion. `npm test` muss unter Node 20 grün sein. ## Review Focus 1. **Plus, dann Minus, dann wieder Plus im selben Monat.** Pakete, die für diesen Monat schon bezahlt sind, kosten kein zweites Mal. Test: Task 8, „erneutes Plus bis zur bezahlten Menge kostet nichts“. 2. **Die Wechsel-Rechnung braucht eine Bankbestätigung (3-D Secure) oder scheitert.** - Karte: Der Wechsel bleibt ausstehend (`pending_update`). Das Abo bleibt `active` als Hosting-Abo. Das Portal zeigt „Ihr Wechsel wartet auf die Zahlung“ mit dem Link zur Stripe-Zahlungsseite. Verfällt die ausstehende Aktualisierung, wird das Ereignis `gescheitert` und K14 geht raus (Rechtsrunde). - SEPA: Der Wechsel gilt sofort. Eine geplatzte Lastschrift führt zu T12 `lastschrift_gescheitert` und wird von Hand rückgängig gemacht. - Der Wechsel bleibt als Ereignis nachweisbar. - Test: Task 9, Zahlung ausstehend liefert `paymentUrl`, Ereignis `offen`. 3. **Gekündigtes Abo, Status `trialing` oder `past_due`.** Wechsel, Plus und Minus werden im Portal mit 409 und klarer Meldung abgelehnt, die Knöpfe sind nicht zu sehen. Tests: Task 8 und Task 9 („gekündigt: 409“), Task 15 (Knöpfe ausgeblendet). 4. **Öffentliche Kündigung oder öffentlicher Widerruf ohne passenden Vertrag.** Das gilt für eine unbekannte E-Mail-Adresse, „nur Zusatzspeicher“ ohne gebuchten Speicher und einen Widerruf nach Fristende. Der Kunde bekommt trotzdem eine Eingangsbestätigung („wir prüfen und melden uns“), es passiert keine automatische Änderung, und das Team bekommt einen Hinweis. Tests: Task 10. 5. **Bestandskunde mit Bedingungen Version 3** (oder Testkonto mit v1/v2). - Der Link „Vertragsbedingungen“ zeigt die Fassung 3, und K01 hängt das v3-PDF an. - Plus verlangt die Zustimmung zu den Regeln zum Zusatzspeicher. - Tests: Task 7 (`termsDocFor`), Task 8 (Plus ohne Zustimmung ergibt 400), Task 12 (K01-Anhang). --- ## Vorbereitung - [ ] Plan 1 ist live: `git -C /home/webklar/apps/kundenbereich.webklar.com/build log --oneline -8` enthält die Commits aus Plan 1. - [ ] Portal-Worktree aktualisieren: ```bash git -C /home/webklar/apps/kundenbereich.webklar.com/dev-vertraege fetch origin git -C /home/webklar/apps/kundenbereich.webklar.com/dev-vertraege rebase origin/main cd /home/webklar/apps/kundenbereich.webklar.com/dev-vertraege && npm test ``` Erwartet: `# fail 0` - [ ] Website-Worktree anlegen: ```bash git -C /home/webklar/apps/webklar.com/build worktree add -b feature/hosting-abo ../dev-hosting-abo origin/main ln -s ../build/node_modules /home/webklar/apps/webklar.com/dev-hosting-abo/node_modules ``` - [ ] Ticketsystem-Worktree anlegen: ```bash git -C /home/webklar/apps/ticket.webklar.com/build fetch origin git -C /home/webklar/apps/ticket.webklar.com/build worktree add -b feature/abo-stundenkonto ../dev-abo-stunden origin/test ``` `node_modules` wird wie in den bestehenden Ticketsystem-Worktrees verlinkt. ## Dateien | Datei | Aufgabe | |---|---| | `public/abo-bedingungen.html` (ändern) | Vertragsbedingungen Version 4 für beide Abos | | `public/vertragsunterlagen/v3/*` (neu) | Archiv der Fassung 3 | | `public/WEBklar-Abo-Vertragsunterlagen.pdf` (neu, erzeugt) | PDF der Fassung 4 | | `scripts/build-legal-pdfs.sh` (ändern) | erzeugt das neue PDF, Fassung 3 bleibt | | `public/datenschutz.html`, `docs/WEBklar-Datenschutzerklaerung.pdf` (ändern) | Datenschutz | | `docs/leitfaden-empfehlungsgeber.html` und PDF (ändern) | beide Abos | | `server/services/contractEvents.js` (neu) | Nachweise, Widerrufsfristen, Speicher-Aufgaben | | `server/services/mailStorage.js` (neu) | Zusatzspeicher: Plan, Vorschau, Änderung, bezahlte Menge | | `server/services/planSwitch.js` (neu) | Wechsel: Vorschau und Ausführung | | `server/services/products.js` (ändern) | `termsDocFor`, `productView` | | `server/services/stripeBilling.js` (ändern) | `ensurePrice`, Testrabatte, Kundenportal, Spiegel (`termStartAt`, `storageUnits`), Checkout je Produkt | | `server/routes/abo.js` (ändern) | `GET /api/abo`, Checkout, Speicher, Wechsel, öffentliche Formulare, Webhook | | `server/routes/storageTasks.js` (neu) | Admin-API „E-Mail-Speicher“ | | `server/routes/portalAdmin.js` (ändern) | bindet `storageTasks` ein | | `server/routes/admin/customers.js` (ändern) | `GET /:customerId/abo` für das Ticketsystem | | `server/services/offers.js`, `server/routes/offers.js` (ändern) | Angebote je Produkt | | `server/services/takeoverCredits.js` (ändern) | Texte „Abo“ statt „Wartungs-Abo“ | | `server/mail-templates/*` (ändern und neu) | Mails mit Produktnamen sowie K12, K13, T12, T13 | | `scripts/setup-abo-collections.mjs` (ändern) | neue Felder und Collection `contractEvents` | | `scripts/stripe-products.mjs` (neu) | Stripe-Produkte einrichten bzw. prüfen (Livegang) | | `public/dashboard.html`, `public/app.js`, `public/kundenbereich.css` (ändern) | Navigation und Ansicht „Verträge“, Bestellseiten, Speicher, Wechsel | | `public/kuendigen.html`, `public/widerrufen.html`, `public/legal-form.js` (ändern) | Auswahl des Vertrags bzw. der Bestellung | | `public/admin.html`, `public/admin.js` (ändern) | Reiter „E-Mail-Speicher“, Abo-Anzeige, Angebot mit Produkt | | `test/*.test.js` (neu und ändern) | Tests je Task | | Website `src/pages/HostingAbo.tsx` (neu), `WartungsAbo.tsx`, `AGB.tsx`, `src/App.tsx`, `src/components/Footer.tsx`, `src/test/abos.test.tsx` (neu) | Website | | Ticketsystem `src/lib/customerAdminApi.js`, `src/hooks/useCustomerAbo.js` (neu), `src/components/CreateWorksheetModal.jsx`, `src/components/WorksheetList.jsx` | Schalter „Vom Abo-Stundenkonto abziehen“ nur bei Wartung | --- ## Block A – Texte (Entwürfe für die Rechtsrunde) Die Tasks 1–4 liefern Entwürfe. **Gate:** Der Nutzer gibt die Texte in seiner Rechtsrunde frei, bevor Task 20 startet. Die Tasks 5–19 dürfen parallel laufen. ### Task 1: Vertragsbedingungen Version 4, Archiv der Fassung 3, PDFs **Files:** - Create: `public/vertragsunterlagen/v3/abo-bedingungen.html` (Kopie der heutigen Datei), `public/vertragsunterlagen/v3/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf` (Kopie) - Modify: `public/abo-bedingungen.html`, `scripts/build-legal-pdfs.sh`, `server/config.js` (`abo.termsVersion`) - Create (erzeugt): `public/WEBklar-Abo-Vertragsunterlagen.pdf` - Test: `test/legalTexts.test.js` **Interfaces:** - Consumes: Spec, Abschnitte 3 und 8 (Inhalt) - Produces: - Anker in der Fassung 4: `#widerruf`, `#hosting` (Leistungsbeschreibung A), `#wartung` (Leistungsbeschreibung B), `#zusatzspeicher`, `#wechsel`, `#avv` - `config.abo.termsVersion = '-v4'`. Task 20 setzt das Freigabedatum. - [ ] **Step 1: Write the failing test** – `test/legalTexts.test.js` ```js const text = (file) => readFileSync(new URL(`../public/${file}`, import.meta.url), 'utf8') .replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ') const between = (s, a, b) => s.slice(s.indexOf(a), s.indexOf(b, s.indexOf(a))) const v3 = text('vertragsunterlagen/v3/abo-bedingungen.html'), v4 = text('abo-bedingungen.html') test('Widerrufsbelehrung wortgleich mit Fassung 3', () => { const muster = (s) => between(s, 'Widerrufsrecht Sie haben das Recht', 'Ende der Widerrufsbelehrung') assert.ok(muster(v3).length > 1500) assert.equal(muster(v4), muster(v3)) }) test('Fassung 3 unverändert archiviert', () => { const sha = createHash('sha256').update(readFileSync(new URL('../public/vertragsunterlagen/v3/abo-bedingungen.html', import.meta.url))).digest('hex') assert.equal(sha, execSync('git show 18c3cdf:public/abo-bedingungen.html | sha256sum').toString().slice(0, 64)) }) test('Fassung 4: beide Abos, Postfächer, Zusatzspeicher, Wechsel', () => { for (const s of ['15,00 €', '50,00 €', 'beliebig viele Postfächer', 'zusammen 5 GB', '1,00 €', 'Zusatzspeicher', '12 Monate', 'statische', 'Hetzner', '§ 19 UStG']) assert.ok(v4.includes(s), s) const wartung = between(v4, 'Leistungsbeschreibung B', 'Widerrufsbelehrung') assert.ok(!wartung.includes('1 E-Mail-Postfach')) }) test('Version und Stand passen zu termsVersion', () => { const [, y, m, d] = /^(\d{4})-(\d{2})-(\d{2})-v4$/.exec(config.abo.termsVersion) assert.ok(v4.includes(`Version 4 · Stand ${d}.${m}.${y}`)) }) ``` - [ ] **Step 2: Run test to verify it fails** Run: `node --test test/legalTexts.test.js` Expected: FAIL, das Archiv fehlt. - [ ] **Step 3: Fassung 3 archivieren** Die beiden Dateien werden mit `cp` angelegt. `git mv` geht nicht, weil `/abo-bedingungen.html` bleibt. - [ ] **Step 4: Fassung 4 schreiben** - **Vorher klären** (Spec 13, Punkt 7): Der Nutzer sagt, wie Kontaktformulare heute auf den Kundenseiten laufen. Zur Prüfung dient `grep -rl "“. Dasselbe Datum steht in `config.abo.termsVersion`. - [ ] **Step 5: `scripts/build-legal-pdfs.sh` anpassen** - Die Zeile `pdf public/abo-bedingungen.html public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf` wird zu `pdf public/abo-bedingungen.html public/WEBklar-Abo-Vertragsunterlagen.pdf`. Die Fassung 3 wird nicht neu erzeugt. - Run: `CHROME=$(ls -d ~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome | tail -1) scripts/build-legal-pdfs.sh` - Expected: `-> public/WEBklar-Abo-Vertragsunterlagen.pdf` - [ ] **Step 6: Run test to verify it passes** Run: `node --test test/legalTexts.test.js && npm test` Expected: PASS - [ ] **Step 7: Commit** ```bash git add public/abo-bedingungen.html public/vertragsunterlagen public/WEBklar-Abo-Vertragsunterlagen.pdf scripts/build-legal-pdfs.sh server/config.js test/legalTexts.test.js git commit -m "Vertragsbedingungen Version 4 (Entwurf): Hosting- und Wartungs-Abo, Postfächer, Zusatzspeicher, Wechsel; Fassung 3 archiviert Co-Authored-By: Claude Opus 5.5 " ``` ### Task 2: Datenschutzerklärung **Files:** - Modify: `public/datenschutz.html`, `docs/WEBklar-Datenschutzerklaerung.pdf` (erzeugt) - Test: `test/legalTexts.test.js` (ergänzen) **Interfaces:** - Consumes: Spec 8.3, Zeile Datenschutzerklärung - Produces: – - [ ] **Step 1: Failing test** ```js test('Datenschutz: Abos, UptimeRobot, neuer Stand', () => { const ds = text('datenschutz.html') for (const s of ['Abos und Zahlung über Stripe', 'UptimeRobot s. r. o.', 'Obchodná 507/2', '811 06 Bratislava', 'Standardvertragsklauseln', 'im Rahmen unserer Abos']) assert.ok(ds.includes(s), s) assert.ok(!ds.includes('insbesondere das Wartungs-Abo')) assert.ok(!ds.includes('Stand 27.09.2026')) }) ``` - [ ] **Step 2: Run** `node --test test/legalTexts.test.js`. Erwartet: FAIL. - [ ] **Step 3: Abschnitte ändern** - Abschnitte 2, 3, 7, 8, 9 und 12 nach Spec 8.3. - Abschnitt 7 nennt als Nachweisdaten ausdrücklich auch „Wechsel“ und „Zusatzspeicher“. - Stand ist das Entwurfsdatum. - [ ] **Step 4: PDF erzeugen** mit `scripts/build-legal-pdfs.sh`, dann `node --test test/legalTexts.test.js && npm test`. Erwartet: PASS. - [ ] **Step 5: Commit** mit `git add public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf test/legalTexts.test.js` und der Nachricht „Datenschutz (Entwurf): unsere Abos, Überwachung mit UptimeRobot“. ### Task 3: Übernahmegutschrift, Prüftexte, Leitfaden **Files:** - Modify: - `public/app.js`: `TAKEOVER_CONDITIONS`, ab Z. ~1046 - `server/config.js`: `takeover.conditionsVersion` - `server/services/takeoverCredits.js`: Beschriftungen und Fehlermeldungen mit „Wartungs-Abo“ - `docs/leitfaden-empfehlungsgeber.html` und das PDF - Test: `test/legalTexts.test.js` (ergänzen) **Interfaces:** - Consumes: – - Produces: `config.takeover.conditionsVersion` = Entwurfsdatum (Format `JJJJ-MM-TT`) - [ ] **Step 1: Failing test** ```js test('Übernahmegutschrift und Leitfaden: produktneutral', () => { const app = readFileSync(new URL('../public/app.js', import.meta.url), 'utf8') const cond = between(app, 'const TAKEOVER_CONDITIONS', ']') assert.ok(!cond.includes('Wartungs-Abo') && cond.includes('Ihr Abo')) assert.notEqual(config.takeover.conditionsVersion, '2026-09-25') const tc = readFileSync(new URL('../server/services/takeoverCredits.js', import.meta.url), 'utf8') assert.ok(!/Wartungs-Abo (läuft|ist beendet)|kein (laufendes )?Wartungs-Abo/.test(tc)) assert.ok(!tc.includes('config.stripe.priceAmountCents')) const lf = readFileSync(new URL('../docs/leitfaden-empfehlungsgeber.html', import.meta.url), 'utf8') assert.ok(lf.includes('Hosting-Abo') && lf.includes('beliebig viele Postfächer')) }) ``` - [ ] **Step 2: Run.** Erwartet: FAIL. - [ ] **Step 3: Texte ändern** - Der Anspruch lautet künftig: „Leistungen, die Ihr Abo ersetzt (Domain, Hosting bzw. Betrieb der Website, E-Mail-Postfach)“. - Die Prüf-Labels in `takeoverCredits.js` sagen „Abo“. - Die Plausibilitätsprüfung „mehr als das Abo“ (Z. ~641) vergleicht mit `getProduct(subscription?.productKey || 'wartung').amountCents` statt mit `config.stripe.priceAmountCents`. - Der Leitfaden nennt beide Abos mit Preisen, und das PDF wird neu erzeugt. - [ ] **Step 4: Run** `node --test test/legalTexts.test.js && npm test`. Erwartet: PASS. - [ ] **Step 5: Commit** mit der Nachricht „Übernahmegutschrift und Leitfaden: für beide Abos (Entwurf)“. ### Task 4: Website – Hosting-Seite, Wartungs-Seite, Footer, AGB **Files (Worktree `/home/webklar/apps/webklar.com/dev-hosting-abo`):** - Create: `src/pages/HostingAbo.tsx`, `src/test/abos.test.tsx` - Modify: - `src/pages/WartungsAbo.tsx` - `src/App.tsx`: Route `/hosting-abo` - `src/components/Footer.tsx`: Link `{ id: 13, title: "Hosting-Abo", to: "/hosting-abo" }` unter „Seiten“ - `src/pages/AGB.tsx`: Modul C und Nr. 9 **Interfaces:** - Consumes: Spec 3.1, 8.3 (Zeilen AGB und Website) - Produces: Die Route `/hosting-abo` existiert. - [ ] **Step 1: Failing test** – `src/test/abos.test.tsx` (vitest mit Testing Library) ```tsx it("Hosting-Abo-Seite: Preis, Postfach, Links", () => { render() expect(screen.getByText(/15,00 € pro Monat/)).toBeInTheDocument() expect(screen.getByText(/1 E-Mail-Postfach/)).toBeInTheDocument() expect(screen.getByRole("link", { name: "Verträge hier kündigen" })).toHaveAttribute("href", "https://project.webklar.com/kuendigen.html") expect(screen.getByRole("link", { name: /Vertragsunterlagen als PDF/ })).toHaveAttribute("href", "https://project.webklar.com/WEBklar-Abo-Vertragsunterlagen.pdf") }) it("Wartungs-Abo-Seite: beliebig viele Postfächer, Zusatzspeicher", () => { render() expect(screen.getByText(/beliebig viele Postfächer/)).toBeInTheDocument() expect(screen.queryByText("1 E-Mail-Postfach")).toBeNull() expect(screen.getByText(/1,00 €/)).toBeInTheDocument() }) it("AGB Modul C verweist auf das Hosting-Abo", () => { render() expect(screen.getByText(/Hosting-Abo/)).toBeInTheDocument() expect(screen.queryByText(/verlängern sich automatisch/)).toBeNull() }) ``` - [ ] **Step 2: Run** Run: `cd /home/webklar/apps/webklar.com/dev-hosting-abo && npx vitest run src/test/abos.test.tsx` Expected: FAIL (Modul `HostingAbo` fehlt) - [ ] **Step 3: Seiten umsetzen** - `HostingAbo.tsx` ist aufgebaut wie `WartungsAbo.tsx`, mit Inhalten aus Spec 3.1 und 3.2 (statische Websites, Zusatzspeicher, Wechsel jederzeit). - `WartungsAbo.tsx`: - Die Karte „1 E-Mail-Postfach“ heißt jetzt „Beliebig viele Postfächer“ („zusammen 5 GB, Zusatzspeicher je 5 GB für 1,00 €/Monat“). - Dazu kommt ein Hinweis auf das Hosting-Abo. - Das PDF heißt `WEBklar-Abo-Vertragsunterlagen.pdf`. - `AGB.tsx`: Modul C sagt künftig „Hosting bieten wir als Hosting-Abo an; es gelten die Vertragsbedingungen der Abos (Link)“. Bestehende Jahresverträge behalten ihre Fassung. Nr. 9 verliert „Hosting-Leistungen sind jährlich im Voraus zu zahlen“. - [ ] **Step 4: Tests und Build** Run: `npx vitest run && npx vite build` Expected: PASS, Build ohne Fehler - [ ] **Step 5: Commit** im Website-Worktree mit der Nachricht „Hosting-Abo-Seite, Wartungs-Abo mit beliebig vielen Postfächern, AGB Modul C (Entwurf)“ und der Co-Authored-By-Zeile. > ⛔ **Gate Rechtsrunde:** Den Nutzer die Texte aus Task 1–4 prüfen lassen: `abo-bedingungen.html`, das PDF, `datenschutz.html`, die Übernahmegutschrift, den Leitfaden, die Website-Seiten und die AGB. Änderungswünsche fließen in diese Tasks zurück. Task 20 startet erst nach der Freigabe. > ✅ **Rechtsrunde abgeschlossen (28.09.2026):** Der Nutzer hat alle 19 Vorschläge des Übersichtsblatts freigegeben („alle Vorschläge ok“). Die Folgen stehen als Hinweis „Rechtsrunde“ in Task 8, 9, 10, 12 und 20, im neuen Task 21 (Löschfristen) und in Spec Abschnitt 14. --- ## Block B – Server ### Task 5: Datenmodell und Dienst `contractEvents` **Files:** - Modify: `scripts/setup-abo-collections.mjs` - `subscriptions` bekommt `{ type: 'datetime', key: 'termStartAt' }`, `{ type: 'integer', key: 'storageUnits' }`, `{ type: 'integer', key: 'storagePaidUnits' }` und `{ type: 'datetime', key: 'storagePaidPeriodEnd' }`. - `aboOffers` bekommt `{ type: 'string', key: 'productKey', size: 16 }`. - Neue Collection `contractEvents` („Vertragsereignisse“): - Attribute: `customerId` (64), `subscriptionId` (64), `type` (16), `productKey` (16), `details` (2000), `termsVersion` (32), `consentAt` (datetime), `consentIp` (64), `consentUserAgent` (300), `earlyStartConsentAt` (datetime), `status` (16), `dueAt` (datetime), `doneAt` (datetime), `doneBy` (255), `confirmationSentAt` (datetime) - Indizes: `idx_customer` und `idx_type_status` über (`type`, `status`) - Create: `server/services/contractEvents.js` - Modify: `server/config.js` (`collections.contractEvents`) - Test: `test/contractEvents.test.js` **Interfaces:** - Consumes: - Nachbauten und `schemaKeys()` aus Plan 1 (`test/stripeBilling.test.js`). Die Hilfsfunktion wird dazu nach `test/support/schema.js` verschoben. - `withdrawalDeadline(contractAt)` aus `server/services/deadlines.js` - Produces: - **`recordContractEvent({ customerId, subscriptionId, type, productKey, details = {}, consent = null, status = 'erledigt', dueAt = null })`:** liefert das Dokument. `consent` hat die Form `{ termsVersion, consentAt, consentIp, consentUserAgent, earlyStartConsentAt? }`, `details` wird als JSON gespeichert. - **`listContractEvents(customerId)`:** liefert die Dokumente, neueste zuerst, mit geparstem `details`. - **`openWithdrawals(events, now = new Date())`:** liefert `[{ type, eventId, until }]`. Das umfasst: - `abschluss` und `wechsel` - `speicher` mit `details.toUnits > details.fromUnits` Jeweils mit `status !== 'widerrufen'` und `now <= withdrawalDeadline(consentAt).endsAt`. - **`listOpenStorageTasks()`:** liefert `type = speicher` und `status = offen`, sortiert nach `dueAt` aufsteigend. - **`completeContractEvent(id, staffEmail)`:** setzt `status: 'erledigt'`, `doneAt` und `doneBy`. - [ ] **Step 1: Failing test** – `test/contractEvents.test.js` (Appwrite-Nachbau mit Schema aus dem Setup-Skript für `contractEvents` und `subscriptions`) ```js test('Ereignis speichern und lesen (details als JSON)', async () => { await recordContractEvent({ customerId: 'c1', subscriptionId: 'sub_1', type: 'speicher', productKey: 'hosting', details: { fromUnits: 0, toUnits: 2 }, consent: { termsVersion: '2026-10-05-v4', consentAt: '2026-10-05T10:00:00.000Z', consentIp: '1.2.3.4', consentUserAgent: 'UA' }, status: 'offen', dueAt: '2026-10-05T10:00:00.000Z' }) const [e] = await listContractEvents('c1') assert.deepEqual(e.details, { fromUnits: 0, toUnits: 2 }) assert.equal((await listOpenStorageTasks()).length, 1) }) test('Widerrufsfristen je Bestellung', () => { const ev = (type, consentAt, details = {}, status = 'erledigt') => ({ $id: type, type, consentAt, details, status }) const now = new Date('2026-10-10T12:00:00Z') const open = openWithdrawals([ev('abschluss', '2026-09-01T10:00:00Z'), ev('wechsel', '2026-10-05T10:00:00Z'), ev('speicher', '2026-10-06T10:00:00Z', { fromUnits: 2, toUnits: 1 }), ev('speicher', '2026-10-07T10:00:00Z', { fromUnits: 0, toUnits: 1 })], now) assert.deepEqual(open.map((o) => o.type), ['wechsel', 'speicher']) assert.equal(openWithdrawals([ev('wechsel', '2026-10-05T10:00:00Z', {}, 'widerrufen')], now).length, 0) }) test('Aufgabe erledigen', async () => { const [t] = await listOpenStorageTasks() const done = await completeContractEvent(t.$id, 'kenso@webklar.com') assert.equal(done.status, 'erledigt'); assert.equal(done.doneBy, 'kenso@webklar.com') }) ``` - [ ] **Step 2: Run** `node --test test/contractEvents.test.js`. Erwartet: FAIL, das Modul fehlt. - [ ] **Step 3: Umsetzen:** Setup-Skript, Dienst und die Konfiguration `collections.contractEvents` (Umgebungsvariable `APPWRITE_COLLECTION_CONTRACT_EVENTS || 'contractEvents'`). - [ ] **Step 4: Run** `node --test test/contractEvents.test.js && npm test`. Erwartet: PASS. - [ ] **Step 5: Commit** mit der Nachricht „Vertragsereignisse: Nachweise, Widerrufsfristen, Speicher-Aufgaben“. ### Task 6: Stripe-Preise je Produkt, Testrabatte, Kundenportal, Spiegel **Files:** - Modify: `server/services/stripeBilling.js`, `server/services/products.js` - Create: `scripts/stripe-products.mjs` - Test: `test/stripeBilling.test.js` (ergänzen), `test/products.test.js` (ergänzen) **Interfaces:** - Consumes: Katalog und `describeItems` (Plan 1), Nachbauten - Produces: - **`ensurePrice(key)`:** `key` ist `'wartung'`, `'hosting'` oder `'mailStorage'`, zurück kommt `Promise`. - Die Funktion ersetzt `ensureAboPrice`, und alle Aufrufer werden umgestellt. - Sie legt Produkt und Preis an mit `tax_behavior: 'inclusive'`, `recurring.interval: 'month'` und `lookup_key`. - Die Produkt-Metadaten `webklar` sind `wartungs-abo`, `hosting-abo` bzw. `mail-speicher`. - Das Ergebnis wird je Schlüssel zwischengespeichert. - **`ensureTestCoupon(productKey)`:** liefert `'WEBKLAR-TEST-050'` für Wartung bzw. `'WEBKLAR-TEST-050-HOSTING'` für Hosting. `amount_off` ist `amountCents - 50`. - **`ensurePortalConfiguration()`:** Die Überschrift ist `'WEBklar – Abo verwalten'`. Eine bestehende eigene Konfiguration mit anderer Überschrift wird per `billingPortal.configurations.update` angepasst. - **`termStartOf(stripeSub)`:** liefert ein `Date` aus `metadata.termStartAt`, sonst `trial_end`, sonst `start_date`. Es ersetzt `serviceStartOf` in `cancelSubscriptionAt`. - **Spiegel:** `syncSubscription` schreibt zusätzlich `termStartAt` (ISO) und `storageUnits`. - **`termsDocFor(termsVersion)`** in `products.js`: - Endet die Version auf `-v4`, liefert die Funktion `{ url: '/abo-bedingungen.html', pdfFile: 'WEBklar-Abo-Vertragsunterlagen.pdf' }`. - Bei jeder anderen Version: `{ url: '/vertragsunterlagen/v3/abo-bedingungen.html', pdfFile: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf' }`. - **`productView(key)`:** liefert `{ key, name, amountCents, minTermMonths, minutesPerMonth, features }`. - `features` ist eine Liste von `[titel, detail]`, neu im Katalog. Die Texte stammen aus Spec 3.1 und stimmen mit Fassung 4 überein. - Die Oberfläche übernimmt diese Liste und hat keine eigenen `ABO_FEATURES` mehr. - **`scripts/stripe-products.mjs`:** - `node scripts/stripe-products.mjs --pruefen` listet, was fehlt oder abweicht. - Ohne Schalter ruft das Skript `ensurePrice` für alle drei auf, setzt die Produktbeschreibung von Wartung auf den Katalogtext und passt das Kundenportal an. - [ ] **Step 1: Failing tests** ```js // test/products.test.js test('Vertragsunterlagen je Version', () => { assert.equal(termsDocFor('2026-10-05-v4').pdfFile, 'WEBklar-Abo-Vertragsunterlagen.pdf') assert.equal(termsDocFor('2026-09-25-v3').url, '/vertragsunterlagen/v3/abo-bedingungen.html') assert.equal(termsDocFor(null).url, '/vertragsunterlagen/v3/abo-bedingungen.html') }) test('Produktansicht enthält Leistungen ohne 1-Postfach-Regel bei Wartung', () => { const w = productView('wartung').features.flat().join(' ') assert.ok(w.includes('beliebig viele Postfächer') && !w.includes('1 E-Mail-Postfach')) assert.ok(productView('hosting').features.flat().join(' ').includes('1 E-Mail-Postfach')) }) // test/stripeBilling.test.js (Stripe-Nachbau braucht dazu: POST /v1/products, POST /v1/prices, GET/POST /v1/coupons, // GET/POST /v1/billing_portal/configurations und POST /v1/billing_portal/configurations/:id) test('ensurePrice legt Hosting einmal an', async () => { const id1 = await ensurePrice('hosting'); const id2 = await ensurePrice('hosting') assert.equal(id1, id2) const created = st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/prices') assert.equal(created.length, 1) assert.equal(created[0].body['lookup_key'], 'webklar_hosting_monatlich_1500') assert.equal(created[0].body['unit_amount'], '1500') }) test('Testrabatt Hosting: 14,50 € Nachlass', async () => { assert.equal(await ensureTestCoupon('hosting'), 'WEBKLAR-TEST-050-HOSTING') assert.equal(st.calls.find((c) => c.path === '/v1/coupons' && c.method === 'POST').body['amount_off'], '1450') }) test('Spiegel: termStartAt aus Metadaten (Wechsel), sonst Leistungsbeginn; storageUnits', async () => { // sub_s: metadata.termStartAt '2026-10-05T10:00:00.000Z', Zusatzspeicher mit Menge 3 const doc = await syncSubscription('sub_s') assert.equal(doc.termStartAt, '2026-10-05T10:00:00.000Z'); assert.equal(doc.storageUnits, 3) }) ``` - [ ] **Step 2: Run** `node --test test/products.test.js test/stripeBilling.test.js`. Erwartet: FAIL. - [ ] **Step 3: Umsetzen:** `ensurePrice`, `ensureTestCoupon(productKey)`, Kundenportal-Überschrift, `termStartOf`, den Spiegel, `termsDocFor`, `productView` mit `features` im Katalog und das Skript. - [ ] **Step 4: Run** `npm test`. Erwartet: PASS. - [ ] **Step 5: Commit** mit der Nachricht „Stripe: Preise je Produkt, Testrabatt Hosting, Kundenportal ‚Abo verwalten‘, Mindestlaufzeit ab termStartAt“. ### Task 7: Neuabschluss je Produkt und `GET /api/abo` **Files:** - Modify: `server/services/stripeBilling.js` (`createAboCheckout`), `server/routes/abo.js` (`GET /`, `POST /checkout`, `publicSubscription`, `minTermEndOf`, Webhook `checkout.session.completed`) - Create: `test/support/testApp.js` - Test: `test/aboRoutes.test.js` **Interfaces:** - Consumes: - `ensurePrice`, `ensureTestCoupon`, `termsDocFor`, `productView` (Task 6) - `recordContractEvent`, `listContractEvents`, `openWithdrawals` (Task 5) - Produces: - **`createAboCheckout(customer, consent, productKey, offer)`:** - Position, Beschreibung, `custom_text` (Preis und „Mindestlaufzeit 12 Monate, danach monatlich kündbar“) und Testrabatt richten sich nach dem Produkt. - Die Metadaten bekommen `productKey`. - **`POST /api/abo/checkout`:** Der Body bekommt zusätzlich `productKey` (`'wartung'` oder `'hosting'`). Ein anderer Wert ergibt 400. - **`GET /api/abo`:** ``` { available, testAccount, termsVersion, lateStartAt, products: [productView('hosting'), productView('wartung')], mailStorage: { includedGb: 5, gbPerUnit: 5, unitPriceCents: 100, maxUnits: 10 }, offers: { hosting: offerView|null, wartung: offerView|null }, ledger: , subscription: null | { ...bisherige Felder, productKey, termsVersion, termsUrl, // termsDocFor(sub.termsVersion).url storage: { units, pendingSetup, // boolean pendingReduction, // null | { units, effectiveAt } }, canChangeStorage, // status 'active' und nicht gekündigt canSwitch, // wie canChangeStorage, dazu productKey 'hosting' withdrawals, // openWithdrawals(...) withdrawalOpen, // withdrawals.length > 0 withdrawalUntil, // spätestes until } } ``` - **`minTermEndOf(doc)`:** rechnet ab `doc.termStartAt || doc.trialEnd || doc.startedAt`. - **Webhook `checkout.session.completed`:** zusätzlich `recordContractEvent({ type: 'abschluss', productKey, consent aus den Metadaten })`. - **`startTestApp(routers)`** (in `test/support/testApp.js`): liefert `Promise<{ url, close }>`. Die App nutzt `express.json` mit `rawBody` und `cookieParser(SESSION_SECRET)`. - **`sessionCookie(session)`:** liefert einen signierten Cookie `webklar_portal_session` (mit `cookie-signature`) für jedes Session-Objekt. Kunde: `{ customerId, appwriteUserId }`. Admin: `{ role: 'admin', appwriteUserId, name, email }`. - **Anfrage-Helfer** in `test/support/testApp.js`, die die Tasks 8–13 nutzen: - `get(path, { cookie, headers })` liefert den JSON-Body und wirft ab Status 400. - `rawGet(path, opts)` liefert `{ status, body }`. - `post(path, body, { cookie, headers })` liefert `{ status, body }`. - **`test/support/fixtures.js`** stellt bereit: - Kunde `c1` mit `k@example.invalid` - `hostingDoc`: `subscriptions`-Dokument `sub_h` von `c1` mit `productKey 'hosting'`, Status `active`, `termsVersion` v4, `storageUnits: 0` und `currentPeriodEnd` in 20 Tagen - `wartungDoc` - Kunde `c2` mit einem zweiten Hosting-Abo `sub_h2` - `consent = { termsVersion: config.abo.termsVersion, consentAt, consentIp: '127.0.0.1', consentUserAgent: 'test' }` - Funktionen `customerCookie()` und `adminCookie()` - **Angebote:** `offers.hosting` bleibt bis Task 11 `null`. `offers.wartung` kommt aus dem heutigen `getOpenOffer(customerId)`. - [ ] **Step 1: Failing test** – `test/aboRoutes.test.js` ```js test('GET /api/abo: Katalog, Hosting-Abo mit Speicher, kein Stundenkonto', async () => { // Appwrite: Kunde c1, Abo-Dokument sub_h (productKey hosting, storageUnits 2, termsVersion '2026-10-05-v4', status active) const r = await get('/api/abo', { cookie: sessionCookie({ customerId: 'c1', appwriteUserId: 'u1' }) }) assert.deepEqual(r.products.map((p) => [p.key, p.amountCents]), [['hosting', 1500], ['wartung', 5000]]) assert.equal(r.subscription.productKey, 'hosting'); assert.equal(r.subscription.storage.units, 2) assert.equal(r.subscription.canSwitch, true); assert.equal(r.ledger, null) assert.equal(r.subscription.termsUrl, '/abo-bedingungen.html') }) test('GET /api/abo: Wartung v3 → Link auf Fassung 3, Stundenkonto da', async () => { aw.seed('subscriptions', { ...wartungDoc, $id: 'sub_h', customerId: 'c1', termsVersion: '2026-09-25-v3' }) const r = await get('/api/abo', { cookie: customerCookie() }) assert.equal(r.subscription.termsUrl, '/vertragsunterlagen/v3/abo-bedingungen.html'); assert.ok(r.ledger) }) test('Checkout Hosting: Preis, Beschreibung, Hinweistext', async () => { const res = await post('/api/abo/checkout', { productKey: 'hosting', acceptTerms: true, startChoice: 'sofort', termsVersion: config.abo.termsVersion, offerId: '' }) const call = st.calls.find((c) => c.path === '/v1/checkout/sessions') assert.equal(call.body['subscription_data[description]'], 'WEBklar Hosting-Abo') assert.match(call.body['custom_text[submit][message]'], /15,00 € monatlich/) assert.equal(call.body['subscription_data[metadata][productKey]'], 'hosting') }) test('Checkout: unbekanntes Produkt → 400', async () => { assert.equal((await post('/api/abo/checkout', { productKey: 'gold' })).status, 400) }) test('Gekündigtes Abo: canSwitch und canChangeStorage false', async () => { aw.seed('subscriptions', { ...hostingDoc, $id: 'sub_h', cancelAtPeriodEnd: true, cancelAt: '2027-10-05T10:00:00.000Z' }) const r = await get('/api/abo', { cookie: customerCookie() }) assert.equal(r.subscription.canSwitch, false); assert.equal(r.subscription.canChangeStorage, false) }) test('Widerrufsfristen aus den Ereignissen', async () => { // Ereignis 'speicher' (0 → 2) mit consentAt vor 2 Tagen const r = await get('/api/abo', { cookie: customerCookie() }) assert.deepEqual(r.subscription.withdrawals.map((w) => w.type), ['speicher']) assert.equal(r.subscription.withdrawalOpen, true) }) ``` - [ ] **Step 2: Run** `node --test test/aboRoutes.test.js`. Erwartet: FAIL. - [ ] **Step 3: Umsetzen** - Checkout je Produkt. Ein Angebot gibt es bis Task 11 nur für Wartung. Bei Hosting mit `offerId` gibt es 409 („Ihr Angebot gilt für ein anderes Abo“). - `GET /api/abo` und `publicSubscription` erweitern. - Im Webhook das Ereignis `abschluss` schreiben. - [ ] **Step 4: Run** `npm test`. Erwartet: PASS. - [ ] **Step 5: Commit** mit der Nachricht „Abo-API: Bestellung je Produkt, Katalog, Speicherstand, Wechsel-Möglichkeit“. ### Task 8: Zusatzspeicher > **Rechtsrunde (28.09.2026):** > - Ein Minus speichert im Ereignis `speicher` keine Zustimmungsdaten (kein `consent`, also keine IP-Adresse und keine Browserkennung). Die Datenschutzerklärung verspricht Nachweisdaten nur für Bestellungen (Abschluss, Wechsel, Plus). Test: Das Ereignis eines Minus hat kein `consentIp`. > - `changeStorage` bekommt als vierten Parameter `options = {}` mit `viaCancellation`. Mit `viaCancellation: true` sind Reduzierungen auch bei gekündigtem Abo erlaubt (Kündigungsformular, Task 10), Erhöhungen nie. Test: gekündigtes Abo mit `viaCancellation` und weniger Paketen gibt keine 409; mit mehr Paketen gibt es 409. **Files:** - Create: `server/services/mailStorage.js` - Modify: `server/routes/abo.js` (`POST /storage/preview`, `POST /storage`, Webhook `invoice.paid` ruft `recordPaidStorage`) - Test: `test/mailStorage.test.js` **Interfaces:** - Consumes: - `ensurePrice('mailStorage')`, `describeItems`, `syncSubscription`, `getCurrentSubscription` - `recordContractEvent` - Mailversand K13 und T13 (Task 12 liefert die Vorlagen; bis dahin schlägt der Versand fehl, der Ablauf aber nicht) - Produces: - **`planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits })`:** liefert `{ direction, steps }`. - `direction` ist `'plus'`, `'minus'` oder `'none'`, `steps` eine Liste `[{ quantity, proration_behavior }]`. - Ist `newUnits` keine ganze Zahl zwischen 0 und `maxUnits`, wirft die Funktion einen Fehler mit Status 400. - Plus bis `paidUnits`: ein Schritt mit `none`. - Plus über `paidUnits`, wenn `currentUnits < paidUnits`: erst `{ paidUnits, none }`, dann `{ newUnits, create_prorations }`. - Sonst Plus: ein Schritt `create_prorations`. - Minus: ein Schritt mit `none`. - **`paidUnitsOf(doc)`:** Stimmt `doc.storagePaidPeriodEnd` mit `doc.currentPeriodEnd` überein, liefert die Funktion `doc.storagePaidUnits`, sonst `doc.storageUnits || 0`. - **`previewStorageChange(customerId, newUnits)`:** liefert `{ direction, units, totalGb, monthlyCents, deltaMonthlyCents, prorationCents, nextChargeAt, effectiveAt }`. - Das anteilige Plus kommt aus `invoices.createPreview` (mit `subscription_details.items`, `proration_behavior`, `proration_date`). - `effectiveAt` ist beim Minus `currentPeriodEnd`, sonst jetzt. - **`changeStorage(customerId, newUnits, consent)`:** liefert `{ subscription, event }`. - Plus ohne `consent` ergibt 400 („Bitte stimmen Sie den Regeln zum Zusatzspeicher zu“). - Ist das Abo nicht `active` oder gekündigt, gibt es 409. - Die Stripe-Schritte laufen nacheinander. Die erste Position wird mit `items: [{ price, quantity }]` angelegt. - Beim Plus wird `storagePaidUnits = max(paidUnits, newUnits)` mit `storagePaidPeriodEnd = currentPeriodEnd` gesetzt. - Bei 0 Paketen bleibt die Position mit `quantity: 0` bestehen. Stripe erlaubt 0 bei Stückpreisen, so behält ein späteres Plus seinen Posten. - Es entsteht das Ereignis `speicher`: Status `offen`, `dueAt` = jetzt (Plus) bzw. `currentPeriodEnd` (Minus), `details: { fromUnits, toUnits }`. - Danach gehen K13 an den Kunden (beim Plus mit PDF) und T13 ans Team. - **`recordPaidStorage(freshInvoice)`:** Hat die Rechnung eine reguläre Zusatzspeicher-Zeile (nicht anteilig), setzt die Funktion `storagePaidUnits` auf deren `quantity` und `storagePaidPeriodEnd` auf `line.period.end`. - **Alle Rechnungszeilen laden** (Hinweis aus dem Review von Plan 1): `invoices.retrieve` bettet höchstens 10 Zeilen ein. Anteilige Zeilen stehen vor den Abo-Zeilen, und mehrere Speicheränderungen in einem Monat können die Wartungs-Zeile hinausschieben. - `grantCreditForInvoice` und `recordPaidStorage` laden deshalb bei `lines.has_more` alle Zeilen per `invoices.listLineItems(id, { limit: 100 })`. - Test: Rechnung mit 11 Zeilen, die Wartungs-Zeile ist die elfte. Ergebnis: 60 Minuten. - **Routen:** - `POST /api/abo/storage/preview` mit dem Body `{ units }` - `POST /api/abo/storage` mit dem Body `{ units, acceptTerms, termsVersion }`, dabei ist `acceptTerms` beim Plus Pflicht - Im Modus „Als Kunde ansehen“ antworten beide mit 403. - [ ] **Step 1: Failing test** – `test/mailStorage.test.js` ```js test('Plan: erstes Plus anteilig', () => { assert.deepEqual(planStorageChange({ currentUnits: 0, paidUnits: 0, newUnits: 2, maxUnits: 10 }), { direction: 'plus', steps: [{ quantity: 2, proration_behavior: 'create_prorations' }] }) }) test('Plan: Minus ohne Erstattung', () => { assert.deepEqual(planStorageChange({ currentUnits: 3, paidUnits: 3, newUnits: 1, maxUnits: 10 }).steps, [{ quantity: 1, proration_behavior: 'none' }]) }) test('Plan: erneutes Plus bis zur bezahlten Menge kostet nichts', () => { assert.deepEqual(planStorageChange({ currentUnits: 1, paidUnits: 3, newUnits: 3, maxUnits: 10 }).steps, [{ quantity: 3, proration_behavior: 'none' }]) assert.deepEqual(planStorageChange({ currentUnits: 1, paidUnits: 3, newUnits: 5, maxUnits: 10 }).steps, [{ quantity: 3, proration_behavior: 'none' }, { quantity: 5, proration_behavior: 'create_prorations' }]) }) test('Plan: Grenzen', () => { for (const n of [-1, 11, 1.5, '2']) assert.throws(() => planStorageChange({ currentUnits: 0, paidUnits: 0, newUnits: n, maxUnits: 10 }), (e) => e.status === 400) assert.equal(planStorageChange({ currentUnits: 2, paidUnits: 2, newUnits: 2, maxUnits: 10 }).direction, 'none') }) test('Bezahlte Menge gilt nur im laufenden Zeitraum', () => { assert.equal(paidUnitsOf({ storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: 'A', currentPeriodEnd: 'A' }), 3) assert.equal(paidUnitsOf({ storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: 'A', currentPeriodEnd: 'B' }), 1) }) test('Plus: Stripe-Aufrufe, Ereignis offen, bezahlte Menge', async () => { /* Nachbauten: sub_h active, ohne Speicher */ await changeStorage('c1', 2, consent) const upd = st.calls.filter((c) => c.path === '/v1/subscriptions/sub_h' && c.method === 'POST') assert.equal(upd.at(-1).body['proration_behavior'], 'create_prorations') assert.equal(upd.at(-1).body['items[0][quantity]'], '2') const [ev] = await listContractEvents('c1'); assert.equal(ev.type, 'speicher'); assert.equal(ev.status, 'offen') assert.equal(aw.docs('subscriptions').get('sub_h').storagePaidUnits, 2) }) test('Plus ohne Zustimmung: 400; gekündigt: 409', async () => { await assert.rejects(changeStorage('c1', 3, null), (e) => e.status === 400) aw.seed('subscriptions', { ...hostingDoc, $id: 'sub_h', cancelAtPeriodEnd: true }) await assert.rejects(changeStorage('c1', 3, consent), (e) => e.status === 409) }) test('invoice.paid: bezahlte Menge aus regulärer Speicher-Zeile', async () => { await recordPaidStorage({ lines: { data: [{ amount: 300, quantity: 3, period: { end: 1793000000 }, pricing: { price_details: { price: 'price_s' } }, parent: { type: 'subscription_item_details', subscription_item_details: { proration: false, subscription: 'sub_h' } } }] } }) const doc = aw.docs('subscriptions').get('sub_h') assert.equal(doc.storagePaidUnits, 3); assert.equal(doc.storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString()) }) ``` - [ ] **Step 2: Run** `node --test test/mailStorage.test.js`. Erwartet: FAIL. - [ ] **Step 3: Umsetzen:** Dienst, Routen und Webhook-Anschluss wie oben beschrieben. - [ ] **Step 4: Run** `npm test`. Erwartet: PASS. - [ ] **Step 5: Commit** mit der Nachricht „Zusatzspeicher: Plus sofort anteilig, Minus zum Monatsende, Aufgaben für konsoleH“. ### Task 9: Wechsel vom Hosting- ins Wartungs-Abo > **Rechtsrunde (28.09.2026), Entscheidung 2: Der Wechsel wird erst mit der Zahlung wirksam.** Das geht vor die Beschreibung unten. > - **Zahlart ermitteln:** Standard-Zahlungsmethode des Abos, sonst die des Kunden (`payment_method.type`). > - **Karte** (`card`, ebenso `link`): Der Aufruf bleibt wie unten, nur mit `payment_behavior: 'pending_if_incomplete'`. Stripe übernimmt Preis, Anker, Metadaten und Rabatte erst, wenn die Wechselrechnung bezahlt ist. `metadata` und `discounts` sind in ausstehenden Aktualisierungen erlaubt (docs.stripe.com/billing/subscriptions/pending-updates-reference, geprüft am 28.09.2026). Eine ausstehende Aktualisierung verfällt nach spätestens 23 Stunden. > - **SEPA-Lastschrift** (`sepa_debit`): Stripe erlaubt dafür keine ausstehenden Aktualisierungen. Der Aufruf bleibt deshalb mit `allow_incomplete`, und der Wechsel gilt sofort. Scheitert die Lastschrift, machen wir ihn von Hand rückgängig (siehe Webhooks). > - **Ergebnis:** > - Hat das Abo danach ein `pending_update` (Karte, Zahlung nicht sofort erfolgreich, etwa wegen 3-D Secure), ist das Ereignis `wechsel` `offen`. `paymentUrl` ist die `hosted_invoice_url`, und Metadaten und Spiegel ändern sich noch nicht. > - Ohne `pending_update` und mit bezahlter Rechnung ist das Ereignis `erledigt`. > - Bei SEPA bleibt es `offen`, bis `invoice.paid` kommt. > - Ein zweiter Wechsel, während einer auf die Zahlung wartet, gibt 409 („Ein Wechsel wartet noch auf die Zahlung“). > - **Mails:** > - K12 geht sofort nach dem Klick raus, als Vertragsbestätigung mit PDF. > - Ist der Wechsel noch nicht wirksam, enthält K12 den Block `zahlung_offen`: „Der Wechsel wird wirksam, sobald die Zahlung bestätigt ist. Andernfalls läuft Ihr Hosting-Abo mit Preis und Mindestlaufzeit wie bisher weiter.“ > - T12 geht erst raus, wenn der Wechsel wirksam ist. > - **Webhooks** (dort, wo die bestehenden Stripe-Ereignisse verarbeitet werden): > - `customer.subscription.pending_update_applied`: Abo spiegeln, Ereignis `wechsel` wird `erledigt`, T12 geht raus. > - `customer.subscription.pending_update_expired`: Ereignis `wechsel` wird `gescheitert`, K14 geht an den Kunden, T12 mit Block `gescheitert`. > - `invoice.payment_failed` für eine Wechselrechnung mit SEPA (Abo mit `switchedAt` in den letzten 30 Tagen, `billing_reason` `subscription_update`): T12 mit Block `lastschrift_gescheitert` und diesen Handgriffen: > - Preis ohne anteilige Verrechnung zurück auf Hosting > - Wechselrechnung stornieren > - Ereignis auf `gescheitert` setzen > - K14 senden > - Der Abbuchungstag bleibt der Wechseltag. > - **Tests (ergänzen):** > - Karte → `pending_if_incomplete`, SEPA → `allow_incomplete`. > - Antwort mit `pending_update` → Ereignis `offen` und `paymentUrl`. > - Webhook `pending_update_expired` → `gescheitert` und K14. Webhook `pending_update_applied` → `erledigt` und T12. > - Wechsel bei ausstehendem Wechsel → 409. > - **Livegang:** Die neuen Webhook-Ereignisse am Stripe-Endpunkt freischalten (Task 20). > - **Nach der Prüfung der Rechtsrunden-Texte:** > - Mails nennen keine feste Frist von 23 Stunden, weil die Frist bei Stripe auch kürzer sein kann. > - Bei gescheiterter SEPA-Lastschrift die Wechselrechnung sofort stornieren, damit ein automatischer Einzugsversuch sie nicht mehr einzieht. > - Weitere Postfächer richten wir erst ein, wenn die Wechselrechnung bezahlt ist (Vertragsbedingungen § 9 Abs. 3). T12 nennt das. **Files:** - Create: `server/services/planSwitch.js` - Modify: `server/routes/abo.js` (`POST /switch/preview`, `POST /switch`) - Test: `test/planSwitch.test.js` **Interfaces:** - Consumes: - `ensurePrice('wartung')`, `ensureTestCoupon('wartung')`, `describeItems`, `syncSubscription` - `recordContractEvent` - Mails K12 und T12 (Task 12) - Produces: - **`previewSwitch(customerId)`:** liefert `{ prorationDate, amountDueCents, creditCents, monthlyCents, nextChargeAt, minTermEnd }`. - Grundlage ist `invoices.createPreview` mit `subscription_details: { items: [{ id: aboItemId, price: wartungPriceId }], billing_cycle_anchor: 'now', proration_behavior: 'create_prorations', proration_date }`. - `creditCents` ist die Summe der negativen anteiligen Zeilen als positiver Betrag. - `minTermEnd` liegt 12 Monate nach `prorationDate`. - **`switchToWartung(customerId, consent, prorationDate)`:** liefert `{ subscription, paymentUrl }`. - Voraussetzungen: `productKey` ist `hosting`, der Status `active`, das Abo nicht gekündigt. Sonst gibt es 409. - Ist `prorationDate` älter als 30 Minuten, gibt es 409 („Bitte Seite neu laden“). - Der Aufruf lautet `subscriptions.update(subId, { items: [{ id: aboItemId, price }], billing_cycle_anchor: 'now', proration_behavior: 'create_prorations', proration_date, payment_behavior: 'allow_incomplete', metadata: {…}, expand: ['latest_invoice'] })`. - `metadata` enthält `{ productKey: 'wartung', termStartAt, termsVersion, termsAcceptedAt, earlyStartConsentAt, consentIp, consentUserAgent, switchedFrom: 'hosting', switchedAt }`. - Bei einem Testkonto werden die Rabatte neu gesetzt: `WEBKLAR-TEST-050-HOSTING` fällt weg, `WEBKLAR-TEST-050` kommt dazu, andere Rabatte bleiben. - `paymentUrl` ist `latest_invoice.hosted_invoice_url`, falls die Rechnung noch `open` ist, sonst `null`. - Danach folgen das Ereignis `wechsel` (mit `consent` und `details: { from: 'hosting', amountDueCents, prorationDate }`) und die Mails K12 (Kunde, PDF der Fassung 4) und T12. - **Routen:** - `POST /api/abo/switch/preview` - `POST /api/abo/switch` mit dem Body `{ acceptTerms: true, earlyStart: true, termsVersion, prorationDate }`. Fehlt eines davon, gibt es 400. Stimmt `termsVersion` nicht, gibt es 409. - Im Modus „Als Kunde ansehen“ antworten beide mit 403. - [ ] **Step 1: Failing test** – `test/planSwitch.test.js` ```js test('Vorschau: Gutschrift und heute fällig aus der Stripe-Vorschau', async () => { // Stripe-Nachbau: POST /v1/invoices/create_preview liefert die Zeilen -900 (anteilig) und 5000 const p = await previewSwitch('c1') assert.equal(p.creditCents, 900); assert.equal(p.amountDueCents, 4100); assert.equal(p.monthlyCents, 5000) }) test('Wechsel: Stripe-Aufruf mit Anker jetzt, Metadaten, Ereignis', async () => { const r = await switchToWartung('c1', consent, p.prorationDate) const call = st.calls.findLast((c) => c.path === '/v1/subscriptions/sub_h' && c.method === 'POST') assert.equal(call.body['billing_cycle_anchor'], 'now') assert.equal(call.body['metadata[productKey]'], 'wartung') assert.equal(call.body['metadata[switchedFrom]'], 'hosting') assert.equal((await listContractEvents('c1'))[0].type, 'wechsel') assert.equal(r.paymentUrl, null) }) test('Offene Rechnung liefert paymentUrl', async () => { st.nextSubscriptionUpdate = { latest_invoice: { status: 'open', hosted_invoice_url: 'https://pay.example/inv' } } const r = await switchToWartung('c2', consent, Math.floor(Date.now() / 1000)) assert.equal(r.paymentUrl, 'https://pay.example/inv') assert.equal((await listContractEvents('c2'))[0].type, 'wechsel') }) test('Gekündigt, Wartung, trialing, past_due: 409', async () => { for (const patch of [{ cancelAtPeriodEnd: true }, { productKey: 'wartung' }, { status: 'trialing' }, { status: 'past_due' }]) { aw.seed('subscriptions', { ...hostingDoc, ...patch }) await assert.rejects(switchToWartung('c1', consent, Math.floor(Date.now() / 1000)), (e) => e.status === 409, JSON.stringify(patch)) } }) test('Alte Vorschau (> 30 min): 409', async () => { await assert.rejects(switchToWartung('c1', consent, Math.floor(Date.now() / 1000) - 31 * 60), (e) => e.status === 409) }) test('Route: ohne earlyStart 400', async () => { const res = await post('/api/abo/switch', { acceptTerms: true, termsVersion: config.abo.termsVersion, prorationDate: 1 }, { cookie: customerCookie() }) assert.equal(res.status, 400) }) ``` - [ ] **Step 2: Run** `node --test test/planSwitch.test.js`. Erwartet: FAIL. - [ ] **Step 3: Umsetzen:** Dienst und Routen wie oben. Der Stripe-Nachbau bekommt `POST /v1/invoices/create_preview` und `POST /v1/subscriptions/:id`, dazu `nextSubscriptionUpdate`: Dieses Objekt wird einmalig in die nächste Antwort von `POST /v1/subscriptions/:id` gemischt. - [ ] **Step 4: Run** `npm test`. Erwartet: PASS. - [ ] **Step 5: Commit** mit der Nachricht „Wechsel Hosting → Wartung: neuer Abbuchungstag, Gutschrift, 12 neue Monate“. ### Task 10: Öffentliche Kündigung und öffentlicher Widerruf mit Auswahl > **Rechtsrunde (28.09.2026), Entscheidung 5: Zusatzspeicher auch teilweise kündigen.** > - Bei der Auswahl „Nur den E-Mail-Zusatzspeicher“ fragt `kuendigen.html` zusätzlich die Zahl der Pakete ab, die gekündigt werden sollen: Feld `packages`, ganze Zahl von 1 bis 10, leer heißt alle gebuchten. `validDeclaration` übernimmt das Feld. > - Mehr Pakete als gebucht heißt alle. Die Reduzierung wirkt zum Ende des Abrechnungsmonats, wie ein Minus und ohne Zustimmungsdaten. > - Sie geht auch, wenn das Abo schon gekündigt ist (`viaCancellation`, Task 8). > - Das Ereignis `kuendigung` bekommt `details: { packages, fromUnits, toUnits }`. > - Die Reduzierung erscheint wie jedes Minus als Speicher-Aufgabe im Admin (Task 13). > - K03 nennt die Zahl der Pakete (Block `zusatzspeicher` mit `{{pakete}}`), T08 ebenso. > - Tests (zusätzlich zu den Tests unten): > - 2 von 3 Paketen ergibt Menge 1. > - Ohne `packages` ergibt Menge 0. > - Gekündigtes Abo: die Menge wird trotzdem reduziert. > - `packages: 0` oder `11` ergibt 400. **Files:** - Modify: `server/routes/abo.js` (`validDeclaration`, `/public/cancel`, `/public/withdraw`), `public/kuendigen.html`, `public/widerrufen.html`, `public/legal-form.js` - Test: `test/publicForms.test.js` **Interfaces:** - Consumes: `changeStorage` (Task 8), `listContractEvents` und `openWithdrawals` (Task 5), Mails K03, K04, T08, T09 (Task 12) - Produces: - **`validDeclaration(body)`:** hat zusätzlich `contract` (`'abo'` oder `'zusatzspeicher'`, Standard `'abo'`) und `order` (`'abschluss'`, `'wechsel'` oder `'zusatzspeicher'`, Standard `'abschluss'`). - **Kündigung „zusatzspeicher“:** Mit gebuchtem Speicher ruft die Route `changeStorage(customerId, restUnits, null, { viaCancellation: true })` auf (`restUnits` = gebuchte Pakete minus `packages`, mindestens 0; siehe Hinweis Rechtsrunde). Die Kündigung wirkt zum Periodenende, dazu kommt das Ereignis `kuendigung`. Ohne gebuchten Speicher entsteht keine Änderung, die Eingangsbestätigung sagt „wir prüfen“. - **Widerruf:** Liegt eine offene Frist des gewählten Typs vor, entsteht das Ereignis `widerruf` mit `details: { order, eventId }`, und das Bezugsereignis bekommt den Status `widerrufen`. - `abschluss`: wie heute `cancelAtPeriodEnd`. - `wechsel` und `zusatzspeicher`: T09 nennt die Handgriffe (Preis zurück bzw. Menge zurück, Erstattung). - Ohne offene Frist gibt es keine Änderung, nur das Ereignis `widerruf` mit `details: { order, eventId: null }`. Die Bestätigung sagt „wir prüfen“, und T09 geht mit dem Hinweis „keine offene Frist gefunden“ raus. - **Formulare:** - `kuendigen.html` hat Radioknöpfe `name="contract"`: „Mein Abo (Hosting- oder Wartungs-Abo, mit Zusatzspeicher)“ und „Nur den E-Mail-Zusatzspeicher“. - `widerrufen.html` hat Radioknöpfe `name="order"`: „Abschluss meines Abos“, „Wechsel ins Wartungs-Abo“ und „E-Mail-Zusatzspeicher“. - `legal-form.js` übernimmt die Auswahl in den Request und in die Bestätigungsseite. Die Zeile „Vertrag“ zeigt dabei die gewählte Beschriftung statt fest „WEBklar Wartungs-Abo“. - [ ] **Step 1: Failing test** – `test/publicForms.test.js` (App mit `aboRoutes`, Nachbauten, SMTP aus) ```js test('Kündigung nur Zusatzspeicher: Menge 0 ohne Erstattung, Ereignis', async () => { await post('/api/abo/public/cancel', { name: 'K', email: 'k@example.invalid', contract: 'zusatzspeicher' }) assert.equal(st.calls.findLast((c) => c.method === 'POST' && c.path === '/v1/subscriptions/sub_h').body['items[0][quantity]'], '0') assert.equal((await listContractEvents('c1'))[0].type, 'kuendigung') }) test('Kündigung Zusatzspeicher ohne gebuchten Speicher: 200, keine Stripe-Änderung', async () => { aw.seed('subscriptions', { ...hostingDoc, storageUnits: 0 }); const before = st.calls.length const res = await post('/api/abo/public/cancel', { name: 'K', email: 'k@example.invalid', contract: 'zusatzspeicher' }) assert.equal(res.status, 200); assert.equal(st.calls.filter((c) => c.method === 'POST').length, st.calls.slice(0, before).filter((c) => c.method === 'POST').length) }) test('Unbekannte E-Mail: 200, keine Änderung', async () => { const before = st.calls.length const res = await post('/api/abo/public/cancel', { name: 'X', email: 'niemand@example.invalid', contract: 'abo' }) assert.equal(res.status, 200); assert.equal(st.calls.length, before) }) test('Widerruf Wechsel in der Frist: Ereignis widerrufen, kein automatischer Preiswechsel', async () => { // Ereignis 'wechsel' mit consentAt vor 3 Tagen await post('/api/abo/public/withdraw', { name: 'K', email: 'k@example.invalid', order: 'wechsel' }) const evs = await listContractEvents('c1') assert.equal(evs.find((e) => e.type === 'wechsel').status, 'widerrufen') assert.equal(st.calls.filter((c) => c.method === 'POST' && /subscriptions/.test(c.path)).length, 0) }) test('Widerruf nach Fristende: 200, nichts geändert', async () => { // Ereignis 'wechsel' mit consentAt vor 30 Tagen const res = await post('/api/abo/public/withdraw', { name: 'K', email: 'k@example.invalid', order: 'wechsel' }) assert.equal(res.status, 200) const evs = await listContractEvents('c1') assert.notEqual(evs.find((e) => e.type === 'wechsel').status, 'widerrufen') assert.equal(evs.find((e) => e.type === 'widerruf').details.eventId, null) }) ``` - [ ] **Step 2: Run** `node --test test/publicForms.test.js`. Erwartet: FAIL. - [ ] **Step 3: Umsetzen:** Server und Formulare wie oben. - [ ] **Step 4: Prüfen** mit `npm test`. Dazu die Oberfläche: Playwright auf `kuendigen.html` und `widerrufen.html` (Fixture-Server aus Task 14, Step 1). Die Auswahl muss sichtbar sein und im Request stehen. Die Bestätigung zeigt die gewählte Beschriftung. - [ ] **Step 5: Commit** mit der Nachricht „Kündigung und Widerruf: Vertrag bzw. Bestellung auswählbar (§ 312k, § 356a BGB)“. ### Task 11: Angebote je Produkt **Files:** - Modify: `server/services/offers.js`, `server/routes/offers.js`, `public/admin.js` (Angebotsformular, ab Z. ~937) - Test: `test/offerProduct.test.js` **Interfaces:** - Consumes: `getProduct` (Plan 1) - Produces: - **`validateOfferInput(body)`:** `productKey` ist Pflicht (`'wartung'` oder `'hosting'`). `regularPriceCents` ist `getProduct(productKey).amountCents`, und der Angebotspreis muss darunter liegen. - **`createOffer`:** speichert `productKey`. - **`getOpenOffer(customerId, productKey)`:** liefert nur Angebote dieses Produkts. Alte Angebote ohne `productKey` gelten als `wartung`. - **`offerContext(customerId)`:** - `regularPriceCents` wird ersetzt durch `products: { hosting: { regularPriceCents: 1500 }, wartung: { regularPriceCents: 5000 } }`. - `subscription.productKey` kommt dazu. - Laufende Angebote (`scope 'laufend'`) gibt es nur für das Produkt des laufenden Abos. - **`activateOfferFromCheckout(session)`:** Weicht `offer.productKey` vom `metadata.productKey` ab, wird nichts angewendet, und T03 geht mit Grund raus. - **`GET /api/abo`** (aus Task 7) liefert jetzt auch `offers.hosting`. Der Checkout prüft, dass `offer.productKey` zum bestellten Produkt passt, sonst gibt es 409. - **Admin-Formular:** Auswahl „Produkt“ (Hosting oder Wartung), Normalpreis und Vorschau je Produkt. K05 und K06 nennen das Produkt. - [ ] **Step 1: Failing test** ```js test('Angebot Hosting: Normalpreis 15 €, Preis darunter', () => { assert.equal(validateOfferInput({ productKey: 'hosting', months: 2, monthlyPriceCents: 1000 }).regularPriceCents, 1500) assert.throws(() => validateOfferInput({ productKey: 'hosting', months: 2, monthlyPriceCents: 1500 }), (e) => e.status === 400) assert.throws(() => validateOfferInput({ months: 2, monthlyPriceCents: 1000 }), (e) => e.status === 400) }) test('Offenes Angebot nur fürs passende Produkt; alte Angebote gelten als Wartung', async () => { aw.seed('aboOffers', { $id: 'o_alt', customerId: 'c1', status: 'offen', validUntil: '2099-01-01T00:00:00.000Z', months: 2, monthlyPriceCents: 2500 }) aw.seed('aboOffers', { $id: 'o_h', customerId: 'c1', status: 'offen', productKey: 'hosting', validUntil: '2099-01-01T00:00:00.000Z', months: 2, monthlyPriceCents: 1000 }) assert.equal((await getOpenOffer('c1', 'hosting')).$id, 'o_h') assert.equal((await getOpenOffer('c1', 'wartung')).$id, 'o_alt') }) ``` - [ ] **Step 2: Run** `node --test test/offerProduct.test.js`. Erwartet: FAIL. - [ ] **Step 3: Umsetzen:** Dienst, Route und Admin-Formular. Das Formular prüft Playwright auf der Admin-Seite mit Fixtures: Produktauswahl vorhanden, Vorschau mit „danach 15 €“ bei Hosting. - [ ] **Step 4: Run** `npm test` (inklusive der bestehenden Tests `offerDiscount` und `offerMail`). Erwartet: PASS. - [ ] **Step 5: Commit** mit der Nachricht „Abo-Angebote: für Hosting- oder Wartungs-Abo“. ### Task 12: Mails > **Rechtsrunde (28.09.2026):** > - **Weitere Vorlagen:** T05, T06 und T08 bekommen `{{produkt}}` statt fest „Wartungs-Abo“ (Spec 9, Zeile „T05, T06, T08, T09“). > - **Paragraph zum Vertragsende:** > - K02, K03, T05 und T07 nennen den Paragraphen zum Vertragsende aus der Fassung des Kunden, über den Platzhalter `{{vertragsende_paragraph}}` aus `termsDocFor(subDoc.termsVersion)`: Fassung 3 → „§ 9“, Fassung 4 → „§ 11“. In Fassung 4 ist § 9 der Wechsel. > - Test: Für ein Abo mit Fassung 4 nennt keine dieser Mails „§ 9 der Vertragsbedingungen“. > - **Neu:** > - K14, Betreff „Ihr Wechsel ins WEBklar Wartungs-Abo ist nicht zustande gekommen“, Platzhalter `{{name}}` und `{{grund}}`. Der Text sagt, dass das Hosting-Abo mit Preis und Mindestlaufzeit wie vor dem Wechsel weiterläuft. > - K12-Block `zahlung_offen` und T12-Blöcke `gescheitert` und `lastschrift_gescheitert` (Task 9). > - K03- und T08-Platzhalter `{{pakete}}` im Block `zusatzspeicher` (Task 10). > - T14 „[Datenschutz] Löschfristen fällig“ (Task 21). > - **Schon erledigt:** K09 wurde in Task 3 angepasst („Ihres Abos“, Verrechnung nach einem Wechsel). **Files:** - Modify: - `server/mail-templates/K01 Vertragsbestaetigung.html`, `K02 …`, `K03 …`, `K04 …`, `K05 …`, `K06 …`, `T02 Neues Wartungs-Abo.html` (umbenennen in `T02 Neues Abo.html`), `T07 Abo beendet.html`, `T09 Widerruf eingegangen.html` - `server/routes/abo.js` (`sendContractConfirmation`: Anhang je Version, Werte `produkt`, `preis`) - Create: `server/mail-templates/K12 Wechsel Wartungs-Abo.html`, `K13 Zusatzspeicher.html`, `T12 Wechsel.html`, `T13 Speicher-Aufgabe.html` (Aufbau wie die bestehenden Vorlagen, Kopfkommentar mit Betreff, Platzhaltern und Blöcken) - Test: `test/mailTemplates.test.js` (ergänzen) **Interfaces:** - Consumes: `renderMail(template, values, blocks)`, `templateIds()`, `termsDocFor` - Produces (Platzhalter und Blöcke, wörtlich): - **`contractAttachmentFor(termsVersion)`** in `server/services/products.js`: liefert `{ filename, path }` mit dem absoluten Pfad unter `public/` und nutzt `termsDocFor`. `sendContractConfirmation` hängt damit an. - **K01:** - Betreff `Ihr WEBklar {{produkt}} – Vertragsbestätigung`. - Neue Platzhalter `{{produkt}}` und `{{preis}}` (z. B. „15,00 € / Monat“). - Neue Blöcke `wartung` und `hosting`, je mit der Liste „Enthalten“ aus Spec 3.1. Die Stunde steht nur bei `wartung`. - Anhang: die Datei `termsDocFor(subDoc.termsVersion).pdfFile`. - **K02–K06:** `{{produkt}}` statt fest „Wartungs-Abo“. - K02 verliert den falschen Hinweis, die Kündigung sei über „Abo verwalten“ gekommen. Kündigen ist dort ausgeschaltet. - K03 bekommt den Block `zusatzspeicher` („Ihr E-Mail-Zusatzspeicher endet zum {{vertragsende}}“). - K04 bekommt den Platzhalter `{{bestellung}}`. - **K12:** Betreff `Ihr Wechsel ins WEBklar Wartungs-Abo – Bestätigung`. Platzhalter: `{{name}}`, `{{wechseltag}}`, `{{betrag_heute}}`, `{{monatspreis}}`, `{{mindestlaufzeit_bis}}`, `{{bedingungen_version}}`, `{{zustimmung_zeitpunkt}}`. PDF im Anhang. - **K13:** Betreff `Ihr E-Mail-Zusatzspeicher – Bestätigung`. Platzhalter: `{{name}}`, `{{pakete}}`, `{{gesamt_gb}}`, `{{monatspreis_zusatz}}`, `{{wirksam_ab}}`. Blöcke `plus` (mit Widerrufshinweis und PDF) und `minus`. - **T02:** Betreff `[Abo] Neues {{produkt}}: {{email}}`. Neue Zeile: „Website als Monitor in UptimeRobot anlegen.“ - **T07:** neue Zeile „Monitor in UptimeRobot entfernen.“ - **T09:** `{{bestellung}}` und Block `keine_frist`. - **T12:** Betreff `[Abo] Wechsel ins Wartungs-Abo: {{email}}`. - **T13:** Betreff `[Speicher] {{aktion}}: {{email}}`. `{{aktion}}` ist „einrichten“ oder „reduzieren zum TT.MM.JJJJ“. Platzhalter: `{{postfaecher_hinweis}}`, `{{neue_groesse_gb}}`, `{{faellig}}`. - [ ] **Step 1: Failing test** (ergänzen in `test/mailTemplates.test.js`) ```js test('Neue Vorlagen vorhanden', () => { for (const id of ['K12', 'K13', 'T12', 'T13']) assert.ok(templateIds().includes(id), id) }) test('K01 Hosting: 1 Postfach, keine Arbeitsstunde; Wartung: beliebig viele Postfächer', () => { const base = { name: 'K', produkt: 'Hosting-Abo', preis: '15,00 € / Monat', vertragsschluss: '01.10.2026', leistungsbeginn: '01.10.2026', mindestlaufzeit_bis: '01.10.2027', bedingungen_version: '2026-10-05-v4', zustimmung_zeitpunkt: '01.10.2026, 10:00', angebot_text: '' } const h = renderMail('K01', base, { hosting: true, sofortbeginn: true }) assert.ok(h.subject.includes('Hosting-Abo') && h.text.includes('1 E-Mail-Postfach') && !h.text.includes('Stunde Arbeitszeit')) assert.deepEqual(h.problems, []) const w = renderMail('K01', { ...base, produkt: 'Wartungs-Abo', preis: '50,00 € / Monat' }, { wartung: true, sofortbeginn: true }) assert.ok(w.text.includes('beliebig viele Postfächer')) }) test('T02 und T07 erinnern an UptimeRobot', () => { const t02 = renderMail('T02', { produkt: 'Hosting-Abo', email: 'k@example.invalid', kunde_name: 'K', kunde_email: 'k@example.invalid', appwrite_kunde: 'c1', stripe_abo_id: 'sub_1' }, {}) assert.ok(t02.subject.startsWith('[Abo] Neues Hosting-Abo') && t02.text.includes('UptimeRobot anlegen')) assert.ok(renderMail('T07', { email: 'k@example.invalid', stripe_abo_id: 'sub_1', status: 'canceled' }, {}).text.includes('UptimeRobot entfernen')) }) test('Keine Vorlage nennt fest „Wartungs-Abo“ im Betreff außer K12 und T12', () => { for (const id of templateIds()) if (!['K12', 'T12'].includes(id)) assert.ok(!loadTemplate(id).subject.includes('Wartungs-Abo'), id) }) test('K01-Anhang: Fassung 3 für v3-Abos, Fassung 4 sonst', () => { // contractAttachmentFor(termsVersion) liefert { filename, path } und wird von sendContractConfirmation benutzt assert.equal(contractAttachmentFor('2026-09-25-v3').filename, 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf') assert.equal(contractAttachmentFor('2026-10-05-v4').filename, 'WEBklar-Abo-Vertragsunterlagen.pdf') assert.ok(existsSync(contractAttachmentFor('2026-10-05-v4').path)) }) ``` - [ ] **Step 2: Run** `node --test test/mailTemplates.test.js`. Erwartet: FAIL. - [ ] **Step 3: Vorlagen und Aufrufe umsetzen** - Aufrufe in `abo.js`, `mailStorage.js` und `planSwitch.js`. - Die bestehenden Tests zu Vorlagen (Platzhalter vollständig, keine offenen Blöcke) müssen grün bleiben. - [ ] **Step 4: Run** `npm test`. Erwartet: PASS. - [ ] **Step 5: Commit** mit der Nachricht „Mails: Produktname statt fest Wartungs-Abo, neue Mails für Wechsel und Zusatzspeicher, UptimeRobot-Hinweis“. ### Task 13: Admin-Reiter „E-Mail-Speicher“, Abo-Anzeige, Schnittstelle fürs Ticketsystem **Files:** - Create: `server/routes/storageTasks.js` - Modify: - `server/routes/portalAdmin.js`: `router.use('/storage-tasks', storageTasksAdminRouter)` - `server/routes/admin/customers.js`: `router.get('/:customerId/abo', …)` - `server/config.js`: `mailStorage.hetznerPackage: { name: 'Webhosting M', gb: 50, warnGb: 40 }` - `test/support/appwriteMock.js`: neu `GET /v1/account`. Beim Header `X-Appwrite-JWT: jwt-admin` kommt `{ $id: 'u_admin', email: 'kenso@webklar.com', labels: ['admin'] }` zurück, bei `jwt-kunde` dasselbe mit `labels: []`, sonst 401. - `public/admin.html` und `public/admin.js`: neuer Reiter „E-Mail-Speicher“. In der Kundenansicht wird die Zeile „Wartungs-Abo“ zu „Abo“ (Produkt, Status, Zusatzspeicher). - Test: `test/storageTasks.test.js` **Interfaces:** - Consumes: `listOpenStorageTasks`, `completeContractEvent` (Task 5) - Produces: - **`GET /api/portal-admin/storage-tasks`:** liefert: ``` { tasks: [{ id, customerId, customerName, email, fromUnits, toUnits, dueAt, createdAt }], allocatedGb, packageGb: 50, warnGb: 40, } ``` `allocatedGb` ist die Summe aus 5 + 5 × `storageUnits` über alle Abos mit aktivem Status. - **`POST /api/portal-admin/storage-tasks/:id/done`:** liefert `{ task }`. - **`GET /api/admin/customers/:customerId/abo`:** liefert `{ productKey: 'wartung'|'hosting'|null, active: boolean }`. Es gilt `requireAppwriteStaff`, und die CORS-Regeln wie heute erlauben ticket.webklar.com. - [ ] **Step 1: Failing test** ```js test('Admin: offene Speicher-Aufgaben und vergebene GB', async () => { // zwei aktive Abos: storageUnits 0 und 2 → 5 + 15 = 20 GB const r = await get('/api/portal-admin/storage-tasks', { cookie: adminCookie() }) assert.equal(r.allocatedGb, 20); assert.equal(r.packageGb, 50) assert.equal(r.tasks[0].toUnits, 2) }) test('Admin: Aufgabe erledigen', async () => { const { tasks } = await get('/api/portal-admin/storage-tasks', { cookie: adminCookie() }) const { body: { task } } = await post(`/api/portal-admin/storage-tasks/${tasks[0].id}/done`, {}, { cookie: adminCookie() }) assert.equal(task.status, 'erledigt'); assert.equal(task.doneBy, 'kenso@webklar.com') }) test('Ohne Admin-Session 401', async () => { assert.equal((await rawGet('/api/portal-admin/storage-tasks', { cookie: customerCookie() })).status, 401) }) test('Ticketsystem-Schnittstelle: Produkt und aktiv', async () => { const r = await get('/api/admin/customers/c1/abo', { headers: { Authorization: 'Bearer jwt-admin' } }) assert.deepEqual(r, { productKey: 'hosting', active: true }) assert.equal((await rawGet('/api/admin/customers/c1/abo', { headers: { Authorization: 'Bearer jwt-kunde' } })).status, 403) }) ``` - [ ] **Step 2: Run** `node --test test/storageTasks.test.js`. Erwartet: FAIL. - [ ] **Step 3: Umsetzen:** Routen und Admin-Oberfläche. Den Reiter prüft Playwright mit Fixtures: Liste, Knopf „Erledigt“, Anzeige „20 von 50 GB vergeben (Paket M)“, ab 40 GB rot. - [ ] **Step 4: Run** `npm test`. Erwartet: PASS. - [ ] **Step 5: Commit** mit der Nachricht „Admin: Reiter E-Mail-Speicher, Abo mit Produkt; Schnittstelle fürs Ticketsystem“. --- ### Task 21: Löschfristen (Rechtsrunde, Entscheidung 11) **Files:** - Create: `server/services/retention.js`, `scripts/loeschfristen.mjs`, `server/mail-templates/T14 Loeschfristen.html` - Test: `test/retention.test.js` **Interfaces:** - Consumes: - Appwrite-Zugriff wie in `contractEvents.js` (Task 5) - `renderMail`, Nachbauten und `schemaKeys()` aus `test/support/` - Collections `subscriptions` (Felder `status`, `endedAt`, `canceledAt`, `currentPeriodEnd`, `customerId`), `contractEvents`, `takeoverCredits` - Produces: - **`findDueDeletions(now = new Date())`:** liefert `{ customers: [{ customerId, lastEndAt, subscriptionIds, eventIds }], takeover: [{ id, reason }] }`. - Ein Kunde ist fällig, wenn alle seine Abos beendet sind und seit dem Ende des Jahres, in dem sein letztes Abo endete, drei Jahre vergangen sind („drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet“, Datenschutzerklärung Abschnitt 7). - Das Skript läuft einmal im Jahr, im Januar. - Das Ende ist `endedAt`, sonst `canceledAt`. Fehlt beides bei `status: 'canceled'`, zählt `currentPeriodEnd`. - Anträge auf Übernahmegutschrift sind fällig nach den Fristen in Abschnitt 7 der Datenschutzerklärung. Sie stehen dort im Absatz zur Übernahmegutschrift und werden wörtlich übernommen. - **`applyDeletions(plan)`:** liefert `{ deleted, manual }`. - Löscht die Appwrite-Dokumente in `subscriptions` und `contractEvents` der fälligen Kunden. - Leert bei Stripe die Metadaten `consentIp` und `consentUserAgent` ihrer Abos. Lehnt Stripe die Änderung eines beendeten Abos ab, landet es in `manual` (Hinweis im Bericht). - Löscht fällige Übernahme-Anträge samt hochgeladenen Nachweisen. - Rechnungen bleiben (Aufbewahrungspflicht, Abschnitt 9). - **`scripts/loeschfristen.mjs`:** läuft im Container wie das Setup-Skript. - Ohne Umgebungsvariable ist es ein Probelauf: Bericht auf der Konsole, es wird nichts gelöscht. - `LOESCHFRISTEN=mail` schickt den Bericht als T14 an das Team. - `LOESCHFRISTEN=apply` löscht. - [ ] **Step 1: Failing test** – `test/retention.test.js` (Appwrite- und Stripe-Nachbau) - Abo beendet am 15.03.2022 → fällig am 01.01.2026, nicht fällig am 31.12.2025. - Abo beendet vor zwei Jahren → nicht fällig. - Altes beendetes Abo und ein laufendes Abo desselben Kunden → nicht fällig. - `applyDeletions` löscht die Dokumente und schickt an Stripe leere Metadaten (Aufruf im Nachbau geprüft). Eine abgelehnte Stripe-Änderung landet in `manual`. - Ein Probelauf des Skripts ändert nichts. - [ ] **Step 2: Run** `node --test test/retention.test.js`. Erwartet: FAIL. - [ ] **Step 3: Umsetzen:** Dienst, Skript und T14 (Aufbau wie die bestehenden Vorlagen). - [ ] **Step 4: Run** `npm test`. Erwartet: PASS. - [ ] **Step 5: Commit** mit der Nachricht „Löschfristen: Probelauf, Bericht ans Team, Löschen nach drei Jahren“. ## Block C – Oberfläche im Kundenportal Alle Prüfungen laufen gegen den Fixture-Server aus Task 14, Step 1. Die Ansichten auf Desktop (1280×800) und Handy (390×844, `hasTouch`) kommen dabei auch per Pixelvergleich mit main (`build/public`). ### Task 14: Navigation „Verträge“ **Files:** - Modify: - `public/dashboard.html` - Nav-Eintrag `data-view="vertraege"` für alle Größen. Er ersetzt `kb-tab-wartung`. - `view-wartung` wird zu `view-vertraege`, `view-abo` zu `view-stunden`. - Neue Sections `view-bestellen-hosting`, `view-bestellen-wartung` und `view-wechsel`. - Zurück-Knopf mit dem Text „Verträge“. - Konto-Menü, Gruppe „Verträge“: erster Eintrag ``. `href` von „Vertragsbedingungen“ setzt JavaScript. - `public/app.js` - `VIEW_HASHES`: `vertraege: 'vertraege'`, `stunden: 'stunden'`, `'bestellen-hosting': 'bestellen-hosting'`, `'bestellen-wartung': 'bestellen-wartung'`, `wechsel: 'wechsel'`, `takeover: 'gutschrift'`. - Alias `wartung` und `abo` → `vertraege` in `viewFromHash`. - `APP_PARENT`: `{ stunden, 'bestellen-hosting', 'bestellen-wartung', wechsel, takeover }` → `'vertraege'`, `projekt: 'projects'`. - `APP_PARENT_LABEL.vertraege = 'Verträge'`. - `?abo=success` führt zu `vertraege`. - Das Badge `kb-tab-wartung-badge` heißt künftig `kb-tab-vertraege-badge`. - `public/kundenbereich.css`: Namen `wartung` → `vertraege` - Test: Playwright-Skript `ui/nav.mjs` im Scratchpad **Interfaces:** - Consumes: – - Produces: `showView('vertraege')`. Die Views `stunden`, `bestellen-hosting`, `bestellen-wartung` und `wechsel` rendern Tasks 15–18. Das Ereignis `kb:view` bleibt, `kb:abo` bleibt. - [ ] **Step 1: Fixture-Server und Playwright im Scratchpad** (`/ui/`) - `fixtures.mjs` liefert je Szenario (Cookie `scenario`) die Antwort von `GET /api/abo` in der Form aus Task 7. Die Szenarien: - `ohne-abo`, `hosting`, `hosting-gekuendigt`, `wartung`, `wartung-v3` - `zahlungsproblem`, `widerrufsfrist`, `speicher-einrichtung`, `speicher-reduzierung` - Dazu kommen die übrigen `/api/*`-Antworten wie im Memory-Rezept „Fixture-Server“. - `server.mjs` liefert `dev-vertraege/public` auf Port 4310 und `build/public` auf Port 4311. - `playwright-core` wird über `createRequire('/root/.npm/_npx/db89d7302a373f10/node_modules/')` geladen, Chrome aus `~/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome`. - [ ] **Step 2: Failing check** – `ui/nav.mjs` - **Desktop:** Die Seitenleiste hat den Eintrag „Verträge“. `#wartung` und `#abo` zeigen `#view-vertraege`. - **Handy:** Die Tab-Leiste zeigt als fünften Tab „Verträge“. Aus `#stunden` zeigt der Zurück-Knopf „Verträge“ und führt dorthin. - **Konto-Menü:** Der erste Eintrag der Gruppe „Verträge“ ist „Mein Vertrag“. - Run: `node /ui/nav.mjs`. Erwartet: FAIL. - [ ] **Step 3: Umsetzen** wie unter Files beschrieben. - [ ] **Step 4: Prüfen** - `node /ui/nav.mjs` meldet PASS. - Pixelvergleich der Views Projekte, Fortschritt, Rechnungen und Chat mit main (Desktop und Handy): identisch. - [ ] **Step 5: Commit** mit der Nachricht „Kundenbereich: ‚Wartung‘ heißt jetzt ‚Verträge‘, auch am Desktop in der Navigation“. ### Task 15: Ansicht „Verträge“ (Mein Vertrag, Vergleich, Wechsel-Karte, Stundenkonto) **Files:** - Modify: `public/app.js`, `public/kundenbereich.css` - `loadAboCard` heißt künftig `loadContracts`. - Neu sind `renderContractsView(view, data)` und `renderSideCard(card, data)`. - `renderAboAccountView` heißt künftig `renderHoursView`. - `ABO_FEATURES` und `ABO_SIDE_FEATURES` fallen weg, die Leistungen kommen aus `data.products[].features`. - Test: `ui/vertraege.mjs` **Interfaces:** - Consumes: `GET /api/abo` (Task 7) - Produces: - `renderStoragePanel(container, data)` wird in Task 17 gefüllt. Bis dahin gibt es eine leere Hülle `#storage-panel`. - Der Knopf „Zum Wartungs-Abo wechseln“ führt zu `data-view="wechsel"`. - [ ] **Step 1: Failing check** – `ui/vertraege.mjs` - **`hosting`:** - Die Karte „Mein Vertrag“ zeigt „Hosting-Abo“, „15,00 €“ und die nächste Abbuchung. - Es gibt kein Stundenkonto, dafür die Karte mit dem Knopf „Zum Wartungs-Abo wechseln“. - Vorhanden sind der Knopf „Postfach anfragen“ und `#storage-panel`. - Der Link „Vertragsbedingungen“ zeigt auf `/abo-bedingungen.html`. - **`hosting-gekuendigt`:** Ein Status-Pill „gekündigt“ ist zu sehen, der Wechsel-Knopf nicht. Stattdessen steht dort „Bitte schreiben Sie uns im Chat“. - **`wartung`:** Die Stunden stehen da, der Link „Stundenkonto“ führt zu `#stunden`, eine Wechsel-Karte gibt es nicht. - **`wartung-v3`:** Der Link „Vertragsbedingungen“ zeigt auf `/vertragsunterlagen/v3/abo-bedingungen.html`, im Konto-Menü ebenso. - **`ohne-abo`:** Zwei Vergleichskarten mit „Abo abschließen“ führen zu `#bestellen-hosting` bzw. `#bestellen-wartung`. - **`widerrufsfrist`:** „Vertrag widerrufen · bis TT.MM.“ ist hervorgehoben, sowohl in der Seitenleisten-Karte als auch in „Mein Vertrag“. - **`zahlungsproblem`:** Der Knopf „Zahlungsart aktualisieren“ ist da. - **„Postfach anfragen“:** Der Knopf öffnet den Chat. Im Eingabefeld steht „Bitte legen Sie ein neues Postfach an: “. - Run: Erwartet: FAIL. - [ ] **Step 2: Umsetzen:** Die Seitenleisten-Karte trägt das Label „Ihr Vertrag“. Die Hinweise zu Zahlungsproblem, Widerrufsfrist und Übernahmegutschrift bleiben wie heute. - [ ] **Step 3: Prüfen** - `ui/vertraege.mjs` meldet PASS auf Desktop und Handy. - Pixelvergleich der nicht betroffenen Views mit main: identisch. - [ ] **Step 4: Commit** mit der Nachricht „Verträge: Mein Vertrag, Vergleich der Abos, Wechsel-Karte, Unterlagen in der zugestimmten Fassung“. ### Task 16: Bestellseiten je Produkt **Files:** - Modify: `public/app.js`. `renderAboOrderView` heißt künftig `renderOrderView(view, data, productKey)`, gerendert in `view-bestellen-hosting` und `view-bestellen-wartung`. - Test: `ui/bestellen.mjs` **Interfaces:** - Consumes: `data.products`, `data.offers[productKey]`, `POST /api/abo/checkout` mit `productKey` - Produces: – - [ ] **Step 1: Failing check** – `ui/bestellen.mjs` - **`#bestellen-hosting`:** - Überschrift „Hosting-Abo“. - Die Übersicht nennt „WEBklar Hosting-Abo“ und „15,00 € pro Monat“. - Die Leistungen stammen aus `products[hosting].features`. - Der Knopf „Zahlungspflichtig bestellen“ sendet `productKey: 'hosting'`. - **`#bestellen-wartung`:** „50,00 € pro Monat“ und „beliebig viele Postfächer“. - **Angebot:** Das Angebot für Hosting erscheint nur auf der Hosting-Seite. - Die Pflicht-Häkchen und die Wahl des Leistungsbeginns funktionieren wie heute. - Run: Erwartet: FAIL. - [ ] **Step 2: Umsetzen** - [ ] **Step 3: Prüfen:** `ui/bestellen.mjs` meldet PASS. - [ ] **Step 4: Commit** mit der Nachricht „Bestellseiten für Hosting- und Wartungs-Abo“. ### Task 17: Zusatzspeicher bedienen **Files:** - Modify: `public/app.js` (`renderStoragePanel`), `public/kundenbereich.css` - Test: `ui/speicher.mjs` **Interfaces:** - Consumes: `data.subscription.storage`, `data.mailStorage`, `POST /api/abo/storage/preview` und `POST /api/abo/storage` (Task 8) - Produces: – - [ ] **Step 1: Failing check** – `ui/speicher.mjs` - **Anzeige:** „5 GB inklusive“, Plus und Minus mit Zähler, „= N GB gesamt“ und „+ N,00 € im Monat“. - **Plus:** - Nach der Vorschau erscheinen der anteilige Betrag, das Datum der nächsten Abbuchung, das Pflicht-Häkchen „Regeln zum Zusatzspeicher (Link `/abo-bedingungen.html#zusatzspeicher`)“ und der Knopf „Zahlungspflichtig bestellen“. - Ohne Häkchen gibt es einen Fehlerhinweis und keinen Request. - Mit Häkchen geht der Request `{ units, acceptTerms: true, termsVersion }` raus. - **Minus:** Der Hinweis lautet „wirkt zum TT.MM.“, der Knopf heißt „Speichern“, der Request enthält kein `acceptTerms`. - **Grenzen:** Bei 0 ist Minus deaktiviert, bei 10 Plus. - **`speicher-einrichtung`:** „wird eingerichtet“ steht da. - **`speicher-reduzierung`:** „ab TT.MM. X GB“ steht da. - **`hosting-gekuendigt`** und **`zahlungsproblem`:** Plus und Minus sind nicht zu sehen. - Run: Erwartet: FAIL. - [ ] **Step 2: Umsetzen** - [ ] **Step 3: Prüfen:** `ui/speicher.mjs` meldet PASS auf Desktop und Handy (Tastatur-Simulation per `setViewportSize`). - [ ] **Step 4: Commit** mit der Nachricht „Zusatzspeicher: Plus und Minus mit Bestellübersicht“. ### Task 18: Wechsel-Seite **Files:** - Modify: `public/app.js` (`renderSwitchView(view, data)`) - Test: `ui/wechsel.mjs` **Interfaces:** - Consumes: `POST /api/abo/switch/preview` und `POST /api/abo/switch` (Task 9) - Produces: – - [ ] **Step 1: Failing check** – `ui/wechsel.mjs` - **Übersicht** direkt über dem Knopf: - „Wartungs-Abo 50,00 € pro Monat“ - „Gutschrift für ungenutzte Hosting-Tage −9,00 €“ und „Heute fällig 41,00 €“ (Werte aus der Fixture-Vorschau) - neuer Abbuchungstag und „Mindestlaufzeit bis TT.MM.JJJJ“ - bei Zusatzspeicher die Zeile dafür - **Häkchen:** Beide Pflicht-Häkchen (Bedingungen, Sofortbeginn) sind nötig, sonst gibt es einen Fehlerhinweis und keinen Request. - **Bestellung:** „Zahlungspflichtig bestellen“ sendet `{ acceptTerms: true, earlyStart: true, termsVersion, prorationDate }`. - **Ergebnis:** - Antwort mit `paymentUrl`: Weiterleitung dorthin. - Antwort ohne: Die Ansicht „Verträge“ zeigt das Wartungs-Abo. - **Szenario `wartung`:** `#wechsel` führt zu „Verträge“. - Run: Erwartet: FAIL. - [ ] **Step 2: Umsetzen** - [ ] **Step 3: Prüfen:** `ui/wechsel.mjs` meldet PASS auf Desktop und Handy. - [ ] **Step 4: Commit** mit der Nachricht „Wechsel ins Wartungs-Abo: Übersicht, Zustimmung, Bestellung“. --- ## Block D – Ticketsystem ### Task 19: Schalter „Vom Abo-Stundenkonto abziehen“ nur bei Wartungs-Abo **Files (Worktree `/home/webklar/apps/ticket.webklar.com/dev-abo-stunden`):** - Modify: `src/lib/customerAdminApi.js` (neu `getCustomerAbo(customerId)`, ruft `GET /api/admin/customers/:id/abo` auf), `src/components/CreateWorksheetModal.jsx`, `src/components/WorksheetList.jsx` - Create: `src/hooks/useCustomerAbo.js`: `useCustomerAbo(customerId)` liefert `{ productKey, active, loading }` und speichert das Ergebnis je Kunde zwischen. **Interfaces:** - Consumes: `GET /api/admin/customers/:customerId/abo` (Task 13) - Produces: – - [ ] **Step 1: Prüfung im bewährten Ticketsystem-Harness schreiben** (esbuild-Bundle unter `systemd-run --scope -p MemoryMax=400M`, Playwright mit gemocktem `adminFetch`, siehe Memory „woms-ticketsystem“) - **Wartung aktiv:** Schalter und Badge „Abo-Stunden“ sichtbar. - **Hosting aktiv:** Schalter nicht sichtbar. - **Kein Abo:** Schalter nicht sichtbar. - **Fehler der Schnittstelle:** Schalter sichtbar wie heute (fail-open), Warnung in der Konsole. - Erwartet: FAIL. - [ ] **Step 2: Umsetzen** - [ ] **Step 3: Prüfen:** Harness meldet PASS. Das Bundle baut ohne Fehler. - [ ] **Step 4: Commit** im Ticketsystem-Worktree mit der Nachricht „Arbeitsnachweis: Abo-Stundenkonto nur bei Kunden mit Wartungs-Abo“. --- ## Block E – Livegang ### Task 20: Livegang (mit dem Nutzer) > **Rechtsrunde (28.09.2026), zusätzlich beim Livegang:** > 1. Die AGB der Website bekommen „Stand “ (Entscheidung 19). > 2. Mit dem neuen `takeover.conditionsVersion` wird der Schlüssel in `TAKEOVER_CONDITIONS_SHA256` (`test/legalTexts.test.js`) mitgezogen. > 3. Den Auftragsverarbeitungsvertrag von UptimeRobot (uptimerobot.com/dpa) herunterladen und ablegen, denn die Datenschutzerklärung bietet eine Kopie an. Die Angaben zu UptimeRobot noch einmal prüfen. > 4. Am Stripe-Webhook-Endpunkt `customer.subscription.pending_update_applied`, `customer.subscription.pending_update_expired` und `invoice.payment_failed` freischalten (Task 9). Das nur mit Freigabe des Nutzers. > 5. Die jährliche Erinnerung an die Löschfristen einrichten (Task 21, Lauf im Januar). Ein Cron-Eintrag auf dem Server nur mit Freigabe des Nutzers. **Files:** `server/config.js` (`abo.termsVersion`), `public/abo-bedingungen.html` (Stand), `public/datenschutz.html` (Stand), PDFs **Interfaces:** - Consumes: alles aus Tasks 1–19 und die Freigabe der Rechtsrunde - Produces: Plan 2 ist live. - [ ] **Step 1: Freigabedatum eintragen** - `abo.termsVersion = '-v4'`, dazu „Version 4 · Stand “ in den Bedingungen. - Der Stand der Datenschutzerklärung wird das Freigabedatum, `takeover.conditionsVersion` ebenfalls. - PDFs neu erzeugen mit `scripts/build-legal-pdfs.sh`. - Run: `npm test`. Erwartet: `# fail 0`, einschließlich `Version und Stand passen zu termsVersion`. - [ ] **Step 2: Node 20** ```bash docker run --rm -v /home/webklar/apps/kundenbereich.webklar.com:/home/webklar/apps/kundenbereich.webklar.com:ro -w /home/webklar/apps/kundenbereich.webklar.com/dev-vertraege node:20-alpine npm test ``` Erwartet: `# fail 0` - [ ] **Step 3: Alle UI-Prüfungen noch einmal:** `nav`, `vertraege`, `bestellen`, `speicher`, `wechsel`, die Formulare und der Admin-Bereich melden PASS. - [ ] **Step 4: Mit main abgleichen:** `git fetch origin && git log --oneline HEAD..origin/main`. Bei Abweichung erst rebasen, dann Step 1–3 wiederholen. - [ ] **Step 5: Schema in Produktion anlegen.** Das macht der Nutzer oder gibt es frei, und zwar vor dem Deploy. ```bash docker exec -i -w /app webklar-kundenbereich-server node --input-type=module - < /home/webklar/apps/kundenbereich.webklar.com/dev-vertraege/scripts/setup-abo-collections.mjs ``` Erwartet: `Fertig.`, neue Zeilen für `termStartAt`, `storageUnits`, `storagePaidUnits`, `storagePaidPeriodEnd`, `aboOffers.productKey` und die Collection `contractEvents`. - [ ] **Step 6: Portal pushen.** Nur mit Freigabe des Nutzers, der Push löst den Deploy aus. ```bash git -C /home/webklar/apps/kundenbereich.webklar.com/dev-vertraege push origin feature/vertraege-hosting-abo:main ``` Danach zeigt `build/` den Commit, der Container ist neu gestartet, und die Logs sind ohne Fehler. - [ ] **Step 7: Stripe einrichten.** Das ist ein Live-Schreibzugriff und braucht die ausdrückliche Freigabe des Nutzers. ```bash docker exec -w /app webklar-kundenbereich-server node scripts/stripe-products.mjs --pruefen docker exec -w /app webklar-kundenbereich-server node scripts/stripe-products.mjs --anlegen docker exec -w /app webklar-kundenbereich-server node scripts/stripe-products.mjs --pruefen ``` Erwartet: Die Preise für Hosting (1500) und Zusatzspeicher (100) sind vorhanden. Die Beschreibung des Wartungs-Produkts entspricht Fassung 4, die Überschrift des Kundenportals lautet „WEBklar – Abo verwalten“. - [ ] **Step 8: Website pushen.** Nur mit Freigabe des Nutzers. ```bash git -C /home/webklar/apps/webklar.com/dev-hosting-abo push origin feature/hosting-abo:main ``` Der Deploy dauert etwa 20 s. Danach liefern `https://webklar.com/hosting-abo` und `https://webklar.com/wartungs-abo` den Status 200, und der Headless-Check zeigt die neuen Texte. - [ ] **Step 9: Ticketsystem.** Der Nutzer pusht selbst auf `test`, und nur auf diesen Branch: ```bash git -C /home/webklar/apps/ticket.webklar.com/dev-abo-stunden push origin feature/abo-stunden-wartung:test ``` - [ ] **Step 10: Live prüfen:** - `https://project.webklar.com/abo-bedingungen.html` zeigt „Version 4“. - `…/WEBklar-Abo-Vertragsunterlagen.pdf` liefert 200. `…/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf` liefert 200 und enthält Version 3. - `…/vertragsunterlagen/v3/abo-bedingungen.html` liefert 200. - Die Datenschutzerklärung zeigt den neuen Stand und UptimeRobot. - `GET /api/abo` ohne Session liefert 401. - Mit dem Testkonto des Nutzers, und nur mit seiner Freigabe: „Verträge“ zeigt das Wartungs-Abo, Plus/Minus und die Vorschau funktionieren. - [ ] **Step 11: Betrieb (Nutzer):** - In UptimeRobot sind alle Abo-Websites als Monitor angelegt. - Der belegte Speicher in konsoleH ist abgelesen (Paket M, Warnschwelle 40 GB). - Die Info-Mail an Bestandskunden mit Fassung 3 (beliebig viele Postfächer) ist entworfen, vom Nutzer freigegeben und verschickt. - [ ] **Step 11a: Zusätzliche Schritte aus Rechtsrunde und Gesamtprüfung (30.09.2026)** - **Zu Step 1:** Mit `takeover.conditionsVersion` auch den Schlüssel in `TAKEOVER_CONDITIONS_SHA256` (`test/legalTexts.test.js`) umstellen. Den Stand in `datenschutz.html` und `abo-bedingungen.html` auf das Freigabedatum setzen. `termsVersion` muss auf `-v4` enden. Danach die PDFs neu erzeugen. - **Vor Step 6:** Stripe-Testmodus mit einer eigenen Instanz mit Testschlüssel, nicht im Produktions-Container. Zu prüfen: - Wechsel mit 3-D-Secure-Testkarte: Metadaten und `termStartAt` bleiben bis zur Zahlung unverändert (F28). - Die gehostete Rechnungsseite bei einem ausstehenden Wechsel. - Rabatte in der Wechsel-Vorschau. - Speicher im flexiblen Abrechnungsmodus: erst Minus, dann Plus über die bezahlte Menge; die Vorschau mit der kommenden Rechnung vergleichen. - **Vor oder mit Step 7:** Die Info-Mail an die Bestandskunden der Fassung 3 geht raus (beliebig viele Postfächer, Zusatzspeicher nur mit Zustimmung). Grund: `--anlegen` ändert die Beschreibung des Wartungs-Produkts in Stripe. - **Stripe-Webhook-Endpunkt** (Dashboard, mit Freigabe des Nutzers): `customer.subscription.pending_update_applied` und `customer.subscription.pending_update_expired` freischalten. Außerdem prüfen, dass diese Ereignisse aktiv sind: - `checkout.session.completed` - `invoice.paid` - `invoice.payment_failed` - `customer.subscription.created` - `customer.subscription.updated` - `customer.subscription.deleted` - **Pilot:** Die Routen für Speicher und Wechsel haben keinen Pilot-Filter. Bevor `ABO_PILOT_EMAILS` nicht `*` ist, entscheidet der Nutzer, ob das so bleiben soll. - **Nach Step 6:** Über „Als Kunde ansehen“ einen echten Kunden mit Wartungs-Abo nach Fassung 3 prüfen: Verträge, Stundenkonto, Fristen. Außerdem muss der Admin-Reiter „E-Mail-Speicher“ laden (Sortierung nach `dueAt` in echtem Appwrite). - **UptimeRobot:** Den Auftragsverarbeitungsvertrag ablegen und die Angaben zu UptimeRobot prüfen. - **Löschfristen** (mit Freigabe des Nutzers): Cron nur mit `LOESCHFRISTEN=mail` im Januar. `apply` erst nach Durchsicht des Berichts, von Hand. - **AGB der Website:** Das Stand-Datum setzen. - [ ] **Step 12:** Memory aktualisieren: Plan 2 live, Commits, offene Punkte.