@blamejs/blamejs-shop 0.4.20 → 0.4.22

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/lib/customers.js CHANGED
@@ -535,6 +535,78 @@ function create(opts) {
535
535
  return true;
536
536
  },
537
537
 
538
+ // Right-to-erasure auth revocation. A subject-access deletion must
539
+ // leave the customer UNABLE TO SIGN BACK IN — anonymizing the profile
540
+ // row alone is not erasure if every login credential still resolves.
541
+ // This deletes the THREE durable sign-in credentials keyed to the
542
+ // customer and severs the magic-link lookup key in ONE call:
543
+ //
544
+ // * customer_passkeys — every enrolled WebAuthn authenticator
545
+ // (passkey sign-in resolves a credential
546
+ // to this id; gone, the assertion misses).
547
+ // * customer_oauth_identities — every federated link (Google / Apple
548
+ // OIDC resolves byOAuthIdentity to this
549
+ // id; gone, the federated sign-in creates
550
+ // a fresh unrelated account instead).
551
+ // * email_hash -> tombstone — the magic-link / guest-order-claim path
552
+ // resolves byEmailHash; overwriting the
553
+ // hash with a per-id, non-reversible
554
+ // tombstone means no future link for the
555
+ // erased address can ever resolve this row
556
+ // (the address itself is never stored, so
557
+ // there is no plaintext to scrub — only
558
+ // the lookup key to break).
559
+ //
560
+ // The sealed 14-day auth cookie is stateless (no server session row to
561
+ // kill), but with no credential left to re-mint it and no lookup key to
562
+ // re-issue a magic link, the account cannot be re-entered after the
563
+ // cookie expires; the live customer-portal sessions are revoked
564
+ // separately by the caller (customerPortal.revokeAllForCustomer). Each
565
+ // step is idempotent — re-running on an already-erased row deletes
566
+ // nothing more and returns zero counts. dry_run reports the counts the
567
+ // wet run WOULD remove without mutating, so the operator preview shows
568
+ // the blast radius. Returns `{ passkeys, oauth_identities, email_hash_cleared }`.
569
+ eraseAuthForCustomer: async function (id, opts) {
570
+ _uuid(id, "customer id");
571
+ var dryRun = !!(opts && opts.dry_run);
572
+ var pkRows = (await query(
573
+ "SELECT COUNT(*) AS n FROM customer_passkeys WHERE customer_id = ?1", [id],
574
+ )).rows[0];
575
+ var oaRows = (await query(
576
+ "SELECT COUNT(*) AS n FROM customer_oauth_identities WHERE customer_id = ?1", [id],
577
+ )).rows[0];
578
+ var existing = (await query("SELECT email_hash FROM customers WHERE id = ?1", [id])).rows[0];
579
+ var emailHashSet = !!(existing && existing.email_hash && String(existing.email_hash).indexOf("erased:") !== 0);
580
+ if (dryRun) {
581
+ return {
582
+ passkeys: pkRows ? Number(pkRows.n) : 0,
583
+ oauth_identities: oaRows ? Number(oaRows.n) : 0,
584
+ email_hash_cleared: emailHashSet ? 1 : 0,
585
+ };
586
+ }
587
+ var ts = _now();
588
+ var pkDel = await query("DELETE FROM customer_passkeys WHERE customer_id = ?1", [id]);
589
+ var oaDel = await query("DELETE FROM customer_oauth_identities WHERE customer_id = ?1", [id]);
590
+ // Tombstone the lookup key with a per-id, non-reversible value that
591
+ // can never collide with a real namespaceHash digest (those are hex)
592
+ // and can never be re-derived from any email address. Only rewrite a
593
+ // live (non-tombstoned) hash so a re-run is a no-op.
594
+ var emailHashCleared = 0;
595
+ if (emailHashSet) {
596
+ var tombstone = "erased:" + b.crypto.namespaceHash("customer-erased-email", id);
597
+ var upd = await query(
598
+ "UPDATE customers SET email_hash = ?1, updated_at = ?2 WHERE id = ?3 AND email_hash = ?4",
599
+ [tombstone, ts, id, existing.email_hash],
600
+ );
601
+ emailHashCleared = Number((upd && upd.rowCount) || 0);
602
+ }
603
+ return {
604
+ passkeys: Number((pkDel && pkDel.rowCount) || 0),
605
+ oauth_identities: Number((oaDel && oaDel.rowCount) || 0),
606
+ email_hash_cleared: emailHashCleared,
607
+ };
608
+ },
609
+
538
610
  // Mutate a customer's editable profile fields. v1 covers display_name
