@crvouga/mockingbird-service-stripe 1.4.0 → 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,18 @@
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
+
3
16
  ## 1.4.0 (2026-09-29)
4
17
 
5
18
  ### Features
package/README.md CHANGED
@@ -199,8 +199,12 @@ now and returns `incomplete` on a decline; `error_if_incomplete` fails the call
199
199
  customer, and paying it through Stripe.js activates the subscription. `trial_end`,
200
200
  `backdate_start_date` + `billing_cycle_anchor` + `proration_behavior=none` (a $0 first invoice),
201
201
  item updates with `always_invoice` (billed now) or `create_prorations` (next invoice), and
202
- discounts with stable `di_` ids (`discounts=""` clears) are modelled. Items must be active recurring
203
- 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
204
208
  `cancellation_details[comment|feedback]`, `prorate` (a credit for the unused time as a pending
205
209
  proration) and `invoice_now` (a final invoice; a net credit lands on the customer balance), in the
206
210
  query string (as stripe-node sends them) or the body. A $0 invoice is `paid` on
@@ -381,10 +385,13 @@ plus `port`, `host`; resolves `{url, port, runtime, close}`), `serveTarget` (the
381
385
  PaymentSheet cannot be redirected (member-app native keeps its fake provider).
382
386
  - **Connect** (`Stripe-Account`, application fees, transfers), tax, shipping, Radar, mandates,
383
387
  meters, quotes, credit notes, payouts, and non-card payment methods (bank debits, wallets).
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.
384
391
  - **Proration arithmetic** is day-fraction approximate (Stripe prorates to the second);
385
392
  `auto_advance` drafts are not finalized an hour later.
386
393
  - **Customer portal extras**: the login page (`login_page.url` is not served), `schedule_at_period_end`
387
- downgrades, `billing_cycle_anchor` resets on plan changes, multi-item subscription updates, locales,
394
+ downgrades, multi-item subscription updates, locales,
388
395
  and payment method configurations. Portal sessions do not expire.
389
396
  - **Webhook endpoint `api_version`**: payloads render at the account's version, not per endpoint.
390
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.4.0" : 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();
@@ -5488,6 +5488,57 @@ var billProration = (scope, subscription, amount, behavior, options) => {
5488
5488
  });
5489
5489
  return void 0;
