@molecule/api-payments-capture

Provider bond · payments · API (Node) · v1.0.1 · Apache-2.0

In-memory payments provider for the dev, browser and offline profile — simulates a hosted-checkout vendor with no network calls: checkouts complete, subscriptions activate, refunds record, and signed webhook events can be built locally. Answers to the stripe name by default so app code is identical; production swaps the stripe bond in.

npm install @molecule/api-payments-capture

npm · Source on GitHub · Implements @molecule/api-payments

How it works

@molecule/api-payments-capture is a provider bond on the API (Node) side: it implements the payments core interface (@molecule/api-payments) with a concrete vendor or library behind it.

Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.

import { bond } from '@molecule/api-bond'
import { provider } from '@molecule/api-payments-capture'

// api/src/bonds/payments.ts — dev profile. Production bonds
// `paymentProvider` from '@molecule/api-payments-stripe' on the same line.
bond('payments', 'stripe', provider)

Works with: @molecule/api-payments

Secrets: PAYMENTS_CAPTURE_WEBHOOK_SECRET (optional)

Live demos

Running apps built with Molecule that install this package — open the app or study the template.

Reference

Payments capture provider for molecule.dev — a payments vendor simulated in memory, for dev, in-browser and offline environments.

It implements the whole PaymentProviderInterface from @molecule/api-payments without a single network call: a checkout completes, a subscription activates, renews, fails a renewal, cancels, a refund is recorded, and a signed webhook can be built and delivered to the app's own webhook route. Production bonds @molecule/api-payments-stripe in its place, and the app code does not change.

Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit src/index.ts JSDoc, not this file.

Quick Start

import { bond } from '@molecule/api-bond'
import { provider } from '@molecule/api-payments-capture'

// api/src/bonds/payments.ts — dev profile. Production bonds
// `paymentProvider` from '@molecule/api-payments-stripe' on the same line.
bond('payments', 'stripe', provider)
// e2e: subscribe, then drive a failed renewal through the app's webhook route.
import {
  createPaymentsCaptureProvider,
  PaymentsCaptureLedger,
} from '@molecule/api-payments-capture'

const payments = createPaymentsCaptureProvider({ ledger: new PaymentsCaptureLedger() })
const result = await payments.updateSubscription({ userId: 'u1', newProductId: 'price_pro' })
// result.checkoutUrl → the app's /plan-updated page with sessionId=cs_capture_000001

const sub = payments.getCaptured().subscriptions[0]
payments.failRenewal(sub.id)
const request = payments.buildWebhookRequest({
  type: 'customer.subscription.updated',
  subscriptionId: sub.id,
})
await fetch(`${apiOrigin}/users/payment-notification/stripe`, {
  method: 'POST',
  headers: request.headers,
  body: request.rawBody,
})

Type

provider

Installation

npm install @molecule/api-payments-capture @molecule/api-payments

API

Interfaces

CapturePrice

One entry of the optional price catalogue.

interface CapturePrice {
  /** The product this price belongs to. Defaults to the price id itself. */
  productId?: string
  /** Unit amount in the currency's minor unit (cents). */
  unitAmount?: number
  /** ISO currency code, lowercase. Defaults to `'usd'`. */
  currency?: string
  /** Billing interval. Defaults to `'month'`. */
  interval?: CaptureInterval
}

CapturedCustomer

A simulated customer.

interface CapturedCustomer {
  /** Customer id (`cus_capture_…`). */
  id: string
  /** The app user the customer was created for, when known. */
  userId?: string
  /** Creation time (ms). */
  createdAt: number
}

CapturedCheckoutSession

A simulated hosted-checkout session.

interface CapturedCheckoutSession {
  /** Session id (`cs_capture_…`). */
  id: string
  /** The app user who started checkout. */
  userId: string
  /** The customer the session bills. */
  customerId: string
  /** Price being purchased. */
  priceId: string
  /** Product of that price. */
  productId: string
  /** Units (seats) being purchased. */
  quantity: number
  /** `open` until completed, `complete` once paid, `expired` when declined or abandoned. */
  status: 'open' | 'complete' | 'expired'
  /** The subscription created when the session completed. */
  subscriptionId?: string
  /** URL handed back as `checkoutUrl` (the app's success URL with the session id filled in). */
  url: string
  /** Where a completed checkout returns the buyer. */
  successUrl: string
  /** Where an abandoned checkout returns the buyer. */
  cancelUrl: string
  /** Creation time (ms). */
  createdAt: number
}

