@blamejs/blamejs-shop 0.0.70 → 0.0.75

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 (46) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +42 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/loyalty-earn-rules.js +786 -0
  22. package/lib/marketing-budget.js +792 -0
  23. package/lib/operator-activity-feed.js +977 -0
  24. package/lib/operator-approvals.js +942 -0
  25. package/lib/operator-help-center.js +1020 -0
  26. package/lib/operator-inbox.js +889 -0
  27. package/lib/operator-sessions.js +701 -0
  28. package/lib/order-exchanges.js +602 -0
  29. package/lib/product-compare.js +804 -0
  30. package/lib/pwa-manifest.js +1005 -0
  31. package/lib/referral-leaderboard.js +612 -0
  32. package/lib/sales-tax-filings.js +807 -0
  33. package/lib/search-ranking.js +859 -0
  34. package/lib/shipping-insurance.js +757 -0
  35. package/lib/shrinkage-report.js +1182 -0
  36. package/lib/sidebar-widgets.js +952 -0
  37. package/lib/smart-restocking.js +1048 -0
  38. package/lib/split-shipments.js +7 -1
  39. package/lib/stock-receipts.js +834 -0
  40. package/lib/subscription-analytics.js +1032 -0
  41. package/lib/suggestion-box.js +921 -0
  42. package/lib/tax-remittance.js +625 -0
  43. package/lib/vendor-invoices.js +1021 -0
  44. package/lib/winback-campaigns.js +1350 -0
  45. package/lib/wishlist-digest.js +1133 -0
  46. package/package.json +1 -1
