@blamejs/blamejs-shop 0.4.23 → 0.4.25

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.
@@ -78,6 +78,43 @@ var MAX_LIST_LIMIT = 200;
78
78
  var RECEIPT_STATUSES = Object.freeze(["pending", "applied", "reversed"]);
79
79
  var RECEIPT_ORDER_KEY = ["received_at:desc", "id:desc"];
80
80
 
81
+ // The receipt lifecycle, modelled on b.fsm — the same composition the order /
82
+ // quote / stock-transfer primitives use. The FSM is the single source of truth
83
+ // for which (status, event) pairs are legal:
84
+ //
85
+ // apply pending -> applied (apply; restocks every line)
86
+ // reverse applied -> reversed (reverse; decrements every line back out)
87
+ //
88
+ // Terminal (reversed) declares no outgoing edge, so the machine itself refuses
89
+ // a reverse-after-reverse. `apply` on an already-'applied' receipt is a
90
+ // documented IDEMPOTENT no-op (returns { applied_count: 0 }) — apply() short-
91
+ // circuits to that no-op before consulting the machine, preserving the
92
+ // replay-safe contract callers depend on.
93
+ var RECEIPT_TRANSITIONS = Object.freeze([
94
+ { from: "pending", to: "applied", on: "apply" },
95
+ { from: "applied", to: "reversed", on: "reverse" },
96
+ ]);
97
+
98
+ var _receiptFsm = null;
99
+ function _getReceiptFsm() {
100
+ if (_receiptFsm) return _receiptFsm;
101
+ try { b.audit.registerNamespace("fsm"); } catch (_e) { /* idempotent; ignore */ }
102
+ _receiptFsm = b.fsm.define({
103
+ name: "inventory_receipt",
104
+ initial: "pending",
105
+ states: { pending: {}, applied: {}, reversed: {} },
106
+ transitions: RECEIPT_TRANSITIONS.map(function (t) {
107
+ return { from: t.from, to: t.to, on: t.on };
108
+ }),
109
+ });
110
+ return _receiptFsm;
111
+ }
112
+
113
+ function _canReceipt(fromStatus, event) {
114
+ var fsm = _getReceiptFsm();
115
+ return fsm.restore({ state: fromStatus, history: [], context: {} }).can(event);
116
+ }
117
+
81
118
  // ---- validators ---------------------------------------------------------
82
119
 