CapturedSubscription

A simulated subscription.

interface CapturedSubscription {
  /** Subscription id (`sub_capture_…`). */
  id: string
  /** Subscription item id (`si_capture_…`). */
  itemId: string
  /** The billed customer. */
  customerId: string
  /** The app user the subscription belongs to. */
  userId: string
  /** Current price. */
  priceId: string
  /** Product of the current price. */
  productId: string
  /** Units (seats) billed. */
  quantity: number
  /** Raw status. */
  status: CaptureSubscriptionStatus
  /** Current period start (Unix seconds). */
  currentPeriodStart: number
  /** Current period end (Unix seconds). */
  currentPeriodEnd: number
  /** Whether the subscription ends at the period end instead of renewing. */
  cancelAtPeriodEnd: boolean
  /** When it was canceled (Unix seconds), if it was. */
  canceledAt: number | null
  /** Creation time (ms). */
  createdAt: number
}

CapturedSetupIntent

A simulated SetupIntent (saved-card flow).

interface CapturedSetupIntent {
  /** SetupIntent id (`seti_capture_…`). */
  id: string
  /** Client secret returned to the caller. */
  clientSecret: string
  /** The customer the card will attach to. */
  customerId: string
  /** `requires_payment_method` until confirmed with a card, then `succeeded`. */
  status: 'requires_payment_method' | 'succeeded'
  /** The payment method created on confirmation. */
  paymentMethodId?: string
  /** Metadata passed at creation. */
  metadata: Record<string, string>
  /** Creation time (ms). */
  createdAt: number
}

CapturedPaymentMethod

A simulated saved card.

interface CapturedPaymentMethod {
  /** Payment method id (`pm_capture_…`). */
  id: string
  /** The customer it is attached to, or `null` once detached. */
  customerId: string | null
  /** Card brand. */
  brand: string
  /** Last four digits. */
  last4: string
  /** Expiry month (1–12). */
  expMonth: number
  /** Four-digit expiry year. */
  expYear: number
  /** Creation time (ms). */
  createdAt: number
}

CapturedRefund

A recorded refund.

interface CapturedRefund {
  /** Refund id (`re_capture_…`). */
  id: string
  /** The refunded subscription. */
  subscriptionId: string
  /** Amount refunded in minor units, or `null` when no price amount is known. */
  amount: number | null
  /** Currency of the amount. */
  currency: string
  /** Free-text reason, when given. */
  reason?: string
  /** Whether the refund also canceled the subscription immediately. */
  canceledSubscription: boolean
  /** Creation time (ms). */
  createdAt: number
}

CapturedPortalSession

A recorded billing-portal session.

interface CapturedPortalSession {
  /** Portal session id (`bps_capture_…`). */
  id: string
  /** The app user who opened it. */
  userId: string
  /** The customer it was opened for. */
  customerId: string
  /** URL returned to the caller. */
  url: string
  /** Creation time (ms). */
  createdAt: number
}

CapturedWebhookEvent

A webhook event built by buildWebhookRequest.

interface CapturedWebhookEvent {
  /** Event id (`evt_capture_…`). */
  id: string
  /** Raw event type (e.g. `customer.subscription.updated`). */
  type: string
  /** The exact raw body that was signed. */
  rawBody: string
  /** The signature header value. */
  signature: string
  /** Creation time (ms). */
  createdAt: number
}

CapturedWebhookDelivery

A webhook request received by handleWebhookEvent.

interface CapturedWebhookDelivery {
  /** Whether the signature verified. */
  verified: boolean
  /** The raw event type, when the body parsed. */
  type?: string
  /** The event id, when the body parsed. */
  eventId?: string
  /** The normalized result returned to the caller (`null` when rejected). */
  result: WebhookEvent | null
  /** Receipt time (ms). */
  receivedAt: number
}

CapturedOperation

One call made on the provider interface.

