@blamejs/blamejs-shop 0.0.53 → 0.0.56
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 +6 -0
- package/lib/addresses.js +430 -0
- package/lib/analytics.js +400 -0
- package/lib/cart-abandonment.js +664 -0
- package/lib/currency-display.js +432 -0
- package/lib/email-suppressions.js +579 -0
- package/lib/email.js +264 -0
- package/lib/index.js +14 -0
- package/lib/inventory-receive.js +494 -0
- package/lib/loyalty.js +496 -0
- package/lib/newsletter.js +176 -12
- package/lib/notifications.js +474 -0
- package/lib/order-tracking.js +456 -0
- package/lib/payment.js +193 -13
- package/lib/referrals.js +649 -0
- package/lib/returns.js +627 -0
- package/lib/reviews.js +412 -0
- package/lib/search-suggestions.js +528 -0
- package/lib/tax-exempt.js +519 -0
- package/lib/tax.js +391 -3
- package/lib/vendor/MANIFEST.json +1 -1
- package/lib/webhooks.js +293 -16
- package/lib/wishlist.js +269 -0
- package/package.json +1 -1
package/lib/referrals.js
ADDED
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.referrals
|
|
4
|
+
* @title Referrals primitive — refer-a-friend with two-sided rewards
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* A referrer (an existing customer) issues an 8-character code via
|
|
8
|
+
* `issueCode`. The shop generates the code from a confusion-
|
|
9
|
+
* resistant alphabet (no 0/O/I/1 ambiguities) over
|
|
10
|
+
* `b.crypto.generateBytes` — 256 % 32 === 0 so each byte modulo-32
|
|
11
|
+
* lands on a uniform alphabet index with no rejection sampling
|
|
12
|
+
* needed. The plaintext code is the public handle; lookup is case-
|
|
13
|
+
* insensitive, storage is uppercase.
|
|
14
|
+
*
|
|
15
|
+
* One active code per referrer is enforced — rotating means
|
|
16
|
+
* disabling the existing row before a new one issues. Historical
|
|
17
|
+
* invitations still resolve to their original code so the audit
|
|
18
|
+
* trail stays intact across rotations.
|
|
19
|
+
*
|
|
20
|
+
* The referred-friend's email is NEVER persisted plaintext. Only
|
|
21
|
+
* `b.crypto.namespaceHash("referral-referee", normalized_email)`
|
|
22
|
+
* reaches storage. Dedupe is on (code_id, email_hash) so the same
|
|
23
|
+
* friend invited twice by the same referrer collapses to a single
|
|
24
|
+
* invitation row.
|
|
25
|
+
*
|
|
26
|
+
* Funnel transitions:
|
|
27
|
+
* issueCode -> referral_codes row, status="active"
|
|
28
|
+
* invite -> invitation row, reward_status="pending"
|
|
29
|
+
* trackVisit -> visited_at populated
|
|
30
|
+
* trackSignup -> signed_up_at + signed_up_customer_id populated
|
|
31
|
+
* trackPurchase -> first_purchase_at + first_order_id populated,
|
|
32
|
+
* reward_status -> "both-rewarded",
|
|
33
|
+
* referrals_count bumped on the code row
|
|
34
|
+
* rewardReferrer / rewardReferee -> records the operator's chosen
|
|
35
|
+
* reward instrument id (gift card / discount /
|
|
36
|
+
* credit) for ledger reconciliation
|
|
37
|
+
*
|
|
38
|
+
* The reward instrument itself (gift card, discount code, ledger
|
|
39
|
+
* credit) is a separate primitive — this surface only records the
|
|
40
|
+
* id the operator handed out so a future audit can join across
|
|
41
|
+
* tables.
|
|
42
|
+
*
|
|
43
|
+
* Composition:
|
|
44
|
+
* var refs = bShop.referrals.create({ query: q });
|
|
45
|
+
* var { code, link } = await refs.issueCode({
|
|
46
|
+
* referrer_customer_id: customerId,
|
|
47
|
+
* });
|
|
48
|
+
* // referrer shares `link` (e.g. https://blamejs.shop/?ref=<code>)
|
|
49
|
+
* await refs.invite({ code: code, referee_email: "alice@example.com" });
|
|
50
|
+
* await refs.trackVisit({ code: code });
|
|
51
|
+
* await refs.trackSignup({ code: code, customer_id: newCustomerId });
|
|
52
|
+
* await refs.trackPurchase({ customer_id: newCustomerId, order_id: orderId });
|
|
53
|
+
* await refs.rewardReferrer(invitationId, { reward_id: giftcardId });
|
|
54
|
+
* await refs.rewardReferee(invitationId, { reward_id: giftcardId });
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
var bShop;
|
|
58
|
+
function _b() {
|
|
59
|
+
if (!bShop) bShop = require("./index");
|
|
60
|
+
return bShop.framework;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
var REFEREE_NAMESPACE = "referral-referee";
|
|
64
|
+
|
|
65
|
+
// Alphabet excludes 0/O/I/1 so a code spoken aloud / shared as a URL
|
|
66
|
+
// fragment doesn't collapse into ambiguous characters. 32 glyphs
|
|
67
|
+
// means each byte modulo-32 lands on a uniform draw (256 % 32 === 0
|
|
68
|
+
// — no modulo-bias correction needed).
|
|
69
|
+
var DEFAULT_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
70
|
+
var CODE_LEN = 8;
|
|
71
|
+
|
|
72
|
+
var DEFAULT_LINK_BASE = "https://blamejs.shop/?ref=";
|
|
73
|
+
|
|
74
|
+
var CODE_STATUSES = ["active", "disabled"];
|
|
75
|
+
var REWARD_STATUSES = ["pending", "referrer-rewarded", "both-rewarded", "expired", "voided"];
|
|
76
|
+
|
|
77
|
+
// ---- validators ---------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
function _uuid(s, label) {
|
|
80
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
81
|
+
catch (e) { throw new TypeError("referrals: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function _normalizeEmail(input) {
|
|
85
|
+
if (typeof input !== "string" || !input.length) {
|
|
86
|
+
throw new TypeError("referrals: referee_email must be a non-empty string");
|
|
87
|
+
}
|
|
88
|
+
var guardEmail = _b().guardEmail;
|
|
89
|
+
var report;
|
|
90
|
+
try {
|
|
91
|
+
report = guardEmail.validate(input, { profile: "strict" });
|
|
92
|
+
} catch (e) {
|
|
93
|
+
throw new TypeError("referrals: referee_email — " + (e && e.message || "invalid email"));
|
|
94
|
+
}
|
|
95
|
+
if (!report || report.ok === false) {
|
|
96
|
+
var first = (report && report.issues && report.issues[0]) || {};
|
|
97
|
+
throw new TypeError("referrals: referee_email — " + (first.snippet || first.ruleId || "refused at strict profile"));
|
|
98
|
+
}
|
|
99
|
+
var canonical;
|
|
100
|
+
try {
|
|
101
|
+
canonical = guardEmail.sanitize(input, { profile: "strict" });
|
|
102
|
+
} catch (e) {
|
|
103
|
+
throw new TypeError("referrals: referee_email — " + (e && e.message || "refused"));
|
|
104
|
+
}
|
|
105
|
+
// Full lowercase before hashing. RFC 5321 leaves local-part case
|
|
106
|
+
// sensitive, but referral dedupe is about "did this human already
|
|
107
|
+
// get invited via this code"; treating `FRIEND@example.com` and
|
|
108
|
+
// `friend@example.com` as the same recipient matches operator
|
|
109
|
+
// intuition. Aligns with the newsletter primitive's posture.
|
|
110
|
+
return canonical.toLowerCase();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function _now() { return Date.now(); }
|
|
114
|
+
|
|
115
|
+
// ---- code generation + canonicalization ---------------------------------
|
|
116
|
+
|
|
117
|
+
function _makeCodeGenerator(alphabet) {
|
|
118
|
+
if (typeof alphabet !== "string" || alphabet.length !== 32) {
|
|
119
|
+
throw new TypeError("referrals: codeAlphabet must be exactly 32 characters (uniform-draw over a random byte)");
|
|
120
|
+
}
|
|
121
|
+
// Guard against operator-supplied alphabets that contain duplicates
|
|
122
|
+
// (which would skew the draw) or lowercase letters (storage is
|
|
123
|
+
// uppercase; lowercase chars would never match a lookup).
|
|
124
|
+
var seen = {};
|
|
125
|
+
for (var i = 0; i < alphabet.length; i += 1) {
|
|
126
|
+
var ch = alphabet.charAt(i);
|
|
127
|
+
if (seen[ch]) {
|
|
128
|
+
throw new TypeError("referrals: codeAlphabet must not contain duplicate characters");
|
|
129
|
+
}
|
|
130
|
+
if (ch !== ch.toUpperCase()) {
|
|
131
|
+
throw new TypeError("referrals: codeAlphabet must be uppercase (codes are stored uppercase)");
|
|
132
|
+
}
|
|
133
|
+
seen[ch] = true;
|
|
134
|
+
}
|
|
135
|
+
return function () {
|
|
136
|
+
var buf = _b().crypto.generateBytes(CODE_LEN);
|
|
137
|
+
var out = "";
|
|
138
|
+
for (var j = 0; j < CODE_LEN; j += 1) {
|
|
139
|
+
out += alphabet.charAt(buf[j] & 31);
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Canonicalize an operator-supplied lookup string into the storage
|
|
146
|
+
// form. Strips ASCII whitespace + hyphens (forgiving for hand-typed
|
|
147
|
+
// codes pasted from email), uppercases, refuses control bytes /
|
|
148
|
+
// unicode whitespace / non-alphabet glyphs.
|
|
149
|
+
function _canonicalCode(input, alphabetRe) {
|
|
150
|
+
if (typeof input !== "string" || !input.length) {
|
|
151
|
+
throw new TypeError("referrals: code must be a non-empty string");
|
|
152
|
+
}
|
|
153
|
+
var stripped = input.replace(/[-\s]+/g, "").toUpperCase();
|
|
154
|
+
if (stripped.length !== CODE_LEN) {
|
|
155
|
+
throw new TypeError("referrals: code must be " + CODE_LEN + " alphabet characters");
|
|
156
|
+
}
|
|
157
|
+
if (!alphabetRe.test(stripped)) {
|
|
158
|
+
throw new TypeError("referrals: code contains characters outside the referral alphabet");
|
|
159
|
+
}
|
|
160
|
+
return stripped;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function _alphabetRe(alphabet) {
|
|
164
|
+
// Escape any regex-special characters from the alphabet — the
|
|
165
|
+
// default alphabet has none, but an operator-supplied 32-char set
|
|
166
|
+
// could legally include `]` or `-`.
|
|
167
|
+
var escaped = alphabet.replace(/[\\\]^-]/g, "\\$&");
|
|
168
|
+
return new RegExp("^[" + escaped + "]+$");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---- factory ------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
function create(opts) {
|
|
174
|
+
opts = opts || {};
|
|
175
|
+
var query = opts.query;
|
|
176
|
+
if (!query) {
|
|
177
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
178
|
+
}
|
|
179
|
+
var alphabet = opts.codeAlphabet || DEFAULT_ALPHABET;
|
|
180
|
+
var generateCode = _makeCodeGenerator(alphabet);
|
|
181
|
+
var alphabetRe = _alphabetRe(alphabet);
|
|
182
|
+
var linkBase = opts.linkBase || DEFAULT_LINK_BASE;
|
|
183
|
+
if (typeof linkBase !== "string" || !linkBase.length) {
|
|
184
|
+
throw new TypeError("referrals: linkBase must be a non-empty string");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Idempotent invitation upsert. Two callers (the referrer and a
|
|
188
|
+
// retry) sending the same (code, referee_email) collapse to a
|
|
189
|
+
// single row; the returned id is the existing one on the second
|
|
190
|
+
// call.
|
|
191
|
+
async function _findInvitationByCodeAndEmail(codeId, emailHash) {
|
|
192
|
+
var r = await query(
|
|
193
|
+
"SELECT id, reward_status FROM referral_invitations " +
|
|
194
|
+
"WHERE referral_code_id = ?1 AND referred_email_hash = ?2 LIMIT 1",
|
|
195
|
+
[codeId, emailHash],
|
|
196
|
+
);
|
|
197
|
+
return r.rows[0] || null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function _activeCodeByReferrer(referrerId) {
|
|
201
|
+
var r = await query(
|
|
202
|
+
"SELECT id, code, status FROM referral_codes " +
|
|
203
|
+
"WHERE referrer_customer_id = ?1 AND status = 'active' LIMIT 1",
|
|
204
|
+
[referrerId],
|
|
205
|
+
);
|
|
206
|
+
return r.rows[0] || null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function _codeRowByCode(code) {
|
|
210
|
+
var canonical = _canonicalCode(code, alphabetRe);
|
|
211
|
+
var r = await query(
|
|
212
|
+
"SELECT id, referrer_customer_id, code, status, referrals_count, created_at, updated_at " +
|
|
213
|
+
"FROM referral_codes WHERE code = ?1 LIMIT 1",
|
|
214
|
+
[canonical],
|
|
215
|
+
);
|
|
216
|
+
return r.rows[0] || null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function _formatLink(code) {
|
|
220
|
+
return linkBase + code;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
REFEREE_NAMESPACE: REFEREE_NAMESPACE,
|
|
225
|
+
CODE_ALPHABET: alphabet,
|
|
226
|
+
CODE_LEN: CODE_LEN,
|
|
227
|
+
CODE_STATUSES: CODE_STATUSES,
|
|
228
|
+
REWARD_STATUSES: REWARD_STATUSES,
|
|
229
|
+
|
|
230
|
+
issueCode: async function (input) {
|
|
231
|
+
if (!input || typeof input !== "object") {
|
|
232
|
+
throw new TypeError("referrals.issueCode: input object required");
|
|
233
|
+
}
|
|
234
|
+
var referrerId = _uuid(input.referrer_customer_id, "referrer_customer_id");
|
|
235
|
+
// One active code per customer. Rotating means the operator
|
|
236
|
+
// calls `disableCode` first; the audit trail of historical
|
|
237
|
+
// invitations is preserved against the original row.
|
|
238
|
+
var existing = await _activeCodeByReferrer(referrerId);
|
|
239
|
+
if (existing) {
|
|
240
|
+
var err = new Error("referrals.issueCode: referrer already has an active code");
|
|
241
|
+
err.code = "REFERRAL_CODE_ALREADY_ACTIVE";
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
var id = _b().uuid.v7();
|
|
245
|
+
// Generation collisions on an 8-char/32-alphabet space are
|
|
246
|
+
// ~32^8 = 2^40 — vanishingly unlikely, but the UNIQUE constraint
|
|
247
|
+
// on `code` is the safety net. Retry on collision up to a small
|
|
248
|
+
// bound; any caller that exhausts it surfaces the underlying
|
|
249
|
+
// error rather than spinning indefinitely.
|
|
250
|
+
var ts = _now();
|
|
251
|
+
var code;
|
|
252
|
+
var attempts = 0;
|
|
253
|
+
var lastErr;
|
|
254
|
+
while (attempts < 5) {
|
|
255
|
+
attempts += 1;
|
|
256
|
+
code = generateCode();
|
|
257
|
+
try {
|
|
258
|
+
await query(
|
|
259
|
+
"INSERT INTO referral_codes (id, referrer_customer_id, code, status, referrals_count, created_at, updated_at) " +
|
|
260
|
+
"VALUES (?1, ?2, ?3, 'active', 0, ?4, ?4)",
|
|
261
|
+
[id, referrerId, code, ts],
|
|
262
|
+
);
|
|
263
|
+
lastErr = null;
|
|
264
|
+
break;
|
|
265
|
+
} catch (e) {
|
|
266
|
+
lastErr = e;
|
|
267
|
+
// UNIQUE-violation: regenerate. Any other error propagates.
|
|
268
|
+
if (!e || !e.message || e.message.indexOf("UNIQUE") === -1) {
|
|
269
|
+
throw e;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (lastErr) throw lastErr;
|
|
274
|
+
return {
|
|
275
|
+
id: id,
|
|
276
|
+
code: code,
|
|
277
|
+
link: _formatLink(code),
|
|
278
|
+
};
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
// Operator-side invitation tracking. Stores only the email hash;
|
|
282
|
+
// raw address never reaches storage. Idempotent on (code, email).
|
|
283
|
+
invite: async function (input) {
|
|
284
|
+
if (!input || typeof input !== "object") {
|
|
285
|
+
throw new TypeError("referrals.invite: input object required");
|
|
286
|
+
}
|
|
287
|
+
var row = await _codeRowByCode(input.code);
|
|
288
|
+
if (!row) {
|
|
289
|
+
var miss = new Error("referrals.invite: code not recognized");
|
|
290
|
+
miss.code = "REFERRAL_CODE_NOT_FOUND";
|
|
291
|
+
throw miss;
|
|
292
|
+
}
|
|
293
|
+
if (row.status !== "active") {
|
|
294
|
+
var inactive = new Error("referrals.invite: code is " + row.status);
|
|
295
|
+
inactive.code = "REFERRAL_CODE_NOT_ACTIVE";
|
|
296
|
+
throw inactive;
|
|
297
|
+
}
|
|
298
|
+
var normalized = _normalizeEmail(input.referee_email);
|
|
299
|
+
var emailHash = _b().crypto.namespaceHash(REFEREE_NAMESPACE, normalized);
|
|
300
|
+
var existing = await _findInvitationByCodeAndEmail(row.id, emailHash);
|
|
301
|
+
if (existing) {
|
|
302
|
+
return {
|
|
303
|
+
id: existing.id,
|
|
304
|
+
referral_code_id: row.id,
|
|
305
|
+
referred_email_hash: emailHash,
|
|
306
|
+
status: "dedup",
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
var id = _b().uuid.v7();
|
|
310
|
+
var ts = _now();
|
|
311
|
+
await query(
|
|
312
|
+
"INSERT INTO referral_invitations " +
|
|
313
|
+
"(id, referral_code_id, referred_email_hash, invited_at, reward_status) " +
|
|
314
|
+
"VALUES (?1, ?2, ?3, ?4, 'pending')",
|
|
315
|
+
[id, row.id, emailHash, ts],
|
|
316
|
+
);
|
|
317
|
+
return {
|
|
318
|
+
id: id,
|
|
319
|
+
referral_code_id: row.id,
|
|
320
|
+
referred_email_hash: emailHash,
|
|
321
|
+
status: "new",
|
|
322
|
+
};
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
// Recorded when someone lands with `?ref=<code>`. Bumps every
|
|
326
|
+
// pending invitation on this code so the operator's funnel-stat
|
|
327
|
+
// queries reflect the visit. Idempotent: re-running keeps the
|
|
328
|
+
// first `visited_at` (we never overwrite a populated column).
|
|
329
|
+
trackVisit: async function (input) {
|
|
330
|
+
if (!input || typeof input !== "object") {
|
|
331
|
+
throw new TypeError("referrals.trackVisit: input object required");
|
|
332
|
+
}
|
|
333
|
+
var row = await _codeRowByCode(input.code);
|
|
334
|
+
if (!row) {
|
|
335
|
+
var miss = new Error("referrals.trackVisit: code not recognized");
|
|
336
|
+
miss.code = "REFERRAL_CODE_NOT_FOUND";
|
|
337
|
+
throw miss;
|
|
338
|
+
}
|
|
339
|
+
var ts = _now();
|
|
340
|
+
var r = await query(
|
|
341
|
+
"UPDATE referral_invitations SET visited_at = ?1 " +
|
|
342
|
+
"WHERE referral_code_id = ?2 AND visited_at IS NULL",
|
|
343
|
+
[ts, row.id],
|
|
344
|
+
);
|
|
345
|
+
return {
|
|
346
|
+
referral_code_id: row.id,
|
|
347
|
+
updated: Number(r.rowCount || 0),
|
|
348
|
+
};
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
// Recorded when the referred friend creates an account. Matches
|
|
352
|
+
// the most recent pending invitation on the code that doesn't
|
|
353
|
+
// already have a signed_up_customer_id pinned. Returns null if
|
|
354
|
+
// no matching invitation exists (the friend may have arrived
|
|
355
|
+
// organically — caller decides whether to treat as a miss).
|
|
356
|
+
trackSignup: async function (input) {
|
|
357
|
+
if (!input || typeof input !== "object") {
|
|
358
|
+
throw new TypeError("referrals.trackSignup: input object required");
|
|
359
|
+
}
|
|
360
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
361
|
+
var row = await _codeRowByCode(input.code);
|
|
362
|
+
if (!row) {
|
|
363
|
+
var miss = new Error("referrals.trackSignup: code not recognized");
|
|
364
|
+
miss.code = "REFERRAL_CODE_NOT_FOUND";
|
|
365
|
+
throw miss;
|
|
366
|
+
}
|
|
367
|
+
var ts = _now();
|
|
368
|
+
// Pin the signup to the oldest pending invitation under this
|
|
369
|
+
// code that doesn't have a customer attached yet — the same
|
|
370
|
+
// friend can be invited by multiple referrers, but only one
|
|
371
|
+
// (the code they actually landed through) gets the funnel
|
|
372
|
+
// attribution.
|
|
373
|
+
var candidate = await query(
|
|
374
|
+
"SELECT id FROM referral_invitations " +
|
|
375
|
+
"WHERE referral_code_id = ?1 AND signed_up_customer_id IS NULL " +
|
|
376
|
+
"ORDER BY invited_at ASC LIMIT 1",
|
|
377
|
+
[row.id],
|
|
378
|
+
);
|
|
379
|
+
if (!candidate.rows.length) {
|
|
380
|
+
// No pending invitation — record nothing. The caller can
|
|
381
|
+
// surface this if they want to gate signup-bonuses to known
|
|
382
|
+
// referrals.
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
var invitationId = candidate.rows[0].id;
|
|
386
|
+
await query(
|
|
387
|
+
"UPDATE referral_invitations " +
|
|
388
|
+
"SET signed_up_at = ?1, signed_up_customer_id = ?2 " +
|
|
389
|
+
"WHERE id = ?3",
|
|
390
|
+
[ts, customerId, invitationId],
|
|
391
|
+
);
|
|
392
|
+
var updated = await query(
|
|
393
|
+
"SELECT id, referral_code_id, signed_up_customer_id, signed_up_at, reward_status " +
|
|
394
|
+
"FROM referral_invitations WHERE id = ?1",
|
|
395
|
+
[invitationId],
|
|
396
|
+
);
|
|
397
|
+
return updated.rows[0] || null;
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
// Recorded when a referred customer makes their first qualifying
|
|
401
|
+
// purchase. Transitions reward_status pending -> both-rewarded
|
|
402
|
+
// (the funnel-complete marker; the operator still has to issue
|
|
403
|
+
// the actual reward instrument via rewardReferrer / rewardReferee)
|
|
404
|
+
// and bumps the referrer's referrals_count. Idempotent: a second
|
|
405
|
+
// purchase by the same customer is a no-op (`first_purchase_at`
|
|
406
|
+
// is set only once).
|
|
407
|
+
trackPurchase: async function (input) {
|
|
408
|
+
if (!input || typeof input !== "object") {
|
|
409
|
+
throw new TypeError("referrals.trackPurchase: input object required");
|
|
410
|
+
}
|
|
411
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
412
|
+
var orderId = _uuid(input.order_id, "order_id");
|
|
413
|
+
|
|
414
|
+
var r = await query(
|
|
415
|
+
"SELECT id, referral_code_id, reward_status, first_purchase_at " +
|
|
416
|
+
"FROM referral_invitations WHERE signed_up_customer_id = ?1 " +
|
|
417
|
+
"ORDER BY signed_up_at ASC LIMIT 1",
|
|
418
|
+
[customerId],
|
|
419
|
+
);
|
|
420
|
+
if (!r.rows.length) return null;
|
|
421
|
+
var inv = r.rows[0];
|
|
422
|
+
if (inv.first_purchase_at != null) {
|
|
423
|
+
// Already recorded the first qualifying purchase — second
|
|
424
|
+
// and later purchases don't re-trigger the reward funnel.
|
|
425
|
+
return {
|
|
426
|
+
id: inv.id,
|
|
427
|
+
reward_status: inv.reward_status,
|
|
428
|
+
first_purchase_at: inv.first_purchase_at,
|
|
429
|
+
status: "already-recorded",
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
var ts = _now();
|
|
433
|
+
await query(
|
|
434
|
+
"UPDATE referral_invitations " +
|
|
435
|
+
"SET first_purchase_at = ?1, first_order_id = ?2, " +
|
|
436
|
+
"reward_status = 'both-rewarded' " +
|
|
437
|
+
"WHERE id = ?3",
|
|
438
|
+
[ts, orderId, inv.id],
|
|
439
|
+
);
|
|
440
|
+
// Bump the referrer's running count. `referrals_count` is the
|
|
441
|
+
// leaderboard key; it counts completed funnels (a friend who
|
|
442
|
+
// signed up but never purchased doesn't count).
|
|
443
|
+
await query(
|
|
444
|
+
"UPDATE referral_codes " +
|
|
445
|
+
"SET referrals_count = referrals_count + 1, updated_at = ?1 " +
|
|
446
|
+
"WHERE id = ?2",
|
|
447
|
+
[ts, inv.referral_code_id],
|
|
448
|
+
);
|
|
449
|
+
var after = await query(
|
|
450
|
+
"SELECT id, referral_code_id, reward_status, first_purchase_at, first_order_id " +
|
|
451
|
+
"FROM referral_invitations WHERE id = ?1",
|
|
452
|
+
[inv.id],
|
|
453
|
+
);
|
|
454
|
+
var out = after.rows[0] || null;
|
|
455
|
+
if (out) out.status = "completed";
|
|
456
|
+
return out;
|
|
457
|
+
},
|
|
458
|
+
|
|
459
|
+
// Operator records that the referrer payout has been issued.
|
|
460
|
+
// `reward_id` is opaque (operator's gift-card id, discount-code
|
|
461
|
+
// id, ledger-credit id — whatever instrument they chose). The
|
|
462
|
+
// FSM accepts transitions from any non-terminal state so the
|
|
463
|
+
// operator can pay out before or after the friend's purchase if
|
|
464
|
+
// their reward policy says so.
|
|
465
|
+
rewardReferrer: async function (invitationId, rewardOpts) {
|
|
466
|
+
var id = _uuid(invitationId, "invitation_id");
|
|
467
|
+
if (!rewardOpts || typeof rewardOpts !== "object") {
|
|
468
|
+
throw new TypeError("referrals.rewardReferrer: opts object with reward_id required");
|
|
469
|
+
}
|
|
470
|
+
if (typeof rewardOpts.reward_id !== "string" || !rewardOpts.reward_id.length) {
|
|
471
|
+
throw new TypeError("referrals.rewardReferrer: reward_id must be a non-empty string");
|
|
472
|
+
}
|
|
473
|
+
var r = await query(
|
|
474
|
+
"SELECT id, reward_status, referrer_reward_id, referee_reward_id " +
|
|
475
|
+
"FROM referral_invitations WHERE id = ?1",
|
|
476
|
+
[id],
|
|
477
|
+
);
|
|
478
|
+
if (!r.rows.length) return null;
|
|
479
|
+
var inv = r.rows[0];
|
|
480
|
+
if (inv.reward_status === "expired" || inv.reward_status === "voided") {
|
|
481
|
+
var terminal = new Error("referrals.rewardReferrer: invitation is " + inv.reward_status);
|
|
482
|
+
terminal.code = "REFERRAL_INVITATION_TERMINAL";
|
|
483
|
+
throw terminal;
|
|
484
|
+
}
|
|
485
|
+
// FSM: pending -> referrer-rewarded; if the referee has already
|
|
486
|
+
// been rewarded, transition to both-rewarded (the operator's
|
|
487
|
+
// chosen order doesn't matter).
|
|
488
|
+
var nextStatus = inv.referee_reward_id ? "both-rewarded" : "referrer-rewarded";
|
|
489
|
+
await query(
|
|
490
|
+
"UPDATE referral_invitations " +
|
|
491
|
+
"SET referrer_reward_id = ?1, reward_status = ?2 " +
|
|
492
|
+
"WHERE id = ?3",
|
|
493
|
+
[rewardOpts.reward_id, nextStatus, id],
|
|
494
|
+
);
|
|
495
|
+
var after = await query(
|
|
496
|
+
"SELECT id, reward_status, referrer_reward_id, referee_reward_id " +
|
|
497
|
+
"FROM referral_invitations WHERE id = ?1",
|
|
498
|
+
[id],
|
|
499
|
+
);
|
|
500
|
+
return after.rows[0] || null;
|
|
501
|
+
},
|
|
502
|
+
|
|
503
|
+
rewardReferee: async function (invitationId, rewardOpts) {
|
|
504
|
+
var id = _uuid(invitationId, "invitation_id");
|
|
505
|
+
if (!rewardOpts || typeof rewardOpts !== "object") {
|
|
506
|
+
throw new TypeError("referrals.rewardReferee: opts object with reward_id required");
|
|
507
|
+
}
|
|
508
|
+
if (typeof rewardOpts.reward_id !== "string" || !rewardOpts.reward_id.length) {
|
|
509
|
+
throw new TypeError("referrals.rewardReferee: reward_id must be a non-empty string");
|
|
510
|
+
}
|
|
511
|
+
var r = await query(
|
|
512
|
+
"SELECT id, reward_status, referrer_reward_id, referee_reward_id " +
|
|
513
|
+
"FROM referral_invitations WHERE id = ?1",
|
|
514
|
+
[id],
|
|
515
|
+
);
|
|
516
|
+
if (!r.rows.length) return null;
|
|
517
|
+
var inv = r.rows[0];
|
|
518
|
+
if (inv.reward_status === "expired" || inv.reward_status === "voided") {
|
|
519
|
+
var terminal = new Error("referrals.rewardReferee: invitation is " + inv.reward_status);
|
|
520
|
+
terminal.code = "REFERRAL_INVITATION_TERMINAL";
|
|
521
|
+
throw terminal;
|
|
522
|
+
}
|
|
523
|
+
var nextStatus = inv.referrer_reward_id ? "both-rewarded" : inv.reward_status;
|
|
524
|
+
// If the referrer hasn't been rewarded yet, the referee payout
|
|
525
|
+
// alone doesn't advance the funnel state — only both-sides
|
|
526
|
+
// payment lands on "both-rewarded". This keeps the leaderboard
|
|
527
|
+
// and stats coherent: a referee paid early without the referrer
|
|
528
|
+
// ever being paid stays visible in the operator's `referee
|
|
529
|
+
// payments outstanding` queue.
|
|
530
|
+
await query(
|
|
531
|
+
"UPDATE referral_invitations " +
|
|
532
|
+
"SET referee_reward_id = ?1, reward_status = ?2 " +
|
|
533
|
+
"WHERE id = ?3",
|
|
534
|
+
[rewardOpts.reward_id, nextStatus, id],
|
|
535
|
+
);
|
|
536
|
+
var after = await query(
|
|
537
|
+
"SELECT id, reward_status, referrer_reward_id, referee_reward_id " +
|
|
538
|
+
"FROM referral_invitations WHERE id = ?1",
|
|
539
|
+
[id],
|
|
540
|
+
);
|
|
541
|
+
return after.rows[0] || null;
|
|
542
|
+
},
|
|
543
|
+
|
|
544
|
+
// Operator lookup. Case-insensitive (lookup folds + uppercases
|
|
545
|
+
// before the SQL query); returns null on miss.
|
|
546
|
+
byCode: async function (code) {
|
|
547
|
+
return await _codeRowByCode(code);
|
|
548
|
+
},
|
|
549
|
+
|
|
550
|
+
// Operator action — flip an active code to "disabled". A
|
|
551
|
+
// referrer can then call issueCode to mint a new one. Returns
|
|
552
|
+
// the post-state row, or null if the id doesn't resolve.
|
|
553
|
+
disableCode: async function (codeId) {
|
|
554
|
+
var id = _uuid(codeId, "code_id");
|
|
555
|
+
var ts = _now();
|
|
556
|
+
var r = await query(
|
|
557
|
+
"UPDATE referral_codes SET status = 'disabled', updated_at = ?1 WHERE id = ?2",
|
|
558
|
+
[ts, id],
|
|
559
|
+
);
|
|
560
|
+
if (Number(r.rowCount || 0) === 0) return null;
|
|
561
|
+
var after = await query(
|
|
562
|
+
"SELECT id, referrer_customer_id, code, status, referrals_count, created_at, updated_at " +
|
|
563
|
+
"FROM referral_codes WHERE id = ?1",
|
|
564
|
+
[id],
|
|
565
|
+
);
|
|
566
|
+
return after.rows[0] || null;
|
|
567
|
+
},
|
|
568
|
+
|
|
569
|
+
// Aggregate funnel stats for one referrer across every code
|
|
570
|
+
// they've ever held. Operators surface this on the referrer's
|
|
571
|
+
// account page ("you've invited N friends, M have purchased,
|
|
572
|
+
// your rewards: X").
|
|
573
|
+
statsForReferrer: async function (customerId) {
|
|
574
|
+
var id = _uuid(customerId, "customer_id");
|
|
575
|
+
var codes = await query(
|
|
576
|
+
"SELECT id, code, status, referrals_count, created_at " +
|
|
577
|
+
"FROM referral_codes WHERE referrer_customer_id = ?1",
|
|
578
|
+
[id],
|
|
579
|
+
);
|
|
580
|
+
var totalCompleted = 0;
|
|
581
|
+
for (var i = 0; i < codes.rows.length; i += 1) {
|
|
582
|
+
totalCompleted += Number(codes.rows[i].referrals_count || 0);
|
|
583
|
+
}
|
|
584
|
+
var counts = await query(
|
|
585
|
+
"SELECT COUNT(*) AS total, " +
|
|
586
|
+
"SUM(CASE WHEN visited_at IS NOT NULL THEN 1 ELSE 0 END) AS visited, " +
|
|
587
|
+
"SUM(CASE WHEN signed_up_at IS NOT NULL THEN 1 ELSE 0 END) AS signed_up, " +
|
|
588
|
+
"SUM(CASE WHEN first_purchase_at IS NOT NULL THEN 1 ELSE 0 END) AS purchased, " +
|
|
589
|
+
"SUM(CASE WHEN referrer_reward_id IS NOT NULL THEN 1 ELSE 0 END) AS referrer_rewarded, " +
|
|
590
|
+
"SUM(CASE WHEN referee_reward_id IS NOT NULL THEN 1 ELSE 0 END) AS referee_rewarded " +
|
|
591
|
+
"FROM referral_invitations WHERE referral_code_id IN (" +
|
|
592
|
+
"SELECT id FROM referral_codes WHERE referrer_customer_id = ?1)",
|
|
593
|
+
[id],
|
|
594
|
+
);
|
|
595
|
+
var row = counts.rows[0] || {};
|
|
596
|
+
return {
|
|
597
|
+
customer_id: id,
|
|
598
|
+
codes: codes.rows,
|
|
599
|
+
completed_referrals: totalCompleted,
|
|
600
|
+
invitations_total: Number(row.total || 0),
|
|
601
|
+
invitations_visited: Number(row.visited || 0),
|
|
602
|
+
invitations_signed_up: Number(row.signed_up || 0),
|
|
603
|
+
invitations_purchased: Number(row.purchased || 0),
|
|
604
|
+
referrer_rewarded: Number(row.referrer_rewarded || 0),
|
|
605
|
+
referee_rewarded: Number(row.referee_rewarded || 0),
|
|
606
|
+
};
|
|
607
|
+
},
|
|
608
|
+
|
|
609
|
+
// Top-N referrers by completed referrals (referrals_count is the
|
|
610
|
+
// funnel-complete counter; signups without a first purchase don't
|
|
611
|
+
// count). Defaults to 10; capped at 100 so an operator can't
|
|
612
|
+
// accidentally page through the entire customer base via the
|
|
613
|
+
// public storefront route.
|
|
614
|
+
leaderboard: async function (input) {
|
|
615
|
+
input = input || {};
|
|
616
|
+
var limit = input.limit == null ? 10 : input.limit;
|
|
617
|
+
if (typeof limit !== "number" || !Number.isInteger(limit) || limit <= 0 || limit > 100) {
|
|
618
|
+
throw new TypeError("referrals.leaderboard: limit must be a positive integer <= 100");
|
|
619
|
+
}
|
|
620
|
+
var r = await query(
|
|
621
|
+
"SELECT referrer_customer_id, SUM(referrals_count) AS completed_referrals " +
|
|
622
|
+
"FROM referral_codes GROUP BY referrer_customer_id " +
|
|
623
|
+
"HAVING SUM(referrals_count) > 0 " +
|
|
624
|
+
"ORDER BY completed_referrals DESC, referrer_customer_id ASC " +
|
|
625
|
+
"LIMIT ?1",
|
|
626
|
+
[limit],
|
|
627
|
+
);
|
|
628
|
+
// Coerce SUM() result to a Number — `node:sqlite` returns BIGINT
|
|
629
|
+
// for SUM() over INTEGER on some Node minor versions, plain
|
|
630
|
+
// Number on others. Normalizing here keeps the operator's
|
|
631
|
+
// downstream JSON serializer from emitting BigInt syntax.
|
|
632
|
+
return r.rows.map(function (row) {
|
|
633
|
+
return {
|
|
634
|
+
referrer_customer_id: row.referrer_customer_id,
|
|
635
|
+
completed_referrals: Number(row.completed_referrals || 0),
|
|
636
|
+
};
|
|
637
|
+
});
|
|
638
|
+
},
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
module.exports = {
|
|
643
|
+
create: create,
|
|
644
|
+
REFEREE_NAMESPACE: REFEREE_NAMESPACE,
|
|
645
|
+
CODE_ALPHABET: DEFAULT_ALPHABET,
|
|
646
|
+
CODE_LEN: CODE_LEN,
|
|
647
|
+
CODE_STATUSES: CODE_STATUSES,
|
|
648
|
+
REWARD_STATUSES: REWARD_STATUSES,
|
|
649
|
+
};
|