@blamejs/blamejs-shop 0.0.60 → 0.0.61

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.
@@ -0,0 +1,711 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.cartBulkOps
4
+ * @title Cart bulk operations — B2B-style multi-line cart primitives
5
+ *
6
+ * @intro
7
+ * Composes on top of the existing `cart` primitive to add the
8
+ * bulk-and-overlay operations a wholesale / B2B storefront needs:
9
+ *
10
+ * addLines — insert N lines in one call with per-batch
11
+ * atomicity (one bad SKU rolls back the whole
12
+ * batch, nothing is half-applied).
13
+ * replaceLines — overwrite the cart's line set in one call.
14
+ * clearLines — drop every line.
15
+ * priceListApply — rewrite the cart's `unit_amount_minor`
16
+ * values from a wholesale overlay table.
17
+ * reorder — clone the lines of a previous order into a
18
+ * cart (SKUs that no longer resolve are
19
+ * skipped with their reason captured).
20
+ * splitCart — fan the cart out into N child carts grouped
21
+ * by a caller-supplied grouper (vendor /
22
+ * category / drop-ship supplier). The source
23
+ * cart is marked `abandoned` once split.
24
+ * quoteForCart — produce a printable customer-facing quote
25
+ * (lines + per-line totals + cart totals)
26
+ * without mutating the cart.
27
+ *
28
+ * Atomicity model: every "bulk" call pre-flight-validates the
29
+ * entire input batch (every SKU resolves to a live variant, every
30
+ * qty is in range, every price is available) BEFORE writing
31
+ * anything. Validation failures throw with a `lines[N]: ...`
32
+ * message that identifies the offending row. This is the same
33
+ * "fail-the-batch on the first bad row" pattern the framework's
34
+ * `translations.bulkSet` uses — the database write only starts
35
+ * after every row has cleared its checks.
36
+ *
37
+ * The factory accepts:
38
+ * query — the same query function the rest of the shop
39
+ * primitives consume (defaults to b.externalDb).
40
+ * cart — REQUIRED. A cart-primitive handle. The bulk ops
41
+ * call into it for status checks / current cart
42
+ * reads; they don't bypass its lifecycle gates.
43
+ * catalog — REQUIRED. Resolves SKU → variant + currency
44
+ * price (the same handle the cart primitive
45
+ * already needs).
46
+ * customers — OPTIONAL. When present, `priceListApply` can
47
+ * resolve the price list assigned to a customer
48
+ * when the caller omits the explicit slug.
49
+ * Operators without the customers primitive in
50
+ * scope pass the slug explicitly on every call.
51
+ * lineGrouper — OPTIONAL. `(line, splitBy) => groupKey`. Called
52
+ * by `splitCart` to map each line to the bucket
53
+ * it belongs in. When absent, splitCart refuses —
54
+ * no built-in heuristic is honest enough to map
55
+ * a SKU to a vendor / category / supplier without
56
+ * a real lookup. Compose this against whatever
57
+ * the operator's data model uses (variants
58
+ * options_json axes, a separate vendors table,
59
+ * etc.).
60
+ */
61
+
62
+ var bShop;
63
+ function _b() {
64
+ if (!bShop) bShop = require("./index");
65
+ return bShop.framework;
66
+ }
67
+
68
+ var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,198}[a-z0-9])?$/;
69
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
70
+ var CURRENCY_RE = /^[A-Z]{3}$/;
71
+ var MAX_BULK = 500;
72
+ var MAX_QTY = 9999;
73
+ var SPLIT_BY = Object.freeze(["vendor", "category", "drop_ship_supplier"]);
74
+
75
+ // ---- validators ---------------------------------------------------------
76
+
77
+ function _uuid(s, label) {
78
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
79
+ catch (e) { throw new TypeError("cartBulkOps: " + label + " — " + (e && e.message || "invalid UUID")); }
80
+ }
81
+ function _slug(s, label) {
82
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
83
+ throw new TypeError("cartBulkOps: " + label + " must match /^[a-z0-9][a-z0-9-]*[a-z0-9]?$/");
84
+ }
85
+ }
86
+ function _sku(s, label) {
87
+ if (typeof s !== "string" || !SKU_RE.test(s)) {
88
+ throw new TypeError("cartBulkOps: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (≤ 128 chars)");
89
+ }
90
+ }
91
+ function _qty(n, label) {
92
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_QTY) {
93
+ throw new TypeError("cartBulkOps: " + label + " must be a positive integer ≤ " + MAX_QTY);
94
+ }
95
+ }
96
+ function _splitBy(s) {
97
+ if (SPLIT_BY.indexOf(s) === -1) {
98
+ throw new TypeError("cartBulkOps: split_by must be one of " + SPLIT_BY.join(", "));
99
+ }
100
+ }
101
+ function _linesArray(lines) {
102
+ if (!Array.isArray(lines)) {
103
+ throw new TypeError("cartBulkOps: lines must be an array");
104
+ }
105
+ if (lines.length > MAX_BULK) {
106
+ throw new TypeError("cartBulkOps: lines must be ≤ " + MAX_BULK + " rows per call");
107
+ }
108
+ }
109
+
110
+ function _now() { return Date.now(); }
111
+
112
+ // ---- factory ------------------------------------------------------------
113
+
114
+ function create(opts) {
115
+ opts = opts || {};
116
+ if (!opts.cart) throw new TypeError("cartBulkOps.create: cart handle required");
117
+ if (!opts.catalog) throw new TypeError("cartBulkOps.create: catalog handle required");
118
+ var cart = opts.cart;
119
+ var catalog = opts.catalog;
120
+ var customers = opts.customers || null;
121
+ var grouper = opts.lineGrouper || null;
122
+ var query = opts.query;
123
+ if (!query) {
124
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
125
+ }
126
+
127
+ // Pre-flight: resolve every {sku, qty} row to {variant, sku, qty,
128
+ // unit_amount_minor, unit_currency}. Throws on the first row that
129
+ // fails — caller sees the index of the bad row in the message so
130
+ // the offending input is identifiable without a re-walk.
131
+ async function _resolveBatch(cartRow, lines, label) {
132
+ _linesArray(lines);
133
+ var resolved = [];
134
+ for (var i = 0; i < lines.length; i += 1) {
135
+ var l = lines[i];
136
+ if (!l || typeof l !== "object") {
137
+ throw new TypeError("cartBulkOps." + label + ": lines[" + i + "] must be an object");
138
+ }
139
+ try { _sku(l.sku, "lines[" + i + "].sku"); }
140
+ catch (e) { throw e; }
141
+ _qty(l.qty, "lines[" + i + "].qty");
142
+ var variant = await catalog.variants.bySku(l.sku);
143
+ if (!variant) {
144
+ throw new TypeError("cartBulkOps." + label + ": lines[" + i + "].sku — variant '" + l.sku + "' not found");
145
+ }
146
+ var unitAmount;
147
+ var unitCurrency;
148
+ if (l.unit_amount_minor != null && l.unit_currency != null) {
149
+ if (!Number.isInteger(l.unit_amount_minor) || l.unit_amount_minor < 0) {
150
+ throw new TypeError("cartBulkOps." + label + ": lines[" + i + "].unit_amount_minor must be a non-negative integer");
151
+ }
152
+ if (typeof l.unit_currency !== "string" || !CURRENCY_RE.test(l.unit_currency)) {
153
+ throw new TypeError("cartBulkOps." + label + ": lines[" + i + "].unit_currency must be a 3-letter ISO 4217 code");
154
+ }
155
+ unitAmount = l.unit_amount_minor;
156
+ unitCurrency = l.unit_currency;
157
+ } else {
158
+ var price = await catalog.prices.current(variant.id, cartRow.currency);
159
+ if (!price) {
160
+ throw new TypeError("cartBulkOps." + label + ": lines[" + i + "].sku — no current price for '" + l.sku + "' in " + cartRow.currency);
161
+ }
162
+ unitAmount = price.amount_minor;
163
+ unitCurrency = price.currency;
164
+ }
165
+ resolved.push({
166
+ variant: variant,
167
+ sku: variant.sku,
168
+ qty: l.qty,
169
+ unit_amount_minor: unitAmount,
170
+ unit_currency: unitCurrency,
171
+ });
172
+ }
173
+ return resolved;
174
+ }
175
+
176
+ async function _loadCart(cartId) {
177
+ var c = await cart.get(cartId);
178
+ if (!c) throw new TypeError("cartBulkOps: cart " + cartId + " not found");
179
+ if (c.status !== "active") {
180
+ throw new TypeError("cartBulkOps: cart status is " + c.status + ", cannot modify");
181
+ }
182
+ return c;
183
+ }
184
+
185
+ async function _insertLineDirect(cartId, resolved, ts) {
186
+ var id = _b().uuid.v7();
187
+ await query(
188
+ "INSERT INTO cart_lines (id, cart_id, variant_id, sku, qty, unit_amount_minor, unit_currency, added_at, updated_at) " +
189
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)",
190
+ [id, cartId, resolved.variant.id, resolved.sku, resolved.qty, resolved.unit_amount_minor, resolved.unit_currency, ts],
191
+ );
192
+ return {
193
+ id: id,
194
+ cart_id: cartId,
195
+ variant_id: resolved.variant.id,
196
+ sku: resolved.sku,
197
+ qty: resolved.qty,
198
+ unit_amount_minor: resolved.unit_amount_minor,
199
+ unit_currency: resolved.unit_currency,
200
+ added_at: ts,
201
+ updated_at: ts,
202
+ };
203
+ }
204
+
205
+ return {
206
+ SPLIT_BY: SPLIT_BY,
207
+ MAX_BULK: MAX_BULK,
208
+
209
+ // Atomic bulk add. Every line in `lines` is pre-flight resolved
210
+ // against catalog (variant lookup + price snapshot). If any row
211
+ // fails resolution the whole batch is refused — no lines are
212
+ // written. Successful calls collapse duplicates (same SKU twice
213
+ // in the batch, OR same SKU already on the cart) by summing qty.
214
+ addLines: async function (input) {
215
+ if (!input || typeof input !== "object") throw new TypeError("cartBulkOps.addLines: input object required");
216
+ _uuid(input.cart_id, "cart_id");
217
+ var cartRow = await _loadCart(input.cart_id);
218
+ var resolved = await _resolveBatch(cartRow, input.lines, "addLines");
219
+ if (resolved.length === 0) return { written: 0, lines: [] };
220
+
221
+ // Collapse duplicates within the batch by SKU.
222
+ var bySku = {};
223
+ var order = [];
224
+ for (var i = 0; i < resolved.length; i += 1) {
225
+ var r = resolved[i];
226
+ if (bySku[r.sku]) {
227
+ bySku[r.sku].qty += r.qty;
228
+ } else {
229
+ bySku[r.sku] = r;
230
+ order.push(r.sku);
231
+ }
232
+ _qty(bySku[r.sku].qty, "lines[" + i + "].qty");
233
+ }
234
+
235
+ // Load existing lines once so per-SKU upsert doesn't query in
236
+ // a loop. Map by variant_id (the cart_lines UNIQUE key).
237
+ var existingRows = (await query(
238
+ "SELECT * FROM cart_lines WHERE cart_id = ?1",
239
+ [input.cart_id],
240
+ )).rows;
241
+ var existingByVariant = {};
242
+ for (var ei = 0; ei < existingRows.length; ei += 1) {
243
+ existingByVariant[existingRows[ei].variant_id] = existingRows[ei];
244
+ }
245
+
246
+ var ts = _now();
247
+ var written = [];
248
+ for (var oi = 0; oi < order.length; oi += 1) {
249
+ var key = order[oi];
250
+ var rr = bySku[key];
251
+ var existing = existingByVariant[rr.variant.id];
252
+ if (existing) {
253
+ var newQty = existing.qty + rr.qty;
254
+ _qty(newQty, "lines[" + oi + "].qty (summed with existing)");
255
+ await query(
256
+ "UPDATE cart_lines SET qty = ?1, updated_at = ?2 WHERE id = ?3",
257
+ [newQty, ts, existing.id],
258
+ );
259
+ written.push(Object.assign({}, existing, { qty: newQty, updated_at: ts }));
260
+ } else {
261
+ written.push(await _insertLineDirect(input.cart_id, rr, ts));
262
+ }
263
+ }
264
+ await query("UPDATE carts SET updated_at = ?1 WHERE id = ?2", [ts, input.cart_id]);
265
+ return { written: written.length, lines: written };
266
+ },
267
+
268
+ // Overwrite the cart's line set. Same atomicity story: every
269
+ // input row is resolved before the existing rows are dropped,
270
+ // so a bad SKU never empties a cart and leaves it half-filled.
271
+ replaceLines: async function (input) {
272
+ if (!input || typeof input !== "object") throw new TypeError("cartBulkOps.replaceLines: input object required");
273
+ _uuid(input.cart_id, "cart_id");
274
+ var cartRow = await _loadCart(input.cart_id);
275
+ var resolved = await _resolveBatch(cartRow, input.lines, "replaceLines");
276
+ // Collapse duplicates within the replacement set by SKU.
277
+ var bySku = {};
278
+ var order = [];
279
+ for (var i = 0; i < resolved.length; i += 1) {
280
+ var r = resolved[i];
281
+ if (bySku[r.sku]) {
282
+ bySku[r.sku].qty += r.qty;
283
+ } else {
284
+ bySku[r.sku] = r;
285
+ order.push(r.sku);
286
+ }
287
+ _qty(bySku[r.sku].qty, "lines[" + i + "].qty");
288
+ }
289
+ var ts = _now();
290
+ await query("DELETE FROM cart_lines WHERE cart_id = ?1", [input.cart_id]);
291
+ var written = [];
292
+ for (var oi = 0; oi < order.length; oi += 1) {
293
+ written.push(await _insertLineDirect(input.cart_id, bySku[order[oi]], ts));
294
+ }
295
+ await query("UPDATE carts SET updated_at = ?1 WHERE id = ?2", [ts, input.cart_id]);
296
+ return { written: written.length, lines: written };
297
+ },
298
+
299
+ clearLines: async function (input) {
300
+ if (!input || typeof input !== "object") throw new TypeError("cartBulkOps.clearLines: input object required");
301
+ _uuid(input.cart_id, "cart_id");
302
+ await _loadCart(input.cart_id);
303
+ var ts = _now();
304
+ var r = await query("DELETE FROM cart_lines WHERE cart_id = ?1", [input.cart_id]);
305
+ await query("UPDATE carts SET updated_at = ?1 WHERE id = ?2", [ts, input.cart_id]);
306
+ return { removed: r.rowCount };
307
+ },
308
+
309
+ // Apply a wholesale price list to the cart. Each existing cart
310
+ // line whose SKU appears in `price_list_members` has its
311
+ // `unit_amount_minor` rewritten to the override. Lines whose
312
+ // SKU is not in the overlay are left untouched (the retail
313
+ // snapshot they were added with stays put). Currency mismatch
314
+ // between the overlay and the cart is refused.
315
+ //
316
+ // Resolution order for the slug:
317
+ // 1. input.price_list_slug (explicit)
318
+ // 2. price_list_assignments[customer_id] (when the caller
319
+ // passes customer_id and the assignment row exists)
320
+ priceListApply: async function (input) {
321
+ if (!input || typeof input !== "object") throw new TypeError("cartBulkOps.priceListApply: input object required");
322
+ _uuid(input.cart_id, "cart_id");
323
+ var cartRow = await _loadCart(input.cart_id);
324
+
325
+ var slug = null;
326
+ if (input.price_list_slug != null) {
327
+ _slug(input.price_list_slug, "price_list_slug");
328
+ slug = input.price_list_slug;
329
+ } else if (input.customer_id != null) {
330
+ _uuid(input.customer_id, "customer_id");
331
+ var assn = (await query(
332
+ "SELECT price_list_slug FROM price_list_assignments WHERE customer_id = ?1",
333
+ [input.customer_id],
334
+ )).rows[0];
335
+ if (!assn) {
336
+ throw new TypeError("cartBulkOps.priceListApply: customer " + input.customer_id + " has no price-list assignment");
337
+ }
338
+ slug = assn.price_list_slug;
339
+ } else {
340
+ throw new TypeError("cartBulkOps.priceListApply: price_list_slug or customer_id required");
341
+ }
342
+ // Touch the customers handle when supplied so an operator
343
+ // wiring it through gets a chance to validate that the
344
+ // customer exists. Optional dependency — refused-silent when
345
+ // the handle is not present is the documented behaviour.
346
+ if (customers && input.customer_id) {
347
+ var custRow = await customers.get(input.customer_id);
348
+ if (!custRow) {
349
+ throw new TypeError("cartBulkOps.priceListApply: customer " + input.customer_id + " not found");
350
+ }
351
+ }
352
+
353
+ var list = (await query(
354
+ "SELECT * FROM price_lists WHERE slug = ?1",
355
+ [slug],
356
+ )).rows[0];
357
+ if (!list) {
358
+ throw new TypeError("cartBulkOps.priceListApply: price list '" + slug + "' not found");
359
+ }
360
+ if (list.archived_at != null || list.active === 0) {
361
+ throw new TypeError("cartBulkOps.priceListApply: price list '" + slug + "' is not active");
362
+ }
363
+ if (list.currency !== cartRow.currency) {
364
+ throw new TypeError("cartBulkOps.priceListApply: price list currency " + list.currency + " does not match cart currency " + cartRow.currency);
365
+ }
366
+
367
+ var members = (await query(
368
+ "SELECT sku, override_unit_minor FROM price_list_members WHERE price_list_slug = ?1",
369
+ [slug],
370
+ )).rows;
371
+ var bySku = {};
372
+ for (var mi = 0; mi < members.length; mi += 1) {
373
+ bySku[members[mi].sku] = members[mi].override_unit_minor;
374
+ }
375
+
376
+ var lines = await cart.listLines(input.cart_id);
377
+ var ts = _now();
378
+ var overridden = 0;
379
+ var skipped = 0;
380
+ for (var li = 0; li < lines.length; li += 1) {
381
+ var line = lines[li];
382
+ if (Object.prototype.hasOwnProperty.call(bySku, line.sku)) {
383
+ await query(
384
+ "UPDATE cart_lines SET unit_amount_minor = ?1, updated_at = ?2 WHERE id = ?3",
385
+ [bySku[line.sku], ts, line.id],
386
+ );
387
+ overridden += 1;
388
+ } else {
389
+ skipped += 1;
390
+ }
391
+ }
392
+ if (overridden > 0) {
393
+ await query("UPDATE carts SET updated_at = ?1 WHERE id = ?2", [ts, input.cart_id]);
394
+ }
395
+ return {
396
+ price_list_slug: slug,
397
+ overridden: overridden,
398
+ skipped: skipped,
399
+ };
400
+ },
401
+
402
+ // Clone a prior order's lines into a cart. Lines whose SKU no
403
+ // longer resolves to a live variant are skipped — they appear
404
+ // in the returned `skipped` array with their reason so the
405
+ // storefront can render "we couldn't add 2 items from your
406
+ // last order" affordances.
407
+ reorder: async function (input) {
408
+ if (!input || typeof input !== "object") throw new TypeError("cartBulkOps.reorder: input object required");
409
+ _uuid(input.cart_id, "cart_id");
410
+ _uuid(input.source_order_id, "source_order_id");
411
+ var cartRow = await _loadCart(input.cart_id);
412
+
413
+ var sourceLines = (await query(
414
+ "SELECT * FROM order_lines WHERE order_id = ?1",
415
+ [input.source_order_id],
416
+ )).rows;
417
+ if (sourceLines.length === 0) {
418
+ throw new TypeError("cartBulkOps.reorder: source order " + input.source_order_id + " has no lines");
419
+ }
420
+
421
+ var added = [];
422
+ var skipped = [];
423
+ var ts = _now();
424
+ for (var i = 0; i < sourceLines.length; i += 1) {
425
+ var sl = sourceLines[i];
426
+ var variant = await catalog.variants.bySku(sl.sku);
427
+ if (!variant) {
428
+ skipped.push({ sku: sl.sku, qty: sl.qty, reason: "variant-not-found" });
429
+ continue;
430
+ }
431
+ if (variant.archived_at != null) {
432
+ skipped.push({ sku: sl.sku, qty: sl.qty, reason: "variant-archived" });
433
+ continue;
434
+ }
435
+ var price = await catalog.prices.current(variant.id, cartRow.currency);
436
+ if (!price) {
437
+ skipped.push({ sku: sl.sku, qty: sl.qty, reason: "no-current-price" });
438
+ continue;
439
+ }
440
+ // Upsert into the cart by variant_id.
441
+ var existing = (await query(
442
+ "SELECT * FROM cart_lines WHERE cart_id = ?1 AND variant_id = ?2 LIMIT 1",
443
+ [input.cart_id, variant.id],
444
+ )).rows[0];
445
+ if (existing) {
446
+ var newQty = existing.qty + sl.qty;
447
+ if (newQty > MAX_QTY) newQty = MAX_QTY;
448
+ await query(
449
+ "UPDATE cart_lines SET qty = ?1, updated_at = ?2 WHERE id = ?3",
450
+ [newQty, ts, existing.id],
451
+ );
452
+ added.push(Object.assign({}, existing, { qty: newQty, updated_at: ts }));
453
+ } else {
454
+ added.push(await _insertLineDirect(input.cart_id, {
455
+ variant: variant,
456
+ sku: variant.sku,
457
+ qty: sl.qty,
458
+ unit_amount_minor: price.amount_minor,
459
+ unit_currency: price.currency,
460
+ }, ts));
461
+ }
462
+ }
463
+ if (added.length > 0) {
464
+ await query("UPDATE carts SET updated_at = ?1 WHERE id = ?2", [ts, input.cart_id]);
465
+ }
466
+ return {
467
+ source_order_id: input.source_order_id,
468
+ added: added.length,
469
+ skipped: skipped,
470
+ lines: added,
471
+ };
472
+ },
473
+
474
+ // Split a cart by a caller-supplied grouper into N child carts.
475
+ // The grouper is required (no built-in heuristic for vendor /
476
+ // category / supplier — the operator's data model is what
477
+ // resolves the mapping). The source cart is marked `abandoned`
478
+ // on success so the shopper isn't left with both the source
479
+ // and the children active at once.
480
+ splitCart: async function (input) {
481
+ if (!input || typeof input !== "object") throw new TypeError("cartBulkOps.splitCart: input object required");
482
+ _uuid(input.cart_id, "cart_id");
483
+ _splitBy(input.split_by);
484
+ if (!grouper) {
485
+ throw new TypeError("cartBulkOps.splitCart: opts.lineGrouper required at create() time to split a cart");
486
+ }
487
+ var cartRow = await _loadCart(input.cart_id);
488
+ var lines = await cart.listLines(input.cart_id);
489
+ if (lines.length === 0) {
490
+ throw new TypeError("cartBulkOps.splitCart: cart has no lines to split");
491
+ }
492
+
493
+ // Pre-flight: every line must group to a non-empty string.
494
+ var byGroup = {};
495
+ var groupOrder = [];
496
+ for (var i = 0; i < lines.length; i += 1) {
497
+ var g = await grouper(lines[i], input.split_by);
498
+ if (typeof g !== "string" || !g.length) {
499
+ throw new TypeError("cartBulkOps.splitCart: lineGrouper returned a non-string / empty value for lines[" + i + "].sku=" + lines[i].sku);
500
+ }
501
+ if (!byGroup[g]) {
502
+ byGroup[g] = [];
503
+ groupOrder.push(g);
504
+ }
505
+ byGroup[g].push(lines[i]);
506
+ }
507
+ if (groupOrder.length < 2) {
508
+ // Splitting into one group is a no-op that mutates state
509
+ // (would abandon the source for no reason). Refuse instead.
510
+ throw new TypeError("cartBulkOps.splitCart: every line grouped to '" + groupOrder[0] + "' — nothing to split");
511
+ }
512
+
513
+ // Mint one child cart per group, copy lines, then mark the
514
+ // source cart abandoned. Each child cart gets a fresh
515
+ // session_id derived from a UUIDv7 so the partial-unique
516
+ // active-session index doesn't refuse them (the source cart
517
+ // is abandoned before the children are inserted, but using
518
+ // a fresh session_id per child is the right shape — child
519
+ // carts are not the shopper's working cart, they're scratch
520
+ // carts for downstream fulfillment / split-order quoting).
521
+ var ts = _now();
522
+ // Mark source abandoned first so the active-session index is
523
+ // free for any reuse.
524
+ await query(
525
+ "UPDATE carts SET status = 'abandoned', updated_at = ?1 WHERE id = ?2",
526
+ [ts, input.cart_id],
527
+ );
528
+ var children = [];
529
+ for (var gi = 0; gi < groupOrder.length; gi += 1) {
530
+ var groupKey = groupOrder[gi];
531
+ var childId = _b().uuid.v7();
532
+ var childSession = "sess_" + _b().uuid.v7().replace(/-/g, "").slice(0, 24);
533
+ var childExpires = ts + 24 * 60 * 60 * 1000;
534
+ await query(
535
+ "INSERT INTO carts (id, session_id, customer_id, currency, status, created_at, updated_at, expires_at) " +
536
+ "VALUES (?1, ?2, ?3, ?4, 'active', ?5, ?5, ?6)",
537
+ [childId, childSession, cartRow.customer_id, cartRow.currency, ts, childExpires],
538
+ );
539
+ var groupLines = byGroup[groupKey];
540
+ for (var li = 0; li < groupLines.length; li += 1) {
541
+ var l = groupLines[li];
542
+ await query(
543
+ "INSERT INTO cart_lines (id, cart_id, variant_id, sku, qty, unit_amount_minor, unit_currency, added_at, updated_at) " +
544
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)",
545
+ [_b().uuid.v7(), childId, l.variant_id, l.sku, l.qty, l.unit_amount_minor, l.unit_currency, ts],
546
+ );
547
+ }
548
+ children.push({
549
+ cart_id: childId,
550
+ group_key: groupKey,
551
+ line_count: groupLines.length,
552
+ });
553
+ }
554
+ return {
555
+ split_by: input.split_by,
556
+ source_cart_id: input.cart_id,
557
+ children: children,
558
+ };
559
+ },
560
+
561
+ // Render a customer-facing quote. Read-only — never mutates.
562
+ // The shape is deliberately operator-renderable (HTML / PDF /
563
+ // plain-text) without re-reading from the database. Totals are
564
+ // computed in minor units; currency formatting is the
565
+ // storefront's job, not the primitive's.
566
+ quoteForCart: async function (input) {
567
+ if (!input || typeof input !== "object") throw new TypeError("cartBulkOps.quoteForCart: input object required");
568
+ _uuid(input.cart_id, "cart_id");
569
+ var cartRow = await cart.get(input.cart_id);
570
+ if (!cartRow) throw new TypeError("cartBulkOps.quoteForCart: cart " + input.cart_id + " not found");
571
+ var lines = await cart.listLines(input.cart_id);
572
+
573
+ var quoteLines = [];
574
+ var subtotalMinor = 0;
575
+ var lineCount = 0;
576
+ var unitCount = 0;
577
+ for (var i = 0; i < lines.length; i += 1) {
578
+ var l = lines[i];
579
+ if (l.unit_currency !== cartRow.currency) {
580
+ // A mixed-currency cart is a data integrity failure; the
581
+ // quote should surface it loudly rather than silently
582
+ // pretend the sum is meaningful.
583
+ throw new TypeError("cartBulkOps.quoteForCart: cart line " + l.id + " currency " + l.unit_currency + " does not match cart currency " + cartRow.currency);
584
+ }
585
+ var lineTotal = l.qty * l.unit_amount_minor;
586
+ subtotalMinor += lineTotal;
587
+ lineCount += 1;
588
+ unitCount += l.qty;
589
+ quoteLines.push({
590
+ line_id: l.id,
591
+ sku: l.sku,
592
+ qty: l.qty,
593
+ unit_amount_minor: l.unit_amount_minor,
594
+ line_total_minor: lineTotal,
595
+ });
596
+ }
597
+
598
+ return {
599
+ cart_id: cartRow.id,
600
+ customer_id: cartRow.customer_id,
601
+ currency: cartRow.currency,
602
+ generated_at: _now(),
603
+ lines: quoteLines,
604
+ line_count: lineCount,
605
+ unit_count: unitCount,
606
+ subtotal_minor: subtotalMinor,
607
+ };
608
+ },
609
+
610
+ // ---- price-list admin --------------------------------------------
611
+ //
612
+ // The bulk-ops primitive owns the small admin surface for the
613
+ // wholesale overlay tables since they're its domain — no other
614
+ // primitive reads or writes them. Operators wire these into
615
+ // their admin UI; the storefront never sees the raw write
616
+ // surface.
617
+ priceLists: {
618
+ create: async function (input) {
619
+ if (!input || typeof input !== "object") throw new TypeError("cartBulkOps.priceLists.create: input object required");
620
+ _slug(input.slug, "slug");
621
+ if (typeof input.title !== "string" || !input.title.length) {
622
+ throw new TypeError("cartBulkOps.priceLists.create: title required (non-empty string)");
623
+ }
624
+ if (typeof input.currency !== "string" || !CURRENCY_RE.test(input.currency)) {
625
+ throw new TypeError("cartBulkOps.priceLists.create: currency must be a 3-letter ISO 4217 code");
626
+ }
627
+ var desc = input.description == null ? "" : String(input.description);
628
+ var ts = _now();
629
+ await query(
630
+ "INSERT INTO price_lists (slug, title, description, currency, active, archived_at, created_at, updated_at) " +
631
+ "VALUES (?1, ?2, ?3, ?4, 1, NULL, ?5, ?5)",
632
+ [input.slug, input.title, desc, input.currency, ts],
633
+ );
634
+ return (await query("SELECT * FROM price_lists WHERE slug = ?1", [input.slug])).rows[0];
635
+ },
636
+ setMember: async function (input) {
637
+ if (!input || typeof input !== "object") throw new TypeError("cartBulkOps.priceLists.setMember: input object required");
638
+ _slug(input.price_list_slug, "price_list_slug");
639
+ _sku(input.sku, "sku");
640
+ if (!Number.isInteger(input.override_unit_minor) || input.override_unit_minor < 0) {
641
+ throw new TypeError("cartBulkOps.priceLists.setMember: override_unit_minor must be a non-negative integer");
642
+ }
643
+ var qb = input.qty_break_minor == null ? null : input.qty_break_minor;
644
+ if (qb != null && (!Number.isInteger(qb) || qb < 0)) {
645
+ throw new TypeError("cartBulkOps.priceLists.setMember: qty_break_minor must be a non-negative integer or null");
646
+ }
647
+ var ts = _now();
648
+ var existing = (await query(
649
+ "SELECT id FROM price_list_members WHERE price_list_slug = ?1 AND sku = ?2",
650
+ [input.price_list_slug, input.sku],
651
+ )).rows[0];
652
+ if (existing) {
653
+ await query(
654
+ "UPDATE price_list_members SET override_unit_minor = ?1, qty_break_minor = ?2 WHERE id = ?3",
655
+ [input.override_unit_minor, qb, existing.id],
656
+ );
657
+ return (await query("SELECT * FROM price_list_members WHERE id = ?1", [existing.id])).rows[0];
658
+ }
659
+ var id = _b().uuid.v7();
660
+ await query(
661
+ "INSERT INTO price_list_members (id, price_list_slug, sku, override_unit_minor, qty_break_minor, created_at) " +
662
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
663
+ [id, input.price_list_slug, input.sku, input.override_unit_minor, qb, ts],
664
+ );
665
+ return (await query("SELECT * FROM price_list_members WHERE id = ?1", [id])).rows[0];
666
+ },
667
+ assign: async function (input) {
668
+ if (!input || typeof input !== "object") throw new TypeError("cartBulkOps.priceLists.assign: input object required");
669
+ _uuid(input.customer_id, "customer_id");
670
+ _slug(input.price_list_slug, "price_list_slug");
671
+ var ts = _now();
672
+ // Upsert — re-assigning a customer to a different list
673
+ // overwrites the prior row in place.
674
+ var existing = (await query(
675
+ "SELECT customer_id FROM price_list_assignments WHERE customer_id = ?1",
676
+ [input.customer_id],
677
+ )).rows[0];
678
+ if (existing) {
679
+ await query(
680
+ "UPDATE price_list_assignments SET price_list_slug = ?1 WHERE customer_id = ?2",
681
+ [input.price_list_slug, input.customer_id],
682
+ );
683
+ } else {
684
+ await query(
685
+ "INSERT INTO price_list_assignments (customer_id, price_list_slug, created_at) " +
686
+ "VALUES (?1, ?2, ?3)",
687
+ [input.customer_id, input.price_list_slug, ts],
688
+ );
689
+ }
690
+ return (await query(
691
+ "SELECT * FROM price_list_assignments WHERE customer_id = ?1",
692
+ [input.customer_id],
693
+ )).rows[0];
694
+ },
695
+ listMembers: async function (slug) {
696
+ _slug(slug, "slug");
697
+ var r = await query(
698
+ "SELECT * FROM price_list_members WHERE price_list_slug = ?1 ORDER BY sku ASC",
699
+ [slug],
700
+ );
701
+ return r.rows;
702
+ },
703
+ },
704
+ };
705
+ }
706
+
707
+ module.exports = {
708
+ create: create,
709
+ SPLIT_BY: SPLIT_BY,
710
+ MAX_BULK: MAX_BULK,
711
+ };