@blamejs/blamejs-shop 0.0.72 → 0.0.75

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. package/package.json +1 -1
@@ -0,0 +1,834 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.stockReceipts
4
+ * @title Stock receipts — customer-facing proof-of-receipt via QR
5
+ *
6
+ * @intro
7
+ * Every shipped order's packing slip prints a QR code that resolves
8
+ * to a single-use plaintext token. When the customer scans the QR on
9
+ * arrival, the storefront walks them through a checklist of the
10
+ * order's line items: confirm received, flag damaged, or leave a
11
+ * line pending until they finish unpacking. The primitive records
12
+ * every scan event (audit trail), tracks per-line state with
13
+ * partial-quantity support, and on `completeReceipt` composes the
14
+ * optional `loyaltyEarnRules` handle to award goods-received points.
15
+ *
16
+ * FSM (receipt token):
17
+ *
18
+ * issued --recordReceiptScan--> scanned --completeReceipt--> completed
19
+ * \ ^
20
+ * \--(expires_at < now)--> expired |
21
+ *
22
+ * `completed` and `expired` are terminal. The first scan flips the
23
+ * row to `scanned`; subsequent scans append to the event log
24
+ * without re-flipping the FSM. `completeReceipt` refuses on
25
+ * `issued` (the customer must scan first) and on `expired` /
26
+ * `completed`.
27
+ *
28
+ * FSM (per-line state):
29
+ *
30
+ * pending --markLineReceived--> received
31
+ * \--markLineDamaged --> damaged
32
+ * \--mixed quantities--> partial (when received > 0 AND damaged > 0)
33
+ *
34
+ * Token plaintext:
35
+ * 32 random bytes from `b.crypto.generateBytes`, rendered as 43-
36
+ * char base64url (no padding). Returned EXACTLY ONCE from
37
+ * `issueReceiptToken`; only the SHA3-512 namespace-hash lands on
38
+ * `stock_receipt_tokens.token_hash`. `recordReceiptScan` re-hashes
39
+ * the presented token and looks up — wrong tokens surface as
40
+ * not-found rather than leaking timing information.
41
+ *
42
+ * Composes:
43
+ * - `b.crypto.generateBytes` — uniform 32-byte plaintext draw.
44
+ * - `b.crypto.namespaceHash` — SHA3-512 hash under the
45
+ * `stock-receipt-token` namespace,
46
+ * and per-scan UA / IP hashing so
47
+ * audit reads don't carry raw PII.
48
+ * - `b.crypto.timingSafeEqual` — constant-time hex compare on
49
+ * recordReceiptScan.
50
+ * - `b.guardUuid` — strict UUID gate on order_id /
51
+ * receipt id reads.
52
+ * - `b.uuid.v7` — receipt token + scan row PKs
53
+ * (lexicographic + monotonic so
54
+ * audit reads sort cleanly).
55
+ * - `loyaltyEarnRules` (optional) — when wired, `completeReceipt`
56
+ * calls `evaluateForEvent({
57
+ * trigger: "per_purchase", ... })`
58
+ * once the line checklist is
59
+ * closed. Failures are drop-silent
60
+ * — the receipt is complete
61
+ * regardless of whether the points
62
+ * landed.
63
+ *
64
+ * Storage: `migrations-d1/0177_stock_receipts.sql` —
65
+ * `stock_receipt_tokens` + `stock_receipt_scans` (FK CASCADE) +
66
+ * `stock_receipt_line_states` (FK CASCADE).
67
+ *
68
+ * @primitive stockReceipts
69
+ * @related b.crypto, b.uuid, b.guardUuid, loyaltyEarnRules
70
+ */
71
+
72
+ var bShop;
73
+ function _b() {
74
+ if (!bShop) bShop = require("./index");
75
+ return bShop.framework;
76
+ }
77
+
78
+ // ---- constants ----------------------------------------------------------
79
+
80
+ var TOKEN_NAMESPACE = "stock-receipt-token";
81
+ var UA_NAMESPACE = "stock-receipt-user-agent";
82
+ var IP_NAMESPACE = "stock-receipt-client-ip";
83
+
84
+ var TOKEN_BYTE_LEN = 32;
85
+ var TOKEN_PLAINTEXT_LEN = 43;
86
+ var TOKEN_PLAINTEXT_RE = /^[A-Za-z0-9_-]{43}$/;
87
+
88
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
89
+ var MAX_REASON_LEN = 500;
90
+ var MAX_UA_LEN = 1024;
91
+ var MAX_IP_LEN = 64;
92
+ var MAX_LIST_LIMIT = 500;
93
+ var MAX_LINES = 200;
94
+
95
+ var DEFAULT_EXPIRES_HOURS = 24 * 30; // 30 days
96
+ var MIN_EXPIRES_HOURS = 1;
97
+ var MAX_EXPIRES_HOURS = 24 * 365; // 1 year
98
+
99
+ var RECEIPT_STATUSES = Object.freeze([
100
+ "issued", "scanned", "completed", "expired",
101
+ ]);
102
+ var LINE_STATES = Object.freeze([
103
+ "pending", "received", "damaged", "partial",
104
+ ]);
105
+
106
+ var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
107
+
108
+ // ---- monotonic clock ----------------------------------------------------
109
+ //
110
+ // FSM transitions + scan events land on epoch-ms timestamps. The
111
+ // receipt's first scan can land in the same millisecond as a follow-
112
+ // up scan from a re-opened tab; strict-monotonic ordering guarantees
113
+ // `scanned_at` is distinct row-by-row so a chronological dashboard
114
+ // sort returns events in the order they were issued.
115
+ var _lastTs = 0;
116
+ function _now() {
117
+ var t = Date.now();
118
+ if (t <= _lastTs) { t = _lastTs + 1; }
119
+ _lastTs = t;
120
+ return t;
121
+ }
122
+
123
+ // ---- validators ---------------------------------------------------------
124
+
125
+ function _orderId(s) {
126
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
127
+ catch (e) { throw new TypeError("stockReceipts: order_id — " + (e && e.message || "invalid UUID")); }
128
+ }
129
+
130
+ function _uuid(s, label) {
131
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
132
+ catch (e) { throw new TypeError("stockReceipts: " + label + " — " + (e && e.message || "invalid UUID")); }
133
+ }
134
+
135
+ function _sku(s) {
136
+ if (typeof s !== "string" || !SKU_RE.test(s)) {
137
+ throw new TypeError("stockReceipts: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
138
+ }
139
+ return s;
140
+ }
141
+
142
+ function _positiveInt(n, label) {
143
+ if (!Number.isInteger(n) || n <= 0) {
144
+ throw new TypeError("stockReceipts: " + label + " must be a positive integer");
145
+ }
146
+ return n;
147
+ }
148
+
149
+ function _nonNegInt(n, label) {
150
+ if (!Number.isInteger(n) || n < 0) {
151
+ throw new TypeError("stockReceipts: " + label + " must be a non-negative integer");
152
+ }
153
+ return n;
154
+ }
155
+
156
+ function _expiresInHours(n) {
157
+ if (n == null) return DEFAULT_EXPIRES_HOURS;
158
+ if (!Number.isInteger(n) || n < MIN_EXPIRES_HOURS || n > MAX_EXPIRES_HOURS) {
159
+ throw new TypeError("stockReceipts: expires_in_hours must be an integer in [" +
160
+ MIN_EXPIRES_HOURS + ", " + MAX_EXPIRES_HOURS + "]");
161
+ }
162
+ return n;
163
+ }
164
+
165
+ function _limit(n) {
166
+ if (n == null) return 50;
167
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
168
+ throw new TypeError("stockReceipts: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
169
+ }
170
+ return n;
171
+ }
172
+
173
+ function _reason(s) {
174
+ if (typeof s !== "string" || !s.length || s.length > MAX_REASON_LEN) {
175
+ throw new TypeError("stockReceipts: reason must be a non-empty string <= " + MAX_REASON_LEN + " chars");
176
+ }
177
+ if (CONTROL_BYTE_RE.test(s)) {
178
+ throw new TypeError("stockReceipts: reason must not contain control bytes");
179
+ }
180
+ return s;
181
+ }
182
+
183
+ function _canonicalToken(input) {
184
+ if (typeof input !== "string" || !input.length) {
185
+ throw new TypeError("stockReceipts: token must be a non-empty string");
186
+ }
187
+ if (!TOKEN_PLAINTEXT_RE.test(input)) {
188
+ throw new TypeError("stockReceipts: token must be 43 base64url characters");
189
+ }
190
+ return input;
191
+ }
192
+
193
+ function _uaOpt(s) {
194
+ if (s == null) return null;
195
+ if (typeof s !== "string" || s.length > MAX_UA_LEN) {
196
+ throw new TypeError("stockReceipts: user_agent must be a string <= " + MAX_UA_LEN + " chars");
197
+ }
198
+ if (CONTROL_BYTE_RE.test(s)) {
199
+ throw new TypeError("stockReceipts: user_agent must not contain control bytes");
200
+ }
201
+ return s;
202
+ }
203
+
204
+ function _ipOpt(s) {
205
+ if (s == null) return null;
206
+ if (typeof s !== "string" || s.length > MAX_IP_LEN) {
207
+ throw new TypeError("stockReceipts: client_ip must be a string <= " + MAX_IP_LEN + " chars");
208
+ }
209
+ if (CONTROL_BYTE_RE.test(s)) {
210
+ throw new TypeError("stockReceipts: client_ip must not contain control bytes");
211
+ }
212
+ return s;
213
+ }
214
+
215
+ function _linesArray(arr) {
216
+ if (!Array.isArray(arr) || arr.length === 0) {
217
+ throw new TypeError("stockReceipts: lines must be a non-empty array");
218
+ }
219
+ if (arr.length > MAX_LINES) {
220
+ throw new TypeError("stockReceipts: lines must contain <= " + MAX_LINES + " entries");
221
+ }
222
+ var seen = Object.create(null);
223
+ var out = [];
224
+ for (var i = 0; i < arr.length; i += 1) {
225
+ var ln = arr[i];
226
+ if (!ln || typeof ln !== "object") {
227
+ throw new TypeError("stockReceipts: lines[" + i + "] must be an object");
228
+ }
229
+ var sku = _sku(ln.sku);
230
+ if (seen[sku]) {
231
+ throw new TypeError("stockReceipts: lines[" + i + "].sku duplicates a previous entry");
232
+ }
233
+ seen[sku] = true;
234
+ var qty = _positiveInt(ln.quantity_expected, "lines[" + i + "].quantity_expected");
235
+ out.push({ sku: sku, quantity_expected: qty });
236
+ }
237
+ return out;
238
+ }
239
+
240
+ // ---- token generation + hashing -----------------------------------------
241
+
242
+ function _generateToken() {
243
+ var buf = _b().crypto.generateBytes(TOKEN_BYTE_LEN);
244
+ return buf.toString("base64")
245
+ .replace(/\+/g, "-")
246
+ .replace(/\//g, "_")
247
+ .replace(/=+$/, "");
248
+ }
249
+
250
+ function _hashToken(canonical) {
251
+ return _b().crypto.namespaceHash(TOKEN_NAMESPACE, canonical);
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
+ // order is optional — when wired, issueReceiptToken can look up the
264
+ // expected line items from order.getById so the operator doesn't
265
+ // have to re-supply them inline. Absent, the caller passes `lines`
266
+ // explicitly.
267
+ var orderPrim = opts.order || null;
268
+ if (orderPrim && typeof orderPrim.getById !== "function") {
269
+ throw new TypeError("stockReceipts.create: opts.order must expose a getById(id) method");
270
+ }
271
+
272
+ // loyaltyEarnRules is optional — when wired, completeReceipt fires
273
+ // a per_purchase award once the customer closes the checklist.
274
+ // Failures are drop-silent (the receipt is complete regardless of
275
+ // whether the loyalty award landed).
276
+ var loyaltyEarnRules = opts.loyaltyEarnRules || null;
277
+ if (loyaltyEarnRules && typeof loyaltyEarnRules.evaluateForEvent !== "function") {
278
+ throw new TypeError("stockReceipts.create: opts.loyaltyEarnRules must expose an evaluateForEvent(input) method");
279
+ }
280
+
281
+ // ---- internal helpers -------------------------------------------------
282
+
283
+ async function _receiptRowById(id) {
284
+ var r = await query("SELECT * FROM stock_receipt_tokens WHERE id = ?1", [id]);
285
+ return r.rows[0] || null;
286
+ }
287
+
288
+ async function _receiptRowByOrder(orderId) {
289
+ var r = await query(
290
+ "SELECT * FROM stock_receipt_tokens WHERE order_id = ?1",
291
+ [orderId],
292
+ );
293
+ return r.rows[0] || null;
294
+ }
295
+
296
+ async function _receiptRowByTokenHash(hash) {
297
+ var r = await query(
298
+ "SELECT * FROM stock_receipt_tokens WHERE token_hash = ?1",
299
+ [hash],
300
+ );
301
+ return r.rows[0] || null;
302
+ }
303
+
304
+ async function _linesForReceipt(receiptId) {
305
+ var r = await query(
306
+ "SELECT * FROM stock_receipt_line_states WHERE receipt_id = ?1 ORDER BY sku ASC",
307
+ [receiptId],
308
+ );
309
+ return r.rows;
310
+ }
311
+
312
+ function _decodeReceipt(row, lines) {
313
+ if (!row) return null;
314
+ return {
315
+ id: row.id,
316
+ order_id: row.order_id,
317
+ status: row.status,
318
+ expires_at: Number(row.expires_at),
319
+ issued_at: Number(row.issued_at),
320
+ first_scanned_at: row.first_scanned_at != null ? Number(row.first_scanned_at) : null,
321
+ completed_at: row.completed_at != null ? Number(row.completed_at) : null,
322
+ created_at: Number(row.created_at),
323
+ updated_at: Number(row.updated_at),
324
+ lines: lines || [],
325
+ };
326
+ }
327
+
328
+ function _decodeLine(row) {
329
+ return {
330
+ sku: row.sku,
331
+ quantity_expected: Number(row.quantity_expected),
332
+ quantity_received: Number(row.quantity_received),
333
+ quantity_damaged: Number(row.quantity_damaged),
334
+ state: row.state,
335
+ damage_reason: row.damage_reason,
336
+ updated_at: Number(row.updated_at),
337
+ };
338
+ }
339
+
340
+ function _decodeScan(row) {
341
+ return {
342
+ id: row.id,
343
+ receipt_id: row.receipt_id,
344
+ scanned_at: Number(row.scanned_at),
345
+ user_agent_hash: row.user_agent_hash,
346
+ client_ip_hash: row.client_ip_hash,
347
+ };
348
+ }
349
+
350
+ async function _hydrate(row) {
351
+ if (!row) return null;
352
+ var lines = await _linesForReceipt(row.id);
353
+ return _decodeReceipt(row, lines.map(_decodeLine));
354
+ }
355
+
356
+ // Per-row terminal-state guard. A `scanned` row whose expires_at has
357
+ // passed reads as terminal (refuse markLineReceived, etc.). The FSM
358
+ // never auto-flips the row to `expired` mid-call — that's a job for
359
+ // a future sweep — but the read-side guard prevents post-expiry
360
+ // mutations.
361
+ function _isLive(row, nowTs) {
362
+ if (row.status === "completed" || row.status === "expired") return false;
363
+ if (Number(row.expires_at) < nowTs) return false;
364
+ return true;
365
+ }
366
+
367
+ // ---- issueReceiptToken -----------------------------------------------
368
+
369
+ async function issueReceiptToken(input) {
370
+ if (!input || typeof input !== "object") {
371
+ throw new TypeError("stockReceipts.issueReceiptToken: input object required");
372
+ }
373
+ var orderId = _orderId(input.order_id);
374
+ var lines = _linesArray(input.lines);
375
+ var expiresHours = _expiresInHours(input.expires_in_hours);
376
+
377
+ // Re-issuance: an existing row for this order is overwritten in
378
+ // place (the prior token_hash is replaced atomically, the scan
379
+ // log persists). Refuse if the row is already `completed` — the
380
+ // customer's audit trail is closed.
381
+ var existing = await _receiptRowByOrder(orderId);
382
+ if (existing && existing.status === "completed") {
383
+ throw new TypeError("stockReceipts.issueReceiptToken: order " + orderId +
384
+ " has a completed receipt; no re-issuance");
385
+ }
386
+
387
+ var id = existing ? existing.id : _b().uuid.v7();
388
+ var plaintext = _generateToken();
389
+ var tokenHash = _hashToken(plaintext);
390
+ var nowTs = _now();
391
+ var expiresAt = nowTs + (expiresHours * 60 * 60 * 1000);
392
+
393
+ if (existing) {
394
+ await query(
395
+ "UPDATE stock_receipt_tokens " +
396
+ "SET token_hash = ?1, status = 'issued', expires_at = ?2, " +
397
+ "first_scanned_at = NULL, completed_at = NULL, updated_at = ?3 " +
398
+ "WHERE id = ?4",
399
+ [tokenHash, expiresAt, nowTs, id],
400
+ );
401
+ // Replace the line states atomically — operators who re-issue
402
+ // after editing the packing slip get a fresh checklist.
403
+ await query("DELETE FROM stock_receipt_line_states WHERE receipt_id = ?1", [id]);
404
+ } else {
405
+ await query(
406
+ "INSERT INTO stock_receipt_tokens " +
407
+ "(id, order_id, token_hash, status, expires_at, issued_at, " +
408
+ " first_scanned_at, completed_at, created_at, updated_at) " +
409
+ "VALUES (?1, ?2, ?3, 'issued', ?4, ?5, NULL, NULL, ?5, ?5)",
410
+ [id, orderId, tokenHash, expiresAt, nowTs],
411
+ );
412
+ }
413
+
414
+ for (var i = 0; i < lines.length; i += 1) {
415
+ var ln = lines[i];
416
+ await query(
417
+ "INSERT INTO stock_receipt_line_states " +
418
+ "(receipt_id, sku, quantity_expected, quantity_received, quantity_damaged, " +
419
+ " state, damage_reason, updated_at) " +
420
+ "VALUES (?1, ?2, ?3, 0, 0, 'pending', NULL, ?4)",
421
+ [id, ln.sku, ln.quantity_expected, nowTs],
422
+ );
423
+ }
424
+
425
+ // Plaintext token is returned EXACTLY ONCE here. Subsequent reads
426
+ // of the receipt row never see it again — the storage column
427
+ // carries only the SHA3-512 namespace-hash. The picker embeds the
428
+ // plaintext in the QR rendered on the packing slip and discards it.
429
+ return {
430
+ receipt_id: id,
431
+ order_id: orderId,
432
+ plaintext_token: plaintext,
433
+ status: "issued",
434
+ expires_at: expiresAt,
435
+ issued_at: nowTs,
436
+ lines: lines.map(function (l) {
437
+ return {
438
+ sku: l.sku,
439
+ quantity_expected: l.quantity_expected,
440
+ quantity_received: 0,
441
+ quantity_damaged: 0,
442
+ state: "pending",
443
+ };
444
+ }),
445
+ };
446
+ }
447
+
448
+ // ---- recordReceiptScan -----------------------------------------------
449
+
450
+ async function recordReceiptScan(input) {
451
+ if (!input || typeof input !== "object") {
452
+ throw new TypeError("stockReceipts.recordReceiptScan: input object required");
453
+ }
454
+ var token = _canonicalToken(input.token);
455
+ var ua = _uaOpt(input.user_agent);
456
+ var ip = _ipOpt(input.client_ip);
457
+
458
+ var hash = _hashToken(token);
459
+ var receipt = await _receiptRowByTokenHash(hash);
460
+ if (!receipt) {
461
+ var miss = new Error("stockReceipts.recordReceiptScan: receipt not found");
462
+ miss.code = "STOCK_RECEIPT_NOT_FOUND";
463
+ throw miss;
464
+ }
465
+
466
+ // Constant-time hex compare on the matched row's hash — belt-
467
+ // and-braces over the SQL = match.
468
+ if (!_b().crypto.timingSafeEqual(receipt.token_hash, hash)) {
469
+ var mismatch = new Error("stockReceipts.recordReceiptScan: receipt not found");
470
+ mismatch.code = "STOCK_RECEIPT_NOT_FOUND";
471
+ throw mismatch;
472
+ }
473
+
474
+ var nowTs = _now();
475
+ if (receipt.status === "completed") {
476
+ var done = new Error("stockReceipts.recordReceiptScan: receipt already completed");
477
+ done.code = "STOCK_RECEIPT_COMPLETED";
478
+ throw done;
479
+ }
480
+ if (receipt.status === "expired" || Number(receipt.expires_at) < nowTs) {
481
+ var expired = new Error("stockReceipts.recordReceiptScan: receipt has expired");
482
+ expired.code = "STOCK_RECEIPT_EXPIRED";
483
+ throw expired;
484
+ }
485
+
486
+ var scanId = _b().uuid.v7();
487
+ var uaHash = ua != null ? _b().crypto.namespaceHash(UA_NAMESPACE, ua) : null;
488
+ var ipHash = ip != null ? _b().crypto.namespaceHash(IP_NAMESPACE, ip) : null;
489
+
490
+ await query(
491
+ "INSERT INTO stock_receipt_scans " +
492
+ "(id, receipt_id, scanned_at, user_agent_hash, client_ip_hash) " +
493
+ "VALUES (?1, ?2, ?3, ?4, ?5)",
494
+ [scanId, receipt.id, nowTs, uaHash, ipHash],
495
+ );
496
+
497
+ // First scan flips the receipt FSM `issued -> scanned`. Subsequent
498
+ // scans append the event without re-flipping (the timestamp on
499
+ // `first_scanned_at` is sticky for audit purposes).
500
+ if (receipt.status === "issued") {
501
+ await query(
502
+ "UPDATE stock_receipt_tokens " +
503
+ "SET status = 'scanned', first_scanned_at = ?1, updated_at = ?1 " +
504
+ "WHERE id = ?2",
505
+ [nowTs, receipt.id],
506
+ );
507
+ } else {
508
+ await query(
509
+ "UPDATE stock_receipt_tokens SET updated_at = ?1 WHERE id = ?2",
510
+ [nowTs, receipt.id],
511
+ );
512
+ }
513
+
514
+ var fresh = await _hydrate(await _receiptRowById(receipt.id));
515
+ return {
516
+ scan_id: scanId,
517
+ receipt: fresh,
518
+ scanned_at: nowTs,
519
+ };
520
+ }
521
+
522
+ // ---- markLineReceived ------------------------------------------------
523
+
524
+ async function markLineReceived(input) {
525
+ if (!input || typeof input !== "object") {
526
+ throw new TypeError("stockReceipts.markLineReceived: input object required");
527
+ }
528
+ var receiptId = _uuid(input.receipt_id, "receipt_id");
529
+ var sku = _sku(input.sku);
530
+ var qty = input.quantity_received == null
531
+ ? null
532
+ : _nonNegInt(input.quantity_received, "quantity_received");
533
+
534
+ var nowTs = _now();
535
+ var receipt = await _receiptRowById(receiptId);
536
+ if (!receipt) {
537
+ throw new TypeError("stockReceipts.markLineReceived: receipt " + receiptId + " not found");
538
+ }
539
+ if (!_isLive(receipt, nowTs)) {
540
+ throw new TypeError("stockReceipts.markLineReceived: receipt status is " +
541
+ receipt.status + " (or expired); only scanned receipts can mark lines");
542
+ }
543
+ if (receipt.status !== "scanned") {
544
+ throw new TypeError("stockReceipts.markLineReceived: receipt status is " +
545
+ receipt.status + "; customer must scan the QR before marking lines");
546
+ }
547
+
548
+ var lineRow = (await query(
549
+ "SELECT * FROM stock_receipt_line_states WHERE receipt_id = ?1 AND sku = ?2",
550
+ [receiptId, sku],
551
+ )).rows[0];
552
+ if (!lineRow) {
553
+ throw new TypeError("stockReceipts.markLineReceived: sku " + JSON.stringify(sku) +
554
+ " is not in the receipt's line set");
555
+ }
556
+
557
+ var expected = Number(lineRow.quantity_expected);
558
+ var received = qty == null ? expected : qty;
559
+ if (received > expected) {
560
+ throw new TypeError("stockReceipts.markLineReceived: quantity_received " + received +
561
+ " exceeds quantity_expected " + expected + " for sku " + JSON.stringify(sku));
562
+ }
563
+
564
+ // Mixed state: if the line already has damaged quantity recorded,
565
+ // received + damaged must not exceed expected, and the line state
566
+ // becomes `partial` rather than `received` (the line isn't fully
567
+ // received because some quantity was damaged).
568
+ var damaged = Number(lineRow.quantity_damaged);
569
+ if (received + damaged > expected) {
570
+ throw new TypeError("stockReceipts.markLineReceived: received " + received +
571
+ " + damaged " + damaged + " exceeds quantity_expected " + expected +
572
+ " for sku " + JSON.stringify(sku));
573
+ }
574
+ var newState;
575
+ if (damaged > 0 && received > 0) {
576
+ newState = "partial";
577
+ } else if (damaged > 0) {
578
+ // received == 0 and damaged > 0 — keep the line damaged
579
+ newState = "damaged";
580
+ } else {
581
+ newState = received === expected ? "received" : "partial";
582
+ }
583
+
584
+ await query(
585
+ "UPDATE stock_receipt_line_states " +
586
+ "SET quantity_received = ?1, state = ?2, updated_at = ?3 " +
587
+ "WHERE receipt_id = ?4 AND sku = ?5",
588
+ [received, newState, nowTs, receiptId, sku],
589
+ );
590
+ await query(
591
+ "UPDATE stock_receipt_tokens SET updated_at = ?1 WHERE id = ?2",
592
+ [nowTs, receiptId],
593
+ );
594
+ return await _hydrate(await _receiptRowById(receiptId));
595
+ }
596
+
597
+ // ---- markLineDamaged -------------------------------------------------
598
+
599
+ async function markLineDamaged(input) {
600
+ if (!input || typeof input !== "object") {
601
+ throw new TypeError("stockReceipts.markLineDamaged: input object required");
602
+ }
603
+ var receiptId = _uuid(input.receipt_id, "receipt_id");
604
+ var sku = _sku(input.sku);
605
+ var qty = input.quantity_damaged == null
606
+ ? null
607
+ : _nonNegInt(input.quantity_damaged, "quantity_damaged");
608
+ var reason = _reason(input.reason);
609
+
610
+ var nowTs = _now();
611
+ var receipt = await _receiptRowById(receiptId);
612
+ if (!receipt) {
613
+ throw new TypeError("stockReceipts.markLineDamaged: receipt " + receiptId + " not found");
614
+ }
615
+ if (!_isLive(receipt, nowTs)) {
616
+ throw new TypeError("stockReceipts.markLineDamaged: receipt status is " +
617
+ receipt.status + " (or expired); only scanned receipts can mark lines");
618
+ }
619
+ if (receipt.status !== "scanned") {
620
+ throw new TypeError("stockReceipts.markLineDamaged: receipt status is " +
621
+ receipt.status + "; customer must scan the QR before marking lines");
622
+ }
623
+
624
+ var lineRow = (await query(
625
+ "SELECT * FROM stock_receipt_line_states WHERE receipt_id = ?1 AND sku = ?2",
626
+ [receiptId, sku],
627
+ )).rows[0];
628
+ if (!lineRow) {
629
+ throw new TypeError("stockReceipts.markLineDamaged: sku " + JSON.stringify(sku) +
630
+ " is not in the receipt's line set");
631
+ }
632
+
633
+ var expected = Number(lineRow.quantity_expected);
634
+ var damaged = qty == null ? expected : qty;
635
+ if (damaged > expected) {
636
+ throw new TypeError("stockReceipts.markLineDamaged: quantity_damaged " + damaged +
637
+ " exceeds quantity_expected " + expected + " for sku " + JSON.stringify(sku));
638
+ }
639
+
640
+ var received = Number(lineRow.quantity_received);
641
+ if (received + damaged > expected) {
642
+ throw new TypeError("stockReceipts.markLineDamaged: received " + received +
643
+ " + damaged " + damaged + " exceeds quantity_expected " + expected +
644
+ " for sku " + JSON.stringify(sku));
645
+ }
646
+ var newState;
647
+ if (received > 0 && damaged > 0) {
648
+ newState = "partial";
649
+ } else if (received > 0) {
650
+ // damaged == 0 — keep the line received
651
+ newState = received === expected ? "received" : "partial";
652
+ } else {
653
+ newState = damaged === expected ? "damaged" : "partial";
654
+ }
655
+
656
+ await query(
657
+ "UPDATE stock_receipt_line_states " +
658
+ "SET quantity_damaged = ?1, damage_reason = ?2, state = ?3, updated_at = ?4 " +
659
+ "WHERE receipt_id = ?5 AND sku = ?6",
660
+ [damaged, reason, newState, nowTs, receiptId, sku],
661
+ );
662
+ await query(
663
+ "UPDATE stock_receipt_tokens SET updated_at = ?1 WHERE id = ?2",
664
+ [nowTs, receiptId],
665
+ );
666
+ return await _hydrate(await _receiptRowById(receiptId));
667
+ }
668
+
669
+ // ---- completeReceipt -------------------------------------------------
670
+
671
+ async function completeReceipt(input) {
672
+ if (!input || typeof input !== "object") {
673
+ throw new TypeError("stockReceipts.completeReceipt: input object required");
674
+ }
675
+ var receiptId = _uuid(input.receipt_id, "receipt_id");
676
+ var customerId = input.customer_id != null
677
+ ? _uuid(input.customer_id, "customer_id")
678
+ : null;
679
+
680
+ var nowTs = _now();
681
+ var receipt = await _receiptRowById(receiptId);
682
+ if (!receipt) {
683
+ throw new TypeError("stockReceipts.completeReceipt: receipt " + receiptId + " not found");
684
+ }
685
+ if (receipt.status === "completed") {
686
+ var done = new Error("stockReceipts.completeReceipt: receipt already completed");
687
+ done.code = "STOCK_RECEIPT_COMPLETED";
688
+ throw done;
689
+ }
690
+ if (receipt.status === "expired" || Number(receipt.expires_at) < nowTs) {
691
+ var expired = new Error("stockReceipts.completeReceipt: receipt has expired");
692
+ expired.code = "STOCK_RECEIPT_EXPIRED";
693
+ throw expired;
694
+ }
695
+ if (receipt.status !== "scanned") {
696
+ throw new TypeError("stockReceipts.completeReceipt: receipt status is " +
697
+ receipt.status + "; customer must scan before completing");
698
+ }
699
+
700
+ var lines = (await _linesForReceipt(receiptId)).map(_decodeLine);
701
+
702
+ await query(
703
+ "UPDATE stock_receipt_tokens " +
704
+ "SET status = 'completed', completed_at = ?1, updated_at = ?1 " +
705
+ "WHERE id = ?2",
706
+ [nowTs, receiptId],
707
+ );
708
+
709
+ var summary = {
710
+ total_lines: lines.length,
711
+ received_lines: 0,
712
+ damaged_lines: 0,
713
+ partial_lines: 0,
714
+ pending_lines: 0,
715
+ total_quantity_received: 0,
716
+ total_quantity_damaged: 0,
717
+ };
718
+ for (var i = 0; i < lines.length; i += 1) {
719
+ var ln = lines[i];
720
+ if (ln.state === "received") summary.received_lines += 1;
721
+ else if (ln.state === "damaged") summary.damaged_lines += 1;
722
+ else if (ln.state === "partial") summary.partial_lines += 1;
723
+ else summary.pending_lines += 1;
724
+ summary.total_quantity_received += ln.quantity_received;
725
+ summary.total_quantity_damaged += ln.quantity_damaged;
726
+ }
727
+
728
+ // Compose loyaltyEarnRules — drop-silent on failure. The
729
+ // receipt is complete regardless of whether the loyalty award
730
+ // landed; an operator audit reads the loyalty_earn_log to find
731
+ // misses. We fire `per_purchase` keyed by the order_id so the
732
+ // dedup UNIQUE on (rule_slug, customer_id, trigger_event_ref)
733
+ // protects against re-completion (which can't happen anyway —
734
+ // completed is terminal — but defends a future re-completion
735
+ // path against double-award).
736
+ var loyaltyResult = null;
737
+ if (loyaltyEarnRules && customerId) {
738
+ try {
739
+ loyaltyResult = await loyaltyEarnRules.evaluateForEvent({
740
+ trigger: "per_purchase",
741
+ customer_id: customerId,
742
+ trigger_event_ref: "stock-receipt:" + receipt.order_id,
743
+ occurred_at: nowTs,
744
+ metadata: {
745
+ order_id: receipt.order_id,
746
+ receipt_id: receiptId,
747
+ received_lines: summary.received_lines,
748
+ damaged_lines: summary.damaged_lines,
749
+ total_quantity_received: summary.total_quantity_received,
750
+ },
751
+ });
752
+ } catch (_e) { /* drop-silent — loyalty failure must not roll back completion */ }
753
+ }
754
+
755
+ var fresh = await _hydrate(await _receiptRowById(receiptId));
756
+ return {
757
+ receipt: fresh,
758
+ summary: summary,
759
+ loyalty_result: loyaltyResult,
760
+ completed_at: nowTs,
761
+ };
762
+ }
763
+
764
+ // ---- getReceiptByToken -----------------------------------------------
765
+
766
+ async function getReceiptByToken(token) {
767
+ var canonical = _canonicalToken(token);
768
+ var hash = _hashToken(canonical);
769
+ var row = await _receiptRowByTokenHash(hash);
770
+ if (!row) return null;
771
+ if (!_b().crypto.timingSafeEqual(row.token_hash, hash)) return null;
772
+ return await _hydrate(row);
773
+ }
774
+
775
+ // ---- receiptsForOrder ------------------------------------------------
776
+
777
+ async function receiptsForOrder(orderId) {
778
+ var id = _orderId(orderId);
779
+ var row = await _receiptRowByOrder(id);
780
+ if (!row) return [];
781
+ return [await _hydrate(row)];
782
+ }
783
+
784
+ // ---- recentScans -----------------------------------------------------
785
+
786
+ async function recentScans(listOpts) {
787
+ listOpts = listOpts || {};
788
+ var limit = _limit(listOpts.limit);
789
+ var rows;
790
+ if (listOpts.receipt_id != null) {
791
+ var rid = _uuid(listOpts.receipt_id, "receipt_id");
792
+ rows = (await query(
793
+ "SELECT * FROM stock_receipt_scans WHERE receipt_id = ?1 " +
794
+ "ORDER BY scanned_at DESC, id DESC LIMIT ?2",
795
+ [rid, limit],
796
+ )).rows;
797
+ } else {
798
+ rows = (await query(
799
+ "SELECT * FROM stock_receipt_scans " +
800
+ "ORDER BY scanned_at DESC, id DESC LIMIT ?1",
801
+ [limit],
802
+ )).rows;
803
+ }
804
+ var out = [];
805
+ for (var i = 0; i < rows.length; i += 1) out.push(_decodeScan(rows[i]));
806
+ return out;
807
+ }
808
+
809
+ return {
810
+ RECEIPT_STATUSES: RECEIPT_STATUSES.slice(),
811
+ LINE_STATES: LINE_STATES.slice(),
812
+ TOKEN_NAMESPACE: TOKEN_NAMESPACE,
813
+ TOKEN_PLAINTEXT_LEN: TOKEN_PLAINTEXT_LEN,
814
+ DEFAULT_EXPIRES_HOURS: DEFAULT_EXPIRES_HOURS,
815
+
816
+ issueReceiptToken: issueReceiptToken,
817
+ recordReceiptScan: recordReceiptScan,
818
+ markLineReceived: markLineReceived,
819
+ markLineDamaged: markLineDamaged,
820
+ completeReceipt: completeReceipt,
821
+ getReceiptByToken: getReceiptByToken,
822
+ receiptsForOrder: receiptsForOrder,
823
+ recentScans: recentScans,
824
+ };
825
+ }
826
+
827
+ module.exports = {
828
+ create: create,
829
+ RECEIPT_STATUSES: RECEIPT_STATUSES,
830
+ LINE_STATES: LINE_STATES,
831
+ TOKEN_NAMESPACE: TOKEN_NAMESPACE,
832
+ TOKEN_PLAINTEXT_LEN: TOKEN_PLAINTEXT_LEN,
833
+ DEFAULT_EXPIRES_HOURS: DEFAULT_EXPIRES_HOURS,
834
+ };