diff --git a/scripts/stripe-products.mjs b/scripts/stripe-products.mjs new file mode 100644 index 0000000..ed162ac --- /dev/null +++ b/scripts/stripe-products.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +/** + * Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher): legt fehlende + * Preise an (ensurePrice), haelt die Wartungs-Produktbeschreibung bei Stripe auf dem Katalogtext + * (server/config.js) und passt die Kundenportal-Konfiguration an ("WEBklar – Abo verwalten"). + * Idempotent: vorhandene Preise werden ueber lookup_key gefunden, nicht neu angelegt. + * + * Usage (im Container): + * node scripts/stripe-products.mjs --pruefen (nur anzeigen, was fehlt oder abweicht) + * node scripts/stripe-products.mjs (anlegen/anpassen) + */ +import 'dotenv/config' +import { config } from '../server/config.js' +import { ensurePortalConfiguration, ensurePrice, getStripe, PORTAL_HEADLINE } from '../server/services/stripeBilling.js' +import { PRODUCT_KEYS } from '../server/services/products.js' + +const KEYS = [...PRODUCT_KEYS, 'mailStorage'] // ['wartung', 'hosting', 'mailStorage'] + +function catalogEntry(key) { + return key === 'mailStorage' ? config.mailStorage : config.products[key] +} + +async function findPrice(lookupKey) { + const { data } = await getStripe().prices.list({ lookup_keys: [lookupKey], active: true, limit: 1 }) + return data[0] || null +} + +async function checkPrice(key) { + const catalog = catalogEntry(key) + const price = await findPrice(catalog.lookupKey) + if (!price) return { ok: false, note: `Preis ${catalog.lookupKey} fehlt bei Stripe` } + const mismatches = [] + if (price.unit_amount !== catalog.amountCents) mismatches.push(`Betrag ${price.unit_amount} statt ${catalog.amountCents}`) + if (price.recurring?.interval !== 'month') mismatches.push(`recurring.interval "${price.recurring?.interval}" statt "month"`) + if (price.tax_behavior !== 'inclusive') mismatches.push(`tax_behavior "${price.tax_behavior}" statt "inclusive"`) + return { ok: mismatches.length === 0, note: mismatches.length ? mismatches.join('; ') : `OK (${price.id})` } +} + +async function checkWartungDescription() { + const price = await findPrice(config.products.wartung.lookupKey) + if (!price) return { ok: false, note: 'Preis fehlt, Produkt kann nicht geprüft werden' } + const productId = typeof price.product === 'string' ? price.product : price.product.id + const product = await getStripe().products.retrieve(productId) + const ok = product.description === config.products.wartung.description + return { ok, note: ok ? 'OK' : 'Produktbeschreibung weicht vom Katalogtext ab' } +} + +async function checkPortalConfig() { + const { data } = await getStripe().billingPortal.configurations.list({ active: true, limit: 20 }) + const own = data.find((c) => c.metadata?.webklar === 'wartungs-abo') + if (!own) return { ok: false, note: 'Konfiguration fehlt bei Stripe' } + const ok = own.business_profile?.headline === PORTAL_HEADLINE + return { ok, note: ok ? 'OK' : `Überschrift "${own.business_profile?.headline}" statt "${PORTAL_HEADLINE}"` } +} + +async function pruefen() { + let allOk = true + for (const key of KEYS) { + const r = await checkPrice(key) + allOk = allOk && r.ok + console.log(`${r.ok ? '✅' : '❌'} Preis ${key}: ${r.note}`) + } + const desc = await checkWartungDescription() + allOk = allOk && desc.ok + console.log(`${desc.ok ? '✅' : '❌'} Wartung-Produktbeschreibung: ${desc.note}`) + const portal = await checkPortalConfig() + allOk = allOk && portal.ok + console.log(`${portal.ok ? '✅' : '❌'} Kundenportal-Konfiguration: ${portal.note}`) + if (!allOk) { + console.log('') + console.log('Zum Anlegen/Anpassen: node scripts/stripe-products.mjs') + } +} + +async function anlegen() { + for (const key of KEYS) { + const priceId = await ensurePrice(key) + console.log(`✅ Preis ${key}: ${priceId}`) + } + const price = await findPrice(config.products.wartung.lookupKey) + const productId = typeof price.product === 'string' ? price.product : price.product.id + await getStripe().products.update(productId, { description: config.products.wartung.description }) + console.log('✅ Wartung-Produktbeschreibung auf Katalogtext gesetzt') + const configurationId = await ensurePortalConfiguration() + console.log(`✅ Kundenportal-Konfiguration: ${configurationId}`) +} + +async function main() { + if (!config.stripe.secretKey) { + console.error('❌ STRIPE_SECRET_KEY fehlt') + process.exit(1) + } + const checkOnly = process.argv.includes('--pruefen') + console.log(checkOnly ? 'Modus: --pruefen (nur lesen, keine Änderungen)' : 'Modus: anlegen/anpassen') + console.log('') + if (checkOnly) await pruefen() + else await anlegen() +} + +main().catch((err) => { + console.error('❌', err.message) + process.exit(1) +}) diff --git a/server/config.js b/server/config.js index 98cfc2b..e176497 100644 --- a/server/config.js +++ b/server/config.js @@ -31,12 +31,20 @@ const products = { name: 'WEBklar Wartungs-Abo', lookupKey: process.env.STRIPE_ABO_PRICE_LOOKUP_KEY || 'webklar_wartung_monatlich_5000', amountCents: 5000, + // Version 4 (Rechtsrunde 28.09.2026): beliebig viele Postfächer statt 1 E-Mail-Postfach (F07) description: - 'Betrieb und Pflege Ihrer Website bzw. Web-Anwendung auf unserem Server in Deutschland (Sicherheitsupdates, SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit), 1 Domain inklusive Verwaltung, 1 E-Mail-Postfach (5 GB) und 1 Stunde Arbeitszeit pro Monat (12 Monate nutzbar). Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.', + 'Betrieb und Pflege Ihrer Website bzw. Web-Anwendung auf unserem Server in Deutschland (Sicherheitsupdates, SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit), 1 Domain inklusive Verwaltung, beliebig viele Postfächer (zusammen 5 GB) und 1 Stunde Arbeitszeit pro Monat (12 Monate nutzbar). Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.', statementDescriptor: 'WEBKLAR WARTUNG', priceNickname: 'Wartungs-Abo monatlich', minTermMonths: 12, minutesPerMonth: 60, + // Leistungen fuers Kundenportal (Vergleich, Bestellseite): [Titel, Detail], Spec 3.1 / Fassung 4 + features: [ + ['Betrieb & Pflege Ihrer Website bzw. Web-Anwendung', 'Server in Deutschland, Updates, Sicherheit, tägliche Datensicherung, Überwachung'], + ['1 Domain inklusive', 'Registrierung auf Ihren Namen, DNS- und Zertifikatsverwaltung'], + ['beliebig viele Postfächer', 'zusammen 5 GB Speicher'], + ['1 Stunde Arbeitszeit pro Monat', 'nicht genutzte Zeit bleibt 12 Monate verfügbar'], + ], }, // Noch nicht bestellbar: Die Beschreibung geht erst mit den Vertragsbedingungen Version 4 an Stripe hosting: { @@ -50,6 +58,12 @@ const products = { priceNickname: 'Hosting-Abo monatlich', minTermMonths: 12, minutesPerMonth: 0, + features: [ + ['Bereitstellung Ihrer statischen Website', 'Server in Deutschland, Updates, Sicherheit, tägliche Datensicherung, Überwachung'], + ['1 Domain inklusive', 'Registrierung auf Ihren Namen, DNS- und Zertifikatsverwaltung'], + ['1 E-Mail-Postfach', 'mit Ihrer eigenen Domain, 5 GB Speicher'], + ['Änderungen nur nach Freigabe', '100 € pro Stunde'], + ], }, } diff --git a/server/services/products.js b/server/services/products.js index c4edf0d..735d101 100644 --- a/server/services/products.js +++ b/server/services/products.js @@ -23,6 +23,30 @@ export function getProduct(key) { return product } +/** + * Vertragsunterlagen zur zugestimmten Fassung: Version 4 (Suffix "-v4") zeigt die aktuellen + * Bedingungen samt PDF, jede aeltere Version (auch unbekannt/keine) das Archiv der Version 3. + */ +export function termsDocFor(termsVersion) { + if (typeof termsVersion === 'string' && termsVersion.endsWith('-v4')) { + return { url: '/abo-bedingungen.html', pdfFile: 'WEBklar-Abo-Vertragsunterlagen.pdf' } + } + return { url: '/vertragsunterlagen/v3/abo-bedingungen.html', pdfFile: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf' } +} + +/** Ansicht eines Produkts fuers Kundenportal (Vergleich, Bestellseite): Preis und Leistungen. */ +export function productView(key) { + const product = getProduct(key) + return { + key: product.key, + name: product.name, + amountCents: product.amountCents, + minTermMonths: product.minTermMonths, + minutesPerMonth: product.minutesPerMonth, + features: product.features, + } +} + /** lookup_key eines Stripe-Preises -> 'wartung' | 'hosting' | 'mailStorage' | null */ export function productKeyForLookupKey(lookupKey) { if (!lookupKey) return null diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index adbec07..1c11b75 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -8,7 +8,7 @@ import { updateDocument, } from './appwriteAdmin.js' import { lateStartAt } from './deadlines.js' -import { creditMinutesForInvoice, describeItems, priceIdsOfInvoice, productKeyForLookupKey } from './products.js' +import { creditMinutesForInvoice, describeItems, getProduct, priceIdsOfInvoice, productKeyForLookupKey } from './products.js' /** * Wartungs-Abo ueber Stripe Billing. @@ -50,40 +50,55 @@ export function isStripeConfigured() { return Boolean(config.stripe.secretKey) } -let cachedPriceId = null -/** Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. */ -export async function ensureAboPrice() { - if (cachedPriceId) return cachedPriceId +// Produkt-Metadaten "webklar" je Katalog-Schluessel (F09/Produces: ensurePrice) +const PRODUCT_METADATA = { wartung: 'wartungs-abo', hosting: 'hosting-abo', mailStorage: 'mail-speicher' } + +/** Katalogeintrag zum Schluessel: die beiden Abos aus products.js, der Zusatzspeicher aus config.mailStorage. */ +function catalogFor(key) { + return key === 'mailStorage' ? config.mailStorage : getProduct(key) +} + +const priceIdCache = new Map() +/** + * Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. key: 'wartung' | 'hosting' | + * 'mailStorage'. Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe + * nie uebertragen, ein neuer Preis bekaeme also ohnehin einen neuen lookup_key im Katalog. + */ +export async function ensurePrice(key) { + if (priceIdCache.has(key)) return priceIdCache.get(key) const stripe = getStripe() + const catalog = catalogFor(key) const { data } = await stripe.prices.list({ - lookup_keys: [config.stripe.priceLookupKey], + lookup_keys: [catalog.lookupKey], active: true, limit: 1, }) if (data[0]) { - cachedPriceId = data[0].id - return cachedPriceId + priceIdCache.set(key, data[0].id) + return data[0].id } const product = await stripe.products.create({ - name: config.stripe.productName, - description: config.stripe.productDescription, - statement_descriptor: config.stripe.statementDescriptor, - images: [config.stripe.productImage], - metadata: { webklar: 'wartungs-abo' }, + name: catalog.name, + ...(catalog.description ? { description: catalog.description } : {}), + ...(catalog.statementDescriptor ? { statement_descriptor: catalog.statementDescriptor } : {}), + metadata: { webklar: PRODUCT_METADATA[key] }, }) const price = await stripe.prices.create({ product: product.id, currency: 'eur', - unit_amount: config.stripe.priceAmountCents, + unit_amount: catalog.amountCents, recurring: { interval: 'month' }, tax_behavior: 'inclusive', - lookup_key: config.stripe.priceLookupKey, - nickname: 'Wartungs-Abo monatlich', + lookup_key: catalog.lookupKey, + nickname: catalog.priceNickname, }) - cachedPriceId = price.id - return cachedPriceId + priceIdCache.set(key, price.id) + return price.id } +// Beide Abos zusammen im Kundenportal, nicht mehr nur die Wartung (Hosting-Abo dazu) +export const PORTAL_HEADLINE = 'WEBklar – Abo verwalten' + let cachedPortalConfigId = null /** Kundenportal-Konfiguration (Zahlungsmittel, Rechnungen, Kuendigung zum Periodenende). */ export async function ensurePortalConfiguration() { @@ -93,11 +108,20 @@ export async function ensurePortalConfiguration() { const own = data.find((c) => c.metadata?.webklar === 'wartungs-abo') if (own) { cachedPortalConfigId = own.id + // Aeltere Konfiguration (frueher nur Wartung) traegt noch die alte Ueberschrift -> anpassen + if (own.business_profile?.headline !== PORTAL_HEADLINE) { + await stripe.billingPortal.configurations.update(own.id, { + business_profile: { + headline: PORTAL_HEADLINE, + terms_of_service_url: `${config.publicBaseUrl}/abo-bedingungen.html`, + }, + }) + } return own.id } const created = await stripe.billingPortal.configurations.create({ business_profile: { - headline: 'Webklar – Wartungs-Abo verwalten', + headline: PORTAL_HEADLINE, terms_of_service_url: `${config.publicBaseUrl}/abo-bedingungen.html`, }, default_return_url: `${config.publicBaseUrl}/dashboard.html`, @@ -197,6 +221,9 @@ export async function syncSubscription(subscriptionId) { stripeSubscriptionId: sub.id, status: sub.status, productKey: items.productKey || 'wartung', + // Plan 2: Wechseltag (Beginn der neuen Mindestlaufzeit) und bezahlter Speicherstand + termStartAt: termStartOf(sub).toISOString(), + storageUnits: items.storageUnits, planKey: items.lookupKey || config.products.wartung.lookupKey, priceAmount: items.priceAmount ?? sub.items?.data?.[0]?.price?.unit_amount ?? config.products.wartung.amountCents, startedAt: toIso(sub.start_date), @@ -265,7 +292,14 @@ export function minTermMonthsFor(termsVersion) { return ['2026-09-25', '2026-09-25-v2'].includes(termsVersion) ? 0 : config.abo.minTermMonths } -export function serviceStartOf(sub) { +/** + * Beginn der aktuellen Mindestlaufzeit: der Wechseltag aus den Metadaten (nach einem Wechsel ins + * Wartungs-Abo), sonst der Leistungsbeginn (Ende der Probezeit bzw. Abo-Start). Ersetzt + * serviceStartOf in cancelSubscriptionAt. + */ +export function termStartOf(sub) { + const termStartAt = sub.metadata?.termStartAt + if (termStartAt) return new Date(termStartAt) return new Date(((sub.trial_end || sub.start_date) || 0) * 1000) } @@ -371,18 +405,28 @@ async function getOrCreateStripeCustomer(customer) { return created.id } -/** Rabatt fuer Testkonten: 49,50 € auf jede Abrechnung -> es werden nur 0,50 € abgebucht. */ -async function ensureTestCoupon() { +// Testrabatt-IDs je Produkt: amount_off = amountCents - 50 -> es werden nur 0,50 € abgebucht +const TEST_COUPON_IDS = { wartung: config.abo.testCouponId, hosting: 'WEBKLAR-TEST-050-HOSTING' } + +/** Rabatt fuer Testkonten holen oder (einmalig) anlegen. productKey: 'wartung' | 'hosting'. */ +export async function ensureTestCoupon(productKey) { + const id = TEST_COUPON_IDS[productKey] + if (!id) { + const err = new Error(`Kein Testrabatt fuer ${productKey}`) + err.status = 400 + throw err + } const stripe = getStripe() - const existing = await stripe.coupons.retrieve(config.abo.testCouponId).catch(() => null) + const existing = await stripe.coupons.retrieve(id).catch(() => null) if (existing) return existing.id + const catalog = getProduct(productKey) const created = await stripe.coupons.create({ - id: config.abo.testCouponId, - name: 'Testzugang WEBklar (0,50 €)', - amount_off: config.stripe.priceAmountCents - 50, + id, + name: `Testzugang WEBklar (0,50 €) – ${catalog.name}`, + amount_off: catalog.amountCents - 50, currency: 'eur', duration: 'forever', - metadata: { webklar: 'wartungs-abo-test' }, + metadata: { webklar: `${productKey}-test` }, }) return created.id } @@ -404,13 +448,13 @@ export async function createAboCheckout(customer, consent, offer = null) { } const stripe = getStripe() const [priceId, stripeCustomerId] = await Promise.all([ - ensureAboPrice(), + ensurePrice('wartung'), getOrCreateStripeCustomer(customer), ]) const lateStart = consent.startChoice === 'nach_frist' const serviceStartAt = lateStart ? consent.serviceStartAt || lateStartAt() : null const isTestAccount = config.abo.testEmails.includes(String(customer.email || '').toLowerCase()) - const testCoupon = isTestAccount ? await ensureTestCoupon() : null + const testCoupon = isTestAccount ? await ensureTestCoupon('wartung') : null // Stripe-Metadaten: max. 500 Zeichen je Wert const meta = { webklarCustomerId: customer.$id, @@ -504,7 +548,7 @@ export async function cancelSubscriptionAt(subscriptionId, requestedDate = null, const stripe = getStripe() const sub = await stripe.subscriptions.retrieve(subscriptionId) const { effectiveAt, minTermEnd } = computeCancellationDate({ - serviceStart: serviceStartOf(sub), + serviceStart: termStartOf(sub), receivedAt, requestedDate, kind, diff --git a/test/products.test.js b/test/products.test.js index 92dc2e6..e511720 100644 --- a/test/products.test.js +++ b/test/products.test.js @@ -2,7 +2,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' const { config } = await import('../server/config.js') -const { getProduct, productKeyForLookupKey, describeItems, priceIdsOfInvoice, creditMinutesForInvoice } = +const { getProduct, productKeyForLookupKey, describeItems, priceIdsOfInvoice, creditMinutesForInvoice, termsDocFor, productView } = await import('../server/services/products.js') const line = (price, amount, proration = false) => ({ @@ -101,3 +101,23 @@ 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']) }) + +test('Vertragsunterlagen je Version', () => { + assert.equal(termsDocFor('2026-10-05-v4').pdfFile, 'WEBklar-Abo-Vertragsunterlagen.pdf') + assert.equal(termsDocFor('2026-09-25-v3').url, '/vertragsunterlagen/v3/abo-bedingungen.html') + assert.equal(termsDocFor(null).url, '/vertragsunterlagen/v3/abo-bedingungen.html') +}) + +test('Produktansicht enthält Leistungen ohne 1-Postfach-Regel bei Wartung', () => { + const w = productView('wartung').features.flat().join(' ') + assert.ok(w.includes('beliebig viele Postfächer') && !w.includes('1 E-Mail-Postfach')) + assert.ok(productView('hosting').features.flat().join(' ').includes('1 E-Mail-Postfach')) +}) + +// F07: die Wartungs-Beschreibung darf die alte 1-Postfach-Regel nicht mehr enthalten (Version 4: +// beliebig viele Postfächer, zusammen 5 GB). Der Stripe-Produkttext selbst wird erst beim +// Livegang ueber scripts/stripe-products.mjs gesetzt. +test('Wartung-Beschreibung ohne 1-Postfach-Regel (F07)', () => { + assert.ok(!getProduct('wartung').description.includes('1 E-Mail-Postfach')) + assert.ok(getProduct('wartung').description.includes('beliebig viele Postfächer')) +}) diff --git a/test/stripeBilling.test.js b/test/stripeBilling.test.js index 26365f3..a04b229 100644 --- a/test/stripeBilling.test.js +++ b/test/stripeBilling.test.js @@ -9,7 +9,7 @@ const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscr const st = await startFakeStripe() after(() => Promise.all([aw.close(), st.close()])) useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) -const { syncSubscription } = await import('../server/services/stripeBilling.js') +const { syncSubscription, ensurePrice, ensureTestCoupon, ensurePortalConfiguration } = await import('../server/services/stripeBilling.js') const sub = (id, items, extra = {}) => ({ id, @@ -60,3 +60,53 @@ test('Spiegel: unbekannter Preis gilt als Wartungs-Abo, Betrag vom ersten Posten const doc = await syncSubscription('sub_x') assert.deepEqual([doc.productKey, doc.planKey, doc.priceAmount], ['wartung', 'webklar_wartung_monatlich_5000', 4900]) }) + +// Stripe-Nachbau braucht dazu: POST /v1/products, POST /v1/prices, GET /v1/coupons/:id, POST /v1/coupons, +// GET/POST /v1/billing_portal/configurations und POST /v1/billing_portal/configurations/:id (F09) +test('ensurePrice legt Hosting einmal an', async () => { + const id1 = await ensurePrice('hosting'); const id2 = await ensurePrice('hosting') + assert.equal(id1, id2) + const created = st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/prices') + assert.equal(created.length, 1) + assert.equal(created[0].body['lookup_key'], 'webklar_hosting_monatlich_1500') + assert.equal(created[0].body['unit_amount'], '1500') +}) + +test('Testrabatt Hosting: 14,50 € Nachlass', async () => { + assert.equal(await ensureTestCoupon('hosting'), 'WEBKLAR-TEST-050-HOSTING') + assert.equal(st.calls.find((c) => c.path === '/v1/coupons' && c.method === 'POST').body['amount_off'], '1450') +}) + +// F09: eine bestehende eigene Konfiguration mit der alten Wartungs-Abo-Überschrift wird angepasst, +// nicht doppelt angelegt. +test('Kundenportal: alte Überschrift → configurations.update', async () => { + st.billingPortalConfigurations.set('bpc_old', { + id: 'bpc_old', + object: 'billing_portal.configuration', + active: true, + business_profile: { headline: 'Webklar – Wartungs-Abo verwalten' }, + metadata: { webklar: 'wartungs-abo' }, + }) + const id = await ensurePortalConfiguration() + assert.equal(id, 'bpc_old') + const updateCall = st.calls.find((c) => c.method === 'POST' && c.path === '/v1/billing_portal/configurations/bpc_old') + assert.ok(updateCall) + assert.equal(updateCall.body['business_profile[headline]'], 'WEBklar – Abo verwalten') +}) + +test('Spiegel: termStartAt aus Metadaten (Wechsel), sonst Leistungsbeginn; storageUnits', async () => { + st.subscriptions.set( + 'sub_s', + sub('sub_s', [item('w2', 'webklar_wartung_monatlich_5000', 5000), item('s2', 'webklar_mailspeicher_5gb_monatlich_100', 100, 3)], { + metadata: { + webklarCustomerId: 'c1', + email: 'k@example.invalid', + termsVersion: '2026-09-25-v3', + termStartAt: '2026-10-05T10:00:00.000Z', + }, + }) + ) + const doc = await syncSubscription('sub_s') + assert.equal(doc.termStartAt, '2026-10-05T10:00:00.000Z') + assert.equal(doc.storageUnits, 3) +}) diff --git a/test/support/fakeStripe.js b/test/support/fakeStripe.js index 26f6a9a..638491a 100644 --- a/test/support/fakeStripe.js +++ b/test/support/fakeStripe.js @@ -9,7 +9,12 @@ export async function startFakeStripe() { const subscriptions = new Map() const prices = new Map() const invoices = new Map() + const products = new Map() + const coupons = new Map() + const billingPortalConfigurations = new Map() const calls = [] + let seq = 0 + const nextId = (prefix) => `${prefix}_test_${(seq += 1)}` const flat = (params) => Object.fromEntries(params.entries()) const noSuch = (what, id) => [404, { error: { type: 'invalid_request_error', code: 'resource_missing', message: `No such ${what}: '${id}'` } }] @@ -22,7 +27,26 @@ export async function startFakeStripe() { req.on('end', () => resolve(new URLSearchParams(raw))) }) - const route = (method, path, query) => { + // Stripe serialisiert verschachtelte Parameter (z. B. recurring[interval]=month) als flache + // Formularschluessel. Fuer die Routen selbst wieder zu einem verschachtelten Objekt zusammenbauen. + const unflatten = (params) => { + const root = {} + for (const [rawKey, value] of params.entries()) { + const path = rawKey.match(/[^[\]]+/g) || [rawKey] + let node = root + path.forEach((key, i) => { + if (i === path.length - 1) { + node[key] = value + } else { + if (!(key in node)) node[key] = /^\d+$/.test(path[i + 1]) ? [] : {} + node = node[key] + } + }) + } + return root + } + + const route = (method, path, query, body) => { let m if (method === 'GET' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) { return subscriptions.has(m[1]) ? [200, subscriptions.get(m[1])] : noSuch('subscription', m[1]) @@ -38,9 +62,94 @@ export async function startFakeStripe() { ) return [200, listOf(found)] } + if (method === 'POST' && path === '/v1/prices') { + const data = unflatten(body) + const obj = { + id: nextId('price'), + object: 'price', + active: true, + product: data.product, + currency: data.currency || 'eur', + unit_amount: data.unit_amount != null ? Number(data.unit_amount) : null, + recurring: data.recurring ? { interval: data.recurring.interval } : null, + tax_behavior: data.tax_behavior || null, + lookup_key: data.lookup_key || null, + nickname: data.nickname || null, + } + prices.set(obj.id, obj) + return [200, obj] + } if (method === 'GET' && (m = path.match(/^\/v1\/invoices\/([^/]+)$/))) { return invoices.has(m[1]) ? [200, invoices.get(m[1])] : noSuch('invoice', m[1]) } + if (method === 'POST' && path === '/v1/products') { + const data = unflatten(body) + const obj = { + id: nextId('prod'), + object: 'product', + active: true, + name: data.name || '', + description: data.description ?? null, + statement_descriptor: data.statement_descriptor ?? null, + metadata: data.metadata || {}, + } + products.set(obj.id, obj) + return [200, obj] + } + if (method === 'GET' && (m = path.match(/^\/v1\/coupons\/([^/]+)$/))) { + return coupons.has(m[1]) ? [200, coupons.get(m[1])] : noSuch('coupon', m[1]) + } + if (method === 'POST' && path === '/v1/coupons') { + const data = unflatten(body) + const id = data.id || nextId('coupon') + const obj = { + id, + object: 'coupon', + valid: true, + name: data.name || null, + amount_off: data.amount_off != null ? Number(data.amount_off) : null, + currency: data.currency || null, + duration: data.duration || 'once', + metadata: data.metadata || {}, + } + coupons.set(id, obj) + return [200, obj] + } + if (method === 'GET' && path === '/v1/billing_portal/configurations') { + const active = query.get('active') + const found = [...billingPortalConfigurations.values()].filter( + (c) => active === null || String(c.active !== false) === active + ) + return [200, listOf(found)] + } + if (method === 'POST' && path === '/v1/billing_portal/configurations') { + const data = unflatten(body) + const obj = { + id: nextId('bpc'), + object: 'billing_portal.configuration', + active: true, + business_profile: data.business_profile || {}, + default_return_url: data.default_return_url || null, + features: data.features || {}, + metadata: data.metadata || {}, + } + billingPortalConfigurations.set(obj.id, obj) + return [200, obj] + } + if (method === 'POST' && (m = path.match(/^\/v1\/billing_portal\/configurations\/([^/]+)$/))) { + const existing = billingPortalConfigurations.get(m[1]) + if (!existing) return noSuch('configuration', m[1]) + const data = unflatten(body) + const updated = { + ...existing, + ...data, + business_profile: { ...existing.business_profile, ...(data.business_profile || {}) }, + features: { ...existing.features, ...(data.features || {}) }, + metadata: { ...existing.metadata, ...(data.metadata || {}) }, + } + billingPortalConfigurations.set(m[1], updated) + return [200, updated] + } return [404, { error: { type: 'invalid_request_error', message: `No such route: ${method} ${path}` } }] } @@ -48,7 +157,7 @@ export async function startFakeStripe() { const url = new URL(req.url, 'http://127.0.0.1') const body = await readBody(req) calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body) }) - const [status, payload] = route(req.method, url.pathname, url.searchParams) + const [status, payload] = route(req.method, url.pathname, url.searchParams, body) res.writeHead(status, { 'Content-Type': 'application/json', 'Request-Id': `req_test_${calls.length}` }) res.end(JSON.stringify(payload)) }) @@ -60,6 +169,9 @@ export async function startFakeStripe() { subscriptions, prices, invoices, + products, + coupons, + billingPortalConfigurations, close: () => new Promise((resolve) => server.close(resolve)), } }