Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-09-27-vertraege-plan-1-vorarbeiten.md
T

584 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
Dieser Schritt bleibt Pflicht, auch mit dem Schutz aus dem Review. Ohne das Feld spiegelt der Server nur ohne `productKey` und schreibt einen Fehler ins Log. Das betrifft auch den öffentlichen Kündigungs- und Widerrufsknopf.
- [ ] **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`.
- `docker logs --since 1h webklar-kundenbereich-server 2>&1 | grep -E 'kein bekannter Abo-Preis|Appwrite kennt das Feld'` bleibt leer. Jeder Treffer ist ein Abo ohne bekannten Preis oder ein fehlendes Feld und wird vor der nächsten Abbuchung geklärt.
- [ ] **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.