@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.
- package/CHANGELOG.md +4 -0
- package/SECURITY.md +5 -3
- package/lib/analytics.js +400 -0
- package/lib/email.js +264 -0
- package/lib/giftcards.js +410 -0
- package/lib/index.js +4 -0
- package/lib/inventory-receive.js +494 -0
- package/lib/newsletter.js +176 -12
- package/lib/payment.js +193 -13
- package/lib/reviews.js +412 -0
- package/lib/storefront.js +52 -20
- package/lib/tax.js +391 -3
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/SECURITY.md +0 -1
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.4.json +19 -0
- package/lib/webhooks.js +293 -16
- package/lib/wishlist.js +269 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|