# 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`, 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 " ``` --- ### 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:/v1`, `docs(collection)` liefert eine `Map`. - 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: ""' }`. - 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 " ``` --- ### 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 : 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 " ``` --- ### 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>`. - 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: }`. 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 " ``` --- ### 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.