@blamejs/blamejs-shop 0.0.70 → 0.0.75
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/lib/announcement-bar.js +753 -0
- package/lib/banner-ab-tests.js +806 -0
- package/lib/bin-locations.js +791 -0
- package/lib/blog-articles.js +1173 -0
- package/lib/carrier-accounts.js +805 -0
- package/lib/cart-recovery.js +1133 -0
- package/lib/category-navigation.js +934 -0
- package/lib/consent-ledger.js +539 -0
- package/lib/customer-impersonation.js +743 -0
- package/lib/customer-merge.js +879 -0
- package/lib/demand-forecast.js +1121 -0
- package/lib/dispute-resolution.js +886 -0
- package/lib/email-ab-tests.js +918 -0
- package/lib/email-engagement-score.js +649 -0
- package/lib/event-log.js +713 -0
- package/lib/fulfillment-sla.js +791 -0
- package/lib/index.js +42 -0
- package/lib/inventory-audits.js +852 -0
- package/lib/line-gift-wrap.js +430 -0
- package/lib/loyalty-earn-rules.js +786 -0
- package/lib/marketing-budget.js +792 -0
- package/lib/operator-activity-feed.js +977 -0
- package/lib/operator-approvals.js +942 -0
- package/lib/operator-help-center.js +1020 -0
- package/lib/operator-inbox.js +889 -0
- package/lib/operator-sessions.js +701 -0
- package/lib/order-exchanges.js +602 -0
- package/lib/product-compare.js +804 -0
- package/lib/pwa-manifest.js +1005 -0
- package/lib/referral-leaderboard.js +612 -0
- package/lib/sales-tax-filings.js +807 -0
- package/lib/search-ranking.js +859 -0
- package/lib/shipping-insurance.js +757 -0
- package/lib/shrinkage-report.js +1182 -0
- package/lib/sidebar-widgets.js +952 -0
- package/lib/smart-restocking.js +1048 -0
- package/lib/split-shipments.js +7 -1
- package/lib/stock-receipts.js +834 -0
- package/lib/subscription-analytics.js +1032 -0
- package/lib/suggestion-box.js +921 -0
- package/lib/tax-remittance.js +625 -0
- package/lib/vendor-invoices.js +1021 -0
- package/lib/winback-campaigns.js +1350 -0
- package/lib/wishlist-digest.js +1133 -0
- 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
|
+
};
|