interface CapturedOperation {
  /** Interface method name. */
  method: string
  /** Arguments it was called with. */
  args: unknown
  /** What the call returned, or the error message it threw. */
  outcome: 'ok' | 'null' | 'refused' | 'error'
  /** Call time (ms). */
  at: number
}

CapturedPayments

Snapshot of everything the capture ledger holds.

interface CapturedPayments {
  /** Customers, in creation order. */
  customers: CapturedCustomer[]
  /** Checkout sessions, in creation order. */
  checkoutSessions: CapturedCheckoutSession[]
  /** Subscriptions, in creation order. */
  subscriptions: CapturedSubscription[]
  /** SetupIntents, in creation order. */
  setupIntents: CapturedSetupIntent[]
  /** Saved cards, in creation order. */
  paymentMethods: CapturedPaymentMethod[]
  /** Refunds, in creation order. */
  refunds: CapturedRefund[]
  /** Billing-portal sessions, in creation order. */
  portalSessions: CapturedPortalSession[]
  /** Webhook events built for local delivery. */
  webhookEvents: CapturedWebhookEvent[]
  /** Webhook requests received by `handleWebhookEvent`. */
  webhookDeliveries: CapturedWebhookDelivery[]
  /** Every interface call, in order. */
  operations: CapturedOperation[]
}

PaymentsCaptureOptions

Options for createPaymentsCaptureProvider.

interface PaymentsCaptureOptions {
  /**
   * Name the provider reports and puts in checkout redirect URLs. Defaults to
   * `'stripe'`, so it can be bonded under the name the production Stripe bond
   * uses and the app's routes (`/verify-payment/stripe`) do not change.
   */
  providerName?: string
  /**
   * Webhook signing secret. Defaults to `PAYMENTS_CAPTURE_WEBHOOK_SECRET`,
   * then the built-in dev secret `whsec_capture_dev_secret`.
   */
  webhookSecret?: string
  /** Webhook replay window in seconds. Defaults to 300. */
  webhookToleranceSeconds?: number
  /**
   * Whether a new checkout completes (as paid) the moment it is created.
   * Defaults to `true`. Set `false` to complete or decline it yourself with
   * `completeCheckout()`.
   */
  autoCompleteCheckout?: boolean
  /** Price catalogue, keyed by price id. Unlisted prices are accepted unless `strictCatalog` is set. */
  catalog?: Record<string, CapturePrice>
  /** Reject prices missing from `catalog`, as a real vendor would. Defaults to `false`. */
  strictCatalog?: boolean
  /** Clock, in milliseconds. Defaults to `Date.now`. */
  now?: () => number
  /** Ledger to record into. Defaults to the shared module ledger. */
  ledger?: PaymentsCaptureLedgerStore
}

PaymentsCaptureLedgerStore

Storage used by the capture provider. PaymentsCaptureLedger is the implementation; the interface exists so the type of options.ledger does not depend on the class.

interface PaymentsCaptureLedgerStore {
  /** Mutable record lists. */
  readonly records: CapturedPayments
  /** Issues the next deterministic id for a prefix (`sub` → `sub_capture_000001`). */
  nextId(prefix: string): string
  /** Returns a deep copy of every record. */
  snapshot(): CapturedPayments
  /** Removes every record and resets id counters. */
  clear(): void
}

BuildWebhookRequestInput

Input for buildWebhookRequest.

interface BuildWebhookRequestInput {
  /** Raw event type, e.g. `customer.subscription.updated`. */
  type: string
  /** Subscription whose current state becomes `data.object` (for `customer.subscription.*`). */
  subscriptionId?: string
  /** Checkout session whose current state becomes `data.object` (for `checkout.session.*`). */
  checkoutSessionId?: string
  /** Explicit `data.object`, for any other event type. */
  object?: Record<string, unknown>
  /** Sign at this time (ms) instead of now — for replay-window tests. */
  signedAt?: number
  /** Sign with this secret instead of the provider's — for forged-signature tests. */
  secret?: string
}

CaptureWebhookRequest

A webhook request ready to POST to an app's webhook route.

