@blamejs/blamejs-shop 0.5.4 → 0.5.5

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.5.x
10
10
 
11
+ - v0.5.5 (2026-06-27) — **Customers can change their subscription plan — proration-aware upgrades and downgrades from the account portal.** Adds a self-serve plan-change surface to the customer subscription portal. From /account/subscriptions a customer can switch an active subscription to any other plan in the same currency: the page previews the proration for each candidate plan and offers two timings. An immediate change applies now and prorates the rest of the current period — an upgrade is charged the prorated difference (recorded on the subscription invoice ledger), and a downgrade is issued the unused remainder as store credit. A next-billing-cycle change is queued and applied automatically when the period rolls over, with the new plan starting clean and no proration. The transition is exactly-once: the immediate path claims the plan switch atomically before recording the charge or credit, and the scheduled path claims each due change with a single guarded update, so a retry or an overlapping scheduler run cannot double-charge or double-credit. A downgrade that owes a credit is never applied without paying it — if the credit write fails the change rolls back and retries. The surface mounts automatically when subscriptions and store credit are composed (the default for a configured storefront); the scheduler runs on the existing cron, and the tables it uses already shipped, so there are no schema changes and no operator action is required. **Added:** *Self-serve subscription plan changes with proration* — Customers can change the plan on an active subscription from /account/subscriptions/:id/change. The page lists every plan in the subscription's currency (excluding the current and archived plans) with a proration preview, and offers an immediate switch or a switch at the next billing date. An immediate upgrade charges the prorated difference for the rest of the period and records it on the subscription invoice ledger; an immediate downgrade issues the unused remainder of the outgoing plan as store credit. A still-pending change is shown on the subscription with a one-click cancel. Cross-currency, same-plan, and archived-plan requests are refused server-side, and the surface enforces subscription ownership before any read or write. · *Scheduler applies next-billing-cycle changes automatically* — A change queued for the next billing cycle is applied without operator involvement: a minute-cadence tick flips every due change to its new plan, records the proration, and issues any owed credit. Each due change is claimed with a single guarded update so an overlapping or retried run cannot apply it twice, and a change that owes a credit is left queued rather than applied if no credit can be issued — the credit is never dropped. The scheduler is inert on a deploy without subscriptions or store credit composed. **Changed:** *Dunning step failures are logged with their cause* — When a dunning step (reminder send, retry-charge record, pause, or cancel) fails, the schedule still advances and the step is recorded as failed — but the underlying error is now written to the server log instead of being discarded, so an operator can see why a step failed rather than only that it did.
12
+
11
13
  - v0.5.4 (2026-06-27) — **Vendored framework refreshed to 0.15.38 — caller-supplied regexes are screened for ReDoS before they run on request data.** Updates the vendored blamejs framework to 0.15.38. The upstream release closes two ReDoS exposures and strengthens the framework's regex guard: a feature-flag targeting condition (`op: regex`) and an MCP tool's input-schema `pattern` each compiled a caller-supplied regex and matched it against request-controlled input behind only a length cap — which does not bound catastrophic backtracking, so a six-character pattern like `(a+)+$` could pin a CPU. Both now pass through `b.guardRegex` (strict) before compilation, and the nested-quantifier detector was hardened to also catch wrapped forms (`((a)+)+`, `(([a-z]+)*)*`) that previously slipped past — which strengthens every guardRegex caller. This storefront exposes neither MCP tools nor operator-authored regex flag rules on its request path, and its own patterns are static and ReDoS-clean, so there is no runtime or behavior change for it; the hardened framework rides along and no operator action is required. **Changed:** *Vendored framework refreshed to 0.15.38 (caller-supplied regexes screened for ReDoS)* — Refreshes the vendored blamejs framework to 0.15.38. The upstream fix screens an operator-supplied feature-flag targeting regex and an MCP tool input-schema `pattern` through `b.guardRegex` (strict) before they are compiled and matched against request input, so a catastrophic-backtracking shape is refused at validation time instead of pinning a CPU; the nested-quantifier detector also now catches wrapped forms at any group depth, strengthening every guardRegex caller. The shipped storefront does not expose those caller-supplied-regex surfaces and its own patterns are static, so its runtime, public API, and wire format are unchanged; the hardened framework simply rides along.
12
14
 
13
15
  - v0.5.3 (2026-06-27) — **Vendored framework refreshed to 0.15.37 — non-finite numeric options can no longer silently disable a security check.** Updates the vendored blamejs framework to 0.15.37. The upstream release closes a class of fail-open configuration bugs: several numeric options validated a value only as `typeof === number && >= 0`, which accepts `Infinity`. Where the value is a clock-skew tolerance or a resource cap, an `Infinity` (or `NaN`) silently turned off the very protection it tunes — an `Infinity` CWT / OCSP-staple / ARC clock-skew made the expiry and freshness comparisons unsatisfiable (so an expired token, a replayed pre-revocation OCSP `good` response, or an expired ARC seal would be accepted), and an `Infinity` WebSocket message/frame cap, inbox/flag-cache size cap, or audit-chain fan-out cap disabled the bound. These options now route through the framework's finite-bounds validator, refusing a non-finite value at the entry point (or falling back to the safe default), while options where unbounded is deliberate (reconnect-indefinitely, retain-indefinitely) still accept it. This storefront configures these options with finite values, so there is no runtime or behavior change for it; the hardened framework rides along and no operator action is required. **Changed:** *Vendored framework refreshed to 0.15.37 (non-finite option values rejected)* — Refreshes the vendored blamejs framework to 0.15.37. The upstream fix makes a clock-skew tolerance or resource cap of `Infinity` / `NaN` a rejected value rather than one that silently disables the check it configures — covering the CWT / OCSP-staple / ARC time checks, the WebSocket-client inbound size and handshake caps, and the inbox / flag-cache / audit-chain size and count caps, with deliberately-unbounded options (reconnect/retain indefinitely) unaffected. The shipped storefront passes finite values to these options, so its runtime, public API, and wire format are unchanged; the hardened framework simply rides along.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.5.4",
2
+ "version": "0.5.5",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
package/lib/dunning.js CHANGED
@@ -108,6 +108,11 @@ var b = require("./vendor/blamejs");
108
108
  // Framework constants (C.TIME duration helpers). Safe at module-eval —
109
109
  // the index entry point exposes `framework` before the require cascade.
110
110
  var C = b.constants;
111
+ // Observability sink for best-effort step failures: a dunning step that
112
+ // throws still returns the "failed" status so the schedule advances, but
113
+ // the CAUSE is logged here rather than discarded — a swallowed exception
114
+ // is a bug deferred to a later patch, not a clean degrade.
115
+ var _log = b.log.create({});
111
116
 
112
117
  // ---- constants ----------------------------------------------------------
113
118
 
@@ -322,7 +327,12 @@ function create(opts) {
322
327
  });
323
328
  }
324
329
  return "ok";
325
- } catch (_e) {
330
+ } catch (e) {
331
+ _log.error("dunning step failed", {
332
+ action: action,
333
+ invoice_id: enrollment.invoice_id,
334
+ err: (e && e.message) || String(e),
335
+ });
326
336
  return "failed";
327
337
  }
328
338
  }
@@ -342,7 +352,12 @@ function create(opts) {
342
352
  failure_code: "dunning_retry",
343
353
  });
344
354
  return "ok";
345
- } catch (_e) {
355
+ } catch (e) {
356
+ _log.error("dunning step failed", {
357
+ action: action,
358
+ invoice_id: enrollment.invoice_id,
359
+ err: (e && e.message) || String(e),
360
+ });
346
361
  return "failed";
347
362
  }
