@blamejs/blamejs-shop 0.0.65 → 0.0.70

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 (54) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/business-hours.js +980 -0
  5. package/lib/click-and-collect.js +711 -0
  6. package/lib/clickstream.js +713 -0
  7. package/lib/cost-layers.js +774 -0
  8. package/lib/credit-limits.js +752 -0
  9. package/lib/currency-rounding.js +525 -0
  10. package/lib/customer-activity.js +862 -0
  11. package/lib/customer-notes.js +712 -0
  12. package/lib/customer-risk-profile.js +593 -0
  13. package/lib/customer-surveys.js +1012 -0
  14. package/lib/damage-photos.js +473 -0
  15. package/lib/discount-allocation.js +557 -0
  16. package/lib/dropship-forwarding.js +645 -0
  17. package/lib/email-templates.js +817 -0
  18. package/lib/index.js +45 -0
  19. package/lib/inventory-allocations.js +559 -0
  20. package/lib/inventory-writeoffs.js +636 -0
  21. package/lib/knowledge-base.js +1104 -0
  22. package/lib/locale-router.js +1077 -0
  23. package/lib/operator-roles.js +768 -0
  24. package/lib/order-escalation.js +951 -0
  25. package/lib/order-ratings.js +495 -0
  26. package/lib/order-tags.js +944 -0
  27. package/lib/packing-slips.js +810 -0
  28. package/lib/payment-retries.js +816 -0
  29. package/lib/pick-lists.js +639 -0
  30. package/lib/pixel-events.js +995 -0
  31. package/lib/preorder.js +595 -0
  32. package/lib/print-queue.js +681 -0
  33. package/lib/product-qa.js +749 -0
  34. package/lib/promo-bundles.js +835 -0
  35. package/lib/push-notifications.js +937 -0
  36. package/lib/refund-automation.js +853 -0
  37. package/lib/reorder-reminders.js +798 -0
  38. package/lib/robots-config.js +753 -0
  39. package/lib/seller-signup.js +1052 -0
  40. package/lib/site-redirects.js +690 -0
  41. package/lib/sitemap-generator.js +717 -0
  42. package/lib/subscription-gifts.js +710 -0
  43. package/lib/tax-cert-renewals.js +632 -0
  44. package/lib/theme-assets.js +711 -0
  45. package/lib/tier-benefits.js +776 -0
  46. package/lib/vendor/MANIFEST.json +2 -2
  47. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  48. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  49. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  50. package/lib/vendor/blamejs/package.json +1 -1
  51. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  52. package/lib/wishlist-alerts.js +842 -0
  53. package/lib/wishlist-sharing.js +718 -0
  54. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -155,4 +155,49 @@ module.exports = {
155
155
  splitShipments: require("./split-shipments"),
156
156
  trustBadges: require("./trust-badges"),
157
157
  webhookReceiver: require("./webhook-receiver"),
158
+ siteRedirects: require("./site-redirects"),
159
+ costLayers: require("./cost-layers"),
160
+ paymentRetries: require("./payment-retries"),
161
+ pickLists: require("./pick-lists"),
162
+ preorder: require("./preorder"),
163
+ discountAllocation: require("./discount-allocation"),
164
+ currencyRounding: require("./currency-rounding"),
165
+ creditLimits: require("./credit-limits"),
166
+ themeAssets: require("./theme-assets"),
167
+ businessHours: require("./business-hours"),
168
+ customerNotes: require("./customer-notes"),
169
+ orderTags: require("./order-tags"),
170
+ customerRiskProfile: require("./customer-risk-profile"),
171
+ tierBenefits: require("./tier-benefits"),
172
+ promoBundles: require("./promo-bundles"),
173
+ subscriptionGifts: require("./subscription-gifts"),
174
+ reorderReminders: require("./reorder-reminders"),
175
+ productQA: require("./product-qa"),
176
+ localeRouter: require("./locale-router"),
177
+ inventoryWriteoffs: require("./inventory-writeoffs"),
178
+ robotsConfig: require("./robots-config"),
179
+ refundAutomation: require("./refund-automation"),
180
+ sellerSignup: require("./seller-signup"),
181
+ taxCertRenewals: require("./tax-cert-renewals"),
182
+ orderEscalation: require("./order-escalation"),
183
+ orderRatings: require("./order-ratings"),
184
+ wishlistSharing: require("./wishlist-sharing"),
185
+ inventoryAllocations: require("./inventory-allocations"),
186
+ dropshipForwarding: require("./dropship-forwarding"),
187
+ damagePhotos: require("./damage-photos"),
188
+ pushNotifications: require("./push-notifications"),
189
+ autoReplenish: require("./auto-replenish"),
190
+ operatorRoles: require("./operator-roles"),
191
+ clickstream: require("./clickstream"),
192
+ customerActivity: require("./customer-activity"),
193
+ packingSlips: require("./packing-slips"),
194
+ printQueue: require("./print-queue"),
195
+ wishlistAlerts: require("./wishlist-alerts"),
196
+ assemblyInstructions: require("./assembly-instructions"),
197
+ clickAndCollect: require("./click-and-collect"),
198
+ customerSurveys: require("./customer-surveys"),
199
+ emailTemplates: require("./email-templates"),
200
+ knowledgeBase: require("./knowledge-base"),
201
+ pixelEvents: require("./pixel-events"),
202
+ sitemapGenerator: require("./sitemap-generator"),
158
203
  };
