@blamejs/blamejs-shop 0.0.64 → 0.0.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ };