@blamejs/blamejs-shop 0.0.54 → 0.0.57

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,452 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.backorder
4
+ * @title Backorder — out-of-stock SKUs that can still be ordered
5
+ *
6
+ * @intro
7
+ * The catalog's inventory bucket says "we have N units on the
8
+ * shelf". A backorderable SKU says "we don't have it on the shelf,
9
+ * but the operator commits to ship by <date>" — and the storefront
10
+ * PDP renders "Ships by <date>" instead of "Out of stock" so the
11
+ * customer can still complete the purchase.
12
+ *
13
+ * Lifecycle:
14
+ *
15
+ * markBackorderable({ sku, max_backorder_quantity?,
16
+ * expected_ship_date, message? })
17
+ * → operator opts a SKU in. Upserts the per-SKU config row.
18
+ * `max_backorder_quantity: null` (or omitted) means
19
+ * unlimited; a positive integer caps the in-flight pending
20
+ * quantity so the operator doesn't commit beyond their
21
+ * supplier's capacity. `pending_quantity` is preserved across
22
+ * re-marks so re-marking the same SKU doesn't reset the
23
+ * counter.
24
+ *
25
+ * markNotBackorderable(sku)
26
+ * → flips `active` to 0. The row is preserved so historical
27
+ * lines still resolve their config; future `availabilityFor`
28
+ * calls return `out_of_stock` once stock_on_hand is also 0.
29
+ *
30
+ * recordBackorder({ order_id, sku, quantity, customer_id })
31
+ * → writes one `backorder_lines` row per backordered line at
32
+ * checkout time. Refuses if the SKU isn't currently
33
+ * backorderable, or if the requested quantity would push
34
+ * `pending_quantity` past the configured cap. Increments the
35
+ * per-SKU counter as part of the same logical operation.
36
+ * Idempotent on `(order_id, sku)` via the UNIQUE constraint —
37
+ * the same line replayed returns `{ status: "dedup" }` and
38
+ * does not double-increment the counter.
39
+ *
40
+ * fulfillBackorder({ order_id, sku }) /
41
+ * cancelBackorder({ order_id, sku, reason })
42
+ * → flip the row to `fulfilled` / `cancelled` and decrement
43
+ * the counter. Refuse on missing row or non-pending status
44
+ * so the counter can never under-flow.
45
+ *
46
+ * availabilityFor(sku) — pure read used by the PDP
47
+ * → returns one of:
48
+ * { status: "in_stock" }
49
+ * { status: "backorderable",
50
+ * expected_ship_date, message }
51
+ * { status: "out_of_stock" }
52
+ * Reads catalog inventory + the per-SKU backorder config;
53
+ * `in_stock` wins whenever stock_on_hand > 0 (the
54
+ * backorderable flag is irrelevant — fulfill from the shelf
55
+ * first).
56
+ *
57
+ * customerBackorders(customer_id) /
58
+ * pendingForSku(sku) /
59
+ * arrivalsThisWeek()
60
+ * → operator + customer dashboard reads.
61
+ *
62
+ * Composition:
63
+ * - b.guardUuid — every order_id / customer_id is UUID-shape
64
+ * validated at the entry point; SKU shape is validated via a
65
+ * local regex matching the rest of the shop primitives.
66
+ * - b.uuid.v7 — backorder_lines.id (sortable; customer dashboard
67
+ * reads sort by id desc to get newest-first without a second
68
+ * index).
69
+ * - catalog.inventory.get(sku) — the single source of truth for
70
+ * on-shelf stock. `availabilityFor` reads it but never mutates
71
+ * it; the catalog stays the owner of stock_on_hand.
72
+ *
73
+ * The factory accepts an optional `query` (defaults to
74
+ * b.externalDb.query) and a required `catalog` handle so the
75
+ * primitive stays decoupled from any specific catalog binding —
76
+ * tests inject an in-memory-SQLite-backed catalog; production wires
77
+ * the real catalog created at boot.
78
+ */
79
+
80
+ var bShop;
81
+ function _b() {
82
+ if (!bShop) bShop = require("./index");
83
+ return bShop.framework;
84
+ }
85
+
86
+ // ---- constants ----------------------------------------------------------
87
+
88
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
89
+ var MAX_MESSAGE_LEN = 280;
90
+ var MAX_REASON_LEN = 280;
91
+ var WEEK_MS = 7 * 24 * 60 * 60 * 1000;
92
+
93
+ var BACKORDER_STATUSES = Object.freeze(["pending", "fulfilled", "cancelled"]);
94
+
95
+ // ---- validators ---------------------------------------------------------
96
+
97
+ function _uuid(s, label) {
98
+ try {
99
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
100
+ } catch (e) {
101
+ throw new TypeError("backorder: " + label + " — " + (e && e.message || "invalid UUID"));
102
+ }
103
+ }
104
+
105
+ function _sku(s) {
106
+ if (typeof s !== "string" || !SKU_RE.test(s)) {
107
+ throw new TypeError("backorder: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, ≤ 128 chars)");
108
+ }
109
+ }
110
+
111
+ function _shortText(s, label, max) {
112
+ if (s == null) return "";
113
+ if (typeof s !== "string" || s.length > max) {
114
+ throw new TypeError("backorder: " + label + " must be a string ≤ " + max + " chars");
115
+ }
116
+ return s;
117
+ }
118
+
119
+ function _positiveInt(n, label) {
120
+ if (!Number.isInteger(n) || n <= 0) {
121
+ throw new TypeError("backorder: " + label + " must be a positive integer");
122
+ }
123
+ }
124
+
125
+ function _nonNegIntOrNull(n, label) {
126
+ if (n === null || n === undefined) return null;
127
+ if (!Number.isInteger(n) || n < 0) {
128
+ throw new TypeError("backorder: " + label + " must be a non-negative integer or null");
129
+ }
130
+ return n;
131
+ }
132
+
133
+ function _epochMs(n, label) {
134
+ if (!Number.isInteger(n) || n < 0) {
135
+ throw new TypeError("backorder: " + label + " must be a non-negative integer (epoch ms)");
136
+ }
137
+ }
138
+
139
+ function _now() { return Date.now(); }
140
+
141
+ // ---- factory ------------------------------------------------------------
142
+
143
+ function create(opts) {
144
+ opts = opts || {};
145
+ if (!opts.catalog || !opts.catalog.inventory || typeof opts.catalog.inventory.get !== "function") {
146
+ throw new TypeError("backorder.create: opts.catalog with inventory.get(sku) required");
147
+ }
148
+ var catalog = opts.catalog;
149
+ var query = opts.query;
150
+ if (!query) {
151
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
152
+ }
153
+
154
+ // Read the per-SKU config row. Returns null on miss so callers can
155
+ // map cleanly to the "not configured" branch without an exception
156
+ // round-trip.
157
+ async function _getConfig(sku) {
158
+ var r = await query("SELECT * FROM backorder_skus WHERE sku = ?1", [sku]);
159
+ return r.rows[0] || null;
160
+ }
161
+
162
+ return {
163
+ // Operator opts a SKU in. Upserts the config row so re-marking
164
+ // updates expected_ship_date / message / cap without resetting
165
+ // the counter. `max_backorder_quantity: null` (or omitted) means
166
+ // unlimited — operators that want a strict cap pass a positive
167
+ // integer; passing 0 means "no new backorders accepted" (the
168
+ // existing pending rows remain).
169
+ markBackorderable: async function (input) {
170
+ if (!input || typeof input !== "object") {
171
+ throw new TypeError("backorder.markBackorderable: input object required");
172
+ }
173
+ _sku(input.sku);
174
+ var max = _nonNegIntOrNull(input.max_backorder_quantity, "max_backorder_quantity");
175
+ _epochMs(input.expected_ship_date, "expected_ship_date");
176
+ var message = _shortText(input.message, "message", MAX_MESSAGE_LEN);
177
+ var ts = _now();
178
+ var existing = await _getConfig(input.sku);
179
+ if (!existing) {
180
+ await query(
181
+ "INSERT INTO backorder_skus (sku, max_quantity, expected_ship_date, message, " +
182
+ "pending_quantity, active, created_at, updated_at) " +
183
+ "VALUES (?1, ?2, ?3, ?4, 0, 1, ?5, ?5)",
184
+ [input.sku, max, input.expected_ship_date, message, ts],
185
+ );
186
+ } else {
187
+ await query(
188
+ "UPDATE backorder_skus SET max_quantity = ?1, expected_ship_date = ?2, " +
189
+ "message = ?3, active = 1, updated_at = ?4 WHERE sku = ?5",
190
+ [max, input.expected_ship_date, message, ts, input.sku],
191
+ );
192
+ }
193
+ return await _getConfig(input.sku);
194
+ },
195
+
196
+ // Flip a SKU's backorder config off. The row is preserved so the
197
+ // pending_quantity counter + historical line resolution still
198
+ // work; future availabilityFor calls fall through to in_stock /
199
+ // out_of_stock without the backorderable branch.
200
+ markNotBackorderable: async function (sku) {
201
+ _sku(sku);
202
+ var ts = _now();
203
+ var r = await query(
204
+ "UPDATE backorder_skus SET active = 0, updated_at = ?1 WHERE sku = ?2",
205
+ [ts, sku],
206
+ );
207
+ if (r.rowCount === 0) return null;
208
+ return await _getConfig(sku);
209
+ },
210
+
211
+ getStatus: async function (sku) {
212
+ _sku(sku);
213
+ return await _getConfig(sku);
214
+ },
215
+
216
+ listBackorderable: async function (listOpts) {
217
+ listOpts = listOpts || {};
218
+ var activeOnly = listOpts.active_only === true;
219
+ var sql, params;
220
+ if (activeOnly) {
221
+ sql = "SELECT * FROM backorder_skus WHERE active = 1 ORDER BY expected_ship_date ASC, sku ASC";
222
+ params = [];
223
+ } else {
224
+ sql = "SELECT * FROM backorder_skus ORDER BY expected_ship_date ASC, sku ASC";
225
+ params = [];
226
+ }
227
+ var r = await query(sql, params);
228
+ return r.rows;
229
+ },
230
+
231
+ // Record one backorder line at checkout time. Refuses if:
232
+ // - the SKU isn't currently backorderable (no config / active=0)
233
+ // - the quantity would push pending_quantity past max_quantity
234
+ // Idempotent on (order_id, sku) via the UNIQUE constraint — the
235
+ // same line replayed returns { status: 'dedup' } and does not
236
+ // double-increment the counter.
237
+ recordBackorder: async function (input) {
238
+ if (!input || typeof input !== "object") {
239
+ throw new TypeError("backorder.recordBackorder: input object required");
240
+ }
241
+ _uuid(input.order_id, "order_id");
242
+ _uuid(input.customer_id, "customer_id");
243
+ _sku(input.sku);
244
+ _positiveInt(input.quantity, "quantity");
245
+
246
+ // Idempotency — same (order_id, sku) returns dedup without
247
+ // mutating the counter. The UNIQUE constraint backstops a race
248
+ // window where two callers raced past the SELECT below. Dedup
249
+ // is checked BEFORE the cap so a replay of an already-recorded
250
+ // line doesn't trip the cap refusal when the existing pending
251
+ // total is at the cap.
252
+ var dup = await query(
253
+ "SELECT id, status FROM backorder_lines WHERE order_id = ?1 AND sku = ?2 LIMIT 1",
254
+ [input.order_id, input.sku],
255
+ );
256
+ if (dup.rows.length) {
257
+ return { id: dup.rows[0].id, status: "dedup", line_status: dup.rows[0].status };
258
+ }
259
+
260
+ var config = await _getConfig(input.sku);
261
+ if (!config || config.active !== 1) {
262
+ throw new TypeError("backorder.recordBackorder: sku " + JSON.stringify(input.sku) + " is not currently backorderable");
263
+ }
264
+ // Cap enforcement. NULL max_quantity = unlimited; a configured
265
+ // cap refuses any line that would push the in-flight pending
266
+ // total past it.
267
+ if (config.max_quantity !== null && config.max_quantity !== undefined) {
268
+ if (config.pending_quantity + input.quantity > config.max_quantity) {
269
+ throw new TypeError("backorder.recordBackorder: would exceed max_backorder_quantity " +
270
+ "(cap=" + config.max_quantity + ", pending=" + config.pending_quantity +
271
+ ", requested=" + input.quantity + ")");
272
+ }
273
+ }
274
+
275
+ var id = _b().uuid.v7();
276
+ var ts = _now();
277
+ try {
278
+ await query(
279
+ "INSERT INTO backorder_lines (id, order_id, customer_id, sku, quantity, status, " +
280
+ "created_at) VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6)",
281
+ [id, input.order_id, input.customer_id, input.sku, input.quantity, ts],
282
+ );
283
+ } catch (e) {
284
+ // Race: another caller landed the same (order_id, sku) tuple
285
+ // between the dup SELECT above and this INSERT. Re-read the
286
+ // existing row and return the dedup shape instead of
287
+ // bubbling a raw SQLITE_CONSTRAINT.
288
+ var redup = await query(
289
+ "SELECT id, status FROM backorder_lines WHERE order_id = ?1 AND sku = ?2 LIMIT 1",
290
+ [input.order_id, input.sku],
291
+ );
292
+ if (redup.rows.length) {
293
+ return { id: redup.rows[0].id, status: "dedup", line_status: redup.rows[0].status };
294
+ }
295
+ throw e;
296
+ }
297
+ await query(
298
+ "UPDATE backorder_skus SET pending_quantity = pending_quantity + ?1, updated_at = ?2 " +
299
+ "WHERE sku = ?3",
300
+ [input.quantity, ts, input.sku],
301
+ );
302
+ return { id: id, status: "recorded" };
303
+ },
304
+
305
+ // Flip a pending backorder line to fulfilled. Decrements the
306
+ // per-SKU counter. Refuses on missing row or non-pending status
307
+ // so the counter can never under-flow.
308
+ fulfillBackorder: async function (input) {
309
+ if (!input || typeof input !== "object") {
310
+ throw new TypeError("backorder.fulfillBackorder: input object required");
311
+ }
312
+ _uuid(input.order_id, "order_id");
313
+ _sku(input.sku);
314
+ var r = await query(
315
+ "SELECT id, quantity, status FROM backorder_lines WHERE order_id = ?1 AND sku = ?2 LIMIT 1",
316
+ [input.order_id, input.sku],
317
+ );
318
+ if (!r.rows.length) {
319
+ throw new TypeError("backorder.fulfillBackorder: no backorder line for order=" +
320
+ input.order_id + " sku=" + JSON.stringify(input.sku));
321
+ }
322
+ var line = r.rows[0];
323
+ if (line.status !== "pending") {
324
+ throw new TypeError("backorder.fulfillBackorder: line is " + line.status +
325
+ ", only pending lines can be fulfilled");
326
+ }
327
+ var ts = _now();
328
+ await query(
329
+ "UPDATE backorder_lines SET status = 'fulfilled', fulfilled_at = ?1 WHERE id = ?2",
330
+ [ts, line.id],
331
+ );
332
+ // MAX(0, ...) clamps the counter so an out-of-band counter
333
+ // corruption (operator hand-edited a row) can't drive it
334
+ // negative. The CHECK constraint on pending_quantity would
335
+ // otherwise refuse the UPDATE outright.
336
+ await query(
337
+ "UPDATE backorder_skus SET pending_quantity = MAX(0, pending_quantity - ?1), " +
338
+ "updated_at = ?2 WHERE sku = ?3",
339
+ [line.quantity, ts, input.sku],
340
+ );
341
+ return { id: line.id, status: "fulfilled" };
342
+ },
343
+
344
+ cancelBackorder: async function (input) {
345
+ if (!input || typeof input !== "object") {
346
+ throw new TypeError("backorder.cancelBackorder: input object required");
347
+ }
348
+ _uuid(input.order_id, "order_id");
349
+ _sku(input.sku);
350
+ var reason = _shortText(input.reason, "reason", MAX_REASON_LEN);
351
+ var r = await query(
352
+ "SELECT id, quantity, status FROM backorder_lines WHERE order_id = ?1 AND sku = ?2 LIMIT 1",
353
+ [input.order_id, input.sku],
354
+ );
355
+ if (!r.rows.length) {
356
+ throw new TypeError("backorder.cancelBackorder: no backorder line for order=" +
357
+ input.order_id + " sku=" + JSON.stringify(input.sku));
358
+ }
359
+ var line = r.rows[0];
360
+ if (line.status !== "pending") {
361
+ throw new TypeError("backorder.cancelBackorder: line is " + line.status +
362
+ ", only pending lines can be cancelled");
363
+ }
364
+ var ts = _now();
365
+ await query(
366
+ "UPDATE backorder_lines SET status = 'cancelled', reason = ?1, cancelled_at = ?2 " +
367
+ "WHERE id = ?3",
368
+ [reason, ts, line.id],
369
+ );
370
+ await query(
371
+ "UPDATE backorder_skus SET pending_quantity = MAX(0, pending_quantity - ?1), " +
372
+ "updated_at = ?2 WHERE sku = ?3",
373
+ [line.quantity, ts, input.sku],
374
+ );
375
+ return { id: line.id, status: "cancelled" };
376
+ },
377
+
378
+ // Pure read used by the PDP. Resolution rules:
379
+ // 1. stock_on_hand > 0 → in_stock (fulfill from shelf
380
+ // first; backorderable flag
381
+ // is irrelevant)
382
+ // 2. config active=1 + stock = 0 → backorderable + ship date +
383
+ // message
384
+ // 3. otherwise → out_of_stock
385
+ // The cap is not enforced here — availabilityFor returns the
386
+ // configured ship date even when the cap is full; the cap refusal
387
+ // surfaces at recordBackorder time so the customer sees the same
388
+ // PDP state until they actually try to commit.
389
+ availabilityFor: async function (sku) {
390
+ _sku(sku);
391
+ var inv = await catalog.inventory.get(sku);
392
+ var onHand = inv && inv.stock_on_hand != null ? inv.stock_on_hand : 0;
393
+ if (onHand > 0) {
394
+ return { status: "in_stock" };
395
+ }
396
+ var config = await _getConfig(sku);
397
+ if (config && config.active === 1) {
398
+ return {
399
+ status: "backorderable",
400
+ expected_ship_date: config.expected_ship_date,
401
+ message: config.message,
402
+ };
403
+ }
404
+ return { status: "out_of_stock" };
405
+ },
406
+
407
+ // Newest first — the customer's account page renders the most
408
+ // recent backorder at the top. The v7-uuid PK sorts
409
+ // lexicographically by creation order so `ORDER BY id DESC` is
410
+ // equivalent to `ORDER BY created_at DESC` without needing the
411
+ // separate timestamp index.
412
+ customerBackorders: async function (customerId) {
413
+ _uuid(customerId, "customer_id");
414
+ var r = await query(
415
+ "SELECT * FROM backorder_lines WHERE customer_id = ?1 ORDER BY id DESC",
416
+ [customerId],
417
+ );
418
+ return r.rows;
419
+ },
420
+
421
+ pendingForSku: async function (sku) {
422
+ _sku(sku);
423
+ var r = await query(
424
+ "SELECT * FROM backorder_lines WHERE sku = ?1 AND status = 'pending' ORDER BY id ASC",
425
+ [sku],
426
+ );
427
+ return r.rows;
428
+ },
429
+
430
+ // Operator dashboard read — pending backorder lines whose
431
+ // configured expected_ship_date falls in the next 7 days
432
+ // (inclusive of now). The join pulls the per-SKU ship date so
433
+ // operators don't have to fan out one read per sku.
434
+ arrivalsThisWeek: async function () {
435
+ var now = _now();
436
+ var cutoff = now + WEEK_MS;
437
+ var r = await query(
438
+ "SELECT l.*, s.expected_ship_date AS expected_ship_date, s.message AS message " +
439
+ "FROM backorder_lines l JOIN backorder_skus s ON s.sku = l.sku " +
440
+ "WHERE l.status = 'pending' AND s.expected_ship_date <= ?1 " +
441
+ "ORDER BY s.expected_ship_date ASC, l.id ASC",
442
+ [cutoff],
443
+ );
444
+ return r.rows;
445
+ },
446
+ };
447
+ }
448
+
449
+ module.exports = {
450
+ create: create,
451
+ BACKORDER_STATUSES: BACKORDER_STATUSES,
452
+ };