Stripe emulator
A local Stripe API server that keeps state, with Connect support, so marketplace tests can run offline. MIT licence.
I run a Connect marketplace (VOID) and wanted its payment tests to run on my laptop without a network. Point the official stripe SDK at this server and you can run onboarding, destination charges, direct charges, transfers, refunds, disputes and payouts end to end, with signed webhooks delivered to localhost.
Why
- Stripe's official stripe-mock is stateless. Its README says POST data is validated and then ignored, so a charge you create never shows up in a balance and a refund cannot reverse a transfer.
- The community stripe-stateful-mock mocks API version 2020-08-27, and its README lists Connect support as creating and deleting accounts.
- Testing transfers and payouts against Stripe's test mode needs a connected account that is fully onboarded with verified bank details, and failure cases (a restricted account, a failed payout, a dispute) take manual steps.
The emulator keeps state in memory, moves money between platform and connected-account balances, and has test-control endpoints to put accounts, payouts and disputes in the state a test needs. It targets API version 2026-09-30.endive, the version pinned by stripe 23.0.0, and does not change behaviour by Stripe-Version.
Quick start
npm install
npx stripe-emulator --port 12111 --webhook http://localhost:3000/webhooksIt prints the platform account id and the webhook signing secret. Pass --webhook-secret whsec_... to choose the secret, and --data state.json to keep state between runs. Needs Node 22.18 or later.
Then point the SDK at it:
import Stripe from 'stripe'
const stripe = new Stripe('sk_test_123', {
host: 'localhost',
port: 12111,
protocol: 'http',
})Any sk_test_ or rk_test_ key works. Live keys are refused.
Or start it inside a test:
import { startEmulator } from './kits/stripe-emulator/src/index.ts'
const emulator = await startEmulator({
port: 0, // random free port
webhooks: [{ url: 'http://localhost:3000/webhooks', secret: 'whsec_test' }],
})
const stripe = new Stripe('sk_test_123', { host: 'localhost', port: emulator.port, protocol: 'http' })
// ...
await emulator.waitForWebhooks() // every delivery and retry has finished
await emulator.close()examples/marketplace.ts runs a full flow with a webhook receiver: node examples/marketplace.ts.
A marketplace test
const seller = await stripe.accounts.create({
country: 'IE',
controller: {
stripe_dashboard: { type: 'none' },
requirement_collection: 'application',
fees: { payer: 'application' },
losses: { payments: 'application' },
},
capabilities: { card_payments: { requested: true }, transfers: { requested: true } },
})
await fetch(`${emulator.url}/_emulator/accounts/${seller.id}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state: 'active' }),
})
const pi = await stripe.paymentIntents.create({
amount: 10000,
currency: 'eur',
application_fee_amount: 1000,
transfer_data: { destination: seller.id },
payment_method: 'pm_card_visa',
confirm: true,
})
const sellerBalance = await stripe.balance.retrieve({}, { stripeAccount: seller.id })
// pending: 9000. Platform: +1000 fee, minus the 175 processing fee.What it speaks
- Form-encoded bodies with nested keys (
metadata[foo],expand[],items[0][price]). Stripe-Accountfor requests on a connected account.Idempotency-Keyon POST: the first response is saved, including card errors, and replayed withIdempotent-Replayed: true. Reusing a key with different parameters returns anidempotency_error. Requests that fail validation are not saved. Keys are scoped per account.- Errors as
{ error: { type, code, message, param } }with Stripe's status codes, so the SDK raisesStripeInvalidRequestError,StripeCardErrorand so on. - Lists with
limit(1 to 100, default 10),starting_after,ending_beforeandhas_more, newest first. SDK auto-pagination works. expand[]for any field holding an id the emulator knows, including nested paths and lists.- Ids with Stripe's prefixes:
acct_,pi_,ch_,tr_,po_,re_,du_,evt_and the rest.
Supported endpoints
- Accounts.
POST /v1/accounts, GET /v1/accounts, GET/POST/DELETE /v1/accounts/:id, GET /v1/account - Account links.
POST /v1/account_links - Login links.
POST /v1/accounts/:id/login_links - PaymentIntents.
POST /v1/payment_intents, GET /v1/payment_intents, GET/POST /v1/payment_intents/:id, POST .../confirm, .../capture, .../cancel - Charges.
GET /v1/charges, GET/POST /v1/charges/:id - Application fees.
GET /v1/application_fees, GET /v1/application_fees/:id, POST/GET /v1/application_fees/:id/refunds - Transfers.
POST/GET /v1/transfers, GET/POST /v1/transfers/:id, POST/GET /v1/transfers/:id/reversals - Payouts.
POST/GET /v1/payouts, GET /v1/payouts/:id - Refunds.
POST/GET /v1/refunds, GET/POST /v1/refunds/:id - Disputes.
GET /v1/disputes, GET/POST /v1/disputes/:id, POST /v1/disputes/:id/close - Balance.
GET /v1/balance, GET /v1/balance_transactions, GET /v1/balance_transactions/:id - Events.
GET /v1/events, GET /v1/events/:id - Webhook endpoints.
POST/GET /v1/webhook_endpoints, GET/POST/DELETE /v1/webhook_endpoints/:id (with connect)
Accounts can be made with type (express, custom, standard) or with controller properties.
Charges, fees and who pays
The processing fee defaults to Stripe's published rate for Ireland, standard EEA cards: 1.5% + 0.25 per charge, and a 20.00 dispute fee (stripe.com/ie/pricing, checked 2026-10-10). Change it with startEmulator({ fees: { percent, fixed, disputeFee } }). The same numbers apply to every currency.
- Destination charge.
transfer_data[destination]on the platform. The platform pays the processing fee. - Direct charge.
Stripe-Accountheader. The connected account pays the processing fee, unlesscontroller.fees.payerisapplication. - Separate charge and transfer. Platform charge, then
POST /v1/transfers. The platform pays.
Refunds come out of the account that owns the charge. reverse_transfer and refund_application_fee reverse a proportional share. The processing fee is not returned. Disputes debit the amount and the dispute fee from the account that owns the charge.
Test payment methods:
pm_card_visa(or any other value): succeedspm_card_visa_chargeDeclined:card_declined,generic_decline(HTTP 402)pm_card_bypassPending: succeeds, funds go to the available balancepm_card_createDispute: succeeds, then afraudulentdispute opens
Webhooks
Register endpoints with --webhook, startEmulator({ webhooks }) or POST /v1/webhook_endpoints. --webhook registers one Connect endpoint and one platform endpoint at the same URL with the same secret, like stripe listen with --forward-connect-to. Each delivery carries a Stripe-Signature header, so stripe.webhooks.constructEvent verifies it. Failed deliveries are retried after 0.5, 2 and 5 seconds.
Test-control endpoints
These are not part of Stripe's API. They take JSON or form bodies. Routes that name an object act as the account that owns it.
POST /_emulator/accounts/:id/state { state, reason? }: Move an account to requirements_due, pending_verification, active, restricted or rejected (reason defaults to rejected.fraud). Emits account.updated.POST /_emulator/balance/release: Move pending funds to available for the platform, or the account in Stripe-Account or account. Emits balance.available.POST /_emulator/payouts/:id/paid: Mark a payout paid. Emits payout.updated and payout.paid.POST /_emulator/payouts/:id/fail { failure_code? }: Fail a payout (default account_closed) and return the money. Emits payout.updated and payout.failed.POST /_emulator/charges/:id/dispute { reason?, amount? }: Open a dispute. Emits charge.dispute.created and charge.dispute.funds_withdrawn.POST /_emulator/disputes/:id/resolve { outcome }: Close a dispute as won or lost.GET /_emulator/webhooks/deliveries: Every delivery with its attempts and status.POST /_emulator/webhooks/deliveries/:id/replay: Send a delivery again.POST /_emulator/reset: Clear all state. Keeps webhook endpoints.
Account links point at /_emulator/hosted/account_links/:id, which redirects to return_url on the first visit and refresh_url after that or once the link is 300 seconds old.
What it does not do
- Only the endpoints above. Customers, PaymentMethods, SetupIntents, Checkout, subscriptions, invoices, Accounts v2 and every other resource return 404. Unknown parameters are ignored rather than rejected.
- One API version. Responses do not change with
Stripe-Version. - No real card logic: payment methods are names, nothing is tokenised, there is no 3D Secure and no
requires_action. - One currency per charge. No currency conversion, no
on_behalf_ofcountry pricing, and one fee rate for every currency and card type. Rounding is to the nearest unit. - The balance model is the emulator's own. Credits land in pending, debits come out of available, and balances can go negative. Pending funds only become available through
/_emulator/balance/release. Stripe would set a refund topending, or refuse a reversal, when funds are short; the emulator does not. - Some balance transaction shapes are my choices, not documented Stripe behaviour.
- Accounts do not move through onboarding on their own. Updating an account does not clear requirements; use the state endpoint.
- Payouts are manual only, with no schedule and no payout fees. Connect fees are not charged.
- Events are snapshot events only. Webhook retries use short local delays, not Stripe's schedule.
--datasaves objects, events and webhook secrets, not idempotency keys or the delivery log.- No auth beyond checking the key looks like a test key. Any test key sees the same data.
Tests
npm install
npm test # vitest, the real stripe SDK against the emulator
npm run typecheckFull source
Every file in the emulator, ready to copy. The tests, package.json and tsconfig live next to them in the repo. Amounts are always in the smallest currency unit.
src/index.tsShowHide
export { startEmulator } from './server.ts'
export type { EmulatorOptions, RunningEmulator, WebhookTarget } from './server.ts'
export { API_VERSION } from './context.ts'
export { DEFAULT_FEES, processingFee } from './fees.ts'
export type { FeeConfig } from './fees.ts'
export { signatureHeader } from './webhooks.ts'
export type { OnboardingState } from './resources/accounts.ts'src/cli.tsShowHide
#!/usr/bin/env node
import { parseArgs } from 'node:util'
import { API_VERSION } from './context.ts'
import { startEmulator } from './server.ts'
const USAGE = `Usage: stripe-emulator [options]
--port <n> Port to listen on (default 12111)
--webhook <url> Send events to this URL, platform and Connect events.
Repeat for more than one URL.
--webhook-secret <s> Signing secret for --webhook (default: generated)
--data <file> Load and save state in this JSON file
--country <cc> Platform account country (default IE)
--help Show this help`
const { values } = parseArgs({
options: {
port: { type: 'string', default: '12111' },
webhook: { type: 'string', multiple: true, default: [] },
'webhook-secret': { type: 'string' },
data: { type: 'string' },
country: { type: 'string', default: 'IE' },
help: { type: 'boolean', default: false },
},
})
if (values.help) {
console.log(USAGE)
process.exit(0)
}
const emulator = await startEmulator({
port: Number(values.port),
platformCountry: values.country,
dataFile: values.data,
webhooks: values.webhook.map((url) => ({ url, secret: values['webhook-secret'] })),
})
console.log(`stripe-emulator listening on ${emulator.url} (API version ${API_VERSION})`)
console.log(`platform account: ${emulator.platformAccountId}`)
for (const ep of emulator.webhookEndpoints) {
console.log(`webhook ${ep.connect ? 'connect ' : 'platform'} ${ep.url} secret ${ep.secret}`)
}
console.log(`
new Stripe('sk_test_123', { host: 'localhost', port: ${emulator.port}, protocol: 'http' })`)
const stop = () => emulator.close().then(() => process.exit(0))
process.on('SIGINT', stop)
process.on('SIGTERM', stop)src/server.tsShowHide
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import type { AddressInfo } from 'node:net'
import { API_VERSION } from './context.ts'
import type { Ctx, Emulator } from './context.ts'
import { accountLinkRedirect, CONTROL_ROUTES } from './control.ts'
import { ApiError } from './errors.ts'
import { expand } from './expand.ts'
import { DEFAULT_FEES } from './fees.ts'
import type { FeeConfig } from './fees.ts'
import { parseForm } from './form.ts'
import type { Params } from './form.ts'
import { newId } from './ids.ts'
import { list } from './params.ts'
import { platformAccount } from './resources/accounts.ts'
import { addEndpoint } from './resources/webhook-endpoints.ts'
import { match, ROUTES } from './routes.ts'
import { Store } from './store.ts'
import { WebhookSender } from './webhooks.ts'
export type WebhookTarget = {
url: string
/** Signing secret (whsec_...). Generated when left out. */
secret?: string
/**
* false: the platform's own events. true: events from connected accounts.
* 'both' (default) registers one endpoint of each kind with the same secret,
* like running `stripe listen --forward-to` and `--forward-connect-to`.
*/
connect?: boolean | 'both'
/** Event types to send. Default ['*']. */
enabledEvents?: Array<string>
}
export type EmulatorOptions = {
/** Default 12111. Pass 0 for a random free port. */
port?: number
/** Interface to listen on. Default: all interfaces, so 'localhost' works over IPv4 and IPv6. */
host?: string
fees?: Partial<FeeConfig>
/** Country of the platform account. Default 'IE'. */
platformCountry?: string
/** Load state from this JSON file on start and save to it after every write. */
dataFile?: string
webhooks?: Array<WebhookTarget>
/** Delays before each retry of a failed delivery. Default [500, 2000, 5000]. */
webhookRetryDelaysMs?: Array<number>
}
export type RunningEmulator = {
url: string
port: number
platformAccountId: string
webhookEndpoints: Array<{ id: string; url: string; secret: string; connect: boolean }>
/** Resolves when every queued webhook delivery and retry has finished. */
waitForWebhooks: () => Promise<void>
close: () => Promise<void>
}
export async function startEmulator(options: EmulatorOptions = {}): Promise<RunningEmulator> {
const platformCountry = (options.platformCountry ?? 'IE').toUpperCase()
const fees: FeeConfig = { ...DEFAULT_FEES, ...options.fees }
const store = new Store(newId('acct', 16))
const loaded = options.dataFile ? store.load(options.dataFile) : false
if (!loaded) store.put(platformAccount(store.platformId, platformCountry), store.platformId)
const emu: Emulator = {
store,
fees,
webhooks: new WebhookSender(store, options.webhookRetryDelaysMs ?? [500, 2000, 5000]),
baseUrl: '',
platformCountry,
}
const server = createServer((req, res) => {
handle(emu, options.dataFile, req, res).catch((err) => {
console.error('[stripe-emulator]', err)
if (!res.headersSent) send(res, 500, { error: { type: 'api_error', message: 'Emulator error' } })
})
})
await new Promise<void>((resolve) => server.listen(options.port ?? 12111, options.host, resolve))
const port = (server.address() as AddressInfo).port
emu.baseUrl = `http://localhost:${port}`
const webhookEndpoints: RunningEmulator['webhookEndpoints'] = []
if (!loaded) {
for (const target of options.webhooks ?? []) {
const secret = target.secret ?? newId('whsec', 32)
const kinds = target.connect === undefined || target.connect === 'both' ? [false, true] : [target.connect]
for (const connect of kinds) {
const ep = addEndpoint(emu, { url: target.url, enabledEvents: target.enabledEvents ?? ['*'], connect, secret })
webhookEndpoints.push({ id: ep.id, url: ep.url, secret, connect })
}
}
} else {
for (const ep of store.list('webhook_endpoint', store.platformId)) {
webhookEndpoints.push({ id: ep.id, url: ep.url, secret: store.secrets[ep.id], connect: ep.connect })
}
}
return {
url: emu.baseUrl,
port,
platformAccountId: store.platformId,
webhookEndpoints,
waitForWebhooks: () => emu.webhooks.idle(),
close: () => closeServer(server, emu),
}
}
function closeServer(server: Server, emu: Emulator): Promise<void> {
emu.webhooks.stop()
server.closeAllConnections()
return new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())))
}
type Idempotent = { fingerprint: string; status: number; body: string }
const idempotency = new WeakMap<Emulator, Map<string, Idempotent>>()
async function handle(emu: Emulator, dataFile: string | undefined, req: IncomingMessage, res: ServerResponse) {
const requestId = newId('req', 14)
res.setHeader('Request-Id', requestId)
const method = req.method ?? 'GET'
const url = new URL(req.url ?? '/', 'http://localhost')
const raw = (await readBody(req)).toString('utf8')
const params = readParams(method, url, raw, String(req.headers['content-type'] ?? ''))
const header = (name: string) => {
const v = req.headers[name]
return Array.isArray(v) ? v[0] : v
}
try {
// Hosted pages for account links and login links.
const hosted = url.pathname.match(/^\/_emulator\/hosted\/(account_links|login_links)\/([^/]+)$/)
if (method === 'GET' && hosted) {
const ctx = makeCtx(emu, params, emu.store.platformId, [], requestId, null)
if (hosted[1] === 'account_links') {
res.writeHead(302, { Location: accountLinkRedirect(ctx, hosted[2]) })
return void res.end()
}
res.writeHead(200, { 'Content-Type': 'text/plain' })
return void res.end(`stripe-emulator: Express dashboard for ${hosted[2]}`)
}
if (url.pathname.startsWith('/_emulator/')) {
if (method === 'POST' && url.pathname === '/_emulator/reset') {
resetState(emu)
save(emu, dataFile)
return send(res, 200, { reset: true, platform_account: emu.store.platformId })
}
const found = match(CONTROL_ROUTES, method, url.pathname)
if (!found) throw unknownUrl(method, url.pathname)
const owner =
(found.path[0] && emu.store.ownerOf(found.path[0])) ||
header('stripe-account') ||
(typeof params.account === 'string' ? params.account : emu.store.platformId)
const result = found.handler(makeCtx(emu, params, owner, found.path, requestId, null))
if (method !== 'GET') save(emu, dataFile)
return send(res, 200, result)
}
const key = apiKey(header('authorization'))
const stripeAccount = header('stripe-account')
const owner = stripeAccount ?? emu.store.platformId
if (stripeAccount) {
const acct = emu.store.find(stripeAccount)
if (!acct || acct.obj.object !== 'account' || stripeAccount === emu.store.platformId) {
throw new ApiError(403, {
type: 'invalid_request_error',
code: 'account_invalid',
message: `The provided key '${redact(key)}' does not have access to account '${stripeAccount}' (or that account does not exist).`,
})
}
}
const found = match(ROUTES, method, url.pathname)
if (!found) throw unknownUrl(method, url.pathname)
// Idempotency: https://docs.stripe.com/api/idempotent_requests. POST only.
// Results are saved once the endpoint runs, including errors, except
// requests that fail validation (invalid_request_error).
const idemKey = method === 'POST' ? (header('idempotency-key') ?? null) : null
const cache = idempotency.get(emu) ?? new Map<string, Idempotent>()
idempotency.set(emu, cache)
const cacheKey = idemKey ? `${owner}|${idemKey}` : null
const fingerprint = `${method} ${url.pathname} ${JSON.stringify(params)}`
if (cacheKey) {
const hit = cache.get(cacheKey)
if (hit) {
if (hit.fingerprint !== fingerprint) {
throw new ApiError(400, {
type: 'idempotency_error',
message: `Keys for idempotent requests can only be used with the same parameters they were first used with. Try using a key other than '${idemKey}' if you meant to execute a different request.`,
})
}
res.setHeader('Idempotency-Key', idemKey!)
res.setHeader('Idempotent-Replayed', 'true')
return sendRaw(res, hit.status, hit.body)
}
}
const ctx = makeCtx(emu, params, owner, found.path, requestId, idemKey)
let status = 200
let body: string
try {
const result = found.handler(ctx)
body = JSON.stringify(expand(structuredClone(result), list(params, 'expand'), emu.store), null, 2)
} catch (err) {
if (!(err instanceof ApiError)) throw err
status = err.status
body = JSON.stringify({ error: { ...err.body, request_log_url: null } }, null, 2)
if (err.body.type === 'invalid_request_error') {
if (method !== 'GET') save(emu, dataFile)
return sendRaw(res, status, body)
}
}
if (cacheKey) {
cache.set(cacheKey, { fingerprint, status, body })
res.setHeader('Idempotency-Key', idemKey!)
}
if (method !== 'GET') save(emu, dataFile)
return sendRaw(res, status, body)
} catch (err) {
if (err instanceof ApiError) return send(res, err.status, { error: err.body })
throw err
}
}
function makeCtx(emu: Emulator, params: Params, owner: string, path: Array<string>, requestId: string, idempotencyKey: string | null): Ctx {
return { emu, params, owner, isPlatform: owner === emu.store.platformId, path, requestId, idempotencyKey }
}
function readParams(method: string, url: URL, raw: string, contentType: string): Params {
const query = parseForm(url.search.slice(1))
if (method === 'GET' || method === 'DELETE') return query
if (contentType.includes('application/json') && raw) {
// Control endpoints also take JSON. Values are turned into strings so the
// same param readers work.
return { ...query, ...stringify(JSON.parse(raw)) }
}
return { ...query, ...parseForm(raw) }
}
function stringify(v: unknown): any {
if (v === null || v === undefined) return ''
if (Array.isArray(v)) return v.map(stringify)
if (typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, stringify(x)]))
return String(v)
}
/** Accepts `Bearer sk_test_...` or HTTP Basic with the key as the user name. */
function apiKey(authorization: string | undefined): string {
let key = ''
if (authorization?.startsWith('Bearer ')) key = authorization.slice(7).trim()
else if (authorization?.startsWith('Basic ')) {
key = Buffer.from(authorization.slice(6), 'base64').toString('utf8').split(':')[0]
}
if (!key) {
throw new ApiError(401, {
type: 'invalid_request_error',
message: 'You did not provide an API key. Use any test key, e.g. sk_test_123, as a Bearer token.',
})
}
if (!/^(sk|rk)_test_/.test(key)) {
throw new ApiError(401, {
type: 'invalid_request_error',
message: `Invalid API Key provided: ${redact(key)}. The emulator only accepts test keys (sk_test_ or rk_test_).`,
})
}
return key
}
function redact(key: string): string {
return key.length > 12 ? `${key.slice(0, 8)}${'*'.repeat(4)}${key.slice(-4)}` : key
}
function unknownUrl(method: string, path: string): ApiError {
return new ApiError(404, {
type: 'invalid_request_error',
message: `Unrecognized request URL (${method}: ${path}). The emulator may not support this endpoint yet.`,
})
}
function resetState(emu: Emulator) {
const fresh = new Store(newId('acct', 16))
fresh.put(platformAccount(fresh.platformId, emu.platformCountry), fresh.platformId)
// Keep registered webhook endpoints and their secrets across a reset.
for (const ep of emu.store.list('webhook_endpoint', emu.store.platformId).reverse()) {
fresh.put(ep, fresh.platformId)
fresh.secrets[ep.id] = emu.store.secrets[ep.id]
}
Object.assign(emu.store, { platformId: fresh.platformId, seq: fresh.seq, objects: fresh.objects, secrets: fresh.secrets, linkState: {} })
emu.webhooks.deliveries = []
idempotency.delete(emu)
}
function save(emu: Emulator, dataFile: string | undefined) {
if (dataFile) emu.store.save(dataFile)
}
function readBody(req: IncomingMessage): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Array<Buffer> = []
req.on('data', (c: Buffer) => chunks.push(c))
req.on('end', () => resolve(Buffer.concat(chunks)))
req.on('error', reject)
})
}
function send(res: ServerResponse, status: number, body: unknown) {
sendRaw(res, status, JSON.stringify(body, null, 2))
}
function sendRaw(res: ServerResponse, status: number, body: string) {
res.writeHead(status, { 'Content-Type': 'application/json', 'Stripe-Version': API_VERSION })
res.end(body)
}src/routes.tsShowHide
import type { Ctx } from './context.ts'
import type { Obj } from './store.ts'
import * as accounts from './resources/accounts.ts'
import * as fees from './resources/application-fees.ts'
import * as balance from './resources/balance.ts'
import * as charges from './resources/charges.ts'
import * as disputes from './resources/disputes.ts'
import * as events from './resources/events.ts'
import * as intents from './resources/payment-intents.ts'
import * as payouts from './resources/payouts.ts'
import * as refunds from './resources/refunds.ts'
import * as transfers from './resources/transfers.ts'
import * as webhooks from './resources/webhook-endpoints.ts'
type Handler = (ctx: Ctx) => Obj
type Route = [method: string, pattern: string, handler: Handler]
/** Stripe API routes. `:id` segments are passed to handlers as ctx.path. */
export const ROUTES: Array<Route> = [
['GET', '/v1/account', accounts.retrieveSelf],
['POST', '/v1/accounts', accounts.createAccount],
['GET', '/v1/accounts', accounts.listAccounts],
['GET', '/v1/accounts/:id', accounts.retrieveAccount],
['POST', '/v1/accounts/:id', accounts.updateAccount],
['DELETE', '/v1/accounts/:id', accounts.deleteAccount],
['POST', '/v1/accounts/:id/login_links', accounts.createLoginLink],
['POST', '/v1/account_links', accounts.createAccountLink],
['POST', '/v1/payment_intents', intents.createPaymentIntent],
['GET', '/v1/payment_intents', intents.listPaymentIntents],
['GET', '/v1/payment_intents/:id', intents.retrievePaymentIntent],
['POST', '/v1/payment_intents/:id', intents.updatePaymentIntent],
['POST', '/v1/payment_intents/:id/confirm', intents.confirmPaymentIntent],
['POST', '/v1/payment_intents/:id/capture', intents.capturePaymentIntent],
['POST', '/v1/payment_intents/:id/cancel', intents.cancelPaymentIntent],
['GET', '/v1/charges', charges.listCharges],
['GET', '/v1/charges/:id', charges.retrieveCharge],
['POST', '/v1/charges/:id', charges.updateCharge],
['GET', '/v1/application_fees', fees.listApplicationFees],
['GET', '/v1/application_fees/:id', fees.retrieveApplicationFee],
['POST', '/v1/application_fees/:id/refunds', fees.createApplicationFeeRefund],
['GET', '/v1/application_fees/:id/refunds', fees.listApplicationFeeRefunds],
['POST', '/v1/transfers', transfers.createTransfer],
['GET', '/v1/transfers', transfers.listTransfers],
['GET', '/v1/transfers/:id', transfers.retrieveTransfer],
['POST', '/v1/transfers/:id', transfers.updateTransfer],
['POST', '/v1/transfers/:id/reversals', transfers.createTransferReversal],
['GET', '/v1/transfers/:id/reversals', transfers.listTransferReversals],
['POST', '/v1/payouts', payouts.createPayout],
['GET', '/v1/payouts', payouts.listPayouts],
['GET', '/v1/payouts/:id', payouts.retrievePayout],
['POST', '/v1/refunds', refunds.createRefund],
['GET', '/v1/refunds', refunds.listRefunds],
['GET', '/v1/refunds/:id', refunds.retrieveRefund],
['POST', '/v1/refunds/:id', refunds.updateRefund],
['GET', '/v1/disputes', disputes.listDisputes],
['GET', '/v1/disputes/:id', disputes.retrieveDispute],
['POST', '/v1/disputes/:id', disputes.updateDispute],
['POST', '/v1/disputes/:id/close', disputes.closeDispute],
['GET', '/v1/balance', balance.retrieveBalance],
['GET', '/v1/balance_transactions', balance.listBalanceTransactions],
['GET', '/v1/balance_transactions/:id', balance.retrieveBalanceTransaction],
['GET', '/v1/events', events.listEvents],
['GET', '/v1/events/:id', events.retrieveEvent],
['POST', '/v1/webhook_endpoints', webhooks.createWebhookEndpoint],
['GET', '/v1/webhook_endpoints', webhooks.listWebhookEndpoints],
['GET', '/v1/webhook_endpoints/:id', webhooks.retrieveWebhookEndpoint],
['POST', '/v1/webhook_endpoints/:id', webhooks.updateWebhookEndpoint],
['DELETE', '/v1/webhook_endpoints/:id', webhooks.deleteWebhookEndpoint],
]
/** Find the route for a request and the values of its `:id` segments. */
export function match(
routes: Array<Route>,
method: string,
pathname: string,
): { handler: Handler; path: Array<string> } | null {
const parts = pathname.replace(/\/+$/, '').split('/')
for (const [m, pattern, handler] of routes) {
if (m !== method) continue
const segs = pattern.split('/')
if (segs.length !== parts.length) continue
const path: Array<string> = []
let ok = true
for (let i = 0; i < segs.length; i++) {
if (segs[i].startsWith(':')) path.push(decodeURIComponent(parts[i]))
else if (segs[i] !== parts[i]) {
ok = false
break
}
}
if (ok) return { handler, path }
}
return null
}src/context.tsShowHide
import { ApiError } from './errors.ts'
import type { FeeConfig } from './fees.ts'
import type { Params } from './form.ts'
import { newId, now } from './ids.ts'
import type { Obj, Store } from './store.ts'
import type { WebhookSender } from './webhooks.ts'
// Pinned to the API version of the stripe npm package this kit is tested with
// (stripe@23.0.0, src/apiVersion.ts: '2026-09-30.endive').
export const API_VERSION = '2026-09-30.endive'
export type Emulator = {
store: Store
fees: FeeConfig
webhooks: WebhookSender
/** Base URL of this server, used for account link and login link URLs. */
baseUrl: string
platformCountry: string
}
/** Everything a route handler needs for one request. */
export type Ctx = {
emu: Emulator
params: Params
/** acct_ id the request acts as: the platform, or the Stripe-Account header. */
owner: string
isPlatform: boolean
path: Array<string>
requestId: string
idempotencyKey: string | null
}
/**
* Store an event and queue it for webhook delivery. Events for objects owned
* by a connected account carry `account`, like Connect events from Stripe.
*/
export function emit(
ctx: Pick<Ctx, 'emu' | 'requestId' | 'idempotencyKey'> | { emu: Emulator },
type: string,
object: Obj,
owner: string,
previousAttributes?: Obj,
): Obj {
const { store, webhooks } = ctx.emu
const event: Obj = {
id: newId('evt'),
object: 'event',
...(owner !== store.platformId ? { account: owner } : {}),
api_version: API_VERSION,
created: now(),
data: {
object: structuredClone(object),
...(previousAttributes ? { previous_attributes: previousAttributes } : {}),
},
livemode: false,
pending_webhooks: 0,
request:
'requestId' in ctx
? { id: ctx.requestId, idempotency_key: ctx.idempotencyKey }
: { id: null, idempotency_key: null },
type,
}
store.put(event, owner)
webhooks.enqueue(event)
return event
}
/** A connected account (not the platform), or a 404 on `param`. */
export function connectedAccount(emu: Emulator, id: string, param = 'id'): Obj {
return emu.store.get('account', id, emu.store.platformId, param)
}
export function requirePlatform(ctx: Ctx) {
if (!ctx.isPlatform) {
throw new ApiError(400, {
type: 'invalid_request_error',
message: 'This request must be made by the platform, without a Stripe-Account header.',
})
}
}
export function accountOf(emu: Emulator, owner: string): Obj {
return emu.store.find(owner)!.obj
}src/control.tsShowHide
import type { Ctx } from './context.ts'
import { invalid, notFound } from './errors.ts'
import type { Obj } from './store.ts'
import { setAccountState } from './resources/accounts.ts'
import type { OnboardingState } from './resources/accounts.ts'
import { releaseBalance } from './resources/balance.ts'
import { getCharge } from './resources/charges.ts'
import { openDispute, resolveDispute } from './resources/disputes.ts'
import { markPayoutFailed, markPayoutPaid } from './resources/payouts.ts'
import { int, str } from './params.ts'
type Handler = (ctx: Ctx) => Obj
/**
* Test-control endpoints under /_emulator. These do not exist on Stripe.
* Routes that name an object act as the account that owns it.
*/
export const CONTROL_ROUTES: Array<[string, string, Handler]> = [
['POST', '/_emulator/accounts/:id/state', (ctx) => {
const state = str(ctx.params, 'state')
if (!state) throw invalid('Missing required param: state.', 'state', 'parameter_missing')
return setAccountState(ctx.emu, ctx.path[0], state as OnboardingState, str(ctx.params, 'reason'))
}],
['POST', '/_emulator/balance/release', releaseBalance],
['POST', '/_emulator/payouts/:id/paid', markPayoutPaid],
['POST', '/_emulator/payouts/:id/fail', markPayoutFailed],
['POST', '/_emulator/charges/:id/dispute', (ctx) =>
openDispute(ctx, getCharge(ctx, ctx.path[0]), { reason: str(ctx.params, 'reason'), amount: int(ctx.params, 'amount') })],
['POST', '/_emulator/disputes/:id/resolve', (ctx) => resolveDispute(ctx, str(ctx.params, 'outcome') ?? '')],
['GET', '/_emulator/webhooks/deliveries', (ctx) => {
const event = str(ctx.params, 'event')
const data = ctx.emu.webhooks.deliveries.filter((d) => !event || d.event === event)
return { object: 'list', data: [...data].reverse(), has_more: false, url: '/_emulator/webhooks/deliveries' }
}],
['POST', '/_emulator/webhooks/deliveries/:id/replay', (ctx) => {
const delivery = ctx.emu.webhooks.replay(ctx.path[0])
if (!delivery) throw notFound('delivery', ctx.path[0])
return delivery
}],
]
/** A visited account link redirects; a second visit goes to refresh_url. */
export function accountLinkRedirect(ctx: Ctx, linkId: string): string {
const link = ctx.emu.store.find(linkId)?.obj
if (!link || link.object !== 'emulator_link') throw notFound('account_link', linkId)
const used = ctx.emu.store.linkState[linkId]
ctx.emu.store.linkState[linkId] = true
const expired = Math.floor(Date.now() / 1000) > link.expires_at
return used || expired ? link.refresh_url : link.return_url
}src/errors.tsShowHide
export type StripeErrorBody = {
type: 'api_error' | 'card_error' | 'idempotency_error' | 'invalid_request_error'
message: string
code?: string
param?: string
decline_code?: string
charge?: string
payment_intent?: unknown
doc_url?: string
}
/**
* An error returned in Stripe's shape: { error: { type, message, code, param } }.
* See https://docs.stripe.com/api/errors for the fields and status codes.
*/
export class ApiError extends Error {
status: number
body: StripeErrorBody
constructor(status: number, body: StripeErrorBody) {
super(body.message)
this.status = status
this.body = body
}
}
export function invalid(message: string, param?: string, code?: string): ApiError {
return new ApiError(400, {
type: 'invalid_request_error',
message,
...(param ? { param } : {}),
...(code ? { code } : {}),
})
}
export function missing(name: string): ApiError {
return invalid(`Missing required param: ${name}.`, name, 'parameter_missing')
}
export function notFound(objectName: string, id: string, param = 'id'): ApiError {
return new ApiError(404, {
type: 'invalid_request_error',
message: `No such ${objectName}: '${id}'`,
param,
code: 'resource_missing',
})
}src/expand.tsShowHide
import type { Store } from './store.ts'
/**
* Replace id strings with the objects they point to, for each dotted path in
* `expand[]` (e.g. 'latest_charge.balance_transaction', or 'data.charge' on a
* list). Only ids stored in the emulator can be expanded; unknown fields are
* left alone.
*/
export function expand(value: any, paths: Array<string>, store: Store): any {
let out = value
for (const path of paths) out = expandPath(out, path.split('.'), store)
return out
}
function expandPath(value: any, segs: Array<string>, store: Store): any {
if (value === null || typeof value !== 'object' || segs.length === 0) return value
if (Array.isArray(value)) return value.map((v) => expandPath(v, segs, store))
const [head, ...rest] = segs
if (!(head in value)) return value
let field = value[head]
if (typeof field === 'string') {
const found = store.find(field)
if (found) field = structuredClone(found.obj)
}
return { ...value, [head]: expandPath(field, rest, store) }
}src/fees.tsShowHide
/**
* Fee settings. Amounts are in the smallest currency unit. The emulator
* applies the same numbers to every currency; it does not convert.
*/
export type FeeConfig = {
/** Processing fee percent, e.g. 1.5 for 1.5%. */
percent: number
/** Flat processing fee per charge. */
fixed: number
/** Fee debited when a dispute is opened. */
disputeFee: number
}
// Stripe's published standard rate for Ireland, standard EEA cards:
// "1.5% + €0.25", and the "Dispute received fee" of €20.00.
// Source: https://stripe.com/ie/pricing (checked 2026-10-10).
// The same rate is in src/lib/stripe-fees/rates.ts in the parent repo.
export const DEFAULT_FEES: FeeConfig = { percent: 1.5, fixed: 25, disputeFee: 2000 }
/** Processing fee for a charge, rounded to the nearest unit. */
export function processingFee(amount: number, fees: FeeConfig): number {
return Math.round((amount * fees.percent) / 100) + fees.fixed
}
/** Proportional share of `total` for a partial amount, rounded. */
export function proportional(total: number, part: number, whole: number): number {
if (whole === 0) return 0
return Math.round((total * part) / whole)
}src/form.tsShowHide
export type Params = { [key: string]: Param }
export type Param = string | Params | Array<Param>
/**
* Parse an application/x-www-form-urlencoded body (or query string) the way
* Stripe reads it: `metadata[foo]=bar` becomes { metadata: { foo: 'bar' } },
* `expand[]=a` and `expand[0]=a` both become { expand: ['a'] }.
*/
export function parseForm(input: string): Params {
const root: Params = {}
if (!input) return root
for (const pair of input.split('&')) {
if (!pair) continue
const eq = pair.indexOf('=')
const rawKey = eq === -1 ? pair : pair.slice(0, eq)
const rawValue = eq === -1 ? '' : pair.slice(eq + 1)
const key = decode(rawKey)
const value = decode(rawValue)
const path = splitKey(key)
assign(root, path, value)
}
return root
}
function decode(s: string): string {
return decodeURIComponent(s.replace(/\+/g, ' '))
}
function splitKey(key: string): Array<string> {
const first = key.indexOf('[')
if (first === -1) return [key]
const path = [key.slice(0, first)]
const re = /\[([^\]]*)\]/g
let m: RegExpExecArray | null
while ((m = re.exec(key.slice(first)))) path.push(m[1])
return path
}
function assign(target: Params | Array<Param>, path: Array<string>, value: string) {
let node: any = target
for (let i = 0; i < path.length; i++) {
const seg = path[i]
const last = i === path.length - 1
const nextSeg = path[i + 1]
const nextIsArray = nextSeg === '' || (nextSeg !== undefined && /^\d+$/.test(nextSeg))
if (Array.isArray(node)) {
const index = seg === '' ? node.length : Number(seg)
if (last) node[index] = value
else node = node[index] ??= nextIsArray ? [] : {}
} else {
if (last) node[seg] = value
else {
if (typeof node[seg] !== 'object' || node[seg] === null) {
node[seg] = nextIsArray ? [] : {}
}
node = node[seg]
}
}
}
}src/ids.tsShowHide
import { randomBytes } from 'node:crypto'
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
/** Random id with a Stripe-style prefix, e.g. newId('pi') -> 'pi_3Kx...'. */
export function newId(prefix: string, length = 24): string {
const bytes = randomBytes(length)
let out = ''
for (let i = 0; i < length; i++) out += ALPHABET[bytes[i] % ALPHABET.length]
return `${prefix}_${out}`
}
/** Unix time in seconds, like every `created` field in the API. */
export function now(): number {
return Math.floor(Date.now() / 1000)
}src/ledger.tsShowHide
import { newId, now } from './ids.ts'
import type { Obj, Store } from './store.ts'
export type FeeDetail = { amount: number; type: 'stripe_fee' | 'application_fee'; description: string }
export type Bucket = 'pending' | 'available'
// type -> reporting_category, from https://docs.stripe.com/reports/reporting-categories
const CATEGORY: Record<string, string> = {
charge: 'charge',
payment: 'charge',
refund: 'refund',
payment_refund: 'refund',
transfer: 'transfer',
transfer_refund: 'transfer_reversal',
payout: 'payout',
payout_failure: 'payout_reversal',
application_fee: 'platform_earning',
application_fee_refund: 'platform_earning_refund',
stripe_fee: 'fee',
}
/**
* Record a balance transaction on `owner`. `amount` is the gross movement
* (negative for debits); fees are subtracted to give `net`.
*
* Emulator model, not Stripe's: credits land in the bucket you pass
* (usually pending), debits come out of available unless told otherwise,
* and balances may go negative. POST /_emulator/balance/release moves
* pending funds to available.
*/
export function addBalanceTransaction(
store: Store,
owner: string,
input: {
amount: number
currency: string
type: string
source: string | null
fees?: Array<FeeDetail>
bucket?: Bucket
description?: string | null
reportingCategory?: string
},
): Obj {
const fees = input.fees ?? []
const fee = fees.reduce((s, f) => s + f.amount, 0)
const created = now()
const bucket = input.bucket ?? (input.amount < 0 ? 'available' : 'pending')
const txn = {
id: newId('txn'),
object: 'balance_transaction',
amount: input.amount,
available_on: created,
balance_type: 'payments',
created,
currency: input.currency,
description: input.description ?? null,
exchange_rate: null,
fee,
fee_details: fees.map((f) => ({
amount: f.amount,
application: f.type === 'application_fee' ? 'ca_emulator' : null,
currency: input.currency,
description: f.description,
type: f.type,
})),
net: input.amount - fee,
reporting_category: input.reportingCategory ?? CATEGORY[input.type] ?? input.type,
source: input.source,
status: bucket,
type: input.type,
}
return store.put(txn, owner)
}
/** Sum of net amounts per currency, split into available and pending. */
export function balanceFor(store: Store, owner: string, defaultCurrency: string): Obj {
const sums: Record<string, Record<Bucket, number>> = {}
for (const t of store.list('balance_transaction', owner)) {
const c = (sums[t.currency] ??= { available: 0, pending: 0 })
c[t.status as Bucket] += t.net
}
if (Object.keys(sums).length === 0) sums[defaultCurrency] = { available: 0, pending: 0 }
const rows = (bucket: Bucket) =>
Object.entries(sums).map(([currency, s]) => ({
amount: s[bucket],
currency,
source_types: { card: s[bucket] },
}))
return { object: 'balance', available: rows('available'), livemode: false, pending: rows('pending') }
}
export function availableIn(store: Store, owner: string, currency: string): number {
return store
.list('balance_transaction', owner, (t) => t.currency === currency && t.status === 'available')
.reduce((s, t) => s + t.net, 0)
}
/** Move every pending balance transaction on `owner` to available. */
export function releasePending(store: Store, owner: string): number {
let moved = 0
for (const t of store.list('balance_transaction', owner, (t) => t.status === 'pending')) {
t.status = 'available'
t.available_on = now()
moved++
}
return moved
}src/list.tsShowHide
import { invalid } from './errors.ts'
import type { Params } from './form.ts'
import { int, str } from './params.ts'
import type { Obj } from './store.ts'
/**
* Cursor pagination as described in https://docs.stripe.com/api/pagination:
* newest first, `limit` 1..100 (default 10), `starting_after` / `ending_before`
* take an object id, and the response says whether `has_more` items exist.
*/
export function paginate(items: Array<Obj>, params: Params, url: string): Obj {
const limit = int(params, 'limit') ?? 10
if (limit < 1 || limit > 100) {
throw invalid('Invalid integer: limit must be between 1 and 100.', 'limit')
}
const after = str(params, 'starting_after')
const before = str(params, 'ending_before')
if (after && before) {
throw invalid('You may only specify one of these parameters: starting_after, ending_before.')
}
let data: Array<Obj>
let hasMore: boolean
if (after) {
const i = indexOf(items, after, 'starting_after')
const rest = items.slice(i + 1)
data = rest.slice(0, limit)
hasMore = rest.length > limit
} else if (before) {
const i = indexOf(items, before, 'ending_before')
const head = items.slice(0, i)
data = head.slice(Math.max(0, head.length - limit))
hasMore = head.length > limit
} else {
data = items.slice(0, limit)
hasMore = items.length > limit
}
return { object: 'list', data, has_more: hasMore, url }
}
function indexOf(items: Array<Obj>, id: string, param: string): number {
const i = items.findIndex((o) => o.id === id)
if (i === -1) throw invalid(`No such object: '${id}'`, param, 'resource_missing')
return i
}
/** An inline list, e.g. transfer.reversals or application_fee.refunds. */
export function inlineList(data: Array<Obj>, url: string): Obj {
return { object: 'list', data, has_more: false, total_count: data.length, url }
}src/params.tsShowHide
import { invalid, missing } from './errors.ts'
import type { Param, Params } from './form.ts'
/** Helpers that read form params, which always arrive as strings. */
export function str(p: Params, name: string): string | undefined {
const v = p[name]
if (v === undefined) return undefined
if (typeof v !== 'string') throw invalid(`Invalid string: ${name}`, name)
return v === '' ? undefined : v
}
export function reqStr(p: Params, name: string): string {
const v = str(p, name)
if (v === undefined) throw missing(name)
return v
}
export function int(p: Params, name: string): number | undefined {
const v = p[name]
if (v === undefined || v === '') return undefined
if (typeof v !== 'string' || !/^-?\d+$/.test(v)) {
throw invalid(`Invalid integer: ${String(v)}`, name, 'parameter_invalid_integer')
}
return Number(v)
}
export function reqInt(p: Params, name: string): number {
const v = int(p, name)
if (v === undefined) throw missing(name)
return v
}
export function bool(p: Params, name: string): boolean | undefined {
const v = p[name]
if (v === undefined || v === '') return undefined
if (v === 'true') return true
if (v === 'false') return false
throw invalid(`Invalid boolean: ${String(v)}`, name)
}
export function obj(p: Params, name: string): Params | undefined {
const v = p[name]
if (v === undefined || v === '') return undefined
if (typeof v !== 'object' || Array.isArray(v)) throw invalid(`Invalid hash`, name)
return v
}
export function list(p: Params, name: string): Array<string> {
const v = p[name]
if (v === undefined || v === '') return []
const arr: Array<Param> = Array.isArray(v)
? v
: typeof v === 'object'
? Object.values(v)
: [v]
return arr.filter((x): x is string => typeof x === 'string')
}
/**
* Apply a metadata param to existing metadata. Posting an empty value to a
* key unsets it; posting an empty value to `metadata` clears everything.
* https://docs.stripe.com/api/metadata
*/
export function mergeMetadata(
current: Record<string, string>,
p: Params,
): Record<string, string> {
const v = p.metadata
if (v === undefined) return current
if (v === '') return {}
if (typeof v !== 'object' || Array.isArray(v)) throw invalid('Invalid hash', 'metadata')
const next = { ...current }
for (const [k, val] of Object.entries(v)) {
if (val === '') delete next[k]
else if (typeof val === 'string') next[k] = val
}
return next
}
/** Recursively turn a parsed param hash into plain JSON for storing. */
export function plain(v: Param | undefined): unknown {
if (v === undefined) return undefined
if (typeof v === 'string') return v
if (Array.isArray(v)) return v.map(plain)
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, plain(x)]))
}src/store.tsShowHide
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { notFound } from './errors.ts'
export type Obj = Record<string, any>
type Stored = { obj: Obj; owner: string; seq: number }
type Snapshot = {
platformId: string
seq: number
objects: Array<Stored>
secrets: Record<string, string>
linkState: Record<string, boolean>
}
/**
* All emulator state, in memory. Every object has an owner: the platform
* account id, or a connected account id for objects created with the
* Stripe-Account header (direct charges, payouts on a connected account...).
*/
export class Store {
platformId: string
seq = 0
objects = new Map<string, Stored>()
/** Webhook endpoint signing secrets, by endpoint id. */
secrets: Record<string, string> = {}
/** Account link and login link ids that were already visited. */
linkState: Record<string, boolean> = {}
constructor(platformId: string) {
this.platformId = platformId
}
put(obj: Obj, owner: string): Obj {
const existing = this.objects.get(obj.id)
this.objects.set(obj.id, { obj, owner, seq: existing?.seq ?? ++this.seq })
return obj
}
find(id: string): Stored | undefined {
return this.objects.get(id)
}
/** Object `id` of type `object`, visible to `owner`, or a 404. */
get(object: string, id: string, owner: string, param = 'id'): Obj {
const s = this.objects.get(id)
if (!s || s.obj.object !== object || s.owner !== owner) {
throw notFound(object, id, param)
}
return s.obj
}
remove(id: string) {
this.objects.delete(id)
}
/** Newest first, the order Stripe's list endpoints use. */
list(object: string, owner: string | null, filter?: (o: Obj) => boolean): Array<Obj> {
const out: Array<Stored> = []
for (const s of this.objects.values()) {
if (s.obj.object !== object) continue
if (owner !== null && s.owner !== owner) continue
if (filter && !filter(s.obj)) continue
out.push(s)
}
return out.sort((a, b) => b.seq - a.seq).map((s) => s.obj)
}
ownerOf(id: string): string | undefined {
return this.objects.get(id)?.owner
}
save(file: string) {
const snap: Snapshot = {
platformId: this.platformId,
seq: this.seq,
objects: [...this.objects.values()],
secrets: this.secrets,
linkState: this.linkState,
}
writeFileSync(file, JSON.stringify(snap))
}
/** Load a snapshot written by save(). Returns false if the file is missing. */
load(file: string): boolean {
if (!existsSync(file)) return false
const snap = JSON.parse(readFileSync(file, 'utf8')) as Snapshot
this.platformId = snap.platformId
this.seq = snap.seq
this.objects = new Map(snap.objects.map((s) => [s.obj.id, s]))
this.secrets = snap.secrets
this.linkState = snap.linkState
return true
}
}src/webhooks.tsShowHide
import { createHmac } from 'node:crypto'
import { newId, now } from './ids.ts'
import type { Obj, Store } from './store.ts'
export type DeliveryAttempt = { at: number; status: number | null; error: string | null }
export type Delivery = {
id: string
event: string
event_type: string
endpoint: string
url: string
status: 'pending' | 'succeeded' | 'failed'
attempts: Array<DeliveryAttempt>
}
/**
* Stripe-Signature header: t=<unix seconds>,v1=<hex HMAC-SHA256 of
* "<t>.<body>" keyed with the endpoint secret>.
* Source: https://docs.stripe.com/webhooks (Verify webhook signatures manually).
*/
export function signatureHeader(body: string, secret: string, timestamp = now()): string {
const v1 = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex')
return `t=${timestamp},v1=${v1}`
}
/**
* Sends events to registered endpoints with retries. Events owned by the
* platform go to endpoints with connect=false; events owned by a connected
* account carry `account` and go to endpoints with connect=true.
*/
export class WebhookSender {
deliveries: Array<Delivery> = []
private inFlight = 0
private idleWaiters: Array<() => void> = []
private timers = new Set<NodeJS.Timeout>()
private store: Store
private retryDelaysMs: Array<number>
constructor(store: Store, retryDelaysMs: Array<number>) {
this.store = store
this.retryDelaysMs = retryDelaysMs
}
/** Queue deliveries for a stored event. Delivery starts after the current request. */
enqueue(event: Obj) {
const connect = typeof event.account === 'string'
const endpoints = this.store.list('webhook_endpoint', this.store.platformId, (e) =>
e.status === 'enabled' &&
Boolean(e.connect) === connect &&
(e.enabled_events.includes('*') || e.enabled_events.includes(event.type)),
)
event.pending_webhooks = endpoints.length
for (const endpoint of endpoints) {
const delivery: Delivery = {
id: newId('whdel'),
event: event.id,
event_type: event.type,
endpoint: endpoint.id,
url: endpoint.url,
status: 'pending',
attempts: [],
}
this.deliveries.push(delivery)
this.schedule(delivery, 0)
}
}
/** Send a delivery again now, regardless of its status. */
replay(id: string): Delivery | undefined {
const delivery = this.deliveries.find((d) => d.id === id)
if (!delivery) return undefined
delivery.status = 'pending'
this.schedule(delivery, 0, false)
return delivery
}
/** Resolves when no delivery is in flight or waiting for a retry. */
idle(): Promise<void> {
if (this.inFlight === 0) return Promise.resolve()
return new Promise((resolve) => this.idleWaiters.push(resolve))
}
stop() {
for (const t of this.timers) clearTimeout(t)
this.timers.clear()
this.inFlight = 0
this.flushIdle()
}
private schedule(delivery: Delivery, delayMs: number, retry = true) {
this.inFlight++
const timer = setTimeout(async () => {
this.timers.delete(timer)
const ok = await this.attempt(delivery)
const retriesUsed = delivery.attempts.length - 1
if (ok) {
delivery.status = 'succeeded'
} else if (retry && retriesUsed < this.retryDelaysMs.length) {
this.schedule(delivery, this.retryDelaysMs[retriesUsed])
} else {
delivery.status = 'failed'
}
this.inFlight--
if (this.inFlight === 0) this.flushIdle()
}, delayMs)
this.timers.add(timer)
}
private async attempt(delivery: Delivery): Promise<boolean> {
const event = this.store.find(delivery.event)?.obj
const endpoint = this.store.find(delivery.endpoint)?.obj
const secret = this.store.secrets[delivery.endpoint]
if (!event || !endpoint || !secret) {
delivery.attempts.push({ at: now(), status: null, error: 'endpoint or event removed' })
return false
}
const body = JSON.stringify(event, null, 2)
try {
const res = await fetch(endpoint.url, {
method: 'POST',
redirect: 'manual',
signal: AbortSignal.timeout(10_000),
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Stripe-Signature': signatureHeader(body, secret),
'User-Agent': 'stripe-emulator',
},
body,
})
await res.arrayBuffer().catch(() => undefined)
delivery.attempts.push({ at: now(), status: res.status, error: null })
// Redirects count as failures, like Stripe: https://docs.stripe.com/webhooks
return res.status >= 200 && res.status < 300
} catch (err) {
delivery.attempts.push({
at: now(),
status: null,
error: err instanceof Error ? err.message : String(err),
})
return false
}
}
private flushIdle() {
const waiters = this.idleWaiters
this.idleWaiters = []
for (const w of waiters) w()
}
}src/resources/accounts.tsShowHide
import { emit, requirePlatform } from '../context.ts'
import type { Ctx, Emulator } from '../context.ts'
import { invalid, notFound } from '../errors.ts'
import { newId, now } from '../ids.ts'
import { inlineList, paginate } from '../list.ts'
import { mergeMetadata, plain, reqStr, str } from '../params.ts'
import type { Obj } from '../store.ts'
/**
* Onboarding states the test-control endpoint can put an account in. The
* requirement codes are examples in Stripe's format; Stripe decides the real
* list per country and business type.
*/
export type OnboardingState =
| 'requirements_due'
| 'pending_verification'
| 'active'
| 'restricted'
| 'rejected'
// The codes in the example Account object at https://docs.stripe.com/api/accounts/object
const INITIAL_DUE = [
'business_profile.mcc',
'business_profile.url',
'business_type',
'external_account',
'representative.first_name',
'representative.last_name',
'tos_acceptance.date',
'tos_acceptance.ip',
]
const DOCUMENT = ['individual.verification.document']
const CURRENCY: Record<string, string> = { US: 'usd', GB: 'gbp', CA: 'cad', AU: 'aud', NZ: 'nzd', CH: 'chf' }
export const currencyFor = (country: string) => CURRENCY[country] ?? 'eur'
function requirements(r: Partial<Obj> = {}): Obj {
return {
alternatives: [],
current_deadline: null,
currently_due: [],
disabled_reason: null,
errors: [],
eventually_due: [],
past_due: [],
pending_verification: [],
...r,
}
}
/**
* Controller properties for the legacy `type` values. Express and Custom map
* to the fee payers `application_express` and `application_custom`
* (https://docs.stripe.com/connect/direct-charges-fee-payer-behavior).
*/
function controllerFor(type: string | undefined, params: Obj | undefined): { type: string; controller: Obj } {
const base = { is_controller: true, type: 'application' }
if (type === 'express') {
return {
type: 'express',
controller: {
...base,
fees: { payer: 'application_express' },
losses: { payments: 'application' },
requirement_collection: 'stripe',
stripe_dashboard: { type: 'express' },
},
}
}
if (type === 'custom') {
return {
type: 'custom',
controller: {
...base,
fees: { payer: 'application_custom' },
losses: { payments: 'application' },
requirement_collection: 'application',
stripe_dashboard: { type: 'none' },
},
}
}
if (type === 'standard') {
return {
type: 'standard',
controller: {
...base,
fees: { payer: 'account' },
losses: { payments: 'stripe' },
requirement_collection: 'stripe',
stripe_dashboard: { type: 'full' },
},
}
}
if (type !== undefined) throw invalid(`Invalid type: ${type}`, 'type')
if (!params) throw invalid('You must specify either `type` or `controller`.', 'type')
const dashboard = (params.stripe_dashboard as Obj | undefined)?.type ?? 'full'
return {
// Accounts made with controller properties report type "none".
type: 'none',
controller: {
...base,
fees: { payer: (params.fees as Obj | undefined)?.payer ?? (dashboard === 'full' ? 'account' : 'application') },
losses: { payments: (params.losses as Obj | undefined)?.payments ?? (dashboard === 'full' ? 'stripe' : 'application') },
requirement_collection: params.requirement_collection ?? (dashboard === 'none' ? 'application' : 'stripe'),
stripe_dashboard: { type: dashboard },
},
}
}
function requestedCapabilities(params: Obj | undefined, existing: Obj, status: string): Obj {
const caps = { ...existing }
for (const [name, v] of Object.entries(params ?? {})) {
const requested = (v as Obj)?.requested
if (requested === 'true' && !caps[name]) caps[name] = status
if (requested === 'false') delete caps[name]
}
return caps
}
export function platformAccount(id: string, country: string): Obj {
return {
id,
object: 'account',
business_profile: { name: 'Emulator platform' },
business_type: 'company',
capabilities: { card_payments: 'active', transfers: 'active' },
charges_enabled: true,
controller: { type: 'account' },
country,
created: now(),
default_currency: currencyFor(country),
details_submitted: true,
email: null,
metadata: {},
payouts_enabled: true,
requirements: requirements(),
future_requirements: requirements(),
settings: {},
tos_acceptance: { date: null, ip: null, user_agent: null },
type: 'standard',
}
}
export function getConnected(emu: Emulator, id: string, param = 'id'): Obj {
if (id === emu.store.platformId) throw notFound('account', id, param)
return emu.store.get('account', id, emu.store.platformId, param)
}
export function createAccount(ctx: Ctx): Obj {
requirePlatform(ctx)
const p = ctx.params
const country = (str(p, 'country') ?? ctx.emu.platformCountry).toUpperCase()
const { type, controller } = controllerFor(str(p, 'type'), plain(p.controller) as Obj | undefined)
const id = newId('acct', 16)
const account: Obj = {
id,
object: 'account',
business_profile: { mcc: null, name: null, product_description: null, support_email: null, url: null, ...(plain(p.business_profile) as Obj) },
business_type: str(p, 'business_type') ?? null,
capabilities: requestedCapabilities(plain(p.capabilities) as Obj, {}, 'inactive'),
charges_enabled: false,
controller,
country,
created: now(),
default_currency: str(p, 'default_currency') ?? currencyFor(country),
details_submitted: false,
email: str(p, 'email') ?? null,
external_accounts: inlineList([], `/v1/accounts/${id}/external_accounts`),
future_requirements: requirements(),
login_links: inlineList([], `/v1/accounts/${id}/login_links`),
metadata: mergeMetadata({}, p),
payouts_enabled: false,
requirements: requirements({
currently_due: INITIAL_DUE,
eventually_due: INITIAL_DUE,
past_due: INITIAL_DUE,
disabled_reason: 'requirements.past_due',
}),
settings: {
payouts: { debit_negative_balances: true, schedule: { delay_days: 2, interval: 'daily' }, statement_descriptor: null },
...(plain(p.settings) as Obj),
},
tos_acceptance: { date: null, ip: null, user_agent: null, ...(plain(p.tos_acceptance) as Obj) },
type,
}
ctx.emu.store.put(account, ctx.emu.store.platformId)
return account
}
export function retrieveAccount(ctx: Ctx): Obj {
const id = ctx.path[0]
if (!ctx.isPlatform && id !== ctx.owner) throw notFound('account', id)
if (id === ctx.emu.store.platformId) return ctx.emu.store.find(id)!.obj
return getConnected(ctx.emu, id)
}
/** GET /v1/account: the account the request acts as. */
export function retrieveSelf(ctx: Ctx): Obj {
return ctx.emu.store.find(ctx.owner)!.obj
}
const UPDATABLE = ['business_profile', 'settings', 'tos_acceptance', 'individual', 'company']
export function updateAccount(ctx: Ctx): Obj {
requirePlatform(ctx)
const account = getConnected(ctx.emu, ctx.path[0])
const p = ctx.params
const previous: Obj = {}
const set = (key: string, value: unknown) => {
previous[key] = structuredClone(account[key] ?? null)
account[key] = value
}
if (p.email !== undefined) set('email', str(p, 'email') ?? null)
if (p.business_type !== undefined) set('business_type', str(p, 'business_type') ?? null)
if (p.default_currency !== undefined) set('default_currency', str(p, 'default_currency'))
if (p.metadata !== undefined) set('metadata', mergeMetadata(account.metadata, p))
for (const key of UPDATABLE) {
if (p[key] !== undefined) set(key, { ...(account[key] ?? {}), ...(plain(p[key]) as Obj) })
}
if (p.capabilities !== undefined) {
const status = account.charges_enabled ? 'active' : 'inactive'
set('capabilities', requestedCapabilities(plain(p.capabilities) as Obj, account.capabilities, status))
}
emit(ctx, 'account.updated', account, account.id, previous)
return account
}
export function deleteAccount(ctx: Ctx): Obj {
requirePlatform(ctx)
const account = getConnected(ctx.emu, ctx.path[0])
ctx.emu.store.remove(account.id)
return { id: account.id, object: 'account', deleted: true }
}
export function listAccounts(ctx: Ctx): Obj {
requirePlatform(ctx)
const platformId = ctx.emu.store.platformId
const items = ctx.emu.store.list('account', platformId, (a) => a.id !== platformId)
return paginate(items, ctx.params, '/v1/accounts')
}
/**
* POST /v1/account_links. Single-use link that expires 300 seconds after it
* is created (the gap in the example response at
* https://docs.stripe.com/api/account_links/create). Visiting the emulator's
* URL redirects to return_url the first time and refresh_url after that.
*/
export function createAccountLink(ctx: Ctx): Obj {
requirePlatform(ctx)
const p = ctx.params
const account = getConnected(ctx.emu, reqStr(p, 'account'), 'account')
const type = reqStr(p, 'type')
if (type !== 'account_onboarding' && type !== 'account_update') throw invalid(`Invalid type: ${type}`, 'type')
if (type === 'account_update' && account.controller.stripe_dashboard?.type !== 'none') {
// https://docs.stripe.com/api/account_links/create: account_update is not
// available for accounts with access to a Stripe-hosted Dashboard.
throw invalid('You cannot create `account_update` type Account Links for accounts with access to a Stripe-hosted Dashboard.', 'type')
}
const returnUrl = reqStr(p, 'return_url')
const refreshUrl = reqStr(p, 'refresh_url')
const linkId = newId('link')
const created = now()
ctx.emu.store.put(
{ id: linkId, object: 'emulator_link', kind: 'account_link', account: account.id, return_url: returnUrl, refresh_url: refreshUrl, expires_at: created + 300 },
ctx.emu.store.platformId,
)
return {
object: 'account_link',
created,
expires_at: created + 300,
url: `${ctx.emu.baseUrl}/_emulator/hosted/account_links/${linkId}`,
}
}
/** POST /v1/accounts/:id/login_links. Express Dashboard accounts only. */
export function createLoginLink(ctx: Ctx): Obj {
requirePlatform(ctx)
const account = getConnected(ctx.emu, ctx.path[0])
if (account.controller.stripe_dashboard?.type !== 'express') {
throw invalid('Login links can only be created for accounts that use the Express Dashboard.', 'account')
}
return {
object: 'login_link',
created: now(),
url: `${ctx.emu.baseUrl}/_emulator/hosted/login_links/${account.id}`,
}
}
/**
* Test control: move a connected account to an onboarding state and emit
* account.updated. Field names follow the Account object; the exact codes
* Stripe would list depend on country and business type.
*/
export function setAccountState(emu: Emulator, id: string, state: OnboardingState, reason?: string): Obj {
const account = getConnected(emu, id)
const previous = {
capabilities: structuredClone(account.capabilities),
charges_enabled: account.charges_enabled,
details_submitted: account.details_submitted,
payouts_enabled: account.payouts_enabled,
requirements: structuredClone(account.requirements),
}
const caps = (status: string) =>
Object.fromEntries(Object.keys(account.capabilities).map((k) => [k, status]))
if (state === 'requirements_due') {
Object.assign(account, {
details_submitted: false,
charges_enabled: false,
payouts_enabled: false,
capabilities: caps('inactive'),
requirements: requirements({ currently_due: INITIAL_DUE, eventually_due: INITIAL_DUE, past_due: INITIAL_DUE, disabled_reason: 'requirements.past_due' }),
})
} else if (state === 'pending_verification') {
Object.assign(account, {
details_submitted: true,
charges_enabled: false,
payouts_enabled: false,
capabilities: caps('pending'),
requirements: requirements({ pending_verification: DOCUMENT, disabled_reason: 'requirements.pending_verification' }),
})
} else if (state === 'active') {
Object.assign(account, {
details_submitted: true,
charges_enabled: true,
payouts_enabled: true,
capabilities: caps('active'),
requirements: requirements(),
})
if (account.external_accounts.data.length === 0) {
account.external_accounts = inlineList([bankAccount(account)], `/v1/accounts/${account.id}/external_accounts`)
}
} else if (state === 'restricted') {
Object.assign(account, {
details_submitted: true,
charges_enabled: false,
payouts_enabled: false,
capabilities: caps('inactive'),
requirements: requirements({ currently_due: DOCUMENT, eventually_due: DOCUMENT, past_due: DOCUMENT, disabled_reason: 'requirements.past_due' }),
})
} else if (state === 'rejected') {
const r = reason ?? 'rejected.fraud'
if (!r.startsWith('rejected.')) throw invalid('reason must start with "rejected."', 'reason')
Object.assign(account, {
charges_enabled: false,
payouts_enabled: false,
capabilities: caps('inactive'),
requirements: requirements({ disabled_reason: r }),
})
} else {
throw invalid(`Unknown state: ${String(state)}. Use requirements_due, pending_verification, active, restricted or rejected.`, 'state')
}
emit({ emu }, 'account.updated', account, account.id, previous)
return account
}
function bankAccount(account: Obj): Obj {
return {
id: newId('ba'),
object: 'bank_account',
account: account.id,
account_holder_name: null,
account_holder_type: null,
bank_name: 'STRIPE TEST BANK',
country: account.country,
currency: account.default_currency,
default_for_currency: true,
last4: '6789',
metadata: {},
status: 'new',
}
}
export function capabilityActive(account: Obj, name: string): boolean {
return account.capabilities?.[name] === 'active'
}src/resources/application-fees.tsShowHide
import { emit, requirePlatform } from '../context.ts'
import type { Ctx } from '../context.ts'
import { invalid } from '../errors.ts'
import { newId, now } from '../ids.ts'
import { addBalanceTransaction } from '../ledger.ts'
import type { Bucket } from '../ledger.ts'
import { inlineList, paginate } from '../list.ts'
import { int, mergeMetadata, str } from '../params.ts'
import type { Obj } from '../store.ts'
/**
* Application fee collected by the platform from a connected account's
* payment. `charge` is the payment on the connected account; for destination
* charges `originatingTransaction` is the platform charge (see the
* `originating_transaction` field in the stripe package types).
*/
export function createApplicationFee(
ctx: Ctx,
input: { account: string; amount: number; charge: string; currency: string; originatingTransaction: string | null; bucket: Bucket },
): Obj {
const { store } = ctx.emu
const id = newId('fee')
const txn = addBalanceTransaction(store, store.platformId, {
amount: input.amount,
currency: input.currency,
type: 'application_fee',
source: id,
bucket: input.bucket,
})
const fee: Obj = {
id,
object: 'application_fee',
account: input.account,
amount: input.amount,
amount_refunded: 0,
application: 'ca_emulator',
balance_transaction: txn.id,
charge: input.charge,
created: now(),
currency: input.currency,
fee_source: { charge: input.charge, type: 'charge' },
livemode: false,
originating_transaction: input.originatingTransaction,
refunded: false,
refunds: inlineList([], `/v1/application_fees/${id}/refunds`),
}
store.put(fee, store.platformId)
emit(ctx, 'application_fee.created', fee, store.platformId)
return fee
}
/**
* Give part of an application fee back to the connected account. The
* platform side is an application_fee_refund balance transaction; the
* emulator books the connected side with the same type.
*/
export function createFeeRefund(ctx: Ctx, fee: Obj, amount: number, metadata: Record<string, string> = {}): Obj {
const { store } = ctx.emu
const remaining = fee.amount - fee.amount_refunded
if (amount <= 0 || amount > remaining) {
throw invalid(`Amount must be between 1 and the ${remaining} left on application fee ${fee.id}.`, 'amount')
}
const id = newId('fr')
const txn = addBalanceTransaction(store, store.platformId, {
amount: -amount,
currency: fee.currency,
type: 'application_fee_refund',
source: id,
})
addBalanceTransaction(store, fee.account, {
amount,
currency: fee.currency,
type: 'application_fee_refund',
source: id,
bucket: 'available',
})
const refund: Obj = {
id,
object: 'fee_refund',
amount,
balance_transaction: txn.id,
created: now(),
currency: fee.currency,
fee: fee.id,
metadata,
}
store.put(refund, store.platformId)
const previous = { amount_refunded: fee.amount_refunded, refunded: fee.refunded }
fee.amount_refunded += amount
fee.refunded = fee.amount_refunded >= fee.amount
fee.refunds = inlineList([refund, ...fee.refunds.data], `/v1/application_fees/${fee.id}/refunds`)
emit(ctx, 'application_fee.refunded', fee, store.platformId, previous)
return refund
}
export function retrieveApplicationFee(ctx: Ctx): Obj {
requirePlatform(ctx)
return ctx.emu.store.get('application_fee', ctx.path[0], ctx.owner)
}
export function listApplicationFees(ctx: Ctx): Obj {
requirePlatform(ctx)
const charge = str(ctx.params, 'charge')
const items = ctx.emu.store.list('application_fee', ctx.owner, (f) =>
!charge || f.charge === charge || f.originating_transaction === charge,
)
return paginate(items, ctx.params, '/v1/application_fees')
}
/** POST /v1/application_fees/:id/refunds. Amount defaults to what is left. */
export function createApplicationFeeRefund(ctx: Ctx): Obj {
const fee = retrieveApplicationFee(ctx)
const amount = int(ctx.params, 'amount') ?? fee.amount - fee.amount_refunded
return createFeeRefund(ctx, fee, amount, mergeMetadata({}, ctx.params))
}
export function listApplicationFeeRefunds(ctx: Ctx): Obj {
const fee = retrieveApplicationFee(ctx)
const items = ctx.emu.store.list('fee_refund', ctx.owner, (r) => r.fee === fee.id)
return paginate(items, ctx.params, `/v1/application_fees/${fee.id}/refunds`)
}src/resources/balance.tsShowHide
import { accountOf, emit } from '../context.ts'
import type { Ctx } from '../context.ts'
import { balanceFor, releasePending } from '../ledger.ts'
import { paginate } from '../list.ts'
import { str } from '../params.ts'
import type { Obj } from '../store.ts'
/** GET /v1/balance for the account the request acts as. */
export function retrieveBalance(ctx: Ctx): Obj {
return balanceFor(ctx.emu.store, ctx.owner, accountOf(ctx.emu, ctx.owner).default_currency)
}
export function retrieveBalanceTransaction(ctx: Ctx): Obj {
return ctx.emu.store.get('balance_transaction', ctx.path[0], ctx.owner)
}
export function listBalanceTransactions(ctx: Ctx): Obj {
const type = str(ctx.params, 'type')
const source = str(ctx.params, 'source')
const currency = str(ctx.params, 'currency')
const items = ctx.emu.store.list('balance_transaction', ctx.owner, (t) =>
(!type || t.type === type) && (!source || t.source === source) && (!currency || t.currency === currency),
)
return paginate(items, ctx.params, '/v1/balance_transactions')
}
/** Test control: make pending funds available and emit balance.available. */
export function releaseBalance(ctx: Ctx): Obj {
releasePending(ctx.emu.store, ctx.owner)
const balance = retrieveBalance(ctx)
emit(ctx, 'balance.available', balance, ctx.owner)
return balance
}src/resources/charges.tsShowHide
import { emit } from '../context.ts'
import type { Ctx, Emulator } from '../context.ts'
import { invalid } from '../errors.ts'
import { processingFee } from '../fees.ts'
import { newId, now } from '../ids.ts'
import { addBalanceTransaction } from '../ledger.ts'
import type { Bucket, FeeDetail } from '../ledger.ts'
import { paginate } from '../list.ts'
import { mergeMetadata, str } from '../params.ts'
import type { Obj } from '../store.ts'
import { createApplicationFee } from './application-fees.ts'
import { capabilityActive, getConnected } from './accounts.ts'
import { createTransferRecord } from './transfers.ts'
/**
* Who pays Stripe's processing fee on a charge owned by `owner`.
*
* - Charges on the platform (destination charges, separate charges and
* transfers): the platform. https://docs.stripe.com/connect/charges
* - Direct charges: the connected account, unless its controller.fees.payer
* is `application`. `account`, `application_custom` and
* `application_express` all bill the connected account for processing.
* https://docs.stripe.com/connect/direct-charges-fee-payer-behavior
*/
export function feePayer(emu: Emulator, owner: string): 'platform' | 'account' {
if (owner === emu.store.platformId) return 'platform'
const account = emu.store.find(owner)!.obj
return account.controller?.fees?.payer === 'application' ? 'platform' : 'account'
}
/** Charge object in its first state. Money moves in captureCharge. */
export function newCharge(pi: Obj, paymentMethod: string, failed?: { code: string; decline_code: string; message: string }): Obj {
return {
id: newId('ch'),
object: 'charge',
amount: pi.amount,
amount_captured: 0,
amount_refunded: 0,
application: null,
application_fee: null,
application_fee_amount: null,
balance_transaction: null,
billing_details: { address: null, email: null, name: null, phone: null },
calculated_statement_descriptor: null,
captured: false,
created: now(),
currency: pi.currency,
customer: pi.customer,
description: pi.description,
disputed: false,
failure_balance_transaction: null,
failure_code: failed?.code ?? null,
failure_message: failed?.message ?? null,
fraud_details: {},
livemode: false,
metadata: { ...pi.metadata },
on_behalf_of: pi.on_behalf_of,
outcome: failed
? { network_status: 'declined_by_network', reason: failed.decline_code, risk_level: 'normal', seller_message: 'The bank did not return any further details with this decline.', type: 'issuer_declined' }
: { network_status: 'approved_by_network', reason: null, risk_level: 'normal', seller_message: 'Payment complete.', type: 'authorized' },
paid: !failed,
payment_intent: pi.id,
payment_method: paymentMethod,
payment_method_details: {
type: 'card',
card: { brand: 'visa', country: 'US', exp_month: 12, exp_year: new Date().getFullYear() + 3, funding: 'credit', last4: failed ? '0002' : '4242' },
},
receipt_email: pi.receipt_email ?? null,
receipt_number: null,
receipt_url: null,
refunded: false,
review: null,
shipping: null,
source: null,
source_transfer: null,
statement_descriptor: null,
statement_descriptor_suffix: null,
status: failed ? 'failed' : 'succeeded',
transfer_data: pi.transfer_data ?? null,
transfer_group: pi.transfer_group,
}
}
/**
* Capture `amount` of a charge and move the money:
*
* Destination charge with application_fee_amount (per
* https://docs.stripe.com/connect/destination-charges, "Collect fees"):
* the full amount is transferred to the connected account, the application
* fee comes back to the platform, and the platform pays the Stripe fee.
* With transfer_data[amount], only that amount is transferred.
*
* Direct charge: the payment lands on the connected account; the
* application fee goes to the platform; the processing fee goes to whoever
* feePayer() says.
*/
export function captureCharge(ctx: Ctx, charge: Obj, amount: number, appFeeAmount: number | null, bucket: Bucket) {
const emu = ctx.emu
const platform = emu.store.platformId
const owner = emu.store.ownerOf(charge.id)!
const currency = charge.currency
const stripeFee = processingFee(amount, emu.fees)
const fee = appFeeAmount === null ? null : Math.min(appFeeAmount, amount)
charge.captured = true
charge.amount_captured = amount
charge.application_fee_amount = fee
const isDirect = owner !== platform
const destination = charge.transfer_data?.destination as string | undefined
const payer = feePayer(emu, owner)
// The charge's own balance transaction, on the account that owns it.
const chargeFees: Array<FeeDetail> = []
if (payer === 'account' || !isDirect) {
chargeFees.push({ amount: stripeFee, type: 'stripe_fee', description: 'Stripe processing fees' })
}
if (isDirect && fee) {
chargeFees.push({ amount: fee, type: 'application_fee', description: 'Application fee' })
}
const txn = addBalanceTransaction(emu.store, owner, {
amount,
currency,
type: 'charge',
source: charge.id,
fees: chargeFees,
bucket,
description: charge.description,
})
charge.balance_transaction = txn.id
if (isDirect && payer === 'platform') {
// Stripe documents that the platform pays here, not how it is booked.
// The emulator books it as a stripe_fee balance transaction on the platform.
addBalanceTransaction(emu.store, platform, {
amount: -stripeFee,
currency,
type: 'stripe_fee',
source: charge.id,
description: `Processing fee for direct charge ${charge.id} on ${owner}`,
})
}
if (isDirect && fee) {
const appFee = createApplicationFee(ctx, { account: owner, amount: fee, charge: charge.id, currency, originatingTransaction: null, bucket })
charge.application_fee = appFee.id
}
if (destination) {
const transferAmount = charge.transfer_data.amount ?? amount
const transfer = createTransferRecord(ctx, {
amount: transferAmount,
currency,
destination,
sourceTransaction: charge.id,
transferGroup: charge.transfer_group,
bucket,
paymentFee: fee,
})
charge.transfer = transfer.id
if (fee) {
const appFee = createApplicationFee(ctx, {
account: destination,
amount: fee,
charge: transfer.destination_payment,
currency,
originatingTransaction: charge.id,
bucket,
})
charge.application_fee = appFee.id
}
}
}
/** Check a destination account can receive the transfer for a charge. */
export function checkDestination(emu: Emulator, id: string, param: string): Obj {
const account = getConnected(emu, id, param)
if (!capabilityActive(account, 'transfers')) {
throw invalid(
`Your destination account needs to have the transfers capability active. ${id} has: ${JSON.stringify(account.capabilities)}.`,
param,
)
}
return account
}
export function getCharge(ctx: Ctx, id: string, param = 'id'): Obj {
return ctx.emu.store.get('charge', id, ctx.owner, param)
}
export function retrieveCharge(ctx: Ctx): Obj {
return getCharge(ctx, ctx.path[0])
}
export function updateCharge(ctx: Ctx): Obj {
const charge = getCharge(ctx, ctx.path[0])
const previous: Obj = {}
if (ctx.params.metadata !== undefined) {
previous.metadata = charge.metadata
charge.metadata = mergeMetadata(charge.metadata, ctx.params)
}
if (ctx.params.description !== undefined) {
previous.description = charge.description
charge.description = str(ctx.params, 'description') ?? null
}
emit(ctx, 'charge.updated', charge, ctx.owner, previous)
return charge
}
export function listCharges(ctx: Ctx): Obj {
const pi = str(ctx.params, 'payment_intent')
const group = str(ctx.params, 'transfer_group')
const items = ctx.emu.store.list('charge', ctx.owner, (c) =>
(!pi || c.payment_intent === pi) && (!group || c.transfer_group === group),
)
return paginate(items, ctx.params, '/v1/charges')
}src/resources/disputes.tsShowHide
import { emit } from '../context.ts'
import type { Ctx } from '../context.ts'
import { invalid } from '../errors.ts'
import { newId, now } from '../ids.ts'
import { addBalanceTransaction } from '../ledger.ts'
import { paginate } from '../list.ts'
import { bool, mergeMetadata, obj, plain, str } from '../params.ts'
import type { Obj } from '../store.ts'
import { feePayer } from './charges.ts'
const OPEN = ['needs_response', 'warning_needs_response', 'under_review', 'warning_under_review']
/**
* Open a dispute on a charge and debit the disputed amount plus the dispute
* fee. The account that owns the charge is debited: the platform for
* destination charges and separate charges and transfers, the connected
* account for direct charges (https://docs.stripe.com/connect/charges,
* "Disputes and chargebacks"). On a direct charge the dispute fee goes to the
* platform when controller.fees.payer is `application`
* (https://docs.stripe.com/connect/direct-charges-fee-payer-behavior).
*/
export function openDispute(ctx: Ctx, charge: Obj, input: { reason?: string; amount?: number }): Obj {
const { store, fees } = ctx.emu
const owner = store.ownerOf(charge.id)!
if (!charge.captured) throw invalid(`Charge ${charge.id} has not been captured.`, 'charge')
if (charge.disputed) throw invalid(`Charge ${charge.id} already has a dispute.`, 'charge')
const amount = input.amount ?? charge.amount_captured - charge.amount_refunded
if (amount <= 0 || amount > charge.amount_captured) throw invalid('Invalid dispute amount.', 'amount')
const id = newId('du')
const platformPaysFee = owner !== store.platformId && feePayer(ctx.emu, owner) === 'platform'
const txn = addBalanceTransaction(store, owner, {
amount: -amount,
currency: charge.currency,
type: 'adjustment',
reportingCategory: 'dispute',
source: id,
description: `Chargeback withdrawal for ${charge.id}`,
fees: platformPaysFee ? [] : [{ amount: fees.disputeFee, type: 'stripe_fee', description: 'Dispute fee' }],
})
if (platformPaysFee) {
addBalanceTransaction(store, store.platformId, {
amount: -fees.disputeFee,
currency: charge.currency,
type: 'stripe_fee',
source: id,
description: `Dispute fee for ${charge.id} on ${owner}`,
})
}
const created = now()
const dispute: Obj = {
id,
object: 'dispute',
amount,
balance_transactions: [structuredClone(txn)],
charge: charge.id,
created,
currency: charge.currency,
enhanced_eligibility_types: [],
evidence: {},
evidence_details: {
due_by: created + 7 * 86400,
enhanced_eligibility: {},
has_evidence: false,
past_due: false,
submission_count: 0,
},
is_charge_refundable: false,
livemode: false,
metadata: {},
payment_intent: charge.payment_intent,
reason: input.reason ?? 'fraudulent',
status: 'needs_response',
}
store.put(dispute, owner)
charge.disputed = true
emit(ctx, 'charge.dispute.created', dispute, owner)
emit(ctx, 'charge.dispute.funds_withdrawn', dispute, owner)
return dispute
}
function getDispute(ctx: Ctx): Obj {
return ctx.emu.store.get('dispute', ctx.path[0], ctx.owner)
}
function close(ctx: Ctx, dispute: Obj, status: 'won' | 'lost') {
const previous = { status: dispute.status }
dispute.status = status
if (status === 'won') {
const txn = addBalanceTransaction(ctx.emu.store, ctx.owner, {
amount: dispute.amount,
currency: dispute.currency,
type: 'adjustment',
reportingCategory: 'dispute_reversal',
source: dispute.id,
description: `Chargeback reversal for ${dispute.charge}`,
bucket: 'available',
})
dispute.balance_transactions = [...dispute.balance_transactions, structuredClone(txn)]
emit(ctx, 'charge.dispute.funds_reinstated', dispute, ctx.owner)
}
emit(ctx, 'charge.dispute.closed', dispute, ctx.owner, previous)
}
/**
* POST /v1/disputes/:id. Evidence is merged; `submit` defaults to true. In
* test mode, uncategorized_text "winning_evidence" or "losing_evidence"
* decides the outcome (https://docs.stripe.com/testing); the emulator
* closes the dispute straight away in those two cases.
*/
export function updateDispute(ctx: Ctx): Obj {
const dispute = getDispute(ctx)
if (!OPEN.includes(dispute.status)) throw invalid(`Dispute ${dispute.id} is ${dispute.status} and cannot be updated.`)
const evidence = obj(ctx.params, 'evidence')
const previous: Obj = { evidence: structuredClone(dispute.evidence), status: dispute.status }
if (evidence) {
dispute.evidence = { ...dispute.evidence, ...(plain(evidence) as Obj) }
dispute.evidence_details.has_evidence = true
}
if (ctx.params.metadata !== undefined) dispute.metadata = mergeMetadata(dispute.metadata, ctx.params)
const submit = bool(ctx.params, 'submit') ?? true
if (submit) {
dispute.status = 'under_review'
dispute.evidence_details.submission_count += 1
}
emit(ctx, 'charge.dispute.updated', dispute, ctx.owner, previous)
const text = dispute.evidence.uncategorized_text
if (submit && text === 'winning_evidence') close(ctx, dispute, 'won')
if (submit && text === 'losing_evidence') close(ctx, dispute, 'lost')
return dispute
}
/** POST /v1/disputes/:id/close: accept the dispute as lost. */
export function closeDispute(ctx: Ctx): Obj {
const dispute = getDispute(ctx)
if (!OPEN.includes(dispute.status)) throw invalid(`Dispute ${dispute.id} is already ${dispute.status}.`)
close(ctx, dispute, 'lost')
return dispute
}
export function retrieveDispute(ctx: Ctx): Obj {
return getDispute(ctx)
}
export function listDisputes(ctx: Ctx): Obj {
const charge = str(ctx.params, 'charge')
const pi = str(ctx.params, 'payment_intent')
const items = ctx.emu.store.list('dispute', ctx.owner, (d) =>
(!charge || d.charge === charge) && (!pi || d.payment_intent === pi),
)
return paginate(items, ctx.params, '/v1/disputes')
}
/** Test control: resolve an open dispute as won or lost. */
export function resolveDispute(ctx: Ctx, outcome: string): Obj {
const dispute = getDispute(ctx)
if (!OPEN.includes(dispute.status)) throw invalid(`Dispute ${dispute.id} is already ${dispute.status}.`)
if (outcome !== 'won' && outcome !== 'lost') throw invalid('outcome must be won or lost.', 'outcome')
close(ctx, dispute, outcome)
return dispute
}src/resources/events.tsShowHide
import type { Ctx } from '../context.ts'
import { paginate } from '../list.ts'
import { list, str } from '../params.ts'
import type { Obj } from '../store.ts'
export function retrieveEvent(ctx: Ctx): Obj {
return ctx.emu.store.get('event', ctx.path[0], ctx.owner)
}
export function listEvents(ctx: Ctx): Obj {
const type = str(ctx.params, 'type')
const types = list(ctx.params, 'types')
const items = ctx.emu.store.list('event', ctx.owner, (e) =>
(!type || e.type === type) && (types.length === 0 || types.includes(e.type)),
)
return paginate(items, ctx.params, '/v1/events')
}src/resources/payment-intents.tsShowHide
import { emit } from '../context.ts'
import type { Ctx } from '../context.ts'
import { ApiError, invalid } from '../errors.ts'
import { newId, now } from '../ids.ts'
import type { Bucket } from '../ledger.ts'
import { paginate } from '../list.ts'
import { bool, int, mergeMetadata, obj, reqInt, reqStr, str } from '../params.ts'
import type { Obj } from '../store.ts'
import { capabilityActive, getConnected } from './accounts.ts'
import { captureCharge, checkDestination, newCharge } from './charges.ts'
import { openDispute } from './disputes.ts'
// Test PaymentMethod ids with special behaviour, from https://docs.stripe.com/testing.
// Any other payment_method value succeeds like pm_card_visa.
const DECLINED = 'pm_card_visa_chargeDeclined'
const BYPASS_PENDING = ['pm_card_bypassPending', 'pm_card_bypassPendingInternational']
const CREATE_DISPUTE = 'pm_card_createDispute'
// "The minimum amount is $0.50 US or equivalent in charge currency."
// https://docs.stripe.com/api/payment_intents/create. The emulator applies 50
// minor units to every currency.
const MINIMUM_AMOUNT = 50
const bucketFor = (pm: string): Bucket => (BYPASS_PENDING.includes(pm) ? 'available' : 'pending')
function getIntent(ctx: Ctx): Obj {
return ctx.emu.store.get('payment_intent', ctx.path[0], ctx.owner)
}
function unexpectedState(pi: Obj, action: string): ApiError {
return new ApiError(400, {
type: 'invalid_request_error',
code: 'payment_intent_unexpected_state',
message: `You cannot ${action} this PaymentIntent because it has a status of ${pi.status}.`,
payment_intent: structuredClone(pi),
})
}
/** Checks for the Connect parameters before any money moves. */
function validateConnect(ctx: Ctx, pi: Obj) {
const isDirect = !ctx.isPlatform
if (isDirect) {
// https://docs.stripe.com/connect/charges: direct charges need the
// card_payments capability active on the connected account.
const account = getConnected(ctx.emu, ctx.owner)
if (!capabilityActive(account, 'card_payments')) {
throw invalid(`${account.id} does not have the card_payments capability active, which direct charges need.`)
}
}
if (pi.transfer_data?.destination) {
if (isDirect) throw invalid('transfer_data cannot be used with the Stripe-Account header.', 'transfer_data[destination]')
checkDestination(ctx.emu, pi.transfer_data.destination, 'transfer_data[destination]')
if (pi.transfer_data.amount !== null && pi.transfer_data.amount !== undefined && pi.transfer_data.amount > pi.amount) {
throw invalid('transfer_data[amount] cannot be greater than amount.', 'transfer_data[amount]')
}
if (pi.transfer_data.amount !== null && pi.transfer_data.amount !== undefined && pi.application_fee_amount !== null) {
throw invalid('You cannot use both application_fee_amount and transfer_data[amount].', 'application_fee_amount')
}
}
if (pi.application_fee_amount !== null && !isDirect && !pi.transfer_data?.destination) {
throw invalid(
'application_fee_amount needs a direct charge (Stripe-Account header) or a destination charge (transfer_data[destination]).',
'application_fee_amount',
)
}
if (pi.on_behalf_of) {
const account = getConnected(ctx.emu, pi.on_behalf_of, 'on_behalf_of')
// https://docs.stripe.com/connect/destination-charges: on_behalf_of needs
// a payments capability such as card_payments.
if (!capabilityActive(account, 'card_payments')) {
throw invalid(`${account.id} does not have the card_payments capability active, which on_behalf_of needs.`, 'on_behalf_of')
}
}
}
function readTransferData(p: Obj): Obj | null {
const td = obj(p, 'transfer_data')
if (!td) return null
return { amount: int(td, 'amount') ?? null, destination: reqStr(td, 'destination') }
}
export function createPaymentIntent(ctx: Ctx): Obj {
const p = ctx.params
const amount = reqInt(p, 'amount')
if (amount < MINIMUM_AMOUNT) {
throw invalid(`Amount must be at least ${MINIMUM_AMOUNT} in the smallest currency unit.`, 'amount', 'amount_too_small')
}
const id = newId('pi')
const pi: Obj = {
id,
object: 'payment_intent',
amount,
amount_capturable: 0,
amount_received: 0,
application: null,
application_fee_amount: int(p, 'application_fee_amount') ?? null,
automatic_payment_methods: { enabled: true },
canceled_at: null,
cancellation_reason: null,
capture_method: str(p, 'capture_method') ?? 'automatic_async',
client_secret: `${id}_secret_${newId('x').slice(2)}`,
confirmation_method: str(p, 'confirmation_method') ?? 'automatic',
created: now(),
currency: reqStr(p, 'currency').toLowerCase(),
customer: str(p, 'customer') ?? null,
description: str(p, 'description') ?? null,
last_payment_error: null,
latest_charge: null,
livemode: false,
metadata: mergeMetadata({}, p),
next_action: null,
on_behalf_of: str(p, 'on_behalf_of') ?? null,
payment_method: str(p, 'payment_method') ?? null,
payment_method_types: ['card'],
processing: null,
receipt_email: str(p, 'receipt_email') ?? null,
review: null,
setup_future_usage: null,
shipping: null,
source: null,
statement_descriptor: null,
statement_descriptor_suffix: str(p, 'statement_descriptor_suffix') ?? null,
status: 'requires_payment_method',
transfer_data: readTransferData(p),
transfer_group: str(p, 'transfer_group') ?? null,
}
if (pi.payment_method) pi.status = 'requires_confirmation'
validateConnect(ctx, pi)
ctx.emu.store.put(pi, ctx.owner)
emit(ctx, 'payment_intent.created', pi, ctx.owner)
if (bool(p, 'confirm')) return confirm(ctx, pi)
return pi
}
export function confirmPaymentIntent(ctx: Ctx): Obj {
const pi = getIntent(ctx)
const pm = str(ctx.params, 'payment_method')
if (pm) pi.payment_method = pm
return confirm(ctx, pi)
}
function confirm(ctx: Ctx, pi: Obj): Obj {
if (pi.status !== 'requires_payment_method' && pi.status !== 'requires_confirmation') {
throw unexpectedState(pi, 'confirm')
}
if (!pi.payment_method) {
throw invalid(
"You cannot confirm this PaymentIntent because it's missing a payment method. Pass payment_method (for example pm_card_visa).",
'payment_method',
)
}
const pm = pi.payment_method as string
const store = ctx.emu.store
if (pm === DECLINED) {
// Declines with card_declined / generic_decline, per https://docs.stripe.com/testing.
const failure = { code: 'card_declined', decline_code: 'generic_decline', message: 'Your card was declined.' }
const charge = newCharge(pi, pm, failure)
store.put(charge, ctx.owner)
pi.latest_charge = charge.id
pi.status = 'requires_payment_method'
pi.payment_method = null
pi.last_payment_error = { type: 'card_error', charge: charge.id, ...failure, payment_method: { id: pm, object: 'payment_method', type: 'card' } }
emit(ctx, 'charge.failed', charge, ctx.owner)
emit(ctx, 'payment_intent.payment_failed', pi, ctx.owner)
throw new ApiError(402, { type: 'card_error', ...failure, charge: charge.id, payment_intent: structuredClone(pi) })
}
const charge = newCharge(pi, pm)
store.put(charge, ctx.owner)
pi.latest_charge = charge.id
pi.last_payment_error = null
if (pi.capture_method === 'manual') {
pi.status = 'requires_capture'
pi.amount_capturable = pi.amount
emit(ctx, 'charge.succeeded', charge, ctx.owner)
emit(ctx, 'payment_intent.amount_capturable_updated', pi, ctx.owner)
return pi
}
captureCharge(ctx, charge, pi.amount, pi.application_fee_amount, bucketFor(pm))
pi.status = 'succeeded'
pi.amount_received = pi.amount
emit(ctx, 'charge.succeeded', charge, ctx.owner)
emit(ctx, 'payment_intent.succeeded', pi, ctx.owner)
if (pm === CREATE_DISPUTE) openDispute(ctx, charge, { reason: 'fraudulent' })
return pi
}
export function capturePaymentIntent(ctx: Ctx): Obj {
const pi = getIntent(ctx)
if (pi.status !== 'requires_capture') throw unexpectedState(pi, 'capture')
const amount = int(ctx.params, 'amount_to_capture') ?? pi.amount_capturable
if (amount <= 0 || amount > pi.amount_capturable) {
throw invalid(`amount_to_capture must be between 1 and ${pi.amount_capturable}.`, 'amount_to_capture')
}
const fee = int(ctx.params, 'application_fee_amount')
if (fee !== undefined) pi.application_fee_amount = fee
const charge = ctx.emu.store.find(pi.latest_charge)!.obj
captureCharge(ctx, charge, amount, pi.application_fee_amount, bucketFor(pi.payment_method))
pi.status = 'succeeded'
pi.amount_capturable = 0
pi.amount_received = amount
emit(ctx, 'charge.captured', charge, ctx.owner)
emit(ctx, 'payment_intent.succeeded', pi, ctx.owner)
return pi
}
const CANCELABLE = ['requires_payment_method', 'requires_capture', 'requires_confirmation', 'requires_action', 'processing']
export function cancelPaymentIntent(ctx: Ctx): Obj {
const pi = getIntent(ctx)
if (!CANCELABLE.includes(pi.status)) throw unexpectedState(pi, 'cancel')
pi.status = 'canceled'
pi.amount_capturable = 0
pi.canceled_at = now()
pi.cancellation_reason = str(ctx.params, 'cancellation_reason') ?? null
emit(ctx, 'payment_intent.canceled', pi, ctx.owner)
return pi
}
export function retrievePaymentIntent(ctx: Ctx): Obj {
return getIntent(ctx)
}
export function updatePaymentIntent(ctx: Ctx): Obj {
const pi = getIntent(ctx)
if (pi.status === 'succeeded' || pi.status === 'canceled') throw unexpectedState(pi, 'update')
const p = ctx.params
const amount = int(p, 'amount')
if (amount !== undefined) pi.amount = amount
const fee = int(p, 'application_fee_amount')
if (fee !== undefined) pi.application_fee_amount = fee
if (p.metadata !== undefined) pi.metadata = mergeMetadata(pi.metadata, p)
for (const key of ['description', 'transfer_group', 'receipt_email', 'customer'] as const) {
if (p[key] !== undefined) pi[key] = str(p, key) ?? null
}
const pm = str(p, 'payment_method')
if (pm) {
pi.payment_method = pm
pi.status = 'requires_confirmation'
}
validateConnect(ctx, pi)
return pi
}
export function listPaymentIntents(ctx: Ctx): Obj {
const customer = str(ctx.params, 'customer')
const items = ctx.emu.store.list('payment_intent', ctx.owner, (pi) => !customer || pi.customer === customer)
return paginate(items, ctx.params, '/v1/payment_intents')
}src/resources/payouts.tsShowHide
import { accountOf, emit } from '../context.ts'
import type { Ctx } from '../context.ts'
import { ApiError, invalid } from '../errors.ts'
import { newId, now } from '../ids.ts'
import { addBalanceTransaction, availableIn } from '../ledger.ts'
import { paginate } from '../list.ts'
import { mergeMetadata, reqInt, str } from '../params.ts'
import type { Obj } from '../store.ts'
// failure_message text for each failure_code, from the Payout object
// reference: https://docs.stripe.com/api/payouts/object
const FAILURE_MESSAGES: Record<string, string> = {
account_closed: 'The bank account has been closed.',
account_frozen: 'The bank account has been frozen.',
bank_account_restricted: 'The bank account has restrictions on either the type, or the number, of payouts allowed.',
could_not_process: 'The bank could not process this payout.',
debit_not_authorized: 'Debit transactions are not approved on the bank account.',
declined: 'The bank has declined this transfer.',
insufficient_funds: 'Your Stripe account has insufficient funds to cover the payout.',
invalid_account_number: 'The routing number seems correct, but the account number is invalid.',
invalid_currency: 'The bank was unable to process this payout because of its currency.',
no_account: 'The bank account details on file are probably incorrect. No bank account could be located with those details.',
}
/**
* POST /v1/payouts. Pays out of the available balance of the account the
* request acts as (use the Stripe-Account header for a connected account).
* The payout stays `pending` until a test-control call marks it paid or failed.
*/
export function createPayout(ctx: Ctx): Obj {
const p = ctx.params
const { store } = ctx.emu
const amount = reqInt(p, 'amount')
if (amount <= 0) throw invalid('Amount must be greater than 0.', 'amount')
const account = accountOf(ctx.emu, ctx.owner)
const currency = (str(p, 'currency') ?? account.default_currency).toLowerCase()
if (!account.payouts_enabled) {
throw new ApiError(400, { type: 'invalid_request_error', code: 'payouts_not_allowed', message: `Payouts are not enabled for ${account.id}.` })
}
if (availableIn(store, ctx.owner, currency) < amount) {
throw new ApiError(400, {
type: 'invalid_request_error',
code: 'balance_insufficient',
message: `You have insufficient funds in your Stripe account for this payout. Release pending funds with POST /_emulator/balance/release.`,
})
}
const destination = account.external_accounts?.data?.[0]?.id ?? null
const id = newId('po')
const txn = addBalanceTransaction(store, ctx.owner, { amount: -amount, currency, type: 'payout', source: id })
const created = now()
const payout: Obj = {
id,
object: 'payout',
amount,
application_fee: null,
application_fee_amount: null,
arrival_date: created + 86400,
automatic: false,
balance_transaction: txn.id,
created,
currency,
description: str(p, 'description') ?? null,
destination,
failure_balance_transaction: null,
failure_code: null,
failure_message: null,
livemode: false,
metadata: mergeMetadata({}, p),
method: str(p, 'method') ?? 'standard',
original_payout: null,
payout_method: null,
reconciliation_status: 'not_applicable',
reversed_by: null,
source_type: 'card',
statement_descriptor: str(p, 'statement_descriptor') ?? null,
status: 'pending',
trace_id: null,
type: 'bank_account',
}
store.put(payout, ctx.owner)
emit(ctx, 'payout.created', payout, ctx.owner)
return payout
}
function getPayout(ctx: Ctx): Obj {
return ctx.emu.store.get('payout', ctx.path[0], ctx.owner)
}
export function retrievePayout(ctx: Ctx): Obj {
return getPayout(ctx)
}
export function listPayouts(ctx: Ctx): Obj {
const status = str(ctx.params, 'status')
const items = ctx.emu.store.list('payout', ctx.owner, (po) => !status || po.status === status)
return paginate(items, ctx.params, '/v1/payouts')
}
/** Test control: mark a pending payout paid. */
export function markPayoutPaid(ctx: Ctx): Obj {
const payout = getPayout(ctx)
if (payout.status !== 'pending' && payout.status !== 'in_transit') {
throw invalid(`Payout ${payout.id} is ${payout.status}, not pending.`)
}
const previous = { status: payout.status }
payout.status = 'paid'
emit(ctx, 'payout.updated', payout, ctx.owner, previous)
emit(ctx, 'payout.paid', payout, ctx.owner)
return payout
}
/** Test control: fail a payout. The amount goes back to the available balance. */
export function markPayoutFailed(ctx: Ctx): Obj {
const payout = getPayout(ctx)
if (payout.status === 'failed' || payout.status === 'canceled') {
throw invalid(`Payout ${payout.id} is already ${payout.status}.`)
}
const code = str(ctx.params, 'failure_code') ?? 'account_closed'
const txn = addBalanceTransaction(ctx.emu.store, ctx.owner, {
amount: payout.amount,
currency: payout.currency,
type: 'payout_failure',
source: payout.id,
bucket: 'available',
})
const previous = { status: payout.status, failure_code: null }
Object.assign(payout, {
status: 'failed',
failure_code: code,
failure_message: FAILURE_MESSAGES[code] ?? null,
failure_balance_transaction: txn.id,
})
emit(ctx, 'payout.updated', payout, ctx.owner, previous)
emit(ctx, 'payout.failed', payout, ctx.owner)
return payout
}src/resources/refunds.tsShowHide
import { emit } from '../context.ts'
import type { Ctx } from '../context.ts'
import { ApiError, invalid } from '../errors.ts'
import { proportional } from '../fees.ts'
import { newId, now } from '../ids.ts'
import { addBalanceTransaction } from '../ledger.ts'
import { paginate } from '../list.ts'
import { bool, int, mergeMetadata, str } from '../params.ts'
import type { Obj } from '../store.ts'
import { createFeeRefund } from './application-fees.ts'
import { createReversal } from './transfers.ts'
/**
* POST /v1/refunds. The refund is debited from the account that owns the
* charge: the platform for destination charges and separate charges and
* transfers, the connected account for direct charges
* (https://docs.stripe.com/connect/charges, "Refunds"). Stripe's processing
* fee is not given back ("The payment processing, Connect and currency
* conversion fees from the original transaction are not returned",
* https://stripe.com/ie/pricing).
*
* reverse_transfer and refund_application_fee reverse a proportional share,
* or everything that is left when the refund completes the charge
* (https://docs.stripe.com/connect/destination-charges, "Issue refunds").
*/
export function createRefund(ctx: Ctx): Obj {
const p = ctx.params
const { store } = ctx.emu
let charge: Obj
const chargeId = str(p, 'charge')
const piId = str(p, 'payment_intent')
if (chargeId) {
charge = store.get('charge', chargeId, ctx.owner, 'charge')
} else if (piId) {
const pi = store.get('payment_intent', piId, ctx.owner, 'payment_intent')
if (!pi.latest_charge) throw invalid(`PaymentIntent ${pi.id} has no charge to refund.`, 'payment_intent')
charge = store.find(pi.latest_charge)!.obj
} else {
throw invalid('One of charge or payment_intent is required.', 'charge', 'parameter_missing')
}
if (!charge.captured) throw invalid(`Charge ${charge.id} has not been captured.`, 'charge')
if (charge.disputed) {
throw new ApiError(400, { type: 'invalid_request_error', code: 'charge_disputed', message: `Charge ${charge.id} has been disputed and cannot be refunded.` })
}
const remaining = charge.amount_captured - charge.amount_refunded
if (remaining <= 0) {
throw new ApiError(400, { type: 'invalid_request_error', code: 'charge_already_refunded', message: `Charge ${charge.id} has already been refunded.` })
}
const amount = int(p, 'amount') ?? remaining
if (amount <= 0 || amount > remaining) {
throw invalid(`Refund amount (${amount}) is greater than unrefunded amount on charge (${remaining}).`, 'amount')
}
const reverseTransfer = bool(p, 'reverse_transfer') ?? false
const refundFee = bool(p, 'refund_application_fee') ?? false
const transfer = charge.transfer ? store.find(charge.transfer)!.obj : null
if (refundFee && transfer && !reverseTransfer) {
// "If you refund the application fee for a destination charge, you must
// also reverse the transfer." https://docs.stripe.com/connect/destination-charges
throw invalid('refund_application_fee on a destination charge also needs reverse_transfer.', 'refund_application_fee')
}
const completes = amount === remaining
const id = newId('re')
const txn = addBalanceTransaction(store, ctx.owner, {
amount: -amount,
currency: charge.currency,
type: 'refund',
source: id,
})
const refund: Obj = {
id,
object: 'refund',
amount,
balance_transaction: txn.id,
charge: charge.id,
created: now(),
currency: charge.currency,
destination_details: { card: { type: 'refund' }, type: 'card' },
metadata: mergeMetadata({}, p),
payment_intent: charge.payment_intent,
reason: str(p, 'reason') ?? null,
receipt_number: null,
source_transfer_reversal: null,
status: 'succeeded',
transfer_reversal: null,
}
store.put(refund, ctx.owner)
if (reverseTransfer && transfer) {
const left = transfer.amount - transfer.amount_reversed
const share = completes ? left : Math.min(left, proportional(transfer.amount, amount, charge.amount_captured))
if (share > 0) {
const reversal = createReversal(ctx, transfer, share, { sourceRefund: id })
refund.transfer_reversal = reversal.id
}
}
if (refundFee && charge.application_fee) {
const fee = store.find(charge.application_fee)!.obj
const left = fee.amount - fee.amount_refunded
const share = completes ? left : Math.min(left, proportional(fee.amount, amount, charge.amount_captured))
if (share > 0) createFeeRefund(ctx, fee, share)
}
const previous = { amount_refunded: charge.amount_refunded, refunded: charge.refunded }
charge.amount_refunded += amount
charge.refunded = charge.amount_refunded >= charge.amount_captured
emit(ctx, 'refund.created', refund, ctx.owner)
emit(ctx, 'charge.refunded', charge, ctx.owner, previous)
return refund
}
export function retrieveRefund(ctx: Ctx): Obj {
return ctx.emu.store.get('refund', ctx.path[0], ctx.owner)
}
export function updateRefund(ctx: Ctx): Obj {
const refund = retrieveRefund(ctx)
const previous = { metadata: refund.metadata }
refund.metadata = mergeMetadata(refund.metadata ?? {}, ctx.params)
emit(ctx, 'refund.updated', refund, ctx.owner, previous)
return refund
}
export function listRefunds(ctx: Ctx): Obj {
const charge = str(ctx.params, 'charge')
const pi = str(ctx.params, 'payment_intent')
const items = ctx.emu.store.list('refund', ctx.owner, (r) =>
(!charge || r.charge === charge) && (!pi || r.payment_intent === pi),
)
return paginate(items, ctx.params, '/v1/refunds')
}src/resources/transfers.tsShowHide
import { emit, requirePlatform } from '../context.ts'
import type { Ctx } from '../context.ts'
import { ApiError, invalid } from '../errors.ts'
import { newId, now } from '../ids.ts'
import { addBalanceTransaction, availableIn } from '../ledger.ts'
import type { Bucket } from '../ledger.ts'
import { inlineList, paginate } from '../list.ts'
import { int, mergeMetadata, reqInt, reqStr, str } from '../params.ts'
import type { Obj } from '../store.ts'
import { capabilityActive, getConnected } from './accounts.ts'
/**
* Create a transfer from the platform to a connected account, with the
* payment (py_) it produces on the connected account and the balance
* transactions on both sides. `paymentFee` is an application fee taken
* from that payment (destination charges with application_fee_amount).
*/
export function createTransferRecord(
ctx: Ctx,
input: {
amount: number
currency: string
destination: string
sourceTransaction: string | null
transferGroup: string | null
bucket: Bucket
paymentFee?: number | null
description?: string | null
metadata?: Record<string, string>
},
): Obj {
const { store } = ctx.emu
const platform = store.platformId
const id = newId('tr')
const paymentId = newId('py')
const created = now()
const platformTxn = addBalanceTransaction(store, platform, {
amount: -input.amount,
currency: input.currency,
type: 'transfer',
source: id,
bucket: input.bucket,
})
const payment: Obj = {
id: paymentId,
object: 'charge',
amount: input.amount,
amount_captured: input.amount,
amount_refunded: 0,
application: null,
application_fee: null,
application_fee_amount: input.paymentFee ?? null,
balance_transaction: null,
captured: true,
created,
currency: input.currency,
description: null,
disputed: false,
livemode: false,
metadata: {},
paid: true,
payment_intent: null,
refunded: false,
source_transfer: id,
status: 'succeeded',
transfer_group: input.transferGroup,
}
store.put(payment, input.destination)
const paymentTxn = addBalanceTransaction(store, input.destination, {
amount: input.amount,
currency: input.currency,
type: 'payment',
source: paymentId,
bucket: input.bucket,
fees: input.paymentFee ? [{ amount: input.paymentFee, type: 'application_fee', description: 'Application fee' }] : [],
})
payment.balance_transaction = paymentTxn.id
const transfer: Obj = {
id,
object: 'transfer',
amount: input.amount,
amount_reversed: 0,
balance_transaction: platformTxn.id,
created,
currency: input.currency,
description: input.description ?? null,
destination: input.destination,
destination_payment: paymentId,
livemode: false,
metadata: input.metadata ?? {},
reversals: inlineList([], `/v1/transfers/${id}/reversals`),
reversed: false,
source_transaction: input.sourceTransaction,
source_type: 'card',
transfer_group: input.transferGroup,
}
store.put(transfer, platform)
emit(ctx, 'transfer.created', transfer, platform)
return transfer
}
/**
* Reverse part of a transfer: money moves back from the connected account's
* payment to the platform. https://docs.stripe.com/api/transfer_reversals
*/
export function createReversal(
ctx: Ctx,
transfer: Obj,
amount: number,
opts: { sourceRefund?: string | null; metadata?: Record<string, string> } = {},
): Obj {
const { store } = ctx.emu
const remaining = transfer.amount - transfer.amount_reversed
if (amount <= 0 || amount > remaining) {
throw invalid(`Amount must be between 1 and the ${remaining} left on transfer ${transfer.id}.`, 'amount')
}
const id = newId('trr')
const platformTxn = addBalanceTransaction(store, store.platformId, {
amount,
currency: transfer.currency,
type: 'transfer_refund',
source: id,
bucket: 'available',
})
// The connected side: a refund (pyr_) on the payment the transfer created.
const payment = store.find(transfer.destination_payment)!.obj
const refundId = newId('pyr')
const refundTxn = addBalanceTransaction(store, transfer.destination, {
amount: -amount,
currency: transfer.currency,
type: 'payment_refund',
source: refundId,
})
store.put(
{
id: refundId,
object: 'refund',
amount,
balance_transaction: refundTxn.id,
charge: payment.id,
created: now(),
currency: transfer.currency,
metadata: {},
payment_intent: null,
reason: null,
source_transfer_reversal: id,
status: 'succeeded',
transfer_reversal: null,
},
transfer.destination,
)
payment.amount_refunded += amount
payment.refunded = payment.amount_refunded >= payment.amount
const reversal: Obj = {
id,
object: 'transfer_reversal',
amount,
balance_transaction: platformTxn.id,
created: now(),
currency: transfer.currency,
destination_payment_refund: refundId,
metadata: opts.metadata ?? {},
source_refund: opts.sourceRefund ?? null,
transfer: transfer.id,
}
store.put(reversal, store.platformId)
const previous = { amount_reversed: transfer.amount_reversed, reversed: transfer.reversed }
transfer.amount_reversed += amount
transfer.reversed = transfer.amount_reversed >= transfer.amount
transfer.reversals = inlineList(
[reversal, ...transfer.reversals.data],
`/v1/transfers/${transfer.id}/reversals`,
)
emit(ctx, 'transfer.reversed', transfer, store.platformId, previous)
return reversal
}
/** POST /v1/transfers */
export function createTransfer(ctx: Ctx): Obj {
requirePlatform(ctx)
const p = ctx.params
const amount = reqInt(p, 'amount')
if (amount <= 0) throw invalid('Amount must be greater than 0.', 'amount')
const currency = reqStr(p, 'currency').toLowerCase()
const destination = getConnected(ctx.emu, reqStr(p, 'destination'), 'destination')
if (!capabilityActive(destination, 'transfers')) {
throw invalid(`Your destination account needs to have the transfers capability active. ${destination.id} has: ${JSON.stringify(destination.capabilities)}.`, 'destination')
}
const sourceTransaction = str(p, 'source_transaction') ?? null
let bucket: Bucket = 'available'
if (sourceTransaction) {
const charge = ctx.emu.store.get('charge', sourceTransaction, ctx.emu.store.platformId, 'source_transaction')
if (!charge.captured) throw invalid('The source_transaction charge has not been captured.', 'source_transaction')
// Funds tied to a charge move when that charge's funds do.
bucket = ctx.emu.store.find(charge.balance_transaction)!.obj.status
} else if (availableIn(ctx.emu.store, ctx.emu.store.platformId, currency) < amount) {
throw new ApiError(400, {
type: 'invalid_request_error',
code: 'balance_insufficient',
message: 'You have insufficient available funds in your Stripe account. Release pending funds with POST /_emulator/balance/release, or pass source_transaction.',
})
}
return createTransferRecord(ctx, {
amount,
currency,
destination: destination.id,
sourceTransaction,
transferGroup: str(p, 'transfer_group') ?? null,
bucket,
description: str(p, 'description') ?? null,
metadata: mergeMetadata({}, p),
})
}
export function retrieveTransfer(ctx: Ctx): Obj {
return ctx.emu.store.get('transfer', ctx.path[0], ctx.owner)
}
export function updateTransfer(ctx: Ctx): Obj {
const transfer = retrieveTransfer(ctx)
const previous: Obj = {}
if (ctx.params.metadata !== undefined) {
previous.metadata = transfer.metadata
transfer.metadata = mergeMetadata(transfer.metadata, ctx.params)
}
if (ctx.params.description !== undefined) {
previous.description = transfer.description
transfer.description = str(ctx.params, 'description') ?? null
}
emit(ctx, 'transfer.updated', transfer, ctx.owner, previous)
return transfer
}
export function listTransfers(ctx: Ctx): Obj {
const destination = str(ctx.params, 'destination')
const group = str(ctx.params, 'transfer_group')
const items = ctx.emu.store.list('transfer', ctx.owner, (t) =>
(!destination || t.destination === destination) && (!group || t.transfer_group === group),
)
return paginate(items, ctx.params, '/v1/transfers')
}
/** POST /v1/transfers/:id/reversals. Amount defaults to what is left. */
export function createTransferReversal(ctx: Ctx): Obj {
const transfer = retrieveTransfer(ctx)
const amount = int(ctx.params, 'amount') ?? transfer.amount - transfer.amount_reversed
return createReversal(ctx, transfer, amount, { metadata: mergeMetadata({}, ctx.params) })
}
export function listTransferReversals(ctx: Ctx): Obj {
const transfer = retrieveTransfer(ctx)
const items = ctx.emu.store.list('transfer_reversal', ctx.owner, (r) => r.transfer === transfer.id)
return paginate(items, ctx.params, `/v1/transfers/${transfer.id}/reversals`)
}src/resources/webhook-endpoints.tsShowHide
import { API_VERSION, requirePlatform } from '../context.ts'
import type { Ctx, Emulator } from '../context.ts'
import { invalid } from '../errors.ts'
import { newId, now } from '../ids.ts'
import { paginate } from '../list.ts'
import { bool, list, mergeMetadata, reqStr, str } from '../params.ts'
import type { Obj } from '../store.ts'
/**
* Register an endpoint. `connect: true` receives events from connected
* accounts (they carry `account`); false receives the platform's own events.
* https://docs.stripe.com/api/webhook_endpoints/create
*/
export function addEndpoint(
emu: Emulator,
input: { url: string; enabledEvents: Array<string>; connect: boolean; secret?: string; description?: string | null; metadata?: Record<string, string> },
): Obj {
const id = newId('we')
const secret = input.secret ?? newId('whsec', 32)
const endpoint: Obj = {
id,
object: 'webhook_endpoint',
api_version: API_VERSION,
application: null,
connect: input.connect,
created: now(),
description: input.description ?? null,
enabled_events: input.enabledEvents,
livemode: false,
metadata: input.metadata ?? {},
status: 'enabled',
url: input.url,
}
emu.store.put(endpoint, emu.store.platformId)
emu.store.secrets[id] = secret
return { ...endpoint, secret }
}
export function createWebhookEndpoint(ctx: Ctx): Obj {
requirePlatform(ctx)
const enabledEvents = list(ctx.params, 'enabled_events')
if (enabledEvents.length === 0) throw invalid('Missing required param: enabled_events.', 'enabled_events', 'parameter_missing')
return addEndpoint(ctx.emu, {
url: reqStr(ctx.params, 'url'),
enabledEvents,
connect: bool(ctx.params, 'connect') ?? false,
description: str(ctx.params, 'description') ?? null,
metadata: mergeMetadata({}, ctx.params),
})
}
function getEndpoint(ctx: Ctx): Obj {
requirePlatform(ctx)
return ctx.emu.store.get('webhook_endpoint', ctx.path[0], ctx.owner)
}
export function retrieveWebhookEndpoint(ctx: Ctx): Obj {
return getEndpoint(ctx)
}
export function updateWebhookEndpoint(ctx: Ctx): Obj {
const endpoint = getEndpoint(ctx)
const url = str(ctx.params, 'url')
if (url) endpoint.url = url
const events = list(ctx.params, 'enabled_events')
if (events.length) endpoint.enabled_events = events
const disabled = bool(ctx.params, 'disabled')
if (disabled !== undefined) endpoint.status = disabled ? 'disabled' : 'enabled'
if (ctx.params.description !== undefined) endpoint.description = str(ctx.params, 'description') ?? null
if (ctx.params.metadata !== undefined) endpoint.metadata = mergeMetadata(endpoint.metadata, ctx.params)
return endpoint
}
export function deleteWebhookEndpoint(ctx: Ctx): Obj {
const endpoint = getEndpoint(ctx)
ctx.emu.store.remove(endpoint.id)
delete ctx.emu.store.secrets[endpoint.id]
return { id: endpoint.id, object: 'webhook_endpoint', deleted: true }
}
export function listWebhookEndpoints(ctx: Ctx): Obj {
requirePlatform(ctx)
return paginate(ctx.emu.store.list('webhook_endpoint', ctx.owner), ctx.params, '/v1/webhook_endpoints')
}examples/marketplace.tsShowHide
/**
* A marketplace flow against the emulator, start to finish, with a webhook
* receiver verifying every event. No network, no Stripe account.
*
* node examples/marketplace.ts
*/
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
import Stripe from 'stripe'
import { startEmulator } from '../src/index.ts'
const SECRET = 'whsec_example_only'
// 1. A webhook endpoint in "your app", verifying signatures like production code.
const verifier = new Stripe('sk_test_example')
const received: Array<string> = []
const app = createServer((req, res) => {
const chunks: Array<Buffer> = []
req.on('data', (c: Buffer) => chunks.push(c))
req.on('end', () => {
const event = verifier.webhooks.constructEvent(Buffer.concat(chunks), String(req.headers['stripe-signature']), SECRET)
received.push(event.account ? `${event.type} (${event.account})` : event.type)
res.writeHead(200).end()
})
})
await new Promise<void>((r) => app.listen(0, '127.0.0.1', r))
const webhookUrl = `http://127.0.0.1:${(app.address() as AddressInfo).port}/webhooks`
// 2. The emulator, and the official SDK pointed at it.
const emulator = await startEmulator({ port: 0, webhooks: [{ url: webhookUrl, secret: SECRET }] })
const stripe = new Stripe('sk_test_example', { host: 'localhost', port: emulator.port, protocol: 'http' })
const control = (path: string, body: object = {}, account?: string) =>
fetch(`${emulator.url}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(account ? { 'Stripe-Account': account } : {}) },
body: JSON.stringify(body),
}).then((r) => r.json())
// 3. Onboard a seller.
const seller = await stripe.accounts.create({
type: 'express',
country: 'IE',
capabilities: { card_payments: { requested: true }, transfers: { requested: true } },
})
const link = await stripe.accountLinks.create({
account: seller.id,
return_url: 'http://localhost:3000/return',
refresh_url: 'http://localhost:3000/refresh',
type: 'account_onboarding',
})
console.log('onboarding link', link.url)
await control(`/_emulator/accounts/${seller.id}/state`, { state: 'active' })
// 4. A customer pays 50.00 for an order; the platform keeps 5.00.
const pi = await stripe.paymentIntents.create({
amount: 5000,
currency: 'eur',
application_fee_amount: 500,
transfer_data: { destination: seller.id },
payment_method: 'pm_card_visa',
confirm: true,
metadata: { order_id: 'order_42' },
})
console.log('payment', pi.id, pi.status)
// 5. Half the order is refunded, pulling back the seller's share and the fee.
await stripe.refunds.create({ payment_intent: pi.id, amount: 2500, reverse_transfer: true, refund_application_fee: true })
// 6. Funds settle and the seller is paid out.
await control('/_emulator/balance/release', {}, seller.id)
const sellerBalance = await stripe.balance.retrieve({}, { stripeAccount: seller.id })
const payout = await stripe.payouts.create(
{ amount: sellerBalance.available[0].amount, currency: 'eur' },
{ stripeAccount: seller.id },
)
await control(`/_emulator/payouts/${payout.id}/paid`)
const platform = await stripe.balance.retrieve()
console.log('platform balance', platform.available[0].amount + platform.pending[0].amount)
console.log('seller paid out', payout.amount)
await emulator.waitForWebhooks()
console.log(`${received.length} webhooks verified:`)
for (const r of received) console.log(' ', r)
await emulator.close()
app.close()Licence
MIT. Use it in anything, commercial or not. No warranty: it is a test tool, so check anything that matters against Stripe's test mode too.
Licence text
MIT License Copyright (c) 2026 David Ewins Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.