@firedrill-tools/stripe 0.1.1

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.
Files changed (59) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +433 -0
  3. package/firedrill/agent.target.json +17 -0
  4. package/firedrill/api-unavailable.scenario.json +11 -0
  5. package/firedrill/baseline.scenario.json +5 -0
  6. package/firedrill/conformance.suite.json +17 -0
  7. package/firedrill/rate-limited.scenario.json +11 -0
  8. package/firedrill/refund-committed-lost.scenario.json +11 -0
  9. package/firedrill/stripe-api-unavailable.drill.json +463 -0
  10. package/firedrill/stripe-denied.drill.json +74 -0
  11. package/firedrill/stripe-large-pages.drill.json +153 -0
  12. package/firedrill/stripe-live-mode.drill.json +136 -0
  13. package/firedrill/stripe-mcp-aliases.drill.json +406 -0
  14. package/firedrill/stripe-no-permissions.drill.json +1143 -0
  15. package/firedrill/stripe-rate-limited.drill.json +616 -0
  16. package/firedrill/stripe-refund-committed-lost.drill.json +139 -0
  17. package/firedrill/stripe-rest-flow.drill.json +1401 -0
  18. package/firedrill/stripe-restricted-key.drill.json +171 -0
  19. package/firedrill/tools/stripe/app/assets/ATTRIBUTION.md +36 -0
  20. package/firedrill/tools/stripe/app/assets/fonts/OFL.txt +93 -0
  21. package/firedrill/tools/stripe/app/assets/stripe-s.svg +1 -0
  22. package/firedrill/tools/stripe/app/assets/stripe.svg +1 -0
  23. package/firedrill/tools/stripe/app/site/app.js +456 -0
  24. package/firedrill/tools/stripe/app/site/assets/fonts/inter-latin.woff2 +0 -0
  25. package/firedrill/tools/stripe/app/site/assets/stripe-s.svg +1 -0
  26. package/firedrill/tools/stripe/app/site/assets/stripe.svg +1 -0
  27. package/firedrill/tools/stripe/app/site/icons.js +90 -0
  28. package/firedrill/tools/stripe/app/site/index.html +137 -0
  29. package/firedrill/tools/stripe/app/site/pages-billing.js +902 -0
  30. package/firedrill/tools/stripe/app/site/pages-catalog.js +314 -0
  31. package/firedrill/tools/stripe/app/site/pages-customers.js +416 -0
  32. package/firedrill/tools/stripe/app/site/pages-home.js +373 -0
  33. package/firedrill/tools/stripe/app/site/pages-payments.js +502 -0
  34. package/firedrill/tools/stripe/app/site/store.js +99 -0
  35. package/firedrill/tools/stripe/app/site/styles.css +2512 -0
  36. package/firedrill/tools/stripe/app/site/ui.js +767 -0
  37. package/firedrill/tools/stripe/app/site/widgets.js +707 -0
  38. package/firedrill/tools/stripe/behavior.mjs +148 -0
  39. package/firedrill/tools/stripe/lib/cards.mjs +53 -0
  40. package/firedrill/tools/stripe/lib/form.mjs +204 -0
  41. package/firedrill/tools/stripe/lib/ids.mjs +85 -0
  42. package/firedrill/tools/stripe/lib/money.mjs +35 -0
  43. package/firedrill/tools/stripe/lib/objects.mjs +229 -0
  44. package/firedrill/tools/stripe/lib/periods.mjs +41 -0
  45. package/firedrill/tools/stripe/lib/size.mjs +55 -0
  46. package/firedrill/tools/stripe/lib/state.mjs +230 -0
  47. package/firedrill/tools/stripe/lib/validate.mjs +184 -0
  48. package/firedrill/tools/stripe/lib/wire.mjs +98 -0
  49. package/firedrill/tools/stripe/ops/billing.mjs +914 -0
  50. package/firedrill/tools/stripe/ops/catalog.mjs +203 -0
  51. package/firedrill/tools/stripe/ops/customers.mjs +241 -0
  52. package/firedrill/tools/stripe/ops/dashboard.mjs +29 -0
  53. package/firedrill/tools/stripe/ops/payments.mjs +608 -0
  54. package/firedrill/tools/stripe/stripe.tool.json +28833 -0
  55. package/firedrill/world.json +8527 -0
  56. package/firedrill.json +5 -0
  57. package/package.json +64 -0
  58. package/starter.json +7999 -0
  59. package/test/conformance.mjs +1133 -0