@@ -0,0 +1,786 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.loyaltyEarnRules
4
+ * @title Loyalty earn rules — per-action point-earning configuration
5
+ *
6
+ * @intro
7
+ * Distinct from `loyalty` (which records the running points balance
8
+ * and the audited transaction trail) and from `tierBenefits` (which
9
+ * configures perks unlocked at each tier). This primitive defines
10
+ * HOW points are earned — operators publish rules keyed by an event
11
+ * `trigger` and the application calls `awardForEvent` at the
12
+ * appropriate lifecycle moment.
13
+ *
14
+ * Triggers (closed enum):
15
+ * - per_dollar_spent — N points per $1 of order subtotal
16
+ * - per_purchase — flat N points per completed order
17
+ * - per_review — N points per review submitted
18
+ * - per_referral_redeemed — N points per referred friend's
19
+ * first order completing
20
+ * - birthday — N points on the customer's birthday
21
+ * - signup_bonus — N points on account creation
22
+ * - first_purchase — N points on the first completed order
23
+ * - abandoned_cart_recovered — N points when a recovered cart converts
24
+ *
25
+ * Composition:
26
+ *
27
+ * var rules = bShop.loyaltyEarnRules.create({
28
+ * query: q,
29
+ * loyalty: loy, // optional — awardForEvent composes loy.earn
30
+ * });
31
+ *
32
+ * await rules.defineRule({
33
+ * slug: "spend-1pt-per-dollar",
34
+ * trigger: "per_dollar_spent",
35
+ * points_per_unit: 1,
36
+ * max_per_event: 5000,
37
+ * customer_status_in: ["active", "vip"],
38
+ * });
39
+ *
40
+ * await rules.awardForEvent({
41
+ * trigger: "per_dollar_spent",
42
+ * customer_id: customerId,
43
+ * dollars_spent: 42,
44
+ * trigger_event_ref: "order:" + orderId,
45
+ * customer_status: "active",
46
+ * });
47
+ *
48
+ * `evaluateForEvent` is the dry-run companion to `awardForEvent`. It
49
+ * returns the same `{ points, reason }` shape but does NOT touch the
50
+ * audit log or the loyalty ledger — operators preview an award at
51
+ * checkout (so the customer sees "you'll earn 42 points") without
52
+ * committing.
53
+ *
54
+ * `applyBatch` runs multiple awards in a single call. Each (rule,
55
+ * event) pair flows through the same validate -> evaluate -> award
56
+ * path; per-pair failures are collected into a `failed[]` array
57
+ * rather than failing the whole batch (operators commonly run nightly
58
+ * sweeps that span thousands of events — a malformed row shouldn't
59
+ * block the rest).
60
+ *
61
+ * Per-event dedup: the (rule_slug, customer_id, trigger_event_ref)
62
+ * UNIQUE on `loyalty_earn_log` collapses retried inserts onto one
63
+ * row so a webhook retry doesn't double-award.
64
+ *
65
+ * Composes:
66
+ * - `b.uuid.v7` — audit-log row ids (lexicographic + monotonic)
67
+ * - `b.guardUuid` — strict UUID gate on every customer_id
68
+ * - `loyalty` — optional; when wired, `awardForEvent` composes
69
+ * `loyalty.earn` so the points land in the
70
+ * customer's balance + the loyalty audit trail
71
+ * in one call.
72
+ *
73
+ * Storage: `migrations-d1/0163_loyalty_earn_rules.sql` —
74
+ * `loyalty_earn_rules` + `loyalty_earn_log`.
75
+ *
76
+ * @primitive loyaltyEarnRules
77
+ * @related loyalty, tierBenefits, b.uuid.v7, b.guardUuid
78
+ */
79
+
80
+ var bShop;
81
+ function _b() {
82
+ if (!bShop) bShop = require("./index");
83
+ return bShop.framework;
84
+ }
85
+
86
+ // ---- constants ----------------------------------------------------------
87
+
88
+ var TRIGGERS = Object.freeze([
89
+ "per_dollar_spent",
90
+ "per_purchase",
91
+ "per_review",
92
+ "per_referral_redeemed",
93
+ "birthday",
94
+ "signup_bonus",
95
+ "first_purchase",
96
+ "abandoned_cart_recovered",
97
+ ]);
98
+
99
+ // Triggers that scale points by a caller-supplied unit count.
100
+ // per_dollar_spent multiplies points_per_unit by the order's dollar
101
+ // subtotal; every other trigger awards a flat points_per_unit.
102
+ var UNIT_TRIGGERS = Object.freeze({
103
+ per_dollar_spent: "dollars_spent",
104
+ });
105
+
106
+ var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$/;
107
+ var TRIGGER_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
108
+ var STATUS_RE = /^[a-z][a-z0-9_-]{0,31}$/;
109
+
110
+ var MAX_STATUS_LIST = 16;
111
+ var MAX_POINTS_PER_UNIT = 1000000; // 1M cap on a single multiplier
112
+ var MAX_MAX_PER_EVENT = 1000000000; // 1B cap on a per-event ceiling
113
+
114
+ var DEFAULT_LIST_LIMIT = 50;
115
+ var MAX_LIST_LIMIT = 500;
116
+
117
+ // ---- monotonic clock ----------------------------------------------------
118
+ //
119
+ // Awards land in `loyalty_earn_log` keyed by `occurred_at`. The metrics
120
+ // rollup window scans by (rule_slug, occurred_at >= from AND <= to);
121
+ // two awards in the same millisecond would tie on the sort key and the
122
+ // `applyBatch` path issues many awards in a tight loop. The strict-
123
+ // monotonic clock guarantees distinct timestamps per call so ordering
124
+ // is deterministic without a tiebreaker column.
125
+
126
+ var _lastTs = 0;
127
+ function _now() {
128
+ var t = Date.now();
129
+ if (t <= _lastTs) { t = _lastTs + 1; }
130
+ _lastTs = t;
131
+ return t;
132
+ }
133
+
134
+ // ---- validators ---------------------------------------------------------
135
+
136
+ function _slug(s, label) {
137
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
138
+ throw new TypeError("loyaltyEarnRules: " + (label || "slug") +
139
+ " must be lowercase alnum + dash, no leading/trailing dash, 1..100 chars");
140
+ }
141
+ return s;
142
+ }
143
+
144
+ function _trigger(s) {
145
+ if (typeof s !== "string" || TRIGGERS.indexOf(s) < 0) {
146
+ throw new TypeError("loyaltyEarnRules: trigger must be one of " + TRIGGERS.join(", "));
147
+ }
148
+ return s;
149
+ }
150
+
151
+ function _pointsPerUnit(n) {
152
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0 || n > MAX_POINTS_PER_UNIT) {
153
+ throw new TypeError("loyaltyEarnRules: points_per_unit must be a positive integer <= " +
154
+ MAX_POINTS_PER_UNIT);
155
+ }
156
+ return n;
157
+ }
158
+
159
+ function _maxPerEvent(n) {
160
+ if (n == null) return null;
161
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0 || n > MAX_MAX_PER_EVENT) {
162
+ throw new TypeError("loyaltyEarnRules: max_per_event must be a positive integer <= " +
163
+ MAX_MAX_PER_EVENT + " (or omitted)");
164
+ }
165
+ return n;
166
+ }
167
+
168
+ function _customerStatusIn(arr) {
169
+ if (arr == null) return null;
170
+ if (!Array.isArray(arr)) {
171
+ throw new TypeError("loyaltyEarnRules: customer_status_in must be an array of status strings");
172
+ }
173
+ if (arr.length === 0) {
174
+ throw new TypeError("loyaltyEarnRules: customer_status_in must be non-empty when provided");
175
+ }
176
+ if (arr.length > MAX_STATUS_LIST) {
177
+ throw new TypeError("loyaltyEarnRules: customer_status_in must contain <= " +
178
+ MAX_STATUS_LIST + " entries");
179
+ }
180
+ var seen = Object.create(null);
181
+ var out = [];
182
+ for (var i = 0; i < arr.length; i += 1) {
183
+ var s = arr[i];
184
+ if (typeof s !== "string" || !STATUS_RE.test(s)) {
185
+ throw new TypeError("loyaltyEarnRules: customer_status_in[" + i +
186
+ "] must be lowercase alnum / underscore / dash, 1..32 chars");
187
+ }
188
+ if (seen[s]) {
189
+ throw new TypeError("loyaltyEarnRules: customer_status_in[" + i +
190
+ "] duplicates a previous entry");
191
+ }
192
+ seen[s] = true;
193
+ out.push(s);
194
+ }
195
+ return out;
196
+ }
197
+
198
+ function _uuid(s, label) {
199
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
200
+ catch (e) { throw new TypeError("loyaltyEarnRules: " + label + " — " + (e && e.message || "invalid UUID")); }
201
+ }
202
+
203
+ function _triggerEventRef(s) {
204
+ if (typeof s !== "string" || !TRIGGER_REF_RE.test(s)) {
205
+ throw new TypeError("loyaltyEarnRules: trigger_event_ref must match /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ (1..128 chars)");
206
+ }
207
+ return s;
208
+ }
209
+
210
+ function _statusOpt(s) {
211
+ if (s == null) return null;
212
+ if (typeof s !== "string" || !STATUS_RE.test(s)) {
213
+ throw new TypeError("loyaltyEarnRules: customer_status must be lowercase alnum / underscore / dash, 1..32 chars");
214
+ }
215
+ return s;
216
+ }
217
+
218
+ function _epochOpt(n, label) {
219
+ if (n == null) return null;
220
+ if (!Number.isInteger(n) || n < 0) {
221
+ throw new TypeError("loyaltyEarnRules: " + label + " must be a non-negative integer (ms epoch) or null");
222
+ }
223
+ return n;
224
+ }
225
+
226
+ function _limit(n) {
227
+ if (n == null) return DEFAULT_LIST_LIMIT;
228
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
229
+ throw new TypeError("loyaltyEarnRules: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
230
+ }
231
+ return n;
232
+ }
233
+
234
+ function _bool(v, label) {
235
+ if (typeof v !== "boolean") {
236
+ throw new TypeError("loyaltyEarnRules: " + label + " must be a boolean");
237
+ }
238
+ return v;
239
+ }
240
+
241
+ // Pure compute — given a rule row + an event context, return the
242
+ // (points, reason) tuple WITHOUT touching storage. Exported via
243
+ // evaluateForEvent + reused inside awardForEvent so the math is
244
+ // single-sourced.
245
+ function _computePoints(rule, ctx) {
246
+ var unitField = UNIT_TRIGGERS[rule.trigger];
247
+ var units = 1;
248
+ if (unitField != null) {
249
+ var raw = ctx[unitField];
250
+ if (typeof raw !== "number" || !isFinite(raw) || raw < 0) {
251
+ return { points: 0, reason: unitField + " must be a non-negative finite number for trigger " + rule.trigger };
252
+ }
253
+ units = Math.floor(raw);
254
+ if (units <= 0) {
255
+ return { points: 0, reason: unitField + " floored to zero" };
256
+ }
257
+ }
258
+ var raw_points = rule.points_per_unit * units;
259
+ if (rule.max_per_event != null && raw_points > rule.max_per_event) {
260
+ return {
261
+ points: rule.max_per_event,
262
+ reason: "capped at max_per_event=" + rule.max_per_event +
263
+ " (uncapped would have been " + raw_points + ")",
264
+ capped: true,
265
+ };
266
+ }
267
+ return { points: raw_points, reason: "trigger=" + rule.trigger + " units=" + units, capped: false };
268
+ }
269
+
270
+ // ---- factory ------------------------------------------------------------
271
+
272
+ function create(opts) {
273
+ opts = opts || {};
274
+ var query = opts.query;
275
+ if (!query) {
276
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
277
+ }
278
+ // Optional loyalty handle — when wired, awardForEvent calls
279
+ // loyalty.earn so the points land in the customer's balance + the
280
+ // loyalty transaction audit trail in one go. Absent, the primitive
281
+ // still writes the loyalty_earn_log breadcrumb but the operator is
282
+ // responsible for posting to the ledger separately.
283
+ var loyaltyHandle = opts.loyalty || null;
284
+
285
+ // ---- internal helpers -----------------------------------------------
286
+
287
+ function _decodeRule(row) {
288
+ if (!row) return null;
289
+ var statusList = null;
290
+ if (row.customer_status_in_json) {
291
+ try { statusList = JSON.parse(row.customer_status_in_json); }
292
+ catch (_e) { statusList = null; }
293
+ }
294
+ return {
295
+ slug: row.slug,
296
+ trigger: row.trigger,
297
+ points_per_unit: Number(row.points_per_unit),
298
+ max_per_event: row.max_per_event == null ? null : Number(row.max_per_event),
299
+ customer_status_in: statusList,
300
+ active: Number(row.active) === 1,
301
+ archived_at: row.archived_at == null ? null : Number(row.archived_at),
302
+ created_at: Number(row.created_at),
303
+ updated_at: Number(row.updated_at),
304
+ };
305
+ }
306
+
307
+ async function _ruleRow(slug) {
308
+ var r = await query("SELECT * FROM loyalty_earn_rules WHERE slug = ?1", [slug]);
309
+ return r.rows[0] || null;
310
+ }
311
+
312
+ // Status gate. If the rule restricts to a status list, the event's
313
+ // customer_status MUST appear in the list. NULL list means no
314
+ // restriction. Returns null when the event passes, or a reason
315
+ // string when it's filtered out.
316
+ function _statusFilter(rule, ctx) {
317
+ if (rule.customer_status_in == null) return null;
318
+ var status = ctx.customer_status;
319
+ if (status == null || rule.customer_status_in.indexOf(status) < 0) {
320
+ return "customer_status=" + JSON.stringify(status) +
321
+ " not in [" + rule.customer_status_in.join(", ") + "]";
322
+ }
323
+ return null;
324
+ }
325
+
326
+ // ---- defineRule -----------------------------------------------------
327
+
328
+ async function defineRule(input) {
329
+ if (!input || typeof input !== "object") {
330
+ throw new TypeError("loyaltyEarnRules.defineRule: input object required");
331
+ }
332
+ var slug = _slug(input.slug, "slug");
333
+ var trigger = _trigger(input.trigger);
334
+ var pointsPerUnit = _pointsPerUnit(input.points_per_unit);
335
+ var maxPerEvent = _maxPerEvent(input.max_per_event);
336
+ var customerStatusIn = _customerStatusIn(input.customer_status_in);
337
+ var active = input.active == null ? true : _bool(input.active, "active");
338
+
339
+ var existing = await _ruleRow(slug);
340
+ var ts = _now();
341
+ if (existing) {
342
+ if (existing.archived_at != null) {
343
+ throw new TypeError("loyaltyEarnRules.defineRule: rule " + JSON.stringify(slug) + " is archived");
344
+ }
345
+ await query(
346
+ "UPDATE loyalty_earn_rules " +
347
+ "SET trigger = ?1, points_per_unit = ?2, max_per_event = ?3, " +
348
+ "customer_status_in_json = ?4, active = ?5, updated_at = ?6 " +
349
+ "WHERE slug = ?7",
350
+ [trigger, pointsPerUnit, maxPerEvent,
351
+ customerStatusIn == null ? null : JSON.stringify(customerStatusIn),
352
+ active ? 1 : 0, ts, slug],
353
+ );
354
+ } else {
355
+ await query(
356
+ "INSERT INTO loyalty_earn_rules " +
357
+ "(slug, trigger, points_per_unit, max_per_event, customer_status_in_json, " +
358
+ " active, archived_at, created_at, updated_at) " +
359
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?7)",
360
+ [slug, trigger, pointsPerUnit, maxPerEvent,
361
+ customerStatusIn == null ? null : JSON.stringify(customerStatusIn),
362
+ active ? 1 : 0, ts],
363
+ );
364
+ }
365
+ return _decodeRule(await _ruleRow(slug));
366
+ }
367
+
368
+ // ---- getRule / listRules --------------------------------------------
369
+
370
+ async function getRule(slug) {
371
+ _slug(slug, "slug");
372
+ return _decodeRule(await _ruleRow(slug));
373
+ }
374
+
375
+ async function listRules(listOpts) {
376
+ listOpts = listOpts || {};
377
+ var activeOnly = listOpts.active_only == null ? false : _bool(listOpts.active_only, "active_only");
378
+ var limit = _limit(listOpts.limit);
379
+ var sql = "SELECT * FROM loyalty_earn_rules";
380
+ var params = [];
381
+ var idx = 1;
382
+ var where = [];
383
+ if (activeOnly) {
384
+ where.push("active = ?" + idx); params.push(1); idx += 1;
385
+ where.push("archived_at IS NULL");
386
+ }
387
+ if (listOpts.trigger != null) {
388
+ where.push("trigger = ?" + idx); params.push(_trigger(listOpts.trigger)); idx += 1;
389
+ }
390
+ if (where.length) sql += " WHERE " + where.join(" AND ");
391
+ sql += " ORDER BY slug ASC LIMIT ?" + idx;
392
+ params.push(limit);
393
+ var r = await query(sql, params);
394
+ var out = [];
395
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_decodeRule(r.rows[i]));
396
+ return out;
397
+ }
398
+
399
+ // ---- updateRule -----------------------------------------------------
400
+
401
+ async function updateRule(slug, patch) {
402
+ _slug(slug, "slug");
403
+ if (!patch || typeof patch !== "object") {
404
+ throw new TypeError("loyaltyEarnRules.updateRule: patch object required");
405
+ }
406
+ var existing = await _ruleRow(slug);
407
+ if (!existing) return null;
408
+ if (existing.archived_at != null) {
409
+ throw new TypeError("loyaltyEarnRules.updateRule: rule " + JSON.stringify(slug) + " is archived");
410
+ }
411
+ var decoded = _decodeRule(existing);
412
+
413
+ var nextPoints = decoded.points_per_unit;
414
+ if (Object.prototype.hasOwnProperty.call(patch, "points_per_unit")) {
415
+ nextPoints = _pointsPerUnit(patch.points_per_unit);
416
+ }
417
+ var nextMax = decoded.max_per_event;
418
+ if (Object.prototype.hasOwnProperty.call(patch, "max_per_event")) {
419
+ nextMax = _maxPerEvent(patch.max_per_event);
420
+ }
421
+ var nextStatus = decoded.customer_status_in;
422
+ if (Object.prototype.hasOwnProperty.call(patch, "customer_status_in")) {
423
+ nextStatus = _customerStatusIn(patch.customer_status_in);
424
+ }
425
+ var nextActive = decoded.active;
426
+ if (Object.prototype.hasOwnProperty.call(patch, "active")) {
427
+ nextActive = _bool(patch.active, "active");
428
+ }
429
+ // trigger is immutable on update — operators that need a different
430
+ // trigger archive the rule and define a new one. Otherwise the
431
+ // metricsForRule history straddles two semantically distinct
432
+ // event spaces and the rollup becomes a lie.
433
+ if (Object.prototype.hasOwnProperty.call(patch, "trigger") && patch.trigger !== decoded.trigger) {
434
+ throw new TypeError("loyaltyEarnRules.updateRule: trigger is immutable — archive + define a new rule instead");
435
+ }
436
+
437
+ var ts = _now();
438
+ await query(
439
+ "UPDATE loyalty_earn_rules SET points_per_unit = ?1, max_per_event = ?2, " +
440
+ "customer_status_in_json = ?3, active = ?4, updated_at = ?5 WHERE slug = ?6",
441
+ [nextPoints, nextMax,
442
+ nextStatus == null ? null : JSON.stringify(nextStatus),
443
+ nextActive ? 1 : 0, ts, slug],
444
+ );
445
+ return _decodeRule(await _ruleRow(slug));
446
+ }
447
+
448
+ // ---- archiveRule ----------------------------------------------------
449
+
450
+ async function archiveRule(slug) {
451
+ _slug(slug, "slug");
452
+ var ts = _now();
453
+ var r = await query(
454
+ "UPDATE loyalty_earn_rules SET archived_at = ?1, active = 0, updated_at = ?1 " +
455
+ "WHERE slug = ?2 AND archived_at IS NULL",
456
+ [ts, slug],
457
+ );
458
+ if (Number(r.rowCount || 0) === 0) {
459
+ var existing = await _ruleRow(slug);
460
+ if (!existing) return null;
461
+ return _decodeRule(existing);
462
+ }
463
+ return _decodeRule(await _ruleRow(slug));
464
+ }
465
+
466
+ // ---- evaluateForEvent (dry-run) -------------------------------------
467
+
468
+ async function evaluateForEvent(input) {
469
+ if (!input || typeof input !== "object") {
470
+ throw new TypeError("loyaltyEarnRules.evaluateForEvent: input object required");
471
+ }
472
+ var trigger = _trigger(input.trigger);
473
+ _uuid(input.customer_id, "customer_id");
474
+ _statusOpt(input.customer_status);
475
+
476
+ // The slug-targeted path lets operators preview a single named
477
+ // rule even when several rules share the same trigger. Absent a
478
+ // slug, every active matching-trigger rule is evaluated; the
479
+ // primitive returns each rule's verdict (eligible / skipped /
480
+ // capped) so the operator-facing UI can render a per-rule
481
+ // breakdown.
482
+ var rules;
483
+ if (input.slug != null) {
484
+ var slug = _slug(input.slug, "slug");
485
+ var r = await _ruleRow(slug);
486
+ rules = r ? [r] : [];
487
+ } else {
488
+ var r2 = await query(
489
+ "SELECT * FROM loyalty_earn_rules WHERE trigger = ?1 AND active = 1 AND archived_at IS NULL " +
490
+ "ORDER BY slug ASC",
491
+ [trigger],
492
+ );
493
+ rules = r2.rows;
494
+ }
495
+
496
+ var verdicts = [];
497
+ var totalPoints = 0;
498
+ for (var i = 0; i < rules.length; i += 1) {
499
+ var rule = _decodeRule(rules[i]);
500
+ if (rule.trigger !== trigger) {
501
+ verdicts.push({ slug: rule.slug, eligible: false, points: 0,
502
+ reason: "rule.trigger=" + rule.trigger + " != requested " + trigger });
503
+ continue;
504
+ }
505
+ if (!rule.active || rule.archived_at != null) {
506
+ verdicts.push({ slug: rule.slug, eligible: false, points: 0,
507
+ reason: "rule is inactive or archived" });
508
+ continue;
509
+ }
510
+ var statusReason = _statusFilter(rule, input);
511
+ if (statusReason) {
512
+ verdicts.push({ slug: rule.slug, eligible: false, points: 0, reason: statusReason });
513
+ continue;
514
+ }
515
+ var calc = _computePoints(rule, input);
516
+ if (calc.points <= 0) {
517
+ verdicts.push({ slug: rule.slug, eligible: false, points: 0, reason: calc.reason });
518
+ continue;
519
+ }
520
+ verdicts.push({
521
+ slug: rule.slug,
522
+ eligible: true,
523
+ points: calc.points,
524
+ reason: calc.reason,
525
+ capped: !!calc.capped,
526
+ });
527
+ totalPoints += calc.points;
528
+ }
529
+
530
+ return {
531
+ trigger: trigger,
532
+ customer_id: input.customer_id,
533
+ total_points: totalPoints,
534
+ verdicts: verdicts,
535
+ };
536
+ }
537
+
538
+ // ---- awardForEvent --------------------------------------------------
539
+
540
+ async function awardForEvent(input) {
541
+ if (!input || typeof input !== "object") {
542
+ throw new TypeError("loyaltyEarnRules.awardForEvent: input object required");
543
+ }
544
+ var trigger = _trigger(input.trigger);
545
+ var customerId = _uuid(input.customer_id, "customer_id");
546
+ var triggerEventRef = _triggerEventRef(input.trigger_event_ref);
547
+ _statusOpt(input.customer_status);
548
+
549
+ var rules;
550
+ if (input.slug != null) {
551
+ var slug = _slug(input.slug, "slug");
552
+ var r = await _ruleRow(slug);
553
+ rules = r ? [r] : [];
554
+ } else {
555
+ var r2 = await query(
556
+ "SELECT * FROM loyalty_earn_rules WHERE trigger = ?1 AND active = 1 AND archived_at IS NULL " +
557
+ "ORDER BY slug ASC",
558
+ [trigger],
559
+ );
560
+ rules = r2.rows;
561
+ }
562
+
563
+ var awarded = [];
564
+ var skipped = [];
565
+ var totalPts = 0;
566
+ for (var i = 0; i < rules.length; i += 1) {
567
+ var rule = _decodeRule(rules[i]);
568
+ if (rule.trigger !== trigger) {
569
+ skipped.push({ slug: rule.slug, reason: "rule.trigger != requested trigger" });
570
+ continue;
571
+ }
572
+ if (!rule.active || rule.archived_at != null) {
573
+ skipped.push({ slug: rule.slug, reason: "rule is inactive or archived" });
574
+ continue;
575
+ }
576
+ var statusReason = _statusFilter(rule, input);
577
+ if (statusReason) {
578
+ skipped.push({ slug: rule.slug, reason: statusReason });
579
+ continue;
580
+ }
581
+ var calc = _computePoints(rule, input);
582
+ if (calc.points <= 0) {
583
+ skipped.push({ slug: rule.slug, reason: calc.reason });
584
+ continue;
585
+ }
586
+
587
+ // Dedup at the storage layer: the UNIQUE (rule_slug,
588
+ // customer_id, trigger_event_ref) collapses a retried award
589
+ // onto the existing row. SQLite's INSERT OR IGNORE is the
590
+ // cheapest portable shape — when 0 rows change we surface the
591
+ // dedup as a skipped reason rather than a hard error so a
592
+ // webhook retry produces a consistent observable result.
593
+ var logId = _b().uuid.v7();
594
+ var ts = _now();
595
+ var ins = await query(
596
+ "INSERT OR IGNORE INTO loyalty_earn_log " +
597
+ "(id, rule_slug, customer_id, points_awarded, trigger_event_ref, occurred_at) " +
598
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
599
+ [logId, rule.slug, customerId, calc.points, triggerEventRef, ts],
600
+ );
601
+ if (Number(ins.rowCount || 0) === 0) {
602
+ skipped.push({
603
+ slug: rule.slug,
604
+ reason: "duplicate trigger_event_ref — already awarded for this event",
605
+ });
606
+ continue;
607
+ }
608
+
609
+ // Compose loyalty.earn when wired. Source is derived from the
610
+ // trigger name — loyalty's source validator demands lowercase
611
+ // alnum + `._-`, which the trigger enum already satisfies.
612
+ if (loyaltyHandle && typeof loyaltyHandle.earn === "function") {
613
+ try {
614
+ await loyaltyHandle.earn({
615
+ customer_id: customerId,
616
+ points: calc.points,
617
+ source: "earn-rule." + rule.slug,
618
+ notes: "trigger=" + trigger + " ref=" + triggerEventRef,
619
+ });
620
+ } catch (err) {
621
+ // Loyalty ledger failed AFTER the audit log wrote. Roll
622
+ // the audit row back so the next retry isn't dedup-skipped
623
+ // against a row that never made it to the ledger. The
624
+ // operator-facing failure carries the underlying loyalty
625
+ // error so debugging hits the root cause not the audit
626
+ // breadcrumb.
627
+ await query(
628
+ "DELETE FROM loyalty_earn_log WHERE id = ?1",
629
+ [logId],
630
+ );
631
+ throw err;
632
+ }
633
+ }
634
+
635
+ awarded.push({
636
+ log_id: logId,
637
+ slug: rule.slug,
638
+ points: calc.points,
639
+ capped: !!calc.capped,
640
+ trigger_event_ref: triggerEventRef,
641
+ occurred_at: ts,
642
+ });
643
+ totalPts += calc.points;
644
+ }
645
+
646
+ return {
647
+ trigger: trigger,
648
+ customer_id: customerId,
649
+ total_points: totalPts,
650
+ awarded: awarded,
651
+ skipped: skipped,
652
+ };
653
+ }
654
+
655
+ // ---- metricsForRule -------------------------------------------------
656
+
657
+ async function metricsForRule(input) {
658
+ if (!input || typeof input !== "object") {
659
+ throw new TypeError("loyaltyEarnRules.metricsForRule: input object required");
660
+ }
661
+ var slug = _slug(input.slug, "slug");
662
+ var from = _epochOpt(input.from, "from");
663
+ var to = _epochOpt(input.to, "to");
664
+ if (from != null && to != null && from > to) {
665
+ throw new TypeError("loyaltyEarnRules.metricsForRule: from must be <= to");
666
+ }
667
+ var ruleRow = await _ruleRow(slug);
668
+ if (!ruleRow) return null;
669
+
670
+ var sql = "SELECT COUNT(*) AS award_count, COUNT(DISTINCT customer_id) AS unique_customers, " +
671
+ "COALESCE(SUM(points_awarded), 0) AS total_points, " +
672
+ "MIN(occurred_at) AS first_award, MAX(occurred_at) AS last_award " +
673
+ "FROM loyalty_earn_log WHERE rule_slug = ?1";
674
+ var params = [slug];
675
+ var idx = 2;
676
+ if (from != null) { sql += " AND occurred_at >= ?" + idx; params.push(from); idx += 1; }
677
+ if (to != null) { sql += " AND occurred_at <= ?" + idx; params.push(to); idx += 1; }
678
+
679
+ var r = await query(sql, params);
680
+ var row = r.rows[0] || { award_count: 0, unique_customers: 0, total_points: 0,
681
+ first_award: null, last_award: null };
682
+ return {
683
+ slug: slug,
684
+ trigger: ruleRow.trigger,
685
+ from: from,
686
+ to: to,
687
+ award_count: Number(row.award_count || 0),
688
+ unique_customers: Number(row.unique_customers || 0),
689
+ total_points: Number(row.total_points || 0),
690
+ first_award: row.first_award == null ? null : Number(row.first_award),
691
+ last_award: row.last_award == null ? null : Number(row.last_award),
692
+ };
693
+ }
694
+
695
+ // ---- applyBatch -----------------------------------------------------
696
+
697
+ async function applyBatch(input) {
698
+ if (!input || typeof input !== "object") {
699
+ throw new TypeError("loyaltyEarnRules.applyBatch: input object required");
700
+ }
701
+ if (!Array.isArray(input.events) || input.events.length === 0) {
702
+ throw new TypeError("loyaltyEarnRules.applyBatch: events must be a non-empty array");
703
+ }
704
+ if (input.events.length > 10000) {
705
+ throw new TypeError("loyaltyEarnRules.applyBatch: events.length must be <= 10000");
706
+ }
707
+ // `rules` is an optional advisory hint — when supplied, the batch
708
+ // restricts to those rule slugs by passing through the per-event
709
+ // `slug` field. The primary path uses the per-event `slug` (when
710
+ // present) or `trigger` (when absent).
711
+ var ruleFilter = null;
712
+ if (input.rules != null) {
713
+ if (!Array.isArray(input.rules)) {
714
+ throw new TypeError("loyaltyEarnRules.applyBatch: rules must be an array of slugs when provided");
715
+ }
716
+ ruleFilter = Object.create(null);
717
+ for (var ri = 0; ri < input.rules.length; ri += 1) {
718
+ ruleFilter[_slug(input.rules[ri], "rules[" + ri + "]")] = true;
719
+ }
720
+ }
721
+
722
+ var awarded = [];
723
+ var skipped = [];
724
+ var failed = [];
725
+ var totalPts = 0;
726
+
727
+ for (var i = 0; i < input.events.length; i += 1) {
728
+ var ev = input.events[i];
729
+ if (!ev || typeof ev !== "object") {
730
+ failed.push({ index: i, reason: "event must be an object" });
731
+ continue;
732
+ }
733
+ if (ruleFilter != null && ev.slug != null && !ruleFilter[ev.slug]) {
734
+ skipped.push({ index: i, slug: ev.slug, reason: "slug not in rules filter" });
735
+ continue;
736
+ }
737
+ try {
738
+ var result = await awardForEvent(ev);
739
+ for (var a = 0; a < result.awarded.length; a += 1) {
740
+ awarded.push({ index: i, award: result.awarded[a] });
741
+ totalPts += result.awarded[a].points;
742
+ }
743
+ for (var s = 0; s < result.skipped.length; s += 1) {
744
+ skipped.push({ index: i, slug: result.skipped[s].slug, reason: result.skipped[s].reason });
745
+ }
746
+ } catch (err) {
747
+ failed.push({ index: i, reason: err && err.message ? err.message : String(err) });
748
+ }
749
+ }
750
+
751
+ return {
752
+ total_events: input.events.length,
753
+ total_points: totalPts,
754
+ awarded: awarded,
755
+ skipped: skipped,
756
+ failed: failed,
757
+ };
758
+ }
759
+
760
+ return {
761
+ TRIGGERS: TRIGGERS.slice(),
762
+ UNIT_TRIGGERS: Object.assign({}, UNIT_TRIGGERS),
763
+ MAX_POINTS_PER_UNIT: MAX_POINTS_PER_UNIT,
764
+ MAX_MAX_PER_EVENT: MAX_MAX_PER_EVENT,
765
+ MAX_STATUS_LIST: MAX_STATUS_LIST,
766
+
767
+ defineRule: defineRule,
768
+ getRule: getRule,
769
+ listRules: listRules,
770
+ updateRule: updateRule,
771
+ archiveRule: archiveRule,
772
+ evaluateForEvent: evaluateForEvent,
773
+ awardForEvent: awardForEvent,
774
+ metricsForRule: metricsForRule,
775
+ applyBatch: applyBatch,
776
+ };
777
+ }
778
+
779
+ module.exports = {
780
+ create: create,
781
+ TRIGGERS: TRIGGERS,
782
+ UNIT_TRIGGERS: UNIT_TRIGGERS,
783
+ MAX_POINTS_PER_UNIT: MAX_POINTS_PER_UNIT,
784
+ MAX_MAX_PER_EVENT: MAX_MAX_PER_EVENT,
785
+ MAX_STATUS_LIST: MAX_STATUS_LIST,
786
+ };