31 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.
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.
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.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.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.