348
363
  }
@@ -362,7 +377,12 @@ function create(opts) {
362
377
  reason: "dunning: pause_subscription step",
363
378
  });
364
379
  return "ok";
365
- } catch (_e) {
380
+ } catch (e) {
381
+ _log.error("dunning step failed", {
382
+ action: action,
383
+ invoice_id: enrollment.invoice_id,
384
+ err: (e && e.message) || String(e),
385
+ });
366
386
  return "failed";
367
387
  }
368
388
  }
@@ -379,7 +399,12 @@ function create(opts) {
379
399
  outcome: "cancelled",
380
400
  });
381
401
  return "ok";
382
- } catch (_e) {
402
+ } catch (e) {
403
+ _log.error("dunning step failed", {
404
+ action: action,
405
+ invoice_id: enrollment.invoice_id,
406
+ err: (e && e.message) || String(e),
407
+ });
383
408
  return "failed";
384
409
  }
385
410
  }
@@ -62,6 +62,12 @@
62
62
 
63
63
  var b = require("./vendor/blamejs");
64
64
 
65
+ // Observability sink for batch-path settlement failures: the scheduler
66
+ // reverts a due row to 'pending' and carries on when a Stripe push or a
67
+ // credit write throws, but the CAUSE is logged here rather than discarded
68
+ // so an operator can see why scheduled plan changes aren't landing.
69
+ var _log = b.log.create({});
70
+
65
71
  // ---- constants ----------------------------------------------------------
66
72
 
67
73
  var CHANGE_KINDS = ["immediate", "next_billing_cycle"];
