@crvouga/mockingbird-service-flex 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,22 @@
1
1
  # Changelog — @crvouga/mockingbird-service-flex
2
2
 
3
- ## 0.2.0 (2026-09-24)
3
+ ## 1.0.0 (2026-09-24)
4
+
5
+ ### ⚠️ Breaking changes
6
+
7
+ - vendor branding for every service; purge internal mocks and consumer names ([f689534](https://github.com/crvouga/mockingbird/commit/f6895343543550dafcb7d76b426cae7635e03226))
8
+
9
+ ### Dependencies
10
+
11
+ - `@crvouga/mockingbird-service-sqlite`
12
+
13
+ ## 0.3.0 (2026-09-24)
14
+
15
+ ### Features
16
+
17
+ - add subscription-mode hosted checkout and promote the service to ready ([dd42cc2](https://github.com/crvouga/mockingbird/commit/dd42cc247a83588cb2b84b89eed4f870494b084f))
18
+
19
+ ## 0.2.0 (2026-09-23)
4
20
 
5
21
  ### Features
6
22
 
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # @crvouga/mockingbird-service-flex
2
2
 
3
3
  Stateful mock of the **Flex** (withflex.com) HSA/FSA payments API for test suites: products
4
- (answered from a recorded catalog corpus), checkout sessions in `payment`, `off_session` and
5
- `setup` modes, customers, setup intents, refunds, the **hosted checkout page**, and the
6
- Svix-signed webhooks Flex posts back. A UI checkout that drove the real
4
+ (answered from a recorded catalog corpus), checkout sessions in `payment` (one-time),
5
+ `subscription`, `off_session` and `setup` modes, subscriptions, customers, setup intents,
6
+ refunds, the **hosted checkout page**, and the Svix-signed webhooks Flex posts back. A UI checkout that drove the real
7
7
  `checkout.withflex.com` page and then waited on a 5-minute reconciler settles here in
8
8
  milliseconds: the page is local, and the signed webhook reaches the app as soon as the card is
9
9
  accepted.
@@ -11,7 +11,9 @@ accepted.
11
11
  - Operation coverage: [SUPPORT.md](https://github.com/crvouga/mockingbird/blob/main/packages/service/flex/SUPPORT.md)
12
12
  - Flex publishes no machine-readable spec: the contract (`openapi.yaml`) is hand-authored from
13
13
  the wire shapes our consumer reads and writes (`B/billing/flex/`), and every field its zod
14
- schemas require is served.
14
+ schemas require is served. Subscription mode, `price_data.recurring` and the subscription
15
+ object follow the [Flex API reference](https://docs.withflex.com/api-reference) (there is
16
+ no sandbox recording).
15
17
 
16
18
  ## Install
17
19
 
@@ -73,6 +75,16 @@ const { checkout_session } = (await (
73
75
  await post(`/__admin/sessions/${checkout_session.checkout_session_id}/complete`, {})
74
76
  ```
75
77
 
78
+ ### Browser E2E suites
79
+
80
+ Run `npx mockingbird-flex serve` on a port, point `FLEX_API_BASE_URL` at it, and the browser
81
+ lands on the mock's page instead of `checkout.withflex.com`: no network, no shared Flex
82
+ account, and the signed webhook reaches the app as soon as Pay is clicked. The page URL takes
83
+ the mock's origin, or `--public-url` when given. A suite that finds the Flex tab by host
84
+ (`/\bcheckout\.withflex\.com\b/`) keeps working with
85
+ `--public-url http://checkout.withflex.com.localhost:8792`: Chromium resolves every
86
+ `*.localhost` name to loopback. Other browsers may need a hosts entry.
87
+
76
88
  ### Routes
77
89
 
78
90
  All API bodies are wrapped: `{product: {…}}`, `{checkout_session: {…}}`, `{customer: {…}}`,
@@ -85,12 +97,13 @@ Errors are `{error: {type, message, param?}}`.
85
97
  | `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`). |
86
98
  | `GET /v1/products/{id}` | `product_id, name, description, url, client_reference_id, hsa_fsa_eligibility, visit_type, active, test_mode, metadata, created_at`. |
87
99
  | `PATCH /v1/products/{id}` | `{product: {active?, name?, description?, url?, metadata?}}`; emits `product.updated`. |
88
- | `POST /v1/checkout/sessions` | `Idempotency-Key` honoured. `mode` `payment` (≥1 line item), `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. |
100
+ | `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. |
89
101
  | `GET /v1/checkout/sessions/{id}?expand_customer=true&expand_payment_intent=true` | The session; expansions return `customer` / `payment_intent` objects instead of ids. |
90
102
  | `GET /v1/checkout/sessions?client_reference_id=&limit=&starting_after=` | Newest first (our ambiguous-create recovery). |
91
103
  | `POST /v1/checkout/sessions/{id}/refund` | `Idempotency-Key` honoured. `{checkout_session: {}}` (full) or `{checkout_session: {amount}}`; 400 when unpaid or over-refunded. |
92
104
  | `POST /v1/customers` | `Idempotency-Key` honoured. `{customer: {first_name, last_name, email, phone}}` (all required). |
93
105
  | `GET /v1/setup_intents/{id}?expand=customer,payment_method` | `setup_intent_id, status, customer, payment_method`. |
106
+ | `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. |
94
107
 
95
108
  Auth: `Authorization: Bearer fsk_test_…` or `fsk_…`; a missing key, or any other format
96
109
  (`sk_test_…`, `whsec_…`), is 401 `authentication_error`. `test_mode` on created objects follows
@@ -129,6 +142,7 @@ intent plus `checkout_session_id`, refund events `checkout_session` and `payment
129
142
  | Event | When |
130
143
  | --- | --- |
131
144
  | `payment_intent.succeeded`, then `checkout.session.completed` | the page (or `…/complete`, or an off-session charge) settles a session |
145
+ | `customer.subscription.created` (the subscription), before those two | a subscription-mode session settles |
132
146
  | `checkout.session.async_payment_succeeded` | settling a session whose intent was `processing` |
133
147
  | `checkout.session.async_payment_failed` | a decline (page, admin, off-session) |
134
148
  | `checkout.session.expired` | `…/expire`, or `expires_at` passing on the mock clock (default 24 h) |
@@ -176,8 +190,8 @@ Fault presets (`POST /__admin/faults {"preset": "<name>", "count"?: n}`; `GET /_
176
190
 
177
191
  `src/corpus/products.ts` is the product side of every row of the consumer's
178
192
  `flexCatalogMappings` reference fixture (663 rows, regenerated with
179
- `bun scripts/corpus.ts <fixture.json>`): product id, client reference, the `geviti_purpose` /
180
- `geviti_merchant_product_id` / `geviti_client_reference_id` metadata our catalog validation
193
+ `bun scripts/corpus.ts <fixture.json>`): product id, client reference, the `acme_purpose` /
194
+ `acme_merchant_product_id` / `acme_client_reference_id` metadata our catalog validation
181
195
  compares, eligibility and visit type. Every product is active and test-mode, so our validation
182
196
  reproduces each mapping row's own `active` flag. No sandbox recording exists (no credentials),
183
197
  so product names are synthesised.
@@ -188,8 +202,12 @@ so product names are synthesised.
188
202
  split payment is reachable only as `next_action: provide_second_payment_method`.
189
203
  - The letter-of-medical-necessity questionnaire: one submit button stands in for it.
190
204
  - Test/live data separation: a live key sees the same objects (only `test_mode` differs).
191
- - Subscriptions, coupons, promotion codes (`allow_promotion_codes` is echoed only), partial
192
- captures, disputes.
205
+ - Subscription lifecycle after checkout: renewals, invoices and the `invoice.*` events,
206
+ trials, `customer.subscription.updated` / `.deleted`, and the subscription update/cancel
207
+ routes. A subscription stays `active` for its first period. The Prices API (`price` ids in
208
+ line items) is not modelled either: use inline `price_data`.
209
+ - Coupons, promotion codes (`allow_promotion_codes` is echoed only), partial captures,
210
+ disputes.
193
211
  - Flex's exact error texts and ids: shapes follow what our consumer reads; ids look like
194
212
  `fprod_01z…`, `fcs_01z…`, `fcus_…`, `fpi_…`, `fseti_…`, `fpm_…`, `fevt_…`.
195
213
 
@@ -197,7 +215,7 @@ so product names are synthesised.
197
215
 
198
216
  | Export | Kind | Description |
199
217
  | --- | --- | --- |
200
- | `FlexAPI` | class | The in-process mock: `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()`, `present(session, view)`. Options: `sqlite`, `now`, `namespace`, `publicNamespace`, `products`, `settings`, `onEvent`. |
218
+ | `FlexAPI` | class | The in-process mock: `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`. |
201
219
  | `createRuntime` | function | The mock with the full service contract. Options: `webhooks: {url, secret, retryDelaysMs?, fetch?}`, `products`, `settings`, `tickMs`, `clock`, `seed`, `adminKey`, `onLog`. |
202
220
  | `FLEX_PRESETS` | object | Every named fault preset. |
203
221
  | `FLEX_NAMESPACE` | string | The service name, `"flex"`. |
@@ -206,7 +224,8 @@ so product names are synthesised.
206
224
  | `isNextActionType` | function | Whether a string is a next-action type. |
207
225
  | `CARDS`, `classifyCard`, `substituteSessionId` | values | The hosted page's test cards, its card classifier, and the `{CHECKOUT_SESSION_ID}` substitution. |
208
226
  | `CORPUS_ROWS`, `corpusProduct` | values | The recorded product corpus and its row → product mapping. |
209
- | `DEFAULT_SETTINGS`, `ELIGIBILITIES`, `NEXT_ACTION_TYPES`, `PAYMENT_INTENT_STATUSES` | values | Defaults and enums. |
227
+ | `DEFAULT_SETTINGS`, `ELIGIBILITIES`, `NEXT_ACTION_TYPES`, `PAYMENT_INTENT_STATUSES`, `SUBSCRIPTION_STATUSES` | values | Defaults and enums. |
228
+ | `periodEnd` | function | `periodEnd(startMs, {interval, interval_count?})`: the end of a billing period (epoch ms). |
210
229
  | `document`, `operationIds`, `supportedOperationIds` | values | The vendored OpenAPI contract and its operation ids. |
211
230
  | `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. |
212
231