@blamejs/blamejs-shop 0.4.20 → 0.4.21

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.
@@ -242,6 +242,77 @@ function create(opts) {
242
242
  if (!subscriptionsHandle || typeof subscriptionsHandle.get !== "function") {
243
243
  throw new TypeError("subscriptionControls.create: opts.subscriptions handle required");
244
244
  }
245
+ // Optional payment handle (the shared Stripe adapter). When wired,
246
+ // quantity changes on a Stripe-backed subscription are pushed to
247
+ // Stripe BEFORE the local row is touched, so the row the shop shows
248
+ // and the quantity Stripe actually bills never diverge. Without it
249
+ // (a deploy with no payment processor, or a non-Stripe row), the
250
+ // controls stay local-only. The handle must expose
251
+ // `subscriptions.retrieve(id)` + `subscriptions.update(id, body)`.
252
+ var payment = opts.payment || null;
253
+ var hasStripe = !!(payment && payment.subscriptions &&
254
+ typeof payment.subscriptions.update === "function" &&
255
+ typeof payment.subscriptions.retrieve === "function");
256
+
257
+ // A row is Stripe-backed when the processor adapter is wired AND the
258
+ // row carries the upstream subscription id the webhook + billing
259
+ // mirror key on. Rows without one are shop-local (e.g. a manually
260
+ // seeded subscription on a deploy that never reached Stripe) and the
261
+ // controls mutate only local columns for them.
262
+ function _isStripeBacked(row) {
263
+ return hasStripe && row && typeof row.stripe_subscription_id === "string" && row.stripe_subscription_id.length > 0;
264
+ }
265
+
266
+ // Push a new line quantity to Stripe for a Stripe-backed subscription.
267
+ // Stripe models quantity on the subscription ITEM, not the
268
+ // subscription, so we retrieve the live subscription to find its
269
+ // (single) item id, then update that item's quantity. Every
270
+ // subscription this shop creates binds exactly one price
271
+ // (`items: [{ price }]`), so the first item is authoritative; if a
272
+ // subscription somehow carries no item we surface a structured error
273
+ // rather than silently writing a local-only change that diverges from
274
+ // Stripe. The idempotency key folds in the subscription id + target
275
+ // quantity so a retried call is a safe no-op at Stripe.
276
+ async function _pushQuantityToStripe(stripeSubscriptionId, newQuantity) {
277
+ var live;
278
+ try {
279
+ live = await payment.subscriptions.retrieve(stripeSubscriptionId);
280
+ } catch (e) {
281
+ var rErr = new Error(
282
+ "subscriptionControls.changeQuantity: could not reach Stripe to update the subscription — " + (e && e.message || e),
283
+ );
284
+ rErr.code = "SUBSCRIPTION_STRIPE_PUSH_FAILED";
285
+ rErr.cause = e;
286
+ throw rErr;
287
+ }
288
+ var items = live && live.items && Array.isArray(live.items.data) ? live.items.data : [];
289
+ if (!items.length || !items[0] || !items[0].id) {
290
+ var noItem = new Error(
291
+ "subscriptionControls.changeQuantity: Stripe subscription " + stripeSubscriptionId + " has no billable item to update",
292
+ );
293
+ noItem.code = "SUBSCRIPTION_STRIPE_NO_ITEM";
294
+ throw noItem;
295
+ }
296
+ var idemKey = "subctl:qty:" + stripeSubscriptionId + ":" + newQuantity;
297
+ try {
298
+ return await payment.subscriptions.update(
299
+ stripeSubscriptionId,
300
+ { items: [{ id: items[0].id, quantity: newQuantity }] },
301
+ idemKey,
302
+ );
303
+ } catch (e2) {
304
+ // The processor rejected or failed the quantity update. Wrap it in
305
+ // a stable code so the route can surface a "nothing changed, retry"
306
+ // notice; the local row is still untouched (this runs before the
307
+ // local write).
308
+ var uErr = new Error(
309
+ "subscriptionControls.changeQuantity: Stripe rejected the quantity update — " + (e2 && e2.message || e2),
310
+ );
311
+ uErr.code = "SUBSCRIPTION_STRIPE_PUSH_FAILED";
312
+ uErr.cause = e2;
313
+ throw uErr;
314
+ }
315
+ }
245
316
 
246
317
  // Fetch the raw subscription row + bound plan in one round-trip,
247
318
  // returning `null` if either is absent. The plan lookup feeds the
@@ -317,6 +388,14 @@ function create(opts) {
317
388
  MAX_SKIP_COUNT: MAX_SKIP_COUNT,
318
389
  MAX_QUANTITY: MAX_QUANTITY,
319
390
  REACTIVATE_GRACE_MS: REACTIVATE_GRACE_MS,
391
+ // True when this instance can push changes to Stripe (a payment
392
+ // handle with retrieve + update was wired). Callers use it to decide
393
+ // whether a row with a stripe_subscription_id is genuinely Stripe-
394
+ // backed — a frequency change is refused (immutable interval) and a
395
+ // quantity change round-trips Stripe. Without it, the controls are
396
+ // local-only even for a Stripe-shaped row, so the storefront keeps
397
+ // offering the frequency control.
398
+ stripeBacked: hasStripe,
320
399
 
321
400
  pause: async function (input) {
322
401
  if (!input || typeof input !== "object") {
@@ -462,6 +541,18 @@ function create(opts) {
462
541
  throw cErr;
463
542
  }
464
543
 
544
+ // Stripe-backed: push the new quantity to Stripe BEFORE the local
545
+ // write. If the processor rejects the update, we throw and leave
546
+ // the local row untouched — the customer sees an honest error and
547
+ // the shop's `quantity` column never diverges from what Stripe
548
+ // actually bills (the divergence this control surface previously
549
+ // produced, writing a local "Quantity updated." while Stripe kept
550
+ // charging the old quantity). The local write only lands after the
551
+ // Stripe update succeeds.
552
+ if (_isStripeBacked(row)) {
553
+ await _pushQuantityToStripe(row.stripe_subscription_id, newQuantity);
554
+ }
555
+
465
556
  var before = _snapshot(row);
466
557
  var ts = _now();
467
558
  await query(
@@ -495,6 +586,28 @@ function create(opts) {
495
586
  throw cErr;
496
587
  }
497
588
 
589
+ // Stripe-backed: refuse. A Stripe Price's `recurring.interval` is
590
+ // IMMUTABLE — you cannot re-cadence an existing price, only swap
591
+ // the subscription item to a DIFFERENT price with the desired
592
+ // interval. This shop binds each subscription to a single
593
+ // `stripe_price_id` from its plan and carries no catalog of
594
+ // per-frequency prices to swap between, so the billing interval
595
+ // simply isn't expressible here. Writing the local `frequency`
596
+ // column anyway would tell the customer "Delivery frequency
597
+ // updated" while Stripe kept invoicing on the original cadence —
598
+ // exactly the divergence this surface must not create. Refuse with
599
+ // honest copy; the customer cancels + re-subscribes on a plan with
600
+ // the cadence they want. (A non-Stripe row falls through to the
601
+ // local-only recompute below — the shop's own cadence view.)
602
+ if (_isStripeBacked(row)) {
603
+ var fErr = new Error(
604
+ "subscriptionControls.changeFrequency: delivery frequency can't be changed on this subscription — " +
605
+ "cancel and start a new subscription on a plan with the cadence you want",
606
+ );
607
+ fErr.code = "SUBSCRIPTION_FREQUENCY_IMMUTABLE";
608
+ throw fErr;
609
+ }
610
+
498
611
  // next_billing_at is recomputed off the row's current cycle
499
612
  // anchor — `current_period_start` (Stripe-mirrored) if present,
500
613
  // otherwise the current next_billing_at, otherwise now. One
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.20",
3
+ "version": "0.4.21",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {