@blamejs/blamejs-shop 0.0.64 → 0.0.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/lib/address-validation.js +529 -0
- package/lib/auto-discount.js +1133 -0
- package/lib/business-hours.js +980 -0
- package/lib/captcha-gate.js +961 -0
- package/lib/catalog-drafts.js +1614 -0
- package/lib/cookie-consent.js +605 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-roles.js +640 -0
- package/lib/cycle-counting.js +802 -0
- package/lib/delivery-estimate.js +1113 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/email-warmup.js +795 -0
- package/lib/index.js +30 -0
- package/lib/metered-usage.js +782 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/preorder.js +595 -0
- package/lib/price-display.js +699 -0
- package/lib/product-bulk-ops.js +797 -0
- package/lib/purchase-orders.js +923 -0
- package/lib/quotes.js +944 -0
- package/lib/recommendations.js +850 -0
- package/lib/reorder-thresholds.js +678 -0
- package/lib/shipping-zones.js +621 -0
- package/lib/site-redirects.js +690 -0
- package/lib/split-shipments.js +773 -0
- package/lib/theme-assets.js +711 -0
- package/lib/trust-badges.js +721 -0
- package/lib/webhook-receiver.js +1034 -0
- package/package.json +1 -1
package/lib/quotes.js
ADDED
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.quotes
|
|
4
|
+
* @title Quotes — B2B request-for-quote (RFQ) negotiation surface
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* A quote is a multi-step negotiation between a customer and the
|
|
8
|
+
* operator. The customer submits an RFQ with N requested lines
|
|
9
|
+
* (SKU + quantity + optional per-line notes); the operator
|
|
10
|
+
* responds with line prices + shipping + tax + an expiry; the
|
|
11
|
+
* customer either accepts (quote converts to an order at the
|
|
12
|
+
* quoted prices), rejects, or lets it expire.
|
|
13
|
+
*
|
|
14
|
+
* This is distinct from the cart "quote" snapshot. That is a
|
|
15
|
+
* read-only view of a live cart at current catalog prices — no
|
|
16
|
+
* negotiation, no per-line operator response, no acceptance
|
|
17
|
+
* contract. The `quotes` table backs the full negotiation
|
|
18
|
+
* lifecycle and is the artifact the operator and the customer
|
|
19
|
+
* both reference when honouring an agreed price.
|
|
20
|
+
*
|
|
21
|
+
* FSM:
|
|
22
|
+
*
|
|
23
|
+
* requested -> responded -> accepted -> converted (happy path)
|
|
24
|
+
* |
|
|
25
|
+
* +-> rejected (customer says no)
|
|
26
|
+
* +-> expired (valid_until passed)
|
|
27
|
+
* +-> cancelled (cancelled before accept)
|
|
28
|
+
*
|
|
29
|
+
* requested -> cancelled (cancelled pre-response)
|
|
30
|
+
*
|
|
31
|
+
* accepted -> cancelled (refused — accept is binding)
|
|
32
|
+
* converted -> * (terminal)
|
|
33
|
+
* rejected -> * (terminal)
|
|
34
|
+
* expired -> * (terminal)
|
|
35
|
+
*
|
|
36
|
+
* `valid_until` is the operator-set expiry timestamp (ms). After
|
|
37
|
+
* it elapses, `listExpired({ as_of })` surfaces the row so a
|
|
38
|
+
* cron / dunning job can transition it to `expired`.
|
|
39
|
+
*
|
|
40
|
+
* `total_minor` is the operator-quoted grand total — the sum of
|
|
41
|
+
* the quote_lines line-totals plus `shipping_minor` + `tax_minor`.
|
|
42
|
+
* It is NULL until the operator has responded.
|
|
43
|
+
*
|
|
44
|
+
* Composes:
|
|
45
|
+
* - b.uuid.v7 — quote + line PKs (sortable; B-tree locality)
|
|
46
|
+
* - b.guardUuid — strict UUID validation on every quote_id +
|
|
47
|
+
* customer_id read
|
|
48
|
+
* - cart (optional) — when injected, `requestQuote({ cart: ... })`
|
|
49
|
+
* can derive the RFQ lines from an existing
|
|
50
|
+
* cart's line set instead of an explicit
|
|
51
|
+
* `lines` array.
|
|
52
|
+
* - order (optional) — when injected, `convertToOrder` composes
|
|
53
|
+
* `order.createFromCart` to land the
|
|
54
|
+
* accepted quote as a `pending` order. When
|
|
55
|
+
* absent, `convertToOrder` still records the
|
|
56
|
+
* status flip + the converted_order_id the
|
|
57
|
+
* caller supplies, leaving the actual order
|
|
58
|
+
* creation to the caller.
|
|
59
|
+
*
|
|
60
|
+
* Surface:
|
|
61
|
+
* requestQuote({ customer_id, lines?, cart?, message?,
|
|
62
|
+
* delivery_terms?, payment_terms?, currency? })
|
|
63
|
+
* respondToQuote({ quote_id, line_prices, shipping_minor?,
|
|
64
|
+
* tax_minor?, valid_until, currency,
|
|
65
|
+
* operator_notes?, delivery_terms?,
|
|
66
|
+
* payment_terms? })
|
|
67
|
+
* customerAccept({ quote_id, accepted_by_customer })
|
|
68
|
+
* customerReject({ quote_id, reject_reason? })
|
|
69
|
+
* cancelQuote({ quote_id, cancel_reason })
|
|
70
|
+
* convertToOrder({ quote_id, ship_to, session_id?, cart_id? })
|
|
71
|
+
* getQuote(quote_id)
|
|
72
|
+
* quotesForCustomer(customer_id, { status?, limit? })
|
|
73
|
+
* pendingResponse({ limit? })
|
|
74
|
+
* listExpired({ as_of })
|
|
75
|
+
*
|
|
76
|
+
* Storage: `migrations-d1/0102_quotes.sql` — two tables, `quotes`
|
|
77
|
+
* + `quote_lines`. ON DELETE CASCADE from quote -> lines.
|
|
78
|
+
*
|
|
79
|
+
* @primitive quotes
|
|
80
|
+
* @related b.uuid, b.guardUuid, shop.cart, shop.order
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
var MAX_LINES = 1000;
|
|
84
|
+
var MAX_QUANTITY = 1000000; // 1M units per line sanity cap
|
|
85
|
+
var MAX_UNIT_PRICE_MINOR = 100000000000; // 1e11 minor units sanity cap
|
|
86
|
+
var MAX_TOTAL_MINOR = 1000000000000; // 1e12 minor units sanity cap on shipping/tax/total
|
|
87
|
+
var MAX_SKU_LEN = 128;
|
|
88
|
+
var MAX_MESSAGE_LEN = 4000;
|
|
89
|
+
var MAX_OPERATOR_NOTES_LEN = 4000;
|
|
90
|
+
var MAX_TERMS_LEN = 280;
|
|
91
|
+
var MAX_LINE_NOTES_LEN = 1000;
|
|
92
|
+
var MAX_REJECT_REASON_LEN = 280;
|
|
93
|
+
var MAX_CANCEL_REASON_LEN = 280;
|
|
94
|
+
var MAX_ACCEPTED_BY_LEN = 256;
|
|
95
|
+
var DEFAULT_LIMIT = 50;
|
|
96
|
+
var MAX_LIMIT = 500;
|
|
97
|
+
|
|
98
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
99
|
+
var CURRENCY_RE = /^[A-Z]{3}$/;
|
|
100
|
+
|
|
101
|
+
// Control bytes + zero-width / direction-override family. Operator-
|
|
102
|
+
// rendered text fields refuse these to keep the downstream dashboard /
|
|
103
|
+
// printout safe from header-injection + visual-spoofing attacks. CR/LF
|
|
104
|
+
// are allowed inside multi-line free-text fields (message,
|
|
105
|
+
// operator_notes, line.notes); single-line fields (terms, reject /
|
|
106
|
+
// cancel reason, accepted_by) refuse them too.
|
|
107
|
+
var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
|
|
108
|
+
var CONTROL_BYTE_MULTILINE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
109
|
+
var ZERO_WIDTH_RE = new RegExp(
|
|
110
|
+
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
var QUOTE_STATUSES = Object.freeze([
|
|
114
|
+
"requested", "responded", "accepted", "rejected",
|
|
115
|
+
"expired", "cancelled", "converted",
|
|
116
|
+
]);
|
|
117
|
+
var TERMINAL_STATUSES = Object.freeze([
|
|
118
|
+
"rejected", "expired", "cancelled", "converted",
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
// Lazy framework handle — matches the pattern every other shop
|
|
122
|
+
// primitive uses; avoids the require cycle that would arise from
|
|
123
|
+
// importing `./index` at module-eval time.
|
|
124
|
+
var bShop;
|
|
125
|
+
function _b() {
|
|
126
|
+
if (!bShop) bShop = require("./index");
|
|
127
|
+
return bShop.framework;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- validators ---------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
function _id(s, label) {
|
|
133
|
+
try {
|
|
134
|
+
return _b().guardUuid.sanitize(s, { profile: "strict" });
|
|
135
|
+
} catch (e) {
|
|
136
|
+
throw new TypeError("quotes: " + label + " — " + (e && e.message || "invalid UUID"));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function _sku(s) {
|
|
141
|
+
if (typeof s !== "string" || !s.length) {
|
|
142
|
+
throw new TypeError("quotes: sku must be a non-empty string");
|
|
143
|
+
}
|
|
144
|
+
if (s.length > MAX_SKU_LEN) {
|
|
145
|
+
throw new TypeError("quotes: sku must be <= " + MAX_SKU_LEN + " characters");
|
|
146
|
+
}
|
|
147
|
+
if (!SKU_RE.test(s)) {
|
|
148
|
+
throw new TypeError("quotes: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/");
|
|
149
|
+
}
|
|
150
|
+
return s;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function _currency(s) {
|
|
154
|
+
if (typeof s !== "string" || !CURRENCY_RE.test(s)) {
|
|
155
|
+
throw new TypeError("quotes: currency must be a 3-letter uppercase ISO-4217 code");
|
|
156
|
+
}
|
|
157
|
+
return s;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function _positiveInt(n, label) {
|
|
161
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
162
|
+
throw new TypeError("quotes: " + label + " must be a positive integer");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function _nonNegInt(n, label) {
|
|
167
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
168
|
+
throw new TypeError("quotes: " + label + " must be a non-negative integer");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function _quantity(n, label) {
|
|
173
|
+
_positiveInt(n, label);
|
|
174
|
+
if (n > MAX_QUANTITY) {
|
|
175
|
+
throw new TypeError("quotes: " + label + " must be <= " + MAX_QUANTITY);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function _unitPrice(n, label) {
|
|
180
|
+
_nonNegInt(n, label);
|
|
181
|
+
if (n > MAX_UNIT_PRICE_MINOR) {
|
|
182
|
+
throw new TypeError("quotes: " + label + " must be <= " + MAX_UNIT_PRICE_MINOR);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function _moneyMinor(n, label) {
|
|
187
|
+
_nonNegInt(n, label);
|
|
188
|
+
if (n > MAX_TOTAL_MINOR) {
|
|
189
|
+
throw new TypeError("quotes: " + label + " must be <= " + MAX_TOTAL_MINOR);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function _ts(n, label) {
|
|
194
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
195
|
+
throw new TypeError("quotes: " + label + " must be a positive integer (epoch ms)");
|
|
196
|
+
}
|
|
197
|
+
return n;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function _optShortText(s, label, max) {
|
|
201
|
+
if (s == null) return null;
|
|
202
|
+
if (typeof s !== "string") {
|
|
203
|
+
throw new TypeError("quotes: " + label + " must be a string");
|
|
204
|
+
}
|
|
205
|
+
if (!s.length) {
|
|
206
|
+
throw new TypeError("quotes: " + label + " must be a non-empty string when provided");
|
|
207
|
+
}
|
|
208
|
+
if (s.length > max) {
|
|
209
|
+
throw new TypeError("quotes: " + label + " must be <= " + max + " characters");
|
|
210
|
+
}
|
|
211
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
212
|
+
throw new TypeError("quotes: " + label + " contains control bytes");
|
|
213
|
+
}
|
|
214
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
215
|
+
throw new TypeError("quotes: " + label + " contains zero-width / direction-override bytes");
|
|
216
|
+
}
|
|
217
|
+
return s;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function _optLongText(s, label, max) {
|
|
221
|
+
if (s == null) return null;
|
|
222
|
+
if (typeof s !== "string") {
|
|
223
|
+
throw new TypeError("quotes: " + label + " must be a string");
|
|
224
|
+
}
|
|
225
|
+
if (!s.length) {
|
|
226
|
+
throw new TypeError("quotes: " + label + " must be a non-empty string when provided");
|
|
227
|
+
}
|
|
228
|
+
if (s.length > max) {
|
|
229
|
+
throw new TypeError("quotes: " + label + " must be <= " + max + " characters");
|
|
230
|
+
}
|
|
231
|
+
if (CONTROL_BYTE_MULTILINE_RE.test(s)) {
|
|
232
|
+
throw new TypeError("quotes: " + label + " contains control bytes");
|
|
233
|
+
}
|
|
234
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
235
|
+
throw new TypeError("quotes: " + label + " contains zero-width / direction-override bytes");
|
|
236
|
+
}
|
|
237
|
+
return s;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function _requiredShortText(s, label, max) {
|
|
241
|
+
if (typeof s !== "string" || !s.length) {
|
|
242
|
+
throw new TypeError("quotes: " + label + " must be a non-empty string");
|
|
243
|
+
}
|
|
244
|
+
return _optShortText(s, label, max);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function _status(s) {
|
|
248
|
+
if (typeof s !== "string" || QUOTE_STATUSES.indexOf(s) === -1) {
|
|
249
|
+
throw new TypeError("quotes: status must be one of " + QUOTE_STATUSES.join(", "));
|
|
250
|
+
}
|
|
251
|
+
return s;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function _now() { return Date.now(); }
|
|
255
|
+
|
|
256
|
+
// Validate + normalize the requested-lines array. Refuses duplicate
|
|
257
|
+
// SKUs — the same SKU twice is a quantity-merge concern, not a
|
|
258
|
+
// per-line decision; the customer should send a single line at the
|
|
259
|
+
// combined quantity.
|
|
260
|
+
function _validateRequestedLines(lines, label) {
|
|
261
|
+
if (!Array.isArray(lines) || lines.length === 0) {
|
|
262
|
+
throw new TypeError("quotes: " + label + " must be a non-empty array");
|
|
263
|
+
}
|
|
264
|
+
if (lines.length > MAX_LINES) {
|
|
265
|
+
throw new TypeError("quotes: " + label + " must contain <= " + MAX_LINES + " entries");
|
|
266
|
+
}
|
|
267
|
+
var seen = Object.create(null);
|
|
268
|
+
var normalized = [];
|
|
269
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
270
|
+
var l = lines[i];
|
|
271
|
+
if (!l || typeof l !== "object") {
|
|
272
|
+
throw new TypeError("quotes: " + label + "[" + i + "] must be an object");
|
|
273
|
+
}
|
|
274
|
+
var sku = _sku(l.sku);
|
|
275
|
+
_quantity(l.quantity, label + "[" + i + "].quantity");
|
|
276
|
+
var notes = _optLongText(l.notes, label + "[" + i + "].notes", MAX_LINE_NOTES_LEN);
|
|
277
|
+
if (seen[sku]) {
|
|
278
|
+
throw new TypeError("quotes: duplicate sku " + JSON.stringify(sku) + " in " + label);
|
|
279
|
+
}
|
|
280
|
+
seen[sku] = true;
|
|
281
|
+
normalized.push({
|
|
282
|
+
sku: sku,
|
|
283
|
+
quantity: l.quantity,
|
|
284
|
+
notes: notes,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return normalized;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Validate the operator's line_prices payload. The shape mirrors the
|
|
291
|
+
// requested lines — one entry per sku-on-the-quote — with a
|
|
292
|
+
// `unit_price_minor`. Every quote line must be priced; partial
|
|
293
|
+
// pricing is refused (the customer can't accept a half-priced quote).
|
|
294
|
+
function _validateLinePrices(linePrices, lineSkus) {
|
|
295
|
+
if (!Array.isArray(linePrices) || linePrices.length === 0) {
|
|
296
|
+
throw new TypeError("quotes: line_prices must be a non-empty array");
|
|
297
|
+
}
|
|
298
|
+
if (linePrices.length !== lineSkus.length) {
|
|
299
|
+
throw new TypeError("quotes: line_prices length " + linePrices.length +
|
|
300
|
+
" does not match quote line count " + lineSkus.length);
|
|
301
|
+
}
|
|
302
|
+
var skuSet = Object.create(null);
|
|
303
|
+
for (var i = 0; i < lineSkus.length; i += 1) skuSet[lineSkus[i]] = true;
|
|
304
|
+
var seen = Object.create(null);
|
|
305
|
+
var byKey = Object.create(null);
|
|
306
|
+
for (var j = 0; j < linePrices.length; j += 1) {
|
|
307
|
+
var lp = linePrices[j];
|
|
308
|
+
if (!lp || typeof lp !== "object") {
|
|
309
|
+
throw new TypeError("quotes: line_prices[" + j + "] must be an object");
|
|
310
|
+
}
|
|
311
|
+
var sku = _sku(lp.sku);
|
|
312
|
+
_unitPrice(lp.unit_price_minor, "line_prices[" + j + "].unit_price_minor");
|
|
313
|
+
if (!skuSet[sku]) {
|
|
314
|
+
throw new TypeError("quotes: line_prices sku " + JSON.stringify(sku) +
|
|
315
|
+
" not on the quote");
|
|
316
|
+
}
|
|
317
|
+
if (seen[sku]) {
|
|
318
|
+
throw new TypeError("quotes: duplicate sku " + JSON.stringify(sku) + " in line_prices");
|
|
319
|
+
}
|
|
320
|
+
seen[sku] = true;
|
|
321
|
+
byKey[sku] = lp.unit_price_minor;
|
|
322
|
+
}
|
|
323
|
+
return byKey;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---- row hydration ------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
function _hydrateQuote(row) {
|
|
329
|
+
if (!row) return null;
|
|
330
|
+
return {
|
|
331
|
+
id: row.id,
|
|
332
|
+
customer_id: row.customer_id,
|
|
333
|
+
status: row.status,
|
|
334
|
+
delivery_terms: row.delivery_terms == null ? null : row.delivery_terms,
|
|
335
|
+
payment_terms: row.payment_terms == null ? null : row.payment_terms,
|
|
336
|
+
message: row.message == null ? null : row.message,
|
|
337
|
+
operator_notes: row.operator_notes == null ? null : row.operator_notes,
|
|
338
|
+
shipping_minor: row.shipping_minor == null ? null : Number(row.shipping_minor),
|
|
339
|
+
tax_minor: row.tax_minor == null ? null : Number(row.tax_minor),
|
|
340
|
+
total_minor: row.total_minor == null ? null : Number(row.total_minor),
|
|
341
|
+
currency: row.currency == null ? null : row.currency,
|
|
342
|
+
valid_until: row.valid_until == null ? null : Number(row.valid_until),
|
|
343
|
+
accepted_at: row.accepted_at == null ? null : Number(row.accepted_at),
|
|
344
|
+
accepted_by_customer: row.accepted_by_customer == null ? null : row.accepted_by_customer,
|
|
345
|
+
rejected_at: row.rejected_at == null ? null : Number(row.rejected_at),
|
|
346
|
+
reject_reason: row.reject_reason == null ? null : row.reject_reason,
|
|
347
|
+
converted_at: row.converted_at == null ? null : Number(row.converted_at),
|
|
348
|
+
converted_order_id: row.converted_order_id == null ? null : row.converted_order_id,
|
|
349
|
+
cancelled_at: row.cancelled_at == null ? null : Number(row.cancelled_at),
|
|
350
|
+
cancel_reason: row.cancel_reason == null ? null : row.cancel_reason,
|
|
351
|
+
created_at: Number(row.created_at),
|
|
352
|
+
updated_at: Number(row.updated_at),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function _hydrateLine(row) {
|
|
357
|
+
return {
|
|
358
|
+
id: row.id,
|
|
359
|
+
quote_id: row.quote_id,
|
|
360
|
+
sku: row.sku,
|
|
361
|
+
quantity: Number(row.quantity),
|
|
362
|
+
notes: row.notes == null ? null : row.notes,
|
|
363
|
+
unit_price_minor: row.unit_price_minor == null ? null : Number(row.unit_price_minor),
|
|
364
|
+
currency: row.currency == null ? null : row.currency,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---- factory ------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
function create(opts) {
|
|
371
|
+
opts = opts || {};
|
|
372
|
+
var query = opts.query;
|
|
373
|
+
if (!query) {
|
|
374
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
375
|
+
}
|
|
376
|
+
// Optional cart handle — when wired, `requestQuote({ cart_id })`
|
|
377
|
+
// can derive the RFQ lines from the cart instead of the caller
|
|
378
|
+
// supplying an explicit `lines` array. The handle must expose
|
|
379
|
+
// `get(cart_id)` + `listLines(cart_id)`.
|
|
380
|
+
var cartHandle = opts.cart || null;
|
|
381
|
+
if (cartHandle && (typeof cartHandle.get !== "function" ||
|
|
382
|
+
typeof cartHandle.listLines !== "function")) {
|
|
383
|
+
throw new TypeError("quotes.create: opts.cart must expose get + listLines when provided");
|
|
384
|
+
}
|
|
385
|
+
// Optional order handle — when wired, `convertToOrder` composes
|
|
386
|
+
// `order.createFromCart` to land the accepted quote as a `pending`
|
|
387
|
+
// order. When absent, `convertToOrder` records the status flip +
|
|
388
|
+
// requires the caller to supply `converted_order_id` directly.
|
|
389
|
+
var orderHandle = opts.order || null;
|
|
390
|
+
if (orderHandle && typeof orderHandle.createFromCart !== "function") {
|
|
391
|
+
throw new TypeError("quotes.create: opts.order must expose createFromCart when provided");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function _getQuoteRaw(id) {
|
|
395
|
+
var r = await query("SELECT * FROM quotes WHERE id = ?1", [id]);
|
|
396
|
+
return r.rows[0] || null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function _getLinesRaw(quoteId) {
|
|
400
|
+
var r = await query(
|
|
401
|
+
"SELECT * FROM quote_lines WHERE quote_id = ?1 ORDER BY sku ASC",
|
|
402
|
+
[quoteId],
|
|
403
|
+
);
|
|
404
|
+
return r.rows;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function _hydrated(id) {
|
|
408
|
+
var q = await _getQuoteRaw(id);
|
|
409
|
+
if (!q) return null;
|
|
410
|
+
var lines = await _getLinesRaw(id);
|
|
411
|
+
var out = _hydrateQuote(q);
|
|
412
|
+
out.lines = lines.map(_hydrateLine);
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
QUOTE_STATUSES: QUOTE_STATUSES.slice(),
|
|
418
|
+
TERMINAL_STATUSES: TERMINAL_STATUSES.slice(),
|
|
419
|
+
MAX_LINES: MAX_LINES,
|
|
420
|
+
MAX_QUANTITY: MAX_QUANTITY,
|
|
421
|
+
|
|
422
|
+
// Customer submits the RFQ. Captures `customer_id`, the requested
|
|
423
|
+
// lines (either explicit via `lines` or derived from an injected
|
|
424
|
+
// cart via `cart_id`), and optional message + terms preferences.
|
|
425
|
+
// No prices yet — that's `respondToQuote`'s job.
|
|
426
|
+
requestQuote: async function (input) {
|
|
427
|
+
if (!input || typeof input !== "object") {
|
|
428
|
+
throw new TypeError("quotes.requestQuote: input object required");
|
|
429
|
+
}
|
|
430
|
+
var customerId = _id(input.customer_id, "customer_id");
|
|
431
|
+
var message = _optLongText(input.message, "message", MAX_MESSAGE_LEN);
|
|
432
|
+
var delivery = _optShortText(input.delivery_terms, "delivery_terms", MAX_TERMS_LEN);
|
|
433
|
+
var payment = _optShortText(input.payment_terms, "payment_terms", MAX_TERMS_LEN);
|
|
434
|
+
|
|
435
|
+
// Resolve the line set. Explicit `lines` takes precedence; if
|
|
436
|
+
// absent, fall back to deriving from `cart_id` via the injected
|
|
437
|
+
// cart handle. Refuse both-present (operator ambiguity) and
|
|
438
|
+
// neither-present (no input to quote against).
|
|
439
|
+
var hasLines = input.lines !== undefined && input.lines !== null;
|
|
440
|
+
var hasCartId = input.cart_id !== undefined && input.cart_id !== null;
|
|
441
|
+
if (hasLines && hasCartId) {
|
|
442
|
+
throw new TypeError("quotes.requestQuote: pass lines OR cart_id, not both");
|
|
443
|
+
}
|
|
444
|
+
if (!hasLines && !hasCartId) {
|
|
445
|
+
throw new TypeError("quotes.requestQuote: lines or cart_id required");
|
|
446
|
+
}
|
|
447
|
+
var lines;
|
|
448
|
+
if (hasLines) {
|
|
449
|
+
lines = _validateRequestedLines(input.lines, "lines");
|
|
450
|
+
} else {
|
|
451
|
+
if (!cartHandle) {
|
|
452
|
+
throw new TypeError("quotes.requestQuote: cart_id supplied but no cart handle is wired");
|
|
453
|
+
}
|
|
454
|
+
var cartId = _id(input.cart_id, "cart_id");
|
|
455
|
+
var cartRow = await cartHandle.get(cartId);
|
|
456
|
+
if (!cartRow) {
|
|
457
|
+
var noCart = new Error("quotes.requestQuote: cart " + cartId + " not found");
|
|
458
|
+
noCart.code = "QUOTE_CART_NOT_FOUND";
|
|
459
|
+
throw noCart;
|
|
460
|
+
}
|
|
461
|
+
var cartLines = await cartHandle.listLines(cartId);
|
|
462
|
+
if (!cartLines || cartLines.length === 0) {
|
|
463
|
+
throw new TypeError("quotes.requestQuote: cart " + cartId + " has no lines to quote");
|
|
464
|
+
}
|
|
465
|
+
// Cart lines are keyed by variant; aggregate by sku so the
|
|
466
|
+
// operator-facing quote groups duplicate variants of the
|
|
467
|
+
// same sku.
|
|
468
|
+
var bySku = Object.create(null);
|
|
469
|
+
var ordered = [];
|
|
470
|
+
for (var i = 0; i < cartLines.length; i += 1) {
|
|
471
|
+
var cl = cartLines[i];
|
|
472
|
+
var sku = _sku(cl.sku);
|
|
473
|
+
var qty = Number(cl.qty);
|
|
474
|
+
if (!Number.isInteger(qty) || qty <= 0) {
|
|
475
|
+
throw new TypeError("quotes.requestQuote: cart line for sku " +
|
|
476
|
+
JSON.stringify(sku) + " has invalid qty " + cl.qty);
|
|
477
|
+
}
|
|
478
|
+
if (bySku[sku] == null) {
|
|
479
|
+
bySku[sku] = qty;
|
|
480
|
+
ordered.push(sku);
|
|
481
|
+
} else {
|
|
482
|
+
bySku[sku] += qty;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
var derived = [];
|
|
486
|
+
for (var k = 0; k < ordered.length; k += 1) {
|
|
487
|
+
derived.push({ sku: ordered[k], quantity: bySku[ordered[k]] });
|
|
488
|
+
}
|
|
489
|
+
lines = _validateRequestedLines(derived, "lines");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
var id = _b().uuid.v7();
|
|
493
|
+
var ts = _now();
|
|
494
|
+
try {
|
|
495
|
+
await query(
|
|
496
|
+
"INSERT INTO quotes " +
|
|
497
|
+
"(id, customer_id, status, delivery_terms, payment_terms, message, " +
|
|
498
|
+
" operator_notes, shipping_minor, tax_minor, total_minor, currency, " +
|
|
499
|
+
" valid_until, accepted_at, accepted_by_customer, rejected_at, reject_reason, " +
|
|
500
|
+
" converted_at, converted_order_id, cancelled_at, cancel_reason, " +
|
|
501
|
+
" created_at, updated_at) " +
|
|
502
|
+
"VALUES (?1, ?2, 'requested', ?3, ?4, ?5, NULL, NULL, NULL, NULL, NULL, " +
|
|
503
|
+
" NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?6, ?6)",
|
|
504
|
+
[id, customerId, delivery, payment, message, ts],
|
|
505
|
+
);
|
|
506
|
+
for (var j = 0; j < lines.length; j += 1) {
|
|
507
|
+
var l = lines[j];
|
|
508
|
+
await query(
|
|
509
|
+
"INSERT INTO quote_lines " +
|
|
510
|
+
"(id, quote_id, sku, quantity, notes, unit_price_minor, currency) " +
|
|
511
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, NULL, NULL)",
|
|
512
|
+
[_b().uuid.v7(), id, l.sku, l.quantity, l.notes],
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
} catch (e) {
|
|
516
|
+
// Best-effort rollback — D1 doesn't expose a transaction
|
|
517
|
+
// handle to this primitive, so the compensating DELETE drops
|
|
518
|
+
// the header + ON DELETE CASCADE clears any landed lines.
|
|
519
|
+
try { await query("DELETE FROM quotes WHERE id = ?1", [id]); }
|
|
520
|
+
catch (_e2) { /* drop-silent — the original error is what the caller needs */ }
|
|
521
|
+
throw e;
|
|
522
|
+
}
|
|
523
|
+
return await _hydrated(id);
|
|
524
|
+
},
|
|
525
|
+
|
|
526
|
+
// FSM: requested -> responded. Operator prices every quote line,
|
|
527
|
+
// sets shipping + tax + grand total currency, and stamps an
|
|
528
|
+
// expiry. The total_minor is computed from the priced lines +
|
|
529
|
+
// shipping_minor + tax_minor; the caller doesn't pass it (and
|
|
530
|
+
// can't override it — the math is the contract).
|
|
531
|
+
respondToQuote: async function (input) {
|
|
532
|
+
if (!input || typeof input !== "object") {
|
|
533
|
+
throw new TypeError("quotes.respondToQuote: input object required");
|
|
534
|
+
}
|
|
535
|
+
var quoteId = _id(input.quote_id, "quote_id");
|
|
536
|
+
var currency = _currency(input.currency);
|
|
537
|
+
var shipping = input.shipping_minor == null ? 0 : input.shipping_minor;
|
|
538
|
+
var tax = input.tax_minor == null ? 0 : input.tax_minor;
|
|
539
|
+
_moneyMinor(shipping, "shipping_minor");
|
|
540
|
+
_moneyMinor(tax, "tax_minor");
|
|
541
|
+
var validUntil = _ts(input.valid_until, "valid_until");
|
|
542
|
+
var operatorNotes = _optLongText(input.operator_notes, "operator_notes", MAX_OPERATOR_NOTES_LEN);
|
|
543
|
+
var delivery = _optShortText(input.delivery_terms, "delivery_terms", MAX_TERMS_LEN);
|
|
544
|
+
var payment = _optShortText(input.payment_terms, "payment_terms", MAX_TERMS_LEN);
|
|
545
|
+
|
|
546
|
+
var current = await _getQuoteRaw(quoteId);
|
|
547
|
+
if (!current) {
|
|
548
|
+
var miss = new Error("quotes.respondToQuote: quote " + quoteId + " not found");
|
|
549
|
+
miss.code = "QUOTE_NOT_FOUND";
|
|
550
|
+
throw miss;
|
|
551
|
+
}
|
|
552
|
+
if (current.status !== "requested") {
|
|
553
|
+
var refused = new Error("quotes.respondToQuote: refused — quote is " + current.status +
|
|
554
|
+
", only requested quotes can be responded to");
|
|
555
|
+
refused.code = "QUOTE_TRANSITION_REFUSED";
|
|
556
|
+
throw refused;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
var lines = await _getLinesRaw(quoteId);
|
|
560
|
+
var lineSkus = lines.map(function (r) { return r.sku; });
|
|
561
|
+
var pricesBySku = _validateLinePrices(input.line_prices, lineSkus);
|
|
562
|
+
|
|
563
|
+
// valid_until must be strictly in the future. A past expiry on
|
|
564
|
+
// a fresh response is operator error — the quote would be
|
|
565
|
+
// expired the moment it lands.
|
|
566
|
+
var ts = _now();
|
|
567
|
+
if (validUntil <= ts) {
|
|
568
|
+
throw new TypeError("quotes.respondToQuote: valid_until must be in the future");
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Compute totals from the priced lines + shipping + tax. Math
|
|
572
|
+
// is integer-only; sum is bounded by MAX_LINES *
|
|
573
|
+
// MAX_QUANTITY * MAX_UNIT_PRICE_MINOR which fits Number.
|
|
574
|
+
var subtotal = 0;
|
|
575
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
576
|
+
var qty = Number(lines[i].quantity);
|
|
577
|
+
var price = pricesBySku[lines[i].sku];
|
|
578
|
+
subtotal += qty * price;
|
|
579
|
+
}
|
|
580
|
+
var total = subtotal + shipping + tax;
|
|
581
|
+
_moneyMinor(total, "total_minor");
|
|
582
|
+
|
|
583
|
+
// Update header + each line. The line update sets
|
|
584
|
+
// unit_price_minor + currency per-line so a future single-line
|
|
585
|
+
// re-quote (out of scope here) has a place to land.
|
|
586
|
+
await query(
|
|
587
|
+
"UPDATE quotes SET status = 'responded', shipping_minor = ?1, " +
|
|
588
|
+
"tax_minor = ?2, total_minor = ?3, currency = ?4, valid_until = ?5, " +
|
|
589
|
+
"operator_notes = ?6, " +
|
|
590
|
+
"delivery_terms = COALESCE(?7, delivery_terms), " +
|
|
591
|
+
"payment_terms = COALESCE(?8, payment_terms), " +
|
|
592
|
+
"updated_at = ?9 WHERE id = ?10",
|
|
593
|
+
[shipping, tax, total, currency, validUntil, operatorNotes,
|
|
594
|
+
delivery, payment, ts, quoteId],
|
|
595
|
+
);
|
|
596
|
+
for (var j = 0; j < lines.length; j += 1) {
|
|
597
|
+
var line = lines[j];
|
|
598
|
+
await query(
|
|
599
|
+
"UPDATE quote_lines SET unit_price_minor = ?1, currency = ?2 WHERE id = ?3",
|
|
600
|
+
[pricesBySku[line.sku], currency, line.id],
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
return await _hydrated(quoteId);
|
|
604
|
+
},
|
|
605
|
+
|
|
606
|
+
// FSM: responded -> accepted. Customer accepts the operator's
|
|
607
|
+
// quoted terms. If valid_until has elapsed at the moment of
|
|
608
|
+
// acceptance, the call refuses — the operator may want to
|
|
609
|
+
// re-quote at fresh prices.
|
|
610
|
+
customerAccept: async function (input) {
|
|
611
|
+
if (!input || typeof input !== "object") {
|
|
612
|
+
throw new TypeError("quotes.customerAccept: input object required");
|
|
613
|
+
}
|
|
614
|
+
var quoteId = _id(input.quote_id, "quote_id");
|
|
615
|
+
var acceptedBy = _requiredShortText(input.accepted_by_customer,
|
|
616
|
+
"accepted_by_customer", MAX_ACCEPTED_BY_LEN);
|
|
617
|
+
var current = await _getQuoteRaw(quoteId);
|
|
618
|
+
if (!current) {
|
|
619
|
+
var miss = new Error("quotes.customerAccept: quote " + quoteId + " not found");
|
|
620
|
+
miss.code = "QUOTE_NOT_FOUND";
|
|
621
|
+
throw miss;
|
|
622
|
+
}
|
|
623
|
+
if (current.status !== "responded") {
|
|
624
|
+
var refused = new Error("quotes.customerAccept: refused — quote is " + current.status +
|
|
625
|
+
", only responded quotes can be accepted");
|
|
626
|
+
refused.code = "QUOTE_TRANSITION_REFUSED";
|
|
627
|
+
throw refused;
|
|
628
|
+
}
|
|
629
|
+
var ts = _now();
|
|
630
|
+
if (current.valid_until != null && Number(current.valid_until) <= ts) {
|
|
631
|
+
var expired = new Error("quotes.customerAccept: refused — quote expired at " +
|
|
632
|
+
Number(current.valid_until) + " (now " + ts + ")");
|
|
633
|
+
expired.code = "QUOTE_EXPIRED";
|
|
634
|
+
throw expired;
|
|
635
|
+
}
|
|
636
|
+
await query(
|
|
637
|
+
"UPDATE quotes SET status = 'accepted', accepted_at = ?1, " +
|
|
638
|
+
"accepted_by_customer = ?2, updated_at = ?1 WHERE id = ?3",
|
|
639
|
+
[ts, acceptedBy, quoteId],
|
|
640
|
+
);
|
|
641
|
+
return await _hydrated(quoteId);
|
|
642
|
+
},
|
|
643
|
+
|
|
644
|
+
// FSM: responded -> rejected. Customer declines. Reason is
|
|
645
|
+
// optional; an operator who needs a reason for analytics requires
|
|
646
|
+
// it at the storefront input layer.
|
|
647
|
+
customerReject: async function (input) {
|
|
648
|
+
if (!input || typeof input !== "object") {
|
|
649
|
+
throw new TypeError("quotes.customerReject: input object required");
|
|
650
|
+
}
|
|
651
|
+
var quoteId = _id(input.quote_id, "quote_id");
|
|
652
|
+
var reason = _optShortText(input.reject_reason, "reject_reason", MAX_REJECT_REASON_LEN);
|
|
653
|
+
var current = await _getQuoteRaw(quoteId);
|
|
654
|
+
if (!current) {
|
|
655
|
+
var miss = new Error("quotes.customerReject: quote " + quoteId + " not found");
|
|
656
|
+
miss.code = "QUOTE_NOT_FOUND";
|
|
657
|
+
throw miss;
|
|
658
|
+
}
|
|
659
|
+
if (current.status !== "responded") {
|
|
660
|
+
var refused = new Error("quotes.customerReject: refused — quote is " + current.status +
|
|
661
|
+
", only responded quotes can be rejected");
|
|
662
|
+
refused.code = "QUOTE_TRANSITION_REFUSED";
|
|
663
|
+
throw refused;
|
|
664
|
+
}
|
|
665
|
+
var ts = _now();
|
|
666
|
+
await query(
|
|
667
|
+
"UPDATE quotes SET status = 'rejected', rejected_at = ?1, " +
|
|
668
|
+
"reject_reason = ?2, updated_at = ?1 WHERE id = ?3",
|
|
669
|
+
[ts, reason, quoteId],
|
|
670
|
+
);
|
|
671
|
+
return await _hydrated(quoteId);
|
|
672
|
+
},
|
|
673
|
+
|
|
674
|
+
// FSM: requested|responded -> cancelled. Either side (operator or
|
|
675
|
+
// customer) pulls the quote before acceptance. Accepted quotes
|
|
676
|
+
// can't be cancelled through this surface — once both sides
|
|
677
|
+
// agreed, the unwind is a commercial conversation routed through
|
|
678
|
+
// the order/refund flow after `convertToOrder`. Terminal states
|
|
679
|
+
// refuse cancellation outright.
|
|
680
|
+
cancelQuote: async function (input) {
|
|
681
|
+
if (!input || typeof input !== "object") {
|
|
682
|
+
throw new TypeError("quotes.cancelQuote: input object required");
|
|
683
|
+
}
|
|
684
|
+
var quoteId = _id(input.quote_id, "quote_id");
|
|
685
|
+
var reason = _requiredShortText(input.cancel_reason, "cancel_reason", MAX_CANCEL_REASON_LEN);
|
|
686
|
+
var current = await _getQuoteRaw(quoteId);
|
|
687
|
+
if (!current) {
|
|
688
|
+
var miss = new Error("quotes.cancelQuote: quote " + quoteId + " not found");
|
|
689
|
+
miss.code = "QUOTE_NOT_FOUND";
|
|
690
|
+
throw miss;
|
|
691
|
+
}
|
|
692
|
+
if (current.status !== "requested" && current.status !== "responded") {
|
|
693
|
+
var refused = new Error("quotes.cancelQuote: refused — quote is " + current.status +
|
|
694
|
+
", only requested or responded quotes can be cancelled");
|
|
695
|
+
refused.code = "QUOTE_TRANSITION_REFUSED";
|
|
696
|
+
throw refused;
|
|
697
|
+
}
|
|
698
|
+
var ts = _now();
|
|
699
|
+
await query(
|
|
700
|
+
"UPDATE quotes SET status = 'cancelled', cancelled_at = ?1, " +
|
|
701
|
+
"cancel_reason = ?2, updated_at = ?1 WHERE id = ?3",
|
|
702
|
+
[ts, reason, quoteId],
|
|
703
|
+
);
|
|
704
|
+
return await _hydrated(quoteId);
|
|
705
|
+
},
|
|
706
|
+
|
|
707
|
+
// FSM: accepted -> converted. The accepted quote becomes a
|
|
708
|
+
// pending order at the quoted prices. When the `order` handle is
|
|
709
|
+
// wired, composes `order.createFromCart` to land the order and
|
|
710
|
+
// captures the resulting `order_id` on the quote row. When
|
|
711
|
+
// absent, the caller supplies `converted_order_id` directly so
|
|
712
|
+
// the operator pipeline retains the order-creation step.
|
|
713
|
+
convertToOrder: async function (input) {
|
|
714
|
+
if (!input || typeof input !== "object") {
|
|
715
|
+
throw new TypeError("quotes.convertToOrder: input object required");
|
|
716
|
+
}
|
|
717
|
+
var quoteId = _id(input.quote_id, "quote_id");
|
|
718
|
+
var current = await _getQuoteRaw(quoteId);
|
|
719
|
+
if (!current) {
|
|
720
|
+
var miss = new Error("quotes.convertToOrder: quote " + quoteId + " not found");
|
|
721
|
+
miss.code = "QUOTE_NOT_FOUND";
|
|
722
|
+
throw miss;
|
|
723
|
+
}
|
|
724
|
+
if (current.status !== "accepted") {
|
|
725
|
+
var refused = new Error("quotes.convertToOrder: refused — quote is " + current.status +
|
|
726
|
+
", only accepted quotes can be converted");
|
|
727
|
+
refused.code = "QUOTE_TRANSITION_REFUSED";
|
|
728
|
+
throw refused;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
var lines = await _getLinesRaw(quoteId);
|
|
732
|
+
var ts = _now();
|
|
733
|
+
var orderId;
|
|
734
|
+
|
|
735
|
+
if (orderHandle) {
|
|
736
|
+
// The order primitive requires cart_id + session_id as
|
|
737
|
+
// UUID-shape inputs. For a quote-driven order, the operator
|
|
738
|
+
// supplies a synthetic cart_id + session_id (typically the
|
|
739
|
+
// same UUID — the quote is acting as the "cart" here) so the
|
|
740
|
+
// order row carries a stable pointer back to the
|
|
741
|
+
// negotiation surface.
|
|
742
|
+
if (!input.ship_to || typeof input.ship_to !== "object") {
|
|
743
|
+
throw new TypeError("quotes.convertToOrder: ship_to object required when order handle is wired");
|
|
744
|
+
}
|
|
745
|
+
var cartId = _id(input.cart_id || quoteId, "cart_id");
|
|
746
|
+
var sessionId = _id(input.session_id || quoteId, "session_id");
|
|
747
|
+
|
|
748
|
+
var subtotal = 0;
|
|
749
|
+
var orderLines = [];
|
|
750
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
751
|
+
var line = lines[i];
|
|
752
|
+
var qty = Number(line.quantity);
|
|
753
|
+
var price = Number(line.unit_price_minor);
|
|
754
|
+
subtotal += qty * price;
|
|
755
|
+
orderLines.push({
|
|
756
|
+
// The order primitive validates variant_id as a UUID.
|
|
757
|
+
// The quote primitive doesn't track variant_id (a quote
|
|
758
|
+
// is SKU-grained, not variant-grained), so synthesize a
|
|
759
|
+
// deterministic UUID per (quote, line) tuple. The
|
|
760
|
+
// operator's variant resolution happens upstream of the
|
|
761
|
+
// quote surface; the order line snapshots the sku +
|
|
762
|
+
// qty + price the customer accepted.
|
|
763
|
+
variant_id: line.id,
|
|
764
|
+
sku: line.sku,
|
|
765
|
+
qty: qty,
|
|
766
|
+
unit_amount_minor: price,
|
|
767
|
+
unit_currency: current.currency,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
var shipping = current.shipping_minor == null ? 0 : Number(current.shipping_minor);
|
|
771
|
+
var tax = current.tax_minor == null ? 0 : Number(current.tax_minor);
|
|
772
|
+
var grand = subtotal + shipping + tax;
|
|
773
|
+
|
|
774
|
+
var createdOrder = await orderHandle.createFromCart({
|
|
775
|
+
cart_id: cartId,
|
|
776
|
+
session_id: sessionId,
|
|
777
|
+
customer_id: current.customer_id,
|
|
778
|
+
currency: current.currency,
|
|
779
|
+
subtotal_minor: subtotal,
|
|
780
|
+
discount_minor: 0,
|
|
781
|
+
tax_minor: tax,
|
|
782
|
+
shipping_minor: shipping,
|
|
783
|
+
grand_total_minor: grand,
|
|
784
|
+
ship_to: input.ship_to,
|
|
785
|
+
lines: orderLines,
|
|
786
|
+
});
|
|
787
|
+
orderId = createdOrder && createdOrder.id;
|
|
788
|
+
if (!orderId) {
|
|
789
|
+
throw new Error("quotes.convertToOrder: order handle did not return an order id");
|
|
790
|
+
}
|
|
791
|
+
} else {
|
|
792
|
+
// Caller supplies converted_order_id when no order handle is
|
|
793
|
+
// wired. This is the in-house-pipeline-owns-orders posture.
|
|
794
|
+
if (input.converted_order_id == null) {
|
|
795
|
+
throw new TypeError("quotes.convertToOrder: converted_order_id required when no order handle is wired");
|
|
796
|
+
}
|
|
797
|
+
if (typeof input.converted_order_id !== "string" || !input.converted_order_id.length) {
|
|
798
|
+
throw new TypeError("quotes.convertToOrder: converted_order_id must be a non-empty string");
|
|
799
|
+
}
|
|
800
|
+
orderId = input.converted_order_id;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
await query(
|
|
804
|
+
"UPDATE quotes SET status = 'converted', converted_at = ?1, " +
|
|
805
|
+
"converted_order_id = ?2, updated_at = ?1 WHERE id = ?3",
|
|
806
|
+
[ts, orderId, quoteId],
|
|
807
|
+
);
|
|
808
|
+
return await _hydrated(quoteId);
|
|
809
|
+
},
|
|
810
|
+
|
|
811
|
+
// Read a hydrated quote + its lines. Returns null on miss so the
|
|
812
|
+
// caller-handler maps cleanly to HTTP 404.
|
|
813
|
+
getQuote: async function (quoteId) {
|
|
814
|
+
var id = _id(quoteId, "quote_id");
|
|
815
|
+
return await _hydrated(id);
|
|
816
|
+
},
|
|
817
|
+
|
|
818
|
+
// List quotes for a given customer. Optional `status` narrows the
|
|
819
|
+
// result; `limit` caps the page size. Sorted by updated_at DESC
|
|
820
|
+
// so the customer's freshest activity surfaces first.
|
|
821
|
+
quotesForCustomer: async function (customerId, listOpts) {
|
|
822
|
+
var cid = _id(customerId, "customer_id");
|
|
823
|
+
listOpts = listOpts || {};
|
|
824
|
+
var hasStatus = listOpts.status !== undefined && listOpts.status !== null;
|
|
825
|
+
if (hasStatus) _status(listOpts.status);
|
|
826
|
+
var limit = listOpts.limit == null ? DEFAULT_LIMIT : listOpts.limit;
|
|
827
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIMIT) {
|
|
828
|
+
throw new TypeError("quotes.quotesForCustomer: limit must be 1..." + MAX_LIMIT);
|
|
829
|
+
}
|
|
830
|
+
var sql, params;
|
|
831
|
+
if (hasStatus) {
|
|
832
|
+
sql = "SELECT * FROM quotes WHERE customer_id = ?1 AND status = ?2 " +
|
|
833
|
+
"ORDER BY updated_at DESC, id DESC LIMIT ?3";
|
|
834
|
+
params = [cid, listOpts.status, limit];
|
|
835
|
+
} else {
|
|
836
|
+
sql = "SELECT * FROM quotes WHERE customer_id = ?1 " +
|
|
837
|
+
"ORDER BY updated_at DESC, id DESC LIMIT ?2";
|
|
838
|
+
params = [cid, limit];
|
|
839
|
+
}
|
|
840
|
+
var r = await query(sql, params);
|
|
841
|
+
var out = [];
|
|
842
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
843
|
+
var hydrated = _hydrateQuote(r.rows[i]);
|
|
844
|
+
hydrated.lines = (await _getLinesRaw(r.rows[i].id)).map(_hydrateLine);
|
|
845
|
+
out.push(hydrated);
|
|
846
|
+
}
|
|
847
|
+
return out;
|
|
848
|
+
},
|
|
849
|
+
|
|
850
|
+
// List quotes awaiting the operator's response. Sorted by
|
|
851
|
+
// created_at ASC so the longest-waiting quote lands at the top
|
|
852
|
+
// of the operator queue.
|
|
853
|
+
pendingResponse: async function (listOpts) {
|
|
854
|
+
listOpts = listOpts || {};
|
|
855
|
+
var limit = listOpts.limit == null ? DEFAULT_LIMIT : listOpts.limit;
|
|
856
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIMIT) {
|
|
857
|
+
throw new TypeError("quotes.pendingResponse: limit must be 1..." + MAX_LIMIT);
|
|
858
|
+
}
|
|
859
|
+
var r = await query(
|
|
860
|
+
"SELECT * FROM quotes WHERE status = 'requested' " +
|
|
861
|
+
"ORDER BY created_at ASC, id ASC LIMIT ?1",
|
|
862
|
+
[limit],
|
|
863
|
+
);
|
|
864
|
+
var out = [];
|
|
865
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
866
|
+
var hydrated = _hydrateQuote(r.rows[i]);
|
|
867
|
+
hydrated.lines = (await _getLinesRaw(r.rows[i].id)).map(_hydrateLine);
|
|
868
|
+
out.push(hydrated);
|
|
869
|
+
}
|
|
870
|
+
return out;
|
|
871
|
+
},
|
|
872
|
+
|
|
873
|
+
// List responded quotes whose `valid_until` has elapsed. A cron
|
|
874
|
+
// job walks the result and either fires `customerAccept` (rare —
|
|
875
|
+
// requires the customer-side timing) or transitions each row to
|
|
876
|
+
// expired via an operator-side step (out of scope here; the
|
|
877
|
+
// operator updates the row directly to expired via
|
|
878
|
+
// `markExpired` when they own the cron).
|
|
879
|
+
listExpired: async function (input) {
|
|
880
|
+
if (!input || typeof input !== "object") {
|
|
881
|
+
throw new TypeError("quotes.listExpired: input object required");
|
|
882
|
+
}
|
|
883
|
+
var asOf = _ts(input.as_of, "as_of");
|
|
884
|
+
var limit = input.limit == null ? DEFAULT_LIMIT : input.limit;
|
|
885
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIMIT) {
|
|
886
|
+
throw new TypeError("quotes.listExpired: limit must be 1..." + MAX_LIMIT);
|
|
887
|
+
}
|
|
888
|
+
var r = await query(
|
|
889
|
+
"SELECT * FROM quotes WHERE status = 'responded' " +
|
|
890
|
+
"AND valid_until IS NOT NULL AND valid_until <= ?1 " +
|
|
891
|
+
"ORDER BY valid_until ASC, id ASC LIMIT ?2",
|
|
892
|
+
[asOf, limit],
|
|
893
|
+
);
|
|
894
|
+
var out = [];
|
|
895
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
896
|
+
var hydrated = _hydrateQuote(r.rows[i]);
|
|
897
|
+
hydrated.lines = (await _getLinesRaw(r.rows[i].id)).map(_hydrateLine);
|
|
898
|
+
out.push(hydrated);
|
|
899
|
+
}
|
|
900
|
+
return out;
|
|
901
|
+
},
|
|
902
|
+
|
|
903
|
+
// Operator-side expiry flip. Walks a single quote whose
|
|
904
|
+
// valid_until has elapsed and moves it from responded -> expired.
|
|
905
|
+
// Refuses if the quote is not in the responded state or if the
|
|
906
|
+
// expiry has not yet passed.
|
|
907
|
+
markExpired: async function (input) {
|
|
908
|
+
if (!input || typeof input !== "object") {
|
|
909
|
+
throw new TypeError("quotes.markExpired: input object required");
|
|
910
|
+
}
|
|
911
|
+
var quoteId = _id(input.quote_id, "quote_id");
|
|
912
|
+
var asOf = _ts(input.as_of, "as_of");
|
|
913
|
+
var current = await _getQuoteRaw(quoteId);
|
|
914
|
+
if (!current) {
|
|
915
|
+
var miss = new Error("quotes.markExpired: quote " + quoteId + " not found");
|
|
916
|
+
miss.code = "QUOTE_NOT_FOUND";
|
|
917
|
+
throw miss;
|
|
918
|
+
}
|
|
919
|
+
if (current.status !== "responded") {
|
|
920
|
+
var refused = new Error("quotes.markExpired: refused — quote is " + current.status +
|
|
921
|
+
", only responded quotes can expire");
|
|
922
|
+
refused.code = "QUOTE_TRANSITION_REFUSED";
|
|
923
|
+
throw refused;
|
|
924
|
+
}
|
|
925
|
+
if (current.valid_until == null || Number(current.valid_until) > asOf) {
|
|
926
|
+
var notYet = new Error("quotes.markExpired: refused — quote " + quoteId +
|
|
927
|
+
" has not yet expired (valid_until=" + current.valid_until + ", as_of=" + asOf + ")");
|
|
928
|
+
notYet.code = "QUOTE_NOT_EXPIRED";
|
|
929
|
+
throw notYet;
|
|
930
|
+
}
|
|
931
|
+
await query(
|
|
932
|
+
"UPDATE quotes SET status = 'expired', updated_at = ?1 WHERE id = ?2",
|
|
933
|
+
[asOf, quoteId],
|
|
934
|
+
);
|
|
935
|
+
return await _hydrated(quoteId);
|
|
936
|
+
},
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
module.exports = {
|
|
941
|
+
create: create,
|
|
942
|
+
QUOTE_STATUSES: QUOTE_STATUSES,
|
|
943
|
+
TERMINAL_STATUSES: TERMINAL_STATUSES,
|
|
944
|
+
};
|