Plan 1: Produktkatalog, Abo-Spiegel mit productKey, Arbeitszeit nur für Wartungs-Rechnungen, Stripe- und Appwrite-Nachbau für Tests. Kann vor den neuen Vertragsbedingungen live gehen. Plan 2: Rechtstexte (Gate Rechtsrunde), Vertragsereignisse, Zusatzspeicher, Wechsel, öffentliche Formulare, Angebote je Produkt, Mails, Admin, Ansicht "Verträge", Website, Ticketsystem, Livegang. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
30 KiB
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.
- beim Spiegeln der Abos nach Appwrite (
- 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 überSTRIPE_ABO_PRICE_LOOKUP_KEYüberschreibbar.webklar_hosting_monatlich_1500webklar_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_ENDPOINTundSTRIPE_API_BASEzeigen in Tests immer auf127.0.0.1. npm testmuss 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-vertraegeauf dem Branchfeature/vertraege-hosting-abo, ohne Upstream. - Niemals
git add -A: Dernode_modules-Symlink ist untracked. - Push nach
mainnur mit Freigabe des Nutzers, denn ein Push dorthin geht sofort live.
Review Focus
- Die Webhook-Nutzlast kommt in einer älteren API-Version (Zeilen ohne
pricingundparent). Die Stunde wird trotzdem richtig vergeben, weilgrantCreditForInvoicedie Rechnung frisch über die SDK-Version lädt. Test: Task 4, „frisch geladene Rechnung zählt, nicht die Webhook-Nutzlast“. - Eine Rechnungszeile hat einen unbekannten Preis, etwa von Hand im Stripe-Dashboard angelegt. Dann gibt es keine Stunde und keinen Absturz. Tests: Task 1 (
creditMinutesForInvoicemit unbekannter Preis-ID ergibt 0) und Task 4 (Preis-Abruf mit 404). - Die Zusatzspeicher-Position steht in
items.datavor der Abo-Position.planKeyundpriceAmountkommen trotzdem vom Abo-Posten, nicht vonitems.data[0]. Tests: Task 1 und Task 3. - 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. - 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_modulesverlinkt:
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:
ln -sfn ../build/node_modules /home/webklar/apps/kundenbereich.webklar.com/dev-vertraege/node_modules
- Ausgangslage grün:
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(BlockstripeZ. 59–75, BlockaboZ. 77–98) - Create:
server/services/products.js - Test:
test/products.test.js
Interfaces:
-
Consumes: –
-
Produces:
config.products.wartungundconfig.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 mitstatus = 400productKeyForLookupKey(lookupKey): liefert'wartung','hosting','mailStorage'odernulldescribeItems(items): liefert{ productKey, aboItemId, aboPriceId, lookupKey, priceAmount, storageItemId, storageUnits }priceIdsOfInvoice(invoice): liefertstring[]creditMinutesForInvoice(invoice, priceKeys):priceKeysist eineMap<priceId, key|null>, Rückgabe sind Minuten als Zahl
-
Step 1: Write the failing test –
test/products.test.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.jsanlegen- Vor
export const configstehenconst products = { wartung, hosting }undconst mailStorage. - wartung:
descriptionist der bisherige Text vonconfig.stripe.productDescription, wörtlich übernommen.statementDescriptor'WEBKLAR WARTUNG',priceNickname'Wartungs-Abo monatlich',lookupKeyprocess.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,productDescriptionundstatementDescriptorlesen ausproducts.wartung, ebensoconfig.abo.minTermMonthsundminutesPerMonth. - Export:
config.products = products,config.mailStorage = mailStorage.
- Vor
-
Step 4:
server/services/products.jsanlegen-
Die Funktionen sind rein, ohne Netzwerk.
-
describeItems(items): Abo-Posten ist das erste Item, dessenprice.lookup_keyaufwartungoderhostingzeigt. Zusatzspeicher ist das erste Item mitmailStorage,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 mitminutesPerMonth > 0line.parent?.type === 'subscription_item_details'line.parent.subscription_item_details.proration === falseline.amount > 0
Zurück kommt
minutesPerMonthdieses 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
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(Blockstripe: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 = {} } = {}): liefertPromise<{ url, docs(collection), seed(collection, doc), close() }>.urlisthttp://127.0.0.1:<port>/v1,docs(collection)liefert eineMap<id, doc>.- Pfade:
GETundPOST /v1/databases/woms-database/collections/:col/documentsGET,PATCHundDELETE …/documents/:idGET /v1/users/:id, liefert{ $id, passwordUpdate: '' }
- Queries kommen aus
queries[](JSON). Unterstützt werdenequal,greaterThan,lessThan,orderAsc,orderDescundlimit. Jede andere Methode liefert 400. POSTerwartet{ documentId, data }. Existiert die ID schon, kommt 409 mit{ message }.PATCHerwartet{ 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,$createdAtund$updatedAt.
-
startFakeStripe(): liefertPromise<{ url, calls, subscriptions, prices, invoices, close() }>. Die drei Objektsammlungen sindMaps, vorbelegbar mitset(id, obj).- Pfade:
GET /v1/subscriptions/:id,GET /v1/prices/:id,GET /v1/prices(mit Filterlookup_keys[]),GET /v1/invoices/:id. - Jeder Aufruf landet in
callsals{ method, path, query, body }. - Unbekannte IDs oder Pfade liefern 404 mit
{ error: { type: 'invalid_request_error', message: 'No such …' } }.
- Pfade:
-
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, erzeugtgetStripe()den Client mit{ host, port, protocol }aus dieser URL.
-
-
Step 1: Write the failing test –
test/support.test.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,
useTestEnvund die Testadresse ingetStripe()umsetzen- Beide Server lauschen auf
127.0.0.1und Port 0. - Den
x-www-form-urlencoded-Rumpf von stripe-node liest der Stripe-Nachbau mitURLSearchParamsin ein flaches Objekt. Die Schlüssel bleiben dabei wieitems[0][price].
- Beide Server lauschen auf
-
Step 4: Run test to verify it passes
Run: node --test test/support.test.js && npm test
Expected: PASS, # fail 0
- Step 5: Commit
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 dersubscriptions-Attributliste kommt nachofferTextdie 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,startFakeStripeunduseTestEnv(Task 2)
-
Produces: Das Dokument in
subscriptionshat danach:productKey:'wartung'oder'hosting'planKey: lookup_key des Abo-PreisespriceAmount:unit_amountdes Abo-Preises
-
Step 1: Write the failing test –
test/stripeBilling.test.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
syncSubscriptionruftconst d = describeItems(sub.items?.data)auf und schreibt:productKey: d.productKey || 'wartung'planKey: d.lookupKey || config.products.wartung.lookupKeypriceAmount: d.priceAmount ?? sub.items?.data?.[0]?.price?.unit_amount ?? config.products.wartung.amountCents
- Ist
d.productKeyleer, folgtconsole.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
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 FunktionresolvePriceKeys - Test:
test/hourCredits.test.js. Eine eigene Datei, damit der Preis-Zwischenspeicher frisch ist.
Interfaces:
-
Consumes:
priceIdsOfInvoiceundcreditMinutesForInvoice(Task 1)- Nachbauten (Task 2)
syncSubscription(Task 3)
-
Produces:
resolvePriceKeys(priceIds: string[]): liefertPromise<Map<string, 'wartung'|'hosting'|'mailStorage'|null>>.- Das Ergebnis bleibt pro Prozess zwischengespeichert.
- Unbekannte IDs holt die Funktion einmal über
stripe.prices.retrieve(id)und liestlookup_key. Bei 404 ist das Ergebnisnull.
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
// 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
minutes0, liefert die Funktion{ skipped: 'keine Wartungs-Leistung' }. - Danach folgen
syncSubscriptionundcreateDocument('hourCredits', …, invoice.id), wie bisher. Dabei gilt:minutesstattconfig.abo.minutesPerMonthgrantedAtausfresh.status_transitions?.paid_at || fresh.created
- Die bisherige Prüfung auf
subtotal > 0entfällt, weil die Zeilenregel sie abdeckt.
- Die Abo-ID wird wie bisher mit
-
Step 4: Run test to verify it passes
Run: node --test test/hourCredits.test.js && npm test
Expected: PASS, # fail 0
- Step 5: Commit
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:
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:
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.
docker exec -i -w /app webklar-kundenbereich-server node --input-type=module - < /home/webklar/apps/kundenbereich.webklar.com/dev-vertraege/scripts/setup-abo-collections.mjs
Expected: Die letzte Zeile lautet Fertig., und für productKey gibt es eine neue Zeile. Alles andere ist mit „existiert“ markiert.
- Step 4: Push nach main, nur mit Freigabe des Nutzers. Der Push löst den Deploy aus.
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 -1zeigt den gepushten Commit.- Der Container ist neu gestartet, und in
docker logs --since 5m webklar-kundenbereich-serversteht kein[abo]- und kein[stripe]-Fehler. curl -s -o /dev/null -w '%{http_code}' https://project.webklar.com/api/aboliefert401.
- Step 6: Spiegel live bestätigen (Lesen in Produktion nur mit Freigabe des Nutzers)
- Der Nutzer sendet im Stripe-Dashboard ein
customer.subscription.updatedseines Testkonto-Abos erneut oder wartet die nächste Abbuchung ab. - Danach hat das Dokument in
subscriptionsproductKey: 'wartung'.
- Der Nutzer sendet im Stripe-Dashboard ein
- Step 7: Memory-Datei
webklar-vertraege-hosting-abo.mdergänzen: Plan 1 live mit Commit, Schema angelegt.