Umsetzungspläne: Plan 1 (unsichtbare Vorarbeiten) und Plan 2 (sichtbarer Teil mit Version 4)

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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-27 15:04:44 +00:00
1 parent c76ea0bf9e
commit 405408d076
2 files changed
+1823

No files matched your search

@@ -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<priceId, key|null>`, 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 <noreply@anthropic.com>"
```
---
### 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:<port>/v1`, `docs(collection)` liefert eine `Map<id, doc>`.
- 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: "<key>"' }`.
- 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 <noreply@anthropic.com>"
```
---
### 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 <id>: 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 <noreply@anthropic.com>"
```
---
### 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<Map<string, 'wartung'|'hosting'|'mailStorage'|null>>`.
- 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: <Grund> }`. 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 <noreply@anthropic.com>"
```
---
### 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.
File diff suppressed because it is too large. Load diff