@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.
- package/CHANGELOG.md +2 -0
- package/lib/gift-options.js +596 -0
- package/lib/index.js +5 -0
- package/lib/quantity-discounts.js +781 -0
- package/lib/sales-reports.js +843 -0
- package/lib/subscription-controls.js +723 -0
- package/lib/support-tickets.js +898 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|