David Ewins

Stripe emulator

A local Stripe API server that keeps state, with Connect support, so marketplace tests can run offline. MIT licence.

src/index.tsShow
src/index.ts
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.tsShow
src/cli.ts
#!/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.tsShow
src/server.ts
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.tsShow
src/routes.ts
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.tsShow
src/context.ts
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.tsShow
src/control.ts
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.tsShow
src/errors.ts
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.tsShow
src/expand.ts
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.tsShow
src/fees.ts
/**
 * 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.tsShow
src/form.ts
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.tsShow
src/ids.ts
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.tsShow
src/ledger.ts
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.tsShow
src/list.ts
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.tsShow
src/params.ts
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.tsShow
src/store.ts
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.tsShow
src/webhooks.ts
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.tsShow
src/resources/accounts.ts
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.tsShow
src/resources/application-fees.ts
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.tsShow
src/resources/balance.ts
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.tsShow
src/resources/charges.ts
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.tsShow
src/resources/disputes.ts
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.tsShow
src/resources/events.ts
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.tsShow
src/resources/payment-intents.ts
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.tsShow
src/resources/payouts.ts
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.tsShow
src/resources/refunds.ts
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.tsShow
src/resources/transfers.ts
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.tsShow
src/resources/webhook-endpoints.ts
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.tsShow
examples/marketplace.ts
/**
 * 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 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.