interface CaptureWebhookRequest {
  /** The exact raw JSON body — send these bytes unchanged. */
  rawBody: string
  /** Same as `rawBody`; lets the object be passed straight to `handleWebhookEvent`. */
  body: string
  /** Headers to send (`stripe-signature`, `content-type`). */
  headers: Record<string, string>
  /** The event object that was serialized. */
  event: Record<string, unknown>
}

CaptureCardInput

Card details for confirmSetupIntent.

interface CaptureCardInput {
  /** Card brand. Defaults to `'visa'`. */
  brand?: string
  /** Last four digits. Defaults to `'4242'`. */
  last4?: string
  /** Expiry month. Defaults to 12. */
  expMonth?: number
  /** Expiry year. Defaults to three years from now. */
  expYear?: number
}

CaptureRefundInput

Input for refund.

interface CaptureRefundInput {
  /** The subscription to refund. */
  subscriptionId: string
  /** Amount in minor units. Defaults to the catalogue price × quantity, else `null`. */
  amount?: number
  /** Free-text reason. */
  reason?: string
  /** Also cancel the subscription immediately. Defaults to `true`. */
  cancelSubscription?: boolean
}

PaymentsCaptureProvider

The capture provider: the full PaymentProviderInterface plus helpers that drive the simulated vendor (complete a checkout, renew, fail a renewal, refund, build a signed webhook) and read its ledger.

interface PaymentsCaptureProvider extends PaymentProviderInterface {
  /** Always `true` — lets app code and tests tell the simulator apart. */
  readonly isCapture: true
  /** The ledger this provider records into. */
  readonly ledger: PaymentsCaptureLedgerStore
  /** The webhook secret requests are signed and verified with. */
  readonly webhookSecret: string
  /**
   * Completes an open checkout. `paid` (default) creates an active
   * subscription; `declined` expires the session with nothing created.
   */
  completeCheckout(
    sessionId: string,
    options?: { outcome?: 'paid' | 'declined' },
  ): CapturedCheckoutSession
  /** Advances a subscription by one period, as a successful renewal payment would. */
  renewSubscription(subscriptionId: string): CapturedSubscription
  /** Marks a subscription `past_due`, as a failed renewal payment would. */
  failRenewal(subscriptionId: string): CapturedSubscription
  /** Records a refund (and by default cancels the subscription now). */
  refund(input: CaptureRefundInput): CapturedRefund
  /** Confirms a SetupIntent with a simulated card and returns the saved card. */
  confirmSetupIntent(setupIntentId: string, card?: CaptureCardInput): CapturedPaymentMethod
  /** Builds a signed webhook request in the vendor's event shape. */
  buildWebhookRequest(input: BuildWebhookRequestInput): CaptureWebhookRequest
  /** Returns a deep copy of this provider's ledger. */
  getCaptured(): CapturedPayments
  /** Empties this provider's ledger. */
  clear(): void
}

VerifyCaptureWebhookOptions

Options for verifyCaptureWebhookSignature.

interface VerifyCaptureWebhookOptions {
  /** Maximum age of the signature in seconds. Defaults to 300. */
  toleranceSeconds?: number
  /** Current time in milliseconds. Defaults to `Date.now()`. */
  now?: number
}

Types

PaymentsCaptureErrorKey

Machine-readable keys carried by PaymentsCaptureError.

  • 'payments.capture.unsupported' (501) — the method belongs to a flow this simulator cannot reproduce honestly (App Store / Play receipts and their server notifications are signed by Apple/Google).
  • 'payments.capture.notFound' (404) — a ledger helper was given an id the ledger never issued.
  • 'payments.capture.invalidState' (409) — a ledger helper was asked to do something the record's current state does not allow (completing a checkout twice, renewing a canceled subscription).
type PaymentsCaptureErrorKey =
  'payments.capture.unsupported' | 'payments.capture.notFound' | 'payments.capture.invalidState'

CaptureSubscriptionStatus

Raw subscription status, in the vendor's (Stripe's) vocabulary.

type CaptureSubscriptionStatus =
  | 'active'
  | 'trialing'
  | 'past_due'
  | 'unpaid'
  | 'canceled'
  | 'incomplete'
  | 'incomplete_expired'
  | 'paused'

CaptureInterval

Billing interval of a captured price.

type CaptureInterval = 'day' | 'week' | 'month' | 'year'

Classes

