@blamejs/blamejs-shop 0.4.23 → 0.4.24
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 +2 -0
- package/README.md +6 -1
- package/SECURITY.md +13 -0
- package/lib/admin.js +1228 -15
- package/lib/asset-manifest.json +5 -5
- 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/quotes.js +107 -15
- package/lib/referrals.js +71 -0
- package/lib/security-middleware.js +27 -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/quotes.js
CHANGED
|
@@ -764,17 +764,26 @@ function create(opts) {
|
|
|
764
764
|
|
|
765
765
|
// Update header + each line. The line update sets
|
|
766
766
|
// unit_price_minor + currency per-line so a future single-line
|
|
767
|
-
// re-quote (out of scope here) has a place to land.
|
|
768
|
-
|
|
767
|
+
// re-quote (out of scope here) has a place to land. The header
|
|
768
|
+
// UPDATE claims the requested -> responded transition atomically
|
|
769
|
+
// (`AND status = 'requested'`) so a concurrent cancel/respond can't
|
|
770
|
+
// both commit against the same row.
|
|
771
|
+
var claim = await query(
|
|
769
772
|
"UPDATE quotes SET status = 'responded', shipping_minor = ?1, " +
|
|
770
773
|
"tax_minor = ?2, total_minor = ?3, currency = ?4, valid_until = ?5, " +
|
|
771
774
|
"operator_notes = ?6, " +
|
|
772
775
|
"delivery_terms = COALESCE(?7, delivery_terms), " +
|
|
773
776
|
"payment_terms = COALESCE(?8, payment_terms), " +
|
|
774
|
-
"updated_at = ?9 WHERE id = ?10",
|
|
777
|
+
"updated_at = ?9 WHERE id = ?10 AND status = 'requested'",
|
|
775
778
|
[shipping, tax, total, currency, validUntil, operatorNotes,
|
|
776
779
|
delivery, payment, ts, quoteId],
|
|
777
780
|
);
|
|
781
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
782
|
+
var raced = new Error("quotes.respondToQuote: refused — quote " + quoteId +
|
|
783
|
+
" is no longer in the requested state (settled by a concurrent call)");
|
|
784
|
+
raced.code = "QUOTE_TRANSITION_REFUSED";
|
|
785
|
+
throw raced;
|
|
786
|
+
}
|
|
778
787
|
for (var j = 0; j < lines.length; j += 1) {
|
|
779
788
|
var line = lines[j];
|
|
780
789
|
await query(
|
|
@@ -810,11 +819,23 @@ function create(opts) {
|
|
|
810
819
|
expired.code = "QUOTE_EXPIRED";
|
|
811
820
|
throw expired;
|
|
812
821
|
}
|
|
813
|
-
|
|
822
|
+
// Claim the responded -> accepted transition atomically. The in-memory
|
|
823
|
+
// FSM assert above only checks the row we read; the `AND status =
|
|
824
|
+
// 'responded'` clause is what serializes a concurrent double-accept (two
|
|
825
|
+
// customerAccept calls on one responded quote) and a withdraw-vs-accept
|
|
826
|
+
// race (an operator cancelQuote running alongside) — only one wins, so a
|
|
827
|
+
// single accept lands and a later convert can't fire twice.
|
|
828
|
+
var claim = await query(
|
|
814
829
|
"UPDATE quotes SET status = 'accepted', accepted_at = ?1, " +
|
|
815
|
-
"accepted_by_customer = ?2, updated_at = ?1 WHERE id = ?3",
|
|
830
|
+
"accepted_by_customer = ?2, updated_at = ?1 WHERE id = ?3 AND status = 'responded'",
|
|
816
831
|
[ts, acceptedBy, quoteId],
|
|
817
832
|
);
|
|
833
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
834
|
+
var raced = new Error("quotes.customerAccept: refused — quote " + quoteId +
|
|
835
|
+
" is no longer responded (accepted, rejected, or withdrawn by a concurrent call)");
|
|
836
|
+
raced.code = "QUOTE_TRANSITION_REFUSED";
|
|
837
|
+
throw raced;
|
|
838
|
+
}
|
|
818
839
|
return await _hydrated(quoteId);
|
|
819
840
|
},
|
|
820
841
|
|
|
@@ -835,11 +856,19 @@ function create(opts) {
|
|
|
835
856
|
}
|
|
836
857
|
_assertTransition(current.status, "reject", "customerReject");
|
|
837
858
|
var ts = _now();
|
|
838
|
-
|
|
859
|
+
// Claim the responded -> rejected transition atomically so a concurrent
|
|
860
|
+
// accept / cancel can't both commit against the same responded quote.
|
|
861
|
+
var claim = await query(
|
|
839
862
|
"UPDATE quotes SET status = 'rejected', rejected_at = ?1, " +
|
|
840
|
-
"reject_reason = ?2, updated_at = ?1 WHERE id = ?3",
|
|
863
|
+
"reject_reason = ?2, updated_at = ?1 WHERE id = ?3 AND status = 'responded'",
|
|
841
864
|
[ts, reason, quoteId],
|
|
842
865
|
);
|
|
866
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
867
|
+
var raced = new Error("quotes.customerReject: refused — quote " + quoteId +
|
|
868
|
+
" is no longer responded (settled by a concurrent call)");
|
|
869
|
+
raced.code = "QUOTE_TRANSITION_REFUSED";
|
|
870
|
+
throw raced;
|
|
871
|
+
}
|
|
843
872
|
return await _hydrated(quoteId);
|
|
844
873
|
},
|
|
845
874
|
|
|
@@ -863,11 +892,24 @@ function create(opts) {
|
|
|
863
892
|
}
|
|
864
893
|
_assertTransition(current.status, "cancel", "cancelQuote");
|
|
865
894
|
var ts = _now();
|
|
866
|
-
|
|
895
|
+
// Claim the (requested|responded) -> cancelled transition atomically.
|
|
896
|
+
// The `AND status IN (...)` clause closes the withdraw-vs-accept race:
|
|
897
|
+
// an operator cancelQuote and a customer customerAccept on the same
|
|
898
|
+
// responded quote can't both commit — whichever UPDATE lands first wins,
|
|
899
|
+
// and the loser refuses instead of silently clobbering the other side's
|
|
900
|
+
// transition (a cancel clobbering an accept would otherwise kill an
|
|
901
|
+
// order the operator thought was live, and vice-versa).
|
|
902
|
+
var claim = await query(
|
|
867
903
|
"UPDATE quotes SET status = 'cancelled', cancelled_at = ?1, " +
|
|
868
|
-
"cancel_reason = ?2, updated_at = ?1 WHERE id = ?3",
|
|
904
|
+
"cancel_reason = ?2, updated_at = ?1 WHERE id = ?3 AND status IN ('requested', 'responded')",
|
|
869
905
|
[ts, reason, quoteId],
|
|
870
906
|
);
|
|
907
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
908
|
+
var raced = new Error("quotes.cancelQuote: refused — quote " + quoteId +
|
|
909
|
+
" is no longer cancellable (accepted, rejected, or settled by a concurrent call)");
|
|
910
|
+
raced.code = "QUOTE_TRANSITION_REFUSED";
|
|
911
|
+
throw raced;
|
|
912
|
+
}
|
|
871
913
|
return await _hydrated(quoteId);
|
|
872
914
|
},
|
|
873
915
|
|
|
@@ -890,10 +932,45 @@ function create(opts) {
|
|
|
890
932
|
}
|
|
891
933
|
_assertTransition(current.status, "convert", "convertToOrder");
|
|
892
934
|
|
|
893
|
-
var lines = await _getLinesRaw(quoteId);
|
|
894
935
|
var ts = _now();
|
|
936
|
+
// Claim the accepted -> converted transition atomically BEFORE placing
|
|
937
|
+
// inventory holds or creating the pending order. The in-memory FSM
|
|
938
|
+
// `_assertTransition` above only answers "is this edge legal for the row
|
|
939
|
+
// I read?" — two concurrent convertToOrder calls both read 'accepted'
|
|
940
|
+
// and both pass it. The conditional UPDATE is the real serialization
|
|
941
|
+
// point: only one matches `status = 'accepted'`, so only one mints holds
|
|
942
|
+
// + an order. (Without it both would create a pending order + double the
|
|
943
|
+
// inventory holds from a single accepted quote.) converted_order_id is
|
|
944
|
+
// backfilled once the order id is known; on any failure the claim is
|
|
945
|
+
// released back to 'accepted' so the operator can retry.
|
|
946
|
+
var claim = await query(
|
|
947
|
+
"UPDATE quotes SET status = 'converted', converted_at = ?1, updated_at = ?1 " +
|
|
948
|
+
"WHERE id = ?2 AND status = 'accepted'",
|
|
949
|
+
[ts, quoteId],
|
|
950
|
+
);
|
|
951
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
952
|
+
var raced = new Error("quotes.convertToOrder: refused — quote " + quoteId +
|
|
953
|
+
" is no longer accepted (converted by a concurrent call)");
|
|
954
|
+
raced.code = "QUOTE_TRANSITION_REFUSED";
|
|
955
|
+
throw raced;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Release the claim back to 'accepted' so a retry is possible after a
|
|
959
|
+
// downstream (hold / order-creation) failure leaves no order behind.
|
|
960
|
+
async function _releaseConvertClaim() {
|
|
961
|
+
try {
|
|
962
|
+
await query(
|
|
963
|
+
"UPDATE quotes SET status = 'accepted', converted_at = NULL, updated_at = ?1 " +
|
|
964
|
+
"WHERE id = ?2 AND status = 'converted' AND converted_order_id IS NULL",
|
|
965
|
+
[_now(), quoteId],
|
|
966
|
+
);
|
|
967
|
+
} catch (_e0) { /* drop-silent — the downstream error is the caller's signal */ }
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
var lines = await _getLinesRaw(quoteId);
|
|
895
971
|
var orderId;
|
|
896
972
|
|
|
973
|
+
try {
|
|
897
974
|
if (orderHandle) {
|
|
898
975
|
// The order primitive requires cart_id + session_id as
|
|
899
976
|
// UUID-shape inputs. For a quote-driven order, the operator
|
|
@@ -982,11 +1059,18 @@ function create(opts) {
|
|
|
982
1059
|
}
|
|
983
1060
|
orderId = input.converted_order_id;
|
|
984
1061
|
}
|
|
1062
|
+
} catch (eConvert) {
|
|
1063
|
+
// The claim already flipped status to 'converted'; nothing downstream
|
|
1064
|
+
// produced an order, so release it back to 'accepted' for a retry.
|
|
1065
|
+
await _releaseConvertClaim();
|
|
1066
|
+
throw eConvert;
|
|
1067
|
+
}
|
|
985
1068
|
|
|
1069
|
+
// Status + converted_at were claimed atomically above; backfill the
|
|
1070
|
+
// resolved order id onto the (already-converted) quote.
|
|
986
1071
|
await query(
|
|
987
|
-
"UPDATE quotes SET
|
|
988
|
-
|
|
989
|
-
[ts, orderId, quoteId],
|
|
1072
|
+
"UPDATE quotes SET converted_order_id = ?1, updated_at = ?2 WHERE id = ?3",
|
|
1073
|
+
[orderId, _now(), quoteId],
|
|
990
1074
|
);
|
|
991
1075
|
return await _hydrated(quoteId);
|
|
992
1076
|
},
|
|
@@ -1145,10 +1229,18 @@ function create(opts) {
|
|
|
1145
1229
|
notYet.code = "QUOTE_NOT_EXPIRED";
|
|
1146
1230
|
throw notYet;
|
|
1147
1231
|
}
|
|
1148
|
-
|
|
1149
|
-
|
|
1232
|
+
// Claim the responded -> expired transition atomically so a concurrent
|
|
1233
|
+
// accept / cancel can't both commit against the same responded quote.
|
|
1234
|
+
var claim = await query(
|
|
1235
|
+
"UPDATE quotes SET status = 'expired', updated_at = ?1 WHERE id = ?2 AND status = 'responded'",
|
|
1150
1236
|
[asOf, quoteId],
|
|
1151
1237
|
);
|
|
1238
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
1239
|
+
var raced = new Error("quotes.markExpired: refused — quote " + quoteId +
|
|
1240
|
+
" is no longer responded (settled by a concurrent call)");
|
|
1241
|
+
raced.code = "QUOTE_TRANSITION_REFUSED";
|
|
1242
|
+
throw raced;
|
|
1243
|
+
}
|
|
1152
1244
|
return await _hydrated(quoteId);
|
|
1153
1245
|
},
|
|
1154
1246
|
};
|
package/lib/referrals.js
CHANGED
|
@@ -452,6 +452,77 @@ function create(opts) {
|
|
|
452
452
|
return out;
|
|
453
453
|
},
|
|
454
454
|
|
|
455
|
+
// Reverse the funnel completion when the order that qualified it is
|
|
456
|
+
// refunded or cancelled — the counterpart to trackPurchase on an order's
|
|
457
|
+
// terminal refund / cancel edge. trackPurchase flips the invitation to
|
|
458
|
+
// `both-rewarded`, pins the first qualifying order, and bumps the
|
|
459
|
+
// referrer's leaderboard `referrals_count`; without this, a referred
|
|
460
|
+
// customer can buy (completing the funnel + ticking the leaderboard) then
|
|
461
|
+
// refund and keep the completion — buy-then-refund reward farming.
|
|
462
|
+
//
|
|
463
|
+
// Keyed on the ORDER id (first_order_id) — the qualifying purchase whose
|
|
464
|
+
// settlement is reversing the funnel. Rolls the invitation back to
|
|
465
|
+
// `pending`, clears first_purchase_at / first_order_id, and decrements the
|
|
466
|
+
// referrer's referrals_count (floored at zero by the schema CHECK + the
|
|
467
|
+
// `referrals_count > 0` guard). Unlike the money tenders this is BINARY,
|
|
468
|
+
// not pro-rata: a refund of the qualifying order — partial or full —
|
|
469
|
+
// un-completes the funnel (the purchase that earned the reward was
|
|
470
|
+
// reversed), so the operator's actual payouts (rewardReferrer /
|
|
471
|
+
// rewardReferee) are NOT touched here — they're separate instruments the
|
|
472
|
+
// operator claws back manually; this only rolls back the funnel state +
|
|
473
|
+
// the leaderboard counter the FSM auto-advanced.
|
|
474
|
+
//
|
|
475
|
+
// Idempotent + concurrency-safe: the invitation is claimed with
|
|
476
|
+
// `WHERE first_order_id = ? AND reversed_at IS NULL` so a re-delivered
|
|
477
|
+
// refund webhook reverses the funnel exactly once; a row whose
|
|
478
|
+
// reward_status is no longer `both-rewarded` (an operator already advanced
|
|
479
|
+
// it past trackPurchase) keeps its reward_status but still drops out of
|
|
480
|
+
// the leaderboard count. A no-op for an order that never completed a
|
|
481
|
+
// funnel (organic purchase, or one already reversed). Returns the reversed
|
|
482
|
+
// invitation row, or null when there was nothing to reverse.
|
|
483
|
+
reverseForOrder: async function (orderId) {
|
|
484
|
+
var oid = _uuid(orderId, "order_id");
|
|
485
|
+
var r = await query(
|
|
486
|
+
"SELECT id, referral_code_id, reward_status FROM referral_invitations " +
|
|
487
|
+
"WHERE first_order_id = ?1 AND reversed_at IS NULL LIMIT 1",
|
|
488
|
+
[oid],
|
|
489
|
+
);
|
|
490
|
+
if (!r.rows.length) return null;
|
|
491
|
+
var inv = r.rows[0];
|
|
492
|
+
var ts = _now();
|
|
493
|
+
// Claim the invitation — the reversed_at predicate is the
|
|
494
|
+
// serialization point so two concurrent reversals can't both
|
|
495
|
+
// decrement the leaderboard. Roll reward_status back to `pending`
|
|
496
|
+
// only from the `both-rewarded` completion trackPurchase set; an
|
|
497
|
+
// operator-advanced terminal (`expired` / `voided`) or an
|
|
498
|
+
// operator-issued payout state is left as the operator chose.
|
|
499
|
+
var claim = await query(
|
|
500
|
+
"UPDATE referral_invitations SET reversed_at = ?1, " +
|
|
501
|
+
"first_purchase_at = NULL, first_order_id = NULL, " +
|
|
502
|
+
"reward_status = CASE WHEN reward_status = 'both-rewarded' THEN 'pending' ELSE reward_status END " +
|
|
503
|
+
"WHERE id = ?2 AND reversed_at IS NULL",
|
|
504
|
+
[ts, inv.id],
|
|
505
|
+
);
|
|
506
|
+
if (Number(claim.rowCount || 0) === 0) return null; // lost the claim
|
|
507
|
+
// Decrement the referrer's leaderboard count, floored at zero (the
|
|
508
|
+
// `> 0` guard plus the schema CHECK keep it non-negative even if the
|
|
509
|
+
// count drifted).
|
|
510
|
+
await query(
|
|
511
|
+
"UPDATE referral_codes " +
|
|
512
|
+
"SET referrals_count = referrals_count - 1, updated_at = ?1 " +
|
|
513
|
+
"WHERE id = ?2 AND referrals_count > 0",
|
|
514
|
+
[ts, inv.referral_code_id],
|
|
515
|
+
);
|
|
516
|
+
var after = await query(
|
|
517
|
+
"SELECT id, referral_code_id, reward_status, first_purchase_at, first_order_id, reversed_at " +
|
|
518
|
+
"FROM referral_invitations WHERE id = ?1",
|
|
519
|
+
[inv.id],
|
|
520
|
+
);
|
|
521
|
+
var out = after.rows[0] || null;
|
|
522
|
+
if (out) out.status = "reversed";
|
|
523
|
+
return out;
|
|
524
|
+
},
|
|
525
|
+
|
|
455
526
|
// Operator records that the referrer payout has been issued.
|
|
456
527
|
// `reward_id` is opaque (operator's gift-card id, discount-code
|
|
457
528
|
// id, ledger-credit id — whatever instrument they chose). The
|
|
@@ -103,6 +103,23 @@ var INTERNAL_BRIDGE_PATHS = [
|
|
|
103
103
|
"/_/stale-order-reap",
|
|
104
104
|
];
|
|
105
105
|
|
|
106
|
+
// Public well-known paths fetched by third-party verification crawlers
|
|
107
|
+
// rather than browsers. Apple's Apple Pay domain-verification crawl GETs
|
|
108
|
+
// the merchantid association file and is not guaranteed to send
|
|
109
|
+
// Accept-Language, which the bot-guard's missing-Accept-Language heuristic
|
|
110
|
+
// would 403 — silently hiding the Apple Pay button (the same
|
|
111
|
+
// machine-caller-blocked-before-its-real-gate failure the worker→container
|
|
112
|
+
// paths above hit). The file is a static, unauthenticated, state-free
|
|
113
|
+
// public resource (the route 404s when unconfigured and only ever serves
|
|
114
|
+
// the operator-supplied association bytes), so skipping the browser
|
|
115
|
+
// fingerprint check on it weakens nothing. In production the crawl lands on
|
|
116
|
+
// the edge Worker, which serves the file before any container hop; this
|
|
117
|
+
// skip keeps a direct-to-container fetch (or an edge-serving-off deploy)
|
|
118
|
+
// from refusing the verification crawl.
|
|
119
|
+
var PUBLIC_WELL_KNOWN_PATHS = [
|
|
120
|
+
"/.well-known/apple-developer-merchantid-domain-association",
|
|
121
|
+
];
|
|
122
|
+
|
|
106
123
|
// The abusable endpoints — POST / auth surfaces where a human does
|
|
107
124
|
// well under five requests a minute. Each gets its own per-client-IP
|
|
108
125
|
// budget so a spray against one can't borrow another's headroom, and a
|
|
@@ -145,6 +162,12 @@ var TIGHT_PREFIXES = [
|
|
|
145
162
|
"/orders/",
|
|
146
163
|
"/stock-alert/",
|
|
147
164
|
"/cart/coupon",
|
|
165
|
+
// Public suggestion box — anonymous submit + vote POSTs. The submit writes
|
|
166
|
+
// a free-text row and the vote bumps a counter; on the loose global bucket
|
|
167
|
+
// alone an anonymous sprayer could flood the backlog or grind a vote. The
|
|
168
|
+
// tight per-(IP+path) budget caps the rate. Container-only (CSRF-tokened),
|
|
169
|
+
// so it is NOT in EDGE_POST_PATHS — the page carries a per-session token.
|
|
170
|
+
"/suggestions",
|
|
148
171
|
];
|
|
149
172
|
|
|
150
173
|
// Edge-served state-changing POST endpoints. These forms are rendered at
|
|
@@ -584,7 +607,9 @@ function globalRateLimitOpts() {
|
|
|
584
607
|
*/
|
|
585
608
|
function botGuardOpts() {
|
|
586
609
|
return {
|
|
587
|
-
skipPaths: INTERNAL_BRIDGE_PATHS.slice()
|
|
610
|
+
skipPaths: INTERNAL_BRIDGE_PATHS.slice()
|
|
611
|
+
.concat(PUBLIC_WELL_KNOWN_PATHS)
|
|
612
|
+
.concat([/^\/admin(\/|$)/]),
|
|
588
613
|
};
|
|
589
614
|
}
|
|
590
615
|
|
|
@@ -733,4 +758,5 @@ module.exports = {
|
|
|
733
758
|
HEALTH_PATH: HEALTH_PATH,
|
|
734
759
|
TIGHT_PREFIXES: TIGHT_PREFIXES,
|
|
735
760
|
EDGE_POST_PATHS: EDGE_POST_PATHS,
|
|
761
|
+
PUBLIC_WELL_KNOWN_PATHS: PUBLIC_WELL_KNOWN_PATHS,
|
|
736
762
|
};
|
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
|
},
|