@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/loyalty.js
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.loyalty
|
|
4
|
+
* @title Loyalty primitive — customer points balance + tier system
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Tracks a running points balance and a never-decremented lifetime
|
|
8
|
+
* total per customer. The lifetime total drives tier placement
|
|
9
|
+
* (bronze / silver / gold / platinum) against operator-tunable
|
|
10
|
+
* thresholds. Every state mutation lands as a row in the
|
|
11
|
+
* `loyalty_transactions` audit trail with an operator-supplied
|
|
12
|
+
* `source` string and an optional `order_id` link.
|
|
13
|
+
*
|
|
14
|
+
* Composition:
|
|
15
|
+
* var loy = bShop.loyalty.create({ query: q });
|
|
16
|
+
* await loy.ensureAccount(customerId);
|
|
17
|
+
* var earn = await loy.earn({
|
|
18
|
+
* customer_id: customerId,
|
|
19
|
+
* points: 120,
|
|
20
|
+
* source: "order-paid",
|
|
21
|
+
* order_id: orderId,
|
|
22
|
+
* });
|
|
23
|
+
* // earn.tier_changed is true on a tier promotion crossing
|
|
24
|
+
* // (e.g. lifetime 499 -> 500 silver).
|
|
25
|
+
*
|
|
26
|
+
* Conversion ratios are operator-tunable but ship with sensible
|
|
27
|
+
* defaults: 1 USD spent = 10 points earned, 100 points = $1 in
|
|
28
|
+
* redemption value. The primitive exposes the ratios on the
|
|
29
|
+
* returned object so callers compose order subtotals and
|
|
30
|
+
* redemption-cap calculations without re-deriving the constants.
|
|
31
|
+
*
|
|
32
|
+
* Tier computation is monotonic in lifetime points — a tier
|
|
33
|
+
* downgrade can only happen via `expire` on the lifetime total,
|
|
34
|
+
* which `expire` does NOT do by default (expiry decrements balance
|
|
35
|
+
* only). Operators that want lifetime-tier sunset semantics use
|
|
36
|
+
* `tier_expires_at` and a separate scheduled job to recompute.
|
|
37
|
+
*
|
|
38
|
+
* Surface:
|
|
39
|
+
* - ensureAccount(customer_id)
|
|
40
|
+
* - earn({ customer_id, points, source, order_id?, notes? })
|
|
41
|
+
* - redeem({ customer_id, points, order_id?, notes? })
|
|
42
|
+
* - adjust({ customer_id, points, source, notes? })
|
|
43
|
+
* - expire({ customer_id, points, reason })
|
|
44
|
+
* - balance(customer_id)
|
|
45
|
+
* - history(customer_id, { limit?, cursor? })
|
|
46
|
+
* - tierLeaderboard({ tier?, limit? })
|
|
47
|
+
* - computeTier(lifetime_points)
|
|
48
|
+
*
|
|
49
|
+
* Storage:
|
|
50
|
+
* - loyalty_accounts, loyalty_transactions (migration 0022).
|
|
51
|
+
*
|
|
52
|
+
* @primitive loyalty
|
|
53
|
+
* @related b.uuid.v7, b.guardUuid
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
var bShop;
|
|
57
|
+
function _b() {
|
|
58
|
+
if (!bShop) bShop = require("./index");
|
|
59
|
+
return bShop.framework;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
var TIERS = ["bronze", "silver", "gold", "platinum"];
|
|
63
|
+
var TX_TYPES = ["earn", "redeem", "expire", "adjust", "tier-bonus"];
|
|
64
|
+
|
|
65
|
+
var DEFAULT_TIER_THRESHOLDS = {
|
|
66
|
+
bronze: 0,
|
|
67
|
+
silver: 500,
|
|
68
|
+
gold: 2000,
|
|
69
|
+
platinum: 10000,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Conversion ratios — operator-tunable via `create({...})`. The
|
|
73
|
+
// defaults match the spec: 1 USD spent earns 10 points; 100 points
|
|
74
|
+
// redeem for $1 in storefront credit. Held in whole-number minor units
|
|
75
|
+
// at the call site to avoid floating-point creep.
|
|
76
|
+
var DEFAULT_POINTS_PER_USD = 10;
|
|
77
|
+
var DEFAULT_REDEMPTION_POINTS_PER_USD = 100;
|
|
78
|
+
|
|
79
|
+
var MAX_SOURCE_LEN = 64;
|
|
80
|
+
var SOURCE_RE = /^[a-z0-9][a-z0-9._-]{0,62}[a-z0-9]$/;
|
|
81
|
+
var MAX_NOTES_LEN = 512;
|
|
82
|
+
|
|
83
|
+
// ---- validators ---------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
function _uuid(s, label) {
|
|
86
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
87
|
+
catch (e) { throw new TypeError("loyalty: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function _positiveInt(n, label) {
|
|
91
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
|
|
92
|
+
throw new TypeError("loyalty: " + label + " must be a positive integer");
|
|
93
|
+
}
|
|
94
|
+
return n;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function _signedInt(n, label) {
|
|
98
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n === 0) {
|
|
99
|
+
throw new TypeError("loyalty: " + label + " must be a non-zero integer");
|
|
100
|
+
}
|
|
101
|
+
return n;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function _source(s) {
|
|
105
|
+
if (typeof s !== "string" || !s.length) {
|
|
106
|
+
throw new TypeError("loyalty: source must be a non-empty string");
|
|
107
|
+
}
|
|
108
|
+
var clean = s.toLowerCase().trim();
|
|
109
|
+
if (clean.length > MAX_SOURCE_LEN) {
|
|
110
|
+
throw new TypeError("loyalty: source must be <= " + MAX_SOURCE_LEN + " chars");
|
|
111
|
+
}
|
|
112
|
+
if (!SOURCE_RE.test(clean)) {
|
|
113
|
+
throw new TypeError("loyalty: source must match /[a-z0-9][a-z0-9._-]*[a-z0-9]/");
|
|
114
|
+
}
|
|
115
|
+
return clean;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function _notes(s) {
|
|
119
|
+
if (s == null || s === "") return "";
|
|
120
|
+
if (typeof s !== "string") {
|
|
121
|
+
throw new TypeError("loyalty: notes must be a string");
|
|
122
|
+
}
|
|
123
|
+
if (s.length > MAX_NOTES_LEN) {
|
|
124
|
+
throw new TypeError("loyalty: notes must be <= " + MAX_NOTES_LEN + " chars");
|
|
125
|
+
}
|
|
126
|
+
// Refuse control bytes outside HT/LF/CR — keep operator-facing
|
|
127
|
+
// strings printable without losing newline-as-separator in long
|
|
128
|
+
// free-form notes.
|
|
129
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) {
|
|
130
|
+
throw new TypeError("loyalty: notes must not contain control bytes");
|
|
131
|
+
}
|
|
132
|
+
return s;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function _validateThresholds(thresholds) {
|
|
136
|
+
if (!thresholds || typeof thresholds !== "object") {
|
|
137
|
+
throw new TypeError("loyalty: tierThresholds must be an object");
|
|
138
|
+
}
|
|
139
|
+
for (var i = 0; i < TIERS.length; i += 1) {
|
|
140
|
+
var t = TIERS[i];
|
|
141
|
+
var v = thresholds[t];
|
|
142
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) {
|
|
143
|
+
throw new TypeError("loyalty: tierThresholds." + t + " must be a non-negative integer");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (thresholds.bronze !== 0) {
|
|
147
|
+
throw new TypeError("loyalty: tierThresholds.bronze must be 0 (base tier)");
|
|
148
|
+
}
|
|
149
|
+
// Thresholds must be strictly monotonically increasing — otherwise
|
|
150
|
+
// computeTier becomes ambiguous (two tiers crossing at the same
|
|
151
|
+
// lifetime point would silently prefer the later one).
|
|
152
|
+
if (!(thresholds.silver > thresholds.bronze
|
|
153
|
+
&& thresholds.gold > thresholds.silver
|
|
154
|
+
&& thresholds.platinum > thresholds.gold)) {
|
|
155
|
+
throw new TypeError("loyalty: tierThresholds must be strictly increasing bronze < silver < gold < platinum");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function _now() { return Date.now(); }
|
|
160
|
+
|
|
161
|
+
// ---- factory ------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
function create(opts) {
|
|
164
|
+
opts = opts || {};
|
|
165
|
+
|
|
166
|
+
var thresholds = opts.tierThresholds
|
|
167
|
+
? { bronze: opts.tierThresholds.bronze, silver: opts.tierThresholds.silver,
|
|
168
|
+
gold: opts.tierThresholds.gold, platinum: opts.tierThresholds.platinum }
|
|
169
|
+
: { bronze: DEFAULT_TIER_THRESHOLDS.bronze, silver: DEFAULT_TIER_THRESHOLDS.silver,
|
|
170
|
+
gold: DEFAULT_TIER_THRESHOLDS.gold, platinum: DEFAULT_TIER_THRESHOLDS.platinum };
|
|
171
|
+
_validateThresholds(thresholds);
|
|
172
|
+
|
|
173
|
+
var pointsPerUsd = opts.pointsPerUsd != null ? opts.pointsPerUsd : DEFAULT_POINTS_PER_USD;
|
|
174
|
+
if (typeof pointsPerUsd !== "number" || !Number.isInteger(pointsPerUsd) || pointsPerUsd <= 0) {
|
|
175
|
+
throw new TypeError("loyalty: pointsPerUsd must be a positive integer");
|
|
176
|
+
}
|
|
177
|
+
var redemptionPointsPerUsd = opts.redemptionPointsPerUsd != null
|
|
178
|
+
? opts.redemptionPointsPerUsd : DEFAULT_REDEMPTION_POINTS_PER_USD;
|
|
179
|
+
if (typeof redemptionPointsPerUsd !== "number" || !Number.isInteger(redemptionPointsPerUsd) || redemptionPointsPerUsd <= 0) {
|
|
180
|
+
throw new TypeError("loyalty: redemptionPointsPerUsd must be a positive integer");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
var query = opts.query;
|
|
184
|
+
if (!query) {
|
|
185
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Pure helper — exposed on the API as `.computeTier` for callers
|
|
189
|
+
// who want to preview a tier without writing an account row. Walks
|
|
190
|
+
// the tier list from highest-threshold down so a lifetime total
|
|
191
|
+
// sitting at exactly the platinum threshold lands on platinum, not
|
|
192
|
+
// gold. (The threshold is inclusive on the upgrade side.)
|
|
193
|
+
function computeTier(lifetime) {
|
|
194
|
+
if (typeof lifetime !== "number" || !Number.isInteger(lifetime) || lifetime < 0) {
|
|
195
|
+
throw new TypeError("loyalty.computeTier: lifetime must be a non-negative integer");
|
|
196
|
+
}
|
|
197
|
+
if (lifetime >= thresholds.platinum) return "platinum";
|
|
198
|
+
if (lifetime >= thresholds.gold) return "gold";
|
|
199
|
+
if (lifetime >= thresholds.silver) return "silver";
|
|
200
|
+
return "bronze";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function _readAccount(customerId) {
|
|
204
|
+
var r = await query(
|
|
205
|
+
"SELECT customer_id, balance_points, lifetime_points, tier, tier_expires_at, created_at, updated_at " +
|
|
206
|
+
"FROM loyalty_accounts WHERE customer_id = ?1",
|
|
207
|
+
[customerId],
|
|
208
|
+
);
|
|
209
|
+
return r.rows[0] || null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function _writeTx(customerId, type, points, source, orderId, notes, ts) {
|
|
213
|
+
var id = _b().uuid.v7();
|
|
214
|
+
await query(
|
|
215
|
+
"INSERT INTO loyalty_transactions " +
|
|
216
|
+
"(id, customer_id, transaction_type, points, source, order_id, notes, occurred_at) " +
|
|
217
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
218
|
+
[id, customerId, type, points, source, orderId, notes, ts],
|
|
219
|
+
);
|
|
220
|
+
return id;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function _ensureAccountRow(customerId, ts) {
|
|
224
|
+
// INSERT OR IGNORE is idempotent — repeated calls land a single
|
|
225
|
+
// row. We don't bump `updated_at` on the existing-row path; the
|
|
226
|
+
// operator's intent is "make sure this exists", not "touch this".
|
|
227
|
+
await query(
|
|
228
|
+
"INSERT OR IGNORE INTO loyalty_accounts " +
|
|
229
|
+
"(customer_id, balance_points, lifetime_points, tier, tier_expires_at, created_at, updated_at) " +
|
|
230
|
+
"VALUES (?1, 0, 0, 'bronze', NULL, ?2, ?2)",
|
|
231
|
+
[customerId, ts],
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
TIERS: TIERS.slice(),
|
|
237
|
+
TX_TYPES: TX_TYPES.slice(),
|
|
238
|
+
TIER_THRESHOLDS: { bronze: thresholds.bronze, silver: thresholds.silver,
|
|
239
|
+
gold: thresholds.gold, platinum: thresholds.platinum },
|
|
240
|
+
POINTS_PER_USD: pointsPerUsd,
|
|
241
|
+
REDEMPTION_POINTS_PER_USD: redemptionPointsPerUsd,
|
|
242
|
+
|
|
243
|
+
computeTier: computeTier,
|
|
244
|
+
|
|
245
|
+
ensureAccount: async function (customerId) {
|
|
246
|
+
_uuid(customerId, "customer_id");
|
|
247
|
+
var ts = _now();
|
|
248
|
+
var before = await _readAccount(customerId);
|
|
249
|
+
if (before) return { created: false, account: before };
|
|
250
|
+
await _ensureAccountRow(customerId, ts);
|
|
251
|
+
var after = await _readAccount(customerId);
|
|
252
|
+
return { created: true, account: after };
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
earn: async function (input) {
|
|
256
|
+
if (!input || typeof input !== "object") {
|
|
257
|
+
throw new TypeError("loyalty.earn: input object required");
|
|
258
|
+
}
|
|
259
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
260
|
+
var points = _positiveInt(input.points, "points");
|
|
261
|
+
var source = _source(input.source);
|
|
262
|
+
var orderId = input.order_id != null ? _uuid(input.order_id, "order_id") : null;
|
|
263
|
+
var notes = _notes(input.notes);
|
|
264
|
+
|
|
265
|
+
var ts = _now();
|
|
266
|
+
await _ensureAccountRow(customerId, ts);
|
|
267
|
+
var before = await _readAccount(customerId);
|
|
268
|
+
var newLifetime = before.lifetime_points + points;
|
|
269
|
+
var newBalance = before.balance_points + points;
|
|
270
|
+
var newTier = computeTier(newLifetime);
|
|
271
|
+
var tierChanged = newTier !== before.tier;
|
|
272
|
+
|
|
273
|
+
await query(
|
|
274
|
+
"UPDATE loyalty_accounts SET balance_points = ?1, lifetime_points = ?2, " +
|
|
275
|
+
"tier = ?3, updated_at = ?4 WHERE customer_id = ?5",
|
|
276
|
+
[newBalance, newLifetime, newTier, ts, customerId],
|
|
277
|
+
);
|
|
278
|
+
await _writeTx(customerId, "earn", points, source, orderId, notes, ts);
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
balance: newBalance,
|
|
282
|
+
lifetime: newLifetime,
|
|
283
|
+
tier: newTier,
|
|
284
|
+
tier_changed: tierChanged,
|
|
285
|
+
};
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
redeem: async function (input) {
|
|
289
|
+
if (!input || typeof input !== "object") {
|
|
290
|
+
throw new TypeError("loyalty.redeem: input object required");
|
|
291
|
+
}
|
|
292
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
293
|
+
var points = _positiveInt(input.points, "points");
|
|
294
|
+
var orderId = input.order_id != null ? _uuid(input.order_id, "order_id") : null;
|
|
295
|
+
var notes = _notes(input.notes);
|
|
296
|
+
|
|
297
|
+
var ts = _now();
|
|
298
|
+
await _ensureAccountRow(customerId, ts);
|
|
299
|
+
var before = await _readAccount(customerId);
|
|
300
|
+
if (before.balance_points < points) {
|
|
301
|
+
var ins = new Error("loyalty.redeem: insufficient balance");
|
|
302
|
+
ins.code = "LOYALTY_INSUFFICIENT_BALANCE";
|
|
303
|
+
throw ins;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Atomic decrement guarded by a balance check at the SQL tier so
|
|
307
|
+
// two concurrent redemptions can't double-spend. Lifetime is not
|
|
308
|
+
// affected — redemption spends from the running balance only.
|
|
309
|
+
var dec = await query(
|
|
310
|
+
"UPDATE loyalty_accounts SET balance_points = balance_points - ?1, " +
|
|
311
|
+
"updated_at = ?2 WHERE customer_id = ?3 AND balance_points >= ?1",
|
|
312
|
+
[points, ts, customerId],
|
|
313
|
+
);
|
|
314
|
+
if (dec.rowCount === 0) {
|
|
315
|
+
var raced = new Error("loyalty.redeem: insufficient balance");
|
|
316
|
+
raced.code = "LOYALTY_INSUFFICIENT_BALANCE";
|
|
317
|
+
throw raced;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
await _writeTx(customerId, "redeem", -points, "redeem", orderId, notes, ts);
|
|
321
|
+
|
|
322
|
+
var after = await _readAccount(customerId);
|
|
323
|
+
return {
|
|
324
|
+
balance: after.balance_points,
|
|
325
|
+
lifetime: after.lifetime_points,
|
|
326
|
+
tier: after.tier,
|
|
327
|
+
tier_expires_at: after.tier_expires_at,
|
|
328
|
+
};
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
adjust: async function (input) {
|
|
332
|
+
if (!input || typeof input !== "object") {
|
|
333
|
+
throw new TypeError("loyalty.adjust: input object required");
|
|
334
|
+
}
|
|
335
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
336
|
+
var delta = _signedInt(input.points, "points");
|
|
337
|
+
var source = _source(input.source);
|
|
338
|
+
var notes = _notes(input.notes);
|
|
339
|
+
|
|
340
|
+
var ts = _now();
|
|
341
|
+
await _ensureAccountRow(customerId, ts);
|
|
342
|
+
var before = await _readAccount(customerId);
|
|
343
|
+
|
|
344
|
+
var newBalance = before.balance_points + delta;
|
|
345
|
+
if (newBalance < 0) {
|
|
346
|
+
var ins = new Error("loyalty.adjust: adjustment would underflow balance");
|
|
347
|
+
ins.code = "LOYALTY_INSUFFICIENT_BALANCE";
|
|
348
|
+
throw ins;
|
|
349
|
+
}
|
|
350
|
+
// Positive adjustments also increment lifetime — operators
|
|
351
|
+
// crediting a customer for a service recovery should see that
|
|
352
|
+
// credit count toward tier. Negative adjustments do NOT
|
|
353
|
+
// decrement lifetime (otherwise a clawback could downgrade tier
|
|
354
|
+
// retroactively, which is a customer-facing surprise).
|
|
355
|
+
var newLifetime = delta > 0 ? before.lifetime_points + delta : before.lifetime_points;
|
|
356
|
+
var newTier = computeTier(newLifetime);
|
|
357
|
+
|
|
358
|
+
await query(
|
|
359
|
+
"UPDATE loyalty_accounts SET balance_points = ?1, lifetime_points = ?2, " +
|
|
360
|
+
"tier = ?3, updated_at = ?4 WHERE customer_id = ?5",
|
|
361
|
+
[newBalance, newLifetime, newTier, ts, customerId],
|
|
362
|
+
);
|
|
363
|
+
await _writeTx(customerId, "adjust", delta, source, null, notes, ts);
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
balance: newBalance,
|
|
367
|
+
lifetime: newLifetime,
|
|
368
|
+
tier: newTier,
|
|
369
|
+
tier_changed: newTier !== before.tier,
|
|
370
|
+
};
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
expire: async function (input) {
|
|
374
|
+
if (!input || typeof input !== "object") {
|
|
375
|
+
throw new TypeError("loyalty.expire: input object required");
|
|
376
|
+
}
|
|
377
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
378
|
+
var points = _positiveInt(input.points, "points");
|
|
379
|
+
if (input.reason == null || input.reason === "") {
|
|
380
|
+
throw new TypeError("loyalty.expire: reason must be a non-empty string");
|
|
381
|
+
}
|
|
382
|
+
var reason = _source(input.reason);
|
|
383
|
+
|
|
384
|
+
var ts = _now();
|
|
385
|
+
await _ensureAccountRow(customerId, ts);
|
|
386
|
+
var before = await _readAccount(customerId);
|
|
387
|
+
|
|
388
|
+
// Expiry caps at the current balance — operators schedule annual
|
|
389
|
+
// sweeps that compute "points older than 365d" from the ledger;
|
|
390
|
+
// when the live balance is already smaller than the requested
|
|
391
|
+
// expire amount (because of an interim redemption), we expire
|
|
392
|
+
// only what's there rather than refusing.
|
|
393
|
+
var toExpire = points > before.balance_points ? before.balance_points : points;
|
|
394
|
+
if (toExpire === 0) {
|
|
395
|
+
// No-op write the ledger row anyway so the audit trail
|
|
396
|
+
// records that the operator ran the sweep and found nothing.
|
|
397
|
+
await _writeTx(customerId, "expire", 0, reason, null, "", ts);
|
|
398
|
+
return {
|
|
399
|
+
balance: before.balance_points,
|
|
400
|
+
lifetime: before.lifetime_points,
|
|
401
|
+
tier: before.tier,
|
|
402
|
+
expired: 0,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
await query(
|
|
407
|
+
"UPDATE loyalty_accounts SET balance_points = balance_points - ?1, " +
|
|
408
|
+
"updated_at = ?2 WHERE customer_id = ?3 AND balance_points >= ?1",
|
|
409
|
+
[toExpire, ts, customerId],
|
|
410
|
+
);
|
|
411
|
+
await _writeTx(customerId, "expire", -toExpire, reason, null, "", ts);
|
|
412
|
+
|
|
413
|
+
var after = await _readAccount(customerId);
|
|
414
|
+
return {
|
|
415
|
+
balance: after.balance_points,
|
|
416
|
+
lifetime: after.lifetime_points,
|
|
417
|
+
tier: after.tier,
|
|
418
|
+
expired: toExpire,
|
|
419
|
+
};
|
|
420
|
+
},
|
|
421
|
+
|
|
422
|
+
balance: async function (customerId) {
|
|
423
|
+
_uuid(customerId, "customer_id");
|
|
424
|
+
var row = await _readAccount(customerId);
|
|
425
|
+
if (!row) {
|
|
426
|
+
return { balance: 0, lifetime: 0, tier: "bronze", tier_expires_at: null };
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
balance: row.balance_points,
|
|
430
|
+
lifetime: row.lifetime_points,
|
|
431
|
+
tier: row.tier,
|
|
432
|
+
tier_expires_at: row.tier_expires_at,
|
|
433
|
+
};
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
history: async function (customerId, opts2) {
|
|
437
|
+
_uuid(customerId, "customer_id");
|
|
438
|
+
opts2 = opts2 || {};
|
|
439
|
+
var limit = opts2.limit != null ? opts2.limit : 50;
|
|
440
|
+
if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 500) {
|
|
441
|
+
throw new TypeError("loyalty.history: limit must be an integer in [1, 500]");
|
|
442
|
+
}
|
|
443
|
+
var cursor = opts2.cursor;
|
|
444
|
+
var sql = "SELECT id, customer_id, transaction_type, points, source, order_id, notes, occurred_at " +
|
|
445
|
+
"FROM loyalty_transactions WHERE customer_id = ?1";
|
|
446
|
+
var params = [customerId];
|
|
447
|
+
if (cursor != null) {
|
|
448
|
+
if (typeof cursor !== "number" || !Number.isInteger(cursor) || cursor < 0) {
|
|
449
|
+
throw new TypeError("loyalty.history: cursor must be a non-negative integer epoch-ms");
|
|
450
|
+
}
|
|
451
|
+
// Cursor is the `occurred_at` of the last row in the previous
|
|
452
|
+
// page — request rows STRICTLY OLDER so a page boundary
|
|
453
|
+
// landing on a tied timestamp doesn't double-return rows.
|
|
454
|
+
sql += " AND occurred_at < ?2";
|
|
455
|
+
params.push(cursor);
|
|
456
|
+
}
|
|
457
|
+
sql += " ORDER BY occurred_at DESC LIMIT ?" + (params.length + 1);
|
|
458
|
+
params.push(limit);
|
|
459
|
+
var r = await query(sql, params);
|
|
460
|
+
var rows = r.rows;
|
|
461
|
+
var nextCursor = rows.length === limit ? rows[rows.length - 1].occurred_at : null;
|
|
462
|
+
return { rows: rows, next_cursor: nextCursor };
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
tierLeaderboard: async function (opts3) {
|
|
466
|
+
opts3 = opts3 || {};
|
|
467
|
+
var limit = opts3.limit != null ? opts3.limit : 10;
|
|
468
|
+
if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 1000) {
|
|
469
|
+
throw new TypeError("loyalty.tierLeaderboard: limit must be an integer in [1, 1000]");
|
|
470
|
+
}
|
|
471
|
+
var sql = "SELECT customer_id, balance_points, lifetime_points, tier, tier_expires_at " +
|
|
472
|
+
"FROM loyalty_accounts";
|
|
473
|
+
var params = [];
|
|
474
|
+
if (opts3.tier != null) {
|
|
475
|
+
if (typeof opts3.tier !== "string" || TIERS.indexOf(opts3.tier) === -1) {
|
|
476
|
+
throw new TypeError("loyalty.tierLeaderboard: tier must be one of " + TIERS.join(", "));
|
|
477
|
+
}
|
|
478
|
+
sql += " WHERE tier = ?1";
|
|
479
|
+
params.push(opts3.tier);
|
|
480
|
+
}
|
|
481
|
+
sql += " ORDER BY lifetime_points DESC, customer_id ASC LIMIT ?" + (params.length + 1);
|
|
482
|
+
params.push(limit);
|
|
483
|
+
var r = await query(sql, params);
|
|
484
|
+
return r.rows;
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
module.exports = {
|
|
490
|
+
create: create,
|
|
491
|
+
TIERS: TIERS,
|
|
492
|
+
TX_TYPES: TX_TYPES,
|
|
493
|
+
DEFAULT_TIER_THRESHOLDS: DEFAULT_TIER_THRESHOLDS,
|
|
494
|
+
DEFAULT_POINTS_PER_USD: DEFAULT_POINTS_PER_USD,
|
|
495
|
+
DEFAULT_REDEMPTION_POINTS_PER_USD: DEFAULT_REDEMPTION_POINTS_PER_USD,
|
|
496
|
+
};
|