PaymentsCaptureError

Error thrown by the capture provider and its ledger helpers.

Carries statusCode + errorKey (the TaggedError convention from @molecule/api-payments) so a route can surface the real cause.

class PaymentsCaptureError extends Error implements TaggedError {
  /** HTTP status the error should be reported with. */
  readonly statusCode: number

  /** Machine-readable key naming the cause. */
  readonly errorKey: PaymentsCaptureErrorKey

  /**
   * Creates a capture error.
   *
   * @param errorKey - Which kind of failure this is.
   * @param message - A sentence naming what failed and why.
   */
  constructor(errorKey: PaymentsCaptureErrorKey, message: string) {
    super(message)
    this.name = 'PaymentsCaptureError'
    this.errorKey = errorKey
    this.statusCode = STATUS_BY_KEY[errorKey]
  }
}

PaymentsCaptureLedger

In-memory store of everything the simulated vendor has done.

Ids are deterministic: each prefix has its own counter, so a fresh ledger always issues cus_capture_000001, then cus_capture_000002, and clear() starts the sequence again. The _capture_ infix makes a simulated id recognisable anywhere it ends up (logs, a dev database), while the leading vendor prefix (cs_, sub_, cus_) keeps app code that branches on it working unchanged.

class PaymentsCaptureLedger implements PaymentsCaptureLedgerStore {
  /** Mutable record lists. Read them through `PaymentsCaptureLedger.snapshot`. */
  readonly records: CapturedPayments = emptyRecords()

  private counters = new Map<string, number>()

  /**
   * Issues the next id for a prefix.
   *
   * @param prefix - Vendor prefix without the underscore (`sub`, `cs`, `cus`).
   * @returns The id, e.g. `sub_capture_000001`.
   */
  nextId(prefix: string): string {
    const next = (this.counters.get(prefix) ?? 0) + 1
    this.counters.set(prefix, next)
    return `${prefix}_capture_${String(next).padStart(6, '0')}`
  }

  /**
   * Returns a deep copy of every record, safe to hold or mutate.
   *
   * @returns The snapshot.
   */
  snapshot(): CapturedPayments {
    return structuredClone(this.records)
  }

  /** Removes every record and resets every id counter. */
  clear(): void {
    for (const key of Object.keys(this.records) as Array<keyof CapturedPayments>) {
      this.records[key].length = 0
    }
    this.counters.clear()
  }
}

Functions

isPaymentsCaptureError(error, errorKey)

Checks whether a caught value is a PaymentsCaptureError, optionally of one specific kind.

function isPaymentsCaptureError(error: unknown, errorKey?: PaymentsCaptureErrorKey): boolean
  • error — The caught value.
  • errorKey — When given, the error must carry this key.

Returns: true when error is a capture error (of that kind).

getCapturedPayments(ledger)

Returns a deep copy of everything captured in the shared ledger (or the one given) — for tests and a dev UI.

function getCapturedPayments(ledger?: PaymentsCaptureLedgerStore): CapturedPayments
  • ledger — Ledger to read. Defaults to the shared ledger.

Returns: The captured records.

clearCapturedPayments(ledger)

Empties the shared ledger (or the one given) and resets its id counters.

function clearCapturedPayments(ledger?: PaymentsCaptureLedgerStore): void
  • ledger — Ledger to clear. Defaults to the shared ledger.

createPaymentsCaptureProvider(options)

Creates a payments capture provider with its own options.

Every provider created without options.ledger records into the shared ledger read by getCapturedPayments(); pass a new PaymentsCaptureLedger() to isolate one (e.g. per test).

function createPaymentsCaptureProvider(options?: PaymentsCaptureOptions): PaymentsCaptureProvider
  • options — Provider name, webhook secret, catalogue, clock, ledger.

Returns: A provider implementing the whole payments interface plus helpers.

signCaptureWebhookPayload(payload, secret, timestampSeconds)

Builds a Stripe-format signature header for a raw payload.

function signCaptureWebhookPayload(
  payload: string,
  secret: string,
  timestampSeconds: number,
): string
  • payload — The exact raw body that will be sent.
  • secret — The webhook secret.
  • timestampSeconds — Unix time in seconds to sign at.

