@blamejs/blamejs-shop 0.0.52 → 0.0.54

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,494 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.inventoryReceive
4
+ * @title Inventory receipts — bulk stock receipt with audit trail
5
+ *
6
+ * @intro
7
+ * The catalog inventory surface exposes per-SKU restock; operators
8
+ * receiving a shipment of stock want a single record that groups
9
+ * every line, persists who-received / what-supplier / what-PO /
10
+ * how-much-it-cost, and applies the whole batch as one atomic
11
+ * operation — and reverses the same way when a delivery turns out
12
+ * wrong. This primitive layers on top of catalog.inventory.restock
13
+ * so the catalog stays the single owner of stock mutation while
14
+ * the receipt record carries the audit trail.
15
+ *
16
+ * Lifecycle:
17
+ *
18
+ * draft({ reference, supplier?, received_by?, notes?, lines })
19
+ * → inserts a 'pending' receipt + every line in one DB pass.
20
+ * No stock mutation; lines are captured but the catalog's
21
+ * inventory table is untouched. Returns
22
+ * { id, status: 'pending', total_qty, total_value_minor }.
23
+ *
24
+ * apply(receipt_id)
25
+ * → walks the lines and calls catalog.inventory.restock(sku, qty)
26
+ * for each one. If any restock throws, the whole apply is
27
+ * rolled back — every successful restock so far is undone via
28
+ * a direct UPDATE that decrements stock_on_hand by the same
29
+ * amount, and the receipt remains 'pending' so the operator
30
+ * can fix the offending line and retry. On success, the
31
+ * status transitions to 'applied'. Idempotent on 'applied':
32
+ * calling apply twice returns the same shape with
33
+ * applied_count = 0 and stock_changes = [].
34
+ *
35
+ * reverse(receipt_id, { reason? })
36
+ * → walks the lines of an 'applied' receipt and reverses each
37
+ * one via a direct stock_on_hand decrement (catalog does not
38
+ * expose a public stock_on_hand decrement verb; restock with
39
+ * a negative qty is refused, by design). The reversal clamps
40
+ * at zero — a receipt can't drive stock negative even if the
41
+ * operator has decremented stock_on_hand by other means
42
+ * between apply and reverse. Status transitions to 'reversed'.
43
+ * Refuses if the receipt is not currently 'applied'.
44
+ *
45
+ * get(id) / byReference(reference) / list({ status?, limit?, cursor? })
46
+ * → reads. list paginates over (received_at DESC, id DESC) via
47
+ * an HMAC-tagged cursor (b.pagination) so an operator can't
48
+ * hand-craft a cursor to skip ahead.
49
+ *
50
+ * Composition:
51
+ * - b.uuid.v7 — receipt + line PKs (sortable; B-tree locality)
52
+ * - b.guardUuid — strict UUID validation on receipt_id reads
53
+ * - b.pagination — HMAC-tagged cursor for list()
54
+ * - catalog.inventory.restock — the one place stock_on_hand grows
55
+ *
56
+ * The factory accepts an optional `query` (defaults to
57
+ * b.externalDb.query) and a required `catalog` handle so the
58
+ * primitive can stay decoupled from any specific catalog binding
59
+ * (tests inject an in-memory-SQLite-backed catalog + a stub
60
+ * restock; production wires the real catalog created at boot).
61
+ */
62
+
63
+ var bShop;
64
+ function _b() {
65
+ if (!bShop) bShop = require("./index");
66
+ return bShop.framework;
67
+ }
68
+
69
+ // ---- constants ----------------------------------------------------------
70
+
71
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
72
+ var REFERENCE_RE = /^[A-Za-z0-9][A-Za-z0-9._\/ -]{0,127}$/;
73
+ var CURRENCY_RE = /^[A-Z]{3}$/;
74
+ var MAX_REF_LEN = 128;
75
+ var MAX_SUPPLIER_LEN = 256;
76
+ var MAX_RECVBY_LEN = 256;
77
+ var MAX_NOTES_LEN = 4000;
78
+ var MAX_LINE_NOTES_LEN = 4000;
79
+ var MAX_LINES = 1000;
80
+ var MAX_LIST_LIMIT = 200;
81
+
82
+ var RECEIPT_STATUSES = Object.freeze(["pending", "applied", "reversed"]);
83
+ var RECEIPT_ORDER_KEY = ["received_at:desc", "id:desc"];
84
+
85
+ // ---- validators ---------------------------------------------------------
86
+
87
+ function _id(s, label) {
88
+ try {
89
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
90
+ } catch (e) {
91
+ throw new TypeError("inventory-receive: " + label + " — " + (e && e.message || "invalid UUID"));
92
+ }
93
+ }
94
+ function _sku(s) {
95
+ if (typeof s !== "string" || !SKU_RE.test(s)) {
96
+ throw new TypeError("inventory-receive: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, ≤ 128 chars)");
97
+ }
98
+ }
99
+ function _reference(s) {
100
+ if (typeof s !== "string" || !s.length || s.length > MAX_REF_LEN || !REFERENCE_RE.test(s)) {
101
+ throw new TypeError("inventory-receive: reference must be a non-empty string ≤ " + MAX_REF_LEN +
102
+ " chars matching /^[A-Za-z0-9][A-Za-z0-9._\\/ -]*$/");
103
+ }
104
+ }
105
+ function _shortText(s, label, max) {
106
+ if (s == null) return "";
107
+ if (typeof s !== "string" || s.length > max) {
108
+ throw new TypeError("inventory-receive: " + label + " must be a string ≤ " + max + " chars");
109
+ }
110
+ return s;
111
+ }
112
+ function _notes(s, label, max) {
113
+ return _shortText(s, label, max);
114
+ }
115
+ function _currency(s) {
116
+ if (typeof s !== "string" || !CURRENCY_RE.test(s)) {
117
+ throw new TypeError("inventory-receive: currency must be a 3-letter ISO 4217 code (uppercase), got " + JSON.stringify(s));
118
+ }
119
+ }
120
+ function _positiveInt(n, label) {
121
+ if (!Number.isInteger(n) || n <= 0) {
122
+ throw new TypeError("inventory-receive: " + label + " must be a positive integer");
123
+ }
124
+ }
125
+ function _nonNegInt(n, label) {
126
+ if (!Number.isInteger(n) || n < 0) {
127
+ throw new TypeError("inventory-receive: " + label + " must be a non-negative integer");
128
+ }
129
+ }
130
+ function _limit(n, label) {
131
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
132
+ throw new TypeError("inventory-receive: " + label + " must be an integer in 1..." + MAX_LIST_LIMIT);
133
+ }
134
+ }
135
+
136
+ function _now() { return Date.now(); }
137
+
138
+ // ---- factory ------------------------------------------------------------
139
+
140
+ function create(opts) {
141
+ opts = opts || {};
142
+ if (!opts.catalog || !opts.catalog.inventory || typeof opts.catalog.inventory.restock !== "function") {
143
+ throw new TypeError("inventory-receive.create: opts.catalog with inventory.restock(sku, qty) required");
144
+ }
145
+ var catalog = opts.catalog;
146
+ var query = opts.query;
147
+ if (!query) {
148
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
149
+ }
150
+ // Pagination cursors are HMAC-tagged so an operator can't hand-
151
+ // craft one to skip ahead or replay across deployments. The
152
+ // secret must be stable for the deployment; rotating it
153
+ // invalidates outstanding cursors (acceptable). Tests inject a
154
+ // fixed dev string; production must supply an explicit secret.
155
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
156
+ if (process.env.NODE_ENV === "production") {
157
+ throw new Error("inventory-receive.create: opts.cursorSecret is required in production");
158
+ }
159
+ opts.cursorSecret = "inventory-receive-cursor-secret-dev-only";
160
+ }
161
+ var cursorSecret = opts.cursorSecret;
162
+
163
+ // Validate the `lines` array shape. Returns the normalized lines
164
+ // plus the cached totals so the draft insert doesn't have to
165
+ // re-walk the array.
166
+ function _validateLines(lines, currency) {
167
+ if (!Array.isArray(lines) || lines.length === 0) {
168
+ throw new TypeError("inventory-receive: lines must be a non-empty array");
169
+ }
170
+ if (lines.length > MAX_LINES) {
171
+ throw new TypeError("inventory-receive: lines must contain ≤ " + MAX_LINES + " entries");
172
+ }
173
+ var totalQty = 0;
174
+ var totalValue = 0;
175
+ var normalized = [];
176
+ for (var i = 0; i < lines.length; i += 1) {
177
+ var l = lines[i];
178
+ if (!l || typeof l !== "object") {
179
+ throw new TypeError("inventory-receive: lines[" + i + "] must be an object");
180
+ }
181
+ _sku(l.sku);
182
+ _positiveInt(l.qty_received, "lines[" + i + "].qty_received");
183
+ var unitCost = l.unit_cost_minor == null ? 0 : l.unit_cost_minor;
184
+ _nonNegInt(unitCost, "lines[" + i + "].unit_cost_minor");
185
+ var unitCurrency = l.unit_cost_currency == null ? currency : l.unit_cost_currency;
186
+ _currency(unitCurrency);
187
+ var lineNotes = _notes(l.notes, "lines[" + i + "].notes", MAX_LINE_NOTES_LEN);
188
+ totalQty += l.qty_received;
189
+ totalValue += l.qty_received * unitCost;
190
+ normalized.push({
191
+ sku: l.sku,
192
+ qty_received: l.qty_received,
193
+ unit_cost_minor: unitCost,
194
+ unit_cost_currency: unitCurrency,
195
+ notes: lineNotes,
196
+ });
197
+ }
198
+ return { lines: normalized, total_qty: totalQty, total_value_minor: totalValue };
199
+ }
200
+
201
+ // Hydrate a receipt row with its lines + parsed numerics. Returns
202
+ // null for an unknown id so the caller-handler can map to HTTP 404.
203
+ async function _getHydrated(id) {
204
+ var rRow = await query("SELECT * FROM inventory_receipts WHERE id = ?1", [id]);
205
+ if (!rRow.rows.length) return null;
206
+ var receipt = rRow.rows[0];
207
+ var rLines = await query(
208
+ "SELECT * FROM inventory_receipt_lines WHERE receipt_id = ?1 ORDER BY id ASC",
209
+ [id],
210
+ );
211
+ receipt.lines = rLines.rows;
212
+ return receipt;
213
+ }
214
+
215
+ return {
216
+ // Create a pending receipt. All lines are inserted in the same
217
+ // logical operation; if any line validates badly, the partial
218
+ // header insert is rolled back via an explicit DELETE so the DB
219
+ // doesn't carry an orphan pending receipt forward.
220
+ draft: async function (input) {
221
+ if (!input || typeof input !== "object") {
222
+ throw new TypeError("inventory-receive.draft: input object required");
223
+ }
224
+ _reference(input.reference);
225
+ var supplier = _shortText(input.supplier, "supplier", MAX_SUPPLIER_LEN);
226
+ var receivedBy = _shortText(input.received_by, "received_by", MAX_RECVBY_LEN);
227
+ var notes = _notes(input.notes, "notes", MAX_NOTES_LEN);
228
+ var totalCurrency = input.total_currency == null ? "USD" : input.total_currency;
229
+ _currency(totalCurrency);
230
+ var normalized = _validateLines(input.lines, totalCurrency);
231
+
232
+ // Refuse the duplicate reference up front with a clean error
233
+ // shape instead of relying on the UNIQUE constraint to throw a
234
+ // raw SQLITE_CONSTRAINT — the catch path below still catches
235
+ // any race-window dup, but the up-front check gives the
236
+ // common case a descriptive message.
237
+ var dup = await query(
238
+ "SELECT id FROM inventory_receipts WHERE reference = ?1 LIMIT 1",
239
+ [input.reference],
240
+ );
241
+ if (dup.rows.length) {
242
+ throw new TypeError("inventory-receive.draft: reference " + JSON.stringify(input.reference) + " already exists");
243
+ }
244
+
245
+ var id = _b().uuid.v7();
246
+ var ts = _now();
247
+ var receivedAt = input.received_at == null ? ts : input.received_at;
248
+ if (!Number.isInteger(receivedAt) || receivedAt < 0) {
249
+ throw new TypeError("inventory-receive.draft: received_at must be a non-negative integer (epoch ms)");
250
+ }
251
+
252
+ try {
253
+ await query(
254
+ "INSERT INTO inventory_receipts (id, reference, supplier, received_by, received_at, status, " +
255
+ "total_qty, total_value_minor, total_currency, notes, created_at, updated_at) " +
256
+ "VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7, ?8, ?9, ?10, ?10)",
257
+ [
258
+ id, input.reference, supplier, receivedBy, receivedAt,
259
+ normalized.total_qty, normalized.total_value_minor, totalCurrency, notes, ts,
260
+ ],
261
+ );
262
+ for (var i = 0; i < normalized.lines.length; i += 1) {
263
+ var l = normalized.lines[i];
264
+ await query(
265
+ "INSERT INTO inventory_receipt_lines (id, receipt_id, sku, qty_received, " +
266
+ "unit_cost_minor, unit_cost_currency, notes, created_at) " +
267
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
268
+ [_b().uuid.v7(), id, l.sku, l.qty_received, l.unit_cost_minor, l.unit_cost_currency, l.notes, ts],
269
+ );
270
+ }
271
+ } catch (e) {
272
+ // Best-effort rollback of the header + any lines that landed
273
+ // before the failure (ON DELETE CASCADE on the FK clears the
274
+ // lines). D1 doesn't expose a transaction handle to this
275
+ // primitive, so the rollback is a compensating DELETE.
276
+ try { await query("DELETE FROM inventory_receipts WHERE id = ?1", [id]); }
277
+ catch (_e2) { /* drop-silent — the original error is what the caller needs */ }
278
+ throw e;
279
+ }
280
+
281
+ return {
282
+ id: id,
283
+ status: "pending",
284
+ total_qty: normalized.total_qty,
285
+ total_value_minor: normalized.total_value_minor,
286
+ };
287
+ },
288
+
289
+ // Apply a pending receipt. Walks lines + calls
290
+ // catalog.inventory.restock(sku, qty) for each. Idempotent on
291
+ // 'applied'. If any restock throws, the partial mutations are
292
+ // undone via direct stock_on_hand decrements and the receipt
293
+ // stays 'pending' so the operator can fix + retry.
294
+ apply: async function (receiptId) {
295
+ _id(receiptId, "receipt id");
296
+ var receipt = await _getHydrated(receiptId);
297
+ if (!receipt) {
298
+ throw new TypeError("inventory-receive.apply: receipt " + receiptId + " not found");
299
+ }
300
+ if (receipt.status === "applied") {
301
+ // Idempotent — caller can replay without surprise.
302
+ return { id: receiptId, applied_count: 0, stock_changes: [] };
303
+ }
304
+ if (receipt.status !== "pending") {
305
+ throw new TypeError("inventory-receive.apply: receipt is " + receipt.status + ", only pending receipts can be applied");
306
+ }
307
+ var stockChanges = [];
308
+ var applied = [];
309
+ try {
310
+ for (var i = 0; i < receipt.lines.length; i += 1) {
311
+ var l = receipt.lines[i];
312
+ await catalog.inventory.restock(l.sku, l.qty_received);
313
+ applied.push({ sku: l.sku, qty: l.qty_received });
314
+ stockChanges.push({ sku: l.sku, qty: l.qty_received });
315
+ }
316
+ } catch (e) {
317
+ // Undo every successful restock so the database state matches
318
+ // the pre-apply snapshot. The receipt stays 'pending' so the
319
+ // operator can fix the offending line and retry.
320
+ for (var j = applied.length - 1; j >= 0; j -= 1) {
321
+ try {
322
+ await query(
323
+ "UPDATE inventory SET stock_on_hand = MAX(0, stock_on_hand - ?1), updated_at = ?2 WHERE sku = ?3",
324
+ [applied[j].qty, _now(), applied[j].sku],
325
+ );
326
+ } catch (_e3) { /* drop-silent — the original apply error is what the operator needs to fix */ }
327
+ }
328
+ var err = new Error("inventory-receive.apply: restock failed — " + (e && e.message || e));
329
+ err.cause = e;
330
+ throw err;
331
+ }
332
+ var ts = _now();
333
+ await query(
334
+ "UPDATE inventory_receipts SET status = 'applied', updated_at = ?1 WHERE id = ?2",
335
+ [ts, receiptId],
336
+ );
337
+ return {
338
+ id: receiptId,
339
+ applied_count: applied.length,
340
+ stock_changes: stockChanges,
341
+ };
342
+ },
343
+
344
+ // Reverse an applied receipt. Walks lines + decrements
345
+ // stock_on_hand directly (catalog does not expose a public
346
+ // stock_on_hand decrement verb — restock refuses negative qty
347
+ // by design). The MAX(0, ...) clamp prevents driving stock
348
+ // negative if the operator has further decremented the SKU via
349
+ // an out-of-band path between apply and reverse.
350
+ reverse: async function (receiptId, reverseOpts) {
351
+ _id(receiptId, "receipt id");
352
+ var reason = reverseOpts && reverseOpts.reason != null
353
+ ? _shortText(reverseOpts.reason, "reason", MAX_NOTES_LEN)
354
+ : "";
355
+ var receipt = await _getHydrated(receiptId);
356
+ if (!receipt) {
357
+ throw new TypeError("inventory-receive.reverse: receipt " + receiptId + " not found");
358
+ }
359
+ if (receipt.status !== "applied") {
360
+ throw new TypeError("inventory-receive.reverse: receipt is " + receipt.status + ", only applied receipts can be reversed");
361
+ }
362
+ var stockChanges = [];
363
+ for (var i = 0; i < receipt.lines.length; i += 1) {
364
+ var l = receipt.lines[i];
365
+ var ts = _now();
366
+ await query(
367
+ "UPDATE inventory SET stock_on_hand = MAX(0, stock_on_hand - ?1), updated_at = ?2 WHERE sku = ?3",
368
+ [l.qty_received, ts, l.sku],
369
+ );
370
+ stockChanges.push({ sku: l.sku, qty: -l.qty_received });
371
+ }
372
+ // Append the reason into the receipt notes so the audit trail
373
+ // carries the rationale. Operators that want a richer reversal
374
+ // record compose on top — this primitive captures the minimal
375
+ // useful signal.
376
+ var newNotes = receipt.notes;
377
+ if (reason.length) {
378
+ var prefix = newNotes.length ? newNotes + "\n" : "";
379
+ newNotes = prefix + "[reversed] " + reason;
380
+ if (newNotes.length > MAX_NOTES_LEN) {
381
+ newNotes = newNotes.slice(0, MAX_NOTES_LEN);
382
+ }
383
+ }
384
+ var ts2 = _now();
385
+ await query(
386
+ "UPDATE inventory_receipts SET status = 'reversed', notes = ?1, updated_at = ?2 WHERE id = ?3",
387
+ [newNotes, ts2, receiptId],
388
+ );
389
+ return {
390
+ id: receiptId,
391
+ reversed_count: receipt.lines.length,
392
+ stock_changes: stockChanges,
393
+ };
394
+ },
395
+
396
+ // Read a receipt + its hydrated lines. Returns null on miss so
397
+ // the caller-handler maps cleanly to HTTP 404.
398
+ get: async function (receiptId) {
399
+ _id(receiptId, "receipt id");
400
+ return await _getHydrated(receiptId);
401
+ },
402
+
403
+ // Operator lookup by the PO / receipt number they typed in.
404
+ byReference: async function (reference) {
405
+ _reference(reference);
406
+ var r = await query(
407
+ "SELECT id FROM inventory_receipts WHERE reference = ?1 LIMIT 1",
408
+ [reference],
409
+ );
410
+ if (!r.rows.length) return null;
411
+ return await _getHydrated(r.rows[0].id);
412
+ },
413
+
414
+ // Paginated list ordered (received_at DESC, id DESC). Cursor is
415
+ // HMAC-tagged so an operator can't tamper or replay across
416
+ // orderKey changes. Mirrors the cursor shape used by
417
+ // order.listForCustomer / catalog.products.list.
418
+ list: async function (listOpts) {
419
+ listOpts = listOpts || {};
420
+ var status = listOpts.status;
421
+ if (status !== undefined && status !== null) {
422
+ if (RECEIPT_STATUSES.indexOf(status) === -1) {
423
+ throw new TypeError("inventory-receive.list: status must be one of " +
424
+ RECEIPT_STATUSES.join(", ") + ", got " + JSON.stringify(status));
425
+ }
426
+ }
427
+ var limit = listOpts.limit == null ? 50 : listOpts.limit;
428
+ _limit(limit, "limit");
429
+ var cursorVals = null;
430
+ if (listOpts.cursor != null) {
431
+ if (typeof listOpts.cursor !== "string") {
432
+ throw new TypeError("inventory-receive.list: cursor must be an opaque string or null");
433
+ }
434
+ try {
435
+ var state = _b().pagination.decodeCursor(listOpts.cursor, cursorSecret);
436
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(RECEIPT_ORDER_KEY)) {
437
+ throw new TypeError("inventory-receive.list: cursor orderKey mismatch");
438
+ }
439
+ cursorVals = state.vals;
440
+ } catch (e) {
441
+ if (e instanceof TypeError) throw e;
442
+ throw new TypeError("inventory-receive.list: cursor — " + (e && e.message || "malformed"));
443
+ }
444
+ }
445
+ var sql, params;
446
+ var hasStatus = status !== undefined && status !== null;
447
+ if (hasStatus && cursorVals) {
448
+ sql = "SELECT * FROM inventory_receipts WHERE status = ?1 AND " +
449
+ "(received_at < ?2 OR (received_at = ?2 AND id < ?3)) " +
450
+ "ORDER BY received_at DESC, id DESC LIMIT ?4";
451
+ params = [status, cursorVals[0], cursorVals[1], limit];
452
+ } else if (hasStatus) {
453
+ sql = "SELECT * FROM inventory_receipts WHERE status = ?1 " +
454
+ "ORDER BY received_at DESC, id DESC LIMIT ?2";
455
+ params = [status, limit];
456
+ } else if (cursorVals) {
457
+ sql = "SELECT * FROM inventory_receipts WHERE " +
458
+ "(received_at < ?1 OR (received_at = ?1 AND id < ?2)) " +
459
+ "ORDER BY received_at DESC, id DESC LIMIT ?3";
460
+ params = [cursorVals[0], cursorVals[1], limit];
461
+ } else {
462
+ sql = "SELECT * FROM inventory_receipts " +
463
+ "ORDER BY received_at DESC, id DESC LIMIT ?1";
464
+ params = [limit];
465
+ }
466
+ var rows = (await query(sql, params)).rows;
467
+ // Hydrate lines per row so the admin UI doesn't need a fan-out
468
+ // fetch. For larger pages this is N+1 reads; the indexed
469
+ // (receipt_id) lookup keeps each one cheap.
470
+ for (var i = 0; i < rows.length; i += 1) {
471
+ var rL = await query(
472
+ "SELECT * FROM inventory_receipt_lines WHERE receipt_id = ?1 ORDER BY id ASC",
473
+ [rows[i].id],
474
+ );
475
+ rows[i].lines = rL.rows;
476
+ }
477
+ var last = rows[rows.length - 1];
478
+ var next = null;
479
+ if (last && rows.length === limit) {
480
+ next = _b().pagination.encodeCursor({
481
+ orderKey: RECEIPT_ORDER_KEY,
482
+ vals: [last.received_at, last.id],
483
+ forward: true,
484
+ }, cursorSecret);
485
+ }
486
+ return { rows: rows, next_cursor: next };
487
+ },
488
+ };
489
+ }
490
+
491
+ module.exports = {
492
+ create: create,
493
+ RECEIPT_STATUSES: RECEIPT_STATUSES,
494
+ };