Compare commits

...
13 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 f016ad0326 Pläne: Hinweise aus dem Review von Plan 1 (Livegang-Prüfung, Rechnungszeilen, lookup_keys)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:47:02 +00:00
ClaudeandClaude Opus 5.5 47c1af7015 Stripe: Testadresse STRIPE_API_BASE nur für 127.0.0.1/localhost
Ein verirrter Wert in Produktion kann den Live-Schlüssel so nie an einen
fremden Server schicken; getStripe() bricht mit klarer Meldung ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:46:26 +00:00
ClaudeandClaude Opus 5.5 2694ef18d6 Abo-Spiegel: fehlt ein neues Feld in Appwrite, wird ohne dieses Feld gespiegelt
Schützt Webhooks und den öffentlichen Kündigungs-/Widerrufsknopf, falls das
Setup-Skript erst nach dem Deploy läuft; der Fehler steht laut im Log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:45:54 +00:00
ClaudeandClaude Opus 5.5 dc61649228 Stundenkonto: Abos ohne bekannten Preis bekommen als Altbestand weiter ihre Stunde
Wie im Abo-Spiegel gelten Rechnungen ohne jeden bekannten Abo-Preis als
Wartung. Ein unbekannter Preis neben einem bekannten (z. B. Hosting) bringt
weiterhin keine Stunde.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:45:09 +00:00
ClaudeandClaude Opus 5.5 51fc2434e5 Stundenkonto: Arbeitszeit nur für bezahlte Wartungs-Monate, nicht für andere Abo-Rechnungen
Die Rechnung wird frisch geladen; eine Stunde bringt nur eine reguläre
(nicht anteilige) Wartungs-Zeile über 0 €. Preis-IDs werden einmal bei
Stripe nachgeschlagen und zwischengespeichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:33:33 +00:00
ClaudeandClaude Opus 5.5 d097704d49 Abo-Spiegel: Produkt und tatsächlicher Preis aus den Stripe-Positionen
Neues Appwrite-Feld subscriptions.productKey (Setup-Skript). Ohne bekannten
Abo-Preis gilt ein Abo weiter als Wartungs-Abo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:31:56 +00:00
ClaudeandClaude Opus 5.5 54defb75c4 Tests: Stripe- und Appwrite-Nachbau im Testprozess
getStripe() nutzt in Tests über STRIPE_API_BASE den Nachbau auf 127.0.0.1;
in Produktion ist der Wert leer und nichts ändert sich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:30:56 +00:00
ClaudeandClaude Opus 5.5 877fde7a6f Abo-Katalog: Wartung, Hosting und Zusatzspeicher als Produkte (noch ohne Verwendung)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:29:16 +00:00
ClaudeandClaude Opus 5.5 afec0b82fd Plan 2: letzter Test ohne Platzhalter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:04:56 +00:00
ClaudeandClaude Opus 5.5 405408d076 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>
2026-09-27 15:04:44 +00:00
ClaudeandClaude Opus 5.5 c76ea0bf9e Konzept: Mailvorlagen sind in main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:54:19 +00:00
ClaudeandClaude Opus 5.5 09870a3b83 Konzept: Hetzner-Paket M, UptimeRobot und neue Mailvorlagen eingetragen
Postfächer liegen im Hetzner-Paket M (50 GB), Wechsel auf L ab 40 GB
belegt. Die Überwachung läuft mit UptimeRobot: Eintrag in der
Datenschutzerklärung, kein Unterauftragsverarbeiter nach Anlage 3,
Monitore beim Abo-Start anlegen und beim Ende entfernen. Die
HTML-Mailvorlagen auf feature/mail-vorlagen enthalten Produktangaben
fest und bekommen Platzhalter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:54:10 +00:00
ClaudeandClaude Opus 5.5 65ab5013cb Konzept: Verträge, Hosting-Abo und E-Mail-Zusatzspeicher
Entwurf zur Durchsicht, noch keine Umsetzung. Beschreibt den Bereich
"Verträge", das Hosting-Abo (15 €), beliebig viele Postfächer im
Wartungs-Abo, den Wechsel Hosting -> Wartung (12 neue Monate) und den
Zusatzspeicher (5 GB je 1 €/Monat, Stückzahl im Stripe-Abo) samt
betroffener Rechtstexte, Mails und Dateien.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:54:10 +00:00
17 changed files with 3199 additions and 20 deletions

No files matched your search