package/README.md ADDED
@@ -0,0 +1,433 @@
1
+ # @firedrill-tools/stripe
2
+
3
+ A synthetic **Stripe account in test mode** for [Firedrill](https://firedrill.run): customers, synthetic card
4
+ payment methods, PaymentIntents with the full status machine, charges, refunds, products, prices, invoice items,
5
+ invoices (draft → open → paid | void), subscriptions and a computed balance, exposed through a bounded subset of
6
+ the Stripe REST API v1 (`/v1/…`, form-encoded requests, API-version `2026-08-26.dahlia` object shapes, the Stripe
7
+ error envelope, `Idempotency-Key`) and through the per-resource tool names of Stripe's agent toolkit MCP server.
8
+ Agents that talk to Stripe through `stripe-node`/`stripe-python`-style REST calls or over MCP can be exercised
9
+ against it without a Stripe account, a secret key or network access.
10
+
11
+ Everything is computed from the world's state: ids come from a counter row, timestamps from Firedrill's virtual
12
+ clock, card outcomes from a fixed test-card catalogue. Nothing here contacts Stripe or a card network; no e-mail,
13
+ receipt, PDF or hosted page is ever sent or served.
14
+
15
+ Package id `stripe`, version `0.1.0`, engine `>=0.1.0 <0.2.0`, license Apache-2.0. The package ships a **browser
16
+ app**: a recreation of the Stripe Dashboard (sandbox) that drives the same operations — see "Browser app" below.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ firedrill tool add @firedrill-tools/stripe --install
22
+ firedrill serve --scenario baseline --no-open
23
+ ```
24
+
25
+ `firedrill serve` prints the HTTP and MCP endpoints and their tokens. A new world receives the starter data and
26
+ the grants below. Existing worlds do not change silently: add the grants and (optionally) the `starter.json` rows
27
+ to your own world or scenario.
28
+
29
+ ### Grants and identities
30
+
31
+ Every actor that should use the Tool needs the grants for the operations it may call (`packageId: "stripe"`):
32
+
33
+ ```
34
+ balance.retrieve dashboard.context
35
+ customers.create customers.retrieve customers.update customers.list
36
+ payment_methods.list payment_methods.attach payment_methods.detach
37
+ payment_intents.create payment_intents.retrieve payment_intents.list payment_intents.confirm
38
+ payment_intents.capture payment_intents.cancel
39
+ charges.retrieve charges.list refunds.create refunds.list
40
+ products.create products.retrieve products.update products.list prices.create prices.retrieve prices.list
41
+ invoice_items.create invoices.create invoices.retrieve invoices.list invoices.finalize invoices.pay invoices.void
42
+ subscriptions.create subscriptions.retrieve subscriptions.list subscriptions.update subscriptions.cancel
43
+ ```
44
+
45
+ A Stripe secret key is account-wide, so there is no per-user record ownership. Actor **attributes** model what
46
+ varies per key; every attribute is optional and a fresh actor without any attribute behaves as a full test-mode
47
+ secret key:
48
+
49
+ | attribute | meaning |
50
+ |---|---|
51
+ | `livemode` (boolean, default `false`) | Every object the actor reads or creates is stamped with this `livemode`. Stripe's test payment method ids (`pm_card_visa`, …) resolve only in test mode; a live-mode key gets `404 resource_missing` (`No such PaymentMethod: 'pm_card_visa'`). Rows are shared across actors regardless of mode (one account, one dataset). |
52
+ | `permissions` (object) | Restricted-key permissions: keys are the resource groups `balance`, `customers`, `payment_methods`, `payment_intents`, `charges`, `refunds`, `products` (products and prices), `invoices` (invoices and invoice items), `subscriptions`; values `none`, `read` or `write`. Absent attribute = a full secret key. A missing key inside the object = `none`. An operation needing a level the key lacks fails `PERMISSION_DENIED` (HTTP 403, `invalid_request_error`, "This API key does not have the required permissions for this endpoint on account … Having the '<group>: <level>' permission would allow this request to continue."). Cross-resource operations need several groups (`refunds.create`: `refunds: write` + `charges: read`; `subscriptions.create`: `subscriptions: write`, `customers: read`, `products: read`, `invoices: write`, `payment_intents: write`). |
53
+ | `keyName` (string) | Display only; never rendered into responses. |
54
+
55
+ Framework grants decide whether an actor may call an operation at all (framework `denied` → 403 in the Stripe
56
+ envelope); the attributes decide what the call may do.
57
+
58
+ ### Connections
59
+
60
+ Two connection recipes are declared: `stripe-sdk` (`STRIPE_SECRET_KEY` = the HTTP binding token, sent as
61
+ `Authorization: Bearer …`; `STRIPE_API_BASE` = the HTTP binding URL) and `stripe-agent-toolkit-mcp`
62
+ (`STRIPE_MCP_URL` / `STRIPE_MCP_TOKEN` for the MCP binding). The values are isolated world tokens for test
63
+ processes only.
64
+
65
+ ## Starting data
66
+
67
+ `starter.json` (and the conformance `firedrill/world.json`) contain 109 fictional rows and set `virtualTimeUs`
68
+ `1789376400000000` (2026-09-14T09:00:00Z), which `firedrill tool add` copies into a new project's world, so objects
69
+ created in a fresh install are stamped 2026-09-14 and sort after the seeded data: account `acct_1S00Q1HxKLumenTr` **Lumen Trail Supply Co.** (US, `usd`, statement
70
+ descriptor `LUMEN TRAIL`), an outdoor-gear shop selling one-off orders and two membership plans, with 12
71
+ customers (one without e-mail, two sharing an e-mail, a mixed-case e-mail, non-ASCII names, a GB customer with
72
+ `tax_exempt: reverse`, a customer with three cards, one without any payment method, one whose default card
73
+ declines, one whose card requires 3D Secure, one delinquent with a past-due invoice, one with three pending invoice
74
+ items in two currencies), 14 materialised test cards (one unattached), 6 products (one inactive), 10 prices (four
75
+ one-time incl. `gbp` and a zero amount, five recurring incl. a 14-day trial and an inactive legacy plan, lookup
76
+ keys `summit_monthly`/`summit_yearly`), 21 PaymentIntents in every status (three charges inside the two-day
77
+ pending window, one uncaptured, one declined with `last_payment_error`, one 3DS `requires_action`, one canceled,
78
+ seven belonging to invoices), 13 charges, 4 refunds, 9 invoice items, 12 invoices (draft with lines, empty draft,
79
+ open `send_invoice`, open past due, paid manual and subscription invoices, void, uncollectible) and 6
80
+ subscriptions (`active` with two items, `trialing`, `past_due`, `active` with `cancel_at_period_end`, `canceled`,
81
+ `incomplete`; one anchored on the 31st). Starter ids use object sequences below 1000; created objects start at
82
+ sequence 1000 (`cus_0000RS…`).
83
+
84
+ ## Operations
85
+
86
+ | operation | MCP alias (agent toolkit) | HTTP route |
87
+ |---|---|---|
88
+ | `balance.retrieve` | `retrieve_balance` | `GET /v1/balance` |
89
+ | `dashboard.context` | — | — (canonical operation only: `POST /v1/operations/stripe/dashboard.context`, MCP `stripe.dashboard.context`) |
90
+ | `customers.create` | `create_customer` | `POST /v1/customers` |
91
+ | `customers.retrieve` | — | `GET /v1/customers/{customer}` |
92
+ | `customers.update` | — | `POST /v1/customers/{customer}` |
93
+ | `customers.list` | `list_customers` | `GET /v1/customers` |
94
+ | `payment_methods.list` | — | `GET /v1/payment_methods?customer=` and `GET /v1/customers/{customer}/payment_methods` |
95
+ | `payment_methods.attach` | — | `POST /v1/payment_methods/{payment_method}/attach` |
96
+ | `payment_methods.detach` | — | `POST /v1/payment_methods/{payment_method}/detach` |
97
+ | `payment_intents.create` | — | `POST /v1/payment_intents` |
98
+ | `payment_intents.retrieve` | — | `GET /v1/payment_intents/{payment_intent}` |
99
+ | `payment_intents.list` | `list_payment_intents` | `GET /v1/payment_intents` |
100
+ | `payment_intents.confirm` | — | `POST /v1/payment_intents/{payment_intent}/confirm` |
101
+ | `payment_intents.capture` | — | `POST /v1/payment_intents/{payment_intent}/capture` |
102
+ | `payment_intents.cancel` | — | `POST /v1/payment_intents/{payment_intent}/cancel` |
103
+ | `charges.retrieve` | — | `GET /v1/charges/{charge}` |
104
+ | `charges.list` | — | `GET /v1/charges` |
105
+ | `refunds.create` | `create_refund` | `POST /v1/refunds` |
106
+ | `refunds.list` | — | `GET /v1/refunds` |
107
+ | `products.create` | `create_product` | `POST /v1/products` |
108
+ | `products.retrieve` | — | `GET /v1/products/{product}` |
109
+ | `products.update` | — | `POST /v1/products/{product}` |
110
+ | `products.list` | `list_products` | `GET /v1/products` |
111
+ | `prices.create` | `create_price` | `POST /v1/prices` |
112
+ | `prices.retrieve` | — | `GET /v1/prices/{price}` |
113
+ | `prices.list` | `list_prices` | `GET /v1/prices` |
114
+ | `invoice_items.create` | `create_invoice_item` | `POST /v1/invoiceitems` |
115
+ | `invoices.create` | `create_invoice` | `POST /v1/invoices` |
116
+ | `invoices.retrieve` | — | `GET /v1/invoices/{invoice}` |
117
+ | `invoices.list` | `list_invoices` | `GET /v1/invoices` |
118
+ | `invoices.finalize` | `finalize_invoice` | `POST /v1/invoices/{invoice}/finalize` |
119
+ | `invoices.pay` | — | `POST /v1/invoices/{invoice}/pay` |
120
+ | `invoices.void` | — | `POST /v1/invoices/{invoice}/void` |
121
+ | `subscriptions.create` | — | `POST /v1/subscriptions` |
122
+ | `subscriptions.retrieve` | — | `GET /v1/subscriptions/{subscription}` |
123
+ | `subscriptions.list` | `list_subscriptions` | `GET /v1/subscriptions` |
124
+ | `subscriptions.update` | `update_subscription` | `POST /v1/subscriptions/{subscription}` |
125
+ | `subscriptions.cancel` | `cancel_subscription` | `DELETE /v1/subscriptions/{subscription}` |
126
+
127
+ Canonical names are also reachable as `POST /v1/operations/stripe/<operation>` and as MCP tools
128
+ `stripe.<operation>`. `dashboard.context` is the one operation without a Stripe route: it returns the simulated
129
+ account (`account.id`, `business_name`, `country`, `default_currency`, `statement_descriptor`, `support_email`),
130
+ the world's virtual time (`now`, Unix seconds), `livemode`, the calling key's permission level per resource group
131
+ (`permissions.<group>` = `none | read | write`) and `api_version`. It never fails (a key without permissions gets
132
+ `none` everywhere) and exists so the browser app — or any client — can render "today" from the world clock rather
133
+ than the machine clock. Every operation takes the Stripe REST parameter names (snake_case, `expand[]`,
134
+ `metadata[key]`, `items[0][price]`, `created[gte]`, …) and returns the Stripe API object or list envelope
135
+ `{ object: "list", data, has_more, url }`.
136
+
137
+ Behaviour highlights, all computed from state:
138
+
139
+ - **Cards** are synthetic. Stripe's published test ids `pm_card_visa`, `pm_card_visa_debit`, `pm_card_mastercard`,
140
+ `pm_card_amex`, `pm_card_chargeDeclined`, `pm_card_chargeDeclinedInsufficientFunds` and
141
+ `pm_card_authenticationRequired` are accepted wherever a payment method is expected; attaching or confirming
142
+ with one materialises a real `pm_…` object (the response id differs from the test id, as in Stripe's test mode).
143
+ A materialised card keeps a private outcome that decides every later confirmation; the fingerprint reveals the
144
+ underlying test card.
145
+ - **PaymentIntents** follow `requires_payment_method → requires_confirmation → requires_action | requires_capture |
146
+ succeeded | canceled`. `confirm=true` on create, `/confirm`, `/capture` (partial capture supported, the remainder
147
+ is released without a refund object) and `/cancel` (an uncaptured charge is released: `refunded: true`). Every
148
+ attempt produces a charge with `payment_method_details.card`, `outcome`, `balance_transaction`, a `.test` receipt
149
+ URL; successful captures emit `payment_intent.succeeded`.
150
+ - **Declines answer HTTP 402 `card_error`** with Stripe's error envelope (`type: card_error`, `code: card_declined`,
151
+ `decline_code`, `message`, `doc_url`, `request_log_url`). **Unlike Stripe, the failed attempt is not persisted**
152
+ by `payment_intents.create` (`confirm=true`), `payment_intents.confirm` or `invoices.pay`: Firedrill discards every
153
+ state write of an operation that ends in a declared error, id counters included (framework constraint, see
154
+ Limitations). The 402 body therefore names only objects that existed before the call:
155
+ - **Creation-time declines** (`payment_intents.create` with `confirm=true`, and `subscriptions.create` with
156
+ `payment_behavior=error_if_incomplete`) deliberately carry **no `payment_intent`, no `payment_method` and no
157
+ object id** — Stripe would include the new intent, but here it was never created. Its id would answer 404 and
158
+ be handed to the next object created, so an agent retrying with it would silently act on an unrelated record.
159
+ Retry with a new create; list `GET /v1/payment_intents` to confirm nothing was stored.
160
+ - **`/confirm` and `/pay` declines** name the **existing** PaymentIntent as the attempt would have left it
161
+ (`requires_payment_method` with `last_payment_error`), but never a charge (`latest_charge: null`, no
162
+ `last_payment_error.charge`) and the PaymentMethod only when it existed before the call (the intent's stored
163
+ method or the customer's default); a catalogue test card such as `pm_card_chargeDeclined` passed to the call is
164
+ materialised inside the rolled-back operation, so `payment_method` and `last_payment_error.payment_method` are
165
+ omitted. A retrieve afterwards shows the pre-attempt state (e.g. `requires_confirmation` with the original
166
+ `payment_method`, `last_payment_error: null`); after a `/pay` decline the invoice stays `open` with
167
+ `attempt_count: 0`.
168
+
169
+ No failed charge is stored and `payment_intent.declined` is not emitted by these three operations. Retrying
170
+ `/confirm` or `/pay` with another `payment_method` works. The failed attempt *is* persisted (intent
171
+ `requires_payment_method` with `last_payment_error`, a `failed` charge, invoice `attempt_count: 1`, event
172
+ `payment_intent.declined`) where the call itself succeeds: `subscriptions.create` with `payment_behavior`
173
+ `default_incomplete` (default) or `allow_incomplete`, which returns an `incomplete` subscription.
174
+ - **Refunds** by `charge` or `payment_intent`, partial or full; `amount_too_large` beyond the unrefunded amount,
175
+ `charge_already_refunded` once fully refunded, `charge_not_captured` for authorisations, `charge_not_refundable`
176
+ for failed charges. Each refund emits `charge.refunded`.
177
+ - **Balance** is computed per currency: `available` = captured charges older than two virtual days minus their
178
+ refunds, `pending` = the same for the last two virtual days.
179
+ - **Customers** (`email` is not unique; the `email` filter is exact and case-sensitive), partial updates,
180
+ metadata merge (`""` deletes a key; 50 keys / 40-char keys / 500-char values), `invoice_settings.default_payment_method`
181
+ must be attached to the customer. `delinquent` is derived: `true` while an open invoice is past its `due_date`.
182
+ - **Products and prices**: `default_price` only through `products.update` with a price of that product;
183
+ `lookup_key` unique among active prices (`resource_already_exists`); recurring intervals bounded to one year;
184
+ prices are immutable (no `prices.update`).
185
+ - **Invoice items** are pending (`invoice: null`) until the customer's next `invoices.create` sweeps the ones in
186
+ the invoice currency (`pending_invoice_items_behavior=exclude` skips them), or are appended to a given **draft**
187
+ invoice (`invoice_not_editable` otherwise). `invoices.create` infers `send_invoice` when `days_until_due` is given.
188
+ - **Invoices**: `finalize` assigns `<customer prefix>-<sequence>`, `.test` hosted/PDF URLs and, for a positive
189
+ total, opens the invoice with one `InvoicePayment` and a `requires_payment_method` PaymentIntent (nothing is
190
+ attempted automatically); a zero total is `paid` immediately. `pay` confirms that intent with `payment_method`,
191
+ the intent's method or the customer default (`invoice_no_payment_method` when none), or marks it
192
+ `paid_out_of_band` (the intent is canceled). `void` cancels the intent (`cancellation_reason: void_invoice`). A
193
+ PaymentIntent that belongs to an open invoice cannot be confirmed or canceled directly
194
+ (`payment_intent_invoice_managed`).
195
+ - **Subscriptions**: every price must be active, recurring, and share one currency and interval; the first invoice
196
+ (`billing_reason: subscription_create`, one `subscription_item_details` line per item) is finalized and — for
197
+ `charge_automatically` with a positive total — paid in the same transaction: `active` on success, `incomplete`
198
+ without a usable method or on a decline, `trialing` with `trial_period_days`/`trial_end` (a `$0` paid invoice),
199
+ `active` with an open invoice for `send_invoice`. Item periods (`current_period_start/end`) live on the items;
200
+ month/year steps clamp to the end of month. `update` edits items (add/change/remove, never all),
201
+ `cancel_at_period_end` (`cancel_at` = period end, `cancellation_details.reason: cancellation_requested`, status
202
+ unchanged), `default_payment_method`, `trial_end` (`"now"` activates), metadata and `cancellation_details`;
203
+ `proration_behavior` is accepted and ignored. `DELETE` cancels immediately (`ended_at`), voids an incomplete
204
+ subscription's open invoice and emits `customer.subscription.deleted`; a second `DELETE` answers 404 as Stripe
205
+ does. Listing hides `canceled`/`incomplete_expired` unless `status=all`, `ended` or the status is named.
206
+ - **Lists** are newest first with `limit` (1–100, default 10), `starting_after`/`ending_before` cursors that must
207
+ name an existing object (`resource_missing` on the cursor otherwise), `created`/`due_date` range filters and the
208
+ documented equality filters. `expand[]` (one level, `data.` prefix on lists) is allowed for: PaymentIntent
209
+ `customer`, `payment_method`, `latest_charge`; Charge `customer`, `payment_intent`, `payment_method`, `refunds`;
210
+ Refund `charge`, `payment_intent`; Customer `invoice_settings.default_payment_method`; PaymentMethod `customer`;
211
+ Product `default_price`; Price `product`; InvoiceItem `customer`, `invoice`; Invoice `customer`,
212
+ `default_payment_method`, `payments.data.payment.payment_intent`, `lines.data.pricing.price_details.price`,
213
+ `parent.subscription_details.subscription`; Subscription `customer`, `default_payment_method`, `latest_invoice`,
214
+ `latest_invoice.payments.data.payment.payment_intent`. Anything else: `This property cannot be expanded`.
215
+ - **Response size.** The framework refuses any HTTP response over 1 MiB, so list pages are filled by `limit` *and* by
216
+ the encoded UTF-8 size of the objects returned, expansions included (at most 950,000 bytes of objects per page).
217
+ A page the byte budget ends early answers `has_more: true`, and `starting_after` (its last object) or
218
+ `ending_before` (its first object) resumes exactly at the first object not returned; backward pages are filled from
219
+ the cursor outward. A single object, with its requested expansions, larger than 900,000 bytes of JSON answers 400
220
+ `invalid_request_error` (`parameter_invalid`, "… is too large to return in one response …") instead of a partial
221
+ body; `POST /v1/invoiceitems` refuses a line that would push its draft invoice past that bound (nothing is written).
222
+ An expanded `charge.refunds` embeds the newest 10 refunds with `has_more` and `total_count`, as Stripe does; list
223
+ the rest with `GET /v1/refunds?charge=`.
224
+
225
+ ### Wire format
226
+
227
+ `POST` routes take `application/x-www-form-urlencoded` bodies in Stripe's bracket syntax (a JSON body gets the
228
+ framework's 415); `GET`/`DELETE` routes take query strings in the same syntax and no body. `Authorization: Bearer
229
+ <token>` is the only accepted auth (HTTP Basic, `curl -u sk_test_…:`, is rejected with the framework's 401).
230
+ `Idempotency-Key` is honoured by the framework: the same key replays the original response (including a replayed
231
+ 503). Every response carries `Request-Id: req_…`, `Stripe-Version: 2026-08-26.dahlia` and echoes
232
+ `Idempotency-Key`. Errors use `{ error: { type, code?, message, param?, doc_url?, decline_code?, payment_intent?,
233
+ payment_method?, request_log_url } }`:
234
+
235
+ | tool error | HTTP | `type` / `code` |
236
+ |---|---|---|
237
+ | `INVALID_REQUEST` | 400 | `invalid_request_error` / `parameter_missing`, `parameter_invalid`, `parameter_invalid_integer`, `parameter_invalid_string`, `parameter_invalid_empty`, `parameter_unknown`, `parameter_invalid_boolean`, `email_invalid`, `resource_already_exists`, `amount_too_small`, `amount_too_large`, `state_bound_exceeded`\* |
238
+ | `INVALID_STATE` | 400 | `invalid_request_error` / `payment_intent_unexpected_state`, `charge_already_refunded`, `invoice_no_customer_line_items`, `invoice_not_editable`, `invoice_payment_intent_requires_action`, `subscription_canceled`, and this Tool's own codes\* `payment_method_already_attached`, `payment_method_unattached`, `payment_method_customer_mismatch`, `payment_intent_payment_method_missing`, `payment_intent_invoice_managed`, `charge_not_captured`, `charge_not_refundable`, `invoice_not_finalized`, `invoice_already_paid`, `invoice_void`, `invoice_no_payment_method`, `invoice_payment_intent_missing`, `customer_missing_payment_method` |
239
+ | `CARD_DECLINED` | 402 | `card_error` / `card_declined` with `decline_code` `generic_decline` or `insufficient_funds` |
240
+ | `PERMISSION_DENIED` | 403 | `invalid_request_error` (no code); a framework-denied actor gets the same shape |
241
+ | `RESOURCE_MISSING` | 404 | `invalid_request_error` / `resource_missing`, `param` names the parameter |
242
+ | `RATE_LIMITED` | 429 | `invalid_request_error` / `rate_limit` |
243
+ | `API_ERROR` | 503 | `api_error` |
244
+ | framework schema rejection | 400 | `invalid_request_error` / `parameter_invalid` with the framework message (`arguments do not match stripe.<operation>`) — an unknown parameter or a non-integer `amount` lands here; the offending parameter is not named |
245
+ | form/query decoding refused | 400 | `invalid_request_error` / `parameter_unknown` "Received unknown parameter: <key>" for a bracket segment `__proto__`, `constructor` or `prototype` (in any position, including `metadata[...]`); `parameter_invalid` "Invalid array: <key>…" for an array index above 999 or more than 1,000 `[]` entries; `parameter_invalid` "Invalid parameter: <key> is nested more than 20 levels deep." — `param` names the (truncated) key. These messages are this Tool's wording\* |
246
+
247
+ Codes marked \* are this Tool's naming where Stripe's exact code was not verified.
248
+
249
+ **Malformed percent-encoding.** The framework decodes query strings and form bodies leniently: a broken escape such as
250
+ `%E0%A4%A` becomes U+FFFD (`%ZZ` stays literal, and a broken escape in a *path* segment is a 404). A filter value that
251
+ carries U+FFFD is a mangled request, not a value that legitimately matches nothing, so every free-text list filter —
252
+ `email` (customers), `customer`, `type` (payment methods), `ids` (products), `product`, `lookup_keys`, `currency`
253
+ (prices), `customer`, `subscription` (invoices), `customer`, `price` (subscriptions), `customer` (payment intents),
254
+ `customer`, `payment_intent` (charges) and `charge`, `payment_intent` (refunds) — answers
255
+ `400 invalid_request_error / parameter_invalid` "Invalid `<param>`: the value contains an
256
+ invalid character (U+FFFD); check the percent-encoding of the request." with `param` naming the parameter, over both the
257
+ `/v1/...` routes and the canonical `/v1/operations/stripe/<operation>` path. A correctly encoded U+FFFD (`%EF%BF%BD`) is
258
+ rejected the same way. Correctly encoded non-ASCII text (`caf%C3%A9`, CJK, emoji) is unaffected and still matches.
259
+ The five closed-enum filters (`type` on
260
+ prices, `status` and `collection_method` on invoices and on subscriptions) never reach that guard: the declared enum
261
+ schema rejects a mangled value first, so they answer `400 invalid_request_error / parameter_invalid` with the generic
262
+ framework message (`arguments do not match stripe.<operation>.list`) and **no** `param` field.
263
+ `starting_after`/`ending_before` cursors and `expand[]` entries already answer `resource_missing` (404) and
264
+ "This property cannot be expanded" (400) for such values, so they are left as they are.
265
+
266
+ The form decoder (`lib/form.mjs`) is bounded and never throws: it builds null-prototype maps, rejects reserved
267
+ segments, caps nesting depth (20 bracket segments) and array indices (0–999, leading zeros not significant, so `items[0001]`
268
+ is index 1), and does work proportional to the request size. A codec cannot return a response from `decode` (a throw becomes the framework's
269
+ `HTTP_REQUEST_MAPPING_FAILED`), so a refused form travels to `encode` as the single argument `firedrill:form_error`,
270
+ which every closed input schema rejects before any handler runs; `encode` renders it as the row above. Such a request
271
+ is never executed and forwards no `Idempotency-Key`. Canonical JSON arguments are parsed by the framework, which drops
272
+ a `__proto__` member before the handler sees it. Paths outside the table above
273
+ get the framework 404 (`{"error":"route not found"}`, not Stripe's `Unrecognized request URL`).
274
+
275
+ ## Events and faults
276
+
277
+ Events: `payment_intent.succeeded` (once per intent reaching `succeeded`), `payment_intent.declined` (once per
278
+ persisted decline — a Tool-specific name; Stripe's analogue is `payment_intent.payment_failed`), `invoice.paid`,
279
+ `charge.refunded` (once per refund, with `fully_refunded`) and `customer.subscription.deleted`. Consumers can attach
280
+ callbacks to them in their own world.
281
+
282
+ Faults (select them in a scenario with `faults: [{ packageId: "stripe", faultId }]`):
283
+
284
+ | fault | timing | effect |
285
+ |---|---|---|
286
+ | `rate-limited` | before, the 37 Stripe-shaped operations (not `dashboard.context`) | `429 rate_limit` "Request rate limit exceeded…"; nothing runs. |
287
+ | `api-unavailable` | before, every write (20 operations) | `503 api_error`; nothing is written, reads keep working. |
288
+ | `refund-committed-lost` | after commit, `refunds.create` | The refund **is** created (charge `amount_refunded` advanced, `charge.refunded` emitted) yet the caller sees 503. A retry with the **same** `Idempotency-Key` replays the 503 without a second refund; a retry with a **new** key refunds again — a partial-amount retry double-refunds silently, a full-amount retry hits `400 charge_already_refunded`. Exactly the trap an agent must handle. |
289
+
290
+ ## Conformance
291
+
292
+ `firedrill tool test stripe` runs ten drills twice over the HTTP and MCP bindings (a scripted target,
293
+ `test/conformance.mjs`, Node built-ins only): the full REST flow (every route, error path and edge case), the MCP
294
+ aliases, a read-only restricted key, a key without permissions, a live-mode key, a framework-denied actor, large
295
+ byte-bounded pages walked both ways (the "Response size" rules), and one drill per fault. Together they observe every operation, every declared error of every operation, all five events
296
+ and all three faults, and the repeat pass proves determinism.
297
+
298
+ ## Protocol compatibility
299
+
300
+ The HTTP surface follows the Stripe API reference field-by-field for the routes above (form encoding,
301
+ `Idempotency-Key`, list envelope, error envelope, statuses, API-version `2026-08-26.dahlia` object shapes), so
302
+ `stripe` (npm 22.x) constructed with `host`/`port`/`protocol` from the HTTP binding and `stripe` (PyPI) with
303
+ `stripe.api_base` are the intended clients — but **no official client has been run against this Tool**:
304
+ compatibility is by reference, not verified against a real client, and the manifest `compatibility` array is
305
+ empty. The sixteen MCP aliases reproduce the per-resource tool names of `@stripe/agent-toolkit` 0.7.9 / `@stripe/mcp`
306
+ ≤ 0.2.x and accept those tools' argument names; they return the full REST object or list (a superset of the
307
+ toolkit's trimmed results). Stripe's current remote MCP server (`mcp.stripe.com`, `@stripe/mcp` 0.3.x,
308
+ `@stripe/agent-toolkit` 0.9.x) exposes generic `stripe_api_read` / `stripe_api_write` / `stripe_api_search` /
309
+ `get_stripe_account_info` tools instead; those, and the toolkit's payment-link, coupon, dispute and documentation
310
+ tools, are not provided.
311
+
312
+ ## Limitations
313
+
314
+ - A bounded synthetic subset: 38 operations (37 Stripe-shaped + `dashboard.context`), 38 routes, eleven resource types. No Checkout Sessions, Payment Links,
315
+ SetupIntents, Sources/Tokens/legacy cards, Coupons, Disputes, Payouts, Transfers/Connect (`Stripe-Account` is
316
+ ignored), Balance transactions, Events, Webhook endpoints, Search endpoints, Tax, Billing portal, Quotes, Credit
317
+ notes, Subscription schedules, metered billing, API v2 or OAuth. `payment_intents.update`,
318
+ `invoices.update/delete/send/mark_uncollectible`, `customers.delete`, `payment_methods.create`, `products.delete`
319
+ and `prices.update` are absent.
320
+ - Byte-bounded responses: pages may hold fewer than `limit` objects when the objects are large (see "Response size");
321
+ the 900,000-byte single-object bound and its error message are this Tool's, not Stripe's (Stripe bounds objects
322
+ through its own field limits).
323
+ - Form and query parameters: the segments `__proto__`, `constructor` and `prototype` are refused everywhere (Stripe
324
+ would accept them as `metadata` keys), nesting is capped at 20 bracket segments and arrays at 1,000 entries
325
+ (indices 0–999); the error messages for these refusals are this Tool's wording, not Stripe's.
326
+ - Cards are the only payment method type and come from a seven-entry test catalogue; no raw card numbers, tokens,
327
+ wallets, bank debits or 3D Secure completion (a `requires_action` intent can only be canceled).
328
+ `automatic_async` capture behaves like `automatic`.
329
+ - **Declined confirmations are not persisted — a framework constraint.** Stripe answers 402 *and* stores the
330
+ attempt. A Firedrill operation either commits, and then its HTTP status is always the route's 2xx
331
+ `successStatus` (`@firedrill-run/protocol-http` `src/wire.ts:423-424`; the codec's `ToolHttpResponse` has no status
332
+ field, `@firedrill-run/tool-sdk` `src/types.ts:105-108`; `successStatus` is limited to 200–299,
333
+ `@firedrill-run/contracts` `src/operation.ts:278`), or fails with a declared error, which the kernel turns into an
334
+ abort of the state transaction the handler ran in (`@firedrill-run/world-kernel` `src/world-kernel.ts:424-433` and
335
+ `:794-811`; `ToolFailureOptions` has no commit option, `@firedrill-run/tool-sdk` `src/failure.ts:6-11`). A 402 with
336
+ persisted state is therefore not expressible, and `payment_intents.create/confirm` and `invoices.pay` declines
337
+ roll back as described under Operations. Failed attempts are persisted only through `subscriptions.create`.
338
+ Because the rollback also resets the id counters, a creation-time decline body (`payment_intents.create` with
339
+ `confirm=true`, `subscriptions.create` with `error_if_incomplete`) deliberately omits the PaymentIntent — it was
340
+ never created, and its id would 404 and then be reassigned to the next object — so it carries the error envelope
341
+ only, unlike Stripe's body; `/confirm` and `/pay` decline bodies name only the existing intent and pre-existing
342
+ PaymentMethod. Assert declines through the 402 envelope, through `subscriptions.create` state or through
343
+ `payment_intent.declined`.
344
+ - Money: integer minor units; the 50-minor-unit minimum applies to every currency (no per-currency minimums, no
345
+ zero-decimal handling beyond accepting `jpy` amounts as-is); twelve supported currencies; no conversion; customer
346
+ `balance` credit is shown but never applied; no taxes, discounts, coupons, shipping or application fees
347
+ (`total == subtotal`); partial capture does not create the release refund object.
348
+ - Invoices: `auto_advance` is stored but nothing advances automatically; no dunning, retries,
349
+ `next_payment_attempt`, e-mail, PDF or hosted page (`.test` URLs resolve nowhere); `paid_out_of_band` cancels the
350
+ intent instead of recording an out-of-band payment object; ad-hoc items carry `pricing: null`; `uncollectible`
351
+ is reachable only through starter data; the customer snapshot is refreshed at finalization.
352
+ - Subscriptions: no renewal cycling, no `past_due`/`unpaid`/`paused`/`incomplete_expired` transitions (starter
353
+ data only), no proration invoice items, one interval per subscription, `trial_end: "now"` activates without an
354
+ invoice, `cancel_at` is stored but never fires, `DELETE` accepts no body (`invoice_now`/`prorate`/
355
+ `cancellation_details` on cancel get the framework's 400 `HTTP_BODY_NOT_ALLOWED`), `billing_mode` is always
356
+ `flexible`.
357
+ - Balance uses a fixed two-day pending window attributed by charge date (a refund reduces its charge's bucket);
358
+ no payouts, fees or `instant_available`; `txn_…` ids have no object behind them.
359
+ - Lists scan the namespace with a bound of 10 000 rows and fail `400 state_bound_exceeded` rather than truncate.
360
+ `url` in list envelopes is a relative path.
361
+ - Authentication is the world token as `Authorization: Bearer`; restricted-key permissions are nine coarse groups
362
+ (no Stripe-granular scopes); the 403 wording is modelled on, not verified against, Stripe's. `Stripe-Version`,
363
+ `Stripe-Account` and `Stripe-Context` headers are accepted and ignored.
364
+ - Framework-level rejections (unknown parameters, schema type mismatches, `Idempotency-Key` longer than 255
365
+ characters, a body on `DELETE`) carry the framework's messages, not Stripe's `parameter_unknown` with `param`.
366
+ - Rate-limit and outage faults are scenario-selected only — no real per-second accounting.
367
+ - The browser app is a recreation of the Dashboard's core screens, not the whole product. Chrome for features outside
368
+ this Tool renders in place with its glyph, hover state and a "(not simulated)" tooltip, and opens a short
369
+ "… is not simulated by this Tool" panel instead of inventing data. Not simulated: sidebar Connect (Overview,
370
+ Connected accounts, Transfers), Payments › Analytics, Disputes, Radar, Payment Links, Terminal, Billing › Overview,
371
+ Usage-based, Revenue recovery, Reporting (Reports, Sigma, Revenue Recognition, Data management), More (Workflows,
372
+ Tax, Identity, Issuing, Financial Connections, Capital, Climate); the sandbox banner's "Switch to live account";
373
+ account menu "Switch to sandbox", "Manage sandboxes", "Create account"; top-bar Apps; Create menu "Payment link",
374
+ "Coupon", "Quote"; help menu "Documentation", "Contact support"; settings menu "Personal details", "Team and
375
+ security", "Branding", "Billing settings"; every list's "Export", "Edit columns" (plus Customers "Analyze" and
376
+ Product catalog "Export prices"); filter chips other than Payments "Date and time" and the Invoices customer
377
+ filter (Amount, Currency, Status, Payment method, Card, Created date, Due date, Type, Customer, Product, Price, More
378
+ filters); Customers segment cards other than All (Top customers, First-time customers, Repeat customers, Recent
379
+ customers, High refunds, High disputes: shown with a "–" count because segment analytics are not computed; All
380
+ counts `customers.list`); Product catalog section tabs Coupons, Shipping rates, Tax rates and Pricing tables (the
381
+ Products tab and its All / Active / Archived cards are live); Home "Add"/"Edit" widgets, the date-range, granularity
382
+ and comparison chips (the overview is fixed to 7 daily points), the "Dispute activity" and "MRR" overview widgets
383
+ and the Payouts card; payment "Add note" and "Events and logs"; customer "Events"; and the bulk actions on selected list rows ("Export selected", "Edit", "Delete" on Payments, Customers, Product catalog, Invoices and Subscriptions) — the row and header checkboxes select and deselect rows for real, but no bulk operation exists behind them. The Home "Net volume" widget is
384
+ captured charge volume minus succeeded refunds per day; Stripe fees are not modelled, so no fee is subtracted. Search matches
385
+ names, e-mails, descriptions and invoice numbers of the newest 1,000 records of each type (a note names every
386
+ group that stopped at that bound) and jumps to pasted ids (no full-text search). Payment status tabs count and filter a client-side index because the API has no status filter
387
+ for PaymentIntents. Shortcuts (pinned and recently visited pages) are a per-browser preference in localStorage.
388
+ Card brands are text chips, not network logos. Dates render in UTC. The sandbox banner's exact colours and copy are
389
+ modelled on Stripe's documented "banner at the top of the Dashboard", not verified pixel-for-pixel.
390
+
391
+ ## Browser app
392
+
393
+ `firedrill serve` also serves a **Stripe Dashboard look-alike** (declared as `ui: { root: "app/site" }`): the
394
+ sandbox banner across the top ("You're testing in a sandbox…"), the account switcher, the left navigation (Home ·
395
+ Balances · Transactions · Customers · Product catalog, Shortcuts with pinned and recent pages, the Products groups
396
+ Connect · Payments · Billing · Reporting · More, Developers), the sidebar search with `/` (directly under the account switcher, as the current Dashboard places it) and the Collapse control at the sidebar foot that turns the navigation into an icon rail (remembered per browser; a Search button in the header opens the navigation on narrow screens), Developers, help (`?`), Apps,
397
+ notifications, settings and the round "+" Create menu, list pages with status cards, dashed filter chips, Export /
398
+ Edit columns, row-selection checkboxes with a floating "N selected" action bar, and cursor pagination (Payments, Customers,
399
+ Product catalog, Invoices, Subscriptions, Refunds, Transactions), detail pages with meta bar, timeline and
400
+ key/value sections (payment, customer with a right-hand Details rail, product, invoice, subscription), Home with a
401
+ Today gross-volume chart (hourly, virtual time), balance card and "Your overview" widgets (Gross volume, Successful
402
+ payments, Failed payments, Net volume and New customers computed from charges, refunds and customers), Customers
403
+ segment cards, Product catalog section tabs with All / Active / Archived counts, and the create flows: "Create a payment"
404
+ (customer picker, saved cards + Stripe's test cards, confirm/capture options, 402 declines shown inline),
405
+ "Add customer", "Add a product" with its first price, "Add a price", "Create an invoice" → draft editor (add
406
+ catalog or one-off items, finalize, charge, void, mark paid out of band), "Create a subscription" (multiple
407
+ prices, trial, collection method, payment behaviour), refund / capture / confirm / cancel dialogs, payment-method
408
+ attach/detach/default, subscription update/cancel/resume. No list stops silently at one request or at a bound.
409
+ Transactions "All activity" merges `charges.list` and `refunds.list` server-side page by page (one
410
+ `starting_after` cursor per stream, Previous / Next), so every charge and refund is reachable. A customer's payments,
411
+ subscriptions and invoices and a subscription's invoices have Previous / Next pagination. Where the app reads a
412
+ client-side index (a product's prices, a payment's refunds, a customer's cards, the Home overview metrics, payment
413
+ status tabs, the price, customer and card pickers and search), the index is read page by page with `starting_after`
414
+ up to 1,000 records. When that bound is reached the app says so: a `+` on counts and totals, "Partial: first N …" on
415
+ Home widgets, "Showing the first N …; more exist" on sections and pickers. The customer picker then looks up an exact
416
+ e-mail (`customers.list?email=`) or `cus_…` id on the server. Price pickers filter currency on the server and
417
+ expand `data.product`. Every control calls the same operations as the REST
418
+ and MCP surfaces through `/_firedrill/client.js` with idempotency keys on mutations; the page polls the world
419
+ revision and re-reads after external changes without discarding a form being edited. Restricted keys see
420
+ "This key does not have … permission" panels; framework-denied actors see a denied panel; faults surface as the
421
+ Dashboard's error banners. Every form label is linked to its control (`for`, or `aria-labelledby` on composite
422
+ controls such as the amount + currency group and the payment-method radio group), hints and field errors are
423
+ attached with `aria-describedby`, and ids come from a counter. "Today" and every date come from `dashboard.context.now` (virtual time), never from
424
+ the browser clock. The app bundles **Inter** (OFL) as the closest permissive match to Stripe's proprietary Söhne
425
+ face and the official Stripe marks downloaded from the `logos-apps` library; sources are listed in
426
+ `firedrill/tools/stripe/app/assets/ATTRIBUTION.md`.
427
+
428
+ ## Trademarks
429
+
430
+ Stripe, the Stripe "S" icon and the Stripe wordmark are trademarks of Stripe, Inc. They are used only to identify
431
+ the service this package simulates in a test environment; there is no affiliation with or endorsement by Stripe.
432
+ Card network names (Visa, Mastercard, American Express) appearing as text labels are trademarks of their
433
+ respective owners and are used descriptively only.
@@ -0,0 +1,17 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "target": {
4
+ "id": "conformance-agent",
5
+ "kind": "command",
6
+ "bindings": [
7
+ "http",
8
+ "mcp"
9
+ ],
10
+ "executable": "node",
11
+ "arguments": [
12
+ "test/conformance.mjs"
13
+ ],
14
+ "workingDirectory": ".",
15
+ "timeoutMs": 120000
16
+ }
17
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "api-unavailable",
4
+ "title": "Every write answers HTTP 503 api_error and leaves state untouched; reads keep working",
5
+ "faults": [
6
+ {
7
+ "packageId": "stripe",
8
+ "faultId": "api-unavailable"
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "baseline",
4
+ "title": "Lumen Trail Supply Co. test-mode account as seeded by the world (no faults)"
5
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "conformance",
4
+ "title": "Stripe Tool conformance: REST flow, MCP aliases, restricted keys, live mode, denial and every fault",
5
+ "drills": [
6
+ "stripe-rest-flow",
7
+ "stripe-mcp-aliases",
8
+ "stripe-restricted-key",
9
+ "stripe-no-permissions",
10
+ "stripe-live-mode",
11
+ "stripe-denied",
12
+ "stripe-rate-limited",
13
+ "stripe-api-unavailable",
14
+ "stripe-refund-committed-lost",
15
+ "stripe-large-pages"
16
+ ]
17
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "rate-limited",
4
+ "title": "Every Stripe-shaped operation (37; not dashboard.context) answers HTTP 429 rate_limit and nothing runs",
5
+ "faults": [
6
+ {
7
+ "packageId": "stripe",
8
+ "faultId": "rate-limited"
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "refund-committed-lost",
4
+ "title": "Refunds commit but the caller sees HTTP 503; a retry with a new Idempotency-Key refunds twice",
5
+ "faults": [
6
+ {
7
+ "packageId": "stripe",
8
+ "faultId": "refund-committed-lost"
9
+ }
10
+ ]
11
+ }