@crvouga/mockingbird-service-stripe 1.3.1 → 1.4.1

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,33 @@
1
1
  # Changelog — @crvouga/mockingbird-service-stripe
2
2
 
3
+ ## 1.4.1 (2026-09-29)
4
+
5
+ ### Fixes and improvements
6
+
7
+ - report a missing invoice item under param id ([c2a5ede](https://github.com/crvouga/mockingbird/commit/c2a5ede5056a6f64d6df128b3f118759d452bc10))
8
+ - validate search limit before an empty page ([cc755f7](https://github.com/crvouga/mockingbird/commit/cc755f7672b4e652bd40c3b087c1fb504b636d85))
9
+ - refuse both price amounts before reading any other parameter ([e6a6735](https://github.com/crvouga/mockingbird/commit/e6a6735ae8bef0e7f9b40152a6e02056497739dd))
10
+ - leave the portal configuration list out of live parity walks ([f378c9d](https://github.com/crvouga/mockingbird/commit/f378c9d76e36f9fdc86343a4c3eeebf5694eb1a0))
11
+ - pick stripe listen's all-events flag from the installed CLI ([89ad2f4](https://github.com/crvouga/mockingbird/commit/89ad2f414745881f1c1f64445c12b8c70f80072d))
12
+ - pass --events to stripe listen so CLI 1.52 starts ([eeb20b2](https://github.com/crvouga/mockingbird/commit/eeb20b2cce57729d817b00d5f8bd9d0681db0ecb))
13
+ - report the stripe listen output when it exits before ready ([0155f0a](https://github.com/crvouga/mockingbird/commit/0155f0acfb8aed6f1677a29339dbeb4ef0bdaaad))
14
+ - reset the billing cycle anchor on a change to a different billing interval ([314910a](https://github.com/crvouga/mockingbird/commit/314910af7ff36ebd900445e97529740639ada4d2))
15
+
16
+ ## 1.4.0 (2026-09-29)
17
+
18
+ ### Features
19
+
20
+ - retry failed subscription renewals and apply the configured outcome ([c737a68](https://github.com/crvouga/mockingbird/commit/c737a6873a5e9b984b382b9f00c0b01f1712116c))
21
+
22
+ ### Fixes and improvements
23
+
24
+ - validate list elements ahead of a schema issue in order ([5a09c88](https://github.com/crvouga/mockingbird/commit/5a09c880a432b4e13e2ef5b519a8a76962d372da))
25
+ - answer a missing SetupIntent with Stripe's 404 resource_missing ([9e8368f](https://github.com/crvouga/mockingbird/commit/9e8368ff2d01303cf5ff5410afc3387c75163ca6))
26
+ - leave the portal configuration list out of live parity walks ([9adfc89](https://github.com/crvouga/mockingbird/commit/9adfc89ed7dbe88d798724deaf9d9ccddea60800))
27
+ - use --all-snapshot with Stripe CLI 1.52 in the webhook oracle ([d95804c](https://github.com/crvouga/mockingbird/commit/d95804cbdf98bcbd2199865dcdd13157d44b1d12))
28
+ - pass --events to stripe listen so Stripe CLI 1.52 starts ([b31d3da](https://github.com/crvouga/mockingbird/commit/b31d3da44396f79630f21f732fd4fb2cc1d3fcc9))
29
+ - report why stripe listen exited in the webhook oracle ([19461e4](https://github.com/crvouga/mockingbird/commit/19461e478a3d0489aac646820ecaad12b2d6ce2a))
30
+
3
31
  ## 1.3.1 (2026-09-29)
4
32
 
5
33
  ### 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`.
@@ -185,8 +199,12 @@ now and returns `incomplete` on a decline; `error_if_incomplete` fails the call
185
199
  customer, and paying it through Stripe.js activates the subscription. `trial_end`,
186
200
  `backdate_start_date` + `billing_cycle_anchor` + `proration_behavior=none` (a $0 first invoice),
187
201
  item updates with `always_invoice` (billed now) or `create_prorations` (next invoice), and
188
- discounts with stable `di_` ids (`discounts=""` clears) are modelled. Items must be active recurring
189
- prices sharing one currency and interval. `DELETE /v1/subscriptions/:id` takes
202
+ discounts with stable `di_` ids (`discounts=""` clears) are modelled. An item update to a price with
203
+ a different billing period (say monthly → yearly, through the API or the portal's **Update plan**)
204
+ resets `billing_cycle_anchor` to now, starts a new period and invoices it at once (less a
205
+ proration credit for the unused time unless `proration_behavior=none`); a trialing subscription
206
+ keeps its trial end instead. Items must be active recurring prices sharing one currency and
207
+ interval. `DELETE /v1/subscriptions/:id` takes
190
208
  `cancellation_details[comment|feedback]`, `prorate` (a credit for the unused time as a pending
191
209
  proration) and `invoice_now` (a final invoice; a net credit lands on the customer balance), in the
192
210
  query string (as stripe-node sends them) or the body. A $0 invoice is `paid` on
@@ -367,12 +385,13 @@ plus `port`, `host`; resolves `{url, port, runtime, close}`), `serveTarget` (the
367
385
  PaymentSheet cannot be redirected (member-app native keeps its fake provider).
368
386
  - **Connect** (`Stripe-Account`, application fees, transfers), tax, shipping, Radar, mandates,
369
387
  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.
388
+ - **Billing cycle anchor resets** other than a price change to a different billing period through
389
+ `POST /v1/subscriptions/:id`: an explicit `billing_cycle_anchor=now|unchanged` on update is ignored,
390
+ and the `/v1/subscription_items` endpoints change prices without resetting the period.
372
391
  - **Proration arithmetic** is day-fraction approximate (Stripe prorates to the second);
373
392
  `auto_advance` drafts are not finalized an hour later.
374
393
  - **Customer portal extras**: the login page (`login_page.url` is not served), `schedule_at_period_end`
375
- downgrades, `billing_cycle_anchor` resets on plan changes, multi-item subscription updates, locales,
394
+ downgrades, multi-item subscription updates, locales,
376
395
  and payment method configurations. Portal sessions do not expire.
377
396
  - **Webhook endpoint `api_version`**: payloads render at the account's version, not per endpoint.
378
397
  - **Live keys** (`sk_live_…`) are refused with Stripe's 401: the mock is test mode only.
@@ -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.1" : 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) => {
@@ -5426,6 +5488,57 @@ var billProration = (scope, subscription, amount, behavior, options) => {
5426
5488
  });
5427
5489
  return void 0;
5428
5490
  };
5491
+ var sameBillingPeriod = (a, b) => a.interval === b.interval && a.interval_count === b.interval_count;
5492
+ var invoiceBillingCycleReset = (scope, subscription, previous, prorate) => {
5493
+ const period = { start: subscription.current_period_start, end: subscription.current_period_end };
5494
+ const left = Math.max(0, previous.period.end - period.start) / (previous.period.end - previous.period.start || 1);
5495
+ const credits = prorate ? previous.items.map((item) => {
5496
+ const price = requirePrice(scope, item.price);
5497
+ return {
5498
+ ...lineFromPrice(
5499
+ scope,
5500
+ price,
5501
+ item.quantity ?? 1,
5502
+ { start: period.start, end: previous.period.end },
5503
+ {
5504
+ subscription: subscription.id,
5505
+ subscriptionItem: item.id,
5506
+ amount: -Math.round(priceAmount(price, item.quantity ?? 1) * left),
5507
+ description: "Unused time on the previous price (prorated)"
5508
+ }
5509
+ ),
5510
+ proration: true
5511
+ };
5512
+ }) : [];
5513
+ const draft = createDraftInvoice(scope, {
5514
+ customer: subscription.customer,
5515
+ subscription: subscription.id,
5516
+ lines: [
5517
+ ...credits.filter((line) => line.amount !== 0),
5518
+ ...cycleLines(scope, subscription, period)
5519
+ ],
5520
+ billingReason: "subscription_update",
5521
+ discountIds: liveDiscounts(scope, subscription.discount_ids, period.start),
5522
+ period,
5523
+ collectionMethod: subscription.collection_method,
5524
+ daysUntilDue: subscription.days_until_due,
5525
+ subscriptionMetadata: subscription.metadata
5526
+ });
5527
+ claimInvoiceItems(scope, draft);
5528
+ const finalized = finalizeInvoice(scope, draft);
5529
+ creditNegativeInvoice(scope, finalized);
5530
+ const collected = collectCycleInvoice(scope, subscription, finalized);
5531
+ const latest = scope.account.subscriptions.get(subscription.id) ?? subscription;
5532
+ const charged = finalized.status === "open" && !subscription.pause_collection && finalized.collection_method === "charge_automatically";
5533
+ const next = {
5534
+ ...latest,
5535
+ status: charged ? collected.failed ? "past_due" : "active" : latest.status,
5536
+ latest_invoice: collected.invoice.id
5537
+ };
5538
+ saveSubscription(scope, latest, next);
5539
+ if (charged && collected.failed && collected.invoice.attempt_count > 0 && collected.invoice.next_payment_attempt === null)
5540
+ applyFailedPaymentOutcome(scope, next);
5541
+ };
5429
5542
  var resumeSubscription = (scope, current, options) => {
5430
5543
  const now = customerNow(scope, current.customer);
5431
5544
  const recurring = recurringOf(scope, current);
@@ -5744,7 +5857,8 @@ var releaseSchedule = (scope, schedule, now) => {
5744
5857
  var DEFAULT_LIFECYCLE = {
5745
5858
  upcomingInvoiceDays: 3,
5746
5859
  trialWillEndDays: 3,
5747
- incompleteExpiryHours: 23
5860
+ incompleteExpiryHours: 23,
5861
+ paymentRetries: DEFAULT_PAYMENT_RETRIES
5748
5862
  };
5749
5863
  var runLifecycle = (scope, settings, onlyCustomers) => {
5750
5864
  const inScope = (customer) => onlyCustomers === void 0 ? customer === null || !findCustomer(scope, customer)?.test_clock : customer !== null && onlyCustomers.has(customer);
@@ -5778,6 +5892,10 @@ var runLifecycle = (scope, settings, onlyCustomers) => {
5778
5892
  );
5779
5893
  }
5780
5894
  for (let guard2 = 0; guard2 < 120; guard2 += 1) {
5895
+ if (subscription.status === "past_due") {
5896
+ retryDueInvoices(scope, subscription, now2);
5897
+ subscription = scope.account.subscriptions.get(subscription.id) ?? subscription;
5898
+ }
5781
5899
  if (!["active", "trialing", "past_due"].includes(subscription.status)) break;
5782
5900
  const cancelAt = subscription.cancel_at;
5783
5901
  if (cancelAt !== null && cancelAt <= now2) {
@@ -6461,7 +6579,13 @@ var parseParams = (schema, raw, options = {}) => {
6461
6579
  if (error) throw error;
6462
6580
  }
6463
6581
  const issue = issues[0];
6464
- if (issue) throw issueToError(issue);
6582
+ if (issue) {
6583
+ const failing = /^[^[]+\[(\d+)\]/.exec(issue.path)?.[1];
6584
+ const list = params[key];
6585
+ if (failing !== void 0 && Number(failing) > 0 && Array.isArray(list))
6586
+ options.validate?.[key]?.({ ...params, [key]: list.slice(0, Number(failing)) });
6587
+ throw issueToError(issue);
6588
+ }
6465
6589
  options.validate?.[key]?.(params);
6466
6590
  }
6467
6591
  const stray = parsed.issues[0];
@@ -6682,6 +6806,11 @@ var updateSubscription = (scope, current, params) => {
6682
6806
  const previousItems = { items: renderSubscription(current, scope.account).items };
6683
6807
  const proration = stringOf(params, "proration_behavior") ?? "create_prorations";
6684
6808
  const itemRequests = requestedItems(params);
6809
+ const previousBilling = {
6810
+ items: subscriptionItems(scope, current),
6811
+ recurring: recurringOf(scope, current),
6812
+ period: { start: current.current_period_start, end: current.current_period_end }
6813
+ };
6685
6814
  const changed = itemRequests.length === 0 ? { itemIds: current.item_ids, prorationAmount: 0 } : changeItems(scope, current, itemRequests, proration);
6686
6815
  const itemIds = changed.itemIds;
6687
6816
  const prorationAmount = current.status === "trialing" ? 0 : changed.prorationAmount;
@@ -6742,12 +6871,23 @@ var updateSubscription = (scope, current, params) => {
6742
6871
  current_period_end: trialEndAt,
6743
6872
  ...next.status === "active" ? { status: "trialing" } : {}
6744
6873
  };
6874
+ const nextRecurring = recurringOf(scope, next);
6875
+ const resetsAnchor = itemRequests.length > 0 && trialEndAt === void 0 && (current.status === "active" || current.status === "past_due") && !sameBillingPeriod(previousBilling.recurring, nextRecurring);
6876
+ if (resetsAnchor)
6877
+ next = {
6878
+ ...next,
6879
+ billing_cycle_anchor: now,
6880
+ current_period_start: now,
6881
+ current_period_end: addInterval(now, nextRecurring.interval, nextRecurring.interval_count)
6882
+ };
6745
6883
  saveSubscription(scope, current, next, previousItems);
6746
- billProration(scope, next, prorationAmount, proration, {
6747
- period: { start: now, end: current.current_period_end },
6748
- description: proration === "always_invoice" ? "Remaining time on the new price (prorated)" : "Proration for subscription change",
6749
- failOnDecline: stringOf(params, "payment_behavior") === "error_if_incomplete"
6750
- });
6884
+ if (resetsAnchor) invoiceBillingCycleReset(scope, next, previousBilling, proration !== "none");
6885
+ else
6886
+ billProration(scope, next, prorationAmount, proration, {
6887
+ period: { start: now, end: current.current_period_end },
6888
+ description: proration === "always_invoice" ? "Remaining time on the new price (prorated)" : "Proration for subscription change",
6889
+ failOnDecline: stringOf(params, "payment_behavior") === "error_if_incomplete"
6890
+ });
6751
6891
  if (endTrialNow) {
6752
6892
  const trialing = scope.account.subscriptions.get(current.id) ?? next;
6753
6893
  const ended = { ...trialing, trial_end: now, current_period_end: now };
@@ -7478,8 +7618,16 @@ var portalChange = (scope, session, config, form) => {
7478
7618
  throw invalidRequest("Choose a different plan or quantity.");
7479
7619
  return { subscription, itemId: item.id, price, quantity };
7480
7620
  };
7621
+ var resetsBillingPeriod = (scope, change) => {
7622
+ const { subscription } = change;
7623
+ if (subscription.status === "trialing") return false;
7624
+ const price = scope.account.prices.get(change.price)?.recurring;
7625
+ return price != null && !sameBillingPeriod(recurringOf(scope, subscription), price);
7626
+ };
7481
7627
  var previewProration = (scope, config, change) => {
7482
- if (config.features.subscription_update.proration_behavior === "none") return 0;
7628
+ const prorate = config.features.subscription_update.proration_behavior !== "none";
7629
+ const resets = resetsBillingPeriod(scope, change);
7630
+ if (!prorate && !resets) return 0;
7483
7631
  const { subscription } = change;
7484
7632
  if (subscription.status === "trialing") return 0;
7485
7633
  const item = scope.account.subscriptionItems.get(change.itemId);
@@ -7487,9 +7635,10 @@ var previewProration = (scope, config, change) => {
7487
7635
  const now = customerNow(scope, subscription.customer);
7488
7636
  const span = Math.max(1, subscription.current_period_end - subscription.current_period_start);
7489
7637
  const left = Math.max(0, subscription.current_period_end - now) / span;
7490
- return Math.round(
7491
- (amount(change.price, change.quantity) - amount(item?.price ?? change.price, item?.quantity ?? 1)) * left
7492
- );
7638
+ const next = amount(change.price, change.quantity);
7639
+ const previous = amount(item?.price ?? change.price, item?.quantity ?? 1);
7640
+ if (resets) return next - (prorate ? Math.round(previous * left) : 0);
7641
+ return Math.round((next - previous) * left);
7493
7642
  };
7494
7643
  var runPortalAction = (scope, session, form, card2) => {
7495
7644
  const config = requireConfiguration(scope, session.configuration);
@@ -8369,7 +8518,6 @@ var searchRecords = (records, params, options) => {
8369
8518
  const { query } = params;
8370
8519
  if (params.expand === "") throw parameterInvalidEmpty("expand");
8371
8520
  if (query === "") throw parameterInvalidEmpty("query");
8372
- if (params.page === "") throw parameterInvalidEmpty("page");
8373
8521
  if (params.limit !== void 0) {
8374
8522
  const limit2 = Number(params.limit);
8375
8523
  if (limit2 < 1 || limit2 > 100)
@@ -8380,7 +8528,7 @@ var searchRecords = (records, params, options) => {
8380
8528
  param: "limit"
8381
8529
  });
8382
8530
  }
8383
- if (query === "") throw parameterInvalidEmpty("query");
8531
+ if (params.page === "") throw parameterInvalidEmpty("page");
8384
8532
  if (typeof query !== "string" || query.trim() === "")
8385
8533
  throw invalidRequest("Missing required param: query.", "query", "parameter_missing");
8386
8534
  const groups = query.split(/\s+OR\s+/).map(parseSearchQuery);
@@ -8748,7 +8896,18 @@ var priceHandlers = (services) => {
8748
8896
  return {
8749
8897
  PostPrices: async (context) => {
8750
8898
  const scope = requestScope(services, context);
8751
- const params = bodyParams(context);
8899
+ const params = bodyParams(context, {
8900
+ order: ["unit_amount", "unit_amount_decimal"],
8901
+ validate: {
8902
+ unit_amount_decimal: (given) => {
8903
+ if (given.unit_amount !== void 0 && given.unit_amount_decimal !== void 0)
8904
+ throw invalidRequest(
8905
+ "You may only specify one of these parameters: unit_amount, unit_amount_decimal.",
8906
+ "unit_amount"
8907
+ );
8908
+ }
8909
+ }
8910
+ });
8752
8911
  const hasProduct = params.product !== void 0;
8753
8912
  const hasProductData = params.product_data !== void 0;
8754
8913
  if (hasProduct && hasProductData)
@@ -8767,11 +8926,6 @@ var priceHandlers = (services) => {
8767
8926
  const currency = normalizeCurrency(params.currency);
8768
8927
  const hasAmount = params.unit_amount !== void 0;
8769
8928
  const hasDecimal = params.unit_amount_decimal !== void 0;
8770
- if (hasAmount && hasDecimal)
8771
- throw invalidRequest(
8772
- "You may only specify one of these parameters: unit_amount, unit_amount_decimal.",
8773
- "unit_amount"
8774
- );
8775
8929
  if (!hasAmount && !hasDecimal)
8776
8930
  throw invalidRequest(
8777
8931
  "Prices require an `unit_amount` or `unit_amount_decimal` parameter to be set."
@@ -9516,7 +9670,7 @@ var confirmUpdatePage = (context, change) => {
9516
9670
  const due = previewProration(scope, config, change);
9517
9671
  const currency = after?.currency ?? change.subscription.currency;
9518
9672
  const behavior = config.features.subscription_update.proration_behavior;
9519
- const dueLine = behavior === "always_invoice" ? due < 0 ? `<div class="row total"><span>Credit to your balance</span><span data-testid="stripe-mock-portal-amount-due">\u2212${formatMoney(-due, currency)}</span></div>` : `<div class="row total"><span>Amount due today</span><span data-testid="stripe-mock-portal-amount-due">${formatMoney(due, currency)}</span></div>` : `<div class="row total"><span>Adjustment on your next invoice</span><span data-testid="stripe-mock-portal-amount-due">${due < 0 ? "\u2212" : ""}${formatMoney(Math.abs(due), currency)}</span></div>`;
9673
+ const dueLine = behavior === "always_invoice" || resetsBillingPeriod(scope, change) ? due < 0 ? `<div class="row total"><span>Credit to your balance</span><span data-testid="stripe-mock-portal-amount-due">\u2212${formatMoney(-due, currency)}</span></div>` : `<div class="row total"><span>Amount due today</span><span data-testid="stripe-mock-portal-amount-due">${formatMoney(due, currency)}</span></div>` : `<div class="row total"><span>Adjustment on your next invoice</span><span data-testid="stripe-mock-portal-amount-due">${due < 0 ? "\u2212" : ""}${formatMoney(Math.abs(due), currency)}</span></div>`;
9520
9674
  return `<h1>Confirm your new plan</h1>
9521
9675
  <div class="summary" data-testid="stripe-mock-portal-update-summary">
9522
9676
  <div class="row"><span>Current plan</span><span>${escapeHtml2(productName(context, before))} \xB7 ${escapeHtml2(priceLabel(before, current?.quantity ?? 1))}</span></div>
@@ -9851,7 +10005,7 @@ var paymentIntentHandlers = (services) => {
9851
10005
  // src/setup-intents.ts
9852
10006
  var requireSetupIntent = (scope, id) => {
9853
10007
  const intent = scope.account.setupIntents.get(id);
9854
- if (!intent) throw invalidRequest(`No such setup_intent: '${id}'`, "intent", "resource_missing");
10008
+ if (!intent) throw resourceMissing("setupintent", id, "intent");
9855
10009
  return intent;
9856
10010
  };
9857
10011
  var confirmSetup = (scope, intent, method, autoAuthenticate = false) => {
@@ -11270,7 +11424,7 @@ var requireItem = (scope, id) => {
11270
11424
  status: 404,
11271
11425
  code: "resource_missing",
11272
11426
  message: `No such Invoice Item: '${id}'(livemode=false)`,
11273
- param: "invoiceitem"
11427
+ param: "id"
11274
11428
  });
11275
11429
  return item;
11276
11430
  };
@@ -16942,6 +17096,10 @@ var StripeAPI = class {
16942
17096
  publish: options.onWebhook,
16943
17097
  accounts,
16944
17098
  deliveryVersion: (account) => accounts.config(account)?.apiVersion ?? webhookVersion,
17099
+ paymentRetries: (account) => ({
17100
+ ...this.settings.paymentRetries,
17101
+ ...accounts.config(account)?.billing?.retries
17102
+ }),
16945
17103
  pendingWebhooks: options.pendingWebhooks ?? (() => 0),
16946
17104
  namespacePrefix: options.publicNamespace === void 0 || options.publicNamespace === "default" ? "" : `/ns/${encodeURIComponent(options.publicNamespace)}`,
16947
17105
  publicUrl: options.publicUrl,
@@ -17244,4 +17402,4 @@ export {
17244
17402
  createRuntime2 as createRuntime,
17245
17403
  StripeAPI
17246
17404
  };
17247
- //# sourceMappingURL=chunk-E54EAWQ7.js.map
17405
+ //# sourceMappingURL=chunk-4YHVIH3S.js.map