@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,792 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.marketingBudget
4
+ * @title Marketing budget — per-channel spend tracking and ROAS
5
+ * reporting.
6
+ *
7
+ * @intro
8
+ * The operator runs a marketing mix (paid social, paid search,
9
+ * email, affiliate, influencer, organic) and needs a single place to
10
+ * record what each channel cost, what revenue each channel returned,
11
+ * and whether a given month's spend is tracking under the declared
12
+ * budget. This primitive owns that ledger.
13
+ *
14
+ * Surface:
15
+ *
16
+ * var mb = bShop.marketingBudget.create({ query: q });
17
+ *
18
+ * // 1. Declare a channel — slug is the stable identifier the rest
19
+ * // of the surface joins against; kind is one of the eleven
20
+ * // enumerated channel kinds.
21
+ * await mb.defineChannel({
22
+ * slug: "google-ads-uk",
23
+ * name: "Google Ads — UK",
24
+ * kind: "google_ads",
25
+ * currency: "GBP",
26
+ * });
27
+ *
28
+ * // 2. Record spend events. Append-only — operators correct a
29
+ * // mistaken entry by recording an offsetting row (the FSM is
30
+ * // deliberately ledger-shaped so reconciliation against an ad-
31
+ * // platform's billing export is straightforward).
32
+ * await mb.recordSpend({
33
+ * channel_slug: "google-ads-uk",
34
+ * spent_at: Date.now(),
35
+ * amount_minor: 25000,
36
+ * memo: "Daily auto-bid",
37
+ * });
38
+ *
39
+ * // 3. Attribute an order to a channel. Last-touch by default;
40
+ * // operators that want multi-touch write their own rules on
41
+ * // top and call attributeOrderToChannel with the resolved
42
+ * // attribution. order_id is UNIQUE — re-calling updates the
43
+ * // existing attribution in place.
44
+ * await mb.attributeOrderToChannel({
45
+ * order_id: orderId,
46
+ * channel_slug: "google-ads-uk",
47
+ * attributed_revenue_minor: 8999,
48
+ * currency: "GBP",
49
+ * attributed_at: Date.now(),
50
+ * });
51
+ *
52
+ * // 4. Read the dashboards.
53
+ * await mb.spendForPeriod({ channel_slug: "google-ads-uk",
54
+ * from: weekAgo, to: now });
55
+ * await mb.revenueForChannel({ channel_slug: "google-ads-uk",
56
+ * from: weekAgo, to: now });
57
+ * await mb.roas({ channel_slug: "google-ads-uk",
58
+ * from: weekAgo, to: now });
59
+ * await mb.topChannels({ from: weekAgo, to: now, limit: 5 });
60
+ * await mb.unattributedRevenue({ from: weekAgo, to: now,
61
+ * order_revenue_total_minor: ... });
62
+ *
63
+ * // 5. Declare a monthly budget + compare against actual spend.
64
+ * await mb.monthlyBudget({ channel_slug: "google-ads-uk",
65
+ * month: "2026-05", amount_minor: 100000,
66
+ * currency: "GBP" });
67
+ * await mb.budgetVsActual({ month: "2026-05" });
68
+ *
69
+ * ROAS arithmetic:
70
+ * Return on ad spend is `revenue / spend`. The primitive returns
71
+ * the ratio as an integer basis-points value (`roas_bps`,
72
+ * 0..unbounded) so a dashboard renders `bps / 100` as a percentage
73
+ * and `bps / 10000` as the raw multiplier. Spend of zero with
74
+ * non-zero revenue surfaces as `null` (undefined ratio); spend
75
+ * and revenue both zero surface as `0`.
76
+ *
77
+ * Channel kinds: google_ads / meta_ads / tiktok_ads / linkedin_ads /
78
+ * email_campaign / affiliate / influencer / organic_search / direct /
79
+ * referral / other. The CHECK constraint on the column means a typo
80
+ * at write time fails loud instead of landing as a silent twelfth
81
+ * bucket on every dashboard.
82
+ *
83
+ * Currency policy:
84
+ * A channel is single-currency by design — totals across mixed
85
+ * currencies aren't meaningful without an FX rate the operator
86
+ * owns. Multi-currency operators define one channel slug per
87
+ * (kind, currency) pair (e.g. "google-ads-uk" + "google-ads-us").
88
+ * `recordSpend` and `attributeOrderToChannel` refuse when the
89
+ * supplied currency doesn't match the channel's declared currency.
90
+ * `topChannels` returns the per-channel rollup; the caller groups
91
+ * by currency in the dashboard rendering layer.
92
+ *
93
+ * Storage: migration 0172_marketing_budget.sql.
94
+ *
95
+ * Composition: zero npm runtime deps. The primitive composes
96
+ * blamejs (`b.uuid.v7`, `b.guardUuid.sanitize`) — every row id is a
97
+ * UUIDv7 so chronological + tie-broken sorts are deterministic, and
98
+ * every `order_id` flowing in goes through the strict UUID gate.
99
+ *
100
+ * @primitive marketingBudget
101
+ * @related b.uuid, b.guardUuid
102
+ */
103
+
104
+ var bShop;
105
+ function _b() {
106
+ if (!bShop) bShop = require("./index");
107
+ return bShop.framework;
108
+ }
109
+
110
+ // ---- constants ----------------------------------------------------------
111
+
112
+ var CHANNEL_KINDS = Object.freeze([
113
+ "google_ads", "meta_ads", "tiktok_ads", "linkedin_ads",
114
+ "email_campaign", "affiliate", "influencer",
115
+ "organic_search", "direct", "referral", "other",
116
+ ]);
117
+
118
+ var SLUG_RE = /^[a-z](?:[a-z0-9-]*[a-z0-9])?$/;
119
+ var MONTH_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
120
+ var CURRENCY_RE = /^[A-Z]{3}$/;
121
+
122
+ var MAX_SLUG_LEN = 64;
123
+ var MAX_NAME_LEN = 200;
124
+ var MAX_MEMO_LEN = 1024;
125
+ var MAX_AMOUNT_MINOR = 1000000000000; // 1 trillion minor units — comfortably above any plausible single spend event
126
+ var MAX_LIST_LIMIT = 200;
127
+ var DEFAULT_LIMIT = 50;
128
+ var MAX_TOP_LIMIT = 100;
129
+ var DEFAULT_TOP = 10;
130
+
131
+ // ---- monotonic clock ----------------------------------------------------
132
+ //
133
+ // Wall-clock can stall on a fast hot loop (multiple inserts inside the
134
+ // same millisecond) and on coarse-grained virtualised hosts. The
135
+ // monotonic shim keeps the per-process `created_at` / `updated_at` /
136
+ // `attributed_at` timestamps strictly increasing — every subsequent
137
+ // call observes a timestamp at least 1ms greater than the previous
138
+ // one. Sibling primitives (clickAndCollect, pixelEvents et al.) use
139
+ // the same shape; the FSM-style reads (spendForPeriod chronological
140
+ // ordering, monthly-budget vs spend joins) rely on strict monotonicity
141
+ // to break ties deterministically.
142
+
143
+ var _lastTs = 0;
144
+ function _now() {
145
+ var t = Date.now();
146
+ if (t <= _lastTs) { t = _lastTs + 1; }
147
+ _lastTs = t;
148
+ return t;
149
+ }
150
+
151
+ // ---- validators ---------------------------------------------------------
152
+
153
+ function _slug(s, label) {
154
+ if (typeof s !== "string" || !s.length) {
155
+ throw new TypeError("marketingBudget: " + label + " must be a non-empty string");
156
+ }
157
+ if (s.length > MAX_SLUG_LEN) {
158
+ throw new TypeError("marketingBudget: " + label + " must be <= " + MAX_SLUG_LEN + " characters");
159
+ }
160
+ if (!SLUG_RE.test(s)) {
161
+ throw new TypeError("marketingBudget: " + label + " must match /^[a-z][a-z0-9-]*[a-z0-9]$/");
162
+ }
163
+ return s;
164
+ }
165
+
166
+ function _name(s) {
167
+ if (typeof s !== "string" || s.length === 0 || s.length > MAX_NAME_LEN) {
168
+ throw new TypeError("marketingBudget: name must be a non-empty string <= " + MAX_NAME_LEN + " chars");
169
+ }
170
+ return s;
171
+ }
172
+
173
+ function _kind(s) {
174
+ if (typeof s !== "string" || CHANNEL_KINDS.indexOf(s) === -1) {
175
+ throw new TypeError("marketingBudget: kind must be one of " + CHANNEL_KINDS.join(", "));
176
+ }
177
+ return s;
178
+ }
179
+
180
+ function _currency(s) {
181
+ if (typeof s !== "string" || s.length !== 3 || !CURRENCY_RE.test(s)) {
182
+ throw new TypeError("marketingBudget: currency must be a 3-letter uppercase ISO-4217 code");
183
+ }
184
+ return s;
185
+ }
186
+
187
+ function _amountMinor(n, label) {
188
+ if (!Number.isInteger(n) || n < 0 || n > MAX_AMOUNT_MINOR) {
189
+ throw new TypeError("marketingBudget: " + label + " must be a non-negative integer <= " + MAX_AMOUNT_MINOR);
190
+ }
191
+ return n;
192
+ }
193
+
194
+ function _epochMs(n, label) {
195
+ if (!Number.isInteger(n) || n <= 0) {
196
+ throw new TypeError("marketingBudget: " + label + " must be a positive integer (epoch ms)");
197
+ }
198
+ return n;
199
+ }
200
+
201
+ function _memo(s) {
202
+ if (s == null) return null;
203
+ if (typeof s !== "string") {
204
+ throw new TypeError("marketingBudget: memo must be a string when provided");
205
+ }
206
+ if (s.length > MAX_MEMO_LEN) {
207
+ throw new TypeError("marketingBudget: memo must be <= " + MAX_MEMO_LEN + " characters");
208
+ }
209
+ return s;
210
+ }
211
+
212
+ function _month(s) {
213
+ if (typeof s !== "string" || !MONTH_RE.test(s)) {
214
+ throw new TypeError("marketingBudget: month must be \"YYYY-MM\"");
215
+ }
216
+ return s;
217
+ }
218
+
219
+ function _orderId(s) {
220
+ try {
221
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
222
+ } catch (e) {
223
+ throw new TypeError("marketingBudget: order_id — " + (e && e.message || "invalid UUID"));
224
+ }
225
+ }
226
+
227
+ function _limit(n, label, max) {
228
+ max = max || MAX_LIST_LIMIT;
229
+ if (n == null) return DEFAULT_LIMIT;
230
+ if (!Number.isInteger(n) || n <= 0 || n > max) {
231
+ throw new TypeError("marketingBudget: " + label + " must be an integer in [1, " + max + "]");
232
+ }
233
+ return n;
234
+ }
235
+
236
+ function _window(opts, label) {
237
+ if (!opts || typeof opts !== "object") {
238
+ throw new TypeError("marketingBudget." + label + ": opts object required");
239
+ }
240
+ _epochMs(opts.from, "from");
241
+ _epochMs(opts.to, "to");
242
+ if (opts.from >= opts.to) {
243
+ throw new TypeError("marketingBudget." + label + ": from must be strictly less than to");
244
+ }
245
+ return { from: opts.from, to: opts.to };
246
+ }
247
+
248
+ // Compute the [start, end) UTC epoch-ms range for a "YYYY-MM" month
249
+ // string. End is the first millisecond of the following month so the
250
+ // span is half-open (matches how every other window in the codebase
251
+ // reads).
252
+ function _monthRange(month) {
253
+ var year = Number(month.slice(0, 4));
254
+ var mon = Number(month.slice(5, 7)); // 1..12
255
+ var start = Date.UTC(year, mon - 1, 1, 0, 0, 0, 0);
256
+ // Adding one to the JS month index naturally rolls 12 -> 13 ->
257
+ // January next year, which is exactly what we want for the upper
258
+ // bound of December.
259
+ var end = Date.UTC(year, mon, 1, 0, 0, 0, 0);
260
+ return { from: start, to: end };
261
+ }
262
+
263
+ // ---- factory ------------------------------------------------------------
264
+
265
+ function create(opts) {
266
+ opts = opts || {};
267
+ var query = opts.query;
268
+ if (!query) {
269
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
270
+ }
271
+
272
+ async function _getChannelRow(slug) {
273
+ var r = await query("SELECT * FROM marketing_channels WHERE slug = ?1", [slug]);
274
+ return r.rows.length ? r.rows[0] : null;
275
+ }
276
+
277
+ return {
278
+
279
+ CHANNEL_KINDS: CHANNEL_KINDS,
280
+
281
+ // Register a marketing channel. Upsert semantics on `slug` — re-
282
+ // defining the same slug updates name + active in place. The kind
283
+ // and currency are pinned on first insert: re-defining with a
284
+ // different kind/currency is refused, because spend and
285
+ // attribution rows are already denormalised against the original
286
+ // values and a silent rewrite would corrupt every prior ROAS
287
+ // calculation.
288
+ defineChannel: async function (input) {
289
+ if (!input || typeof input !== "object") {
290
+ throw new TypeError("marketingBudget.defineChannel: input object required");
291
+ }
292
+ _slug(input.slug, "slug");
293
+ _name(input.name);
294
+ _kind(input.kind);
295
+ _currency(input.currency);
296
+ var active = input.active === false ? 0 : 1;
297
+
298
+ var now = _now();
299
+ var existing = await _getChannelRow(input.slug);
300
+ if (existing) {
301
+ if (existing.kind !== input.kind) {
302
+ throw new TypeError("marketingBudget.defineChannel: cannot change kind of existing channel " +
303
+ JSON.stringify(input.slug) + " (was " + existing.kind + ", got " + input.kind + ")");
304
+ }
305
+ if (existing.currency !== input.currency) {
306
+ throw new TypeError("marketingBudget.defineChannel: cannot change currency of existing channel " +
307
+ JSON.stringify(input.slug) + " (was " + existing.currency + ", got " + input.currency + ")");
308
+ }
309
+ await query(
310
+ "UPDATE marketing_channels SET name = ?1, active = ?2, updated_at = ?3 WHERE slug = ?4",
311
+ [input.name, active, now, input.slug],
312
+ );
313
+ } else {
314
+ await query(
315
+ "INSERT INTO marketing_channels (slug, name, kind, currency, active, created_at, updated_at) " +
316
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
317
+ [input.slug, input.name, input.kind, input.currency, active, now, now],
318
+ );
319
+ }
320
+ return await _getChannelRow(input.slug);
321
+ },
322
+
323
+ // Hydrated read of a single channel. Returns null on miss.
324
+ getChannel: async function (slug) {
325
+ _slug(slug, "slug");
326
+ return await _getChannelRow(slug);
327
+ },
328
+
329
+ // List every channel. `active_only` defaults true so the operator-
330
+ // dashboard read doesn't accidentally surface archived rows.
331
+ listChannels: async function (listOpts) {
332
+ listOpts = listOpts || {};
333
+ var activeOnly = listOpts.active_only !== false;
334
+ var sql = "SELECT * FROM marketing_channels";
335
+ var params = [];
336
+ if (activeOnly) {
337
+ sql += " WHERE active = 1";
338
+ }
339
+ sql += " ORDER BY slug ASC";
340
+ var r = await query(sql, params);
341
+ return r.rows;
342
+ },
343
+
344
+ // Append-only spend event. Currency is denormalised onto the spend
345
+ // row so a later channel-currency rewrite (which is itself refused
346
+ // — see defineChannel) couldn't poison historical totals.
347
+ recordSpend: async function (input) {
348
+ if (!input || typeof input !== "object") {
349
+ throw new TypeError("marketingBudget.recordSpend: input object required");
350
+ }
351
+ _slug(input.channel_slug, "channel_slug");
352
+ _epochMs(input.spent_at, "spent_at");
353
+ _amountMinor(input.amount_minor, "amount_minor");
354
+ var memo = _memo(input.memo);
355
+
356
+ var channel = await _getChannelRow(input.channel_slug);
357
+ if (!channel) {
358
+ throw new TypeError("marketingBudget.recordSpend: channel_slug " +
359
+ JSON.stringify(input.channel_slug) + " not found");
360
+ }
361
+
362
+ var id = _b().uuid.v7();
363
+ var now = _now();
364
+ await query(
365
+ "INSERT INTO marketing_spend (id, channel_slug, spent_at, amount_minor, currency, memo, created_at) " +
366
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
367
+ [id, input.channel_slug, input.spent_at, input.amount_minor, channel.currency, memo, now],
368
+ );
369
+ var r = await query("SELECT * FROM marketing_spend WHERE id = ?1", [id]);
370
+ return r.rows[0];
371
+ },
372
+
373
+ // Map an order to a channel (last-touch by default — multi-touch
374
+ // attribution is an operator extension). order_id is UNIQUE; re-
375
+ // calling updates the existing attribution in place so a corrected
376
+ // attribution overwrites the prior one cleanly.
377
+ attributeOrderToChannel: async function (input) {
378
+ if (!input || typeof input !== "object") {
379
+ throw new TypeError("marketingBudget.attributeOrderToChannel: input object required");
380
+ }
381
+ var orderId = _orderId(input.order_id);
382
+ _slug(input.channel_slug, "channel_slug");
383
+ _amountMinor(input.attributed_revenue_minor, "attributed_revenue_minor");
384
+ _currency(input.currency);
385
+ _epochMs(input.attributed_at, "attributed_at");
386
+
387
+ var channel = await _getChannelRow(input.channel_slug);
388
+ if (!channel) {
389
+ throw new TypeError("marketingBudget.attributeOrderToChannel: channel_slug " +
390
+ JSON.stringify(input.channel_slug) + " not found");
391
+ }
392
+ if (channel.currency !== input.currency) {
393
+ throw new TypeError("marketingBudget.attributeOrderToChannel: currency " +
394
+ JSON.stringify(input.currency) + " does not match channel currency " +
395
+ JSON.stringify(channel.currency));
396
+ }
397
+
398
+ var now = _now();
399
+ var existing = await query(
400
+ "SELECT id FROM marketing_attributions WHERE order_id = ?1", [orderId],
401
+ );
402
+ if (existing.rows.length) {
403
+ await query(
404
+ "UPDATE marketing_attributions SET channel_slug = ?1, attributed_revenue_minor = ?2, " +
405
+ "currency = ?3, attributed_at = ?4 WHERE order_id = ?5",
406
+ [input.channel_slug, input.attributed_revenue_minor, input.currency,
407
+ input.attributed_at, orderId],
408
+ );
409
+ } else {
410
+ await query(
411
+ "INSERT INTO marketing_attributions (id, order_id, channel_slug, " +
412
+ "attributed_revenue_minor, currency, attributed_at, created_at) " +
413
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
414
+ [_b().uuid.v7(), orderId, input.channel_slug, input.attributed_revenue_minor,
415
+ input.currency, input.attributed_at, now],
416
+ );
417
+ }
418
+ var r = await query("SELECT * FROM marketing_attributions WHERE order_id = ?1", [orderId]);
419
+ return r.rows[0];
420
+ },
421
+
422
+ // Spend in a window for a single channel. Returns
423
+ // `{ rows, total_minor, currency }` — the row list is in
424
+ // chronological (spent_at, id) order so a downstream ledger
425
+ // export reads as a contiguous timeline. `limit` caps the row
426
+ // count; the total is computed across every matching row, not
427
+ // just the page.
428
+ spendForPeriod: async function (input) {
429
+ var w = _window(input, "spendForPeriod");
430
+ _slug(input.channel_slug, "channel_slug");
431
+ var limit = _limit(input.limit, "limit");
432
+
433
+ var channel = await _getChannelRow(input.channel_slug);
434
+ if (!channel) {
435
+ throw new TypeError("marketingBudget.spendForPeriod: channel_slug " +
436
+ JSON.stringify(input.channel_slug) + " not found");
437
+ }
438
+
439
+ var rowsR = await query(
440
+ "SELECT * FROM marketing_spend " +
441
+ " WHERE channel_slug = ?1 AND spent_at >= ?2 AND spent_at < ?3 " +
442
+ " ORDER BY spent_at ASC, id ASC LIMIT ?4",
443
+ [input.channel_slug, w.from, w.to, limit],
444
+ );
445
+ var totalR = await query(
446
+ "SELECT COALESCE(SUM(amount_minor), 0) AS total FROM marketing_spend " +
447
+ " WHERE channel_slug = ?1 AND spent_at >= ?2 AND spent_at < ?3",
448
+ [input.channel_slug, w.from, w.to],
449
+ );
450
+ return {
451
+ channel_slug: input.channel_slug,
452
+ currency: channel.currency,
453
+ total_minor: Number(totalR.rows[0].total) || 0,
454
+ rows: rowsR.rows,
455
+ };
456
+ },
457
+
458
+ // Sum of attributed revenue for a channel in a window. Returns
459
+ // `{ channel_slug, currency, total_minor, order_count }` so the
460
+ // dashboard renders both gross revenue + the count of attributed
461
+ // orders without a follow-up call.
462
+ revenueForChannel: async function (input) {
463
+ var w = _window(input, "revenueForChannel");
464
+ _slug(input.channel_slug, "channel_slug");
465
+
466
+ var channel = await _getChannelRow(input.channel_slug);
467
+ if (!channel) {
468
+ throw new TypeError("marketingBudget.revenueForChannel: channel_slug " +
469
+ JSON.stringify(input.channel_slug) + " not found");
470
+ }
471
+
472
+ var r = await query(
473
+ "SELECT COALESCE(SUM(attributed_revenue_minor), 0) AS total, COUNT(*) AS n " +
474
+ " FROM marketing_attributions " +
475
+ " WHERE channel_slug = ?1 AND attributed_at >= ?2 AND attributed_at < ?3",
476
+ [input.channel_slug, w.from, w.to],
477
+ );
478
+ return {
479
+ channel_slug: input.channel_slug,
480
+ currency: channel.currency,
481
+ total_minor: Number(r.rows[0].total) || 0,
482
+ order_count: Number(r.rows[0].n) || 0,
483
+ };
484
+ },
485
+
486
+ // Return on ad spend (ROAS) for a channel in a window. The ratio
487
+ // is reported as integer basis-points — operators render
488
+ // `bps / 100` as a percentage and `bps / 10000` as the raw
489
+ // multiplier. Spend of zero with non-zero revenue surfaces as
490
+ // `null` (undefined ratio); spend and revenue both zero surface
491
+ // as `0`.
492
+ roas: async function (input) {
493
+ var w = _window(input, "roas");
494
+ _slug(input.channel_slug, "channel_slug");
495
+
496
+ var channel = await _getChannelRow(input.channel_slug);
497
+ if (!channel) {
498
+ throw new TypeError("marketingBudget.roas: channel_slug " +
499
+ JSON.stringify(input.channel_slug) + " not found");
500
+ }
501
+
502
+ var spendR = await query(
503
+ "SELECT COALESCE(SUM(amount_minor), 0) AS total FROM marketing_spend " +
504
+ " WHERE channel_slug = ?1 AND spent_at >= ?2 AND spent_at < ?3",
505
+ [input.channel_slug, w.from, w.to],
506
+ );
507
+ var revR = await query(
508
+ "SELECT COALESCE(SUM(attributed_revenue_minor), 0) AS total FROM marketing_attributions " +
509
+ " WHERE channel_slug = ?1 AND attributed_at >= ?2 AND attributed_at < ?3",
510
+ [input.channel_slug, w.from, w.to],
511
+ );
512
+ var spend = Number(spendR.rows[0].total) || 0;
513
+ var revenue = Number(revR.rows[0].total) || 0;
514
+ var bps;
515
+ if (spend === 0 && revenue === 0) {
516
+ bps = 0;
517
+ } else if (spend === 0) {
518
+ bps = null;
519
+ } else {
520
+ bps = Math.round((revenue / spend) * 10000);
521
+ }
522
+ return {
523
+ channel_slug: input.channel_slug,
524
+ currency: channel.currency,
525
+ spend_minor: spend,
526
+ revenue_minor: revenue,
527
+ roas_bps: bps,
528
+ };
529
+ },
530
+
531
+ // Top-N channels by attributed revenue across the window. Returns
532
+ // one row per channel with spend + revenue + ROAS denormalised so
533
+ // the dashboard renders the leaderboard without N follow-up
534
+ // calls. Sort is `revenue DESC, channel_slug ASC` for deterministic
535
+ // ties. `limit` defaults to 10, max 100.
536
+ topChannels: async function (input) {
537
+ var w = _window(input, "topChannels");
538
+ var limit = _limit((input && input.limit) == null ? DEFAULT_TOP : input.limit, "limit", MAX_TOP_LIMIT);
539
+
540
+ // Revenue and spend live in sibling tables; compute each side
541
+ // independently and merge in JS. The merge keys off the channel
542
+ // slug so a channel with revenue but no spend (organic) and a
543
+ // channel with spend but no revenue (a flop) both surface.
544
+ var revR = await query(
545
+ "SELECT channel_slug, currency, " +
546
+ " COALESCE(SUM(attributed_revenue_minor), 0) AS revenue, " +
547
+ " COUNT(*) AS order_count " +
548
+ " FROM marketing_attributions " +
549
+ " WHERE attributed_at >= ?1 AND attributed_at < ?2 " +
550
+ " GROUP BY channel_slug, currency",
551
+ [w.from, w.to],
552
+ );
553
+ var spendR = await query(
554
+ "SELECT channel_slug, currency, " +
555
+ " COALESCE(SUM(amount_minor), 0) AS spend " +
556
+ " FROM marketing_spend " +
557
+ " WHERE spent_at >= ?1 AND spent_at < ?2 " +
558
+ " GROUP BY channel_slug, currency",
559
+ [w.from, w.to],
560
+ );
561
+
562
+ var byChannel = Object.create(null);
563
+ for (var i = 0; i < revR.rows.length; i += 1) {
564
+ var rr = revR.rows[i];
565
+ byChannel[rr.channel_slug] = {
566
+ channel_slug: rr.channel_slug,
567
+ currency: rr.currency,
568
+ spend_minor: 0,
569
+ revenue_minor: Number(rr.revenue) || 0,
570
+ order_count: Number(rr.order_count) || 0,
571
+ };
572
+ }
573
+ for (var k = 0; k < spendR.rows.length; k += 1) {
574
+ var sr = spendR.rows[k];
575
+ if (byChannel[sr.channel_slug]) {
576
+ byChannel[sr.channel_slug].spend_minor = Number(sr.spend) || 0;
577
+ } else {
578
+ byChannel[sr.channel_slug] = {
579
+ channel_slug: sr.channel_slug,
580
+ currency: sr.currency,
581
+ spend_minor: Number(sr.spend) || 0,
582
+ revenue_minor: 0,
583
+ order_count: 0,
584
+ };
585
+ }
586
+ }
587
+ var slugs = Object.keys(byChannel);
588
+ var rows = [];
589
+ for (var j = 0; j < slugs.length; j += 1) {
590
+ var row = byChannel[slugs[j]];
591
+ var bps;
592
+ if (row.spend_minor === 0 && row.revenue_minor === 0) {
593
+ bps = 0;
594
+ } else if (row.spend_minor === 0) {
595
+ bps = null;
596
+ } else {
597
+ bps = Math.round((row.revenue_minor / row.spend_minor) * 10000);
598
+ }
599
+ row.roas_bps = bps;
600
+ rows.push(row);
601
+ }
602
+ // Deterministic tie-break: revenue DESC, then channel_slug ASC.
603
+ // Channels with `roas_bps = null` (revenue-but-no-spend) sort by
604
+ // their revenue value alone — the same as a finite ROAS row
605
+ // would.
606
+ rows.sort(function (a, b) {
607
+ if (b.revenue_minor !== a.revenue_minor) return b.revenue_minor - a.revenue_minor;
608
+ return a.channel_slug < b.channel_slug ? -1 : a.channel_slug > b.channel_slug ? 1 : 0;
609
+ });
610
+ return rows.slice(0, limit);
611
+ },
612
+
613
+ // Revenue NOT yet attributed to any channel in the window. The
614
+ // caller supplies the gross revenue total for the same window
615
+ // (computed via salesReports or the operator's own aggregator) +
616
+ // a currency; this primitive subtracts the attributed-revenue
617
+ // sum for that currency and returns the delta. The result is
618
+ // `{ currency, total_order_revenue_minor, attributed_minor,
619
+ // unattributed_minor }`. Floors at zero — the attributed sum
620
+ // can exceed the supplied total if the caller mixed currencies
621
+ // or trimmed the input window inconsistently, and a negative
622
+ // delta would render nonsensically on a dashboard.
623
+ unattributedRevenue: async function (input) {
624
+ var w = _window(input, "unattributedRevenue");
625
+ if (input.currency != null) _currency(input.currency);
626
+ _amountMinor(input.order_revenue_total_minor, "order_revenue_total_minor");
627
+
628
+ var sql = "SELECT COALESCE(SUM(attributed_revenue_minor), 0) AS total " +
629
+ " FROM marketing_attributions " +
630
+ " WHERE attributed_at >= ?1 AND attributed_at < ?2";
631
+ var params = [w.from, w.to];
632
+ if (input.currency != null) {
633
+ sql += " AND currency = ?3";
634
+ params.push(input.currency);
635
+ }
636
+ var r = await query(sql, params);
637
+ var attributed = Number(r.rows[0].total) || 0;
638
+ var unattributed = input.order_revenue_total_minor - attributed;
639
+ if (unattributed < 0) unattributed = 0;
640
+ return {
641
+ currency: input.currency || null,
642
+ total_order_revenue_minor: input.order_revenue_total_minor,
643
+ attributed_minor: attributed,
644
+ unattributed_minor: unattributed,
645
+ };
646
+ },
647
+
648
+ // Declare or update a per-channel monthly budget. (channel_slug,
649
+ // month) is UNIQUE — re-calling for the same pair updates the cap
650
+ // in place. The supplied currency must match the channel's
651
+ // declared currency for the same reason recordSpend gates currency
652
+ // — a silent mismatch would corrupt every budgetVsActual
653
+ // comparison after.
654
+ monthlyBudget: async function (input) {
655
+ if (!input || typeof input !== "object") {
656
+ throw new TypeError("marketingBudget.monthlyBudget: input object required");
657
+ }
658
+ _slug(input.channel_slug, "channel_slug");
659
+ _month(input.month);
660
+ _amountMinor(input.amount_minor, "amount_minor");
661
+ _currency(input.currency);
662
+
663
+ var channel = await _getChannelRow(input.channel_slug);
664
+ if (!channel) {
665
+ throw new TypeError("marketingBudget.monthlyBudget: channel_slug " +
666
+ JSON.stringify(input.channel_slug) + " not found");
667
+ }
668
+ if (channel.currency !== input.currency) {
669
+ throw new TypeError("marketingBudget.monthlyBudget: currency " +
670
+ JSON.stringify(input.currency) + " does not match channel currency " +
671
+ JSON.stringify(channel.currency));
672
+ }
673
+
674
+ var now = _now();
675
+ var existing = await query(
676
+ "SELECT id FROM marketing_budgets WHERE channel_slug = ?1 AND month = ?2",
677
+ [input.channel_slug, input.month],
678
+ );
679
+ if (existing.rows.length) {
680
+ await query(
681
+ "UPDATE marketing_budgets SET amount_minor = ?1, currency = ?2, updated_at = ?3 " +
682
+ "WHERE channel_slug = ?4 AND month = ?5",
683
+ [input.amount_minor, input.currency, now, input.channel_slug, input.month],
684
+ );
685
+ } else {
686
+ await query(
687
+ "INSERT INTO marketing_budgets (id, channel_slug, month, amount_minor, currency, " +
688
+ "created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
689
+ [_b().uuid.v7(), input.channel_slug, input.month, input.amount_minor, input.currency,
690
+ now, now],
691
+ );
692
+ }
693
+ var r = await query(
694
+ "SELECT * FROM marketing_budgets WHERE channel_slug = ?1 AND month = ?2",
695
+ [input.channel_slug, input.month],
696
+ );
697
+ return r.rows[0];
698
+ },
699
+
700
+ // Budget vs actual rollup for a month. Returns one row per channel
701
+ // that has either a declared budget OR recorded spend in the
702
+ // month. The variance is `budget_minor - actual_minor` (positive
703
+ // == under budget; negative == over). `pct_used_bps` is the
704
+ // integer basis-points of `actual / budget` (0..unbounded) so a
705
+ // dashboard renders `bps / 100` as a percentage. A channel with
706
+ // recorded spend but no declared budget surfaces with
707
+ // `budget_minor = 0`, `pct_used_bps = null`, `over_budget = true`
708
+ // — the operator forgot to declare a cap, the dashboard flags
709
+ // the gap.
710
+ budgetVsActual: async function (input) {
711
+ if (!input || typeof input !== "object") {
712
+ throw new TypeError("marketingBudget.budgetVsActual: input object required");
713
+ }
714
+ _month(input.month);
715
+ if (input.channel_slug != null) _slug(input.channel_slug, "channel_slug");
716
+
717
+ var range = _monthRange(input.month);
718
+
719
+ var budgetSql = "SELECT channel_slug, amount_minor AS budget, currency " +
720
+ " FROM marketing_budgets WHERE month = ?1";
721
+ var budgetParams = [input.month];
722
+ if (input.channel_slug) {
723
+ budgetSql += " AND channel_slug = ?2";
724
+ budgetParams.push(input.channel_slug);
725
+ }
726
+ var budgetR = await query(budgetSql, budgetParams);
727
+
728
+ var spendSql = "SELECT channel_slug, currency, COALESCE(SUM(amount_minor), 0) AS actual " +
729
+ " FROM marketing_spend " +
730
+ " WHERE spent_at >= ?1 AND spent_at < ?2";
731
+ var spendParams = [range.from, range.to];
732
+ if (input.channel_slug) {
733
+ spendSql += " AND channel_slug = ?3";
734
+ spendParams.push(input.channel_slug);
735
+ }
736
+ spendSql += " GROUP BY channel_slug, currency";
737
+ var spendR = await query(spendSql, spendParams);
738
+
739
+ var byChannel = Object.create(null);
740
+ for (var i = 0; i < budgetR.rows.length; i += 1) {
741
+ var br = budgetR.rows[i];
742
+ byChannel[br.channel_slug] = {
743
+ channel_slug: br.channel_slug,
744
+ month: input.month,
745
+ currency: br.currency,
746
+ budget_minor: Number(br.budget) || 0,
747
+ actual_minor: 0,
748
+ };
749
+ }
750
+ for (var k = 0; k < spendR.rows.length; k += 1) {
751
+ var sr = spendR.rows[k];
752
+ if (byChannel[sr.channel_slug]) {
753
+ byChannel[sr.channel_slug].actual_minor = Number(sr.actual) || 0;
754
+ } else {
755
+ byChannel[sr.channel_slug] = {
756
+ channel_slug: sr.channel_slug,
757
+ month: input.month,
758
+ currency: sr.currency,
759
+ budget_minor: 0,
760
+ actual_minor: Number(sr.actual) || 0,
761
+ };
762
+ }
763
+ }
764
+
765
+ var slugs = Object.keys(byChannel);
766
+ var rows = [];
767
+ for (var j = 0; j < slugs.length; j += 1) {
768
+ var row = byChannel[slugs[j]];
769
+ row.variance_minor = row.budget_minor - row.actual_minor;
770
+ if (row.budget_minor === 0) {
771
+ row.pct_used_bps = null;
772
+ // Spend with no budget cap = unbounded over-budget signal.
773
+ row.over_budget = row.actual_minor > 0;
774
+ } else {
775
+ row.pct_used_bps = Math.round((row.actual_minor / row.budget_minor) * 10000);
776
+ row.over_budget = row.actual_minor > row.budget_minor;
777
+ }
778
+ rows.push(row);
779
+ }
780
+ rows.sort(function (a, b) {
781
+ if (b.actual_minor !== a.actual_minor) return b.actual_minor - a.actual_minor;
782
+ return a.channel_slug < b.channel_slug ? -1 : a.channel_slug > b.channel_slug ? 1 : 0;
783
+ });
784
+ return rows;
785
+ },
786
+ };
787
+ }
788
+
789
+ module.exports = {
790
+ create: create,
791
+ CHANNEL_KINDS: CHANNEL_KINDS,
792
+ };