@blamejs/blamejs-shop 0.4.103 → 0.4.105

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/SECURITY.md +11 -8
  4. package/lib/affiliates.js +2 -2
  5. package/lib/api-keys.js +1 -1
  6. package/lib/asset-manifest.json +1 -1
  7. package/lib/auto-discount.js +4 -4
  8. package/lib/auto-replenish.js +1 -1
  9. package/lib/backorder.js +2 -2
  10. package/lib/carrier-accounts.js +1 -1
  11. package/lib/cart-bulk-ops.js +1 -1
  12. package/lib/cart.js +2 -2
  13. package/lib/catalog.js +3 -3
  14. package/lib/click-and-collect.js +2 -2
  15. package/lib/customer-merge.js +2 -2
  16. package/lib/customer-surveys.js +1 -1
  17. package/lib/cycle-counting.js +1 -1
  18. package/lib/dropship-forwarding.js +1 -1
  19. package/lib/dunning.js +1 -1
  20. package/lib/email-campaigns.js +4 -4
  21. package/lib/giftcards.js +6 -6
  22. package/lib/inventory-allocations.js +1 -1
  23. package/lib/inventory-audits.js +1 -1
  24. package/lib/inventory-receive.js +2 -2
  25. package/lib/inventory-writeoffs.js +1 -1
  26. package/lib/invoice-renderer.js +1 -1
  27. package/lib/knowledge-base.js +1 -1
  28. package/lib/loyalty-redemption.js +3 -3
  29. package/lib/loyalty.js +410 -195
  30. package/lib/operator-approvals.js +1 -1
  31. package/lib/operator-help-center.js +1 -1
  32. package/lib/order-exchanges.js +1 -1
  33. package/lib/order-export.js +4 -4
  34. package/lib/payment-retries.js +2 -2
  35. package/lib/pick-lists.js +1 -1
  36. package/lib/plan-changes.js +1 -1
  37. package/lib/preorder.js +4 -4
  38. package/lib/print-on-demand.js +1 -1
  39. package/lib/print-queue.js +2 -2
  40. package/lib/purchase-orders.js +1 -1
  41. package/lib/push-notifications.js +2 -2
  42. package/lib/quotes.js +7 -7
  43. package/lib/referrals.js +3 -3
  44. package/lib/reorder-reminders.js +1 -1
  45. package/lib/returns.js +4 -4
  46. package/lib/seller-signup.js +1 -1
  47. package/lib/shipping-insurance.js +2 -2
  48. package/lib/sms-dispatcher.js +2 -2
  49. package/lib/split-shipments.js +1 -1
  50. package/lib/stock-alerts.js +1 -1
  51. package/lib/stock-transfers.js +4 -4
  52. package/lib/subscription-gifts.js +2 -2
  53. package/lib/suggestion-box.js +2 -2
  54. package/lib/webhooks.js +10 -3
  55. package/lib/wishlist-alerts.js +1 -1
  56. package/lib/wishlist-digest.js +1 -1
  57. package/package.json +1 -1