@@ -0,0 +1,583 @@
# 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.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,532 @@
# Verträge, Hosting-Abo und E-Mail-Zusatzspeicher – Konzept
Stand 27.09.2026 · Entwurf zur Durchsicht · umgesetzt ist noch nichts
Betrifft das Kundenportal (project.webklar.com), die Website (webklar.com), das Ticketsystem (ticket.webklar.com), Stripe, Hetzner konsoleH und die Rechtstexte.
## 1. Ziel
1. Der Bereich „Wartung“ im Kundenportal heißt künftig **Verträge**. Dort liegt das Abo des Kunden.
2. Neues **Hosting-Abo** für 15 € im Monat: Wir hosten die Website, dazu kommen 1 Domain und 1 E-Mail-Postfach.
3. **Wartungs-Abo** (50 €): Statt einem Postfach gibt es beliebig viele, zusammen 5 GB.
4. Hosting-Kunden können **jederzeit ins Wartungs-Abo wechseln**, auch in den ersten 12 Monaten.
5. **E-Mail-Zusatzspeicher**: je 5 GB für 1 € im Monat, per Plus und Minus im eigenen Bereich, abgerechnet im laufenden Abo über Stripe.
Nicht Teil dieses Konzepts:
- mehrere Abos pro Kunde, etwa für zwei Websites. Pro Kunde gibt es weiterhin höchstens ein laufendes Abo.
- Postfächer selbst im Portal anlegen oder löschen. Hetzner bietet dafür keine Schnittstelle, deshalb läuft das per Anfrage im Chat.
- jährliche Zahlung.
- Rückwechsel von Wartung zu Hosting per Knopf. Das geht nur auf Anfrage (3.6).
## 2. Entscheidungen
| Thema | Entscheidung | Herkunft |
|---|---|---|
| Name des Bereichs | „Verträge“ | Vorgabe |
| Preis Hosting-Abo | 15,00 € pro Monat | Vorgabe |
| Umfang Hosting-Abo | siehe 3.1: ohne Arbeitsstunde, ohne Pflege der Website | Vorschlag, bestätigt |
| Hosting-Abo nur für | statische Websites (3.2) | Vorschlag, bestätigt |
| Domain im Hosting-Abo | ja, 1 Domain | Vorschlag, bestätigt |
| Mindestlaufzeit | beide Abos 12 Monate ab Leistungsbeginn, danach monatlich kündbar | Vorschlag, bestätigt |
| Postfächer | Hosting: 1 Postfach, 5 GB. Wartung: beliebig viele, zusammen 5 GB | Vorgabe |
| Wechsel Hosting → Wartung | jederzeit, als neuer Vertrag. Mit dem Wechsel beginnen 12 neue Monate Mindestlaufzeit (Variante A) | Vorgabe |
| Zusatzspeicher | 5 GB je Paket für 1,00 € pro Monat, in beiden Abos, als zweite Position im selben Stripe-Abo | Vorgabe |
| Aufbau der Rechtstexte | eine gemeinsame Fassung für beide Abos (8.1) | Vorschlag |
| Offene Einzelpunkte | vorbelegt, siehe 13 | Vorschlag, bitte prüfen |
Alle Preise sind Endpreise. Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.
## 3. Produkte
### 3.1 Leistungen im Vergleich
| | Hosting-Abo | Wartungs-Abo |
|---|---|---|
| Preis | 15,00 € pro Monat | 50,00 € pro Monat |
| Website auf unserem Server in Deutschland (Hetzner, Falkenstein) | ✓ | ✓ |
| SSL-Zertifikat, tägliche Datensicherung, Überwachung der Erreichbarkeit | ✓ | ✓ |
| Updates von Betriebssystem und Server-Software | ✓ | ✓ |
| Updates und Pflege der Website selbst | – | ✓ |
| Geeignet für | statische Websites | alle Websites und Web-Anwendungen, die wir erstellt oder zur Betreuung übernommen haben |
| Domain (.de, .com, .eu, .net, .org) | 1 | 1 |
| E-Mail | 1 Postfach, 5 GB | beliebig viele Postfächer, zusammen 5 GB |
| Zusatzspeicher | je 5 GB für 1,00 € pro Monat | je 5 GB für 1,00 € pro Monat |
| Arbeitszeit | nur nach Freigabe, 100 € pro Stunde in 15-Minuten-Schritten | 1 Stunde pro Monat inklusive, darüber 100 € pro Stunde |
| Servicezeiten und Reaktion | wie heute im Wartungs-Abo (Mo–Fr 9–17 Uhr, Antwort bis Ende des nächsten Werktags) | wie heute |
| Mindestlaufzeit | 12 Monate | 12 Monate, auch nach einem Wechsel |
### 3.2 Hosting-Abo: nur statische Websites
- Statisch heißt: Der Webserver liefert die Dateien (HTML, CSS, JavaScript, Bilder) unverändert aus. Dazu gehören auch unsere React- und Vite-Seiten.
- Nicht erlaubt sind ein CMS, ein Login, eine Datenbank und eigener serverseitiger Code wie PHP.
- Der Grund: Eine dynamische Website ohne laufende Updates läge ungepflegt auf demselben Server wie Kundenportal, Datenbank und Rechnungen.
- Kontaktformulare sind nur über einen Dienst möglich, den wir zentral betreiben und pflegen (siehe 13, Punkt 7).
- Braucht eine Website später dynamische Teile, bieten wir den Wechsel ins Wartungs-Abo an.
- Änderungen an der Website gibt es nur nach Freigabe und gegen 100 € pro Stunde, Anfrage im Chat. Die Behebung von Störungen an Server, Domain und Postfach ist inklusive, wie im Wartungs-Abo.
### 3.3 Postfächer (beide Abos)
- Die Postfächer liegen bei Hetzner Webhosting, Rechenzentrum Nürnberg. Das Hosting-Abo hat 1 Postfach, das Wartungs-Abo beliebig viele.
- Hetzner setzt die Speichergrenze je Postfach. „Zusammen 5 GB“ heißt: Die Grenzen aller Postfächer eines Kunden ergeben zusammen höchstens 5 GB plus Zusatzspeicher.
- Wie der Speicher verteilt wird, bestimmt der Kunde. Ohne Vorgabe teilen wir gleichmäßig auf.
- Ist die Grenze eines Postfachs erreicht, nimmt es keine neuen Mails mehr an. Darauf weisen Portal und Bedingungen hin.
- Anlegen, Löschen, neues Passwort oder andere Aufteilung fragt der Kunde im Chat an. Der Knopf „Postfach anfragen“ füllt die Nachricht vor.
- Wir setzen es in konsoleH um, in der Regel innerhalb eines Werktags.
- Zugangsdaten geben wir nur dem Kunden oder den Personen, die er in Textform benennt.
- Faire Nutzung:
- Postfächer nur für den Kunden, seine Beschäftigten und seine Organisation
- kein Weiterverkauf
- kein Newsletter- oder Massenversand
- Es gelten die Versandgrenzen des Anbieters.
- Kapazität: Für alle Postfächer, auch unsere eigenen, nutzen wir das Hetzner-Paket **Webhosting M** (50 GB, 4,90 € pro Monat). Alle Postfächer teilen sich diesen Speicher.
- Das Admin-Panel zeigt die Summe der vergebenen Kontingente: 5 GB je laufendem Abo plus alle Pakete Zusatzspeicher.
- Maßgeblich ist der tatsächlich belegte Speicher in konsoleH. Sind 40 GB (80 %) belegt, wechseln wir auf Paket L (100 GB, 9,90 € pro Monat).
- Die Mehrkosten deckt der Zusatzspeicher: Bei Hetzner kosten 5 GB rund 0,50 € im Monat, wir berechnen 1 €.
- Domains, die nicht bei Hetzner registriert sind, kosten dort 0,76 € pro Monat extra, wenn wir Postfächer dafür anlegen. Die Domain ist in beiden Abos enthalten, deshalb ist der Umzug zu Hetzner der Normalfall.
### 3.4 E-Mail-Zusatzspeicher
Ein Paket hat 5 GB und kostet 1,00 € pro Monat. Jeder Kunde kann 0 bis 10 Pakete buchen, in beiden Abos, auch Bestandskunden mit Version 3.
**Plus (Bestellung)**
- Direkt über dem Knopf steht eine Übersicht mit:
- neuer Gesamtgröße
- Mehrpreis pro Monat
- anteiligem Betrag für den Rest des laufenden Monats (aus der Stripe-Vorschau)
- Datum der nächsten Abbuchung
- Link auf Bedingungen und Widerrufsbelehrung
- Der Knopf heißt „Zahlungspflichtig bestellen“ (§ 312j Abs. 3 BGB). Der Vertrag kommt mit dem Klick zustande. Bezahlt wird mit der hinterlegten Zahlungsart.
- Abrechnung:
- Den Rest des laufenden Monats berechnet Stripe anteilig mit der nächsten Abbuchung, danach 1,00 € je Paket und Monat.
- Pakete, die für den laufenden Monat schon bezahlt sind (etwa nach einem Minus im selben Monat), werden nicht noch einmal berechnet.
- Bereitstellung: Wir stellen die Grenzen in konsoleH um, in der Regel innerhalb eines Werktags. Bis dahin zeigt das Portal „wird eingerichtet“.
- Der Kunde bekommt eine Bestätigungsmail mit Zusammenfassung und den Vertragsbedingungen als PDF.
**Minus**
- Der Knopf heißt „Speichern“. Die Änderung wirkt zum Ende des laufenden Abrechnungsmonats, ohne anteilige Erstattung.
- Das Portal zeigt „ab TT.MM. X GB“.
- Der Dialog weist darauf hin: Passen die gespeicherten Mails dann nicht mehr hinein, nimmt das betroffene Postfach keine neuen Mails an, bis wieder Platz ist.
- Der Kunde bekommt eine Bestätigungsmail.
**Widerruf**
- Jede Bestellung hat 14 Tage Widerrufsfrist.
- Wir verlangen keinen ausdrücklichen Sofortbeginn und stellen trotzdem sofort bereit.
- Bei Widerruf nehmen wir das Paket zurück und erstatten alles, was dafür gezahlt wurde. Wertersatz verlangen wir nicht.
**Kündigung**
- Jederzeit per Minus oder über „Verträge hier kündigen“ mit der Auswahl „Nur den E-Mail-Zusatzspeicher“.
- Der Zusatzspeicher endet automatisch mit dem Abo. Bei einem Wechsel ins Wartungs-Abo bleibt er unverändert.
### 3.5 Wechsel vom Hosting- ins Wartungs-Abo
- **Wer:** Kunden mit laufendem, bezahltem Hosting-Abo (Stripe-Status `active`). Nicht möglich ist es in der Wartezeit bis zum Leistungsbeginn und bei offener Zahlung.
- **Wann:** jederzeit, auch während der Mindestlaufzeit des Hosting-Abos.
- **Rechtlich:** Es ist ein neuer Vertrag über das Wartungs-Abo. Das Hosting-Abo endet mit dem Wechsel im gegenseitigen Einvernehmen.
- **Bestellung:**
- Direkt über dem Knopf steht eine Übersicht mit:
- Wartungs-Abo 50,00 € pro Monat
- Zusatzspeicher, falls gebucht
- Gutschrift für die ungenutzten Hosting-Tage
- heute fälliger Betrag (aus der Stripe-Vorschau)
- neuer Abbuchungstag
- Ende der neuen Mindestlaufzeit
- Der Kunde stimmt den Vertragsbedingungen (Version 4) einschließlich der Auftragsverarbeitung zu.
- Pflicht-Häkchen: Der Kunde verlangt ausdrücklich, dass wir sofort beginnen, und nimmt die Folge für den Wertersatz zur Kenntnis (§ 357a Abs. 2 BGB). Einen späteren Beginn bietet der Wechsel nicht an.
- Knopf „Zahlungspflichtig bestellen“.
- **Abrechnung:**
- Es bleibt dasselbe Stripe-Abo, der Preis wechselt von Hosting auf Wartung.
- Der Abbuchungstag wird der Wechseltag.
- Die ungenutzten Hosting-Tage werden auf der sofort fälligen Rechnung gutgeschrieben.
- **Leistung:** Sie beginnt sofort. Die erste Stunde Arbeitszeit wird gutgeschrieben, sobald die Wechsel-Rechnung bezahlt ist, danach monatlich.
- **Mindestlaufzeit:** 12 Monate ab dem Wechseltag.
- **Widerruf des Wechsels:**
- Das Hosting-Abo läuft zu den bisherigen Bedingungen weiter, als hätte es den Wechsel nicht gegeben, einschließlich seiner ursprünglichen Mindestlaufzeit.
- Wir erstatten den Mehrbetrag, abzüglich Wertersatz nach § 357a Abs. 2 BGB (anteiliger Mehrpreis bis zum Widerruf).
- Den Preis in Stripe stellen wir von Hand zurück, wie heute bei Widerrufen.
- **Rabatte und Guthaben:** Ein laufender Angebotsrabatt läuft bis zu seinem Ende weiter. Guthaben aus einer Übernahmegutschrift wird weiter verrechnet.
### 3.6 Wechsel vom Wartungs- ins Hosting-Abo
- Möglich erst nach Ablauf der Mindestlaufzeit des Wartungs-Abos, zum Ende eines Abrechnungsmonats. Der Kunde meldet den Wunsch in Textform (Chat oder E-Mail), einen Knopf im Portal gibt es dafür nicht.
- Voraussetzung ist eine statische Website (3.2). Vorher legt der Kunde fest, welches Postfach bleibt. Die anderen exportieren wir auf Wunsch.
- Es beginnt keine neue Mindestlaufzeit, das Hosting-Abo ist danach monatlich kündbar.
- Nicht genutzte Arbeitszeit verfällt mit dem Wechsel, wie bei Vertragsende.
- Umsetzung von Hand: In Stripe wechselt der Preis zum Ende des Abrechnungsmonats auf Hosting, ohne anteilige Verrechnung. Der Kunde bekommt eine Bestätigungsmail.
## 4. Kundenportal
### 4.1 Navigation
- **Menüpunkt „Verträge“:** Er steht am Desktop in der Seitenleiste und in der App-Ansicht in der Tab-Leiste und ersetzt den Tab „Wartung“.
- Hash `#vertraege`. Alte Links mit `#wartung` und `#abo` führen dorthin, `#abo` steht etwa in den Angebots-Mails.
- **Seitenleisten-Karte:** „Ihre Wartung“ heißt künftig „Ihr Vertrag“. Sie zeigt:
- den Produktnamen
- die verfügbare Arbeitszeit, nur beim Wartungs-Abo
- die nächste Abbuchung
- die Hinweise wie heute (Zahlungsproblem, Widerrufsfrist, Übernahmegutschrift)
- **Konto-Menü, Gruppe „Verträge“:**
- Neuer erster Eintrag „Mein Vertrag“, er führt zu `#vertraege`.
- „Vertragsbedingungen“ öffnet die Fassung, der der Kunde zugestimmt hat.
- Die übrigen Einträge bleiben: „Abo verwalten & Rechnungen“, „Verträge hier kündigen“, „Vertrag widerrufen“.
### 4.2 Ansicht „Verträge“
Oben steht **Mein Vertrag**, wenn ein Abo läuft oder beendet ist:
- Produkt, Monatspreis einschließlich Zusatzspeicher und Status (gekündigt, Zahlungsproblem)
- nächste Abbuchung mit Betrag und Hinweis zur Laufzeit („Mindestlaufzeit bis …“)
- nur beim Wartungs-Abo: das Stundenkonto als Unterseite wie heute
- E-Mail-Speicher mit Plus und Minus (4.3) und dem Knopf „Postfach anfragen“
- Unterlagen: Vertragsbedingungen in der zugestimmten Fassung (PDF) und Rechnungen (Stripe-Kundenportal)
- „Übernahmegutschrift beantragen“
- während einer laufenden Widerrufsfrist hervorgehoben „Vertrag widerrufen · bis TT.MM.“ (§ 356a BGB). Das gilt auch für Wechsel und Zusatzspeicher innerhalb ihrer Frist.
- bei einem Zahlungsproblem „Zahlungsart aktualisieren“
Darunter:
- **Hosting-Kunden:** eine Karte „Zum Wartungs-Abo wechseln“ mit den zusätzlichen Leistungen und dem Knopf.
- **Ohne laufendes Abo:** beide Abos nebeneinander, jeweils mit „Abo abschließen“ und eigener Bestellseite (`#bestellen-hosting`, `#bestellen-wartung`). Persönliche Angebote erscheinen bei dem Abo, für das sie gelten.
### 4.3 Zusatzspeicher bedienen
```
E-Mail-Speicher 5 GB inklusive
Zusatzspeicher [ − ] 2 [ + ] × 5 GB = 15 GB gesamt
+ 2,00 € im Monat
[ Zahlungspflichtig bestellen ]
```
- Der Knopf erscheint erst nach einer Änderung.
- Mehr als bisher: Es folgt die Übersicht aus 3.4 und der Knopf „Zahlungspflichtig bestellen“.
- Weniger als bisher: Es folgt der Hinweis auf das Monatsende und der Knopf „Speichern“.
- Solange eine Erhöhung noch nicht eingerichtet ist, steht dort „wird eingerichtet“. Bei einer geplanten Reduzierung steht dort „ab TT.MM. X GB“.
- Plus und Minus gehen nur bei Status `active`, also nicht in der Wartezeit bis zum Leistungsbeginn und nicht bei einem Zahlungsproblem.
### 4.4 Bestellseiten
- **Neuabschluss:**
- Der Ablauf bleibt wie heute:
- Übersicht direkt über dem Knopf
- Zustimmung einschließlich Auftragsverarbeitung
- Wahl zwischen sofortigem Beginn und Beginn nach der Widerrufsfrist
- „Zahlungspflichtig bestellen“, danach Stripe Checkout
- Neu ist, dass jedes Produkt seine eigene Übersicht, seinen Preis und seinen Stripe-Hinweistext bekommt.
- **Wechsel:**
- Eigene Seite `#wechsel` nach 3.5, ohne Stripe Checkout. Belastet wird die hinterlegte Zahlungsart.
- Verlangt die Bank eine Bestätigung (3-D Secure), leitet das Portal auf die Stripe-Zahlungsseite dieser Rechnung weiter.
### 4.5 Öffentliche Formulare ohne Login
- **`kuendigen.html`:** Statt des festen „WEBklar Wartungs-Abo“ wählt der Kunde zwischen zwei Möglichkeiten (§ 312k Abs. 3 BGB verlangt eine eindeutige Bezeichnung des Vertrags):
- „Mein Abo (Hosting- oder Wartungs-Abo, mit Zusatzspeicher)“
- „Nur den E-Mail-Zusatzspeicher“
Die Bestätigung nennt das tatsächliche Abo.
- **`widerrufen.html`:** Der Kunde wählt die Bestellung aus (§ 356a BGB):
- „Abschluss meines Abos“
- „Wechsel ins Wartungs-Abo“
- „E-Mail-Zusatzspeicher“
- **Server:** Er ordnet die Erklärung wie heute über die E-Mail-Adresse zu. Die Auswahl bestimmt, was gekündigt oder widerrufen wird.
## 5. Stripe
Produkte und Preise (monatlich, EUR, `tax_behavior: inclusive` wie heute):
| Produkt | Preis | lookup_key |
|---|---|---|
| WEBklar Wartungs-Abo (besteht) | 50,00 € | `webklar_wartung_monatlich_5000` |
| WEBklar Hosting-Abo (neu) | 15,00 € | `webklar_hosting_monatlich_1500` |
| WEBklar E-Mail-Zusatzspeicher 5 GB (neu) | 1,00 € je Stück | `webklar_mailspeicher_5gb_monatlich_100` |
- **Beschreibung des Wartungs-Produkts:** Sie wird in Stripe auf Version 4 geändert („beliebig viele Postfächer, zusammen 5 GB“). Der Code legt Produkte nur einmal an, deshalb geht das per Skript oder im Stripe-Dashboard.
- **Aufbau:** ein Stripe-Abo je Kunde mit zwei Positionen, dem Abo-Preis (Hosting oder Wartung) und optional dem Zusatzspeicher mit Stückzahl.
- **Aufrufe:**
- Plus: `subscriptions.update` mit der neuen Stückzahl und `proration_behavior: create_prorations`. Bis zur schon bezahlten Stückzahl gilt `none`, notfalls in zwei Schritten.
- Minus: `proration_behavior: none`.
- Wechsel: Preis der Abo-Position tauschen mit `billing_cycle_anchor: now` und `proration_behavior: create_prorations`, dabei dasselbe `proration_date` wie in der Vorschau.
- Den Betrag zeigen wir vorher jeweils mit `invoices.createPreview` an.
- **Stripe-Kundenportal:** Die Überschrift wird „WEBklar – Abo verwalten“. Tarif- und Mengenänderungen bleiben dort aus, weil Stripe unsere Mindestlaufzeiten und Bestellschritte nicht kennt. Die Kündigung dort bleibt ebenfalls aus.
- **Checkout-Hinweistext** (`custom_text`): je Produkt mit Preis und Mindestlaufzeit, statt fest „50,00 € … 12 Monate“.
## 6. Daten (Appwrite, woms-database)
**`subscriptions`** (ein Dokument je Stripe-Abo) bekommt neue Felder:
- `productKey`: `hosting` oder `wartung`. Alte Dokumente ohne Wert gelten als `wartung`.
- `termStartAt`: Beginn der laufenden Mindestlaufzeit. Bei einem Neuabschluss ist das der Leistungsbeginn, bei einem Wechsel der Wechseltag.
- `storageUnits`: gebuchte Pakete Zusatzspeicher.
- `storagePaidUnits` und `storagePaidPeriodEnd`: Pakete, die für den laufenden Monat schon bezahlt sind.
- `planKey` enthält künftig den lookup_key des tatsächlichen Abo-Preises.
**Neue Collection `contractEvents`.** Sie weist jede Bestellung und jede Änderung nach:
- Felder:
- `customerId`, `subscriptionId`
- `type`: `abschluss`, `wechsel`, `speicher`, `kuendigung` oder `widerruf`
- `productKey`
- `details`: JSON mit Stückzahl alt und neu, Beträgen und Wirksamkeitsdatum
- `termsVersion`, `consentAt`, `consentIp`, `consentUserAgent`, `earlyStartConsentAt`
- `status`: `offen`, `erledigt` oder `widerrufen`
- `dueAt`, `doneAt`, `doneBy`, `confirmationSentAt`
- Die offenen `speicher`-Ereignisse sind die Aufgabenliste für konsoleH.
- Aus diesen Ereignissen ergibt sich die Widerrufsfrist je Bestellung. Heute zählt nur `startedAt`.
- Aufbewahrt werden sie wie die bisherigen Zustimmungsdaten: drei Jahre nach Vertragsende (Datenschutzerklärung, Abschnitt 7).
**`aboOffers`** bekommt ein Feld `productKey`. Alte Angebote gelten als `wartung`. Der Normalpreis kommt aus dem Produktkatalog statt fest 50 €.
Einrichtung: `scripts/setup-abo-collections.mjs` wird erweitert.
## 7. Server (Kundenportal)
**`server/config.js`**
- Neuer Produktkatalog mit `products.hosting` und `products.wartung`. Jeder Eintrag enthält:
- Name, Preis und lookup_key
- Stripe-Beschreibung
- Leistungsliste für die Oberfläche
- Mindestlaufzeit und Minuten pro Monat (0 bzw. 60)
- Neu ist auch `mailStorage` mit lookup_key, 100 Cent, 5 GB und höchstens 10 Paketen.
- `abo.termsVersion` bleibt ein einziger Wert, weil es eine gemeinsame Fassung gibt (8.1).
**`server/services/stripeBilling.js`**
- Preise werden je Produkt angelegt oder gefunden.
- `syncSubscription` liest Abo-Preis und Zusatzspeicher aus den Positionen.
- Arbeitszeit gibt es nur, wenn die bezahlte Rechnung eine Wartungs-Position enthält, die keine anteilige Verrechnung ist und mehr als 0 € kostet. Heute bekommt jede bezahlte Abo-Rechnung eine Stunde.
- Checkout je Produkt. Die Sperre „höchstens ein laufendes Abo je Kunde“ bleibt.
- Neu kommen Wechsel, Zusatzspeicher und die Vorschauen dazu.
- Die Mindestlaufzeit zählt ab `termStartAt`.
**`server/routes/abo.js`**
- `GET /api/abo` liefert:
- den Produktkatalog
- `productKey` und Speicherstand
- ob ein Wechsel möglich ist
- das Stundenkonto, aber nur beim Wartungs-Abo
- Neue Aufrufe:
- `POST /api/abo/checkout` mit `productKey`
- `POST /api/abo/switch/preview` und `POST /api/abo/switch`
- `POST /api/abo/storage/preview` und `POST /api/abo/storage`
- Öffentliche Kündigung und öffentlicher Widerruf bekommen das Feld `contract`.
**Mails** (`server/services/mailer.js`): produktneutral, siehe 9.
**Admin** (`public/admin.html`, `public/admin.js`, Routen in `server/routes/portalAdmin.js`)
- Die Abo-Anzeige nennt Produkt und Speicher.
- Neuer Reiter „E-Mail-Speicher“ mit:
- offenen Aufgaben (einrichten, reduzieren zum Datum)
- Knopf „Erledigt“
- Summe der vergebenen Kontingente
- Abo-Angebote bekommen eine Produktauswahl.
**Ticketsystem (WOMS):** Der Schalter „aufs Stundenkonto“ im Arbeitsnachweis erscheint nur bei Kunden mit laufendem Wartungs-Abo.
## 8. Rechtstexte
### 8.1 Aufbau
Eine gemeinsame Fassung „Vertragsbedingungen für die WEBklar-Abos“ in Version 4:
- allgemeiner Teil: Anbieter, Geltungsbereich, Vertragsschluss, Preise und Zahlung, Laufzeit und Kündigung, Wechsel, Zusatzspeicher, Vertragsende, Mängel, Haftung, Datenschutz, Änderungen, Schlussbestimmungen
- Leistungsbeschreibung A (Hosting-Abo) und Leistungsbeschreibung B (Wartungs-Abo)
- Widerrufsbelehrung nach dem gesetzlichen Muster (Wortlaut unverändert) mit Muster-Widerrufsformular
- Anlage 1 Auftragsverarbeitungsvertrag, Anlage 2 TOM, Anlage 3 Unterauftragsverarbeiter, jeweils für beide Abos
Weitere Festlegungen:
- **Adresse:** Sie bleibt `project.webklar.com/abo-bedingungen.html`, weil sie in Mails, auf der Website und im Stripe-Kundenportal steht.
- **PDF:** Das neue heißt `WEBklar-Abo-Vertragsunterlagen.pdf`.
- **Version 3:** Sie wird archiviert (HTML und PDF unter `vertragsunterlagen/v3/`). `WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf` bleibt als Fassung 3 abrufbar, denn § 2 sagt zu, dass wir den Vertragstext speichern.
- **Zustimmung:** Gespeichert werden Version und Produkt.
- **Versionsdatum:** Es ist der Tag der Freigabe in der Rechtsrunde.
### 8.2 Änderungen gegenüber Version 3
- **„Das Wichtigste auf einen Blick“:** beide Abos mit Preisen und Zusatzspeicher.
- **Geltungsbereich:** beide Abos, mit Vorrang vor den AGB einschließlich Leistungsmodul C (jährliches Hosting).
- **Vertragsschluss:**
- Der Bestellweg führt über „Verträge“.
- Wechsel und Zusatzspeicher sind eigene Bestellungen: Knopf, Vertragsschluss mit dem Klick, hinterlegte Zahlungsart, Bestätigungsmail.
- **Leistungen:**
- Aufteilung in A und B.
- Postfächer: 1 bzw. beliebig viele, zusammen 5 GB, Aufteilung, Einrichtung auf Anfrage, volle Postfächer, faire Nutzung, Zugangsdaten nur an den Kunden.
- Das Hosting-Abo gilt nur für statische Websites.
- **Stundenkontingent:** nur im Wartungs-Abo. Die erste Stunde gibt es beim Wechsel.
- **Pflichten des Kunden:**
- Zugangsdaten aller Postfächer geheim halten.
- Weitergabe an Beschäftigte in eigener Verantwortung.
- Im Hosting-Abo keine dynamischen Inhalte ohne Wechsel.
- **Preise und Zahlung:**
- 15 €, 50 € und Zusatzspeicher.
- Anteilige Berechnung beim Plus.
- Abrechnung beim Wechsel.
- Eine Sperre ist ab Verzug mit zwei Monatsgebühren möglich, statt wie bisher ab „100 €“.
- **Laufzeit und Kündigung:**
- Mindestlaufzeiten wie oben. Ein Wechsel startet 12 neue Monate.
- Rückwechsel nach 3.6. Der Zusatzspeicher ist monatlich kündbar.
- „Verträge hier kündigen“ bekommt die Auswahl.
- **Widerruf:** Die Hinweise stehen außerhalb der Musterbelehrung:
- Das Widerrufsrecht gilt je Bestellung.
- Folgen beim Widerruf eines Wechsels.
- Beim Zusatzspeicher kein Wertersatz.
- **Vertragsende:** Export aller Postfächer.
- **Auftragsverarbeitung:**
- Gegenstand sind beide Abos, Datenart „Inhalte der Postfächer“.
- Die Nutzer der Postfächer zählen zu den Betroffenen.
- Bei Widerspruch gegen einen Unterauftragsverarbeiter wird „das Abo“ kündbar.
- **TOM:** „Automatische Überwachung der Erreichbarkeit“ bleibt stehen. Umgesetzt ist sie mit UptimeRobot (10).
- **Anlage 3:** Hier kommt kein Eintrag für UptimeRobot hinzu. UptimeRobot ruft nur die öffentlichen Seiten ab wie jeder Besucher. Formulareingaben, Postfächer und Server-Protokolle der Kunden bekommt es nicht.
### 8.3 Weitere Texte
| Text | Änderung |
|---|---|
| Datenschutzerklärung (`public/datenschutz.html` + PDF) | Abschnitt 2 „unsere Abos“. Abschnitt 7 heißt „Abos und Zahlung über Stripe“, Nachweisdaten auch bei Wechsel und Zusatzspeicher. Abschnitt 8: Postfächer „im Rahmen unserer Abos“. Abschnitt 9: „für die Abos über Stripe“. Neu in Abschnitt 3 und 12: Überwachung der Erreichbarkeit durch UptimeRobot s. r. o., Obchodná 507/2, 811 06 Bratislava (Slowakei). UptimeRobot erhält die Adressen der überwachten Websites und verarbeitet auch außerhalb der EU, auf Grundlage von Standardvertragsklauseln. Der Auftragsverarbeitungsvertrag ist Teil der UptimeRobot-Bedingungen. Neues Stand-Datum |
| Kündigungs- und Widerrufsseite | Auswahl nach 4.5, Bestätigungsseite (`legal-form.js`) |
| Bedingungen der Übernahmegutschrift | gelten für beide Abos, `takeover.conditionsVersion` erhöhen |
| AGB webklar.com, Leistungsmodul C | Hosting gibt es als Hosting-Abo nach den Abo-Bedingungen. Bestehende Jahresverträge behalten ihre Fassung. Nr. 9 („jährlich im Voraus“) anpassen |
| Website | `/wartungs-abo` aktualisieren, neue Seite `/hosting-abo`, Link im Footer |
| Leitfaden Empfehlungsgeber (+ PDF) | beide Abos |
| Stripe | Produktbeschreibungen, Checkout-Hinweise |
## 9. Mails
Diese Änderungen gehören in die laufende Überarbeitung aller Portal-Mails. Die neuen HTML-Vorlagen mit Textfassung liegen unter `server/mail-templates/`. Sie sind seit dem 27.09.2026 in main und live (18c3cdf). Die Nummer K11 ist dort schon vergeben („Passwort geändert“).
In den Vorlagen steht heute fest:
- „Wartungs-Abo“ in allen Betreffzeilen
- in K01 der Preiskasten „50,00 € / Monat“ und die Liste „Enthalten“ mit „1 E-Mail-Postfach“
- in K05 „Mindestlaufzeit 12 Monate“
Diese Stellen werden zu Platzhaltern wie `{{produkt}}`, `{{preis}}` und `{{leistungen}}`, die der Server aus dem Produktkatalog füllt. Neue Mails bekommen die nächsten freien Nummern.
| Mail | Änderung |
|---|---|
| K01 Vertragsbestätigung | je Produkt, mit PDF der zugestimmten Fassung |
| K02 Kündigungsbestätigung (Stripe) | Produktname. Der Hinweis auf „Abo verwalten“ ist falsch, weil die Kündigung dort aus ist |
| K03 Eingangsbestätigung Kündigung | nennt den gekündigten Vertrag (Abo oder Zusatzspeicher) |
| K04 Eingangsbestätigung Widerruf | nennt die widerrufene Bestellung |
| K05, K06 Angebote | Produkt des Angebots, Normalpreis aus dem Katalog |
| K07–K10 Übernahmegutschrift | „Ihr Abo“ statt „Wartungs-Abo“ |
| neu: Wechsel ins Wartungs-Abo bestätigt | mit PDF, Widerrufsbelehrung und neuer Mindestlaufzeit |
| neu: Zusatzspeicher bestellt oder geändert | Zusammenfassung und Wirksamkeitsdatum, beim Plus mit PDF |
| T02 Neues Abo | Produktname, dazu die Aufgabe „Website als Monitor in UptimeRobot anlegen“ |
| T07 Abo beendet | Produktname, dazu die Aufgabe „Monitor in UptimeRobot entfernen“ |
| T05, T06, T08, T09 | Produktname |
| neu intern: Wechsel | Kunde und Beträge |
| neu intern: Speicher-Aufgabe | Kunde, Postfächer, neue Größe, Fälligkeit |
## 10. Voraussetzungen vor dem Start
- **Überwachung der Erreichbarkeit:** Sie läuft mit UptimeRobot.
- Jede Abo-Website ist dort als Monitor angelegt.
- Der Free-Plan erlaubt laut UptimeRobot-Hilfeseite (Stand 27.08.2026) auch gewerbliche Nutzung, mit 50 Monitoren und einer Prüfung alle 5 Minuten. Ab etwa 45 Monitoren prüfen wir den Tarif.
- **Hetzner-Paket M:** Vor dem Start lesen wir in konsoleH ab, wie viel Speicher schon belegt ist (Statistiken → Speicherplatz).
- Die Rechtsrunde ist abgeschlossen: Version 4, Datenschutz, AGB, Formulare.
- Die produktneutralen Mails sind fertig.
- Die Stripe-Produkte sind angelegt. Ein Live-Check läuft nur mit ausdrücklicher Freigabe.
## 11. Reihenfolge
Die Umsetzung wird in zwei Pläne geteilt.
1. **Rechtstexte als Entwurf für die Rechtsrunde.** Das läuft parallel zu Plan 1.
2. **Plan 1, unsichtbare Vorarbeiten.** Sie können vorher live gehen, ohne sichtbare Änderung und ohne neuen Text:
- Produktkatalog
- `planKey` und `productKey` aus Stripe
- Stunde nur fürs Wartungs-Abo
- Tests
3. **Plan 2, sichtbarer Teil.** Er geht gemeinsam mit Version 4 live und umfasst:
- Ansicht „Verträge“, Bestellseiten, Wechsel und Zusatzspeicher
- Formulare, Admin und Mails
- Website, AGB, Leitfaden und WOMS
Die Ansicht „Verträge“ kann nicht früher live gehen, weil § 2 der Version 3 den heutigen Bestellweg beschreibt.
4. **Start:**
- Stripe-Produkte anlegen
- `termsVersion` auf Version 4 setzen, PDFs erzeugen
- Portal und Website ausliefern und live prüfen
## 12. Tests
- **Unit-Tests** (`npm test`):
- Produktkatalog
- Stundengutschrift: Hosting-Rechnung ergibt keine Stunde, Wartungs-Rechnung 60 Minuten, Wechsel-Rechnung 60 Minuten, reine Speicheränderung keine
- Speicher-Abrechnung: Plus, Minus, erneutes Plus im selben Monat
- Mindestlaufzeit nach einem Wechsel
- Widerrufsfrist je Bestellung
- Zuordnung von Kündigung und Widerruf
- **Server-Ablauf** mit Appwrite-Mock, SMTP-Sink und Fake-Stripe (Aufbau wie bei den bisherigen Tests):
- Neuabschluss je Produkt
- Wechsel mit Vorschau
- Plus und Minus
- Webhooks mit mehreren Positionen
- öffentliche Kündigung und öffentlicher Widerruf
- alle Mails
- **Oberfläche** mit Playwright und Fixtures:
- „Verträge“ in allen Zuständen: kein Abo, Hosting, Wartung, gekündigt, Zahlungsproblem, Widerrufsfrist, Speicher in Einrichtung, Reduzierung geplant
- jeweils auf Handy und Desktop
- Pixelvergleich der übrigen Ansichten
- **Textprüfung:** Alle Dateien im Anhang nach „1 E-Mail-Postfach“ und nach festem „Wartungs-Abo“ durchsuchen.
## 13. Offene Punkte zur Durchsicht
Vorbelegt mit meinem Vorschlag, bitte bestätigen oder ändern:
1. Die Übernahmegutschrift gibt es auch fürs Hosting-Abo.
2. Den Wechsel gibt es nur mit sofortigem Beginn (Pflicht-Häkchen).
3. Zusatzspeicher ohne Häkchen für den Sofortbeginn. Bei Widerruf erstatten wir alles.
4. Laufende Angebotsrabatte laufen beim Wechsel weiter.
5. Rückwechsel von Wartung zu Hosting nur auf Anfrage nach der Mindestlaufzeit. Nicht genutzte Stunden verfallen.
6. Höchstens 10 Pakete Zusatzspeicher je Kunde.
7. Kontaktformulare auf statischen Seiten nur über einen Dienst, den wir zentral betreiben. Zu klären: Wie laufen Formulare heute auf Kundenseiten?
8. Bestandskunden mit Version 3 behalten ihre Fassung.
- Beliebig viele Postfächer bieten wir ihnen kostenlos per Mail an.
- Zusatzspeicher können sie buchen, wenn sie dabei den Regeln zum Zusatzspeicher zustimmen.
9. Abo-Start mit Version 3 jetzt oder erst mit Version 4: Das hängt nicht von diesem Konzept ab.
Geklärt am 27.09.2026: Wir nutzen das Hetzner-Paket M, und die Erreichbarkeit überwacht UptimeRobot.
## Anhang: Dateiliste
**Kundenportal** (`knso/Webklar-Kundenbereich`)
- Rechtstexte:
- `public/abo-bedingungen.html` und `public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf`
- `scripts/build-legal-pdfs.sh`
- `public/datenschutz.html` und `docs/WEBklar-Datenschutzerklaerung.pdf`
- Formulare: `public/kuendigen.html`, `public/widerrufen.html`, `public/legal-form.js`
- Oberfläche: `public/dashboard.html`, `public/app.js`, `public/kundenbereich.css`
- Admin: `public/admin.html`, `public/admin.js`
- Server:
- `server/config.js`
- `server/services/stripeBilling.js`, `server/services/offers.js`, `server/services/takeoverCredits.js`, `server/services/mailer.js`, `server/services/hourLedger.js`
- `server/routes/abo.js`, `server/routes/offers.js`, `server/routes/portalAdmin.js`
- Einrichtung: `scripts/setup-abo-collections.mjs`
- Mailvorlagen: `server/mail-templates/*.html` und `server/services/mailTemplates.js`
- Leitfaden: `docs/leitfaden-empfehlungsgeber.html` (+ PDF)
- Tests: neue Dateien unter `test/`
**Website** (`JUSN/Webklar`)
- `src/pages/WartungsAbo.tsx`
- neu: `src/pages/HostingAbo.tsx`
- `src/App.tsx`, `src/components/Footer.tsx`, `src/pages/AGB.tsx`
**Ticketsystem** (`JUSN/tickte-system`)
- `src/components/CreateWorksheetModal.jsx`, `src/components/WorksheetList.jsx`, `src/components/TicketRow.jsx`
**Außerhalb von Git**
- Stripe: Produkte, Preise, Beschreibung, Konfiguration des Kundenportals
- Hetzner konsoleH: Paket M, Postfach-Grenzen
- UptimeRobot: Monitore für die Abo-Websites
+2
View File
@@ -117,6 +117,8 @@ await ensureCollection(
// Individuelles Angebot, mit dem das Abo abgeschlossen wurde (Nachweis der Preisabrede)
{ type: 'string', key: 'offerId', size: 64 },
{ type: 'string', key: 'offerText', size: 300 },
// Plan 1: Produkt des Abos (wartung | hosting)
{ type: 'string', key: 'productKey', size: 16 },
],
[
{ key: 'idx_customer', type: 'key', attributes: ['customerId'] },
+55 -8
View File
@@ -23,6 +23,48 @@ function resolveDatabaseId() {
return WOMS_DATABASE_ID
}
// Abo-Produkte (Spec 2026-09-27 "Verträge, Hosting-Abo und E-Mail-Zusatzspeicher"). Preise in Cent,
// Endpreise gemäß § 19 UStG. Texte, die Kunden sehen, stimmen mit den Vertragsbedingungen überein.
const products = {
wartung: {
key: 'wartung',
name: 'WEBklar Wartungs-Abo',
lookupKey: process.env.STRIPE_ABO_PRICE_LOOKUP_KEY || 'webklar_wartung_monatlich_5000',
amountCents: 5000,
description:
'Betrieb und Pflege Ihrer Website bzw. Web-Anwendung auf unserem Server in Deutschland (Sicherheitsupdates, SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit), 1 Domain inklusive Verwaltung, 1 E-Mail-Postfach (5 GB) und 1 Stunde Arbeitszeit pro Monat (12 Monate nutzbar). Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.',
statementDescriptor: 'WEBKLAR WARTUNG',
priceNickname: 'Wartungs-Abo monatlich',
minTermMonths: 12,
minutesPerMonth: 60,
},
// Noch nicht bestellbar: Die Beschreibung geht erst mit den Vertragsbedingungen Version 4 an Stripe
hosting: {
key: 'hosting',
name: 'WEBklar Hosting-Abo',
lookupKey: 'webklar_hosting_monatlich_1500',
amountCents: 1500,
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.',
statementDescriptor: 'WEBKLAR HOSTING',
priceNickname: 'Hosting-Abo monatlich',
minTermMonths: 12,
minutesPerMonth: 0,
},
}
// E-Mail-Zusatzspeicher: zweite Position (Stückzahl) im selben Stripe-Abo
const mailStorage = {
key: 'mailStorage',
name: 'WEBklar E-Mail-Zusatzspeicher 5 GB',
lookupKey: 'webklar_mailspeicher_5gb_monatlich_100',
amountCents: 100,
gbPerUnit: 5,
includedGb: 5,
maxUnits: 10,
priceNickname: 'E-Mail-Zusatzspeicher 5 GB monatlich',
}
export const config = {
port: Number(process.env.PORT) || 3000,
sessionSecret: process.env.SESSION_SECRET || '',
@@ -56,16 +98,21 @@ export const config = {
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
products,
mailStorage,
// Wartungs-Abo (Stripe Billing). Preis/Produkt legt der Server per lookup_key selbst an.
// Die Werte kommen aus dem Produktkatalog (products.wartung).
stripe: {
secretKey: process.env.STRIPE_SECRET_KEY || '',
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
priceLookupKey: process.env.STRIPE_ABO_PRICE_LOOKUP_KEY || 'webklar_wartung_monatlich_5000',
priceAmountCents: 5000,
productName: 'WEBklar Wartungs-Abo',
productDescription:
'Betrieb und Pflege Ihrer Website bzw. Web-Anwendung auf unserem Server in Deutschland (Sicherheitsupdates, SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit), 1 Domain inklusive Verwaltung, 1 E-Mail-Postfach (5 GB) und 1 Stunde Arbeitszeit pro Monat (12 Monate nutzbar). Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.',
statementDescriptor: 'WEBKLAR WARTUNG',
// Nur für Tests: Stripe-Nachbau statt api.stripe.com (z. B. http://127.0.0.1:12111)
apiBase: process.env.STRIPE_API_BASE || '',
priceLookupKey: products.wartung.lookupKey,
priceAmountCents: products.wartung.amountCents,
productName: products.wartung.name,
productDescription: products.wartung.description,
statementDescriptor: products.wartung.statementDescriptor,
productImage: 'https://project.webklar.com/img/wartungs-abo-produkt.png',
// Kleinunternehmer: Pflichthinweis auf jeder Stripe-Rechnung
invoiceFooter:
@@ -77,8 +124,8 @@ export const config = {
// Bei jeder Textaenderung an Bedingungen oder Bestellseite hochziehen (wird mit der Zustimmung gespeichert)
termsVersion: '2026-09-25-v3',
// Mindestlaufzeit ab Leistungsbeginn; Kuendigung zum Ende bis 1 Monat vorher, danach monatlich
minTermMonths: 12,
minutesPerMonth: 60,
minTermMonths: products.wartung.minTermMonths,
minutesPerMonth: products.wartung.minutesPerMonth,
creditValidityMonths: 12,
usageRoundingMinutes: 15,
// Widerrufsfrist und "Beginn nach Ablauf der Widerrufsfrist": server/services/deadlines.js
+83
View File
@@ -0,0 +1,83 @@
import { config } from '../config.js'
/**
* Abo-Produkte (Hosting-Abo, Wartungs-Abo) und E-Mail-Zusatzspeicher.
*
* Reine Funktionen ohne Netzwerk: Sie ordnen Stripe-Preise (lookup_key) den Produkten zu und
* werten Abo-Positionen und Rechnungszeilen aus. Stripe-Objekte haben die Form der API-Version
* des SDK (2025-10-29.clover).
*/
export const PRODUCT_KEYS = Object.freeze(['wartung', 'hosting'])
function httpError(status, message) {
const err = new Error(message)
err.status = status
return err
}
/** Produkt aus dem Katalog; unbekannter Schlüssel -> 400. */
export function getProduct(key) {
const product = PRODUCT_KEYS.includes(key) ? config.products[key] : null
if (!product) throw httpError(400, `Unbekanntes Abo: ${key}`)
return product
}
/** lookup_key eines Stripe-Preises -> 'wartung' | 'hosting' | 'mailStorage' | null */
export function productKeyForLookupKey(lookupKey) {
if (!lookupKey) return null
const product = PRODUCT_KEYS.find((key) => config.products[key].lookupKey === lookupKey)
if (product) return product
return config.mailStorage.lookupKey === lookupKey ? 'mailStorage' : null
}
/**
* Positionen eines Stripe-Abos (subscription.items.data) auswerten. Der Abo-Posten ist die
* erste Position mit einem Hosting- oder Wartungs-Preis, unabhängig von der Reihenfolge.
*/
export function describeItems(items) {
const list = Array.isArray(items) ? items : []
const abo = list.find((item) => PRODUCT_KEYS.includes(productKeyForLookupKey(item?.price?.lookup_key)))
const storage = list.find((item) => productKeyForLookupKey(item?.price?.lookup_key) === 'mailStorage')
return {
productKey: abo ? productKeyForLookupKey(abo.price.lookup_key) : null,
aboItemId: abo?.id || null,
aboPriceId: abo?.price?.id || null,
lookupKey: abo?.price?.lookup_key || null,
priceAmount: abo?.price?.unit_amount ?? null,
storageItemId: storage?.id || null,
storageUnits: storage ? Number(storage.quantity) || 0 : 0,
}
}
const priceIdOfLine = (line) => line?.pricing?.price_details?.price || null
/** Preis-IDs aller Rechnungszeilen, jede nur einmal. */
export function priceIdsOfInvoice(invoice) {
const ids = (invoice?.lines?.data || []).map(priceIdOfLine).filter(Boolean)
return [...new Set(ids)]
}
/**
* Arbeitszeit, die eine bezahlte Rechnung gutschreibt: die Minuten des Produkts der ersten
* regulären (nicht anteiligen) Abo-Zeile über 0 €, deren Produkt Arbeitszeit enthält. Anteilige
* Verrechnungen, Probezeiten (0 €), Hosting und Zusatzspeicher zählen nicht.
* Unbekannte Preise zählen nur, wenn die Rechnung gar keinen bekannten Abo-Preis enthält: Solche
* Abos gelten wie im Abo-Spiegel als Wartungs-Abo (Altbestand bzw. von Hand angelegt).
* priceKeys: Map Preis-ID -> 'wartung' | 'hosting' | 'mailStorage' | null
*/
export function creditMinutesForInvoice(invoice, priceKeys) {
const lines = invoice?.lines?.data || []
const legacy = !lines.some((line) => PRODUCT_KEYS.includes(priceKeys.get(priceIdOfLine(line))))
for (const line of lines) {
const known = priceKeys.get(priceIdOfLine(line))
const key = legacy && known == null ? 'wartung' : known
if (!PRODUCT_KEYS.includes(key)) continue
const minutes = config.products[key].minutesPerMonth
const regular =
line.parent?.type === 'subscription_item_details' &&
line.parent.subscription_item_details?.proration === false
if (minutes > 0 && regular && line.amount > 0) return minutes
}
return 0
}
+73 -12
View File
@@ -8,6 +8,7 @@ import {
updateDocument,
} from './appwriteAdmin.js'
import { lateStartAt } from './deadlines.js'
import { creditMinutesForInvoice, describeItems, priceIdsOfInvoice, productKeyForLookupKey } from './products.js'
/**
* Wartungs-Abo ueber Stripe Billing.
@@ -30,7 +31,17 @@ export function getStripe() {
throw err
}
if (!stripeClient) {
stripeClient = new Stripe(config.stripe.secretKey, { maxNetworkRetries: 2 })
const options = { maxNetworkRetries: 2 }
// Tests: Stripe-Nachbau auf 127.0.0.1 (config.stripe.apiBase ist in Produktion leer). Nur lokale
// Adressen, damit ein verirrter Wert den Schlüssel nie an einen fremden Server schickt.
if (config.stripe.apiBase) {
const base = new URL(config.stripe.apiBase)
if (!['127.0.0.1', 'localhost'].includes(base.hostname)) {
throw new Error(`STRIPE_API_BASE darf nur auf 127.0.0.1 oder localhost zeigen, nicht auf ${base.hostname}.`)
}
Object.assign(options, { host: base.hostname, port: Number(base.port), protocol: base.protocol.replace(':', '') })
}
stripeClient = new Stripe(config.stripe.secretKey, options)
}
return stripeClient
}
@@ -131,7 +142,7 @@ function periodEndOf(sub) {
return sub.current_period_end || sub.items?.data?.[0]?.current_period_end || null
}
async function upsertSubscriptionDoc(id, data) {
async function writeSubscriptionDoc(id, data) {
const existing = await getDocument(config.collections.subscriptions, id).catch(() => null)
if (existing) return updateDocument(config.collections.subscriptions, id, data)
try {
@@ -143,6 +154,25 @@ async function upsertSubscriptionDoc(id, data) {
}
}
async function upsertSubscriptionDoc(id, data) {
let fields = data
for (;;) {
try {
return await writeSubscriptionDoc(id, fields)
} catch (err) {
// Neues Feld noch nicht in Appwrite (Setup-Skript nicht gelaufen): ohne das Feld spiegeln,
// damit Webhooks und der öffentliche Kündigungs-/Widerrufsknopf weiter funktionieren
const missing = err.status === 400 && /Unknown attribute: "(\w+)"/.exec(err.message || '')?.[1]
if (!missing || !(missing in fields)) throw err
console.error(
`[stripe] Appwrite kennt das Feld "${missing}" nicht (scripts/setup-abo-collections.mjs ausführen) – Abo ${id} ohne dieses Feld gespiegelt`
)
const { [missing]: _dropped, ...rest } = fields
fields = rest
}
}
}
/** Stripe-Subscription frisch laden und nach Appwrite spiegeln. */
export async function syncSubscription(subscriptionId) {
const stripe = getStripe()
@@ -154,14 +184,21 @@ export async function syncSubscription(subscriptionId) {
return null
}
const customer = typeof sub.customer === 'string' ? null : sub.customer
// Abo-Posten (Hosting/Wartung) unabhängig von der Reihenfolge; ohne bekannten Preis gilt das Abo
// als Wartungs-Abo (Altbestand bzw. von Hand angelegt)
const items = describeItems(sub.items?.data)
if (!items.productKey) {
console.warn(`[stripe] Abo ${sub.id}: kein bekannter Abo-Preis – als Wartungs-Abo gespiegelt`)
}
const doc = await upsertSubscriptionDoc(sub.id, {
customerId,
email: meta.email || customer?.email || '',
stripeCustomerId: typeof sub.customer === 'string' ? sub.customer : sub.customer?.id,
stripeSubscriptionId: sub.id,
status: sub.status,
planKey: config.stripe.priceLookupKey,
priceAmount: sub.items?.data?.[0]?.price?.unit_amount ?? config.stripe.priceAmountCents,
productKey: items.productKey || 'wartung',
planKey: items.lookupKey || config.products.wartung.lookupKey,
priceAmount: items.priceAmount ?? sub.items?.data?.[0]?.price?.unit_amount ?? config.products.wartung.amountCents,
startedAt: toIso(sub.start_date),
currentPeriodEnd: toIso(periodEndOf(sub)),
cancelAtPeriodEnd: Boolean(sub.cancel_at_period_end || sub.cancel_at),
@@ -241,20 +278,44 @@ function subscriptionIdOfInvoice(invoice) {
)
}
/** Bezahlte Abo-Rechnung -> 1 Stunde gutschreiben (idempotent ueber Invoice-ID). */
// Preis-ID -> Produkt ('wartung' | 'hosting' | 'mailStorage' | null); Preise ändern ihren lookup_key nicht
const priceKeyCache = new Map()
/** Produkte zu Preis-IDs; unbekannte IDs einmal bei Stripe nachschlagen (gelöscht/404 -> null). */
export async function resolvePriceKeys(priceIds) {
const keys = new Map()
for (const id of priceIds) {
if (!priceKeyCache.has(id)) {
const price = await getStripe()
.prices.retrieve(id)
.catch((err) => {
if (err?.statusCode === 404) return null
throw err
})
priceKeyCache.set(id, productKeyForLookupKey(price?.lookup_key))
}
keys.set(id, priceKeyCache.get(id))
}
return keys
}
/** Bezahlte Wartungs-Rechnung -> 1 Stunde gutschreiben (idempotent ueber Invoice-ID). */
export async function grantCreditForInvoice(invoice) {
const ref = subscriptionIdOfInvoice(invoice)
const subscriptionId = typeof ref === 'string' ? ref : ref?.id
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
// subtotal (vor Rabatt und Guthaben) statt amount_paid: Auch ein Monat, den ein Angebot oder eine
// Uebernahmegutschrift ganz deckt, ist ein bezahlter Leistungsmonat. Nur Probezeit-Rechnungen
// haben eine Zwischensumme von 0 € und bringen keine Stunde.
if (!(invoice.subtotal > 0)) return { skipped: 'Betrag 0' }
// Frisch laden: Die Webhook-Nutzlast hat die API-Version des Webhook-Endpunkts, nicht die des SDK.
// Es zählt die reguläre Wartungs-Zeile vor Rabatt und Guthaben: Auch ein Monat, den ein Angebot
// oder eine Übernahmegutschrift ganz deckt, ist ein bezahlter Leistungsmonat. Probezeit (0 €),
// anteilige Verrechnungen, Hosting und Zusatzspeicher bringen keine Stunde.
const fresh = await getStripe().invoices.retrieve(invoice.id)
const minutes = creditMinutesForInvoice(fresh, await resolvePriceKeys(priceIdsOfInvoice(fresh)))
if (!minutes) return { skipped: 'keine Wartungs-Leistung' }
const sub = await syncSubscription(subscriptionId)
if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' }
const grantedAt = new Date((invoice.status_transitions?.paid_at || invoice.created) * 1000)
const grantedAt = new Date((fresh.status_transitions?.paid_at || fresh.created) * 1000)
try {
await createDocument(
config.collections.hourCredits,
@@ -262,11 +323,11 @@ export async function grantCreditForInvoice(invoice) {
customerId: sub.customerId,
stripeSubscriptionId: subscriptionId,
stripeInvoiceId: invoice.id,
minutes: config.abo.minutesPerMonth,
minutes,
grantedAt: grantedAt.toISOString(),
expiresAt: addMonths(grantedAt, config.abo.creditValidityMonths).toISOString(),
source: 'stripe_invoice',
note: invoice.number ? `Rechnung ${invoice.number}` : '',
note: fresh.number ? `Rechnung ${fresh.number}` : '',
},
invoice.id
)
+118
View File
@@ -0,0 +1,118 @@
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'
import { schemaKeys } from './support/schema.js'
const aw = await startAppwriteMock({
schema: { subscriptions: schemaKeys('subscriptions'), hourCredits: schemaKeys('hourCredits') },
})
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { grantCreditForInvoice } = await import('../server/services/stripeBilling.js')
const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', lookup_key, unit_amount, active: true })
st.prices.set('price_w', price('price_w', 'webklar_wartung_monatlich_5000', 5000))
st.prices.set('price_h', price('price_h', 'webklar_hosting_monatlich_1500', 1500))
const sub = (id, priceObj) => ({
id,
object: 'subscription',
status: 'active',
customer: 'cus_1',
start_date: 1790000000,
current_period_end: 1792600000,
metadata: { webklarCustomerId: 'c1', email: 'k@example.invalid', termsVersion: '2026-09-25-v3' },
items: { object: 'list', data: [{ id: `si_${id}`, object: 'subscription_item', quantity: 1, price: priceObj }] },
})
st.subscriptions.set('sub_w', sub('sub_w', st.prices.get('price_w')))
st.subscriptions.set('sub_h', sub('sub_h', st.prices.get('price_h')))
// Frisch geladene Rechnung in der Form der SDK-API-Version (2025-10-29.clover)
const inv = (id, subscription, lines) => ({
id,
object: 'invoice',
number: `WK-${id}`,
created: 1790000000,
status_transitions: { paid_at: 1790000100 },
parent: { type: 'subscription_details', subscription_details: { subscription } },
lines: { object: 'list', data: lines },
})
const regular = (priceId, amount) => ({
amount,
pricing: { type: 'price_details', price_details: { price: priceId } },
parent: { type: 'subscription_item_details', subscription_item_details: { proration: false } },
})
// Webhook-Nutzlast (Auszug), wie sie heute ankommt
const payload = (id, subscription, subtotal) => ({
id,
subtotal,
created: 1790000000,
status_transitions: { paid_at: 1790000100 },
parent: { subscription_details: { subscription } },
})
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(payload('in_1', 'sub_w', 5000))
assert.equal(r.granted, true)
const credit = aw.docs('hourCredits').get('in_1')
assert.equal(credit.minutes, 60)
assert.equal(credit.customerId, 'c1')
assert.equal(credit.grantedAt, new Date(1790000100 * 1000).toISOString())
assert.deepEqual(await grantCreditForInvoice(payload('in_1', 'sub_w', 5000)), { 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(payload('in_2', 'sub_h', 1500)), { 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',
subtotal: 5000,
created: 1790000000,
status_transitions: { paid_at: 1790000100 },
lines: { data: [{ amount: 5000, price: { id: 'price_w' }, proration: false }] },
}
assert.deepEqual(await grantCreditForInvoice(oldPayload), { skipped: 'keine Wartungs-Leistung' })
assert.equal(aw.docs('hourCredits').has('in_3'), false)
})
test('Preis-Zuordnung wird zwischengespeichert', async () => {
st.invoices.set('in_4', inv('in_4', 'sub_w', [regular('price_w', 5000)]))
await grantCreditForInvoice(payload('in_4', 'sub_w', 5000))
assert.equal(aw.docs('hourCredits').get('in_4').minutes, 60)
assert.equal(st.calls.filter((c) => c.path === '/v1/prices/price_w').length, 1)
})
test('Unbekannte Preis-ID (404) neben Hosting: keine Stunde, kein Absturz', async () => {
st.invoices.set('in_5', inv('in_5', 'sub_h', [regular('price_h', 1500), regular('price_weg', 800)]))
assert.deepEqual(await grantCreditForInvoice(payload('in_5', 'sub_h', 2300)), { skipped: 'keine Wartungs-Leistung' })
assert.equal(aw.docs('hourCredits').has('in_5'), false)
})
test('Altbestand: Abo ohne bekannten Preis bekommt weiter seine Stunde, auch bei alter Nutzlast', async () => {
st.prices.set('price_alt', price('price_alt', null, 5000))
st.subscriptions.set('sub_alt', sub('sub_alt', st.prices.get('price_alt')))
st.invoices.set('in_6', inv('in_6', 'sub_alt', [regular('price_alt', 5000)]))
const oldPayload = {
id: 'in_6',
subscription: 'sub_alt',
subtotal: 5000,
created: 1790000000,
status_transitions: { paid_at: 1790000100 },
lines: { data: [{ amount: 5000, price: { id: 'price_alt' }, proration: false }] },
}
const r = await grantCreditForInvoice(oldPayload)
assert.equal(r.granted, true)
assert.equal(aw.docs('hourCredits').get('in_6').minutes, 60)
assert.equal(aw.docs('subscriptions').get('sub_alt').productKey, 'wartung')
})
+103
View File
@@ -0,0 +1,103 @@
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
// unbekannter Preis neben einem bekannten Abo-Preis: keine Stunde
assert.equal(creditMinutesForInvoice(inv(line('price_h', 1500), line('price_x', 800)), keys), 0)
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('Altbestand: Rechnung ohne bekannten Abo-Preis zählt als Wartung (wie im Abo-Spiegel)', () => {
const inv = (...lines) => ({ lines: { data: lines } })
assert.equal(creditMinutesForInvoice(inv(line('price_x', 5000)), keys), 60)
assert.equal(creditMinutesForInvoice(inv(line('price_x', 0)), keys), 0) // Probezeit bleibt ohne Stunde
assert.equal(creditMinutesForInvoice(inv(line('price_x', 2300, true)), keys), 0) // anteilig bleibt ohne Stunde
})
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'])
})
+11
View File
@@ -0,0 +1,11 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
// Fehlkonfiguration in Produktion: STRIPE_API_BASE zeigt auf einen fremden Server
process.env.STRIPE_SECRET_KEY = 'sk_live_nicht_echt'
process.env.STRIPE_API_BASE = 'https://stripe.fremd.example'
const { getStripe } = await import('../server/services/stripeBilling.js')
test('STRIPE_API_BASE außerhalb von 127.0.0.1/localhost: kein Stripe-Client, klare Meldung', () => {
assert.throws(() => getStripe(), /STRIPE_API_BASE/)
})
+62
View File
@@ -0,0 +1,62 @@
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'
import { schemaKeys } from './support/schema.js'
const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions') } })
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { syncSubscription } = await import('../server/services/stripeBilling.js')
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,
cancel_at: null,
canceled_at: null,
ended_at: null,
metadata: { webklarCustomerId: 'c1', email: 'k@example.invalid', termsVersion: '2026-09-25-v3' },
items: { object: 'list', data: items },
...extra,
})
const item = (id, lookup_key, unit_amount, quantity = 1) => ({
id: `si_${id}`,
object: 'subscription_item',
quantity,
price: { id: `price_${id}`, object: 'price', 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])
})
+45
View File
@@ -0,0 +1,45 @@
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'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird
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')
await createDocument('subscriptions', { customerId: 'c2', status: 'active' }, 'sub_c')
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: doppelte ID 409, fehlendes Dokument 404', async () => {
await assert.rejects(createDocument('subscriptions', { customerId: 'c1' }, 'sub_a'), (e) => e.status === 409)
await assert.rejects(getDocument('subscriptions', 'gibt_es_nicht'), (e) => e.status === 404)
})
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: { object: 'list', data: [] } })
assert.equal((await getStripe().subscriptions.retrieve('sub_1')).id, 'sub_1')
assert.equal(st.calls.at(-1).path, '/v1/subscriptions/sub_1')
})
test('Stripe-Nachbau: unbekannte ID 404', async () => {
await assert.rejects(getStripe().prices.retrieve('price_weg'), (e) => e.statusCode === 404)
})
test('useTestEnv lehnt fremde Hosts ab', () => {
assert.throws(() => useTestEnv({ appwriteUrl: 'https://ticket.webklar.com/v1', stripeUrl: st.url }))
assert.equal(new URL(process.env.APPWRITE_ENDPOINT).hostname, '127.0.0.1')
})
+133
View File
@@ -0,0 +1,133 @@
import http from 'node:http'
/**
* Appwrite-Nachbau für Tests: die REST-Pfade, die server/services/appwriteAdmin.js benutzt, im
* Speicher. Mit schema = { collection: ['attr', ...] } lehnt er unbekannte Attribute ab wie
* Appwrite ("Invalid document structure: Unknown attribute").
*/
export async function startAppwriteMock({ schema = {} } = {}) {
const collections = new Map()
let sequence = 0
const docs = (collection) => {
if (!collections.has(collection)) collections.set(collection, new Map())
return collections.get(collection)
}
const stamp = (doc, collection) => {
const now = new Date().toISOString()
return { $createdAt: now, $updatedAt: now, $collectionId: collection, ...doc, $seq: (sequence += 1) }
}
const seed = (collection, doc) => {
if (!doc?.$id) throw new Error('seed: $id fehlt')
docs(collection).set(doc.$id, stamp(doc, collection))
}
const unknownAttribute = (collection, data) => {
const allowed = schema[collection]
if (!allowed) return null
return Object.keys(data).find((key) => !key.startsWith('$') && !allowed.includes(key)) || null
}
const matches = (doc, query) => {
const value = doc[query.attribute]
switch (query.method) {
case 'equal':
return query.values.includes(value)
case 'greaterThan':
return value > query.values[0]
case 'lessThan':
return value < query.values[0]
default:
return true
}
}
const list = (collection, rawQueries) => {
const queries = rawQueries.map((q) => JSON.parse(q))
const unsupported = queries.find((q) => !['equal', 'greaterThan', 'lessThan', 'orderAsc', 'orderDesc', 'limit'].includes(q.method))
if (unsupported) return [400, { message: `Query ${unsupported.method} wird nicht nachgebaut`, code: 400 }]
let result = [...docs(collection).values()].filter((doc) => queries.every((q) => matches(doc, q)))
for (const q of queries.filter((q) => q.method === 'orderAsc' || q.method === 'orderDesc')) {
const dir = q.method === 'orderAsc' ? 1 : -1
result.sort((a, b) => {
const x = a[q.attribute]
const y = b[q.attribute]
if (x === y) return (a.$seq - b.$seq) * dir
return (x > y ? 1 : -1) * dir
})
}
const limit = queries.find((q) => q.method === 'limit')
if (limit) result = result.slice(0, limit.values[0])
return [200, { total: result.length, documents: result.map(publicDoc) }]
}
const publicDoc = ({ $seq, ...doc }) => doc
const readBody = (req) =>
new Promise((resolve) => {
let raw = ''
req.on('data', (chunk) => (raw += chunk))
req.on('end', () => resolve(raw ? JSON.parse(raw) : {}))
})
const handle = async (req) => {
const url = new URL(req.url, 'http://127.0.0.1')
const docPath = url.pathname.match(/^\/v1\/databases\/[^/]+\/collections\/([^/]+)\/documents(?:\/([^/]+))?$/)
const userPath = url.pathname.match(/^\/v1\/users\/([^/]+)$/)
if (userPath && req.method === 'GET') return [200, { $id: userPath[1], passwordUpdate: '' }]
if (!docPath) return [404, { message: 'Route not found', code: 404 }]
const [, collection, id] = docPath
const store = docs(collection)
const notFound = [404, { message: 'Document with the requested ID could not be found.', code: 404, type: 'document_not_found' }]
if (!id && req.method === 'GET') return list(collection, url.searchParams.getAll('queries[]'))
if (!id && req.method === 'POST') {
const { documentId, data = {} } = await readBody(req)
if (store.has(documentId)) {
return [409, { message: 'Document with the requested ID already exists.', code: 409, type: 'document_already_exists' }]
}
const bad = unknownAttribute(collection, data)
if (bad) return [400, { message: `Invalid document structure: Unknown attribute: "${bad}"`, code: 400 }]
const doc = stamp({ ...data, $id: documentId }, collection)
store.set(documentId, doc)
return [201, publicDoc(doc)]
}
if (id && req.method === 'GET') return store.has(id) ? [200, publicDoc(store.get(id))] : notFound
if (id && req.method === 'PATCH') {
if (!store.has(id)) return notFound
const { data = {} } = await readBody(req)
const bad = unknownAttribute(collection, data)
if (bad) return [400, { message: `Invalid document structure: Unknown attribute: "${bad}"`, code: 400 }]
const doc = { ...store.get(id), ...data, $updatedAt: new Date().toISOString() }
store.set(id, doc)
return [200, publicDoc(doc)]
}
if (id && req.method === 'DELETE') {
if (!store.delete(id)) return notFound
return [204, null]
}
return [405, { message: 'Method not allowed', code: 405 }]
}
const server = http.createServer(async (req, res) => {
try {
const [status, body] = await handle(req)
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(body === null ? '' : JSON.stringify(body))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ message: err.message, code: 500 }))
}
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
return {
url: `http://127.0.0.1:${server.address().port}/v1`,
docs: (collection) => new Map([...docs(collection)].map(([id, doc]) => [id, publicDoc(doc)])),
seed,
close: () => new Promise((resolve) => server.close(resolve)),
}
}
+65
View File
@@ -0,0 +1,65 @@
import http from 'node:http'
/**
* Stripe-Nachbau für Tests. stripe-node spricht ihn über config.stripe.apiBase (STRIPE_API_BASE)
* an. Objekte werden vorab in die Maps gelegt; jeder Aufruf landet in calls als
* { method, path, query, body } (Formular-Schlüssel flach, z. B. 'items[0][price]').
*/
export async function startFakeStripe() {
const subscriptions = new Map()
const prices = new Map()
const invoices = new Map()
const calls = []
const flat = (params) => Object.fromEntries(params.entries())
const noSuch = (what, id) => [404, { error: { type: 'invalid_request_error', code: 'resource_missing', message: `No such ${what}: '${id}'` } }]
const listOf = (data) => ({ object: 'list', data, has_more: false, url: '/v1/list' })
const readBody = (req) =>
new Promise((resolve) => {
let raw = ''
req.on('data', (chunk) => (raw += chunk))
req.on('end', () => resolve(new URLSearchParams(raw)))
})
const route = (method, path, query) => {
let m
if (method === 'GET' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) {
return subscriptions.has(m[1]) ? [200, subscriptions.get(m[1])] : noSuch('subscription', m[1])
}
if (method === 'GET' && (m = path.match(/^\/v1\/prices\/([^/]+)$/))) {
return prices.has(m[1]) ? [200, prices.get(m[1])] : noSuch('price', m[1])
}
if (method === 'GET' && path === '/v1/prices') {
const keys = [...query.entries()].filter(([k]) => /^lookup_keys\[\d*\]$/.test(k)).map(([, v]) => v)
const active = query.get('active')
const found = [...prices.values()].filter(
(p) => (!keys.length || keys.includes(p.lookup_key)) && (active === null || String(p.active !== false) === active)
)
return [200, listOf(found)]
}
if (method === 'GET' && (m = path.match(/^\/v1\/invoices\/([^/]+)$/))) {
return invoices.has(m[1]) ? [200, invoices.get(m[1])] : noSuch('invoice', m[1])
}
return [404, { error: { type: 'invalid_request_error', message: `No such route: ${method} ${path}` } }]
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://127.0.0.1')
const body = await readBody(req)
calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body) })
const [status, payload] = route(req.method, url.pathname, url.searchParams)
res.writeHead(status, { 'Content-Type': 'application/json', 'Request-Id': `req_test_${calls.length}` })
res.end(JSON.stringify(payload))
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
return {
url: `http://127.0.0.1:${server.address().port}`,
calls,
subscriptions,
prices,
invoices,
close: () => new Promise((resolve) => server.close(resolve)),
}
}
+14
View File
@@ -0,0 +1,14 @@
import { readFileSync } from 'node:fs'
/**
* Attribut-Schlüssel einer Collection, wie sie scripts/setup-abo-collections.mjs in Appwrite
* anlegt. Tests geben sie dem Appwrite-Nachbau als Schema: Schreibt der Code ein Feld, das das
* Setup-Skript nicht anlegt, scheitert der Test wie Appwrite in Produktion.
*/
export function schemaKeys(collection) {
const src = readFileSync(new URL('../../scripts/setup-abo-collections.mjs', import.meta.url), 'utf8')
const start = src.indexOf(`'${collection}',`)
if (start < 0) throw new Error(`Collection ${collection} fehlt im Setup-Skript`)
const block = src.slice(start, src.indexOf('\n ],', start))
return [...block.matchAll(/key: '(\w+)'/g)].map((m) => m[1])
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Umgebung für Tests mit den Nachbauten. Muss vor dem ersten Import von server/config.js laufen.
* Verweigert jede Adresse außerhalb von 127.0.0.1, damit kein Test Produktion erreicht.
*/
export function useTestEnv({ appwriteUrl, stripeUrl }) {
for (const url of [appwriteUrl, stripeUrl]) {
if (new URL(url).hostname !== '127.0.0.1') throw new Error(`Testumgebung: nur 127.0.0.1 erlaubt, nicht ${url}`)
}
Object.assign(process.env, {
APPWRITE_ENDPOINT: appwriteUrl,
APPWRITE_API_KEY: 'test-key',
STRIPE_SECRET_KEY: 'sk_test_fake',
STRIPE_API_BASE: stripeUrl,
STRIPE_WEBHOOK_SECRET: 'whsec_test',
SMTP_HOST: '',
SESSION_SECRET: 'test-session-secret-mit-mindestens-32-zeichen',
ABO_PILOT_EMAILS: '*',
})
}
+51
View File
@@ -0,0 +1,51 @@
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'
import { schemaKeys } from './support/schema.js'
// Produktion, bevor das Setup-Skript gelaufen ist: Appwrite kennt productKey noch nicht
const aw = await startAppwriteMock({
schema: { subscriptions: schemaKeys('subscriptions').filter((key) => key !== 'productKey') },
})
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { syncSubscription } = await import('../server/services/stripeBilling.js')
const sub = (id, cancelAtPeriodEnd) => ({
id,
object: 'subscription',
status: 'active',
customer: 'cus_1',
start_date: 1790000000,
current_period_end: 1792600000,
cancel_at_period_end: cancelAtPeriodEnd,
metadata: { webklarCustomerId: 'c1', email: 'k@example.invalid', termsVersion: '2026-09-25-v3' },
items: {
object: 'list',
data: [{ id: 'si_w', quantity: 1, price: { id: 'price_w', lookup_key: 'webklar_wartung_monatlich_5000', unit_amount: 5000 } }],
},
})
test('Fehlendes Feld in Appwrite: Abo wird trotzdem gespiegelt (anlegen und ändern)', async () => {
const errors = []
const original = console.error
console.error = (...args) => errors.push(args.join(' '))
try {
st.subscriptions.set('sub_w', sub('sub_w', false))
const created = await syncSubscription('sub_w')
assert.equal(created.status, 'active')
assert.equal('productKey' in created, false)
// z. B. Kündigung über den öffentlichen Knopf: Stripe ist schon geändert, der Spiegel muss folgen
st.subscriptions.set('sub_w', sub('sub_w', true))
const updated = await syncSubscription('sub_w')
assert.equal(updated.cancelAtPeriodEnd, true)
assert.equal(aw.docs('subscriptions').get('sub_w').cancelAtPeriodEnd, true)
} finally {
console.error = original
}
assert.ok(errors.some((line) => line.includes('productKey')), 'Fehler im Log nennt das fehlende Feld')
})