@blamejs/blamejs-shop 0.4.20 → 0.4.22

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.
@@ -58,11 +58,19 @@ var _vendoredSecurityHeaders = require("./vendor/blamejs/lib/middleware/security
58
58
 
59
59
  var C = b.constants;
60
60
 
61
- // Payment webhooks are server-to-server POSTs from Stripe / PayPal:
62
- // cross-site by nature, unthrottleable by a per-IP human budget, and
63
- // already authenticated by an HMAC signature the edge + container both
64
- // verify. They are exempt from BOTH the rate limiters and fetch-metadata.
65
- var WEBHOOK_PATHS = ["/api/webhooks/stripe", "/api/webhooks/paypal"];
61
+ // Server-to-server webhooks: cross-site by nature, unthrottleable by a
62
+ // per-IP human budget, and each authenticated by its own gate the handler
63
+ // verifies first thing an HMAC signature (Stripe / PayPal) or a per-
64
+ // endpoint signing secret (the ESP bounce / complaint intake). They are
65
+ // exempt from the rate limiters, fetch-metadata, and the double-submit CSRF
66
+ // token (a third-party POST carries no session cookie or token). The
67
+ // `/api/` prefix also lands them in the vendored bot-guard's onlyForHtml
68
+ // skip, so the secret / signature gate is the deciding check.
69
+ var WEBHOOK_PATHS = [
70
+ "/api/webhooks/stripe",
71
+ "/api/webhooks/paypal",
72
+ "/api/webhooks/mail-bounce",
73
+ ];
66
74
 
67
75
  // Liveness / readiness probe — the container's Docker HEALTHCHECK hits
68
76
  // this on a fixed cadence; never rate-limit it or a slow cold start
@@ -67,6 +67,14 @@ var C = b.constants;
67
67
  var KINDS = ["credit", "debit", "expire"];
68
68
  var SOURCES = ["refund", "goodwill", "promotional", "manual", "loyalty_redemption"];
69
69
 
70
+ // Reserved source_ref stamped on every expire row the scheduled sweep
71
+ // writes. `cleanupExpired` keys its "already swept" idempotency on this
72
+ // marker SO THAT operator-initiated `expire()` rows (which carry the
73
+ // operator's own reason) don't masquerade as prior sweep output and
74
+ // suppress a legitimate expiry. The marker is short + control-byte-free
75
+ // so it passes `_sourceRef` validation when written.
76
+ var SWEEP_SOURCE_REF = "scheduled-expiry-sweep";
77
+
70
78
  var MAX_SOURCE_REF_LEN = 128;
71
79
  // source_ref / reason are short correlation handles. Refuse all
72
80
  // control bytes (including CR/LF and tab) — log-injection cover has
@@ -488,20 +496,25 @@ function create(opts) {
488
496
  var now = _epochMs(input.now, "now");
489
497
  if (now == null) now = _now();
490
498
 
491
- // Walk every credit row whose deadline has passed. For each,
492
- // check whether a later `expire` row has already offset it
493
- // if so, skip (idempotent re-run produces no duplicates).
494
- // Otherwise write an offsetting expire row capped at the
495
- // wallet's current balance.
499
+ // Walk every credit row whose deadline has passed. For each
500
+ // customer, expire the still-unburned portion of their expired
501
+ // credit, capped at the wallet's current balance.
496
502
  //
497
- // The "already offset" check is per-customer rather than
498
- // per-credit-row because expire rows have no parent pointer
499
- // at the schema level. We sum the customer's expired-credit
500
- // amounts (kind=credit AND expires_at <= now) and the
501
- // customer's burn-expire amounts (kind=expire) the delta
502
- // is the still-unburned expiring credit for that customer.
503
- // When the delta is zero, the sweep is a no-op for that
504
- // customer.
503
+ // The "already swept" check is per-customer rather than
504
+ // per-credit-row because expire rows have no parent pointer at
505
+ // the schema level. The idempotency key is the SWEEP's OWN prior
506
+ // output expire rows stamped with `SWEEP_SOURCE_REF` — NOT
507
+ // every expire row. Counting all expire rows here was the bug:
508
+ // an operator-initiated `expire()` (a clawback, a goodwill burn)
509
+ // or any non-sweep expire would be subtracted from the expired-
510
+ // credit total, shrinking `pendingBurn` and leaving genuinely
511
+ // expired credit un-swept. Operator expires + debits already
512
+ // reduced the BALANCE; the `min(pendingBurn, balance)` cap below
513
+ // is what keeps the sweep from over-burning when the wallet was
514
+ // partly drained — so they must not also be netted out of the
515
+ // expired pool, or the reduction is double-counted. When the
516
+ // unburned remainder is zero, the sweep is a no-op for that
517
+ // customer (idempotent re-run produces no duplicates).
505
518
  var expiredByCustomer = (await query(
506
519
  "SELECT customer_id, SUM(amount_minor) AS total " +
507
520
  "FROM store_credit_ledger " +
@@ -519,16 +532,15 @@ function create(opts) {
519
532
  var burnRow = (await query(
520
533
  "SELECT COALESCE(SUM(amount_minor), 0) AS total " +
521
534
  "FROM store_credit_ledger " +
522
- "WHERE customer_id = ?1 AND kind = 'expire'",
523
- [customerId],
535
+ "WHERE customer_id = ?1 AND kind = 'expire' AND source_ref = ?2",
536
+ [customerId, SWEEP_SOURCE_REF],
524
537
  )).rows[0];
525
538
  var alreadyBurned = burnRow ? burnRow.total : 0;
526
539
  var pendingBurn = expiredTotal - alreadyBurned;
527
540
 
528
541
  if (pendingBurn <= 0) {
529
- // Already fully offset by prior expire rows (or by
530
- // debits that drained the wallet below the expired
531
- // amount — see cap below). Idempotent skip.
542
+ // The sweep already burned this customer's expired credit on
543
+ // a prior run. Idempotent skip.
532
544
  continue;
533
545
  }
534
546
 
@@ -548,7 +560,7 @@ function create(opts) {
548
560
  }
549
561
  var ts = _resolveOccurredAt(now, latest.occurred_at);
550
562
  var after = latest.balance - toBurn;
551
- var id = await _writeRow(customerId, "expire", toBurn, null, "scheduled-expiry-sweep", null, after, null, ts);
563
+ var id = await _writeRow(customerId, "expire", toBurn, null, SWEEP_SOURCE_REF, null, after, null, ts);
552
564
  processed.push({
553
565
  id: id,
554
566
  customer_id: customerId,
package/lib/storefront.js CHANGED
@@ -5795,6 +5795,21 @@ function _subscriptionControlState(sub) {
5795
5795
  return "active";
5796
5796
  }
5797
5797
 
5798
+ // Stripe statuses that wind a subscription down for good. A subscription
5799
+ // the customer cancelled at Stripe (or that lapsed unpaid) syncs to one
5800
+ // of these via the `customer.subscription.*` webhook, which writes
5801
+ // `status` but NOT the local `cancelled_at`. Without checking the synced
5802
+ // status, a Stripe-cancelled subscription whose local control-state is
5803
+ // still "active" would render live pause / skip / quantity / frequency
5804
+ // controls that mutate a row Stripe no longer bills — a customer would
5805
+ // "pause" or "change the quantity" of a subscription that's already
5806
+ // dead. So the self-manage controls are gated on the synced status: a
5807
+ // terminally-wound-down subscription shows its state, not live controls.
5808
+ var TERMINAL_SUB_STATUSES = ["canceled", "incomplete_expired", "unpaid"];
5809
+ function _subscriptionSelfManageable(sub) {
5810
+ return TERMINAL_SUB_STATUSES.indexOf(sub.status) === -1;
5811
+ }
5812
+
5798
5813
  // The 90-day reactivation grace mirrors subscriptionControls.REACTIVATE_
5799
5814
  // GRACE_MS. A cancelled row is reactivatable from the storefront while
5800
5815
  // the cancellation is inside that window; past it, the primitive refuses
@@ -5841,6 +5856,13 @@ var SUBSCRIPTION_ERR = {
5841
5856
  frequency: "Choose a valid delivery frequency.",
5842
5857
  state: "That change isn't available for this subscription right now.",
5843
5858
  grace: "This subscription was cancelled too long ago to reactivate. Start a new one instead.",
5859
+ // Stripe doesn't let an active subscription's billing cadence change in
5860
+ // place; the customer cancels + re-subscribes on a plan with the
5861
+ // cadence they want.
5862
+ freq_locked: "Delivery frequency can't be changed on this subscription. Cancel it and start a new one on the cadence you want.",
5863
+ // A Stripe-side quantity push failed; the local quantity was NOT
5864
+ // changed, so the customer sees the unchanged value and can retry.
5865
+ processor: "We couldn't update your subscription with the payment processor. Nothing changed — please try again.",
5844
5866
  };
5845
5867
  function _subscriptionErrorCopy(code) {
5846
5868
  if (code == null) return null;
@@ -5853,14 +5875,23 @@ function _subscriptionErrorCopy(code) {
5853
5875
  // payment handle wired — the list renders read-only with a note, since
5854
5876
  // cancel composes Stripe. `opts.self_manage` adds the pause / resume /
5855
5877
  // skip / change-quantity / change-frequency / reactivate controls (wired
5856
- // only when the subscriptionControls primitive is available). Empty state
5857
- // points at the catalog (creation is a separate Stripe-subscription-
5858
- // checkout surface, not built here).
5878
+ // only when the subscriptionControls primitive is available).
5879
+ // `opts.stripe_backed` is true when those controls can reach Stripe — it
5880
+ // suppresses the change-frequency form on a Stripe-backed row (the
5881
+ // billing interval is immutable at Stripe) and gates the whole control
5882
+ // block on the synced status so a Stripe-cancelled subscription shows its
5883
+ // state, not live controls. Empty state points at the catalog (creation
5884
+ // is a separate Stripe-subscription-checkout surface, not built here).
5859
5885
  function renderAccountSubscriptions(opts) {
5860
5886
  var esc = b.template.escapeHtml;
5861
5887
  var subs = opts.subscriptions || [];
5862
5888
  var canCancel = opts.can_cancel !== false;
5863
5889
  var selfManage = opts.self_manage === true;
5890
+ // True when the controls can actually reach Stripe (retrieve+update
5891
+ // wired). Only then is a row's billing cadence processor-locked and the
5892
+ // frequency control suppressed; a local-only controls instance keeps
5893
+ // offering it even on a Stripe-shaped row.
5894
+ var stripeAware = opts.stripe_backed === true;
5864
5895
  var rowsHtml = "";
5865
5896
  for (var i = 0; i < subs.length; i += 1) {
5866
5897
  var s = subs[i];
@@ -5920,11 +5951,22 @@ function renderAccountSubscriptions(opts) {
5920
5951
  // cancel (a second server-rendered screen, no inline confirm()); the
5921
5952
  // reversible controls post directly. Quantity / frequency take input
5922
5953
  // validated server-side.
5954
+ //
5955
+ // The active + paused control sets are additionally gated on the
5956
+ // SYNCED Stripe status: a subscription Stripe has wound down
5957
+ // (canceled / incomplete_expired / unpaid via webhook) but whose
5958
+ // LOCAL control-state still reads "active"/"paused" must NOT offer
5959
+ // pause / skip / quantity / frequency / resume — those would mutate a
5960
+ // row Stripe no longer bills. The cancelled-within-grace REACTIVATE
5961
+ // path is intentionally NOT gated this way: a Stripe-canceled status
5962
+ // is the normal state of a row the customer is reactivating, and
5963
+ // reactivate is the recovery action for exactly that case.
5923
5964
  var manageControls = "";
5965
+ var liveManageable = _subscriptionSelfManageable(s);
5924
5966
  if (selfManage) {
5925
5967
  var actId = esc(s.id);
5926
5968
  var ctrls = "";
5927
- if (controlState === "active") {
5969
+ if (controlState === "active" && liveManageable) {
5928
5970
  ctrls +=
5929
5971
  "<a class=\"btn-ghost btn-ghost--sm\" href=\"/account/subscriptions/" + actId + "/pause\">Pause</a>";
5930
5972
  ctrls +=
@@ -5939,15 +5981,26 @@ function renderAccountSubscriptions(opts) {
5939
5981
  "</label>" +
5940
5982
  "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Update quantity</button>" +
5941
5983
  "</form>";
5942
- ctrls +=
5943
- "<form class=\"subscription-card__control subscription-card__control--freq\" method=\"post\" action=\"/account/subscriptions/" + actId + "/frequency\">" +
5944
- "<label class=\"form-field form-field--inline\">" +
5945
- "<span class=\"form-field__label\">Frequency</span>" +
5946
- "<select name=\"frequency\" required>" + _frequencyOptions(s.frequency || null) + "</select>" +
5947
- "</label>" +
5948
- "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Update frequency</button>" +
5949
- "</form>";
5950
- } else if (controlState === "paused") {
5984
+ // The frequency control only renders for a shop-local (non-
5985
+ // Stripe) subscription. A Stripe-backed subscription's billing
5986
+ // cadence is fixed by its Stripe Price (interval is immutable),
5987
+ // so there's nothing for this form to change — offering it would
5988
+ // be a button that always errors. `stripeAware` is set only when
5989
+ // the controls can reach Stripe; combined with the row's
5990
+ // `stripe_subscription_id` it identifies a genuinely Stripe-
5991
+ // backed row.
5992
+ var stripeBacked = stripeAware && s.stripe_subscription_id != null && String(s.stripe_subscription_id).length > 0;
5993
+ if (!stripeBacked) {
5994
+ ctrls +=
5995
+ "<form class=\"subscription-card__control subscription-card__control--freq\" method=\"post\" action=\"/account/subscriptions/" + actId + "/frequency\">" +
5996
+ "<label class=\"form-field form-field--inline\">" +
5997
+ "<span class=\"form-field__label\">Frequency</span>" +
5998
+ "<select name=\"frequency\" required>" + _frequencyOptions(s.frequency || null) + "</select>" +
5999
+ "</label>" +
6000
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Update frequency</button>" +
6001
+ "</form>";
6002
+ }
6003
+ } else if (controlState === "paused" && liveManageable) {
5951
6004
  ctrls +=
5952
6005
  "<form class=\"subscription-card__control\" method=\"post\" action=\"/account/subscriptions/" + actId + "/resume\">" +
5953
6006
  "<button type=\"submit\" class=\"btn-primary btn-primary--sm\">Resume</button>" +
@@ -17487,6 +17540,11 @@ function mount(router, deps) {
17487
17540
  subscriptions: rows,
17488
17541
  can_cancel: subsCanCancel,
17489
17542
  self_manage: !!subControls,
17543
+ // The frequency control is suppressed for Stripe-backed rows
17544
+ // ONLY when the controls can actually reach Stripe (a payment
17545
+ // handle with retrieve + update was wired) — otherwise the
17546
+ // controls are local-only and the cadence is still editable.
17547
+ stripe_backed: !!(subControls && subControls.stripeBacked),
17490
17548
  notice: notice,
17491
17549
  error: _subscriptionErrorCopy(errKind),
17492
17550
  shop_name: shopName,
@@ -17513,6 +17571,7 @@ function mount(router, deps) {
17513
17571
  if (e && e.code === "SUBSCRIPTION_REACTIVATE_GRACE_EXPIRED") return "grace";
17514
17572
  if (e && e.code === "SUBSCRIPTION_STATE_REFUSED") return "state";
17515
17573
  if (e && e.code === "SUBSCRIPTION_NOT_FOUND") return "state";
17574
+ if (e && e.code === "SUBSCRIPTION_FREQUENCY_IMMUTABLE") return "freq_locked";
17516
17575
  if (e instanceof TypeError) return "state";
17517
17576
  return null;
17518
17577
  }
@@ -17522,13 +17581,32 @@ function mount(router, deps) {
17522
17581
  return res.end ? res.end() : res.send("");
17523
17582
  }
17524
17583
 
17584
+ // Resolve an owned subscription AND refuse the self-manage action
17585
+ // when the synced Stripe status is terminal (canceled /
17586
+ // incomplete_expired / unpaid). The display gates the controls on
17587
+ // the same status, but the backend validates independently — a
17588
+ // forged POST to a wound-down subscription's pause / skip /
17589
+ // quantity / frequency endpoint bounces to the list with the
17590
+ // generic state error rather than mutating a row Stripe no longer
17591
+ // bills. Returns null (after redirecting) when ownership fails or
17592
+ // the status is terminal; the caller bails on null.
17593
+ async function _ownedManageableSubscription(req, res, auth) {
17594
+ var sub = await _ownedSubscription(req, res, auth);
17595
+ if (!sub) return null;
17596
+ if (!_subscriptionSelfManageable(sub)) {
17597
+ _redirect(res, "?error=state");
17598
+ return null;
17599
+ }
17600
+ return sub;
17601
+ }
17602
+
17525
17603
  // Pause is confirm-gated (GET → POST), mirroring cancel — a
17526
17604
  // deliberate, reversible hold. The confirm page only renders for a
17527
17605
  // currently-active subscription; a paused/cancelled row bounces
17528
17606
  // back to the list.
17529
17607
  router.get("/account/subscriptions/:id/pause", async function (req, res) {
17530
17608
  var auth = _subsAuth(req, res); if (!auth) return;
17531
- var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
17609
+ var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
17532
17610
  if (_subscriptionControlState(sub) !== "active") return _redirect(res, "");
17533
17611
  if (sub.plan_id != null) {
17534
17612
  try { sub.plan = await subscriptions.plans.get(sub.plan_id); }
@@ -17544,7 +17622,7 @@ function mount(router, deps) {
17544
17622
 
17545
17623
  router.post("/account/subscriptions/:id/pause", async function (req, res) {
17546
17624
  var auth = _subsAuth(req, res); if (!auth) return;
17547
- var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
17625
+ var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
17548
17626
  try {
17549
17627
  await subControls.pause({ subscription_id: sub.id, reason: "customer self-service pause", actor: SELF_ACTOR });
17550
17628
  } catch (e) {
@@ -17556,7 +17634,7 @@ function mount(router, deps) {
17556
17634
 
17557
17635
  router.post("/account/subscriptions/:id/resume", async function (req, res) {
17558
17636
  var auth = _subsAuth(req, res); if (!auth) return;
17559
- var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
17637
+ var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
17560
17638
  try {
17561
17639
  await subControls.resume({ subscription_id: sub.id, reason: "customer self-service resume", actor: SELF_ACTOR });
17562
17640
  } catch (e) {
@@ -17568,7 +17646,7 @@ function mount(router, deps) {
17568
17646
 
17569
17647
  router.post("/account/subscriptions/:id/skip", async function (req, res) {
17570
17648
  var auth = _subsAuth(req, res); if (!auth) return;
17571
- var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
17649
+ var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
17572
17650
  try {
17573
17651
  await subControls.skipNext({ subscription_id: sub.id, count: 1, reason: "customer self-service skip", actor: SELF_ACTOR });
17574
17652
  } catch (e) {
@@ -17580,7 +17658,7 @@ function mount(router, deps) {
17580
17658
 
17581
17659
  router.post("/account/subscriptions/:id/quantity", async function (req, res) {
17582
17660
  var auth = _subsAuth(req, res); if (!auth) return;
17583
- var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
17661
+ var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
17584
17662
  // Backend validates: a non-positive / non-integer / missing value
17585
17663
  // is a client error → bounce with the quantity error code rather
17586
17664
  // than handing garbage to the primitive (which would throw a
@@ -17591,6 +17669,14 @@ function mount(router, deps) {
17591
17669
  await subControls.changeQuantity({ subscription_id: sub.id, new_quantity: qty, reason: "customer self-service quantity change", actor: SELF_ACTOR });
17592
17670
  } catch (e) {
17593
17671
  if (e instanceof TypeError) return _redirect(res, "?error=quantity");
17672
+ // A Stripe-side push failure (no billable item, or the
17673
+ // processor rejected/timed out the update) left the local
17674
+ // quantity unchanged. Surface a clean "nothing changed, retry"
17675
+ // notice instead of 500-ing — the customer's row still shows
17676
+ // the old quantity, which is the truth.
17677
+ if (e && (e.code === "SUBSCRIPTION_STRIPE_NO_ITEM" || e.code === "SUBSCRIPTION_STRIPE_PUSH_FAILED")) {
17678
+ return _redirect(res, "?error=processor");
17679
+ }
17594
17680
  var code = _controlError(e); if (code == null) throw e;
17595
17681
  return _redirect(res, "?error=" + code);
17596
17682
  }
@@ -17599,7 +17685,7 @@ function mount(router, deps) {
17599
17685
 
17600
17686
  router.post("/account/subscriptions/:id/frequency", async function (req, res) {
17601
17687
  var auth = _subsAuth(req, res); if (!auth) return;
17602
- var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
17688
+ var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
17603
17689
  // Backend validates: reject anything outside the allowed cadence
17604
17690
  // enum before composing the primitive.
17605
17691
  var freq = String((req.body || {}).frequency || "");
@@ -17616,6 +17702,11 @@ function mount(router, deps) {
17616
17702
 
17617
17703
  router.post("/account/subscriptions/:id/reactivate", async function (req, res) {
17618
17704
  var auth = _subsAuth(req, res); if (!auth) return;
17705
+ // Reactivate is the recovery path for a cancelled subscription,
17706
+ // so it is NOT gated on the terminal-status guard (a Stripe-
17707
+ // canceled status is the normal state of a row being
17708
+ // reactivated). The primitive enforces the cancelled-state +
17709
+ // grace-window FSM.
17619
17710
  var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
17620
17711
  try {
17621
17712
  await subControls.reactivate({ subscription_id: sub.id, reason: "customer self-service reactivate", actor: SELF_ACTOR });
@@ -19902,7 +19993,22 @@ function mount(router, deps) {
19902
19993
 
19903
19994
  router.post("/unsubscribe", async function (req, res) {
19904
19995
  var body = req.body || {};
19905
- var token = typeof body.token === "string" ? body.token : "";
19996
+ // RFC 8058 one-click: the mail client POSTs to the EXACT URL in the
19997
+ // List-Unsubscribe header — token in the `?token=` query string —
19998
+ // with a `List-Unsubscribe=One-Click` form body and NOTHING else
19999
+ // (no token of its own). So the URL token is authoritative; the
20000
+ // body `token` is only the fallback the on-page confirm form POSTs
20001
+ // from its hidden field. Reading body.token alone (the prior shape)
20002
+ // meant a native one-click POST carried no token -> "not-found" ->
20003
+ // the recipient was never unsubscribed. Parse the token off req.url
20004
+ // (the router only populates req.query when a route declares a query
20005
+ // validator), then fall back to the confirm-form body field.
20006
+ var urlToken = "";
20007
+ try {
20008
+ var u = req.url ? new URL(req.url, "http://localhost") : null;
20009
+ if (u) urlToken = u.searchParams.get("token") || "";
20010
+ } catch (_eUrl) { urlToken = ""; }
20011
+ var token = urlToken || (typeof body.token === "string" ? body.token : "");
19906
20012
  var cartCount = 0;
19907
20013
  try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
19908
20014
  var outcome;
@@ -19910,6 +20016,9 @@ function mount(router, deps) {
19910
20016
  // `consumeUnsubscribeToken` returns a structured result (it does
19911
20017
  // not throw on a bad/missing token — it returns `{ ok:false,
19912
20018
  // error:"not-found" }`). An empty token is handled the same way.
20019
+ // It is single-use, so the one-click POST and a later confirm-form
20020
+ // POST of the same token are idempotent (the second reads
20021
+ // "already" — still a success page, no error).
19913
20022
  var result = await deps.newsletter.consumeUnsubscribeToken(token);
19914
20023
  outcome = _unsubscribeOutcome(result);
19915
20024
  } catch (e) {
@@ -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