@blamejs/blamejs-shop 0.4.22 → 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/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
- await query(
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
- await query(
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
- await query(
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
- await query(
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 status = 'converted', converted_at = ?1, " +
988
- "converted_order_id = ?2, updated_at = ?1 WHERE id = ?3",
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
- await query(
1149
- "UPDATE quotes SET status = 'expired', updated_at = ?1 WHERE id = ?2",
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().concat([/^\/admin(\/|$)/]),
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
  };
@@ -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 !== "open") {
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
- await query(
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 !== "shipped" && transfer.status !== "in_transit") {
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 !== "shipped" && transfer.status !== "in_transit") {
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
- // Walk every shipped line; write quantity_received (defaulting
518
- // to 0 for SKUs the operator didn't scan).
519
- for (var u = 0; u < transfer.lines.length; u += 1) {
520
- var line = transfer.lines[u];
521
- var got = Object.prototype.hasOwnProperty.call(rxMap, line.sku) ? rxMap[line.sku] : 0;
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 stock_transfer_lines SET quantity_received = ?1 WHERE id = ?2",
524
- [got, line.id],
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
- if (transfer.status !== "received") {
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. A failure here
556
- // leaves the operator with a known-bad state: the receiving
557
- // shelf is partially credited and the transfer is still
558
- // 'received'. The operator can retry every adjustStock that
559
- // already landed shows up in the audit log so the second
560
- // attempt won't double-credit because the FSM gate refuses
561
- // reconcile on non-'received' status.
562
- for (var i = 0; i < transfer.lines.length; i += 1) {
563
- var line = transfer.lines[i];
564
- var rx = line.quantity_received == null ? 0 : line.quantity_received;
565
- if (rx > 0) {
566
- await locations.adjustStock({
567
- sku: line.sku,
568
- location_code: transfer.to_location,
569
- delta: rx,
570
- reason: "stock-transfer:reconcile:" + id,
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
- var diff = line.quantity_shipped - rx;
707
+ } catch (e) {
574
708
  await query(
575
- "UPDATE stock_transfer_lines SET discrepancy = ?1 WHERE id = ?2",
576
- [diff, line.id],
709
+ "UPDATE stock_transfers SET status = 'received', reconciled_at = NULL " +
710
+ "WHERE id = ?1 AND status = 'reconciled'",
711
+ [id],
577
712
  );
578
- if (diff !== 0) {
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 === "reconciled" || transfer.status === "exception") {
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
- await query(
621
- "UPDATE stock_transfers SET status = 'exception', exception_reason = ?1 WHERE id = ?2",
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
  },