@crvouga/mockingbird-service-stripe 1.3.1 → 1.4.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,20 @@
1
1
  # Changelog — @crvouga/mockingbird-service-stripe
2
2
 
3
+ ## 1.4.0 (2026-09-29)
4
+
5
+ ### Features
6
+
7
+ - retry failed subscription renewals and apply the configured outcome ([c737a68](https://github.com/crvouga/mockingbird/commit/c737a6873a5e9b984b382b9f00c0b01f1712116c))
8
+
9
+ ### Fixes and improvements
10
+
11
+ - validate list elements ahead of a schema issue in order ([5a09c88](https://github.com/crvouga/mockingbird/commit/5a09c880a432b4e13e2ef5b519a8a76962d372da))
12
+ - answer a missing SetupIntent with Stripe's 404 resource_missing ([9e8368f](https://github.com/crvouga/mockingbird/commit/9e8368ff2d01303cf5ff5410afc3387c75163ca6))
13
+ - leave the portal configuration list out of live parity walks ([9adfc89](https://github.com/crvouga/mockingbird/commit/9adfc89ed7dbe88d798724deaf9d9ccddea60800))
14
+ - use --all-snapshot with Stripe CLI 1.52 in the webhook oracle ([d95804c](https://github.com/crvouga/mockingbird/commit/d95804cbdf98bcbd2199865dcdd13157d44b1d12))
15
+ - pass --events to stripe listen so Stripe CLI 1.52 starts ([b31d3da](https://github.com/crvouga/mockingbird/commit/b31d3da44396f79630f21f732fd4fb2cc1d3fcc9))
16
+ - report why stripe listen exited in the webhook oracle ([19461e4](https://github.com/crvouga/mockingbird/commit/19461e478a3d0489aac646820ecaad12b2d6ce2a))
17
+
3
18
  ## 1.3.1 (2026-09-29)
4
19
 
5
20
  ### Fixes and improvements
package/README.md CHANGED
@@ -86,7 +86,7 @@ the mock's hosted page.
86
86
 
87
87
  State is partitioned by **account**, and the account is chosen by API key:
88
88
 
89
- - `PUT /__admin/accounts {"accounts": [{id, keys, apiVersion?, webhookSecrets?, corpus?, displayName?}]}`
89
+ - `PUT /__admin/accounts {"accounts": [{id, keys, apiVersion?, webhookSecrets?, corpus?, displayName?, billing?}]}`
90
90
  (also `createRuntime({accounts})` / `serve --accounts <json|file>`). Every key listed on an
91
91
  account acts as it. Any other test key is an account of its own (`accountOfKey(key)`), so an MSO
92
92
  key reading a PC object gets Stripe's exact `404 resource_missing` (`No such payment_intent: 'pi_…'`).
@@ -94,6 +94,8 @@ State is partitioned by **account**, and the account is chosen by API key:
94
94
  its webhook payloads render at (default `2024-06-20`, what every backend receiver of ours pins).
95
95
  - `webhookSecrets: {"<receiver url>": "whsec_…"}` delivers every event of the account there.
96
96
  - `corpus: true` seeds the recorded catalog (below).
97
+ - `billing: {retries: {scheduleDays?, afterAllFail?}}` sets the account's failed-renewal retries
98
+ (see Lifecycles and the clock); unset fields use `createRuntime({lifecycle: {paymentRetries}})`.
97
99
 
98
100
  **Namespaces** isolate parallel workers; each namespace has its own copy of every account. Carriers:
99
101
  the `x-mockingbird-namespace` header, the `/ns/<namespace>/…` path prefix, or **by API key**:
@@ -171,6 +173,18 @@ clock-driven lifecycle, so their webhooks fire at once; the served mock also tic
171
173
  voided (`void`), marked `uncollectible` (`mark_uncollectible`, `invoice.marked_uncollectible`) or
172
174
  left as drafts (`keep_as_draft`), never charged; the status holds. `pause_collection[resumes_at]`
173
175
  lifts the pause on its own before the renewal it precedes; `pause_collection=""` lifts it now.
176
+ - **Payment retries**: a declined renewal (`charge_automatically`) leaves the invoice `open` with
177
+ `attempt_count: 1` and `next_payment_attempt`, and the subscription `past_due`. Each retry
178
+ `scheduleDays` after the first failure (default `[3, 5, 7]`, Stripe's classic "3 retries over
179
+ 1 week") charges again when the clock reaches it: a decline bumps `attempt_count`, emits
180
+ `invoice.payment_failed` and moves `next_payment_attempt`; a working default payment method pays
181
+ the invoice (`invoice.paid`) and the subscription is `active` again
182
+ (`customer.subscription.updated`). After the last retry `next_payment_attempt` is `null` and
183
+ `afterAllFail` applies: `cancel` (the default: `canceled`, `cancellation_details.reason:
184
+ payment_failed`, `customer.subscription.deleted`), `unpaid` (`customer.subscription.updated`) or
185
+ `past_due` (nothing changes). An empty schedule applies it at the first failure. The schedule is
186
+ fixed, not Stripe's ML-timed Smart Retries; set it per account (`billing.retries`, also
187
+ `PUT /__admin/accounts`) or for the runtime (`lifecycle.paymentRetries`). Dunning emails are not sent.
174
188
  - `incomplete` subscriptions become `incomplete_expired` after 23 h (their invoice is voided).
175
189
  - Checkout Sessions expire at `expires_at` (`checkout.session.expired`).
176
190
  - Schedule phases advance; the last one releases or cancels per `end_behavior`.
@@ -367,8 +381,6 @@ plus `port`, `host`; resolves `{url, port, runtime, close}`), `serveTarget` (the
367
381
  PaymentSheet cannot be redirected (member-app native keeps its fake provider).
368
382
  - **Connect** (`Stripe-Account`, application fees, transfers), tax, shipping, Radar, mandates,
369
383
  meters, quotes, credit notes, payouts, and non-card payment methods (bank debits, wallets).
370
- - **Smart retries / dunning**: a failed renewal goes `past_due` once; later automatic retries,
371
- `unpaid` and dunning emails are not run.
372
384
  - **Proration arithmetic** is day-fraction approximate (Stripe prorates to the second);
373
385
  `auto_advance` drafts are not finalized an hour later.
374
386
  - **Customer portal extras**: the login page (`login_page.url` is not served), `schedule_at_period_end`
@@ -1559,7 +1559,7 @@ var restoreNamespace = (sqlite, namespace, snapshot) => {
1559
1559
  };
1560
1560
 
1561
1561
  // ../core/dist/version.js
1562
- var PACKAGE_VERSION = true ? "1.3.1" : UNRELEASED_VERSION;
1562
+ var PACKAGE_VERSION = true ? "1.4.0" : UNRELEASED_VERSION;
1563
1563
 
1564
1564
  // ../core/dist/signing.js
1565
1565
  var encoder = new TextEncoder();
@@ -2583,6 +2583,27 @@ var eraOf = (version) => {
2583
2583
  };
2584
2584
 
2585
2585
  // src/accounts.ts
2586
+ var DEFAULT_PAYMENT_RETRIES = {
2587
+ scheduleDays: [3, 5, 7],
2588
+ afterAllFail: "cancel"
2589
+ };
2590
+ var RETRY_OUTCOMES = ["cancel", "unpaid", "past_due"];
2591
+ var validateRetries = (id, value) => {
2592
+ const raw = value?.retries;
2593
+ if (typeof value !== "object" || value === null || typeof raw !== "object" || raw === null)
2594
+ return `${id}: billing must be {"retries": {"scheduleDays": [3, 5, 7], "afterAllFail": "cancel"}}`;
2595
+ const { scheduleDays, afterAllFail } = raw;
2596
+ if (scheduleDays !== void 0 && (!Array.isArray(scheduleDays) || scheduleDays.some(
2597
+ (day, index) => typeof day !== "number" || !Number.isFinite(day) || day <= 0 || index > 0 && day <= scheduleDays[index - 1]
2598
+ )))
2599
+ return `${id}: billing.retries.scheduleDays must be increasing positive numbers of days`;
2600
+ if (afterAllFail !== void 0 && !RETRY_OUTCOMES.includes(afterAllFail))
2601
+ return `${id}: billing.retries.afterAllFail must be one of ${RETRY_OUTCOMES.join(", ")}`;
2602
+ return {
2603
+ ...scheduleDays !== void 0 ? { scheduleDays } : {},
2604
+ ...afterAllFail !== void 0 ? { afterAllFail } : {}
2605
+ };
2606
+ };
2586
2607
  var DEFAULT_WEBHOOK_API_VERSION = "2024-06-20";
2587
2608
  var KEY = /^(?:sk|rk|pk)_(?:test|live)_[A-Za-z0-9_]+$/;
2588
2609
  var validateAccounts = (value) => {
@@ -2607,8 +2628,11 @@ var validateAccounts = (value) => {
2607
2628
  const secrets = raw.webhookSecrets;
2608
2629
  if (secrets !== void 0 && (typeof secrets !== "object" || secrets === null || Object.values(secrets).some((secret) => typeof secret !== "string")))
2609
2630
  return `${raw.id}: webhookSecrets must map receiver URLs to whsec_ secrets`;
2631
+ const retries = raw.billing === void 0 ? void 0 : validateRetries(raw.id, raw.billing);
2632
+ if (typeof retries === "string") return retries;
2610
2633
  accounts.push({
2611
2634
  id: raw.id,
2635
+ ...retries ? { billing: { retries } } : {},
2612
2636
  keys: raw.keys,
2613
2637
  ...typeof raw.apiVersion === "string" ? { apiVersion: raw.apiVersion } : {},
2614
2638
  ...secrets ? { webhookSecrets: secrets } : {},
@@ -4875,11 +4899,19 @@ var activateAfterPayment = (scope, subscriptionId, intent) => {
4875
4899
  };
4876
4900
  var markPaymentFailed = (scope, invoice) => {
4877
4901
  const now = customerNow(scope, invoice.customer);
4902
+ const attempts = invoice.attempt_count + 1;
4903
+ let next = now + 3 * DAY;
4904
+ if (invoice.subscription !== null) {
4905
+ const days = scope.services.paymentRetries(scope.account.account).scheduleDays;
4906
+ const first = attempts === 1 || invoice.next_payment_attempt === null ? now : invoice.next_payment_attempt - (days[attempts - 2] ?? 0) * DAY;
4907
+ const day = days[attempts - 1];
4908
+ next = day === void 0 ? null : first + day * DAY;
4909
+ }
4878
4910
  const failed = {
4879
4911
  ...invoice,
4880
- attempt_count: invoice.attempt_count + 1,
4912
+ attempt_count: attempts,
4881
4913
  attempted: true,
4882
- next_payment_attempt: now + 3 * DAY
4914
+ next_payment_attempt: next
4883
4915
  };
4884
4916
  scope.account.invoices.update(failed.id, failed);
4885
4917
  scope.emit("invoice.payment_failed", renderInvoice(failed, scope.account));
@@ -5315,12 +5347,15 @@ var cycleSubscription = (scope, current) => {
5315
5347
  });
5316
5348
  claimInvoiceItems(scope, invoice);
5317
5349
  let status = moved.status;
5350
+ let exhausted = false;
5318
5351
  if (!keepDraft) {
5319
5352
  const finalized = finalizeInvoice(scope, invoice);
5320
5353
  const collected = collectCycleInvoice(scope, current, finalized);
5321
5354
  invoice = collected.invoice;
5322
- if (finalized.status === "open" && current.pause_collection === null && finalized.collection_method === "charge_automatically")
5355
+ if (finalized.status === "open" && current.pause_collection === null && finalized.collection_method === "charge_automatically") {
5323
5356
  status = collected.failed ? "past_due" : "active";
5357
+ exhausted = collected.failed && invoice.attempt_count > 0 && invoice.next_payment_attempt === null;
5358
+ }
5324
5359
  }
5325
5360
  const latest = scope.account.subscriptions.get(current.id) ?? moved;
5326
5361
  const next = {
@@ -5334,6 +5369,33 @@ var cycleSubscription = (scope, current) => {
5334
5369
  const after = renderSubscription(next, scope.account);
5335
5370
  const changed = changedFields(before, after);
5336
5371
  scope.emit("customer.subscription.updated", after, changed);
5372
+ if (exhausted) applyFailedPaymentOutcome(scope, next);
5373
+ };
5374
+ var applyFailedPaymentOutcome = (scope, current) => {
5375
+ const outcome = scope.services.paymentRetries(scope.account.account).afterAllFail;
5376
+ if (outcome === "cancel") cancelSubscription(scope, current, "payment_failed");
5377
+ else if (outcome === "unpaid") saveSubscription(scope, current, { ...current, status: "unpaid" });
5378
+ };
5379
+ var retryDueInvoices = (scope, subscription, now) => {
5380
+ for (let guard2 = 0; guard2 < 50; guard2 += 1) {
5381
+ const due = scope.account.invoices.list({
5382
+ order: "oldest",
5383
+ where: (invoice) => invoice.subscription === subscription.id && invoice.status === "open" && invoice.collection_method === "charge_automatically" && invoice.next_payment_attempt !== null && invoice.next_payment_attempt <= now
5384
+ })[0]?.value;
5385
+ if (due === void 0) return;
5386
+ let settled;
5387
+ try {
5388
+ settled = payInvoice(scope, due, { offSession: true });
5389
+ } catch (error) {
5390
+ if (!(error instanceof StripeError)) throw error;
5391
+ settled = scope.account.invoices.get(due.id) ?? due;
5392
+ }
5393
+ const current = scope.account.subscriptions.get(subscription.id);
5394
+ if (settled.status !== "open" || current === void 0) return;
5395
+ if (settled.next_payment_attempt === due.next_payment_attempt) return;
5396
+ if (settled.next_payment_attempt === null) applyFailedPaymentOutcome(scope, current);
5397
+ if (settled.next_payment_attempt === null || current.status !== "past_due") return;
5398
+ }
5337
5399
  };
5338
5400
  var periodAmount = (scope, subscription, at) => {
5339
5401
  const lines = subscriptionItems(scope, subscription).map((item) => {
@@ -5744,7 +5806,8 @@ var releaseSchedule = (scope, schedule, now) => {
5744
5806
  var DEFAULT_LIFECYCLE = {
5745
5807
  upcomingInvoiceDays: 3,
5746
5808
  trialWillEndDays: 3,
5747
- incompleteExpiryHours: 23
5809
+ incompleteExpiryHours: 23,
5810
+ paymentRetries: DEFAULT_PAYMENT_RETRIES
5748
5811
  };
5749
5812
  var runLifecycle = (scope, settings, onlyCustomers) => {
5750
5813
  const inScope = (customer) => onlyCustomers === void 0 ? customer === null || !findCustomer(scope, customer)?.test_clock : customer !== null && onlyCustomers.has(customer);
@@ -5778,6 +5841,10 @@ var runLifecycle = (scope, settings, onlyCustomers) => {
5778
5841
  );
5779
5842
  }
5780
5843
  for (let guard2 = 0; guard2 < 120; guard2 += 1) {
5844
+ if (subscription.status === "past_due") {
5845
+ retryDueInvoices(scope, subscription, now2);
5846
+ subscription = scope.account.subscriptions.get(subscription.id) ?? subscription;
5847
+ }
5781
5848
  if (!["active", "trialing", "past_due"].includes(subscription.status)) break;
5782
5849
  const cancelAt = subscription.cancel_at;
5783
5850
  if (cancelAt !== null && cancelAt <= now2) {
@@ -6461,7 +6528,13 @@ var parseParams = (schema, raw, options = {}) => {
6461
6528
  if (error) throw error;
6462
6529
  }
6463
6530
  const issue = issues[0];
6464
- if (issue) throw issueToError(issue);
6531
+ if (issue) {
6532
+ const failing = /^[^[]+\[(\d+)\]/.exec(issue.path)?.[1];
6533
+ const list = params[key];
6534
+ if (failing !== void 0 && Number(failing) > 0 && Array.isArray(list))
6535
+ options.validate?.[key]?.({ ...params, [key]: list.slice(0, Number(failing)) });
6536
+ throw issueToError(issue);
6537
+ }
6465
6538
  options.validate?.[key]?.(params);
6466
6539
  }
6467
6540
  const stray = parsed.issues[0];
@@ -9851,7 +9924,7 @@ var paymentIntentHandlers = (services) => {
9851
9924
  // src/setup-intents.ts
9852
9925
  var requireSetupIntent = (scope, id) => {
9853
9926
  const intent = scope.account.setupIntents.get(id);
9854
- if (!intent) throw invalidRequest(`No such setup_intent: '${id}'`, "intent", "resource_missing");
9927
+ if (!intent) throw resourceMissing("setupintent", id, "intent");
9855
9928
  return intent;
9856
9929
  };
9857
9930
  var confirmSetup = (scope, intent, method, autoAuthenticate = false) => {
@@ -16942,6 +17015,10 @@ var StripeAPI = class {
16942
17015
  publish: options.onWebhook,
16943
17016
  accounts,
16944
17017
  deliveryVersion: (account) => accounts.config(account)?.apiVersion ?? webhookVersion,
17018
+ paymentRetries: (account) => ({
17019
+ ...this.settings.paymentRetries,
17020
+ ...accounts.config(account)?.billing?.retries
17021
+ }),
16945
17022
  pendingWebhooks: options.pendingWebhooks ?? (() => 0),
16946
17023
  namespacePrefix: options.publicNamespace === void 0 || options.publicNamespace === "default" ? "" : `/ns/${encodeURIComponent(options.publicNamespace)}`,
16947
17024
  publicUrl: options.publicUrl,
@@ -17244,4 +17321,4 @@ export {
17244
17321
  createRuntime2 as createRuntime,
17245
17322
  StripeAPI
17246
17323
  };
17247
- //# sourceMappingURL=chunk-E54EAWQ7.js.map
17324
+ //# sourceMappingURL=chunk-P77GCNSW.js.map