@blamejs/blamejs-shop 0.0.65 → 0.0.70

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/business-hours.js +980 -0
  5. package/lib/click-and-collect.js +711 -0
  6. package/lib/clickstream.js +713 -0
  7. package/lib/cost-layers.js +774 -0
  8. package/lib/credit-limits.js +752 -0
  9. package/lib/currency-rounding.js +525 -0
  10. package/lib/customer-activity.js +862 -0
  11. package/lib/customer-notes.js +712 -0
  12. package/lib/customer-risk-profile.js +593 -0
  13. package/lib/customer-surveys.js +1012 -0
  14. package/lib/damage-photos.js +473 -0
  15. package/lib/discount-allocation.js +557 -0
  16. package/lib/dropship-forwarding.js +645 -0
  17. package/lib/email-templates.js +817 -0
  18. package/lib/index.js +45 -0
  19. package/lib/inventory-allocations.js +559 -0
  20. package/lib/inventory-writeoffs.js +636 -0
  21. package/lib/knowledge-base.js +1104 -0
  22. package/lib/locale-router.js +1077 -0
  23. package/lib/operator-roles.js +768 -0
  24. package/lib/order-escalation.js +951 -0
  25. package/lib/order-ratings.js +495 -0
  26. package/lib/order-tags.js +944 -0
  27. package/lib/packing-slips.js +810 -0
  28. package/lib/payment-retries.js +816 -0
  29. package/lib/pick-lists.js +639 -0
  30. package/lib/pixel-events.js +995 -0
  31. package/lib/preorder.js +595 -0
  32. package/lib/print-queue.js +681 -0
  33. package/lib/product-qa.js +749 -0
  34. package/lib/promo-bundles.js +835 -0
  35. package/lib/push-notifications.js +937 -0
  36. package/lib/refund-automation.js +853 -0
  37. package/lib/reorder-reminders.js +798 -0
  38. package/lib/robots-config.js +753 -0
  39. package/lib/seller-signup.js +1052 -0
  40. package/lib/site-redirects.js +690 -0
  41. package/lib/sitemap-generator.js +717 -0
  42. package/lib/subscription-gifts.js +710 -0
  43. package/lib/tax-cert-renewals.js +632 -0
  44. package/lib/theme-assets.js +711 -0
  45. package/lib/tier-benefits.js +776 -0
  46. package/lib/vendor/MANIFEST.json +2 -2
  47. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  48. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  49. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  50. package/lib/vendor/blamejs/package.json +1 -1
  51. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  52. package/lib/wishlist-alerts.js +842 -0
  53. package/lib/wishlist-sharing.js +718 -0
  54. package/package.json +1 -1
