@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 +15 -0
- package/README.md +15 -3
- package/dist/{chunk-E54EAWQ7.js → chunk-P77GCNSW.js} +85 -8
- package/dist/chunk-P77GCNSW.js.map +7 -0
- package/dist/{chunk-VNST6CJX.js → chunk-ZSCTOAWQ.js} +2 -2
- package/dist/cli.js +2 -2
- package/dist/index.d.ts +18 -0
- package/dist/index.js +1 -1
- package/dist/server.d.ts +18 -0
- package/dist/server.js +2 -2
- package/package.json +1 -1
- package/dist/chunk-E54EAWQ7.js.map +0 -7
- /package/dist/{chunk-VNST6CJX.js.map → chunk-ZSCTOAWQ.js.map} +0 -0
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.
|
|
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:
|
|
4912
|
+
attempt_count: attempts,
|
|
4881
4913
|
attempted: true,
|
|
4882
|
-
next_payment_attempt:
|
|
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)
|
|
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
|
|
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-
|
|
17324
|
+
//# sourceMappingURL=chunk-P77GCNSW.js.map
|