@crvouga/mockingbird-service-stripe 0.3.0 → 0.4.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 +12 -1
- package/README.md +270 -247
- package/dist/{chunk-DGGBNO2M.js → chunk-ECXHTVKF.js} +107 -13
- package/dist/chunk-ECXHTVKF.js.map +7 -0
- package/dist/chunk-VD3R2QWB.js +14116 -0
- package/dist/chunk-VD3R2QWB.js.map +7 -0
- package/dist/cli.js +2 -2
- package/dist/index.d.ts +1049 -29
- package/dist/index.js +21 -1
- package/dist/server.d.ts +1137 -26
- package/dist/server.js +2 -2
- package/package.json +11 -5
- package/dist/chunk-DGGBNO2M.js.map +0 -7
- package/dist/chunk-QSKI7ZTR.js +0 -5906
- package/dist/chunk-QSKI7ZTR.js.map +0 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
# Changelog — @crvouga/mockingbird-service-stripe
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.4.0 (2026-09-21)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- add vendor mocks for the full service catalog ([f870287](https://github.com/crvouga/mockingbird/commit/f8702874c807b1be4ce4ae2c5b46c8fd59a5e8eb))
|
|
8
|
+
|
|
9
|
+
### Fixes and improvements
|
|
10
|
+
|
|
11
|
+
- give each vendor-SDK README a self-contained ts Usage example ([27b3c7d](https://github.com/crvouga/mockingbird/commit/27b3c7dbe8786355c82fb8835d1ac84abdb25101))
|
|
12
|
+
- fence vendor-SDK README examples as js, as main's stripe README does ([6fc55a7](https://github.com/crvouga/mockingbird/commit/6fc55a76235d6d3f2f02c952a6680066d054ff30))
|
|
13
|
+
|
|
14
|
+
## 0.3.0 (2026-09-20)
|
|
4
15
|
|
|
5
16
|
### Features
|
|
6
17
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
(
|
|
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).
|
|
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
|
|
51
|
-
npx mockingbird-stripe serve --
|
|
52
|
-
npx mockingbird-stripe serve --config mockingbird.json # every service in one
|
|
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
|
|
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:
|
|
142
|
-
port:
|
|
48
|
+
host: url.hostname,
|
|
49
|
+
port: Number(url.port),
|
|
143
50
|
protocol: "http",
|
|
144
51
|
})
|
|
145
|
-
await
|
|
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
|
-
|
|
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 {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
`
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
225
|
-
|
|
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
|
|
230
|
-
| `StripeAPI` | Class
|
|
231
|
-
| `
|
|
232
|
-
| `
|
|
233
|
-
| `
|
|
234
|
-
| `
|
|
235
|
-
| `
|
|
236
|
-
| `
|
|
237
|
-
| `
|
|
238
|
-
| `
|
|
239
|
-
| `
|
|
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
|
|
242
|
-
| `QA_TEST_CARD_TOKENS` | Test card tokens the
|
|
243
|
-
| `QA_SEARCH_QUERIES` | Search queries the
|
|
244
|
-
| `QA_COUPON_CODES` |
|
|
245
|
-
| `reshapeQaCommand` | Parity-walk hook
|
|
246
|
-
|
|
247
|
-
`
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
282
|
-
bun run
|
|
283
|
-
bun run
|
|
284
|
-
bun run parity
|
|
285
|
-
|
|
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
|
|
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
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).
|