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:
1 parent
03a7f1d877
commit
da43ee2b41
3 files changed
+556
-3
No files matched your search
+155
-3
@@ -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
|
||||
|
||||
Reference in new issue
Block a user