@blamejs/blamejs-shop 0.0.60 → 0.0.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,548 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.discountAnalytics
4
+ * @title Discount analytics — per-coupon + per-tier-set redemption,
5
+ * impression, conversion, and revenue-impact aggregates.
6
+ *
7
+ * @intro
8
+ * Operator-dashboard read primitive backed by two append-only
9
+ * tables (`discount_impressions`, `discount_redemptions`).
10
+ * Storefront components record an impression each time a coupon
11
+ * bar / promo banner / cart-rail renders a code; the checkout
12
+ * pipeline records a redemption each time a code or quantity-
13
+ * discount tier set is accepted at checkout. This primitive then
14
+ * aggregates those rows into the surfaces an operator dashboard
15
+ * wants.
16
+ *
17
+ * Distinct from `coupons` (which records the canonical
18
+ * redemption-on-order event tied to the order FSM) and from
19
+ * `quantityDiscounts` (which decides per-line price breaks
20
+ * automatically). The optional `coupons` / `quantityDiscounts`
21
+ * handles let the operator wire cross-checks ("does the redemption
22
+ * count match the coupons table?") without making either handle
23
+ * load-bearing — every aggregate runs against the locally-owned
24
+ * tables.
25
+ *
26
+ * v1 surface:
27
+ *
28
+ * - recordImpression({ coupon_code, session_id })
29
+ * Log a coupon-bar view. `session_id` is hashed via
30
+ * `b.crypto.namespaceHash("discount-analytics-session", ...)`
31
+ * at the boundary so the database never sees the raw
32
+ * identifier. Returns the row id. Append-only — re-rendering
33
+ * the bar 100 times produces 100 rows (the
34
+ * `COUNT(DISTINCT session_id_hash)` view collapses them).
35
+ *
36
+ * - recordRedemption({ coupon_code, order_id, amount_minor,
37
+ * currency? })
38
+ * Log a redemption. `amount_minor` is the discount amount in
39
+ * minor units (≥ 0); `currency` is the 3-letter ISO code and
40
+ * defaults to "USD" when omitted. Append-only. Quantity-
41
+ * discount tier-set redemptions use the convention
42
+ * `coupon_code = "tier:<tier_set_id>"` so the same table
43
+ * backs `tierPerformance`.
44
+ *
45
+ * - topCoupons({ from, to, limit })
46
+ * Top-N coupons by redemption count across the window.
47
+ * Returns `[{ coupon_code, redemptions, gross_revenue_minor,
48
+ * currency }]` ordered by redemptions DESC, code ASC.
49
+ * `tier:` codes participate by default — operators who only
50
+ * want operator-typed codes filter the returned list.
51
+ *
52
+ * - couponPerformance({ coupon_code, from, to })
53
+ * Per-coupon roll-up: `{ redemptions, gross_revenue_minor,
54
+ * average_discount_minor, conversion_rate, impressions,
55
+ * unique_sessions }`. `conversion_rate` is `redemptions /
56
+ * unique_sessions` as a float in [0, 1]; 0 when no
57
+ * impressions exist (rather than NaN). Multi-currency
58
+ * redemptions sum into `gross_revenue_minor` only when every
59
+ * redemption shares a currency; mixed-currency windows return
60
+ * a `currency: "MIXED"` shape so the operator UI knows to
61
+ * break the report out further.
62
+ *
63
+ * - tierPerformance({ tier_set_id, from, to })
64
+ * Same shape as `couponPerformance` but keyed off the
65
+ * `tier:<tier_set_id>` synthetic code. When a
66
+ * `quantityDiscounts` handle is wired in, this method
67
+ * additionally returns the hydrated tier-set definition under
68
+ * `tier_set` (best-effort — a missing / archived set surfaces
69
+ * as `null`).
70
+ *
71
+ * - revenueImpact({ from, to, currency? })
72
+ * Window-wide total discount given vs gross. Returns per-
73
+ * currency `[{ currency, total_discount_minor,
74
+ * redemption_count }]` — the dashboard composes the "gross
75
+ * revenue" side from the existing `salesReports` primitive
76
+ * and divides this row's `total_discount_minor` by that gross
77
+ * to get the impact percentage. Pre-computing the ratio here
78
+ * would force a join across two primitives' storage, which
79
+ * we deliberately avoid.
80
+ *
81
+ * - redemptionFunnel({ coupon_code, from, to })
82
+ * Three-step funnel: `{ created, viewed, redeemed }`.
83
+ * created — total impression rows (every bar render)
84
+ * viewed — COUNT(DISTINCT session_id_hash) (unique
85
+ * sessions that saw the bar)
86
+ * redeemed — total redemption rows
87
+ * The "shrinkage" between `created` -> `viewed` measures bar-
88
+ * dwell vs single-render-many-pageloads; between `viewed` ->
89
+ * `redeemed` measures the actual code-redemption conversion.
90
+ *
91
+ * Composition:
92
+ *
93
+ * - `b.crypto.namespaceHash("discount-analytics-session", id)`
94
+ * hashes every session id at the boundary. No raw session id
95
+ * reaches storage.
96
+ * - `b.uuid.v7` mints every row id.
97
+ * - Optional `coupons` handle — currently consulted only by
98
+ * cross-check hooks (none in v1 surface); reserved so an
99
+ * operator may pass it without behaviour change as soon as the
100
+ * coupons primitive lands.
101
+ * - Optional `quantityDiscounts` handle — when wired, exposes
102
+ * `tierBreakdown(tier_set_id)` or any of its read surfaces
103
+ * (`list`, `getTiersForLine`); the primitive only invokes the
104
+ * handle's read methods, never its mutating methods. Used by
105
+ * `tierPerformance` to hydrate the tier-set definition
106
+ * alongside the aggregate.
107
+ *
108
+ * Storage:
109
+ *
110
+ * - `discount_impressions` (migration `0073_discount_analytics.sql`)
111
+ * - `discount_redemptions` (migration `0073_discount_analytics.sql`)
112
+ *
113
+ * @primitive discountAnalytics
114
+ * @related coupons, quantityDiscounts, salesReports, promoBanners
115
+ */
116
+
117
+ // ---- constants ----------------------------------------------------------
118
+
119
+ var SESSION_NAMESPACE = "discount-analytics-session";
120
+
121
+ var MAX_CODE_LEN = 96;
122
+ var MAX_ORDER_ID_LEN = 128;
123
+ var MAX_SESSION_LEN = 256;
124
+ var MAX_TIER_ID_LEN = 128;
125
+ var MAX_LIMIT = 200;
126
+
127
+ var ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
128
+ var DEFAULT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
129
+
130
+ // Coupon-code shape — alnum + dot/dash/underscore plus a `:` to
131
+ // admit the `tier:<id>` convention. Length-capped so a giant string
132
+ // can't bloat the index.
133
+ var CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,95}$/;
134
+
135
+ // Tier-set id shape — matches uuid.v7 + any alnum/dash/underscore
136
+ // the operator might pass. Same length cap as MAX_TIER_ID_LEN.
137
+ var TIER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
138
+
139
+ // Order id shape — loose at the boundary; the order primitive owns
140
+ // stricter rules for its own ids.
141
+ var ORDER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
142
+
143
+ var bShop;
144
+ function _b() {
145
+ if (!bShop) bShop = require("./index");
146
+ return bShop.framework;
147
+ }
148
+
149
+ // ---- validators ---------------------------------------------------------
150
+
151
+ function _code(value, label) {
152
+ if (typeof value !== "string" || !CODE_RE.test(value)) {
153
+ throw new TypeError(
154
+ "discountAnalytics: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ " +
155
+ "(≤ " + MAX_CODE_LEN + " chars)"
156
+ );
157
+ }
158
+ return value;
159
+ }
160
+
161
+ function _tierSetId(value) {
162
+ if (typeof value !== "string" || !TIER_ID_RE.test(value)) {
163
+ throw new TypeError(
164
+ "discountAnalytics: tier_set_id must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ " +
165
+ "(≤ " + MAX_TIER_ID_LEN + " chars)"
166
+ );
167
+ }
168
+ return value;
169
+ }
170
+
171
+ function _orderId(value) {
172
+ if (typeof value !== "string" || !ORDER_ID_RE.test(value)) {
173
+ throw new TypeError(
174
+ "discountAnalytics: order_id must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ " +
175
+ "(≤ " + MAX_ORDER_ID_LEN + " chars)"
176
+ );
177
+ }
178
+ return value;
179
+ }
180
+
181
+ var _lastTs = 0;
182
+ function _nowMs() {
183
+ var t = Date.now();
184
+ if (t <= _lastTs) { t = _lastTs + 1; }
185
+ _lastTs = t;
186
+ return t;
187
+ }
188
+
189
+ function _sessionId(value) {
190
+ if (typeof value !== "string" || !value.length || value.length > MAX_SESSION_LEN) {
191
+ throw new TypeError(
192
+ "discountAnalytics: session_id must be a non-empty string ≤ " + MAX_SESSION_LEN + " chars"
193
+ );
194
+ }
195
+ if (/[\x00-\x1f\x7f]/.test(value)) {
196
+ throw new TypeError("discountAnalytics: session_id must not contain control bytes");
197
+ }
198
+ return value;
199
+ }
200
+
201
+ function _amount(value) {
202
+ if (!Number.isInteger(value) || value < 0) {
203
+ throw new TypeError("discountAnalytics: amount_minor must be a non-negative integer");
204
+ }
205
+ return value;
206
+ }
207
+
208
+ function _currency(value, allowDefault) {
209
+ if (value == null) {
210
+ if (allowDefault) return "USD";
211
+ throw new TypeError("discountAnalytics: currency must be a 3-letter ISO code");
212
+ }
213
+ if (typeof value !== "string" || value.length !== 3 || !/^[A-Z]{3}$/.test(value)) {
214
+ throw new TypeError("discountAnalytics: currency must be a 3-letter uppercase ISO code");
215
+ }
216
+ return value;
217
+ }
218
+
219
+ function _epochMs(value, label) {
220
+ if (!Number.isInteger(value) || value < 0) {
221
+ throw new TypeError("discountAnalytics: " + label + " must be a non-negative integer (epoch ms)");
222
+ }
223
+ return value;
224
+ }
225
+
226
+ function _resolveWindow(opts) {
227
+ opts = opts || {};
228
+ var now = Date.now();
229
+ var from = opts.from == null ? (now - DEFAULT_WINDOW_MS) : opts.from;
230
+ var to = opts.to == null ? now : opts.to;
231
+ _epochMs(from, "from");
232
+ _epochMs(to, "to");
233
+ if (from >= to) {
234
+ throw new TypeError("discountAnalytics: from must be strictly less than to");
235
+ }
236
+ if ((to - from) > ONE_YEAR_MS) {
237
+ throw new TypeError("discountAnalytics: window (to - from) must be ≤ 1 year");
238
+ }
239
+ return { from: from, to: to };
240
+ }
241
+
242
+ function _limit(value, label) {
243
+ if (!Number.isInteger(value) || value < 1 || value > MAX_LIMIT) {
244
+ throw new TypeError("discountAnalytics: " + label + " must be an integer in [1, " + MAX_LIMIT + "]");
245
+ }
246
+ return value;
247
+ }
248
+
249
+ // ---- factory ------------------------------------------------------------
250
+
251
+ function create(opts) {
252
+ opts = opts || {};
253
+ var query = opts.query;
254
+ if (!query) {
255
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
256
+ }
257
+ // Optional cross-check handles. Neither is load-bearing for any v1
258
+ // aggregate — the locally-owned tables are the source of truth.
259
+ // `coupons` is reserved for future cross-checks once that primitive
260
+ // lands; `quantityDiscounts` is consulted by `tierPerformance` to
261
+ // hydrate the tier-set definition next to the aggregate.
262
+ var coupons = opts.coupons || null;
263
+ var quantityDiscounts = opts.quantityDiscounts || null;
264
+
265
+ function _hashSession(sessionId) {
266
+ return _b().crypto.namespaceHash(SESSION_NAMESPACE, sessionId);
267
+ }
268
+
269
+ // ---- recordImpression ------------------------------------------------
270
+
271
+ async function recordImpression(input) {
272
+ if (!input || typeof input !== "object") {
273
+ throw new TypeError("discountAnalytics.recordImpression: input object required");
274
+ }
275
+ var code = _code(input.coupon_code, "coupon_code");
276
+ var sessionId = _sessionId(input.session_id);
277
+ var occurredAt = input.occurred_at == null ? _nowMs() : _epochMs(input.occurred_at, "occurred_at");
278
+
279
+ var id = _b().uuid.v7();
280
+ var hash = _hashSession(sessionId);
281
+ await query(
282
+ "INSERT INTO discount_impressions " +
283
+ "(id, coupon_code, session_id_hash, occurred_at) VALUES (?1, ?2, ?3, ?4)",
284
+ [id, code, hash, occurredAt],
285
+ );
286
+ return { id: id, coupon_code: code, occurred_at: occurredAt };
287
+ }
288
+
289
+ // ---- recordRedemption ------------------------------------------------
290
+
291
+ async function recordRedemption(input) {
292
+ if (!input || typeof input !== "object") {
293
+ throw new TypeError("discountAnalytics.recordRedemption: input object required");
294
+ }
295
+ var code = _code(input.coupon_code, "coupon_code");
296
+ var orderId = _orderId(input.order_id);
297
+ var amount = _amount(input.amount_minor);
298
+ var currency = _currency(input.currency, true);
299
+ var occurredAt = input.occurred_at == null ? _nowMs() : _epochMs(input.occurred_at, "occurred_at");
300
+
301
+ var id = _b().uuid.v7();
302
+ await query(
303
+ "INSERT INTO discount_redemptions " +
304
+ "(id, coupon_code, order_id, amount_minor, currency, occurred_at) " +
305
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
306
+ [id, code, orderId, amount, currency, occurredAt],
307
+ );
308
+ return {
309
+ id: id,
310
+ coupon_code: code,
311
+ order_id: orderId,
312
+ amount_minor: amount,
313
+ currency: currency,
314
+ occurred_at: occurredAt,
315
+ };
316
+ }
317
+
318
+ // ---- topCoupons ------------------------------------------------------
319
+
320
+ async function topCoupons(windowOpts) {
321
+ var w = _resolveWindow(windowOpts);
322
+ var rawLimit = (windowOpts && windowOpts.limit) == null ? 10 : windowOpts.limit;
323
+ _limit(rawLimit, "limit");
324
+
325
+ // GROUP BY coupon_code first; the per-currency split is folded
326
+ // into a single currency string when every redemption for the
327
+ // code shares one (the common case), or "MIXED" otherwise.
328
+ var r = await query(
329
+ "SELECT coupon_code, " +
330
+ " COUNT(*) AS redemptions, " +
331
+ " SUM(amount_minor) AS gross_revenue_minor, " +
332
+ " COUNT(DISTINCT currency) AS currency_variants, " +
333
+ " MIN(currency) AS first_currency " +
334
+ " FROM discount_redemptions " +
335
+ " WHERE occurred_at >= ?1 AND occurred_at < ?2 " +
336
+ " GROUP BY coupon_code " +
337
+ " ORDER BY redemptions DESC, coupon_code ASC " +
338
+ " LIMIT ?3",
339
+ [w.from, w.to, rawLimit],
340
+ );
341
+ return r.rows.map(function (row) {
342
+ var variants = Number(row.currency_variants) || 0;
343
+ return {
344
+ coupon_code: row.coupon_code,
345
+ redemptions: Number(row.redemptions) || 0,
346
+ gross_revenue_minor: Number(row.gross_revenue_minor) || 0,
347
+ currency: variants > 1 ? "MIXED" : (row.first_currency || "USD"),
348
+ };
349
+ });
350
+ }
351
+
352
+ // ---- couponPerformance ----------------------------------------------
353
+
354
+ async function _perCodePerformance(code, windowOpts) {
355
+ var w = _resolveWindow(windowOpts);
356
+ // Redemptions aggregate.
357
+ var redRow = (await query(
358
+ "SELECT COUNT(*) AS redemptions, " +
359
+ " SUM(amount_minor) AS gross_revenue_minor, " +
360
+ " COUNT(DISTINCT currency) AS currency_variants, " +
361
+ " MIN(currency) AS first_currency " +
362
+ " FROM discount_redemptions " +
363
+ " WHERE coupon_code = ?1 AND occurred_at >= ?2 AND occurred_at < ?3",
364
+ [code, w.from, w.to],
365
+ )).rows[0] || {};
366
+
367
+ // Impression aggregate.
368
+ var impRow = (await query(
369
+ "SELECT COUNT(*) AS impressions, " +
370
+ " COUNT(DISTINCT session_id_hash) AS unique_sessions " +
371
+ " FROM discount_impressions " +
372
+ " WHERE coupon_code = ?1 AND occurred_at >= ?2 AND occurred_at < ?3",
373
+ [code, w.from, w.to],
374
+ )).rows[0] || {};
375
+
376
+ var redemptions = Number(redRow.redemptions) || 0;
377
+ var gross = Number(redRow.gross_revenue_minor) || 0;
378
+ var variants = Number(redRow.currency_variants) || 0;
379
+ var impressions = Number(impRow.impressions) || 0;
380
+ var uniqueSessions = Number(impRow.unique_sessions) || 0;
381
+ var averageDiscount = redemptions > 0 ? Math.round(gross / redemptions) : 0;
382
+ // Conversion rate keyed off unique sessions — total-impressions
383
+ // double-counts a bar that re-renders in the same session, which
384
+ // would understate the rate. uniqueSessions = 0 collapses to 0.
385
+ var conversionRate = uniqueSessions > 0 ? redemptions / uniqueSessions : 0;
386
+
387
+ return {
388
+ coupon_code: code,
389
+ redemptions: redemptions,
390
+ gross_revenue_minor: gross,
391
+ average_discount_minor: averageDiscount,
392
+ conversion_rate: conversionRate,
393
+ impressions: impressions,
394
+ unique_sessions: uniqueSessions,
395
+ currency: variants > 1 ? "MIXED" : (redRow.first_currency || null),
396
+ };
397
+ }
398
+
399
+ async function couponPerformance(input) {
400
+ if (!input || typeof input !== "object") {
401
+ throw new TypeError("discountAnalytics.couponPerformance: input object required");
402
+ }
403
+ var code = _code(input.coupon_code, "coupon_code");
404
+ return await _perCodePerformance(code, input);
405
+ }
406
+
407
+ // ---- tierPerformance ------------------------------------------------
408
+
409
+ async function tierPerformance(input) {
410
+ if (!input || typeof input !== "object") {
411
+ throw new TypeError("discountAnalytics.tierPerformance: input object required");
412
+ }
413
+ var tierSetId = _tierSetId(input.tier_set_id);
414
+ var syntheticCode = "tier:" + tierSetId;
415
+ if (syntheticCode.length > MAX_CODE_LEN) {
416
+ throw new TypeError(
417
+ "discountAnalytics.tierPerformance: derived coupon_code (" + syntheticCode.length +
418
+ " chars) exceeds " + MAX_CODE_LEN
419
+ );
420
+ }
421
+ var perf = await _perCodePerformance(syntheticCode, input);
422
+
423
+ // Optional tier-set hydration. The `quantityDiscounts` handle is
424
+ // best-effort — a missing handle / archived set / handle without
425
+ // the expected method all collapse to `tier_set: null` so the
426
+ // dashboard always has SOMETHING to render.
427
+ var tierSet = null;
428
+ if (quantityDiscounts && typeof quantityDiscounts.tierBreakdown === "function") {
429
+ try {
430
+ var breakdown = await quantityDiscounts.tierBreakdown({ tier_set_id: tierSetId });
431
+ if (breakdown && typeof breakdown === "object") tierSet = breakdown;
432
+ } catch (_e) {
433
+ tierSet = null; // drop-silent — best-effort hydration, real aggregate already returned
434
+ }
435
+ } else if (quantityDiscounts && typeof quantityDiscounts.list === "function") {
436
+ try {
437
+ // Fall back to `list` when the operator didn't wire a
438
+ // `tierBreakdown` shorthand. Filter to the matching id.
439
+ var listed = await quantityDiscounts.list({ limit: MAX_LIMIT });
440
+ if (Array.isArray(listed)) {
441
+ for (var i = 0; i < listed.length; i += 1) {
442
+ var entry = listed[i];
443
+ var setId = entry && entry.tier_set && entry.tier_set.id;
444
+ if (setId === tierSetId) { tierSet = entry; break; }
445
+ }
446
+ }
447
+ } catch (_e) {
448
+ tierSet = null; // drop-silent — best-effort hydration
449
+ }
450
+ }
451
+
452
+ perf.tier_set_id = tierSetId;
453
+ perf.tier_set = tierSet;
454
+ return perf;
455
+ }
456
+
457
+ // ---- revenueImpact --------------------------------------------------
458
+
459
+ async function revenueImpact(windowOpts) {
460
+ var w = _resolveWindow(windowOpts);
461
+ var currencyFilter = "";
462
+ var params = [w.from, w.to];
463
+ if (windowOpts && windowOpts.currency != null) {
464
+ var cur = _currency(windowOpts.currency, false);
465
+ currencyFilter = " AND currency = ?3";
466
+ params.push(cur);
467
+ }
468
+ var r = await query(
469
+ "SELECT currency, " +
470
+ " COUNT(*) AS redemption_count, " +
471
+ " SUM(amount_minor) AS total_discount_minor " +
472
+ " FROM discount_redemptions " +
473
+ " WHERE occurred_at >= ?1 AND occurred_at < ?2 " +
474
+ currencyFilter +
475
+ " GROUP BY currency " +
476
+ " ORDER BY total_discount_minor DESC, currency ASC",
477
+ params,
478
+ );
479
+ return r.rows.map(function (row) {
480
+ return {
481
+ currency: row.currency,
482
+ redemption_count: Number(row.redemption_count) || 0,
483
+ total_discount_minor: Number(row.total_discount_minor) || 0,
484
+ };
485
+ });
486
+ }
487
+
488
+ // ---- redemptionFunnel -----------------------------------------------
489
+
490
+ async function redemptionFunnel(input) {
491
+ if (!input || typeof input !== "object") {
492
+ throw new TypeError("discountAnalytics.redemptionFunnel: input object required");
493
+ }
494
+ var code = _code(input.coupon_code, "coupon_code");
495
+ var w = _resolveWindow(input);
496
+
497
+ var impRow = (await query(
498
+ "SELECT COUNT(*) AS created, " +
499
+ " COUNT(DISTINCT session_id_hash) AS viewed " +
500
+ " FROM discount_impressions " +
501
+ " WHERE coupon_code = ?1 AND occurred_at >= ?2 AND occurred_at < ?3",
502
+ [code, w.from, w.to],
503
+ )).rows[0] || {};
504
+
505
+ var redRow = (await query(
506
+ "SELECT COUNT(*) AS redeemed " +
507
+ " FROM discount_redemptions " +
508
+ " WHERE coupon_code = ?1 AND occurred_at >= ?2 AND occurred_at < ?3",
509
+ [code, w.from, w.to],
510
+ )).rows[0] || {};
511
+
512
+ return {
513
+ coupon_code: code,
514
+ created: Number(impRow.created) || 0,
515
+ viewed: Number(impRow.viewed) || 0,
516
+ redeemed: Number(redRow.redeemed) || 0,
517
+ };
518
+ }
519
+
520
+ // Reference the optional handle so the lint pass doesn't flag the
521
+ // accepted-but-currently-unused `coupons` constructor opt. The
522
+ // hook is reserved for a near-future cross-check surface and the
523
+ // primitive should accept the handle today without behaviour
524
+ // change.
525
+ void coupons;
526
+
527
+ return {
528
+ SESSION_NAMESPACE: SESSION_NAMESPACE,
529
+ DEFAULT_WINDOW_MS: DEFAULT_WINDOW_MS,
530
+ ONE_YEAR_MS: ONE_YEAR_MS,
531
+
532
+ recordImpression: recordImpression,
533
+ recordRedemption: recordRedemption,
534
+ topCoupons: topCoupons,
535
+ couponPerformance: couponPerformance,
536
+ tierPerformance: tierPerformance,
537
+ revenueImpact: revenueImpact,
538
+ redemptionFunnel: redemptionFunnel,
539
+ };
540
+ }
541
+
542
+ module.exports = {
543
+ create: create,
544
+ SESSION_NAMESPACE: SESSION_NAMESPACE,
545
+ DEFAULT_WINDOW_MS: DEFAULT_WINDOW_MS,
546
+ ONE_YEAR_MS: ONE_YEAR_MS,
547
+ MAX_LIMIT: MAX_LIMIT,
548
+ };