@blamejs/blamejs-shop 0.0.72 → 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 (44) hide show
  1. package/CHANGELOG.md +6 -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 +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. package/package.json +1 -1
@@ -0,0 +1,612 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.referralLeaderboard
4
+ * @title Referral leaderboard — top-referrer reports + tiered rewards
5
+ *
6
+ * @intro
7
+ * Sits on top of the existing referrals primitive (migration 0025).
8
+ * Reads from `referral_codes` + `referral_invitations` to compute:
9
+ *
10
+ * - `topReferrers({ from, to, limit })` — completed funnels per
11
+ * referrer in the [from, to] window, ordered by count DESC,
12
+ * referrer_customer_id ASC for stable ties. Lifetime referral
13
+ * revenue is filled in by an optional `referrals.revenueForCustomer`
14
+ * hook on the injected handle; absent the hook, the field is 0.
15
+ *
16
+ * - `referralTier({ customer_id, as_of? })` — counts completed
17
+ * funnels in the trailing 90 days against the operator-tunable
18
+ * thresholds in `referral_tier_config`. Returns one of
19
+ * bronze / silver / gold / platinum.
20
+ *
21
+ * - `awardLeaderboardBonus({ customer_id, tier, period_label })`
22
+ * — UNIQUE on (customer_id, period_label, tier) is the re-award
23
+ * gate. On a fresh row the composition calls `loyalty.earn`
24
+ * (when wired) for the tier's configured point bonus; on a
25
+ * duplicate, returns `{ status: "already-awarded" }` and
26
+ * NEVER re-invokes loyalty.
27
+ *
28
+ * - `monthlyChampions({ year, month, limit })` — convenience
29
+ * wrapper around topReferrers with the month's UTC window.
30
+ *
31
+ * - `cleanupExpiredBonuses(days)` — sweeps audit-log rows older
32
+ * than `days` days from `referral_leaderboard_bonuses`. The
33
+ * loyalty side is untouched (points already landed in the
34
+ * customer's account); this is a log-retention sweep, not a
35
+ * reversal.
36
+ *
37
+ * Tier thresholds (operator-tunable via `setTierThresholds`):
38
+ *
39
+ * - bronze — 0 completed referrals in trailing 90d
40
+ * - silver — 5
41
+ * - gold — 15
42
+ * - platinum — 40
43
+ *
44
+ * Point bonuses per tier (operator-tunable via factory opts):
45
+ *
46
+ * - bronze — 100 points
47
+ * - silver — 500
48
+ * - gold — 2000
49
+ * - platinum — 10000
50
+ *
51
+ * Monotonic per-process clock: two awards in the same millisecond
52
+ * would tie on `occurred_at` and make a sort-by-timestamp read
53
+ * ambiguous. `_now()` bumps to `prior + 1` on collision so the
54
+ * `historyForCustomer` ORDER BY occurred_at DESC carries a strict
55
+ * per-process ordering.
56
+ *
57
+ * Composes:
58
+ * - `b.uuid.v7` — bonus row ids.
59
+ * - `b.guardUuid` — strict UUID gate on every
60
+ * customer_id at the entry point.
61
+ * - `referrals` (optional) — when wired, `topReferrers`
62
+ * surfaces `lifetime_revenue` via
63
+ * `referrals.revenueForCustomer`.
64
+ * Absent, the field is 0.
65
+ * - `loyalty` (optional) — when wired,
66
+ * `awardLeaderboardBonus` composes
67
+ * `loyalty.earn(...)` for the
68
+ * tier's configured bonus.
69
+ * Absent, the bonus row lands
70
+ * and the operator pays out via
71
+ * their own channel.
72
+ *
73
+ * Storage:
74
+ * - referral_tier_config, referral_leaderboard_bonuses
75
+ * (migration `0182_referral_leaderboard.sql`).
76
+ *
77
+ * @primitive referralLeaderboard
78
+ * @related shop.referrals, shop.loyalty, b.uuid.v7, b.guardUuid
79
+ */
80
+
81
+ var bShop;
82
+ function _b() {
83
+ if (!bShop) bShop = require("./index");
84
+ return bShop.framework;
85
+ }
86
+
87
+ var TIERS = ["bronze", "silver", "gold", "platinum"];
88
+
89
+ var DEFAULT_THRESHOLDS = {
90
+ bronze: 0,
91
+ silver: 5,
92
+ gold: 15,
93
+ platinum: 40,
94
+ };
95
+
96
+ var DEFAULT_TIER_BONUS_POINTS = {
97
+ bronze: 100,
98
+ silver: 500,
99
+ gold: 2000,
100
+ platinum: 10000,
101
+ };
102
+
103
+ var MS_PER_DAY = 24 * 60 * 60 * 1000;
104
+ var ROLLING_WINDOW_DAYS = 90;
105
+
106
+ var MAX_LEADERBOARD_LIMIT = 100;
107
+ var DEFAULT_LEADERBOARD_LIMIT = 10;
108
+
109
+ var MAX_PERIOD_LABEL_LEN = 64;
110
+ // `2026-05` / `2026-Q2` / `2026-week-21` / `monthly-2026-05` etc.
111
+ var PERIOD_LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}[A-Za-z0-9]$/;
112
+
113
+ var BONUS_LOG_SOURCE = "referral-leaderboard-bonus";
114
+
115
+ // ---- monotonic clock ---------------------------------------------------
116
+ //
117
+ // Operator-driven awards can land in the same millisecond on fast
118
+ // machines. Bumping by 1ms on a tie keeps the timeline strictly
119
+ // increasing so `historyForCustomer` ORDER BY occurred_at returns
120
+ // rows in issue order.
121
+
122
+ var _lastTs = 0;
123
+ function _now() {
124
+ var t = Date.now();
125
+ if (t <= _lastTs) t = _lastTs + 1;
126
+ _lastTs = t;
127
+ return t;
128
+ }
129
+
130
+ // ---- validators --------------------------------------------------------
131
+
132
+ function _uuid(s, label) {
133
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
134
+ catch (e) {
135
+ throw new TypeError("referralLeaderboard: " + label + " — " + (e && e.message || "invalid UUID"));
136
+ }
137
+ }
138
+
139
+ function _positiveInt(n, label, maxOpt) {
140
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
141
+ throw new TypeError("referralLeaderboard: " + label + " must be a positive integer");
142
+ }
143
+ if (typeof maxOpt === "number" && n > maxOpt) {
144
+ throw new TypeError("referralLeaderboard: " + label + " must be <= " + maxOpt);
145
+ }
146
+ return n;
147
+ }
148
+
149
+ function _nonNegInt(n, label) {
150
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
151
+ throw new TypeError("referralLeaderboard: " + label + " must be a non-negative integer");
152
+ }
153
+ return n;
154
+ }
155
+
156
+ function _timestamp(n, label) {
157
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
158
+ throw new TypeError("referralLeaderboard: " + label + " must be a non-negative integer timestamp");
159
+ }
160
+ return n;
161
+ }
162
+
163
+ function _periodLabel(s) {
164
+ if (typeof s !== "string" || !s.length) {
165
+ throw new TypeError("referralLeaderboard: period_label must be a non-empty string");
166
+ }
167
+ if (s.length > MAX_PERIOD_LABEL_LEN) {
168
+ throw new TypeError("referralLeaderboard: period_label must be <= " + MAX_PERIOD_LABEL_LEN + " characters");
169
+ }
170
+ if (!PERIOD_LABEL_RE.test(s)) {
171
+ throw new TypeError("referralLeaderboard: period_label must match /^[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9]$/");
172
+ }
173
+ return s;
174
+ }
175
+
176
+ function _tier(s) {
177
+ if (typeof s !== "string" || TIERS.indexOf(s) === -1) {
178
+ throw new TypeError("referralLeaderboard: tier must be one of " + TIERS.join(", "));
179
+ }
180
+ return s;
181
+ }
182
+
183
+ function _validateThresholds(t) {
184
+ if (!t || typeof t !== "object") {
185
+ throw new TypeError("referralLeaderboard: thresholds object required");
186
+ }
187
+ for (var i = 0; i < TIERS.length; i += 1) {
188
+ var k = TIERS[i];
189
+ if (!Object.prototype.hasOwnProperty.call(t, k)) {
190
+ throw new TypeError("referralLeaderboard: thresholds." + k + " missing");
191
+ }
192
+ _nonNegInt(t[k], "thresholds." + k);
193
+ }
194
+ // Monotonicity — silver >= bronze, gold >= silver, platinum >= gold.
195
+ // Equal is permitted (the operator may collapse tiers); strictly-
196
+ // decreasing is refused since the tier-resolver assumes the array
197
+ // is sorted by threshold ASC.
198
+ if (t.silver < t.bronze || t.gold < t.silver || t.platinum < t.gold) {
199
+ throw new TypeError("referralLeaderboard: thresholds must be non-decreasing (bronze <= silver <= gold <= platinum)");
200
+ }
201
+ return { bronze: t.bronze, silver: t.silver, gold: t.gold, platinum: t.platinum };
202
+ }
203
+
204
+ function _computeTierFromCount(count, thresholds) {
205
+ // Walk tiers from highest to lowest; first whose threshold the
206
+ // count meets or exceeds wins. Bronze is the floor (threshold 0
207
+ // by default), so the loop always terminates with a placement.
208
+ if (count >= thresholds.platinum) return "platinum";
209
+ if (count >= thresholds.gold) return "gold";
210
+ if (count >= thresholds.silver) return "silver";
211
+ return "bronze";
212
+ }
213
+
214
+ // ---- factory -----------------------------------------------------------
215
+
216
+ function create(opts) {
217
+ opts = opts || {};
218
+ var query = opts.query;
219
+ if (!query) {
220
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
221
+ }
222
+
223
+ // referrals is optional — when wired, topReferrers surfaces
224
+ // `lifetime_revenue` via `referrals.revenueForCustomer(customer_id)`.
225
+ // Absent the hook (or the handle entirely), the field falls back to 0
226
+ // so the leaderboard surface stays usable on operator setups that
227
+ // haven't wired revenue tracking yet.
228
+ var referralsHandle = opts.referrals || null;
229
+ if (referralsHandle
230
+ && typeof referralsHandle !== "object") {
231
+ throw new TypeError("referralLeaderboard.create: opts.referrals must be an object");
232
+ }
233
+ if (referralsHandle
234
+ && typeof referralsHandle.revenueForCustomer !== "function"
235
+ && referralsHandle.revenueForCustomer !== undefined) {
236
+ throw new TypeError("referralLeaderboard.create: opts.referrals.revenueForCustomer must be a function when provided");
237
+ }
238
+
239
+ // loyalty is optional — when wired, awardLeaderboardBonus composes
240
+ // `loyalty.earn(...)` for the configured tier bonus. Absent, the
241
+ // bonus log row still lands; the operator pays out via their own
242
+ // channel (gift card / discount / store credit).
243
+ var loyaltyHandle = opts.loyalty || null;
244
+ if (loyaltyHandle && typeof loyaltyHandle.earn !== "function") {
245
+ throw new TypeError("referralLeaderboard.create: opts.loyalty must expose an earn(input) method");
246
+ }
247
+
248
+ // Tier bonus point values are operator-tunable per factory; the
249
+ // defaults match the loyalty primitive's silver/gold/platinum
250
+ // promotion thresholds so a tier-bonus payout matches the
251
+ // loyalty-tier promotion threshold by default.
252
+ var tierBonusPoints = Object.assign({}, DEFAULT_TIER_BONUS_POINTS);
253
+ if (opts.tierBonusPoints && typeof opts.tierBonusPoints === "object") {
254
+ for (var bi = 0; bi < TIERS.length; bi += 1) {
255
+ var bk = TIERS[bi];
256
+ if (Object.prototype.hasOwnProperty.call(opts.tierBonusPoints, bk)) {
257
+ _nonNegInt(opts.tierBonusPoints[bk], "tierBonusPoints." + bk);
258
+ tierBonusPoints[bk] = opts.tierBonusPoints[bk];
259
+ }
260
+ }
261
+ }
262
+
263
+ async function _readThresholds() {
264
+ var r = await query(
265
+ "SELECT bronze_threshold, silver_threshold, gold_threshold, platinum_threshold " +
266
+ "FROM referral_tier_config WHERE id = 1",
267
+ [],
268
+ );
269
+ if (!r.rows.length) {
270
+ return Object.assign({}, DEFAULT_THRESHOLDS);
271
+ }
272
+ var row = r.rows[0];
273
+ return {
274
+ bronze: Number(row.bronze_threshold),
275
+ silver: Number(row.silver_threshold),
276
+ gold: Number(row.gold_threshold),
277
+ platinum: Number(row.platinum_threshold),
278
+ };
279
+ }
280
+
281
+ // Window-bounded completed-referral counts joined across referral
282
+ // codes + invitations. `from` and `to` are inclusive on
283
+ // `first_purchase_at` (the funnel-complete timestamp from the
284
+ // referrals primitive). Returns one row per referrer with non-zero
285
+ // completions in the window.
286
+ async function _completedCountsInWindow(from, to, limit) {
287
+ var r = await query(
288
+ "SELECT c.referrer_customer_id AS referrer_customer_id, " +
289
+ "COUNT(i.id) AS completed_referrals " +
290
+ "FROM referral_invitations i " +
291
+ "JOIN referral_codes c ON c.id = i.referral_code_id " +
292
+ "WHERE i.first_purchase_at IS NOT NULL " +
293
+ "AND i.first_purchase_at >= ?1 AND i.first_purchase_at <= ?2 " +
294
+ "GROUP BY c.referrer_customer_id " +
295
+ "HAVING COUNT(i.id) > 0 " +
296
+ "ORDER BY completed_referrals DESC, c.referrer_customer_id ASC " +
297
+ "LIMIT ?3",
298
+ [from, to, limit],
299
+ );
300
+ return r.rows.map(function (row) {
301
+ return {
302
+ referrer_customer_id: row.referrer_customer_id,
303
+ completed_referrals: Number(row.completed_referrals || 0),
304
+ };
305
+ });
306
+ }
307
+
308
+ async function _completedCountForCustomerSince(customerId, sinceTs, untilTs) {
309
+ var r = await query(
310
+ "SELECT COUNT(i.id) AS n FROM referral_invitations i " +
311
+ "JOIN referral_codes c ON c.id = i.referral_code_id " +
312
+ "WHERE c.referrer_customer_id = ?1 " +
313
+ "AND i.first_purchase_at IS NOT NULL " +
314
+ "AND i.first_purchase_at >= ?2 AND i.first_purchase_at <= ?3",
315
+ [customerId, sinceTs, untilTs],
316
+ );
317
+ return Number((r.rows[0] && r.rows[0].n) || 0);
318
+ }
319
+
320
+ async function _lifetimeRevenueFor(customerId) {
321
+ if (!referralsHandle || typeof referralsHandle.revenueForCustomer !== "function") {
322
+ return 0;
323
+ }
324
+ var v = await referralsHandle.revenueForCustomer(customerId);
325
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0) return 0;
326
+ return v;
327
+ }
328
+
329
+ // UTC month window. `month` is 1-based to match operator intuition
330
+ // (2026-05 -> month = 5); the Date constructor's 0-based month is
331
+ // an implementation detail of this primitive.
332
+ function _monthWindow(year, month) {
333
+ if (!Number.isInteger(year) || year < 1970 || year > 9999) {
334
+ throw new TypeError("referralLeaderboard: year must be an integer in [1970, 9999]");
335
+ }
336
+ if (!Number.isInteger(month) || month < 1 || month > 12) {
337
+ throw new TypeError("referralLeaderboard: month must be an integer in [1, 12]");
338
+ }
339
+ var from = Date.UTC(year, month - 1, 1, 0, 0, 0, 0);
340
+ // First instant of the next month, minus 1ms = last instant of
341
+ // the requested month.
342
+ var to = Date.UTC(year, month, 1, 0, 0, 0, 0) - 1;
343
+ return { from: from, to: to };
344
+ }
345
+
346
+ return {
347
+ TIERS: TIERS.slice(),
348
+ DEFAULT_THRESHOLDS: Object.assign({}, DEFAULT_THRESHOLDS),
349
+ DEFAULT_TIER_BONUS_POINTS: Object.assign({}, DEFAULT_TIER_BONUS_POINTS),
350
+ ROLLING_WINDOW_DAYS: ROLLING_WINDOW_DAYS,
351
+ BONUS_LOG_SOURCE: BONUS_LOG_SOURCE,
352
+
353
+ topReferrers: async function (input) {
354
+ if (!input || typeof input !== "object") {
355
+ throw new TypeError("referralLeaderboard.topReferrers: input object required");
356
+ }
357
+ _timestamp(input.from, "from");
358
+ _timestamp(input.to, "to");
359
+ if (input.from > input.to) {
360
+ throw new TypeError("referralLeaderboard.topReferrers: from must be <= to");
361
+ }
362
+ var limit = input.limit == null ? DEFAULT_LEADERBOARD_LIMIT : input.limit;
363
+ _positiveInt(limit, "limit", MAX_LEADERBOARD_LIMIT);
364
+
365
+ var rows = await _completedCountsInWindow(input.from, input.to, limit);
366
+ var out = [];
367
+ for (var i = 0; i < rows.length; i += 1) {
368
+ var revenue = await _lifetimeRevenueFor(rows[i].referrer_customer_id);
369
+ out.push({
370
+ referrer_customer_id: rows[i].referrer_customer_id,
371
+ completed_referrals: rows[i].completed_referrals,
372
+ lifetime_revenue: revenue,
373
+ });
374
+ }
375
+ return out;
376
+ },
377
+
378
+ referralTier: async function (input) {
379
+ if (!input || typeof input !== "object") {
380
+ throw new TypeError("referralLeaderboard.referralTier: input object required");
381
+ }
382
+ var customerId = _uuid(input.customer_id, "customer_id");
383
+ var asOf = input.as_of == null ? _now() : _timestamp(input.as_of, "as_of");
384
+ // The rolling-90-day window — `since` is asOf minus the window
385
+ // length. A referral whose `first_purchase_at` lands exactly on
386
+ // `since` still counts; the gate is inclusive on both ends.
387
+ var since = asOf - (ROLLING_WINDOW_DAYS * MS_PER_DAY);
388
+ if (since < 0) since = 0;
389
+ var thresholds = await _readThresholds();
390
+ var count = await _completedCountForCustomerSince(customerId, since, asOf);
391
+ var tier = _computeTierFromCount(count, thresholds);
392
+ return {
393
+ customer_id: customerId,
394
+ tier: tier,
395
+ rolling_completed_referrals: count,
396
+ rolling_window_days: ROLLING_WINDOW_DAYS,
397
+ as_of: asOf,
398
+ thresholds: thresholds,
399
+ };
400
+ },
401
+
402
+ tierThresholds: async function () {
403
+ return await _readThresholds();
404
+ },
405
+
406
+ setTierThresholds: async function (input) {
407
+ var thresholds = _validateThresholds(input);
408
+ var ts = _now();
409
+ // Singleton upsert: id = 1 always. node:sqlite + d1 both honor
410
+ // INSERT OR REPLACE here (PK = 1 collapses to a single row).
411
+ await query(
412
+ "INSERT INTO referral_tier_config " +
413
+ "(id, bronze_threshold, silver_threshold, gold_threshold, platinum_threshold, updated_at) " +
414
+ "VALUES (1, ?1, ?2, ?3, ?4, ?5) " +
415
+ "ON CONFLICT(id) DO UPDATE SET " +
416
+ "bronze_threshold = excluded.bronze_threshold, " +
417
+ "silver_threshold = excluded.silver_threshold, " +
418
+ "gold_threshold = excluded.gold_threshold, " +
419
+ "platinum_threshold = excluded.platinum_threshold, " +
420
+ "updated_at = excluded.updated_at",
421
+ [thresholds.bronze, thresholds.silver, thresholds.gold, thresholds.platinum, ts],
422
+ );
423
+ return Object.assign({}, thresholds, { updated_at: ts });
424
+ },
425
+
426
+ // Composes loyalty.earn (when wired) for the tier's configured
427
+ // bonus, then logs the payout to `referral_leaderboard_bonuses`.
428
+ // UNIQUE on (customer_id, period_label, tier) is the re-award
429
+ // gate — a second call with the same triple returns
430
+ // `{ status: "already-awarded" }` and NEVER re-invokes loyalty.
431
+ //
432
+ // FSM order: the bonus log row is inserted FIRST (so two
433
+ // concurrent callers race on the UNIQUE constraint, not on
434
+ // loyalty.earn). Only the winning insert proceeds to the
435
+ // loyalty.earn call. On loyalty.earn failure, the bonus row is
436
+ // rolled back via DELETE so a retry can re-attempt the full
437
+ // composition — without rollback, a wedged loyalty primitive
438
+ // would permanently lock out the customer from this tier's
439
+ // award.
440
+ awardLeaderboardBonus: async function (input) {
441
+ if (!input || typeof input !== "object") {
442
+ throw new TypeError("referralLeaderboard.awardLeaderboardBonus: input object required");
443
+ }
444
+ var customerId = _uuid(input.customer_id, "customer_id");
445
+ var tier = _tier(input.tier);
446
+ var periodLabel = _periodLabel(input.period_label);
447
+ var pointsBudget = tierBonusPoints[tier];
448
+
449
+ // Existing-row probe (idempotency short-circuit). The UNIQUE
450
+ // constraint is still the source of truth — this is the read-
451
+ // side win for the common "second call is a duplicate" case so
452
+ // we don't allocate a row id + risk a no-op INSERT.
453
+ var existing = await query(
454
+ "SELECT id, customer_id, tier, period_label, points_awarded, occurred_at " +
455
+ "FROM referral_leaderboard_bonuses " +
456
+ "WHERE customer_id = ?1 AND period_label = ?2 AND tier = ?3 LIMIT 1",
457
+ [customerId, periodLabel, tier],
458
+ );
459
+ if (existing.rows.length) {
460
+ var row = existing.rows[0];
461
+ return {
462
+ id: row.id,
463
+ customer_id: row.customer_id,
464
+ tier: row.tier,
465
+ period_label: row.period_label,
466
+ points_awarded: Number(row.points_awarded || 0),
467
+ occurred_at: Number(row.occurred_at || 0),
468
+ status: "already-awarded",
469
+ };
470
+ }
471
+
472
+ var id = _b().uuid.v7();
473
+ var ts = _now();
474
+ try {
475
+ await query(
476
+ "INSERT INTO referral_leaderboard_bonuses " +
477
+ "(id, customer_id, tier, period_label, points_awarded, occurred_at) " +
478
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
479
+ [id, customerId, tier, periodLabel, pointsBudget, ts],
480
+ );
481
+ } catch (e) {
482
+ if (e && e.message && e.message.indexOf("UNIQUE") !== -1) {
483
+ // Lost the race to a concurrent caller — return their row.
484
+ var raced = await query(
485
+ "SELECT id, customer_id, tier, period_label, points_awarded, occurred_at " +
486
+ "FROM referral_leaderboard_bonuses " +
487
+ "WHERE customer_id = ?1 AND period_label = ?2 AND tier = ?3 LIMIT 1",
488
+ [customerId, periodLabel, tier],
489
+ );
490
+ if (raced.rows.length) {
491
+ var rr = raced.rows[0];
492
+ return {
493
+ id: rr.id,
494
+ customer_id: rr.customer_id,
495
+ tier: rr.tier,
496
+ period_label: rr.period_label,
497
+ points_awarded: Number(rr.points_awarded || 0),
498
+ occurred_at: Number(rr.occurred_at || 0),
499
+ status: "already-awarded",
500
+ };
501
+ }
502
+ }
503
+ throw e;
504
+ }
505
+
506
+ // Compose loyalty.earn for the points budget. On failure, roll
507
+ // back the bonus row so a retry can re-attempt cleanly.
508
+ if (loyaltyHandle && pointsBudget > 0) {
509
+ try {
510
+ await loyaltyHandle.earn({
511
+ customer_id: customerId,
512
+ points: pointsBudget,
513
+ source: BONUS_LOG_SOURCE,
514
+ notes: "tier=" + tier + " period=" + periodLabel,
515
+ });
516
+ } catch (e) {
517
+ await query(
518
+ "DELETE FROM referral_leaderboard_bonuses WHERE id = ?1",
519
+ [id],
520
+ );
521
+ throw e;
522
+ }
523
+ }
524
+
525
+ return {
526
+ id: id,
527
+ customer_id: customerId,
528
+ tier: tier,
529
+ period_label: periodLabel,
530
+ points_awarded: pointsBudget,
531
+ occurred_at: ts,
532
+ status: "awarded",
533
+ };
534
+ },
535
+
536
+ historyForCustomer: async function (input) {
537
+ if (!input || typeof input !== "object") {
538
+ throw new TypeError("referralLeaderboard.historyForCustomer: input object required");
539
+ }
540
+ var customerId = _uuid(input.customer_id, "customer_id");
541
+ var r = await query(
542
+ "SELECT id, customer_id, tier, period_label, points_awarded, occurred_at " +
543
+ "FROM referral_leaderboard_bonuses " +
544
+ "WHERE customer_id = ?1 " +
545
+ "ORDER BY occurred_at DESC, id DESC",
546
+ [customerId],
547
+ );
548
+ return r.rows.map(function (row) {
549
+ return {
550
+ id: row.id,
551
+ customer_id: row.customer_id,
552
+ tier: row.tier,
553
+ period_label: row.period_label,
554
+ points_awarded: Number(row.points_awarded || 0),
555
+ occurred_at: Number(row.occurred_at || 0),
556
+ };
557
+ });
558
+ },
559
+
560
+ monthlyChampions: async function (input) {
561
+ if (!input || typeof input !== "object") {
562
+ throw new TypeError("referralLeaderboard.monthlyChampions: input object required");
563
+ }
564
+ var win = _monthWindow(input.year, input.month);
565
+ var limit = input.limit == null ? DEFAULT_LEADERBOARD_LIMIT : input.limit;
566
+ _positiveInt(limit, "limit", MAX_LEADERBOARD_LIMIT);
567
+ var rows = await _completedCountsInWindow(win.from, win.to, limit);
568
+ var out = [];
569
+ for (var i = 0; i < rows.length; i += 1) {
570
+ var revenue = await _lifetimeRevenueFor(rows[i].referrer_customer_id);
571
+ out.push({
572
+ referrer_customer_id: rows[i].referrer_customer_id,
573
+ completed_referrals: rows[i].completed_referrals,
574
+ lifetime_revenue: revenue,
575
+ });
576
+ }
577
+ return {
578
+ year: input.year,
579
+ month: input.month,
580
+ from: win.from,
581
+ to: win.to,
582
+ champions: out,
583
+ };
584
+ },
585
+
586
+ // Retention sweep on the audit-log table — the loyalty side is
587
+ // untouched (points already landed in the customer's account).
588
+ // This deletes bonus log rows older than `days` days; the
589
+ // operator drives the sweep cadence.
590
+ cleanupExpiredBonuses: async function (days) {
591
+ _positiveInt(days, "days");
592
+ var cutoff = _now() - (days * MS_PER_DAY);
593
+ var r = await query(
594
+ "DELETE FROM referral_leaderboard_bonuses WHERE occurred_at < ?1",
595
+ [cutoff],
596
+ );
597
+ return {
598
+ deleted_count: Number(r.rowCount || 0),
599
+ cutoff_at: cutoff,
600
+ };
601
+ },
602
+ };
603
+ }
604
+
605
+ module.exports = {
606
+ create: create,
607
+ TIERS: TIERS,
608
+ DEFAULT_THRESHOLDS: DEFAULT_THRESHOLDS,
609
+ DEFAULT_TIER_BONUS_POINTS: DEFAULT_TIER_BONUS_POINTS,
610
+ ROLLING_WINDOW_DAYS: ROLLING_WINDOW_DAYS,
611
+ BONUS_LOG_SOURCE: BONUS_LOG_SOURCE,
612
+ };