@blamejs/blamejs-shop 0.0.61 → 0.0.64
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/compliance-export.js +614 -0
- package/lib/email-campaigns.js +844 -0
- package/lib/error-log.js +525 -0
- package/lib/geolocation.js +651 -0
- package/lib/gift-registry.js +820 -0
- package/lib/index.js +15 -0
- package/lib/invoice-renderer.js +618 -0
- package/lib/live-chat.js +714 -0
- package/lib/loyalty-redemption.js +673 -0
- package/lib/plan-changes.js +508 -0
- package/lib/refund-policy.js +965 -0
- package/lib/sms-dispatcher.js +7 -1
- package/lib/stock-transfers.js +777 -0
- package/lib/store-credit.js +565 -0
- package/lib/storefront-dashboards.js +863 -0
- package/lib/vendors.js +797 -0
- package/package.json +1 -1
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.loyaltyRedemption
|
|
4
|
+
* @title Loyalty redemption — customer-facing redemption layer on top
|
|
5
|
+
* of the `loyalty` points ledger.
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Operators define a reward catalog (discount tier, free product,
|
|
9
|
+
* free shipping); customers redeem points for a catalog item at
|
|
10
|
+
* checkout. This primitive composes:
|
|
11
|
+
*
|
|
12
|
+
* - `loyalty` (required) — points-ledger writes. The redemption
|
|
13
|
+
* debits points via `loyalty.redeem` and refunds them via
|
|
14
|
+
* `loyalty.adjust` on cancellation.
|
|
15
|
+
* - `coupons` (optional) — when injected, redemption mints a
|
|
16
|
+
* single-use coupon code via `coupons.issueSingleUseFromReward`
|
|
17
|
+
* (or whichever handle the operator wires). When absent, the
|
|
18
|
+
* redemption is a points-only event the operator's checkout UI
|
|
19
|
+
* surfaces and applies manually.
|
|
20
|
+
*
|
|
21
|
+
* Surface:
|
|
22
|
+
*
|
|
23
|
+
* - `defineReward({ slug, kind, title, point_cost, value_json,
|
|
24
|
+
* max_per_customer?,
|
|
25
|
+
* expires_days_after_redemption?, active })`
|
|
26
|
+
* Operator-authored catalog row. `kind` is one of
|
|
27
|
+
* `discount_percent`, `discount_amount`, `free_product`,
|
|
28
|
+
* `free_shipping`. Refuses redefinition — operators
|
|
29
|
+
* `updateReward` to mutate an existing slug.
|
|
30
|
+
*
|
|
31
|
+
* - `redeemForCustomer({ customer_id, reward_slug })`
|
|
32
|
+
* Debits points via `loyalty.redeem`, mints a single-use
|
|
33
|
+
* coupon via the injected `coupons` handle when present,
|
|
34
|
+
* writes a `loyalty_redemptions` row at status `active`, and
|
|
35
|
+
* returns `{ redemption_id, coupon_code?, expires_at }`.
|
|
36
|
+
* Refusals:
|
|
37
|
+
* * reward archived or inactive — REWARD_NOT_REDEEMABLE
|
|
38
|
+
* * lifetime cap reached — REDEMPTION_CAP_REACHED
|
|
39
|
+
* * insufficient points — propagated from `loyalty.redeem`
|
|
40
|
+
*
|
|
41
|
+
* - `markConsumed({ redemption_id, order_id })`
|
|
42
|
+
* FSM transition active → consumed. Records the order the
|
|
43
|
+
* redemption settled against. Refuses anything other than the
|
|
44
|
+
* `active` state.
|
|
45
|
+
*
|
|
46
|
+
* - `cancelRedemption({ redemption_id, reason })`
|
|
47
|
+
* FSM transition active → cancelled. Refunds the debited points
|
|
48
|
+
* via `loyalty.adjust(+points)`. A consumed/expired/cancelled
|
|
49
|
+
* redemption is refused — operators issue a manual
|
|
50
|
+
* `loyalty.adjust` row if the points need to come back after
|
|
51
|
+
* consumption.
|
|
52
|
+
*
|
|
53
|
+
* - `getRedemption(redemption_id)` / `redemptionsForCustomer(id,
|
|
54
|
+
* { limit?, cursor? })` — read paths.
|
|
55
|
+
*
|
|
56
|
+
* - `listRewards({ active_only? })` / `updateReward(slug, patch)` /
|
|
57
|
+
* `archiveReward(slug)` — catalog CRUD.
|
|
58
|
+
*
|
|
59
|
+
* Storage: `loyalty_rewards`, `loyalty_redemptions` (migration
|
|
60
|
+
* 0085).
|
|
61
|
+
*
|
|
62
|
+
* @primitive loyaltyRedemption
|
|
63
|
+
* @related shop.loyalty, shop.coupons (optional)
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
var bShop;
|
|
67
|
+
function _b() {
|
|
68
|
+
if (!bShop) bShop = require("./index");
|
|
69
|
+
return bShop.framework;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---- constants ----------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
var KINDS = ["discount_percent", "discount_amount", "free_product", "free_shipping"];
|
|
75
|
+
var STATUSES = ["active", "consumed", "expired", "cancelled"];
|
|
76
|
+
|
|
77
|
+
var MAX_SLUG_LEN = 80;
|
|
78
|
+
var MAX_TITLE_LEN = 200;
|
|
79
|
+
var MAX_REASON_LEN = 256;
|
|
80
|
+
var MAX_LIST_LIMIT = 200;
|
|
81
|
+
var MAX_REDEMPTIONS_LIMIT = 200;
|
|
82
|
+
var MAX_POINT_COST = 100000000;
|
|
83
|
+
var MAX_PER_CUSTOMER = 100000;
|
|
84
|
+
var MAX_EXPIRES_DAYS = 3650;
|
|
85
|
+
var MS_PER_DAY = 86400000;
|
|
86
|
+
|
|
87
|
+
// Slug shape matches the catalog / promo-banners convention — alnum +
|
|
88
|
+
// hyphen + underscore + dot, leading char alnum, capped length.
|
|
89
|
+
var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
90
|
+
|
|
91
|
+
var ALLOWED_PATCH_COLUMNS = Object.freeze([
|
|
92
|
+
"title",
|
|
93
|
+
"point_cost",
|
|
94
|
+
"value_json",
|
|
95
|
+
"max_per_customer",
|
|
96
|
+
"expires_days_after_redemption",
|
|
97
|
+
"active",
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
// `value_json` shape per `kind`. The catalog refuses entries whose
|
|
101
|
+
// payload doesn't satisfy the kind's contract at define / update time
|
|
102
|
+
// so a downstream consumer (the checkout UI, the coupons primitive)
|
|
103
|
+
// can trust the row.
|
|
104
|
+
function _validateValueJsonFor(kind, value) {
|
|
105
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
|
106
|
+
throw new TypeError("loyaltyRedemption: value_json must be a plain object");
|
|
107
|
+
}
|
|
108
|
+
if (kind === "discount_percent") {
|
|
109
|
+
if (!Number.isInteger(value.percent) || value.percent < 1 || value.percent > 100) {
|
|
110
|
+
throw new TypeError("loyaltyRedemption: value_json.percent must be an integer in [1, 100] for discount_percent");
|
|
111
|
+
}
|
|
112
|
+
} else if (kind === "discount_amount") {
|
|
113
|
+
if (!Number.isInteger(value.amount_minor) || value.amount_minor <= 0) {
|
|
114
|
+
throw new TypeError("loyaltyRedemption: value_json.amount_minor must be a positive integer (minor units) for discount_amount");
|
|
115
|
+
}
|
|
116
|
+
} else if (kind === "free_product") {
|
|
117
|
+
if (typeof value.product_id !== "string" || !value.product_id.length) {
|
|
118
|
+
throw new TypeError("loyaltyRedemption: value_json.product_id must be a non-empty string for free_product");
|
|
119
|
+
}
|
|
120
|
+
} else if (kind === "free_shipping") {
|
|
121
|
+
// free_shipping has no per-row payload; an empty object is the
|
|
122
|
+
// contract. Refuse unknown keys so a typo can't silently encode
|
|
123
|
+
// a different intent.
|
|
124
|
+
var keys = Object.keys(value);
|
|
125
|
+
if (keys.length !== 0) {
|
|
126
|
+
throw new TypeError("loyaltyRedemption: value_json for free_shipping must be an empty object");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- validators ---------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
function _uuid(s, label) {
|
|
134
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
135
|
+
catch (e) { throw new TypeError("loyaltyRedemption: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function _slug(s) {
|
|
139
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
140
|
+
throw new TypeError("loyaltyRedemption: slug must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_SLUG_LEN + " chars)");
|
|
141
|
+
}
|
|
142
|
+
return s;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function _kind(s) {
|
|
146
|
+
if (typeof s !== "string" || KINDS.indexOf(s) === -1) {
|
|
147
|
+
throw new TypeError("loyaltyRedemption: kind must be one of " + KINDS.join(", "));
|
|
148
|
+
}
|
|
149
|
+
return s;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function _title(s) {
|
|
153
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
|
|
154
|
+
throw new TypeError("loyaltyRedemption: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
|
|
155
|
+
}
|
|
156
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
157
|
+
throw new TypeError("loyaltyRedemption: title must not contain control bytes");
|
|
158
|
+
}
|
|
159
|
+
return s;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function _pointCost(n) {
|
|
163
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_POINT_COST) {
|
|
164
|
+
throw new TypeError("loyaltyRedemption: point_cost must be a positive integer <= " + MAX_POINT_COST);
|
|
165
|
+
}
|
|
166
|
+
return n;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function _maxPerCustomer(n) {
|
|
170
|
+
if (n == null) return null;
|
|
171
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_PER_CUSTOMER) {
|
|
172
|
+
throw new TypeError("loyaltyRedemption: max_per_customer must be a positive integer <= " + MAX_PER_CUSTOMER + " (or null)");
|
|
173
|
+
}
|
|
174
|
+
return n;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function _expiresDays(n) {
|
|
178
|
+
if (n == null) return null;
|
|
179
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_EXPIRES_DAYS) {
|
|
180
|
+
throw new TypeError("loyaltyRedemption: expires_days_after_redemption must be a positive integer <= " + MAX_EXPIRES_DAYS + " (or null)");
|
|
181
|
+
}
|
|
182
|
+
return n;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function _active(v) {
|
|
186
|
+
if (typeof v !== "boolean") {
|
|
187
|
+
throw new TypeError("loyaltyRedemption: active must be a boolean");
|
|
188
|
+
}
|
|
189
|
+
return v;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function _reason(s) {
|
|
193
|
+
if (typeof s !== "string" || !s.length) {
|
|
194
|
+
throw new TypeError("loyaltyRedemption: reason must be a non-empty string");
|
|
195
|
+
}
|
|
196
|
+
if (s.length > MAX_REASON_LEN) {
|
|
197
|
+
throw new TypeError("loyaltyRedemption: reason must be <= " + MAX_REASON_LEN + " chars");
|
|
198
|
+
}
|
|
199
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) {
|
|
200
|
+
throw new TypeError("loyaltyRedemption: reason must not contain control bytes");
|
|
201
|
+
}
|
|
202
|
+
return s;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function _now() { return Date.now(); }
|
|
206
|
+
|
|
207
|
+
// ---- row hydration ------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
function _safeParseObject(s, fallback) {
|
|
210
|
+
if (s == null) return fallback;
|
|
211
|
+
try {
|
|
212
|
+
var parsed = JSON.parse(s);
|
|
213
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
214
|
+
return fallback;
|
|
215
|
+
} catch (_e) {
|
|
216
|
+
return fallback;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function _hydrateReward(r) {
|
|
221
|
+
if (!r) return null;
|
|
222
|
+
return {
|
|
223
|
+
slug: r.slug,
|
|
224
|
+
kind: r.kind,
|
|
225
|
+
title: r.title,
|
|
226
|
+
point_cost: Number(r.point_cost),
|
|
227
|
+
value_json: _safeParseObject(r.value_json, {}),
|
|
228
|
+
max_per_customer: r.max_per_customer == null ? null : Number(r.max_per_customer),
|
|
229
|
+
expires_days_after_redemption: r.expires_days_after_redemption == null ? null : Number(r.expires_days_after_redemption),
|
|
230
|
+
active: r.active === 1 || r.active === true,
|
|
231
|
+
archived_at: r.archived_at == null ? null : Number(r.archived_at),
|
|
232
|
+
created_at: Number(r.created_at),
|
|
233
|
+
updated_at: Number(r.updated_at),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function _hydrateRedemption(r) {
|
|
238
|
+
if (!r) return null;
|
|
239
|
+
return {
|
|
240
|
+
id: r.id,
|
|
241
|
+
customer_id: r.customer_id,
|
|
242
|
+
reward_slug: r.reward_slug,
|
|
243
|
+
points_debited: Number(r.points_debited),
|
|
244
|
+
coupon_code: r.coupon_code == null ? null : r.coupon_code,
|
|
245
|
+
status: r.status,
|
|
246
|
+
redeemed_at: Number(r.redeemed_at),
|
|
247
|
+
consumed_at: r.consumed_at == null ? null : Number(r.consumed_at),
|
|
248
|
+
expires_at: r.expires_at == null ? null : Number(r.expires_at),
|
|
249
|
+
order_id: r.order_id == null ? null : r.order_id,
|
|
250
|
+
cancel_reason: r.cancel_reason == null ? null : r.cancel_reason,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---- factory ------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
function create(opts) {
|
|
257
|
+
opts = opts || {};
|
|
258
|
+
var query = opts.query;
|
|
259
|
+
if (!query) {
|
|
260
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
var loyalty = opts.loyalty;
|
|
264
|
+
if (!loyalty || typeof loyalty.redeem !== "function" || typeof loyalty.adjust !== "function") {
|
|
265
|
+
throw new TypeError("loyaltyRedemption: opts.loyalty handle required (must expose redeem + adjust)");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Optional coupons handle. When wired, expected shape is
|
|
269
|
+
// `issueSingleUseFromReward({ customer_id, reward_slug, value_json,
|
|
270
|
+
// expires_at? }) -> Promise<{ code }>`. Absent that, the redemption
|
|
271
|
+
// proceeds without a coupon_code — operators surface the
|
|
272
|
+
// redemption_id in their checkout UI and apply the discount
|
|
273
|
+
// manually. Refuse a handle that's not shaped as expected up front
|
|
274
|
+
// so a typo doesn't surface deep inside a redeem call.
|
|
275
|
+
var coupons = opts.coupons || null;
|
|
276
|
+
if (coupons != null && typeof coupons.issueSingleUseFromReward !== "function") {
|
|
277
|
+
throw new TypeError("loyaltyRedemption: opts.coupons handle must expose issueSingleUseFromReward(...)");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---- defineReward --------------------------------------------------
|
|
281
|
+
|
|
282
|
+
async function defineReward(input) {
|
|
283
|
+
if (!input || typeof input !== "object") {
|
|
284
|
+
throw new TypeError("loyaltyRedemption.defineReward: input object required");
|
|
285
|
+
}
|
|
286
|
+
var slug = _slug(input.slug);
|
|
287
|
+
var kind = _kind(input.kind);
|
|
288
|
+
var title = _title(input.title);
|
|
289
|
+
var pointCost = _pointCost(input.point_cost);
|
|
290
|
+
_validateValueJsonFor(kind, input.value_json);
|
|
291
|
+
var maxPerCustomer = _maxPerCustomer(input.max_per_customer);
|
|
292
|
+
var expiresDays = _expiresDays(input.expires_days_after_redemption);
|
|
293
|
+
var active = _active(input.active);
|
|
294
|
+
|
|
295
|
+
// Refuse a redefine — operators should `updateReward` to mutate.
|
|
296
|
+
var existing = (await query(
|
|
297
|
+
"SELECT slug FROM loyalty_rewards WHERE slug = ?1 LIMIT 1",
|
|
298
|
+
[slug],
|
|
299
|
+
)).rows[0];
|
|
300
|
+
if (existing) {
|
|
301
|
+
throw new TypeError("loyaltyRedemption.defineReward: slug " + JSON.stringify(slug) + " already exists — use updateReward");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
var ts = _now();
|
|
305
|
+
await query(
|
|
306
|
+
"INSERT INTO loyalty_rewards (slug, kind, title, point_cost, value_json, max_per_customer, " +
|
|
307
|
+
"expires_days_after_redemption, active, archived_at, created_at, updated_at) " +
|
|
308
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?9)",
|
|
309
|
+
[
|
|
310
|
+
slug, kind, title, pointCost, JSON.stringify(input.value_json),
|
|
311
|
+
maxPerCustomer, expiresDays, active ? 1 : 0, ts,
|
|
312
|
+
],
|
|
313
|
+
);
|
|
314
|
+
return await getReward(slug);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---- getReward / listRewards ---------------------------------------
|
|
318
|
+
|
|
319
|
+
async function getReward(slug) {
|
|
320
|
+
_slug(slug);
|
|
321
|
+
var r = (await query(
|
|
322
|
+
"SELECT * FROM loyalty_rewards WHERE slug = ?1 LIMIT 1",
|
|
323
|
+
[slug],
|
|
324
|
+
)).rows[0];
|
|
325
|
+
return _hydrateReward(r);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function listRewards(listOpts) {
|
|
329
|
+
listOpts = listOpts || {};
|
|
330
|
+
var activeOnly = false;
|
|
331
|
+
if (listOpts.active_only != null) {
|
|
332
|
+
if (typeof listOpts.active_only !== "boolean") {
|
|
333
|
+
throw new TypeError("loyaltyRedemption.listRewards: active_only must be a boolean");
|
|
334
|
+
}
|
|
335
|
+
activeOnly = listOpts.active_only;
|
|
336
|
+
}
|
|
337
|
+
var limit = listOpts.limit == null ? 50 : listOpts.limit;
|
|
338
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
|
|
339
|
+
throw new TypeError("loyaltyRedemption.listRewards: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
|
|
340
|
+
}
|
|
341
|
+
var sql, params;
|
|
342
|
+
if (activeOnly) {
|
|
343
|
+
sql = "SELECT * FROM loyalty_rewards WHERE active = 1 AND archived_at IS NULL " +
|
|
344
|
+
"ORDER BY point_cost ASC, slug ASC LIMIT ?1";
|
|
345
|
+
params = [limit];
|
|
346
|
+
} else {
|
|
347
|
+
sql = "SELECT * FROM loyalty_rewards ORDER BY created_at DESC, slug ASC LIMIT ?1";
|
|
348
|
+
params = [limit];
|
|
349
|
+
}
|
|
350
|
+
var rows = (await query(sql, params)).rows;
|
|
351
|
+
var out = [];
|
|
352
|
+
for (var i = 0; i < rows.length; i += 1) out.push(_hydrateReward(rows[i]));
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---- updateReward --------------------------------------------------
|
|
357
|
+
|
|
358
|
+
async function updateReward(slug, patch) {
|
|
359
|
+
_slug(slug);
|
|
360
|
+
if (!patch || typeof patch !== "object") {
|
|
361
|
+
throw new TypeError("loyaltyRedemption.updateReward: patch object required");
|
|
362
|
+
}
|
|
363
|
+
var keys = Object.keys(patch);
|
|
364
|
+
if (!keys.length) {
|
|
365
|
+
throw new TypeError("loyaltyRedemption.updateReward: patch must include at least one column");
|
|
366
|
+
}
|
|
367
|
+
var current = await getReward(slug);
|
|
368
|
+
if (!current) {
|
|
369
|
+
throw new TypeError("loyaltyRedemption.updateReward: slug " + JSON.stringify(slug) + " not found");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
var sets = [];
|
|
373
|
+
var params = [];
|
|
374
|
+
var idx = 1;
|
|
375
|
+
|
|
376
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
377
|
+
var col = keys[i];
|
|
378
|
+
if (ALLOWED_PATCH_COLUMNS.indexOf(col) === -1) {
|
|
379
|
+
throw new TypeError("loyaltyRedemption.updateReward: unsupported column " + JSON.stringify(col));
|
|
380
|
+
}
|
|
381
|
+
if (col === "title") {
|
|
382
|
+
sets.push("title = ?" + idx);
|
|
383
|
+
params.push(_title(patch[col]));
|
|
384
|
+
} else if (col === "point_cost") {
|
|
385
|
+
sets.push("point_cost = ?" + idx);
|
|
386
|
+
params.push(_pointCost(patch[col]));
|
|
387
|
+
} else if (col === "value_json") {
|
|
388
|
+
_validateValueJsonFor(current.kind, patch[col]);
|
|
389
|
+
sets.push("value_json = ?" + idx);
|
|
390
|
+
params.push(JSON.stringify(patch[col]));
|
|
391
|
+
} else if (col === "max_per_customer") {
|
|
392
|
+
sets.push("max_per_customer = ?" + idx);
|
|
393
|
+
params.push(_maxPerCustomer(patch[col]));
|
|
394
|
+
} else if (col === "expires_days_after_redemption") {
|
|
395
|
+
sets.push("expires_days_after_redemption = ?" + idx);
|
|
396
|
+
params.push(_expiresDays(patch[col]));
|
|
397
|
+
} else /* active */ {
|
|
398
|
+
sets.push("active = ?" + idx);
|
|
399
|
+
params.push(_active(patch[col]) ? 1 : 0);
|
|
400
|
+
}
|
|
401
|
+
idx += 1;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
sets.push("updated_at = ?" + idx);
|
|
405
|
+
params.push(_now());
|
|
406
|
+
idx += 1;
|
|
407
|
+
params.push(slug);
|
|
408
|
+
|
|
409
|
+
var r = await query(
|
|
410
|
+
"UPDATE loyalty_rewards SET " + sets.join(", ") + " WHERE slug = ?" + idx,
|
|
411
|
+
params,
|
|
412
|
+
);
|
|
413
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
414
|
+
throw new TypeError("loyaltyRedemption.updateReward: slug " + JSON.stringify(slug) + " not found");
|
|
415
|
+
}
|
|
416
|
+
return await getReward(slug);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---- archiveReward -------------------------------------------------
|
|
420
|
+
|
|
421
|
+
async function archiveReward(slug) {
|
|
422
|
+
_slug(slug);
|
|
423
|
+
var ts = _now();
|
|
424
|
+
var r = await query(
|
|
425
|
+
"UPDATE loyalty_rewards SET archived_at = ?1, active = 0, updated_at = ?1 " +
|
|
426
|
+
"WHERE slug = ?2 AND archived_at IS NULL",
|
|
427
|
+
[ts, slug],
|
|
428
|
+
);
|
|
429
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
430
|
+
var existing = await getReward(slug);
|
|
431
|
+
if (!existing) {
|
|
432
|
+
throw new TypeError("loyaltyRedemption.archiveReward: slug " + JSON.stringify(slug) + " not found");
|
|
433
|
+
}
|
|
434
|
+
// Already archived — return idempotently.
|
|
435
|
+
return existing;
|
|
436
|
+
}
|
|
437
|
+
return await getReward(slug);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ---- redeemForCustomer ---------------------------------------------
|
|
441
|
+
|
|
442
|
+
async function _countActiveOrConsumedForCustomer(customerId, rewardSlug) {
|
|
443
|
+
var r = await query(
|
|
444
|
+
"SELECT COUNT(*) AS n FROM loyalty_redemptions " +
|
|
445
|
+
"WHERE customer_id = ?1 AND reward_slug = ?2 AND status IN ('active','consumed')",
|
|
446
|
+
[customerId, rewardSlug],
|
|
447
|
+
);
|
|
448
|
+
var row = r.rows[0] || { n: 0 };
|
|
449
|
+
return Number(row.n || row.N || 0);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function redeemForCustomer(input) {
|
|
453
|
+
if (!input || typeof input !== "object") {
|
|
454
|
+
throw new TypeError("loyaltyRedemption.redeemForCustomer: input object required");
|
|
455
|
+
}
|
|
456
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
457
|
+
var rewardSlug = _slug(input.reward_slug);
|
|
458
|
+
|
|
459
|
+
var reward = await getReward(rewardSlug);
|
|
460
|
+
if (!reward) {
|
|
461
|
+
throw new TypeError("loyaltyRedemption.redeemForCustomer: reward_slug " + JSON.stringify(rewardSlug) + " not found");
|
|
462
|
+
}
|
|
463
|
+
if (reward.archived_at != null || reward.active === false) {
|
|
464
|
+
var nope = new Error("loyaltyRedemption.redeemForCustomer: reward not currently redeemable");
|
|
465
|
+
nope.code = "REWARD_NOT_REDEEMABLE";
|
|
466
|
+
throw nope;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (reward.max_per_customer != null) {
|
|
470
|
+
var count = await _countActiveOrConsumedForCustomer(customerId, rewardSlug);
|
|
471
|
+
if (count >= reward.max_per_customer) {
|
|
472
|
+
var cap = new Error("loyaltyRedemption.redeemForCustomer: customer has reached the per-customer redemption cap");
|
|
473
|
+
cap.code = "REDEMPTION_CAP_REACHED";
|
|
474
|
+
throw cap;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Debit points via the composed loyalty primitive. The redeem
|
|
479
|
+
// call surfaces LOYALTY_INSUFFICIENT_BALANCE on its own when the
|
|
480
|
+
// customer can't afford the reward; that error propagates as-is.
|
|
481
|
+
await loyalty.redeem({
|
|
482
|
+
customer_id: customerId,
|
|
483
|
+
points: reward.point_cost,
|
|
484
|
+
notes: "redeem:" + rewardSlug,
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
var redemptionId = _b().uuid.v7();
|
|
488
|
+
var ts = _now();
|
|
489
|
+
var expiresAt = reward.expires_days_after_redemption == null
|
|
490
|
+
? null
|
|
491
|
+
: ts + (reward.expires_days_after_redemption * MS_PER_DAY);
|
|
492
|
+
|
|
493
|
+
var couponCode = null;
|
|
494
|
+
if (coupons) {
|
|
495
|
+
var minted = await coupons.issueSingleUseFromReward({
|
|
496
|
+
customer_id: customerId,
|
|
497
|
+
reward_slug: rewardSlug,
|
|
498
|
+
value_json: reward.value_json,
|
|
499
|
+
expires_at: expiresAt,
|
|
500
|
+
});
|
|
501
|
+
if (!minted || typeof minted.code !== "string" || !minted.code.length) {
|
|
502
|
+
throw new TypeError("loyaltyRedemption.redeemForCustomer: coupons.issueSingleUseFromReward must return { code: string }");
|
|
503
|
+
}
|
|
504
|
+
couponCode = minted.code;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
await query(
|
|
508
|
+
"INSERT INTO loyalty_redemptions (id, customer_id, reward_slug, points_debited, coupon_code, " +
|
|
509
|
+
"status, redeemed_at, consumed_at, expires_at, order_id, cancel_reason) " +
|
|
510
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, 'active', ?6, NULL, ?7, NULL, NULL)",
|
|
511
|
+
[redemptionId, customerId, rewardSlug, reward.point_cost, couponCode, ts, expiresAt],
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
return {
|
|
515
|
+
redemption_id: redemptionId,
|
|
516
|
+
coupon_code: couponCode,
|
|
517
|
+
expires_at: expiresAt,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ---- getRedemption / redemptionsForCustomer ------------------------
|
|
522
|
+
|
|
523
|
+
async function getRedemption(redemptionId) {
|
|
524
|
+
_uuid(redemptionId, "redemption_id");
|
|
525
|
+
var r = (await query(
|
|
526
|
+
"SELECT * FROM loyalty_redemptions WHERE id = ?1 LIMIT 1",
|
|
527
|
+
[redemptionId],
|
|
528
|
+
)).rows[0];
|
|
529
|
+
return _hydrateRedemption(r);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async function redemptionsForCustomer(customerId, listOpts) {
|
|
533
|
+
_uuid(customerId, "customer_id");
|
|
534
|
+
listOpts = listOpts || {};
|
|
535
|
+
var limit = listOpts.limit == null ? 50 : listOpts.limit;
|
|
536
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_REDEMPTIONS_LIMIT) {
|
|
537
|
+
throw new TypeError("loyaltyRedemption.redemptionsForCustomer: limit must be an integer in [1, " + MAX_REDEMPTIONS_LIMIT + "]");
|
|
538
|
+
}
|
|
539
|
+
var sql = "SELECT * FROM loyalty_redemptions WHERE customer_id = ?1";
|
|
540
|
+
var params = [customerId];
|
|
541
|
+
if (listOpts.cursor != null) {
|
|
542
|
+
if (!Number.isInteger(listOpts.cursor) || listOpts.cursor < 0) {
|
|
543
|
+
throw new TypeError("loyaltyRedemption.redemptionsForCustomer: cursor must be a non-negative integer epoch-ms");
|
|
544
|
+
}
|
|
545
|
+
sql += " AND redeemed_at < ?2";
|
|
546
|
+
params.push(listOpts.cursor);
|
|
547
|
+
}
|
|
548
|
+
sql += " ORDER BY redeemed_at DESC, id DESC LIMIT ?" + (params.length + 1);
|
|
549
|
+
params.push(limit);
|
|
550
|
+
var rows = (await query(sql, params)).rows;
|
|
551
|
+
var hydrated = [];
|
|
552
|
+
for (var i = 0; i < rows.length; i += 1) hydrated.push(_hydrateRedemption(rows[i]));
|
|
553
|
+
var nextCursor = rows.length === limit ? hydrated[hydrated.length - 1].redeemed_at : null;
|
|
554
|
+
return { rows: hydrated, next_cursor: nextCursor };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// ---- markConsumed --------------------------------------------------
|
|
558
|
+
|
|
559
|
+
async function markConsumed(input) {
|
|
560
|
+
if (!input || typeof input !== "object") {
|
|
561
|
+
throw new TypeError("loyaltyRedemption.markConsumed: input object required");
|
|
562
|
+
}
|
|
563
|
+
var redemptionId = _uuid(input.redemption_id, "redemption_id");
|
|
564
|
+
var orderId = _uuid(input.order_id, "order_id");
|
|
565
|
+
|
|
566
|
+
var current = await getRedemption(redemptionId);
|
|
567
|
+
if (!current) {
|
|
568
|
+
throw new TypeError("loyaltyRedemption.markConsumed: redemption_id " + JSON.stringify(redemptionId) + " not found");
|
|
569
|
+
}
|
|
570
|
+
if (current.status !== "active") {
|
|
571
|
+
var bad = new Error("loyaltyRedemption.markConsumed: redemption status is '" + current.status + "', only 'active' may be consumed");
|
|
572
|
+
bad.code = "REDEMPTION_NOT_ACTIVE";
|
|
573
|
+
throw bad;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
var ts = _now();
|
|
577
|
+
var r = await query(
|
|
578
|
+
"UPDATE loyalty_redemptions SET status = 'consumed', consumed_at = ?1, order_id = ?2 " +
|
|
579
|
+
"WHERE id = ?3 AND status = 'active'",
|
|
580
|
+
[ts, orderId, redemptionId],
|
|
581
|
+
);
|
|
582
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
583
|
+
// Lost the race — re-read so the caller gets the row's actual
|
|
584
|
+
// post-race state. Refuse with the same FSM error so the call
|
|
585
|
+
// surface is identical whether the loser noticed pre- or
|
|
586
|
+
// post-SQL.
|
|
587
|
+
var raceAfter = await getRedemption(redemptionId);
|
|
588
|
+
var raceErr = new Error("loyaltyRedemption.markConsumed: redemption status is '" + (raceAfter && raceAfter.status) + "', only 'active' may be consumed");
|
|
589
|
+
raceErr.code = "REDEMPTION_NOT_ACTIVE";
|
|
590
|
+
throw raceErr;
|
|
591
|
+
}
|
|
592
|
+
return await getRedemption(redemptionId);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ---- cancelRedemption ----------------------------------------------
|
|
596
|
+
|
|
597
|
+
async function cancelRedemption(input) {
|
|
598
|
+
if (!input || typeof input !== "object") {
|
|
599
|
+
throw new TypeError("loyaltyRedemption.cancelRedemption: input object required");
|
|
600
|
+
}
|
|
601
|
+
var redemptionId = _uuid(input.redemption_id, "redemption_id");
|
|
602
|
+
var reason = _reason(input.reason);
|
|
603
|
+
|
|
604
|
+
var current = await getRedemption(redemptionId);
|
|
605
|
+
if (!current) {
|
|
606
|
+
throw new TypeError("loyaltyRedemption.cancelRedemption: redemption_id " + JSON.stringify(redemptionId) + " not found");
|
|
607
|
+
}
|
|
608
|
+
if (current.status !== "active") {
|
|
609
|
+
// Only an `active` redemption is refundable through this
|
|
610
|
+
// primitive. A consumed redemption is settled against an
|
|
611
|
+
// order; an expired redemption forfeited the points on
|
|
612
|
+
// purpose; a cancelled redemption already refunded. Operators
|
|
613
|
+
// needing to claw back points after consumption issue a
|
|
614
|
+
// manual `loyalty.adjust(-points)` row.
|
|
615
|
+
var bad = new Error("loyaltyRedemption.cancelRedemption: redemption status is '" + current.status + "', only 'active' may be cancelled with a point refund");
|
|
616
|
+
bad.code = "REDEMPTION_NOT_ACTIVE";
|
|
617
|
+
throw bad;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
var ts = _now();
|
|
621
|
+
var r = await query(
|
|
622
|
+
"UPDATE loyalty_redemptions SET status = 'cancelled', cancel_reason = ?1 " +
|
|
623
|
+
"WHERE id = ?2 AND status = 'active'",
|
|
624
|
+
[reason, redemptionId],
|
|
625
|
+
);
|
|
626
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
627
|
+
var raceAfter = await getRedemption(redemptionId);
|
|
628
|
+
var raceErr = new Error("loyaltyRedemption.cancelRedemption: redemption status is '" + (raceAfter && raceAfter.status) + "', only 'active' may be cancelled with a point refund");
|
|
629
|
+
raceErr.code = "REDEMPTION_NOT_ACTIVE";
|
|
630
|
+
throw raceErr;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Refund the debited points via loyalty.adjust. Positive
|
|
634
|
+
// delta — `loyalty.adjust` will credit lifetime as well, which
|
|
635
|
+
// matches the customer's lived experience (the redemption never
|
|
636
|
+
// happened, the points come back, the tier-driving lifetime
|
|
637
|
+
// total returns to where it was). Note that the underlying
|
|
638
|
+
// `loyalty.adjust` lifetime semantics only credit on positive
|
|
639
|
+
// delta, which is exactly what we want here.
|
|
640
|
+
await loyalty.adjust({
|
|
641
|
+
customer_id: current.customer_id,
|
|
642
|
+
points: current.points_debited,
|
|
643
|
+
source: "redemption-refund",
|
|
644
|
+
notes: "redemption:" + redemptionId + " " + reason,
|
|
645
|
+
});
|
|
646
|
+
void ts;
|
|
647
|
+
|
|
648
|
+
return await getRedemption(redemptionId);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
return {
|
|
652
|
+
KINDS: KINDS.slice(),
|
|
653
|
+
STATUSES: STATUSES.slice(),
|
|
654
|
+
|
|
655
|
+
defineReward: defineReward,
|
|
656
|
+
getReward: getReward,
|
|
657
|
+
listRewards: listRewards,
|
|
658
|
+
updateReward: updateReward,
|
|
659
|
+
archiveReward: archiveReward,
|
|
660
|
+
|
|
661
|
+
redeemForCustomer: redeemForCustomer,
|
|
662
|
+
getRedemption: getRedemption,
|
|
663
|
+
redemptionsForCustomer: redemptionsForCustomer,
|
|
664
|
+
markConsumed: markConsumed,
|
|
665
|
+
cancelRedemption: cancelRedemption,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
module.exports = {
|
|
670
|
+
create: create,
|
|
671
|
+
KINDS: KINDS,
|
|
672
|
+
STATUSES: STATUSES,
|
|
673
|
+
};
|