@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.
package/lib/loyalty.js CHANGED
@@ -228,6 +228,25 @@ function create(opts) {
228
228
  );
229
229
  }
230
230
 
231
+ // SQL fragment that derives the tier from a lifetime-points
232
+ // expression evaluated INSIDE the UPDATE statement, so balance,
233
+ // lifetime, and tier all move in one atomic write off the row's
234
+ // live value rather than a stale snapshot. `lifetimeExpr` is the
235
+ // post-mutation lifetime SQL (e.g. `lifetime_points + ?2`). The
236
+ // operator-tunable thresholds bind as literals — they're validated
237
+ // non-negative integers at factory time, never operator input here,
238
+ // so inlining them keeps the CASE a single self-contained
239
+ // expression without widening the bound-parameter list per call.
240
+ // Mirrors computeTier's highest-threshold-first ladder so the SQL
241
+ // and JS classifications never diverge.
242
+ function _tierCase(lifetimeExpr) {
243
+ return "CASE" +
244
+ " WHEN (" + lifetimeExpr + ") >= " + thresholds.platinum + " THEN 'platinum'" +
245
+ " WHEN (" + lifetimeExpr + ") >= " + thresholds.gold + " THEN 'gold'" +
246
+ " WHEN (" + lifetimeExpr + ") >= " + thresholds.silver + " THEN 'silver'" +
247
+ " ELSE 'bronze' END";
248
+ }
249
+
231
250
  return {
232
251
  TIERS: TIERS.slice(),
233
252
  TX_TYPES: TX_TYPES.slice(),
@@ -260,24 +279,27 @@ function create(opts) {
260
279
 
261
280
  var ts = _now();
262
281
  await _ensureAccountRow(customerId, ts);
282
+ // Snapshot the tier ONLY to report `tier_changed` — the balance /
283
+ // lifetime / tier mutation itself is relative-atomic below, so a
284
+ // concurrent earn can't clobber this credit (the lost-update the
285
+ // absolute write suffered). The tier is recomputed in-SQL off the
286
+ // row's live `lifetime_points`, not this stale snapshot.
263
287
  var before = await _readAccount(customerId);
264
- var newLifetime = before.lifetime_points + points;
265
- var newBalance = before.balance_points + points;
266
- var newTier = computeTier(newLifetime);
267
- var tierChanged = newTier !== before.tier;
268
-
269
288
  await query(
270
- "UPDATE loyalty_accounts SET balance_points = ?1, lifetime_points = ?2, " +
271
- "tier = ?3, updated_at = ?4 WHERE customer_id = ?5",
272
- [newBalance, newLifetime, newTier, ts, customerId],
289
+ "UPDATE loyalty_accounts SET balance_points = balance_points + ?1, " +
290
+ "lifetime_points = lifetime_points + ?1, " +
291
+ "tier = " + _tierCase("lifetime_points + ?1") + ", " +
292
+ "updated_at = ?2 WHERE customer_id = ?3",
293
+ [points, ts, customerId],
273
294
  );
274
295
  await _writeTx(customerId, "earn", points, source, orderId, notes, ts);
275
296
 
297
+ var after = await _readAccount(customerId);
276
298
  return {
277
- balance: newBalance,
278
- lifetime: newLifetime,
279
- tier: newTier,
280
- tier_changed: tierChanged,
299
+ balance: after.balance_points,
300
+ lifetime: after.lifetime_points,
301
+ tier: after.tier,
302
+ tier_changed: after.tier !== before.tier,
281
303
  };
282
304
  },
283
305
 
@@ -335,34 +357,45 @@ function create(opts) {
335
357
 
336
358
  var ts = _now();
337
359
  await _ensureAccountRow(customerId, ts);
360
+ // Snapshot the pre-adjust tier only to report `tier_changed`. The
361
+ // mutation is relative-atomic with an underflow guard at the SQL
362
+ // tier so two concurrent adjustments can't lose an update or drive
363
+ // the balance negative past each other.
338
364
  var before = await _readAccount(customerId);
339
365
 
340
- var newBalance = before.balance_points + delta;
341
- if (newBalance < 0) {
342
- var ins = new Error("loyalty.adjust: adjustment would underflow balance");
343
- ins.code = "LOYALTY_INSUFFICIENT_BALANCE";
344
- throw ins;
345
- }
346
366
  // Positive adjustments also increment lifetime — operators
347
367
  // crediting a customer for a service recovery should see that
348
368
  // credit count toward tier. Negative adjustments do NOT
349
369
  // decrement lifetime (otherwise a clawback could downgrade tier
350
- // retroactively, which is a customer-facing surprise).
351
- var newLifetime = delta > 0 ? before.lifetime_points + delta : before.lifetime_points;
352
- var newTier = computeTier(newLifetime);
353
-
354
- await query(
355
- "UPDATE loyalty_accounts SET balance_points = ?1, lifetime_points = ?2, " +
356
- "tier = ?3, updated_at = ?4 WHERE customer_id = ?5",
357
- [newBalance, newLifetime, newTier, ts, customerId],
370
+ // retroactively, which is a customer-facing surprise). The
371
+ // lifetime delta is therefore the positive part of `delta`.
372
+ var lifetimeDelta = delta > 0 ? delta : 0;
373
+ // Conditional UPDATE: the row mutates ONLY when the post-adjust
374
+ // balance stays non-negative, checked against the row's LIVE
375
+ // balance (not the stale snapshot). A racing concurrent adjust
376
+ // that already spent the balance makes this match zero rows, so
377
+ // we surface the same insufficient-balance refusal rather than
378
+ // writing a ledger row that diverges from the account.
379
+ var upd = await query(
380
+ "UPDATE loyalty_accounts SET balance_points = balance_points + ?1, " +
381
+ "lifetime_points = lifetime_points + ?2, " +
382
+ "tier = " + _tierCase("lifetime_points + ?2") + ", " +
383
+ "updated_at = ?3 WHERE customer_id = ?4 AND balance_points + ?1 >= 0",
384
+ [delta, lifetimeDelta, ts, customerId],
358
385
  );
386
+ if (Number(upd.rowCount || 0) === 0) {
387
+ var ins = new Error("loyalty.adjust: adjustment would underflow balance");
388
+ ins.code = "LOYALTY_INSUFFICIENT_BALANCE";
389
+ throw ins;
390
+ }
359
391
  await _writeTx(customerId, "adjust", delta, source, null, notes, ts);
360
392
 
393
+ var after = await _readAccount(customerId);
361
394
  return {
362
- balance: newBalance,
363
- lifetime: newLifetime,
364
- tier: newTier,
365
- tier_changed: newTier !== before.tier,
395
+ balance: after.balance_points,
396
+ lifetime: after.lifetime_points,
397
+ tier: after.tier,
398
+ tier_changed: after.tier !== before.tier,
366
399
  };
367
400
  },
368
401
 
package/lib/order.js CHANGED
@@ -587,6 +587,30 @@ function create(opts) {
587
587
  if (result.to === "cancelled" || result.to === "refunded") {
588
588
  await _settleGiftCards(orderId);
589
589
  }
590
+ // Loyalty earn-reversal fan-out — fire-and-forget, same discipline
591
+ // as the earn-on-purchase block below. On a cancel / refund edge for
592
+ // an order carrying a customer_id, the points awarded when the order
593
+ // went paid are clawed back off the balance (floored at zero), or a
594
+ // buy-then-refund mints free rewards. reverseForEvent claims the
595
+ // earn-log rows with an unreversed predicate, so it is idempotent (a
596
+ // re-delivered cancel webhook or the reaper racing a refund reverses
597
+ // exactly once) and a natural no-op for an order that never earned
598
+ // (a guest order, or one that never reached paid — the never-awarded
599
+ // earn-log is empty). The award is detached so a loyalty failure
600
+ // lives in the loyalty ledger's own audit trail, never as an
601
+ // unhandledRejection and never on the transition's latency.
602
+ if (loyaltyEarnRules && typeof loyaltyEarnRules.reverseForEvent === "function"
603
+ && (result.to === "cancelled" || result.to === "refunded")
604
+ && refreshed && refreshed.customer_id) {
605
+ var _revCustomer = refreshed.customer_id;
606
+ var _revOrderId = refreshed.id;
607
+ Promise.resolve().then(function () {
608
+ return loyaltyEarnRules.reverseForEvent({
609
+ customer_id: _revCustomer,
610
+ trigger_event_ref: "order:" + _revOrderId,
611
+ });
612
+ }).catch(function () { /* drop-silent — loyalty ledger holds its own audit trail */ });
613
+ }
590
614
  // Fan-out to merchant webhook subscribers is fire-and-forget. The
591
615
  // transition has already persisted; the request must not wait on
592
616
  // outbound HTTP, or a slow / unreachable endpoint would block the
@@ -700,6 +700,48 @@ function create(opts) {
700
700
  if (input.session_id != null) {
701
701
  sessionHash = _hashSession(_sessionIdRaw(input.session_id, "searchRanking.recordSearchEvent"));
702
702
  }
703
+
704
+ // Server-side click attribution. A `click` carries a `?sq=<query>`
705
+ // marker from the result link, but the marker is attacker-
706
+ // controllable: anyone can hit a PDP with `?from=search&sq=dress`
707
+ // and inflate the click count for "dress" without ever having
708
+ // seen — let alone clicked through — a real result list. That let
709
+ // CTR be spoofed and pushed past 100% (more clicks than the
710
+ // impressions that were ever rendered). When the click carries a
711
+ // session, we only record it if THAT session already logged an
712
+ // `impression` for the same (weights_slug, query): the click must
713
+ // descend from a search the same session actually ran. An
714
+ // unattributed click (no matching impression for the session) is
715
+ // dropped — it never reaches the event log, so the rollup can't
716
+ // count it. Clicks without a session (a session-less worker
717
+ // deployment, or a click whose session cookie was lost) can't be
718
+ // attribution-checked, so they record as before; the storefront
719
+ // attaches the session whenever one exists, so the spoof path is
720
+ // closed in the deployed configuration.
721
+ if (eventType === "click" && sessionHash != null) {
722
+ var imp = await query(
723
+ "SELECT 1 FROM search_events " +
724
+ "WHERE weights_slug = ?1 AND query = ?2 AND session_id_hash = ?3 " +
725
+ "AND event_type = 'impression' LIMIT 1",
726
+ [weightsSlug, normalizedQuery, sessionHash]
727
+ );
728
+ if (!imp.rows.length) {
729
+ // No impression for this session + query under this weight
730
+ // set — the click can't be a genuine result-list click-
731
+ // through. Refuse to record it (drop, don't throw — the hot
732
+ // path swallows the result).
733
+ return {
734
+ query: normalizedQuery,
735
+ product_id: productId,
736
+ weights_slug: weightsSlug,
737
+ event_type: eventType,
738
+ position: position,
739
+ recorded: false,
740
+ reason: "click-without-matching-impression",
741
+ };
742
+ }
743
+ }
744
+
703
745
  var ts = _now();
704
746
  await query(
705
747
  "INSERT INTO search_events " +
@@ -714,6 +756,7 @@ function create(opts) {
714
756
  event_type: eventType,
715
757
  position: position,
716
758
  occurred_at: ts,
759
+ recorded: true,
717
760
  };
718
761
  },
719
762
 
@@ -757,6 +800,19 @@ function create(opts) {
757
800
  else if (row.event_type === "click") clicks = c;
758
801
  else if (row.event_type === "purchase") purchases = c;
759
802
  }
803
+ // A single rendered result list (one impression) can legitimately
804
+ // yield more than one click — the shopper opens a product, returns
805
+ // to the same list, opens another. So clicks/impressions can
806
+ // exceed 1.0 even on honest data; an unbounded ratio reads as a
807
+ // nonsensical ">100% CTR" on the operator dashboard. Bound the
808
+ // reported CTR at 1.0 so the metric stays interpretable (the raw
809
+ // counts remain available for an operator who wants the unbounded
810
+ // figure). conversion_rate is bounded the same way — purchases are
811
+ // gated by clicks which are gated by impressions, but the bound
812
+ // keeps the displayed rate honest under the same multi-click
813
+ // reality.
814
+ var rawCtr = impressions > 0 ? clicks / impressions : null;
815
+ var rawConv = impressions > 0 ? purchases / impressions : null;
760
816
  return {
761
817
  weights_slug: weightsSlug,
762
818
  from: from,
@@ -764,8 +820,8 @@ function create(opts) {
764
820
  impressions: impressions,
765
821
  clicks: clicks,
766
822
  purchases: purchases,
767
- ctr: impressions > 0 ? clicks / impressions : null,
768
- conversion_rate: impressions > 0 ? purchases / impressions : null,
823
+ ctr: rawCtr == null ? null : Math.min(1, rawCtr),
824
+ conversion_rate: rawConv == null ? null : Math.min(1, rawConv),
769
825
  click_to_purchase: clicks > 0 ? purchases / clicks : null,
770
826
  };
771
827
  },
@@ -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 });