@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.
- package/CHANGELOG.md +2 -0
- package/lib/carrier-rates.js +683 -0
- package/lib/cart-bulk-ops.js +711 -0
- package/lib/cms-blocks.js +651 -0
- package/lib/code-minter.js +535 -0
- package/lib/customer-import.js +590 -0
- package/lib/discount-analytics.js +548 -0
- package/lib/dunning.js +700 -0
- package/lib/gift-card-ledger.js +483 -0
- package/lib/index.js +11 -0
- package/lib/operator-audit-log.js +621 -0
- package/lib/search-facets.js +825 -0
- package/lib/sms-dispatcher.js +945 -0
- package/lib/storefront-forms.js +884 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|