@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 +2 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/dunning.js +29 -4
- package/lib/plan-changes.js +288 -66
- package/lib/storefront.js +420 -0
- package/package.json +1 -1
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.
|
package/lib/asset-manifest.json
CHANGED
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
}
|
package/lib/plan-changes.js
CHANGED
|
@@ -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
|
-
|
|
413
|
-
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
|
|
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 —
|
|
572
|
-
// caller whose UPDATE matches one row proceeds;
|
|
573
|
-
// (The synchronous executeChange path is already
|
|
574
|
-
// plan_id claim; this is the batch path's
|
|
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 (
|
|
597
|
-
//
|
|
598
|
-
// the customer's money, so a transient write
|
|
599
|
-
// finalize this downgrade. Roll the plan back and
|
|
600
|
-
// to 'pending' (guarded on the just-applied state)
|
|
601
|
-
// sweep retries it cleanly; one bad row must not abort
|
|
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