@blamejs/blamejs-shop 0.0.57 → 0.0.59
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 +4 -0
- package/lib/affiliates.js +1025 -0
- package/lib/collections.js +916 -0
- package/lib/customer-segments.js +817 -0
- package/lib/gift-options.js +596 -0
- package/lib/index.js +16 -0
- package/lib/mailing-audiences.js +855 -0
- package/lib/order-timeline.js +1073 -0
- package/lib/promo-banners.js +726 -0
- package/lib/quantity-discounts.js +781 -0
- package/lib/recently-viewed.js +511 -0
- package/lib/return-labels.js +477 -0
- package/lib/sales-reports.js +843 -0
- package/lib/search-synonyms.js +792 -0
- package/lib/shipping-labels.js +603 -0
- package/lib/stock-alerts.js +563 -0
- package/lib/subscription-controls.js +723 -0
- package/lib/support-tickets.js +898 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1025 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.affiliates
|
|
4
|
+
* @title Affiliates primitive — partner program with attribution +
|
|
5
|
+
* commission events
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Each affiliate is registered with a payout rule
|
|
9
|
+
* (commission_kind + commission_value) and gets a unique URL-safe
|
|
10
|
+
* tracking code. The storefront route handler accepts `?ref=<code>`,
|
|
11
|
+
* calls `recordVisit({ code, visitor_session_id })`, and persists
|
|
12
|
+
* the attribution against a SHA3-512 hash of the visitor session id
|
|
13
|
+
* (raw session ids never reach storage). At checkout the order
|
|
14
|
+
* handler resolves the latest live attribution via
|
|
15
|
+
* `attributionForSession(visitor_session_id)`; if one resolves
|
|
16
|
+
* inside the affiliate's attribution_window_days, the post-checkout
|
|
17
|
+
* hook calls `recordCommissionEvent` to write the commission row.
|
|
18
|
+
*
|
|
19
|
+
* Commission math:
|
|
20
|
+
*
|
|
21
|
+
* percent_bps commission_minor = floor(order_total_minor * value / 10000)
|
|
22
|
+
* amount_per_order_minor commission_minor = value
|
|
23
|
+
* amount_per_signup_minor commission_minor = value
|
|
24
|
+
*
|
|
25
|
+
* Operators payout via a separate finance process that walks
|
|
26
|
+
* `payoutsDue({ as_of, min_payout_minor })`, issues the payment,
|
|
27
|
+
* then calls `markCommissionPaid` per commission_event_id with the
|
|
28
|
+
* payment-network reference. Refunds / chargebacks call
|
|
29
|
+
* `markCommissionVoided` which preserves the row for audit but
|
|
30
|
+
* removes it from the payouts-due sum.
|
|
31
|
+
*
|
|
32
|
+
* Composes:
|
|
33
|
+
* - `b.guardUuid` — UUID-shape validation for ids
|
|
34
|
+
* - `b.guardEmail` — strict-profile validate + sanitize
|
|
35
|
+
* - `b.crypto.generateBytes` — uniform draw for code generation
|
|
36
|
+
* - `b.crypto.namespaceHash` — email + session-id hashing (SHA3-512)
|
|
37
|
+
* - `b.uuid.v7` — row ids
|
|
38
|
+
* - `b.pagination` — HMAC-tagged tuple cursors for
|
|
39
|
+
* commissionsForAffiliate
|
|
40
|
+
*
|
|
41
|
+
* Surface:
|
|
42
|
+
* registerAffiliate({ name, email, payout_method, payout_address,
|
|
43
|
+
* commission_kind, commission_value,
|
|
44
|
+
* attribution_window_days })
|
|
45
|
+
* getAffiliate(affiliate_id) / affiliateByCode(code)
|
|
46
|
+
* listAffiliates({ active_only? })
|
|
47
|
+
* updateAffiliate(affiliate_id, patch) /
|
|
48
|
+
* pauseAffiliate(affiliate_id, { reason? }) /
|
|
49
|
+
* reinstateAffiliate(affiliate_id)
|
|
50
|
+
* recordVisit({ code, visitor_session_id, referrer?, occurred_at? })
|
|
51
|
+
* attributionForSession(visitor_session_id, { now? })
|
|
52
|
+
* recordCommissionEvent({ order_id, affiliate_id,
|
|
53
|
+
* order_total_minor, currency, occurred_at? })
|
|
54
|
+
* commissionsForAffiliate({ affiliate_id, from?, to?,
|
|
55
|
+
* status_filter?, cursor?, limit? })
|
|
56
|
+
* markCommissionPaid({ commission_event_id, paid_at,
|
|
57
|
+
* payout_reference })
|
|
58
|
+
* markCommissionVoided({ commission_event_id, reason })
|
|
59
|
+
* payoutsDue({ as_of, min_payout_minor })
|
|
60
|
+
* topAffiliates({ from, to, limit? })
|
|
61
|
+
*
|
|
62
|
+
* Storage:
|
|
63
|
+
* - `affiliates` + `affiliate_visits` + `affiliate_commissions`
|
|
64
|
+
* (migration `0057_affiliates.sql`).
|
|
65
|
+
*
|
|
66
|
+
* @primitive affiliates
|
|
67
|
+
* @related b.guardUuid, b.guardEmail, b.crypto, b.pagination, b.uuid
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
var MAX_NAME_LEN = 200;
|
|
71
|
+
var MAX_PAYOUT_ADDRESS_LEN = 512;
|
|
72
|
+
var MAX_REFERRER_LEN = 2048;
|
|
73
|
+
var MAX_REASON_LEN = 280;
|
|
74
|
+
var MAX_PAYOUT_REF_LEN = 256;
|
|
75
|
+
var MAX_LIST_LIMIT = 100;
|
|
76
|
+
var DEFAULT_LIST_LIMIT = 25;
|
|
77
|
+
var MAX_TOP_LIMIT = 100;
|
|
78
|
+
var DEFAULT_TOP_LIMIT = 10;
|
|
79
|
+
var MAX_WINDOW_DAYS = 365;
|
|
80
|
+
|
|
81
|
+
var EMAIL_NAMESPACE = "affiliate-email";
|
|
82
|
+
var SESSION_NAMESPACE = "affiliate-session";
|
|
83
|
+
|
|
84
|
+
var PAYOUT_METHODS = [
|
|
85
|
+
"paypal", "bank_transfer", "stripe_connect", "gift_card", "store_credit",
|
|
86
|
+
];
|
|
87
|
+
var COMMISSION_KINDS = [
|
|
88
|
+
"percent_bps", "amount_per_order_minor", "amount_per_signup_minor",
|
|
89
|
+
];
|
|
90
|
+
var COMMISSION_STATUSES = ["pending", "paid", "voided"];
|
|
91
|
+
|
|
92
|
+
var BPS_DENOMINATOR = 10000;
|
|
93
|
+
var MAX_BPS = 10000;
|
|
94
|
+
var MAX_AMOUNT_MINOR = 100000000000; // 1e11 — sanity cap on per-row
|
|
95
|
+
// payouts; an operator wiring a
|
|
96
|
+
// commission_value bigger than
|
|
97
|
+
// this is mis-configured.
|
|
98
|
+
|
|
99
|
+
// commissionsForAffiliate ordering — (occurred_at DESC, id DESC). The
|
|
100
|
+
// tuple is HMAC-tagged via b.pagination so a tampered cursor refuses
|
|
101
|
+
// to decode.
|
|
102
|
+
var LIST_ORDER_KEY = ["occurred_at:desc", "id:desc"];
|
|
103
|
+
|
|
104
|
+
// Public handle alphabet — confusion-resistant (no 0/O/I/1), 32
|
|
105
|
+
// glyphs so a single random byte maps modulo-32 to a uniform draw.
|
|
106
|
+
// 8 characters of 32-glyph alphabet = 32^8 ≈ 2^40 codes; collision
|
|
107
|
+
// probability is vanishingly low and the UNIQUE constraint is the
|
|
108
|
+
// safety net. Mirrors the referrals primitive's posture so operator
|
|
109
|
+
// vocabularies stay consistent.
|
|
110
|
+
var CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
111
|
+
var CODE_LEN = 8;
|
|
112
|
+
var CODE_RE = /^[A-HJ-NP-Z2-9]{8}$/;
|
|
113
|
+
|
|
114
|
+
// Control bytes + zero-width / direction-override family. The name +
|
|
115
|
+
// reason + payout_address render in operator dashboards; embedded
|
|
116
|
+
// control / direction-override bytes are a slipping-class for header
|
|
117
|
+
// injection + visual-spoofing attacks downstream. Spelled with
|
|
118
|
+
// \u-escapes so ESLint's no-irregular-whitespace stays happy.
|
|
119
|
+
var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
|
|
120
|
+
var CONTROL_BYTE_LOOSE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
121
|
+
var ZERO_WIDTH_RE = new RegExp(
|
|
122
|
+
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
var ALLOWED_UPDATE_COLUMNS = Object.freeze([
|
|
126
|
+
"name", "payout_method", "payout_address", "commission_kind",
|
|
127
|
+
"commission_value", "attribution_window_days",
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
// Lazy framework handle — matches the pattern used by every other
|
|
131
|
+
// shop primitive; avoids the require cycle that would arise from
|
|
132
|
+
// importing `./index` at module-eval time.
|
|
133
|
+
var bShop;
|
|
134
|
+
function _b() {
|
|
135
|
+
if (!bShop) bShop = require("./index");
|
|
136
|
+
return bShop.framework;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---- validators ---------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
function _uuid(s, label) {
|
|
142
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
143
|
+
catch (e) { throw new TypeError("affiliates: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _name(s) {
|
|
147
|
+
if (typeof s !== "string") {
|
|
148
|
+
throw new TypeError("affiliates: name must be a string");
|
|
149
|
+
}
|
|
150
|
+
var trimmed = s.trim();
|
|
151
|
+
if (!trimmed.length) {
|
|
152
|
+
throw new TypeError("affiliates: name must be non-empty after trim");
|
|
153
|
+
}
|
|
154
|
+
if (s.length > MAX_NAME_LEN) {
|
|
155
|
+
throw new TypeError("affiliates: name must be <= " + MAX_NAME_LEN + " characters");
|
|
156
|
+
}
|
|
157
|
+
if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
158
|
+
throw new TypeError("affiliates: name contains control / zero-width bytes");
|
|
159
|
+
}
|
|
160
|
+
return s;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function _payoutMethod(s) {
|
|
164
|
+
if (typeof s !== "string" || PAYOUT_METHODS.indexOf(s) === -1) {
|
|
165
|
+
throw new TypeError("affiliates: payout_method must be one of " + PAYOUT_METHODS.join(", "));
|
|
166
|
+
}
|
|
167
|
+
return s;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function _payoutAddress(s) {
|
|
171
|
+
if (typeof s !== "string") {
|
|
172
|
+
throw new TypeError("affiliates: payout_address must be a string");
|
|
173
|
+
}
|
|
174
|
+
var trimmed = s.trim();
|
|
175
|
+
if (!trimmed.length) {
|
|
176
|
+
throw new TypeError("affiliates: payout_address must be non-empty after trim");
|
|
177
|
+
}
|
|
178
|
+
if (s.length > MAX_PAYOUT_ADDRESS_LEN) {
|
|
179
|
+
throw new TypeError("affiliates: payout_address must be <= " + MAX_PAYOUT_ADDRESS_LEN + " characters");
|
|
180
|
+
}
|
|
181
|
+
if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
182
|
+
throw new TypeError("affiliates: payout_address contains control / zero-width bytes");
|
|
183
|
+
}
|
|
184
|
+
return s;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function _commissionKind(s) {
|
|
188
|
+
if (typeof s !== "string" || COMMISSION_KINDS.indexOf(s) === -1) {
|
|
189
|
+
throw new TypeError("affiliates: commission_kind must be one of " + COMMISSION_KINDS.join(", "));
|
|
190
|
+
}
|
|
191
|
+
return s;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function _commissionValue(n, kind) {
|
|
195
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
196
|
+
throw new TypeError("affiliates: commission_value must be a non-negative integer");
|
|
197
|
+
}
|
|
198
|
+
if (kind === "percent_bps" && n > MAX_BPS) {
|
|
199
|
+
throw new TypeError("affiliates: commission_value (percent_bps) must be <= " + MAX_BPS + " (100%)");
|
|
200
|
+
}
|
|
201
|
+
if (kind !== "percent_bps" && n > MAX_AMOUNT_MINOR) {
|
|
202
|
+
throw new TypeError("affiliates: commission_value must be <= " + MAX_AMOUNT_MINOR);
|
|
203
|
+
}
|
|
204
|
+
return n;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function _attributionWindow(n) {
|
|
208
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_WINDOW_DAYS) {
|
|
209
|
+
throw new TypeError("affiliates: attribution_window_days must be an integer 1.." + MAX_WINDOW_DAYS);
|
|
210
|
+
}
|
|
211
|
+
return n;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function _status(s, label) {
|
|
215
|
+
if (typeof s !== "string" || COMMISSION_STATUSES.indexOf(s) === -1) {
|
|
216
|
+
throw new TypeError("affiliates: " + label + " must be one of " + COMMISSION_STATUSES.join(", "));
|
|
217
|
+
}
|
|
218
|
+
return s;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function _orderTotal(n) {
|
|
222
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
223
|
+
throw new TypeError("affiliates: order_total_minor must be a non-negative integer");
|
|
224
|
+
}
|
|
225
|
+
if (n > MAX_AMOUNT_MINOR) {
|
|
226
|
+
throw new TypeError("affiliates: order_total_minor must be <= " + MAX_AMOUNT_MINOR);
|
|
227
|
+
}
|
|
228
|
+
return n;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function _currency(s) {
|
|
232
|
+
if (typeof s !== "string" || !/^[A-Z]{3}$/.test(s)) {
|
|
233
|
+
throw new TypeError("affiliates: currency must be a 3-letter uppercase ISO-4217 code");
|
|
234
|
+
}
|
|
235
|
+
return s;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function _referrer(s) {
|
|
239
|
+
if (s == null) return null;
|
|
240
|
+
if (typeof s !== "string") {
|
|
241
|
+
throw new TypeError("affiliates: referrer must be a string or null");
|
|
242
|
+
}
|
|
243
|
+
if (!s.length) return null;
|
|
244
|
+
if (s.length > MAX_REFERRER_LEN) {
|
|
245
|
+
throw new TypeError("affiliates: referrer must be <= " + MAX_REFERRER_LEN + " characters");
|
|
246
|
+
}
|
|
247
|
+
if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
248
|
+
throw new TypeError("affiliates: referrer contains control / zero-width bytes");
|
|
249
|
+
}
|
|
250
|
+
return s;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function _reason(r) {
|
|
254
|
+
if (r == null) return null;
|
|
255
|
+
if (typeof r !== "string") {
|
|
256
|
+
throw new TypeError("affiliates: reason must be a string or null");
|
|
257
|
+
}
|
|
258
|
+
if (!r.length) return null;
|
|
259
|
+
if (r.length > MAX_REASON_LEN) {
|
|
260
|
+
throw new TypeError("affiliates: reason must be <= " + MAX_REASON_LEN + " characters");
|
|
261
|
+
}
|
|
262
|
+
if (CONTROL_BYTE_LOOSE_RE.test(r) || ZERO_WIDTH_RE.test(r)) {
|
|
263
|
+
throw new TypeError("affiliates: reason contains control / zero-width bytes");
|
|
264
|
+
}
|
|
265
|
+
return r;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function _payoutReference(s) {
|
|
269
|
+
if (typeof s !== "string") {
|
|
270
|
+
throw new TypeError("affiliates: payout_reference must be a string");
|
|
271
|
+
}
|
|
272
|
+
var trimmed = s.trim();
|
|
273
|
+
if (!trimmed.length) {
|
|
274
|
+
throw new TypeError("affiliates: payout_reference must be non-empty after trim");
|
|
275
|
+
}
|
|
276
|
+
if (s.length > MAX_PAYOUT_REF_LEN) {
|
|
277
|
+
throw new TypeError("affiliates: payout_reference must be <= " + MAX_PAYOUT_REF_LEN + " characters");
|
|
278
|
+
}
|
|
279
|
+
if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
280
|
+
throw new TypeError("affiliates: payout_reference contains control / zero-width bytes");
|
|
281
|
+
}
|
|
282
|
+
return s;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function _limit(n, max, def) {
|
|
286
|
+
if (n == null) return def;
|
|
287
|
+
if (!Number.isInteger(n) || n <= 0 || n > max) {
|
|
288
|
+
throw new TypeError("affiliates: limit must be an integer 1..." + max);
|
|
289
|
+
}
|
|
290
|
+
return n;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function _timestampRange(from, to, label) {
|
|
294
|
+
if (!Number.isInteger(from) || from < 0) {
|
|
295
|
+
throw new TypeError("affiliates." + label + ": from must be a non-negative integer (ms epoch)");
|
|
296
|
+
}
|
|
297
|
+
if (!Number.isInteger(to) || to < 0) {
|
|
298
|
+
throw new TypeError("affiliates." + label + ": to must be a non-negative integer (ms epoch)");
|
|
299
|
+
}
|
|
300
|
+
if (from > to) {
|
|
301
|
+
throw new TypeError("affiliates." + label + ": from must be <= to");
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function _sessionId(s) {
|
|
306
|
+
if (typeof s !== "string" || !s.length) {
|
|
307
|
+
throw new TypeError("affiliates: visitor_session_id must be a non-empty string");
|
|
308
|
+
}
|
|
309
|
+
if (s.length > 512) {
|
|
310
|
+
throw new TypeError("affiliates: visitor_session_id must be <= 512 characters");
|
|
311
|
+
}
|
|
312
|
+
if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
313
|
+
throw new TypeError("affiliates: visitor_session_id contains control / zero-width bytes");
|
|
314
|
+
}
|
|
315
|
+
return s;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function _normalizeEmail(input) {
|
|
319
|
+
if (typeof input !== "string" || !input.length) {
|
|
320
|
+
throw new TypeError("affiliates: email must be a non-empty string");
|
|
321
|
+
}
|
|
322
|
+
var guardEmail = _b().guardEmail;
|
|
323
|
+
var report;
|
|
324
|
+
try {
|
|
325
|
+
report = guardEmail.validate(input, { profile: "strict" });
|
|
326
|
+
} catch (e) {
|
|
327
|
+
throw new TypeError("affiliates: email — " + (e && e.message || "invalid email"));
|
|
328
|
+
}
|
|
329
|
+
if (!report || report.ok === false) {
|
|
330
|
+
var first = (report && report.issues && report.issues[0]) || {};
|
|
331
|
+
throw new TypeError("affiliates: email — " + (first.snippet || first.ruleId || "refused at strict profile"));
|
|
332
|
+
}
|
|
333
|
+
var canonical;
|
|
334
|
+
try {
|
|
335
|
+
canonical = guardEmail.sanitize(input, { profile: "strict" });
|
|
336
|
+
} catch (e2) {
|
|
337
|
+
throw new TypeError("affiliates: email — " + (e2 && e2.message || "refused"));
|
|
338
|
+
}
|
|
339
|
+
return canonical.trim().toLowerCase();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function _now() { return Date.now(); }
|
|
343
|
+
|
|
344
|
+
// ---- code generation + canonicalization ---------------------------------
|
|
345
|
+
|
|
346
|
+
function _generateCode() {
|
|
347
|
+
var buf = _b().crypto.generateBytes(CODE_LEN);
|
|
348
|
+
var out = "";
|
|
349
|
+
for (var j = 0; j < CODE_LEN; j += 1) {
|
|
350
|
+
out += CODE_ALPHABET.charAt(buf[j] & 31);
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function _canonicalCode(input) {
|
|
356
|
+
if (typeof input !== "string" || !input.length) {
|
|
357
|
+
throw new TypeError("affiliates: code must be a non-empty string");
|
|
358
|
+
}
|
|
359
|
+
// Forgiving for hand-typed codes pasted from email — strip ASCII
|
|
360
|
+
// whitespace + hyphens, fold to uppercase before the regex check.
|
|
361
|
+
var stripped = input.replace(/[-\s]+/g, "").toUpperCase();
|
|
362
|
+
if (stripped.length !== CODE_LEN) {
|
|
363
|
+
throw new TypeError("affiliates: code must be " + CODE_LEN + " alphabet characters");
|
|
364
|
+
}
|
|
365
|
+
if (!CODE_RE.test(stripped)) {
|
|
366
|
+
throw new TypeError("affiliates: code contains characters outside the affiliate alphabet");
|
|
367
|
+
}
|
|
368
|
+
return stripped;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ---- commission math ----------------------------------------------------
|
|
372
|
+
|
|
373
|
+
function _computeCommissionMinor(kind, value, orderTotalMinor) {
|
|
374
|
+
if (kind === "percent_bps") {
|
|
375
|
+
// Floor division on integers keeps the rounding consistent across
|
|
376
|
+
// platforms; the operator absorbs the sub-cent dust (alternative
|
|
377
|
+
// is rounding up, which lets a clever affiliate over-claim by
|
|
378
|
+
// splitting orders).
|
|
379
|
+
return Math.floor((orderTotalMinor * value) / BPS_DENOMINATOR);
|
|
380
|
+
}
|
|
381
|
+
// amount_per_order_minor and amount_per_signup_minor are flat — the
|
|
382
|
+
// order_total_minor parameter is ignored beyond bounds-checking.
|
|
383
|
+
return value;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ---- factory ------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
function create(opts) {
|
|
389
|
+
opts = opts || {};
|
|
390
|
+
var query = opts.query;
|
|
391
|
+
if (!query) {
|
|
392
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Pagination cursors are HMAC-tagged via b.pagination so a caller
|
|
396
|
+
// can't hand-craft one to skip across affiliates or replay across
|
|
397
|
+
// deployments. The secret defaults to a dev-only placeholder so the
|
|
398
|
+
// primitive boots in tests; production deployments must supply a
|
|
399
|
+
// derived value (typically b.crypto.namespaceHash("affiliates-
|
|
400
|
+
// cursor", D1_BRIDGE_SECRET)).
|
|
401
|
+
if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
|
|
402
|
+
if (process.env.NODE_ENV === "production") {
|
|
403
|
+
throw new Error("affiliates.create: opts.cursorSecret is required in production");
|
|
404
|
+
}
|
|
405
|
+
opts.cursorSecret = "affiliates-cursor-secret-dev-only";
|
|
406
|
+
}
|
|
407
|
+
var cursorSecret = opts.cursorSecret;
|
|
408
|
+
|
|
409
|
+
function _decodeCursor(cursor, label) {
|
|
410
|
+
if (cursor == null) return null;
|
|
411
|
+
if (typeof cursor !== "string") {
|
|
412
|
+
throw new TypeError("affiliates." + label + ": cursor must be an opaque string or null");
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
var state = _b().pagination.decodeCursor(cursor, cursorSecret);
|
|
416
|
+
if (JSON.stringify(state.orderKey) !== JSON.stringify(LIST_ORDER_KEY)) {
|
|
417
|
+
throw new TypeError("affiliates." + label + ": cursor orderKey mismatch");
|
|
418
|
+
}
|
|
419
|
+
return state.vals;
|
|
420
|
+
} catch (e) {
|
|
421
|
+
if (e instanceof TypeError) throw e;
|
|
422
|
+
throw new TypeError("affiliates." + label + ": cursor — " + (e && e.message || "malformed"));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function _encodeNext(rows, limit) {
|
|
427
|
+
var last = rows[rows.length - 1];
|
|
428
|
+
if (!last || rows.length < limit) return null;
|
|
429
|
+
return _b().pagination.encodeCursor({
|
|
430
|
+
orderKey: LIST_ORDER_KEY,
|
|
431
|
+
vals: [last.occurred_at, last.id],
|
|
432
|
+
forward: true,
|
|
433
|
+
}, cursorSecret);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function _hashEmail(canonicalEmail) {
|
|
437
|
+
return _b().crypto.namespaceHash(EMAIL_NAMESPACE, canonicalEmail);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function _hashSession(sessionId) {
|
|
441
|
+
return _b().crypto.namespaceHash(SESSION_NAMESPACE, sessionId);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function _getAffiliateRaw(id) {
|
|
445
|
+
var r = await query("SELECT * FROM affiliates WHERE id = ?1", [id]);
|
|
446
|
+
return r.rows[0] || null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function _getCommissionRaw(id) {
|
|
450
|
+
var r = await query("SELECT * FROM affiliate_commissions WHERE id = ?1", [id]);
|
|
451
|
+
return r.rows[0] || null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Inserting an affiliate row. Code generation retries on UNIQUE
|
|
455
|
+
// violation up to a small bound; on the 32^8 space the chance of
|
|
456
|
+
// running out is vanishingly low, but the retry keeps the boot
|
|
457
|
+
// surface deterministic instead of leaking the SQL-level error.
|
|
458
|
+
async function _insertAffiliate(row) {
|
|
459
|
+
var attempts = 0;
|
|
460
|
+
var lastErr;
|
|
461
|
+
while (attempts < 5) {
|
|
462
|
+
attempts += 1;
|
|
463
|
+
row.code = _generateCode();
|
|
464
|
+
try {
|
|
465
|
+
await query(
|
|
466
|
+
"INSERT INTO affiliates " +
|
|
467
|
+
"(id, code, name, email_hash, email_normalised, payout_method, " +
|
|
468
|
+
" payout_address, commission_kind, commission_value, " +
|
|
469
|
+
" attribution_window_days, active, paused_at, paused_reason, " +
|
|
470
|
+
" created_at, updated_at) " +
|
|
471
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 1, NULL, NULL, ?11, ?11)",
|
|
472
|
+
[
|
|
473
|
+
row.id, row.code, row.name, row.email_hash, row.email_normalised,
|
|
474
|
+
row.payout_method, row.payout_address, row.commission_kind,
|
|
475
|
+
row.commission_value, row.attribution_window_days, row.created_at,
|
|
476
|
+
],
|
|
477
|
+
);
|
|
478
|
+
lastErr = null;
|
|
479
|
+
break;
|
|
480
|
+
} catch (e) {
|
|
481
|
+
lastErr = e;
|
|
482
|
+
if (!e || !e.message || e.message.indexOf("UNIQUE") === -1) throw e;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (lastErr) throw lastErr;
|
|
486
|
+
return row.code;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
EMAIL_NAMESPACE: EMAIL_NAMESPACE,
|
|
491
|
+
SESSION_NAMESPACE: SESSION_NAMESPACE,
|
|
492
|
+
CODE_ALPHABET: CODE_ALPHABET,
|
|
493
|
+
CODE_LEN: CODE_LEN,
|
|
494
|
+
PAYOUT_METHODS: PAYOUT_METHODS.slice(),
|
|
495
|
+
COMMISSION_KINDS: COMMISSION_KINDS.slice(),
|
|
496
|
+
COMMISSION_STATUSES: COMMISSION_STATUSES.slice(),
|
|
497
|
+
MAX_NAME_LEN: MAX_NAME_LEN,
|
|
498
|
+
MAX_PAYOUT_ADDRESS_LEN: MAX_PAYOUT_ADDRESS_LEN,
|
|
499
|
+
MAX_REFERRER_LEN: MAX_REFERRER_LEN,
|
|
500
|
+
MAX_REASON_LEN: MAX_REASON_LEN,
|
|
501
|
+
MAX_PAYOUT_REF_LEN: MAX_PAYOUT_REF_LEN,
|
|
502
|
+
MAX_WINDOW_DAYS: MAX_WINDOW_DAYS,
|
|
503
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
504
|
+
DEFAULT_LIST_LIMIT: DEFAULT_LIST_LIMIT,
|
|
505
|
+
BPS_DENOMINATOR: BPS_DENOMINATOR,
|
|
506
|
+
MAX_BPS: MAX_BPS,
|
|
507
|
+
|
|
508
|
+
registerAffiliate: async function (input) {
|
|
509
|
+
if (!input || typeof input !== "object") {
|
|
510
|
+
throw new TypeError("affiliates.registerAffiliate: input object required");
|
|
511
|
+
}
|
|
512
|
+
var name = _name(input.name);
|
|
513
|
+
var emailNorm = _normalizeEmail(input.email);
|
|
514
|
+
var emailHash = _hashEmail(emailNorm);
|
|
515
|
+
var payoutMethod = _payoutMethod(input.payout_method);
|
|
516
|
+
var payoutAddress = _payoutAddress(input.payout_address);
|
|
517
|
+
var commissionKind = _commissionKind(input.commission_kind);
|
|
518
|
+
var commissionValue = _commissionValue(input.commission_value, commissionKind);
|
|
519
|
+
var attributionDays = _attributionWindow(input.attribution_window_days);
|
|
520
|
+
|
|
521
|
+
var id = _b().uuid.v7();
|
|
522
|
+
var ts = _now();
|
|
523
|
+
var row = {
|
|
524
|
+
id: id,
|
|
525
|
+
name: name,
|
|
526
|
+
email_hash: emailHash,
|
|
527
|
+
email_normalised: emailNorm,
|
|
528
|
+
payout_method: payoutMethod,
|
|
529
|
+
payout_address: payoutAddress,
|
|
530
|
+
commission_kind: commissionKind,
|
|
531
|
+
commission_value: commissionValue,
|
|
532
|
+
attribution_window_days: attributionDays,
|
|
533
|
+
created_at: ts,
|
|
534
|
+
};
|
|
535
|
+
await _insertAffiliate(row);
|
|
536
|
+
return await _getAffiliateRaw(id);
|
|
537
|
+
},
|
|
538
|
+
|
|
539
|
+
getAffiliate: async function (id) {
|
|
540
|
+
id = _uuid(id, "affiliate_id");
|
|
541
|
+
return await _getAffiliateRaw(id);
|
|
542
|
+
},
|
|
543
|
+
|
|
544
|
+
affiliateByCode: async function (code) {
|
|
545
|
+
var canonical = _canonicalCode(code);
|
|
546
|
+
var r = await query("SELECT * FROM affiliates WHERE code = ?1", [canonical]);
|
|
547
|
+
return r.rows[0] || null;
|
|
548
|
+
},
|
|
549
|
+
|
|
550
|
+
listAffiliates: async function (listOpts) {
|
|
551
|
+
listOpts = listOpts || {};
|
|
552
|
+
var sql, params;
|
|
553
|
+
if (listOpts.active_only) {
|
|
554
|
+
sql = "SELECT * FROM affiliates WHERE active = 1 ORDER BY created_at DESC, id DESC";
|
|
555
|
+
params = [];
|
|
556
|
+
} else {
|
|
557
|
+
sql = "SELECT * FROM affiliates ORDER BY created_at DESC, id DESC";
|
|
558
|
+
params = [];
|
|
559
|
+
}
|
|
560
|
+
var r = await query(sql, params);
|
|
561
|
+
return r.rows;
|
|
562
|
+
},
|
|
563
|
+
|
|
564
|
+
// Patch-style update — only ALLOWED_UPDATE_COLUMNS can be set.
|
|
565
|
+
// Email + code are immutable post-registration (changing the code
|
|
566
|
+
// would orphan attribution rows; changing the email would orphan
|
|
567
|
+
// the email-hash audit trail).
|
|
568
|
+
updateAffiliate: async function (affiliateId, patch) {
|
|
569
|
+
var id = _uuid(affiliateId, "affiliate_id");
|
|
570
|
+
if (!patch || typeof patch !== "object") {
|
|
571
|
+
throw new TypeError("affiliates.updateAffiliate: patch object required");
|
|
572
|
+
}
|
|
573
|
+
var keys = Object.keys(patch);
|
|
574
|
+
if (!keys.length) {
|
|
575
|
+
throw new TypeError("affiliates.updateAffiliate: patch must contain at least one column");
|
|
576
|
+
}
|
|
577
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
578
|
+
if (ALLOWED_UPDATE_COLUMNS.indexOf(keys[i]) === -1) {
|
|
579
|
+
throw new TypeError("affiliates.updateAffiliate: column '" + keys[i] + "' not updatable");
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
var current = await _getAffiliateRaw(id);
|
|
584
|
+
if (!current) return null;
|
|
585
|
+
|
|
586
|
+
// Validate each patched value through its dedicated guard. The
|
|
587
|
+
// commission_value validation depends on the (possibly patched)
|
|
588
|
+
// commission_kind — resolve the effective kind first.
|
|
589
|
+
var effectiveKind = patch.commission_kind != null
|
|
590
|
+
? _commissionKind(patch.commission_kind)
|
|
591
|
+
: current.commission_kind;
|
|
592
|
+
|
|
593
|
+
var sets = [];
|
|
594
|
+
var params = [];
|
|
595
|
+
var idx = 1;
|
|
596
|
+
function _set(col, val) {
|
|
597
|
+
sets.push(col + " = ?" + idx);
|
|
598
|
+
params.push(val);
|
|
599
|
+
idx += 1;
|
|
600
|
+
}
|
|
601
|
+
if (patch.name != null) _set("name", _name(patch.name));
|
|
602
|
+
if (patch.payout_method != null) _set("payout_method", _payoutMethod(patch.payout_method));
|
|
603
|
+
if (patch.payout_address != null) _set("payout_address", _payoutAddress(patch.payout_address));
|
|
604
|
+
if (patch.commission_kind != null) _set("commission_kind", effectiveKind);
|
|
605
|
+
if (patch.commission_value != null) _set("commission_value", _commissionValue(patch.commission_value, effectiveKind));
|
|
606
|
+
if (patch.attribution_window_days != null) _set("attribution_window_days", _attributionWindow(patch.attribution_window_days));
|
|
607
|
+
|
|
608
|
+
var ts = _now();
|
|
609
|
+
_set("updated_at", ts);
|
|
610
|
+
params.push(id);
|
|
611
|
+
var sql = "UPDATE affiliates SET " + sets.join(", ") + " WHERE id = ?" + idx;
|
|
612
|
+
await query(sql, params);
|
|
613
|
+
return await _getAffiliateRaw(id);
|
|
614
|
+
},
|
|
615
|
+
|
|
616
|
+
pauseAffiliate: async function (affiliateId, opts2) {
|
|
617
|
+
var id = _uuid(affiliateId, "affiliate_id");
|
|
618
|
+
var reason = (opts2 && opts2.reason != null) ? _reason(opts2.reason) : null;
|
|
619
|
+
var current = await _getAffiliateRaw(id);
|
|
620
|
+
if (!current) return null;
|
|
621
|
+
var ts = _now();
|
|
622
|
+
await query(
|
|
623
|
+
"UPDATE affiliates SET active = 0, paused_at = ?1, paused_reason = ?2, updated_at = ?1 WHERE id = ?3",
|
|
624
|
+
[ts, reason, id],
|
|
625
|
+
);
|
|
626
|
+
return await _getAffiliateRaw(id);
|
|
627
|
+
},
|
|
628
|
+
|
|
629
|
+
reinstateAffiliate: async function (affiliateId) {
|
|
630
|
+
var id = _uuid(affiliateId, "affiliate_id");
|
|
631
|
+
var current = await _getAffiliateRaw(id);
|
|
632
|
+
if (!current) return null;
|
|
633
|
+
var ts = _now();
|
|
634
|
+
await query(
|
|
635
|
+
"UPDATE affiliates SET active = 1, paused_at = NULL, paused_reason = NULL, updated_at = ?1 WHERE id = ?2",
|
|
636
|
+
[ts, id],
|
|
637
|
+
);
|
|
638
|
+
return await _getAffiliateRaw(id);
|
|
639
|
+
},
|
|
640
|
+
|
|
641
|
+
// Storefront-side attribution write. The session id is hashed at
|
|
642
|
+
// the door; raw value never lands on disk. Refused if the code
|
|
643
|
+
// doesn't resolve to an active affiliate (paused affiliates don't
|
|
644
|
+
// accrue new attribution — historical commissions stay; new
|
|
645
|
+
// visits short-circuit). Idempotent against (session, code) for
|
|
646
|
+
// the same calendar minute so a refresh-spam doesn't blow up the
|
|
647
|
+
// visits table; older identical visits become new rows so the
|
|
648
|
+
// affiliate's funnel-stats reflect repeat traffic.
|
|
649
|
+
recordVisit: async function (input) {
|
|
650
|
+
if (!input || typeof input !== "object") {
|
|
651
|
+
throw new TypeError("affiliates.recordVisit: input object required");
|
|
652
|
+
}
|
|
653
|
+
var canonical = _canonicalCode(input.code);
|
|
654
|
+
var sessionId = _sessionId(input.visitor_session_id);
|
|
655
|
+
var sessionHash = _hashSession(sessionId);
|
|
656
|
+
var referrer = _referrer(input.referrer);
|
|
657
|
+
var occurredAt;
|
|
658
|
+
if (input.occurred_at != null) {
|
|
659
|
+
if (!Number.isInteger(input.occurred_at) || input.occurred_at < 0) {
|
|
660
|
+
throw new TypeError("affiliates.recordVisit: occurred_at must be a non-negative integer (ms epoch)");
|
|
661
|
+
}
|
|
662
|
+
occurredAt = input.occurred_at;
|
|
663
|
+
} else {
|
|
664
|
+
occurredAt = _now();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
var affRow = await query("SELECT id, active FROM affiliates WHERE code = ?1", [canonical]);
|
|
668
|
+
var aff = affRow.rows[0];
|
|
669
|
+
if (!aff) {
|
|
670
|
+
var miss = new Error("affiliates.recordVisit: code not recognized");
|
|
671
|
+
miss.code = "AFFILIATE_CODE_NOT_FOUND";
|
|
672
|
+
throw miss;
|
|
673
|
+
}
|
|
674
|
+
if (Number(aff.active) !== 1) {
|
|
675
|
+
var paused = new Error("affiliates.recordVisit: affiliate is paused");
|
|
676
|
+
paused.code = "AFFILIATE_PAUSED";
|
|
677
|
+
throw paused;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Dedupe within one calendar minute (60000 ms). A refresh in
|
|
681
|
+
// the same minute collapses to a single visit; later traffic
|
|
682
|
+
// gets its own row so funnel-stats stay coherent.
|
|
683
|
+
var dedupeWindow = 60000;
|
|
684
|
+
var existing = await query(
|
|
685
|
+
"SELECT id FROM affiliate_visits " +
|
|
686
|
+
"WHERE visitor_session_id_hash = ?1 AND code = ?2 AND occurred_at >= ?3 " +
|
|
687
|
+
"ORDER BY occurred_at DESC LIMIT 1",
|
|
688
|
+
[sessionHash, canonical, occurredAt - dedupeWindow],
|
|
689
|
+
);
|
|
690
|
+
if (existing.rows.length) {
|
|
691
|
+
return {
|
|
692
|
+
id: existing.rows[0].id,
|
|
693
|
+
affiliate_id: aff.id,
|
|
694
|
+
code: canonical,
|
|
695
|
+
status: "dedup",
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
var visitId = _b().uuid.v7();
|
|
700
|
+
await query(
|
|
701
|
+
"INSERT INTO affiliate_visits " +
|
|
702
|
+
"(id, code, affiliate_id, visitor_session_id_hash, referrer, occurred_at) " +
|
|
703
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
704
|
+
[visitId, canonical, aff.id, sessionHash, referrer, occurredAt],
|
|
705
|
+
);
|
|
706
|
+
return {
|
|
707
|
+
id: visitId,
|
|
708
|
+
affiliate_id: aff.id,
|
|
709
|
+
code: canonical,
|
|
710
|
+
status: "new",
|
|
711
|
+
};
|
|
712
|
+
},
|
|
713
|
+
|
|
714
|
+
// Resolve the most recent live attribution for a visitor session.
|
|
715
|
+
// "Live" means the visit's occurred_at lands inside the
|
|
716
|
+
// affiliate's attribution_window_days budget — older visits are
|
|
717
|
+
// out of the cookie's lifetime and don't attribute. Returns null
|
|
718
|
+
// on miss.
|
|
719
|
+
attributionForSession: async function (visitorSessionId, optsArg) {
|
|
720
|
+
var sessionId = _sessionId(visitorSessionId);
|
|
721
|
+
var sessionHash = _hashSession(sessionId);
|
|
722
|
+
var now;
|
|
723
|
+
if (optsArg && optsArg.now != null) {
|
|
724
|
+
if (!Number.isInteger(optsArg.now) || optsArg.now < 0) {
|
|
725
|
+
throw new TypeError("affiliates.attributionForSession: opts.now must be a non-negative integer (ms epoch)");
|
|
726
|
+
}
|
|
727
|
+
now = optsArg.now;
|
|
728
|
+
} else {
|
|
729
|
+
now = _now();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Newest visit first; iterate joined rows because the
|
|
733
|
+
// attribution window lives on the affiliate, not the visit.
|
|
734
|
+
var r = await query(
|
|
735
|
+
"SELECT v.id AS visit_id, v.code, v.affiliate_id, v.occurred_at, " +
|
|
736
|
+
" a.attribution_window_days, a.active " +
|
|
737
|
+
"FROM affiliate_visits v JOIN affiliates a ON a.id = v.affiliate_id " +
|
|
738
|
+
"WHERE v.visitor_session_id_hash = ?1 " +
|
|
739
|
+
"ORDER BY v.occurred_at DESC",
|
|
740
|
+
[sessionHash],
|
|
741
|
+
);
|
|
742
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
743
|
+
var row = r.rows[i];
|
|
744
|
+
var windowMs = Number(row.attribution_window_days) * 24 * 3600 * 1000;
|
|
745
|
+
if (now - Number(row.occurred_at) <= windowMs) {
|
|
746
|
+
return {
|
|
747
|
+
visit_id: row.visit_id,
|
|
748
|
+
code: row.code,
|
|
749
|
+
affiliate_id: row.affiliate_id,
|
|
750
|
+
occurred_at: Number(row.occurred_at),
|
|
751
|
+
active: Number(row.active) === 1,
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return null;
|
|
756
|
+
},
|
|
757
|
+
|
|
758
|
+
// Write a commission row at order-completion time. The
|
|
759
|
+
// commission_minor is computed at write time from the affiliate's
|
|
760
|
+
// *current* commission_kind + commission_value; the row preserves
|
|
761
|
+
// those values implicitly via commission_minor so a later
|
|
762
|
+
// operator-side rate change doesn't retroactively alter historical
|
|
763
|
+
// payouts. Idempotent on (order_id, affiliate_id) — a second call
|
|
764
|
+
// for the same order returns the existing row's status.
|
|
765
|
+
recordCommissionEvent: async function (input) {
|
|
766
|
+
if (!input || typeof input !== "object") {
|
|
767
|
+
throw new TypeError("affiliates.recordCommissionEvent: input object required");
|
|
768
|
+
}
|
|
769
|
+
var orderId = _uuid(input.order_id, "order_id");
|
|
770
|
+
var affiliateId = _uuid(input.affiliate_id, "affiliate_id");
|
|
771
|
+
var orderTotal = _orderTotal(input.order_total_minor);
|
|
772
|
+
var currency = _currency(input.currency);
|
|
773
|
+
var occurredAt;
|
|
774
|
+
if (input.occurred_at != null) {
|
|
775
|
+
if (!Number.isInteger(input.occurred_at) || input.occurred_at < 0) {
|
|
776
|
+
throw new TypeError("affiliates.recordCommissionEvent: occurred_at must be a non-negative integer (ms epoch)");
|
|
777
|
+
}
|
|
778
|
+
occurredAt = input.occurred_at;
|
|
779
|
+
} else {
|
|
780
|
+
occurredAt = _now();
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
var aff = await _getAffiliateRaw(affiliateId);
|
|
784
|
+
if (!aff) {
|
|
785
|
+
var miss = new Error("affiliates.recordCommissionEvent: affiliate not found");
|
|
786
|
+
miss.code = "AFFILIATE_NOT_FOUND";
|
|
787
|
+
throw miss;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Idempotency check on (order_id, affiliate_id).
|
|
791
|
+
var dupe = await query(
|
|
792
|
+
"SELECT * FROM affiliate_commissions WHERE order_id = ?1 AND affiliate_id = ?2",
|
|
793
|
+
[orderId, affiliateId],
|
|
794
|
+
);
|
|
795
|
+
if (dupe.rows.length) {
|
|
796
|
+
return dupe.rows[0];
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
var commissionMinor = _computeCommissionMinor(
|
|
800
|
+
aff.commission_kind, Number(aff.commission_value), orderTotal
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
var id = _b().uuid.v7();
|
|
804
|
+
await query(
|
|
805
|
+
"INSERT INTO affiliate_commissions " +
|
|
806
|
+
"(id, order_id, affiliate_id, order_total_minor, commission_minor, " +
|
|
807
|
+
" currency, status, occurred_at, paid_at, voided_at, payout_reference, void_reason) " +
|
|
808
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7, NULL, NULL, NULL, NULL)",
|
|
809
|
+
[id, orderId, affiliateId, orderTotal, commissionMinor, currency, occurredAt],
|
|
810
|
+
);
|
|
811
|
+
return await _getCommissionRaw(id);
|
|
812
|
+
},
|
|
813
|
+
|
|
814
|
+
commissionsForAffiliate: async function (listOpts) {
|
|
815
|
+
if (!listOpts || typeof listOpts !== "object") {
|
|
816
|
+
throw new TypeError("affiliates.commissionsForAffiliate: input object required");
|
|
817
|
+
}
|
|
818
|
+
var affiliateId = _uuid(listOpts.affiliate_id, "affiliate_id");
|
|
819
|
+
var limit = _limit(listOpts.limit, MAX_LIST_LIMIT, DEFAULT_LIST_LIMIT);
|
|
820
|
+
var cursorVals = _decodeCursor(listOpts.cursor, "commissionsForAffiliate");
|
|
821
|
+
var statusFilter;
|
|
822
|
+
if (listOpts.status_filter != null) {
|
|
823
|
+
statusFilter = _status(listOpts.status_filter, "status_filter");
|
|
824
|
+
}
|
|
825
|
+
var from = null;
|
|
826
|
+
var to = null;
|
|
827
|
+
if (listOpts.from != null || listOpts.to != null) {
|
|
828
|
+
from = listOpts.from == null ? 0 : listOpts.from;
|
|
829
|
+
to = listOpts.to == null ? Number.MAX_SAFE_INTEGER : listOpts.to;
|
|
830
|
+
_timestampRange(from, to, "commissionsForAffiliate");
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
var where = ["affiliate_id = ?1"];
|
|
834
|
+
var params = [affiliateId];
|
|
835
|
+
var idx = 2;
|
|
836
|
+
if (from !== null) {
|
|
837
|
+
where.push("occurred_at >= ?" + idx);
|
|
838
|
+
params.push(from);
|
|
839
|
+
idx += 1;
|
|
840
|
+
where.push("occurred_at <= ?" + idx);
|
|
841
|
+
params.push(to);
|
|
842
|
+
idx += 1;
|
|
843
|
+
}
|
|
844
|
+
if (statusFilter !== undefined) {
|
|
845
|
+
where.push("status = ?" + idx);
|
|
846
|
+
params.push(statusFilter);
|
|
847
|
+
idx += 1;
|
|
848
|
+
}
|
|
849
|
+
if (cursorVals) {
|
|
850
|
+
var a = idx;
|
|
851
|
+
var b = idx + 1;
|
|
852
|
+
where.push(
|
|
853
|
+
"(occurred_at < ?" + a + " OR " +
|
|
854
|
+
"(occurred_at = ?" + a + " AND id < ?" + b + "))"
|
|
855
|
+
);
|
|
856
|
+
params.push(cursorVals[0], cursorVals[1]);
|
|
857
|
+
idx += 2;
|
|
858
|
+
}
|
|
859
|
+
params.push(limit);
|
|
860
|
+
var sql = "SELECT * FROM affiliate_commissions WHERE " + where.join(" AND ") +
|
|
861
|
+
" ORDER BY occurred_at DESC, id DESC LIMIT ?" + idx;
|
|
862
|
+
var r = await query(sql, params);
|
|
863
|
+
return { rows: r.rows, next_cursor: _encodeNext(r.rows, limit) };
|
|
864
|
+
},
|
|
865
|
+
|
|
866
|
+
// FSM transition: pending -> paid. Refuses if the row is already
|
|
867
|
+
// paid (idempotency hazard — a second payout reference would
|
|
868
|
+
// double-pay) or voided (terminal). `paid_at` + `payout_reference`
|
|
869
|
+
// are stamped together.
|
|
870
|
+
markCommissionPaid: async function (input) {
|
|
871
|
+
if (!input || typeof input !== "object") {
|
|
872
|
+
throw new TypeError("affiliates.markCommissionPaid: input object required");
|
|
873
|
+
}
|
|
874
|
+
var id = _uuid(input.commission_event_id, "commission_event_id");
|
|
875
|
+
if (!Number.isInteger(input.paid_at) || input.paid_at < 0) {
|
|
876
|
+
throw new TypeError("affiliates.markCommissionPaid: paid_at must be a non-negative integer (ms epoch)");
|
|
877
|
+
}
|
|
878
|
+
var paidAt = input.paid_at;
|
|
879
|
+
var reference = _payoutReference(input.payout_reference);
|
|
880
|
+
|
|
881
|
+
var current = await _getCommissionRaw(id);
|
|
882
|
+
if (!current) {
|
|
883
|
+
var miss = new Error("affiliates.markCommissionPaid: commission not found");
|
|
884
|
+
miss.code = "AFFILIATE_COMMISSION_NOT_FOUND";
|
|
885
|
+
throw miss;
|
|
886
|
+
}
|
|
887
|
+
if (current.status !== "pending") {
|
|
888
|
+
var refused = new Error(
|
|
889
|
+
"affiliates.markCommissionPaid: refused — commission is " + current.status
|
|
890
|
+
);
|
|
891
|
+
refused.code = "AFFILIATE_COMMISSION_TRANSITION_REFUSED";
|
|
892
|
+
throw refused;
|
|
893
|
+
}
|
|
894
|
+
await query(
|
|
895
|
+
"UPDATE affiliate_commissions SET status = 'paid', paid_at = ?1, payout_reference = ?2 WHERE id = ?3",
|
|
896
|
+
[paidAt, reference, id],
|
|
897
|
+
);
|
|
898
|
+
return await _getCommissionRaw(id);
|
|
899
|
+
},
|
|
900
|
+
|
|
901
|
+
// FSM transition: pending -> voided. Refunds / chargebacks /
|
|
902
|
+
// operator overrides land here. Refuses if already paid (operator
|
|
903
|
+
// recoups via a separate clawback row, not by mutating the paid
|
|
904
|
+
// commission) or already voided.
|
|
905
|
+
markCommissionVoided: async function (input) {
|
|
906
|
+
if (!input || typeof input !== "object") {
|
|
907
|
+
throw new TypeError("affiliates.markCommissionVoided: input object required");
|
|
908
|
+
}
|
|
909
|
+
var id = _uuid(input.commission_event_id, "commission_event_id");
|
|
910
|
+
if (input.reason == null) {
|
|
911
|
+
throw new TypeError("affiliates.markCommissionVoided: reason is required");
|
|
912
|
+
}
|
|
913
|
+
var reason = _reason(input.reason);
|
|
914
|
+
if (reason == null) {
|
|
915
|
+
// _reason returns null for empty string; voiding requires a
|
|
916
|
+
// real reason for the audit trail.
|
|
917
|
+
throw new TypeError("affiliates.markCommissionVoided: reason must be a non-empty string");
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
var current = await _getCommissionRaw(id);
|
|
921
|
+
if (!current) {
|
|
922
|
+
var miss = new Error("affiliates.markCommissionVoided: commission not found");
|
|
923
|
+
miss.code = "AFFILIATE_COMMISSION_NOT_FOUND";
|
|
924
|
+
throw miss;
|
|
925
|
+
}
|
|
926
|
+
if (current.status !== "pending") {
|
|
927
|
+
var refused = new Error(
|
|
928
|
+
"affiliates.markCommissionVoided: refused — commission is " + current.status
|
|
929
|
+
);
|
|
930
|
+
refused.code = "AFFILIATE_COMMISSION_TRANSITION_REFUSED";
|
|
931
|
+
throw refused;
|
|
932
|
+
}
|
|
933
|
+
var ts = _now();
|
|
934
|
+
await query(
|
|
935
|
+
"UPDATE affiliate_commissions SET status = 'voided', voided_at = ?1, void_reason = ?2 WHERE id = ?3",
|
|
936
|
+
[ts, reason, id],
|
|
937
|
+
);
|
|
938
|
+
return await _getCommissionRaw(id);
|
|
939
|
+
},
|
|
940
|
+
|
|
941
|
+
// Operator dashboard — which affiliates are owed at least
|
|
942
|
+
// `min_payout_minor` as of `as_of`. Returns one row per affiliate
|
|
943
|
+
// whose sum-of-pending commissions (occurred_at <= as_of) meets
|
|
944
|
+
// the threshold, with `pending_minor` total + commission count.
|
|
945
|
+
payoutsDue: async function (input) {
|
|
946
|
+
if (!input || typeof input !== "object") {
|
|
947
|
+
throw new TypeError("affiliates.payoutsDue: input object required");
|
|
948
|
+
}
|
|
949
|
+
if (!Number.isInteger(input.as_of) || input.as_of < 0) {
|
|
950
|
+
throw new TypeError("affiliates.payoutsDue: as_of must be a non-negative integer (ms epoch)");
|
|
951
|
+
}
|
|
952
|
+
if (!Number.isInteger(input.min_payout_minor) || input.min_payout_minor < 0) {
|
|
953
|
+
throw new TypeError("affiliates.payoutsDue: min_payout_minor must be a non-negative integer");
|
|
954
|
+
}
|
|
955
|
+
var r = await query(
|
|
956
|
+
"SELECT affiliate_id, SUM(commission_minor) AS pending_minor, COUNT(*) AS commission_count " +
|
|
957
|
+
"FROM affiliate_commissions " +
|
|
958
|
+
"WHERE status = 'pending' AND occurred_at <= ?1 " +
|
|
959
|
+
"GROUP BY affiliate_id " +
|
|
960
|
+
"HAVING SUM(commission_minor) >= ?2 " +
|
|
961
|
+
"ORDER BY pending_minor DESC, affiliate_id ASC",
|
|
962
|
+
[input.as_of, input.min_payout_minor],
|
|
963
|
+
);
|
|
964
|
+
return r.rows.map(function (row) {
|
|
965
|
+
return {
|
|
966
|
+
affiliate_id: row.affiliate_id,
|
|
967
|
+
pending_minor: Number(row.pending_minor || 0),
|
|
968
|
+
commission_count: Number(row.commission_count || 0),
|
|
969
|
+
};
|
|
970
|
+
});
|
|
971
|
+
},
|
|
972
|
+
|
|
973
|
+
// Top-N affiliates by total commission_minor across paid + pending
|
|
974
|
+
// rows in [from, to]. Voided rows are excluded — they didn't
|
|
975
|
+
// ultimately earn anything. Ranking ignores currency mixing on
|
|
976
|
+
// the assumption the operator's program is single-currency; a
|
|
977
|
+
// multi-currency program should call this once per currency and
|
|
978
|
+
// merge client-side.
|
|
979
|
+
topAffiliates: async function (input) {
|
|
980
|
+
if (!input || typeof input !== "object") {
|
|
981
|
+
throw new TypeError("affiliates.topAffiliates: input object required");
|
|
982
|
+
}
|
|
983
|
+
_timestampRange(input.from, input.to, "topAffiliates");
|
|
984
|
+
var limit = _limit(input.limit, MAX_TOP_LIMIT, DEFAULT_TOP_LIMIT);
|
|
985
|
+
var r = await query(
|
|
986
|
+
"SELECT affiliate_id, SUM(commission_minor) AS total_minor, COUNT(*) AS commission_count " +
|
|
987
|
+
"FROM affiliate_commissions " +
|
|
988
|
+
"WHERE status != 'voided' AND occurred_at >= ?1 AND occurred_at <= ?2 " +
|
|
989
|
+
"GROUP BY affiliate_id " +
|
|
990
|
+
"HAVING SUM(commission_minor) > 0 " +
|
|
991
|
+
"ORDER BY total_minor DESC, affiliate_id ASC " +
|
|
992
|
+
"LIMIT ?3",
|
|
993
|
+
[input.from, input.to, limit],
|
|
994
|
+
);
|
|
995
|
+
return r.rows.map(function (row) {
|
|
996
|
+
return {
|
|
997
|
+
affiliate_id: row.affiliate_id,
|
|
998
|
+
total_minor: Number(row.total_minor || 0),
|
|
999
|
+
commission_count: Number(row.commission_count || 0),
|
|
1000
|
+
};
|
|
1001
|
+
});
|
|
1002
|
+
},
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
module.exports = {
|
|
1007
|
+
create: create,
|
|
1008
|
+
EMAIL_NAMESPACE: EMAIL_NAMESPACE,
|
|
1009
|
+
SESSION_NAMESPACE: SESSION_NAMESPACE,
|
|
1010
|
+
CODE_ALPHABET: CODE_ALPHABET,
|
|
1011
|
+
CODE_LEN: CODE_LEN,
|
|
1012
|
+
PAYOUT_METHODS: PAYOUT_METHODS.slice(),
|
|
1013
|
+
COMMISSION_KINDS: COMMISSION_KINDS.slice(),
|
|
1014
|
+
COMMISSION_STATUSES: COMMISSION_STATUSES.slice(),
|
|
1015
|
+
MAX_NAME_LEN: MAX_NAME_LEN,
|
|
1016
|
+
MAX_PAYOUT_ADDRESS_LEN: MAX_PAYOUT_ADDRESS_LEN,
|
|
1017
|
+
MAX_REFERRER_LEN: MAX_REFERRER_LEN,
|
|
1018
|
+
MAX_REASON_LEN: MAX_REASON_LEN,
|
|
1019
|
+
MAX_PAYOUT_REF_LEN: MAX_PAYOUT_REF_LEN,
|
|
1020
|
+
MAX_WINDOW_DAYS: MAX_WINDOW_DAYS,
|
|
1021
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
1022
|
+
DEFAULT_LIST_LIMIT: DEFAULT_LIST_LIMIT,
|
|
1023
|
+
BPS_DENOMINATOR: BPS_DENOMINATOR,
|
|
1024
|
+
MAX_BPS: MAX_BPS,
|
|
1025
|
+
};
|