Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f016ad0326 | ||
|
|
47c1af7015 | ||
|
|
2694ef18d6 | ||
|
|
dc61649228 | ||
|
|
51fc2434e5 | ||
|
|
d097704d49 | ||
|
|
54defb75c4 | ||
|
|
877fde7a6f | ||
|
|
afec0b82fd | ||
|
|
405408d076 | ||
|
|
c76ea0bf9e | ||
|
|
09870a3b83 | ||
|
|
65ab5013cb |
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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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])
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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: '*',
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
Reference in new issue
Block a user