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