@blamejs/blamejs-shop 0.4.15 → 0.4.16

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
@@ -42,6 +42,12 @@
42
42
  * It is NULL until the operator has responded.
43
43
  *
44
44
  * Composes:
45
+ * - b.fsm — the quote lifecycle machine. Every status
46
+ * change replays the machine from the row's
47
+ * current status and asks it whether the edge
48
+ * is legal before touching the database, so an
49
+ * illegal transition is refused the same way
50
+ * regardless of which surface fired it.
45
51
  * - b.uuid.v7 — quote + line PKs (sortable; B-tree locality)
46
52
  * - b.guardUuid — strict UUID validation on every quote_id +
47
53
  * customer_id read
@@ -56,6 +62,14 @@
56
62
  * status flip + the converted_order_id the
57
63
  * caller supplies, leaving the actual order
58
64
  * creation to the caller.
65
+ * - inventory (opt) — when injected alongside `order`,
66
+ * `convertToOrder` reserves shelf stock per
67
+ * quote line BEFORE creating the pending order
68
+ * (the same atomic hold checkout.confirm
69
+ * takes), so a quote can't oversell against a
70
+ * concurrent cart checkout. The pending order
71
+ * owns the holds and settles them on its own
72
+ * paid / cancelled edge.
59
73
  *
60
74
  * Surface:
