@crvouga/mockingbird-service-stripe 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,27 @@
1
1
  # Changelog — @crvouga/mockingbird-service-stripe
2
2
 
3
- ## 0.3.0 (2026-09-21)
3
+ ## 0.5.0 (2026-09-22)
4
+
5
+ ### Features
6
+
7
+ - agent issue reporting for parity, features and new services; in-process medplum mock ([fc3a53b](https://github.com/crvouga/mockingbird/commit/fc3a53be83e2840e55aef78970326c27982311a8))
8
+
9
+ ### Dependencies
10
+
11
+ - `@crvouga/mockingbird-service-sqlite`
12
+
13
+ ## 0.4.0 (2026-09-21)
14
+
15
+ ### Features
16
+
17
+ - add vendor mocks for the full service catalog ([f870287](https://github.com/crvouga/mockingbird/commit/f8702874c807b1be4ce4ae2c5b46c8fd59a5e8eb))
18
+
19
+ ### Fixes and improvements
20
+
21
+ - give each vendor-SDK README a self-contained ts Usage example ([27b3c7d](https://github.com/crvouga/mockingbird/commit/27b3c7dbe8786355c82fb8835d1ac84abdb25101))
22
+ - fence vendor-SDK README examples as js, as main's stripe README does ([6fc55a7](https://github.com/crvouga/mockingbird/commit/6fc55a76235d6d3f2f02c952a6680066d054ff30))
23
+
24
+ ## 0.3.0 (2026-09-20)
4
25
 
5
26
  ### Features
6
27
 
package/README.md CHANGED
@@ -1,21 +1,16 @@
1
1
  # @crvouga/mockingbird-service-stripe
2
2
 
3
- Stateful, in-process mock of the [Stripe API](https://docs.stripe.com/api) for test suites. It is
4
- driven by a vendored subset of Stripe's OpenAPI contract and verified by differential property tests
5
- against Stripe test mode. Covered: customers (including search and balance transactions), payment
6
- methods, payment and setup intents, charges, refunds, disputes (read-only), checkout sessions,
7
- invoices and invoice items, subscriptions and subscription schedules, coupons and promotion codes,
8
- products, prices, and an event ledger (`/v1/events`) with a webhook hook.
9
-
10
- Use it when server-side code talks to Stripe through `fetch` or stripe-node and you want the suite
11
- to run offline with no `api.stripe.com` egress. It does not serve Stripe.js or hosted checkout
12
- (`js.stripe.com`, `checkout.stripe.com`); browser-driven checkout still needs real Stripe test mode.
13
-
14
- - Operation coverage (88 of 108 operations in the vendored spec, with reasons for each gap):
3
+ Stateful, in-process mock of the [Stripe API](https://docs.stripe.com/api) for test suites: accounts
4
+ chosen by API key, customers and balances, payment methods, payment and setup intents, charges,
5
+ refunds, disputes, checkout (with a hosted page and a Stripe.js stand-in), invoices, subscriptions
6
+ that renew when the clock moves, subscription schedules, coupons, promotion codes, products,
7
+ prices, test clocks, webhook endpoints, the balance ledger and the event log — with signed webhooks
8
+ fanned out to every matching endpoint. Responses are rendered at the caller's `Stripe-Version`
9
+ (`2024-06-20`, `2025-02-24.acacia`, or the vendored latest), and the whole surface is verified by
10
+ differential property tests against Stripe test mode.
11
+
12
+ - Operation coverage (111 of 115 operations in the vendored spec, with reasons for each gap):
15
13
  [SUPPORT.md](https://github.com/crvouga/mockingbird/blob/main/packages/service/stripe/SUPPORT.md)
16
- - Scope and proof: [stripe-drop-in.md](https://github.com/crvouga/mockingbird/blob/main/packages/service/stripe/docs/stripe-drop-in.md)
17
- · Consumer wiring checklist: [qa-followon.md](https://github.com/crvouga/mockingbird/blob/main/packages/service/stripe/docs/qa-followon.md)
18
- · [QA coverage](https://github.com/crvouga/mockingbird/blob/main/packages/service/stripe/docs/qa-coverage.md)
19
14
  - Stripe API reference: https://docs.stripe.com/api · Upstream OpenAPI: https://github.com/stripe/openapi
20
15
 
21
16
  ## Install
@@ -25,274 +20,302 @@ npm install -D @crvouga/mockingbird-service-stripe
25
20
  ```
26
21
 
27
22
  ESM only. Requires Node >= 22 or Bun >= 1.2. No native dependencies: state lives in an in-memory
28
- SQLite engine (pure TypeScript, bundled in). To serve it over HTTP run `npx mockingbird-stripe serve`, or
29
- use `createServer` from `./server` (Node) or `createRuntime` with any Fetch server.
23
+ SQLite engine (pure TypeScript, bundled in).
30
24
 
31
25
  ## Usage
32
26
 
33
- Behaviour the examples rely on (all from the source):
34
-
35
- - **Any host works.** Routing uses only the path (`/v1/...`), so `https://api.stripe.com`,
36
- `http://127.0.0.1:<port>` or any made-up origin is fine.
37
- - **Auth is required.** Every request needs `Authorization: Bearer <key>` where the key matches
38
- `^(sk|rk)_test_[A-Za-z0-9]+$`. Missing header or any other shape (including `sk_live_...` and
39
- keys with extra underscores such as `sk_test_my_key`) returns Stripe's 401 error body.
40
- - **One key = one account.** State is partitioned by bearer key, so an object created with
41
- `sk_test_a` is `resource_missing` (404) under `sk_test_b`.
42
- - Request bodies are `application/x-www-form-urlencoded` with Stripe's bracket notation, exactly
43
- as stripe-node sends them. Every response carries `request-id` and `stripe-version` headers.
44
- - `Idempotency-Key` on POSTs is honoured: a replay returns the cached response, a replay with
45
- different parameters returns 400.
46
-
47
- ### Serve it: `mockingbird-stripe serve` or `createServer`
48
-
49
27
  ```bash
50
- npx mockingbird-stripe serve # http://127.0.0.1:12111
51
- npx mockingbird-stripe serve --port 0 --log json --admin-key local-admin
52
- npx mockingbird-stripe serve --config mockingbird.json # every service in one config
53
- ```
54
-
55
- ```ts
56
- import { createServer } from "@crvouga/mockingbird-service-stripe/server"
57
-
58
- const server = await createServer() // any free port; server.url, server.port
59
- const response = await fetch(`${server.url}/v1/products?limit=3`, { headers: { authorization: "Bearer sk_test_mockingbird" } })
60
- console.log(response.status) // 200
61
- await server.close()
28
+ npx mockingbird-stripe serve # http://127.0.0.1:12111
29
+ npx mockingbird-stripe serve --accounts accounts.json --admin-key local-admin
30
+ npx mockingbird-stripe serve --config mockingbird.json # every service in one process
62
31
  ```
63
32
 
64
- Served this way — or through `createRuntime()`, the same thing as one runtime-neutral `fetch` —
65
- the mock also answers Mockingbird's service contract, outside Stripe's bearer-key check:
66
-
67
- - `GET /health` — unauthenticated readiness probe.
68
- - `/__admin/*` — reset (`POST /__admin/reset`), snapshots (`POST /__admin/snapshots`,
69
- `POST /__admin/snapshots/{id}/restore`), clock (`POST /__admin/clock {"advance": "2h"}`), fault
70
- injection (`POST /__admin/faults {"operationId": …, "status": 503, "count": 1}`), and metrics with
71
- unmatched-route counts (`GET /__admin/metrics`). `GET /__admin` lists every route; `--admin-key`
72
- locks them behind `x-mockingbird-admin-key`.
73
- - `x-mockingbird-namespace: <name>` — isolates a request's data, so parallel workers share one
74
- process without seeing each other.
75
-
76
- The [Junction README](https://github.com/crvouga/mockingbird/tree/main/packages/service/junction#the-service-contract)
77
- documents the contract in full.
78
-
79
- ### In-process (inject `fetch`)
80
-
81
- ```ts
82
- import { StripeAPI } from "@crvouga/mockingbird-service-stripe"
83
-
84
- const stripe = new StripeAPI({ now: () => Date.UTC(2026, 0, 1) })
85
-
86
- const auth = { authorization: "Bearer sk_test_mockingbird" }
87
-
88
- const created = await stripe.fetch(
89
- new Request("https://api.stripe.com/v1/customers", {
90
- method: "POST",
91
- headers: { ...auth, "content-type": "application/x-www-form-urlencoded" },
92
- body: new URLSearchParams({ email: "qa@example.com", "metadata[userId]": "1001" }),
93
- }),
94
- )
95
- const customer = (await created.json()) as { id: string; created: number }
96
- console.log(created.status, customer.id) // 200 "cus_..."
97
-
98
- // Any code that accepts a fetch function can be pointed at the mock:
99
- const mockFetch = (input: string | URL | Request, init?: RequestInit) =>
100
- stripe.fetch(new Request(input, init))
101
- const listed = await mockFetch("https://api.stripe.com/v1/customers?limit=10", { headers: auth })
102
- console.log(((await listed.json()) as { data: unknown[] }).data.length) // 1
103
- ```
104
-
105
- `now` drives `created`-style fields (Stripe returns seconds; `now` returns milliseconds).
106
-
107
- ### Over HTTP
108
-
109
- ```ts
110
- import { StripeAPI } from "@crvouga/mockingbird-service-stripe"
111
-
112
- const stripe = new StripeAPI()
113
- const server = Bun.serve({
114
- port: 0, // ephemeral
115
- hostname: "127.0.0.1",
116
- fetch: (request) => stripe.fetch(request),
117
- })
118
- const baseUrl = `http://127.0.0.1:${server.port}`
119
-
120
- const response = await fetch(`${baseUrl}/v1/products?limit=3`, {
121
- headers: { authorization: "Bearer sk_test_mockingbird" },
122
- })
123
- console.log(response.status) // 200
124
-
125
- server.stop()
126
- ```
127
-
128
- On Node, `createServer` (above) is the listener; any Fetch-style server also works with
129
- `StripeAPI#fetch` or `createRuntime().fetch`.
130
-
131
- ### Pointing stripe-node at it
132
-
133
- stripe-node accepts `host`, `port` and `protocol`. This is the construction the package's own
134
- client smoke test uses (stripe 16.x, `apiVersion: "2024-06-20"`):
135
-
136
33
  ```js
137
34
  import Stripe from "stripe"
35
+ import { createServer } from "@crvouga/mockingbird-service-stripe/server"
138
36
 
139
- const client = new Stripe("sk_test_mockingbird", {
37
+ const server = await createServer({
38
+ accounts: [
39
+ // Legacy STRIPE_API_KEY, STRIPE_MSO_API_KEY and the EMR key all act as MSO and share state.
40
+ { id: "acct_mso", keys: ["sk_test_legacy", "sk_test_mso", "sk_test_emr", "pk_test_mso"], corpus: true },
41
+ { id: "acct_pc", keys: ["sk_test_pc"] },
42
+ { id: "acct_pp", keys: ["sk_test_pp"], apiVersion: "2025-02-24.acacia" },
43
+ ],
44
+ })
45
+ const url = new URL(server.url)
46
+ const stripe = new Stripe("sk_test_mso", {
140
47
  apiVersion: "2024-06-20",
141
- host: "127.0.0.1",
142
- port: server.port, // from Bun.serve() above
48
+ host: url.hostname,
49
+ port: Number(url.port),
143
50
  protocol: "http",
144
51
  })
145
- await client.customers.create({ email: "qa@example.com" })
52
+ const customer = await stripe.customers.create({ email: "qa@example.com" })
53
+ await stripe.paymentMethods.attach("pm_card_visa", { customer: customer.id }) // a new pm_ id
54
+ await server.close()
146
55
  ```
147
56
 
148
- With `mockingbird-stripe serve` on port 12111, host, port and protocol are the only wiring. Add a
149
- base-URL override (e.g. `STRIPE_API_BASE_URL=http://127.0.0.1:12111`) at every place your app
150
- constructs a Stripe client; a client built with `new Stripe(key)` and no options cannot be
151
- redirected. Test payment methods and tokens such as `pm_card_visa`, `pm_card_authenticationRequired`
152
- and `tok_chargeDeclinedInsufficientFunds` behave like their Stripe counterparts
153
- (`QA_TEST_PAYMENT_METHODS` and `QA_TEST_CARD_TOKENS` list the ones the suites exercise).
154
-
155
- ### Webhooks
156
-
157
- The mock records an event for every state change (readable through `GET /v1/events` and
158
- `webhookEvents()`), and calls `onWebhook` with each one. Delivery and signing are up to you;
159
- Stripe signs `"<t>.<body>"` with HMAC-SHA256 keyed by the `whsec_` secret verbatim, which
160
- `stripe.webhooks.constructEvent` accepts:
57
+ Or over raw HTTP, the way any Stripe client talks to it:
161
58
 
162
59
  ```ts
163
- import { createHmac } from "node:crypto"
164
- import { accountOfKey, StripeAPI } from "@crvouga/mockingbird-service-stripe"
165
-
166
- const WEBHOOK_URL = "http://127.0.0.1:3100/webhooks/stripe"
167
- const WEBHOOK_SECRET = "whsec_local_test"
168
- const account = accountOfKey("sk_test_mockingbird")
169
-
170
- const stripe = new StripeAPI({
171
- onWebhook: (event) => {
172
- if (event.account !== account) return
173
- const t = Math.floor(Date.now() / 1000)
174
- const v1 = createHmac("sha256", WEBHOOK_SECRET).update(`${t}.${event.body}`).digest("hex")
175
- void fetch(WEBHOOK_URL, {
176
- method: "POST",
177
- headers: { "content-type": "application/json", "stripe-signature": `t=${t},v1=${v1}` },
178
- body: event.body,
179
- }).catch(() => undefined)
60
+ import { createServer } from "@crvouga/mockingbird-service-stripe/server"
61
+
62
+ const server = await createServer()
63
+ const response = await fetch(`${server.url}/v1/customers`, {
64
+ method: "POST",
65
+ headers: {
66
+ authorization: "Bearer sk_test_mso",
67
+ "content-type": "application/x-www-form-urlencoded",
180
68
  },
69
+ body: new URLSearchParams({ email: "qa@example.com" }),
181
70
  })
182
-
183
- // Or assert on recorded events directly:
184
- console.log(stripe.webhookEvents(account).map((event) => event.type))
71
+ const customer = (await response.json()) as { id: string; email: string }
72
+ await server.close()
185
73
  ```
186
74
 
187
- ### Resetting between tests
188
-
189
- `reset()` clears every account's objects, the event ledger and the idempotency cache. Create one
190
- instance per suite and reset it in `beforeEach`:
75
+ ### Pointing the app at it
76
+
77
+ stripe-node accepts `host`, `port` and `protocol`; route every `new Stripe(...)` through one options
78
+ factory reading `STRIPE_API_HOST` / `STRIPE_API_PORT` / `STRIPE_API_PROTOCOL` (the catalog's G-S1),
79
+ and give raw `fetch('https://api.stripe.com…')` call sites the same base URL. Keys must look like
80
+ test keys (`sk_test_…`, `rk_test_…`; `pk_test_…` for the Stripe.js stand-in). Point the browser at
81
+ the mock's `GET /v3` instead of `https://js.stripe.com/v3` (G-S3); `session.url` already points at
82
+ the mock's hosted page.
83
+
84
+ ### Accounts and namespaces
85
+
86
+ State is partitioned by **account**, and the account is chosen by API key:
87
+
88
+ - `PUT /__admin/accounts {"accounts": [{id, keys, apiVersion?, webhookSecrets?, corpus?, displayName?}]}`
89
+ (also `createRuntime({accounts})` / `serve --accounts <json|file>`). Every key listed on an
90
+ account acts as it. Any other test key is an account of its own (`accountOfKey(key)`), so an MSO
91
+ key reading a PC object gets Stripe's exact `404 resource_missing` (`No such payment_intent: 'pi_…'`).
92
+ - `apiVersion` is the account's default version (requests without `Stripe-Version`) and the version
93
+ its webhook payloads render at (default `2024-06-20`, what every backend receiver of ours pins).
94
+ - `webhookSecrets: {"<receiver url>": "whsec_…"}` delivers every event of the account there.
95
+ - `corpus: true` seeds the recorded catalog (below).
96
+
97
+ **Namespaces** isolate parallel workers; each namespace has its own copy of every account. Carriers:
98
+ the `x-mockingbird-namespace` header, the `/ns/<namespace>/…` path prefix, or **by API key**:
99
+ `PUT /__admin/credentials {"credentials": {"sk_test_worker1": "w1"}}` (stripe-node cannot add
100
+ headers). Hosted-page URLs carry the `/ns/<namespace>` prefix so the browser lands in the same one.
101
+
102
+ ### API versions
103
+
104
+ `Stripe-Version` picks the shape. `2024-06-20` (and anything before 2024-09-30) returns
105
+ `invoice.discount` (with the coupon embedded), `invoice.charge` / `payment_intent` / `subscription` /
106
+ `paid` / `subscription_details`, `subscription.current_period_*` and `subscription.discount`, and
107
+ invoice lines with `price` objects; `2025-02-24.acacia` adds `total_pretax_credit_amounts`;
108
+ 2025-03-31.basil and later (the vendored latest) drop those and use `parent`, `pricing`,
109
+ `discount.source` and item-level periods. `charge.refunds` appears only with `expand[]=refunds` at
110
+ every one of these versions. `GET /v1/invoices/upcoming` answers at the older versions and returns
111
+ Stripe's "deprecated" 404 at basil and later. Expansion is generic (any path through ids the mock
112
+ holds, ancestors included); Stripe's own rules are enforced: a non-expandable first segment is
113
+ `This property cannot be expanded (metadata).` and more than four levels is
114
+ `property_expansion_max_depth` (verified against test mode at all three versions).
191
115
 
192
- ```ts
193
- import { beforeEach, expect, test } from "bun:test"
194
- import { StripeAPI } from "@crvouga/mockingbird-service-stripe"
195
-
196
- const stripe = new StripeAPI()
197
- beforeEach(() => stripe.reset())
198
-
199
- test("starts empty", async () => {
200
- const response = await stripe.fetch(
201
- new Request("https://api.stripe.com/v1/customers", {
202
- headers: { authorization: "Bearer sk_test_mockingbird" },
203
- }),
204
- )
205
- expect(((await response.json()) as { data: unknown[] }).data).toEqual([])
206
- })
207
- ```
208
-
209
- ## What is and is not modelled
116
+ ### Webhooks
210
117
 
211
- - **Modelled**: the 88 operations in [SUPPORT.md](https://github.com/crvouga/mockingbird/blob/main/packages/service/stripe/SUPPORT.md),
212
- whose behaviour is checked by live parity against Stripe test mode; state partitioned per API key;
213
- the test payment methods and card tokens listed above.
214
- - **Not modelled**: the 20 operations SUPPORT.md marks unsupported, each with its reason; Stripe.js
215
- and hosted checkout (`js.stripe.com`, `checkout.stripe.com`); real rate-limit and 5xx bodies —
216
- `POST /__admin/faults` injects Mockingbird's own, which are shape-plausible, not recorded;
217
- anything outside the vendored spec, which 404s and is counted in `GET /__admin/metrics` under
218
- `unmatched`.
219
- - **Determinism**: with a fixed clock and `seed`, ids and timestamps replay exactly — two runtimes
220
- given the same clock produce the same `cus_…` ids and `created` values.
118
+ Every state change records an event in the account's log (`GET /v1/events`, filterable by
119
+ `types[]` and `created`) and publishes it through the shared webhook hub, signed
120
+ `Stripe-Signature: t=<wall-clock unix>,v1=<hex HMAC-SHA256(secret, "t.body")>` over the exact bytes —
121
+ `stripe.webhooks.constructEvent` verifies them.
122
+
123
+ - Endpoints: `PUT /__admin/webhook-endpoints [{account, url, secret, enabledEvents: ["*"|…]}]`
124
+ (`account` is an account id or any of its keys; omit it to receive every account),
125
+ `serve --webhook-url/--webhook-secret`, accounts' `webhookSecrets`, and endpoints created through
126
+ `POST /v1/webhook_endpoints` (signed with the `whsec_` returned at creation). One event fans out to
127
+ every matching endpoint, as on Stripe. Retries follow the hub's schedule.
128
+ - `GET /__admin/webhooks`, `/webhooks/events`, `POST /__admin/webhooks/:id/replay`, `/webhooks/flush`.
129
+ - Delivery faults: presets `webhook_duplicate` (same event id twice — our receiver's in-flight
130
+ dedupe answers 500), `webhook_reorder` (the next two swapped), `webhook_drop` (never delivered,
131
+ still in `GET /v1/events` for the replay worker).
132
+ - Metadata is copied verbatim, so the PC route's quarantine rule (`metadata.intent ∈ {pc_order,
133
+ kb_membership, shop_purchase, stripe_membership}` or `source=supplement` + `billingInvoiceId`)
134
+ only fires for sessions that would trip it on Stripe.
135
+
136
+ Events emitted: `customer.*`, `payment_method.attached|detached|updated`,
137
+ `payment_intent.created|succeeded|payment_failed|canceled|requires_action|amount_capturable_updated`,
138
+ `charge.succeeded|failed|captured|refunded|dispute.created`, `setup_intent.created|succeeded|setup_failed|canceled|requires_action`,
139
+ `checkout.session.completed|expired|async_payment_succeeded`,
140
+ `invoice.created|finalized|updated|paid|payment_succeeded|payment_failed|voided|deleted|upcoming`,
141
+ `invoiceitem.created`, `customer.subscription.created|updated|deleted` (with
142
+ `data.previous_attributes`), `subscription_schedule.*`, `refund.created|updated|failed`,
143
+ `product.*`, `price.*` (with `previous_attributes`), `coupon.*`, `promotion_code.*`,
144
+ `test_helpers.test_clock.*`.
145
+
146
+ ### Lifecycles and the clock
147
+
148
+ `POST /__admin/clock {"advance": "32d"}` (or `set`) moves the mock clock and immediately runs every
149
+ clock-driven lifecycle, so their webhooks fire at once; the served mock also ticks every second.
150
+
151
+ - **Renewals**: past `current_period_end` a subscription cycles — a `subscription_cycle` invoice is
152
+ finalized and charged off-session to the default payment method, then `invoice.paid` +
153
+ `customer.subscription.updated` (`previous_attributes.current_period_end`), or
154
+ `invoice.payment_failed` and `past_due`. Trials end into a cycle; `cancel_at_period_end` cancels
155
+ (`customer.subscription.deleted`). `invoice.upcoming` fires 3 days before renewal.
156
+ - `incomplete` subscriptions become `incomplete_expired` after 23 h (their invoice is voided).
157
+ - Checkout Sessions expire at `expires_at` (`checkout.session.expired`).
158
+ - Schedule phases advance; the last one releases or cancels per `end_behavior`.
159
+ - **Test clocks**: customers created with `test_clock` live on the clock's time;
160
+ `POST /v1/test_helpers/test_clocks/:id/advance` runs their lifecycles and the clock reads `ready`
161
+ on the next retrieve.
162
+ - `POST /__admin/tick` runs the lifecycle without moving the clock.
163
+
164
+ Payment behaviour: `payment_behavior` omitted (`allow_incomplete`) charges the default payment method
165
+ now and returns `incomplete` on a decline; `error_if_incomplete` fails the call with the 402;
166
+ `default_incomplete` leaves the first invoice's PaymentIntent (with its `client_secret`) for the
167
+ customer, and paying it through Stripe.js activates the subscription. `trial_end`,
168
+ `backdate_start_date` + `billing_cycle_anchor` + `proration_behavior=none` (a $0 first invoice),
169
+ item updates with `always_invoice` (billed now) or `create_prorations` (next invoice), and
170
+ discounts with stable `di_` ids (`discounts=""` clears) are modelled. A $0 invoice is `paid` on
171
+ finalize; a customer credit balance is applied at finalize; `void` works only on open invoices
172
+ (`You can only pass in open invoices. This invoice isn't open.`).
173
+
174
+ ### Hosted Checkout page and Stripe.js
175
+
176
+ - `GET /c/pay/:sessionId` — the page `session.url` points to: card number, expiry, CVC, ZIP and
177
+ Pay/Cancel with `data-testid`s `stripe-mock-card`, `stripe-mock-exp`, `stripe-mock-cvc`,
178
+ `stripe-mock-zip`, `stripe-mock-pay`, `stripe-mock-cancel` (a decline shows
179
+ `stripe-mock-error`). Pay completes the session (creating the customer, the PaymentIntent with
180
+ `payment_intent_data.metadata`, the Subscription with `subscription_data.metadata`, or the
181
+ SetupIntent), emits `checkout.session.completed` and 302s to `success_url` with
182
+ `{CHECKOUT_SESSION_ID}` substituted raw and `%7B…%7D`-encoded; Cancel 302s to `cancel_url`.
183
+ - `POST /__admin/checkout/sessions/:id/complete {"card": "4242…"}` does the same without a browser;
184
+ `…/expire` and `…/async_payment_succeeded` too.
185
+ - `GET /v3` — the Stripe.js stand-in: `Stripe(pk)`, `elements()` → `create("payment"|"card")`,
186
+ `confirmPayment`, `confirmSetup`, `confirmCardPayment`, `confirmCardSetup`,
187
+ `retrievePaymentIntent`, `retrieveSetupIntent`, `createPaymentMethod`, `handleCardAction`. It calls
188
+ `POST /v1/{payment,setup}_intents/:id/confirm` with the publishable key and `client_secret` (the
189
+ requests UI suites already wait for); 3-D Secure cards are authenticated in place.
190
+
191
+ A publishable key may only confirm or read an intent whose `client_secret` it presents, and create
192
+ payment methods; anything else is Stripe's 401.
193
+
194
+ ### Test values
195
+
196
+ - Payment methods: `pm_card_visa`, `pm_card_mastercard`, `pm_card_amex`, `pm_card_discover`,
197
+ `pm_card_visa_debit`, `pm_card_chargeDeclined`, `pm_card_chargeDeclinedInsufficientFunds`,
198
+ `pm_card_chargeDeclinedExpiredCard`, `pm_card_chargeCustomerFail`,
199
+ `pm_card_authenticationRequired`, `pm_card_threeDSecure2Required`, `pm_card_createDispute` —
200
+ each use clones a new `pm_`.
201
+ - Tokens: `tok_visa`, `tok_chargeCustomerFail` (attaches, then every charge declines),
202
+ `tok_chargeDeclinedInsufficientFunds`, `tok_chargeDeclinedExpiredCard`, `tok_createDispute`, …
203
+ - Card numbers (page, Stripe.js): `4242424242424242` succeeds, `4000000000000002` declines,
204
+ `4000000000009995` insufficient funds, `4000002500003155` 3-D Secure, `4000051230000072` the HSA
205
+ card (`funding: prepaid`, `issuer: OPTUM BANK`, what our HSA/FSA detection matches).
206
+ - Off-session declines answer 402 `card_error` with `charge`, `decline_code`, `advice_code`,
207
+ `payment_method` and the failed `payment_intent` embedded, as Stripe does.
208
+ - Client secrets are `pi_<id>_secret_<x>` / `seti_<id>_secret_<x>`.
209
+
210
+ ### Idempotency
211
+
212
+ POSTs with `Idempotency-Key` go through the shared `IdempotencyStore`, scoped per account: a
213
+ replay returns the stored response byte for byte (with `idempotent-replayed: true`); the same key
214
+ with different parameters is 400 `idempotency_error`; a concurrent request on an in-flight key is
215
+ 409 `idempotency_key_in_use`, with Stripe's wording.
216
+
217
+ ### Fault presets
218
+
219
+ `POST /__admin/faults {"preset": "<name>", "count"?: n}`: `card_declined`, `insufficient_funds`,
220
+ `expired_card`, `authentication_required` (the next charge attempt declines), `rate_limited` (429
221
+ `rate_limit`), `api_error` (500 `api_error`), `permission_error` (403), `connection_drop`,
222
+ `idempotency_in_flight` (500 ms processing, so a concurrent retry gets 409), `search_lag` (search
223
+ hides objects younger than 60 s — search is consistent otherwise), `webhook_duplicate`,
224
+ `webhook_reorder`, `webhook_drop`.
225
+
226
+ ### Admin routes (beyond the standard contract)
227
+
228
+ `GET|PUT /__admin/accounts`, `PUT /__admin/webhook-endpoints`, `PUT /__admin/refunds/:id
229
+ {status, failure_reason}` (emits `refund.failed` / `refund.updated`), `POST /__admin/disputes
230
+ {payment_intent|charge, reason?, amount?}` (emits `charge.dispute.created`),
231
+ `POST /__admin/checkout/sessions/:id/complete|expire|async_payment_succeeded`,
232
+ `POST /__admin/setup_intents/:id/succeed`, `GET /__admin/charges/:id`, `POST /__admin/tick`. The
233
+ standard ones (`/health`, reset, snapshots, clock, faults, metrics, `GET /__admin/requests`,
234
+ credentials, webhooks) come from the shared runtime. The journal records operation, status and ids
235
+ only — never bodies, card numbers or emails.
236
+
237
+ ### Corpus
238
+
239
+ `GEVITI_CORPUS` is the recorded test-mode catalog our seeded fixtures point at (reference-data
240
+ products and prices, catalog plans, shop fixtures, QA snapshots such as `prod_SNj3rQYHrHNS0H` /
241
+ `price_1StxtjGBBGmxLhdL8PzNSEgX`, and runbook coupons and promotion codes; 144 products, 172
242
+ prices). Accounts with `corpus: true` answer those ids byte for byte; customers, intents and
243
+ subscriptions are never recorded. The membership lookup keys our env expects
244
+ (`membership_<tier>_<interval>`, e.g. `membership_plus_annually`) are attached to the matching
245
+ recorded prices (listed in `synthesizedLookupKeys`). Pass your own with `createRuntime({corpus})`.
221
246
 
222
247
  ## API
223
248
 
224
- `StripeAPI` is the main export; the rest supports account scoping, contract introspection and the
225
- QA corpus used by the parity suites.
249
+ `StripeAPI` is the engine; `createRuntime` wraps it in the service contract. From
250
+ `@crvouga/mockingbird-service-stripe`:
226
251
 
227
252
  | Export | Description |
228
253
  | --- | --- |
229
- | `createRuntime` | `(options?) => StripeRuntime` — the mock with the service contract (health, admin, namespaces, clock, faults, metrics) as one runtime-neutral `fetch`. Options: `sqlite`, `clock`, `seed`, `adminKey`, `onLog`, `onWebhook`. `./server` adds `createServer(options?)` (Node; `port`, `host`), `serveTarget` and `DEFAULT_PORT` (`12111`). |
230
- | `StripeAPI` | Class. `new StripeAPI(options?)`; implements the Fetch contract `fetch(request: Request): Promise<Response>`. |
231
- | `accountOfKey` | `(key: string) => string` — the opaque `acct_...` partition id for an API key (use it to filter `webhookEvents`). |
232
- | `accountOf` | `(request: Request) => string` — the partition id for a request's bearer key. |
233
- | `STRIPE_NAMESPACE` | `"stripe"` — SQLite namespace holding every Stripe record when sharing a `sqlite` client. |
234
- | `document` | The vendored Stripe OpenAPI document (Mockingbird subset) that drives routing and validation. |
235
- | `operationIds` | Every `operationId` in `document` (108). |
236
- | `supportedOperationIds` | The `operationId`s the mock implements (88); the rest return a Stripe-shaped error. |
237
- | `QA_SURFACE_OPS` | Operations the QA suites exercise (same set as `supportedOperationIds`). |
238
- | `QA_METADATA` | Pinned metadata values (`intent`, `source`, `userId`) the suites send. |
239
- | `QA_AMOUNTS` | Pinned amounts in cents: `1000`, `15000`, `17999`. |
254
+ | `createRuntime` | `(options?) => StripeRuntime` — the mock with the full contract. Options: `accounts`, `webhooks {endpoints, retryDelaysMs, fetch}`, `corpus`, `publicUrl`, `webhookApiVersion`, `lifecycle`, `tickMs`, `sqlite`, `clock`, `seed`, `adminKey`, `onLog`, `onWebhook`. The runtime adds `webhooks`, `accounts`, `tick()`, `stop()`. |
255
+ | `StripeAPI` | Class; `new StripeAPI(options?)` implements `fetch(request)`. Members: `reset()`, `tick(force?)`, `webhookEvents(account?)`, `webhookDeliveryAttempts(account?)`, `apiWebhookEndpoints()`, `accountIds()`, `scopeFor(account)`, `importStateFrom(source)`, `accounts`, `app`, `sqlite`. |
256
+ | `STRIPE_PRESETS` | The named fault presets above. |
257
+ | `AccountDirectory` | Keys → accounts (`configure`, `accountFor`, `config`, `list`, `resolve`). |
258
+ | `DEFAULT_WEBHOOK_API_VERSION` | `"2024-06-20"`. |
259
+ | `accountOfKey` | `(key) => string` — the account id of an unconfigured key. |
260
+ | `accountOf` | `(request) => string` — the same, from a request's bearer key. |
261
+ | `STRIPE_API_VERSION` | The vendored latest version (`2026-08-26.dahlia`). |
262
+ | `LEGACY_API_VERSION` | `"2024-06-20"`. |
263
+ | `ACACIA_API_VERSION` | `"2025-02-24.acacia"`. |
264
+ | `GEVITI_CORPUS` | The bundled recorded catalog. |
265
+ | `TEST_TOKENS` | Every modelled `tok_…`. |
266
+ | `TEST_PAYMENT_METHOD_IDS` | Every modelled magic `pm_card_…`. |
267
+ | `TEST_CARD_NUMBERS` | Every modelled test card number. |
268
+ | `STRIPE_NAMESPACE` | `"stripe"` — SQLite namespace of every record. |
269
+ | `document` | The vendored OpenAPI document (Mockingbird subset). |
270
+ | `operationIds` | Every `operationId` in `document`. |
271
+ | `supportedOperationIds` | The ones the mock implements. |
272
+ | `QA_SURFACE_OPS` | Operations the parity walks cover (supported, minus the browser pages). |
273
+ | `QA_METADATA` | Pinned metadata values the parity walks send. |
274
+ | `QA_AMOUNTS` | Pinned amounts in cents. |
240
275
  | `QA_CUSTOMER` | Pinned customer `email`, `name`, `phone`. |
241
- | `QA_TEST_PAYMENT_METHODS` | Test payment method ids the suites attach (`pm_card_visa`, ...). |
242
- | `QA_TEST_CARD_TOKENS` | Test card tokens the suites use (`tok_visa`, decline tokens, ...). |
243
- | `QA_SEARCH_QUERIES` | Search queries the suites issue against `/v1/customers/search`. |
244
- | `QA_COUPON_CODES` | Coupon / promotion codes used by the coupon flows. |
245
- | `reshapeQaCommand` | Parity-walk hook that pins sampled commands onto QA corpus values (for the repo's parity runner). |
246
-
247
- `StripeAPI` members:
248
-
249
- | Member | Description |
250
- | --- | --- |
251
- | `fetch(request)` | Handle one Stripe REST request. |
252
- | `reset()` | `Promise<void>` — clear all state, events and cached idempotent responses. |
253
- | `webhookEvents(account?)` | `StripeWebhookEvent[]`, oldest first; `account` narrows to one `accountOfKey(...)` partition. |
254
- | `webhookDeliveryAttempts(account?)` | Delivery attempts recorded for the event ledger. |
255
- | `importStateFrom(source)` | Adopt another `StripeAPI` instance's state (used by seeded parity walks). |
256
- | `app` | The underlying Hono app. |
257
- | `sqlite` | The `SqliteClient` holding state. |
258
-
259
- Options and types:
260
-
261
- ```text
262
- type StripeAPIOptions = {
263
- sqlite?: SqliteClient // share one client across services; default: fresh in-memory DB
264
- now?: () => number // clock in ms for created-style fields; default Date.now
265
- onWebhook?: WebhookPublisher // called with every event the mock records
266
- }
267
- type StripeWebhookEvent = { type: string; account: string; body: string } // body is the JSON event
268
- type WebhookPublisher = (event: StripeWebhookEvent) => void
269
- type OperationId / SupportedOperationId // string unions of operationIds / supportedOperationIds
270
- ```
271
-
272
- `SqliteClient` is the storage port bundled with this package (`exec`, `prepare(sql).run/all/get`,
273
- `transaction`); `Database` from `@crvouga/mockingbird-service-sqlite` satisfies it, as do
274
- better-sqlite3 and wrapped `bun:sqlite`.
276
+ | `QA_TEST_PAYMENT_METHODS` | Test payment methods the walks use. |
277
+ | `QA_TEST_CARD_TOKENS` | Test card tokens the walks use. |
278
+ | `QA_SEARCH_QUERIES` | Search queries the walks issue. |
279
+ | `QA_COUPON_CODES` | Promotion codes the walks use. |
280
+ | `reshapeQaCommand` | Parity-walk hook pinning sampled commands onto those values. |
281
+
282
+ From `@crvouga/mockingbird-service-stripe/server` (Node): `createServer(options?)` (runtime options
283
+ plus `port`, `host`; resolves `{url, port, runtime, close}`), `serveTarget` (the `serve` wiring:
284
+ `--accounts`, `--webhook-url`, `--webhook-secret`, `--public-url`) and `DEFAULT_PORT` (`12111`).
285
+
286
+ ## Deliberately not modelled
287
+
288
+ - **Stripe.js internals**: the stand-in covers the calls our UI makes; Payment Element wallets,
289
+ Link, `paymentRequest` (it reports no wallet) and Elements styling are not modelled. Native
290
+ PaymentSheet cannot be redirected (member-app native keeps its fake provider).
291
+ - **Connect** (`Stripe-Account`, application fees, transfers), tax, shipping, Radar, mandates,
292
+ meters, quotes, credit notes, payouts, and non-card payment methods (bank debits, wallets).
293
+ - **Smart retries / dunning**: a failed renewal goes `past_due` once; later automatic retries,
294
+ `unpaid` and dunning emails are not run.
295
+ - **Proration arithmetic** is day-fraction approximate (Stripe prorates to the second);
296
+ `auto_advance` drafts are not finalized an hour later.
297
+ - **Webhook endpoint `api_version`**: payloads render at the account's version, not per endpoint.
298
+ - **Live keys** (`sk_live_…`) are refused with Stripe's 401: the mock is test mode only.
299
+ - Operations marked unsupported in SUPPORT.md (charge create/update, checkout session update,
300
+ dispute evidence).
301
+ - Rate-limit, 5xx and permission bodies come from presets, worded as Stripe words them but not
302
+ recorded from traffic.
275
303
 
276
304
  ## Development
277
305
 
278
306
  For contributors to the mockingbird repo only; these scripts are not shipped in the npm package.
279
307
 
280
308
  ```bash
281
- bun test # self-parity, auth/idempotency and namespace suites (offline)
282
- bun run mock:server # serve over HTTP on PORT (default 12111), GET /health for readiness
283
- bun run client-parity # stripe-node smoke proof, including webhook signature verification
284
- bun run parity # live differential parity against Stripe test mode
285
- MOCKINGBIRD_STRIPE_SECRET_KEY=sk_test_... bun run parity -- --only GetPrices,GetProducts
286
- ```
287
-
288
- `mock:server` delivers webhooks to the targets in `MOCKINGBIRD_STRIPE_WEBHOOK_TARGETS` (a JSON array
289
- of `{apiKey, url, secret}`, matched by the API key that produced the event) or to a single fallback
290
- target from `MOCKINGBIRD_STRIPE_WEBHOOK_URL` + `MOCKINGBIRD_STRIPE_WEBHOOK_SECRET`:
291
-
292
- ```bash
293
- PORT=12111 MOCKINGBIRD_STRIPE_WEBHOOK_TARGETS='[{"apiKey":"sk_test_mso","url":"http://127.0.0.1:3100/billing/webhooks/stripe/mso","secret":"whsec_..."}]' bun run mock:server
309
+ bun test # self-parity, acceptance (via test/consumer.ts), stripe-node drop-in, contract
310
+ bun run parity # live differential parity against Stripe test mode (safe operations)
311
+ bun run parity -- --include-unsafe --only PostCustomers,GetCustomers
312
+ bun run client-parity # stripe-node smoke proof with webhook signature verification
313
+ bun run vendor # re-vendor openapi.yaml from the pinned upstream spec
294
314
  ```
295
315
 
296
- Live parity needs a real `sk_test_` key and mutates a shared test account.
316
+ Live parity loads `MOCKINGBIRD_STRIPE_SECRET_KEY` (a `sk_test_` key) from the environment or Vault
317
+ (`secret/personal/prd`) and exits 2 without one. By default it walks only safe operations and
318
+ leaves out account-global ones (account profile, lifetime balance, lingering test clocks and
319
+ webhook endpoints).
297
320
 
298
- Part of [mockingbird](https://github.com/crvouga/mockingbird) — agent integration guide: [README](https://github.com/crvouga/mockingbird#readme) · [llms.txt](https://github.com/crvouga/mockingbird/blob/main/llms.txt).
321
+ Part of [mockingbird](https://github.com/crvouga/mockingbird) — agent integration guide: [README](https://github.com/crvouga/mockingbird#readme) · [llms.txt](https://github.com/crvouga/mockingbird/blob/main/llms.txt) · [report an issue or request a feature](https://github.com/crvouga/mockingbird/blob/main/docs/REPORTING_ISSUES.md).