@emulates/flex 2.3.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog — @emulates/flex
2
+
3
+ ## 2.3.1 (2026-10-06)
4
+
5
+ Initial release.
6
+
7
+ ### Dependencies
8
+
9
+ - `@emulates/sqlite`
package/DISCOVERY.md ADDED
@@ -0,0 +1,55 @@
1
+ # @emulates/flex discovery
2
+
3
+ This is the installed-package index for coding agents and tooling. All relative links resolve
4
+ inside `node_modules/@emulates/flex/`; no repository checkout is needed to discover the emulator's
5
+ supported surface or documented behavior.
6
+
7
+ ## Capability and behavior sources
8
+
9
+ | Question | Authoritative file | What it contains |
10
+ | --- | --- | --- |
11
+ | Behaviour and integration | [`README.md`](README.md) | Routes, state transitions, auth, webhooks, controls, presets and deliberate omissions. |
12
+ | Exact capabilities | [`SUPPORT.md`](SUPPORT.md) | Supported, unsupported and parity-covered operations or commands, including reasons for gaps. |
13
+ | Wire contract | [`openapi.yaml`](openapi.yaml) | Machine-readable paths, methods, schemas, responses and parity annotations. |
14
+ | Public API | [`dist/index.d.ts`](dist/index.d.ts) | The installed package's exact TypeScript exports and signatures. |
15
+ | Package metadata | [`package.json`](package.json) | Runtime/entry-point claims, vendor links, parity scope/tier and `emulates.discovery`. |
16
+
17
+ Read these together: the contract/capability matrix says *what* is available, while the README
18
+ defines stateful behavior, lifecycle rules, test controls, and intentional oracle differences.
19
+ If prose and an executable surface disagree, report a parity mismatch instead of adding a
20
+ consumer-side workaround.
21
+
22
+ ## Parity and oracle
23
+
24
+ - Declared parity surface: **Checkout, subscriptions, and webhooks**.
25
+ - Parity tier: **cold** (the repository controls when live checks run).
26
+ - Oracle: **Live vendor API or sandbox**.
27
+ - Repository command: `bun run parity:service -- flex`.
28
+ - Evidence model: Run from an Emulates checkout; credentials come only from .env.local or GitHub Actions secrets. Missing credentials exit 2.
29
+
30
+ The npm package contains evidence summaries and the exact contract, not credentials or the
31
+ repository-only parity harness. Self-parity/property and acceptance tests run in the Emulates
32
+ repository; live parity is an additional oracle check, not a substitute for the packaged matrix.
33
+
34
+ ## Runtime introspection
35
+
36
+ - `GET /__admin/health`
37
+ - `GET /__admin`
38
+ - `GET /__admin/state`
39
+ - `GET /__admin/requests`
40
+ - `GET /__admin/metrics`
41
+ - `GET /__admin/faults/presets`
42
+ - `GET /__admin/ui`
43
+
44
+ For HTTP services, use `x-emulates-namespace` (or the documented credential/path carrier) so
45
+ parallel tests do not share state. Admin state, journal, metrics and fault-preset endpoints are
46
+ designed for assertions and diagnosis by consuming test suites.
47
+
48
+ ## Report a mismatch or missing capability
49
+
50
+ Follow the [agent reporting contract](https://github.com/crvouga/emulators/blob/main/docs/REPORTING_ISSUES.md). Include package version,
51
+ operation/command, a minimal redacted request, actual emulator result, expected oracle result or vendor
52
+ documentation, and whether the mismatch appears in the matrix. Never include keys, tokens,
53
+ customer data, prompts, PHI, card data, or unredacted recordings.
54
+
55
+ Service key: `flex`.
package/README.md ADDED
@@ -0,0 +1,234 @@
1
+ # @emulates/flex
2
+
3
+ > Part of [Emulates](https://github.com/crvouga/emulators): high-fidelity, in-process emulators for APIs and databases.
4
+
5
+ Stateful emulator of the **Flex** (withflex.com) HSA/FSA payments API for test suites: products
6
+ (answered from a recorded catalog corpus), checkout sessions in `payment` (one-time),
7
+ `subscription`, `off_session` and `setup` modes, subscriptions, customers, setup intents,
8
+ refunds, the **hosted checkout page**, and the Svix-signed webhooks Flex posts back. A UI checkout that drove the real
9
+ `checkout.withflex.com` page and then waited on a 5-minute reconciler settles here in
10
+ milliseconds: the page is local, and the signed webhook reaches the app as soon as the card is
11
+ accepted.
12
+
13
+ - Operation coverage: [SUPPORT.md](https://github.com/crvouga/emulators/blob/main/packages/service/flex/SUPPORT.md)
14
+ - Flex publishes no machine-readable spec: the contract (`openapi.yaml`) is hand-authored from
15
+ the wire shapes our consumer reads and writes (`B/billing/flex/`), and every field its zod
16
+ schemas require is served. Subscription mode, `price_data.recurring` and the subscription
17
+ object follow the [Flex API reference](https://docs.withflex.com/api-reference) (there is
18
+ no sandbox recording).
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ npm install -D @emulates/flex
24
+ ```
25
+
26
+ ESM only. Node >= 22 or Bun >= 1.2. No native dependencies. Serve it with
27
+ `npx emulates-flex serve`, `createServer` from `./server` (Node), or `createRuntime` with any
28
+ Fetch server.
29
+
30
+ ## Usage
31
+
32
+ Point the app at the emulator:
33
+
34
+ | Env | Value |
35
+ | --- | --- |
36
+ | `FLEX_API_BASE_URL` | `http://127.0.0.1:8792` (or `…/__admin/ns/<namespace>`) |
37
+ | `FLEX_API_KEY` | any `fsk_test_…` key (test mode); `fsk_…` is live mode; other formats get 401 |
38
+ | `FLEX_WEBHOOK_SECRET` | the same value as `--webhook-secret`: `fwhsec_<base64>` or `whsec_<base64>` |
39
+
40
+ ```bash
41
+ npx emulates-flex serve --port 8792 \
42
+ --webhook-url http://127.0.0.1:3000/billing/webhooks/flex \
43
+ --webhook-secret "$FLEX_WEBHOOK_SECRET"
44
+ ```
45
+
46
+ ```ts
47
+ import { createRuntime } from "@emulates/flex"
48
+
49
+ const flex = createRuntime({
50
+ webhooks: {
51
+ url: "http://127.0.0.1:3000/billing/webhooks/flex",
52
+ secret: "fwhsec_ZmxleC1tb2NrLXNpZ25pbmcta2V5",
53
+ },
54
+ })
55
+ const post = (path: string, body: unknown) =>
56
+ flex.fetch(
57
+ new Request(`http://flex.test${path}`, {
58
+ method: "POST",
59
+ headers: { "content-type": "application/json", authorization: "Bearer fsk_test_suite" },
60
+ body: JSON.stringify(body),
61
+ }),
62
+ )
63
+
64
+ const { checkout_session } = (await (
65
+ await post("/v1/checkout/sessions", {
66
+ checkout_session: {
67
+ success_url: "https://app.test/done?session_id={CHECKOUT_SESSION_ID}",
68
+ cancel_url: "https://app.test/cart",
69
+ client_reference_id: "attempt-1",
70
+ line_items: [
71
+ { price_data: { product: "fprod_01m0tgysj4ahvf8fas60c2ef2d", unit_amount: 4500 }, quantity: 1 },
72
+ ],
73
+ },
74
+ })
75
+ ).json()) as { checkout_session: { checkout_session_id: string; url: string } }
76
+ // …open checkout_session.url in the browser and pay with 4000 0512 3000 0072, or:
77
+ await post(`/__admin/sessions/${checkout_session.checkout_session_id}/complete`, {})
78
+ ```
79
+
80
+ ### Browser E2E suites
81
+
82
+ Run `npx emulates-flex serve` on a port, point `FLEX_API_BASE_URL` at it, and the browser
83
+ lands on the emulator's page instead of `checkout.withflex.com`: no network, no shared Flex
84
+ account, and the signed webhook reaches the app as soon as Pay is clicked. The page URL takes
85
+ the emulator's origin, or `--public-url` when given. A suite that finds the Flex tab by host
86
+ (`/\bcheckout\.withflex\.com\b/`) keeps working with
87
+ `--public-url http://checkout.withflex.com.localhost:8792`: Chromium resolves every
88
+ `*.localhost` name to loopback. Other browsers may need a hosts entry.
89
+
90
+ ### Routes
91
+
92
+ All API bodies are wrapped: `{product: {…}}`, `{checkout_session: {…}}`, `{customer: {…}}`,
93
+ `{setup_intent: {…}}`, `{products: […], has_more}`, `{checkout_sessions: […], has_more}`.
94
+ Errors are `{error: {type, message, param?}}`.
95
+
96
+ | Route | Behaviour |
97
+ | --- | --- |
98
+ | `GET /v1/products?limit=&starting_after=` | Oldest first; `limit` 1–100 (default 10). The corpus comes first, then created products. |
99
+ | `POST /v1/products` | `{product: {name, description?, url?, client_reference_id?, metadata?}}`. New products are active, `hsa_fsa_eligibility: null` until classified (`PUT /__admin/products/:id`). |
100
+ | `GET /v1/products/{id}` | `product_id, name, description, url, client_reference_id, hsa_fsa_eligibility, visit_type, active, test_mode, metadata, created_at`. |
101
+ | `PATCH /v1/products/{id}` | `{product: {active?, name?, description?, url?, metadata?}}`; emits `product.updated`. |
102
+ | `POST /v1/checkout/sessions` | `Idempotency-Key` honoured. `mode` `payment` (≥1 line item), `subscription` (≥1 line item whose `price_data.recurring` is `{interval: day\|week\|month\|year, interval_count?}`, else 400; optional `subscription_data: {cancel_at_period_end?, metadata?}`), `setup` (a customer and no line items, else 400), `off_session` (customer + a saved `payment_method`, charged before answering: the response is already `complete`, or its expanded payment intent is `requires_payment_method` / `requires_action` per `offSessionOutcome`). Unknown or inactive products, customers and payment methods are 400. `redirect_url` and `url` are the hosted page. |
103
+ | `GET /v1/checkout/sessions/{id}?expand_customer=true&expand_payment_intent=true` | The session; expansions return `customer` / `payment_intent` objects instead of ids. |
104
+ | `GET /v1/checkout/sessions?client_reference_id=&limit=&starting_after=` | Newest first (our ambiguous-create recovery). |
105
+ | `POST /v1/checkout/sessions/{id}/refund` | `Idempotency-Key` honoured. `{checkout_session: {}}` (full) or `{checkout_session: {amount}}`; 400 when unpaid or over-refunded. |
106
+ | `POST /v1/customers` | `Idempotency-Key` honoured. `{customer: {first_name, last_name, email, phone}}` (all required). |
107
+ | `GET /v1/setup_intents/{id}?expand=customer,payment_method` | `setup_intent_id, status, customer, payment_method`. |
108
+ | `GET /v1/subscriptions/{id}` | `{subscription: {subscription_id, status, items, customer, default_payment_method, cancel_at_period_end, current_period_start, current_period_end, canceled_at, metadata, test_mode, created_at}}`. A paid subscription-mode session starts one: `active`, `items` = its recurring line items, the period one interval of the first recurring item (month/year steps clamp to the month's last day), charged to the card just used; the session's `subscription` holds its id. |
109
+
110
+ Auth: `Authorization: Bearer fsk_test_…` or `fsk_…`; a missing key, or any other format
111
+ (`sk_test_…`, `whsec_…`), is 401 `authentication_error`. `test_mode` on created objects follows
112
+ the key. Same key + same body replays the stored response; same key + a different body is 400
113
+ `idempotency_error`; a concurrent request with an in-flight key is 409.
114
+
115
+ ### Hosted page
116
+
117
+ `GET /pay/{sessionId}` renders a plain form (no scripts) with `data-testid`s
118
+ `flex-mock-email`, `-first-name`, `-last-name`, `-phone`, `-card`, `-exp`, `-cvc`, `-zip`,
119
+ `-pay`, `-cancel`, `-error`, `-amount`, and on the letter step `-lmn-submit`. Inputs also carry
120
+ the `name`s and placeholders our codecept locators look for (`cardNumber`, `expiry`, `cvc`,
121
+ `postalCode`, `email`, …). `POST /pay/{sessionId}` submits it:
122
+
123
+ | Card | Result |
124
+ | --- | --- |
125
+ | `4000 0512 3000 0072` | HSA card: succeeds. |
126
+ | `4242 4242 4242 4242` (or any other valid card) | Succeeds; if a line item's product is `letter_of_medical_necessity`, the session gets `next_action: {type: "collect_letter_of_medical_necessity", collect_letter_of_medical_necessity: {url}}` and the browser goes to that step; submitting it completes the payment. |
127
+ | `4000 0000 0000 0002` | Declines: 402 page with `<div role="alert">Your card was declined.</div>`; the payment intent is `requires_payment_method`. |
128
+
129
+ Success 302s to `success_url` with `{CHECKOUT_SESSION_ID}` substituted (raw and
130
+ `%7BCHECKOUT_SESSION_ID%7D`); `GET /pay/{id}/cancel` (the Cancel link) 302s to `cancel_url`,
131
+ leaving the session open. A contact email on a session without a customer creates one. In a
132
+ namespace the page URL carries `/__admin/ns/<name>` (the browser sends no headers); `publicUrl`
133
+ overrides the origin.
134
+
135
+ ### Webhooks
136
+
137
+ Svix-signed (`svix-id`, `svix-timestamp` = wall clock, `svix-signature: v1,<base64
138
+ HMAC-SHA256(key, "<id>.<ts>.<body>")>`, key = base64-decoded secret after `fwhsec_`/`whsec_`).
139
+ Body: `{event: {event_id, event_type, object, event_dt, test_mode, created_at}}`.
140
+ Checkout events carry the session (so `object.checkout_session_id`), payment-intent events the
141
+ intent plus `checkout_session_id`, refund events `checkout_session` and `payment_intent`, and
142
+ `product.updated` the product (`object.product_id`).
143
+
144
+ | Event | When |
145
+ | --- | --- |
146
+ | `payment_intent.succeeded`, then `checkout.session.completed` | the page (or `…/complete`, or an off-session charge) settles a session |
147
+ | `customer.subscription.created` (the subscription), before those two | a subscription-mode session settles |
148
+ | `checkout.session.async_payment_succeeded` | settling a session whose intent was `processing` |
149
+ | `checkout.session.async_payment_failed` | a decline (page, admin, off-session) |
150
+ | `checkout.session.expired` | `…/expire`, or `expires_at` passing on the emulator clock (default 24 h) |
151
+ | `refund.created`, `charge.refunded`, `checkout.session.refunded`, `refund.updated`, `charge.refund.updated` | each refund |
152
+ | `product.updated` | `PATCH /v1/products/{id}` and `PUT /__admin/products/:id` |
153
+ | `checkout_session.completed`, `checkout_session.expired` | the aliases, with `PUT /__admin/settings {"eventNaming": "underscored"}` |
154
+
155
+ `POST /__admin/events {type, session | product}` emits any type on demand. Non-2xx answers are
156
+ retried (immediately, 5 s, 5 min, 30 min, 2 h); `GET /__admin/webhooks`, `…/events`,
157
+ `POST /__admin/webhooks/flush`, `…/:id/replay`, `PUT /__admin/webhook-endpoints` as usual.
158
+
159
+ ### Admin (beyond the standard contract)
160
+
161
+ | Route | Effect |
162
+ | --- | --- |
163
+ | `PUT /__admin/products/:id` | `{hsa_fsa_eligibility?, active?, test_mode?, visit_type?, client_reference_id?, name?, metadata?}`; emits `product.updated`. |
164
+ | `POST /__admin/sessions/:id/complete` | `{card?}`: settle as if paid (HSA card unless `card` is `4242…`). |
165
+ | `POST /__admin/sessions/:id/decline` | Payment intent → `requires_payment_method`. |
166
+ | `POST /__admin/sessions/:id/expire` | Session → `expired`. |
167
+ | `POST /__admin/sessions/:id/require_action` | `{next_action_type?}`: `collect_letter_of_medical_necessity` (default), `provide_second_payment_method`, `provide_alternative_payment_method`, `payment_failed`. |
168
+ | `PUT /__admin/sessions/:id/payment-intent` | `{status, amount_received?}`: `requires_payment_method`, `requires_action`, `processing`, `succeeded`, `canceled`. |
169
+ | `GET /__admin/sessions`, `GET /__admin/sessions/:id` | The namespace's sessions. |
170
+ | `POST /__admin/events` | Emit any event type for a session or product. |
171
+ | `GET/PUT /__admin/settings` | `{eventNaming, offSessionOutcome, sessionTtlSeconds, lmnOnRegularCard, publicUrl}`. |
172
+ | `POST /__admin/tick` | Expire due sessions now (the served emulator ticks every 100 ms). |
173
+
174
+ Every orchestrator state is reachable: pending (open), action_required (`require_action`, or an
175
+ intent `requires_action`), processing, canceled (intent `canceled`, or expired), failed
176
+ (`decline`), succeeded, refunded (full refund), quarantined (partial refund,
177
+ `amount_mismatch`, duplicate sessions).
178
+
179
+ Fault presets (`POST /__admin/faults {"preset": "<name>", "count"?: n}`; `GET /__admin/faults/presets`):
180
+ `create_4xx` (400, nothing created), `create_5xx` (creates, then 500: recovery adopts it),
181
+ `create_5xx_not_created`, `timeout` (creates, answers after 16 s, past the client's 15 s abort;
182
+ `params.delayMs` overrides), `invalid_shape` (no `redirect_url`/`url`), `amount_mismatch`
183
+ (`amount_total` + 100), `duplicate_sessions_for_client_reference` (two sessions, then 500),
184
+ `refund_4xx`, `server_error`, `webhook_duplicate`, `webhook_reorder`, `webhook_drop`.
185
+
186
+ ### Namespaces
187
+
188
+ `x-emulates-namespace`, a `/__admin/ns/<name>` prefix on `FLEX_API_BASE_URL`, or by API key:
189
+ `PUT /__admin/credentials {"credentials": {"<FLEX_API_KEY>": "<namespace>"}}`.
190
+
191
+ ### Corpus
192
+
193
+ `src/corpus/products.ts` is the product side of every row of the consumer's
194
+ `flexCatalogMappings` reference fixture (663 rows, regenerated with
195
+ `bun scripts/corpus.ts <fixture.json>`): product id, client reference, the `acme_purpose` /
196
+ `acme_merchant_product_id` / `acme_client_reference_id` metadata our catalog validation
197
+ compares, eligibility and visit type. Every product is active and test-mode, so our validation
198
+ reproduces each mapping row's own `active` flag. No sandbox recording exists (no credentials),
199
+ so product names are synthesised.
200
+
201
+ ### Deliberately not modelled
202
+
203
+ - Real card processing, Stripe iframes and split-tender payments: the page is a plain form, and
204
+ split payment is reachable only as `next_action: provide_second_payment_method`.
205
+ - The letter-of-medical-necessity questionnaire: one submit button stands in for it.
206
+ - Test/live data separation: a live key sees the same objects (only `test_mode` differs).
207
+ - Subscription lifecycle after checkout: renewals, invoices and the `invoice.*` events,
208
+ trials, `customer.subscription.updated` / `.deleted`, and the subscription update/cancel
209
+ routes. A subscription stays `active` for its first period. The Prices API (`price` ids in
210
+ line items) is not modelled either: use inline `price_data`.
211
+ - Coupons, promotion codes (`allow_promotion_codes` is echoed only), partial captures,
212
+ disputes.
213
+ - Flex's exact error texts and ids: shapes follow what our consumer reads; ids look like
214
+ `fprod_01z…`, `fcs_01z…`, `fcus_…`, `fpi_…`, `fseti_…`, `fpm_…`, `fevt_…`.
215
+
216
+ ## API
217
+
218
+ | Export | Kind | Description |
219
+ | --- | --- | --- |
220
+ | `FlexAPI` | class | The in-process emulator: `fetch(request)`, `reset()`, `settle(id)`, `decline(id)`, `expire(id)`, `requireAction(id, type)`, `setPaymentIntent(id, patch)`, `applyRefund(id, amount)`, `putProduct(product)`, `emitFor(type, target)`, `tick()`, `sessions()`, `subscriptions()`, `present(session, view)`. Options: `sqlite`, `now`, `namespace`, `publicNamespace`, `products`, `settings`, `onEvent`. |
221
+ | `createRuntime` | function | The emulator with the full service contract. Options: `webhooks: {url, secret, retryDelaysMs?, fetch?}`, `products`, `settings`, `tickMs`, `clock`, `seed`, `adminKey`, `onLog`. |
222
+ | `FLEX_PRESETS` | object | Every named fault preset. |
223
+ | `FLEX_NAMESPACE` | string | The service name, `"flex"`. |
224
+ | `FLEX_EVENT_TYPES` | array | Every webhook event type, aliases included. |
225
+ | `keyMode` | function | `"test"` for `fsk_test_…`, `"live"` for `fsk_…`, otherwise `undefined`. |
226
+ | `isNextActionType` | function | Whether a string is a next-action type. |
227
+ | `CARDS`, `classifyCard`, `substituteSessionId` | values | The hosted page's test cards, its card classifier, and the `{CHECKOUT_SESSION_ID}` substitution. |
228
+ | `CORPUS_ROWS`, `corpusProduct` | values | The recorded product corpus and its row → product mapping. |
229
+ | `DEFAULT_SETTINGS`, `ELIGIBILITIES`, `NEXT_ACTION_TYPES`, `PAYMENT_INTENT_STATUSES`, `SUBSCRIPTION_STATUSES` | values | Defaults and enums. |
230
+ | `periodEnd` | function | `periodEnd(startMs, {interval, interval_count?})`: the end of a billing period (epoch ms). |
231
+ | `document`, `operationIds`, `supportedOperationIds` | values | The vendored OpenAPI contract and its operation ids. |
232
+ | `createServer`, `serveTarget`, `DEFAULT_PORT` (`./server`) | Node | Serve over `node:http` (expiry ticks every 100 ms); the `serve` CLI target (`--webhook-url`, `--webhook-secret`, `--public-url`, `--event-naming`); port 8792. |
233
+
234
+ Part of [emulators](https://github.com/crvouga/emulators).
package/SUPPORT.md ADDED
@@ -0,0 +1,24 @@
1
+ # Flex HSA/FSA payments API (Emulates subset) — operation support
2
+
3
+ Generated from `openapi.yaml`; do not edit by hand.
4
+
5
+ - operations in spec: **14**
6
+ - supported by the emulator: **14**
7
+ - parity enabled: **11**
8
+
9
+ | operationId | route | emulator | parity | notes |
10
+ | --- | --- | --- | --- | --- |
11
+ | `ListProducts` | `GET /v1/products` | ✅ supported | ✅ | |
12
+ | `CreateProduct` | `POST /v1/products` | ✅ supported | ⚠️ unsafe (opt-in) | |
13
+ | `GetProduct` | `GET /v1/products/{productId}` | ✅ supported | ✅ | |
14
+ | `UpdateProduct` | `PATCH /v1/products/{productId}` | ✅ supported | ⚠️ unsafe (opt-in) | |
15
+ | `ListCheckoutSessions` | `GET /v1/checkout/sessions` | ✅ supported | ✅ | |
16
+ | `CreateCheckoutSession` | `POST /v1/checkout/sessions` | ✅ supported | ⚠️ unsafe (opt-in) | |
17
+ | `GetCheckoutSession` | `GET /v1/checkout/sessions/{sessionId}` | ✅ supported | ✅ | |
18
+ | `RefundCheckoutSession` | `POST /v1/checkout/sessions/{sessionId}/refund` | ✅ supported | ⚠️ unsafe (opt-in) | |
19
+ | `CreateCustomer` | `POST /v1/customers` | ✅ supported | ⚠️ unsafe (opt-in) | |
20
+ | `GetSetupIntent` | `GET /v1/setup_intents/{setupIntentId}` | ✅ supported | ✅ | |
21
+ | `GetSubscription` | `GET /v1/subscriptions/{subscriptionId}` | ✅ supported | ✅ | |
22
+ | `HostedCheckoutPage` | `GET /pay/{sessionId}` | ✅ supported | ❌ disabled | A browser-facing HTML page; covered by the acceptance suite, not by JSON walks. |
23
+ | `SubmitHostedCheckout` | `POST /pay/{sessionId}` | ✅ supported | ❌ disabled | A browser form post; covered by the acceptance suite. |
24
+ | `CancelHostedCheckout` | `GET /pay/{sessionId}/cancel` | ✅ supported | ❌ disabled | A browser navigation to cancel_url; covered by the acceptance suite. |