@blamejs/blamejs-shop 0.0.59 → 0.0.60
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/api-keys.js +789 -0
- package/lib/barcodes.js +671 -0
- package/lib/coupon-stacking.js +717 -0
- package/lib/customer-portal.js +359 -0
- package/lib/experiments.js +697 -0
- package/lib/index.js +14 -0
- package/lib/inventory-snapshots.js +691 -0
- package/lib/print-receipts.js +675 -0
- package/lib/product-import.js +1034 -0
- package/lib/storefront-pages.js +701 -0
- package/lib/subscription-billing.js +644 -0
- package/lib/tax-rates.js +559 -0
- package/lib/tenants.js +665 -0
- package/lib/translations.js +553 -0
- package/lib/webhook-subscriptions.js +565 -0
- package/package.json +1 -1
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.couponStacking
|
|
4
|
+
* @title Coupon-stacking policies — operator-authored rules that gate
|
|
5
|
+
* which codes (and which `quantityDiscounts`) may combine on a cart.
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* A stacking policy answers a single question:
|
|
9
|
+
*
|
|
10
|
+
* "This cart is presenting codes [A, B, C], may have an automatic
|
|
11
|
+
* quantity discount already attached to its lines, and belongs to
|
|
12
|
+
* customer X. Which of those discounts are allowed to apply
|
|
13
|
+
* together?"
|
|
14
|
+
*
|
|
15
|
+
* The primitive does not validate the codes themselves (the
|
|
16
|
+
* `coupons` surface owns expiry, per-code discount math, redemption
|
|
17
|
+
* counters). Codes reach this primitive as opaque strings; the
|
|
18
|
+
* policy decides combination, not redemption.
|
|
19
|
+
*
|
|
20
|
+
* Surface:
|
|
21
|
+
*
|
|
22
|
+
* - `definePolicy({ slug, title, allow_combine, max_codes_per_order,
|
|
23
|
+
* exclusive_codes?, order_min_minor?,
|
|
24
|
+
* customer_segment_in? })`
|
|
25
|
+
* Create a new policy.
|
|
26
|
+
* `allow_combine` is `{ with_quantity_discounts?: bool,
|
|
27
|
+
* with_other_codes?: bool }`. Both default
|
|
28
|
+
* to `false` — strict refusal is the safe default. Unknown
|
|
29
|
+
* keys are refused at define time so a typo can't silently
|
|
30
|
+
* widen the surface.
|
|
31
|
+
* `max_codes_per_order` (>=1) caps the codes the policy
|
|
32
|
+
* accepts; over-cap codes are refused in cart-order with
|
|
33
|
+
* reason `max_codes_per_order_exceeded`.
|
|
34
|
+
* `exclusive_codes` lists codes that, when present on a cart,
|
|
35
|
+
* block every other code on the same order — useful for "this
|
|
36
|
+
* loyalty-tier code never stacks with anything else".
|
|
37
|
+
* `order_min_minor` is the subtotal floor (in minor units)
|
|
38
|
+
* the policy requires; carts below the floor fall through to
|
|
39
|
+
* the next-priority policy.
|
|
40
|
+
* `customer_segment_in` is a list of segment slugs the
|
|
41
|
+
* customer must intersect with for the policy to apply; carts
|
|
42
|
+
* without a `customer_id` never satisfy this gate.
|
|
43
|
+
*
|
|
44
|
+
* - `evaluate({ codes, cart, customer_id? })`
|
|
45
|
+
* Walks active policies in (priority DESC, created_at ASC)
|
|
46
|
+
* order. The first policy whose preconditions
|
|
47
|
+
* (`order_min_minor`, `customer_segment_in`) match the cart
|
|
48
|
+
* is the governing policy; its rules decide every refusal.
|
|
49
|
+
* When no policy matches, the cart's first code is allowed
|
|
50
|
+
* and every subsequent code is refused with
|
|
51
|
+
* `no_policy_allows_stacking` — the conservative default
|
|
52
|
+
* is "one code, no stacking" until the operator authors a
|
|
53
|
+
* policy.
|
|
54
|
+
* Returns `{ allowed, applied: [code], refused: [{ code, reason }] }`.
|
|
55
|
+
* `allowed` is the AND of "applied is non-empty" and "the
|
|
56
|
+
* cart's quantity-discount stack is not in violation" (a
|
|
57
|
+
* q-discount line in a `with_quantity_discounts=false` policy
|
|
58
|
+
* when codes are also presented is refused at the cart level,
|
|
59
|
+
* surfacing as `allowed=false` with reason
|
|
60
|
+
* `quantity_discount_stack_refused`).
|
|
61
|
+
*
|
|
62
|
+
* - `activePoliciesForCart({ cart, customer_id? })`
|
|
63
|
+
* Operator dashboard hint — returns every active, non-archived
|
|
64
|
+
* policy whose `order_min_minor` is met by the cart subtotal
|
|
65
|
+
* and whose `customer_segment_in` matches the customer (or
|
|
66
|
+
* is empty). Ordered by priority DESC. Lets the admin UI
|
|
67
|
+
* render "these policies would govern this cart".
|
|
68
|
+
*
|
|
69
|
+
* - `getPolicy(slug)` / `listPolicies({ active_only? })` /
|
|
70
|
+
* `updatePolicy(slug, patch)` / `archivePolicy(slug)`.
|
|
71
|
+
*
|
|
72
|
+
* Composition:
|
|
73
|
+
*
|
|
74
|
+
* - `customerSegments` (optional handle, shape: `isMember(customer_id,
|
|
75
|
+
* segment_slug) -> Promise<bool>`). Required only when at least
|
|
76
|
+
* one policy has a non-empty `customer_segment_in`; absent that,
|
|
77
|
+
* `evaluate` runs without consulting any external handle.
|
|
78
|
+
*
|
|
79
|
+
* - The `cart` argument is the storefront-shaped cart object the
|
|
80
|
+
* framework already passes between cart / checkout primitives:
|
|
81
|
+
* `{ subtotal_minor, has_quantity_discount?, lines? }`.
|
|
82
|
+
* Only `subtotal_minor` and `has_quantity_discount` are read by
|
|
83
|
+
* this primitive; the rest rides through unread.
|
|
84
|
+
*
|
|
85
|
+
* Storage:
|
|
86
|
+
*
|
|
87
|
+
* - `coupon_stacking_policies` (migration
|
|
88
|
+
* `0067_coupon_stacking.sql`).
|
|
89
|
+
*
|
|
90
|
+
* @primitive couponStacking
|
|
91
|
+
* @related customerSegments, quantityDiscounts
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
// ---- constants ----------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
var MAX_SLUG_LEN = 80;
|
|
97
|
+
var MAX_TITLE_LEN = 200;
|
|
98
|
+
var MAX_CODE_LEN = 64;
|
|
99
|
+
var MAX_EXCLUSIVE_CODES = 64;
|
|
100
|
+
var MAX_SEGMENT_SLUGS = 32;
|
|
101
|
+
var MAX_SEGMENT_SLUG_LEN = 64;
|
|
102
|
+
var MAX_MAX_CODES = 32;
|
|
103
|
+
var MAX_PRIORITY = 1000000;
|
|
104
|
+
var MAX_LIST_LIMIT = 200;
|
|
105
|
+
|
|
106
|
+
// Slug shape matches the catalog / promo-banners convention — alnum +
|
|
107
|
+
// hyphen + underscore + dot, leading char alnum, capped length.
|
|
108
|
+
var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
109
|
+
var SEGMENT_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
110
|
+
|
|
111
|
+
// Code shape — operator codes typed by shoppers are case-sensitive
|
|
112
|
+
// strings, alnum + hyphen + underscore. Tight enough to refuse
|
|
113
|
+
// control bytes / whitespace, loose enough that any reasonable
|
|
114
|
+
// coupons-primitive shape passes.
|
|
115
|
+
var CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
116
|
+
|
|
117
|
+
var ALLOWED_COMBINE_KEYS = Object.freeze([
|
|
118
|
+
"with_quantity_discounts",
|
|
119
|
+
"with_other_codes",
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
var ALLOWED_PATCH_COLUMNS = Object.freeze([
|
|
123
|
+
"title",
|
|
124
|
+
"allow_combine",
|
|
125
|
+
"max_codes_per_order",
|
|
126
|
+
"exclusive_codes",
|
|
127
|
+
"order_min_minor",
|
|
128
|
+
"customer_segment_in",
|
|
129
|
+
"active",
|
|
130
|
+
"priority",
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
var bShop;
|
|
134
|
+
function _b() {
|
|
135
|
+
if (!bShop) bShop = require("./index");
|
|
136
|
+
return bShop.framework;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---- validators ---------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
function _slug(s) {
|
|
142
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
143
|
+
throw new TypeError("couponStacking: slug must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (≤ " + MAX_SLUG_LEN + " chars)");
|
|
144
|
+
}
|
|
145
|
+
return s;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function _title(s) {
|
|
149
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
|
|
150
|
+
throw new TypeError("couponStacking: title must be a non-empty string ≤ " + MAX_TITLE_LEN + " chars");
|
|
151
|
+
}
|
|
152
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
153
|
+
throw new TypeError("couponStacking: title must not contain control bytes");
|
|
154
|
+
}
|
|
155
|
+
return s;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function _code(s, label) {
|
|
159
|
+
if (typeof s !== "string" || !CODE_RE.test(s)) {
|
|
160
|
+
throw new TypeError("couponStacking: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (≤ " + MAX_CODE_LEN + " chars)");
|
|
161
|
+
}
|
|
162
|
+
return s;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function _segmentSlug(s) {
|
|
166
|
+
if (typeof s !== "string" || !SEGMENT_SLUG_RE.test(s)) {
|
|
167
|
+
throw new TypeError("couponStacking: customer_segment_in entries must match /^[a-z0-9][a-z0-9_-]*$/ (≤ " + MAX_SEGMENT_SLUG_LEN + " chars)");
|
|
168
|
+
}
|
|
169
|
+
return s;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function _maxCodes(n) {
|
|
173
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_MAX_CODES) {
|
|
174
|
+
throw new TypeError("couponStacking: max_codes_per_order must be an integer in [1, " + MAX_MAX_CODES + "]");
|
|
175
|
+
}
|
|
176
|
+
return n;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function _orderMin(n) {
|
|
180
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
181
|
+
throw new TypeError("couponStacking: order_min_minor must be a non-negative integer");
|
|
182
|
+
}
|
|
183
|
+
return n;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function _priority(n) {
|
|
187
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_PRIORITY) {
|
|
188
|
+
throw new TypeError("couponStacking: priority must be an integer in [0, " + MAX_PRIORITY + "]");
|
|
189
|
+
}
|
|
190
|
+
return n;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function _allowCombine(v) {
|
|
194
|
+
if (v == null) return { with_quantity_discounts: false, with_other_codes: false };
|
|
195
|
+
if (typeof v !== "object" || Array.isArray(v)) {
|
|
196
|
+
throw new TypeError("couponStacking: allow_combine must be an object");
|
|
197
|
+
}
|
|
198
|
+
var out = { with_quantity_discounts: false, with_other_codes: false };
|
|
199
|
+
var keys = Object.keys(v);
|
|
200
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
201
|
+
var k = keys[i];
|
|
202
|
+
if (ALLOWED_COMBINE_KEYS.indexOf(k) === -1) {
|
|
203
|
+
throw new TypeError("couponStacking: allow_combine unknown key " + JSON.stringify(k) +
|
|
204
|
+
" (allowed: " + JSON.stringify(ALLOWED_COMBINE_KEYS) + ")");
|
|
205
|
+
}
|
|
206
|
+
if (typeof v[k] !== "boolean") {
|
|
207
|
+
throw new TypeError("couponStacking: allow_combine." + k + " must be a boolean");
|
|
208
|
+
}
|
|
209
|
+
out[k] = v[k];
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function _exclusiveCodes(v) {
|
|
215
|
+
if (v == null) return [];
|
|
216
|
+
if (!Array.isArray(v)) {
|
|
217
|
+
throw new TypeError("couponStacking: exclusive_codes must be an array of code strings");
|
|
218
|
+
}
|
|
219
|
+
if (v.length > MAX_EXCLUSIVE_CODES) {
|
|
220
|
+
throw new TypeError("couponStacking: exclusive_codes length " + v.length + " exceeds cap " + MAX_EXCLUSIVE_CODES);
|
|
221
|
+
}
|
|
222
|
+
var seen = Object.create(null);
|
|
223
|
+
var out = [];
|
|
224
|
+
for (var i = 0; i < v.length; i += 1) {
|
|
225
|
+
var c = _code(v[i], "exclusive_codes[" + i + "]");
|
|
226
|
+
if (seen[c]) {
|
|
227
|
+
throw new TypeError("couponStacking: exclusive_codes contains duplicate " + JSON.stringify(c));
|
|
228
|
+
}
|
|
229
|
+
seen[c] = true;
|
|
230
|
+
out.push(c);
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function _customerSegmentIn(v) {
|
|
236
|
+
if (v == null) return [];
|
|
237
|
+
if (!Array.isArray(v)) {
|
|
238
|
+
throw new TypeError("couponStacking: customer_segment_in must be an array of segment slugs");
|
|
239
|
+
}
|
|
240
|
+
if (v.length > MAX_SEGMENT_SLUGS) {
|
|
241
|
+
throw new TypeError("couponStacking: customer_segment_in length " + v.length + " exceeds cap " + MAX_SEGMENT_SLUGS);
|
|
242
|
+
}
|
|
243
|
+
var seen = Object.create(null);
|
|
244
|
+
var out = [];
|
|
245
|
+
for (var i = 0; i < v.length; i += 1) {
|
|
246
|
+
var s = _segmentSlug(v[i]);
|
|
247
|
+
if (seen[s]) {
|
|
248
|
+
throw new TypeError("couponStacking: customer_segment_in contains duplicate " + JSON.stringify(s));
|
|
249
|
+
}
|
|
250
|
+
seen[s] = true;
|
|
251
|
+
out.push(s);
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function _active(v) {
|
|
257
|
+
if (typeof v !== "boolean") {
|
|
258
|
+
throw new TypeError("couponStacking: active must be a boolean");
|
|
259
|
+
}
|
|
260
|
+
return v;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function _now() { return Date.now(); }
|
|
264
|
+
|
|
265
|
+
// ---- row hydration ------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
function _safeParseObject(s, fallback) {
|
|
268
|
+
if (s == null) return fallback;
|
|
269
|
+
try {
|
|
270
|
+
var parsed = JSON.parse(s);
|
|
271
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
272
|
+
return fallback;
|
|
273
|
+
} catch (_e) {
|
|
274
|
+
return fallback;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function _safeParseArray(s) {
|
|
279
|
+
if (s == null) return [];
|
|
280
|
+
try {
|
|
281
|
+
var parsed = JSON.parse(s);
|
|
282
|
+
if (Array.isArray(parsed)) return parsed;
|
|
283
|
+
return [];
|
|
284
|
+
} catch (_e) {
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function _hydrateRow(r) {
|
|
290
|
+
if (!r) return null;
|
|
291
|
+
var allowCombineRaw = _safeParseObject(r.allow_combine_json, {});
|
|
292
|
+
return {
|
|
293
|
+
slug: r.slug,
|
|
294
|
+
title: r.title,
|
|
295
|
+
allow_combine: {
|
|
296
|
+
with_quantity_discounts: allowCombineRaw.with_quantity_discounts === true,
|
|
297
|
+
with_other_codes: allowCombineRaw.with_other_codes === true,
|
|
298
|
+
},
|
|
299
|
+
max_codes_per_order: Number(r.max_codes_per_order),
|
|
300
|
+
exclusive_codes: _safeParseArray(r.exclusive_codes_json),
|
|
301
|
+
order_min_minor: Number(r.order_min_minor),
|
|
302
|
+
customer_segment_in: _safeParseArray(r.customer_segment_in_json),
|
|
303
|
+
active: r.active === 1 || r.active === true,
|
|
304
|
+
archived_at: r.archived_at == null ? null : Number(r.archived_at),
|
|
305
|
+
priority: Number(r.priority),
|
|
306
|
+
created_at: Number(r.created_at),
|
|
307
|
+
updated_at: Number(r.updated_at),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---- cart-shape reader (defensive) --------------------------------------
|
|
312
|
+
|
|
313
|
+
// The cart object travels through several primitives; the stacking
|
|
314
|
+
// primitive only ever reads two fields. A missing cart is refused;
|
|
315
|
+
// missing optional fields default to safe values.
|
|
316
|
+
function _readCart(cart) {
|
|
317
|
+
if (!cart || typeof cart !== "object") {
|
|
318
|
+
throw new TypeError("couponStacking: cart object required");
|
|
319
|
+
}
|
|
320
|
+
var subtotal = cart.subtotal_minor;
|
|
321
|
+
if (subtotal == null) subtotal = 0;
|
|
322
|
+
if (!Number.isInteger(subtotal) || subtotal < 0) {
|
|
323
|
+
throw new TypeError("couponStacking: cart.subtotal_minor must be a non-negative integer when present");
|
|
324
|
+
}
|
|
325
|
+
var hasQd = cart.has_quantity_discount === true;
|
|
326
|
+
return { subtotal_minor: subtotal, has_quantity_discount: hasQd };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---- factory ------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
function create(opts) {
|
|
332
|
+
opts = opts || {};
|
|
333
|
+
var query = opts.query;
|
|
334
|
+
if (!query) {
|
|
335
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
336
|
+
}
|
|
337
|
+
// Optional segments handle. Required only when at least one policy
|
|
338
|
+
// has a non-empty `customer_segment_in`; the evaluator enforces
|
|
339
|
+
// this lazily so a deployment with no segment-gated policies can
|
|
340
|
+
// skip wiring it.
|
|
341
|
+
var customerSegments = opts.customerSegments || null;
|
|
342
|
+
|
|
343
|
+
// ---- definePolicy --------------------------------------------------
|
|
344
|
+
|
|
345
|
+
async function definePolicy(input) {
|
|
346
|
+
if (!input || typeof input !== "object") {
|
|
347
|
+
throw new TypeError("couponStacking.definePolicy: input object required");
|
|
348
|
+
}
|
|
349
|
+
var slug = _slug(input.slug);
|
|
350
|
+
var title = _title(input.title);
|
|
351
|
+
var allowCombine = _allowCombine(input.allow_combine);
|
|
352
|
+
var maxCodes = _maxCodes(input.max_codes_per_order);
|
|
353
|
+
var exclusiveCodes = _exclusiveCodes(input.exclusive_codes);
|
|
354
|
+
var orderMin = input.order_min_minor == null ? 0 : _orderMin(input.order_min_minor);
|
|
355
|
+
var customerSegmentIn = _customerSegmentIn(input.customer_segment_in);
|
|
356
|
+
var priority = input.priority == null ? 0 : _priority(input.priority);
|
|
357
|
+
|
|
358
|
+
// Refuse a redefine — operators should `updatePolicy` to mutate
|
|
359
|
+
// an existing slug. A blind INSERT would clobber the policy's
|
|
360
|
+
// created_at timestamp and is rarely what the operator wants.
|
|
361
|
+
var existing = (await query(
|
|
362
|
+
"SELECT slug FROM coupon_stacking_policies WHERE slug = ?1 LIMIT 1",
|
|
363
|
+
[slug],
|
|
364
|
+
)).rows[0];
|
|
365
|
+
if (existing) {
|
|
366
|
+
throw new TypeError("couponStacking.definePolicy: slug " + JSON.stringify(slug) + " already exists — use updatePolicy");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
var ts = _now();
|
|
370
|
+
await query(
|
|
371
|
+
"INSERT INTO coupon_stacking_policies (slug, title, allow_combine_json, max_codes_per_order, " +
|
|
372
|
+
"exclusive_codes_json, order_min_minor, customer_segment_in_json, active, archived_at, " +
|
|
373
|
+
"priority, created_at, updated_at) " +
|
|
374
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1, NULL, ?8, ?9, ?9)",
|
|
375
|
+
[
|
|
376
|
+
slug,
|
|
377
|
+
title,
|
|
378
|
+
JSON.stringify(allowCombine),
|
|
379
|
+
maxCodes,
|
|
380
|
+
JSON.stringify(exclusiveCodes),
|
|
381
|
+
orderMin,
|
|
382
|
+
JSON.stringify(customerSegmentIn),
|
|
383
|
+
priority,
|
|
384
|
+
ts,
|
|
385
|
+
],
|
|
386
|
+
);
|
|
387
|
+
return await getPolicy(slug);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---- getPolicy / listPolicies --------------------------------------
|
|
391
|
+
|
|
392
|
+
async function getPolicy(slug) {
|
|
393
|
+
_slug(slug);
|
|
394
|
+
var r = (await query(
|
|
395
|
+
"SELECT * FROM coupon_stacking_policies WHERE slug = ?1 LIMIT 1",
|
|
396
|
+
[slug],
|
|
397
|
+
)).rows[0];
|
|
398
|
+
return _hydrateRow(r);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function listPolicies(listOpts) {
|
|
402
|
+
listOpts = listOpts || {};
|
|
403
|
+
var activeOnly = false;
|
|
404
|
+
if (listOpts.active_only != null) {
|
|
405
|
+
if (typeof listOpts.active_only !== "boolean") {
|
|
406
|
+
throw new TypeError("couponStacking.listPolicies: active_only must be a boolean");
|
|
407
|
+
}
|
|
408
|
+
activeOnly = listOpts.active_only;
|
|
409
|
+
}
|
|
410
|
+
var limit = listOpts.limit == null ? 50 : listOpts.limit;
|
|
411
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
|
|
412
|
+
throw new TypeError("couponStacking.listPolicies: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
|
|
413
|
+
}
|
|
414
|
+
var sql, params;
|
|
415
|
+
if (activeOnly) {
|
|
416
|
+
sql = "SELECT * FROM coupon_stacking_policies WHERE active = 1 AND archived_at IS NULL " +
|
|
417
|
+
"ORDER BY priority DESC, created_at ASC, slug ASC LIMIT ?1";
|
|
418
|
+
params = [limit];
|
|
419
|
+
} else {
|
|
420
|
+
sql = "SELECT * FROM coupon_stacking_policies " +
|
|
421
|
+
"ORDER BY priority DESC, created_at ASC, slug ASC LIMIT ?1";
|
|
422
|
+
params = [limit];
|
|
423
|
+
}
|
|
424
|
+
var rows = (await query(sql, params)).rows;
|
|
425
|
+
var out = [];
|
|
426
|
+
for (var i = 0; i < rows.length; i += 1) out.push(_hydrateRow(rows[i]));
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ---- updatePolicy --------------------------------------------------
|
|
431
|
+
|
|
432
|
+
async function updatePolicy(slug, patch) {
|
|
433
|
+
_slug(slug);
|
|
434
|
+
if (!patch || typeof patch !== "object") {
|
|
435
|
+
throw new TypeError("couponStacking.updatePolicy: patch object required");
|
|
436
|
+
}
|
|
437
|
+
var keys = Object.keys(patch);
|
|
438
|
+
if (!keys.length) {
|
|
439
|
+
throw new TypeError("couponStacking.updatePolicy: patch must include at least one column");
|
|
440
|
+
}
|
|
441
|
+
var current = await getPolicy(slug);
|
|
442
|
+
if (!current) {
|
|
443
|
+
throw new TypeError("couponStacking.updatePolicy: slug " + JSON.stringify(slug) + " not found");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
var sets = [];
|
|
447
|
+
var params = [];
|
|
448
|
+
var idx = 1;
|
|
449
|
+
|
|
450
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
451
|
+
var col = keys[i];
|
|
452
|
+
if (ALLOWED_PATCH_COLUMNS.indexOf(col) === -1) {
|
|
453
|
+
throw new TypeError("couponStacking.updatePolicy: unsupported column " + JSON.stringify(col));
|
|
454
|
+
}
|
|
455
|
+
if (col === "title") {
|
|
456
|
+
sets.push("title = ?" + idx);
|
|
457
|
+
params.push(_title(patch[col]));
|
|
458
|
+
} else if (col === "allow_combine") {
|
|
459
|
+
sets.push("allow_combine_json = ?" + idx);
|
|
460
|
+
params.push(JSON.stringify(_allowCombine(patch[col])));
|
|
461
|
+
} else if (col === "max_codes_per_order") {
|
|
462
|
+
sets.push("max_codes_per_order = ?" + idx);
|
|
463
|
+
params.push(_maxCodes(patch[col]));
|
|
464
|
+
} else if (col === "exclusive_codes") {
|
|
465
|
+
sets.push("exclusive_codes_json = ?" + idx);
|
|
466
|
+
params.push(JSON.stringify(_exclusiveCodes(patch[col])));
|
|
467
|
+
} else if (col === "order_min_minor") {
|
|
468
|
+
sets.push("order_min_minor = ?" + idx);
|
|
469
|
+
params.push(_orderMin(patch[col]));
|
|
470
|
+
} else if (col === "customer_segment_in") {
|
|
471
|
+
sets.push("customer_segment_in_json = ?" + idx);
|
|
472
|
+
params.push(JSON.stringify(_customerSegmentIn(patch[col])));
|
|
473
|
+
} else if (col === "active") {
|
|
474
|
+
sets.push("active = ?" + idx);
|
|
475
|
+
params.push(_active(patch[col]) ? 1 : 0);
|
|
476
|
+
} else /* priority */ {
|
|
477
|
+
sets.push("priority = ?" + idx);
|
|
478
|
+
params.push(_priority(patch[col]));
|
|
479
|
+
}
|
|
480
|
+
idx += 1;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
sets.push("updated_at = ?" + idx);
|
|
484
|
+
params.push(_now());
|
|
485
|
+
idx += 1;
|
|
486
|
+
params.push(slug);
|
|
487
|
+
|
|
488
|
+
var r = await query(
|
|
489
|
+
"UPDATE coupon_stacking_policies SET " + sets.join(", ") + " WHERE slug = ?" + idx,
|
|
490
|
+
params,
|
|
491
|
+
);
|
|
492
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
493
|
+
throw new TypeError("couponStacking.updatePolicy: slug " + JSON.stringify(slug) + " not found");
|
|
494
|
+
}
|
|
495
|
+
return await getPolicy(slug);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ---- archivePolicy -------------------------------------------------
|
|
499
|
+
|
|
500
|
+
async function archivePolicy(slug) {
|
|
501
|
+
_slug(slug);
|
|
502
|
+
var ts = _now();
|
|
503
|
+
var r = await query(
|
|
504
|
+
"UPDATE coupon_stacking_policies SET archived_at = ?1, active = 0, updated_at = ?1 " +
|
|
505
|
+
"WHERE slug = ?2 AND archived_at IS NULL",
|
|
506
|
+
[ts, slug],
|
|
507
|
+
);
|
|
508
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
509
|
+
var existing = await getPolicy(slug);
|
|
510
|
+
if (!existing) {
|
|
511
|
+
throw new TypeError("couponStacking.archivePolicy: slug " + JSON.stringify(slug) + " not found");
|
|
512
|
+
}
|
|
513
|
+
// Already archived — return the existing row idempotently so
|
|
514
|
+
// an "archive everything" sweep doesn't have to special-case
|
|
515
|
+
// a policy a coworker archived first.
|
|
516
|
+
return existing;
|
|
517
|
+
}
|
|
518
|
+
return await getPolicy(slug);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ---- _activePoliciesForCart (shared between public + evaluate) -----
|
|
522
|
+
|
|
523
|
+
// Walks active, non-archived policies in (priority DESC, created_at
|
|
524
|
+
// ASC, slug ASC) order, applying the `order_min_minor` and
|
|
525
|
+
// `customer_segment_in` preconditions against the cart + customer.
|
|
526
|
+
// Returns the surviving policies in priority order.
|
|
527
|
+
async function _activePoliciesFor(cart, customerId) {
|
|
528
|
+
var read = _readCart(cart);
|
|
529
|
+
var rows = (await query(
|
|
530
|
+
"SELECT * FROM coupon_stacking_policies " +
|
|
531
|
+
"WHERE active = 1 AND archived_at IS NULL " +
|
|
532
|
+
"ORDER BY priority DESC, created_at ASC, slug ASC",
|
|
533
|
+
[],
|
|
534
|
+
)).rows;
|
|
535
|
+
|
|
536
|
+
var customerIdNorm = customerId == null ? null : customerId;
|
|
537
|
+
if (customerIdNorm != null && (typeof customerIdNorm !== "string" || !customerIdNorm.length)) {
|
|
538
|
+
throw new TypeError("couponStacking: customer_id must be a non-empty string when provided");
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
var out = [];
|
|
542
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
543
|
+
var p = _hydrateRow(rows[i]);
|
|
544
|
+
if (read.subtotal_minor < p.order_min_minor) continue;
|
|
545
|
+
if (p.customer_segment_in.length > 0) {
|
|
546
|
+
if (customerIdNorm == null) continue;
|
|
547
|
+
if (!customerSegments) {
|
|
548
|
+
throw new TypeError("couponStacking: policy " + JSON.stringify(p.slug) +
|
|
549
|
+
" has a customer_segment_in gate but no customerSegments handle was wired into create()");
|
|
550
|
+
}
|
|
551
|
+
if (typeof customerSegments.isMember !== "function") {
|
|
552
|
+
throw new TypeError("couponStacking: customerSegments handle must expose isMember(customer_id, segment_slug)");
|
|
553
|
+
}
|
|
554
|
+
var matched = false;
|
|
555
|
+
for (var j = 0; j < p.customer_segment_in.length; j += 1) {
|
|
556
|
+
if (await customerSegments.isMember(customerIdNorm, p.customer_segment_in[j])) {
|
|
557
|
+
matched = true;
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (!matched) continue;
|
|
562
|
+
}
|
|
563
|
+
out.push(p);
|
|
564
|
+
}
|
|
565
|
+
return out;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function activePoliciesForCart(input) {
|
|
569
|
+
if (!input || typeof input !== "object") {
|
|
570
|
+
throw new TypeError("couponStacking.activePoliciesForCart: input object required");
|
|
571
|
+
}
|
|
572
|
+
return await _activePoliciesFor(input.cart, input.customer_id);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ---- evaluate ------------------------------------------------------
|
|
576
|
+
|
|
577
|
+
async function evaluate(input) {
|
|
578
|
+
if (!input || typeof input !== "object") {
|
|
579
|
+
throw new TypeError("couponStacking.evaluate: input object required");
|
|
580
|
+
}
|
|
581
|
+
if (!Array.isArray(input.codes)) {
|
|
582
|
+
throw new TypeError("couponStacking.evaluate: codes must be an array of code strings");
|
|
583
|
+
}
|
|
584
|
+
var read = _readCart(input.cart);
|
|
585
|
+
|
|
586
|
+
// Validate every code shape up front. Duplicates inside the
|
|
587
|
+
// incoming code list are refused as a separate, application-level
|
|
588
|
+
// mistake (not a stacking-policy concern) so the operator surface
|
|
589
|
+
// sees the right error.
|
|
590
|
+
var codes = [];
|
|
591
|
+
var seenCode = Object.create(null);
|
|
592
|
+
for (var i = 0; i < input.codes.length; i += 1) {
|
|
593
|
+
var c = _code(input.codes[i], "codes[" + i + "]");
|
|
594
|
+
if (seenCode[c]) {
|
|
595
|
+
throw new TypeError("couponStacking.evaluate: codes array contains duplicate " + JSON.stringify(c));
|
|
596
|
+
}
|
|
597
|
+
seenCode[c] = true;
|
|
598
|
+
codes.push(c);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// No codes presented — nothing to refuse, nothing to apply. The
|
|
602
|
+
// quantity-discount stack travels through unchallenged in this
|
|
603
|
+
// case (the cart code path didn't ask about it because no code
|
|
604
|
+
// was offered).
|
|
605
|
+
if (codes.length === 0) {
|
|
606
|
+
return { allowed: true, applied: [], refused: [] };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
var policies = await _activePoliciesFor(input.cart, input.customer_id);
|
|
610
|
+
|
|
611
|
+
// No governing policy — conservative default. Allow the first
|
|
612
|
+
// code, refuse the rest with `no_policy_allows_stacking`. The
|
|
613
|
+
// quantity-discount stack is left untouched (a missing policy
|
|
614
|
+
// can't make a stacking decision about it).
|
|
615
|
+
if (policies.length === 0) {
|
|
616
|
+
var applied0 = [codes[0]];
|
|
617
|
+
var refused0 = [];
|
|
618
|
+
for (var k = 1; k < codes.length; k += 1) {
|
|
619
|
+
refused0.push({ code: codes[k], reason: "no_policy_allows_stacking" });
|
|
620
|
+
}
|
|
621
|
+
return { allowed: true, applied: applied0, refused: refused0 };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// The highest-priority surviving policy is the governing one.
|
|
625
|
+
var gov = policies[0];
|
|
626
|
+
|
|
627
|
+
var applied = [];
|
|
628
|
+
var refused = [];
|
|
629
|
+
|
|
630
|
+
// Exclusive-codes short-circuit. When any code in the cart
|
|
631
|
+
// appears on the governing policy's exclusive list, that code
|
|
632
|
+
// wins outright and every other code is refused with
|
|
633
|
+
// `exclusive_code_present`. Multiple exclusive codes in one cart
|
|
634
|
+
// collapse to "the first one wins" — the remaining exclusives
|
|
635
|
+
// are also refused (you can't stack two exclusives any more than
|
|
636
|
+
// an exclusive + a regular code).
|
|
637
|
+
var exclusiveSet = Object.create(null);
|
|
638
|
+
for (var e = 0; e < gov.exclusive_codes.length; e += 1) exclusiveSet[gov.exclusive_codes[e]] = true;
|
|
639
|
+
|
|
640
|
+
var presentedExclusives = codes.filter(function (cc) { return exclusiveSet[cc]; });
|
|
641
|
+
if (presentedExclusives.length > 0) {
|
|
642
|
+
applied.push(presentedExclusives[0]);
|
|
643
|
+
for (var m = 0; m < codes.length; m += 1) {
|
|
644
|
+
if (codes[m] === presentedExclusives[0]) continue;
|
|
645
|
+
refused.push({ code: codes[m], reason: "exclusive_code_present" });
|
|
646
|
+
}
|
|
647
|
+
// Quantity-discount stack also refused in the exclusive case
|
|
648
|
+
// regardless of the policy's `with_quantity_discounts` flag —
|
|
649
|
+
// "exclusive" means exclusive of everything, including
|
|
650
|
+
// automatic discounts. Surface that as the cart-level
|
|
651
|
+
// `allowed=false` only when a q-discount is actually present;
|
|
652
|
+
// otherwise the result still allows.
|
|
653
|
+
if (read.has_quantity_discount) {
|
|
654
|
+
return {
|
|
655
|
+
allowed: false,
|
|
656
|
+
applied: applied,
|
|
657
|
+
refused: refused.concat([{ code: "__quantity_discount__", reason: "quantity_discount_stack_refused" }]),
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
return { allowed: true, applied: applied, refused: refused };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// No exclusive code presented — apply the with_other_codes +
|
|
664
|
+
// max_codes_per_order rules.
|
|
665
|
+
if (codes.length === 1) {
|
|
666
|
+
applied.push(codes[0]);
|
|
667
|
+
} else if (gov.allow_combine.with_other_codes) {
|
|
668
|
+
// Multiple codes allowed up to the cap. Codes are taken in the
|
|
669
|
+
// order they were presented; over-cap codes are refused with
|
|
670
|
+
// `max_codes_per_order_exceeded`.
|
|
671
|
+
for (var p2 = 0; p2 < codes.length; p2 += 1) {
|
|
672
|
+
if (applied.length < gov.max_codes_per_order) {
|
|
673
|
+
applied.push(codes[p2]);
|
|
674
|
+
} else {
|
|
675
|
+
refused.push({ code: codes[p2], reason: "max_codes_per_order_exceeded" });
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
} else {
|
|
679
|
+
// Single-code policy — first code applies, rest refused.
|
|
680
|
+
applied.push(codes[0]);
|
|
681
|
+
for (var p3 = 1; p3 < codes.length; p3 += 1) {
|
|
682
|
+
refused.push({ code: codes[p3], reason: "other_codes_not_allowed_to_stack" });
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Quantity-discount stack rule. When a q-discount is already on
|
|
687
|
+
// the cart and the governing policy refuses
|
|
688
|
+
// `with_quantity_discounts`, the cart-level allowed flag flips
|
|
689
|
+
// off — the cart layer must drop either the codes or the
|
|
690
|
+
// q-discounts (operator UI decides which).
|
|
691
|
+
var allowed = applied.length > 0;
|
|
692
|
+
if (read.has_quantity_discount && !gov.allow_combine.with_quantity_discounts) {
|
|
693
|
+
refused.push({ code: "__quantity_discount__", reason: "quantity_discount_stack_refused" });
|
|
694
|
+
allowed = false;
|
|
695
|
+
}
|
|
696
|
+
return { allowed: allowed, applied: applied, refused: refused };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
return {
|
|
700
|
+
definePolicy: definePolicy,
|
|
701
|
+
getPolicy: getPolicy,
|
|
702
|
+
listPolicies: listPolicies,
|
|
703
|
+
updatePolicy: updatePolicy,
|
|
704
|
+
archivePolicy: archivePolicy,
|
|
705
|
+
evaluate: evaluate,
|
|
706
|
+
activePoliciesForCart: activePoliciesForCart,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
module.exports = {
|
|
711
|
+
create: create,
|
|
712
|
+
ALLOWED_COMBINE_KEYS: ALLOWED_COMBINE_KEYS,
|
|
713
|
+
ALLOWED_PATCH_COLUMNS: ALLOWED_PATCH_COLUMNS,
|
|
714
|
+
MAX_EXCLUSIVE_CODES: MAX_EXCLUSIVE_CODES,
|
|
715
|
+
MAX_SEGMENT_SLUGS: MAX_SEGMENT_SLUGS,
|
|
716
|
+
MAX_MAX_CODES: MAX_MAX_CODES,
|
|
717
|
+
};
|