@blamejs/blamejs-shop 0.2.19 → 0.2.20

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
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.2.x
10
10
 
11
+ - v0.2.20 (2026-05-29) — **Manage a subscription yourself — pause, skip, change, or reactivate.** The subscriptions account screen previously only let a customer cancel. It now exposes the full self-service set: pause and resume, skip the next shipment, change the quantity, change the delivery frequency, and reactivate a recently-cancelled subscription within its grace window. Each action is state-gated (only the controls that apply to a subscription's current state are shown), confirm-aware where it changes billing, and reports back with a status notice. No more cancelling just to make a change. **Added:** *Subscription self-management* — `/account/subscriptions` gains Pause / Resume, Skip next shipment, Change quantity, Change frequency, and Reactivate (within the grace window) alongside Cancel. Controls are shown only when valid for the subscription's state; quantity and frequency are validated server-side; pause and cancel go through a confirmation step.
12
+
11
13
  - v0.2.19 (2026-05-29) — **Search results render the same product cards as the rest of the store.** A product with no image showed an outdated, text-only card on the search-results page when that page was served from the edge, while the home and category grids (and the origin-rendered search page) used the current placeholder-illustration card. The edge search page now emits the same card markup as every other grid, so a no-image product looks identical wherever it appears. A render-level parity check now pins the search page's markup byte-for-byte across the edge and origin paths so this kind of drift is caught automatically. **Fixed:** *Consistent no-image product card on search* — The edge-rendered search results page used a stale text-only card for products without a hero image; it now renders the placeholder-illustration card used across the rest of the storefront, byte-identical to the origin render.
12
14
 
13
15
  - v0.2.18 (2026-05-29) — **Admin: a clear "payments aren't live" warning, and void a gift card.** Two admin-console fixes. The landing page now warns that payments aren't live whenever Stripe isn't configured — independently of the setup wizard — so an operator who finishes the identity wizard isn't misled into thinking customers can check out when they still can't. And gift cards can now be voided from the console: the card detail screen gets a confirm-gated Void action for active cards, composing the existing void capability. **Added:** *Gift-card void* — The gift-card detail screen shows a confirm-gated "Void card" action for active cards. Voiding goes through a confirmation step and is content-negotiated like the rest of the console (a bearer-token client gets the voided card as JSON; already-redeemed cards are refused). **Changed:** *Payment-readiness banner on the admin landing* — The admin landing shows a "Payments aren't live yet — customers can't check out. See Integrations" banner whenever Stripe isn't enabled, gated on live payment readiness rather than on whether the setup wizard was marked complete. The existing not-set-up identity banner is unchanged; both can appear together.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.2.19",
2
+ "version": "0.2.20",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/lib/storefront.js CHANGED
@@ -3156,16 +3156,83 @@ function _subscriptionIsCancelable(sub) {
3156
3156
  return CANCELABLE_SUB_STATUSES.indexOf(sub.status) !== -1 && Number(sub.cancel_at_period_end) !== 1;
3157
3157
  }
3158
3158
 
3159
+ // Local control-state, derived the same way subscriptionControls derives
3160
+ // it — `cancelled_at` set ⇒ cancelled, else `paused_at` set ⇒ paused,
3161
+ // else active. This is the lifecycle the self-manage controls act on; it
3162
+ // rides alongside Stripe's `status` (the billing mirror), which the
3163
+ // controls primitive never writes.
3164
+ function _subscriptionControlState(sub) {
3165
+ if (sub.cancelled_at != null) return "cancelled";
3166
+ if (sub.paused_at != null) return "paused";
3167
+ return "active";
3168
+ }
3169
+
3170
+ // The 90-day reactivation grace mirrors subscriptionControls.REACTIVATE_
3171
+ // GRACE_MS. A cancelled row is reactivatable from the storefront while
3172
+ // the cancellation is inside that window; past it, the primitive refuses
3173
+ // and the customer must re-subscribe.
3174
+ var REACTIVATE_GRACE_MS = b.constants.TIME.days(90);
3175
+ function _subscriptionIsReactivatable(sub) {
3176
+ return sub.cancelled_at != null && (Date.now() - Number(sub.cancelled_at)) <= REACTIVATE_GRACE_MS;
3177
+ }
3178
+
3179
+ // The cadence-change <select> options — mirrors subscriptionControls.
3180
+ // FREQUENCIES. Pre-selects the row's current frequency when set.
3181
+ var SUB_FREQUENCIES = ["weekly", "biweekly", "monthly", "quarterly", "semiannual", "annual"];
3182
+ function _frequencyOptions(current) {
3183
+ var esc = b.template.escapeHtml;
3184
+ var out = "";
3185
+ for (var i = 0; i < SUB_FREQUENCIES.length; i += 1) {
3186
+ var f = SUB_FREQUENCIES[i];
3187
+ out += "<option value=\"" + f + "\"" + (f === current ? " selected" : "") + ">" + esc(f) + "</option>";
3188
+ }
3189
+ return out;
3190
+ }
3191
+
3192
+ // Map the ?ok=<kind> self-manage redirect marker to confirmation copy
3193
+ // rendered (role="status") at the top of the list. Unknown markers
3194
+ // degrade to no notice so a forged query can't inject copy. The legacy
3195
+ // ?canceled=1 marker keeps its own message (set by the route).
3196
+ var SUBSCRIPTION_OK = {
3197
+ paused: "Your subscription is paused.",
3198
+ resumed: "Your subscription has resumed.",
3199
+ skipped: "Your next shipment has been skipped.",
3200
+ quantity: "Quantity updated.",
3201
+ frequency: "Delivery frequency updated.",
3202
+ reactivated: "Your subscription has been reactivated.",
3203
+ };
3204
+ function _subscriptionOkCopy(kind) {
3205
+ return Object.prototype.hasOwnProperty.call(SUBSCRIPTION_OK, kind) ? SUBSCRIPTION_OK[kind] : null;
3206
+ }
3207
+
3208
+ // Self-manage failures round-trip a fixed ?error=<code> so the list can
3209
+ // echo a human message without reflecting an attacker-controlled string.
3210
+ // Unknown codes → no notice.
3211
+ var SUBSCRIPTION_ERR = {
3212
+ quantity: "Enter a quantity of 1 or more.",
3213
+ frequency: "Choose a valid delivery frequency.",
3214
+ state: "That change isn't available for this subscription right now.",
3215
+ grace: "This subscription was cancelled too long ago to reactivate. Start a new one instead.",
3216
+ };
3217
+ function _subscriptionErrorCopy(code) {
3218
+ if (code == null) return null;
3219
+ return Object.prototype.hasOwnProperty.call(SUBSCRIPTION_ERR, code) ? SUBSCRIPTION_ERR[code] : null;
3220
+ }
3221
+
3159
3222
  // Customer-facing subscription list. `opts.subscriptions` is an array of
3160
3223
  // subscription rows, each optionally carrying a resolved `plan` (joined
3161
3224
  // by the route). `opts.can_cancel` is false when the deploy has no
3162
3225
  // payment handle wired — the list renders read-only with a note, since
3163
- // cancel composes Stripe. Empty state points at the catalog (creation is
3164
- // a separate Stripe-subscription-checkout surface, not built here).
3226
+ // cancel composes Stripe. `opts.self_manage` adds the pause / resume /
3227
+ // skip / change-quantity / change-frequency / reactivate controls (wired
3228
+ // only when the subscriptionControls primitive is available). Empty state
3229
+ // points at the catalog (creation is a separate Stripe-subscription-
3230
+ // checkout surface, not built here).
3165
3231
  function renderAccountSubscriptions(opts) {
3166
3232
  var esc = b.template.escapeHtml;
3167
3233
  var subs = opts.subscriptions || [];
3168
3234
  var canCancel = opts.can_cancel !== false;
3235
+ var selfManage = opts.self_manage === true;
3169
3236
  var rowsHtml = "";
3170
3237
  for (var i = 0; i < subs.length; i += 1) {
3171
3238
  var s = subs[i];
@@ -3196,6 +3263,20 @@ function renderAccountSubscriptions(opts) {
3196
3263
  } else if (periodEnd) {
3197
3264
  renewalNote = "Renews <time datetime=\"" + esc(periodEnd) + "\">" + esc(periodEnd) + "</time>";
3198
3265
  }
3266
+ var controlState = _subscriptionControlState(s);
3267
+ // Local-state meta line (paused / cancelled) so the customer sees the
3268
+ // self-managed cadence state distinct from the Stripe billing pill.
3269
+ var stateNote = "";
3270
+ if (selfManage && controlState === "paused") {
3271
+ if (s.paused_until != null) {
3272
+ var pUntil = new Date(Number(s.paused_until)).toISOString().slice(0, 10);
3273
+ stateNote = "Paused until <time datetime=\"" + esc(pUntil) + "\">" + esc(pUntil) + "</time>";
3274
+ } else {
3275
+ stateNote = "Paused";
3276
+ }
3277
+ } else if (selfManage && controlState === "cancelled") {
3278
+ stateNote = "Cancelled";
3279
+ }
3199
3280
  var cancelControl = "";
3200
3281
  if (canCancel && _subscriptionIsCancelable(s)) {
3201
3282
  // The cancel decision (and its "immediate vs. at period end"
@@ -3205,6 +3286,52 @@ function renderAccountSubscriptions(opts) {
3205
3286
  cancelControl =
3206
3287
  "<a class=\"btn-ghost btn-ghost--sm subscription-card__cancel-link\" href=\"/account/subscriptions/" + esc(s.id) + "/cancel\">Cancel subscription</a>";
3207
3288
  }
3289
+ // Self-manage controls — state-gated, mirroring the primitive's FSM
3290
+ // (active ⇒ pause / skip / change-qty / change-freq; paused ⇒ resume;
3291
+ // cancelled-within-grace ⇒ reactivate). Pause is confirm-gated like
3292
+ // cancel (a second server-rendered screen, no inline confirm()); the
3293
+ // reversible controls post directly. Quantity / frequency take input
3294
+ // validated server-side.
3295
+ var manageControls = "";
3296
+ if (selfManage) {
3297
+ var actId = esc(s.id);
3298
+ var ctrls = "";
3299
+ if (controlState === "active") {
3300
+ ctrls +=
3301
+ "<a class=\"btn-ghost btn-ghost--sm\" href=\"/account/subscriptions/" + actId + "/pause\">Pause</a>";
3302
+ ctrls +=
3303
+ "<form class=\"subscription-card__control\" method=\"post\" action=\"/account/subscriptions/" + actId + "/skip\">" +
3304
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Skip next shipment</button>" +
3305
+ "</form>";
3306
+ ctrls +=
3307
+ "<form class=\"subscription-card__control subscription-card__control--qty\" method=\"post\" action=\"/account/subscriptions/" + actId + "/quantity\">" +
3308
+ "<label class=\"form-field form-field--inline\">" +
3309
+ "<span class=\"form-field__label\">Quantity</span>" +
3310
+ "<input type=\"number\" name=\"quantity\" min=\"1\" step=\"1\" value=\"" + esc(String(s.quantity == null ? 1 : s.quantity)) + "\" required>" +
3311
+ "</label>" +
3312
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Update quantity</button>" +
3313
+ "</form>";
3314
+ ctrls +=
3315
+ "<form class=\"subscription-card__control subscription-card__control--freq\" method=\"post\" action=\"/account/subscriptions/" + actId + "/frequency\">" +
3316
+ "<label class=\"form-field form-field--inline\">" +
3317
+ "<span class=\"form-field__label\">Frequency</span>" +
3318
+ "<select name=\"frequency\" required>" + _frequencyOptions(s.frequency || null) + "</select>" +
3319
+ "</label>" +
3320
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Update frequency</button>" +
3321
+ "</form>";
3322
+ } else if (controlState === "paused") {
3323
+ ctrls +=
3324
+ "<form class=\"subscription-card__control\" method=\"post\" action=\"/account/subscriptions/" + actId + "/resume\">" +
3325
+ "<button type=\"submit\" class=\"btn-primary btn-primary--sm\">Resume</button>" +
3326
+ "</form>";
3327
+ } else if (controlState === "cancelled" && _subscriptionIsReactivatable(s)) {
3328
+ ctrls +=
3329
+ "<form class=\"subscription-card__control\" method=\"post\" action=\"/account/subscriptions/" + actId + "/reactivate\">" +
3330
+ "<button type=\"submit\" class=\"btn-primary btn-primary--sm\">Reactivate</button>" +
3331
+ "</form>";
3332
+ }
3333
+ if (ctrls) manageControls = "<div class=\"subscription-card__manage\">" + ctrls + "</div>";
3334
+ }
3208
3335
  rowsHtml +=
3209
3336
  "<li class=\"subscription-card\">" +
3210
3337
  "<div class=\"subscription-card__head\">" +
@@ -3212,6 +3339,8 @@ function renderAccountSubscriptions(opts) {
3212
3339
  _subscriptionStatusBadge(s.status) +
3213
3340
  "</div>" +
3214
3341
  (renewalNote ? "<p class=\"subscription-card__meta\">" + renewalNote + "</p>" : "") +
3342
+ (stateNote ? "<p class=\"subscription-card__state\">" + stateNote + "</p>" : "") +
3343
+ manageControls +
3215
3344
  cancelControl +
3216
3345
  "</li>";
3217
3346
  }
@@ -3225,6 +3354,15 @@ function renderAccountSubscriptions(opts) {
3225
3354
  "<p class=\"account-empty__lede\">You have no active subscriptions.</p>" +
3226
3355
  "<a class=\"btn-secondary\" href=\"/\">Browse the shop →</a>" +
3227
3356
  "</div>";
3357
+ // The notice is either the success copy from a self-manage ?ok=<kind>
3358
+ // round-trip (role="status") or an error string the POST handler passes
3359
+ // back (role="alert"). The legacy cancel notice arrives as opts.notice.
3360
+ var noticeHtml = "";
3361
+ if (opts.error) {
3362
+ noticeHtml = "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.error)) + "</p>";
3363
+ } else if (opts.notice) {
3364
+ noticeHtml = "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(String(opts.notice)) + "</p>";
3365
+ }
3228
3366
  var body =
3229
3367
  "<section class=\"account-subscriptions\">" +
3230
3368
  "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
@@ -3232,7 +3370,7 @@ function renderAccountSubscriptions(opts) {
3232
3370
  "<li aria-current=\"page\">Subscriptions</li>" +
3233
3371
  "</ol></nav>" +
3234
3372
  "<h1 class=\"account-subscriptions__title\">Subscriptions</h1>" +
3235
- (opts.notice ? "<p class=\"form-notice\" role=\"status\">" + esc(String(opts.notice)) + "</p>" : "") +
3373
+ noticeHtml +
3236
3374
  inner +
3237
3375
  "</section>";
3238
3376
  return _wrap({
@@ -3317,6 +3455,52 @@ function renderSubscriptionCancelConfirm(opts) {
3317
3455
  });
3318
3456
  }
3319
3457
 
3458
+ // Server-rendered confirmation step for pausing a subscription. Pause is
3459
+ // a reversible-but-deliberate state change, so it gets the same confirm-
3460
+ // GET → POST gate the cancel flow uses (CSP forbids an inline confirm()).
3461
+ // The form posts straight back to /pause; a "Keep it active" link returns
3462
+ // to the list. JS-off-native.
3463
+ function renderSubscriptionPauseConfirm(opts) {
3464
+ var esc = b.template.escapeHtml;
3465
+ var s = opts.subscription || {};
3466
+ var plan = s.plan || null;
3467
+ var planSummary = "this subscription";
3468
+ if (plan) {
3469
+ var ccy = String(plan.currency || "usd").toUpperCase();
3470
+ var every = Number(plan.interval_count) > 1
3471
+ ? "every " + Number(plan.interval_count) + " " + String(plan.interval) + "s"
3472
+ : "per " + String(plan.interval);
3473
+ var priceStr = "";
3474
+ try { priceStr = pricing.format(Number(plan.amount_minor), ccy) + " "; } catch (_e) { priceStr = ""; }
3475
+ planSummary = priceStr + every;
3476
+ }
3477
+ var pauseForm =
3478
+ "<form method=\"post\" action=\"/account/subscriptions/" + esc(s.id) + "/pause\">" +
3479
+ "<p class=\"account-confirm__option\">Pausing holds your deliveries until you resume. No shipments go out and you won't be billed for the paused period; resume any time to pick back up.</p>" +
3480
+ "<button type=\"submit\" class=\"btn-primary\">Pause subscription</button>" +
3481
+ "</form>";
3482
+ var body =
3483
+ "<section class=\"account-confirm\">" +
3484
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
3485
+ "<li><a href=\"/account\">Account</a></li>" +
3486
+ "<li><a href=\"/account/subscriptions\">Subscriptions</a></li>" +
3487
+ "<li aria-current=\"page\">Pause</li>" +
3488
+ "</ol></nav>" +
3489
+ "<h1 class=\"account-confirm__title\">Pause " + esc(planSummary) + "?</h1>" +
3490
+ "<div class=\"account-confirm__actions\">" +
3491
+ pauseForm +
3492
+ "<a class=\"btn-ghost\" href=\"/account/subscriptions\">Keep it active</a>" +
3493
+ "</div>" +
3494
+ "</section>";
3495
+ return _wrap({
3496
+ title: "Pause subscription",
3497
+ shop_name: opts.shop_name || "blamejs.shop",
3498
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
3499
+ theme_css: opts.theme_css,
3500
+ body: body,
3501
+ });
3502
+ }
3503
+
3320
3504
  // Product-level "Save to wishlist" control + social-proof count.
3321
3505
  // Byte-compatible with the edge renderer (`worker/render/product.js`)
3322
3506
  // so both paths emit identical markup. Action-only label — the toggle
@@ -8637,6 +8821,13 @@ function mount(router, deps) {
8637
8821
  // flow) is a separate surface; this is the management view.
8638
8822
  if (subscriptions) {
8639
8823
  var subsCanCancel = !!deps.payment;
8824
+ // Lifecycle controls (pause / resume / skip / change-qty / change-
8825
+ // freq / reactivate) mount only when the subscriptionControls
8826
+ // primitive is wired. Independent of Stripe — the controls write
8827
+ // local columns on the subscription row, not the upstream billing
8828
+ // state — so they're available even on a deploy with no payment
8829
+ // handle. The list above stays a read-only view when this is absent.
8830
+ var subControls = deps.subscriptionControls || null;
8640
8831
 
8641
8832
  function _subsAuth(req, res) {
8642
8833
  var auth;
@@ -8702,15 +8893,159 @@ function mount(router, deps) {
8702
8893
  var cartCount = await _cartCountForReq(req);
8703
8894
  var url = req.url ? new URL(req.url, "http://localhost") : null;
8704
8895
  var canceled = !!(url && url.searchParams.get("canceled"));
8896
+ // Success copy comes from either the legacy ?canceled=1 marker or
8897
+ // a self-manage ?ok=<kind> round-trip; an ?error message a control
8898
+ // POST bounces back surfaces as an alert. Unknown ?ok keys → no
8899
+ // notice (a forged query can't inject copy).
8900
+ var okKind = url ? url.searchParams.get("ok") : null;
8901
+ var notice = canceled
8902
+ ? "Your subscription has been canceled."
8903
+ : _subscriptionOkCopy(okKind);
8904
+ var errKind = url ? url.searchParams.get("error") : null;
8705
8905
  _send(res, 200, renderAccountSubscriptions({
8706
8906
  subscriptions: rows,
8707
8907
  can_cancel: subsCanCancel,
8708
- notice: canceled ? "Your subscription has been canceled." : null,
8908
+ self_manage: !!subControls,
8909
+ notice: notice,
8910
+ error: _subscriptionErrorCopy(errKind),
8709
8911
  shop_name: shopName,
8710
8912
  cart_count: cartCount,
8711
8913
  }));
8712
8914
  });
8713
8915
 
8916
+ // Self-manage lifecycle — pause / resume / skip / change-quantity /
8917
+ // change-frequency / reactivate. Each route owns its subscription
8918
+ // via the same ownership check the cancel flow uses (a forged/foreign
8919
+ // id 404s before any write), then composes the matching control
8920
+ // method with a `customer` actor. State-machine refusals from the
8921
+ // primitive (e.g. pause a cancelled sub) and shape errors bounce back
8922
+ // to the list with a fixed ?error code rather than 500-ing. Wired
8923
+ // independently of Stripe (the controls write local columns).
8924
+ if (subControls) {
8925
+ var SELF_ACTOR = { actor_type: "customer", actor_id: null };
8926
+
8927
+ // Translate a control-method rejection into the list redirect.
8928
+ // Validation TypeErrors and FSM/grace refusals (which carry a
8929
+ // `code`) map to a fixed ?error; anything else rethrows (a real
8930
+ // 500, e.g. an unmigrated table on a misconfigured deploy).
8931
+ function _controlError(e) {
8932
+ if (e && e.code === "SUBSCRIPTION_REACTIVATE_GRACE_EXPIRED") return "grace";
8933
+ if (e && e.code === "SUBSCRIPTION_STATE_REFUSED") return "state";
8934
+ if (e && e.code === "SUBSCRIPTION_NOT_FOUND") return "state";
8935
+ if (e instanceof TypeError) return "state";
8936
+ return null;
8937
+ }
8938
+ function _redirect(res, suffix) {
8939
+ res.status(303);
8940
+ res.setHeader && res.setHeader("location", "/account/subscriptions" + suffix);
8941
+ return res.end ? res.end() : res.send("");
8942
+ }
8943
+
8944
+ // Pause is confirm-gated (GET → POST), mirroring cancel — a
8945
+ // deliberate, reversible hold. The confirm page only renders for a
8946
+ // currently-active subscription; a paused/cancelled row bounces
8947
+ // back to the list.
8948
+ router.get("/account/subscriptions/:id/pause", async function (req, res) {
8949
+ var auth = _subsAuth(req, res); if (!auth) return;
8950
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
8951
+ if (_subscriptionControlState(sub) !== "active") return _redirect(res, "");
8952
+ if (sub.plan_id != null) {
8953
+ try { sub.plan = await subscriptions.plans.get(sub.plan_id); }
8954
+ catch (_e) { sub.plan = null; }
8955
+ }
8956
+ var cartCount = await _cartCountForReq(req);
8957
+ _send(res, 200, renderSubscriptionPauseConfirm({
8958
+ subscription: sub,
8959
+ shop_name: shopName,
8960
+ cart_count: cartCount,
8961
+ }));
8962
+ });
8963
+
8964
+ router.post("/account/subscriptions/:id/pause", async function (req, res) {
8965
+ var auth = _subsAuth(req, res); if (!auth) return;
8966
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
8967
+ try {
8968
+ await subControls.pause({ subscription_id: sub.id, reason: "customer self-service pause", actor: SELF_ACTOR });
8969
+ } catch (e) {
8970
+ var code = _controlError(e); if (code == null) throw e;
8971
+ return _redirect(res, "?error=" + code);
8972
+ }
8973
+ return _redirect(res, "?ok=paused");
8974
+ });
8975
+
8976
+ router.post("/account/subscriptions/:id/resume", async function (req, res) {
8977
+ var auth = _subsAuth(req, res); if (!auth) return;
8978
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
8979
+ try {
8980
+ await subControls.resume({ subscription_id: sub.id, reason: "customer self-service resume", actor: SELF_ACTOR });
8981
+ } catch (e) {
8982
+ var code = _controlError(e); if (code == null) throw e;
8983
+ return _redirect(res, "?error=" + code);
8984
+ }
8985
+ return _redirect(res, "?ok=resumed");
8986
+ });
8987
+
8988
+ router.post("/account/subscriptions/:id/skip", async function (req, res) {
8989
+ var auth = _subsAuth(req, res); if (!auth) return;
8990
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
8991
+ try {
8992
+ await subControls.skipNext({ subscription_id: sub.id, count: 1, reason: "customer self-service skip", actor: SELF_ACTOR });
8993
+ } catch (e) {
8994
+ var code = _controlError(e); if (code == null) throw e;
8995
+ return _redirect(res, "?error=" + code);
8996
+ }
8997
+ return _redirect(res, "?ok=skipped");
8998
+ });
8999
+
9000
+ router.post("/account/subscriptions/:id/quantity", async function (req, res) {
9001
+ var auth = _subsAuth(req, res); if (!auth) return;
9002
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
9003
+ // Backend validates: a non-positive / non-integer / missing value
9004
+ // is a client error → bounce with the quantity error code rather
9005
+ // than handing garbage to the primitive (which would throw a
9006
+ // TypeError mapped to a generic "state" message).
9007
+ var qty = parseInt(String((req.body || {}).quantity), 10);
9008
+ if (!Number.isInteger(qty) || qty <= 0) return _redirect(res, "?error=quantity");
9009
+ try {
9010
+ await subControls.changeQuantity({ subscription_id: sub.id, new_quantity: qty, reason: "customer self-service quantity change", actor: SELF_ACTOR });
9011
+ } catch (e) {
9012
+ if (e instanceof TypeError) return _redirect(res, "?error=quantity");
9013
+ var code = _controlError(e); if (code == null) throw e;
9014
+ return _redirect(res, "?error=" + code);
9015
+ }
9016
+ return _redirect(res, "?ok=quantity");
9017
+ });
9018
+
9019
+ router.post("/account/subscriptions/:id/frequency", async function (req, res) {
9020
+ var auth = _subsAuth(req, res); if (!auth) return;
9021
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
9022
+ // Backend validates: reject anything outside the allowed cadence
9023
+ // enum before composing the primitive.
9024
+ var freq = String((req.body || {}).frequency || "");
9025
+ if (SUB_FREQUENCIES.indexOf(freq) === -1) return _redirect(res, "?error=frequency");
9026
+ try {
9027
+ await subControls.changeFrequency({ subscription_id: sub.id, new_frequency: freq, reason: "customer self-service frequency change", actor: SELF_ACTOR });
9028
+ } catch (e) {
9029
+ if (e instanceof TypeError) return _redirect(res, "?error=frequency");
9030
+ var code = _controlError(e); if (code == null) throw e;
9031
+ return _redirect(res, "?error=" + code);
9032
+ }
9033
+ return _redirect(res, "?ok=frequency");
9034
+ });
9035
+
9036
+ router.post("/account/subscriptions/:id/reactivate", async function (req, res) {
9037
+ var auth = _subsAuth(req, res); if (!auth) return;
9038
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
9039
+ try {
9040
+ await subControls.reactivate({ subscription_id: sub.id, reason: "customer self-service reactivate", actor: SELF_ACTOR });
9041
+ } catch (e) {
9042
+ var code = _controlError(e); if (code == null) throw e;
9043
+ return _redirect(res, "?error=" + code);
9044
+ }
9045
+ return _redirect(res, "?ok=reactivated");
9046
+ });
9047
+ }
9048
+
8714
9049
  // Cancel mounts only when payment is wired (cancel composes Stripe).
8715
9050
  // Without payment the list above stays read-only with a note.
8716
9051
  if (deps.payment) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.2.19",
3
+ "version": "0.2.20",
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": {