Returns: The header value, t=<timestamp>,v1=<signature>.

verifyCaptureWebhookSignature(payload, header, secret, options)

Verifies a Stripe-format signature header against a raw payload.

Rejects a missing/malformed header, a signature that does not match any v1 entry (compared in constant time), and a timestamp outside the tolerance window (replay protection).

function verifyCaptureWebhookSignature(
  payload: string,
  header: string,
  secret: string,
  options?: VerifyCaptureWebhookOptions,
): boolean
  • payload — The raw request body exactly as received.
  • header — The stripe-signature header value.
  • secret — The webhook secret.
  • options — Tolerance and clock overrides.

Returns: true when the signature is valid and fresh.

mapCaptureEventType(eventType)

Maps a Stripe event type to the simplified type the payment notification handler acts on. Identical to @molecule/api-payments-stripe's mapping, so an app sees the same WebhookEvent.type from either bond.

function mapCaptureEventType(eventType: string): string
  • eventType — The raw event type (e.g. customer.subscription.deleted).

Returns: The simplified type (created, renewed, canceled, …) or the raw type when it is not a subscription lifecycle event.

normalizeCaptureStatus(rawStatus)

Maps a raw (Stripe-vocabulary) subscription status to the provider-agnostic SubscriptionStatus, with the same table the Stripe bond uses.

function normalizeCaptureStatus(rawStatus: string | undefined): SubscriptionStatus
  • rawStatus — The raw status string.

Returns: The normalized status ('unknown' when unrecognized).

parseCaptureWebhookEvent(event)

Parses a verified event body into the core WebhookEvent, reading the same fields @molecule/api-payments-stripe's handleWebhookEvent reads.

function parseCaptureWebhookEvent(event: {
  type: string
  data?: { object?: Record<string, unknown> }
}): WebhookEvent
  • event — The parsed event JSON ({ type, data: { object } }).

Returns: The normalized webhook event.

Constants

defaultPaymentsCaptureLedger

The ledger shared by the default provider and the module-level helpers.

const defaultPaymentsCaptureLedger: PaymentsCaptureLedger

provider

Default payments capture provider: named 'stripe', auto-completing checkouts, recording into the shared ledger.

const provider: PaymentsCaptureProvider

paymentProvider

Alias of provider, matching the export name of @molecule/api-payments-stripe so the bond line swaps by import alone.

const paymentProvider: PaymentProviderInterface

CAPTURE_SIGNATURE_HEADER

Header that carries the webhook signature (the Stripe header name).

const CAPTURE_SIGNATURE_HEADER: 'stripe-signature'

DEFAULT_CAPTURE_WEBHOOK_SECRET

Dev webhook secret used when none is configured. Never a production value.

const DEFAULT_CAPTURE_WEBHOOK_SECRET: 'whsec_capture_dev_secret'

DEFAULT_WEBHOOK_TOLERANCE_SECONDS

