From 405408d0761597382cf09eee12f50c1a1c6c7ffb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:04:44 +0000 Subject: [PATCH] =?UTF-8?q?Umsetzungspl=C3=A4ne:=20Plan=201=20(unsichtbare?= =?UTF-8?q?=20Vorarbeiten)=20und=20Plan=202=20(sichtbarer=20Teil=20mit=20V?= =?UTF-8?q?ersion=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 1: Produktkatalog, Abo-Spiegel mit productKey, Arbeitszeit nur für Wartungs-Rechnungen, Stripe- und Appwrite-Nachbau für Tests. Kann vor den neuen Vertragsbedingungen live gehen. Plan 2: Rechtstexte (Gate Rechtsrunde), Vertragsereignisse, Zusatzspeicher, Wechsel, öffentliche Formulare, Angebote je Produkt, Mails, Admin, Ansicht "Verträge", Website, Ticketsystem, Livegang. Co-Authored-By: Claude Opus 5.5 --- ...2026-09-27-vertraege-plan-1-vorarbeiten.md | 580 ++++++++ .../2026-09-27-vertraege-plan-2-sichtbar.md | 1243 +++++++++++++++++ 2 files changed, 1823 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-27-vertraege-plan-1-vorarbeiten.md create mode 100644 docs/superpowers/plans/2026-09-27-vertraege-plan-2-sichtbar.md diff --git a/docs/superpowers/plans/2026-09-27-vertraege-plan-1-vorarbeiten.md b/docs/superpowers/plans/2026-09-27-vertraege-plan-1-vorarbeiten.md new file mode 100644 index 0000000..2f24117 --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-vertraege-plan-1-vorarbeiten.md @@ -0,0 +1,580 @@ +# Plan 1 – Unsichtbare Vorarbeiten: Produktkatalog, Abo-Zuordnung, Stunden nur fürs Wartungs-Abo + +> **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:** Das Portal kennt mehrere Abo-Produkte, und Arbeitszeit gibt es nur noch für Wartungs-Rechnungen. Kunden sehen dabei nichts Neues, und es ändert sich kein Rechtstext. + +**Aufbau:** + +- **Produktkatalog in `server/config.js`:** beschreibt die zwei Abos und den Zusatzspeicher. +- **Reine Funktionen in `server/services/products.js`:** werten Stripe-Positionen und Rechnungszeilen aus. +- **`server/services/stripeBilling.js`:** nutzt diese Funktionen an zwei Stellen: + - beim Spiegeln der Abos nach Appwrite (`productKey`, `planKey`, `priceAmount`) + - bei der Stundengutschrift. Die Rechnung wird frisch geladen, und nur eine nicht anteilige Wartungs-Zeile über 0 € zählt. +- **Tests:** Sie laufen im Testprozess gegen einen Stripe-Nachbau und einen Appwrite-Nachbau. + +**Technik:** + +- Node 20 in Produktion, Node 24 auf dem Host +- ESM, Express +- stripe-node 19.3.0 (API `2025-10-29.clover`) +- Appwrite-REST über `server/services/appwriteAdmin.js` +- `node --test` + +**Spec:** `docs/superpowers/specs/2026-09-27-vertraege-hosting-abo-design.md`. Maßgeblich sind die Abschnitte 5, 6, 7, 11 (Punkt 2) und 12. + +## Global Constraints + +**Was sich nicht ändern darf** + +- Keine sichtbare Änderung im Portal, keine Änderung an Bedingungen, Datenschutz oder Mails (Spec 11, Punkt 2). +- Diese Dateien fasst Plan 1 nicht an, sie gehören zu Plan 2: `server/routes/abo.js`, `server/services/mailer.js`, `server/services/mailTemplates.js`, `server/services/offers.js`, `server/services/takeoverCredits.js`, `public/*`. + +**Werte, die wörtlich gelten** + +- **lookup_keys:** + - `webklar_wartung_monatlich_5000`. Er besteht schon und bleibt über `STRIPE_ABO_PRICE_LOOKUP_KEY` überschreibbar. + - `webklar_hosting_monatlich_1500` + - `webklar_mailspeicher_5gb_monatlich_100` +- **Preise in Cent:** Wartung 5000, Hosting 1500, Zusatzspeicher 100. +- **Arbeitszeit pro Monat:** Wartung 60 Minuten, Hosting 0. +- **Mindestlaufzeit:** beide Abos 12 Monate. +- **Zusatzspeicher:** 5 GB je Paket, 5 GB inklusive, höchstens 10 Pakete. +- **Altbestand:** Abos ohne erkennbaren Abo-Preis gelten als `wartung`. + +**Tests** + +- Tests erreichen nie Produktion. `APPWRITE_ENDPOINT` und `STRIPE_API_BASE` zeigen in Tests immer auf `127.0.0.1`. +- `npm test` muss auch unter Node 20 grün sein, denn das ist die Laufzeit in Produktion. + +**Git** + +- Gearbeitet wird im Worktree `/home/webklar/apps/kundenbereich.webklar.com/dev-vertraege` auf dem Branch `feature/vertraege-hosting-abo`, ohne Upstream. +- Niemals `git add -A`: Der `node_modules`-Symlink ist untracked. +- Push nach `main` nur mit Freigabe des Nutzers, denn ein Push dorthin geht sofort live. + +## Review Focus + +1. **Die Webhook-Nutzlast kommt in einer älteren API-Version** (Zeilen ohne `pricing` und `parent`). Die Stunde wird trotzdem richtig vergeben, weil `grantCreditForInvoice` die Rechnung frisch über die SDK-Version lädt. Test: Task 4, „frisch geladene Rechnung zählt, nicht die Webhook-Nutzlast“. +2. **Eine Rechnungszeile hat einen unbekannten Preis**, etwa von Hand im Stripe-Dashboard angelegt. Dann gibt es keine Stunde und keinen Absturz. Tests: Task 1 (`creditMinutesForInvoice` mit unbekannter Preis-ID ergibt 0) und Task 4 (Preis-Abruf mit 404). +3. **Die Zusatzspeicher-Position steht in `items.data` vor der Abo-Position.** `planKey` und `priceAmount` kommen trotzdem vom Abo-Posten, nicht von `items.data[0]`. Tests: Task 1 und Task 3. +4. **In der Appwrite-Collection fehlt ein Attribut, das der Spiegel schreibt.** Appwrite lehnt das Dokument ab, der Webhook liefert 500. Test: Task 3, der Appwrite-Nachbau erzwingt das Schema aus `scripts/setup-abo-collections.mjs`. Im Livegang (Task 5) läuft das Schema-Skript deshalb vor dem Deploy. +5. **Ein Abo hat überhaupt keinen bekannten Abo-Preis** (Altbestand oder von Hand angelegt). Es wird als Wartung gespiegelt und im Log gewarnt. Test: Task 3. + +--- + +## Vorbereitung + +- [ ] Worktree aktuell und `node_modules` verlinkt: + +```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 +ls -l /home/webklar/apps/kundenbereich.webklar.com/dev-vertraege/node_modules +``` + +Erwartet: Der Rebase meldet Erfolg, und `node_modules -> ../build/node_modules` existiert. Fehlt der Link: + +```bash +ln -sfn ../build/node_modules /home/webklar/apps/kundenbereich.webklar.com/dev-vertraege/node_modules +``` + +- [ ] Ausgangslage grün: + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-vertraege && npm test +``` + +Erwartet: Alle bestehenden Tests laufen durch (`# fail 0`). + +## Dateien + +| Datei | Aufgabe | +|---|---| +| `server/config.js` (ändern) | Produktkatalog `products` und `mailStorage` sowie `stripe.apiBase`. Die bisherigen Werte `stripe.*` und `abo.*` werden aus dem Katalog abgeleitet und bleiben gleich. | +| `server/services/products.js` (neu) | Reine Funktionen: Produkt nachschlagen, lookup_key zuordnen, Abo-Positionen und Rechnungszeilen auswerten | +| `server/services/stripeBilling.js` (ändern) | `getStripe` mit Testadresse, `resolvePriceKeys`, `syncSubscription`, `grantCreditForInvoice` | +| `scripts/setup-abo-collections.mjs` (ändern) | Attribut `subscriptions.productKey` | +| `test/support/appwriteMock.js` (neu) | Appwrite-Nachbau im Testprozess | +| `test/support/fakeStripe.js` (neu) | Stripe-Nachbau im Testprozess | +| `test/support/testEnv.js` (neu) | Umgebungsvariablen für Tests, verweigert fremde Hosts | +| `test/products.test.js`, `test/support.test.js`, `test/stripeBilling.test.js`, `test/hourCredits.test.js` (neu) | Tests | + +--- + +### Task 1: Produktkatalog und Auswertung + +**Files:** +- Modify: `server/config.js` (Block `stripe` Z. 59–75, Block `abo` Z. 77–98) +- Create: `server/services/products.js` +- Test: `test/products.test.js` + +**Interfaces:** +- Consumes: – +- Produces: + - **`config.products.wartung` und `config.products.hosting`:** jeweils `{ key, name, lookupKey, amountCents, description, statementDescriptor, priceNickname, minTermMonths, minutesPerMonth }`. + - **`config.mailStorage`:** `{ key: 'mailStorage', name, lookupKey, amountCents: 100, gbPerUnit: 5, includedGb: 5, maxUnits: 10, priceNickname }`. + - **Exporte aus `server/services/products.js`:** + - `PRODUCT_KEYS`: `['wartung', 'hosting']` + - `getProduct(key)`: liefert das Produkt und wirft bei unbekanntem Schlüssel einen Fehler mit `status = 400` + - `productKeyForLookupKey(lookupKey)`: liefert `'wartung'`, `'hosting'`, `'mailStorage'` oder `null` + - `describeItems(items)`: liefert `{ productKey, aboItemId, aboPriceId, lookupKey, priceAmount, storageItemId, storageUnits }` + - `priceIdsOfInvoice(invoice)`: liefert `string[]` + - `creditMinutesForInvoice(invoice, priceKeys)`: `priceKeys` ist eine `Map`, Rückgabe sind Minuten als Zahl + +- [ ] **Step 1: Write the failing test** – `test/products.test.js` + +```js +import { test } from 'node:test' +import assert from 'node:assert/strict' +const { config } = await import('../server/config.js') +const { getProduct, productKeyForLookupKey, describeItems, priceIdsOfInvoice, creditMinutesForInvoice } = + await import('../server/services/products.js') + +const line = (price, amount, proration = false) => ({ + amount, pricing: { price_details: { price } }, + parent: { type: 'subscription_item_details', subscription_item_details: { proration } }, +}) +const keys = new Map([['price_w', 'wartung'], ['price_h', 'hosting'], ['price_s', 'mailStorage']]) + +test('Katalog: Preise, lookup_keys, Minuten, Mindestlaufzeit', () => { + assert.equal(getProduct('wartung').amountCents, 5000) + assert.equal(getProduct('hosting').amountCents, 1500) + assert.equal(getProduct('hosting').lookupKey, 'webklar_hosting_monatlich_1500') + assert.equal(getProduct('wartung').minutesPerMonth, 60) + assert.equal(getProduct('hosting').minutesPerMonth, 0) + assert.equal(getProduct('hosting').minTermMonths, 12) + assert.equal(config.mailStorage.lookupKey, 'webklar_mailspeicher_5gb_monatlich_100') + assert.equal(config.mailStorage.maxUnits, 10) + assert.throws(() => getProduct('gold'), (e) => e.status === 400) +}) + +test('Bisherige Werte bleiben gleich', () => { + assert.equal(config.stripe.priceLookupKey, 'webklar_wartung_monatlich_5000') + assert.equal(config.stripe.priceAmountCents, 5000) + assert.equal(config.stripe.productName, 'WEBklar Wartungs-Abo') + assert.equal(config.stripe.statementDescriptor, 'WEBKLAR WARTUNG') + assert.equal(config.abo.minutesPerMonth, 60) + assert.equal(config.abo.minTermMonths, 12) +}) + +test('lookup_key → Produkt', () => { + assert.equal(productKeyForLookupKey('webklar_wartung_monatlich_5000'), 'wartung') + assert.equal(productKeyForLookupKey('webklar_hosting_monatlich_1500'), 'hosting') + assert.equal(productKeyForLookupKey('webklar_mailspeicher_5gb_monatlich_100'), 'mailStorage') + assert.equal(productKeyForLookupKey('irgendwas'), null) + assert.equal(productKeyForLookupKey(undefined), null) +}) + +test('describeItems: Abo-Posten und Zusatzspeicher, Reihenfolge egal', () => { + const items = [ + { id: 'si_s', quantity: 2, price: { id: 'price_s', lookup_key: 'webklar_mailspeicher_5gb_monatlich_100', unit_amount: 100 } }, + { id: 'si_a', quantity: 1, price: { id: 'price_h', lookup_key: 'webklar_hosting_monatlich_1500', unit_amount: 1500 } }, + ] + assert.deepEqual(describeItems(items), { + productKey: 'hosting', aboItemId: 'si_a', aboPriceId: 'price_h', lookupKey: 'webklar_hosting_monatlich_1500', + priceAmount: 1500, storageItemId: 'si_s', storageUnits: 2, + }) +}) + +test('describeItems: ohne bekannten Abo-Preis', () => { + const d = describeItems([{ id: 'si_x', quantity: 1, price: { id: 'price_x', lookup_key: null, unit_amount: 4900 } }]) + assert.deepEqual(d, { productKey: null, aboItemId: null, aboPriceId: null, lookupKey: null, priceAmount: null, storageItemId: null, storageUnits: 0 }) + assert.deepEqual(describeItems(undefined).storageUnits, 0) +}) + +test('Stunden nur für eine reguläre Wartungs-Zeile über 0 €', () => { + const inv = (...lines) => ({ lines: { data: lines } }) + assert.equal(creditMinutesForInvoice(inv(line('price_w', 5000)), keys), 60) + assert.equal(creditMinutesForInvoice(inv(line('price_h', 1500), line('price_s', 200)), keys), 0) + assert.equal(creditMinutesForInvoice(inv(line('price_w', 2300, true)), keys), 0) // anteilige Verrechnung + assert.equal(creditMinutesForInvoice(inv(line('price_w', 0)), keys), 0) // Probezeit + assert.equal(creditMinutesForInvoice(inv(line('price_x', 5000)), keys), 0) // unbekannter Preis + const invoiceItem = { amount: 1200, pricing: { price_details: { price: 'price_w' } }, + parent: { type: 'invoice_item_details', invoice_item_details: { proration: true } } } + assert.equal(creditMinutesForInvoice(inv(invoiceItem), keys), 0) + // Wechsel-Rechnung: Gutschrift Hosting (anteilig, negativ) und voller Wartungs-Monat + assert.equal(creditMinutesForInvoice(inv(line('price_h', -900, true), line('price_w', 5000)), keys), 60) + assert.equal(creditMinutesForInvoice({}, keys), 0) +}) + +test('priceIdsOfInvoice: eindeutige Preis-IDs, ohne Lücken', () => { + const inv = { lines: { data: [line('price_w', 5000), line('price_w', -100, true), { amount: 5 }] } } + assert.deepEqual(priceIdsOfInvoice(inv), ['price_w']) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-vertraege && node --test test/products.test.js` +Expected: FAIL, `Cannot find module '…/server/services/products.js'` + +- [ ] **Step 3: Katalog in `server/config.js` anlegen** + - Vor `export const config` stehen `const products = { wartung, hosting }` und `const mailStorage`. + - **wartung:** + - `description` ist der bisherige Text von `config.stripe.productDescription`, wörtlich übernommen. + - `statementDescriptor` `'WEBKLAR WARTUNG'`, `priceNickname` `'Wartungs-Abo monatlich'`, `lookupKey` `process.env.STRIPE_ABO_PRICE_LOOKUP_KEY || 'webklar_wartung_monatlich_5000'`. + - **hosting:** + - `name` `'WEBklar Hosting-Abo'`, `statementDescriptor` `'WEBKLAR HOSTING'`, `priceNickname` `'Hosting-Abo monatlich'`. + - `description`: `'Bereitstellung Ihrer statischen Website auf unserem Server in Deutschland (SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit, Updates von Betriebssystem und Server-Software), 1 Domain inklusive Verwaltung und 1 E-Mail-Postfach (5 GB). Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.'` + - Dieser Text geht erst in Plan 2 an Stripe und wird dort mit Version 4 abgeglichen. + - **mailStorage:** `name` `'WEBklar E-Mail-Zusatzspeicher 5 GB'`, `priceNickname` `'E-Mail-Zusatzspeicher 5 GB monatlich'`. + - **Abgeleitete Altwerte:** `config.stripe.priceLookupKey`, `priceAmountCents`, `productName`, `productDescription` und `statementDescriptor` lesen aus `products.wartung`, ebenso `config.abo.minTermMonths` und `minutesPerMonth`. + - **Export:** `config.products = products`, `config.mailStorage = mailStorage`. + +- [ ] **Step 4: `server/services/products.js` anlegen** + - Die Funktionen sind rein, ohne Netzwerk. + - **`describeItems(items)`:** Abo-Posten ist das erste Item, dessen `price.lookup_key` auf `wartung` oder `hosting` zeigt. Zusatzspeicher ist das erste Item mit `mailStorage`, `storageUnits = quantity`, sonst 0. Ohne Array liefert die Funktion die Leerform. + - **`creditMinutesForInvoice`:** Es zählt die erste Zeile, für die alles zusammen gilt: + - `priceKeys.get(line.pricing?.price_details?.price)` ist ein Produkt mit `minutesPerMonth > 0` + - `line.parent?.type === 'subscription_item_details'` + - `line.parent.subscription_item_details.proration === false` + - `line.amount > 0` + + Zurück kommt `minutesPerMonth` dieses Produkts, sonst 0. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `node --test test/products.test.js && npm test` +Expected: PASS, Gesamtlauf mit `# fail 0` + +- [ ] **Step 6: Commit** + +```bash +git add server/config.js server/services/products.js test/products.test.js +git commit -m "Abo-Katalog: Wartung, Hosting und Zusatzspeicher als Produkte (noch ohne Verwendung) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Test-Nachbauten und Stripe-Adresse für Tests + +**Files:** +- Modify: `server/config.js` (Block `stripe`: `apiBase`), `server/services/stripeBilling.js:26-36` (`getStripe`) +- Create: `test/support/appwriteMock.js`, `test/support/fakeStripe.js`, `test/support/testEnv.js` +- Test: `test/support.test.js` + +**Interfaces:** +- Consumes: – +- Produces: + - **`startAppwriteMock({ schema = {} } = {})`:** liefert `Promise<{ url, docs(collection), seed(collection, doc), close() }>`. + - `url` ist `http://127.0.0.1:/v1`, `docs(collection)` liefert eine `Map`. + - Pfade: + - `GET` und `POST /v1/databases/woms-database/collections/:col/documents` + - `GET`, `PATCH` und `DELETE …/documents/:id` + - `GET /v1/users/:id`, liefert `{ $id, passwordUpdate: '' }` + - Queries kommen aus `queries[]` (JSON). Unterstützt werden `equal`, `greaterThan`, `lessThan`, `orderAsc`, `orderDesc` und `limit`. Jede andere Methode liefert 400. + - `POST` erwartet `{ documentId, data }`. Existiert die ID schon, kommt 409 mit `{ message }`. + - `PATCH` erwartet `{ data }`. Fehlt das Dokument, kommt 404. + - Ist `schema[col]` gesetzt, liefert ein unbekannter Schlüssel 400 mit `{ message: 'Invalid document structure: Unknown attribute: ""' }`. + - Jedes Dokument bekommt `$id`, `$createdAt` und `$updatedAt`. + - **`startFakeStripe()`:** liefert `Promise<{ url, calls, subscriptions, prices, invoices, close() }>`. Die drei Objektsammlungen sind `Map`s, vorbelegbar mit `set(id, obj)`. + - Pfade: `GET /v1/subscriptions/:id`, `GET /v1/prices/:id`, `GET /v1/prices` (mit Filter `lookup_keys[]`), `GET /v1/invoices/:id`. + - Jeder Aufruf landet in `calls` als `{ method, path, query, body }`. + - Unbekannte IDs oder Pfade liefern 404 mit `{ error: { type: 'invalid_request_error', message: 'No such …' } }`. + - **`useTestEnv({ appwriteUrl, stripeUrl })`:** setzt: + - `APPWRITE_ENDPOINT`, `APPWRITE_API_KEY='test-key'` + - `STRIPE_SECRET_KEY='sk_test_fake'`, `STRIPE_API_BASE`, `STRIPE_WEBHOOK_SECRET='whsec_test'` + - `SMTP_HOST=''` + - `SESSION_SECRET` (mindestens 32 Zeichen), `ABO_PILOT_EMAILS='*'` + + Zeigt eine Adresse nicht auf `127.0.0.1`, wirft die Funktion einen Fehler. + - **`config.stripe.apiBase`:** `process.env.STRIPE_API_BASE || ''`. Ist der Wert gesetzt, erzeugt `getStripe()` den Client mit `{ host, port, protocol }` aus dieser URL. + +- [ ] **Step 1: Write the failing test** – `test/support.test.js` + +```js +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' + +const aw = await startAppwriteMock({ schema: { subscriptions: ['customerId', 'status'] } }) +const st = await startFakeStripe() +after(() => Promise.all([aw.close(), st.close()])) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { createDocument, getDocument, listDocuments, Query } = await import('../server/services/appwriteAdmin.js') +const { getStripe } = await import('../server/services/stripeBilling.js') + +test('Appwrite-Nachbau: anlegen, lesen, filtern', async () => { + await createDocument('subscriptions', { customerId: 'c1', status: 'active' }, 'sub_a') + assert.equal((await getDocument('subscriptions', 'sub_a')).status, 'active') + const list = await listDocuments('subscriptions', [Query.equal('customerId', 'c1'), Query.limit(5)]) + assert.deepEqual(list.map((d) => d.$id), ['sub_a']) +}) + +test('Appwrite-Nachbau: Schema wie Appwrite', async () => { + await assert.rejects(createDocument('subscriptions', { unbekannt: 1 }, 'sub_b'), (e) => e.status === 400) +}) + +test('Stripe-Nachbau: getStripe spricht mit 127.0.0.1', async () => { + st.subscriptions.set('sub_1', { id: 'sub_1', object: 'subscription', items: { data: [] } }) + assert.equal((await getStripe().subscriptions.retrieve('sub_1')).id, 'sub_1') + assert.equal(st.calls.at(-1).path, '/v1/subscriptions/sub_1') +}) + +test('useTestEnv lehnt fremde Hosts ab', () => { + assert.throws(() => useTestEnv({ appwriteUrl: 'https://ticket.webklar.com/v1', stripeUrl: st.url })) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test test/support.test.js` +Expected: FAIL, `Cannot find module './support/appwriteMock.js'` + +- [ ] **Step 3: Nachbauten, `useTestEnv` und die Testadresse in `getStripe()` umsetzen** + - Beide Server lauschen auf `127.0.0.1` und Port 0. + - Den `x-www-form-urlencoded`-Rumpf von stripe-node liest der Stripe-Nachbau mit `URLSearchParams` in ein flaches Objekt. Die Schlüssel bleiben dabei wie `items[0][price]`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test test/support.test.js && npm test` +Expected: PASS, `# fail 0` + +- [ ] **Step 5: Commit** + +```bash +git add server/config.js server/services/stripeBilling.js test/support test/support.test.js +git commit -m "Tests: Stripe- und Appwrite-Nachbau im Testprozess + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Abo-Spiegel mit Produkt + +**Files:** +- Modify: `server/services/stripeBilling.js` (`syncSubscription`, Z. 147–183) +- Modify: `scripts/setup-abo-collections.mjs`: In der `subscriptions`-Attributliste kommt nach `offerText` die Zeile `{ type: 'string', key: 'productKey', size: 16 }` dazu, mit dem Kommentar `// Plan 1: Produkt des Abos (wartung | hosting)`. +- Test: `test/stripeBilling.test.js` + +**Interfaces:** +- Consumes: + - `describeItems` (Task 1) + - `startAppwriteMock`, `startFakeStripe` und `useTestEnv` (Task 2) +- Produces: Das Dokument in `subscriptions` hat danach: + - `productKey`: `'wartung'` oder `'hosting'` + - `planKey`: lookup_key des Abo-Preises + - `priceAmount`: `unit_amount` des Abo-Preises + +- [ ] **Step 1: Write the failing test** – `test/stripeBilling.test.js` + +```js +// Setup wie in test/support.test.js. Das Schema kommt aus dem Setup-Skript: +import { readFileSync } from 'node:fs' +function schemaKeys(collection) { + const src = readFileSync(new URL('../scripts/setup-abo-collections.mjs', import.meta.url), 'utf8') + const start = src.indexOf(`'${collection}',`) + const block = src.slice(start, src.indexOf('\n ],', start)) + return [...block.matchAll(/key: '(\w+)'/g)].map((m) => m[1]) +} +// startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions') } }) … +const sub = (id, items, extra = {}) => ({ id, object: 'subscription', status: 'active', customer: 'cus_1', + start_date: 1790000000, current_period_end: 1792600000, trial_end: null, cancel_at_period_end: false, + metadata: { webklarCustomerId: 'c1', email: 'k@example.invalid', termsVersion: '2026-09-25-v3' }, + items: { data: items }, ...extra }) +const item = (id, lookup_key, unit_amount, quantity = 1) => ({ id, quantity, price: { id: `price_${id}`, lookup_key, unit_amount } }) + +test('Setup-Skript legt productKey an', () => { + assert.ok(schemaKeys('subscriptions').includes('productKey')) +}) + +test('Spiegel: Hosting-Abo, Zusatzspeicher steht vorne', async () => { + st.subscriptions.set('sub_h', sub('sub_h', [item('s', 'webklar_mailspeicher_5gb_monatlich_100', 100, 2), item('h', 'webklar_hosting_monatlich_1500', 1500)])) + const doc = await syncSubscription('sub_h') + assert.equal(doc.productKey, 'hosting') + assert.equal(doc.planKey, 'webklar_hosting_monatlich_1500') + assert.equal(doc.priceAmount, 1500) +}) + +test('Spiegel: Wartungs-Abo wie bisher', async () => { + st.subscriptions.set('sub_w', sub('sub_w', [item('w', 'webklar_wartung_monatlich_5000', 5000)])) + const doc = await syncSubscription('sub_w') + assert.deepEqual([doc.productKey, doc.planKey, doc.priceAmount], ['wartung', 'webklar_wartung_monatlich_5000', 5000]) +}) + +test('Spiegel: unbekannter Preis gilt als Wartungs-Abo, Betrag vom ersten Posten', async () => { + st.subscriptions.set('sub_x', sub('sub_x', [item('x', null, 4900)])) + const doc = await syncSubscription('sub_x') + assert.deepEqual([doc.productKey, doc.planKey, doc.priceAmount], ['wartung', 'webklar_wartung_monatlich_5000', 4900]) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test test/stripeBilling.test.js` +Expected: FAIL. Entweder fehlt `productKey` im Setup-Skript, oder der Nachbau lehnt ab, oder `doc.productKey` ist `undefined`. + +- [ ] **Step 3: Umsetzen** + - `syncSubscription` ruft `const d = describeItems(sub.items?.data)` auf und schreibt: + - `productKey: d.productKey || 'wartung'` + - `planKey: d.lookupKey || config.products.wartung.lookupKey` + - `priceAmount: d.priceAmount ?? sub.items?.data?.[0]?.price?.unit_amount ?? config.products.wartung.amountCents` + - Ist `d.productKey` leer, folgt `console.warn('[stripe] Abo : kein bekannter Abo-Preis – als Wartungs-Abo gespiegelt')`. + - Außerdem die neue Attributzeile ins Setup-Skript eintragen. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test test/stripeBilling.test.js && npm test` +Expected: PASS, `# fail 0` + +- [ ] **Step 5: Commit** + +```bash +git add server/services/stripeBilling.js scripts/setup-abo-collections.mjs test/stripeBilling.test.js +git commit -m "Abo-Spiegel: Produkt und tatsächlicher Preis aus den Stripe-Positionen + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Stunden nur für Wartungs-Rechnungen + +**Files:** +- Modify: `server/services/stripeBilling.js`: `grantCreditForInvoice` (Z. 244–279) und die neue Funktion `resolvePriceKeys` +- Test: `test/hourCredits.test.js`. Eine eigene Datei, damit der Preis-Zwischenspeicher frisch ist. + +**Interfaces:** +- Consumes: + - `priceIdsOfInvoice` und `creditMinutesForInvoice` (Task 1) + - Nachbauten (Task 2) + - `syncSubscription` (Task 3) +- Produces: + - **`resolvePriceKeys(priceIds: string[])`:** liefert `Promise>`. + - Das Ergebnis bleibt pro Prozess zwischengespeichert. + - Unbekannte IDs holt die Funktion einmal über `stripe.prices.retrieve(id)` und liest `lookup_key`. Bei 404 ist das Ergebnis `null`. + - **`grantCreditForInvoice(invoice)`:** Die Rückgabe bleibt wie bisher: `{ granted: true, customerId, subscription }` oder `{ skipped: }`. Neuer Grund ist `'keine Wartungs-Leistung'`. + +- [ ] **Step 1: Write the failing test** – `test/hourCredits.test.js` + +```js +// Setup wie Task 3. Vorbelegung im Stripe-Nachbau: +// price_w (lookup wartung, 5000), price_h (lookup hosting, 1500) +// sub_w mit price_w, sub_h mit price_h (metadata.webklarCustomerId 'c1') +const inv = (id, sub, lines) => ({ id, object: 'invoice', created: 1790000000, status_transitions: { paid_at: 1790000100 }, + parent: { subscription_details: { subscription: sub } }, lines: { data: lines } }) +const regular = (price, amount) => ({ amount, pricing: { price_details: { price } }, + parent: { type: 'subscription_item_details', subscription_item_details: { proration: false } } }) + +test('Wartungs-Rechnung: 60 Minuten, genau einmal', async () => { + st.invoices.set('in_1', inv('in_1', 'sub_w', [regular('price_w', 5000)])) + const r = await grantCreditForInvoice({ id: 'in_1', parent: { subscription_details: { subscription: 'sub_w' } } }) + assert.equal(r.granted, true) + assert.equal(aw.docs('hourCredits').get('in_1').minutes, 60) + assert.deepEqual(await grantCreditForInvoice({ id: 'in_1', parent: { subscription_details: { subscription: 'sub_w' } } }), { skipped: 'bereits gutgeschrieben' }) +}) + +test('Hosting-Rechnung: keine Stunde', async () => { + st.invoices.set('in_2', inv('in_2', 'sub_h', [regular('price_h', 1500)])) + assert.deepEqual(await grantCreditForInvoice({ id: 'in_2', parent: { subscription_details: { subscription: 'sub_h' } } }), { skipped: 'keine Wartungs-Leistung' }) + assert.equal(aw.docs('hourCredits').has('in_2'), false) +}) + +test('Frisch geladene Rechnung zählt, nicht die Webhook-Nutzlast', async () => { + // Nutzlast in alter Form (ohne pricing/parent) behauptet Wartung, frisch geladen ist es Hosting + st.invoices.set('in_3', inv('in_3', 'sub_h', [regular('price_h', 1500)])) + const oldPayload = { id: 'in_3', subscription: 'sub_h', lines: { data: [{ amount: 5000, price: { id: 'price_w' }, proration: false }] } } + assert.deepEqual(await grantCreditForInvoice(oldPayload), { skipped: 'keine Wartungs-Leistung' }) +}) + +test('Preis-Zuordnung wird zwischengespeichert', async () => { + st.invoices.set('in_4', inv('in_4', 'sub_w', [regular('price_w', 5000)])) + await grantCreditForInvoice({ id: 'in_4', parent: { subscription_details: { subscription: 'sub_w' } } }) + assert.equal(st.calls.filter((c) => c.path === '/v1/prices/price_w').length, 1) +}) + +test('Unbekannte Preis-ID (404): keine Stunde, kein Absturz', async () => { + st.invoices.set('in_5', inv('in_5', 'sub_w', [regular('price_weg', 5000)])) + assert.deepEqual(await grantCreditForInvoice({ id: 'in_5', parent: { subscription_details: { subscription: 'sub_w' } } }), { skipped: 'keine Wartungs-Leistung' }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test test/hourCredits.test.js` +Expected: FAIL. `in_2` bekommt heute 60 Minuten, weil die heutige Prüfung nur `subtotal > 0` ansieht. + +- [ ] **Step 3: Umsetzen** + - Die Abo-ID wird wie bisher mit `subscriptionIdOfInvoice(invoice)` ermittelt. + - `const fresh = await getStripe().invoices.retrieve(invoice.id)` + - `const minutes = creditMinutesForInvoice(fresh, await resolvePriceKeys(priceIdsOfInvoice(fresh)))` + - Ist `minutes` 0, liefert die Funktion `{ skipped: 'keine Wartungs-Leistung' }`. + - Danach folgen `syncSubscription` und `createDocument('hourCredits', …, invoice.id)`, wie bisher. Dabei gilt: + - `minutes` statt `config.abo.minutesPerMonth` + - `grantedAt` aus `fresh.status_transitions?.paid_at || fresh.created` + - Die bisherige Prüfung auf `subtotal > 0` entfällt, weil die Zeilenregel sie abdeckt. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test test/hourCredits.test.js && npm test` +Expected: PASS, `# fail 0` + +- [ ] **Step 5: Commit** + +```bash +git add server/services/stripeBilling.js test/hourCredits.test.js +git commit -m "Stundenkonto: Arbeitszeit nur für bezahlte Wartungs-Monate, nicht für andere Abo-Rechnungen + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: Abschluss und Livegang (mit dem Nutzer) + +**Files:** keine neuen + +**Interfaces:** +- Consumes: Tasks 1–4 +- Produces: Plan 1 ist live, und die Abo-Dokumente bekommen `productKey`. + +- [ ] **Step 1: Node 20 (Produktionslaufzeit)** + +Run: + +```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 +``` + +Expected: `# fail 0` + +- [ ] **Step 2: Mit main abgleichen** + +Run: + +```bash +git fetch origin && git log --oneline HEAD..origin/main +``` + +Expected: keine Ausgabe. Hat sich main bewegt, folgt `git rebase origin/main`, dann `npm test` und Step 1 noch einmal. + +- [ ] **Step 3: Schema in Produktion anlegen, vor dem Deploy.** Das macht der Nutzer oder gibt es ausdrücklich frei, denn es ist eine Schemaänderung in der Live-Datenbank. + +```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 +``` + +Expected: Die letzte Zeile lautet `Fertig.`, und für `productKey` gibt es eine neue Zeile. Alles andere ist mit „existiert“ markiert. + +- [ ] **Step 4: Push nach main, 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 +``` + +Mit dabei sind die Konzept- und Plan-Dokumente. Sie sind harmlos, weil sie in `docs/` liegen. + +- [ ] **Step 5: Live prüfen** + - `git -C /home/webklar/apps/kundenbereich.webklar.com/build log --oneline -1` zeigt den gepushten Commit. + - Der Container ist neu gestartet, und in `docker logs --since 5m webklar-kundenbereich-server` steht kein `[abo]`- und kein `[stripe]`-Fehler. + - `curl -s -o /dev/null -w '%{http_code}' https://project.webklar.com/api/abo` liefert `401`. +- [ ] **Step 6: Spiegel live bestätigen** (Lesen in Produktion nur mit Freigabe des Nutzers) + - Der Nutzer sendet im Stripe-Dashboard ein `customer.subscription.updated` seines Testkonto-Abos erneut oder wartet die nächste Abbuchung ab. + - Danach hat das Dokument in `subscriptions` `productKey: 'wartung'`. +- [ ] **Step 7:** Memory-Datei `webklar-vertraege-hosting-abo.md` ergänzen: Plan 1 live mit Commit, Schema angelegt. diff --git a/docs/superpowers/plans/2026-09-27-vertraege-plan-2-sichtbar.md b/docs/superpowers/plans/2026-09-27-vertraege-plan-2-sichtbar.md new file mode 100644 index 0000000..da33bf4 --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-vertraege-plan-2-sichtbar.md @@ -0,0 +1,1243 @@ +# 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–13 und Anhang. + +## 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). +- **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. +- `contractEvents.type` ist `abschluss`, `wechsel`, `speicher`, `kuendigung` oder `widerruf`. `status` ist `offen`, `erledigt` oder `widerrufen`. +- 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). + +**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.** + - Das Portal leitet auf die Stripe-Zahlungsseite der Rechnung weiter, bzw. zeigt wie bei jeder Abbuchung das Zahlungsproblem. + - Der Wechsel bleibt als Ereignis nachweisbar. + - Test: Task 9, „offene Rechnung liefert paymentUrl“. +3. **Gekündigtes Abo, Status `trialing` oder `past_due`.** Wechsel, Plus und Minus werden 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. + +--- + +## 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 () => { /* … */ + 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 + +**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`. + - **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 + +**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 + +**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, 0, null)` auf. 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 + +**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“. + +--- + +## 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) + +**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 + ``` + + 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-stundenkonto: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 12:** Memory aktualisieren: Plan 2 live, Commits, offene Punkte.