@@ -0,0 +1,776 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.tierBenefits
4
+ * @title Tier-benefits primitive — per-loyalty-tier perks (free
5
+ * shipping, percent off, early access, priority support,
6
+ * exclusive products, birthday bonuses).
7
+ *
8
+ * @intro
9
+ * A loyalty tier (bronze / silver / gold / platinum, or whatever
10
+ * ladder the operator runs) carries persistent perks. The
11
+ * `loyalty` primitive answers "what tier is this customer on?";
12
+ * this primitive answers "what does that tier unlock?". The
13
+ * storefront uses it to render perk badges and gate early-access
14
+ * drops; checkout uses it to decide whether free-shipping or
15
+ * percent-off applies before computing totals.
16
+ *
17
+ * Distinct from `loyaltyRedemption` (which spends points for a
18
+ * one-shot coupon). Tier benefits cost nothing to "use" and don't
19
+ * decrement any balance — they're continuous perks the customer
20
+ * enjoys as long as their lifetime points keep them at the tier.
21
+ *
22
+ * Composition:
23
+ * var tb = bShop.tierBenefits.create({ query: q, loyalty: loy });
24
+ * await tb.defineBenefit({
25
+ * slug: "platinum-free-ship",
26
+ * tier: "platinum",
27
+ * kind: "free_shipping",
28
+ * value: { min_order_minor: 5000 },
29
+ * });
30
+ * var perks = await tb.benefitsForCustomer(custId);
31
+ * // [{ slug:"platinum-free-ship", tier:"platinum",
32
+ * // kind:"free_shipping", value:{...}, conditions:null }]
33
+ *
34
+ * Kinds:
35
+ *
36
+ * - `free_shipping` — value `{ min_order_minor?: N }`.
37
+ * - `percent_off` — value `{ percent: 1-100 }`.
38
+ * - `early_access` — value `{ hours: 1-720 }`.
39
+ * - `priority_support` — value `{ sla_minutes: 1-10080 }`.
40
+ * - `exclusive_access` — value `{ collection_slug: "vip" }`.
41
+ * - `birthday_bonus` — value `{ points?: N }` or
42
+ * `{ percent_off?: 1-100 }` (exactly one).
43
+ *
44
+ * Conditions (optional, on every kind):
45
+ *
46
+ * - `min_order_minor: N` — apply only if subtotal >= N.
47
+ * - `currencies: ["USD",...]` — restrict by currency.
48
+ * - `regions: ["US","CA",...]` — restrict by region code.
49
+ * - `starts_at: ms, ends_at: ms` — time-window gate.
50
+ *
51
+ * `benefitsForTier(tier)` and `benefitsForCustomer(customer_id,
52
+ * ctx?)` filter the live (non-archived) set against the supplied
53
+ * context envelope. `benefitsForCustomer` resolves the customer's
54
+ * tier by calling `loyalty.tierForCustomer(customer_id)` (or
55
+ * `loyalty.balance(customer_id).tier` when the loyalty handle
56
+ * doesn't expose `tierForCustomer`).
57
+ *
58
+ * `recordUsage` writes a usage event; `usageForBenefit` lists
59
+ * events for one benefit (optionally filtered by customer);
60
+ * `metricsForBenefit` aggregates count + sum(savings_minor).
61
+ *
62
+ * Monotonic per-process clock: two writes in the same millisecond
63
+ * would tie on `occurred_at` and make the "latest row" ambiguous
64
+ * for ordering reads. `_now` bumps to `prior + 1` on collision so
65
+ * the `(benefit_slug, occurred_at DESC)` and `(customer_id,
66
+ * occurred_at DESC)` indexes carry a strict per-process ordering.
67
+ *
68
+ * Surface:
69
+ * - defineBenefit({ slug, tier, kind, value, conditions? })
70
+ * - updateBenefit(slug, patch)
71
+ * - archiveBenefit(slug)
72
+ * - listBenefits({ tier?, kind?, include_archived? })
73
+ * - benefitsForTier(tier, ctx?)
74
+ * - benefitsForCustomer(customer_id, ctx?)
75
+ * - recordUsage({ benefit_slug, customer_id, context?,
76
+ * savings_minor?, occurred_at? })
77
+ * - usageForBenefit({ slug, from, to, customer_id?, limit? })
78
+ * - metricsForBenefit(slug, { from?, to? }?)
79
+ *
80
+ * Storage:
81
+ * - tier_benefits, tier_benefit_usages (migration
82
+ * 0141_tier_benefits.sql).
83
+ *
84
+ * @primitive tierBenefits
85
+ * @related b.uuid.v7, b.guardUuid, shop.loyalty,
86
+ * shop.loyaltyRedemption, shop.shipping
87
+ */
88
+
89
+ var bShop;
90
+ function _b() {
91
+ if (!bShop) bShop = require("./index");
92
+ return bShop.framework;
93
+ }
94
+
95
+ // ---- constants ----------------------------------------------------------
96
+
97
+ var KINDS = Object.freeze([
98
+ "free_shipping",
99
+ "percent_off",
100
+ "early_access",
101
+ "priority_support",
102
+ "exclusive_access",
103
+ "birthday_bonus",
104
+ ]);
105
+
106
+ var SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,79}$/;
107
+ var TIER_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
108
+ var PRINTABLE_RE = /^[^\x00-\x1f\x7f]*$/;
109
+
110
+ var MAX_CONTEXT_LEN = 256;
111
+ var MAX_CURRENCY_LEN = 16;
112
+ var MAX_REGION_LEN = 16;
113
+ var MAX_CURRENCIES_PER_GATE = 32;
114
+ var MAX_REGIONS_PER_GATE = 64;
115
+ var MAX_COLLECTION_SLUG_LEN = 80;
116
+
117
+ // ---- validators ---------------------------------------------------------
118
+
119
+ function _slug(s, label) {
120
+ if (typeof s !== "string" || !s.length) {
121
+ throw new TypeError("tierBenefits: " + label + " must be a non-empty string");
122
+ }
123
+ if (!SLUG_RE.test(s)) {
124
+ throw new TypeError(
125
+ "tierBenefits: " + label + " must match /^[a-z0-9][a-z0-9_-]{0,79}$/"
126
+ );
127
+ }
128
+ return s;
129
+ }
130
+
131
+ function _tier(s) {
132
+ if (typeof s !== "string" || !s.length) {
133
+ throw new TypeError("tierBenefits: tier must be a non-empty string");
134
+ }
135
+ var clean = s.toLowerCase();
136
+ if (!TIER_RE.test(clean)) {
137
+ throw new TypeError(
138
+ "tierBenefits: tier must match /^[a-z0-9][a-z0-9_-]{0,31}$/"
139
+ );
140
+ }
141
+ return clean;
142
+ }
143
+
144
+ function _kind(s) {
145
+ if (typeof s !== "string" || KINDS.indexOf(s) === -1) {
146
+ throw new TypeError("tierBenefits: kind must be one of " + KINDS.join(", "));
147
+ }
148
+ return s;
149
+ }
150
+
151
+ function _customerId(s) {
152
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
153
+ catch (e) {
154
+ throw new TypeError("tierBenefits: customer_id — " + (e && e.message || "invalid UUID"));
155
+ }
156
+ }
157
+
158
+ function _context(s) {
159
+ if (s == null) return null;
160
+ if (typeof s !== "string") {
161
+ throw new TypeError("tierBenefits: context must be a string");
162
+ }
163
+ if (!s.length) {
164
+ throw new TypeError("tierBenefits: context must be a non-empty string when provided");
165
+ }
166
+ if (s.length > MAX_CONTEXT_LEN) {
167
+ throw new TypeError("tierBenefits: context must be <= " + MAX_CONTEXT_LEN + " chars");
168
+ }
169
+ if (!PRINTABLE_RE.test(s)) {
170
+ throw new TypeError("tierBenefits: context must not contain control bytes");
171
+ }
172
+ return s;
173
+ }
174
+
175
+ function _epochMs(ts, label) {
176
+ if (typeof ts !== "number" || !Number.isInteger(ts) || ts < 0) {
177
+ throw new TypeError("tierBenefits: " + label + " must be a non-negative integer epoch-ms");
178
+ }
179
+ return ts;
180
+ }
181
+
182
+ function _savingsMinor(n) {
183
+ if (n == null) return null;
184
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
185
+ throw new TypeError("tierBenefits: savings_minor must be a non-negative integer when provided");
186
+ }
187
+ return n;
188
+ }
189
+
190
+ function _positiveIntInRange(n, label, lo, hi) {
191
+ if (typeof n !== "number" || !Number.isInteger(n) || n < lo || n > hi) {
192
+ throw new TypeError(
193
+ "tierBenefits: " + label + " must be an integer in [" + lo + ", " + hi + "]"
194
+ );
195
+ }
196
+ return n;
197
+ }
198
+
199
+ function _nonNegativeInt(n, label) {
200
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
201
+ throw new TypeError("tierBenefits: " + label + " must be a non-negative integer");
202
+ }
203
+ return n;
204
+ }
205
+
206
+ // ---- value validators (per-kind) ---------------------------------------
207
+
208
+ function _validateValue(kind, value) {
209
+ if (value == null || typeof value !== "object" || Array.isArray(value)) {
210
+ throw new TypeError("tierBenefits: value must be a plain object for kind " + kind);
211
+ }
212
+ var out = {};
213
+ switch (kind) {
214
+ case "free_shipping":
215
+ if (value.min_order_minor != null) {
216
+ out.min_order_minor = _nonNegativeInt(value.min_order_minor, "value.min_order_minor");
217
+ }
218
+ return out;
219
+ case "percent_off":
220
+ if (value.percent == null) {
221
+ throw new TypeError("tierBenefits: value.percent required for kind percent_off");
222
+ }
223
+ out.percent = _positiveIntInRange(value.percent, "value.percent", 1, 100);
224
+ return out;
225
+ case "early_access":
226
+ if (value.hours == null) {
227
+ throw new TypeError("tierBenefits: value.hours required for kind early_access");
228
+ }
229
+ out.hours = _positiveIntInRange(value.hours, "value.hours", 1, 720);
230
+ return out;
231
+ case "priority_support":
232
+ if (value.sla_minutes == null) {
233
+ throw new TypeError("tierBenefits: value.sla_minutes required for kind priority_support");
234
+ }
235
+ out.sla_minutes = _positiveIntInRange(value.sla_minutes, "value.sla_minutes", 1, 10080);
236
+ return out;
237
+ case "exclusive_access":
238
+ if (typeof value.collection_slug !== "string" || !value.collection_slug.length) {
239
+ throw new TypeError(
240
+ "tierBenefits: value.collection_slug required for kind exclusive_access"
241
+ );
242
+ }
243
+ if (value.collection_slug.length > MAX_COLLECTION_SLUG_LEN) {
244
+ throw new TypeError(
245
+ "tierBenefits: value.collection_slug must be <= " + MAX_COLLECTION_SLUG_LEN + " chars"
246
+ );
247
+ }
248
+ if (!SLUG_RE.test(value.collection_slug)) {
249
+ throw new TypeError(
250
+ "tierBenefits: value.collection_slug must match /^[a-z0-9][a-z0-9_-]{0,79}$/"
251
+ );
252
+ }
253
+ out.collection_slug = value.collection_slug;
254
+ return out;
255
+ case "birthday_bonus": {
256
+ var hasPoints = value.points != null;
257
+ var hasPct = value.percent_off != null;
258
+ if (hasPoints === hasPct) {
259
+ throw new TypeError(
260
+ "tierBenefits: birthday_bonus value must carry exactly one of points / percent_off"
261
+ );
262
+ }
263
+ if (hasPoints) {
264
+ out.points = _positiveIntInRange(value.points, "value.points", 1, 1000000);
265
+ } else {
266
+ out.percent_off = _positiveIntInRange(value.percent_off, "value.percent_off", 1, 100);
267
+ }
268
+ return out;
269
+ }
270
+ default:
271
+ // Unreachable — kind is validated upstream.
272
+ throw new TypeError("tierBenefits: unknown kind " + kind);
273
+ }
274
+ }
275
+
276
+ // ---- conditions validator ----------------------------------------------
277
+
278
+ function _validateConditions(conditions) {
279
+ if (conditions == null) return null;
280
+ if (typeof conditions !== "object" || Array.isArray(conditions)) {
281
+ throw new TypeError("tierBenefits: conditions must be a plain object or null");
282
+ }
283
+ var out = {};
284
+ var sawAny = false;
285
+ if (conditions.min_order_minor != null) {
286
+ out.min_order_minor = _nonNegativeInt(conditions.min_order_minor, "conditions.min_order_minor");
287
+ sawAny = true;
288
+ }
289
+ if (conditions.currencies != null) {
290
+ if (!Array.isArray(conditions.currencies) || !conditions.currencies.length) {
291
+ throw new TypeError("tierBenefits: conditions.currencies must be a non-empty array");
292
+ }
293
+ if (conditions.currencies.length > MAX_CURRENCIES_PER_GATE) {
294
+ throw new TypeError(
295
+ "tierBenefits: conditions.currencies length must be <= " + MAX_CURRENCIES_PER_GATE
296
+ );
297
+ }
298
+ var currencies = [];
299
+ for (var i = 0; i < conditions.currencies.length; i += 1) {
300
+ var c = conditions.currencies[i];
301
+ if (typeof c !== "string" || !c.length || c.length > MAX_CURRENCY_LEN || !PRINTABLE_RE.test(c)) {
302
+ throw new TypeError(
303
+ "tierBenefits: conditions.currencies[" + i + "] must be a printable string <= "
304
+ + MAX_CURRENCY_LEN + " chars"
305
+ );
306
+ }
307
+ currencies.push(c);
308
+ }
309
+ out.currencies = currencies;
310
+ sawAny = true;
311
+ }
312
+ if (conditions.regions != null) {
313
+ if (!Array.isArray(conditions.regions) || !conditions.regions.length) {
314
+ throw new TypeError("tierBenefits: conditions.regions must be a non-empty array");
315
+ }
316
+ if (conditions.regions.length > MAX_REGIONS_PER_GATE) {
317
+ throw new TypeError(
318
+ "tierBenefits: conditions.regions length must be <= " + MAX_REGIONS_PER_GATE
319
+ );
320
+ }
321
+ var regions = [];
322
+ for (var j = 0; j < conditions.regions.length; j += 1) {
323
+ var r = conditions.regions[j];
324
+ if (typeof r !== "string" || !r.length || r.length > MAX_REGION_LEN || !PRINTABLE_RE.test(r)) {
325
+ throw new TypeError(
326
+ "tierBenefits: conditions.regions[" + j + "] must be a printable string <= "
327
+ + MAX_REGION_LEN + " chars"
328
+ );
329
+ }
330
+ regions.push(r);
331
+ }
332
+ out.regions = regions;
333
+ sawAny = true;
334
+ }
335
+ if (conditions.starts_at != null || conditions.ends_at != null) {
336
+ if (conditions.starts_at == null || conditions.ends_at == null) {
337
+ throw new TypeError(
338
+ "tierBenefits: conditions.starts_at + conditions.ends_at must be paired"
339
+ );
340
+ }
341
+ var start = _epochMs(conditions.starts_at, "conditions.starts_at");
342
+ var end = _epochMs(conditions.ends_at, "conditions.ends_at");
343
+ if (end <= start) {
344
+ throw new TypeError("tierBenefits: conditions.ends_at must be > conditions.starts_at");
345
+ }
346
+ out.starts_at = start;
347
+ out.ends_at = end;
348
+ sawAny = true;
349
+ }
350
+ // Refuse unknown keys so a typo doesn't silently disable a gate.
351
+ var allowed = { min_order_minor: 1, currencies: 1, regions: 1, starts_at: 1, ends_at: 1 };
352
+ var keys = Object.keys(conditions);
353
+ for (var k = 0; k < keys.length; k += 1) {
354
+ if (!Object.prototype.hasOwnProperty.call(allowed, keys[k])) {
355
+ throw new TypeError("tierBenefits: conditions has unknown key " + JSON.stringify(keys[k]));
356
+ }
357
+ }
358
+ return sawAny ? out : null;
359
+ }
360
+
361
+ // ---- context-envelope matcher ------------------------------------------
362
+
363
+ function _matchConditions(conditions, ctx) {
364
+ if (!conditions) return true;
365
+ if (conditions.min_order_minor != null) {
366
+ var subtotal = ctx && typeof ctx.subtotal_minor === "number" ? ctx.subtotal_minor : 0;
367
+ if (subtotal < conditions.min_order_minor) return false;
368
+ }
369
+ if (conditions.currencies != null) {
370
+ var cur = ctx && ctx.currency;
371
+ if (typeof cur !== "string" || conditions.currencies.indexOf(cur) === -1) return false;
372
+ }
373
+ if (conditions.regions != null) {
374
+ var reg = ctx && ctx.region;
375
+ if (typeof reg !== "string" || conditions.regions.indexOf(reg) === -1) return false;
376
+ }
377
+ if (conditions.starts_at != null && conditions.ends_at != null) {
378
+ var nowTs = ctx && typeof ctx.now === "number" ? ctx.now : Date.now();
379
+ if (nowTs < conditions.starts_at || nowTs >= conditions.ends_at) return false;
380
+ }
381
+ return true;
382
+ }
383
+
384
+ // ---- monotonic clock ---------------------------------------------------
385
+ //
386
+ // Two writes against the same benefit/customer in the same millisecond
387
+ // would tie on `occurred_at` and make the "latest row" ambiguous for
388
+ // the `(benefit_slug, occurred_at DESC)` index that drives the usage
389
+ // feed. Tests that record + read back in tight loops depend on the
390
+ // strict-monotonic ordering — fast platforms collapse Date.now() to
391
+ // the same int across an entire batch.
392
+ var _lastTs = 0;
393
+ function _now() {
394
+ var t = Date.now();
395
+ if (t <= _lastTs) t = _lastTs + 1;
396
+ _lastTs = t;
397
+ return t;
398
+ }
399
+
400
+ // ---- factory ------------------------------------------------------------
401
+
402
+ function create(opts) {
403
+ opts = opts || {};
404
+ var query = opts.query;
405
+ if (!query) {
406
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
407
+ }
408
+ var loyalty = opts.loyalty || null;
409
+
410
+ async function _readBenefit(slug) {
411
+ var r = await query(
412
+ "SELECT slug, tier, kind, value_json, conditions_json, archived_at, created_at, updated_at "
413
+ + "FROM tier_benefits WHERE slug = ?1 LIMIT 1",
414
+ [slug],
415
+ );
416
+ return r.rows.length ? r.rows[0] : null;
417
+ }
418
+
419
+ function _hydrate(row) {
420
+ return {
421
+ slug: row.slug,
422
+ tier: row.tier,
423
+ kind: row.kind,
424
+ value: JSON.parse(row.value_json),
425
+ conditions: row.conditions_json == null ? null : JSON.parse(row.conditions_json),
426
+ archived_at: row.archived_at == null ? null : Number(row.archived_at),
427
+ created_at: Number(row.created_at),
428
+ updated_at: Number(row.updated_at),
429
+ };
430
+ }
431
+
432
+ async function _resolveTier(customerId) {
433
+ if (!loyalty) {
434
+ throw new TypeError(
435
+ "tierBenefits.benefitsForCustomer: loyalty handle not configured "
436
+ + "— pass { loyalty } to create(...)"
437
+ );
438
+ }
439
+ if (typeof loyalty.tierForCustomer === "function") {
440
+ var t = await loyalty.tierForCustomer(customerId);
441
+ if (typeof t !== "string" || !t.length) {
442
+ throw new TypeError(
443
+ "tierBenefits.benefitsForCustomer: loyalty.tierForCustomer returned "
444
+ + JSON.stringify(t) + " — expected a non-empty tier string"
445
+ );
446
+ }
447
+ return _tier(t);
448
+ }
449
+ if (typeof loyalty.balance === "function") {
450
+ var bal = await loyalty.balance(customerId);
451
+ if (!bal || typeof bal.tier !== "string" || !bal.tier.length) {
452
+ throw new TypeError(
453
+ "tierBenefits.benefitsForCustomer: loyalty.balance returned no .tier — "
454
+ + "cannot resolve customer tier"
455
+ );
456
+ }
457
+ return _tier(bal.tier);
458
+ }
459
+ throw new TypeError(
460
+ "tierBenefits.benefitsForCustomer: loyalty handle must expose "
461
+ + ".tierForCustomer(id) or .balance(id)"
462
+ );
463
+ }
464
+
465
+ // ---- defineBenefit -----------------------------------------------------
466
+
467
+ async function defineBenefit(input) {
468
+ if (!input || typeof input !== "object") {
469
+ throw new TypeError("tierBenefits.defineBenefit: input object required");
470
+ }
471
+ var slug = _slug(input.slug, "slug");
472
+ var tier = _tier(input.tier);
473
+ var kind = _kind(input.kind);
474
+ var value = _validateValue(kind, input.value);
475
+ var conditions = _validateConditions(input.conditions);
476
+
477
+ var existing = await _readBenefit(slug);
478
+ if (existing) {
479
+ throw new TypeError(
480
+ "tierBenefits.defineBenefit: slug " + JSON.stringify(slug)
481
+ + " already exists — use updateBenefit"
482
+ );
483
+ }
484
+
485
+ var ts = _now();
486
+ await query(
487
+ "INSERT INTO tier_benefits "
488
+ + "(slug, tier, kind, value_json, conditions_json, archived_at, created_at, updated_at) "
489
+ + "VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?6)",
490
+ [slug, tier, kind, JSON.stringify(value),
491
+ conditions == null ? null : JSON.stringify(conditions), ts],
492
+ );
493
+ var row = await _readBenefit(slug);
494
+ return _hydrate(row);
495
+ }
496
+
497
+ // ---- updateBenefit -----------------------------------------------------
498
+
499
+ async function updateBenefit(slug, patch) {
500
+ var s = _slug(slug, "slug");
501
+ if (!patch || typeof patch !== "object") {
502
+ throw new TypeError("tierBenefits.updateBenefit: patch object required");
503
+ }
504
+ var row = await _readBenefit(s);
505
+ if (!row) {
506
+ throw new TypeError(
507
+ "tierBenefits.updateBenefit: slug " + JSON.stringify(s) + " not found"
508
+ );
509
+ }
510
+ if (row.archived_at != null) {
511
+ throw new TypeError(
512
+ "tierBenefits.updateBenefit: slug " + JSON.stringify(s)
513
+ + " is archived — restore by writing a new slug"
514
+ );
515
+ }
516
+
517
+ var newTier = row.tier;
518
+ var newKind = row.kind;
519
+ var newValue = JSON.parse(row.value_json);
520
+ var newCond = row.conditions_json == null ? null : JSON.parse(row.conditions_json);
521
+
522
+ if (patch.tier !== undefined) newTier = _tier(patch.tier);
523
+ if (patch.kind !== undefined) newKind = _kind(patch.kind);
524
+ // Revalidate value against the (possibly new) kind. When kind changes
525
+ // without a fresh value the operator is wrong about the new value
526
+ // shape — refuse rather than silently re-coerce.
527
+ if (patch.kind !== undefined && patch.value === undefined) {
528
+ throw new TypeError(
529
+ "tierBenefits.updateBenefit: kind change requires a fresh value"
530
+ );
531
+ }
532
+ if (patch.value !== undefined) {
533
+ newValue = _validateValue(newKind, patch.value);
534
+ } else if (patch.kind !== undefined) {
535
+ newValue = _validateValue(newKind, newValue);
536
+ }
537
+ if (patch.conditions !== undefined) {
538
+ newCond = patch.conditions === null ? null : _validateConditions(patch.conditions);
539
+ }
540
+
541
+ // Refuse unknown patch keys — typo defense.
542
+ var allowed = { tier: 1, kind: 1, value: 1, conditions: 1 };
543
+ var keys = Object.keys(patch);
544
+ for (var i = 0; i < keys.length; i += 1) {
545
+ if (!Object.prototype.hasOwnProperty.call(allowed, keys[i])) {
546
+ throw new TypeError(
547
+ "tierBenefits.updateBenefit: unknown patch key " + JSON.stringify(keys[i])
548
+ );
549
+ }
550
+ }
551
+
552
+ var ts = _now();
553
+ await query(
554
+ "UPDATE tier_benefits SET tier = ?1, kind = ?2, value_json = ?3, conditions_json = ?4, "
555
+ + "updated_at = ?5 WHERE slug = ?6",
556
+ [newTier, newKind, JSON.stringify(newValue),
557
+ newCond == null ? null : JSON.stringify(newCond), ts, s],
558
+ );
559
+ var after = await _readBenefit(s);
560
+ return _hydrate(after);
561
+ }
562
+
563
+ // ---- archiveBenefit ----------------------------------------------------
564
+
565
+ async function archiveBenefit(slug) {
566
+ var s = _slug(slug, "slug");
567
+ var row = await _readBenefit(s);
568
+ if (!row) {
569
+ throw new TypeError(
570
+ "tierBenefits.archiveBenefit: slug " + JSON.stringify(s) + " not found"
571
+ );
572
+ }
573
+ if (row.archived_at != null) {
574
+ // Idempotent — return the existing archived row rather than
575
+ // double-stamping `updated_at` on a repeat call.
576
+ return _hydrate(row);
577
+ }
578
+ var ts = _now();
579
+ await query(
580
+ "UPDATE tier_benefits SET archived_at = ?1, updated_at = ?1 WHERE slug = ?2",
581
+ [ts, s],
582
+ );
583
+ var after = await _readBenefit(s);
584
+ return _hydrate(after);
585
+ }
586
+
587
+ // ---- listBenefits ------------------------------------------------------
588
+
589
+ async function listBenefits(filters) {
590
+ filters = filters || {};
591
+ var clauses = [];
592
+ var params = [];
593
+ var idx = 1;
594
+ if (filters.tier != null) {
595
+ var t = _tier(filters.tier);
596
+ clauses.push("tier = ?" + idx); params.push(t); idx += 1;
597
+ }
598
+ if (filters.kind != null) {
599
+ var k = _kind(filters.kind);
600
+ clauses.push("kind = ?" + idx); params.push(k); idx += 1;
601
+ }
602
+ if (!filters.include_archived) {
603
+ clauses.push("archived_at IS NULL");
604
+ }
605
+ var sql = "SELECT slug, tier, kind, value_json, conditions_json, archived_at, "
606
+ + "created_at, updated_at FROM tier_benefits";
607
+ if (clauses.length) sql += " WHERE " + clauses.join(" AND ");
608
+ sql += " ORDER BY tier ASC, slug ASC";
609
+ var r = await query(sql, params);
610
+ var out = [];
611
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_hydrate(r.rows[i]));
612
+ return out;
613
+ }
614
+
615
+ // ---- benefitsForTier ---------------------------------------------------
616
+
617
+ async function benefitsForTier(tier, ctx) {
618
+ var t = _tier(tier);
619
+ var r = await query(
620
+ "SELECT slug, tier, kind, value_json, conditions_json, archived_at, "
621
+ + "created_at, updated_at FROM tier_benefits "
622
+ + "WHERE tier = ?1 AND archived_at IS NULL ORDER BY slug ASC",
623
+ [t],
624
+ );
625
+ var out = [];
626
+ for (var i = 0; i < r.rows.length; i += 1) {
627
+ var row = _hydrate(r.rows[i]);
628
+ if (_matchConditions(row.conditions, ctx || null)) out.push(row);
629
+ }
630
+ return out;
631
+ }
632
+
633
+ // ---- benefitsForCustomer ----------------------------------------------
634
+
635
+ async function benefitsForCustomer(customerId, ctx) {
636
+ _customerId(customerId);
637
+ var tier = await _resolveTier(customerId);
638
+ return await benefitsForTier(tier, ctx);
639
+ }
640
+
641
+ // ---- recordUsage -------------------------------------------------------
642
+
643
+ async function recordUsage(input) {
644
+ if (!input || typeof input !== "object") {
645
+ throw new TypeError("tierBenefits.recordUsage: input object required");
646
+ }
647
+ var slug = _slug(input.benefit_slug, "benefit_slug");
648
+ var customerId = _customerId(input.customer_id);
649
+ var context = _context(input.context == null ? null : input.context);
650
+ var savings = _savingsMinor(input.savings_minor);
651
+ var occurredAt = input.occurred_at != null
652
+ ? _epochMs(input.occurred_at, "occurred_at")
653
+ : _now();
654
+
655
+ var row = await _readBenefit(slug);
656
+ if (!row) {
657
+ throw new TypeError(
658
+ "tierBenefits.recordUsage: benefit_slug " + JSON.stringify(slug) + " not found"
659
+ );
660
+ }
661
+ if (row.archived_at != null) {
662
+ throw new TypeError(
663
+ "tierBenefits.recordUsage: benefit_slug " + JSON.stringify(slug)
664
+ + " is archived — usage refused"
665
+ );
666
+ }
667
+
668
+ var id = _b().uuid.v7();
669
+ await query(
670
+ "INSERT INTO tier_benefit_usages "
671
+ + "(id, benefit_slug, customer_id, context, savings_minor, occurred_at) "
672
+ + "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
673
+ [id, slug, customerId, context, savings, occurredAt],
674
+ );
675
+ return {
676
+ id: id,
677
+ benefit_slug: slug,
678
+ customer_id: customerId,
679
+ context: context,
680
+ savings_minor: savings,
681
+ occurred_at: occurredAt,
682
+ };
683
+ }
684
+
685
+ // ---- usageForBenefit --------------------------------------------------
686
+
687
+ async function usageForBenefit(filters) {
688
+ if (!filters || typeof filters !== "object") {
689
+ throw new TypeError("tierBenefits.usageForBenefit: filters object required");
690
+ }
691
+ var slug = _slug(filters.slug, "slug");
692
+ var from = _epochMs(filters.from, "from");
693
+ var to = _epochMs(filters.to, "to");
694
+ if (to < from) {
695
+ throw new TypeError("tierBenefits.usageForBenefit: to must be >= from");
696
+ }
697
+ var limit = filters.limit != null ? filters.limit : 500;
698
+ if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 5000) {
699
+ throw new TypeError("tierBenefits.usageForBenefit: limit must be an integer in [1, 5000]");
700
+ }
701
+ var sql = "SELECT id, benefit_slug, customer_id, context, savings_minor, occurred_at "
702
+ + "FROM tier_benefit_usages "
703
+ + "WHERE benefit_slug = ?1 AND occurred_at >= ?2 AND occurred_at <= ?3";
704
+ var params = [slug, from, to];
705
+ if (filters.customer_id != null) {
706
+ var cid = _customerId(filters.customer_id);
707
+ sql += " AND customer_id = ?" + (params.length + 1);
708
+ params.push(cid);
709
+ }
710
+ sql += " ORDER BY occurred_at DESC LIMIT ?" + (params.length + 1);
711
+ params.push(limit);
712
+ var r = await query(sql, params);
713
+ var out = [];
714
+ for (var i = 0; i < r.rows.length; i += 1) {
715
+ var row = r.rows[i];
716
+ out.push({
717
+ id: row.id,
718
+ benefit_slug: row.benefit_slug,
719
+ customer_id: row.customer_id,
720
+ context: row.context == null ? null : row.context,
721
+ savings_minor: row.savings_minor == null ? null : Number(row.savings_minor),
722
+ occurred_at: Number(row.occurred_at),
723
+ });
724
+ }
725
+ return out;
726
+ }
727
+
728
+ // ---- metricsForBenefit ------------------------------------------------
729
+
730
+ async function metricsForBenefit(slug, window) {
731
+ var s = _slug(slug, "slug");
732
+ window = window || {};
733
+ var fromMs = window.from != null ? _epochMs(window.from, "from") : 0;
734
+ var toMs = window.to != null ? _epochMs(window.to, "to") : Number.MAX_SAFE_INTEGER;
735
+ if (toMs < fromMs) {
736
+ throw new TypeError("tierBenefits.metricsForBenefit: to must be >= from");
737
+ }
738
+ var r = await query(
739
+ "SELECT COUNT(*) AS count_all, "
740
+ + "COUNT(savings_minor) AS count_with_savings, "
741
+ + "COALESCE(SUM(savings_minor), 0) AS sum_savings "
742
+ + "FROM tier_benefit_usages "
743
+ + "WHERE benefit_slug = ?1 AND occurred_at >= ?2 AND occurred_at <= ?3",
744
+ [s, fromMs, toMs],
745
+ );
746
+ var row = r.rows[0] || { count_all: 0, count_with_savings: 0, sum_savings: 0 };
747
+ return {
748
+ slug: s,
749
+ count: Number(row.count_all),
750
+ count_with_savings: Number(row.count_with_savings),
751
+ sum_savings_minor: Number(row.sum_savings),
752
+ from: fromMs,
753
+ to: toMs === Number.MAX_SAFE_INTEGER ? null : toMs,
754
+ };
755
+ }
756
+
757
+ return {
758
+ KINDS: KINDS.slice(),
759
+ SLUG_RE: SLUG_RE,
760
+
761
+ defineBenefit: defineBenefit,
762
+ updateBenefit: updateBenefit,
763
+ archiveBenefit: archiveBenefit,
764
+ listBenefits: listBenefits,
765
+ benefitsForTier: benefitsForTier,
766
+ benefitsForCustomer: benefitsForCustomer,
767
+ recordUsage: recordUsage,
768
+ usageForBenefit: usageForBenefit,
769
+ metricsForBenefit: metricsForBenefit,
770
+ };
771
+ }
772
+
773
+ module.exports = {
774
+ create: create,
775
+ KINDS: KINDS,
776
+ };