@crvouga/mockingbird-service-flex 3.10.0 → 4.0.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog — @crvouga/mockingbird-service-flex
2
2
 
3
+ ## 4.0.1 (2026-10-29)
4
+
5
+ ### Fixes and improvements
6
+
7
+ - send checkout.session.refunded on a refund, as Flex does ([607eb28](https://github.com/crvouga/mockingbird/commit/607eb289587b2096fa5d9de447ccbeb03ea72563))
8
+
9
+ ## 4.0.0 (2026-10-29)
10
+
11
+ ### ⚠️ Breaking changes
12
+
13
+ - nest webhook objects under their resource key and send refunds as Flex does ([e460af6](https://github.com/crvouga/mockingbird/commit/e460af601280becdf8bd7c4f16dfada2cf181545))
14
+
3
15
  ## 3.10.0 (2026-10-24)
4
16
 
5
17
  ### Features
@@ -7,10 +19,6 @@
7
19
  - add buffered SQL durability ([288bdcc](https://github.com/crvouga/mockingbird/commit/288bdcca84582787a54c4fdf9e803db9aac0b8d7))
8
20
  - add efficient browser durability ([a8be579](https://github.com/crvouga/mockingbird/commit/a8be5799a883ddf6f731208e06f399410de4661a))
9
21
 
10
- ### Dependencies
11
-
12
- - `@crvouga/mockingbird-service-sqlite`
13
-
14
22
  ## 3.9.0 (2026-10-21)
15
23
 
16
24
  ### Features
package/README.md CHANGED
@@ -140,10 +140,14 @@ overrides the origin.
140
140
 
141
141
  Svix-signed (`svix-id`, `svix-timestamp` = wall clock, `svix-signature: v1,<base64
142
142
  HMAC-SHA256(key, "<id>.<ts>.<body>")>`, key = base64-decoded secret after `fwhsec_`/`whsec_`).
143
- Body: `{event: {event_id, event_type, object, event_dt, test_mode, created_at}}`.
144
- Checkout events carry the session (so `object.checkout_session_id`), payment-intent events the
145
- intent plus `checkout_session_id`, refund events `checkout_session` and `payment_intent`, and
146
- `product.updated` the product (`object.product_id`).
143
+ Body: `{event: {event_id, event_type, object, event_dt, test_mode, created_at}}`. As in Flex's
144
+ OpenAPI (`EventObject`) and production deliveries, `object` holds one key named for the
145
+ resource: checkout events `{checkout_session}` (so `object.checkout_session.checkout_session_id`),
146
+ payment-intent events `{payment_intent}`, `charge.*` events `{charge}` (its intent is
147
+ `charge.payment_intent`), `refund.*` and `charge.refund.updated` events `{refund}` (its intent is
148
+ `refund.payment_intent_id`), `customer.subscription.*` `{subscription}` and `product.updated`
149
+ `{product}`. Payment-intent, charge and refund objects name no checkout session: a receiver
150
+ finds the payment through the payment intent.
147
151
 
148
152
  | Event | When |
149
153
  | --- | --- |
@@ -152,10 +156,15 @@ intent plus `checkout_session_id`, refund events `checkout_session` and `payment
152
156
  | `checkout.session.async_payment_succeeded` | settling a session whose intent was `processing` |
153
157
  | `checkout.session.async_payment_failed` | a decline (page, admin, off-session) |
154
158
  | `checkout.session.expired` | `…/expire`, or `expires_at` passing on the emulator clock (default 24 h) |
155
- | `refund.created`, `charge.refunded`, `checkout.session.refunded`, `refund.updated`, `charge.refund.updated` | each refund |
159
+ | `checkout.session.refunded` (the session, with every refund in `refunds`), then `refund.created` (twice), `charge.refunded`, `refund.updated`, `charge.refund.updated` | each refund, through the API or the dashboard (`POST /__admin/sessions/:id/refund`). The refund and charge events follow the order production delivers a dashboard refund; each refund's `items` name its payment intent |
156
160
  | `product.updated` | `PATCH /v1/products/{id}` and `PUT /__admin/products/:id` |
157
161
  | `checkout_session.completed`, `checkout_session.expired` | the aliases, with `PUT /__admin/settings {"eventNaming": "underscored"}` |
158
162
 
163
+ An endpoint receives only the event types it subscribes to, as in Flex's dashboard: pass
164
+ `webhooks: {events: [...]}` (default: every type) or `PUT /__admin/webhook-endpoints`. Subscribe
165
+ to what your production endpoint subscribes to, so a test cannot pass on an event production
166
+ never receives.
167
+
159
168
  `POST /__admin/events {type, session | product}` emits any type on demand. Non-2xx answers are
160
169
  retried (immediately, 5 s, 5 min, 30 min, 2 h); `GET /__admin/webhooks`, `…/events`,
161
170
  `POST /__admin/webhooks/flush`, `…/:id/replay`, `PUT /__admin/webhook-endpoints` as usual.
@@ -168,6 +177,7 @@ retried (immediately, 5 s, 5 min, 30 min, 2 h); `GET /__admin/webhooks`, `…/ev
168
177
  | `POST /__admin/sessions/:id/complete` | `{card?}`: settle as if paid (HSA card unless `card` is `4242…`). |
169
178
  | `POST /__admin/sessions/:id/decline` | Payment intent → `requires_payment_method`. |
170
179
  | `POST /__admin/sessions/:id/expire` | Session → `expired`. |
180
+ | `POST /__admin/sessions/:id/refund` | `{amount?}`: a refund made in Flex's dashboard (full by default); emits the refund events. 409 when unpaid, 400 over the unrefunded amount. |
171
181
  | `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`. |
172
182
  | `PUT /__admin/sessions/:id/payment-intent` | `{status, amount_received?}`: `requires_payment_method`, `requires_action`, `processing`, `succeeded`, `canceled`. |
173
183
  | `GET /__admin/sessions`, `GET /__admin/sessions/:id` | The namespace's sessions. |
@@ -221,11 +231,12 @@ so product names are synthesised.
221
231
 
222
232
  | Export | Kind | Description |
223
233
  | --- | --- | --- |
224
- | `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`. |
225
- | `createRuntime` | function | The emulator with the full service contract. Options: `webhooks: {url, secret, retryDelaysMs?, fetch?}`, `products`, `settings`, `tickMs`, `clock`, `seed`, `adminKey`, `onLog`. |
234
+ | `FlexAPI` | class | The in-process emulator: `fetch(request)`, `reset()`, `settle(id)`, `decline(id)`, `expire(id)`, `requireAction(id, type)`, `setPaymentIntent(id, patch)`, `applyRefund(id, amount)`, `dashboardRefund(id, amount?)`, `putProduct(product)`, `emitFor(type, target)`, `tick()`, `sessions()`, `subscriptions()`, `present(session, view)`. Options: `sqlite`, `now`, `namespace`, `publicNamespace`, `products`, `settings`, `onEvent`. |
235
+ | `createRuntime` | function | The emulator with the full service contract. Options: `webhooks: {url, secret, events?, retryDelaysMs?, fetch?}`, `products`, `settings`, `tickMs`, `clock`, `seed`, `adminKey`, `onLog`. |
226
236
  | `FLEX_PRESETS` | object | Every named fault preset. |
227
237
  | `FLEX_NAMESPACE` | string | The service name, `"flex"`. |
228
238
  | `FLEX_EVENT_TYPES` | array | Every webhook event type, aliases included. |
239
+ | `eventObjectKey` | function | The key an event type's `object` is nested under (`refund.created` → `refund`, `charge.refund.updated` → `refund`). |
229
240
  | `keyMode` | function | `"test"` for `fsk_test_…`, `"live"` for `fsk_…`, otherwise `undefined`. |
230
241
  | `isNextActionType` | function | Whether a string is a next-action type. |
231
242
  | `CARDS`, `classifyCard`, `substituteSessionId` | values | The hosted page's test cards, its card classifier, and the `{CHECKOUT_SESSION_ID}` substitution. |
@@ -4097,7 +4097,7 @@ var stateAdminRoutes = (routes) => {
4097
4097
  };
4098
4098
 
4099
4099
  // ../core/dist/version.js
4100
- var PACKAGE_VERSION = true ? "3.10.0" : UNRELEASED_VERSION;
4100
+ var PACKAGE_VERSION = true ? "4.0.1" : UNRELEASED_VERSION;
4101
4101
 
4102
4102
  // ../core/dist/signing.js
4103
4103
  var encoder = new TextEncoder();
@@ -7295,6 +7295,17 @@ var adminRoutes = (runtime) => {
7295
7295
  `session ${params.id}`
7296
7296
  );
7297
7297
  },
7298
+ "POST /sessions/:id/refund": ({ namespace, params, body }) => {
7299
+ if (body !== void 0 && body !== null && !isRecord5(body)) {
7300
+ return adminError4(400, 'expected {"amount"?: <cents>}');
7301
+ }
7302
+ const amount = isRecord5(body) ? body.amount : void 0;
7303
+ if (amount !== void 0 && typeof amount !== "number") {
7304
+ return adminError4(400, "amount: integer cents");
7305
+ }
7306
+ const outcome = runtime.instance(namespace).dashboardRefund(params.id, amount);
7307
+ return "session" in outcome ? json5(200, outcome.session) : adminError4(outcome.status, outcome.message);
7308
+ },
7298
7309
  "PUT /sessions/:id/payment-intent": ({ namespace, params, body }) => {
7299
7310
  if (!isRecord5(body) || !PAYMENT_INTENT_STATUSES.includes(String(body.status))) {
7300
7311
  return adminError4(400, `status: ${PAYMENT_INTENT_STATUSES.join(" | ")}`);
@@ -7435,6 +7446,17 @@ var FLEX_EVENT_TYPES = [
7435
7446
  "charge.refunded",
7436
7447
  "charge.refund.updated"
7437
7448
  ];
7449
+ var eventObjectKey = (type) => {
7450
+ const prefixes2 = [
7451
+ ["checkout.session.", "checkout_session"],
7452
+ ["checkout_session.", "checkout_session"],
7453
+ ["charge.refund.", "refund"],
7454
+ ["charge.dispute.", "dispute"],
7455
+ ["customer.subscription.", "subscription"]
7456
+ ];
7457
+ const match = prefixes2.find(([prefix]) => type.startsWith(prefix));
7458
+ return match ? match[1] : type.split(".")[0];
7459
+ };
7438
7460
  var keyMode = (key) => {
7439
7461
  if (!key) return void 0;
7440
7462
  if (/^fsk_test_[A-Za-z0-9_-]+$/.test(key)) return "test";
@@ -7837,10 +7859,12 @@ var FlexAPI = class {
7837
7859
  const { expiresAtMs: _expires, url, ...rest } = session;
7838
7860
  const customer = session.customer ? view.expandCustomer ? this.state.customers.get(session.customer) ?? session.customer : session.customer : null;
7839
7861
  const intent = session.payment_intent ? view.expandPaymentIntent ? this.state.paymentIntents.get(session.payment_intent) ?? session.payment_intent : session.payment_intent : null;
7862
+ const refunds = this.state.refunds.list().map((row) => row.value).filter((refund) => refund.checkout_session === session.checkout_session_id).map((refund) => this.presentRefund(refund, session.test_mode));
7840
7863
  const out = {
7841
7864
  ...rest,
7842
7865
  customer,
7843
7866
  payment_intent: intent,
7867
+ refunds,
7844
7868
  redirect_url: url,
7845
7869
  url
7846
7870
  };
@@ -7910,7 +7934,13 @@ var FlexAPI = class {
7910
7934
  this.applyRefund(id, amount);
7911
7935
  return this.sessionResponse(context, id);
7912
7936
  }
7913
- /** Refund part or all of a paid session, emitting the refund events. */
7937
+ /**
7938
+ * Refund part or all of a paid session, emitting the refund events Flex sends:
7939
+ * `checkout.session.refunded` (seen in test mode, carrying the session and its refunds),
7940
+ * then, in the order production delivers a dashboard refund, `refund.created` twice,
7941
+ * `charge.refunded`, `refund.updated` and `charge.refund.updated`. An endpoint receives only
7942
+ * the types it subscribes to.
7943
+ */
7914
7944
  applyRefund(id, amount) {
7915
7945
  const session = this.state.sessions.get(id);
7916
7946
  if (!session) return void 0;
@@ -7925,21 +7955,86 @@ var FlexAPI = class {
7925
7955
  created_at: this.iso()
7926
7956
  };
7927
7957
  this.state.refunds.insert(refund.refund_id, refund);
7928
- const charge = {
7929
- charge_id: `fch_${refund.refund_id.slice(4)}`,
7930
- checkout_session_id: id,
7931
- payment_intent: session.payment_intent,
7932
- amount: session.amount_received,
7933
- amount_refunded: next.amount_refunded,
7934
- refunded: next.amount_refunded >= (session.amount_received ?? 0)
7935
- };
7936
- this.emit("refund.created", { ...refund, status: "pending" }, session.test_mode);
7937
- this.emit("charge.refunded", charge, session.test_mode);
7958
+ const refundObject = this.presentRefund(refund, session.test_mode);
7959
+ const chargeObject = this.presentCharge(next, refund.refund_id);
7938
7960
  this.emitSession("checkout.session.refunded", id);
7939
- this.emit("refund.updated", refund, session.test_mode);
7940
- this.emit("charge.refund.updated", { ...refund, charge: charge.charge_id }, session.test_mode);
7961
+ this.emit("refund.created", refundObject, session.test_mode);
7962
+ this.emit("refund.created", refundObject, session.test_mode);
7963
+ this.emit("charge.refunded", chargeObject, session.test_mode);
7964
+ this.emit("refund.updated", refundObject, session.test_mode);
7965
+ this.emit("charge.refund.updated", refundObject, session.test_mode);
7941
7966
  return this.state.sessions.get(id);
7942
7967
  }
7968
+ /** Refund a paid session from Flex's dashboard (`POST /__admin/sessions/:id/refund`). */
7969
+ dashboardRefund(id, amount) {
7970
+ const session = this.state.sessions.get(id);
7971
+ if (!session) return { status: 404, message: `no session ${id}` };
7972
+ const received = session.amount_received ?? 0;
7973
+ if (session.status !== "complete" || received <= 0) {
7974
+ return { status: 409, message: `session ${id} has no payment to refund` };
7975
+ }
7976
+ const remaining = received - session.amount_refunded;
7977
+ const value = amount ?? remaining;
7978
+ if (!Number.isInteger(value) || value <= 0 || value > remaining) {
7979
+ return { status: 400, message: `amount must be between 1 and ${remaining}` };
7980
+ }
7981
+ return { session: this.applyRefund(id, value) };
7982
+ }
7983
+ presentRefund(refund, testMode) {
7984
+ const intent = refund.payment_intent ? this.state.paymentIntents.get(refund.payment_intent) : void 0;
7985
+ return {
7986
+ refund_id: refund.refund_id,
7987
+ amount: refund.amount,
7988
+ status: refund.status,
7989
+ payment_intent_id: refund.payment_intent,
7990
+ items: [
7991
+ {
7992
+ payment_intent: refund.payment_intent,
7993
+ amount_refunded: refund.amount,
7994
+ payment_method_id: intent?.payment_method ?? null,
7995
+ reference_id: null,
7996
+ reference_type: null,
7997
+ reference_status: null
7998
+ }
7999
+ ],
8000
+ metadata: null,
8001
+ reason: null,
8002
+ reference_id: null,
8003
+ reference_type: null,
8004
+ reference_status: null,
8005
+ balance_transaction: null,
8006
+ failure_balance_transaction: null,
8007
+ test_mode: testMode,
8008
+ created_at: refund.created_at
8009
+ };
8010
+ }
8011
+ presentCharge(session, seed) {
8012
+ const intent = session.payment_intent ? this.state.paymentIntents.get(session.payment_intent) : void 0;
8013
+ const received = session.amount_received ?? 0;
8014
+ return {
8015
+ charge_id: `fch_${seed.slice(4)}`,
8016
+ payment_intent: session.payment_intent,
8017
+ amount: received,
8018
+ amount_captured: received,
8019
+ amount_refunded: session.amount_refunded,
8020
+ refunded: session.amount_refunded >= received,
8021
+ paid: true,
8022
+ captured: true,
8023
+ status: "succeeded",
8024
+ customer: session.customer,
8025
+ payment_method_id: intent?.payment_method ?? null,
8026
+ invoice: null,
8027
+ refunds: null,
8028
+ disputed: false,
8029
+ failure_message: null,
8030
+ balance_transaction: null,
8031
+ failure_balance_transaction: null,
8032
+ application_fee_amount: null,
8033
+ transfer_group: null,
8034
+ test_mode: session.test_mode,
8035
+ created_at: intent?.created_at ?? this.iso()
8036
+ };
8037
+ }
7943
8038
  // --- customers and setup intents ---------------------------------------------------------
7944
8039
  createCustomer(context) {
7945
8040
  const problem = this.validate(context);
@@ -8052,11 +8147,7 @@ var FlexAPI = class {
8052
8147
  const intent = { ...this.paymentIntentFor(session), ...patch };
8053
8148
  this.state.paymentIntents.update(intent.payment_intent_id, intent);
8054
8149
  if (patch.status === "succeeded") {
8055
- this.emit(
8056
- "payment_intent.succeeded",
8057
- { ...intent, checkout_session_id: id },
8058
- session.test_mode
8059
- );
8150
+ this.emit("payment_intent.succeeded", { ...intent }, session.test_mode);
8060
8151
  }
8061
8152
  return intent;
8062
8153
  }
@@ -8183,8 +8274,40 @@ var FlexAPI = class {
8183
8274
  const product = this.state.products.get(target.productId);
8184
8275
  return product ? this.emit(type, { ...product }, product.test_mode) : void 0;
8185
8276
  }
8186
- if (target.sessionId) return this.emitSession(type, target.sessionId);
8187
- return void 0;
8277
+ if (!target.sessionId) return void 0;
8278
+ const session = this.state.sessions.get(target.sessionId);
8279
+ if (!session) return void 0;
8280
+ const key = eventObjectKey(type);
8281
+ const existingIntent = session.payment_intent ? this.state.paymentIntents.get(session.payment_intent) : void 0;
8282
+ if (key === "payment_intent") {
8283
+ const intent = existingIntent ?? {
8284
+ payment_intent_id: `fpi_${session.checkout_session_id.slice(4)}`,
8285
+ amount: session.amount_total,
8286
+ amount_received: null,
8287
+ customer: session.customer,
8288
+ payment_method: null,
8289
+ status: "requires_payment_method",
8290
+ created_at: session.created_at
8291
+ };
8292
+ return this.emit(type, { ...intent }, session.test_mode);
8293
+ }
8294
+ if (key === "refund" || key === "charge") {
8295
+ const refund = this.state.refunds.list().map((row) => row.value).filter((row) => row.checkout_session === session.checkout_session_id).at(-1) ?? {
8296
+ refund_id: this.state.nextId("fre_"),
8297
+ checkout_session: session.checkout_session_id,
8298
+ payment_intent: session.payment_intent,
8299
+ amount: session.amount_received ?? session.amount_total,
8300
+ status: "succeeded",
8301
+ created_at: this.iso()
8302
+ };
8303
+ const current = this.state.sessions.get(session.checkout_session_id);
8304
+ return this.emit(
8305
+ type,
8306
+ key === "refund" ? this.presentRefund(refund, session.test_mode) : this.presentCharge(current, refund.refund_id),
8307
+ session.test_mode
8308
+ );
8309
+ }
8310
+ return this.emitSession(type, target.sessionId);
8188
8311
  }
8189
8312
  emitSession(type, id) {
8190
8313
  const session = this.state.sessions.get(id);
@@ -8199,7 +8322,7 @@ var FlexAPI = class {
8199
8322
  const event = {
8200
8323
  event_id: this.state.nextId("fevt_"),
8201
8324
  event_type: type,
8202
- object,
8325
+ object: { [eventObjectKey(type)]: object },
8203
8326
  event_dt: Math.floor(this.now() / 1e3),
8204
8327
  test_mode: testMode,
8205
8328
  created_at: this.iso()
@@ -8327,9 +8450,10 @@ export {
8327
8450
  createRuntime2 as createRuntime,
8328
8451
  FLEX_NAMESPACE,
8329
8452
  FLEX_EVENT_TYPES,
8453
+ eventObjectKey,
8330
8454
  keyMode,
8331
8455
  periodEnd,
8332
8456
  FlexAPI,
8333
8457
  isNextActionType
8334
8458
  };
8335
- //# sourceMappingURL=chunk-NKBXNJ5B.js.map
8459
+ //# sourceMappingURL=chunk-AGWRBLUO.js.map