package/lib/loyalty.js CHANGED
@@ -154,6 +154,51 @@ function _validateThresholds(thresholds) {
154
154
 
155
155
  function _now() { return Date.now(); }
156
156
 
157
+ // ---- chain hashing ------------------------------------------------------
158
+ //
159
+ // loyalty_transactions is a per-customer running-balance hash chain (the
160
+ // gift-card-ledger / store-credit-ledger model). Each mutating row carries the
161
+ // running balance_after_points + lifetime_after_points + tier_after, written by
162
+ // the SAME guarded INSERT that records the event, so the balance change and the
163
+ // audit row are one write — the stored-column two-statement window (and the
164
+ // double-credit-on-revert hazard it forced) is structurally gone. A SHA3-512
165
+ // chain (prev_hash + row_hash, fenced by UNIQUE(customer_id, prev_hash)) makes
166
+ // the running snapshot tamper-evident and serializes concurrent writers.
167
+ //
168
+ // row_hash = SHA3-512(prev_hash || canonical-json(row-fields))
169
+ //
170
+ // order_id and restored_points are EXCLUDED from the hashed fields: both are
171
+ // mutated AFTER the row is inserted (linkRedemptionToOrder stamps order_id;
172
+ // reverse/restore advance restored_points), so hashing them would report a
173
+ // false tamper on every linked redemption and every restore.
174
+ var SHA3_512_HEX_LEN = 128;
175
+ var ZERO_HASH = "0".repeat(SHA3_512_HEX_LEN);
176
+
177
+ function _rowFieldsForHash(row) {
178
+ return {
179
+ id: row.id,
180
+ customer_id: row.customer_id,
181
+ transaction_type: row.transaction_type,
182
+ points: Number(row.points),
183
+ source: row.source == null ? null : row.source,
184
+ notes: row.notes == null ? null : row.notes,
185
+ balance_after_points: Number(row.balance_after_points),
186
+ lifetime_after_points: Number(row.lifetime_after_points),
187
+ tier_after: row.tier_after,
188
+ occurred_at: Number(row.occurred_at),
189
+ // order_id + restored_points EXCLUDED — both mutated post-insert.
190
+ };
191
+ }
192
+
193
+ function _computeRowHash(prevHash, rowFields) {
194
+ var canonical = b.auditChain.canonicalize(rowFields, ["prev_hash", "row_hash"]);
195
+ var preimage = Buffer.concat([
196
+ Buffer.from(prevHash, "hex"),
197
+ Buffer.from(canonical, "utf8"),
198
+ ]);
199
+ return b.crypto.sha3Hash(preimage);
200
+ }
201
+
157
202
  // ---- factory ------------------------------------------------------------
158
203
 
159
204
  function create(opts) {
@@ -205,17 +250,6 @@ function create(opts) {
205
250
  return r.rows[0] || null;
206
251
  }
207
252
 
208
- async function _writeTx(customerId, type, points, source, orderId, notes, ts) {
209
- var id = b.uuid.v7();
210
- await query(
211
- "INSERT INTO loyalty_transactions " +
212
- "(id, customer_id, transaction_type, points, source, order_id, notes, occurred_at) " +
213
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
214
- [id, customerId, type, points, source, orderId, notes, ts],
215
- );
216
- return id;
217
- }
218
-
219
253
  async function _ensureAccountRow(customerId, ts) {
220
254
  // INSERT OR IGNORE is idempotent — repeated calls land a single
221
255
  // row. We don't bump `updated_at` on the existing-row path; the
@@ -228,23 +262,133 @@ function create(opts) {
228
262
  );
229
263
  }
230
264
 
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";
265
+ // O(1) chain-tip read: the latest row by (customer_id, occurred_at DESC,
266
+ // id DESC) carries the running balance/lifetime/tier snapshot AFTER it.
267
+ // No SUM at read time. Falls through to a bronze zero-tip when the customer
268
+ // has no rows (a brand-new account anchors its chain from ZERO on the first
269
+ // write). A legacy/genesis NULL row_hash coerces to ZERO_HASH so the first
270
+ // hashed row anchors the chain (the lazy genesis anchor).
271
+ async function _readLatest(customerId) {
272
+ var r = await query(
273
+ "SELECT id, balance_after_points, lifetime_after_points, tier_after, occurred_at, row_hash " +
274
+ "FROM loyalty_transactions WHERE customer_id = ?1 " +
275
+ "ORDER BY occurred_at DESC, id DESC LIMIT 1",
276
+ [customerId],
277
+ );
278
+ if (!r.rows.length) {
279
+ return { id: null, balance: 0, lifetime: 0, tier: "bronze",
280
+ occurred_at: null, row_hash: ZERO_HASH };
281
+ }
282
+ var row = r.rows[0];
283
+ return {
284
+ id: row.id,
285
+ balance: Number(row.balance_after_points),
286
+ lifetime: Number(row.lifetime_after_points),
287
+ tier: row.tier_after,
288
+ occurred_at: Number(row.occurred_at),
289
+ row_hash: row.row_hash == null ? ZERO_HASH : row.row_hash,
290
+ };
291
+ }
292
+
293
+ // Strict-monotonic per-customer occurred_at: two writes in the same ms (or a
294
+ // backdated write) would tie and make the tip ambiguous, so a colliding
295
+ // timestamp bumps to prior + 1. Non-colliding (future) timestamps land as
296
+ // requested.
297
+ function _resolveOccurredAt(requestedTs, latestTs) {
298
+ if (latestTs == null) return requestedTs;
299
+ if (requestedTs > latestTs) return requestedTs;
300
+ return latestTs + 1;
301
+ }
302
+
303
+ // Re-derive the tip this many times when the chain-parent fence refuses an
304
+ // INSERT (a concurrent write claimed the slot). Each fence round lets exactly
305
+ // one racing writer win, so the cap is sized well beyond realistic same-
306
+ // customer fan-in; a genuine non-collision insert error re-throws on attempt
307
+ // one (the tip is unchanged), so a high cap never spins on a real failure.
308
+ var CHAIN_WRITE_ATTEMPTS = 64;
309
+
310
+ // One fenced write attempt off a freshly-read tip. Every event kind writes
311
+ // through here so every row joins the per-customer chain. The chain-parent
312
+ // fence (UNIQUE(customer_id, prev_hash)) makes the app-computed prev_hash /
313
+ // row_hash safe: a row derived from a stale tip collides instead of forking
314
+ // the chain or persisting a stale balance_after. The overdraft / underflow
315
+ // gate stays INSIDE the statement (correlated subquery against the live tip,
316
+ // never an app-side check) so a refusal is a zero-row no-op. Returns one of
317
+ // { written }, { refused } (guard said no), { collided } (tip moved — retry).
318
+ async function _attemptChainedWrite(customerId, d) {
319
+ var id = b.uuid.v7();
320
+ var rowHash = _computeRowHash(d.prevHash, {
321
+ id: id,
322
+ customer_id: customerId,
323
+ transaction_type: d.type,
324
+ points: d.points,
325
+ source: d.source,
326
+ notes: d.notes,
327
+ balance_after_points: d.balanceAfter,
328
+ lifetime_after_points: d.lifetimeAfter,
329
+ tier_after: d.tierAfter,
330
+ occurred_at: d.ts,
331
+ });
332
+ var balSub =
333
+ "COALESCE((SELECT balance_after_points FROM loyalty_transactions " +
334
+ "WHERE customer_id = ?2 ORDER BY occurred_at DESC, id DESC LIMIT 1), 0)";
335
+ try {
336
+ var res = await query(
337
+ "INSERT INTO loyalty_transactions " +
338
+ "(id, customer_id, transaction_type, points, source, order_id, notes, occurred_at, " +
339
+ "balance_after_points, lifetime_after_points, tier_after, prev_hash, row_hash) " +
340
+ "SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13 " +
341
+ "WHERE " + (d.guard ? d.guard.replace(/__BAL__/g, balSub) : "1"),
342
+ [id, customerId, d.type, d.points, d.source, d.orderId, d.notes, d.ts,
343
+ d.balanceAfter, d.lifetimeAfter, d.tierAfter, d.prevHash, rowHash],
344
+ );
345
+ if (Number(res.rowCount || 0) === 0) return { refused: true };
346
+ return { written: { id: id, points: d.points, balance: d.balanceAfter,
347
+ lifetime: d.lifetimeAfter, tier: d.tierAfter, occurred_at: d.ts } };
348
+ } catch (e) {
349
+ // State-agnostic collision detection — never error-message matching (the
350
+ // prod D1 bridge redacts the SQLite UNIQUE text to a generic HTTP 500).
351
+ // If the tip advanced past the parent we tried to chain off, a competing
352
+ // write claimed our slot — retry; otherwise the insert failed for another
353
+ // reason — re-throw.
354
+ var after = await _readLatest(customerId);
355
+ if (after.row_hash !== d.prevHash) return { collided: true };
356
+ throw e;
357
+ }
358
+ }
359
+
360
+ // Run a chained write with bounded tip-contention retries. `derive(tip)` maps
361
+ // the freshly-read tip to the concrete row fields; each fence collision
362
+ // re-reads + re-derives so the values that land are always consistent with
363
+ // the parent they chain off. Returns the written row, or { refused: true }
364
+ // when the in-statement guard rejected it against the live tip.
365
+ async function _writeChained(customerId, derive) {
366
+ for (var attempt = 0; attempt < CHAIN_WRITE_ATTEMPTS; attempt += 1) {
367
+ var latest = await _readLatest(customerId);
368
+ var d = derive(latest);
369
+ d.prevHash = latest.row_hash;
370
+ var r = await _attemptChainedWrite(customerId, d);
371
+ if (r.collided) continue;
372
+ if (r.refused) return { refused: true };
373
+ return r.written;
374
+ }
375
+ var contention = new Error("loyalty: persistent chain-tip contention — retry the write");
376
+ contention.code = "LOYALTY_CONTENTION";
377
+ throw contention;
378
+ }
379
+
380
+ // Best-effort refresh of the loyalty_accounts mirror from a freshly-written
381
+ // tip. The mirror is NOT authoritative and NOT on any mutation's decision
382
+ // path — balance() and every guard read the chain tip; the mirror exists only
383
+ // for the cross-customer tierLeaderboard and the operator tier_expires_at
384
+ // time-bound. Runs OUTSIDE the atomicity boundary: a crash here leaves money
385
+ // exactly-once correct and the next advancing write re-syncs it.
386
+ async function _refreshMirror(customerId, w, ts) {
387
+ await query(
388
+ "UPDATE loyalty_accounts SET balance_points = ?1, lifetime_points = ?2, " +
389
+ "tier = ?3, updated_at = ?4 WHERE customer_id = ?5",
390
+ [w.balance, w.lifetime, w.tier, ts, customerId],
391
+ );
248
392
  }
249
393
 
250
394
  return {
@@ -277,29 +421,31 @@ function create(opts) {
277
421
  var orderId = input.order_id != null ? _uuid(input.order_id, "order_id") : null;
278
422
  var notes = _notes(input.notes);
279
423
 
280
- var ts = _now();
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.
287
- var before = await _readAccount(customerId);
288
- await query(
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],
294
- );
295
- await _writeTx(customerId, "earn", points, source, orderId, notes, ts);
296
-
297
- var after = await _readAccount(customerId);
424
+ var requested = _now();
425
+ await _ensureAccountRow(customerId, requested);
426
+ // Balance + lifetime + tier all ride the SAME guarded chained INSERT off
427
+ // the freshly-read tip there is no stored-column second statement to
428
+ // lose. earn advances lifetime and recomputes the tier off the new
429
+ // lifetime; `tier_changed` compares the chain's before/after tiers (both
430
+ // from the tip, no separate snapshot read).
431
+ var tierBefore;
432
+ var w = await _writeChained(customerId, function (latest) {
433
+ tierBefore = latest.tier;
434
+ var lifeAfter = latest.lifetime + points;
435
+ return {
436
+ type: "earn", points: points, source: source, orderId: orderId, notes: notes,
437
+ balanceAfter: latest.balance + points,
438
+ lifetimeAfter: lifeAfter,
439
+ tierAfter: computeTier(lifeAfter),
440
+ ts: _resolveOccurredAt(requested, latest.occurred_at),
441
+ };
442
+ });
443
+ await _refreshMirror(customerId, w, requested);
298
444
  return {
299
- balance: after.balance_points,
300
- lifetime: after.lifetime_points,
301
- tier: after.tier,
302
- tier_changed: after.tier !== before.tier,
445
+ balance: w.balance,
446
+ lifetime: w.lifetime,
447
+ tier: w.tier,
448
+ tier_changed: w.tier !== tierBefore,
303
449
  };
304
450
  },
305
451
 
@@ -312,43 +458,41 @@ function create(opts) {
312
458
  var orderId = input.order_id != null ? _uuid(input.order_id, "order_id") : null;
313
459
  var notes = _notes(input.notes);
314
460
 
315
- var ts = _now();
316
- await _ensureAccountRow(customerId, ts);
317
- var before = await _readAccount(customerId);
318
- if (before.balance_points < points) {
461
+ var requested = _now();
462
+ await _ensureAccountRow(customerId, requested);
463
+ // The overdraft refusal lives INSIDE the guarded INSERT (balSub + points
464
+ // >= 0, where points is the negative burn delta) — never an app-side
465
+ // pre-check — so two concurrent redemptions can't double-spend off a
466
+ // stale snapshot: the chain-parent fence serializes them and the loser
467
+ // re-reads the winner's smaller balance and is refused. Lifetime is not
468
+ // affected; redemption spends from the running balance only.
469
+ var w = await _writeChained(customerId, function (latest) {
470
+ return {
471
+ type: "redeem", points: -points, source: "redeem", orderId: orderId, notes: notes,
472
+ balanceAfter: latest.balance - points,
473
+ lifetimeAfter: latest.lifetime,
474
+ tierAfter: latest.tier,
475
+ ts: _resolveOccurredAt(requested, latest.occurred_at),
476
+ guard: "__BAL__ + ?4 >= 0",
477
+ };
478
+ });
479
+ if (w.refused) {
319
480
  var ins = new Error("loyalty.redeem: insufficient balance");
320
481
  ins.code = "LOYALTY_INSUFFICIENT_BALANCE";
321
482
  throw ins;
322
483
  }
323
-
324
- // Atomic decrement guarded by a balance check at the SQL tier so
325
- // two concurrent redemptions can't double-spend. Lifetime is not
326
- // affected — redemption spends from the running balance only.
327
- var dec = await query(
328
- "UPDATE loyalty_accounts SET balance_points = balance_points - ?1, " +
329
- "updated_at = ?2 WHERE customer_id = ?3 AND balance_points >= ?1",
330
- [points, ts, customerId],
331
- );
332
- if (dec.rowCount === 0) {
333
- var raced = new Error("loyalty.redeem: insufficient balance");
334
- raced.code = "LOYALTY_INSUFFICIENT_BALANCE";
335
- throw raced;
336
- }
337
-
338
- var txId = await _writeTx(customerId, "redeem", -points, "redeem", orderId, notes, ts);
339
-
340
- var after = await _readAccount(customerId);
484
+ await _refreshMirror(customerId, w, requested);
485
+ var acct = await _readAccount(customerId);
341
486
  return {
342
- balance: after.balance_points,
343
- lifetime: after.lifetime_points,
344
- tier: after.tier,
345
- tier_expires_at: after.tier_expires_at,
346
- // Ledger row id of this burn — checkout debits points BEFORE its
347
- // order exists (the debit is the double-spend gate) and links the
348
- // row to the order afterwards via linkRedemptionToOrder, or
349
- // compensates via reverseRedemptionById when the checkout dies
350
- // before the order is created.
351
- tx_id: txId,
487
+ balance: w.balance,
488
+ lifetime: w.lifetime,
489
+ tier: w.tier,
490
+ tier_expires_at: acct ? acct.tier_expires_at : null,
491
+ // Ledger row id of this burn — checkout debits points BEFORE its order
492
+ // exists (the debit is the double-spend gate) and links the row to the
493
+ // order afterwards via linkRedemptionToOrder, or compensates via
494
+ // reverseRedemptionById when the checkout dies before the order exists.
495
+ tx_id: w.id,
352
496
  };
353
497
  },
354
498
 
@@ -366,7 +510,7 @@ function create(opts) {
366
510
  "WHERE id = ?2 AND transaction_type = 'redeem' AND order_id IS NULL",
367
511
  [oid, tid],
368
512
  );
369
- return Number(res.rowCount || 0) === 1;
513
+ return b.sql.casWon(res).won;
370
514
  },
371
515
 
372
516
  // Reverse ONE redeem burn by its ledger row id — the compensation edge
@@ -394,15 +538,24 @@ function create(opts) {
394
538
  "WHERE id = ?2 AND restored_points = 0",
395
539
  [spent, row.id],
396
540
  );
397
- if (Number(claim.rowCount || 0) === 0) return { restored_points: 0 }; // lost the claim
541
+ if (!b.sql.casWon(claim).won) return { restored_points: 0 }; // lost the claim
398
542
  await _ensureAccountRow(row.customer_id, ts);
399
- await query(
400
- "UPDATE loyalty_accounts SET balance_points = balance_points + ?1, " +
401
- "updated_at = ?2 WHERE customer_id = ?3",
402
- [spent, ts, row.customer_id],
403
- );
404
- await _writeTx(row.customer_id, "redeem", spent, "redeem-reversal", null,
405
- "restored ref=tx:" + row.id, ts);
543
+ // Forward-only credit-back: ONE chained +spent row off the live tip
544
+ // (lifetime untouched). The restored_points claim above is the
545
+ // serialization point, so a double-fire wins no claim and appends
546
+ // nothing — exactly-once. There is no revert anywhere, so the
547
+ // two-statement double-credit-on-revert hazard is structurally gone.
548
+ var w = await _writeChained(row.customer_id, function (latest) {
549
+ return {
550
+ type: "redeem", points: spent, source: "redeem-reversal", orderId: null,
551
+ notes: "restored ref=tx:" + row.id,
552
+ balanceAfter: latest.balance + spent,
553
+ lifetimeAfter: latest.lifetime,
554
+ tierAfter: latest.tier,
555
+ ts: _resolveOccurredAt(ts, latest.occurred_at),
556
+ };
557
+ });
558
+ await _refreshMirror(row.customer_id, w, ts);
406
559
  return { restored_points: spent };
407
560
  },
408
561
 
@@ -473,20 +626,26 @@ function create(opts) {
473
626
  "WHERE id = ?2 AND restored_points = ?3",
474
627
  [target, row.id, already],
475
628
  );
476
- if (Number(claim.rowCount || 0) === 0) continue; // lost the claim
629
+ if (!b.sql.casWon(claim).won) continue; // lost the claim
477
630
  await _ensureAccountRow(row.customer_id, ts);
478
- // Credit BALANCE only lifetime is untouched (a redeem never moved
479
- // lifetime; restoring it mustn't either). Relative-atomic so a
480
- // concurrent earn/redeem can't clobber it.
481
- await query(
482
- "UPDATE loyalty_accounts SET balance_points = balance_points + ?1, " +
483
- "updated_at = ?2 WHERE customer_id = ?3",
484
- [delta, ts, row.customer_id],
485
- );
486
- // Positive `redeem`-type ledger row so the trail nets to the
487
- // un-refunded spend. `source` distinguishes it from the original burn.
488
- await _writeTx(row.customer_id, "redeem", delta, "redeem-reversal", oid,
489
- "restored ref=order:" + oid, ts);
631
+ // Forward-only credit-back: ONE chained +delta row off the live tip,
632
+ // BALANCE only (lifetime untouched a redeem never moved it, so the
633
+ // restore mustn't either). The restored_points claim above is the
634
+ // serialization point; there is no revert, so no double-credit hazard.
635
+ // `source='redeem-reversal'` distinguishes it from the original burn so
636
+ // the scan above never re-reads it. _writeChained is awaited before the
637
+ // loop advances, so `delta`/`oid`/`ts` are stable inside the derive.
638
+ var wRestore = await _writeChained(row.customer_id, function (latest) {
639
+ return {
640
+ type: "redeem", points: delta, source: "redeem-reversal", orderId: oid,
641
+ notes: "restored ref=order:" + oid,
642
+ balanceAfter: latest.balance + delta,
643
+ lifetimeAfter: latest.lifetime,
644
+ tierAfter: latest.tier,
645
+ ts: _resolveOccurredAt(ts, latest.occurred_at),
646
+ };
647
+ });
648
+ await _refreshMirror(row.customer_id, wRestore, ts);
490
649
  restoredTotal += delta;
491
650
  }
