@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-capturenpm · 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.tsJSDoc, 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— Thestripe-signatureheader 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'splatformKeyand the frontend's provider name stay the same. PasscreatePaymentsCaptureProvider({ providerName: 'capture' })to bond it under its own name instead. -
Checkout has no hosted payment page.
updateSubscriptionfor a user with no live subscription creates a checkout session and returns, ascheckoutUrl, the app's own return URL (APP_ORIGIN/plan-updated?provider=stripe&sessionId=cs_capture_…, fromresolveCheckoutRedirectUrls). By default the session is completed as paid at once, so the return page's verify call finds an active subscription. PassautoCompleteCheckout: falseto drive it yourself withprovider.completeCheckout(sessionId, { outcome: 'paid' | 'declined' })— until thenverifySubscriptionreturnsnull, as it would for an unpaid session. -
Behaviour mirrors the Stripe bond, including its refusals:
verifySubscriptionaccepts acs_orsub_id and returnsnullunless the subscription is active/trialing with an unelapsed period;updateSubscriptionchanges the plan in place for a paid-up subscription, refuses ({ updated: false }) one that is past due, and starts a new checkout otherwise;cancelSubscriptioncancels at period end. -
Refused, not faked:
verifyReceipt,verifyPurchaseandparseNotificationreject with aPaymentsCaptureError(errorKey'payments.capture.unsupported',statusCode501). 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.
createPortalSessionreturns the return URL itself (andnullfor a user with no customer), so the round trip lands straight back in the app. Cancel withcancelSubscriptionor the ledger helpers. -
Webhooks use Stripe's signature scheme and event shape. The secret is
options.webhookSecret, elsePAYMENTS_CAPTURE_WEBHOOK_SECRET, else the built-inwhsec_capture_dev_secret.provider.buildWebhookRequest()returns{ rawBody, headers }— POST those exact bytes to the app's webhook route (/users/payment-notification/stripe), wherehandleWebhookEventverifies and parses them. Because the scheme is Stripe's, the same request also passes the real Stripe bond when itsSTRIPE_WEBHOOK_SECRETis 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 withbuildWebhookRequestand send it when the test wants it. ReadwebhookDeliveriesin 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 passledger: new PaymentsCaptureLedger(). -
Saved cards:
createSetupIntentcreates a customer when none is given; there is no card form, so confirm the SetupIntent withprovider.confirmSetupIntent(id, { brand, last4 }), then send the returnedpm_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 untilprovider.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/stripeis accepted, and its effect shows (e.g. afterfailRenewal(sub.id), the account shows past due). - Canceling from the account screen ends the subscription at period end and the status says so.