Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-09-27-vertraege-plan-1-vorarbeiten.md
T

31 KiB
Raw Blame History

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:
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 (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

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
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 Maps, 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

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

// 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
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

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