@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.
- package/CHANGELOG.md +2 -0
- package/lib/carrier-rates.js +683 -0
- package/lib/cart-bulk-ops.js +711 -0
- package/lib/cms-blocks.js +651 -0
- package/lib/code-minter.js +535 -0
- package/lib/customer-import.js +590 -0
- package/lib/discount-analytics.js +548 -0
- package/lib/dunning.js +700 -0
- package/lib/gift-card-ledger.js +483 -0
- package/lib/index.js +11 -0
- package/lib/operator-audit-log.js +621 -0
- package/lib/search-facets.js +825 -0
- package/lib/sms-dispatcher.js +945 -0
- package/lib/storefront-forms.js +884 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|