@blamejs/blamejs-shop 0.0.59 → 0.0.60

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