@@ -0,0 +1,559 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.inventoryAllocations
4
+ * @title Inventory allocations — soft-reserve holds for in-progress carts
5
+ *
6
+ * @intro
7
+ * `inventoryLocations` owns the committed shelf — every row in
8
+ * `inventory_stock` is real, claimable inventory. The gap that
9
+ * opens during checkout is the in-progress cart: the shopper has
10
+ * selected N units of a SKU but hasn't paid yet. Without a holds
11
+ * layer, a second shopper hitting "add to cart" a moment later
12
+ * sees the same N units available, and one of the two has to be
13
+ * told at payment time the item is gone — the classic oversell.
14
+ *
15
+ * This primitive is the holds layer. Each `holdForCart` call
16
+ * reserves quantity against (sku, location_code?) for a specific
17
+ * cart_id with a TTL. Reads that need "how many can I actually
18
+ * sell?" subtract the open-hold sum from the committed shelf via
19
+ * `availableForSku`. On checkout completion the hold converts to
20
+ * a real stock movement via `inventoryLocations.adjustStock` —
21
+ * this primitive never writes `inventory_stock` directly; it
22
+ * composes the locations primitive for every committed mutation.
23
+ *
24
+ * Verbs:
25
+ * holdForCart — open a hold for { cart_id, sku, variant_id?,
26
+ * quantity, location_code?, ttl_seconds? }.
27
+ * Refuses if the requested qty would drive
28
+ * availableForSku below zero.
29
+ * releaseHold — flip a single hold to `released` with an
30
+ * operator-supplied reason.
31
+ * releaseAllForCart — bulk-release every active hold for a cart
32
+ * (the abandonment / cancel path).
33
+ * commitHold — finalize the hold: composes
34
+ * inventoryLocations.adjustStock to debit
35
+ * the committed shelf, then flips the hold
36
+ * to `committed` with the order_id.
37
+ * extendHold — bump expires_at by additional TTL seconds
38
+ * (the shopper went idle but is still on
39
+ * the page).
40
+ * availableForSku — committed total minus open-hold sum for
41
+ * the SKU (optionally filtered by location).
42
+ * holdsForCart — every hold (any status) for a cart_id —
43
+ * audit + UI surface.
44
+ * cleanupExpiredHolds — walks rows with expires_at <= now and
45
+ * status='held', flips them to `expired`.
46
+ * Idempotent.
47
+ * metricsForSku — aggregate hold activity for a SKU in a
48
+ * time window: counts by status, total qty
49
+ * held / released / committed / expired.
50
+ *
51
+ * Composition:
52
+ * - inventoryLocations — required. The holds layer composes its
53
+ * `adjustStock`, `stockForSku`, and
54
+ * `getLocation` verbs. Refusing to wire
55
+ * one through fails loud at boot.
56
+ * - b.uuid.v7 — hold-row PKs (sortable by insertion).
57
+ *
58
+ * Three-tier input validation (use the discipline; don't write
59
+ * the labels): every public verb here is a defensive request-
60
+ * shape reader OR a config-time entry point. Both throw on bad
61
+ * input. No drop-silent hot-path sinks.
62
+ *
63
+ * Monotonic clock: a per-factory monotonic timestamp ensures
64
+ * two holds opened against the same SKU in the same wall-clock
65
+ * millisecond carry strictly-increasing created_at values so
66
+ * the audit-by-time queries always order them deterministically.
67
+ * Forward-leaps when the wall clock outpaces the counter;
68
+ * otherwise bumps by 1ms.
69
+ */
70
+
71
+ var bShop;
72
+ function _b() {
73
+ if (!bShop) bShop = require("./index");
74
+ return bShop.framework;
75
+ }
76
+
77
+ // ---- constants ----------------------------------------------------------
78
+
79
+ var CART_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
80
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
81
+ var VARIANT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
82
+ var CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
83
+ var ORDER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
84
+ var HOLD_ID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
85
+ var REASON_RE = /^[\S\s]{1,256}$/;
86
+ var DEFAULT_TTL = 900; // 15 minutes by default
87
+ var MAX_TTL = 24 * 60 * 60; // 24h ceiling — anything longer is a usage-error smell
88
+ var MIN_TTL = 1;
89
+
90
+ // ---- validators ---------------------------------------------------------
91
+
92
+ function _cartId(s) {
93
+ if (typeof s !== "string" || !CART_ID_RE.test(s)) {
94
+ throw new TypeError("inventory-allocations: cart_id must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
95
+ }
96
+ }
97
+ function _sku(s) {
98
+ if (typeof s !== "string" || !SKU_RE.test(s)) {
99
+ throw new TypeError("inventory-allocations: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
100
+ }
101
+ }
102
+ function _variantId(s) {
103
+ if (s == null) return null;
104
+ if (typeof s !== "string" || !VARIANT_ID_RE.test(s)) {
105
+ throw new TypeError("inventory-allocations: variant_id must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars) or be omitted");
106
+ }
107
+ return s;
108
+ }
109
+ function _locationCode(s) {
110
+ if (s == null) return null;
111
+ if (typeof s !== "string" || !CODE_RE.test(s)) {
112
+ throw new TypeError("inventory-allocations: location_code must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..64 chars) or be omitted");
113
+ }
114
+ return s;
115
+ }
116
+ function _orderId(s) {
117
+ if (typeof s !== "string" || !ORDER_ID_RE.test(s)) {
118
+ throw new TypeError("inventory-allocations: order_id must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
119
+ }
120
+ }
121
+ function _holdId(s) {
122
+ if (typeof s !== "string" || !HOLD_ID_RE.test(s)) {
123
+ throw new TypeError("inventory-allocations: hold_id must be a UUID");
124
+ }
125
+ }
126
+ function _positiveInt(n, label) {
127
+ if (!Number.isInteger(n) || n <= 0) {
128
+ throw new TypeError("inventory-allocations: " + label + " must be a positive integer");
129
+ }
130
+ }
131
+ function _nonNegInt(n, label) {
132
+ if (!Number.isInteger(n) || n < 0) {
133
+ throw new TypeError("inventory-allocations: " + label + " must be a non-negative integer");
134
+ }
135
+ }
136
+ function _ttlSeconds(n, label) {
137
+ if (!Number.isInteger(n) || n < MIN_TTL || n > MAX_TTL) {
138
+ throw new TypeError("inventory-allocations: " + (label || "ttl_seconds") +
139
+ " must be an integer in " + MIN_TTL + ".." + MAX_TTL + " (got " + JSON.stringify(n) + ")");
140
+ }
141
+ }
142
+ function _reason(s, label) {
143
+ if (typeof s !== "string" || !REASON_RE.test(s) || s.length > 256) {
144
+ throw new TypeError("inventory-allocations: " + (label || "reason") +
145
+ " must be a non-empty string ≤ 256 chars");
146
+ }
147
+ return s;
148
+ }
149
+
150
+ function _now() { return Date.now(); }
151
+
152
+ // ---- row hydration ------------------------------------------------------
153
+
154
+ function _shapeHold(row) {
155
+ if (!row) return null;
156
+ return {
157
+ id: row.id,
158
+ cart_id: row.cart_id,
159
+ sku: row.sku,
160
+ variant_id: row.variant_id == null ? null : row.variant_id,
161
+ location_code: row.location_code == null ? null : row.location_code,
162
+ quantity: Number(row.quantity),
163
+ status: row.status,
164
+ ttl_seconds: Number(row.ttl_seconds),
165
+ expires_at: Number(row.expires_at),
166
+ committed_order_id: row.committed_order_id == null ? null : row.committed_order_id,
167
+ release_reason: row.release_reason == null ? null : row.release_reason,
168
+ created_at: Number(row.created_at),
169
+ released_at: row.released_at == null ? null : Number(row.released_at),
170
+ committed_at: row.committed_at == null ? null : Number(row.committed_at),
171
+ expired_at: row.expired_at == null ? null : Number(row.expired_at),
172
+ };
173
+ }
174
+
175
+ // ---- factory ------------------------------------------------------------
176
+
177
+ function create(opts) {
178
+ opts = opts || {};
179
+ // The inventoryLocations primitive owns every commit-time mutation
180
+ // on `inventory_stock` — the holds layer composes its adjustStock
181
+ // / stockForSku / getLocation verbs. Refusing to wire one through
182
+ // fails loud at boot rather than at first call.
183
+ if (!opts.inventoryLocations ||
184
+ typeof opts.inventoryLocations.adjustStock !== "function" ||
185
+ typeof opts.inventoryLocations.stockForSku !== "function" ||
186
+ typeof opts.inventoryLocations.getLocation !== "function") {
187
+ throw new TypeError("inventory-allocations.create: opts.inventoryLocations with " +
188
+ "adjustStock + stockForSku + getLocation is required");
189
+ }
190
+ var locations = opts.inventoryLocations;
191
+ var query = opts.query;
192
+ if (!query) {
193
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
194
+ }
195
+
196
+ // Per-factory monotonic clock. Two holds opened against the same
197
+ // SKU in the same wall-clock millisecond would otherwise tie on
198
+ // created_at and make audit-by-time queries non-deterministic.
199
+ // Forward-leap when the wall clock outpaces the counter; otherwise
200
+ // bump by 1ms so the sequence is strictly increasing per factory
201
+ // instance.
202
+ var _lastEventTs = 0;
203
+ function _monotonicTs() {
204
+ var wall = _now();
205
+ if (wall > _lastEventTs) _lastEventTs = wall;
206
+ else _lastEventTs += 1;
207
+ return _lastEventTs;
208
+ }
209
+
210
+ async function _getHoldRow(holdId) {
211
+ var r = await query("SELECT * FROM inventory_holds WHERE id = ?1", [holdId]);
212
+ return r.rows[0] || null;
213
+ }
214
+
215
+ // Sum the qty of held rows for a SKU, optionally restricted to a
216
+ // single location. Holds with NULL location_code count against
217
+ // every location query (they're not pinned yet), matching the
218
+ // pessimistic-safe rule: an un-pinned hold should subtract from
219
+ // the answer to "can I sell at any location?"
220
+ async function _sumHeldQty(sku, locationCode) {
221
+ var sql, params;
222
+ if (locationCode == null) {
223
+ sql = "SELECT COALESCE(SUM(quantity), 0) AS total " +
224
+ "FROM inventory_holds WHERE sku = ?1 AND status = 'held'";
225
+ params = [sku];
226
+ } else {
227
+ sql = "SELECT COALESCE(SUM(quantity), 0) AS total " +
228
+ "FROM inventory_holds WHERE sku = ?1 AND status = 'held' " +
229
+ "AND (location_code = ?2 OR location_code IS NULL)";
230
+ params = [sku, locationCode];
231
+ }
232
+ var r = await query(sql, params);
233
+ return r.rows[0] ? Number(r.rows[0].total) : 0;
234
+ }
235
+
236
+ // ---- holdForCart -----------------------------------------------------
237
+
238
+ async function holdForCart(input) {
239
+ if (!input || typeof input !== "object") {
240
+ throw new TypeError("inventory-allocations.holdForCart: input object required");
241
+ }
242
+ _cartId(input.cart_id);
243
+ _sku(input.sku);
244
+ var variantId = _variantId(input.variant_id);
245
+ var locationCode = _locationCode(input.location_code);
246
+ _positiveInt(input.quantity, "quantity");
247
+ var ttlSeconds = input.ttl_seconds == null ? DEFAULT_TTL : input.ttl_seconds;
248
+ _ttlSeconds(ttlSeconds, "ttl_seconds");
249
+
250
+ if (locationCode != null) {
251
+ var loc = await locations.getLocation(locationCode);
252
+ if (!loc) {
253
+ throw new TypeError("inventory-allocations.holdForCart: location_code " +
254
+ JSON.stringify(locationCode) + " not found");
255
+ }
256
+ }
257
+
258
+ // Refuse if the new hold would drive availableForSku below zero.
259
+ // The shelf snapshot + the current held sum are read in sequence
260
+ // — at this layer the primitive accepts a small race window
261
+ // (two concurrent factories could both see headroom and both
262
+ // commit). Operators that need strict serializability run the
263
+ // hold path under a per-SKU advisory lock at the request layer;
264
+ // this primitive's contract is "best-effort soft reserve."
265
+ var shelf = await locations.stockForSku(input.sku);
266
+ var committed;
267
+ if (locationCode == null) {
268
+ committed = shelf.total;
269
+ } else {
270
+ committed = 0;
271
+ for (var i = 0; i < shelf.by_location.length; i += 1) {
272
+ if (shelf.by_location[i].code === locationCode) {
273
+ committed = shelf.by_location[i].quantity;
274
+ break;
275
+ }
276
+ }
277
+ }
278
+ var alreadyHeld = await _sumHeldQty(input.sku, locationCode);
279
+ var available = committed - alreadyHeld;
280
+ if (input.quantity > available) {
281
+ throw new TypeError("inventory-allocations.holdForCart: insufficient available stock for " +
282
+ input.sku + " (need " + input.quantity + ", available " + available +
283
+ " = committed " + committed + " - held " + alreadyHeld + ")");
284
+ }
285
+
286
+ var ts = _monotonicTs();
287
+ var expiresAt = ts + ttlSeconds * 1000;
288
+ var id = _b().uuid.v7();
289
+ await query(
290
+ "INSERT INTO inventory_holds (id, cart_id, sku, variant_id, location_code, quantity, " +
291
+ "status, ttl_seconds, expires_at, created_at) " +
292
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'held', ?7, ?8, ?9)",
293
+ [id, input.cart_id, input.sku, variantId, locationCode, input.quantity,
294
+ ttlSeconds, expiresAt, ts],
295
+ );
296
+ return _shapeHold(await _getHoldRow(id));
297
+ }
298
+
299
+ // ---- releaseHold -----------------------------------------------------
300
+
301
+ async function releaseHold(input) {
302
+ if (!input || typeof input !== "object") {
303
+ throw new TypeError("inventory-allocations.releaseHold: input object required");
304
+ }
305
+ _holdId(input.hold_id);
306
+ var reason = _reason(input.reason, "reason");
307
+
308
+ var existing = await _getHoldRow(input.hold_id);
309
+ if (!existing) {
310
+ throw new TypeError("inventory-allocations.releaseHold: hold_id " +
311
+ JSON.stringify(input.hold_id) + " not found");
312
+ }
313
+ if (existing.status !== "held") {
314
+ throw new TypeError("inventory-allocations.releaseHold: hold is " + existing.status +
315
+ ", expected 'held'");
316
+ }
317
+
318
+ var ts = _monotonicTs();
319
+ await query(
320
+ "UPDATE inventory_holds SET status = 'released', released_at = ?1, release_reason = ?2 " +
321
+ "WHERE id = ?3 AND status = 'held'",
322
+ [ts, reason, input.hold_id],
323
+ );
324
+ return _shapeHold(await _getHoldRow(input.hold_id));
325
+ }
326
+
327
+ // ---- releaseAllForCart -----------------------------------------------
328
+
329
+ async function releaseAllForCart(input) {
330
+ if (!input || typeof input !== "object") {
331
+ throw new TypeError("inventory-allocations.releaseAllForCart: input object required");
332
+ }
333
+ _cartId(input.cart_id);
334
+ var reason = _reason(input.reason, "reason");
335
+
336
+ var ts = _monotonicTs();
337
+ var r = await query(
338
+ "UPDATE inventory_holds SET status = 'released', released_at = ?1, release_reason = ?2 " +
339
+ "WHERE cart_id = ?3 AND status = 'held'",
340
+ [ts, reason, input.cart_id],
341
+ );
342
+ return { cart_id: input.cart_id, released_count: r.rowCount };
343
+ }
344
+
345
+ // ---- commitHold ------------------------------------------------------
346
+
347
+ async function commitHold(input) {
348
+ if (!input || typeof input !== "object") {
349
+ throw new TypeError("inventory-allocations.commitHold: input object required");
350
+ }
351
+ _holdId(input.hold_id);
352
+ _orderId(input.order_id);
353
+
354
+ var existing = await _getHoldRow(input.hold_id);
355
+ if (!existing) {
356
+ throw new TypeError("inventory-allocations.commitHold: hold_id " +
357
+ JSON.stringify(input.hold_id) + " not found");
358
+ }
359
+ if (existing.status !== "held") {
360
+ throw new TypeError("inventory-allocations.commitHold: hold is " + existing.status +
361
+ ", expected 'held'");
362
+ }
363
+
364
+ // commitHold needs a concrete location to debit the shelf. A
365
+ // hold opened with location_code=NULL must be pinned before
366
+ // commit — the storefront / checkout flow decides which
367
+ // location ships and re-opens a pinned hold (or the operator
368
+ // adds a default-location override at the request layer).
369
+ if (existing.location_code == null) {
370
+ throw new TypeError("inventory-allocations.commitHold: hold " + existing.id +
371
+ " has no location_code — pin a location before commit");
372
+ }
373
+
374
+ // Debit the committed shelf through inventoryLocations.adjustStock
375
+ // FIRST. If the shelf write fails (insufficient stock, unknown
376
+ // location), the hold row stays in 'held' status so a retry or
377
+ // a manual release decides next. Only after the debit lands does
378
+ // the hold flip to 'committed' — the audit trail then proves
379
+ // the commit happened.
380
+ await locations.adjustStock({
381
+ sku: existing.sku,
382
+ location_code: existing.location_code,
383
+ delta: -existing.quantity,
384
+ reason: "hold-commit:" + existing.id + " order:" + input.order_id,
385
+ });
386
+
387
+ var ts = _monotonicTs();
388
+ await query(
389
+ "UPDATE inventory_holds SET status = 'committed', committed_at = ?1, " +
390
+ "committed_order_id = ?2 WHERE id = ?3 AND status = 'held'",
391
+ [ts, input.order_id, input.hold_id],
392
+ );
393
+ return _shapeHold(await _getHoldRow(input.hold_id));
394
+ }
395
+
396
+ // ---- extendHold ------------------------------------------------------
397
+
398
+ async function extendHold(input) {
399
+ if (!input || typeof input !== "object") {
400
+ throw new TypeError("inventory-allocations.extendHold: input object required");
401
+ }
402
+ _holdId(input.hold_id);
403
+ _positiveInt(input.additional_ttl_seconds, "additional_ttl_seconds");
404
+ if (input.additional_ttl_seconds > MAX_TTL) {
405
+ throw new TypeError("inventory-allocations.extendHold: additional_ttl_seconds must be ≤ " + MAX_TTL);
406
+ }
407
+
408
+ var existing = await _getHoldRow(input.hold_id);
409
+ if (!existing) {
410
+ throw new TypeError("inventory-allocations.extendHold: hold_id " +
411
+ JSON.stringify(input.hold_id) + " not found");
412
+ }
413
+ if (existing.status !== "held") {
414
+ throw new TypeError("inventory-allocations.extendHold: hold is " + existing.status +
415
+ ", expected 'held'");
416
+ }
417
+
418
+ var nextExpires = Number(existing.expires_at) + input.additional_ttl_seconds * 1000;
419
+ await query(
420
+ "UPDATE inventory_holds SET expires_at = ?1 WHERE id = ?2 AND status = 'held'",
421
+ [nextExpires, input.hold_id],
422
+ );
423
+ return _shapeHold(await _getHoldRow(input.hold_id));
424
+ }
425
+
426
+ // ---- availableForSku -------------------------------------------------
427
+
428
+ async function availableForSku(input) {
429
+ if (!input || typeof input !== "object") {
430
+ throw new TypeError("inventory-allocations.availableForSku: input object required");
431
+ }
432
+ _sku(input.sku);
433
+ var locationCode = _locationCode(input.location_code);
434
+
435
+ var shelf = await locations.stockForSku(input.sku);
436
+ var committed;
437
+ if (locationCode == null) {
438
+ committed = shelf.total;
439
+ } else {
440
+ committed = 0;
441
+ for (var i = 0; i < shelf.by_location.length; i += 1) {
442
+ if (shelf.by_location[i].code === locationCode) {
443
+ committed = shelf.by_location[i].quantity;
444
+ break;
445
+ }
446
+ }
447
+ }
448
+ var held = await _sumHeldQty(input.sku, locationCode);
449
+ var available = committed - held;
450
+ if (available < 0) available = 0;
451
+ return {
452
+ sku: input.sku,
453
+ location_code: locationCode,
454
+ committed: committed,
455
+ on_hold: held,
456
+ available: available,
457
+ };
458
+ }
459
+
460
+ // ---- holdsForCart ----------------------------------------------------
461
+
462
+ async function holdsForCart(cartId) {
463
+ _cartId(cartId);
464
+ var r = await query(
465
+ "SELECT * FROM inventory_holds WHERE cart_id = ?1 ORDER BY created_at ASC",
466
+ [cartId],
467
+ );
468
+ return r.rows.map(_shapeHold);
469
+ }
470
+
471
+ // ---- cleanupExpiredHolds ---------------------------------------------
472
+
473
+ async function cleanupExpiredHolds(input) {
474
+ input = input || {};
475
+ var now = input.now == null ? _now() : input.now;
476
+ _nonNegInt(now, "now");
477
+
478
+ var ts = _monotonicTs();
479
+ if (ts < now) ts = now; // forward-leap the monotonic to the supplied clock if needed
480
+ var r = await query(
481
+ "UPDATE inventory_holds SET status = 'expired', expired_at = ?1, " +
482
+ "release_reason = 'ttl_expired' " +
483
+ "WHERE status = 'held' AND expires_at <= ?2",
484
+ [ts, now],
485
+ );
486
+ return { expired_count: r.rowCount };
487
+ }
488
+
489
+ // ---- metricsForSku ---------------------------------------------------
490
+
491
+ async function metricsForSku(input) {
492
+ if (!input || typeof input !== "object") {
493
+ throw new TypeError("inventory-allocations.metricsForSku: input object required");
494
+ }
495
+ _sku(input.sku);
496
+ _nonNegInt(input.from, "from");
497
+ _nonNegInt(input.to, "to");
498
+ if (input.to < input.from) {
499
+ throw new TypeError("inventory-allocations.metricsForSku: to must be ≥ from");
500
+ }
501
+
502
+ // Count + sum-qty per status. Window predicate matches the row
503
+ // against the timestamp that corresponds to its current status:
504
+ // 'held' uses created_at, terminal statuses use their respective
505
+ // transition timestamp. This makes the metric semantically
506
+ // honest — a hold that opened in the window but committed
507
+ // outside it doesn't double-count.
508
+ var r = await query(
509
+ "SELECT status, COUNT(*) AS row_count, COALESCE(SUM(quantity), 0) AS qty_sum " +
510
+ "FROM inventory_holds " +
511
+ "WHERE sku = ?1 AND (" +
512
+ " (status = 'held' AND created_at BETWEEN ?2 AND ?3) OR " +
513
+ " (status = 'released' AND released_at BETWEEN ?2 AND ?3) OR " +
514
+ " (status = 'committed' AND committed_at BETWEEN ?2 AND ?3) OR " +
515
+ " (status = 'expired' AND expired_at BETWEEN ?2 AND ?3) " +
516
+ ") " +
517
+ "GROUP BY status",
518
+ [input.sku, input.from, input.to],
519
+ );
520
+
521
+ var out = {
522
+ sku: input.sku,
523
+ from: input.from,
524
+ to: input.to,
525
+ held: { count: 0, quantity: 0 },
526
+ released: { count: 0, quantity: 0 },
527
+ committed: { count: 0, quantity: 0 },
528
+ expired: { count: 0, quantity: 0 },
529
+ };
530
+ for (var i = 0; i < r.rows.length; i += 1) {
531
+ var row = r.rows[i];
532
+ if (out[row.status]) {
533
+ out[row.status] = {
534
+ count: Number(row.row_count),
535
+ quantity: Number(row.qty_sum),
536
+ };
537
+ }
538
+ }
539
+ return out;
540
+ }
541
+
542
+ return {
543
+ holdForCart: holdForCart,
544
+ releaseHold: releaseHold,
545
+ releaseAllForCart: releaseAllForCart,
546
+ commitHold: commitHold,
547
+ extendHold: extendHold,
548
+ availableForSku: availableForSku,
549
+ holdsForCart: holdsForCart,
550
+ cleanupExpiredHolds: cleanupExpiredHolds,
551
+ metricsForSku: metricsForSku,
552
+ };
553
+ }
554
+
555
+ module.exports = {
556
+ create: create,
557
+ DEFAULT_TTL: DEFAULT_TTL,
558
+ MAX_TTL: MAX_TTL,
559
+ };