61
75
  * requestQuote({ customer_id, lines?, cart?, message?,
@@ -77,7 +91,7 @@
77
91
  * + `quote_lines`. ON DELETE CASCADE from quote -> lines.
78
92
  *
79
93
  * @primitive quotes
80
- * @related b.uuid, b.guardUuid, shop.cart, shop.order
94
+ * @related b.fsm, b.uuid, b.guardUuid, shop.cart, shop.order
81
95
  */
82
96
 
83
97
  var MAX_LINES = 1000;
@@ -98,6 +112,14 @@ var MAX_LIMIT = 500;
98
112
  var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
99
113
  var CURRENCY_RE = /^[A-Z]{3}$/;
100
114
 
115
+ // View-token material for the customer-facing quote page (/quote/:token).
116
+ // 32 random bytes -> 43-char base64url plaintext, stored only as its SHA3-512
117
+ // namespace hash (never the plaintext) so a read of the quotes table never
118
+ // yields a working link. Mirrors the survey-invitation token discipline.
119
+ var VIEW_TOKEN_BYTE_LEN = 32;
120
+ var VIEW_TOKEN_NAMESPACE = "shop.quotes.view-token:v1";
121
+ var VIEW_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/;
122
+
101
123
  // Control bytes + zero-width / direction-override family. Operator-
102
124
  // rendered text fields refuse these to keep the downstream dashboard /
103
125
  // printout safe from header-injection + visual-spoofing attacks. CR/LF
@@ -120,6 +142,86 @@ var TERMINAL_STATUSES = Object.freeze([
120
142
 
121
143
  var b = require("./vendor/blamejs");
122
144
 
145
+ // The quote lifecycle, modelled on b.fsm — the same composition the order
146
+ // primitive uses (lib/order.js#_getOrderFsm). The FSM is the single source of
147
+ // truth for which (status, event) pairs are legal: every status-changing verb
148
+ // below replays the machine from the row's current status and asks it whether
149
+ // the edge is allowed before it touches the database, so an illegal transition
150
+ // is refused identically whether it arrives via the customer surface, the
151
+ // operator console, or the cron. Each event name maps 1:1 to the verb that
152
+ // fires it:
153
+ //
154
+ // respond requested -> responded (operator prices the quote)
155
+ // accept responded -> accepted (customer accepts)
156
+ // reject responded -> rejected (customer declines)
157
+ // cancel requested|responded -> cancelled (either side pulls it)
158
+ // expire responded -> expired (valid_until elapsed)
159
+ // convert accepted -> converted (lands a pending order)
160
+ //
161
+ // Terminal states declare no outgoing edge, so the machine itself refuses a
162
+ // double-accept / convert-after-cancel without a hand-written status check.
163
+ var QUOTE_TRANSITIONS = Object.freeze([
164
+ { from: "requested", to: "responded", on: "respond" },
165
+ { from: "requested", to: "cancelled", on: "cancel" },
166
+ { from: "responded", to: "accepted", on: "accept" },
167
+ { from: "responded", to: "rejected", on: "reject" },
168
+ { from: "responded", to: "cancelled", on: "cancel" },
169
+ { from: "responded", to: "expired", on: "expire" },
170
+ { from: "accepted", to: "converted", on: "convert" },
171
+ ]);
172
+
173
+ var _quoteFsm = null;
174
+ function _getQuoteFsm() {
175
+ if (_quoteFsm) return _quoteFsm;
176
+ // b.fsm emits audit events under the 'fsm' namespace — register it
177
+ // (idempotent) so the audit sink keeps the events instead of dropping them
178
+ // with a noisy warning, exactly as the order FSM does.
179
+ try { b.audit.registerNamespace("fsm"); } catch (_e) { /* idempotent; ignore */ }
180
+ _quoteFsm = b.fsm.define({
181
+ name: "quote",
182
+ initial: "requested",
183
+ states: {
184
+ requested: {}, responded: {}, accepted: {},
185
+ rejected: {}, expired: {}, cancelled: {}, converted: {},
186
+ },
187
+ transitions: QUOTE_TRANSITIONS.map(function (t) {
188
+ return { from: t.from, to: t.to, on: t.on };
189
+ }),
190
+ });
191
+ return _quoteFsm;
192
+ }
193
+
194
+ // Resolve the to-status for a legal (from, event) pair off the transition
195
+ // table — the FSM's `can()` only answers yes/no, so the destination comes from
196
+ // the declaration the machine was built from (one source of truth).
197
+ function _toStatus(fromStatus, event) {
198
+ for (var i = 0; i < QUOTE_TRANSITIONS.length; i += 1) {
199
+ var t = QUOTE_TRANSITIONS[i];
200
+ if (t.from === fromStatus && t.on === event) return t.to;
201
+ }
202
+ return null;
203
+ }
204
+
205
+ // Validate one status-changing edge through the FSM. `event` is the verb name
206
+ // above; `fromStatus` is the row's CURRENT status. Returns the resolved
207
+ // to-status on success. Throws a coded QUOTE_TRANSITION_REFUSED Error when the
208
+ // machine refuses the edge (terminal state, wrong source state, or unknown
209
+ // event) so the caller maps it to a uniform 409 regardless of which guard
210
+ // tripped — the same shape the hand-rolled status checks used to throw. The
211
+ // machine's synchronous `can()` is the gate (no guards declared, so it never
212
+ // awaits); the destination is read back off the transition table.
213
+ function _assertTransition(fromStatus, event, verbLabel) {
214
+ var fsm = _getQuoteFsm();
215
+ var instance = fsm.restore({ state: fromStatus, history: [], context: {} });
216
+ if (!instance.can(event)) {
217
+ var refused = new Error("quotes." + verbLabel + ": refused — quote is " +
218
+ fromStatus + ", cannot " + event);
219
+ refused.code = "QUOTE_TRANSITION_REFUSED";
220
+ throw refused;
221
+ }
222
+ return _toStatus(fromStatus, event);
223
+ }
224
+
123
225
  // ---- validators ---------------------------------------------------------
124
226
 
125
227
  function _id(s, label) {
@@ -246,6 +348,26 @@ function _status(s) {
246
348
 
247
349
  function _now() { return Date.now(); }
248
350
 
351
+ // Mint a fresh view-token plaintext. Routes through b.crypto's linear-time
352
+ // base64url encoder (no ReDoS-shaped padding strip), like the survey tokens.
353
+ function _mintViewToken() {
354
+ return b.crypto.toBase64Url(b.crypto.generateBytes(VIEW_TOKEN_BYTE_LEN));
355
+ }
356
+
357
+ // Canonicalize + shape-check a presented token before it ever touches the
358
+ // database. Returns the canonical string; throws on a malformed token so a
359
+ // garbage value can't widen the hash lookup.
360
+ function _canonicalViewToken(input) {
361
+ if (typeof input !== "string" || !VIEW_TOKEN_RE.test(input)) {
362
+ throw new TypeError("quotes: view token must be 43 base64url characters");
363
+ }
364
+ return input;
365
+ }
366
+
367
+ function _hashViewToken(canonical) {
368
+ return b.crypto.namespaceHash(VIEW_TOKEN_NAMESPACE, canonical);
369
+ }
370
+
249
371
  // Validate + normalize the requested-lines array. Refuses duplicate
250
372
  // SKUs — the same SKU twice is a quantity-merge concern, not a
251
373
  // per-line decision; the customer should send a single line at the
@@ -383,6 +505,23 @@ function create(opts) {
383
505
  if (orderHandle && typeof orderHandle.createFromCart !== "function") {
384
506
  throw new TypeError("quotes.create: opts.order must expose createFromCart when provided");
385
507
  }
508
+ // Optional inventory handle — when wired ALONGSIDE the order handle,
509
+ // `convertToOrder` reserves shelf stock for each quote line BEFORE
510
+ // creating the pending order, exactly as checkout.confirm does: the
511
+ // pending order then OWNS those holds and settles them on its own
512
+ // paid / cancelled FSM edges (decrement on paid, release on cancel),
513
+ // so a quote-driven order can't oversell against a concurrent cart
514
+ // checkout. Insufficient stock on any line rolls back every hold the
515
+ // pass placed and refuses the conversion with a coded
516
+ // QUOTE_INSUFFICIENT_STOCK error. Must expose hold(sku, qty) +
517
+ // release(sku, qty), the same shape catalog.inventory exposes. Absent
518
+ // it (or absent the order handle) conversion lands no holds — the
519
+ // in-house-pipeline posture where the operator owns stock truth.
520
+ var inventoryHandle = opts.inventory || null;
521
+ if (inventoryHandle && (typeof inventoryHandle.hold !== "function" ||
522
+ typeof inventoryHandle.release !== "function")) {
523
+ throw new TypeError("quotes.create: opts.inventory must expose hold + release when provided");
524
+ }
386
525
 
387
526
  async function _getQuoteRaw(id) {
388
527
  var r = await query("SELECT * FROM quotes WHERE id = ?1", [id]);
@@ -406,6 +545,51 @@ function create(opts) {
406
545
  return out;
407
546
  }
408
547
 
548
+ // Reserve shelf stock for every quote line before the conversion creates
549
+ // the pending order — the same atomic conditional-UPDATE hold checkout uses
550
+ // (`inventory.hold(sku, qty)`). An un-tracked SKU (hold → null) is unlimited
551
+ // and passes through; a `held:false` result means the line oversells, so we
552
+ // roll back every hold this pass already placed and refuse the conversion
553
+ // with a coded error. Returns the placed-holds list and a per-sku held-qty
554
+ // map so the order lines can record `stock_held_qty` (the order's paid edge
555
+ // debits exactly those units; its cancel edge releases them). No-op (empty)
556
+ // when no inventory handle is wired.
557
+ async function _placeQuoteHolds(lines) {
558
+ var placed = [];
559
+ var heldBySku = Object.create(null);
560
+ if (!inventoryHandle) return { placed: placed, heldBySku: heldBySku };
561
+ for (var i = 0; i < lines.length; i += 1) {
562
+ var sku = lines[i].sku;
563
+ var qty = Number(lines[i].quantity);
564
+ var res = await inventoryHandle.hold(sku, qty);
565
+ if (res == null) continue; // un-tracked SKU — unlimited
566
+ if (res.held) {
567
+ heldBySku[sku] = (heldBySku[sku] || 0) + qty;
568
+ placed.push({ sku: sku, qty: qty });
569
+ continue;
570
+ }
571
+ await _releaseQuoteHolds(placed);
572
+ var refused = new Error("quotes.convertToOrder: refused — sku " + sku +
573
+ " does not have " + qty + " unit(s) available to fulfil the quote");
574
+ refused.code = "QUOTE_INSUFFICIENT_STOCK";
575
+ refused.sku = sku;
576
+ throw refused;
577
+ }
578
+ return { placed: placed, heldBySku: heldBySku };
579
+ }
580
+
581
+ // Best-effort release of a set of placed holds — the rollback path. Drop-
582
+ // silent per hold so a release failure never masks the error that triggered
583
+ // the rollback. Runs ONLY when no pending order was created; once the order
584
+ // exists it owns its holds (settled on its own paid / cancel edge).
585
+ async function _releaseQuoteHolds(holds) {
586
+ if (!inventoryHandle || !Array.isArray(holds)) return;
587
+ for (var i = 0; i < holds.length; i += 1) {
588
+ try { await inventoryHandle.release(holds[i].sku, holds[i].qty); }
589
+ catch (_e) { /* drop-silent — rollback best-effort */ }
590
+ }
591
+ }
592
+
409
593
  return {
410
594
  QUOTE_STATUSES: QUOTE_STATUSES.slice(),
411
595
  TERMINAL_STATUSES: TERMINAL_STATUSES.slice(),
@@ -484,6 +668,12 @@ function create(opts) {
484
668
 
485
669
  var id = b.uuid.v7();
486
670
  var ts = _now();
671
+ // Mint the customer-facing view token up front so the request flow can
672
+ // hand the customer their link immediately (and an operator console
673
+ // detail can re-issue it). Only the hash lands in the row; the plaintext
674
+ // rides back on the return value and is never stored.
675
+ var viewTokenPlain = _mintViewToken();
676
+ var viewTokenHash = _hashViewToken(viewTokenPlain);
487
677
  try {
488
678
  await query(
489
679
  "INSERT INTO quotes " +
@@ -491,10 +681,10 @@ function create(opts) {
491
681
  " operator_notes, shipping_minor, tax_minor, total_minor, currency, " +
492
682
  " valid_until, accepted_at, accepted_by_customer, rejected_at, reject_reason, " +
493
683
  " converted_at, converted_order_id, cancelled_at, cancel_reason, " +
494
- " created_at, updated_at) " +
684
+ " view_token_hash, created_at, updated_at) " +
495
685
  "VALUES (?1, ?2, 'requested', ?3, ?4, ?5, NULL, NULL, NULL, NULL, NULL, " +
496
- " NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?6, ?6)",
497
- [id, customerId, delivery, payment, message, ts],
686
+ " NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?7, ?6, ?6)",
687
+ [id, customerId, delivery, payment, message, ts, viewTokenHash],
498
688
  );
499
689
  for (var j = 0; j < lines.length; j += 1) {
500
690
  var l = lines[j];
@@ -513,7 +703,11 @@ function create(opts) {
513
703
  catch (_e2) { /* drop-silent — the original error is what the caller needs */ }
514
704
  throw e;
515
705
  }
516
- return await _hydrated(id);
706
+ var hydrated = await _hydrated(id);
707
+ // Surface the plaintext token once on the create result so the caller
708
+ // can build the /quote/:token link without a second round-trip.
709
+ if (hydrated) hydrated.view_token = viewTokenPlain;
710
+ return hydrated;
517
711
  },
518
712
 
519
713
  // FSM: requested -> responded. Operator prices every quote line,
@@ -542,12 +736,7 @@ function create(opts) {
542
736
  miss.code = "QUOTE_NOT_FOUND";
543
737
  throw miss;
544
738
  }
545
- if (current.status !== "requested") {
546
- var refused = new Error("quotes.respondToQuote: refused — quote is " + current.status +
547
- ", only requested quotes can be responded to");
548
- refused.code = "QUOTE_TRANSITION_REFUSED";
549
- throw refused;
550
- }
739
+ _assertTransition(current.status, "respond", "respondToQuote");
551
740
 
552
741
  var lines = await _getLinesRaw(quoteId);
553
742
  var lineSkus = lines.map(function (r) { return r.sku; });
@@ -613,12 +802,7 @@ function create(opts) {
613
802
  miss.code = "QUOTE_NOT_FOUND";
614
803
  throw miss;
615
804
  }
616
- if (current.status !== "responded") {
617
- var refused = new Error("quotes.customerAccept: refused — quote is " + current.status +
618
- ", only responded quotes can be accepted");
619
- refused.code = "QUOTE_TRANSITION_REFUSED";
620
- throw refused;
621
- }
805
+ _assertTransition(current.status, "accept", "customerAccept");
622
806
  var ts = _now();
623
807
  if (current.valid_until != null && Number(current.valid_until) <= ts) {
624
808
  var expired = new Error("quotes.customerAccept: refused — quote expired at " +
@@ -649,12 +833,7 @@ function create(opts) {
649
833
  miss.code = "QUOTE_NOT_FOUND";
650
834
  throw miss;
651
835
  }
652
- if (current.status !== "responded") {
653
- var refused = new Error("quotes.customerReject: refused — quote is " + current.status +
654
- ", only responded quotes can be rejected");
655
- refused.code = "QUOTE_TRANSITION_REFUSED";
656
- throw refused;
657
- }
836
+ _assertTransition(current.status, "reject", "customerReject");
658
837
  var ts = _now();
659
838
  await query(
660
839
  "UPDATE quotes SET status = 'rejected', rejected_at = ?1, " +
@@ -682,12 +861,7 @@ function create(opts) {
682
861
  miss.code = "QUOTE_NOT_FOUND";
683
862
  throw miss;
684
863
  }
685
- if (current.status !== "requested" && current.status !== "responded") {
686
- var refused = new Error("quotes.cancelQuote: refused — quote is " + current.status +
687
- ", only requested or responded quotes can be cancelled");
688
- refused.code = "QUOTE_TRANSITION_REFUSED";
689
- throw refused;
690
- }
864
+ _assertTransition(current.status, "cancel", "cancelQuote");
691
865
  var ts = _now();
692
866
  await query(
693
867
  "UPDATE quotes SET status = 'cancelled', cancelled_at = ?1, " +
@@ -714,12 +888,7 @@ function create(opts) {
714
888
  miss.code = "QUOTE_NOT_FOUND";
715
889
  throw miss;
716
890
  }
717
- if (current.status !== "accepted") {
718
- var refused = new Error("quotes.convertToOrder: refused — quote is " + current.status +
719
- ", only accepted quotes can be converted");
720
- refused.code = "QUOTE_TRANSITION_REFUSED";
721
- throw refused;
722
- }
891
+ _assertTransition(current.status, "convert", "convertToOrder");
723
892
 
724
893
  var lines = await _getLinesRaw(quoteId);
725
894
  var ts = _now();
@@ -738,48 +907,69 @@ function create(opts) {
738
907
  var cartId = _id(input.cart_id || quoteId, "cart_id");
739
908
  var sessionId = _id(input.session_id || quoteId, "session_id");
740
909
 
741
- var subtotal = 0;
742
- var orderLines = [];
743
- for (var i = 0; i < lines.length; i += 1) {
744
- var line = lines[i];
745
- var qty = Number(line.quantity);
746
- var price = Number(line.unit_price_minor);
747
- subtotal += qty * price;
748
- orderLines.push({
749
- // The order primitive validates variant_id as a UUID.
750
- // The quote primitive doesn't track variant_id (a quote
751
- // is SKU-grained, not variant-grained), so synthesize a
752
- // deterministic UUID per (quote, line) tuple. The
753
- // operator's variant resolution happens upstream of the
754
- // quote surface; the order line snapshots the sku +
755
- // qty + price the customer accepted.
756
- variant_id: line.id,
757
- sku: line.sku,
758
- qty: qty,
759
- unit_amount_minor: price,
760
- unit_currency: current.currency,
910
+ // Reserve shelf stock for the whole quote before the order lands.
911
+ // Throws QUOTE_INSUFFICIENT_STOCK (after rolling back its own holds)
912
+ // if any line oversells no order is created, no holds strand. A
913
+ // no-op returning empty maps when no inventory handle is wired.
914
+ var holdResult = await _placeQuoteHolds(lines);
915
+ var placedHolds = holdResult.placed;
916
+ var heldBySku = holdResult.heldBySku;
917
+ // From here until the order is committed, any throw must release the
918
+ // holds this conversion placed once createFromCart succeeds the
919
+ // pending order owns them (its paid/cancel edge settles them) and a
920
+ // blanket release would double-free / eat a concurrent shopper's hold.
921
+ var orderCreated = false;
922
+ try {
923
+ var subtotal = 0;
924
+ var orderLines = [];
925
+ for (var i = 0; i < lines.length; i += 1) {
926
+ var line = lines[i];
927
+ var qty = Number(line.quantity);
928
+ var price = Number(line.unit_price_minor);
929
+ subtotal += qty * price;
930
+ orderLines.push({
931
+ // The order primitive validates variant_id as a UUID.
932
+ // The quote primitive doesn't track variant_id (a quote
933
+ // is SKU-grained, not variant-grained), so synthesize a
934
+ // deterministic UUID per (quote, line) tuple. The
935
+ // operator's variant resolution happens upstream of the
936
+ // quote surface; the order line snapshots the sku +
937
+ // qty + price the customer accepted.
938
+ variant_id: line.id,
939
+ sku: line.sku,
940
+ qty: qty,
941
+ unit_amount_minor: price,
942
+ unit_currency: current.currency,
943
+ // Units this line reserved above (0 for an un-tracked SKU) so
944
+ // the pending order settles exactly the holds it owns.
945
+ stock_held_qty: heldBySku[line.sku] ? qty : 0,
946
+ });
947
+ }
948
+ var shipping = current.shipping_minor == null ? 0 : Number(current.shipping_minor);
949
+ var tax = current.tax_minor == null ? 0 : Number(current.tax_minor);
950
+ var grand = subtotal + shipping + tax;
951
+
952
+ var createdOrder = await orderHandle.createFromCart({
953
+ cart_id: cartId,
954
+ session_id: sessionId,
955
+ customer_id: current.customer_id,
956
+ currency: current.currency,
957
+ subtotal_minor: subtotal,
958
+ discount_minor: 0,
959
+ tax_minor: tax,
960
+ shipping_minor: shipping,
961
+ grand_total_minor: grand,
962
+ ship_to: input.ship_to,
963
+ lines: orderLines,
761
964
  });
762
- }
763
- var shipping = current.shipping_minor == null ? 0 : Number(current.shipping_minor);
764
- var tax = current.tax_minor == null ? 0 : Number(current.tax_minor);
765
- var grand = subtotal + shipping + tax;
766
-
767
- var createdOrder = await orderHandle.createFromCart({
768
- cart_id: cartId,
769
- session_id: sessionId,
770
- customer_id: current.customer_id,
771
- currency: current.currency,
772
- subtotal_minor: subtotal,
773
- discount_minor: 0,
774
- tax_minor: tax,
775
- shipping_minor: shipping,
776
- grand_total_minor: grand,
777
- ship_to: input.ship_to,
778
- lines: orderLines,
779
- });
780
- orderId = createdOrder && createdOrder.id;
781
- if (!orderId) {
782
- throw new Error("quotes.convertToOrder: order handle did not return an order id");
965
+ orderCreated = true;
966
+ orderId = createdOrder && createdOrder.id;
967
+ if (!orderId) {
968
+ throw new Error("quotes.convertToOrder: order handle did not return an order id");
969
+ }
970
+ } catch (e) {
971
+ if (!orderCreated) await _releaseQuoteHolds(placedHolds);
972
+ throw e;
783
973
  }
784
974
  } else {
785
975
  // Caller supplies converted_order_id when no order handle is
@@ -808,6 +998,45 @@ function create(opts) {
808
998
  return await _hydrated(id);
809
999
  },
810
1000
 
1001
+ // Mint (or rotate) the customer-facing view token for a quote and return
1002
+ // the PLAINTEXT once — the caller renders it into the link / email and
1003
+ // never persists it. Only the SHA3-512 hash lands in the row, so the
1004
+ // plaintext is unrecoverable from storage. Idempotent-by-rotation: each
1005
+ // call mints a fresh token, invalidating any prior link. Returns null on
1006
+ // an unknown quote so the caller maps to 404.
1007
+ issueViewToken: async function (quoteId) {
1008
+ var id = _id(quoteId, "quote_id");
1009
+ var current = await _getQuoteRaw(id);
1010
+ if (!current) return null;
1011
+ var plaintext = _mintViewToken();
1012
+ var ts = _now();
1013
+ await query(
1014
+ "UPDATE quotes SET view_token_hash = ?1, updated_at = ?2 WHERE id = ?3",
1015
+ [_hashViewToken(plaintext), ts, id],
1016
+ );
1017
+ return { quote_id: id, plaintext_token: plaintext };
1018
+ },
1019
+
1020
+ // Resolve a quote by its presented view token. Shape-checks the token,
1021
+ // hashes it, looks up the row by hash, then constant-time-compares the
1022
+ // stored hash before returning the hydrated quote — so a malformed or
1023
+ // non-matching token yields null (the route maps that to 404,
1024
+ // indistinguishable from a missing quote). Never accepts a quote id here:
1025
+ // the token is the only key this path honours.
1026
+ getByViewToken: async function (token) {
1027
+ var canon;
1028
+ try { canon = _canonicalViewToken(token); }
1029
+ catch (_e) { return null; }
1030
+ var hash = _hashViewToken(canon);
1031
+ var r = await query("SELECT * FROM quotes WHERE view_token_hash = ?1", [hash]);
1032
+ var row = r.rows[0];
1033
+ if (!row || row.view_token_hash == null) return null;
1034
+ if (!b.crypto.timingSafeEqual(row.view_token_hash, hash)) return null;
1035
+ var out = _hydrateQuote(row);
1036
+ out.lines = (await _getLinesRaw(row.id)).map(_hydrateLine);
1037
+ return out;
1038
+ },
1039
+
811
1040
  // List quotes for a given customer. Optional `status` narrows the
812
1041
  // result; `limit` caps the page size. Sorted by updated_at DESC
813
1042
  // so the customer's freshest activity surfaces first.
@@ -909,12 +1138,7 @@ function create(opts) {
909
1138
  miss.code = "QUOTE_NOT_FOUND";
910
1139
  throw miss;
911
1140
  }
912
- if (current.status !== "responded") {
913
- var refused = new Error("quotes.markExpired: refused — quote is " + current.status +
914
- ", only responded quotes can expire");
915
- refused.code = "QUOTE_TRANSITION_REFUSED";
916
- throw refused;
917
- }
1141
+ _assertTransition(current.status, "expire", "markExpired");
918
1142
  if (current.valid_until == null || Number(current.valid_until) > asOf) {
919
1143
  var notYet = new Error("quotes.markExpired: refused — quote " + quoteId +
920
1144
  " has not yet expired (valid_until=" + current.valid_until + ", as_of=" + asOf + ")");