@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/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
+ };