Default replay window, in seconds (Stripe's default tolerance).

const DEFAULT_WEBHOOK_TOLERANCE_SECONDS: 300

Core Interface

Implements @molecule/api-payments interface.

Bond Wiring

Setup function to register this provider with the bond system:

import { bond } from '@molecule/api-bond'
import { paymentProvider } from '@molecule/api-payments-capture'

export function setupPaymentsCapture(): void {
  bond('payments', 'capture', paymentProvider)
}

Injection Notes

Requirements

Peer dependencies:

  • @molecule/api-payments ^1.2.1

Environment Variables

  • PAYMENTS_CAPTURE_WEBHOOK_SECRET (optional) — Local webhook signing secret — default: whsec_capture_dev_secret
    • Setup: Signs and verifies the webhook events the capture provider builds locally. Dev only.
    • Example: whsec_capture_dev_secret

Runtime Dependencies

  • @molecule/api-payments

  • Dev only — it grants plans without taking money. Bond it only in the dev / browser / offline profile. Every id it issues carries a _capture_ infix (cus_capture_000001, sub_capture_000001, cs_capture_000001) so a simulated record is recognisable wherever it ends up; the leading vendor prefix (cs_, sub_) is kept so app code that branches on it behaves as it does against Stripe.

  • It answers to the name 'stripe' by default, so it can be bonded under the name production uses: bond('payments', 'stripe', provider). The app's verify route (/users/:id/verify-payment/stripe), the plan catalogue's platformKey and the frontend's provider name stay the same. Pass createPaymentsCaptureProvider({ providerName: 'capture' }) to bond it under its own name instead.

  • Checkout has no hosted payment page. updateSubscription for a user with no live subscription creates a checkout session and returns, as checkoutUrl, the app's own return URL (APP_ORIGIN/plan-updated?provider=stripe&sessionId=cs_capture_…, from resolveCheckoutRedirectUrls). By default the session is completed as paid at once, so the return page's verify call finds an active subscription. Pass autoCompleteCheckout: false to drive it yourself with provider.completeCheckout(sessionId, { outcome: 'paid' | 'declined' }) — until then verifySubscription returns null, as it would for an unpaid session.

  • Behaviour mirrors the Stripe bond, including its refusals: verifySubscription accepts a cs_ or sub_ id and returns null unless the subscription is active/trialing with an unelapsed period; updateSubscription changes the plan in place for a paid-up subscription, refuses ({ updated: false }) one that is past due, and starts a new checkout otherwise; cancelSubscription cancels at period end.

  • Refused, not faked: verifyReceipt, verifyPurchase and parseNotification reject with a PaymentsCaptureError (errorKey 'payments.capture.unsupported', statusCode 501). App Store and Play receipts and their store notifications are signed by Apple and Google and cannot be produced or checked offline; this provider simulates a hosted-checkout vendor (verifyFlow: 'subscription', notificationFlow: 'webhook').

  • No hosted billing portal either. createPortalSession returns the return URL itself (and null for a user with no customer), so the round trip lands straight back in the app. Cancel with cancelSubscription or the ledger helpers.

  • Webhooks use Stripe's signature scheme and event shape. The secret is options.webhookSecret, else PAYMENTS_CAPTURE_WEBHOOK_SECRET, else the built-in whsec_capture_dev_secret. provider.buildWebhookRequest() returns { rawBody, headers } — POST those exact bytes to the app's webhook route (/users/payment-notification/stripe), where handleWebhookEvent verifies and parses them. Because the scheme is Stripe's, the same request also passes the real Stripe bond when its STRIPE_WEBHOOK_SECRET is set to the dev secret. Re-serializing the body breaks the signature, exactly as it does with Stripe.

  • Webhooks are not sent on their own. A ledger change (completing a checkout, failRenewal, refund) does not deliver an event; build one with buildWebhookRequest and send it when the test wants it. Read webhookDeliveries in the ledger to see what the handler accepted.

  • The ledger is in memory and per process. getCapturedPayments() returns a deep copy of everything (customers, checkout sessions, subscriptions, SetupIntents, cards, refunds, portal sessions, built and received webhooks, and every interface call); clearCapturedPayments() empties it and restarts the id sequence. A restart loses it. For isolated tests pass ledger: new PaymentsCaptureLedger().

  • Saved cards: createSetupIntent creates a customer when none is given; there is no card form, so confirm the SetupIntent with provider.confirmSetupIntent(id, { brand, last4 }), then send the returned pm_capture_… id to your API as the card form would.

  • Nothing here is recorded to @molecule/api-activity: the ledger is the inspection surface.

E2E Tests

Integration checklist for an app bonded to this provider — drive the real UI (live preview, no mocks) and check every box. This provider makes no network call, so there are no test cards and no captured deliveries to read: YOU play the vendor with the provider's own methods.

  • Upgrading from the pricing or billing surface lands on the app's own /plan-updated?provider=stripe&sessionId=cs_capture_… page, and the plan shows as active after its verify call (checkouts complete as paid by default).
  • With autoCompleteCheckout: false, the plan stays unpaid until provider.completeCheckout(sessionId, { outcome: 'paid' }), and { outcome: 'declined' } leaves the user on their old plan with a sane UI.
  • A webhook built with provider.buildWebhookRequest(...) and POSTed byte for byte to /users/payment-notification/stripe is accepted, and its effect shows (e.g. after failRenewal(sub.id), the account shows past due).
  • Canceling from the account screen ends the subscription at period end and the status says so.