import http from 'node:http' /** * Stripe-Nachbau für Tests. stripe-node spricht ihn über config.stripe.apiBase (STRIPE_API_BASE) * an. Objekte werden vorab in die Maps gelegt; jeder Aufruf landet in calls als * { method, path, query, body } (Formular-Schlüssel flach, z. B. 'items[0][price]'). */ 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}'` } }] const listOf = (data) => ({ object: 'list', data, has_more: false, url: '/v1/list' }) const readBody = (req) => new Promise((resolve) => { let raw = '' req.on('data', (chunk) => (raw += chunk)) req.on('end', () => resolve(new URLSearchParams(raw))) }) // 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]) } if (method === 'GET' && (m = path.match(/^\/v1\/prices\/([^/]+)$/))) { return prices.has(m[1]) ? [200, prices.get(m[1])] : noSuch('price', m[1]) } if (method === 'GET' && path === '/v1/prices') { const keys = [...query.entries()].filter(([k]) => /^lookup_keys\[\d*\]$/.test(k)).map(([, v]) => v) const active = query.get('active') const found = [...prices.values()].filter( (p) => (!keys.length || keys.includes(p.lookup_key)) && (active === null || String(p.active !== false) === active) ) 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}` } }] } const server = http.createServer(async (req, res) => { 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, body) res.writeHead(status, { 'Content-Type': 'application/json', 'Request-Id': `req_test_${calls.length}` }) res.end(JSON.stringify(payload)) }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) return { url: `http://127.0.0.1:${server.address().port}`, calls, subscriptions, prices, invoices, products, coupons, billingPortalConfigurations, close: () => new Promise((resolve) => server.close(resolve)), } }