@crvouga/mockingbird-service-stripe 3.5.0 → 3.7.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,18 @@
1
1
  # Changelog — @crvouga/mockingbird-service-stripe
2
2
 
3
- ## 3.5.0 (2026-10-16)
3
+ ## 3.7.0 (2026-10-19)
4
+
5
+ ### Features
6
+
7
+ - resolve the agent-reported issue queue in one batch ([25c52b6](https://github.com/crvouga/mockingbird/commit/25c52b668a53ddb80b968419601a20abf33f956b))
8
+
9
+ ## 3.6.0 (2026-10-16)
10
+
11
+ ### Features
12
+
13
+ - resolve fleet and emulator issue batch ([26d2f8c](https://github.com/crvouga/mockingbird/commit/26d2f8cee99fefed93192204bac44fb1be35c1da))
14
+
15
+ ## 3.5.0 (2026-10-15)
4
16
 
5
17
  ### Features
6
18
 
package/README.md CHANGED
@@ -147,6 +147,17 @@ Every state change records an event in the account's log (`GET /v1/events`, filt
147
147
  - Delivery faults: presets `webhook_duplicate` (same event id twice — our receiver's in-flight
148
148
  dedupe answers 500), `webhook_reorder` (the next two swapped), `webhook_drop` (never delivered,
149
149
  still in `GET /v1/events` for the replay worker).
150
+ - Payment holds, per customer and namespace, for delayed-payment flows:
151
+ `PUT /__admin/payment-holds/:customer {"webhooks"?: true, "lookups"?: true}` (both default on).
152
+ While `webhooks` is on, events whose `data.object.customer` is that customer are not delivered but
153
+ queued (the in-process `onWebhook` still sees them, and they are in `GET /v1/events`). While
154
+ `lookups` is on, secret-key `GET`s filtered by `?customer=` that customer, and the retrieve of a
155
+ PaymentIntent it owns, answer 503 `api_error`; publishable-key calls (Stripe.js) are unaffected.
156
+ `POST /__admin/payment-holds/:customer/release` ends the hold and delivers the queue oldest first,
157
+ each event signed with the `Stripe-Signature` timestamp it was held at (so a receiver's tolerance
158
+ check sees the age; retries are signed afresh). `DELETE` ends the hold and drops the queue.
159
+ `GET /__admin/payment-holds` lists holds with their queued event ids. A reset clears them; snapshots
160
+ do not include them.
150
161
  - Metadata is copied verbatim, so the PC route's quarantine rule (`metadata.intent ∈ {pc_order,
151
162
  kb_membership, shop_purchase, stripe_membership}` or `source=supplement` + `billingInvoiceId`)
152
163
  only fires for sessions that would trip it on Stripe.
@@ -344,7 +355,9 @@ hides objects younger than 60 s — search is consistent otherwise), `webhook_du
344
355
  {status, failure_reason}` (emits `refund.failed` / `refund.updated`), `POST /__admin/disputes
345
356
  {payment_intent|charge, reason?, amount?}` (emits `charge.dispute.created`),
346
357
  `POST /__admin/checkout/sessions/:id/complete|expire|async_payment_succeeded`,
347
- `POST /__admin/setup_intents/:id/succeed`, `GET /__admin/charges/:id`, `POST /__admin/tick`. The
358
+ `POST /__admin/setup_intents/:id/succeed`, `GET /__admin/charges/:id`, `POST /__admin/tick`,
359
+ `GET /__admin/payment-holds`, `PUT|DELETE /__admin/payment-holds/:customer`,
360
+ `POST /__admin/payment-holds/:customer/release`. The
348
361
  standard ones (`/__admin/health`, reset, snapshots, clock, faults, metrics, `GET /__admin/requests`,
349
362
  credentials, webhooks) come from the shared runtime. The journal records operation, status and ids
350
363
  only — never bodies, card numbers or emails.
@@ -375,6 +388,7 @@ The filter composes with `active`, `coupon`, customer, created-time and paginati
375
388
  | `createRuntime` | `(options?) => StripeRuntime` — the emulator with the full contract. Options: `accounts`, `webhooks {endpoints, retryDelaysMs, fetch}`, `corpus`, `publicUrl`, `webhookApiVersion`, `lifecycle`, `tickMs`, `sqlite`, `clock`, `seed`, `adminKey`, `onLog`, `onWebhook`. The runtime adds `webhooks`, `accounts`, `tick()`, `stop()`. |
376
389
  | `StripeAPI` | Class; `new StripeAPI(options?)` implements `fetch(request)`. Members: `reset()`, `tick(force?)`, `webhookEvents(account?)`, `webhookDeliveryAttempts(account?)`, `apiWebhookEndpoints()`, `accountIds()`, `scopeFor(account)`, `importStateFrom(source)`, `accounts`, `app`, `sqlite`. |
377
390
  | `STRIPE_PRESETS` | The named fault presets above. |
391
+ | `PaymentHolds` | Per-customer holds behind `/__admin/payment-holds` (`set`, `list`, `has`, `release`, `discard`, `clear`); `StripeAPI.holds` is the registry. |
378
392
  | `AccountDirectory` | Keys → accounts (`configure`, `accountFor`, `config`, `list`, `resolve`). |
379
393
  | `DEFAULT_WEBHOOK_API_VERSION` | `"2024-06-20"`. |
380
394
  | `accountOfKey` | `(key) => string` — the account id of an unconfigured key. |
@@ -17,7 +17,7 @@ import {
17
17
  requestBaseUrl,
18
18
  resolveSchema,
19
19
  signers
20
- } from "./chunk-Z5GZOENC.js";
20
+ } from "./chunk-BP7APLM4.js";
21
21
 
22
22
  // src/account.ts
23
23
  var accountOfKey = (key) => `acct_${opaqueToken(key, 8)}`;
@@ -10075,6 +10075,92 @@ var ledgerHandlers = (services) => ({
10075
10075
  }
10076
10076
  });
10077
10077
 
10078
+ // src/payment-holds.ts
10079
+ var customerOfEvent = (event) => {
10080
+ let parsed;
10081
+ try {
10082
+ parsed = JSON.parse(event.body);
10083
+ } catch {
10084
+ return void 0;
10085
+ }
10086
+ const object = parsed?.data?.object;
10087
+ const customer = object?.customer;
10088
+ if (typeof customer === "string") return customer;
10089
+ const id = customer?.id;
10090
+ return typeof id === "string" ? id : void 0;
10091
+ };
10092
+ var PaymentHolds = class {
10093
+ holds = /* @__PURE__ */ new Map();
10094
+ queue = [];
10095
+ set(customer, hold) {
10096
+ const next = { customer, ...hold };
10097
+ this.holds.set(customer, next);
10098
+ return next;
10099
+ }
10100
+ list() {
10101
+ return [...this.holds.values()].map((hold) => ({
10102
+ ...hold,
10103
+ held: this.queue.filter((entry) => entry.customer === hold.customer).map((entry) => ({ id: entry.event.id, type: entry.event.type }))
10104
+ }));
10105
+ }
10106
+ has(customer) {
10107
+ return this.holds.has(customer);
10108
+ }
10109
+ /** Whether any hold exists, so the common case skips parsing event bodies. */
10110
+ get active() {
10111
+ return this.holds.size > 0;
10112
+ }
10113
+ /** The held customer an event belongs to, when its webhooks are held. */
10114
+ webhookHold(event) {
10115
+ if (!this.holds.size) return void 0;
10116
+ const customer = customerOfEvent(event);
10117
+ return customer !== void 0 && this.holds.get(customer)?.webhooks ? customer : void 0;
10118
+ }
10119
+ holdsLookups(customer) {
10120
+ return customer !== null && customer !== void 0 && this.holds.get(customer)?.lookups === true;
10121
+ }
10122
+ enqueue(entry) {
10123
+ this.queue.push(entry);
10124
+ }
10125
+ /** End the hold. The customer's queued events, oldest first, are returned to be delivered. */
10126
+ release(customer) {
10127
+ return this.take(customer);
10128
+ }
10129
+ /** End the hold and forget the queued events (they stay in `GET /v1/events`). */
10130
+ discard(customer) {
10131
+ return this.take(customer).length;
10132
+ }
10133
+ clear() {
10134
+ this.holds.clear();
10135
+ this.queue = [];
10136
+ }
10137
+ take(customer) {
10138
+ this.holds.delete(customer);
10139
+ const taken = this.queue.filter((entry) => entry.customer === customer);
10140
+ this.queue = this.queue.filter((entry) => entry.customer !== customer);
10141
+ return taken;
10142
+ }
10143
+ };
10144
+ var SigningPins = class {
10145
+ signedAt = /* @__PURE__ */ new Map();
10146
+ signedOnce = /* @__PURE__ */ new Set();
10147
+ pin(messageId, seconds2) {
10148
+ this.signedAt.set(messageId, seconds2);
10149
+ }
10150
+ /** The pinned timestamp for a message's first signing at this URL; `undefined` once used or unpinned. */
10151
+ firstSigning(messageId, url) {
10152
+ const pinned = this.signedAt.get(messageId);
10153
+ const key = `${messageId}\0${url}`;
10154
+ if (pinned === void 0 || this.signedOnce.has(key)) return void 0;
10155
+ this.signedOnce.add(key);
10156
+ return pinned;
10157
+ }
10158
+ clear() {
10159
+ this.signedAt.clear();
10160
+ this.signedOnce.clear();
10161
+ }
10162
+ };
10163
+
10078
10164
  // src/payment-methods.ts
10079
10165
  var paymentMethodHandlers = (services) => ({
10080
10166
  GetPaymentMethods: async (context) => {
@@ -14994,8 +15080,14 @@ var createRuntime2 = (options = {}) => {
14994
15080
  const accounts = new AccountDirectory(options.accounts ?? []);
14995
15081
  const baseClock = options.clock ?? createClock();
14996
15082
  const timers = options.clock ? createClockTimers(options.clock) : void 0;
15083
+ const hubNow = options.webhooks?.now ?? (options.clock ? () => baseClock.now() : Date.now);
15084
+ const pins = new SigningPins();
15085
+ const stripeSigner = signers.timestamped("Stripe-Signature");
14997
15086
  const hub = createWebhookHub({
14998
- signer: signers.timestamped("Stripe-Signature"),
15087
+ signer: (input) => {
15088
+ const pinned = pins.firstSigning(input.messageId, input.url);
15089
+ return pinned === void 0 ? stripeSigner(input) : stripeSigner({ ...input, timestampSeconds: pinned });
15090
+ },
14999
15091
  ...options.webhooks?.retryDelaysMs ? { retryDelaysMs: options.webhooks.retryDelaysMs } : {},
15000
15092
  ...options.webhooks?.fetch ? { fetch: options.webhooks.fetch } : {},
15001
15093
  ...options.clock ? { now: () => baseClock.now() } : {},
@@ -15007,6 +15099,16 @@ var createRuntime2 = (options = {}) => {
15007
15099
  endpoints: options.webhooks?.endpoints ?? []
15008
15100
  });
15009
15101
  let runtimeRef;
15102
+ const publishEvent = (namespace, event, pinnedAt) => {
15103
+ if (pinnedAt !== void 0) pins.pin(event.id, pinnedAt);
15104
+ hub.publish({
15105
+ namespace,
15106
+ type: event.type,
15107
+ body: event.body,
15108
+ id: event.id,
15109
+ tags: { account: event.account }
15110
+ });
15111
+ };
15010
15112
  const derivedEndpoints = (namespace) => {
15011
15113
  const fromAccounts = accounts.list().flatMap(
15012
15114
  (account) => Object.entries(account.webhookSecrets ?? {}).map(([url, secret], index) => ({
@@ -15061,6 +15163,7 @@ var createRuntime2 = (options = {}) => {
15061
15163
  for (const row of api.adminWebhookEndpoints.list()) api.adminWebhookEndpoints.delete(row.id);
15062
15164
  }
15063
15165
  hub.clear(namespace);
15166
+ pins.clear();
15064
15167
  if (namespace === void 0) syncAll();
15065
15168
  else syncEndpoints(namespace);
15066
15169
  }
@@ -15242,6 +15345,35 @@ var createRuntime2 = (options = {}) => {
15242
15345
  const charge = scope?.account.charges.get(id);
15243
15346
  return charge ? json(200, renderCharge(charge)) : adminError(404, `no charge ${id}`);
15244
15347
  },
15348
+ "GET /payment-holds": ({ namespace }) => json(200, { holds: runtime2.instance(namespace).holds.list() }),
15349
+ "PUT /payment-holds/:customer": ({ params, body, namespace }) => {
15350
+ const customer = params.customer;
15351
+ const flag = (key) => isRecord3(body) && key in body ? body[key] : true;
15352
+ const webhooks = flag("webhooks");
15353
+ const lookups = flag("lookups");
15354
+ if (typeof webhooks !== "boolean" || typeof lookups !== "boolean")
15355
+ return adminError(400, 'expected {"webhooks"?: boolean, "lookups"?: boolean}');
15356
+ if (!webhooks && !lookups) return adminError(400, "a hold needs webhooks or lookups");
15357
+ const hold = runtime2.instance(namespace).holds.set(customer, { webhooks, lookups });
15358
+ return json(200, hold);
15359
+ },
15360
+ "POST /payment-holds/:customer/release": ({ params, namespace }) => {
15361
+ const customer = params.customer;
15362
+ const holds = runtime2.instance(namespace).holds;
15363
+ if (!holds.has(customer)) return adminError(404, `no payment hold on ${customer}`);
15364
+ const released = holds.release(customer);
15365
+ for (const entry of released) publishEvent(namespace, entry.event, entry.signedAt);
15366
+ return json(200, {
15367
+ customer,
15368
+ released: released.map((entry) => ({ id: entry.event.id, type: entry.event.type }))
15369
+ });
15370
+ },
15371
+ "DELETE /payment-holds/:customer": ({ params, namespace }) => {
15372
+ const customer = params.customer;
15373
+ const holds = runtime2.instance(namespace).holds;
15374
+ if (!holds.has(customer)) return adminError(404, `no payment hold on ${customer}`);
15375
+ return json(200, { customer, discarded: holds.discard(customer) });
15376
+ },
15245
15377
  "POST /tick": ({ namespace }) => {
15246
15378
  runtime2.instance(namespace).tick(true);
15247
15379
  return json(200, { status: "ok" });
@@ -15269,34 +15401,38 @@ var createRuntime2 = (options = {}) => {
15269
15401
  accounts: accounts.list().map((account) => account.id),
15270
15402
  webhooks: hub.endpoints("default").length > 0 ? "on" : "off"
15271
15403
  }),
15272
- create: ({ sqlite, namespace, publicNamespace, adminPrefix, clock: instanceClock }) => new StripeAPI({
15273
- sqlite,
15274
- namespace,
15275
- now: instanceClock.now,
15276
- accounts,
15277
- corpus,
15278
- publicNamespace,
15279
- adminPrefix,
15280
- ...options.publicUrl ? {
15281
- publicUrl: `${options.publicUrl.replace(/\/$/, "")}${publicNamespace === "default" ? "" : `${adminPrefix}/ns/${encodeURIComponent(publicNamespace)}`}`
15282
- } : {},
15283
- ...options.webhookApiVersion ? { webhookApiVersion: options.webhookApiVersion } : {},
15284
- ...options.lifecycle ? { lifecycle: options.lifecycle } : {},
15285
- pendingWebhooks: (account, type) => hub.endpoints(publicNamespace).filter(
15286
- (endpoint) => (endpoint.tags?.account === void 0 || endpoint.tags.account === account) && (endpoint.events === void 0 || endpoint.events.includes("*") || endpoint.events.includes(type))
15287
- ).length,
15288
- onEndpointsChanged: () => syncEndpoints(publicNamespace),
15289
- onWebhook: (event) => {
15290
- options.onWebhook?.(event);
15291
- hub.publish({
15292
- namespace: publicNamespace,
15293
- type: event.type,
15294
- body: event.body,
15295
- id: event.id,
15296
- tags: { account: event.account }
15297
- });
15298
- }
15299
- }),
15404
+ create: ({ sqlite, namespace, publicNamespace, adminPrefix, clock: instanceClock }) => {
15405
+ const api = new StripeAPI({
15406
+ sqlite,
15407
+ namespace,
15408
+ now: instanceClock.now,
15409
+ accounts,
15410
+ corpus,
15411
+ publicNamespace,
15412
+ adminPrefix,
15413
+ ...options.publicUrl ? {
15414
+ publicUrl: `${options.publicUrl.replace(/\/$/, "")}${publicNamespace === "default" ? "" : `${adminPrefix}/ns/${encodeURIComponent(publicNamespace)}`}`
15415
+ } : {},
15416
+ ...options.webhookApiVersion ? { webhookApiVersion: options.webhookApiVersion } : {},
15417
+ ...options.lifecycle ? { lifecycle: options.lifecycle } : {},
15418
+ pendingWebhooks: (account, type) => hub.endpoints(publicNamespace).filter(
15419
+ (endpoint) => (endpoint.tags?.account === void 0 || endpoint.tags.account === account) && (endpoint.events === void 0 || endpoint.events.includes("*") || endpoint.events.includes(type))
15420
+ ).length,
15421
+ onEndpointsChanged: () => syncEndpoints(publicNamespace),
15422
+ onWebhook: (event) => {
15423
+ options.onWebhook?.(event);
15424
+ const held = api.holds.webhookHold(event);
15425
+ if (held === void 0) publishEvent(publicNamespace, event);
15426
+ else
15427
+ api.holds.enqueue({
15428
+ event,
15429
+ customer: held,
15430
+ signedAt: Math.floor(hubNow() / 1e3)
15431
+ });
15432
+ }
15433
+ });
15434
+ return api;
15435
+ },
15300
15436
  admin
15301
15437
  });
15302
15438
  runtimeRef = runtime;
@@ -15354,6 +15490,8 @@ var StripeAPI = class {
15354
15490
  sqlite;
15355
15491
  accounts;
15356
15492
  adminWebhookEndpoints;
15493
+ /** Customers whose webhooks are held and whose lookups fail (`/__admin/payment-holds`). */
15494
+ holds = new PaymentHolds();
15357
15495
  service;
15358
15496
  state;
15359
15497
  services;
@@ -15533,6 +15671,17 @@ var StripeAPI = class {
15533
15671
  const config = this.accounts.config(account);
15534
15672
  if (config?.corpus && this.corpus) seedCorpus(partition, this.corpus);
15535
15673
  this.tick();
15674
+ const heldLookup = this.heldLookup(request, url, partition);
15675
+ if (heldLookup)
15676
+ return this.decorate(
15677
+ this.errorResponse({
15678
+ status: 503,
15679
+ type: "api_error",
15680
+ message: "An unknown error occurred"
15681
+ }),
15682
+ version,
15683
+ cors
15684
+ );
15536
15685
  const idempotencyKey = request.method === "POST" ? request.headers.get("idempotency-key") : null;
15537
15686
  if (idempotencyKey !== null && (idempotencyKey.length === 0 || idempotencyKey.length > 255))
15538
15687
  return fail({ status: 400, message: INVALID_IDEMPOTENCY_KEY });
@@ -15567,6 +15716,23 @@ var StripeAPI = class {
15567
15716
  }
15568
15717
  return this.decorate(response, version, cors);
15569
15718
  }
15719
+ /**
15720
+ * A secret-key read a payment hold fails: a list filtered by a held customer, or the retrieve
15721
+ * of a PaymentIntent that customer owns. Publishable-key calls (Stripe.js) are not affected.
15722
+ */
15723
+ heldLookup(request, url, partition) {
15724
+ if (!this.holds.active || request.method !== "GET" || requestInfo(request).publishable)
15725
+ return false;
15726
+ if (this.holds.holdsLookups(url.searchParams.get("customer"))) return true;
15727
+ const intentId = /^\/v1\/payment_intents\/([^/]+)$/.exec(url.pathname)?.[1];
15728
+ if (intentId === void 0) return false;
15729
+ let id = intentId;
15730
+ try {
15731
+ id = decodeURIComponent(intentId);
15732
+ } catch {
15733
+ }
15734
+ return this.holds.holdsLookups(partition.paymentIntents.get(id)?.customer);
15735
+ }
15570
15736
  /** Expand and version-shape a JSON response. */
15571
15737
  async finish(request, response, partition, era) {
15572
15738
  const type = response.headers.get("content-type") ?? "";
@@ -15653,6 +15819,7 @@ var StripeAPI = class {
15653
15819
  }
15654
15820
  async reset() {
15655
15821
  this.lastTick = Number.NEGATIVE_INFINITY;
15822
+ this.holds.clear();
15656
15823
  await this.service.reset();
15657
15824
  }
15658
15825
  };
@@ -15673,6 +15840,7 @@ export {
15673
15840
  operationIds,
15674
15841
  supportedOperationIds,
15675
15842
  STRIPE_NAMESPACE,
15843
+ PaymentHolds,
15676
15844
  ACME_CORPUS,
15677
15845
  QA_SURFACE_OPS,
15678
15846
  QA_METADATA,
@@ -15687,4 +15855,4 @@ export {
15687
15855
  createRuntime2 as createRuntime,
15688
15856
  StripeAPI
15689
15857
  };
15690
- //# sourceMappingURL=chunk-EUF2MBTV.js.map
15858
+ //# sourceMappingURL=chunk-42WPENBP.js.map