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:
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
Reference in new issue
Block a user