@blamejs/blamejs-shop 0.4.19 → 0.4.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/lib/admin.js +60 -18
- package/lib/asset-manifest.json +1 -1
- package/lib/cart.js +44 -0
- package/lib/checkout.js +188 -51
- package/lib/click-and-collect.js +30 -15
- package/lib/collections.js +282 -14
- package/lib/gift-card-ledger.js +37 -8
- package/lib/gift-registry.js +43 -5
- package/lib/giftcards.js +60 -0
- package/lib/loyalty-earn-rules.js +106 -0
- package/lib/loyalty.js +63 -30
- package/lib/order.js +135 -1
- package/lib/returns.js +45 -5
- package/lib/search-ranking.js +58 -2
- package/lib/store-credit.js +31 -19
- package/lib/storefront.js +147 -19
- package/lib/subscription-controls.js +113 -0
- package/package.json +1 -1
package/lib/store-credit.js
CHANGED
|
@@ -67,6 +67,14 @@ var C = b.constants;
|
|
|
67
67
|
var KINDS = ["credit", "debit", "expire"];
|
|
68
68
|
var SOURCES = ["refund", "goodwill", "promotional", "manual", "loyalty_redemption"];
|
|
69
69
|
|
|
70
|
+
// Reserved source_ref stamped on every expire row the scheduled sweep
|
|
71
|
+
// writes. `cleanupExpired` keys its "already swept" idempotency on this
|
|
72
|
+
// marker SO THAT operator-initiated `expire()` rows (which carry the
|
|
73
|
+
// operator's own reason) don't masquerade as prior sweep output and
|
|
74
|
+
// suppress a legitimate expiry. The marker is short + control-byte-free
|
|
75
|
+
// so it passes `_sourceRef` validation when written.
|
|
76
|
+
var SWEEP_SOURCE_REF = "scheduled-expiry-sweep";
|
|
77
|
+
|
|
70
78
|
var MAX_SOURCE_REF_LEN = 128;
|
|
71
79
|
// source_ref / reason are short correlation handles. Refuse all
|
|
72
80
|
// control bytes (including CR/LF and tab) — log-injection cover has
|
|
@@ -488,20 +496,25 @@ function create(opts) {
|
|
|
488
496
|
var now = _epochMs(input.now, "now");
|
|
489
497
|
if (now == null) now = _now();
|
|
490
498
|
|
|
491
|
-
// Walk every credit row whose deadline has passed. For each
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
// Otherwise write an offsetting expire row capped at the
|
|
495
|
-
// wallet's current balance.
|
|
499
|
+
// Walk every credit row whose deadline has passed. For each
|
|
500
|
+
// customer, expire the still-unburned portion of their expired
|
|
501
|
+
// credit, capped at the wallet's current balance.
|
|
496
502
|
//
|
|
497
|
-
// The "already
|
|
498
|
-
// per-credit-row because expire rows have no parent pointer
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
503
|
+
// The "already swept" check is per-customer rather than
|
|
504
|
+
// per-credit-row because expire rows have no parent pointer at
|
|
505
|
+
// the schema level. The idempotency key is the SWEEP's OWN prior
|
|
506
|
+
// output — expire rows stamped with `SWEEP_SOURCE_REF` — NOT
|
|
507
|
+
// every expire row. Counting all expire rows here was the bug:
|
|
508
|
+
// an operator-initiated `expire()` (a clawback, a goodwill burn)
|
|
509
|
+
// or any non-sweep expire would be subtracted from the expired-
|
|
510
|
+
// credit total, shrinking `pendingBurn` and leaving genuinely
|
|
511
|
+
// expired credit un-swept. Operator expires + debits already
|
|
512
|
+
// reduced the BALANCE; the `min(pendingBurn, balance)` cap below
|
|
513
|
+
// is what keeps the sweep from over-burning when the wallet was
|
|
514
|
+
// partly drained — so they must not also be netted out of the
|
|
515
|
+
// expired pool, or the reduction is double-counted. When the
|
|
516
|
+
// unburned remainder is zero, the sweep is a no-op for that
|
|
517
|
+
// customer (idempotent re-run produces no duplicates).
|
|
505
518
|
var expiredByCustomer = (await query(
|
|
506
519
|
"SELECT customer_id, SUM(amount_minor) AS total " +
|
|
507
520
|
"FROM store_credit_ledger " +
|
|
@@ -519,16 +532,15 @@ function create(opts) {
|
|
|
519
532
|
var burnRow = (await query(
|
|
520
533
|
"SELECT COALESCE(SUM(amount_minor), 0) AS total " +
|
|
521
534
|
"FROM store_credit_ledger " +
|
|
522
|
-
"WHERE customer_id = ?1 AND kind = 'expire'",
|
|
523
|
-
[customerId],
|
|
535
|
+
"WHERE customer_id = ?1 AND kind = 'expire' AND source_ref = ?2",
|
|
536
|
+
[customerId, SWEEP_SOURCE_REF],
|
|
524
537
|
)).rows[0];
|
|
525
538
|
var alreadyBurned = burnRow ? burnRow.total : 0;
|
|
526
539
|
var pendingBurn = expiredTotal - alreadyBurned;
|
|
527
540
|
|
|
528
541
|
if (pendingBurn <= 0) {
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
// amount — see cap below). Idempotent skip.
|
|
542
|
+
// The sweep already burned this customer's expired credit on
|
|
543
|
+
// a prior run. Idempotent skip.
|
|
532
544
|
continue;
|
|
533
545
|
}
|
|
534
546
|
|
|
@@ -548,7 +560,7 @@ function create(opts) {
|
|
|
548
560
|
}
|
|
549
561
|
var ts = _resolveOccurredAt(now, latest.occurred_at);
|
|
550
562
|
var after = latest.balance - toBurn;
|
|
551
|
-
var id = await _writeRow(customerId, "expire", toBurn, null,
|
|
563
|
+
var id = await _writeRow(customerId, "expire", toBurn, null, SWEEP_SOURCE_REF, null, after, null, ts);
|
|
552
564
|
processed.push({
|
|
553
565
|
id: id,
|
|
554
566
|
customer_id: customerId,
|
package/lib/storefront.js
CHANGED
|
@@ -5795,6 +5795,21 @@ function _subscriptionControlState(sub) {
|
|
|
5795
5795
|
return "active";
|
|
5796
5796
|
}
|
|
5797
5797
|
|
|
5798
|
+
// Stripe statuses that wind a subscription down for good. A subscription
|
|
5799
|
+
// the customer cancelled at Stripe (or that lapsed unpaid) syncs to one
|
|
5800
|
+
// of these via the `customer.subscription.*` webhook, which writes
|
|
5801
|
+
// `status` but NOT the local `cancelled_at`. Without checking the synced
|
|
5802
|
+
// status, a Stripe-cancelled subscription whose local control-state is
|
|
5803
|
+
// still "active" would render live pause / skip / quantity / frequency
|
|
5804
|
+
// controls that mutate a row Stripe no longer bills — a customer would
|
|
5805
|
+
// "pause" or "change the quantity" of a subscription that's already
|
|
5806
|
+
// dead. So the self-manage controls are gated on the synced status: a
|
|
5807
|
+
// terminally-wound-down subscription shows its state, not live controls.
|
|
5808
|
+
var TERMINAL_SUB_STATUSES = ["canceled", "incomplete_expired", "unpaid"];
|
|
5809
|
+
function _subscriptionSelfManageable(sub) {
|
|
5810
|
+
return TERMINAL_SUB_STATUSES.indexOf(sub.status) === -1;
|
|
5811
|
+
}
|
|
5812
|
+
|
|
5798
5813
|
// The 90-day reactivation grace mirrors subscriptionControls.REACTIVATE_
|
|
5799
5814
|
// GRACE_MS. A cancelled row is reactivatable from the storefront while
|
|
5800
5815
|
// the cancellation is inside that window; past it, the primitive refuses
|
|
@@ -5841,6 +5856,13 @@ var SUBSCRIPTION_ERR = {
|
|
|
5841
5856
|
frequency: "Choose a valid delivery frequency.",
|
|
5842
5857
|
state: "That change isn't available for this subscription right now.",
|
|
5843
5858
|
grace: "This subscription was cancelled too long ago to reactivate. Start a new one instead.",
|
|
5859
|
+
// Stripe doesn't let an active subscription's billing cadence change in
|
|
5860
|
+
// place; the customer cancels + re-subscribes on a plan with the
|
|
5861
|
+
// cadence they want.
|
|
5862
|
+
freq_locked: "Delivery frequency can't be changed on this subscription. Cancel it and start a new one on the cadence you want.",
|
|
5863
|
+
// A Stripe-side quantity push failed; the local quantity was NOT
|
|
5864
|
+
// changed, so the customer sees the unchanged value and can retry.
|
|
5865
|
+
processor: "We couldn't update your subscription with the payment processor. Nothing changed — please try again.",
|
|
5844
5866
|
};
|
|
5845
5867
|
function _subscriptionErrorCopy(code) {
|
|
5846
5868
|
if (code == null) return null;
|
|
@@ -5853,14 +5875,23 @@ function _subscriptionErrorCopy(code) {
|
|
|
5853
5875
|
// payment handle wired — the list renders read-only with a note, since
|
|
5854
5876
|
// cancel composes Stripe. `opts.self_manage` adds the pause / resume /
|
|
5855
5877
|
// skip / change-quantity / change-frequency / reactivate controls (wired
|
|
5856
|
-
// only when the subscriptionControls primitive is available).
|
|
5857
|
-
//
|
|
5858
|
-
//
|
|
5878
|
+
// only when the subscriptionControls primitive is available).
|
|
5879
|
+
// `opts.stripe_backed` is true when those controls can reach Stripe — it
|
|
5880
|
+
// suppresses the change-frequency form on a Stripe-backed row (the
|
|
5881
|
+
// billing interval is immutable at Stripe) and gates the whole control
|
|
5882
|
+
// block on the synced status so a Stripe-cancelled subscription shows its
|
|
5883
|
+
// state, not live controls. Empty state points at the catalog (creation
|
|
5884
|
+
// is a separate Stripe-subscription-checkout surface, not built here).
|
|
5859
5885
|
function renderAccountSubscriptions(opts) {
|
|
5860
5886
|
var esc = b.template.escapeHtml;
|
|
5861
5887
|
var subs = opts.subscriptions || [];
|
|
5862
5888
|
var canCancel = opts.can_cancel !== false;
|
|
5863
5889
|
var selfManage = opts.self_manage === true;
|
|
5890
|
+
// True when the controls can actually reach Stripe (retrieve+update
|
|
5891
|
+
// wired). Only then is a row's billing cadence processor-locked and the
|
|
5892
|
+
// frequency control suppressed; a local-only controls instance keeps
|
|
5893
|
+
// offering it even on a Stripe-shaped row.
|
|
5894
|
+
var stripeAware = opts.stripe_backed === true;
|
|
5864
5895
|
var rowsHtml = "";
|
|
5865
5896
|
for (var i = 0; i < subs.length; i += 1) {
|
|
5866
5897
|
var s = subs[i];
|
|
@@ -5920,11 +5951,22 @@ function renderAccountSubscriptions(opts) {
|
|
|
5920
5951
|
// cancel (a second server-rendered screen, no inline confirm()); the
|
|
5921
5952
|
// reversible controls post directly. Quantity / frequency take input
|
|
5922
5953
|
// validated server-side.
|
|
5954
|
+
//
|
|
5955
|
+
// The active + paused control sets are additionally gated on the
|
|
5956
|
+
// SYNCED Stripe status: a subscription Stripe has wound down
|
|
5957
|
+
// (canceled / incomplete_expired / unpaid via webhook) but whose
|
|
5958
|
+
// LOCAL control-state still reads "active"/"paused" must NOT offer
|
|
5959
|
+
// pause / skip / quantity / frequency / resume — those would mutate a
|
|
5960
|
+
// row Stripe no longer bills. The cancelled-within-grace REACTIVATE
|
|
5961
|
+
// path is intentionally NOT gated this way: a Stripe-canceled status
|
|
5962
|
+
// is the normal state of a row the customer is reactivating, and
|
|
5963
|
+
// reactivate is the recovery action for exactly that case.
|
|
5923
5964
|
var manageControls = "";
|
|
5965
|
+
var liveManageable = _subscriptionSelfManageable(s);
|
|
5924
5966
|
if (selfManage) {
|
|
5925
5967
|
var actId = esc(s.id);
|
|
5926
5968
|
var ctrls = "";
|
|
5927
|
-
if (controlState === "active") {
|
|
5969
|
+
if (controlState === "active" && liveManageable) {
|
|
5928
5970
|
ctrls +=
|
|
5929
5971
|
"<a class=\"btn-ghost btn-ghost--sm\" href=\"/account/subscriptions/" + actId + "/pause\">Pause</a>";
|
|
5930
5972
|
ctrls +=
|
|
@@ -5939,15 +5981,26 @@ function renderAccountSubscriptions(opts) {
|
|
|
5939
5981
|
"</label>" +
|
|
5940
5982
|
"<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Update quantity</button>" +
|
|
5941
5983
|
"</form>";
|
|
5942
|
-
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
|
|
5948
|
-
|
|
5949
|
-
|
|
5950
|
-
|
|
5984
|
+
// The frequency control only renders for a shop-local (non-
|
|
5985
|
+
// Stripe) subscription. A Stripe-backed subscription's billing
|
|
5986
|
+
// cadence is fixed by its Stripe Price (interval is immutable),
|
|
5987
|
+
// so there's nothing for this form to change — offering it would
|
|
5988
|
+
// be a button that always errors. `stripeAware` is set only when
|
|
5989
|
+
// the controls can reach Stripe; combined with the row's
|
|
5990
|
+
// `stripe_subscription_id` it identifies a genuinely Stripe-
|
|
5991
|
+
// backed row.
|
|
5992
|
+
var stripeBacked = stripeAware && s.stripe_subscription_id != null && String(s.stripe_subscription_id).length > 0;
|
|
5993
|
+
if (!stripeBacked) {
|
|
5994
|
+
ctrls +=
|
|
5995
|
+
"<form class=\"subscription-card__control subscription-card__control--freq\" method=\"post\" action=\"/account/subscriptions/" + actId + "/frequency\">" +
|
|
5996
|
+
"<label class=\"form-field form-field--inline\">" +
|
|
5997
|
+
"<span class=\"form-field__label\">Frequency</span>" +
|
|
5998
|
+
"<select name=\"frequency\" required>" + _frequencyOptions(s.frequency || null) + "</select>" +
|
|
5999
|
+
"</label>" +
|
|
6000
|
+
"<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Update frequency</button>" +
|
|
6001
|
+
"</form>";
|
|
6002
|
+
}
|
|
6003
|
+
} else if (controlState === "paused" && liveManageable) {
|
|
5951
6004
|
ctrls +=
|
|
5952
6005
|
"<form class=\"subscription-card__control\" method=\"post\" action=\"/account/subscriptions/" + actId + "/resume\">" +
|
|
5953
6006
|
"<button type=\"submit\" class=\"btn-primary btn-primary--sm\">Resume</button>" +
|
|
@@ -13999,6 +14052,16 @@ function mount(router, deps) {
|
|
|
13999
14052
|
// fat-fingered value re-prompts rather than 500-ing checkout.
|
|
14000
14053
|
var code = (e && typeof e.code === "string") ? e.code : "";
|
|
14001
14054
|
var msg = (e && e.message) || "checkout failed";
|
|
14055
|
+
// Lost the single-charge claim: another POST for this cart (a double-
|
|
14056
|
+
// click / second tab) is already converting it. The winner created
|
|
14057
|
+
// (or is creating) the one order; bounce this loser to the cart so it
|
|
14058
|
+
// can't start a second checkout. The winner's redirect carries the
|
|
14059
|
+
// shopper to /pay or /orders — this branch just refuses the duplicate.
|
|
14060
|
+
if (code === "CHECKOUT_IN_PROGRESS") {
|
|
14061
|
+
res.status(303);
|
|
14062
|
+
res.setHeader && res.setHeader("location", "/cart");
|
|
14063
|
+
return res.end ? res.end() : res.send("");
|
|
14064
|
+
}
|
|
14002
14065
|
// A coded gift-card / loyalty / out-of-stock error is something the
|
|
14003
14066
|
// shopper can fix in place — re-render the checkout form with the
|
|
14004
14067
|
// message inline (preserving the cart + their prefilled fields where
|
|
@@ -17477,6 +17540,11 @@ function mount(router, deps) {
|
|
|
17477
17540
|
subscriptions: rows,
|
|
17478
17541
|
can_cancel: subsCanCancel,
|
|
17479
17542
|
self_manage: !!subControls,
|
|
17543
|
+
// The frequency control is suppressed for Stripe-backed rows
|
|
17544
|
+
// ONLY when the controls can actually reach Stripe (a payment
|
|
17545
|
+
// handle with retrieve + update was wired) — otherwise the
|
|
17546
|
+
// controls are local-only and the cadence is still editable.
|
|
17547
|
+
stripe_backed: !!(subControls && subControls.stripeBacked),
|
|
17480
17548
|
notice: notice,
|
|
17481
17549
|
error: _subscriptionErrorCopy(errKind),
|
|
17482
17550
|
shop_name: shopName,
|
|
@@ -17503,6 +17571,7 @@ function mount(router, deps) {
|
|
|
17503
17571
|
if (e && e.code === "SUBSCRIPTION_REACTIVATE_GRACE_EXPIRED") return "grace";
|
|
17504
17572
|
if (e && e.code === "SUBSCRIPTION_STATE_REFUSED") return "state";
|
|
17505
17573
|
if (e && e.code === "SUBSCRIPTION_NOT_FOUND") return "state";
|
|
17574
|
+
if (e && e.code === "SUBSCRIPTION_FREQUENCY_IMMUTABLE") return "freq_locked";
|
|
17506
17575
|
if (e instanceof TypeError) return "state";
|
|
17507
17576
|
return null;
|
|
17508
17577
|
}
|
|
@@ -17512,13 +17581,32 @@ function mount(router, deps) {
|
|
|
17512
17581
|
return res.end ? res.end() : res.send("");
|
|
17513
17582
|
}
|
|
17514
17583
|
|
|
17584
|
+
// Resolve an owned subscription AND refuse the self-manage action
|
|
17585
|
+
// when the synced Stripe status is terminal (canceled /
|
|
17586
|
+
// incomplete_expired / unpaid). The display gates the controls on
|
|
17587
|
+
// the same status, but the backend validates independently — a
|
|
17588
|
+
// forged POST to a wound-down subscription's pause / skip /
|
|
17589
|
+
// quantity / frequency endpoint bounces to the list with the
|
|
17590
|
+
// generic state error rather than mutating a row Stripe no longer
|
|
17591
|
+
// bills. Returns null (after redirecting) when ownership fails or
|
|
17592
|
+
// the status is terminal; the caller bails on null.
|
|
17593
|
+
async function _ownedManageableSubscription(req, res, auth) {
|
|
17594
|
+
var sub = await _ownedSubscription(req, res, auth);
|
|
17595
|
+
if (!sub) return null;
|
|
17596
|
+
if (!_subscriptionSelfManageable(sub)) {
|
|
17597
|
+
_redirect(res, "?error=state");
|
|
17598
|
+
return null;
|
|
17599
|
+
}
|
|
17600
|
+
return sub;
|
|
17601
|
+
}
|
|
17602
|
+
|
|
17515
17603
|
// Pause is confirm-gated (GET → POST), mirroring cancel — a
|
|
17516
17604
|
// deliberate, reversible hold. The confirm page only renders for a
|
|
17517
17605
|
// currently-active subscription; a paused/cancelled row bounces
|
|
17518
17606
|
// back to the list.
|
|
17519
17607
|
router.get("/account/subscriptions/:id/pause", async function (req, res) {
|
|
17520
17608
|
var auth = _subsAuth(req, res); if (!auth) return;
|
|
17521
|
-
var sub = await
|
|
17609
|
+
var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
|
|
17522
17610
|
if (_subscriptionControlState(sub) !== "active") return _redirect(res, "");
|
|
17523
17611
|
if (sub.plan_id != null) {
|
|
17524
17612
|
try { sub.plan = await subscriptions.plans.get(sub.plan_id); }
|
|
@@ -17534,7 +17622,7 @@ function mount(router, deps) {
|
|
|
17534
17622
|
|
|
17535
17623
|
router.post("/account/subscriptions/:id/pause", async function (req, res) {
|
|
17536
17624
|
var auth = _subsAuth(req, res); if (!auth) return;
|
|
17537
|
-
var sub = await
|
|
17625
|
+
var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
|
|
17538
17626
|
try {
|
|
17539
17627
|
await subControls.pause({ subscription_id: sub.id, reason: "customer self-service pause", actor: SELF_ACTOR });
|
|
17540
17628
|
} catch (e) {
|
|
@@ -17546,7 +17634,7 @@ function mount(router, deps) {
|
|
|
17546
17634
|
|
|
17547
17635
|
router.post("/account/subscriptions/:id/resume", async function (req, res) {
|
|
17548
17636
|
var auth = _subsAuth(req, res); if (!auth) return;
|
|
17549
|
-
var sub = await
|
|
17637
|
+
var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
|
|
17550
17638
|
try {
|
|
17551
17639
|
await subControls.resume({ subscription_id: sub.id, reason: "customer self-service resume", actor: SELF_ACTOR });
|
|
17552
17640
|
} catch (e) {
|
|
@@ -17558,7 +17646,7 @@ function mount(router, deps) {
|
|
|
17558
17646
|
|
|
17559
17647
|
router.post("/account/subscriptions/:id/skip", async function (req, res) {
|
|
17560
17648
|
var auth = _subsAuth(req, res); if (!auth) return;
|
|
17561
|
-
var sub = await
|
|
17649
|
+
var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
|
|
17562
17650
|
try {
|
|
17563
17651
|
await subControls.skipNext({ subscription_id: sub.id, count: 1, reason: "customer self-service skip", actor: SELF_ACTOR });
|
|
17564
17652
|
} catch (e) {
|
|
@@ -17570,7 +17658,7 @@ function mount(router, deps) {
|
|
|
17570
17658
|
|
|
17571
17659
|
router.post("/account/subscriptions/:id/quantity", async function (req, res) {
|
|
17572
17660
|
var auth = _subsAuth(req, res); if (!auth) return;
|
|
17573
|
-
var sub = await
|
|
17661
|
+
var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
|
|
17574
17662
|
// Backend validates: a non-positive / non-integer / missing value
|
|
17575
17663
|
// is a client error → bounce with the quantity error code rather
|
|
17576
17664
|
// than handing garbage to the primitive (which would throw a
|
|
@@ -17581,6 +17669,14 @@ function mount(router, deps) {
|
|
|
17581
17669
|
await subControls.changeQuantity({ subscription_id: sub.id, new_quantity: qty, reason: "customer self-service quantity change", actor: SELF_ACTOR });
|
|
17582
17670
|
} catch (e) {
|
|
17583
17671
|
if (e instanceof TypeError) return _redirect(res, "?error=quantity");
|
|
17672
|
+
// A Stripe-side push failure (no billable item, or the
|
|
17673
|
+
// processor rejected/timed out the update) left the local
|
|
17674
|
+
// quantity unchanged. Surface a clean "nothing changed, retry"
|
|
17675
|
+
// notice instead of 500-ing — the customer's row still shows
|
|
17676
|
+
// the old quantity, which is the truth.
|
|
17677
|
+
if (e && (e.code === "SUBSCRIPTION_STRIPE_NO_ITEM" || e.code === "SUBSCRIPTION_STRIPE_PUSH_FAILED")) {
|
|
17678
|
+
return _redirect(res, "?error=processor");
|
|
17679
|
+
}
|
|
17584
17680
|
var code = _controlError(e); if (code == null) throw e;
|
|
17585
17681
|
return _redirect(res, "?error=" + code);
|
|
17586
17682
|
}
|
|
@@ -17589,7 +17685,7 @@ function mount(router, deps) {
|
|
|
17589
17685
|
|
|
17590
17686
|
router.post("/account/subscriptions/:id/frequency", async function (req, res) {
|
|
17591
17687
|
var auth = _subsAuth(req, res); if (!auth) return;
|
|
17592
|
-
var sub = await
|
|
17688
|
+
var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
|
|
17593
17689
|
// Backend validates: reject anything outside the allowed cadence
|
|
17594
17690
|
// enum before composing the primitive.
|
|
17595
17691
|
var freq = String((req.body || {}).frequency || "");
|
|
@@ -17606,6 +17702,11 @@ function mount(router, deps) {
|
|
|
17606
17702
|
|
|
17607
17703
|
router.post("/account/subscriptions/:id/reactivate", async function (req, res) {
|
|
17608
17704
|
var auth = _subsAuth(req, res); if (!auth) return;
|
|
17705
|
+
// Reactivate is the recovery path for a cancelled subscription,
|
|
17706
|
+
// so it is NOT gated on the terminal-status guard (a Stripe-
|
|
17707
|
+
// canceled status is the normal state of a row being
|
|
17708
|
+
// reactivated). The primitive enforces the cancelled-state +
|
|
17709
|
+
// grace-window FSM.
|
|
17609
17710
|
var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
|
|
17610
17711
|
try {
|
|
17611
17712
|
await subControls.reactivate({ subscription_id: sub.id, reason: "customer self-service reactivate", actor: SELF_ACTOR });
|
|
@@ -17968,6 +18069,18 @@ function mount(router, deps) {
|
|
|
17968
18069
|
var auth = _returnsAuth(req, res); if (!auth) return;
|
|
17969
18070
|
var order = await _ownedOrder(req, res, auth); if (!order) return;
|
|
17970
18071
|
var cartCount = await _cartCountForReq(req);
|
|
18072
|
+
// An ineligible order (unpaid, cancelled, or already refunded) never
|
|
18073
|
+
// gets the open-return form — the same window the return button is
|
|
18074
|
+
// gated on. Without this the form renders on, say, a refunded order
|
|
18075
|
+
// and the POST below would mint an RMA the operator could then refund
|
|
18076
|
+
// a SECOND time through the provider.
|
|
18077
|
+
if (!_orderEligibleForReturn(order.status)) {
|
|
18078
|
+
return _send(res, 400, renderReturnForm({
|
|
18079
|
+
order: order, lines: order.lines || [],
|
|
18080
|
+
notice: "This order isn't eligible for a return.",
|
|
18081
|
+
shop_name: shopName, cart_count: cartCount,
|
|
18082
|
+
}));
|
|
18083
|
+
}
|
|
17971
18084
|
_send(res, 200, renderReturnForm({ order: order, lines: order.lines || [], shop_name: shopName, cart_count: cartCount }));
|
|
17972
18085
|
});
|
|
17973
18086
|
|
|
@@ -17976,6 +18089,21 @@ function mount(router, deps) {
|
|
|
17976
18089
|
var order = await _ownedOrder(req, res, auth); if (!order) return;
|
|
17977
18090
|
var body = req.body || {};
|
|
17978
18091
|
var cartCount = await _cartCountForReq(req);
|
|
18092
|
+
// Server-side eligibility gate — the load-bearing check. A return is
|
|
18093
|
+
// only openable while the goods are in the customer's hands and paid
|
|
18094
|
+
// for (paid / fulfilling / shipped / delivered); an unpaid pending
|
|
18095
|
+
// order, a cancelled one, or an already-refunded one is refused. The
|
|
18096
|
+
// form/button gate on the same predicate, but a forged direct POST —
|
|
18097
|
+
// or an order that changed state between the GET and this POST — must
|
|
18098
|
+
// not slip an RMA onto an ineligible order (which would enable a
|
|
18099
|
+
// second provider refund downstream).
|
|
18100
|
+
if (!_orderEligibleForReturn(order.status)) {
|
|
18101
|
+
return _send(res, 400, renderReturnForm({
|
|
18102
|
+
order: order, lines: order.lines || [],
|
|
18103
|
+
notice: "This order isn't eligible for a return.",
|
|
18104
|
+
shop_name: shopName, cart_count: cartCount,
|
|
18105
|
+
}));
|
|
18106
|
+
}
|
|
17979
18107
|
// Build the return lines from the order's own lines (authoritative
|
|
17980
18108
|
// sku/qty), keyed by the checkboxes the customer ticked — never
|
|
17981
18109
|
// trust a client-supplied sku.
|
|
@@ -242,6 +242,77 @@ function create(opts) {
|
|
|
242
242
|
if (!subscriptionsHandle || typeof subscriptionsHandle.get !== "function") {
|
|
243
243
|
throw new TypeError("subscriptionControls.create: opts.subscriptions handle required");
|
|
244
244
|
}
|
|
245
|
+
// Optional payment handle (the shared Stripe adapter). When wired,
|
|
246
|
+
// quantity changes on a Stripe-backed subscription are pushed to
|
|
247
|
+
// Stripe BEFORE the local row is touched, so the row the shop shows
|
|
248
|
+
// and the quantity Stripe actually bills never diverge. Without it
|
|
249
|
+
// (a deploy with no payment processor, or a non-Stripe row), the
|
|
250
|
+
// controls stay local-only. The handle must expose
|
|
251
|
+
// `subscriptions.retrieve(id)` + `subscriptions.update(id, body)`.
|
|
252
|
+
var payment = opts.payment || null;
|
|
253
|
+
var hasStripe = !!(payment && payment.subscriptions &&
|
|
254
|
+
typeof payment.subscriptions.update === "function" &&
|
|
255
|
+
typeof payment.subscriptions.retrieve === "function");
|
|
256
|
+
|
|
257
|
+
// A row is Stripe-backed when the processor adapter is wired AND the
|
|
258
|
+
// row carries the upstream subscription id the webhook + billing
|
|
259
|
+
// mirror key on. Rows without one are shop-local (e.g. a manually
|
|
260
|
+
// seeded subscription on a deploy that never reached Stripe) and the
|
|
261
|
+
// controls mutate only local columns for them.
|
|
262
|
+
function _isStripeBacked(row) {
|
|
263
|
+
return hasStripe && row && typeof row.stripe_subscription_id === "string" && row.stripe_subscription_id.length > 0;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Push a new line quantity to Stripe for a Stripe-backed subscription.
|
|
267
|
+
// Stripe models quantity on the subscription ITEM, not the
|
|
268
|
+
// subscription, so we retrieve the live subscription to find its
|
|
269
|
+
// (single) item id, then update that item's quantity. Every
|
|
270
|
+
// subscription this shop creates binds exactly one price
|
|
271
|
+
// (`items: [{ price }]`), so the first item is authoritative; if a
|
|
272
|
+
// subscription somehow carries no item we surface a structured error
|
|
273
|
+
// rather than silently writing a local-only change that diverges from
|
|
274
|
+
// Stripe. The idempotency key folds in the subscription id + target
|
|
275
|
+
// quantity so a retried call is a safe no-op at Stripe.
|
|
276
|
+
async function _pushQuantityToStripe(stripeSubscriptionId, newQuantity) {
|
|
277
|
+
var live;
|
|
278
|
+
try {
|
|
279
|
+
live = await payment.subscriptions.retrieve(stripeSubscriptionId);
|
|
280
|
+
} catch (e) {
|
|
281
|
+
var rErr = new Error(
|
|
282
|
+
"subscriptionControls.changeQuantity: could not reach Stripe to update the subscription — " + (e && e.message || e),
|
|
283
|
+
);
|
|
284
|
+
rErr.code = "SUBSCRIPTION_STRIPE_PUSH_FAILED";
|
|
285
|
+
rErr.cause = e;
|
|
286
|
+
throw rErr;
|
|
287
|
+
}
|
|
288
|
+
var items = live && live.items && Array.isArray(live.items.data) ? live.items.data : [];
|
|
289
|
+
if (!items.length || !items[0] || !items[0].id) {
|
|
290
|
+
var noItem = new Error(
|
|
291
|
+
"subscriptionControls.changeQuantity: Stripe subscription " + stripeSubscriptionId + " has no billable item to update",
|
|
292
|
+
);
|
|
293
|
+
noItem.code = "SUBSCRIPTION_STRIPE_NO_ITEM";
|
|
294
|
+
throw noItem;
|
|
295
|
+
}
|
|
296
|
+
var idemKey = "subctl:qty:" + stripeSubscriptionId + ":" + newQuantity;
|
|
297
|
+
try {
|
|
298
|
+
return await payment.subscriptions.update(
|
|
299
|
+
stripeSubscriptionId,
|
|
300
|
+
{ items: [{ id: items[0].id, quantity: newQuantity }] },
|
|
301
|
+
idemKey,
|
|
302
|
+
);
|
|
303
|
+
} catch (e2) {
|
|
304
|
+
// The processor rejected or failed the quantity update. Wrap it in
|
|
305
|
+
// a stable code so the route can surface a "nothing changed, retry"
|
|
306
|
+
// notice; the local row is still untouched (this runs before the
|
|
307
|
+
// local write).
|
|
308
|
+
var uErr = new Error(
|
|
309
|
+
"subscriptionControls.changeQuantity: Stripe rejected the quantity update — " + (e2 && e2.message || e2),
|
|
310
|
+
);
|
|
311
|
+
uErr.code = "SUBSCRIPTION_STRIPE_PUSH_FAILED";
|
|
312
|
+
uErr.cause = e2;
|
|
313
|
+
throw uErr;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
245
316
|
|
|
246
317
|
// Fetch the raw subscription row + bound plan in one round-trip,
|
|
247
318
|
// returning `null` if either is absent. The plan lookup feeds the
|
|
@@ -317,6 +388,14 @@ function create(opts) {
|
|
|
317
388
|
MAX_SKIP_COUNT: MAX_SKIP_COUNT,
|
|
318
389
|
MAX_QUANTITY: MAX_QUANTITY,
|
|
319
390
|
REACTIVATE_GRACE_MS: REACTIVATE_GRACE_MS,
|
|
391
|
+
// True when this instance can push changes to Stripe (a payment
|
|
392
|
+
// handle with retrieve + update was wired). Callers use it to decide
|
|
393
|
+
// whether a row with a stripe_subscription_id is genuinely Stripe-
|
|
394
|
+
// backed — a frequency change is refused (immutable interval) and a
|
|
395
|
+
// quantity change round-trips Stripe. Without it, the controls are
|
|
396
|
+
// local-only even for a Stripe-shaped row, so the storefront keeps
|
|
397
|
+
// offering the frequency control.
|
|
398
|
+
stripeBacked: hasStripe,
|
|
320
399
|
|
|
321
400
|
pause: async function (input) {
|
|
322
401
|
if (!input || typeof input !== "object") {
|
|
@@ -462,6 +541,18 @@ function create(opts) {
|
|
|
462
541
|
throw cErr;
|
|
463
542
|
}
|
|
464
543
|
|
|
544
|
+
// Stripe-backed: push the new quantity to Stripe BEFORE the local
|
|
545
|
+
// write. If the processor rejects the update, we throw and leave
|
|
546
|
+
// the local row untouched — the customer sees an honest error and
|
|
547
|
+
// the shop's `quantity` column never diverges from what Stripe
|
|
548
|
+
// actually bills (the divergence this control surface previously
|
|
549
|
+
// produced, writing a local "Quantity updated." while Stripe kept
|
|
550
|
+
// charging the old quantity). The local write only lands after the
|
|
551
|
+
// Stripe update succeeds.
|
|
552
|
+
if (_isStripeBacked(row)) {
|
|
553
|
+
await _pushQuantityToStripe(row.stripe_subscription_id, newQuantity);
|
|
554
|
+
}
|
|
555
|
+
|
|
465
556
|
var before = _snapshot(row);
|
|
466
557
|
var ts = _now();
|
|
467
558
|
await query(
|
|
@@ -495,6 +586,28 @@ function create(opts) {
|
|
|
495
586
|
throw cErr;
|
|
496
587
|
}
|
|
497
588
|
|
|
589
|
+
// Stripe-backed: refuse. A Stripe Price's `recurring.interval` is
|
|
590
|
+
// IMMUTABLE — you cannot re-cadence an existing price, only swap
|
|
591
|
+
// the subscription item to a DIFFERENT price with the desired
|
|
592
|
+
// interval. This shop binds each subscription to a single
|
|
593
|
+
// `stripe_price_id` from its plan and carries no catalog of
|
|
594
|
+
// per-frequency prices to swap between, so the billing interval
|
|
595
|
+
// simply isn't expressible here. Writing the local `frequency`
|
|
596
|
+
// column anyway would tell the customer "Delivery frequency
|
|
597
|
+
// updated" while Stripe kept invoicing on the original cadence —
|
|
598
|
+
// exactly the divergence this surface must not create. Refuse with
|
|
599
|
+
// honest copy; the customer cancels + re-subscribes on a plan with
|
|
600
|
+
// the cadence they want. (A non-Stripe row falls through to the
|
|
601
|
+
// local-only recompute below — the shop's own cadence view.)
|
|
602
|
+
if (_isStripeBacked(row)) {
|
|
603
|
+
var fErr = new Error(
|
|
604
|
+
"subscriptionControls.changeFrequency: delivery frequency can't be changed on this subscription — " +
|
|
605
|
+
"cancel and start a new subscription on a plan with the cadence you want",
|
|
606
|
+
);
|
|
607
|
+
fErr.code = "SUBSCRIPTION_FREQUENCY_IMMUTABLE";
|
|
608
|
+
throw fErr;
|
|
609
|
+
}
|
|
610
|
+
|
|
498
611
|
// next_billing_at is recomputed off the row's current cycle
|
|
499
612
|
// anchor — `current_period_start` (Stripe-mirrored) if present,
|
|
500
613
|
// otherwise the current next_billing_at, otherwise now. One
|
package/package.json
CHANGED