@crvouga/mockingbird-service-flex 3.10.0 → 4.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,5 +1,11 @@
1
1
  # Changelog — @crvouga/mockingbird-service-flex
2
2
 
3
+ ## 4.0.0 (2026-10-29)
4
+
5
+ ### ⚠️ Breaking changes
6
+
7
+ - nest webhook objects under their resource key and send refunds as Flex does ([e460af6](https://github.com/crvouga/mockingbird/commit/e460af601280becdf8bd7c4f16dfada2cf181545))
8
+
3
9
  ## 3.10.0 (2026-10-24)
4
10
 
5
11
  ### Features
@@ -7,10 +13,6 @@
7
13
  - add buffered SQL durability ([288bdcc](https://github.com/crvouga/mockingbird/commit/288bdcca84582787a54c4fdf9e803db9aac0b8d7))
8
14
  - add efficient browser durability ([a8be579](https://github.com/crvouga/mockingbird/commit/a8be5799a883ddf6f731208e06f399410de4661a))
9
15
 
10
- ### Dependencies
11
-
12
- - `@crvouga/mockingbird-service-sqlite`
13
-
14
16
  ## 3.9.0 (2026-10-21)
15
17
 
16
18
  ### 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,7 +156,7 @@ 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
+ | `refund.created` (twice), `charge.refunded`, `refund.updated`, `charge.refund.updated` | each refund, through the API or the dashboard (`POST /__admin/sessions/:id/refund`), in the order production delivers a dashboard refund. `checkout.session.refunded` is in Flex's event list but has never been seen on a refund, so it is only sent through `POST /__admin/events` |
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
 
@@ -168,6 +172,7 @@ retried (immediately, 5 s, 5 min, 30 min, 2 h); `GET /__admin/webhooks`, `…/ev
168
172
  | `POST /__admin/sessions/:id/complete` | `{card?}`: settle as if paid (HSA card unless `card` is `4242…`). |
169
173
  | `POST /__admin/sessions/:id/decline` | Payment intent → `requires_payment_method`. |
170
174
  | `POST /__admin/sessions/:id/expire` | Session → `expired`. |
175
+ | `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
176
  | `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
177
  | `PUT /__admin/sessions/:id/payment-intent` | `{status, amount_received?}`: `requires_payment_method`, `requires_action`, `processing`, `succeeded`, `canceled`. |
173
178
  | `GET /__admin/sessions`, `GET /__admin/sessions/:id` | The namespace's sessions. |
@@ -221,11 +226,12 @@ so product names are synthesised.
221
226
 
222
227
  | Export | Kind | Description |
223
228
  | --- | --- | --- |
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`. |
229
+ | `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`. |
225
230
  | `createRuntime` | function | The emulator with the full service contract. Options: `webhooks: {url, secret, retryDelaysMs?, fetch?}`, `products`, `settings`, `tickMs`, `clock`, `seed`, `adminKey`, `onLog`. |
226
231
  | `FLEX_PRESETS` | object | Every named fault preset. |
227
232
  | `FLEX_NAMESPACE` | string | The service name, `"flex"`. |
228
233
  | `FLEX_EVENT_TYPES` | array | Every webhook event type, aliases included. |
234
+ | `eventObjectKey` | function | The key an event type's `object` is nested under (`refund.created` → `refund`, `charge.refund.updated` → `refund`). |
229
235
  | `keyMode` | function | `"test"` for `fsk_test_…`, `"live"` for `fsk_…`, otherwise `undefined`. |
230
236
  | `isNextActionType` | function | Whether a string is a next-action type. |
231
237
  | `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.0" : 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";
@@ -7910,7 +7932,11 @@ var FlexAPI = class {
7910
7932
  this.applyRefund(id, amount);
7911
7933
  return this.sessionResponse(context, id);
7912
7934
  }
7913
- /** Refund part or all of a paid session, emitting the refund events. */
7935
+ /**
7936
+ * Refund part or all of a paid session, emitting the refund events in the order Flex sends
7937
+ * them for a dashboard refund: `refund.created` twice, `charge.refunded`, then
7938
+ * `refund.updated` and `charge.refund.updated`.
7939
+ */
7914
7940
  applyRefund(id, amount) {
7915
7941
  const session = this.state.sessions.get(id);
7916
7942
  if (!session) return void 0;
@@ -7925,20 +7951,74 @@ var FlexAPI = class {
7925
7951
  created_at: this.iso()
7926
7952
  };
7927
7953
  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,
7954
+ const refundObject = this.presentRefund(refund, session.test_mode);
7955
+ const chargeObject = this.presentCharge(next, refund.refund_id);
7956
+ this.emit("refund.created", refundObject, session.test_mode);
7957
+ this.emit("refund.created", refundObject, session.test_mode);
7958
+ this.emit("charge.refunded", chargeObject, session.test_mode);
7959
+ this.emit("refund.updated", refundObject, session.test_mode);
7960
+ this.emit("charge.refund.updated", refundObject, session.test_mode);
7961
+ return this.state.sessions.get(id);
7962
+ }
7963
+ /** Refund a paid session from Flex's dashboard (`POST /__admin/sessions/:id/refund`). */
7964
+ dashboardRefund(id, amount) {
7965
+ const session = this.state.sessions.get(id);
7966
+ if (!session) return { status: 404, message: `no session ${id}` };
7967
+ const received = session.amount_received ?? 0;
7968
+ if (session.status !== "complete" || received <= 0) {
7969
+ return { status: 409, message: `session ${id} has no payment to refund` };
7970
+ }
7971
+ const remaining = received - session.amount_refunded;
7972
+ const value = amount ?? remaining;
7973
+ if (!Number.isInteger(value) || value <= 0 || value > remaining) {
7974
+ return { status: 400, message: `amount must be between 1 and ${remaining}` };
7975
+ }
7976
+ return { session: this.applyRefund(id, value) };
7977
+ }
7978
+ presentRefund(refund, testMode) {
7979
+ return {
7980
+ refund_id: refund.refund_id,
7981
+ amount: refund.amount,
7982
+ status: refund.status,
7983
+ payment_intent_id: refund.payment_intent,
7984
+ items: [],
7985
+ metadata: null,
7986
+ reason: null,
7987
+ reference_id: null,
7988
+ reference_type: null,
7989
+ reference_status: null,
7990
+ balance_transaction: null,
7991
+ failure_balance_transaction: null,
7992
+ test_mode: testMode,
7993
+ created_at: refund.created_at
7994
+ };
7995
+ }
7996
+ presentCharge(session, seed) {
7997
+ const intent = session.payment_intent ? this.state.paymentIntents.get(session.payment_intent) : void 0;
7998
+ const received = session.amount_received ?? 0;
7999
+ return {
8000
+ charge_id: `fch_${seed.slice(4)}`,
7931
8001
  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)
8002
+ amount: received,
8003
+ amount_captured: received,
8004
+ amount_refunded: session.amount_refunded,
8005
+ refunded: session.amount_refunded >= received,
8006
+ paid: true,
8007
+ captured: true,
8008
+ status: "succeeded",
8009
+ customer: session.customer,
8010
+ payment_method_id: intent?.payment_method ?? null,
8011
+ invoice: null,
8012
+ refunds: null,
8013
+ disputed: false,
8014
+ failure_message: null,
8015
+ balance_transaction: null,
8016
+ failure_balance_transaction: null,
8017
+ application_fee_amount: null,
8018
+ transfer_group: null,
8019
+ test_mode: session.test_mode,
8020
+ created_at: intent?.created_at ?? this.iso()
7935
8021
  };
7936
- this.emit("refund.created", { ...refund, status: "pending" }, session.test_mode);
7937
- this.emit("charge.refunded", charge, session.test_mode);
7938
- 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);
7941
- return this.state.sessions.get(id);
7942
8022
  }
7943
8023
  // --- customers and setup intents ---------------------------------------------------------
7944
8024
  createCustomer(context) {
@@ -8052,11 +8132,7 @@ var FlexAPI = class {
8052
8132
  const intent = { ...this.paymentIntentFor(session), ...patch };
8053
8133
  this.state.paymentIntents.update(intent.payment_intent_id, intent);
8054
8134
  if (patch.status === "succeeded") {
8055
- this.emit(
8056
- "payment_intent.succeeded",
8057
- { ...intent, checkout_session_id: id },
8058
- session.test_mode
8059
- );
8135
+ this.emit("payment_intent.succeeded", { ...intent }, session.test_mode);
8060
8136
  }
8061
8137
  return intent;
8062
8138
  }
@@ -8183,8 +8259,40 @@ var FlexAPI = class {
8183
8259
  const product = this.state.products.get(target.productId);
8184
8260
  return product ? this.emit(type, { ...product }, product.test_mode) : void 0;
8185
8261
  }
8186
- if (target.sessionId) return this.emitSession(type, target.sessionId);
8187
- return void 0;
8262
+ if (!target.sessionId) return void 0;
8263
+ const session = this.state.sessions.get(target.sessionId);
8264
+ if (!session) return void 0;
8265
+ const key = eventObjectKey(type);
8266
+ const existingIntent = session.payment_intent ? this.state.paymentIntents.get(session.payment_intent) : void 0;
8267
+ if (key === "payment_intent") {
8268
+ const intent = existingIntent ?? {
8269
+ payment_intent_id: `fpi_${session.checkout_session_id.slice(4)}`,
8270
+ amount: session.amount_total,
8271
+ amount_received: null,
8272
+ customer: session.customer,
8273
+ payment_method: null,
8274
+ status: "requires_payment_method",
8275
+ created_at: session.created_at
8276
+ };
8277
+ return this.emit(type, { ...intent }, session.test_mode);
8278
+ }
8279
+ if (key === "refund" || key === "charge") {
8280
+ const refund = this.state.refunds.list().map((row) => row.value).filter((row) => row.checkout_session === session.checkout_session_id).at(-1) ?? {
8281
+ refund_id: this.state.nextId("fre_"),
8282
+ checkout_session: session.checkout_session_id,
8283
+ payment_intent: session.payment_intent,
8284
+ amount: session.amount_received ?? session.amount_total,
8285
+ status: "succeeded",
8286
+ created_at: this.iso()
8287
+ };
8288
+ const current = this.state.sessions.get(session.checkout_session_id);
8289
+ return this.emit(
8290
+ type,
8291
+ key === "refund" ? this.presentRefund(refund, session.test_mode) : this.presentCharge(current, refund.refund_id),
8292
+ session.test_mode
8293
+ );
8294
+ }
8295
+ return this.emitSession(type, target.sessionId);
8188
8296
  }
8189
8297
  emitSession(type, id) {
8190
8298
  const session = this.state.sessions.get(id);
@@ -8199,7 +8307,7 @@ var FlexAPI = class {
8199
8307
  const event = {
8200
8308
  event_id: this.state.nextId("fevt_"),
8201
8309
  event_type: type,
8202
- object,
8310
+ object: { [eventObjectKey(type)]: object },
8203
8311
  event_dt: Math.floor(this.now() / 1e3),
8204
8312
  test_mode: testMode,
8205
8313
  created_at: this.iso()
@@ -8327,9 +8435,10 @@ export {
8327
8435
  createRuntime2 as createRuntime,
8328
8436
  FLEX_NAMESPACE,
8329
8437
  FLEX_EVENT_TYPES,
8438
+ eventObjectKey,
8330
8439
  keyMode,
8331
8440
  periodEnd,
8332
8441
  FlexAPI,
8333
8442
  isNextActionType
8334
8443
  };
8335
- //# sourceMappingURL=chunk-NKBXNJ5B.js.map
8444
+ //# sourceMappingURL=chunk-3FSLZROR.js.map