@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.
@@ -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
+ };