@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/stock-transfers.js
CHANGED
|
@@ -118,6 +118,70 @@ var TRANSFER_STATUSES = Object.freeze([
|
|
|
118
118
|
var TRANSFER_ROLES = Object.freeze(["origin", "destination"]);
|
|
119
119
|
var TRANSFER_ORDER_KEY = ["opened_at:desc", "id:desc"];
|
|
120
120
|
|
|
121
|
+
// The transfer lifecycle, modelled on b.fsm — the same composition the order
|
|
122
|
+
// and quote primitives use (lib/order.js#_getOrderFsm, lib/quotes.js). The FSM
|
|
123
|
+
// is the single source of truth for which (status, event) pairs are legal:
|
|
124
|
+
// every status-changing verb replays the machine from the row's current status
|
|
125
|
+
// and asks whether the edge is allowed before it touches the database, so an
|
|
126
|
+
// illegal transition is refused identically regardless of which surface fired
|
|
127
|
+
// it. Each event name maps 1:1 to the verb that fires it:
|
|
128
|
+
//
|
|
129
|
+
// ship open -> shipped (markShipped)
|
|
130
|
+
// transit shipped|in_transit -> in_transit (markInTransit; self-edge
|
|
131
|
+
// keeps the scan-beat log)
|
|
132
|
+
// receive shipped|in_transit -> received (markReceived)
|
|
133
|
+
// reconcile received -> reconciled (reconcile; credits dest)
|
|
134
|
+
// except open|shipped|in_transit|received
|
|
135
|
+
// -> exception (markException)
|
|
136
|
+
//
|
|
137
|
+
// Terminal states (reconciled, exception) declare no outgoing edge, so the
|
|
138
|
+
// machine itself refuses a double-reconcile / re-ship-after-exception without
|
|
139
|
+
// a hand-written status check.
|
|
140
|
+
var TRANSFER_TRANSITIONS = Object.freeze([
|
|
141
|
+
{ from: "open", to: "shipped", on: "ship" },
|
|
142
|
+
{ from: "shipped", to: "in_transit", on: "transit" },
|
|
143
|
+
{ from: "in_transit", to: "in_transit", on: "transit" },
|
|
144
|
+
{ from: "shipped", to: "received", on: "receive" },
|
|
145
|
+
{ from: "in_transit", to: "received", on: "receive" },
|
|
146
|
+
{ from: "received", to: "reconciled", on: "reconcile" },
|
|
147
|
+
{ from: "open", to: "exception", on: "except" },
|
|
148
|
+
{ from: "shipped", to: "exception", on: "except" },
|
|
149
|
+
{ from: "in_transit", to: "exception", on: "except" },
|
|
150
|
+
{ from: "received", to: "exception", on: "except" },
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
var _transferFsm = null;
|
|
154
|
+
function _getTransferFsm() {
|
|
155
|
+
if (_transferFsm) return _transferFsm;
|
|
156
|
+
// b.fsm emits audit events under the 'fsm' namespace — register it
|
|
157
|
+
// (idempotent) so the audit sink keeps the events instead of dropping them
|
|
158
|
+
// with a noisy warning, exactly as the order/quote FSMs do.
|
|
159
|
+
try { b.audit.registerNamespace("fsm"); } catch (_e) { /* idempotent; ignore */ }
|
|
160
|
+
_transferFsm = b.fsm.define({
|
|
161
|
+
name: "stock_transfer",
|
|
162
|
+
initial: "open",
|
|
163
|
+
states: {
|
|
164
|
+
open: {}, shipped: {}, in_transit: {},
|
|
165
|
+
received: {}, reconciled: {}, exception: {},
|
|
166
|
+
},
|
|
167
|
+
transitions: TRANSFER_TRANSITIONS.map(function (t) {
|
|
168
|
+
return { from: t.from, to: t.to, on: t.on };
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
return _transferFsm;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Validate one status-changing edge through the FSM. Returns true when the
|
|
175
|
+
// edge is legal from `fromStatus`, false otherwise. The verbs below use this
|
|
176
|
+
// to decide whether to issue the atomic claim-guard UPDATE — they keep
|
|
177
|
+
// throwing a TypeError on an illegal transition so the admin route's
|
|
178
|
+
// `e instanceof TypeError -> 400` mapping (admin.js#_transferAction) stays
|
|
179
|
+
// intact rather than surfacing a wrong-state as a 500.
|
|
180
|
+
function _canTransfer(fromStatus, event) {
|
|
181
|
+
var fsm = _getTransferFsm();
|
|
182
|
+
return fsm.restore({ state: fromStatus, history: [], context: {} }).can(event);
|
|
183
|
+
}
|
|
184
|
+
|
|
121
185
|
// ---- validators ---------------------------------------------------------
|
|
122
186
|
|
|
123
187
|
function _id(s, label) {
|
|
@@ -419,15 +483,21 @@ function create(opts) {
|
|
|
419
483
|
if (!transfer) {
|
|
420
484
|
throw new TypeError("stock-transfers.markShipped: transfer " + id + " not found");
|
|
421
485
|
}
|
|
422
|
-
if (transfer.status
|
|
486
|
+
if (!_canTransfer(transfer.status, "ship")) {
|
|
423
487
|
throw new TypeError("stock-transfers.markShipped: transfer is " + transfer.status +
|
|
424
488
|
", only open transfers can be shipped");
|
|
425
489
|
}
|
|
426
|
-
|
|
490
|
+
// Claim the open -> shipped transition atomically — the WHERE status
|
|
491
|
+
// clause makes a concurrent double-ship a no-op for the loser.
|
|
492
|
+
var claim = await query(
|
|
427
493
|
"UPDATE stock_transfers SET status = 'shipped', shipped_at = ?1, " +
|
|
428
|
-
"carrier = ?2, tracking_number = ?3 WHERE id = ?4",
|
|
494
|
+
"carrier = ?2, tracking_number = ?3 WHERE id = ?4 AND status = 'open'",
|
|
429
495
|
[shippedAt, carrier, tracking, id],
|
|
430
496
|
);
|
|
497
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
498
|
+
throw new TypeError("stock-transfers.markShipped: transfer " + id +
|
|
499
|
+
" is no longer open (shipped by a concurrent call)");
|
|
500
|
+
}
|
|
431
501
|
await _writeEvent(id, "ship", transfer.from_location, {
|
|
432
502
|
carrier: carrier, tracking_number: tracking,
|
|
433
503
|
}, shippedAt);
|
|
@@ -448,13 +518,17 @@ function create(opts) {
|
|
|
448
518
|
if (!transfer) {
|
|
449
519
|
throw new TypeError("stock-transfers.markInTransit: transfer " + id + " not found");
|
|
450
520
|
}
|
|
451
|
-
if (transfer.status
|
|
521
|
+
if (!_canTransfer(transfer.status, "transit")) {
|
|
452
522
|
throw new TypeError("stock-transfers.markInTransit: transfer is " + transfer.status +
|
|
453
523
|
", only shipped or in_transit transfers can record an in_transit scan");
|
|
454
524
|
}
|
|
525
|
+
// Only the shipped -> in_transit edge flips status; the in_transit
|
|
526
|
+
// self-edge is an idempotent scan-beat that just appends an event. Claim
|
|
527
|
+
// the shipped -> in_transit flip with a WHERE status clause so two
|
|
528
|
+
// concurrent first-scans don't both believe they advanced the row.
|
|
455
529
|
if (transfer.status === "shipped") {
|
|
456
530
|
await query(
|
|
457
|
-
"UPDATE stock_transfers SET status = 'in_transit' WHERE id = ?1",
|
|
531
|
+
"UPDATE stock_transfers SET status = 'in_transit' WHERE id = ?1 AND status = 'shipped'",
|
|
458
532
|
[id],
|
|
459
533
|
);
|
|
460
534
|
}
|
|
@@ -495,7 +569,7 @@ function create(opts) {
|
|
|
495
569
|
if (!transfer) {
|
|
496
570
|
throw new TypeError("stock-transfers.markReceived: transfer " + id + " not found");
|
|
497
571
|
}
|
|
498
|
-
if (transfer.status
|
|
572
|
+
if (!_canTransfer(transfer.status, "receive")) {
|
|
499
573
|
throw new TypeError("stock-transfers.markReceived: transfer is " + transfer.status +
|
|
500
574
|
", only shipped or in_transit transfers can be received");
|
|
501
575
|
}
|
|
@@ -514,20 +588,44 @@ function create(opts) {
|
|
|
514
588
|
" was not on the original transfer");
|
|
515
589
|
}
|
|
516
590
|
}
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
591
|
+
// Claim the (shipped|in_transit) -> received transition atomically
|
|
592
|
+
// BEFORE writing the per-line received quantities. Two concurrent
|
|
593
|
+
// receives both pass the read above, but only one UPDATE matches the
|
|
594
|
+
// expected status — the loser refuses, so a single set of
|
|
595
|
+
// quantity_received values lands and reconcile can't later credit twice.
|
|
596
|
+
var priorStatus = transfer.status;
|
|
597
|
+
var claim = await query(
|
|
598
|
+
"UPDATE stock_transfers SET status = 'received', received_at = ?1 " +
|
|
599
|
+
"WHERE id = ?2 AND status IN ('shipped', 'in_transit')",
|
|
600
|
+
[receivedAt, id],
|
|
601
|
+
);
|
|
602
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
603
|
+
throw new TypeError("stock-transfers.markReceived: transfer " + id +
|
|
604
|
+
" is no longer shipped or in_transit (received by a concurrent call)");
|
|
605
|
+
}
|
|
606
|
+
// Walk every shipped line; write quantity_received (defaulting to 0 for
|
|
607
|
+
// SKUs the operator didn't scan). Each write is idempotent (the same
|
|
608
|
+
// quantity lands on a retry), so if a line throws the terminal claim is
|
|
609
|
+
// rolled back to its prior status — the transfer stays eligible for
|
|
610
|
+
// markReceived rather than stranding with partial / default quantities a
|
|
611
|
+
// later reconcile would then credit against.
|
|
612
|
+
try {
|
|
613
|
+
for (var u = 0; u < transfer.lines.length; u += 1) {
|
|
614
|
+
var line = transfer.lines[u];
|
|
615
|
+
var got = Object.prototype.hasOwnProperty.call(rxMap, line.sku) ? rxMap[line.sku] : 0;
|
|
616
|
+
await query(
|
|
617
|
+
"UPDATE stock_transfer_lines SET quantity_received = ?1 WHERE id = ?2",
|
|
618
|
+
[got, line.id],
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
} catch (e) {
|
|
522
622
|
await query(
|
|
523
|
-
"UPDATE
|
|
524
|
-
|
|
623
|
+
"UPDATE stock_transfers SET status = ?1, received_at = NULL " +
|
|
624
|
+
"WHERE id = ?2 AND status = 'received'",
|
|
625
|
+
[priorStatus, id],
|
|
525
626
|
);
|
|
627
|
+
throw e;
|
|
526
628
|
}
|
|
527
|
-
await query(
|
|
528
|
-
"UPDATE stock_transfers SET status = 'received', received_at = ?1 WHERE id = ?2",
|
|
529
|
-
[receivedAt, id],
|
|
530
|
-
);
|
|
531
629
|
await _writeEvent(id, "receive", transfer.to_location, {
|
|
532
630
|
received_lines: input.received_lines,
|
|
533
631
|
}, receivedAt);
|
|
@@ -546,48 +644,74 @@ function create(opts) {
|
|
|
546
644
|
if (!transfer) {
|
|
547
645
|
throw new TypeError("stock-transfers.reconcile: transfer " + id + " not found");
|
|
548
646
|
}
|
|
549
|
-
|
|
647
|
+
// Refuse an illegal transition with a TypeError (kept for the route's
|
|
648
|
+
// 400 mapping). The status read here is only a fast-fail / clear-message
|
|
649
|
+
// path; the conditional claim-guard below is what actually serializes
|
|
650
|
+
// two concurrent reconciles.
|
|
651
|
+
if (!_canTransfer(transfer.status, "reconcile")) {
|
|
550
652
|
throw new TypeError("stock-transfers.reconcile: transfer is " + transfer.status +
|
|
551
653
|
", only received transfers can be reconciled");
|
|
552
654
|
}
|
|
553
655
|
var ts = _now();
|
|
656
|
+
// Claim the received -> reconciled transition atomically BEFORE crediting
|
|
657
|
+
// the destination. Two concurrent reconciles both pass the read above,
|
|
658
|
+
// but only one UPDATE matches `status = 'received'` — the loser gets
|
|
659
|
+
// rowCount 0 and refuses, so the destination shelf is credited exactly
|
|
660
|
+
// once. (Without this guard both calls would credit, minting phantom
|
|
661
|
+
// inventory at the destination.)
|
|
662
|
+
var claim = await query(
|
|
663
|
+
"UPDATE stock_transfers SET status = 'reconciled', reconciled_at = ?1 " +
|
|
664
|
+
"WHERE id = ?2 AND status = 'received'",
|
|
665
|
+
[ts, id],
|
|
666
|
+
);
|
|
667
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
668
|
+
throw new TypeError("stock-transfers.reconcile: transfer " + id +
|
|
669
|
+
" is no longer received (already reconciled by a concurrent call) — refusing to double-credit");
|
|
670
|
+
}
|
|
554
671
|
var discrepancies = [];
|
|
555
|
-
// Credit the destination one line at a time.
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
var
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
672
|
+
// Credit the destination one line at a time. The claim above flipped the
|
|
673
|
+
// status, so a concurrent call can't reach this loop for the same
|
|
674
|
+
// transfer. Each line is skipped once its discrepancy column is stamped,
|
|
675
|
+
// so a retry (after the compensation below rolls the claim back) re-credits
|
|
676
|
+
// ONLY the lines that hadn't landed yet — no double-credit on the lines
|
|
677
|
+
// that already succeeded. If any line throws, the terminal claim is rolled
|
|
678
|
+
// back to 'received' so the transfer stays eligible for another reconcile
|
|
679
|
+
// that finishes the remaining credits, rather than stranding it.
|
|
680
|
+
try {
|
|
681
|
+
for (var i = 0; i < transfer.lines.length; i += 1) {
|
|
682
|
+
var line = transfer.lines[i];
|
|
683
|
+
if (line.discrepancy != null) continue; // already credited on a prior attempt
|
|
684
|
+
var rx = line.quantity_received == null ? 0 : line.quantity_received;
|
|
685
|
+
if (rx > 0) {
|
|
686
|
+
await locations.adjustStock({
|
|
687
|
+
sku: line.sku,
|
|
688
|
+
location_code: transfer.to_location,
|
|
689
|
+
delta: rx,
|
|
690
|
+
reason: "stock-transfer:reconcile:" + id,
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
var diff = line.quantity_shipped - rx;
|
|
694
|
+
await query(
|
|
695
|
+
"UPDATE stock_transfer_lines SET discrepancy = ?1 WHERE id = ?2",
|
|
696
|
+
[diff, line.id],
|
|
697
|
+
);
|
|
698
|
+
if (diff !== 0) {
|
|
699
|
+
discrepancies.push({
|
|
700
|
+
sku: line.sku,
|
|
701
|
+
quantity_shipped: line.quantity_shipped,
|
|
702
|
+
quantity_received: rx,
|
|
703
|
+
discrepancy: diff,
|
|
704
|
+
});
|
|
705
|
+
}
|
|
572
706
|
}
|
|
573
|
-
|
|
707
|
+
} catch (e) {
|
|
574
708
|
await query(
|
|
575
|
-
"UPDATE
|
|
576
|
-
|
|
709
|
+
"UPDATE stock_transfers SET status = 'received', reconciled_at = NULL " +
|
|
710
|
+
"WHERE id = ?1 AND status = 'reconciled'",
|
|
711
|
+
[id],
|
|
577
712
|
);
|
|
578
|
-
|
|
579
|
-
discrepancies.push({
|
|
580
|
-
sku: line.sku,
|
|
581
|
-
quantity_shipped: line.quantity_shipped,
|
|
582
|
-
quantity_received: rx,
|
|
583
|
-
discrepancy: diff,
|
|
584
|
-
});
|
|
585
|
-
}
|
|
713
|
+
throw e;
|
|
586
714
|
}
|
|
587
|
-
await query(
|
|
588
|
-
"UPDATE stock_transfers SET status = 'reconciled', reconciled_at = ?1 WHERE id = ?2",
|
|
589
|
-
[ts, id],
|
|
590
|
-
);
|
|
591
715
|
await _writeEvent(id, "reconcile", transfer.to_location, {
|
|
592
716
|
discrepancies: discrepancies,
|
|
593
717
|
}, ts);
|
|
@@ -612,15 +736,23 @@ function create(opts) {
|
|
|
612
736
|
if (!transfer) {
|
|
613
737
|
throw new TypeError("stock-transfers.markException: transfer " + id + " not found");
|
|
614
738
|
}
|
|
615
|
-
if (transfer.status
|
|
739
|
+
if (!_canTransfer(transfer.status, "except")) {
|
|
616
740
|
throw new TypeError("stock-transfers.markException: transfer is " + transfer.status +
|
|
617
741
|
", terminal states cannot transition to exception");
|
|
618
742
|
}
|
|
619
743
|
var ts = _now();
|
|
620
|
-
|
|
621
|
-
|
|
744
|
+
// Claim the non-terminal -> exception transition atomically. The WHERE
|
|
745
|
+
// status clause refuses if a concurrent reconcile / exception already
|
|
746
|
+
// moved the row to a terminal state.
|
|
747
|
+
var claim = await query(
|
|
748
|
+
"UPDATE stock_transfers SET status = 'exception', exception_reason = ?1 " +
|
|
749
|
+
"WHERE id = ?2 AND status IN ('open', 'shipped', 'in_transit', 'received')",
|
|
622
750
|
[reason, id],
|
|
623
751
|
);
|
|
752
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
753
|
+
throw new TypeError("stock-transfers.markException: transfer " + id +
|
|
754
|
+
" is no longer in a non-terminal state (settled by a concurrent call)");
|
|
755
|
+
}
|
|
624
756
|
await _writeEvent(id, "exception", null, { reason: reason }, ts);
|
|
625
757
|
return await _getHydrated(id);
|
|
626
758
|
},
|