@blamejs/blamejs-shop 0.0.56 → 0.0.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/lib/backorder.js +452 -0
- package/lib/bundles.js +587 -0
- package/lib/fraud-screen.js +808 -0
- package/lib/gift-options.js +596 -0
- package/lib/index.js +15 -0
- package/lib/inventory-locations.js +774 -0
- package/lib/order-export.js +724 -0
- package/lib/order-notes.js +563 -0
- package/lib/payment-methods.js +522 -0
- package/lib/print-on-demand.js +709 -0
- package/lib/quantity-discounts.js +781 -0
- package/lib/sales-reports.js +843 -0
- package/lib/save-for-later.js +667 -0
- package/lib/subscription-controls.js +723 -0
- package/lib/support-tickets.js +898 -0
- package/lib/variants.js +726 -0
- package/package.json +1 -1
package/lib/bundles.js
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.bundles
|
|
4
|
+
* @title Bundles primitive — virtual composite SKUs (kit products)
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* A bundle is a virtual SKU that resolves to N child SKUs with
|
|
8
|
+
* integer quantity multipliers. The storefront sells the bundle as
|
|
9
|
+
* a single line item; the cart-line resolution layer composes
|
|
10
|
+
* `bundles.expand()` to convert that line into the underlying
|
|
11
|
+
* component lines so inventory deduction, fulfillment picking and
|
|
12
|
+
* tax/shipping see the real components.
|
|
13
|
+
*
|
|
14
|
+
* Composition rules:
|
|
15
|
+
*
|
|
16
|
+
* - Component SKUs MUST exist in the catalog at define time. The
|
|
17
|
+
* primitive composes `catalog.variants.bySku(sku)` to verify;
|
|
18
|
+
* an unknown SKU throws synchronously.
|
|
19
|
+
*
|
|
20
|
+
* - A component MAY itself be a bundle. Nesting is capped at two
|
|
21
|
+
* levels — a top-level bundle whose components are themselves
|
|
22
|
+
* bundles is allowed; a three-level chain (bundle → bundle →
|
|
23
|
+
* bundle) is refused at `expand()` time.
|
|
24
|
+
*
|
|
25
|
+
* - Cyclic references are refused at `defineBundle()`. The
|
|
26
|
+
* primitive walks every component (and their components) up to
|
|
27
|
+
* the nesting cap, looking for a back-edge to the bundle being
|
|
28
|
+
* defined.
|
|
29
|
+
*
|
|
30
|
+
* Pricing:
|
|
31
|
+
*
|
|
32
|
+
* `priceBundle({ bundle_sku, pricing })` sums every leaf
|
|
33
|
+
* component's `pricing.priceFor(sku) → { amount_minor, currency }`
|
|
34
|
+
* (multiplied by its effective quantity), optionally applying the
|
|
35
|
+
* stored `bundle_discount_bps` override. The caller is
|
|
36
|
+
* responsible for currency consistency across components; mixed
|
|
37
|
+
* currencies throw rather than silently coercing.
|
|
38
|
+
*
|
|
39
|
+
* Deletion:
|
|
40
|
+
*
|
|
41
|
+
* `deleteBundle()` refuses while any active cart references the
|
|
42
|
+
* bundle. Reactivation of an "abandoned" cart that referenced the
|
|
43
|
+
* bundle is the operator's problem to resolve; the primitive
|
|
44
|
+
* only blocks on rows currently held by `carts.status = 'active'`.
|
|
45
|
+
*
|
|
46
|
+
* Subscriptions in this codebase bind to a variant_id (not an
|
|
47
|
+
* SKU), so a subscription cannot directly hold a bundle today.
|
|
48
|
+
* The deletion check stays scoped to cart_lines; a future
|
|
49
|
+
* subscription-by-sku surface composes the same check by passing
|
|
50
|
+
* an `extraReferenceCheck` callback to the factory.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
var bShop;
|
|
54
|
+
function _b() {
|
|
55
|
+
if (!bShop) bShop = require("./index");
|
|
56
|
+
return bShop.framework;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// SKU shape is shared with the catalog primitive — same alphabet, same
|
|
60
|
+
// length cap. Component lookup goes through the catalog, so the two
|
|
61
|
+
// validators must stay in lockstep; if catalog.SKU_RE ever changes,
|
|
62
|
+
// this regex changes with it. The two are pinned by tests so drift
|
|
63
|
+
// surfaces immediately.
|
|
64
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
65
|
+
var MAX_TITLE_LEN = 500;
|
|
66
|
+
var MAX_LIMIT = 200;
|
|
67
|
+
var MAX_NESTING_DEPTH = 2;
|
|
68
|
+
|
|
69
|
+
// Mutable columns for updateBundle — `bundle_sku` is the natural key
|
|
70
|
+
// and is immutable post-create (operators delete + redefine to rename
|
|
71
|
+
// a bundle SKU). `created_at` is also immutable.
|
|
72
|
+
var ALLOWED_BUNDLE_COLUMNS = Object.freeze(["title", "bundle_discount_bps"]);
|
|
73
|
+
|
|
74
|
+
var BUNDLE_ORDER_KEY = ["updated_at:desc", "bundle_sku:desc"];
|
|
75
|
+
|
|
76
|
+
// ---- validators ----------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
function _sku(s, label) {
|
|
79
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
80
|
+
throw new TypeError("bundles: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, <= 128 chars)");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function _title(s) {
|
|
85
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
|
|
86
|
+
throw new TypeError("bundles: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function _positiveInt(n, label) {
|
|
91
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
92
|
+
throw new TypeError("bundles: " + label + " must be a positive integer");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function _discountBps(n) {
|
|
97
|
+
if (n == null) return null;
|
|
98
|
+
if (!Number.isInteger(n) || n < 0 || n > 10000) {
|
|
99
|
+
throw new TypeError("bundles: bundle_discount_bps must be an integer in [0, 10000] (basis points) or null");
|
|
100
|
+
}
|
|
101
|
+
return n;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function _now() { return Date.now(); }
|
|
105
|
+
|
|
106
|
+
// ---- factory -------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
function create(opts) {
|
|
109
|
+
opts = opts || {};
|
|
110
|
+
var query = opts.query;
|
|
111
|
+
if (!query) {
|
|
112
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
113
|
+
}
|
|
114
|
+
var catalog = opts.catalog;
|
|
115
|
+
if (!catalog || !catalog.variants || typeof catalog.variants.bySku !== "function") {
|
|
116
|
+
throw new TypeError("bundles.create: opts.catalog must expose variants.bySku(sku)");
|
|
117
|
+
}
|
|
118
|
+
if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
|
|
119
|
+
if (process.env.NODE_ENV === "production") {
|
|
120
|
+
throw new Error("bundles.create: opts.cursorSecret is required in production");
|
|
121
|
+
}
|
|
122
|
+
opts.cursorSecret = "bundles-cursor-secret-dev-only";
|
|
123
|
+
}
|
|
124
|
+
var cursorSecret = opts.cursorSecret;
|
|
125
|
+
|
|
126
|
+
// Optional caller-supplied reference check fires alongside the
|
|
127
|
+
// built-in active-cart check inside deleteBundle. Returns truthy to
|
|
128
|
+
// veto the delete (signature: `async (bundle_sku) => bool`). Lets a
|
|
129
|
+
// downstream subscription-by-sku surface compose its own veto
|
|
130
|
+
// without a schema change here.
|
|
131
|
+
var extraReferenceCheck = typeof opts.extraReferenceCheck === "function"
|
|
132
|
+
? opts.extraReferenceCheck
|
|
133
|
+
: null;
|
|
134
|
+
|
|
135
|
+
// ---- internal helpers --------------------------------------------------
|
|
136
|
+
|
|
137
|
+
async function _bundleRow(bundleSku) {
|
|
138
|
+
var r = await query("SELECT * FROM bundles WHERE bundle_sku = ?1", [bundleSku]);
|
|
139
|
+
return r.rows[0] || null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function _componentRows(bundleSku) {
|
|
143
|
+
var r = await query(
|
|
144
|
+
"SELECT bundle_sku, sku, quantity, sort_order FROM bundle_components " +
|
|
145
|
+
"WHERE bundle_sku = ?1 ORDER BY sort_order ASC, sku ASC",
|
|
146
|
+
[bundleSku],
|
|
147
|
+
);
|
|
148
|
+
return r.rows;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Cycle + missing-SKU walker. Used at defineBundle to verify that
|
|
152
|
+
// none of the proposed components (or their transitive components,
|
|
153
|
+
// up to MAX_NESTING_DEPTH) loops back to `rootSku`, and that every
|
|
154
|
+
// referenced SKU exists in the catalog OR resolves to another
|
|
155
|
+
// already-stored bundle (which itself was verified at its own
|
|
156
|
+
// define time).
|
|
157
|
+
//
|
|
158
|
+
// The walker is bounded: it returns control after MAX_NESTING_DEPTH
|
|
159
|
+
// levels regardless of whether the leaves are bundles or variants.
|
|
160
|
+
// expand() applies the same cap with the matching error message.
|
|
161
|
+
async function _verifyComponentsAcyclic(rootSku, components) {
|
|
162
|
+
// Components are already shape-validated by the caller.
|
|
163
|
+
for (var i = 0; i < components.length; i += 1) {
|
|
164
|
+
var sku = components[i].sku;
|
|
165
|
+
if (sku === rootSku) {
|
|
166
|
+
throw new TypeError("bundles.defineBundle: cyclic reference — component sku " + JSON.stringify(sku) + " equals bundle_sku");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// For each component, classify as bundle vs variant and walk
|
|
170
|
+
// bundle children one more level deep. A component that itself
|
|
171
|
+
// points to bundles forms a 2-level structure; we must look at
|
|
172
|
+
// those grandchildren to catch a back-edge to rootSku.
|
|
173
|
+
for (var j = 0; j < components.length; j += 1) {
|
|
174
|
+
var csku = components[j].sku;
|
|
175
|
+
var childBundle = await _bundleRow(csku);
|
|
176
|
+
if (childBundle) {
|
|
177
|
+
var grand = await _componentRows(csku);
|
|
178
|
+
for (var k = 0; k < grand.length; k += 1) {
|
|
179
|
+
if (grand[k].sku === rootSku) {
|
|
180
|
+
throw new TypeError("bundles.defineBundle: cyclic reference — component " + JSON.stringify(csku) +
|
|
181
|
+
" contains " + JSON.stringify(rootSku));
|
|
182
|
+
}
|
|
183
|
+
// Walking further would exceed MAX_NESTING_DEPTH; the
|
|
184
|
+
// grandchild MUST be a leaf variant (not a third-level
|
|
185
|
+
// bundle) for the structure to be expandable. Verify the
|
|
186
|
+
// leaf is a real catalog SKU; if it's another bundle, we
|
|
187
|
+
// reject here because expand() would refuse it anyway.
|
|
188
|
+
var grandIsBundle = await _bundleRow(grand[k].sku);
|
|
189
|
+
if (grandIsBundle) {
|
|
190
|
+
throw new TypeError("bundles.defineBundle: nesting exceeds " + MAX_NESTING_DEPTH +
|
|
191
|
+
" levels — component " + JSON.stringify(csku) + " contains bundle " + JSON.stringify(grand[k].sku));
|
|
192
|
+
}
|
|
193
|
+
var leafVariant = await catalog.variants.bySku(grand[k].sku);
|
|
194
|
+
if (!leafVariant) {
|
|
195
|
+
throw new TypeError("bundles.defineBundle: nested component sku " + JSON.stringify(grand[k].sku) +
|
|
196
|
+
" (in bundle " + JSON.stringify(csku) + ") not found in catalog");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
// Not a bundle — must be a catalog variant.
|
|
202
|
+
var variant = await catalog.variants.bySku(csku);
|
|
203
|
+
if (!variant) {
|
|
204
|
+
throw new TypeError("bundles.defineBundle: component sku " + JSON.stringify(csku) + " not found in catalog");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---- defineBundle ------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
async function defineBundle(input) {
|
|
212
|
+
if (!input || typeof input !== "object") {
|
|
213
|
+
throw new TypeError("bundles.defineBundle: input object required");
|
|
214
|
+
}
|
|
215
|
+
_sku(input.bundle_sku, "bundle_sku");
|
|
216
|
+
_title(input.title);
|
|
217
|
+
if (!Array.isArray(input.components) || input.components.length === 0) {
|
|
218
|
+
throw new TypeError("bundles.defineBundle: components must be a non-empty array");
|
|
219
|
+
}
|
|
220
|
+
var discountBps = _discountBps(input.bundle_discount_bps == null ? null : input.bundle_discount_bps);
|
|
221
|
+
|
|
222
|
+
// Validate every component shape before any DB write.
|
|
223
|
+
var seen = Object.create(null);
|
|
224
|
+
var components = [];
|
|
225
|
+
for (var i = 0; i < input.components.length; i += 1) {
|
|
226
|
+
var c = input.components[i];
|
|
227
|
+
if (!c || typeof c !== "object") {
|
|
228
|
+
throw new TypeError("bundles.defineBundle: components[" + i + "] must be an object");
|
|
229
|
+
}
|
|
230
|
+
_sku(c.sku, "components[" + i + "].sku");
|
|
231
|
+
_positiveInt(c.quantity, "components[" + i + "].quantity");
|
|
232
|
+
if (seen[c.sku]) {
|
|
233
|
+
throw new TypeError("bundles.defineBundle: duplicate component sku " + JSON.stringify(c.sku));
|
|
234
|
+
}
|
|
235
|
+
seen[c.sku] = true;
|
|
236
|
+
components.push({ sku: c.sku, quantity: c.quantity });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Refuse if the bundle_sku collides with an existing catalog
|
|
240
|
+
// variant — bundle SKUs and variant SKUs share a namespace and
|
|
241
|
+
// an ambiguous lookup would silently route the wrong row.
|
|
242
|
+
var collision = await catalog.variants.bySku(input.bundle_sku);
|
|
243
|
+
if (collision) {
|
|
244
|
+
throw new TypeError("bundles.defineBundle: bundle_sku " + JSON.stringify(input.bundle_sku) +
|
|
245
|
+
" collides with an existing catalog variant SKU");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Refuse on existing bundle_sku.
|
|
249
|
+
var existing = await _bundleRow(input.bundle_sku);
|
|
250
|
+
if (existing) {
|
|
251
|
+
throw new TypeError("bundles.defineBundle: bundle_sku " + JSON.stringify(input.bundle_sku) + " already exists");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Verify every component exists in catalog (or resolves to a
|
|
255
|
+
// bundle whose own components live in catalog), and that no
|
|
256
|
+
// transitive component loops back to bundle_sku. Pure read-only
|
|
257
|
+
// — runs before any insert.
|
|
258
|
+
await _verifyComponentsAcyclic(input.bundle_sku, components);
|
|
259
|
+
|
|
260
|
+
var ts = _now();
|
|
261
|
+
await query(
|
|
262
|
+
"INSERT INTO bundles (bundle_sku, title, bundle_discount_bps, created_at, updated_at) " +
|
|
263
|
+
"VALUES (?1, ?2, ?3, ?4, ?4)",
|
|
264
|
+
[input.bundle_sku, input.title, discountBps, ts],
|
|
265
|
+
);
|
|
266
|
+
for (var k = 0; k < components.length; k += 1) {
|
|
267
|
+
await query(
|
|
268
|
+
"INSERT INTO bundle_components (bundle_sku, sku, quantity, sort_order) VALUES (?1, ?2, ?3, ?4)",
|
|
269
|
+
[input.bundle_sku, components[k].sku, components[k].quantity, k],
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
return await getBundle(input.bundle_sku);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---- getBundle ---------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
async function getBundle(bundleSku) {
|
|
278
|
+
_sku(bundleSku, "bundle_sku");
|
|
279
|
+
var row = await _bundleRow(bundleSku);
|
|
280
|
+
if (!row) return null;
|
|
281
|
+
var comps = await _componentRows(bundleSku);
|
|
282
|
+
return {
|
|
283
|
+
bundle_sku: row.bundle_sku,
|
|
284
|
+
title: row.title,
|
|
285
|
+
bundle_discount_bps: row.bundle_discount_bps,
|
|
286
|
+
created_at: row.created_at,
|
|
287
|
+
updated_at: row.updated_at,
|
|
288
|
+
components: comps.map(function (c) {
|
|
289
|
+
return { sku: c.sku, quantity: c.quantity, sort_order: c.sort_order };
|
|
290
|
+
}),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---- listBundles -------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
async function listBundles(input) {
|
|
297
|
+
input = input || {};
|
|
298
|
+
var limit = input.limit == null ? 50 : input.limit;
|
|
299
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIMIT) {
|
|
300
|
+
throw new TypeError("bundles.listBundles: limit must be 1..." + MAX_LIMIT);
|
|
301
|
+
}
|
|
302
|
+
var cursorVals = null;
|
|
303
|
+
if (input.cursor != null) {
|
|
304
|
+
if (typeof input.cursor !== "string") {
|
|
305
|
+
throw new TypeError("bundles.listBundles: cursor must be an opaque string or null");
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
var state = _b().pagination.decodeCursor(input.cursor, cursorSecret);
|
|
309
|
+
if (JSON.stringify(state.orderKey) !== JSON.stringify(BUNDLE_ORDER_KEY)) {
|
|
310
|
+
throw new TypeError("bundles.listBundles: cursor orderKey mismatch");
|
|
311
|
+
}
|
|
312
|
+
cursorVals = state.vals;
|
|
313
|
+
} catch (e) {
|
|
314
|
+
if (e instanceof TypeError) throw e;
|
|
315
|
+
throw new TypeError("bundles.listBundles: cursor — " + (e && e.message || "malformed"));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
var sql, params;
|
|
319
|
+
if (cursorVals) {
|
|
320
|
+
sql = "SELECT * FROM bundles WHERE updated_at < ?1 OR (updated_at = ?1 AND bundle_sku < ?2) " +
|
|
321
|
+
"ORDER BY updated_at DESC, bundle_sku DESC LIMIT ?3";
|
|
322
|
+
params = [cursorVals[0], cursorVals[1], limit];
|
|
323
|
+
} else {
|
|
324
|
+
sql = "SELECT * FROM bundles ORDER BY updated_at DESC, bundle_sku DESC LIMIT ?1";
|
|
325
|
+
params = [limit];
|
|
326
|
+
}
|
|
327
|
+
var r = await query(sql, params);
|
|
328
|
+
var last = r.rows[r.rows.length - 1];
|
|
329
|
+
var next = null;
|
|
330
|
+
if (last && r.rows.length === limit) {
|
|
331
|
+
next = _b().pagination.encodeCursor({
|
|
332
|
+
orderKey: BUNDLE_ORDER_KEY,
|
|
333
|
+
vals: [last.updated_at, last.bundle_sku],
|
|
334
|
+
forward: true,
|
|
335
|
+
}, cursorSecret);
|
|
336
|
+
}
|
|
337
|
+
return { rows: r.rows, next_cursor: next };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---- expand ------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
// Flatten a bundle into its leaf components with multipliers applied.
|
|
343
|
+
// Handles nested bundles two levels deep — a leaf that is itself a
|
|
344
|
+
// bundle gets expanded once more; a third-level bundle throws.
|
|
345
|
+
//
|
|
346
|
+
// Returns `[{ sku, quantity }, ...]` where each row represents the
|
|
347
|
+
// total demanded quantity for that leaf SKU across the entire
|
|
348
|
+
// expansion. If two components (directly or via nesting) reference
|
|
349
|
+
// the same leaf SKU, their quantities are summed into one row.
|
|
350
|
+
async function expand(input) {
|
|
351
|
+
if (!input || typeof input !== "object") {
|
|
352
|
+
throw new TypeError("bundles.expand: input object required");
|
|
353
|
+
}
|
|
354
|
+
_sku(input.bundle_sku, "bundle_sku");
|
|
355
|
+
_positiveInt(input.quantity, "quantity");
|
|
356
|
+
|
|
357
|
+
var row = await _bundleRow(input.bundle_sku);
|
|
358
|
+
if (!row) {
|
|
359
|
+
throw new TypeError("bundles.expand: bundle_sku " + JSON.stringify(input.bundle_sku) + " not found");
|
|
360
|
+
}
|
|
361
|
+
var comps = await _componentRows(input.bundle_sku);
|
|
362
|
+
|
|
363
|
+
var totals = Object.create(null);
|
|
364
|
+
function _add(sku, qty) {
|
|
365
|
+
totals[sku] = (totals[sku] || 0) + qty;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
for (var i = 0; i < comps.length; i += 1) {
|
|
369
|
+
var c = comps[i];
|
|
370
|
+
var demanded = c.quantity * input.quantity;
|
|
371
|
+
var childBundle = await _bundleRow(c.sku);
|
|
372
|
+
if (childBundle) {
|
|
373
|
+
var childComps = await _componentRows(c.sku);
|
|
374
|
+
for (var j = 0; j < childComps.length; j += 1) {
|
|
375
|
+
var gc = childComps[j];
|
|
376
|
+
var grandIsBundle = await _bundleRow(gc.sku);
|
|
377
|
+
if (grandIsBundle) {
|
|
378
|
+
throw new TypeError("bundles.expand: nesting exceeds " + MAX_NESTING_DEPTH +
|
|
379
|
+
" levels — " + JSON.stringify(c.sku) + " contains bundle " + JSON.stringify(gc.sku));
|
|
380
|
+
}
|
|
381
|
+
_add(gc.sku, gc.quantity * demanded);
|
|
382
|
+
}
|
|
383
|
+
} else {
|
|
384
|
+
_add(c.sku, demanded);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
var out = [];
|
|
389
|
+
var keys = Object.keys(totals).sort();
|
|
390
|
+
for (var k = 0; k < keys.length; k += 1) {
|
|
391
|
+
out.push({ sku: keys[k], quantity: totals[keys[k]] });
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ---- priceBundle -------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
async function priceBundle(input) {
|
|
399
|
+
if (!input || typeof input !== "object") {
|
|
400
|
+
throw new TypeError("bundles.priceBundle: input object required");
|
|
401
|
+
}
|
|
402
|
+
_sku(input.bundle_sku, "bundle_sku");
|
|
403
|
+
var pricing = input.pricing;
|
|
404
|
+
if (!pricing || typeof pricing.priceFor !== "function") {
|
|
405
|
+
throw new TypeError("bundles.priceBundle: pricing.priceFor(sku) required");
|
|
406
|
+
}
|
|
407
|
+
var row = await _bundleRow(input.bundle_sku);
|
|
408
|
+
if (!row) {
|
|
409
|
+
throw new TypeError("bundles.priceBundle: bundle_sku " + JSON.stringify(input.bundle_sku) + " not found");
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Expand to leaf quantities at multiplier=1 so the per-leaf price
|
|
413
|
+
// lookup happens once. The bundle is priced at its own "unit"
|
|
414
|
+
// (one bundle); cart math multiplies that by the line qty
|
|
415
|
+
// downstream.
|
|
416
|
+
var leaves = await expand({ bundle_sku: input.bundle_sku, quantity: 1 });
|
|
417
|
+
|
|
418
|
+
var currency = null;
|
|
419
|
+
var listTotal = 0;
|
|
420
|
+
for (var i = 0; i < leaves.length; i += 1) {
|
|
421
|
+
var leaf = leaves[i];
|
|
422
|
+
var priced = await pricing.priceFor(leaf.sku);
|
|
423
|
+
if (!priced || typeof priced !== "object") {
|
|
424
|
+
throw new TypeError("bundles.priceBundle: pricing.priceFor(" + JSON.stringify(leaf.sku) + ") returned no row");
|
|
425
|
+
}
|
|
426
|
+
if (!Number.isInteger(priced.amount_minor) || priced.amount_minor < 0) {
|
|
427
|
+
throw new TypeError("bundles.priceBundle: pricing.priceFor(" + JSON.stringify(leaf.sku) + ").amount_minor must be a non-negative integer");
|
|
428
|
+
}
|
|
429
|
+
if (typeof priced.currency !== "string" || !priced.currency.length) {
|
|
430
|
+
throw new TypeError("bundles.priceBundle: pricing.priceFor(" + JSON.stringify(leaf.sku) + ").currency must be a non-empty string");
|
|
431
|
+
}
|
|
432
|
+
if (currency === null) {
|
|
433
|
+
currency = priced.currency;
|
|
434
|
+
} else if (priced.currency !== currency) {
|
|
435
|
+
throw new TypeError("bundles.priceBundle: mixed currencies — " + JSON.stringify(currency) +
|
|
436
|
+
" vs " + JSON.stringify(priced.currency) + " on sku " + JSON.stringify(leaf.sku));
|
|
437
|
+
}
|
|
438
|
+
listTotal += priced.amount_minor * leaf.quantity;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
var discountBps = row.bundle_discount_bps;
|
|
442
|
+
var discountMinor = 0;
|
|
443
|
+
if (discountBps != null && discountBps > 0) {
|
|
444
|
+
// Integer floor — basis points are 1/100 of a percent (10000 =
|
|
445
|
+
// 100%). The floor keeps the customer from paying a fractional
|
|
446
|
+
// cent and matches the rounding convention the pricing
|
|
447
|
+
// primitive uses for line totals.
|
|
448
|
+
discountMinor = Math.floor((listTotal * discountBps) / 10000);
|
|
449
|
+
}
|
|
450
|
+
var grandTotal = listTotal - discountMinor;
|
|
451
|
+
return {
|
|
452
|
+
bundle_sku: input.bundle_sku,
|
|
453
|
+
currency: currency,
|
|
454
|
+
list_total_minor: listTotal,
|
|
455
|
+
bundle_discount_bps: discountBps,
|
|
456
|
+
discount_minor: discountMinor,
|
|
457
|
+
amount_minor: grandTotal,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ---- updateBundle ------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
async function updateBundle(bundleSku, patch) {
|
|
464
|
+
_sku(bundleSku, "bundle_sku");
|
|
465
|
+
if (!patch || typeof patch !== "object") {
|
|
466
|
+
throw new TypeError("bundles.updateBundle: patch object required");
|
|
467
|
+
}
|
|
468
|
+
var sets = [];
|
|
469
|
+
var params = [];
|
|
470
|
+
var idx = 1;
|
|
471
|
+
function _addSet(col, val) {
|
|
472
|
+
// Allow-list defense even though the column name is a literal —
|
|
473
|
+
// a future refactor that introduces a dynamic patch key path
|
|
474
|
+
// can't widen the surface to an attacker-controlled column.
|
|
475
|
+
_b().safeSql.assertOneOf(col, ALLOWED_BUNDLE_COLUMNS);
|
|
476
|
+
sets.push(_b().safeSql.quoteIdentifier(col, "sqlite") + " = ?" + (idx++));
|
|
477
|
+
params.push(val);
|
|
478
|
+
}
|
|
479
|
+
if (patch.title !== undefined) {
|
|
480
|
+
_title(patch.title);
|
|
481
|
+
_addSet("title", patch.title);
|
|
482
|
+
}
|
|
483
|
+
if (patch.bundle_discount_bps !== undefined) {
|
|
484
|
+
_addSet("bundle_discount_bps", _discountBps(patch.bundle_discount_bps));
|
|
485
|
+
}
|
|
486
|
+
if (patch.components !== undefined) {
|
|
487
|
+
// Component rewrites are a structural change: validate every
|
|
488
|
+
// proposed component, re-run the cycle walker against the
|
|
489
|
+
// existing bundle_sku, then atomically (best-effort under D1's
|
|
490
|
+
// no-cross-statement-transaction model) delete + reinsert.
|
|
491
|
+
if (!Array.isArray(patch.components) || patch.components.length === 0) {
|
|
492
|
+
throw new TypeError("bundles.updateBundle: components must be a non-empty array");
|
|
493
|
+
}
|
|
494
|
+
var seen = Object.create(null);
|
|
495
|
+
var newComponents = [];
|
|
496
|
+
for (var i = 0; i < patch.components.length; i += 1) {
|
|
497
|
+
var pc = patch.components[i];
|
|
498
|
+
if (!pc || typeof pc !== "object") {
|
|
499
|
+
throw new TypeError("bundles.updateBundle: components[" + i + "] must be an object");
|
|
500
|
+
}
|
|
501
|
+
_sku(pc.sku, "components[" + i + "].sku");
|
|
502
|
+
_positiveInt(pc.quantity, "components[" + i + "].quantity");
|
|
503
|
+
if (seen[pc.sku]) {
|
|
504
|
+
throw new TypeError("bundles.updateBundle: duplicate component sku " + JSON.stringify(pc.sku));
|
|
505
|
+
}
|
|
506
|
+
seen[pc.sku] = true;
|
|
507
|
+
newComponents.push({ sku: pc.sku, quantity: pc.quantity });
|
|
508
|
+
}
|
|
509
|
+
var existing = await _bundleRow(bundleSku);
|
|
510
|
+
if (!existing) return null;
|
|
511
|
+
await _verifyComponentsAcyclic(bundleSku, newComponents);
|
|
512
|
+
await query("DELETE FROM bundle_components WHERE bundle_sku = ?1", [bundleSku]);
|
|
513
|
+
for (var m = 0; m < newComponents.length; m += 1) {
|
|
514
|
+
await query(
|
|
515
|
+
"INSERT INTO bundle_components (bundle_sku, sku, quantity, sort_order) VALUES (?1, ?2, ?3, ?4)",
|
|
516
|
+
[bundleSku, newComponents[m].sku, newComponents[m].quantity, m],
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (sets.length === 0 && patch.components === undefined) {
|
|
521
|
+
throw new TypeError("bundles.updateBundle: patch contained no updatable fields");
|
|
522
|
+
}
|
|
523
|
+
var ts = _now();
|
|
524
|
+
if (sets.length > 0) {
|
|
525
|
+
sets.push("updated_at = ?" + (idx++));
|
|
526
|
+
params.push(ts);
|
|
527
|
+
params.push(bundleSku);
|
|
528
|
+
var r = await query(
|
|
529
|
+
"UPDATE bundles SET " + sets.join(", ") + " WHERE bundle_sku = ?" + idx,
|
|
530
|
+
params,
|
|
531
|
+
);
|
|
532
|
+
if (r.rowCount === 0 && patch.components === undefined) return null;
|
|
533
|
+
} else {
|
|
534
|
+
// Components-only patch — still bump updated_at so cursors order
|
|
535
|
+
// recent edits correctly.
|
|
536
|
+
await query("UPDATE bundles SET updated_at = ?1 WHERE bundle_sku = ?2", [ts, bundleSku]);
|
|
537
|
+
}
|
|
538
|
+
return await getBundle(bundleSku);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ---- deleteBundle ------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
async function deleteBundle(bundleSku) {
|
|
544
|
+
_sku(bundleSku, "bundle_sku");
|
|
545
|
+
// Active-cart reference check. `carts.status = 'active'` is the
|
|
546
|
+
// only state that holds a live customer claim on the bundle;
|
|
547
|
+
// converted/abandoned carts are historical and don't block
|
|
548
|
+
// deletion (the order primitive owns post-conversion state).
|
|
549
|
+
var refs = await query(
|
|
550
|
+
"SELECT COUNT(*) AS n FROM cart_lines cl " +
|
|
551
|
+
"JOIN carts c ON c.id = cl.cart_id " +
|
|
552
|
+
"WHERE cl.sku = ?1 AND c.status = 'active'",
|
|
553
|
+
[bundleSku],
|
|
554
|
+
);
|
|
555
|
+
var refCount = refs.rows[0] && (refs.rows[0].n || refs.rows[0].N);
|
|
556
|
+
if (refCount && refCount > 0) {
|
|
557
|
+
throw new TypeError("bundles.deleteBundle: bundle_sku " + JSON.stringify(bundleSku) +
|
|
558
|
+
" is referenced by " + refCount + " active cart line(s) — clear those carts before deleting");
|
|
559
|
+
}
|
|
560
|
+
if (extraReferenceCheck) {
|
|
561
|
+
var extra = await extraReferenceCheck(bundleSku);
|
|
562
|
+
if (extra) {
|
|
563
|
+
throw new TypeError("bundles.deleteBundle: bundle_sku " + JSON.stringify(bundleSku) +
|
|
564
|
+
" is referenced by an external subscription / order surface");
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// FK ON DELETE CASCADE drops the bundle_components rows.
|
|
568
|
+
var r = await query("DELETE FROM bundles WHERE bundle_sku = ?1", [bundleSku]);
|
|
569
|
+
return r.rowCount > 0;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
defineBundle: defineBundle,
|
|
574
|
+
getBundle: getBundle,
|
|
575
|
+
listBundles: listBundles,
|
|
576
|
+
expand: expand,
|
|
577
|
+
priceBundle: priceBundle,
|
|
578
|
+
updateBundle: updateBundle,
|
|
579
|
+
deleteBundle: deleteBundle,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
module.exports = {
|
|
584
|
+
create: create,
|
|
585
|
+
MAX_NESTING_DEPTH: MAX_NESTING_DEPTH,
|
|
586
|
+
ALLOWED_BUNDLE_COLUMNS: ALLOWED_BUNDLE_COLUMNS,
|
|
587
|
+
};
|