492
651
  return { restored_points: restoredTotal };
@@ -501,47 +660,38 @@ function create(opts) {
501
660
  var source = _source(input.source);
502
661
  var notes = _notes(input.notes);
503
662
 
504
- var ts = _now();
505
- await _ensureAccountRow(customerId, ts);
506
- // Snapshot the pre-adjust tier only to report `tier_changed`. The
507
- // mutation is relative-atomic with an underflow guard at the SQL
508
- // tier so two concurrent adjustments can't lose an update or drive
509
- // the balance negative past each other.
510
- var before = await _readAccount(customerId);
511
-
512
- // Positive adjustments also increment lifetime — operators
513
- // crediting a customer for a service recovery should see that
514
- // credit count toward tier. Negative adjustments do NOT
515
- // decrement lifetime (otherwise a clawback could downgrade tier
516
- // retroactively, which is a customer-facing surprise). The
517
- // lifetime delta is therefore the positive part of `delta`.
663
+ var requested = _now();
664
+ await _ensureAccountRow(customerId, requested);
665
+ // Positive adjustments increment lifetime (operator goodwill counts
666
+ // toward tier) and recompute the tier; negative adjustments do NOT move
667
+ // lifetime (no retroactive tier downgrade) and are guarded so the balance
668
+ // can't go below zero (balSub + delta >= 0, delta negative) the loser
669
+ // of a concurrent over-draw re-reads the live balance and is refused.
518
670
  var lifetimeDelta = delta > 0 ? delta : 0;
519
- // Conditional UPDATE: the row mutates ONLY when the post-adjust
520
- // balance stays non-negative, checked against the row's LIVE
521
- // balance (not the stale snapshot). A racing concurrent adjust
522
- // that already spent the balance makes this match zero rows, so
523
- // we surface the same insufficient-balance refusal rather than
524
- // writing a ledger row that diverges from the account.
525
- var upd = await query(
526
- "UPDATE loyalty_accounts SET balance_points = balance_points + ?1, " +
527
- "lifetime_points = lifetime_points + ?2, " +
528
- "tier = " + _tierCase("lifetime_points + ?2") + ", " +
529
- "updated_at = ?3 WHERE customer_id = ?4 AND balance_points + ?1 >= 0",
530
- [delta, lifetimeDelta, ts, customerId],
531
- );
532
- if (Number(upd.rowCount || 0) === 0) {
671
+ var tierBefore;
672
+ var w = await _writeChained(customerId, function (latest) {
673
+ tierBefore = latest.tier;
674
+ var lifeAfter = latest.lifetime + lifetimeDelta;
675
+ return {
676
+ type: "adjust", points: delta, source: source, orderId: null, notes: notes,
677
+ balanceAfter: latest.balance + delta,
678
+ lifetimeAfter: lifeAfter,
679
+ tierAfter: delta > 0 ? computeTier(lifeAfter) : latest.tier,
680
+ ts: _resolveOccurredAt(requested, latest.occurred_at),
681
+ guard: delta < 0 ? "__BAL__ + ?4 >= 0" : undefined,
682
+ };
683
+ });
684
+ if (w.refused) {
533
685
  var ins = new Error("loyalty.adjust: adjustment would underflow balance");
534
686
  ins.code = "LOYALTY_INSUFFICIENT_BALANCE";
535
687
  throw ins;
536
688
  }
537
- await _writeTx(customerId, "adjust", delta, source, null, notes, ts);
538
-
539
- var after = await _readAccount(customerId);
689
+ await _refreshMirror(customerId, w, requested);
540
690
  return {
541
- balance: after.balance_points,
542
- lifetime: after.lifetime_points,
543
- tier: after.tier,
544
- tier_changed: after.tier !== before.tier,
691
+ balance: w.balance,
692
+ lifetime: w.lifetime,
693
+ tier: w.tier,
694
+ tier_changed: w.tier !== tierBefore,
545
695
  };
546
696
  },
547
697
 
@@ -564,21 +714,22 @@ function create(opts) {
564
714
  var source = _source(input.source);
565
715
  var notes = _notes(input.notes);
566
716
 
567
- var ts = _now();
568
- await _ensureAccountRow(customerId, ts);
569
- await query(
570
- "UPDATE loyalty_accounts SET balance_points = balance_points + ?1, " +
571
- "updated_at = ?2 WHERE customer_id = ?3",
572
- [points, ts, customerId],
573
- );
574
- await _writeTx(customerId, "adjust", points, source, null, notes, ts);
575
-
576
- var after = await _readAccount(customerId);
577
- return {
578
- balance: after.balance_points,
579
- lifetime: after.lifetime_points,
580
- tier: after.tier,
581
- };
717
+ var requested = _now();
718
+ await _ensureAccountRow(customerId, requested);
719
+ // Forward-only +points chained row, BALANCE only — never moves lifetime
720
+ // and never recomputes tier (giving redeemed points back must not inflate
721
+ // the tier-driving total above what was actually earned).
722
+ var w = await _writeChained(customerId, function (latest) {
723
+ return {
724
+ type: "adjust", points: points, source: source, orderId: null, notes: notes,
725
+ balanceAfter: latest.balance + points,
726
+ lifetimeAfter: latest.lifetime,
727
+ tierAfter: latest.tier,
728
+ ts: _resolveOccurredAt(requested, latest.occurred_at),
729
+ };
730
+ });
731
+ await _refreshMirror(customerId, w, requested);
732
+ return { balance: w.balance, lifetime: w.lifetime, tier: w.tier };
582
733
  },
583
734
 
584
735
  expire: async function (input) {
@@ -592,55 +743,49 @@ function create(opts) {
592
743
  }
593
744
  var reason = _source(input.reason);
594
745
 
595
- var ts = _now();
596
- await _ensureAccountRow(customerId, ts);
597
- var before = await _readAccount(customerId);
598
-
599
- // Expiry caps at the current balance operators schedule annual
600
- // sweeps that compute "points older than 365d" from the ledger;
601
- // when the live balance is already smaller than the requested
602
- // expire amount (because of an interim redemption), we expire
603
- // only what's there rather than refusing.
604
- var toExpire = points > before.balance_points ? before.balance_points : points;
605
- if (toExpire === 0) {
606
- // No-op write the ledger row anyway so the audit trail
607
- // records that the operator ran the sweep and found nothing.
608
- await _writeTx(customerId, "expire", 0, reason, null, "", ts);
746
+ var requested = _now();
747
+ await _ensureAccountRow(customerId, requested);
748
+ // Cap at the LIVE balance on every attempt (an interim redemption may
749
+ // have shrunk it) — expire only what's there, never refusing. A zero-cap
750
+ // (empty balance) still writes a points:0 breadcrumb so the audit trail
751
+ // records the sweep ran. Lifetime is untouched. The chained row carries
752
+ // the new running balance, so there is no stored-column second statement.
753
+ var w = await _writeChained(customerId, function (latest) {
754
+ var toExpire = points > latest.balance ? latest.balance : points;
609
755
  return {
610
- balance: before.balance_points,
611
- lifetime: before.lifetime_points,
612
- tier: before.tier,
613
- expired: 0,
756
+ type: "expire", points: -toExpire, source: reason, orderId: null, notes: "",
757
+ balanceAfter: latest.balance - toExpire,
758
+ lifetimeAfter: latest.lifetime,
759
+ tierAfter: latest.tier,
760
+ ts: _resolveOccurredAt(requested, latest.occurred_at),
761
+ // toExpire is capped at the same tip so the debit never underflows;
762
+ // the guard is belt-and-braces against a racing debit (which would
763
+ // instead collide on the fence and re-cap on retry).
764
+ guard: "__BAL__ + ?4 >= 0",
614
765
  };
615
- }
616
-
617
- await query(
618
- "UPDATE loyalty_accounts SET balance_points = balance_points - ?1, " +
619
- "updated_at = ?2 WHERE customer_id = ?3 AND balance_points >= ?1",
620
- [toExpire, ts, customerId],
621
- );
622
- await _writeTx(customerId, "expire", -toExpire, reason, null, "", ts);
623
-
624
- var after = await _readAccount(customerId);
766
+ });
767
+ await _refreshMirror(customerId, w, requested);
625
768
  return {
626
- balance: after.balance_points,
627
- lifetime: after.lifetime_points,
628
- tier: after.tier,
629
- expired: toExpire,
769
+ balance: w.balance,
770
+ lifetime: w.lifetime,
771
+ tier: w.tier,
772
+ expired: -w.points,
630
773
  };
631
774
  },
632
775
 
633
776
  balance: async function (customerId) {
634
777
  _uuid(customerId, "customer_id");
635
- var row = await _readAccount(customerId);
636
- if (!row) {
637
- return { balance: 0, lifetime: 0, tier: "bronze", tier_expires_at: null };
638
- }
778
+ // Authoritative balance/lifetime/tier come from the chain tip (O(1) on
779
+ // the running index), NOT the advisory loyalty_accounts mirror. Only
780
+ // tier_expires_at an operator time-bound the ledger doesn't carry — is
781
+ // read from the mirror.
782
+ var tip = await _readLatest(customerId);
783
+ var acct = await _readAccount(customerId);
639
784
  return {
640
- balance: row.balance_points,
641
- lifetime: row.lifetime_points,
642
- tier: row.tier,
643
- tier_expires_at: row.tier_expires_at,
785
+ balance: tip.balance,
786
+ lifetime: tip.lifetime,
787
+ tier: tip.tier,
788
+ tier_expires_at: acct ? acct.tier_expires_at : null,
644
789
  };
645
790
  },
646
791
 
@@ -652,8 +797,14 @@ function create(opts) {
652
797
  throw new TypeError("loyalty.history: limit must be an integer in [1, 500]");
653
798
  }
654
799
  var cursor = opts2.cursor;
800
+ // Exclude the per-customer chain-genesis anchor (the migration backfill
801
+ // row, id 'genesis-<customer_id>', points 0) — it carries the running-
802
+ // balance snapshot for the chain, not a real customer transaction. A real
803
+ // uuid.v7 id never starts with 'genesis-', so this excludes only the
804
+ // anchor (filtering by id-prefix, not by source: an operator source could
805
+ // legitimately be 'chain-genesis').
655
806
  var sql = "SELECT id, customer_id, transaction_type, points, source, order_id, notes, occurred_at " +
656
- "FROM loyalty_transactions WHERE customer_id = ?1";
807
+ "FROM loyalty_transactions WHERE customer_id = ?1 AND id NOT LIKE 'genesis-%'";
657
808
  var params = [customerId];
658
809
  if (cursor != null) {
659
810
  if (typeof cursor !== "number" || !Number.isInteger(cursor) || cursor < 0) {
@@ -700,6 +851,70 @@ function create(opts) {
700
851
  var r = await query(sql, params);
701
852
  return r.rows;
702
853
  },
854
+
855
+ // Walk a customer's running-balance chain and recompute every row_hash to
856
+ // prove no row was edited or re-ordered after the fact (the loyalty leg of
857
+ // the money-ledger tamper-evidence). Mirrors gift-card-ledger.verifyChain:
858
+ // a NULL-row_hash legacy/genesis prefix is skipped until the first hashed
859
+ // row anchors the chain; a populated all-NULL ledger is `unanchored` (the
860
+ // shape a full rewrite produces), not "valid". An optional trusted
861
+ // { count, head } anchor additionally rules out a tail truncation. Verifies
862
+ // over _rowFieldsForHash — the SAME tuple the writer hashes (order_id +
863
+ // restored_points excluded), so a linked redemption or a restore can't
864
+ // false-tamper. O(n); operator-audit use, not hot-path.
865
+ verifyChain: async function (customerId, opts) {
866
+ _uuid(customerId, "customer_id");
867
+ opts = opts || {};
868
+ var anchor = null;
869
+ if (opts.anchor != null) {
870
+ if (typeof opts.anchor !== "object"
871
+ || typeof opts.anchor.count !== "number" || !Number.isInteger(opts.anchor.count) || opts.anchor.count < 1
872
+ || typeof opts.anchor.head !== "string" || opts.anchor.head.length !== SHA3_512_HEX_LEN) {
873
+ throw new TypeError("loyalty.verifyChain: anchor must be { count: positive integer, head: " + SHA3_512_HEX_LEN + "-hex-char string }");
874
+ }
875
+ anchor = opts.anchor;
876
+ }
877
+ var rows = (await query(
878
+ "SELECT * FROM loyalty_transactions WHERE customer_id = ?1 ORDER BY occurred_at ASC, id ASC",
879
+ [customerId],
880
+ )).rows;
881
+ var legacyPrefix = 0, anchored = false, prevHash = ZERO_HASH;
882
+ for (var i = 0; i < rows.length; i += 1) {
883
+ var row = rows[i];
884
+ if (!anchored && row.row_hash == null) { legacyPrefix += 1; continue; }
885
+ anchored = true;
886
+ var breakBase = { ok: false, rows_verified: i - legacyPrefix, legacy_prefix: legacyPrefix,
887
+ break_at: i, break_row_id: row.id };
888
+ if (row.row_hash == null) return Object.assign(breakBase, { reason: "unhashed row after chain anchor" });
889
+ if (row.prev_hash !== prevHash) {
890
+ return Object.assign(breakBase, { reason: "prev_hash mismatch", expected: prevHash, actual: row.prev_hash });
891
+ }
892
+ var computed = _computeRowHash(prevHash, _rowFieldsForHash(row));
893
+ if (computed !== row.row_hash) {
894
+ return Object.assign(breakBase, { reason: "row_hash mismatch", expected: computed, actual: row.row_hash });
895
+ }
896
+ prevHash = row.row_hash;
897
+ }
898
+ if (!anchored && rows.length > 0) {
899
+ return { ok: false, rows_verified: 0, legacy_prefix: legacyPrefix, break_at: 0,
900
+ break_row_id: rows[0].id, reason: "unanchored chain (no hashed row in a populated ledger)" };
901
+ }
902
+ if (anchor) {
903
+ if (rows.length < anchor.count) {
904
+ return { ok: false, rows_verified: rows.length - legacyPrefix, legacy_prefix: legacyPrefix,
905
+ anchor_checked: true, reason: "row count below anchor (possible tail truncation)",
906
+ expected_count: anchor.count, actual_count: rows.length };
907
+ }
908
+ var anchorRow = rows[anchor.count - 1];
909
+ if (anchorRow.row_hash !== anchor.head) {
910
+ return { ok: false, rows_verified: rows.length - legacyPrefix, legacy_prefix: legacyPrefix,
911
+ anchor_checked: true, reason: "anchor head mismatch (chain replaced below snapshot)",
912
+ expected: anchor.head, actual: anchorRow.row_hash };
913
+ }
914
+ }
915
+ return { ok: true, rows_verified: rows.length - legacyPrefix, legacy_prefix: legacyPrefix,
916
+ head: prevHash, anchor_checked: !!anchor };
917
+ },
703
918
  };
704
919
  }
705
920