539
611
  // only — the one field a customer can safely change without a
540
612
  // verification round trip.
@@ -783,7 +783,14 @@ function create(opts) {
783
783
  var signup = await newsletter.byEmailHash(emailHash);
784
784
  if (!signup || !signup.id) return null;
785
785
  var issued = await newsletter.issueUnsubscribeToken(signup.id);
786
- var url = unsubscribeBaseUrl + "/newsletter/unsubscribe?token=" + encodeURIComponent(issued.token);
786
+ // The storefront mounts the one-click unsubscribe at GET/POST
787
+ // /unsubscribe (lib/storefront.js + EDGE_POST_PATHS). The token rides
788
+ // in the URL — a mail client's RFC 8058 one-click POST fires at this
789
+ // exact URL with a `List-Unsubscribe=One-Click` body and no token of
790
+ // its own, so the route MUST be the real one and MUST read the token
791
+ // from here. (An earlier `/newsletter/unsubscribe` target had no route
792
+ // behind it — every native one-click POST 404'd and never unsubscribed.)
793
+ var url = unsubscribeBaseUrl + "/unsubscribe?token=" + encodeURIComponent(issued.token);
787
794
  // Validate the header SHAPE through the vendored RFC 2369 + RFC 8058
788
795
  // guard so a malformed link (non-https, control byte) never reaches
789
796
  // the wire — Gmail / Yahoo refuse mail that carries a broken pair.
@@ -240,24 +240,53 @@ function create(opts) {
240
240
  var requested = _epochMs(input.occurred_at, "occurred_at");
241
241
  if (requested == null) requested = _now();
242
242
 
243
- var latest = await _readLatest(giftCardId);
244
- if (amount > latest.balance) {
243
+ // Atomic guarded INSERT — the overdraft check and the row write
244
+ // happen in ONE statement so two concurrent debits can't both
245
+ // read the same balance, both pass the check, and both write
246
+ // (the read-then-write race that corrupted `balance_after_minor`).
247
+ // The latest row's snapshot (balance + occurred_at) is read by
248
+ // correlated scalar subqueries INSIDE the INSERT; the WHERE gates
249
+ // the write on `current_balance >= amount` against that live
250
+ // value, and the inserted `balance_after_minor` / monotonic
251
+ // `occurred_at` are derived from the same subqueries — so on D1
252
+ // (where a single statement is atomic) exactly one of two racing
253
+ // debits lands. rowCount === 0 means the guard refused: either a
254
+ // genuine overdraft or the loser of a race.
255
+ var id = b.uuid.v7();
256
+ var balSub =
257
+ "COALESCE((SELECT balance_after_minor FROM gift_card_ledger " +
258
+ "WHERE gift_card_id = ?2 ORDER BY occurred_at DESC LIMIT 1), 0)";
259
+ var tsSub =
260
+ "(SELECT occurred_at FROM gift_card_ledger " +
261
+ "WHERE gift_card_id = ?2 ORDER BY occurred_at DESC LIMIT 1)";
262
+ var ins = await query(
263
+ "INSERT INTO gift_card_ledger " +
264
+ "(id, gift_card_id, kind, amount_minor, source, source_ref, order_id, balance_after_minor, occurred_at) " +
265
+ "SELECT ?1, ?2, 'debit', ?3, NULL, NULL, ?4, " +
266
+ balSub + " - ?3, " +
267
+ "CASE WHEN ?5 > COALESCE(" + tsSub + ", 0) THEN ?5 ELSE COALESCE(" + tsSub + ", 0) + 1 END " +
268
+ "WHERE " + balSub + " >= ?3",
269
+ [id, giftCardId, amount, orderId, requested],
270
+ );
271
+ if (Number(ins.rowCount || 0) === 0) {
245
272
  var insufficient = new Error("giftCardLedger.debit: amount exceeds available balance");
246
273
  insufficient.code = "GIFT_CARD_LEDGER_INSUFFICIENT_BALANCE";
247
274
  throw insufficient;
248
275
  }
249
- var ts = _resolveOccurredAt(requested, latest.occurred_at);
250
- var after = latest.balance - amount;
251
- var id = await _writeRow(giftCardId, "debit", amount, null, null, orderId, after, ts);
252
-
276
+ // Re-read the row we just wrote to surface the resolved
277
+ // balance_after / occurred_at without recomputing them client-side.
278
+ var wrote = (await query(
279
+ "SELECT balance_after_minor, occurred_at FROM gift_card_ledger WHERE id = ?1",
280
+ [id],
281
+ )).rows[0];
253
282
  return {
254
283
  id: id,
255
284
  gift_card_id: giftCardId,
256
285
  kind: "debit",
257
286
  amount_minor: amount,
258
287
  order_id: orderId,
259
- balance_after_minor: after,
260
- occurred_at: ts,
288
+ balance_after_minor: wrote ? wrote.balance_after_minor : null,
289
+ occurred_at: wrote ? wrote.occurred_at : requested,
261
290
  };
262
291
  },
263
292
 
@@ -622,10 +622,12 @@ function create(opts) {
622
622
  if (item.archived_at != null) {
623
623
  throw new TypeError("giftRegistry.purchaseItem: item " + JSON.stringify(itemId) + " has been removed");
624
624
  }
625
- // Refuse over-purchase: aggregate prior purchases + this one
626
- // must not exceed `quantity_desired`. The owner's wish-list is
625
+ // Refuse over-purchase: aggregate prior purchases + this one must
626
+ // not exceed `quantity_desired`. The owner's wish-list is
627
627
  // authoritative — a fourth blender is not a gift, it's a return
628
- // pending.
628
+ // pending. The pre-read below only shapes a friendly "remaining N"
629
+ // error for the common (uncontended) case; the AUTHORITATIVE check
630
+ // is the guarded INSERT that follows.
629
631
  var priorRes = await query(
630
632
  "SELECT COALESCE(SUM(quantity), 0) AS sum FROM gift_registry_purchases " +
631
633
  "WHERE registry_slug = ?1 AND item_id = ?2",
@@ -644,12 +646,48 @@ function create(opts) {
644
646
 
645
647
  var id = b.uuid.v7();
646
648
  var ts = _now();
647
- await query(
649
+ // Atomic guarded INSERT — the SUM-of-prior-purchases check and the
650
+ // purchase write land in ONE statement so two concurrent buyers
651
+ // can't both read the same prior sum, both pass the pre-check, and
652
+ // both insert (the read-then-write race that let purchases exceed
653
+ // `quantity_desired`). The constraint is re-evaluated against the
654
+ // LIVE purchase sum + the item's live `quantity_desired` inside the
655
+ // INSERT; the item must also still exist and be unarchived. On D1 a
656
+ // single statement is atomic, so exactly one of two racing
657
+ // purchases for the last unit lands. rowCount === 0 means the guard
658
+ // refused — a concurrent purchase claimed the remaining quantity
659
+ // first (or the item was archived between the pre-check and here).
660
+ var ins = await query(
648
661
  "INSERT INTO gift_registry_purchases " +
649
662
  "(id, registry_slug, item_id, quantity, buyer_customer_id, buyer_message, reveal_buyer, occurred_at) " +
650
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
663
+ "SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8 " +
664
+ "WHERE EXISTS (" +
665
+ " SELECT 1 FROM gift_registry_items gi " +
666
+ " WHERE gi.id = ?3 AND gi.registry_slug = ?2 AND gi.archived_at IS NULL " +
667
+ " AND (" +
668
+ " SELECT COALESCE(SUM(gp.quantity), 0) FROM gift_registry_purchases gp " +
669
+ " WHERE gp.registry_slug = ?2 AND gp.item_id = ?3" +
670
+ " ) + ?4 <= gi.quantity_desired" +
671
+ ")",
651
672
  [id, slug, itemId, qty, buyerId, buyerMsg, reveal ? 1 : 0, ts],
652
673
  );
674
+ if (Number(ins.rowCount || 0) === 0) {
675
+ // Lost the race (or the item was archived between the pre-check
676
+ // and the guarded INSERT). Recompute the live remaining for an
677
+ // honest message; the write applied nothing.
678
+ var liveSum = Number((await query(
679
+ "SELECT COALESCE(SUM(quantity), 0) AS sum FROM gift_registry_purchases " +
680
+ "WHERE registry_slug = ?1 AND item_id = ?2",
681
+ [slug, itemId],
682
+ )).rows[0].sum || 0);
683
+ var raced = new Error(
684
+ "giftRegistry.purchaseItem: quantity " + qty +
685
+ " exceeds remaining " + Math.max(0, item.quantity_desired - liveSum) +
686
+ " on item " + itemId,
687
+ );
688
+ raced.code = "GIFT_REGISTRY_OVER_PURCHASE";
689
+ throw raced;
690
+ }
653
691
  await query(
654
692
  "UPDATE gift_registries SET updated_at = ?1 WHERE slug = ?2",
655
693
  [ts, slug],
@@ -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
 
@@ -212,7 +212,8 @@ function _limit(n) {
212
212
  // RFC-4180 quoting: every cell wrapped in `"`, embedded `"` doubled.
213
213
  // We quote unconditionally — the cost is a few extra bytes per cell;
214
214
  // the win is that a downstream parser never has to track quote-vs-
215
- // bare-cell state for a column with mixed shapes.
215
+ // bare-cell state for a column with mixed shapes. The injection
216
+ // neutralization runs first via the shared vendored primitive.
216
217
  function _csvCell(value) {
217
218
  var s = _coerceCell(value);
218
219
  s = _neutralizeInjection(s);
@@ -227,23 +228,19 @@ function _coerceCell(value) {
227
228
  return JSON.stringify(value);
228
229
  }
229
230
 
230
- // Numeric-with-sign detector`+15.00` / `-3.50` / `+0` parse as
231
- // legitimate amounts and should pass through unmolested. The
232
- // detector rejects anything with embedded whitespace or trailing
233
- // non-numeric tail so `+15 SUM(A1)` still gets escaped.
234
- var _NUMERIC_SIGN_RE = /^[+-](?:\d+(?:\.\d+)?|\.\d+)$/;
235
-
236
- // CSV injection neutralization see OWASP "CSV Injection". A cell
237
- // beginning with `=`, `+`, `-`, or `@` is interpreted as a formula
238
- // by most spreadsheet renderers. The defense is to prefix with `'`
239
- // so the renderer treats the cell as literal text. Signed numerics
240
- // are the deliberate exception (legitimate amount strings).
231
+ // CSV injection neutralization composes the vendored b.guardCsv.escapeCell
232
+ // (OWASP "CSV Injection" defense). The vendored primitive is the single
233
+ // shared neutralizer across every CSV export surface (order-export +
234
+ // customer-segments). It prefixes a leading TAB when a cell starts with ANY
235
+ // formula-trigger char — `= + - @` AND the tab / CR / LF / pipe / full-width
236
+ // variants a hand-rolled `= + - @`-only check misses. A leading tab renders
237
+ // as invisible whitespace in a spreadsheet, so the cell reads as text and
238
+ // never evaluates as a formula. The earlier in-tree check exempted signed
239
+ // numerics (`+15.00`); the shared primitive prefixes those too (the safe
240
+ // OWASP posture `-2+3+cmd|…` is a real injection that begins like an
241
+ // amount), which is the more complete behavior this consolidation buys.
241
242
  function _neutralizeInjection(s) {
242
- if (s.length === 0) return s;
243
- var first = s.charAt(0);
244
- if (first !== "=" && first !== "+" && first !== "-" && first !== "@") return s;
245
- if ((first === "+" || first === "-") && _NUMERIC_SIGN_RE.test(s)) return s;
246
- return "'" + s;
243
+ return b.guardCsv.escapeCell(s);
247
244
  }
248
245
 
249
246
  function _csvRow(cells) {
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
@@ -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
  },