@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/CHANGELOG.md +2 -0
- package/README.md +3 -2
- package/lib/admin.js +351 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/email.js +51 -0
- package/lib/quotes.js +306 -82
- package/lib/storefront.js +416 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
qty
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
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 + ")");
|