@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
|
@@ -648,6 +648,111 @@ function create(opts) {
|
|
|
648
648
|
};
|
|
649
649
|
}
|
|
650
650
|
|
|
651
|
+
// ---- reverseForEvent ------------------------------------------------
|
|
652
|
+
|
|
653
|
+
// Reverse every award booked for one (customer, event) — the
|
|
654
|
+
// counterpart to awardForEvent on an order's cancel / refund edge. A
|
|
655
|
+
// paid order awards points; if that order later dies the points must
|
|
656
|
+
// come back off the balance or a buy-then-refund mints free rewards.
|
|
657
|
+
//
|
|
658
|
+
// The earn-log `reversed_at` claim IS the idempotency guard: the
|
|
659
|
+
// `UPDATE ... WHERE reversed_at IS NULL` serializes a concurrent
|
|
660
|
+
// double-fire (a re-delivered webhook, or the stale-order reaper
|
|
661
|
+
// racing a refund) so the points are clawed back exactly once. A
|
|
662
|
+
// never-awarded event (a guest order, or one that never reached paid)
|
|
663
|
+
// claims zero rows and is a natural no-op — no paid-state precondition
|
|
664
|
+
// needed. Returns { reversed_points, clawed_points }: reversed_points
|
|
665
|
+
// is what the awards totalled; clawed_points is what actually came off
|
|
666
|
+
// the balance (floored at zero — a customer may have already spent the
|
|
667
|
+
// points, and the balance can't go negative).
|
|
668
|
+
async function reverseForEvent(input) {
|
|
669
|
+
if (!input || typeof input !== "object") {
|
|
670
|
+
throw new TypeError("loyaltyEarnRules.reverseForEvent: input object required");
|
|
671
|
+
}
|
|
672
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
673
|
+
var triggerEventRef = _triggerEventRef(input.trigger_event_ref);
|
|
674
|
+
|
|
675
|
+
// Atomic claim across every rule that awarded for this event. The
|
|
676
|
+
// unreversed predicate is the serialization point — a row claimed
|
|
677
|
+
// here can't be claimed by a racing reversal, and an already-reversed
|
|
678
|
+
// (or never-awarded) event claims nothing.
|
|
679
|
+
var ts = _now();
|
|
680
|
+
var claim = await query(
|
|
681
|
+
"UPDATE loyalty_earn_log SET reversed_at = ?1 " +
|
|
682
|
+
"WHERE customer_id = ?2 AND trigger_event_ref = ?3 AND reversed_at IS NULL",
|
|
683
|
+
[ts, customerId, triggerEventRef],
|
|
684
|
+
);
|
|
685
|
+
if (Number(claim.rowCount || 0) === 0) {
|
|
686
|
+
return { reversed_points: 0, clawed_points: 0 };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Sum exactly the rows this call claimed (reversed_at === ts pins them
|
|
690
|
+
// to this reversal, not an earlier one against the same event).
|
|
691
|
+
var sumRow = (await query(
|
|
692
|
+
"SELECT COALESCE(SUM(points_awarded), 0) AS earned FROM loyalty_earn_log " +
|
|
693
|
+
"WHERE customer_id = ?1 AND trigger_event_ref = ?2 AND reversed_at = ?3",
|
|
694
|
+
[customerId, triggerEventRef, ts],
|
|
695
|
+
)).rows[0] || { earned: 0 };
|
|
696
|
+
var earned = Number(sumRow.earned || 0);
|
|
697
|
+
|
|
698
|
+
// Claw the earned points back off the running balance, floored at
|
|
699
|
+
// zero. loyalty.adjust refuses an underflow by THROWING an Error with
|
|
700
|
+
// code LOYALTY_INSUFFICIENT_BALANCE; a concurrent spend can shrink the
|
|
701
|
+
// balance between our read and the adjust, so on that refusal we
|
|
702
|
+
// re-read and retry against the smaller balance (≤3 attempts). The
|
|
703
|
+
// non-negative guard inside adjust makes the race safe — the worst
|
|
704
|
+
// case is we claw less, never below zero, never negative. Lifetime is
|
|
705
|
+
// not decremented (adjust's stance — tier never downgrades
|
|
706
|
+
// retroactively). Skip the adjust entirely when there's nothing to
|
|
707
|
+
// claw (adjust requires a non-zero delta).
|
|
708
|
+
var clawed = 0;
|
|
709
|
+
if (loyaltyHandle && typeof loyaltyHandle.adjust === "function"
|
|
710
|
+
&& typeof loyaltyHandle.balance === "function" && earned > 0) {
|
|
711
|
+
try {
|
|
712
|
+
for (var attempt = 0; attempt < 3; attempt += 1) {
|
|
713
|
+
var bal = await loyaltyHandle.balance(customerId);
|
|
714
|
+
var claw = Math.min(earned, Number((bal && bal.balance) || 0));
|
|
715
|
+
if (claw <= 0) break;
|
|
716
|
+
try {
|
|
717
|
+
await loyaltyHandle.adjust({
|
|
718
|
+
customer_id: customerId,
|
|
719
|
+
points: -claw,
|
|
720
|
+
source: "earn-reversal",
|
|
721
|
+
notes: "reversed ref=" + triggerEventRef,
|
|
722
|
+
});
|
|
723
|
+
clawed = claw;
|
|
724
|
+
break;
|
|
725
|
+
} catch (err) {
|
|
726
|
+
// A concurrent spend drained the balance below `claw` between
|
|
727
|
+
// the read and the adjust. Re-read and retry against the new,
|
|
728
|
+
// smaller balance. Any other failure escapes to the claim
|
|
729
|
+
// release below.
|
|
730
|
+
if (!(err && err.code === "LOYALTY_INSUFFICIENT_BALANCE")) throw err;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
} catch (clawErr) {
|
|
734
|
+
// The clawback failed for a reason that is NOT the floor-at-zero
|
|
735
|
+
// refusal (a transient DB fault, an unmigrated ledger). Holding
|
|
736
|
+
// the claim would be a silent loss: a retry would see the rows
|
|
737
|
+
// already reversed and no-op while the balance keeps the points.
|
|
738
|
+
// Release the claim so a later reversal can run, then surface the
|
|
739
|
+
// original failure. The release is best-effort — if it ALSO
|
|
740
|
+
// fails, the original error still propagates and the rows stay
|
|
741
|
+
// claimed for manual reconciliation.
|
|
742
|
+
try {
|
|
743
|
+
await query(
|
|
744
|
+
"UPDATE loyalty_earn_log SET reversed_at = NULL " +
|
|
745
|
+
"WHERE customer_id = ?1 AND trigger_event_ref = ?2 AND reversed_at = ?3",
|
|
746
|
+
[customerId, triggerEventRef, ts],
|
|
747
|
+
);
|
|
748
|
+
} catch (_releaseErr) { /* drop-silent — the original failure is the signal */ }
|
|
749
|
+
throw clawErr;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return { reversed_points: earned, clawed_points: clawed };
|
|
754
|
+
}
|
|
755
|
+
|
|
651
756
|
// ---- metricsForRule -------------------------------------------------
|
|
652
757
|
|
|
653
758
|
async function metricsForRule(input) {
|
|
@@ -767,6 +872,7 @@ function create(opts) {
|
|
|
767
872
|
archiveRule: archiveRule,
|
|
768
873
|
evaluateForEvent: evaluateForEvent,
|
|
769
874
|
awardForEvent: awardForEvent,
|
|
875
|
+
reverseForEvent: reverseForEvent,
|
|
770
876
|
metricsForRule: metricsForRule,
|
|
771
877
|
applyBatch: applyBatch,
|
|
772
878
|
};
|
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
|
@@ -58,6 +58,25 @@ var ORDER_TRANSITIONS = Object.freeze([
|
|
|
58
58
|
{ from: "delivered", to: "refunded", on: "refund", label: "Refund" },
|
|
59
59
|
]);
|
|
60
60
|
|
|
61
|
+
// Pickup (BOPIS) edges. An in-store collection has no carrier ship leg:
|
|
62
|
+
// the goods sit on the hold shelf and the customer walks in. So a pickup
|
|
63
|
+
// order goes straight from `paid` (or `fulfilling`, if the picker started
|
|
64
|
+
// staging) to `delivered` on a single `mark_picked_up` event — driving it
|
|
65
|
+
// through mark_shipped would record a carrier handoff that never happened.
|
|
66
|
+
// These edges are kept OUT of ORDER_TRANSITIONS (and therefore out of
|
|
67
|
+
// transitionsFrom) so the operator order-detail page doesn't sprout a
|
|
68
|
+
// "mark picked up" button on every paid order — the click-and-collect
|
|
69
|
+
// primitive fires the event when the front-counter operator captures the
|
|
70
|
+
// pickup. They ARE merged into the FSM definition below so the transition
|
|
71
|
+
// is legal: previously markPickedUp tried `mark_delivered` from `paid`,
|
|
72
|
+
// which the FSM refused (delivered is only reachable from shipped) and the
|
|
73
|
+
// caller swallowed — leaving the pickup schedule `picked_up` while the
|
|
74
|
+
// parent order stayed stuck at paid/fulfilling.
|
|
75
|
+
var PICKUP_TRANSITIONS = Object.freeze([
|
|
76
|
+
{ from: "paid", to: "delivered", on: "mark_picked_up" },
|
|
77
|
+
{ from: "fulfilling", to: "delivered", on: "mark_picked_up" },
|
|
78
|
+
]);
|
|
79
|
+
|
|
61
80
|
function _getOrderFsm() {
|
|
62
81
|
if (_orderFsm) return _orderFsm;
|
|
63
82
|
// b.fsm emits audit events under the 'fsm' namespace —
|
|
@@ -76,7 +95,7 @@ function _getOrderFsm() {
|
|
|
76
95
|
refunded: {},
|
|
77
96
|
cancelled: {},
|
|
78
97
|
},
|
|
79
|
-
transitions: ORDER_TRANSITIONS.map(function (t) {
|
|
98
|
+
transitions: ORDER_TRANSITIONS.concat(PICKUP_TRANSITIONS).map(function (t) {
|
|
80
99
|
return { from: t.from, to: t.to, on: t.on };
|
|
81
100
|
}),
|
|
82
101
|
});
|
|
@@ -217,6 +236,28 @@ function create(opts) {
|
|
|
217
236
|
// settlement failures still surface to the audit sink (b.audit.safeEmit
|
|
218
237
|
// below), just not the durable error feed.
|
|
219
238
|
var errorLog = opts.errorLog || null;
|
|
239
|
+
// Optional gift-card handle — when present, the order FSM credits a
|
|
240
|
+
// gift-card spend back when the order dies without completing. A checkout
|
|
241
|
+
// that partially covers payment with a gift card debits the card's balance
|
|
242
|
+
// at confirm; if the order is then cancelled (the stale-pending reaper, an
|
|
243
|
+
// explicit cancel) or refunded (a payment-failed / refund webhook), that
|
|
244
|
+
// debit must return to the card or the customer's balance is silently
|
|
245
|
+
// burned. This mirrors the inventory-hold release: it's transition-driven
|
|
246
|
+
// on the same cancel / refund edges, runs synchronously before the
|
|
247
|
+
// fire-and-forget fan-outs, and is idempotent (reverseRedemption claims
|
|
248
|
+
// each redemption with an unreversed predicate) so a re-delivered webhook
|
|
249
|
+
// can't double-credit. Opt-in like the other handles; absent it, an
|
|
250
|
+
// unwired deploy (or a test with no gift cards) runs unchanged.
|
|
251
|
+
var giftCards = opts.giftCards || null;
|
|
252
|
+
if (giftCards && typeof giftCards.reverseRedemption !== "function") {
|
|
253
|
+
throw new TypeError("order.create: opts.giftCards must expose a reverseRedemption(order_id) method");
|
|
254
|
+
}
|
|
255
|
+
// Optional gift-card ledger handle — when present alongside giftCards, a
|
|
256
|
+
// reversal also writes a refund_to_giftcard credit so the append-only
|
|
257
|
+
// ledger history records the money returning to the card (the card row's
|
|
258
|
+
// balance is authoritative; the ledger is the audit trail surfaced in the
|
|
259
|
+
// admin console).
|
|
260
|
+
var giftCardLedger = opts.giftCardLedger || null;
|
|
220
261
|
// Pagination cursors for listForCustomer are HMAC-tagged via
|
|
221
262
|
// b.pagination so an operator can't hand-craft one to skip past a
|
|
222
263
|
// hidden order or replay across deployments. The secret defaults
|
|
@@ -273,6 +314,60 @@ function create(opts) {
|
|
|
273
314
|
}
|
|
274
315
|
}
|
|
275
316
|
|
|
317
|
+
// Credit back any gift-card spend on an order that died without
|
|
318
|
+
// completing — the cancel / refund edge releasing the customer's money,
|
|
319
|
+
// mirroring how _settleSku releases an inventory hold. reverseRedemption
|
|
320
|
+
// is idempotent (each redemption claimed with an unreversed predicate), so
|
|
321
|
+
// a re-delivered webhook or the reaper racing the cancel can't double-
|
|
322
|
+
// credit. For each restored redemption a refund_to_giftcard ledger credit
|
|
323
|
+
// is written when the ledger is wired so the audit trail records the money
|
|
324
|
+
// returning.
|
|
325
|
+
//
|
|
326
|
+
// drop-silent-with-capture — same discipline as _settleSku: the cancel /
|
|
327
|
+
// refund has already persisted and the webhook driving it MUST return 2xx,
|
|
328
|
+
// so a reversal failure is caught, NOT re-thrown, and surfaced loudly (an
|
|
329
|
+
// `order.giftcard.reversal.error` audit event plus, when the error-log
|
|
330
|
+
// handle is wired, a durable /admin/errors row) for manual reconciliation.
|
|
331
|
+
async function _settleGiftCards(orderId) {
|
|
332
|
+
if (!giftCards) return;
|
|
333
|
+
try {
|
|
334
|
+
var reversed = await giftCards.reverseRedemption(orderId);
|
|
335
|
+
if (giftCardLedger && typeof giftCardLedger.credit === "function") {
|
|
336
|
+
for (var i = 0; i < reversed.length; i += 1) {
|
|
337
|
+
var rev = reversed[i];
|
|
338
|
+
try {
|
|
339
|
+
// allow:money-binding-currency-without-catalog-check — this is a REVERSAL credit of an existing redemption: it replays the exact amount already debited from an already-issued card (whose currency was ISO-4217-validated at giftcards.issue time). No currency is supplied or chosen here — the ledger credit carries no currency field; it inherits the card's. There is no new currency surface to catalog-check.
|
|
340
|
+
await giftCardLedger.credit({
|
|
341
|
+
gift_card_id: rev.gift_card_id,
|
|
342
|
+
amount_minor: rev.amount_minor,
|
|
343
|
+
source: "refund_to_giftcard",
|
|
344
|
+
source_ref: orderId,
|
|
345
|
+
});
|
|
346
|
+
} catch (_ledgerErr) { /* drop-silent — the card-row balance restore above is authoritative; the ledger credit is the audit trail */ }
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
} catch (e) {
|
|
350
|
+
var message = "order.giftcard-reversal failed — order=" + orderId + ": " + (e && e.message || e);
|
|
351
|
+
try {
|
|
352
|
+
b.audit.safeEmit({
|
|
353
|
+
action: "order.giftcard.reversal.error",
|
|
354
|
+
outcome: "failure",
|
|
355
|
+
metadata: { order_id: orderId, message: (e && e.message) || String(e) },
|
|
356
|
+
});
|
|
357
|
+
} catch (_auditErr) { /* drop-silent — the capture below is the durable record */ }
|
|
358
|
+
if (errorLog && typeof errorLog.captureServerError === "function") {
|
|
359
|
+
try {
|
|
360
|
+
await errorLog.captureServerError({
|
|
361
|
+
route: "/order/" + orderId + "/giftcard-reversal",
|
|
362
|
+
method: "POST",
|
|
363
|
+
status: 500,
|
|
364
|
+
message: message,
|
|
365
|
+
});
|
|
366
|
+
} catch (_logErr) { /* drop-silent — never let the error-feed write mask the original failure */ }
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
276
371
|
return {
|
|
277
372
|
TERMINAL_STATES: TERMINAL_STATES,
|
|
278
373
|
|
|
@@ -477,6 +572,45 @@ function create(opts) {
|
|
|
477
572
|
}
|
|
478
573
|
}
|
|
479
574
|
}
|
|
575
|
+
// Gift-card settlement — SYNCHRONOUS, alongside the inventory release.
|
|
576
|
+
// An order that reaches a terminal state WITHOUT completing the sale
|
|
577
|
+
// (cancelled — pending-abandon or post-paid; refunded — payment failed
|
|
578
|
+
// or a refund issued) must return any gift-card spend to the card.
|
|
579
|
+
// Unlike inventory (where a refund deliberately doesn't auto-restock a
|
|
580
|
+
// physical good), the gift-card credit is the customer's own money, so
|
|
581
|
+
// it's released on every death edge. reverseRedemption is idempotent,
|
|
582
|
+
// so a re-delivered webhook or the reaper racing a cancel credits back
|
|
583
|
+
// exactly once. Drop-silent-with-capture (see _settleGiftCards): the
|
|
584
|
+
// transition has already persisted and the webhook must return 2xx, so
|
|
585
|
+
// a reversal failure surfaces loudly for reconciliation rather than
|
|
586
|
+
// 500ing the request.
|
|
587
|
+
if (result.to === "cancelled" || result.to === "refunded") {
|
|
588
|
+
await _settleGiftCards(orderId);
|
|
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
|
+
}
|
|
480
614
|
// Fan-out to merchant webhook subscribers is fire-and-forget. The
|
|
481
615
|
// transition has already persisted; the request must not wait on
|
|
482
616
|
// outbound HTTP, or a slow / unreachable endpoint would block the
|
package/lib/returns.js
CHANGED
|
@@ -376,21 +376,61 @@ function create(opts) {
|
|
|
376
376
|
var refundedAt = _epochOrNull(input.refunded_at, "refunded_at");
|
|
377
377
|
var operatorNotes = _boundedString(input.operator_notes, "operator_notes", MAX_NOTE_LEN);
|
|
378
378
|
|
|
379
|
-
var current = await this._currentStatus(rmaId);
|
|
380
|
-
_assertTransition(current, "refund");
|
|
381
|
-
|
|
382
379
|
var ts = _now();
|
|
383
380
|
var rfAt = refundedAt == null ? ts : refundedAt;
|
|
384
|
-
|
|
381
|
+
// Atomic claim: the `AND status = 'received'` predicate is the
|
|
382
|
+
// serialization point. Two concurrent refund POSTs (an operator
|
|
383
|
+
// double-click, two operators on the same RMA) both read 'received'
|
|
384
|
+
// up the stack, but on D1 a single UPDATE is atomic — exactly one
|
|
385
|
+
// matches the row and writes 'refunded'; the loser matches zero rows
|
|
386
|
+
// and is refused. Without this the read-then-write let both pass and
|
|
387
|
+
// both moved the RMA to refunded (and, on the provider-backed path,
|
|
388
|
+
// both issued a refund). A zero-row result distinguishes "already
|
|
389
|
+
// refunded / not received" — surfaced as RMA_TRANSITION_REFUSED so
|
|
390
|
+
// the request layer maps it to 409, the same shape the prior
|
|
391
|
+
// _assertTransition refusal produced.
|
|
392
|
+
var upd = await query(
|
|
385
393
|
"UPDATE return_authorizations SET status = 'refunded', " +
|
|
386
394
|
"refunded_at = ?1, " +
|
|
387
395
|
"operator_notes = CASE WHEN ?2 = '' THEN operator_notes ELSE ?2 END, " +
|
|
388
|
-
"updated_at = ?3 WHERE id = ?4",
|
|
396
|
+
"updated_at = ?3 WHERE id = ?4 AND status = 'received'",
|
|
389
397
|
[rfAt, operatorNotes, ts, rmaId],
|
|
390
398
|
);
|
|
399
|
+
if (Number(upd.rowCount || 0) === 0) {
|
|
400
|
+
// Either the RMA doesn't exist or it isn't in 'received'. Read the
|
|
401
|
+
// current status to surface the right typed refusal (RMA_NOT_FOUND
|
|
402
|
+
// vs RMA_TRANSITION_REFUSED) so the caller's mapping is unchanged.
|
|
403
|
+
var current = await this._currentStatus(rmaId);
|
|
404
|
+
_assertTransition(current, "refund");
|
|
405
|
+
// _assertTransition throws for any non-'received' state; reaching
|
|
406
|
+
// here means the RMA was 'received' a moment ago but a concurrent
|
|
407
|
+
// claim won the row first. Refuse with the transition-refused shape.
|
|
408
|
+
var raced = new Error("returns: refund already claimed for rma " + rmaId);
|
|
409
|
+
raced.code = "RMA_TRANSITION_REFUSED";
|
|
410
|
+
throw raced;
|
|
411
|
+
}
|
|
391
412
|
return await this.get(rmaId);
|
|
392
413
|
},
|
|
393
414
|
|
|
415
|
+
// Revert a refund claim back to 'received'. The provider-backed refund
|
|
416
|
+
// flow claims the RMA (refund() above) BEFORE moving money so a
|
|
417
|
+
// concurrent racer is locked out; if the provider call then fails, the
|
|
418
|
+
// caller releases the claim so a retry can run. Atomic + self-targeting:
|
|
419
|
+
// the `AND status = 'refunded'` predicate makes a double-release (or a
|
|
420
|
+
// release of an RMA a webhook already advanced) a no-op, never an
|
|
421
|
+
// underflow. Returns true when the claim was released, false when there
|
|
422
|
+
// was nothing to release.
|
|
423
|
+
releaseRefundClaim: async function (rmaId) {
|
|
424
|
+
_uuid(rmaId, "rma id");
|
|
425
|
+
var ts = _now();
|
|
426
|
+
var upd = await query(
|
|
427
|
+
"UPDATE return_authorizations SET status = 'received', refunded_at = NULL, " +
|
|
428
|
+
"updated_at = ?1 WHERE id = ?2 AND status = 'refunded'",
|
|
429
|
+
[ts, rmaId],
|
|
430
|
+
);
|
|
431
|
+
return Number(upd.rowCount || 0) === 1;
|
|
432
|
+
},
|
|
433
|
+
|
|
394
434
|
reject: async function (rmaId, input) {
|
|
395
435
|
_uuid(rmaId, "rma id");
|
|
396
436
|
if (!input || typeof input !== "object") {
|
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
|
},
|