Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-09-27-vertraege-plan-1-vorarbeiten.md
T
ClaudeandClaude Opus 5.5 405408d076 Umsetzungspläne: Plan 1 (unsichtbare Vorarbeiten) und Plan 2 (sichtbarer Teil mit Version 4)
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>
2026-09-27 15:04:44 +00:00

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

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