@crvouga/mockingbird-service-flex 3.9.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,18 @@
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
+
9
+ ## 3.10.0 (2026-10-24)
10
+
11
+ ### Features
12
+
13
+ - add buffered SQL durability ([288bdcc](https://github.com/crvouga/mockingbird/commit/288bdcca84582787a54c4fdf9e803db9aac0b8d7))
14
+ - add efficient browser durability ([a8be579](https://github.com/crvouga/mockingbird/commit/a8be5799a883ddf6f731208e06f399410de4661a))
15
+
3
16
  ## 3.9.0 (2026-10-21)
4
17
 
5
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. |
@@ -331,15 +331,24 @@ var Collection = class {
331
331
  sqlite;
332
332
  namespace;
333
333
  collectionName;
334
+ statements = /* @__PURE__ */ new Map();
335
+ prepare(sql) {
336
+ let statement = this.statements.get(sql);
337
+ if (!statement) {
338
+ statement = this.sqlite.prepare(sql);
339
+ this.statements.set(sql, statement);
340
+ }
341
+ return statement;
342
+ }
334
343
  constructor(sqlite, namespace, collectionName) {
335
344
  this.sqlite = sqlite;
336
345
  this.namespace = namespace;
337
346
  this.collectionName = collectionName;
338
347
  }
339
348
  bumpCollectionSeq() {
340
- const row = this.sqlite.prepare("SELECT value FROM mockingbird_sequences WHERE namespace = ? AND name = ? AND kind = 'collection'").get(this.namespace, this.collectionName);
349
+ const row = this.prepare("SELECT value FROM mockingbird_sequences WHERE namespace = ? AND name = ? AND kind = 'collection'").get(this.namespace, this.collectionName);
341
350
  const next = (row?.value ?? 0) + 1;
342
- this.sqlite.prepare(`INSERT INTO mockingbird_sequences (namespace, name, kind, value) VALUES (?, ?, 'collection', ?)
351
+ this.prepare(`INSERT INTO mockingbird_sequences (namespace, name, kind, value) VALUES (?, ?, 'collection', ?)
343
352
  ON CONFLICT(namespace, name, kind) DO UPDATE SET value = excluded.value`).run(this.namespace, this.collectionName, next);
344
353
  return next;
345
354
  }
@@ -347,13 +356,13 @@ var Collection = class {
347
356
  return this.sqlite.transaction(() => this.bumpCollectionSeq());
348
357
  }
349
358
  get(id) {
350
- const row = this.sqlite.prepare("SELECT value FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").get(this.namespace, this.collectionName, id);
359
+ const row = this.prepare("SELECT value FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").get(this.namespace, this.collectionName, id);
351
360
  if (!row)
352
361
  return void 0;
353
362
  return JSON.parse(row.value).value;
354
363
  }
355
364
  has(id) {
356
- const row = this.sqlite.prepare("SELECT 1 AS ok FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").get(this.namespace, this.collectionName, id);
365
+ const row = this.prepare("SELECT 1 AS ok FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").get(this.namespace, this.collectionName, id);
357
366
  return row !== void 0;
358
367
  }
359
368
  /** Insert a new record, assigning it the next sequence number. */
@@ -361,7 +370,7 @@ var Collection = class {
361
370
  return this.sqlite.transaction(() => {
362
371
  const seq = this.bumpCollectionSeq();
363
372
  const stored = { seq, value };
364
- this.sqlite.prepare(`INSERT INTO mockingbird_records (namespace, collection, id, seq, value)
373
+ this.prepare(`INSERT INTO mockingbird_records (namespace, collection, id, seq, value)
365
374
  VALUES (?, ?, ?, ?, ?)
366
375
  ON CONFLICT(namespace, collection, id) DO UPDATE SET seq = excluded.seq, value = excluded.value`).run(this.namespace, this.collectionName, id, seq, JSON.stringify(stored));
367
376
  return stored;
@@ -370,25 +379,25 @@ var Collection = class {
370
379
  /** Replace an existing record's value, keeping its position. */
371
380
  update(id, value) {
372
381
  return this.sqlite.transaction(() => {
373
- const row = this.sqlite.prepare("SELECT seq, value FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").get(this.namespace, this.collectionName, id);
382
+ const row = this.prepare("SELECT seq, value FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").get(this.namespace, this.collectionName, id);
374
383
  if (!row)
375
384
  return void 0;
376
385
  const stored = { seq: row.seq, value };
377
- this.sqlite.prepare("UPDATE mockingbird_records SET value = ? WHERE namespace = ? AND collection = ? AND id = ?").run(JSON.stringify(stored), this.namespace, this.collectionName, id);
386
+ this.prepare("UPDATE mockingbird_records SET value = ? WHERE namespace = ? AND collection = ? AND id = ?").run(JSON.stringify(stored), this.namespace, this.collectionName, id);
378
387
  return stored;
379
388
  });
380
389
  }
381
390
  delete(id) {
382
- const result = this.sqlite.prepare("DELETE FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").run(this.namespace, this.collectionName, id);
391
+ const result = this.prepare("DELETE FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").run(this.namespace, this.collectionName, id);
383
392
  return result.changes > 0;
384
393
  }
385
394
  /** How many records the collection holds, without reading them. */
386
395
  count() {
387
- const row = this.sqlite.prepare("SELECT COUNT(*) AS n FROM mockingbird_records WHERE namespace = ? AND collection = ?").get(this.namespace, this.collectionName);
396
+ const row = this.prepare("SELECT COUNT(*) AS n FROM mockingbird_records WHERE namespace = ? AND collection = ?").get(this.namespace, this.collectionName);
388
397
  return Number(row?.n ?? 0);
389
398
  }
390
399
  list(options = {}) {
391
- const rows = this.sqlite.prepare("SELECT id, seq, value FROM mockingbird_records WHERE namespace = ? AND collection = ?").all(this.namespace, this.collectionName);
400
+ const rows = this.prepare("SELECT id, seq, value FROM mockingbird_records WHERE namespace = ? AND collection = ?").all(this.namespace, this.collectionName);
392
401
  const out = [];
393
402
  for (const row of rows) {
394
403
  const stored = JSON.parse(row.value);
@@ -4088,7 +4097,7 @@ var stateAdminRoutes = (routes) => {
4088
4097
  };
4089
4098
 
4090
4099
  // ../core/dist/version.js
4091
- var PACKAGE_VERSION = true ? "3.9.0" : UNRELEASED_VERSION;
4100
+ var PACKAGE_VERSION = true ? "4.0.0" : UNRELEASED_VERSION;
4092
4101
 
4093
4102
  // ../core/dist/signing.js
4094
4103
  var encoder = new TextEncoder();
@@ -7286,6 +7295,17 @@ var adminRoutes = (runtime) => {
7286
7295
  `session ${params.id}`
7287
7296
  );
7288
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
+ },
7289
7309
  "PUT /sessions/:id/payment-intent": ({ namespace, params, body }) => {
7290
7310
  if (!isRecord5(body) || !PAYMENT_INTENT_STATUSES.includes(String(body.status))) {
7291
7311
  return adminError4(400, `status: ${PAYMENT_INTENT_STATUSES.join(" | ")}`);
@@ -7426,6 +7446,17 @@ var FLEX_EVENT_TYPES = [
7426
7446
  "charge.refunded",
7427
7447
  "charge.refund.updated"
7428
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
+ };
7429
7460
  var keyMode = (key) => {
7430
7461
  if (!key) return void 0;
7431
7462
  if (/^fsk_test_[A-Za-z0-9_-]+$/.test(key)) return "test";
@@ -7901,7 +7932,11 @@ var FlexAPI = class {
7901
7932
  this.applyRefund(id, amount);
7902
7933
  return this.sessionResponse(context, id);
7903
7934
  }
7904
- /** 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
+ */
7905
7940
  applyRefund(id, amount) {
7906
7941
  const session = this.state.sessions.get(id);
7907
7942
  if (!session) return void 0;
@@ -7916,20 +7951,74 @@ var FlexAPI = class {
7916
7951
  created_at: this.iso()
7917
7952
  };
7918
7953
  this.state.refunds.insert(refund.refund_id, refund);
7919
- const charge = {
7920
- charge_id: `fch_${refund.refund_id.slice(4)}`,
7921
- 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)}`,
7922
8001
  payment_intent: session.payment_intent,
7923
- amount: session.amount_received,
7924
- amount_refunded: next.amount_refunded,
7925
- 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()
7926
8021
  };
7927
- this.emit("refund.created", { ...refund, status: "pending" }, session.test_mode);
7928
- this.emit("charge.refunded", charge, session.test_mode);
7929
- this.emitSession("checkout.session.refunded", id);
7930
- this.emit("refund.updated", refund, session.test_mode);
7931
- this.emit("charge.refund.updated", { ...refund, charge: charge.charge_id }, session.test_mode);
7932
- return this.state.sessions.get(id);
7933
8022
  }
7934
8023
  // --- customers and setup intents ---------------------------------------------------------
7935
8024
  createCustomer(context) {
@@ -8043,11 +8132,7 @@ var FlexAPI = class {
8043
8132
  const intent = { ...this.paymentIntentFor(session), ...patch };
8044
8133
  this.state.paymentIntents.update(intent.payment_intent_id, intent);
8045
8134
  if (patch.status === "succeeded") {
8046
- this.emit(
8047
- "payment_intent.succeeded",
8048
- { ...intent, checkout_session_id: id },
8049
- session.test_mode
8050
- );
8135
+ this.emit("payment_intent.succeeded", { ...intent }, session.test_mode);
8051
8136
  }
8052
8137
  return intent;
8053
8138
  }
@@ -8174,8 +8259,40 @@ var FlexAPI = class {
8174
8259
  const product = this.state.products.get(target.productId);
8175
8260
  return product ? this.emit(type, { ...product }, product.test_mode) : void 0;
8176
8261
  }
8177
- if (target.sessionId) return this.emitSession(type, target.sessionId);
8178
- 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);
8179
8296
  }
8180
8297
  emitSession(type, id) {
8181
8298
  const session = this.state.sessions.get(id);
@@ -8190,7 +8307,7 @@ var FlexAPI = class {
8190
8307
  const event = {
8191
8308
  event_id: this.state.nextId("fevt_"),
8192
8309
  event_type: type,
8193
- object,
8310
+ object: { [eventObjectKey(type)]: object },
8194
8311
  event_dt: Math.floor(this.now() / 1e3),
8195
8312
  test_mode: testMode,
8196
8313
  created_at: this.iso()
@@ -8318,9 +8435,10 @@ export {
8318
8435
  createRuntime2 as createRuntime,
8319
8436
  FLEX_NAMESPACE,
8320
8437
  FLEX_EVENT_TYPES,
8438
+ eventObjectKey,
8321
8439
  keyMode,
8322
8440
  periodEnd,
8323
8441
  FlexAPI,
8324
8442
  isNextActionType
8325
8443
  };
8326
- //# sourceMappingURL=chunk-5DQQEGW6.js.map
8444
+ //# sourceMappingURL=chunk-3FSLZROR.js.map