@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.
- package/CHANGELOG.md +4 -0
- package/README.md +6 -1
- package/SECURITY.md +13 -0
- package/lib/admin.js +1328 -49
- package/lib/asset-manifest.json +5 -5
- package/lib/compliance-export.js +61 -7
- package/lib/customers.js +53 -0
- package/lib/cycle-counting.js +24 -4
- package/lib/gift-card-ledger.js +81 -10
- package/lib/giftcards.js +88 -0
- package/lib/inventory-allocations.js +33 -14
- package/lib/inventory-receive.js +116 -20
- package/lib/inventory-writeoffs.js +53 -64
- package/lib/loyalty-earn-rules.js +117 -0
- package/lib/loyalty.js +79 -0
- package/lib/newsletter.js +39 -2
- package/lib/operator-audit-log.js +20 -0
- package/lib/operator-inbox.js +202 -9
- package/lib/order.js +227 -27
- package/lib/payment.js +91 -18
- package/lib/quotes.js +107 -15
- package/lib/referrals.js +71 -0
- package/lib/security-middleware.js +33 -1
- package/lib/stock-transfers.js +185 -53
- package/lib/storefront.js +979 -126
- package/lib/translations.js +1 -0
- package/lib/webhook-receiver.js +15 -19
- package/lib/wishlist-alerts.js +37 -0
- package/package.json +1 -1
package/lib/inventory-receive.js
CHANGED
|
@@ -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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (receipt.status
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
var
|
|
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
|
|
364
|
-
[
|
|
457
|
+
"UPDATE inventory_receipts SET status = 'applied', updated_at = ?1 WHERE id = ?2 AND status = 'reversed'",
|
|
458
|
+
[_now(), receiptId],
|
|
365
459
|
);
|
|
366
|
-
|
|
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
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
//
|
|
546
|
-
//
|
|
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
|
-
*
|
|
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 -------------------------------------------------------
|