@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.
@@ -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 = ?1, lifetime_points = ?2, " +
271
- "tier = ?3, updated_at = ?4 WHERE customer_id = ?5",
272
- [newBalance, newLifetime, newTier, ts, customerId],
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: newBalance,
278
- lifetime: newLifetime,
279
- tier: newTier,
280
- tier_changed: tierChanged,
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
- var newLifetime = delta > 0 ? before.lifetime_points + delta : before.lifetime_points;
352
- var newTier = computeTier(newLifetime);
353
-
354
- await query(
355
- "UPDATE loyalty_accounts SET balance_points = ?1, lifetime_points = ?2, " +
356
- "tier = ?3, updated_at = ?4 WHERE customer_id = ?5",
357
- [newBalance, newLifetime, newTier, ts, customerId],
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: newBalance,
363
- lifetime: newLifetime,
364
- tier: newTier,
365
- tier_changed: newTier !== before.tier,
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
- await query(
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") {
@@ -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: impressions > 0 ? clicks / impressions : null,
768
- conversion_rate: impressions > 0 ? purchases / impressions : null,
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
  },