Shop D1: Connect-Dienst (Konto, Onboarding, Checkout, Erstattung, Webhook-Prüfung) und Fake-Stripe für Connect

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 08:44:10 +00:00
1 parent 03a7f1d877
commit da43ee2b41
3 files changed
+556 -3

No files matched your search

+155 -3
View File
@@ -32,6 +32,16 @@ import http from 'node:http'
* Kunden: GET /v1/customers/search (Metadaten), POST /v1/customers (anlegen) und POST /v1/customers/:id (aendern:
* email, name, address, metadata; unbekannte ID -> 404). Wie Stripe fuehrt ein Update die Anschrift feldweise zusammen
* (nur gesendete Felder aendern sich) und ein leerer Text loescht das Feld (gespeichert als null).
*
* Connect (Webshop D1): die Kopfzeile Stripe-Account steht als account in calls (nur bei Aufrufen mit Konto). POST /v1/accounts
* (v1 mit controller; Konto startet unfertig: charges_enabled false, requirements.currently_due ['external_account'];
* gleicher Idempotency-Key liefert dasselbe Konto), GET /v1/accounts/:id (unbekannt -> 404), POST /v1/account_links
* (Konto muss existieren). st.setAccount(id, patch) legt ein Konto an oder aendert es (requirements wird feldweise gemischt).
* Aufrufe mit Stripe-Account arbeiten auf der Ablage dieses Kontos (st.accountSessions(id), st.refunds(id)); ohne Kopfzeile
* bleibt alles wie bisher in st.checkoutSessions. Dort: POST /v1/checkout/sessions, GET .../:id, POST .../:id/expire (nur open),
* POST /v1/refunds (amount fehlt = Rest der Zahlung; Zahlungen legt st.setPaymentIntent(acct, id, { amount }) an; Summe der
* Erstattungen darf die Zahlung nicht uebersteigen) und GET /v1/refunds/:id. Idempotency-Key wiederholt die erste Antwort
* (je Konto und Schluessel) fuer Sitzungen und Erstattungen.
*/
// Parameter, die Stripe bei ausstehenden Aktualisierungen annimmt (docs.stripe.com/billing/subscriptions/
@@ -54,6 +64,16 @@ export async function startFakeStripe() {
// Rechnungsposten ohne Rechnung (pending invoice items), je ID
const invoiceItems = new Map()
const checkoutSessions = new Map()
// Connect: Konten, deren Sitzungen, Zahlungen und Erstattungen (je Konto-ID), Antworten je Idempotency-Key
const accounts = new Map()
const accountSessionMaps = new Map()
const paymentIntentMaps = new Map()
const refundMaps = new Map()
const idempotentReplies = new Map()
const bucket = (maps, accountId) => {
if (!maps.has(accountId)) maps.set(accountId, new Map())
return maps.get(accountId)
}
// Abo-Aenderungen, die Stripe bis zur Zahlung zurueckhaelt (pending_update), je Abo-ID
const pendingUpdates = new Map()
const calls = []
@@ -166,8 +186,112 @@ export async function startFakeStripe() {
return merged
}
const route = (method, path, query, body) => {
const route = (method, path, query, body, account = null) => {
let m
// ---- Connect ----
if (method === 'POST' && path === '/v1/accounts') {
const data = unflatten(body)
const id = `acct_test${(seq += 1)}`
const obj = {
id,
object: 'account',
type: 'standard',
country: data.country || 'DE',
email: data.email ?? null,
business_profile: data.business_profile || {},
controller: { type: 'application', is_controller: false, ...(data.controller || {}) },
capabilities: Object.fromEntries(Object.keys(data.capabilities || {}).map((k) => [k, 'inactive'])),
metadata: data.metadata || {},
charges_enabled: false,
payouts_enabled: false,
details_submitted: false,
requirements: { currently_due: ['external_account'], eventually_due: ['external_account'], past_due: [], errors: [], disabled_reason: null },
}
accounts.set(id, obj)
return [200, obj]
}
if (method === 'GET' && (m = path.match(/^\/v1\/accounts\/([^/]+)$/))) {
return accounts.has(m[1]) ? [200, accounts.get(m[1])] : noSuch('account', m[1])
}
if (method === 'POST' && path === '/v1/account_links') {
const data = unflatten(body)
if (!accounts.has(data.account)) return noSuch('account', data.account)
if (!data.type) return invalid('type', 'Missing required param: type.')
return [200, {
object: 'account_link',
created: nowUnix(),
expires_at: nowUnix() + 300,
url: `https://connect.stripe.test/setup/${data.account}/${(seq += 1)}`,
}]
}
if (account) {
const sessions = bucket(accountSessionMaps, account)
if (method === 'POST' && path === '/v1/checkout/sessions') {
const data = unflatten(body)
const id = nextId('cs')
const obj = {
id,
object: 'checkout.session',
url: `https://checkout.stripe.test/c/pay/${id}`,
mode: data.mode || 'payment',
client_reference_id: data.client_reference_id ?? null,
success_url: data.success_url ?? null,
cancel_url: data.cancel_url ?? null,
expires_at: data.expires_at ? Number(data.expires_at) : nowUnix() + 86400,
metadata: data.metadata || {},
status: 'open',
payment_status: 'unpaid',
payment_intent: null,
amount_total: null,
currency: 'eur',
customer_details: null,
}
sessions.set(id, obj)
return [200, obj]
}
if (method === 'GET' && (m = path.match(/^\/v1\/checkout\/sessions\/([^/]+)$/))) {
return sessions.has(m[1]) ? [200, sessions.get(m[1])] : noSuch('checkout.session', m[1])
}
if (method === 'POST' && (m = path.match(/^\/v1\/checkout\/sessions\/([^/]+)\/expire$/))) {
const found = sessions.get(m[1])
if (!found) return noSuch('checkout.session', m[1])
if (found.status !== 'open') return invalid(undefined, 'Only Checkout Sessions with a status in open can be expired.')
const expired = { ...found, status: 'expired' }
sessions.set(found.id, expired)
return [200, expired]
}
if (method === 'POST' && path === '/v1/refunds') {
const data = unflatten(body)
const intents = bucket(paymentIntentMaps, account)
const refunds = bucket(refundMaps, account)
const intent = intents.get(data.payment_intent)
if (!data.payment_intent) return invalid('payment_intent', 'Either payment_intent or charge must be provided.')
if (!intent && !data.amount) return noSuch('payment_intent', data.payment_intent)
const already = [...refunds.values()].filter((r) => r.payment_intent === data.payment_intent).reduce((sum, r) => sum + r.amount, 0)
const rest = intent ? intent.amount - already : null
const amount = data.amount ? Number(data.amount) : rest
if (!Number.isInteger(amount) || amount <= 0) return invalid('amount', 'Invalid positive integer')
if (rest !== null && amount > rest) return invalid('amount', `Refund amount (${amount}) is greater than unrefunded amount on payment (${rest}).`)
const obj = {
id: nextId('re'),
object: 'refund',
amount,
currency: 'eur',
payment_intent: data.payment_intent,
reason: data.reason ?? null,
metadata: data.metadata || {},
status: 'succeeded',
created: nowUnix(),
}
refunds.set(obj.id, obj)
return [200, obj]
}
if (method === 'GET' && (m = path.match(/^\/v1\/refunds\/([^/]+)$/))) {
const found = bucket(refundMaps, account).get(m[1])
return found ? [200, found] : noSuch('refund', m[1])
}
}
// ---- Plattform ----
if (method === 'GET' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) {
return subscriptions.has(m[1]) ? [200, subscriptions.get(m[1])] : noSuch('subscription', m[1])
}
@@ -626,7 +750,9 @@ export async function startFakeStripe() {
const body = await readBody(req)
// idempotencyKey (Kopfzeile Idempotency-Key) steht nur an Aufrufen, die einen mitschicken
const idempotencyKey = req.headers['idempotency-key']
calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body), ...(idempotencyKey ? { idempotencyKey } : {}) })
// Connect: Kopfzeile Stripe-Account (Aufruf im Namen eines Haendlerkontos)
const account = req.headers['stripe-account']
calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body), ...(idempotencyKey ? { idempotencyKey } : {}), ...(account ? { account } : {}) })
const delay = plannedDelay(req.method, url.pathname)
if (delay) await new Promise((resolve) => setTimeout(resolve, delay))
const failure = plannedFailure(req.method, url.pathname)
@@ -640,7 +766,10 @@ export async function startFakeStripe() {
res.writeHead(200, { 'Content-Type': 'application/pdf' })
return res.end(`%PDF-1.4\n% Stripe-Nachbau ${pdfMatch[1]}\n%%EOF\n`)
}
const [status, payload] = failure || route(req.method, url.pathname, url.searchParams, body)
// Wiederholung mit gleichem Idempotency-Key (nur Connect-Aufrufe und Kontoanlage): erste Antwort, kein neuer Zustand
const replyKey = !failure && req.method === 'POST' && idempotencyKey && (account || url.pathname === '/v1/accounts') ? `${account || ''}|${idempotencyKey}` : null
const [status, payload] = failure || (replyKey && idempotentReplies.get(replyKey)) || route(req.method, url.pathname, url.searchParams, body, account || null)
if (replyKey && status === 200 && !idempotentReplies.has(replyKey)) idempotentReplies.set(replyKey, [status, payload])
res.writeHead(status, { 'Content-Type': 'application/json', 'Request-Id': `req_test_${calls.length}` })
res.end(JSON.stringify(payload))
})
@@ -659,6 +788,29 @@ export async function startFakeStripe() {
payouts,
invoiceItems,
checkoutSessions,
accounts,
/** Sitzungen eines Haendlerkontos (Map id -> Sitzung); Tests setzen status/payment_status/payment_intent direkt darin. */
accountSessions: (accountId) => bucket(accountSessionMaps, accountId),
/** Erstattungen eines Haendlerkontos (Map id -> Erstattung). */
refunds: (accountId) => bucket(refundMaps, accountId),
/** Konto anlegen oder aendern (z. B. { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } }). */
setAccount(id, patch = {}) {
const base = accounts.get(id) || {
id, object: 'account', type: 'standard', country: 'DE', email: null, business_profile: {}, metadata: {},
controller: { type: 'application', is_controller: false }, capabilities: {},
charges_enabled: false, payouts_enabled: false, details_submitted: false,
requirements: { currently_due: ['external_account'], eventually_due: [], past_due: [], errors: [], disabled_reason: null },
}
const next = { ...base, ...patch, requirements: { ...base.requirements, ...(patch.requirements || {}) } }
accounts.set(id, next)
return next
},
/** Zahlung eines Haendlerkontos (Betrag in Cent) fuer Erstattungen: setPaymentIntent('acct_x', 'pi_x', { amount: 2500 }). */
setPaymentIntent(accountId, id, { amount, currency = 'eur' }) {
const obj = { id, object: 'payment_intent', amount, currency }
bucket(paymentIntentMaps, accountId).set(id, obj)
return obj
},
/**
* Einmalig in die naechste Antwort von POST /v1/subscriptions/:id gemischt (z. B. latest_invoice). Mit
* pending_update bleibt das Abo wie bei Stripe unveraendert (und traegt pending_update), bis