@@ -180,8 +186,92 @@ function create(opts) {
180
186
  // ledger; if the handle is absent the change is refused (executeChange
181
187
  // throws) or left pending (applyScheduledChanges) rather than applied
182
188
  // with the credit lost.
189
+ //
190
+ // NOTE: the store-credit / invoice path is the model for NON-Stripe
191
+ // subscriptions only. A Stripe-backed subscription (below) lets Stripe
192
+ // own the proration — Stripe invoices the difference (upgrade) or
193
+ // carries the balance forward as a credit (downgrade), so this primitive
194
+ // must NOT also issue a local credit / invoice for those rows, or the
195
+ // customer would be settled twice.
183
196
  var storeCreditHandle = opts.storeCredit || null;
184
197
 
198
+ // Optional payment handle (the shared Stripe adapter). When wired, a
199
+ // plan change on a Stripe-backed subscription swaps the subscription
200
+ // item's price at Stripe BEFORE the local plan_id is touched, so the
201
+ // plan the shop shows and the plan Stripe actually bills never diverge.
202
+ // Without it (a deploy with no payment processor, or a non-Stripe row),
203
+ // the change stays local-only and the store-credit / invoice proration
204
+ // model applies. The handle must expose `subscriptions.retrieve(id)` +
205
+ // `subscriptions.update(id, body, idempotencyKey)` — the same shape
206
+ // subscriptionControls composes.
207
+ var payment = opts.payment || null;
208
+ var hasStripe = !!(payment && payment.subscriptions &&
209
+ typeof payment.subscriptions.update === "function" &&
210
+ typeof payment.subscriptions.retrieve === "function");
211
+
212
+ // A subscription is Stripe-backed when the processor adapter is wired
213
+ // AND the row carries the upstream subscription id the webhook + billing
214
+ // mirror key on. Rows without one are shop-local (e.g. a manually seeded
215
+ // subscription on a deploy that never reached Stripe) and the local
216
+ // store-credit / invoice proration model applies to them.
217
+ function _isStripeBacked(sub) {
218
+ return hasStripe && sub && typeof sub.stripe_subscription_id === "string" && sub.stripe_subscription_id.length > 0;
219
+ }
220
+
221
+ // Swap the subscription item's price at Stripe for a Stripe-backed
222
+ // subscription. Stripe models the price on the subscription ITEM, not
223
+ // the subscription, so we retrieve the live subscription to find its
224
+ // (single) item id, then update that item's price. Every subscription
225
+ // this shop creates binds exactly one price (`items: [{ price }]`), so
226
+ // the first item is authoritative; if a subscription somehow carries no
227
+ // item we surface a structured error rather than silently writing a
228
+ // local-only change that diverges from Stripe.
229
+ //
230
+ // `prorationBehavior` selects who owns the money: "always_invoice"
231
+ // (immediate change) has Stripe invoice the proration delta now;
232
+ // "none" (next-cycle change) applies the new price cleanly from the
233
+ // next invoice with no mid-cycle adjustment. The idempotency key folds
234
+ // in the plan-change id so a retried call is a safe no-op at Stripe.
235
+ async function _pushPlanToStripe(stripeSubscriptionId, newStripePriceId, prorationBehavior, planChangeId) {
236
+ var live;
237
+ try {
238
+ live = await payment.subscriptions.retrieve(stripeSubscriptionId);
239
+ } catch (e) {
240
+ var rErr = new Error(
241
+ "planChanges: could not reach Stripe to swap the subscription plan — " + (e && e.message || e),
242
+ );
243
+ rErr.code = "PLAN_CHANGE_STRIPE_PUSH_FAILED";
244
+ rErr.cause = e;
245
+ throw rErr;
246
+ }
247
+ var items = live && live.items && Array.isArray(live.items.data) ? live.items.data : [];
248
+ if (!items.length || !items[0] || !items[0].id) {
249
+ var noItem = new Error(
250
+ "planChanges: Stripe subscription " + stripeSubscriptionId + " has no billable item to reprice",
251
+ );
252
+ noItem.code = "PLAN_CHANGE_STRIPE_NO_ITEM";
253
+ throw noItem;
254
+ }
255
+ var idemKey = "planchange:" + planChangeId;
256
+ try {
257
+ return await payment.subscriptions.update(
258
+ stripeSubscriptionId,
259
+ { items: [{ id: items[0].id, price: newStripePriceId }], proration_behavior: prorationBehavior },
260
+ idemKey,
261
+ );
262
+ } catch (e2) {
263
+ // The processor rejected or failed the plan swap. Wrap it in a stable
264
+ // code so the route can surface a "nothing changed, retry" notice; the
265
+ // local row is still untouched (this runs before the local write).
266
+ var uErr = new Error(
267
+ "planChanges: Stripe rejected the plan swap — " + (e2 && e2.message || e2),
268
+ );
269
+ uErr.code = "PLAN_CHANGE_STRIPE_PUSH_FAILED";
270
+ uErr.cause = e2;
271
+ throw uErr;
272
+ }
273
+ }
274
+
185
275
  async function _getSubscription(subscriptionId) {
186
276
  // Prefer the injected handle (production composition); fall back
187
277
  // to a direct read so tests that pass a minimal handle
@@ -399,6 +489,9 @@ function create(opts) {
399
489
  // ledger) and the credit (owed to the customer). Exactly one is
400
490
  // non-zero: an upgrade charges the difference, a downgrade leaves
401
491
  // the customer in credit for the outgoing plan's unused remainder.
492
+ // For a Stripe-backed subscription these are informational only —
493
+ // Stripe owns the money — but they're still recorded on the row for
494
+ // history / display.
402
495
  var chargeMinor = _max0(proposed.first_charge_minor - proposed.proration_credit_minor);
403
496
  var creditMinor = _max0(proposed.proration_credit_minor - proposed.first_charge_minor);
404
497
  // Status: `executed` when the effective clock is now-or-past,
@@ -409,12 +502,37 @@ function create(opts) {
409
502
  var status = proposed.effective_at <= ts ? "executed" : "pending";
410
503
  var executedAt = status === "executed" ? ts : null;
411
504
 
412
- // Fail closed: a downgrade taking effect now owes the customer a
413
- // credit, and the store-credit ledger is the only vehicle for it.
414
- // Refuse before any row is written rather than apply the transition
415
- // and silently drop the credit. (A future-dated downgrade is left
416
- // pending; applyScheduledChanges re-checks at execution time.)
417
- if (status === "executed") _assertCreditable(creditMinor);
505
+ var stripeBacked = _isStripeBacked(sub);
506
+
507
+ // Stripe owns the proration for a Stripe-backed subscription: it
508
+ // invoices the upgrade delta now (always_invoice) or carries a
509
+ // downgrade balance forward. So the local store-credit / invoice
510
+ // settlement MUST NOT also run for these rows — that would settle the
511
+ // customer twice. Conversely, a non-Stripe row's only settlement
512
+ // vehicle is the local store-credit ledger, so the credit-vehicle
513
+ // gate still applies there.
514
+ if (status === "executed" && stripeBacked) {
515
+ // Confirm the target plan carries a Stripe price to swap to. A plan
516
+ // with no `stripe_price_id` can't be expressed at Stripe; refuse
517
+ // before any local row is written rather than diverge the local plan
518
+ // from what Stripe bills.
519
+ var toPlanImmediate = await _getPlan(newPlanId);
520
+ if (!toPlanImmediate || !toPlanImmediate.stripe_price_id) {
521
+ var mErr = new Error(
522
+ "planChanges.executeChange: target plan " + newPlanId +
523
+ " has no stripe_price_id — cannot reprice a Stripe-backed subscription"
524
+ );
525
+ mErr.code = "PLAN_CHANGE_STRIPE_PRICE_MISSING";
526
+ throw mErr;
527
+ }
528
+ } else if (status === "executed") {
529
+ // Fail closed: a downgrade taking effect now owes the customer a
530
+ // credit, and the store-credit ledger is the only vehicle for it.
531
+ // Refuse before any row is written rather than apply the transition
532
+ // and silently drop the credit. (A future-dated downgrade is left
533
+ // pending; applyScheduledChanges re-checks at execution time.)
534
+ _assertCreditable(creditMinor);
535
+ }
418
536
 
419
537
  await query(
420
538
  "INSERT INTO subscription_plan_changes " +
@@ -429,56 +547,94 @@ function create(opts) {
429
547
  ],
430
548
  );
431
549
 
432
- if (status === "executed") {
433
- // Atomic transition claim. The plan update is conditional on the
434
- // CURRENT plan id, so two concurrent executeChange calls for the same
435
- // immediate change can't both apply it — only the call that observes
436
- // the pre-change plan id transitions the row (rowCount 1); a racing
437
- // second call sees rowCount 0. The _pendingFor guard above only blocks
438
- // PENDING changes, so without this an immediate change executed twice
439
- // (a double-submit or retry) would record the proration invoice twice
440
- // and double-charge. The proration below is gated on winning the claim.
441
- var planUpd = await query(
550
+ // A pending (future / next-cycle) change is NOT applied here, whether
551
+ // Stripe-backed or not applyScheduledChanges pushes it to Stripe (or
552
+ // settles the local credit/invoice) once its clock matures.
553
+ if (status !== "executed") return await _getById(id);
554
+
555
+ if (stripeBacked) {
556
+ // STRIPE-FIRST: swap the price at Stripe with always_invoice (Stripe
557
+ // invoices the proration delta now) BEFORE the local plan_id write.
558
+ // If Stripe rejects, throw and leave the local row on the outgoing
559
+ // plan the row the shop shows never diverges from what Stripe bills.
560
+ // (toPlanImmediate is resolved above and is guaranteed to carry a
561
+ // stripe_price_id.)
562
+ try {
563
+ await _pushPlanToStripe(sub.stripe_subscription_id, toPlanImmediate.stripe_price_id, "always_invoice", id);
564
+ } catch (e) {
565
+ // The Stripe swap failed. The row was inserted 'executed' above but
566
+ // the subscription was NOT repriced and its plan_id is untouched —
567
+ // void the row so history never shows a phantom-executed change that
568
+ // didn't happen, then rethrow so the route surfaces a clean retry.
569
+ await query(
570
+ "UPDATE subscription_plan_changes SET status = 'cancelled', cancelled_at = ?1, " +
571
+ "cancel_reason = ?2 WHERE id = ?3",
572
+ [_now(), "stripe-push-failed", id],
573
+ );
574
+ throw e;
575
+ }
576
+
577
+ // Atomic transition claim — same CAS as the local path. Stripe owns
578
+ // the money, so on a lost race (a concurrent executeChange already
579
+ // flipped the plan) there is no local credit/invoice to undo; we just
580
+ // skip. The Stripe push is idempotent (idempotencyKey folds in this
581
+ // change id), so a racing duplicate is a Stripe no-op.
582
+ await query(
442
583
  "UPDATE subscriptions SET plan_id = ?1, updated_at = ?2 WHERE id = ?3 AND plan_id = ?4",
443
584
  [newPlanId, ts, subscriptionId, sub.plan_id],
444
585
  );
445
- var wonTransition = Number((planUpd && planUpd.rowCount) || 0) > 0;
446
- if (wonTransition) {
447
- if (creditMinor > 0) {
448
- try {
449
- await _issueDowngradeCredit({
450
- customerId: sub.customer_id,
451
- creditMinor: creditMinor,
452
- planChangeId: id,
453
- });
454
- } catch (e) {
455
- // Revert-on-throw: the credit is the customer's money, so a
456
- // transient write failure must not finalize a downgrade it
457
- // was never paid for. Roll the subscription back to the
458
- // outgoing plan (guarded on the just-applied plan id so a
459
- // concurrent change isn't clobbered) and void this change,
460
- // then rethrow so the caller retries from a clean state.
461
- await query(
462
- "UPDATE subscriptions SET plan_id = ?1, updated_at = ?2 WHERE id = ?3 AND plan_id = ?4",
463
- [sub.plan_id, _now(), subscriptionId, newPlanId],
464
- );
465
- await query(
466
- "UPDATE subscription_plan_changes SET status = 'cancelled', cancelled_at = ?1, " +
467
- "cancel_reason = ?2 WHERE id = ?3",
468
- [_now(), "credit-settlement-failed", id],
469
- );
470
- throw e;
471
- }
586
+ return await _getById(id);
587
+ }
588
+
589
+ // NON-STRIPE — today's local settlement path, unchanged.
590
+ // Atomic transition claim. The plan update is conditional on the
591
+ // CURRENT plan id, so two concurrent executeChange calls for the same
592
+ // immediate change can't both apply it — only the call that observes
593
+ // the pre-change plan id transitions the row (rowCount 1); a racing
594
+ // second call sees rowCount 0. The _pendingFor guard above only blocks
595
+ // PENDING changes, so without this an immediate change executed twice
596
+ // (a double-submit or retry) would record the proration invoice twice
597
+ // and double-charge. The proration below is gated on winning the claim.
598
+ var planUpd = await query(
599
+ "UPDATE subscriptions SET plan_id = ?1, updated_at = ?2 WHERE id = ?3 AND plan_id = ?4",
600
+ [newPlanId, ts, subscriptionId, sub.plan_id],
601
+ );
602
+ var wonTransition = Number((planUpd && planUpd.rowCount) || 0) > 0;
603
+ if (wonTransition) {
604
+ if (creditMinor > 0) {
605
+ try {
606
+ await _issueDowngradeCredit({
607
+ customerId: sub.customer_id,
608
+ creditMinor: creditMinor,
609
+ planChangeId: id,
610
+ });
611
+ } catch (e) {
612
+ // Revert-on-throw: the credit is the customer's money, so a
613
+ // transient write failure must not finalize a downgrade it
614
+ // was never paid for. Roll the subscription back to the
615
+ // outgoing plan (guarded on the just-applied plan id so a
616
+ // concurrent change isn't clobbered) and void this change,
617
+ // then rethrow so the caller retries from a clean state.
618
+ await query(
619
+ "UPDATE subscriptions SET plan_id = ?1, updated_at = ?2 WHERE id = ?3 AND plan_id = ?4",
620
+ [sub.plan_id, _now(), subscriptionId, newPlanId],
621
+ );
622
+ await query(
623
+ "UPDATE subscription_plan_changes SET status = 'cancelled', cancelled_at = ?1, " +
624
+ "cancel_reason = ?2 WHERE id = ?3",
625
+ [_now(), "credit-settlement-failed", id],
626
+ );
627
+ throw e;
472
628
  }
473
- await _recordProrationInvoice({
474
- subscriptionId: subscriptionId,
475
- chargeMinor: chargeMinor,
476
- currency: proposed.currency,
477
- periodStart: proposed.effective_at,
478
- periodEnd: sub.current_period_end,
479
- planChangeId: id,
480
- });
481
629
  }
630
+ await _recordProrationInvoice({
631
+ subscriptionId: subscriptionId,
632
+ chargeMinor: chargeMinor,
633
+ currency: proposed.currency,
634
+ periodStart: proposed.effective_at,
635
+ periodEnd: sub.current_period_end,
636
+ planChangeId: id,
637
+ });
482
638
  }
483
639
 
484
640
  return await _getById(id);
@@ -558,20 +714,34 @@ function create(opts) {
558
714
  var row = due.rows[i];
559
715
  var chargeMinor = _max0(row.first_charge_minor - row.proration_credit_minor);
560
716
  var creditMinor = _max0(row.proration_credit_minor - row.first_charge_minor);
561
- // Fail closed: a due downgrade owes the customer a credit. If no
562
- // store-credit vehicle is wired, leave the row PENDING (skip
563
- // before claiming) so it applies on a later sweep once the handle
564
- // exists never transition it and drop the credit.
565
- if (creditMinor > 0 && !storeCreditHandle) continue;
717
+
718
+ // Read the subscription up front to decide the settlement model.
719
+ // A Stripe-backed row lets Stripe own the proration (the new price
720
+ // applies cleanly from the next invoice next-cycle semantics), so
721
+ // the local store-credit / invoice path is skipped for it entirely,
722
+ // INCLUDING the credit-vehicle skip guard below (that guard only
723
+ // gates the local store-credit path; Stripe carries a downgrade
724
+ // balance forward without one).
725
+ var dueSub = await _getSubscription(row.subscription_id);
726
+ var rowStripeBacked = _isStripeBacked(dueSub);
727
+
728
+ if (!rowStripeBacked) {
729
+ // Fail closed: a due downgrade owes the customer a credit. If no
730
+ // store-credit vehicle is wired, leave the row PENDING (skip
731
+ // before claiming) so it applies on a later sweep once the handle
732
+ // exists — never transition it and drop the credit.
733
+ if (creditMinor > 0 && !storeCreditHandle) continue;
734
+ }
566
735
 
567
736
  // Atomic claim: flip pending -> executed gated on the status.
568
737
  // Two overlapping scheduler runs (cron overrun, retry, two
569
738
  // workers) both SELECT the same due row; WITHOUT this guard both
570
739
  // would update the subscription plan AND record a proration
571
- // invoice — billing the customer twice for one change. Only the
572
- // caller whose UPDATE matches one row proceeds; the loser skips.
573
- // (The synchronous executeChange path is already protected by its
574
- // plan_id claim; this is the batch path's equivalent.)
740
+ // invoice / push to Stripe settling the customer twice for one
741
+ // change. Only the caller whose UPDATE matches one row proceeds;
742
+ // the loser skips. (The synchronous executeChange path is already
743
+ // protected by its plan_id claim; this is the batch path's
744
+ // equivalent.) Both settlement models claim the same way.
575
745
  var claim = await query(
576
746
  "UPDATE subscription_plan_changes SET status = 'executed', executed_at = ?1 " +
577
747
  "WHERE id = ?2 AND status = 'pending'",
@@ -579,6 +749,52 @@ function create(opts) {
579
749
  );
580
750
  if (!b.sql.casWon(claim).won) continue; // another run claimed it
581
751
 
752
+ if (rowStripeBacked) {
753
+ // STRIPE-FIRST: swap the price at Stripe with proration_behavior
754
+ // "none" — the new price applies from the next invoice cleanly
755
+ // (next-cycle semantics), no mid-cycle adjustment. On any Stripe
756
+ // failure, roll the claim back to 'pending' (guarded on the
757
+ // just-claimed state) so a later sweep retries it; the local
758
+ // plan_id was NOT touched yet, so there's nothing else to undo.
759
+ // One bad row must not abort the run.
760
+ var toPlan = await _getPlan(row.to_plan_id);
761
+ if (!toPlan || !toPlan.stripe_price_id) {
762
+ await query(
763
+ "UPDATE subscription_plan_changes SET status = 'pending', executed_at = NULL " +
764
+ "WHERE id = ?1 AND status = 'executed'",
765
+ [row.id],
766
+ );
767
+ continue;
768
+ }
769
+ try {
770
+ await _pushPlanToStripe(dueSub.stripe_subscription_id, toPlan.stripe_price_id, "none", row.id);
771
+ } catch (e) {
772
+ // Observe the cause (don't discard it), then roll the claim back
773
+ // to 'pending' so a later sweep retries this row; the local
774
+ // plan_id was not touched. One bad row never aborts the run.
775
+ _log.error("planChanges: scheduled Stripe plan swap failed", {
776
+ plan_change_id: row.id,
777
+ subscription_id: row.subscription_id,
778
+ err: (e && e.message) || String(e),
779
+ });
780
+ await query(
781
+ "UPDATE subscription_plan_changes SET status = 'pending', executed_at = NULL " +
782
+ "WHERE id = ?1 AND status = 'executed'",
783
+ [row.id],
784
+ );
785
+ continue;
786
+ }
787
+ // Stripe accepted — land the local plan_id transition. No local
788
+ // credit / invoice for a Stripe-backed row: Stripe owns the money.
789
+ await query(
790
+ "UPDATE subscriptions SET plan_id = ?1, updated_at = ?2 WHERE id = ?3",
791
+ [row.to_plan_id, now, row.subscription_id],
792
+ );
793
+ executed.push(await _getById(row.id));
794
+ continue;
795
+ }
796
+
797
+ // NON-STRIPE — today's local settlement path, unchanged.
582
798
  await query(
583
799
  "UPDATE subscriptions SET plan_id = ?1, updated_at = ?2 WHERE id = ?3",
584
800
  [row.to_plan_id, now, row.subscription_id],
@@ -593,12 +809,18 @@ function create(opts) {
593
809
  creditMinor: creditMinor,
594
810
  planChangeId: row.id,
595
811
  });
596
- } catch (_e) {
597
- // Revert-on-throw, then carry on with the batch: the credit is
598
- // the customer's money, so a transient write failure must not
599
- // finalize this downgrade. Roll the plan back and return the row
600
- // to 'pending' (guarded on the just-applied state) so a later
601
- // sweep retries it cleanly; one bad row must not abort the run.
812
+ } catch (e) {
813
+ // Observe the cause, then revert-on-throw and carry on with the
814
+ // batch: the credit is the customer's money, so a transient write
815
+ // failure must not finalize this downgrade. Roll the plan back and
816
+ // return the row to 'pending' (guarded on the just-applied state)
817
+ // so a later sweep retries it cleanly; one bad row must not abort
818
+ // the run.
819
+ _log.error("planChanges: scheduled downgrade credit failed", {
820
+ plan_change_id: row.id,
821
+ subscription_id: row.subscription_id,
822
+ err: (e && e.message) || String(e),
823
+ });
602
824
  await query(
603
825
  "UPDATE subscriptions SET plan_id = ?1, updated_at = ?2 WHERE id = ?3 AND plan_id = ?4",
604
826
  [row.from_plan_id, now, row.subscription_id, row.to_plan_id],
package/lib/storefront.js CHANGED
@@ -6230,6 +6230,8 @@ var SUBSCRIPTION_OK = {
6230
6230
  quantity: "Quantity updated.",
6231
6231
  frequency: "Delivery frequency updated.",
6232
6232
  reactivated: "Your subscription has been reactivated.",
6233
+ plan_changed: "Your plan change is set.",
6234
+ change_canceled: "Your scheduled plan change was canceled.",
6233
6235
  };
6234
6236
  function _subscriptionOkCopy(kind) {
6235
6237
  return Object.prototype.hasOwnProperty.call(SUBSCRIPTION_OK, kind) ? SUBSCRIPTION_OK[kind] : null;
@@ -6250,6 +6252,12 @@ var SUBSCRIPTION_ERR = {
6250
6252
  // A Stripe-side quantity push failed; the local quantity was NOT
6251
6253
  // changed, so the customer sees the unchanged value and can retry.
6252
6254
  processor: "We couldn't update your subscription with the payment processor. Nothing changed — please try again.",
6255
+ // A plan change already scheduled — the customer must cancel the
6256
+ // pending one before queuing another.
6257
+ pending_exists: "You already have a plan change scheduled. Cancel it before choosing another.",
6258
+ // The requested plan can't be switched to (same plan / different
6259
+ // currency / no longer offered).
6260
+ plan: "That plan isn't available to switch to. Pick another.",
6253
6261
  };
6254
6262
  function _subscriptionErrorCopy(code) {
6255
6263
  if (code == null) return null;
@@ -6279,6 +6287,12 @@ function renderAccountSubscriptions(opts) {
6279
6287
  // frequency control suppressed; a local-only controls instance keeps
6280
6288
  // offering it even on a Stripe-shaped row.
6281
6289
  var stripeAware = opts.stripe_backed === true;
6290
+ // When the plan-change surface is wired, each active subscription gains a
6291
+ // "Change plan" link, and any open (proposed/pending) change renders a
6292
+ // banner with a cancel control. `opts.pending_changes` maps subscription
6293
+ // id → the open change row (joined to its `to_plan`).
6294
+ var canChange = opts.can_change === true;
6295
+ var pendingChanges = opts.pending_changes || {};
6282
6296
  var rowsHtml = "";
6283
6297
  for (var i = 0; i < subs.length; i += 1) {
6284
6298
  var s = subs[i];
@@ -6332,6 +6346,38 @@ function renderAccountSubscriptions(opts) {
6332
6346
  cancelControl =
6333
6347
  "<a class=\"btn-ghost btn-ghost--sm subscription-card__cancel-link\" href=\"/account/subscriptions/" + esc(s.id) + "/cancel\">Cancel subscription</a>";
6334
6348
  }
6349
+ // Change-plan link — offered only on a live (self-manageable, non-
6350
+ // terminal) subscription with an active billing period, mirroring the
6351
+ // change route's own preconditions. The proration preview + timing
6352
+ // choice lives on the dedicated /change page.
6353
+ var changeControl = "";
6354
+ if (canChange && _subscriptionSelfManageable(s) && _subscriptionControlState(s) === "active" &&
6355
+ s.current_period_start != null && s.current_period_end != null) {
6356
+ changeControl =
6357
+ "<a class=\"btn-ghost btn-ghost--sm subscription-card__change-link\" href=\"/account/subscriptions/" + esc(s.id) + "/change\">Change plan</a>";
6358
+ }
6359
+ // Pending plan-change banner — when an open change is scheduled for this
6360
+ // row, show the target plan + timing and a cancel control. The cancel
6361
+ // posts to the /change/cancel route; the change page also surfaces it.
6362
+ var pendingBanner = "";
6363
+ var pcRow = Object.prototype.hasOwnProperty.call(pendingChanges, s.id) ? pendingChanges[s.id] : null;
6364
+ if (pcRow) {
6365
+ var pcTo = pcRow.to_plan ? _planPriceCadence(pcRow.to_plan) : "a new plan";
6366
+ var pcWhen = pcRow.change_kind === "next_billing_cycle"
6367
+ ? "at your next billing date"
6368
+ : "shortly";
6369
+ var pcEff = pcRow.effective_at
6370
+ ? new Date(Number(pcRow.effective_at)).toISOString().slice(0, 10)
6371
+ : "";
6372
+ pendingBanner =
6373
+ "<div class=\"subscription-card__pending-change\" role=\"status\">" +
6374
+ "<span class=\"subscription-card__pending-copy\">Plan change to " + pcTo + " scheduled " + esc(pcWhen) +
6375
+ (pcEff ? " (<time datetime=\"" + esc(pcEff) + "\">" + esc(pcEff) + "</time>)" : "") + ".</span>" +
6376
+ "<form class=\"subscription-card__control\" method=\"post\" action=\"/account/subscriptions/" + esc(s.id) + "/change/cancel\">" +
6377
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Cancel change</button>" +
6378
+ "</form>" +
6379
+ "</div>";
6380
+ }
6335
6381
  // Self-manage controls — state-gated, mirroring the primitive's FSM
6336
6382
  // (active ⇒ pause / skip / change-qty / change-freq; paused ⇒ resume;
6337
6383
  // cancelled-within-grace ⇒ reactivate). Pause is confirm-gated like
@@ -6408,7 +6454,9 @@ function renderAccountSubscriptions(opts) {
6408
6454
  "</div>" +
6409
6455
  (renewalNote ? "<p class=\"subscription-card__meta\">" + renewalNote + "</p>" : "") +
6410
6456
  (stateNote ? "<p class=\"subscription-card__state\">" + stateNote + "</p>" : "") +
6457
+ pendingBanner +
6411
6458
  manageControls +
6459
+ changeControl +
6412
6460
  cancelControl +
6413
6461
  "</li>";
6414
6462
  }
@@ -6639,6 +6687,162 @@ function renderSubscriptionPauseConfirm(opts) {
6639
6687
  });
6640
6688
  }
6641
6689
 
6690
+ // Render a plan's "price + cadence" one-liner (e.g. "$24.99 per month").
6691
+ // Returns an already-escaped string. The price is formatted only when the
6692
+ // amount + currency are well-formed (a validated plan row always is); a
6693
+ // malformed row degrades to the cadence-only phrase by an EXPLICIT check
6694
+ // rather than by swallowing a thrown pricing.format error — so bad data
6695
+ // degrades visibly-by-design and never hides a defect behind a catch.
6696
+ function _planPriceCadence(plan) {
6697
+ var esc = b.template.escapeHtml;
6698
+ if (!plan) return "Plan unavailable";
6699
+ var ccy = String(plan.currency || "").toUpperCase();
6700
+ var amount = Number(plan.amount_minor);
6701
+ var every = Number(plan.interval_count) > 1
6702
+ ? "every " + Number(plan.interval_count) + " " + esc(String(plan.interval)) + "s"
6703
+ : "per " + esc(String(plan.interval));
6704
+ // pricing.format throws on a non-integer/negative amount or a non-ISO-4217
6705
+ // currency; gate on exactly those preconditions so it is only ever called
6706
+ // with input it accepts (no try/catch needed).
6707
+ if (Number.isInteger(amount) && amount >= 0 && /^[A-Z]{3}$/.test(ccy)) {
6708
+ return esc(pricing.format(amount, ccy)) + " " + every;
6709
+ }
6710
+ return every;
6711
+ }
6712
+
6713
+ // Customer-facing plan-change page. `opts.subscription` carries the
6714
+ // resolved current `plan`; `opts.candidates` is an array of
6715
+ // { plan, preview } where preview is the proposeChange result for the
6716
+ // IMMEDIATE timing (proration_credit_minor + first_charge_minor +
6717
+ // currency). `opts.pending` is the open change to offer a cancel for, or
6718
+ // null. Confirm-gated: a single form, one radio per candidate plan, an
6719
+ // immediate-vs-next-cycle timing choice, and a submit. CSP forbids
6720
+ // inline script, so the proration math is server-rendered as static copy
6721
+ // — there's no client recompute. The CSRF field is spliced into the POST
6722
+ // form by `_injectCsrfFields` at send time. Everything dynamic is esc()'d.
6723
+ function renderSubscriptionPlanChange(opts) {
6724
+ var esc = b.template.escapeHtml;
6725
+ var s = opts.subscription || {};
6726
+ var candidates = opts.candidates || [];
6727
+ var pending = opts.pending || null;
6728
+ var currentSummary = _planPriceCadence(s.plan || null);
6729
+
6730
+ var noticeHtml = opts.error
6731
+ ? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.error)) + "</p>"
6732
+ : "";
6733
+
6734
+ // A pending change blocks queuing a new one (the primitive refuses a
6735
+ // second). Render the standing change + a cancel control instead of the
6736
+ // candidate picker.
6737
+ var pendingHtml = "";
6738
+ if (pending) {
6739
+ var toSummary = _planPriceCadence(pending.to_plan || null);
6740
+ var kindCopy = pending.change_kind === "next_billing_cycle"
6741
+ ? "at your next billing date"
6742
+ : "immediately";
6743
+ var effDate = pending.effective_at
6744
+ ? new Date(Number(pending.effective_at)).toISOString().slice(0, 10)
6745
+ : "";
6746
+ pendingHtml =
6747
+ "<div class=\"plan-change-pending\">" +
6748
+ "<p class=\"plan-change-pending__lede\">A change to " + toSummary + " is scheduled " + esc(kindCopy) +
6749
+ (effDate ? " (<time datetime=\"" + esc(effDate) + "\">" + esc(effDate) + "</time>)" : "") + ".</p>" +
6750
+ "<form method=\"post\" action=\"/account/subscriptions/" + esc(s.id) + "/change/cancel\">" +
6751
+ "<button type=\"submit\" class=\"btn-ghost\">Cancel scheduled change</button>" +
6752
+ "</form>" +
6753
+ "</div>";
6754
+ }
6755
+
6756
+ // Candidate picker — only when there's no pending change to clear first.
6757
+ var pickerHtml = "";
6758
+ if (!pending) {
6759
+ if (!candidates.length) {
6760
+ pickerHtml = "<p class=\"plan-change__empty\">There are no other plans to switch to right now.</p>";
6761
+ } else {
6762
+ var optionsHtml = "";
6763
+ for (var i = 0; i < candidates.length; i += 1) {
6764
+ var c = candidates[i];
6765
+ var plan = c.plan;
6766
+ var preview = c.preview || {};
6767
+ var prevCcy = String(preview.currency || (plan && plan.currency) || "usd").toUpperCase();
6768
+ var creditMinor = Number(preview.proration_credit_minor) || 0;
6769
+ var chargeMinor = Number(preview.first_charge_minor) || 0;
6770
+ // Net the proration into the single number the customer cares about:
6771
+ // an upgrade charges (charge − credit) now; a downgrade leaves them
6772
+ // (credit − charge) in store credit. Exactly one is non-zero, both
6773
+ // floored at 0. Mirrors the primitive's executeChange split.
6774
+ var netCharge = chargeMinor - creditMinor > 0 ? chargeMinor - creditMinor : 0;
6775
+ var netCredit = creditMinor - chargeMinor > 0 ? creditMinor - chargeMinor : 0;
6776
+ // netCharge/netCredit are non-negative integers by construction; the
6777
+ // only value that could make pricing.format throw is a malformed
6778
+ // currency, so gate the formatted copy on a valid ISO-4217 code by an
6779
+ // EXPLICIT check rather than catching a thrown format error. A bad
6780
+ // currency degrades to the amount-free phrase, visibly-by-design.
6781
+ var validCcy = /^[A-Z]{3}$/.test(prevCcy);
6782
+ var prorationCopy;
6783
+ if (validCcy && netCharge > 0) {
6784
+ prorationCopy = "Switch now and you'll be charged " + esc(pricing.format(netCharge, prevCcy)) + " for the rest of this period.";
6785
+ } else if (validCcy && netCredit > 0) {
6786
+ prorationCopy = "Switch now and you'll get " + esc(pricing.format(netCredit, prevCcy)) + " in store credit for the unused part of this period.";
6787
+ } else if (validCcy) {
6788
+ prorationCopy = "Switch now at no extra charge for the rest of this period.";
6789
+ } else {
6790
+ prorationCopy = "Switch now — proration applies for the rest of this period.";
6791
+ }
6792
+ optionsHtml +=
6793
+ "<li class=\"plan-change-option\">" +
6794
+ "<label class=\"plan-change-option__label\">" +
6795
+ "<input type=\"radio\" name=\"new_plan_id\" value=\"" + esc(String(plan.id)) + "\"" + (i === 0 ? " checked" : "") + " required>" +
6796
+ "<span class=\"plan-change-option__plan\">" + _planPriceCadence(plan) + "</span>" +
6797
+ "<span class=\"plan-change-option__proration\">" + prorationCopy + "</span>" +
6798
+ "</label>" +
6799
+ "</li>";
6800
+ }
6801
+ pickerHtml =
6802
+ "<form method=\"post\" action=\"/account/subscriptions/" + esc(s.id) + "/change\">" +
6803
+ "<fieldset class=\"plan-change__plans\">" +
6804
+ "<legend class=\"plan-change__legend\">Choose a new plan</legend>" +
6805
+ "<ul class=\"plan-change-options\">" + optionsHtml + "</ul>" +
6806
+ "</fieldset>" +
6807
+ "<fieldset class=\"plan-change__timing\">" +
6808
+ "<legend class=\"plan-change__legend\">When should it take effect?</legend>" +
6809
+ "<label class=\"plan-change-timing__option\">" +
6810
+ "<input type=\"radio\" name=\"timing\" value=\"immediate\" checked>" +
6811
+ "<span>Now — with proration for the rest of this period</span>" +
6812
+ "</label>" +
6813
+ "<label class=\"plan-change-timing__option\">" +
6814
+ "<input type=\"radio\" name=\"timing\" value=\"next_cycle\">" +
6815
+ "<span>At my next billing date — no proration, the new plan starts clean</span>" +
6816
+ "</label>" +
6817
+ "</fieldset>" +
6818
+ "<button type=\"submit\" class=\"btn-primary\">Change plan</button>" +
6819
+ "</form>";
6820
+ }
6821
+ }
6822
+
6823
+ var body =
6824
+ "<section class=\"account-confirm plan-change\">" +
6825
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
6826
+ "<li><a href=\"/account\">Account</a></li>" +
6827
+ "<li><a href=\"/account/subscriptions\">Subscriptions</a></li>" +
6828
+ "<li aria-current=\"page\">Change plan</li>" +
6829
+ "</ol></nav>" +
6830
+ "<h1 class=\"account-confirm__title\">Change your plan</h1>" +
6831
+ "<p class=\"plan-change__current\">You're currently on " + currentSummary + ".</p>" +
6832
+ noticeHtml +
6833
+ pendingHtml +
6834
+ pickerHtml +
6835
+ "<a class=\"btn-ghost\" href=\"/account/subscriptions\">Back to subscriptions</a>" +
6836
+ "</section>";
6837
+ return _wrap({
6838
+ title: "Change plan",
6839
+ shop_name: opts.shop_name || "blamejs.shop",
6840
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
6841
+ theme_css: opts.theme_css,
6842
+ body: body,
6843
+ });
6844
+ }
6845
+
6642
6846
  // Product-level "Save to wishlist" control + social-proof count.
6643
6847
  // Byte-compatible with the edge renderer (`worker/render/product.js`)
6644
6848
  // so both paths emit identical markup. Action-only label — the toggle
@@ -18865,6 +19069,14 @@ function mount(router, deps) {
18865
19069
  // state — so they're available even on a deploy with no payment
18866
19070
  // handle. The list above stays a read-only view when this is absent.
18867
19071
  var subControls = deps.subscriptionControls || null;
19072
+ // Proration-aware plan upgrade / downgrade. Mounts the
19073
+ // /account/subscriptions/:id/change confirm-gated surface (preview the
19074
+ // proration credit / first charge per candidate plan + pick immediate
19075
+ // vs next-cycle timing) plus the cancel-pending POST. Wired only when
19076
+ // server.js composed it (needs subscriptions + store credit — the
19077
+ // vehicle a mid-cycle downgrade's owed credit pays into). Absent it,
19078
+ // the list stays the lifecycle-only view above.
19079
+ var planChanges = deps.planChanges || null;
18868
19080
 
18869
19081
  async function _subsAuth(req, res) {
18870
19082
  var auth;
@@ -18939,6 +19151,33 @@ function mount(router, deps) {
18939
19151
  ? "Your subscription has been canceled."
18940
19152
  : _subscriptionOkCopy(okKind);
18941
19153
  var errKind = url ? url.searchParams.get("error") : null;
19154
+ // Pending plan changes — when the plan-change surface is wired, look
19155
+ // up the open (proposed/pending) change per subscription so the list
19156
+ // can show a "change scheduled" banner with a cancel control. The
19157
+ // banner is an enhancement to a critical page (the list must render),
19158
+ // so a read failure is OBSERVED via _log.error and the banners are
19159
+ // dropped for this render — degrade-with-observability, never a silent
19160
+ // swallow. plans.get returns null for a deleted target plan (handled
19161
+ // by the renderer); only a genuine failure reaches the catch.
19162
+ var pendingChanges = {};
19163
+ if (planChanges) {
19164
+ try {
19165
+ for (var pci = 0; pci < rows.length; pci += 1) {
19166
+ var pcSub = rows[pci];
19167
+ var pcRow = await planChanges.pendingChangeFor(pcSub.id);
19168
+ if (pcRow) {
19169
+ pcRow.to_plan = await subscriptions.plans.get(pcRow.to_plan_id);
19170
+ pendingChanges[pcSub.id] = pcRow;
19171
+ }
19172
+ }
19173
+ } catch (e) {
19174
+ _log.error("subscription list: pending plan-change lookup failed", {
19175
+ request_id: (req && req.requestId) || null,
19176
+ err: (e && e.message) || String(e),
19177
+ });
19178
+ pendingChanges = {};
19179
+ }
19180
+ }
18942
19181
  _send(res, 200, renderAccountSubscriptions({
18943
19182
  subscriptions: rows,
18944
19183
  can_cancel: subsCanCancel,
@@ -18948,6 +19187,10 @@ function mount(router, deps) {
18948
19187
  // handle with retrieve + update was wired) — otherwise the
18949
19188
  // controls are local-only and the cadence is still editable.
18950
19189
  stripe_backed: !!(subControls && subControls.stripeBacked),
19190
+ // Plan-change link + pending-change banner render only when the
19191
+ // surface is wired.
19192
+ can_change: !!planChanges,
19193
+ pending_changes: pendingChanges,
18951
19194
  notice: notice,
18952
19195
  error: _subscriptionErrorCopy(errKind),
18953
19196
  shop_name: shopName,
@@ -19172,6 +19415,183 @@ function mount(router, deps) {
19172
19415
  return res.end ? res.end() : res.send("");
19173
19416
  });
19174
19417
  }
19418
+
19419
+ // Plan change — proration-aware self-serve upgrade / downgrade. Mounts
19420
+ // only when the plan-change primitive is wired (server.js composes it
19421
+ // from subscriptions + store credit). The GET renders a confirm-gated
19422
+ // page listing every candidate plan with its proration preview; the
19423
+ // POST executes the chosen change (immediate or next-cycle); a third
19424
+ // POST cancels a still-pending change. Every route owns its
19425
+ // subscription through the same `_ownedSubscription` IDOR check the
19426
+ // cancel flow uses (a forged/foreign id 404s before any read), and
19427
+ // every primitive refusal maps to a fixed ?error code rather than a
19428
+ // 500.
19429
+ if (planChanges) {
19430
+ function _pcRedirect(res, suffix) {
19431
+ res.status(303);
19432
+ res.setHeader && res.setHeader("location", "/account/subscriptions" + suffix);
19433
+ return res.end ? res.end() : res.send("");
19434
+ }
19435
+
19436
+ // Translate a planChanges rejection into a fixed ?error code. A
19437
+ // pending-change collision, an unknown subscription/plan, and a
19438
+ // validation TypeError (cross-currency / same-plan / archived plan)
19439
+ // each map to a stable code; a Stripe-backed change whose target
19440
+ // plan has no Stripe price (PLAN_CHANGE_STRIPE_PRICE_MISSING) is a
19441
+ // misconfiguration the customer can't act on but must not 500;
19442
+ // a transient Stripe push failure (PLAN_CHANGE_STRIPE_PUSH_FAILED /
19443
+ // _NO_ITEM) leaves the local plan untouched and surfaces a "nothing
19444
+ // changed, retry" notice rather than a 500. Anything else rethrows
19445
+ // (a real 500, e.g. an unmigrated table on a misconfigured deploy).
19446
+ function _planChangeError(e) {
19447
+ if (e && e.code === "PLAN_CHANGE_REFUSED") return "pending_exists";
19448
+ if (e && e.code === "SUBSCRIPTION_NOT_FOUND") return "state";
19449
+ if (e && e.code === "PLAN_NOT_FOUND") return "state";
19450
+ if (e && e.code === "NO_PENDING_CHANGE") return "state";
19451
+ if (e && e.code === "PLAN_CHANGE_STRIPE_PRICE_MISSING") return "plan";
19452
+ if (e && e.code === "PLAN_CHANGE_STRIPE_PUSH_FAILED") return "processor";
19453
+ if (e && e.code === "PLAN_CHANGE_STRIPE_NO_ITEM") return "processor";
19454
+ if (e instanceof TypeError) return "plan";
19455
+ return null;
19456
+ }
19457
+
19458
+ // Resolve every plan a subscription can switch TO: every active plan
19459
+ // sharing the current plan's currency, minus the current plan
19460
+ // itself. A cross-currency or archived plan is excluded here so the
19461
+ // confirm page never offers a candidate the primitive would refuse.
19462
+ // For each candidate, compute the immediate-timing proration preview
19463
+ // (credit + first charge) — the page shows both timings, but only
19464
+ // the immediate one prorates, so a single proposeChange per candidate
19465
+ // covers the preview. proposeChange persists nothing.
19466
+ async function _changeCandidates(sub) {
19467
+ // A read failure here is a real error, not a "no candidates" state —
19468
+ // let it propagate to the route's _routeError (logged + generic),
19469
+ // never mask it. The current-plan lookup returns null for a deleted
19470
+ // plan (a legitimate value handled below), and only throws on a real
19471
+ // DB failure (which also propagates).
19472
+ var allPlans = await subscriptions.plans.list({ active: true });
19473
+ var fromPlan = sub.plan_id != null ? await subscriptions.plans.get(sub.plan_id) : null;
19474
+ if (!fromPlan) return []; // no resolvable current plan → nothing to prorate a switch against
19475
+ var fromCcy = String(fromPlan.currency);
19476
+ var fromAmount = Number(fromPlan.amount_minor);
19477
+ var periodStart = Number(sub.current_period_start);
19478
+ var periodEnd = Number(sub.current_period_end);
19479
+ var nowMs = Date.now();
19480
+ var out = [];
19481
+ for (var i = 0; i < allPlans.length; i += 1) {
19482
+ var p = allPlans[i];
19483
+ if (p.id === sub.plan_id) continue; // never the current plan
19484
+ if (!p.active) continue; // archived → executeChange refuses
19485
+ if (String(p.currency) !== fromCcy) continue; // cross-currency → executeChange refuses
19486
+ // Immediate-timing proration preview via the primitive's exported
19487
+ // PURE math — no DB round-trip and no throw for valid integers, so
19488
+ // there is no error to swallow. The actual switch re-validates the
19489
+ // full shape through executeChange on POST.
19490
+ var pr = planChanges.prorate(fromAmount, Number(p.amount_minor), periodStart, periodEnd, nowMs);
19491
+ out.push({
19492
+ plan: p,
19493
+ preview: {
19494
+ proration_credit_minor: pr.proration_credit_minor,
19495
+ first_charge_minor: pr.first_charge_minor,
19496
+ currency: fromCcy,
19497
+ },
19498
+ });
19499
+ }
19500
+ return out;
19501
+ }
19502
+
19503
+ router.get("/account/subscriptions/:id/change", async function (req, res) {
19504
+ var auth = await _subsAuth(req, res); if (!auth) return;
19505
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
19506
+ // Only an active, self-manageable subscription can change plans — a
19507
+ // terminal / paused / cancelled row (even one that still carries
19508
+ // period columns) is refused here, matching the display gate that
19509
+ // decides whether to show the "Change plan" link.
19510
+ if (!_subscriptionSelfManageable(sub) || _subscriptionControlState(sub) !== "active") {
19511
+ return _pcRedirect(res, "?error=state");
19512
+ }
19513
+ // A subscription with no live billing period can't be prorated;
19514
+ // bounce to the list rather than render an empty change page.
19515
+ if (sub.current_period_start == null || sub.current_period_end == null) {
19516
+ return _pcRedirect(res, "?error=state");
19517
+ }
19518
+ try {
19519
+ // A read failure on any of these is a real error — route it
19520
+ // through _routeError (logs server-side + returns a generic,
19521
+ // leak-free message) rather than masking it with a per-read
19522
+ // catch. plans.get returns null for a deleted plan (a value the
19523
+ // renderer handles); only a genuine failure throws here.
19524
+ if (sub.plan_id != null) sub.plan = await subscriptions.plans.get(sub.plan_id);
19525
+ var candidates = await _changeCandidates(sub);
19526
+ var pending = await planChanges.pendingChangeFor(sub.id);
19527
+ if (pending) pending.to_plan = await subscriptions.plans.get(pending.to_plan_id);
19528
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
19529
+ var errKind = url ? url.searchParams.get("error") : null;
19530
+ var cartCount = await _cartCountForReq(req);
19531
+ _send(res, 200, renderSubscriptionPlanChange({
19532
+ subscription: sub,
19533
+ candidates: candidates,
19534
+ pending: pending,
19535
+ error: _subscriptionErrorCopy(errKind),
19536
+ shop_name: shopName,
19537
+ cart_count: cartCount,
19538
+ }));
19539
+ } catch (e) {
19540
+ return _routeError(req, res, e);
19541
+ }
19542
+ });
19543
+
19544
+ router.post("/account/subscriptions/:id/change", async function (req, res) {
19545
+ var auth = await _subsAuth(req, res); if (!auth) return;
19546
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
19547
+ // Reject a plan change on a non-active subscription (terminal /
19548
+ // paused / cancelled) before any write — the same gate the display
19549
+ // and GET enforce, so a forged POST can't re-plan a wound-down row.
19550
+ if (!_subscriptionSelfManageable(sub) || _subscriptionControlState(sub) !== "active") {
19551
+ return _pcRedirect(res, "?error=state");
19552
+ }
19553
+ var body = req.body || {};
19554
+ var newPlanId = String(body.new_plan_id || "");
19555
+ // Backend validates: an empty / missing plan id is a client error
19556
+ // → bounce with the plan code rather than handing garbage to the
19557
+ // primitive.
19558
+ if (!newPlanId) return _pcRedirect(res, "/" + encodeURIComponent(sub.id) + "/change?error=plan");
19559
+ var timing = String(body.timing || "immediate");
19560
+ if (timing !== "immediate" && timing !== "next_cycle") {
19561
+ return _pcRedirect(res, "/" + encodeURIComponent(sub.id) + "/change?error=state");
19562
+ }
19563
+ // next_cycle defers the change to the period boundary: the
19564
+ // primitive derives `next_billing_cycle` (and zero proration) when
19565
+ // change_at >= current_period_end. Immediate omits change_at, so
19566
+ // the primitive applies it at Date.now() with full proration.
19567
+ var changeArgs = { subscription_id: sub.id, new_plan_id: newPlanId };
19568
+ if (timing === "next_cycle") {
19569
+ if (sub.current_period_end == null) {
19570
+ return _pcRedirect(res, "/" + encodeURIComponent(sub.id) + "/change?error=state");
19571
+ }
19572
+ changeArgs.change_at = Number(sub.current_period_end);
19573
+ }
19574
+ try {
19575
+ await planChanges.executeChange(changeArgs);
19576
+ } catch (e) {
19577
+ var code = _planChangeError(e); if (code == null) throw e;
19578
+ return _pcRedirect(res, "/" + encodeURIComponent(sub.id) + "/change?error=" + code);
19579
+ }
19580
+ return _pcRedirect(res, "?ok=plan_changed");
19581
+ });
19582
+
19583
+ router.post("/account/subscriptions/:id/change/cancel", async function (req, res) {
19584
+ var auth = await _subsAuth(req, res); if (!auth) return;
19585
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
19586
+ try {
19587
+ await planChanges.cancelPendingChange({ subscription_id: sub.id });
19588
+ } catch (e) {
19589
+ var code = _planChangeError(e); if (code == null) throw e;
19590
+ return _pcRedirect(res, "?error=" + code);
19591
+ }
19592
+ return _pcRedirect(res, "?ok=change_canceled");
19593
+ });
19594
+ }
19175
19595
  }
19176
19596
 
19177
19597
  // Pre-order reservations — the PDP reserve POST + the customer's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
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": {