@blamejs/blamejs-shop 0.4.20 → 0.4.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/collections.js +282 -14
- package/lib/gift-card-ledger.js +37 -8
- package/lib/gift-registry.js +43 -5
- package/lib/loyalty-earn-rules.js +106 -0
- package/lib/loyalty.js +63 -30
- package/lib/order.js +24 -0
- package/lib/search-ranking.js +58 -2
- package/lib/store-credit.js +31 -19
- package/lib/storefront.js +110 -19
- package/lib/subscription-controls.js +113 -0
- package/package.json +1 -1
package/lib/loyalty.js
CHANGED
|
@@ -228,6 +228,25 @@ function create(opts) {
|
|
|
228
228
|
);
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
// SQL fragment that derives the tier from a lifetime-points
|
|
232
|
+
// expression evaluated INSIDE the UPDATE statement, so balance,
|
|
233
|
+
// lifetime, and tier all move in one atomic write off the row's
|
|
234
|
+
// live value rather than a stale snapshot. `lifetimeExpr` is the
|
|
235
|
+
// post-mutation lifetime SQL (e.g. `lifetime_points + ?2`). The
|
|
236
|
+
// operator-tunable thresholds bind as literals — they're validated
|
|
237
|
+
// non-negative integers at factory time, never operator input here,
|
|
238
|
+
// so inlining them keeps the CASE a single self-contained
|
|
239
|
+
// expression without widening the bound-parameter list per call.
|
|
240
|
+
// Mirrors computeTier's highest-threshold-first ladder so the SQL
|
|
241
|
+
// and JS classifications never diverge.
|
|
242
|
+
function _tierCase(lifetimeExpr) {
|
|
243
|
+
return "CASE" +
|
|
244
|
+
" WHEN (" + lifetimeExpr + ") >= " + thresholds.platinum + " THEN 'platinum'" +
|
|
245
|
+
" WHEN (" + lifetimeExpr + ") >= " + thresholds.gold + " THEN 'gold'" +
|
|
246
|
+
" WHEN (" + lifetimeExpr + ") >= " + thresholds.silver + " THEN 'silver'" +
|
|
247
|
+
" ELSE 'bronze' END";
|
|
248
|
+
}
|
|
249
|
+
|
|
231
250
|
return {
|
|
232
251
|
TIERS: TIERS.slice(),
|
|
233
252
|
TX_TYPES: TX_TYPES.slice(),
|
|
@@ -260,24 +279,27 @@ function create(opts) {
|
|
|
260
279
|
|
|
261
280
|
var ts = _now();
|
|
262
281
|
await _ensureAccountRow(customerId, ts);
|
|
282
|
+
// Snapshot the tier ONLY to report `tier_changed` — the balance /
|
|
283
|
+
// lifetime / tier mutation itself is relative-atomic below, so a
|
|
284
|
+
// concurrent earn can't clobber this credit (the lost-update the
|
|
285
|
+
// absolute write suffered). The tier is recomputed in-SQL off the
|
|
286
|
+
// row's live `lifetime_points`, not this stale snapshot.
|
|
263
287
|
var before = await _readAccount(customerId);
|
|
264
|
-
var newLifetime = before.lifetime_points + points;
|
|
265
|
-
var newBalance = before.balance_points + points;
|
|
266
|
-
var newTier = computeTier(newLifetime);
|
|
267
|
-
var tierChanged = newTier !== before.tier;
|
|
268
|
-
|
|
269
288
|
await query(
|
|
270
|
-
"UPDATE loyalty_accounts SET balance_points =
|
|
271
|
-
"
|
|
272
|
-
|
|
289
|
+
"UPDATE loyalty_accounts SET balance_points = balance_points + ?1, " +
|
|
290
|
+
"lifetime_points = lifetime_points + ?1, " +
|
|
291
|
+
"tier = " + _tierCase("lifetime_points + ?1") + ", " +
|
|
292
|
+
"updated_at = ?2 WHERE customer_id = ?3",
|
|
293
|
+
[points, ts, customerId],
|
|
273
294
|
);
|
|
274
295
|
await _writeTx(customerId, "earn", points, source, orderId, notes, ts);
|
|
275
296
|
|
|
297
|
+
var after = await _readAccount(customerId);
|
|
276
298
|
return {
|
|
277
|
-
balance:
|
|
278
|
-
lifetime:
|
|
279
|
-
tier:
|
|
280
|
-
tier_changed:
|
|
299
|
+
balance: after.balance_points,
|
|
300
|
+
lifetime: after.lifetime_points,
|
|
301
|
+
tier: after.tier,
|
|
302
|
+
tier_changed: after.tier !== before.tier,
|
|
281
303
|
};
|
|
282
304
|
},
|
|
283
305
|
|
|
@@ -335,34 +357,45 @@ function create(opts) {
|
|
|
335
357
|
|
|
336
358
|
var ts = _now();
|
|
337
359
|
await _ensureAccountRow(customerId, ts);
|
|
360
|
+
// Snapshot the pre-adjust tier only to report `tier_changed`. The
|
|
361
|
+
// mutation is relative-atomic with an underflow guard at the SQL
|
|
362
|
+
// tier so two concurrent adjustments can't lose an update or drive
|
|
363
|
+
// the balance negative past each other.
|
|
338
364
|
var before = await _readAccount(customerId);
|
|
339
365
|
|
|
340
|
-
var newBalance = before.balance_points + delta;
|
|
341
|
-
if (newBalance < 0) {
|
|
342
|
-
var ins = new Error("loyalty.adjust: adjustment would underflow balance");
|
|
343
|
-
ins.code = "LOYALTY_INSUFFICIENT_BALANCE";
|
|
344
|
-
throw ins;
|
|
345
|
-
}
|
|
346
366
|
// Positive adjustments also increment lifetime — operators
|
|
347
367
|
// crediting a customer for a service recovery should see that
|
|
348
368
|
// credit count toward tier. Negative adjustments do NOT
|
|
349
369
|
// decrement lifetime (otherwise a clawback could downgrade tier
|
|
350
|
-
// retroactively, which is a customer-facing surprise).
|
|
351
|
-
|
|
352
|
-
var
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
370
|
+
// retroactively, which is a customer-facing surprise). The
|
|
371
|
+
// lifetime delta is therefore the positive part of `delta`.
|
|
372
|
+
var lifetimeDelta = delta > 0 ? delta : 0;
|
|
373
|
+
// Conditional UPDATE: the row mutates ONLY when the post-adjust
|
|
374
|
+
// balance stays non-negative, checked against the row's LIVE
|
|
375
|
+
// balance (not the stale snapshot). A racing concurrent adjust
|
|
376
|
+
// that already spent the balance makes this match zero rows, so
|
|
377
|
+
// we surface the same insufficient-balance refusal rather than
|
|
378
|
+
// writing a ledger row that diverges from the account.
|
|
379
|
+
var upd = await query(
|
|
380
|
+
"UPDATE loyalty_accounts SET balance_points = balance_points + ?1, " +
|
|
381
|
+
"lifetime_points = lifetime_points + ?2, " +
|
|
382
|
+
"tier = " + _tierCase("lifetime_points + ?2") + ", " +
|
|
383
|
+
"updated_at = ?3 WHERE customer_id = ?4 AND balance_points + ?1 >= 0",
|
|
384
|
+
[delta, lifetimeDelta, ts, customerId],
|
|
358
385
|
);
|
|
386
|
+
if (Number(upd.rowCount || 0) === 0) {
|
|
387
|
+
var ins = new Error("loyalty.adjust: adjustment would underflow balance");
|
|
388
|
+
ins.code = "LOYALTY_INSUFFICIENT_BALANCE";
|
|
389
|
+
throw ins;
|
|
390
|
+
}
|
|
359
391
|
await _writeTx(customerId, "adjust", delta, source, null, notes, ts);
|
|
360
392
|
|
|
393
|
+
var after = await _readAccount(customerId);
|
|
361
394
|
return {
|
|
362
|
-
balance:
|
|
363
|
-
lifetime:
|
|
364
|
-
tier:
|
|
365
|
-
tier_changed:
|
|
395
|
+
balance: after.balance_points,
|
|
396
|
+
lifetime: after.lifetime_points,
|
|
397
|
+
tier: after.tier,
|
|
398
|
+
tier_changed: after.tier !== before.tier,
|
|
366
399
|
};
|
|
367
400
|
},
|
|
368
401
|
|
package/lib/order.js
CHANGED
|
@@ -587,6 +587,30 @@ function create(opts) {
|
|
|
587
587
|
if (result.to === "cancelled" || result.to === "refunded") {
|
|
588
588
|
await _settleGiftCards(orderId);
|
|
589
589
|
}
|
|
590
|
+
// Loyalty earn-reversal fan-out — fire-and-forget, same discipline
|
|
591
|
+
// as the earn-on-purchase block below. On a cancel / refund edge for
|
|
592
|
+
// an order carrying a customer_id, the points awarded when the order
|
|
593
|
+
// went paid are clawed back off the balance (floored at zero), or a
|
|
594
|
+
// buy-then-refund mints free rewards. reverseForEvent claims the
|
|
595
|
+
// earn-log rows with an unreversed predicate, so it is idempotent (a
|
|
596
|
+
// re-delivered cancel webhook or the reaper racing a refund reverses
|
|
597
|
+
// exactly once) and a natural no-op for an order that never earned
|
|
598
|
+
// (a guest order, or one that never reached paid — the never-awarded
|
|
599
|
+
// earn-log is empty). The award is detached so a loyalty failure
|
|
600
|
+
// lives in the loyalty ledger's own audit trail, never as an
|
|
601
|
+
// unhandledRejection and never on the transition's latency.
|
|
602
|
+
if (loyaltyEarnRules && typeof loyaltyEarnRules.reverseForEvent === "function"
|
|
603
|
+
&& (result.to === "cancelled" || result.to === "refunded")
|
|
604
|
+
&& refreshed && refreshed.customer_id) {
|
|
605
|
+
var _revCustomer = refreshed.customer_id;
|
|
606
|
+
var _revOrderId = refreshed.id;
|
|
607
|
+
Promise.resolve().then(function () {
|
|
608
|
+
return loyaltyEarnRules.reverseForEvent({
|
|
609
|
+
customer_id: _revCustomer,
|
|
610
|
+
trigger_event_ref: "order:" + _revOrderId,
|
|
611
|
+
});
|
|
612
|
+
}).catch(function () { /* drop-silent — loyalty ledger holds its own audit trail */ });
|
|
613
|
+
}
|
|
590
614
|
// Fan-out to merchant webhook subscribers is fire-and-forget. The
|
|
591
615
|
// transition has already persisted; the request must not wait on
|
|
592
616
|
// outbound HTTP, or a slow / unreachable endpoint would block the
|
package/lib/search-ranking.js
CHANGED
|
@@ -700,6 +700,48 @@ function create(opts) {
|
|
|
700
700
|
if (input.session_id != null) {
|
|
701
701
|
sessionHash = _hashSession(_sessionIdRaw(input.session_id, "searchRanking.recordSearchEvent"));
|
|
702
702
|
}
|
|
703
|
+
|
|
704
|
+
// Server-side click attribution. A `click` carries a `?sq=<query>`
|
|
705
|
+
// marker from the result link, but the marker is attacker-
|
|
706
|
+
// controllable: anyone can hit a PDP with `?from=search&sq=dress`
|
|
707
|
+
// and inflate the click count for "dress" without ever having
|
|
708
|
+
// seen — let alone clicked through — a real result list. That let
|
|
709
|
+
// CTR be spoofed and pushed past 100% (more clicks than the
|
|
710
|
+
// impressions that were ever rendered). When the click carries a
|
|
711
|
+
// session, we only record it if THAT session already logged an
|
|
712
|
+
// `impression` for the same (weights_slug, query): the click must
|
|
713
|
+
// descend from a search the same session actually ran. An
|
|
714
|
+
// unattributed click (no matching impression for the session) is
|
|
715
|
+
// dropped — it never reaches the event log, so the rollup can't
|
|
716
|
+
// count it. Clicks without a session (a session-less worker
|
|
717
|
+
// deployment, or a click whose session cookie was lost) can't be
|
|
718
|
+
// attribution-checked, so they record as before; the storefront
|
|
719
|
+
// attaches the session whenever one exists, so the spoof path is
|
|
720
|
+
// closed in the deployed configuration.
|
|
721
|
+
if (eventType === "click" && sessionHash != null) {
|
|
722
|
+
var imp = await query(
|
|
723
|
+
"SELECT 1 FROM search_events " +
|
|
724
|
+
"WHERE weights_slug = ?1 AND query = ?2 AND session_id_hash = ?3 " +
|
|
725
|
+
"AND event_type = 'impression' LIMIT 1",
|
|
726
|
+
[weightsSlug, normalizedQuery, sessionHash]
|
|
727
|
+
);
|
|
728
|
+
if (!imp.rows.length) {
|
|
729
|
+
// No impression for this session + query under this weight
|
|
730
|
+
// set — the click can't be a genuine result-list click-
|
|
731
|
+
// through. Refuse to record it (drop, don't throw — the hot
|
|
732
|
+
// path swallows the result).
|
|
733
|
+
return {
|
|
734
|
+
query: normalizedQuery,
|
|
735
|
+
product_id: productId,
|
|
736
|
+
weights_slug: weightsSlug,
|
|
737
|
+
event_type: eventType,
|
|
738
|
+
position: position,
|
|
739
|
+
recorded: false,
|
|
740
|
+
reason: "click-without-matching-impression",
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
703
745
|
var ts = _now();
|
|
704
746
|
await query(
|
|
705
747
|
"INSERT INTO search_events " +
|
|
@@ -714,6 +756,7 @@ function create(opts) {
|
|
|
714
756
|
event_type: eventType,
|
|
715
757
|
position: position,
|
|
716
758
|
occurred_at: ts,
|
|
759
|
+
recorded: true,
|
|
717
760
|
};
|
|
718
761
|
},
|
|
719
762
|
|
|
@@ -757,6 +800,19 @@ function create(opts) {
|
|
|
757
800
|
else if (row.event_type === "click") clicks = c;
|
|
758
801
|
else if (row.event_type === "purchase") purchases = c;
|
|
759
802
|
}
|
|
803
|
+
// A single rendered result list (one impression) can legitimately
|
|
804
|
+
// yield more than one click — the shopper opens a product, returns
|
|
805
|
+
// to the same list, opens another. So clicks/impressions can
|
|
806
|
+
// exceed 1.0 even on honest data; an unbounded ratio reads as a
|
|
807
|
+
// nonsensical ">100% CTR" on the operator dashboard. Bound the
|
|
808
|
+
// reported CTR at 1.0 so the metric stays interpretable (the raw
|
|
809
|
+
// counts remain available for an operator who wants the unbounded
|
|
810
|
+
// figure). conversion_rate is bounded the same way — purchases are
|
|
811
|
+
// gated by clicks which are gated by impressions, but the bound
|
|
812
|
+
// keeps the displayed rate honest under the same multi-click
|
|
813
|
+
// reality.
|
|
814
|
+
var rawCtr = impressions > 0 ? clicks / impressions : null;
|
|
815
|
+
var rawConv = impressions > 0 ? purchases / impressions : null;
|
|
760
816
|
return {
|
|
761
817
|
weights_slug: weightsSlug,
|
|
762
818
|
from: from,
|
|
@@ -764,8 +820,8 @@ function create(opts) {
|
|
|
764
820
|
impressions: impressions,
|
|
765
821
|
clicks: clicks,
|
|
766
822
|
purchases: purchases,
|
|
767
|
-
ctr:
|
|
768
|
-
conversion_rate:
|
|
823
|
+
ctr: rawCtr == null ? null : Math.min(1, rawCtr),
|
|
824
|
+
conversion_rate: rawConv == null ? null : Math.min(1, rawConv),
|
|
769
825
|
click_to_purchase: clicks > 0 ? purchases / clicks : null,
|
|
770
826
|
};
|
|
771
827
|
},
|
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>" +
|
|
@@ -17487,6 +17540,11 @@ function mount(router, deps) {
|
|
|
17487
17540
|
subscriptions: rows,
|
|
17488
17541
|
can_cancel: subsCanCancel,
|
|
17489
17542
|
self_manage: !!subControls,
|
|
17543
|
+
// The frequency control is suppressed for Stripe-backed rows
|
|
17544
|
+
// ONLY when the controls can actually reach Stripe (a payment
|
|
17545
|
+
// handle with retrieve + update was wired) — otherwise the
|
|
17546
|
+
// controls are local-only and the cadence is still editable.
|
|
17547
|
+
stripe_backed: !!(subControls && subControls.stripeBacked),
|
|
17490
17548
|
notice: notice,
|
|
17491
17549
|
error: _subscriptionErrorCopy(errKind),
|
|
17492
17550
|
shop_name: shopName,
|
|
@@ -17513,6 +17571,7 @@ function mount(router, deps) {
|
|
|
17513
17571
|
if (e && e.code === "SUBSCRIPTION_REACTIVATE_GRACE_EXPIRED") return "grace";
|
|
17514
17572
|
if (e && e.code === "SUBSCRIPTION_STATE_REFUSED") return "state";
|
|
17515
17573
|
if (e && e.code === "SUBSCRIPTION_NOT_FOUND") return "state";
|
|
17574
|
+
if (e && e.code === "SUBSCRIPTION_FREQUENCY_IMMUTABLE") return "freq_locked";
|
|
17516
17575
|
if (e instanceof TypeError) return "state";
|
|
17517
17576
|
return null;
|
|
17518
17577
|
}
|
|
@@ -17522,13 +17581,32 @@ function mount(router, deps) {
|
|
|
17522
17581
|
return res.end ? res.end() : res.send("");
|
|
17523
17582
|
}
|
|
17524
17583
|
|
|
17584
|
+
// Resolve an owned subscription AND refuse the self-manage action
|
|
17585
|
+
// when the synced Stripe status is terminal (canceled /
|
|
17586
|
+
// incomplete_expired / unpaid). The display gates the controls on
|
|
17587
|
+
// the same status, but the backend validates independently — a
|
|
17588
|
+
// forged POST to a wound-down subscription's pause / skip /
|
|
17589
|
+
// quantity / frequency endpoint bounces to the list with the
|
|
17590
|
+
// generic state error rather than mutating a row Stripe no longer
|
|
17591
|
+
// bills. Returns null (after redirecting) when ownership fails or
|
|
17592
|
+
// the status is terminal; the caller bails on null.
|
|
17593
|
+
async function _ownedManageableSubscription(req, res, auth) {
|
|
17594
|
+
var sub = await _ownedSubscription(req, res, auth);
|
|
17595
|
+
if (!sub) return null;
|
|
17596
|
+
if (!_subscriptionSelfManageable(sub)) {
|
|
17597
|
+
_redirect(res, "?error=state");
|
|
17598
|
+
return null;
|
|
17599
|
+
}
|
|
17600
|
+
return sub;
|
|
17601
|
+
}
|
|
17602
|
+
|
|
17525
17603
|
// Pause is confirm-gated (GET → POST), mirroring cancel — a
|
|
17526
17604
|
// deliberate, reversible hold. The confirm page only renders for a
|
|
17527
17605
|
// currently-active subscription; a paused/cancelled row bounces
|
|
17528
17606
|
// back to the list.
|
|
17529
17607
|
router.get("/account/subscriptions/:id/pause", async function (req, res) {
|
|
17530
17608
|
var auth = _subsAuth(req, res); if (!auth) return;
|
|
17531
|
-
var sub = await
|
|
17609
|
+
var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
|
|
17532
17610
|
if (_subscriptionControlState(sub) !== "active") return _redirect(res, "");
|
|
17533
17611
|
if (sub.plan_id != null) {
|
|
17534
17612
|
try { sub.plan = await subscriptions.plans.get(sub.plan_id); }
|
|
@@ -17544,7 +17622,7 @@ function mount(router, deps) {
|
|
|
17544
17622
|
|
|
17545
17623
|
router.post("/account/subscriptions/:id/pause", async function (req, res) {
|
|
17546
17624
|
var auth = _subsAuth(req, res); if (!auth) return;
|
|
17547
|
-
var sub = await
|
|
17625
|
+
var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
|
|
17548
17626
|
try {
|
|
17549
17627
|
await subControls.pause({ subscription_id: sub.id, reason: "customer self-service pause", actor: SELF_ACTOR });
|
|
17550
17628
|
} catch (e) {
|
|
@@ -17556,7 +17634,7 @@ function mount(router, deps) {
|
|
|
17556
17634
|
|
|
17557
17635
|
router.post("/account/subscriptions/:id/resume", async function (req, res) {
|
|
17558
17636
|
var auth = _subsAuth(req, res); if (!auth) return;
|
|
17559
|
-
var sub = await
|
|
17637
|
+
var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
|
|
17560
17638
|
try {
|
|
17561
17639
|
await subControls.resume({ subscription_id: sub.id, reason: "customer self-service resume", actor: SELF_ACTOR });
|
|
17562
17640
|
} catch (e) {
|
|
@@ -17568,7 +17646,7 @@ function mount(router, deps) {
|
|
|
17568
17646
|
|
|
17569
17647
|
router.post("/account/subscriptions/:id/skip", async function (req, res) {
|
|
17570
17648
|
var auth = _subsAuth(req, res); if (!auth) return;
|
|
17571
|
-
var sub = await
|
|
17649
|
+
var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
|
|
17572
17650
|
try {
|
|
17573
17651
|
await subControls.skipNext({ subscription_id: sub.id, count: 1, reason: "customer self-service skip", actor: SELF_ACTOR });
|
|
17574
17652
|
} catch (e) {
|
|
@@ -17580,7 +17658,7 @@ function mount(router, deps) {
|
|
|
17580
17658
|
|
|
17581
17659
|
router.post("/account/subscriptions/:id/quantity", async function (req, res) {
|
|
17582
17660
|
var auth = _subsAuth(req, res); if (!auth) return;
|
|
17583
|
-
var sub = await
|
|
17661
|
+
var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
|
|
17584
17662
|
// Backend validates: a non-positive / non-integer / missing value
|
|
17585
17663
|
// is a client error → bounce with the quantity error code rather
|
|
17586
17664
|
// than handing garbage to the primitive (which would throw a
|
|
@@ -17591,6 +17669,14 @@ function mount(router, deps) {
|
|
|
17591
17669
|
await subControls.changeQuantity({ subscription_id: sub.id, new_quantity: qty, reason: "customer self-service quantity change", actor: SELF_ACTOR });
|
|
17592
17670
|
} catch (e) {
|
|
17593
17671
|
if (e instanceof TypeError) return _redirect(res, "?error=quantity");
|
|
17672
|
+
// A Stripe-side push failure (no billable item, or the
|
|
17673
|
+
// processor rejected/timed out the update) left the local
|
|
17674
|
+
// quantity unchanged. Surface a clean "nothing changed, retry"
|
|
17675
|
+
// notice instead of 500-ing — the customer's row still shows
|
|
17676
|
+
// the old quantity, which is the truth.
|
|
17677
|
+
if (e && (e.code === "SUBSCRIPTION_STRIPE_NO_ITEM" || e.code === "SUBSCRIPTION_STRIPE_PUSH_FAILED")) {
|
|
17678
|
+
return _redirect(res, "?error=processor");
|
|
17679
|
+
}
|
|
17594
17680
|
var code = _controlError(e); if (code == null) throw e;
|
|
17595
17681
|
return _redirect(res, "?error=" + code);
|
|
17596
17682
|
}
|
|
@@ -17599,7 +17685,7 @@ function mount(router, deps) {
|
|
|
17599
17685
|
|
|
17600
17686
|
router.post("/account/subscriptions/:id/frequency", async function (req, res) {
|
|
17601
17687
|
var auth = _subsAuth(req, res); if (!auth) return;
|
|
17602
|
-
var sub = await
|
|
17688
|
+
var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
|
|
17603
17689
|
// Backend validates: reject anything outside the allowed cadence
|
|
17604
17690
|
// enum before composing the primitive.
|
|
17605
17691
|
var freq = String((req.body || {}).frequency || "");
|
|
@@ -17616,6 +17702,11 @@ function mount(router, deps) {
|
|
|
17616
17702
|
|
|
17617
17703
|
router.post("/account/subscriptions/:id/reactivate", async function (req, res) {
|
|
17618
17704
|
var auth = _subsAuth(req, res); if (!auth) return;
|
|
17705
|
+
// Reactivate is the recovery path for a cancelled subscription,
|
|
17706
|
+
// so it is NOT gated on the terminal-status guard (a Stripe-
|
|
17707
|
+
// canceled status is the normal state of a row being
|
|
17708
|
+
// reactivated). The primitive enforces the cancelled-state +
|
|
17709
|
+
// grace-window FSM.
|
|
17619
17710
|
var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
|
|
17620
17711
|
try {
|
|
17621
17712
|
await subControls.reactivate({ subscription_id: sub.id, reason: "customer self-service reactivate", actor: SELF_ACTOR });
|