5490
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
+ };
5491
5542
  var resumeSubscription = (scope, current, options) => {
5492
5543
  const now = customerNow(scope, current.customer);
5493
5544
  const recurring = recurringOf(scope, current);
@@ -6755,6 +6806,11 @@ var updateSubscription = (scope, current, params) => {
6755
6806
  const previousItems = { items: renderSubscription(current, scope.account).items };
6756
6807
  const proration = stringOf(params, "proration_behavior") ?? "create_prorations";
6757
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
+ };
6758
6814
  const changed = itemRequests.length === 0 ? { itemIds: current.item_ids, prorationAmount: 0 } : changeItems(scope, current, itemRequests, proration);
6759
6815
  const itemIds = changed.itemIds;
6760
6816
  const prorationAmount = current.status === "trialing" ? 0 : changed.prorationAmount;
@@ -6815,12 +6871,23 @@ var updateSubscription = (scope, current, params) => {
6815
6871
  current_period_end: trialEndAt,
6816
6872
  ...next.status === "active" ? { status: "trialing" } : {}
6817
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
+ };
6818
6883
  saveSubscription(scope, current, next, previousItems);
6819
- billProration(scope, next, prorationAmount, proration, {
6820
- period: { start: now, end: current.current_period_end },
6821
- description: proration === "always_invoice" ? "Remaining time on the new price (prorated)" : "Proration for subscription change",
6822
- failOnDecline: stringOf(params, "payment_behavior") === "error_if_incomplete"
6823
- });
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
+ });
6824
6891
  if (endTrialNow) {
6825
6892
  const trialing = scope.account.subscriptions.get(current.id) ?? next;
6826
6893
  const ended = { ...trialing, trial_end: now, current_period_end: now };
@@ -7551,8 +7618,16 @@ var portalChange = (scope, session, config, form) => {
7551
7618
  throw invalidRequest("Choose a different plan or quantity.");
7552
7619
  return { subscription, itemId: item.id, price, quantity };
7553
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
+ };
7554
7627
  var previewProration = (scope, config, change) => {
7555
- 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;
7556
7631
  const { subscription } = change;
7557
7632
  if (subscription.status === "trialing") return 0;
7558
7633
  const item = scope.account.subscriptionItems.get(change.itemId);
@@ -7560,9 +7635,10 @@ var previewProration = (scope, config, change) => {
7560
7635
  const now = customerNow(scope, subscription.customer);
7561
7636
  const span = Math.max(1, subscription.current_period_end - subscription.current_period_start);
7562
7637
  const left = Math.max(0, subscription.current_period_end - now) / span;
7563
- return Math.round(
7564
- (amount(change.price, change.quantity) - amount(item?.price ?? change.price, item?.quantity ?? 1)) * left
7565
- );
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);
7566
7642
  };
7567
7643
  var runPortalAction = (scope, session, form, card2) => {
7568
7644
  const config = requireConfiguration(scope, session.configuration);
@@ -8442,7 +8518,6 @@ var searchRecords = (records, params, options) => {
8442
8518
  const { query } = params;
8443
8519
  if (params.expand === "") throw parameterInvalidEmpty("expand");
8444
8520
  if (query === "") throw parameterInvalidEmpty("query");
8445
- if (params.page === "") throw parameterInvalidEmpty("page");
8446
8521
  if (params.limit !== void 0) {
8447
8522
  const limit2 = Number(params.limit);
8448
8523
  if (limit2 < 1 || limit2 > 100)
@@ -8453,7 +8528,7 @@ var searchRecords = (records, params, options) => {
8453
8528
  param: "limit"
8454
8529
  });
8455
8530
  }
8456
- if (query === "") throw parameterInvalidEmpty("query");
8531
+ if (params.page === "") throw parameterInvalidEmpty("page");
8457
8532
  if (typeof query !== "string" || query.trim() === "")
8458
8533
  throw invalidRequest("Missing required param: query.", "query", "parameter_missing");
8459
8534
  const groups = query.split(/\s+OR\s+/).map(parseSearchQuery);
@@ -8821,7 +8896,18 @@ var priceHandlers = (services) => {
8821
8896
  return {
8822
8897
  PostPrices: async (context) => {
8823
8898
  const scope = requestScope(services, context);
8824
- 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
+ });
8825
8911
  const hasProduct = params.product !== void 0;
8826
8912
  const hasProductData = params.product_data !== void 0;
8827
8913
  if (hasProduct && hasProductData)
@@ -8840,11 +8926,6 @@ var priceHandlers = (services) => {
8840
8926
  const currency = normalizeCurrency(params.currency);
8841
8927
  const hasAmount = params.unit_amount !== void 0;
8842
8928
  const hasDecimal = params.unit_amount_decimal !== void 0;
8843
- if (hasAmount && hasDecimal)
8844
- throw invalidRequest(
8845
- "You may only specify one of these parameters: unit_amount, unit_amount_decimal.",
8846
- "unit_amount"
8847
- );
8848
8929
  if (!hasAmount && !hasDecimal)
8849
8930
  throw invalidRequest(
8850
8931
  "Prices require an `unit_amount` or `unit_amount_decimal` parameter to be set."
@@ -9589,7 +9670,7 @@ var confirmUpdatePage = (context, change) => {
9589
9670
  const due = previewProration(scope, config, change);
9590
9671
  const currency = after?.currency ?? change.subscription.currency;
9591
9672
  const behavior = config.features.subscription_update.proration_behavior;
9592
- 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>`;
9593
9674
  return `<h1>Confirm your new plan</h1>
9594
9675
  <div class="summary" data-testid="stripe-mock-portal-update-summary">
9595
9676
  <div class="row"><span>Current plan</span><span>${escapeHtml2(productName(context, before))} \xB7 ${escapeHtml2(priceLabel(before, current?.quantity ?? 1))}</span></div>
@@ -11343,7 +11424,7 @@ var requireItem = (scope, id) => {
11343
11424
  status: 404,
11344
11425
  code: "resource_missing",
11345
11426
  message: `No such Invoice Item: '${id}'(livemode=false)`,
11346
- param: "invoiceitem"
11427
+ param: "id"
11347
11428
  });
11348
11429
  return item;
11349
11430
  };
@@ -17321,4 +17402,4 @@ export {
17321
17402
  createRuntime2 as createRuntime,
17322
17403
  StripeAPI
17323
17404
  };
17324
- //# sourceMappingURL=chunk-P77GCNSW.js.map
17405
+ //# sourceMappingURL=chunk-4YHVIH3S.js.map