83
120
  function _id(s, label) {
@@ -293,13 +330,32 @@ function create(opts) {
293
330
  if (!receipt) {
294
331
  throw new TypeError("inventory-receive.apply: receipt " + receiptId + " not found");
295
332
  }
296
- if (receipt.status === "applied") {
297
- // Idempotent caller can replay without surprise.
298
- return { id: receiptId, applied_count: 0, stock_changes: [] };
299
- }
300
- if (receipt.status !== "pending") {
333
+ // Preserve the idempotent no-op on an already-'applied' receipt: the
334
+ // FSM has no apply edge out of 'applied', so short-circuit here BEFORE
335
+ // asserting the transition (regression-safe replay contract callers
336
+ // replay apply() without surprise).
337
+ if (!_canReceipt(receipt.status, "apply")) {
338
+ if (receipt.status === "applied") {
339
+ return { id: receiptId, applied_count: 0, stock_changes: [] };
340
+ }
301
341
  throw new TypeError("inventory-receive.apply: receipt is " + receipt.status + ", only pending receipts can be applied");
302
342
  }
343
+ // Claim the pending -> applied transition atomically BEFORE restocking.
344
+ // Two concurrent applies both pass the read above, but only one UPDATE
345
+ // matches `status = 'pending'`; the loser sees rowCount 0 and returns the
346
+ // idempotent no-op instead of double-restocking the catalog. (Without
347
+ // this guard both calls would restock every line, inflating
348
+ // stock_on_hand.)
349
+ var ts = _now();
350
+ var claim = await query(
351
+ "UPDATE inventory_receipts SET status = 'applied', updated_at = ?1 WHERE id = ?2 AND status = 'pending'",
352
+ [ts, receiptId],
353
+ );
354
+ if (Number(claim.rowCount || 0) !== 1) {
355
+ // A concurrent call won the claim and is restocking (or already did).
356
+ // Replay-safe no-op so the loser doesn't double-apply.
357
+ return { id: receiptId, applied_count: 0, stock_changes: [] };
358
+ }
303
359
  var stockChanges = [];
304
360
  var applied = [];
305
361
  try {
@@ -310,8 +366,8 @@ function create(opts) {
310
366
  stockChanges.push({ sku: l.sku, qty: l.qty_received });
311
367
  }
312
368
  } catch (e) {
313
- // Undo every successful restock so the database state matches
314
- // the pre-apply snapshot. The receipt stays 'pending' so the
369
+ // Undo every successful restock so the database state matches the
370
+ // pre-apply snapshot, then release the claim back to 'pending' so the
315
371
  // operator can fix the offending line and retry.
316
372
  for (var j = applied.length - 1; j >= 0; j -= 1) {
317
373
  try {
@@ -321,15 +377,16 @@ function create(opts) {
321
377
  );
322
378
  } catch (_e3) { /* drop-silent — the original apply error is what the operator needs to fix */ }
323
379
  }
380
+ try {
381
+ await query(
382
+ "UPDATE inventory_receipts SET status = 'pending', updated_at = ?1 WHERE id = ?2 AND status = 'applied'",
383
+ [_now(), receiptId],
384
+ );
385
+ } catch (_e4) { /* drop-silent — the original apply error is the operator's signal */ }
324
386
  var err = new Error("inventory-receive.apply: restock failed — " + (e && e.message || e));
325
387
  err.cause = e;
326
388
  throw err;
327
389
  }
328
- var ts = _now();
329
- await query(
330
- "UPDATE inventory_receipts SET status = 'applied', updated_at = ?1 WHERE id = ?2",
331
- [ts, receiptId],
332
- );
333
390
  return {
334
391
  id: receiptId,
335
392
  applied_count: applied.length,
@@ -352,18 +409,55 @@ function create(opts) {
352
409
  if (!receipt) {
353
410
  throw new TypeError("inventory-receive.reverse: receipt " + receiptId + " not found");
354
411
  }
355
- if (receipt.status !== "applied") {
412
+ if (!_canReceipt(receipt.status, "reverse")) {
356
413
  throw new TypeError("inventory-receive.reverse: receipt is " + receipt.status + ", only applied receipts can be reversed");
357
414
  }
415
+ // Claim the applied -> reversed transition atomically BEFORE decrementing.
416
+ // Two concurrent reverses both pass the read above, but only one UPDATE
417
+ // matches `status = 'applied'`; the loser refuses, so the shelf is
418
+ // decremented exactly once. (Without this guard both calls would
419
+ // decrement every line, destroying unrelated base stock.)
420
+ var claimTs = _now();
421
+ var claim = await query(
422
+ "UPDATE inventory_receipts SET status = 'reversed', updated_at = ?1 WHERE id = ?2 AND status = 'applied'",
423
+ [claimTs, receiptId],
424
+ );
425
+ if (Number(claim.rowCount || 0) !== 1) {
426
+ throw new TypeError("inventory-receive.reverse: receipt " + receiptId +
427
+ " is no longer applied (reversed by a concurrent call)");
428
+ }
358
429
  var stockChanges = [];
359
- for (var i = 0; i < receipt.lines.length; i += 1) {
360
- var l = receipt.lines[i];
361
- var ts = _now();
430
+ var decremented = [];
431
+ try {
432
+ for (var i = 0; i < receipt.lines.length; i += 1) {
433
+ var l = receipt.lines[i];
434
+ var ts = _now();
435
+ await query(
436
+ "UPDATE inventory SET stock_on_hand = MAX(0, stock_on_hand - ?1), updated_at = ?2 WHERE sku = ?3",
437
+ [l.qty_received, ts, l.sku],
438
+ );
439
+ decremented.push(l);
440
+ stockChanges.push({ sku: l.sku, qty: -l.qty_received });
441
+ }
442
+ } catch (e) {
443
+ // Compensate the decrements that already landed and release the claim so
444
+ // the receipt stays eligible for another reverse — otherwise a mid-loop
445
+ // failure strands it 'reversed' with only a partial stock rollback that
446
+ // the status check then refuses to retry.
447
+ for (var c = 0; c < decremented.length; c += 1) {
448
+ var dl = decremented[c];
449
+ try {
450
+ await query(
451
+ "UPDATE inventory SET stock_on_hand = stock_on_hand + ?1, updated_at = ?2 WHERE sku = ?3",
452
+ [dl.qty_received, _now(), dl.sku],
453
+ );
454
+ } catch (_compErr) { /* best-effort compensation; the claim release below restores retryability */ }
455
+ }
362
456
  await query(
363
- "UPDATE inventory SET stock_on_hand = MAX(0, stock_on_hand - ?1), updated_at = ?2 WHERE sku = ?3",
364
- [l.qty_received, ts, l.sku],
457
+ "UPDATE inventory_receipts SET status = 'applied', updated_at = ?1 WHERE id = ?2 AND status = 'reversed'",
458
+ [_now(), receiptId],
365
459
  );
366
- stockChanges.push({ sku: l.sku, qty: -l.qty_received });
460
+ throw e;
367
461
  }
368
462
  // Append the reason into the receipt notes so the audit trail
369
463
  // carries the rationale. Operators that want a richer reversal
@@ -377,9 +471,11 @@ function create(opts) {
377
471
  newNotes = newNotes.slice(0, MAX_NOTES_LEN);
378
472
  }
379
473
  }
474
+ // Status was already flipped to 'reversed' by the atomic claim above;
475
+ // this write only stamps the reversal reason into the notes.
380
476
  var ts2 = _now();
381
477
  await query(
382
- "UPDATE inventory_receipts SET status = 'reversed', notes = ?1, updated_at = ?2 WHERE id = ?3",
478
+ "UPDATE inventory_receipts SET notes = ?1, updated_at = ?2 WHERE id = ?3",
383
479
  [newNotes, ts2, receiptId],
384
480
  );
385
481
  return {
@@ -530,41 +530,64 @@ function create(opts) {
530
530
  throw new TypeError("inventory-writeoffs.reverseWriteoff: writeoff " + id +
531
531
  " is " + row.status + ", only recorded writeoffs can be reversed");
532
532
  }
533
+ // A cost-impact attribution with costLayers unwired is an operator
534
+ // misconfiguration — refuse up front BEFORE claiming the row, so the
535
+ // claim only happens on a reversal we can actually complete.
536
+ if (row.cost_impact_minor != null && costLayers === null) {
537
+ throw new TypeError("inventory-writeoffs.reverseWriteoff: writeoff " + id +
538
+ " carries a cost-impact attribution but costLayers is not wired — " +
539
+ "rewire costLayers before reversing this row");
540
+ }
541
+
542
+ // Claim the recorded -> reversed transition atomically BEFORE restoring
543
+ // the shelf or the cost-layer pool. Two concurrent reverses both pass the
544
+ // read above, but only one UPDATE matches `status = 'recorded'`; the
545
+ // loser refuses, so the shelf restore + cost-layer reversal each run
546
+ // exactly once. (Without this guard both calls would restore stock and
547
+ // record a cost-layer reversal, double-restoring.)
548
+ var ts = _now();
549
+ var claim = await query(
550
+ "UPDATE inventory_writeoffs SET status = 'reversed', reversed_at = ?1, " +
551
+ "reverse_reason = ?2 WHERE id = ?3 AND status = 'recorded'",
552
+ [ts, reason, id],
553
+ );
554
+ if (Number(claim.rowCount || 0) !== 1) {
555
+ throw new TypeError("inventory-writeoffs.reverseWriteoff: writeoff " + id +
556
+ " is no longer recorded (reversed by a concurrent call)");
557
+ }
558
+
559
+ // Compensating release of the claim — flips the row back to 'recorded'
560
+ // so the operator can retry once they've fixed the side-effect failure.
561
+ async function _releaseClaim() {
562
+ try {
563
+ await query(
564
+ "UPDATE inventory_writeoffs SET status = 'recorded', reversed_at = NULL, " +
565
+ "reverse_reason = NULL WHERE id = ?1 AND status = 'reversed'",
566
+ [id],
567
+ );
568
+ } catch (_e0) { /* drop-silent — the side-effect error is the operator's signal */ }
569
+ }
570
+
533
571
  // Restore the shelf. Mirrors the recordWriteoff logic: when
534
572
  // location_code is null the original write-off didn't touch a
535
573
  // specific shelf, so nothing to restore.
536
574
  if (row.location_code != null) {
537
- await locations.adjustStock({
538
- sku: row.sku,
539
- location_code: row.location_code,
540
- delta: row.quantity,
541
- reason: "writeoff:reverse:" + id,
542
- });
575
+ try {
576
+ await locations.adjustStock({
577
+ sku: row.sku,
578
+ location_code: row.location_code,
579
+ delta: row.quantity,
580
+ reason: "writeoff:reverse:" + id,
581
+ });
582
+ } catch (e1) {
583
+ await _releaseClaim();
584
+ throw e1;
585
+ }
543
586
  }
544
- // Restore the cost layer pool when the original write-off
545
- // attributed COGS. costLayers may have been unwired since the
546
- // original write-off the operator gets a clear refusal in
547
- // that case rather than a silent skip that desynchronizes the
548
- // shelf from the cost ledger.
587
+ // Restore the cost layer pool when the original write-off attributed
588
+ // COGS. On failure, undo the shelf restore + release the claim so the
589
+ // operator sees a clean refusal rather than a half-applied reversal.
549
590
  if (row.cost_impact_minor != null) {
550
- if (costLayers === null) {
551
- // Compensating action: undo the shelf restore so the
552
- // operator sees a clean refusal rather than a half-applied
553
- // reversal.
554
- if (row.location_code != null) {
555
- try {
556
- await locations.adjustStock({
557
- sku: row.sku,
558
- location_code: row.location_code,
559
- delta: -row.quantity,
560
- reason: "writeoff:reverse:rollback:" + id,
561
- });
562
- } catch (_e1) { /* drop-silent — original refusal is the operator's signal */ }
563
- }
564
- throw new TypeError("inventory-writeoffs.reverseWriteoff: writeoff " + id +
565
- " carries a cost-impact attribution but costLayers is not wired — " +
566
- "rewire costLayers before reversing this row");
567
- }
568
591
  try {
569
592
  await costLayers.recordReversal({
570
593
  order_id: "writeoff:" + id,
@@ -582,44 +605,10 @@ function create(opts) {
582
605
  });
583
606
  } catch (_e2) { /* drop-silent — the costLayers error is the operator's signal */ }
584
607
  }
608
+ await _releaseClaim();
585
609
  throw e;
586
610
  }
587
611
  }
588
- var ts = _now();
589
- try {
590
- await query(
591
- "UPDATE inventory_writeoffs SET status = 'reversed', reversed_at = ?1, " +
592
- "reverse_reason = ?2 WHERE id = ?3",
593
- [ts, reason, id],
594
- );
595
- } catch (e3) {
596
- if (row.cost_impact_minor != null && costLayers !== null) {
597
- // Best-effort compensating: re-consume the cost layer so
598
- // the cost ledger doesn't sit in a contradictory state.
599
- // Failure here is drop-silent because the original DB
600
- // error is what the operator needs to fix; the audit row
601
- // on cost_layers still tells the story.
602
- try {
603
- await costLayers.consumeForSale({
604
- sku: row.sku,
605
- quantity: row.quantity,
606
- order_id: "writeoff:" + id,
607
- line_id: "1",
608
- });
609
- } catch (_e4) { /* drop-silent — original DB error is the operator's signal */ }
610
- }
611
- if (row.location_code != null) {
612
- try {
613
- await locations.adjustStock({
614
- sku: row.sku,
615
- location_code: row.location_code,
616
- delta: -row.quantity,
617
- reason: "writeoff:reverse:rollback:" + id,
618
- });
619
- } catch (_e5) { /* drop-silent — original DB error is the operator's signal */ }
620
- }
621
- throw e3;
622
- }
623
612
  return await _getWriteoffRow(id);
624
613
  },
625
614
  };
@@ -753,6 +753,122 @@ function create(opts) {
753
753
  return { reversed_points: earned, clawed_points: clawed };
754
754
  }
755
755
 
756
+ // ---- reverseForEventProRata -----------------------------------------
757
+
758
+ // Claw back earned points PROPORTIONALLY to a partial refund — the
759
+ // refund-by-amount counterpart to reverseForEvent's all-or-nothing (cancel)
760
+ // claw. A full refund claws every awarded point; a partial refund must claw
761
+ // only the share the refund covers, or a 10%-refunded order claws 100% of
762
+ // the points the buyer legitimately earned on the 90% they kept.
763
+ //
764
+ // For each award row the target cumulative claw is
765
+ // floor(points_awarded * refunded_minor / order_total_minor), clamped to
766
+ // points_awarded. The claw is the delta vs `clawed_points` (the cumulative
767
+ // already clawed), advanced under a guarded UPDATE keyed on the row's live
768
+ // value so a concurrent double-fire can't double-claw. A row that reaches
769
+ // full claw also stamps reversed_at so the binary reverseForEvent treats it
770
+ // as done. The total delta is then taken off the running balance, floored at
771
+ // zero (a customer may have spent the points) with the same
772
+ // re-read-and-retry on a concurrent-spend underflow as reverseForEvent.
773
+ //
774
+ // Idempotent + a natural no-op for an order that earned nothing or one
775
+ // already clawed to the requested proportion. Returns
776
+ // { reversed_points, clawed_points }: reversed_points is the delta targeted
777
+ // this call; clawed_points is what actually came off the balance.
778
+ async function reverseForEventProRata(input) {
779
+ if (!input || typeof input !== "object") {
780
+ throw new TypeError("loyaltyEarnRules.reverseForEventProRata: input object required");
781
+ }
782
+ var customerId = _uuid(input.customer_id, "customer_id");
783
+ var triggerEventRef = _triggerEventRef(input.trigger_event_ref);
784
+ var refundedMinor = input.refunded_minor;
785
+ var orderTotalMinor = input.order_total_minor;
786
+ if (typeof refundedMinor !== "number" || !Number.isInteger(refundedMinor) || refundedMinor < 0) {
787
+ throw new TypeError("loyaltyEarnRules.reverseForEventProRata: refunded_minor must be a non-negative integer (minor units)");
788
+ }
789
+ if (typeof orderTotalMinor !== "number" || !Number.isInteger(orderTotalMinor) || orderTotalMinor <= 0) {
790
+ throw new TypeError("loyaltyEarnRules.reverseForEventProRata: order_total_minor must be a positive integer (minor units)");
791
+ }
792
+ var effRefunded = refundedMinor > orderTotalMinor ? orderTotalMinor : refundedMinor;
793
+
794
+ var rows = (await query(
795
+ "SELECT id, points_awarded, clawed_points FROM loyalty_earn_log " +
796
+ "WHERE customer_id = ?1 AND trigger_event_ref = ?2",
797
+ [customerId, triggerEventRef],
798
+ )).rows;
799
+
800
+ // First pass: claim the proportional delta on each award row. The
801
+ // guarded UPDATE (clawed_points = expected-prior) serializes a concurrent
802
+ // double-fire so a slice is claimed once. Sum the claimed deltas — that's
803
+ // what comes off the balance below.
804
+ var toClaw = 0;
805
+ var claimedRows = [];
806
+ for (var i = 0; i < rows.length; i += 1) {
807
+ var row = rows[i];
808
+ var awarded = Number(row.points_awarded || 0);
809
+ if (awarded <= 0) continue;
810
+ var already = Number(row.clawed_points || 0);
811
+ var target = Math.floor((awarded * effRefunded) / orderTotalMinor);
812
+ if (target > awarded) target = awarded;
813
+ var delta = target - already;
814
+ if (delta <= 0) continue;
815
+ var ts = _now();
816
+ var nowFull = target >= awarded;
817
+ var claim = await query(
818
+ "UPDATE loyalty_earn_log SET clawed_points = ?1, " +
819
+ "reversed_at = CASE WHEN ?2 = 1 THEN ?3 ELSE reversed_at END " +
820
+ "WHERE id = ?4 AND clawed_points = ?5",
821
+ [target, nowFull ? 1 : 0, ts, row.id, already],
822
+ );
823
+ if (Number(claim.rowCount || 0) === 0) continue; // lost the claim
824
+ toClaw += delta;
825
+ claimedRows.push({ id: row.id, delta: delta, prior: already });
826
+ }
827
+ if (toClaw <= 0) {
828
+ return { reversed_points: 0, clawed_points: 0 };
829
+ }
830
+
831
+ // Take the claimed total off the running balance, floored at zero, same
832
+ // re-read-and-retry discipline as reverseForEvent. On a non-floor failure
833
+ // release the claimed slices so a later reversal can re-attempt, then
834
+ // surface the original error.
835
+ var clawed = 0;
836
+ if (loyaltyHandle && typeof loyaltyHandle.adjust === "function"
837
+ && typeof loyaltyHandle.balance === "function") {
838
+ try {
839
+ for (var attempt = 0; attempt < 3; attempt += 1) {
840
+ var bal = await loyaltyHandle.balance(customerId);
841
+ var claw = Math.min(toClaw, Number((bal && bal.balance) || 0));
842
+ if (claw <= 0) break;
843
+ try {
844
+ await loyaltyHandle.adjust({
845
+ customer_id: customerId,
846
+ points: -claw,
847
+ source: "earn-reversal",
848
+ notes: "reversed ref=" + triggerEventRef + " partial",
849
+ });
850
+ clawed = claw;
851
+ break;
852
+ } catch (err) {
853
+ if (!(err && err.code === "LOYALTY_INSUFFICIENT_BALANCE")) throw err;
854
+ }
855
+ }
856
+ } catch (clawErr) {
857
+ for (var r = 0; r < claimedRows.length; r += 1) {
858
+ try {
859
+ await query(
860
+ "UPDATE loyalty_earn_log SET clawed_points = ?1, reversed_at = NULL WHERE id = ?2",
861
+ [claimedRows[r].prior, claimedRows[r].id],
862
+ );
863
+ } catch (_releaseErr) { /* drop-silent — the original failure is the signal */ }
864
+ }
865
+ throw clawErr;
866
+ }
867
+ }
868
+
869
+ return { reversed_points: toClaw, clawed_points: clawed };
870
+ }
871
+
756
872
  // ---- metricsForRule -------------------------------------------------
757
873
 
758
874
  async function metricsForRule(input) {
@@ -873,6 +989,7 @@ function create(opts) {
873
989
  evaluateForEvent: evaluateForEvent,
874
990
  awardForEvent: awardForEvent,
875
991
  reverseForEvent: reverseForEvent,
992
+ reverseForEventProRata: reverseForEventProRata,
876
993
  metricsForRule: metricsForRule,
877
994
  applyBatch: applyBatch,
878
995
  };
package/lib/loyalty.js CHANGED
@@ -346,6 +346,85 @@ function create(opts) {
346
346
  };
347
347
  },
348
348
 
349
+ // Restore points a customer SPENT as a checkout tender when the order
350
+ // that spent them is refunded — the symmetric counterpart to the
351
+ // gift-card-spend restore. `redeem` debited the balance at checkout
352
+ // against `order_id`; a refund returns money to the buyer, so the points
353
+ // they tendered have to come back to their balance or the refund silently
354
+ // burns them (inconsistent with the gift-card spend, which IS restored).
355
+ //
356
+ // PROPORTIONAL to the refund: the target cumulative restore per redeem row
357
+ // is floor(spent_points * refunded_minor / order_total_minor), clamped to
358
+ // the points spent, so a partial refund restores only the covered share
359
+ // and a partial-then-final sequence converges exactly on the points spent.
360
+ // The credit is the delta vs `restored_points` (the cumulative already
361
+ // restored), advanced under a guarded UPDATE keyed on the row's live value
362
+ // so a concurrent double-fire can't double-restore. Balance only —
363
+ // lifetime is NOT touched (redeem never decremented it, so the restore
364
+ // mustn't inflate it and retroactively promote a tier). A new positive
365
+ // `redeem` ledger row records each restore so the audit trail nets to the
366
+ // un-refunded spend.
367
+ //
368
+ // Idempotent + a natural no-op for an order that tendered no points (no
369
+ // redeem rows) or one already restored to the requested proportion.
370
+ // Returns { restored_points } — the points credited back this call.
371
+ restoreRedemption: async function (orderId, input) {
372
+ var oid = _uuid(orderId, "order_id");
373
+ input = input || {};
374
+ var refundedMinor = input.refunded_minor;
375
+ var orderTotalMinor = input.order_total_minor;
376
+ if (typeof refundedMinor !== "number" || !Number.isInteger(refundedMinor) || refundedMinor < 0) {
377
+ throw new TypeError("loyalty.restoreRedemption: refunded_minor must be a non-negative integer (minor units)");
378
+ }
379
+ if (typeof orderTotalMinor !== "number" || !Number.isInteger(orderTotalMinor) || orderTotalMinor <= 0) {
380
+ throw new TypeError("loyalty.restoreRedemption: order_total_minor must be a positive integer (minor units)");
381
+ }
382
+ var effRefunded = refundedMinor > orderTotalMinor ? orderTotalMinor : refundedMinor;
383
+
384
+ var rows = (await query(
385
+ "SELECT id, customer_id, points, restored_points FROM loyalty_transactions " +
386
+ "WHERE order_id = ?1 AND transaction_type = 'redeem'",
387
+ [oid],
388
+ )).rows;
389
+ var restoredTotal = 0;
390
+ for (var i = 0; i < rows.length; i += 1) {
391
+ var row = rows[i];
392
+ // `points` on a redeem row is the NEGATIVE delta; the spend is its
393
+ // magnitude.
394
+ var spent = Math.abs(Number(row.points || 0));
395
+ if (spent <= 0) continue;
396
+ var already = Number(row.restored_points || 0);
397
+ var target = Math.floor((spent * effRefunded) / orderTotalMinor);
398
+ if (target > spent) target = spent;
399
+ var delta = target - already;
400
+ if (delta <= 0) continue;
401
+ var ts = _now();
402
+ // Claim the slice: advance restored_points from its expected prior
403
+ // value so a concurrent restore can't double-credit.
404
+ var claim = await query(
405
+ "UPDATE loyalty_transactions SET restored_points = ?1 " +
406
+ "WHERE id = ?2 AND restored_points = ?3",
407
+ [target, row.id, already],
408
+ );
409
+ if (Number(claim.rowCount || 0) === 0) continue; // lost the claim
410
+ await _ensureAccountRow(row.customer_id, ts);
411
+ // Credit BALANCE only — lifetime is untouched (a redeem never moved
412
+ // lifetime; restoring it mustn't either). Relative-atomic so a
413
+ // concurrent earn/redeem can't clobber it.
414
+ await query(
415
+ "UPDATE loyalty_accounts SET balance_points = balance_points + ?1, " +
416
+ "updated_at = ?2 WHERE customer_id = ?3",
417
+ [delta, ts, row.customer_id],
418
+ );
419
+ // Positive `redeem`-type ledger row so the trail nets to the
420
+ // un-refunded spend. `source` distinguishes it from the original burn.
421
+ await _writeTx(row.customer_id, "redeem", delta, "redeem-reversal", oid,
422
+ "restored ref=order:" + oid, ts);
423
+ restoredTotal += delta;
424
+ }
425
+ return { restored_points: restoredTotal };
426
+ },
427
+
349
428
  adjust: async function (input) {
350
429
  if (!input || typeof input !== "object") {
351
430
  throw new TypeError("loyalty.adjust: input object required");
package/lib/newsletter.js CHANGED
@@ -30,7 +30,10 @@
30
30
  * - `consumeUnsubscribeToken(plaintext)` — single-use exchange.
31
31
  * Marks the token consumed and stamps
32
32
  * `newsletter_signups.unsubscribed_at` for the linked signup.
33
- * Returns a structured result with one of the error codes
33
+ * When an `emailSuppressions` handle is wired into `create`, it
34
+ * also adds the address to the marketing-scope suppression list
35
+ * so every other marketing flow honours the opt-out. Returns a
36
+ * structured result with one of the error codes
34
37
  * `"not-found" | "already-consumed" | "expired"`, or `"ok"`
35
38
  * on success.
36
39
  * - `resubscribe({ email })` — clears `unsubscribed_at` for an
@@ -115,6 +118,18 @@ function create(opts) {
115
118
  query = function (sql, params) { return b.externalDb.query(sql, params); };
116
119
  }
117
120
 
121
+ // Optional marketing-suppression handle. When wired, an unsubscribe
122
+ // feeds the suppression list (marketing scope) so EVERY other
123
+ // marketing flow — wishlist alerts, abandoned-cart, review requests —
124
+ // honours the opt-out, not just the newsletter broadcast that reads
125
+ // `unsubscribed_at` directly. Without it the unsubscribe still stamps
126
+ // `unsubscribed_at` (newsletter broadcasts respect that), but the
127
+ // suppression list is the single source of truth other flows consult.
128
+ var emailSuppressions = opts.emailSuppressions || null;
129
+ if (emailSuppressions && typeof emailSuppressions.add !== "function") {
130
+ throw new TypeError("newsletter.create: opts.emailSuppressions must expose add when wired");
131
+ }
132
+
118
133
  return {
119
134
  EMAIL_NAMESPACE: EMAIL_NAMESPACE,
120
135
 
@@ -258,9 +273,31 @@ function create(opts) {
258
273
  [now, row.signup_id],
259
274
  );
260
275
  var signup = (await query(
261
- "SELECT email_hash FROM newsletter_signups WHERE id = ?1 LIMIT 1",
276
+ "SELECT email_hash, email_normalized FROM newsletter_signups WHERE id = ?1 LIMIT 1",
262
277
  [row.signup_id],
263
278
  )).rows[0];
279
+
280
+ // Feed the suppression list so every OTHER marketing flow (wishlist
281
+ // alerts, abandoned-cart, review requests) stops mailing this
282
+ // address — not just the newsletter broadcast that reads
283
+ // `unsubscribed_at`. The signup row persists the plaintext
284
+ // `email_normalized` precisely so the operator can act on the
285
+ // address; pass it through verbatim. Best-effort: a suppression
286
+ // write failure must not turn a successful unsubscribe into an
287
+ // error page — the `unsubscribed_at` stamp already landed and the
288
+ // broadcast path honours it.
289
+ if (emailSuppressions && signup && typeof signup.email_normalized === "string" && signup.email_normalized.length) {
290
+ try {
291
+ await emailSuppressions.add({
292
+ email: signup.email_normalized,
293
+ suppression_type: "unsubscribe",
294
+ scope: "marketing",
295
+ source: "newsletter-unsubscribe",
296
+ reason: "one-click unsubscribe",
297
+ });
298
+ } catch (_eSup) { /* drop-silent — unsubscribe already committed */ }
299
+ }
300
+
264
301
  return {
265
302
  ok: true,
266
303
  error: "ok",
@@ -309,6 +309,20 @@ function create(opts) {
309
309
  query = function (sql, params) { return b.externalDb.query(sql, params); };
310
310
  }
311
311
 
312
+ // Single-writer discipline for the append path. `record()` reads the
313
+ // chain head, derives prev_hash + row_hash from it, then INSERTs — a
314
+ // read-derive-write sequence that is NOT atomic across the three
315
+ // awaits. Two concurrent record() calls would otherwise both read the
316
+ // same head, both stamp the same prev_hash, and fork the chain — which
317
+ // verifyChain() then reports as a prev_hash mismatch, indistinguishable
318
+ // from a real tamper. Serializing the whole body behind a per-instance
319
+ // mutex collapses the window: the second writer blocks until the first
320
+ // has committed its row, so it reads the freshly-advanced head. In-
321
+ // process only — it narrows the fork window to a single isolate, the
322
+ // same bound the framework chain primitive carries; the checkpoint
323
+ // anchoring below remains the cross-isolate / full-rewrite defense.
324
+ var appendMutex = new b.safeAsync.Mutex();
325
+
312
326
  async function _currentHead() {
313
327
  var r = await query(
314
328
  "SELECT row_hash, occurred_at, id FROM operator_audit_events " +
@@ -331,6 +345,8 @@ function create(opts) {
331
345
  if (!input || typeof input !== "object") {
332
346
  throw new TypeError("operatorAuditLog.record: input object required");
333
347
  }
348
+ // Validate OUTSIDE the lock — a bad-input throw shouldn't hold the
349
+ // append serializer, and validation touches no shared chain state.
334
350
  var actorType = _actorType(input.actor_type);
335
351
  var actorId = _ident(input.actor_id, "actor_id", IDENT_RE, MAX_ACTOR_ID_LEN);
336
352
  var action = _ident(input.action, "action", ACTION_RE, MAX_ACTION_LEN);
@@ -341,6 +357,9 @@ function create(opts) {
341
357
  var ipHash = _ipHash(input.ip_hash);
342
358
  var uaClass = _uaClass(input.ua_class);
343
359
 
360
+ // Acquire the append mutex for the head-read → hash → INSERT body so
361
+ // the chain advances under a single writer.
362
+ return appendMutex.runExclusive(async function () {
344
363
  var head = await _currentHead();
345
364
  var prevHash = head.row_hash;
346
365
  var id = b.uuid.v7();
@@ -398,6 +417,7 @@ function create(opts) {
398
417
  prev_hash: prevHash,
399
418
  row_hash: rowHash,
400
419
  };
420
+ });
401
421
  }
402
422
 
403
423
  // -- listByActor -------------------------------------------------------