@blamejs/blamejs-shop 0.0.57 → 0.0.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,781 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.quantityDiscounts
4
+ * @title Quantity-discount tiers — automatic per-line price breaks
5
+ *
6
+ * @intro
7
+ * A quantity discount is an automatic, code-less price reduction
8
+ * that activates when a cart line crosses a quantity threshold.
9
+ * "Buy 5 of SKU X for 10% off, buy 10 for 20% off" is the
10
+ * canonical shape. Distinct from coupons (per-cart codes typed by
11
+ * the shopper) and from bundle pricing (a single composite SKU's
12
+ * price): a quantity discount attaches to a SCOPE (one SKU, one
13
+ * product, a vendor, a category, a collection slug, or
14
+ * "everything") and a SCHEDULE of (min_quantity, discount_kind,
15
+ * value) rules. The pricing primitive consults this surface during
16
+ * cart-line resolution and applies the best (lowest final unit
17
+ * price) matching rule.
18
+ *
19
+ * Scope specificity ordering:
20
+ *
21
+ * sku > product > collection_slug > vendor > category > global
22
+ *
23
+ * Multiple non-exclusive tier sets across different scopes can
24
+ * stack — applyToLine walks every applicable rule and picks the
25
+ * single best one (the one that yields the lowest discounted unit
26
+ * price). An `exclusive` tier set short-circuits the walk: when an
27
+ * exclusive set's best rule applies, no other tier-set rule may
28
+ * compete.
29
+ *
30
+ * Discount kinds:
31
+ *
32
+ * percent_off — value is basis points (0..10000)
33
+ * amount_off_each — value is minor units off each unit
34
+ * amount_off_total — value is minor units off the line total
35
+ * fixed_each_price — value is the new unit price in minor
36
+ * units (overrides the catalog price)
37
+ *
38
+ * The discounted unit price is clamped at 0 — a schedule that
39
+ * would push the unit below zero collapses to "free", never
40
+ * negative. The pricing primitive composes this surface; it does
41
+ * not formatting / locale / tax math (those land elsewhere).
42
+ *
43
+ * Composition with `b.money`:
44
+ *
45
+ * Every per-unit / per-line minor-unit multiplication routes
46
+ * through `b.money.fromMinorUnits(...).multiply(...)` so the
47
+ * half-even rounding stays consistent with the framework's
48
+ * Money class. The primitive returns integer minor units to the
49
+ * caller (the cart / pricing surface that already speaks minor
50
+ * units); the BigInt-Money round-trip is an internal detail.
51
+ */
52
+
53
+ var bShop;
54
+ function _b() {
55
+ if (!bShop) bShop = require("./index");
56
+ return bShop.framework;
57
+ }
58
+
59
+ // ---- constants -----------------------------------------------------------
60
+
61
+ var VALID_SCOPES = Object.freeze([
62
+ "sku", "product", "collection_slug", "vendor", "category", "global",
63
+ ]);
64
+
65
+ // Ordering reads sku-first (most specific) -> global (least). The
66
+ // applyToLine walk consults the rule list in this order so the
67
+ // "best" rule across stacked tier sets is deterministically the
68
+ // most-specific scope's rule when multiple kinds produce the same
69
+ // final price.
70
+ var SCOPE_SPECIFICITY = Object.freeze({
71
+ sku: 6,
72
+ product: 5,
73
+ collection_slug: 4,
74
+ vendor: 3,
75
+ category: 2,
76
+ global: 1,
77
+ });
78
+
79
+ var VALID_KINDS = Object.freeze([
80
+ "percent_off",
81
+ "amount_off_each",
82
+ "amount_off_total",
83
+ "fixed_each_price",
84
+ ]);
85
+
86
+ // Mutable columns for update() — `scope` / `scope_id` are the
87
+ // natural key for lookups and immutable post-create (operators
88
+ // archive + redefine to rebind a schedule). `created_at` is
89
+ // immutable.
90
+ var ALLOWED_SET_COLUMNS = Object.freeze(["exclusive"]);
91
+
92
+ var MAX_TIERS_PER_SET = 50;
93
+ var MAX_LIMIT = 200;
94
+
95
+ // Generous upper cap on scope_id (SKU max is 128 in catalog, slugs
96
+ // and vendor names tend to fit well under 256). We keep the cap
97
+ // loose at the primitive boundary; the catalog primitive owns
98
+ // stricter shape rules for its own ids.
99
+ var MAX_SCOPE_ID_LEN = 256;
100
+
101
+ var SCOPE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9 ._\-:/]{0,255}$/;
102
+
103
+ // ---- validators ----------------------------------------------------------
104
+
105
+ function _assertScope(s) {
106
+ if (typeof s !== "string" || VALID_SCOPES.indexOf(s) === -1) {
107
+ throw new TypeError("quantityDiscounts: scope must be one of " +
108
+ JSON.stringify(VALID_SCOPES) + ", got " + JSON.stringify(s));
109
+ }
110
+ }
111
+
112
+ function _assertScopeIdShape(scope, scopeId) {
113
+ if (scope === "global") {
114
+ if (scopeId != null) {
115
+ throw new TypeError("quantityDiscounts: scope_id must be null when scope = 'global'");
116
+ }
117
+ return;
118
+ }
119
+ if (typeof scopeId !== "string" || !scopeId.length || scopeId.length > MAX_SCOPE_ID_LEN || !SCOPE_ID_RE.test(scopeId)) {
120
+ throw new TypeError("quantityDiscounts: scope_id must be a non-empty string (<= " + MAX_SCOPE_ID_LEN +
121
+ " chars, alnum + ' ._-:/') when scope <> 'global'");
122
+ }
123
+ }
124
+
125
+ function _assertKind(k) {
126
+ if (typeof k !== "string" || VALID_KINDS.indexOf(k) === -1) {
127
+ throw new TypeError("quantityDiscounts: discount_kind must be one of " +
128
+ JSON.stringify(VALID_KINDS) + ", got " + JSON.stringify(k));
129
+ }
130
+ }
131
+
132
+ function _assertPositiveInt(n, label) {
133
+ if (!Number.isInteger(n) || n <= 0) {
134
+ throw new TypeError("quantityDiscounts: " + label + " must be a positive integer, got " + JSON.stringify(n));
135
+ }
136
+ }
137
+
138
+ function _assertNonNegInt(n, label) {
139
+ if (!Number.isInteger(n) || n < 0) {
140
+ throw new TypeError("quantityDiscounts: " + label + " must be a non-negative integer, got " + JSON.stringify(n));
141
+ }
142
+ }
143
+
144
+ // Per-kind upper bound on `value`. The CHECK in SQL only enforces
145
+ // value >= 0; the kind-specific cap belongs at the app layer where
146
+ // the error message carries the domain meaning.
147
+ function _assertValueForKind(kind, value) {
148
+ _assertNonNegInt(value, "value");
149
+ if (kind === "percent_off" && value > 10000) {
150
+ throw new TypeError("quantityDiscounts: percent_off value must be 0..10000 basis points (100% = 10000), got " + value);
151
+ }
152
+ }
153
+
154
+ function _now() { return Date.now(); }
155
+
156
+ // ---- factory -------------------------------------------------------------
157
+
158
+ function create(opts) {
159
+ opts = opts || {};
160
+ var query = opts.query;
161
+ if (!query) {
162
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
163
+ }
164
+ var catalog = opts.catalog;
165
+ if (!catalog || !catalog.variants || typeof catalog.variants.bySku !== "function") {
166
+ throw new TypeError("quantityDiscounts.create: opts.catalog must expose variants.bySku(sku)");
167
+ }
168
+
169
+ // ---- internal helpers ------------------------------------------------
170
+
171
+ async function _setRow(id) {
172
+ var r = await query("SELECT * FROM qd_tier_sets WHERE id = ?1", [id]);
173
+ return r.rows[0] || null;
174
+ }
175
+
176
+ async function _tierRowsForSet(setId) {
177
+ var r = await query(
178
+ "SELECT id, tier_set_id, min_quantity, discount_kind, value, sort_order " +
179
+ "FROM qd_tiers WHERE tier_set_id = ?1 ORDER BY sort_order ASC, min_quantity ASC",
180
+ [setId],
181
+ );
182
+ return r.rows;
183
+ }
184
+
185
+ function _shapeTierSet(setRow, tierRows) {
186
+ return {
187
+ id: setRow.id,
188
+ scope: setRow.scope,
189
+ scope_id: setRow.scope_id,
190
+ exclusive: setRow.exclusive === 1 || setRow.exclusive === true,
191
+ archived_at: setRow.archived_at == null ? null : setRow.archived_at,
192
+ created_at: setRow.created_at,
193
+ updated_at: setRow.updated_at,
194
+ tiers: tierRows.map(function (t) {
195
+ return {
196
+ id: t.id,
197
+ min_quantity: t.min_quantity,
198
+ discount_kind: t.discount_kind,
199
+ value: t.value,
200
+ sort_order: t.sort_order,
201
+ };
202
+ }),
203
+ };
204
+ }
205
+
206
+ // ---- defineTier ------------------------------------------------------
207
+
208
+ async function defineTier(input) {
209
+ if (!input || typeof input !== "object") {
210
+ throw new TypeError("quantityDiscounts.defineTier: input object required");
211
+ }
212
+ _assertScope(input.scope);
213
+ var scopeId = input.scope_id == null ? null : input.scope_id;
214
+ _assertScopeIdShape(input.scope, scopeId);
215
+
216
+ if (!Array.isArray(input.tiers) || input.tiers.length === 0) {
217
+ throw new TypeError("quantityDiscounts.defineTier: tiers must be a non-empty array");
218
+ }
219
+ if (input.tiers.length > MAX_TIERS_PER_SET) {
220
+ throw new TypeError("quantityDiscounts.defineTier: tiers length " + input.tiers.length +
221
+ " exceeds cap of " + MAX_TIERS_PER_SET);
222
+ }
223
+
224
+ var exclusive = input.exclusive === true ? 1 : 0;
225
+
226
+ // Validate every tier shape AND refuse overlapping min_quantity
227
+ // values within this set before any write. Overlap-refusal here
228
+ // is the primary defense; the application layer is the only
229
+ // place that knows the per-set context (SQL CHECKs can't span
230
+ // rows in a single statement).
231
+ var seenMin = Object.create(null);
232
+ var prepped = [];
233
+ for (var i = 0; i < input.tiers.length; i += 1) {
234
+ var t = input.tiers[i];
235
+ if (!t || typeof t !== "object") {
236
+ throw new TypeError("quantityDiscounts.defineTier: tiers[" + i + "] must be an object");
237
+ }
238
+ _assertPositiveInt(t.min_quantity, "tiers[" + i + "].min_quantity");
239
+ _assertKind(t.discount_kind);
240
+ _assertValueForKind(t.discount_kind, t.value);
241
+ if (seenMin[t.min_quantity]) {
242
+ throw new TypeError("quantityDiscounts.defineTier: duplicate min_quantity " + t.min_quantity +
243
+ " — overlapping thresholds in one tier set are refused");
244
+ }
245
+ seenMin[t.min_quantity] = true;
246
+ prepped.push({
247
+ min_quantity: t.min_quantity,
248
+ discount_kind: t.discount_kind,
249
+ value: t.value,
250
+ });
251
+ }
252
+
253
+ // For scope = 'sku', verify the SKU exists in the catalog. The
254
+ // primitive composes catalog.variants.bySku to keep the
255
+ // referential check live; scope = 'product' / 'vendor' /
256
+ // 'category' / 'collection_slug' identifiers are opaque to this
257
+ // primitive (the catalog primitive owns those namespaces and
258
+ // doesn't expose a uniform lookup for them yet).
259
+ if (input.scope === "sku") {
260
+ var variant = await catalog.variants.bySku(scopeId);
261
+ if (!variant) {
262
+ throw new TypeError("quantityDiscounts.defineTier: sku " + JSON.stringify(scopeId) + " not found in catalog");
263
+ }
264
+ }
265
+
266
+ var id = _b().uuid.v7();
267
+ var ts = _now();
268
+ await query(
269
+ "INSERT INTO qd_tier_sets (id, scope, scope_id, exclusive, archived_at, created_at, updated_at) " +
270
+ "VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?5)",
271
+ [id, input.scope, scopeId, exclusive, ts],
272
+ );
273
+ for (var k = 0; k < prepped.length; k += 1) {
274
+ var tierId = _b().uuid.v7();
275
+ await query(
276
+ "INSERT INTO qd_tiers (id, tier_set_id, min_quantity, discount_kind, value, sort_order) " +
277
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
278
+ [tierId, id, prepped[k].min_quantity, prepped[k].discount_kind, prepped[k].value, k],
279
+ );
280
+ }
281
+ var setRow = await _setRow(id);
282
+ var tierRows = await _tierRowsForSet(id);
283
+ return _shapeTierSet(setRow, tierRows);
284
+ }
285
+
286
+ // ---- getTiersForLine -------------------------------------------------
287
+
288
+ // Find every active tier set whose scope matches some attribute of
289
+ // the line, ordered most-specific first. The caller (cart-line
290
+ // resolver) passes whatever scope identifiers it knows: sku is
291
+ // required, product_id is required for product-scope matching,
292
+ // and optional vendor / category / collection_slug travel as
293
+ // context. Quantity also rides along so the active-rule pick can
294
+ // happen in one walk.
295
+ async function getTiersForLine(input) {
296
+ if (!input || typeof input !== "object") {
297
+ throw new TypeError("quantityDiscounts.getTiersForLine: input object required");
298
+ }
299
+ if (typeof input.sku !== "string" || !input.sku.length) {
300
+ throw new TypeError("quantityDiscounts.getTiersForLine: sku required (non-empty string)");
301
+ }
302
+ _assertPositiveInt(input.quantity, "quantity");
303
+
304
+ // Build the (scope, scope_id) candidates from the line context.
305
+ // Missing attributes (e.g. no vendor on the line) skip that
306
+ // scope's lookup entirely.
307
+ var candidates = [];
308
+ candidates.push({ scope: "sku", scope_id: input.sku });
309
+ if (input.product_id != null) {
310
+ candidates.push({ scope: "product", scope_id: String(input.product_id) });
311
+ }
312
+ if (input.collection_slug != null) {
313
+ candidates.push({ scope: "collection_slug", scope_id: String(input.collection_slug) });
314
+ }
315
+ if (input.vendor != null) {
316
+ candidates.push({ scope: "vendor", scope_id: String(input.vendor) });
317
+ }
318
+ if (input.category != null) {
319
+ candidates.push({ scope: "category", scope_id: String(input.category) });
320
+ }
321
+ candidates.push({ scope: "global", scope_id: null });
322
+
323
+ var results = [];
324
+ for (var i = 0; i < candidates.length; i += 1) {
325
+ var c = candidates[i];
326
+ var setsRes;
327
+ if (c.scope === "global") {
328
+ setsRes = await query(
329
+ "SELECT * FROM qd_tier_sets WHERE scope = 'global' AND archived_at IS NULL",
330
+ [],
331
+ );
332
+ } else {
333
+ setsRes = await query(
334
+ "SELECT * FROM qd_tier_sets WHERE scope = ?1 AND scope_id = ?2 AND archived_at IS NULL",
335
+ [c.scope, c.scope_id],
336
+ );
337
+ }
338
+ for (var j = 0; j < setsRes.rows.length; j += 1) {
339
+ var setRow = setsRes.rows[j];
340
+ var tierRows = await _tierRowsForSet(setRow.id);
341
+ // Filter to tier rules whose min_quantity is met by the
342
+ // line's quantity. Higher-min rules are preferred when
343
+ // multiple within one set apply — best-rule picking happens
344
+ // in applyToLine via final-price compare.
345
+ var applicable = tierRows.filter(function (t) { return input.quantity >= t.min_quantity; });
346
+ if (applicable.length === 0) continue;
347
+ results.push({
348
+ tier_set: _shapeTierSet(setRow, tierRows),
349
+ specificity: SCOPE_SPECIFICITY[setRow.scope],
350
+ applicable: applicable,
351
+ });
352
+ }
353
+ }
354
+
355
+ // Sort by specificity desc so the caller (applyToLine) walks
356
+ // most-specific first. Tie-broken by tier_set.id for stability
357
+ // — two sets at the same scope sort deterministically.
358
+ results.sort(function (a, b) {
359
+ if (b.specificity !== a.specificity) return b.specificity - a.specificity;
360
+ return a.tier_set.id < b.tier_set.id ? -1 : a.tier_set.id > b.tier_set.id ? 1 : 0;
361
+ });
362
+ return results;
363
+ }
364
+
365
+ // ---- applyToLine -----------------------------------------------------
366
+
367
+ // Walk applicable tier sets, compute the candidate
368
+ // (discounted_unit_minor, line_subtotal_minor, line_discount_minor)
369
+ // for the best rule within each set, then pick the candidate with
370
+ // the lowest line_subtotal_minor across all sets. If any set is
371
+ // marked exclusive, its best-rule candidate (if applicable) wins
372
+ // outright over non-exclusive sets even at higher cost — the
373
+ // operator's intent is "this rule applies, ignore the rest".
374
+ //
375
+ // Pure function aside from the rule lookup. Money math routes
376
+ // through b.money.fromMinorUnits + .multiply so half-even rounding
377
+ // stays consistent with the framework's Money class.
378
+ async function applyToLine(input) {
379
+ if (!input || typeof input !== "object" || !input.line || typeof input.line !== "object") {
380
+ throw new TypeError("quantityDiscounts.applyToLine: input.line object required");
381
+ }
382
+ var line = input.line;
383
+ if (typeof line.sku !== "string" || !line.sku.length) {
384
+ throw new TypeError("quantityDiscounts.applyToLine: line.sku required");
385
+ }
386
+ _assertPositiveInt(line.quantity, "line.quantity");
387
+ _assertNonNegInt(line.unit_price_minor, "line.unit_price_minor");
388
+
389
+ var rules = await getTiersForLine({
390
+ sku: line.sku,
391
+ product_id: line.product_id,
392
+ quantity: line.quantity,
393
+ vendor: line.vendor,
394
+ category: line.category,
395
+ collection_slug: line.collection_slug,
396
+ });
397
+
398
+ var original = line.unit_price_minor;
399
+ var qty = line.quantity;
400
+ var defaultResult = {
401
+ original_unit_minor: original,
402
+ discounted_unit_minor: original,
403
+ line_subtotal_minor: original * qty,
404
+ line_discount_minor: 0,
405
+ applied_tier_id: null,
406
+ };
407
+ if (rules.length === 0) return defaultResult;
408
+
409
+ // Compute the best candidate within each tier set, then choose
410
+ // the best across sets respecting `exclusive`.
411
+ var perSet = [];
412
+ for (var i = 0; i < rules.length; i += 1) {
413
+ var bucket = rules[i];
414
+ var bestForSet = null;
415
+ for (var j = 0; j < bucket.applicable.length; j += 1) {
416
+ var t = bucket.applicable[j];
417
+ var candidate = _applyRule(original, qty, t);
418
+ if (!bestForSet || candidate.line_subtotal_minor < bestForSet.line_subtotal_minor) {
419
+ bestForSet = candidate;
420
+ bestForSet.applied_tier_id = t.id;
421
+ bestForSet.tier_set_id = bucket.tier_set.id;
422
+ bestForSet.specificity = bucket.specificity;
423
+ bestForSet.exclusive = bucket.tier_set.exclusive;
424
+ }
425
+ }
426
+ if (bestForSet) perSet.push(bestForSet);
427
+ }
428
+
429
+ if (perSet.length === 0) return defaultResult;
430
+
431
+ // Exclusive rules win outright. If multiple exclusive sets all
432
+ // produce candidates, the most-specific one wins (ties broken
433
+ // by lowest subtotal).
434
+ var exclusives = perSet.filter(function (r) { return r.exclusive; });
435
+ var pool = exclusives.length > 0 ? exclusives : perSet;
436
+
437
+ var winner = pool[0];
438
+ for (var k = 1; k < pool.length; k += 1) {
439
+ var c = pool[k];
440
+ if (c.line_subtotal_minor < winner.line_subtotal_minor) {
441
+ winner = c;
442
+ } else if (c.line_subtotal_minor === winner.line_subtotal_minor && c.specificity > winner.specificity) {
443
+ winner = c;
444
+ }
445
+ }
446
+
447
+ return {
448
+ original_unit_minor: winner.original_unit_minor,
449
+ discounted_unit_minor: winner.discounted_unit_minor,
450
+ line_subtotal_minor: winner.line_subtotal_minor,
451
+ line_discount_minor: winner.line_discount_minor,
452
+ applied_tier_id: winner.applied_tier_id,
453
+ };
454
+ }
455
+
456
+ // ---- _applyRule (internal pure math) ---------------------------------
457
+
458
+ // Compose the per-unit / per-line minor-unit math via b.money so
459
+ // half-even rounding matches the framework Money class. Returns
460
+ // integer minor units to the caller (the calling primitive
461
+ // already speaks minor units).
462
+ function _applyRule(originalUnit, qty, tier) {
463
+ var money = _b().money;
464
+ var currency = "USD"; // placeholder; the rounding behaviour is currency-exponent-independent for integer minor math here
465
+ var discountedUnit;
466
+ var lineSubtotal;
467
+
468
+ if (tier.discount_kind === "percent_off") {
469
+ // value is basis points (1/10000). discountedUnit =
470
+ // floor((original * (10000 - bps)) / 10000) via b.money's
471
+ // half-even rounded multiply on the BigInt-Money round trip.
472
+ var keepBps = 10000 - tier.value;
473
+ if (keepBps < 0) keepBps = 0;
474
+ var pricedUnit = money.fromMinorUnits(BigInt(originalUnit), currency)
475
+ .multiply([BigInt(keepBps), BigInt(10000)]);
476
+ discountedUnit = Number(pricedUnit.toMinorUnits());
477
+ if (discountedUnit < 0) discountedUnit = 0;
478
+ lineSubtotal = discountedUnit * qty;
479
+ } else if (tier.discount_kind === "amount_off_each") {
480
+ discountedUnit = originalUnit - tier.value;
481
+ if (discountedUnit < 0) discountedUnit = 0;
482
+ lineSubtotal = discountedUnit * qty;
483
+ } else if (tier.discount_kind === "amount_off_total") {
484
+ var listLine = originalUnit * qty;
485
+ lineSubtotal = listLine - tier.value;
486
+ if (lineSubtotal < 0) lineSubtotal = 0;
487
+ // Effective unit price for receipt-line display rounds
488
+ // half-even via b.money — the storefront renders one number
489
+ // per line, derived from the line subtotal.
490
+ var effUnitMoney = money.fromMinorUnits(BigInt(lineSubtotal), currency)
491
+ .multiply([1n, BigInt(qty)]);
492
+ discountedUnit = Number(effUnitMoney.toMinorUnits());
493
+ } else if (tier.discount_kind === "fixed_each_price") {
494
+ discountedUnit = tier.value;
495
+ if (discountedUnit < 0) discountedUnit = 0;
496
+ lineSubtotal = discountedUnit * qty;
497
+ } else {
498
+ // Defensive — the kind enum is gated by _assertKind upstream,
499
+ // but a future schema migration could widen the column ahead
500
+ // of an app-layer update; refuse rather than silently apply
501
+ // the wrong math.
502
+ throw new TypeError("quantityDiscounts: unknown discount_kind " + JSON.stringify(tier.discount_kind));
503
+ }
504
+
505
+ var lineDiscount = (originalUnit * qty) - lineSubtotal;
506
+ if (lineDiscount < 0) lineDiscount = 0;
507
+ return {
508
+ original_unit_minor: originalUnit,
509
+ discounted_unit_minor: discountedUnit,
510
+ line_subtotal_minor: lineSubtotal,
511
+ line_discount_minor: lineDiscount,
512
+ };
513
+ }
514
+
515
+ // ---- applyToCart -----------------------------------------------------
516
+
517
+ async function applyToCart(input) {
518
+ if (!input || typeof input !== "object" || !Array.isArray(input.lines)) {
519
+ throw new TypeError("quantityDiscounts.applyToCart: input.lines array required");
520
+ }
521
+ var lines = input.lines;
522
+ var perLine = [];
523
+ var totalSubtotal = 0;
524
+ var totalDiscount = 0;
525
+ var totalOriginal = 0;
526
+ for (var i = 0; i < lines.length; i += 1) {
527
+ var r = await applyToLine({ line: lines[i] });
528
+ perLine.push({
529
+ sku: lines[i].sku,
530
+ product_id: lines[i].product_id == null ? null : lines[i].product_id,
531
+ quantity: lines[i].quantity,
532
+ original_unit_minor: r.original_unit_minor,
533
+ discounted_unit_minor: r.discounted_unit_minor,
534
+ line_subtotal_minor: r.line_subtotal_minor,
535
+ line_discount_minor: r.line_discount_minor,
536
+ applied_tier_id: r.applied_tier_id,
537
+ });
538
+ totalSubtotal += r.line_subtotal_minor;
539
+ totalDiscount += r.line_discount_minor;
540
+ totalOriginal += r.original_unit_minor * lines[i].quantity;
541
+ }
542
+ return {
543
+ lines: perLine,
544
+ original_total_minor: totalOriginal,
545
+ discount_total_minor: totalDiscount,
546
+ subtotal_minor: totalSubtotal,
547
+ };
548
+ }
549
+
550
+ // ---- update / archive / unarchive / list -----------------------------
551
+
552
+ async function update(tierSetId, patch) {
553
+ if (typeof tierSetId !== "string" || !tierSetId.length) {
554
+ throw new TypeError("quantityDiscounts.update: tier_set_id required");
555
+ }
556
+ if (!patch || typeof patch !== "object") {
557
+ throw new TypeError("quantityDiscounts.update: patch object required");
558
+ }
559
+ var sets = [];
560
+ var params = [];
561
+ var idx = 1;
562
+ function _addSet(col, val) {
563
+ // Allow-list defense even though the column name is a
564
+ // literal — a future patch-key path that becomes dynamic
565
+ // can't widen the surface to an attacker-controlled column.
566
+ _b().safeSql.assertOneOf(col, ALLOWED_SET_COLUMNS);
567
+ sets.push(_b().safeSql.quoteIdentifier(col, "sqlite") + " = ?" + (idx++));
568
+ params.push(val);
569
+ }
570
+ if (patch.exclusive !== undefined) {
571
+ if (typeof patch.exclusive !== "boolean") {
572
+ throw new TypeError("quantityDiscounts.update: exclusive must be boolean");
573
+ }
574
+ _addSet("exclusive", patch.exclusive ? 1 : 0);
575
+ }
576
+
577
+ // Tier rewrite path — replace the set's rules wholesale,
578
+ // applying the same overlap + shape checks as defineTier.
579
+ if (patch.tiers !== undefined) {
580
+ if (!Array.isArray(patch.tiers) || patch.tiers.length === 0) {
581
+ throw new TypeError("quantityDiscounts.update: tiers must be a non-empty array");
582
+ }
583
+ if (patch.tiers.length > MAX_TIERS_PER_SET) {
584
+ throw new TypeError("quantityDiscounts.update: tiers length " + patch.tiers.length +
585
+ " exceeds cap of " + MAX_TIERS_PER_SET);
586
+ }
587
+ var seenMin2 = Object.create(null);
588
+ var newTiers = [];
589
+ for (var i = 0; i < patch.tiers.length; i += 1) {
590
+ var t = patch.tiers[i];
591
+ if (!t || typeof t !== "object") {
592
+ throw new TypeError("quantityDiscounts.update: tiers[" + i + "] must be an object");
593
+ }
594
+ _assertPositiveInt(t.min_quantity, "tiers[" + i + "].min_quantity");
595
+ _assertKind(t.discount_kind);
596
+ _assertValueForKind(t.discount_kind, t.value);
597
+ if (seenMin2[t.min_quantity]) {
598
+ throw new TypeError("quantityDiscounts.update: duplicate min_quantity " + t.min_quantity +
599
+ " — overlapping thresholds refused");
600
+ }
601
+ seenMin2[t.min_quantity] = true;
602
+ newTiers.push({
603
+ min_quantity: t.min_quantity,
604
+ discount_kind: t.discount_kind,
605
+ value: t.value,
606
+ });
607
+ }
608
+ var existing = await _setRow(tierSetId);
609
+ if (!existing) return null;
610
+ await query("DELETE FROM qd_tiers WHERE tier_set_id = ?1", [tierSetId]);
611
+ for (var m = 0; m < newTiers.length; m += 1) {
612
+ var tid = _b().uuid.v7();
613
+ await query(
614
+ "INSERT INTO qd_tiers (id, tier_set_id, min_quantity, discount_kind, value, sort_order) " +
615
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
616
+ [tid, tierSetId, newTiers[m].min_quantity, newTiers[m].discount_kind, newTiers[m].value, m],
617
+ );
618
+ }
619
+ }
620
+ if (sets.length === 0 && patch.tiers === undefined) {
621
+ throw new TypeError("quantityDiscounts.update: patch contained no updatable fields");
622
+ }
623
+ var ts = _now();
624
+ if (sets.length > 0) {
625
+ sets.push("updated_at = ?" + (idx++));
626
+ params.push(ts);
627
+ params.push(tierSetId);
628
+ var r = await query(
629
+ "UPDATE qd_tier_sets SET " + sets.join(", ") + " WHERE id = ?" + idx,
630
+ params,
631
+ );
632
+ if (r.rowCount === 0 && patch.tiers === undefined) return null;
633
+ } else {
634
+ // Tiers-only patch — still bump updated_at so the operator
635
+ // sees the schedule recently moved.
636
+ await query("UPDATE qd_tier_sets SET updated_at = ?1 WHERE id = ?2", [ts, tierSetId]);
637
+ }
638
+ var setRow = await _setRow(tierSetId);
639
+ if (!setRow) return null;
640
+ var tierRows = await _tierRowsForSet(tierSetId);
641
+ return _shapeTierSet(setRow, tierRows);
642
+ }
643
+
644
+ async function archive(tierSetId) {
645
+ if (typeof tierSetId !== "string" || !tierSetId.length) {
646
+ throw new TypeError("quantityDiscounts.archive: tier_set_id required");
647
+ }
648
+ var ts = _now();
649
+ var r = await query(
650
+ "UPDATE qd_tier_sets SET archived_at = ?1, updated_at = ?1 WHERE id = ?2 AND archived_at IS NULL",
651
+ [ts, tierSetId],
652
+ );
653
+ return r.rowCount > 0;
654
+ }
655
+
656
+ async function unarchive(tierSetId) {
657
+ if (typeof tierSetId !== "string" || !tierSetId.length) {
658
+ throw new TypeError("quantityDiscounts.unarchive: tier_set_id required");
659
+ }
660
+ var ts = _now();
661
+ var r = await query(
662
+ "UPDATE qd_tier_sets SET archived_at = NULL, updated_at = ?1 WHERE id = ?2 AND archived_at IS NOT NULL",
663
+ [ts, tierSetId],
664
+ );
665
+ return r.rowCount > 0;
666
+ }
667
+
668
+ async function list(input) {
669
+ input = input || {};
670
+ var limit = input.limit == null ? 50 : input.limit;
671
+ if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIMIT) {
672
+ throw new TypeError("quantityDiscounts.list: limit must be 1..." + MAX_LIMIT);
673
+ }
674
+ var where = [];
675
+ var params = [];
676
+ var idx = 1;
677
+ if (input.scope !== undefined) {
678
+ _assertScope(input.scope);
679
+ where.push("scope = ?" + (idx++));
680
+ params.push(input.scope);
681
+ }
682
+ // archived filter: false (default) = active only, true = archived
683
+ // only, null = both. Explicit-tri lets the operator-facing list
684
+ // distinguish "show me archived rules" from "show everything".
685
+ if (input.archived === undefined || input.archived === false) {
686
+ where.push("archived_at IS NULL");
687
+ } else if (input.archived === true) {
688
+ where.push("archived_at IS NOT NULL");
689
+ } else if (input.archived !== null) {
690
+ throw new TypeError("quantityDiscounts.list: archived must be boolean or null");
691
+ }
692
+ var sql = "SELECT * FROM qd_tier_sets" +
693
+ (where.length ? " WHERE " + where.join(" AND ") : "") +
694
+ " ORDER BY updated_at DESC, id DESC LIMIT ?" + (idx++);
695
+ params.push(limit);
696
+ var r = await query(sql, params);
697
+ var out = [];
698
+ for (var i = 0; i < r.rows.length; i += 1) {
699
+ var setRow = r.rows[i];
700
+ var tierRows = await _tierRowsForSet(setRow.id);
701
+ out.push(_shapeTierSet(setRow, tierRows));
702
+ }
703
+ return out;
704
+ }
705
+
706
+ // ---- tierBreakdown ---------------------------------------------------
707
+
708
+ // Operator-friendly display surface — the active tier schedule for
709
+ // a (scope, scope_id) pair, rendered as a list of rows. Includes
710
+ // the per-rule "discounted unit at min_quantity" so the operator
711
+ // can sanity-check the schedule against a sample unit price.
712
+ async function tierBreakdown(input) {
713
+ if (!input || typeof input !== "object") {
714
+ throw new TypeError("quantityDiscounts.tierBreakdown: input object required");
715
+ }
716
+ _assertScope(input.scope);
717
+ var scopeId = input.scope_id == null ? null : input.scope_id;
718
+ _assertScopeIdShape(input.scope, scopeId);
719
+ var sampleUnit = input.sample_unit_price_minor;
720
+ if (sampleUnit !== undefined) {
721
+ _assertNonNegInt(sampleUnit, "sample_unit_price_minor");
722
+ }
723
+
724
+ var sql, params;
725
+ if (input.scope === "global") {
726
+ sql = "SELECT * FROM qd_tier_sets WHERE scope = 'global' AND archived_at IS NULL ORDER BY created_at ASC";
727
+ params = [];
728
+ } else {
729
+ sql = "SELECT * FROM qd_tier_sets WHERE scope = ?1 AND scope_id = ?2 AND archived_at IS NULL ORDER BY created_at ASC";
730
+ params = [input.scope, scopeId];
731
+ }
732
+ var setsRes = await query(sql, params);
733
+ var rows = [];
734
+ for (var i = 0; i < setsRes.rows.length; i += 1) {
735
+ var setRow = setsRes.rows[i];
736
+ var tierRows = await _tierRowsForSet(setRow.id);
737
+ for (var j = 0; j < tierRows.length; j += 1) {
738
+ var t = tierRows[j];
739
+ var entry = {
740
+ tier_set_id: setRow.id,
741
+ exclusive: setRow.exclusive === 1 || setRow.exclusive === true,
742
+ min_quantity: t.min_quantity,
743
+ discount_kind: t.discount_kind,
744
+ value: t.value,
745
+ };
746
+ if (sampleUnit !== undefined) {
747
+ var candidate = _applyRule(sampleUnit, t.min_quantity, t);
748
+ entry.sample_discounted_unit_minor = candidate.discounted_unit_minor;
749
+ entry.sample_line_subtotal_minor = candidate.line_subtotal_minor;
750
+ entry.sample_line_discount_minor = candidate.line_discount_minor;
751
+ }
752
+ rows.push(entry);
753
+ }
754
+ }
755
+ return {
756
+ scope: input.scope,
757
+ scope_id: scopeId,
758
+ rows: rows,
759
+ };
760
+ }
761
+
762
+ return {
763
+ defineTier: defineTier,
764
+ getTiersForLine: getTiersForLine,
765
+ applyToLine: applyToLine,
766
+ applyToCart: applyToCart,
767
+ update: update,
768
+ archive: archive,
769
+ unarchive: unarchive,
770
+ list: list,
771
+ tierBreakdown: tierBreakdown,
772
+ };
773
+ }
774
+
775
+ module.exports = {
776
+ create: create,
777
+ VALID_SCOPES: VALID_SCOPES,
778
+ VALID_KINDS: VALID_KINDS,
779
+ ALLOWED_SET_COLUMNS: ALLOWED_SET_COLUMNS,
780
+ MAX_TIERS_PER_SET: MAX_TIERS_PER_SET,
781
+ };