@blamejs/blamejs-shop 0.0.64 → 0.0.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/lib/address-validation.js +529 -0
- package/lib/auto-discount.js +1133 -0
- package/lib/business-hours.js +980 -0
- package/lib/captcha-gate.js +961 -0
- package/lib/catalog-drafts.js +1614 -0
- package/lib/cookie-consent.js +605 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-roles.js +640 -0
- package/lib/cycle-counting.js +802 -0
- package/lib/delivery-estimate.js +1113 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/email-warmup.js +795 -0
- package/lib/index.js +30 -0
- package/lib/metered-usage.js +782 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/preorder.js +595 -0
- package/lib/price-display.js +699 -0
- package/lib/product-bulk-ops.js +797 -0
- package/lib/purchase-orders.js +923 -0
- package/lib/quotes.js +944 -0
- package/lib/recommendations.js +850 -0
- package/lib/reorder-thresholds.js +678 -0
- package/lib/shipping-zones.js +621 -0
- package/lib/site-redirects.js +690 -0
- package/lib/split-shipments.js +773 -0
- package/lib/theme-assets.js +711 -0
- package/lib/trust-badges.js +721 -0
- package/lib/webhook-receiver.js +1034 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.autoDiscount
|
|
4
|
+
* @title Automatic discounts — operator-authored rules that apply to a
|
|
5
|
+
* cart without a coupon code
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* An automatic discount answers a single question during cart
|
|
9
|
+
* resolution:
|
|
10
|
+
*
|
|
11
|
+
* "Given this cart (and optionally this customer), which rules
|
|
12
|
+
* should the cart-resolution layer apply, and what is each
|
|
13
|
+
* rule's monetary effect on this cart?"
|
|
14
|
+
*
|
|
15
|
+
* Distinct from `couponStacking` (which decides per-code
|
|
16
|
+
* combination) and from `quantityDiscounts` (which decides per-line
|
|
17
|
+
* price breaks against the catalog scope graph). An autoDiscount
|
|
18
|
+
* rule is a CART-LEVEL automatic — "free shipping over $50", "10%
|
|
19
|
+
* off when you buy 3 items from category X", "BOGO on tag SALE" —
|
|
20
|
+
* that the shopper never types.
|
|
21
|
+
*
|
|
22
|
+
* Surface:
|
|
23
|
+
*
|
|
24
|
+
* - `defineRule({ slug, title, trigger, value, applies_to?,
|
|
25
|
+
* customer_segment_in?, exclusions?,
|
|
26
|
+
* priority?, starts_at?, expires_at?,
|
|
27
|
+
* max_redemptions_total?,
|
|
28
|
+
* max_redemptions_per_customer? })`
|
|
29
|
+
* Create a new rule. The primitive enforces a tight slug
|
|
30
|
+
* shape and validates `trigger` / `value` / `applies_to` /
|
|
31
|
+
* `exclusions` at define time against the open-ended JSON
|
|
32
|
+
* vocabularies described below.
|
|
33
|
+
*
|
|
34
|
+
* - `evaluate({ cart, customer_id? })`
|
|
35
|
+
* Walks every active, non-archived, in-window rule in
|
|
36
|
+
* priority DESC order. For each rule whose trigger matches
|
|
37
|
+
* the cart, whose exclusions do not fire, whose segment
|
|
38
|
+
* (when specified) intersects the customer, and whose
|
|
39
|
+
* redemption caps are not exhausted, computes the rule's
|
|
40
|
+
* monetary effect and appends it to the result list.
|
|
41
|
+
* Returns `{ applied: [{ rule_slug, title, value_kind,
|
|
42
|
+
* savings_minor, free_shipping?, bogo_eligible? }],
|
|
43
|
+
* skipped: [{ rule_slug, reason }] }`. Multiple rules across
|
|
44
|
+
* different value kinds may apply on the same cart (an
|
|
45
|
+
* `amount_off_total` AND a `free_shipping` can coexist).
|
|
46
|
+
*
|
|
47
|
+
* - `recordApplication({ rule_slug, order_id, savings_minor,
|
|
48
|
+
* customer_id? })`
|
|
49
|
+
* Atomically increments the rule's denormalized
|
|
50
|
+
* `redemptions_used` counter and inserts a row into the
|
|
51
|
+
* `auto_discount_applications` event log. The cart-
|
|
52
|
+
* resolution layer calls this after the order commits so
|
|
53
|
+
* the cap check on subsequent `evaluate` calls sees the
|
|
54
|
+
* applied use.
|
|
55
|
+
*
|
|
56
|
+
* - `metricsForRule({ rule_slug, from?, to? })`
|
|
57
|
+
* Returns `{ rule_slug, applications, gross_savings_minor,
|
|
58
|
+
* unique_customers }` aggregated across the requested time
|
|
59
|
+
* window. NULL `from` / `to` default to "all time".
|
|
60
|
+
*
|
|
61
|
+
* - `listRules({ active_only?, limit? })` /
|
|
62
|
+
* `updateRule(slug, patch)` / `archiveRule(slug)`.
|
|
63
|
+
*
|
|
64
|
+
* Trigger kinds (`trigger.kind`):
|
|
65
|
+
*
|
|
66
|
+
* - `cart_total_min` — fires when `cart.subtotal_minor >=
|
|
67
|
+
* trigger.min_minor`. `min_minor` is a non-negative integer.
|
|
68
|
+
*
|
|
69
|
+
* - `item_count_min` — fires when the sum of cart line
|
|
70
|
+
* quantities is `>= trigger.min_count`. `min_count` is a
|
|
71
|
+
* positive integer.
|
|
72
|
+
*
|
|
73
|
+
* - `sku_purchase` — fires when the cart contains at least
|
|
74
|
+
* `trigger.min_quantity` units of any sku in `trigger.skus[]`.
|
|
75
|
+
* `min_quantity` defaults to 1; `skus` is a non-empty array
|
|
76
|
+
* of sku strings.
|
|
77
|
+
*
|
|
78
|
+
* Value kinds (`value.kind`):
|
|
79
|
+
*
|
|
80
|
+
* - `percent_off` — `value.basis_points` (0..10000) off the
|
|
81
|
+
* subtotal (or off the lines matched by `applies_to` when
|
|
82
|
+
* provided). Rounded half-away-from-zero to integer minor.
|
|
83
|
+
*
|
|
84
|
+
* - `amount_off_total` — `value.minor` (positive integer) off
|
|
85
|
+
* the cart subtotal, clamped to the subtotal floor.
|
|
86
|
+
*
|
|
87
|
+
* - `amount_off_each` — `value.minor` off each matched line
|
|
88
|
+
* unit (matched via `applies_to`, or every line when
|
|
89
|
+
* `applies_to` is null). Clamped at zero per unit.
|
|
90
|
+
*
|
|
91
|
+
* - `free_shipping` — flags the cart as eligible for free
|
|
92
|
+
* shipping; the shipping primitive consults the flag.
|
|
93
|
+
* `savings_minor` reports the shipping cost the cart would
|
|
94
|
+
* have paid (cart provides `shipping_cost_minor` when known;
|
|
95
|
+
* 0 otherwise — the storefront fills it after rate quotes).
|
|
96
|
+
*
|
|
97
|
+
* - `bogo` — for every `value.buy_qty` units of an eligible
|
|
98
|
+
* line in the cart, the next `value.get_qty` units of that
|
|
99
|
+
* same line are free. `applies_to` (when provided) restricts
|
|
100
|
+
* eligibility; without it, every line is eligible
|
|
101
|
+
* independently. Returns `bogo_eligible: [{ sku, free_qty,
|
|
102
|
+
* savings_minor }]` so the cart layer renders the per-line
|
|
103
|
+
* BOGO breakdown.
|
|
104
|
+
*
|
|
105
|
+
* Applies-to filter (`applies_to`, optional):
|
|
106
|
+
*
|
|
107
|
+
* - `{ kind: "sku", values: [<sku>, ...] }` — match lines whose
|
|
108
|
+
* sku appears in the list.
|
|
109
|
+
*
|
|
110
|
+
* - `{ kind: "category", values: [<cat>, ...] }` — match lines
|
|
111
|
+
* whose `category` field appears in the list, OR whose `tags`
|
|
112
|
+
* array contains any list entry. The line shape the cart
|
|
113
|
+
* provides may carry either form.
|
|
114
|
+
*
|
|
115
|
+
* - NULL = "whole cart" (the value kind decides the surface
|
|
116
|
+
* — `percent_off` discounts the subtotal, `amount_off_each`
|
|
117
|
+
* discounts every line, etc.).
|
|
118
|
+
*
|
|
119
|
+
* Exclusions:
|
|
120
|
+
*
|
|
121
|
+
* - `{ kind: "sku", value: <sku> }` — if the cart contains the
|
|
122
|
+
* sku, the rule is skipped with `reason:
|
|
123
|
+
* "excluded_sku_present"`.
|
|
124
|
+
*
|
|
125
|
+
* - `{ kind: "category", value: <cat> }` — if any line is
|
|
126
|
+
* tagged with the category, the rule is skipped with
|
|
127
|
+
* `reason: "excluded_category_present"`.
|
|
128
|
+
*
|
|
129
|
+
* Composition:
|
|
130
|
+
*
|
|
131
|
+
* - `catalog` (optional handle, shape:
|
|
132
|
+
* `getLineMeta(sku) -> Promise<{ category?, tags? }>`).
|
|
133
|
+
* Consulted ONLY when a rule's `applies_to.kind === "category"`
|
|
134
|
+
* or an exclusion's `kind === "category"` and the cart line
|
|
135
|
+
* did not embed the metadata directly. Most carts already
|
|
136
|
+
* carry `category` / `tags` on each line because they came
|
|
137
|
+
* from the catalog primitive in the same request; the handle
|
|
138
|
+
* is the fallback for line shapes that didn't.
|
|
139
|
+
*
|
|
140
|
+
* - `customerSegments` (optional handle, shape:
|
|
141
|
+
* `isMember(customer_id, segment_slug) -> Promise<bool>`).
|
|
142
|
+
* Required only when at least one rule has a non-empty
|
|
143
|
+
* `customer_segment_in`; absent that, evaluate runs without
|
|
144
|
+
* consulting any segments handle.
|
|
145
|
+
*
|
|
146
|
+
* - The `cart` argument is the storefront cart shape:
|
|
147
|
+
* `{ subtotal_minor, shipping_cost_minor?, lines: [{ sku,
|
|
148
|
+
* quantity, unit_price_minor, category?, tags? }] }`.
|
|
149
|
+
*
|
|
150
|
+
* Storage:
|
|
151
|
+
*
|
|
152
|
+
* - `auto_discount_rules` and `auto_discount_applications`
|
|
153
|
+
* (migration `0107_auto_discount.sql`).
|
|
154
|
+
*
|
|
155
|
+
* @primitive autoDiscount
|
|
156
|
+
* @related couponStacking, quantityDiscounts, customerSegments
|
|
157
|
+
*/
|
|
158
|
+
|
|
159
|
+
// ---- constants ----------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
var MAX_SLUG_LEN = 80;
|
|
162
|
+
var MAX_TITLE_LEN = 200;
|
|
163
|
+
var MAX_SKU_LEN = 128;
|
|
164
|
+
var MAX_CATEGORY_LEN = 128;
|
|
165
|
+
var MAX_APPLIES_TO_VALUES = 256;
|
|
166
|
+
var MAX_EXCLUSIONS = 64;
|
|
167
|
+
var MAX_SEGMENT_SLUGS = 32;
|
|
168
|
+
var MAX_SEGMENT_SLUG_LEN = 64;
|
|
169
|
+
var MAX_TRIGGER_SKUS = 256;
|
|
170
|
+
var MAX_PRIORITY = 1000000;
|
|
171
|
+
var MAX_LIST_LIMIT = 500;
|
|
172
|
+
var MAX_BASIS_POINTS = 10000;
|
|
173
|
+
var MAX_MINOR_VALUE = 1e12;
|
|
174
|
+
var MAX_BOGO_QTY = 1000;
|
|
175
|
+
|
|
176
|
+
// Slug shape — alnum + dot + hyphen + underscore, alnum leading.
|
|
177
|
+
var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
178
|
+
// Sku shape — same family the catalog primitive uses (alnum + dot +
|
|
179
|
+
// hyphen + underscore + slash + colon), tight enough to refuse
|
|
180
|
+
// whitespace + control bytes.
|
|
181
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
|
182
|
+
// Category / tag — lowercase alnum + hyphen + underscore.
|
|
183
|
+
var CATEGORY_RE = /^[a-z0-9][a-z0-9_-]{0,127}$/;
|
|
184
|
+
var SEGMENT_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
185
|
+
|
|
186
|
+
var VALID_TRIGGER_KINDS = Object.freeze([
|
|
187
|
+
"cart_total_min",
|
|
188
|
+
"item_count_min",
|
|
189
|
+
"sku_purchase",
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
var VALID_VALUE_KINDS = Object.freeze([
|
|
193
|
+
"percent_off",
|
|
194
|
+
"amount_off_total",
|
|
195
|
+
"amount_off_each",
|
|
196
|
+
"free_shipping",
|
|
197
|
+
"bogo",
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
var VALID_APPLIES_TO_KINDS = Object.freeze(["sku", "category"]);
|
|
201
|
+
|
|
202
|
+
var VALID_EXCLUSION_KINDS = Object.freeze(["sku", "category"]);
|
|
203
|
+
|
|
204
|
+
var ALLOWED_PATCH_COLUMNS = Object.freeze([
|
|
205
|
+
"title",
|
|
206
|
+
"trigger",
|
|
207
|
+
"value",
|
|
208
|
+
"applies_to",
|
|
209
|
+
"customer_segment_in",
|
|
210
|
+
"exclusions",
|
|
211
|
+
"priority",
|
|
212
|
+
"starts_at",
|
|
213
|
+
"expires_at",
|
|
214
|
+
"max_redemptions_total",
|
|
215
|
+
"max_redemptions_per_customer",
|
|
216
|
+
"active",
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
var bShop;
|
|
220
|
+
function _b() {
|
|
221
|
+
if (!bShop) bShop = require("./index");
|
|
222
|
+
return bShop.framework;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---- validators ---------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
function _slug(s) {
|
|
228
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
229
|
+
throw new TypeError("autoDiscount: slug must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_SLUG_LEN + " chars)");
|
|
230
|
+
}
|
|
231
|
+
return s;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function _title(s) {
|
|
235
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
|
|
236
|
+
throw new TypeError("autoDiscount: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
|
|
237
|
+
}
|
|
238
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
239
|
+
throw new TypeError("autoDiscount: title must not contain control bytes");
|
|
240
|
+
}
|
|
241
|
+
return s;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function _sku(s, label) {
|
|
245
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
246
|
+
throw new TypeError("autoDiscount: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/ (<= " + MAX_SKU_LEN + " chars)");
|
|
247
|
+
}
|
|
248
|
+
return s;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function _category(s, label) {
|
|
252
|
+
if (typeof s !== "string" || !CATEGORY_RE.test(s)) {
|
|
253
|
+
throw new TypeError("autoDiscount: " + label + " must match /^[a-z0-9][a-z0-9_-]*$/ (<= " + MAX_CATEGORY_LEN + " chars)");
|
|
254
|
+
}
|
|
255
|
+
return s;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function _segmentSlug(s) {
|
|
259
|
+
if (typeof s !== "string" || !SEGMENT_SLUG_RE.test(s)) {
|
|
260
|
+
throw new TypeError("autoDiscount: customer_segment_in entries must match /^[a-z0-9][a-z0-9_-]*$/ (<= " + MAX_SEGMENT_SLUG_LEN + " chars)");
|
|
261
|
+
}
|
|
262
|
+
return s;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function _posInt(n, label, max) {
|
|
266
|
+
if (!Number.isInteger(n) || n < 1 || n > (max || MAX_MINOR_VALUE)) {
|
|
267
|
+
throw new TypeError("autoDiscount: " + label + " must be a positive integer (<= " + (max || MAX_MINOR_VALUE) + ")");
|
|
268
|
+
}
|
|
269
|
+
return n;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function _nonNegInt(n, label, max) {
|
|
273
|
+
if (!Number.isInteger(n) || n < 0 || n > (max || MAX_MINOR_VALUE)) {
|
|
274
|
+
throw new TypeError("autoDiscount: " + label + " must be a non-negative integer (<= " + (max || MAX_MINOR_VALUE) + ")");
|
|
275
|
+
}
|
|
276
|
+
return n;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function _priority(n) {
|
|
280
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_PRIORITY) {
|
|
281
|
+
throw new TypeError("autoDiscount: priority must be an integer in [0, " + MAX_PRIORITY + "]");
|
|
282
|
+
}
|
|
283
|
+
return n;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function _epochMs(n, label) {
|
|
287
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
288
|
+
throw new TypeError("autoDiscount: " + label + " must be a non-negative integer (epoch ms)");
|
|
289
|
+
}
|
|
290
|
+
return n;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function _bool(v, label) {
|
|
294
|
+
if (typeof v !== "boolean") {
|
|
295
|
+
throw new TypeError("autoDiscount: " + label + " must be a boolean");
|
|
296
|
+
}
|
|
297
|
+
return v;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function _trigger(v) {
|
|
301
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) {
|
|
302
|
+
throw new TypeError("autoDiscount: trigger must be an object");
|
|
303
|
+
}
|
|
304
|
+
if (VALID_TRIGGER_KINDS.indexOf(v.kind) === -1) {
|
|
305
|
+
throw new TypeError("autoDiscount: trigger.kind must be one of " + JSON.stringify(VALID_TRIGGER_KINDS));
|
|
306
|
+
}
|
|
307
|
+
if (v.kind === "cart_total_min") {
|
|
308
|
+
var minMinor = v.min_minor;
|
|
309
|
+
if (minMinor == null) {
|
|
310
|
+
throw new TypeError("autoDiscount: trigger.min_minor required for cart_total_min");
|
|
311
|
+
}
|
|
312
|
+
_nonNegInt(minMinor, "trigger.min_minor");
|
|
313
|
+
return { kind: "cart_total_min", min_minor: minMinor };
|
|
314
|
+
}
|
|
315
|
+
if (v.kind === "item_count_min") {
|
|
316
|
+
var minCount = v.min_count;
|
|
317
|
+
if (minCount == null) {
|
|
318
|
+
throw new TypeError("autoDiscount: trigger.min_count required for item_count_min");
|
|
319
|
+
}
|
|
320
|
+
_posInt(minCount, "trigger.min_count", 100000);
|
|
321
|
+
return { kind: "item_count_min", min_count: minCount };
|
|
322
|
+
}
|
|
323
|
+
// sku_purchase
|
|
324
|
+
if (!Array.isArray(v.skus) || v.skus.length === 0) {
|
|
325
|
+
throw new TypeError("autoDiscount: trigger.skus must be a non-empty array");
|
|
326
|
+
}
|
|
327
|
+
if (v.skus.length > MAX_TRIGGER_SKUS) {
|
|
328
|
+
throw new TypeError("autoDiscount: trigger.skus length " + v.skus.length + " exceeds cap " + MAX_TRIGGER_SKUS);
|
|
329
|
+
}
|
|
330
|
+
var seenSku = Object.create(null);
|
|
331
|
+
var skus = [];
|
|
332
|
+
for (var i = 0; i < v.skus.length; i += 1) {
|
|
333
|
+
var s = _sku(v.skus[i], "trigger.skus[" + i + "]");
|
|
334
|
+
if (seenSku[s]) {
|
|
335
|
+
throw new TypeError("autoDiscount: trigger.skus contains duplicate " + JSON.stringify(s));
|
|
336
|
+
}
|
|
337
|
+
seenSku[s] = true;
|
|
338
|
+
skus.push(s);
|
|
339
|
+
}
|
|
340
|
+
var minQty = v.min_quantity == null ? 1 : v.min_quantity;
|
|
341
|
+
_posInt(minQty, "trigger.min_quantity", 100000);
|
|
342
|
+
return { kind: "sku_purchase", skus: skus, min_quantity: minQty };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function _value(v) {
|
|
346
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) {
|
|
347
|
+
throw new TypeError("autoDiscount: value must be an object");
|
|
348
|
+
}
|
|
349
|
+
if (VALID_VALUE_KINDS.indexOf(v.kind) === -1) {
|
|
350
|
+
throw new TypeError("autoDiscount: value.kind must be one of " + JSON.stringify(VALID_VALUE_KINDS));
|
|
351
|
+
}
|
|
352
|
+
if (v.kind === "percent_off") {
|
|
353
|
+
var bp = v.basis_points;
|
|
354
|
+
if (!Number.isInteger(bp) || bp < 1 || bp > MAX_BASIS_POINTS) {
|
|
355
|
+
throw new TypeError("autoDiscount: value.basis_points must be an integer in [1, " + MAX_BASIS_POINTS + "]");
|
|
356
|
+
}
|
|
357
|
+
return { kind: "percent_off", basis_points: bp };
|
|
358
|
+
}
|
|
359
|
+
if (v.kind === "amount_off_total" || v.kind === "amount_off_each") {
|
|
360
|
+
var minor = v.minor;
|
|
361
|
+
if (!Number.isInteger(minor) || minor < 1 || minor > MAX_MINOR_VALUE) {
|
|
362
|
+
throw new TypeError("autoDiscount: value.minor must be a positive integer (<= " + MAX_MINOR_VALUE + ")");
|
|
363
|
+
}
|
|
364
|
+
return { kind: v.kind, minor: minor };
|
|
365
|
+
}
|
|
366
|
+
if (v.kind === "free_shipping") {
|
|
367
|
+
return { kind: "free_shipping" };
|
|
368
|
+
}
|
|
369
|
+
// bogo
|
|
370
|
+
var buyQty = v.buy_qty;
|
|
371
|
+
var getQty = v.get_qty;
|
|
372
|
+
if (!Number.isInteger(buyQty) || buyQty < 1 || buyQty > MAX_BOGO_QTY) {
|
|
373
|
+
throw new TypeError("autoDiscount: value.buy_qty must be a positive integer (<= " + MAX_BOGO_QTY + ")");
|
|
374
|
+
}
|
|
375
|
+
if (!Number.isInteger(getQty) || getQty < 1 || getQty > MAX_BOGO_QTY) {
|
|
376
|
+
throw new TypeError("autoDiscount: value.get_qty must be a positive integer (<= " + MAX_BOGO_QTY + ")");
|
|
377
|
+
}
|
|
378
|
+
return { kind: "bogo", buy_qty: buyQty, get_qty: getQty };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function _appliesTo(v) {
|
|
382
|
+
if (v == null) return null;
|
|
383
|
+
if (typeof v !== "object" || Array.isArray(v)) {
|
|
384
|
+
throw new TypeError("autoDiscount: applies_to must be an object or null");
|
|
385
|
+
}
|
|
386
|
+
if (VALID_APPLIES_TO_KINDS.indexOf(v.kind) === -1) {
|
|
387
|
+
throw new TypeError("autoDiscount: applies_to.kind must be one of " + JSON.stringify(VALID_APPLIES_TO_KINDS));
|
|
388
|
+
}
|
|
389
|
+
if (!Array.isArray(v.values) || v.values.length === 0) {
|
|
390
|
+
throw new TypeError("autoDiscount: applies_to.values must be a non-empty array");
|
|
391
|
+
}
|
|
392
|
+
if (v.values.length > MAX_APPLIES_TO_VALUES) {
|
|
393
|
+
throw new TypeError("autoDiscount: applies_to.values length " + v.values.length + " exceeds cap " + MAX_APPLIES_TO_VALUES);
|
|
394
|
+
}
|
|
395
|
+
var seen = Object.create(null);
|
|
396
|
+
var values = [];
|
|
397
|
+
for (var i = 0; i < v.values.length; i += 1) {
|
|
398
|
+
var entry = v.kind === "sku"
|
|
399
|
+
? _sku(v.values[i], "applies_to.values[" + i + "]")
|
|
400
|
+
: _category(v.values[i], "applies_to.values[" + i + "]");
|
|
401
|
+
if (seen[entry]) {
|
|
402
|
+
throw new TypeError("autoDiscount: applies_to.values contains duplicate " + JSON.stringify(entry));
|
|
403
|
+
}
|
|
404
|
+
seen[entry] = true;
|
|
405
|
+
values.push(entry);
|
|
406
|
+
}
|
|
407
|
+
return { kind: v.kind, values: values };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function _exclusions(v) {
|
|
411
|
+
if (v == null) return [];
|
|
412
|
+
if (!Array.isArray(v)) {
|
|
413
|
+
throw new TypeError("autoDiscount: exclusions must be an array");
|
|
414
|
+
}
|
|
415
|
+
if (v.length > MAX_EXCLUSIONS) {
|
|
416
|
+
throw new TypeError("autoDiscount: exclusions length " + v.length + " exceeds cap " + MAX_EXCLUSIONS);
|
|
417
|
+
}
|
|
418
|
+
var out = [];
|
|
419
|
+
for (var i = 0; i < v.length; i += 1) {
|
|
420
|
+
var e = v[i];
|
|
421
|
+
if (!e || typeof e !== "object") {
|
|
422
|
+
throw new TypeError("autoDiscount: exclusions[" + i + "] must be an object");
|
|
423
|
+
}
|
|
424
|
+
if (VALID_EXCLUSION_KINDS.indexOf(e.kind) === -1) {
|
|
425
|
+
throw new TypeError("autoDiscount: exclusions[" + i + "].kind must be one of " + JSON.stringify(VALID_EXCLUSION_KINDS));
|
|
426
|
+
}
|
|
427
|
+
var val = e.kind === "sku"
|
|
428
|
+
? _sku(e.value, "exclusions[" + i + "].value")
|
|
429
|
+
: _category(e.value, "exclusions[" + i + "].value");
|
|
430
|
+
out.push({ kind: e.kind, value: val });
|
|
431
|
+
}
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function _customerSegmentIn(v) {
|
|
436
|
+
if (v == null) return [];
|
|
437
|
+
if (!Array.isArray(v)) {
|
|
438
|
+
throw new TypeError("autoDiscount: customer_segment_in must be an array of segment slugs");
|
|
439
|
+
}
|
|
440
|
+
if (v.length > MAX_SEGMENT_SLUGS) {
|
|
441
|
+
throw new TypeError("autoDiscount: customer_segment_in length " + v.length + " exceeds cap " + MAX_SEGMENT_SLUGS);
|
|
442
|
+
}
|
|
443
|
+
var seen = Object.create(null);
|
|
444
|
+
var out = [];
|
|
445
|
+
for (var i = 0; i < v.length; i += 1) {
|
|
446
|
+
var s = _segmentSlug(v[i]);
|
|
447
|
+
if (seen[s]) {
|
|
448
|
+
throw new TypeError("autoDiscount: customer_segment_in contains duplicate " + JSON.stringify(s));
|
|
449
|
+
}
|
|
450
|
+
seen[s] = true;
|
|
451
|
+
out.push(s);
|
|
452
|
+
}
|
|
453
|
+
return out;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function _now() { return Date.now(); }
|
|
457
|
+
|
|
458
|
+
// ---- row hydration ------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
function _safeParseObject(s) {
|
|
461
|
+
if (s == null) return null;
|
|
462
|
+
try {
|
|
463
|
+
var parsed = JSON.parse(s);
|
|
464
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
465
|
+
return null;
|
|
466
|
+
} catch (_e) {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function _safeParseArray(s) {
|
|
472
|
+
if (s == null) return [];
|
|
473
|
+
try {
|
|
474
|
+
var parsed = JSON.parse(s);
|
|
475
|
+
if (Array.isArray(parsed)) return parsed;
|
|
476
|
+
return [];
|
|
477
|
+
} catch (_e) {
|
|
478
|
+
return [];
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function _hydrateRow(r) {
|
|
483
|
+
if (!r) return null;
|
|
484
|
+
return {
|
|
485
|
+
slug: r.slug,
|
|
486
|
+
title: r.title,
|
|
487
|
+
trigger: _safeParseObject(r.trigger_json) || {},
|
|
488
|
+
value: _safeParseObject(r.value_json) || {},
|
|
489
|
+
applies_to: _safeParseObject(r.applies_to_json),
|
|
490
|
+
customer_segment_in: _safeParseArray(r.customer_segment_in_json),
|
|
491
|
+
exclusions: _safeParseArray(r.exclusions_json),
|
|
492
|
+
priority: Number(r.priority),
|
|
493
|
+
starts_at: r.starts_at == null ? null : Number(r.starts_at),
|
|
494
|
+
expires_at: r.expires_at == null ? null : Number(r.expires_at),
|
|
495
|
+
max_redemptions_total: r.max_redemptions_total == null ? null : Number(r.max_redemptions_total),
|
|
496
|
+
max_redemptions_per_customer: r.max_redemptions_per_customer == null ? null : Number(r.max_redemptions_per_customer),
|
|
497
|
+
active: r.active === 1 || r.active === true,
|
|
498
|
+
archived_at: r.archived_at == null ? null : Number(r.archived_at),
|
|
499
|
+
redemptions_used: Number(r.redemptions_used) || 0,
|
|
500
|
+
created_at: Number(r.created_at),
|
|
501
|
+
updated_at: Number(r.updated_at),
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ---- cart-shape reader (defensive) --------------------------------------
|
|
506
|
+
|
|
507
|
+
function _readCart(cart) {
|
|
508
|
+
if (!cart || typeof cart !== "object") {
|
|
509
|
+
throw new TypeError("autoDiscount: cart object required");
|
|
510
|
+
}
|
|
511
|
+
var subtotal = cart.subtotal_minor;
|
|
512
|
+
if (subtotal == null) subtotal = 0;
|
|
513
|
+
if (!Number.isInteger(subtotal) || subtotal < 0) {
|
|
514
|
+
throw new TypeError("autoDiscount: cart.subtotal_minor must be a non-negative integer when present");
|
|
515
|
+
}
|
|
516
|
+
var shipping = cart.shipping_cost_minor;
|
|
517
|
+
if (shipping == null) shipping = 0;
|
|
518
|
+
if (!Number.isInteger(shipping) || shipping < 0) {
|
|
519
|
+
throw new TypeError("autoDiscount: cart.shipping_cost_minor must be a non-negative integer when present");
|
|
520
|
+
}
|
|
521
|
+
var lines = cart.lines;
|
|
522
|
+
if (lines == null) lines = [];
|
|
523
|
+
if (!Array.isArray(lines)) {
|
|
524
|
+
throw new TypeError("autoDiscount: cart.lines must be an array when present");
|
|
525
|
+
}
|
|
526
|
+
var normLines = [];
|
|
527
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
528
|
+
var L = lines[i];
|
|
529
|
+
if (!L || typeof L !== "object") {
|
|
530
|
+
throw new TypeError("autoDiscount: cart.lines[" + i + "] must be an object");
|
|
531
|
+
}
|
|
532
|
+
if (typeof L.sku !== "string" || !L.sku.length) {
|
|
533
|
+
throw new TypeError("autoDiscount: cart.lines[" + i + "].sku required");
|
|
534
|
+
}
|
|
535
|
+
if (!Number.isInteger(L.quantity) || L.quantity < 1) {
|
|
536
|
+
throw new TypeError("autoDiscount: cart.lines[" + i + "].quantity must be a positive integer");
|
|
537
|
+
}
|
|
538
|
+
if (!Number.isInteger(L.unit_price_minor) || L.unit_price_minor < 0) {
|
|
539
|
+
throw new TypeError("autoDiscount: cart.lines[" + i + "].unit_price_minor must be a non-negative integer");
|
|
540
|
+
}
|
|
541
|
+
var tags = Array.isArray(L.tags) ? L.tags.slice() : [];
|
|
542
|
+
normLines.push({
|
|
543
|
+
sku: L.sku,
|
|
544
|
+
quantity: L.quantity,
|
|
545
|
+
unit_price_minor: L.unit_price_minor,
|
|
546
|
+
category: typeof L.category === "string" ? L.category : null,
|
|
547
|
+
tags: tags,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
subtotal_minor: subtotal,
|
|
552
|
+
shipping_cost_minor: shipping,
|
|
553
|
+
lines: normLines,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// ---- line metadata resolver --------------------------------------------
|
|
558
|
+
|
|
559
|
+
// Returns the (category, tags) for a line. Prefers the line's
|
|
560
|
+
// embedded metadata; falls back to the catalog handle when wired and
|
|
561
|
+
// the line shape is sparse.
|
|
562
|
+
async function _resolveLineMeta(line, catalog) {
|
|
563
|
+
var category = line.category;
|
|
564
|
+
var tags = Array.isArray(line.tags) ? line.tags : [];
|
|
565
|
+
if (category != null || tags.length > 0) return { category: category, tags: tags };
|
|
566
|
+
if (!catalog || typeof catalog.getLineMeta !== "function") return { category: null, tags: [] };
|
|
567
|
+
try {
|
|
568
|
+
var meta = await catalog.getLineMeta(line.sku);
|
|
569
|
+
if (meta && typeof meta === "object") {
|
|
570
|
+
return {
|
|
571
|
+
category: typeof meta.category === "string" ? meta.category : null,
|
|
572
|
+
tags: Array.isArray(meta.tags) ? meta.tags : [],
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
} catch (_e) { // drop-silent — best-effort enrichment; missing metadata collapses to "no match" which is the safe default
|
|
576
|
+
return { category: null, tags: [] };
|
|
577
|
+
}
|
|
578
|
+
return { category: null, tags: [] };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function _lineMatches(lineMeta, sku, appliesTo) {
|
|
582
|
+
if (!appliesTo) return true;
|
|
583
|
+
if (appliesTo.kind === "sku") return appliesTo.values.indexOf(sku) !== -1;
|
|
584
|
+
// category — match either L.category OR any tag in L.tags
|
|
585
|
+
if (lineMeta.category != null && appliesTo.values.indexOf(lineMeta.category) !== -1) return true;
|
|
586
|
+
for (var i = 0; i < lineMeta.tags.length; i += 1) {
|
|
587
|
+
if (appliesTo.values.indexOf(lineMeta.tags[i]) !== -1) return true;
|
|
588
|
+
}
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ---- factory ------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
function create(opts) {
|
|
595
|
+
opts = opts || {};
|
|
596
|
+
var query = opts.query;
|
|
597
|
+
if (!query) {
|
|
598
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
599
|
+
}
|
|
600
|
+
var catalog = opts.catalog || null;
|
|
601
|
+
var customerSegments = opts.customerSegments || null;
|
|
602
|
+
|
|
603
|
+
// ---- defineRule ----------------------------------------------------
|
|
604
|
+
|
|
605
|
+
async function defineRule(input) {
|
|
606
|
+
if (!input || typeof input !== "object") {
|
|
607
|
+
throw new TypeError("autoDiscount.defineRule: input object required");
|
|
608
|
+
}
|
|
609
|
+
var slug = _slug(input.slug);
|
|
610
|
+
var title = _title(input.title);
|
|
611
|
+
var trigger = _trigger(input.trigger);
|
|
612
|
+
var value = _value(input.value);
|
|
613
|
+
var appliesTo = _appliesTo(input.applies_to);
|
|
614
|
+
var customerSegmentIn = _customerSegmentIn(input.customer_segment_in);
|
|
615
|
+
var exclusions = _exclusions(input.exclusions);
|
|
616
|
+
var priority = input.priority == null ? 0 : _priority(input.priority);
|
|
617
|
+
var startsAt = input.starts_at == null ? null : _epochMs(input.starts_at, "starts_at");
|
|
618
|
+
var expiresAt = input.expires_at == null ? null : _epochMs(input.expires_at, "expires_at");
|
|
619
|
+
if (startsAt != null && expiresAt != null && expiresAt <= startsAt) {
|
|
620
|
+
throw new TypeError("autoDiscount.defineRule: expires_at must be greater than starts_at");
|
|
621
|
+
}
|
|
622
|
+
var maxTotal = input.max_redemptions_total == null ? null : _nonNegInt(input.max_redemptions_total, "max_redemptions_total");
|
|
623
|
+
var maxPerCustomer = input.max_redemptions_per_customer == null ? null : _nonNegInt(input.max_redemptions_per_customer, "max_redemptions_per_customer");
|
|
624
|
+
|
|
625
|
+
var existing = (await query(
|
|
626
|
+
"SELECT slug FROM auto_discount_rules WHERE slug = ?1 LIMIT 1",
|
|
627
|
+
[slug],
|
|
628
|
+
)).rows[0];
|
|
629
|
+
if (existing) {
|
|
630
|
+
throw new TypeError("autoDiscount.defineRule: slug " + JSON.stringify(slug) + " already exists -- use updateRule");
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
var ts = _now();
|
|
634
|
+
await query(
|
|
635
|
+
"INSERT INTO auto_discount_rules (slug, title, trigger_json, value_json, applies_to_json, " +
|
|
636
|
+
"customer_segment_in_json, exclusions_json, priority, starts_at, expires_at, " +
|
|
637
|
+
"max_redemptions_total, max_redemptions_per_customer, active, archived_at, " +
|
|
638
|
+
"redemptions_used, created_at, updated_at) " +
|
|
639
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 1, NULL, 0, ?13, ?13)",
|
|
640
|
+
[
|
|
641
|
+
slug,
|
|
642
|
+
title,
|
|
643
|
+
JSON.stringify(trigger),
|
|
644
|
+
JSON.stringify(value),
|
|
645
|
+
appliesTo == null ? null : JSON.stringify(appliesTo),
|
|
646
|
+
JSON.stringify(customerSegmentIn),
|
|
647
|
+
JSON.stringify(exclusions),
|
|
648
|
+
priority,
|
|
649
|
+
startsAt,
|
|
650
|
+
expiresAt,
|
|
651
|
+
maxTotal,
|
|
652
|
+
maxPerCustomer,
|
|
653
|
+
ts,
|
|
654
|
+
],
|
|
655
|
+
);
|
|
656
|
+
return await getRule(slug);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ---- getRule / listRules -------------------------------------------
|
|
660
|
+
|
|
661
|
+
async function getRule(slug) {
|
|
662
|
+
_slug(slug);
|
|
663
|
+
var r = (await query(
|
|
664
|
+
"SELECT * FROM auto_discount_rules WHERE slug = ?1 LIMIT 1",
|
|
665
|
+
[slug],
|
|
666
|
+
)).rows[0];
|
|
667
|
+
return _hydrateRow(r);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function listRules(listOpts) {
|
|
671
|
+
listOpts = listOpts || {};
|
|
672
|
+
var activeOnly = false;
|
|
673
|
+
if (listOpts.active_only != null) {
|
|
674
|
+
activeOnly = _bool(listOpts.active_only, "active_only");
|
|
675
|
+
}
|
|
676
|
+
var limit = listOpts.limit == null ? 50 : listOpts.limit;
|
|
677
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
|
|
678
|
+
throw new TypeError("autoDiscount.listRules: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
|
|
679
|
+
}
|
|
680
|
+
var sql;
|
|
681
|
+
if (activeOnly) {
|
|
682
|
+
sql = "SELECT * FROM auto_discount_rules WHERE active = 1 AND archived_at IS NULL " +
|
|
683
|
+
"ORDER BY priority DESC, created_at ASC, slug ASC LIMIT ?1";
|
|
684
|
+
} else {
|
|
685
|
+
sql = "SELECT * FROM auto_discount_rules " +
|
|
686
|
+
"ORDER BY priority DESC, created_at ASC, slug ASC LIMIT ?1";
|
|
687
|
+
}
|
|
688
|
+
var rows = (await query(sql, [limit])).rows;
|
|
689
|
+
var out = [];
|
|
690
|
+
for (var i = 0; i < rows.length; i += 1) out.push(_hydrateRow(rows[i]));
|
|
691
|
+
return out;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ---- updateRule ----------------------------------------------------
|
|
695
|
+
|
|
696
|
+
async function updateRule(slug, patch) {
|
|
697
|
+
_slug(slug);
|
|
698
|
+
if (!patch || typeof patch !== "object") {
|
|
699
|
+
throw new TypeError("autoDiscount.updateRule: patch object required");
|
|
700
|
+
}
|
|
701
|
+
var keys = Object.keys(patch);
|
|
702
|
+
if (!keys.length) {
|
|
703
|
+
throw new TypeError("autoDiscount.updateRule: patch must include at least one column");
|
|
704
|
+
}
|
|
705
|
+
var current = await getRule(slug);
|
|
706
|
+
if (!current) {
|
|
707
|
+
throw new TypeError("autoDiscount.updateRule: slug " + JSON.stringify(slug) + " not found");
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
var sets = [];
|
|
711
|
+
var params = [];
|
|
712
|
+
var idx = 1;
|
|
713
|
+
|
|
714
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
715
|
+
var col = keys[i];
|
|
716
|
+
if (ALLOWED_PATCH_COLUMNS.indexOf(col) === -1) {
|
|
717
|
+
throw new TypeError("autoDiscount.updateRule: unsupported column " + JSON.stringify(col));
|
|
718
|
+
}
|
|
719
|
+
if (col === "title") {
|
|
720
|
+
sets.push("title = ?" + idx);
|
|
721
|
+
params.push(_title(patch[col]));
|
|
722
|
+
} else if (col === "trigger") {
|
|
723
|
+
sets.push("trigger_json = ?" + idx);
|
|
724
|
+
params.push(JSON.stringify(_trigger(patch[col])));
|
|
725
|
+
} else if (col === "value") {
|
|
726
|
+
sets.push("value_json = ?" + idx);
|
|
727
|
+
params.push(JSON.stringify(_value(patch[col])));
|
|
728
|
+
} else if (col === "applies_to") {
|
|
729
|
+
var at = _appliesTo(patch[col]);
|
|
730
|
+
sets.push("applies_to_json = ?" + idx);
|
|
731
|
+
params.push(at == null ? null : JSON.stringify(at));
|
|
732
|
+
} else if (col === "customer_segment_in") {
|
|
733
|
+
sets.push("customer_segment_in_json = ?" + idx);
|
|
734
|
+
params.push(JSON.stringify(_customerSegmentIn(patch[col])));
|
|
735
|
+
} else if (col === "exclusions") {
|
|
736
|
+
sets.push("exclusions_json = ?" + idx);
|
|
737
|
+
params.push(JSON.stringify(_exclusions(patch[col])));
|
|
738
|
+
} else if (col === "priority") {
|
|
739
|
+
sets.push("priority = ?" + idx);
|
|
740
|
+
params.push(_priority(patch[col]));
|
|
741
|
+
} else if (col === "starts_at") {
|
|
742
|
+
sets.push("starts_at = ?" + idx);
|
|
743
|
+
params.push(patch[col] == null ? null : _epochMs(patch[col], "starts_at"));
|
|
744
|
+
} else if (col === "expires_at") {
|
|
745
|
+
sets.push("expires_at = ?" + idx);
|
|
746
|
+
params.push(patch[col] == null ? null : _epochMs(patch[col], "expires_at"));
|
|
747
|
+
} else if (col === "max_redemptions_total") {
|
|
748
|
+
sets.push("max_redemptions_total = ?" + idx);
|
|
749
|
+
params.push(patch[col] == null ? null : _nonNegInt(patch[col], "max_redemptions_total"));
|
|
750
|
+
} else if (col === "max_redemptions_per_customer") {
|
|
751
|
+
sets.push("max_redemptions_per_customer = ?" + idx);
|
|
752
|
+
params.push(patch[col] == null ? null : _nonNegInt(patch[col], "max_redemptions_per_customer"));
|
|
753
|
+
} else /* active */ {
|
|
754
|
+
sets.push("active = ?" + idx);
|
|
755
|
+
params.push(_bool(patch[col], "active") ? 1 : 0);
|
|
756
|
+
}
|
|
757
|
+
idx += 1;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
sets.push("updated_at = ?" + idx);
|
|
761
|
+
params.push(_now());
|
|
762
|
+
idx += 1;
|
|
763
|
+
params.push(slug);
|
|
764
|
+
|
|
765
|
+
var r = await query(
|
|
766
|
+
"UPDATE auto_discount_rules SET " + sets.join(", ") + " WHERE slug = ?" + idx,
|
|
767
|
+
params,
|
|
768
|
+
);
|
|
769
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
770
|
+
throw new TypeError("autoDiscount.updateRule: slug " + JSON.stringify(slug) + " not found");
|
|
771
|
+
}
|
|
772
|
+
return await getRule(slug);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ---- archiveRule ---------------------------------------------------
|
|
776
|
+
|
|
777
|
+
async function archiveRule(slug) {
|
|
778
|
+
_slug(slug);
|
|
779
|
+
var ts = _now();
|
|
780
|
+
var r = await query(
|
|
781
|
+
"UPDATE auto_discount_rules SET archived_at = ?1, active = 0, updated_at = ?1 " +
|
|
782
|
+
"WHERE slug = ?2 AND archived_at IS NULL",
|
|
783
|
+
[ts, slug],
|
|
784
|
+
);
|
|
785
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
786
|
+
var existing = await getRule(slug);
|
|
787
|
+
if (!existing) {
|
|
788
|
+
throw new TypeError("autoDiscount.archiveRule: slug " + JSON.stringify(slug) + " not found");
|
|
789
|
+
}
|
|
790
|
+
// Idempotent — already archived.
|
|
791
|
+
return existing;
|
|
792
|
+
}
|
|
793
|
+
return await getRule(slug);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ---- evaluate ------------------------------------------------------
|
|
797
|
+
|
|
798
|
+
async function evaluate(input) {
|
|
799
|
+
if (!input || typeof input !== "object") {
|
|
800
|
+
throw new TypeError("autoDiscount.evaluate: input object required");
|
|
801
|
+
}
|
|
802
|
+
var cart = _readCart(input.cart);
|
|
803
|
+
var customerId = input.customer_id == null ? null : input.customer_id;
|
|
804
|
+
if (customerId != null && (typeof customerId !== "string" || !customerId.length)) {
|
|
805
|
+
throw new TypeError("autoDiscount.evaluate: customer_id must be a non-empty string when provided");
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
var ts = _now();
|
|
809
|
+
var rows = (await query(
|
|
810
|
+
"SELECT * FROM auto_discount_rules " +
|
|
811
|
+
"WHERE active = 1 AND archived_at IS NULL " +
|
|
812
|
+
" AND (starts_at IS NULL OR starts_at <= ?1) " +
|
|
813
|
+
" AND (expires_at IS NULL OR expires_at > ?1) " +
|
|
814
|
+
"ORDER BY priority DESC, created_at ASC, slug ASC",
|
|
815
|
+
[ts],
|
|
816
|
+
)).rows;
|
|
817
|
+
|
|
818
|
+
// Build a sku -> total-quantity index once per evaluate call.
|
|
819
|
+
var skuTotals = Object.create(null);
|
|
820
|
+
var itemCount = 0;
|
|
821
|
+
for (var li = 0; li < cart.lines.length; li += 1) {
|
|
822
|
+
var L = cart.lines[li];
|
|
823
|
+
skuTotals[L.sku] = (skuTotals[L.sku] || 0) + L.quantity;
|
|
824
|
+
itemCount += L.quantity;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Eager line-meta resolution — only consulted when the catalog
|
|
828
|
+
// handle is wired and a rule needs category data. Cached across
|
|
829
|
+
// rules in this evaluate call.
|
|
830
|
+
var lineMetaCache = {};
|
|
831
|
+
async function _metaFor(line) {
|
|
832
|
+
if (Object.prototype.hasOwnProperty.call(lineMetaCache, line.sku)) {
|
|
833
|
+
return lineMetaCache[line.sku];
|
|
834
|
+
}
|
|
835
|
+
var m = await _resolveLineMeta(line, catalog);
|
|
836
|
+
lineMetaCache[line.sku] = m;
|
|
837
|
+
return m;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
var applied = [];
|
|
841
|
+
var skipped = [];
|
|
842
|
+
|
|
843
|
+
for (var ri = 0; ri < rows.length; ri += 1) {
|
|
844
|
+
var rule = _hydrateRow(rows[ri]);
|
|
845
|
+
|
|
846
|
+
// 1. Total-redemption cap
|
|
847
|
+
if (rule.max_redemptions_total != null && rule.redemptions_used >= rule.max_redemptions_total) {
|
|
848
|
+
skipped.push({ rule_slug: rule.slug, reason: "max_redemptions_total_reached" });
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// 2. Per-customer redemption cap — only evaluable when customer_id present.
|
|
853
|
+
if (rule.max_redemptions_per_customer != null) {
|
|
854
|
+
if (customerId == null) {
|
|
855
|
+
skipped.push({ rule_slug: rule.slug, reason: "per_customer_cap_requires_customer" });
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
var capRow = (await query(
|
|
859
|
+
"SELECT COUNT(*) AS used FROM auto_discount_applications " +
|
|
860
|
+
"WHERE rule_slug = ?1 AND customer_id = ?2",
|
|
861
|
+
[rule.slug, customerId],
|
|
862
|
+
)).rows[0] || {};
|
|
863
|
+
var usedByCustomer = Number(capRow.used) || 0;
|
|
864
|
+
if (usedByCustomer >= rule.max_redemptions_per_customer) {
|
|
865
|
+
skipped.push({ rule_slug: rule.slug, reason: "max_redemptions_per_customer_reached" });
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// 3. Segment gate
|
|
871
|
+
if (rule.customer_segment_in.length > 0) {
|
|
872
|
+
if (customerId == null) {
|
|
873
|
+
skipped.push({ rule_slug: rule.slug, reason: "segment_gate_requires_customer" });
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
if (!customerSegments) {
|
|
877
|
+
throw new TypeError("autoDiscount: rule " + JSON.stringify(rule.slug) +
|
|
878
|
+
" has a customer_segment_in gate but no customerSegments handle was wired into create()");
|
|
879
|
+
}
|
|
880
|
+
if (typeof customerSegments.isMember !== "function") {
|
|
881
|
+
throw new TypeError("autoDiscount: customerSegments handle must expose isMember(customer_id, segment_slug)");
|
|
882
|
+
}
|
|
883
|
+
var matched = false;
|
|
884
|
+
for (var sj = 0; sj < rule.customer_segment_in.length; sj += 1) {
|
|
885
|
+
if (await customerSegments.isMember(customerId, rule.customer_segment_in[sj])) {
|
|
886
|
+
matched = true;
|
|
887
|
+
break;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
if (!matched) {
|
|
891
|
+
skipped.push({ rule_slug: rule.slug, reason: "customer_not_in_segment" });
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// 4. Exclusions
|
|
897
|
+
var excluded = false;
|
|
898
|
+
var excludeReason = null;
|
|
899
|
+
for (var xi = 0; xi < rule.exclusions.length; xi += 1) {
|
|
900
|
+
var ex = rule.exclusions[xi];
|
|
901
|
+
if (ex.kind === "sku") {
|
|
902
|
+
if (skuTotals[ex.value] > 0) {
|
|
903
|
+
excluded = true;
|
|
904
|
+
excludeReason = "excluded_sku_present";
|
|
905
|
+
break;
|
|
906
|
+
}
|
|
907
|
+
} else /* category */ {
|
|
908
|
+
for (var ci = 0; ci < cart.lines.length; ci += 1) {
|
|
909
|
+
var cmeta = await _metaFor(cart.lines[ci]);
|
|
910
|
+
if (cmeta.category === ex.value || cmeta.tags.indexOf(ex.value) !== -1) {
|
|
911
|
+
excluded = true;
|
|
912
|
+
excludeReason = "excluded_category_present";
|
|
913
|
+
break;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
if (excluded) break;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
if (excluded) {
|
|
920
|
+
skipped.push({ rule_slug: rule.slug, reason: excludeReason });
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// 5. Trigger
|
|
925
|
+
var triggered = false;
|
|
926
|
+
if (rule.trigger.kind === "cart_total_min") {
|
|
927
|
+
triggered = cart.subtotal_minor >= (rule.trigger.min_minor || 0);
|
|
928
|
+
} else if (rule.trigger.kind === "item_count_min") {
|
|
929
|
+
triggered = itemCount >= (rule.trigger.min_count || 1);
|
|
930
|
+
} else /* sku_purchase */ {
|
|
931
|
+
var needed = rule.trigger.min_quantity || 1;
|
|
932
|
+
var skus = rule.trigger.skus || [];
|
|
933
|
+
for (var ti = 0; ti < skus.length; ti += 1) {
|
|
934
|
+
if ((skuTotals[skus[ti]] || 0) >= needed) { triggered = true; break; }
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (!triggered) {
|
|
938
|
+
skipped.push({ rule_slug: rule.slug, reason: "trigger_not_met" });
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// 6. Compute monetary effect
|
|
943
|
+
var savingsMinor = 0;
|
|
944
|
+
var freeShipping = false;
|
|
945
|
+
var bogoEligible = null;
|
|
946
|
+
|
|
947
|
+
if (rule.value.kind === "percent_off") {
|
|
948
|
+
// applies_to restricts the base; default base is subtotal.
|
|
949
|
+
var base = 0;
|
|
950
|
+
if (!rule.applies_to) {
|
|
951
|
+
base = cart.subtotal_minor;
|
|
952
|
+
} else {
|
|
953
|
+
for (var pi = 0; pi < cart.lines.length; pi += 1) {
|
|
954
|
+
var pLine = cart.lines[pi];
|
|
955
|
+
var pMeta = await _metaFor(pLine);
|
|
956
|
+
if (_lineMatches(pMeta, pLine.sku, rule.applies_to)) {
|
|
957
|
+
base += pLine.unit_price_minor * pLine.quantity;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
// Half-away-from-zero round to integer minor.
|
|
962
|
+
var raw = (base * rule.value.basis_points) / 10000;
|
|
963
|
+
savingsMinor = raw >= 0 ? Math.floor(raw + 0.5) : -Math.floor(-raw + 0.5);
|
|
964
|
+
if (savingsMinor > base) savingsMinor = base;
|
|
965
|
+
} else if (rule.value.kind === "amount_off_total") {
|
|
966
|
+
savingsMinor = rule.value.minor;
|
|
967
|
+
if (savingsMinor > cart.subtotal_minor) savingsMinor = cart.subtotal_minor;
|
|
968
|
+
} else if (rule.value.kind === "amount_off_each") {
|
|
969
|
+
for (var ei = 0; ei < cart.lines.length; ei += 1) {
|
|
970
|
+
var eLine = cart.lines[ei];
|
|
971
|
+
var eMeta = await _metaFor(eLine);
|
|
972
|
+
if (!_lineMatches(eMeta, eLine.sku, rule.applies_to)) continue;
|
|
973
|
+
var perUnit = rule.value.minor;
|
|
974
|
+
if (perUnit > eLine.unit_price_minor) perUnit = eLine.unit_price_minor;
|
|
975
|
+
savingsMinor += perUnit * eLine.quantity;
|
|
976
|
+
}
|
|
977
|
+
} else if (rule.value.kind === "free_shipping") {
|
|
978
|
+
freeShipping = true;
|
|
979
|
+
savingsMinor = cart.shipping_cost_minor;
|
|
980
|
+
} else /* bogo */ {
|
|
981
|
+
bogoEligible = [];
|
|
982
|
+
for (var bi = 0; bi < cart.lines.length; bi += 1) {
|
|
983
|
+
var bLine = cart.lines[bi];
|
|
984
|
+
var bMeta = await _metaFor(bLine);
|
|
985
|
+
if (!_lineMatches(bMeta, bLine.sku, rule.applies_to)) continue;
|
|
986
|
+
var groupSize = rule.value.buy_qty + rule.value.get_qty;
|
|
987
|
+
var fullGroups = Math.floor(bLine.quantity / groupSize);
|
|
988
|
+
var freeUnits = fullGroups * rule.value.get_qty;
|
|
989
|
+
if (freeUnits === 0) continue;
|
|
990
|
+
var lineSavings = freeUnits * bLine.unit_price_minor;
|
|
991
|
+
savingsMinor += lineSavings;
|
|
992
|
+
bogoEligible.push({
|
|
993
|
+
sku: bLine.sku,
|
|
994
|
+
free_qty: freeUnits,
|
|
995
|
+
savings_minor: lineSavings,
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
if (bogoEligible.length === 0) {
|
|
999
|
+
// BOGO triggered (e.g. total-min) but no eligible line had a
|
|
1000
|
+
// full buy+get group — surface as skipped rather than
|
|
1001
|
+
// appending a zero-savings entry.
|
|
1002
|
+
skipped.push({ rule_slug: rule.slug, reason: "bogo_no_eligible_group" });
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Zero-savings rules (percent_off on a $0 matched base, etc.)
|
|
1008
|
+
// don't earn a slot — they would noise the cart layer with
|
|
1009
|
+
// entries that do nothing. The free_shipping flag is an
|
|
1010
|
+
// exception: a rule that switches shipping off still ships
|
|
1011
|
+
// even if shipping_cost_minor is zero, because downstream rate
|
|
1012
|
+
// quotes may re-resolve.
|
|
1013
|
+
if (savingsMinor <= 0 && !freeShipping) {
|
|
1014
|
+
skipped.push({ rule_slug: rule.slug, reason: "zero_savings" });
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
var entry = {
|
|
1019
|
+
rule_slug: rule.slug,
|
|
1020
|
+
title: rule.title,
|
|
1021
|
+
value_kind: rule.value.kind,
|
|
1022
|
+
savings_minor: savingsMinor,
|
|
1023
|
+
};
|
|
1024
|
+
if (freeShipping) entry.free_shipping = true;
|
|
1025
|
+
if (bogoEligible) entry.bogo_eligible = bogoEligible;
|
|
1026
|
+
applied.push(entry);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
return { applied: applied, skipped: skipped };
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// ---- recordApplication --------------------------------------------
|
|
1033
|
+
|
|
1034
|
+
async function recordApplication(input) {
|
|
1035
|
+
if (!input || typeof input !== "object") {
|
|
1036
|
+
throw new TypeError("autoDiscount.recordApplication: input object required");
|
|
1037
|
+
}
|
|
1038
|
+
var slug = _slug(input.rule_slug);
|
|
1039
|
+
var orderId = input.order_id;
|
|
1040
|
+
if (typeof orderId !== "string" || !orderId.length || orderId.length > 128) {
|
|
1041
|
+
throw new TypeError("autoDiscount.recordApplication: order_id must be a non-empty string (<= 128 chars)");
|
|
1042
|
+
}
|
|
1043
|
+
var savings = input.savings_minor;
|
|
1044
|
+
_nonNegInt(savings, "savings_minor");
|
|
1045
|
+
var customerId = input.customer_id == null ? null : input.customer_id;
|
|
1046
|
+
if (customerId != null && (typeof customerId !== "string" || !customerId.length)) {
|
|
1047
|
+
throw new TypeError("autoDiscount.recordApplication: customer_id must be a non-empty string when provided");
|
|
1048
|
+
}
|
|
1049
|
+
var appliedAt = input.applied_at == null ? _now() : _epochMs(input.applied_at, "applied_at");
|
|
1050
|
+
|
|
1051
|
+
// Confirm the rule exists. Inserting against a non-existent slug
|
|
1052
|
+
// would be refused by the FK anyway; we want the typed error.
|
|
1053
|
+
var rule = await getRule(slug);
|
|
1054
|
+
if (!rule) {
|
|
1055
|
+
throw new TypeError("autoDiscount.recordApplication: rule_slug " + JSON.stringify(slug) + " not found");
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
var id = _b().uuid.v7();
|
|
1059
|
+
await query(
|
|
1060
|
+
"INSERT INTO auto_discount_applications (id, rule_slug, order_id, customer_id, savings_minor, applied_at) " +
|
|
1061
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
1062
|
+
[id, slug, orderId, customerId, savings, appliedAt],
|
|
1063
|
+
);
|
|
1064
|
+
await query(
|
|
1065
|
+
"UPDATE auto_discount_rules SET redemptions_used = redemptions_used + 1, updated_at = ?1 WHERE slug = ?2",
|
|
1066
|
+
[_now(), slug],
|
|
1067
|
+
);
|
|
1068
|
+
return {
|
|
1069
|
+
id: id,
|
|
1070
|
+
rule_slug: slug,
|
|
1071
|
+
order_id: orderId,
|
|
1072
|
+
customer_id: customerId,
|
|
1073
|
+
savings_minor: savings,
|
|
1074
|
+
applied_at: appliedAt,
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// ---- metricsForRule ------------------------------------------------
|
|
1079
|
+
|
|
1080
|
+
async function metricsForRule(input) {
|
|
1081
|
+
if (!input || typeof input !== "object") {
|
|
1082
|
+
throw new TypeError("autoDiscount.metricsForRule: input object required");
|
|
1083
|
+
}
|
|
1084
|
+
var slug = _slug(input.rule_slug);
|
|
1085
|
+
var from = input.from == null ? null : _epochMs(input.from, "from");
|
|
1086
|
+
var to = input.to == null ? null : _epochMs(input.to, "to");
|
|
1087
|
+
if (from != null && to != null && to <= from) {
|
|
1088
|
+
throw new TypeError("autoDiscount.metricsForRule: to must be greater than from");
|
|
1089
|
+
}
|
|
1090
|
+
var rule = await getRule(slug);
|
|
1091
|
+
if (!rule) {
|
|
1092
|
+
throw new TypeError("autoDiscount.metricsForRule: rule_slug " + JSON.stringify(slug) + " not found");
|
|
1093
|
+
}
|
|
1094
|
+
var sql = "SELECT COUNT(*) AS applications, " +
|
|
1095
|
+
" COALESCE(SUM(savings_minor), 0) AS gross_savings_minor, " +
|
|
1096
|
+
" COUNT(DISTINCT customer_id) AS unique_customers " +
|
|
1097
|
+
" FROM auto_discount_applications " +
|
|
1098
|
+
" WHERE rule_slug = ?1";
|
|
1099
|
+
var params = [slug];
|
|
1100
|
+
var idx = 2;
|
|
1101
|
+
if (from != null) { sql += " AND applied_at >= ?" + idx; params.push(from); idx += 1; }
|
|
1102
|
+
if (to != null) { sql += " AND applied_at < ?" + idx; params.push(to); idx += 1; }
|
|
1103
|
+
var row = (await query(sql, params)).rows[0] || {};
|
|
1104
|
+
return {
|
|
1105
|
+
rule_slug: slug,
|
|
1106
|
+
applications: Number(row.applications) || 0,
|
|
1107
|
+
gross_savings_minor: Number(row.gross_savings_minor) || 0,
|
|
1108
|
+
unique_customers: Number(row.unique_customers) || 0,
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
return {
|
|
1113
|
+
defineRule: defineRule,
|
|
1114
|
+
getRule: getRule,
|
|
1115
|
+
listRules: listRules,
|
|
1116
|
+
updateRule: updateRule,
|
|
1117
|
+
archiveRule: archiveRule,
|
|
1118
|
+
evaluate: evaluate,
|
|
1119
|
+
recordApplication: recordApplication,
|
|
1120
|
+
metricsForRule: metricsForRule,
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
module.exports = {
|
|
1125
|
+
create: create,
|
|
1126
|
+
VALID_TRIGGER_KINDS: VALID_TRIGGER_KINDS,
|
|
1127
|
+
VALID_VALUE_KINDS: VALID_VALUE_KINDS,
|
|
1128
|
+
VALID_APPLIES_TO_KINDS: VALID_APPLIES_TO_KINDS,
|
|
1129
|
+
VALID_EXCLUSION_KINDS: VALID_EXCLUSION_KINDS,
|
|
1130
|
+
ALLOWED_PATCH_COLUMNS: ALLOWED_PATCH_COLUMNS,
|
|
1131
|
+
MAX_APPLIES_TO_VALUES: MAX_APPLIES_TO_VALUES,
|
|
1132
|
+
MAX_TRIGGER_SKUS: MAX_TRIGGER_SKUS,
|
|
1133
|
+
};
|