@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
@@ -0,0 +1,595 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.preorder
4
+ * @title Pre-order — reservations against SKUs that haven't shipped yet
5
+ *
6
+ * @intro
7
+ * Pre-order is the COUNTERPART to `backorder`. They look similar
8
+ * from the storefront ("can't ship today, can still take the
9
+ * order") but the underlying state is different:
10
+ *
11
+ * - backorder = the SKU exists in the catalog, its inventory
12
+ * bucket is at zero, the operator commits to ship by a known
13
+ * restock date. The `backorder_skus` row pivots on a real
14
+ * `inventory.sku`.
15
+ * - preorder = the SKU isn't released yet. There is no
16
+ * catalog stock to debit. The operator schedules a launch
17
+ * date and caps the reservation pool independently of any
18
+ * shelf count; customers reserve a unit (with or without a
19
+ * deposit) and the launch flow auto-converts active
20
+ * reservations into regular orders.
21
+ *
22
+ * Lifecycle:
23
+ *
24
+ * defineCampaign({ slug, sku, variant_id?, launch_at,
25
+ * charge_at?, max_units_available?,
26
+ * deposit_minor?, full_price_minor, currency,
27
+ * marketing_copy? })
28
+ * Operator opens a campaign. `slug` is the URL-friendly
29
+ * identifier (and the PK). `launch_at` is the wall-clock
30
+ * moment the campaign converts; `charge_at` defaults to
31
+ * `launch_at` (defer billing to launch day) and can be set
32
+ * earlier when the operator collects up front.
33
+ * `max_units_available: null` (or omitted) is unlimited; a
34
+ * non-negative integer caps the reservation pool so the
35
+ * operator can't oversell against a known fab batch.
36
+ * `deposit_minor` defaults to 0 — the per-unit partial
37
+ * payment captured at reservation time; `full_price_minor`
38
+ * is the locked-in unit price on the eventual order line.
39
+ *
40
+ * reserve({ campaign_slug, customer_id, quantity,
41
+ * payment_intent_id? })
42
+ * Refuses if the campaign isn't active, would exceed the
43
+ * capacity cap, or has already launched / closed. Writes a
44
+ * reservation row + increments the per-campaign
45
+ * `units_reserved` counter so the next cap check is a single
46
+ * read, not a COUNT(*) aggregate.
47
+ *
48
+ * cancelReservation({ reservation_id, reason })
49
+ * Flips an active reservation to `cancelled` and decrements
50
+ * the per-campaign counter — the freed capacity is
51
+ * immediately available to subsequent `reserve` calls.
52
+ * Refuses on missing row or non-active status so the counter
53
+ * can't under-flow.
54
+ *
55
+ * convertReservationToOrder({ reservation_id })
56
+ * Flips an active reservation to `converted` and, when an
57
+ * order handle is wired, composes `order.createFromCart` to
58
+ * land the regular order. When no handle is wired, the
59
+ * caller supplies the `converted_order_id` via the
60
+ * per-reservation field. The counter is NOT decremented on
61
+ * conversion — the reserved capacity is consumed by the
62
+ * order, not freed.
63
+ *
64
+ * launchCampaign({ slug, now })
65
+ * Sweeps every active reservation for the campaign and runs
66
+ * `convertReservationToOrder` on each. Flips the campaign
67
+ * to `launched`. Refuses if the campaign is already launched
68
+ * / closed, or if `now < launch_at` (the operator can't
69
+ * launch ahead of the wall clock; the caller passes the
70
+ * monotonic clock so tests can drive it deterministically).
71
+ *
72
+ * closeCampaign({ slug, reason })
73
+ * Operator-driven terminal flip without launching — used
74
+ * when a campaign is cancelled outright (fab batch fell
75
+ * through, design changed). Refuses if already launched
76
+ * / closed. Reservations remain in `active` so the operator
77
+ * can refund deposits through the regular order/refund
78
+ * surface and then cancel the reservations explicitly.
79
+ *
80
+ * availability({ slug })
81
+ * Pure read used by the marketing page. Returns
82
+ * `{ remaining_units, days_until_launch, ... }` —
83
+ * `remaining_units` is `max_units_available - units_reserved`
84
+ * (or `null` for unlimited campaigns); `days_until_launch` is
85
+ * `ceil((launch_at - now) / 86400000)` (clamped at 0 for
86
+ * launched / past-due campaigns).
87
+ *
88
+ * Composition:
89
+ * - b.guardUuid — every customer_id / reservation_id is
90
+ * UUID-shape-validated at the entry point.
91
+ * - b.uuid.v7 — preorder_reservations.id (sortable, monotonic
92
+ * so a tied `reservation_at` still sorts deterministically).
93
+ * - order (optional) — when injected,
94
+ * `convertReservationToOrder` composes `order.createFromCart`
95
+ * to land the regular order at launch. When absent, the
96
+ * conversion still records the status flip + lets the caller
97
+ * supply `converted_order_id` directly.
98
+ *
99
+ * The factory accepts an optional `query` (defaults to
100
+ * `b.externalDb.query`), an optional `catalog` handle (currently
101
+ * unused — kept for future spec drift onto the catalog inventory
102
+ * bucket), and an optional `order` handle (composes
103
+ * `order.createFromCart` at conversion time when wired).
104
+ */
105
+
106
+ var bShop;
107
+ function _b() {
108
+ if (!bShop) bShop = require("./index");
109
+ return bShop.framework;
110
+ }
111
+
112
+ // ---- constants ----------------------------------------------------------
113
+
114
+ var SLUG_RE = /^[a-z0-9][a-z0-9-]{0,127}$/;
115
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
116
+ var CURRENCY_RE = /^[A-Z]{3}$/;
117
+ var MAX_COPY_LEN = 2000;
118
+ var MAX_REASON_LEN = 280;
119
+ var DAY_MS = 24 * 60 * 60 * 1000;
120
+
121
+ var CAMPAIGN_STATUSES = Object.freeze(["active", "launched", "closed"]);
122
+ var RESERVATION_STATUSES = Object.freeze(["active", "converted", "cancelled"]);
123
+
124
+ // ---- monotonic clock ----------------------------------------------------
125
+ //
126
+ // Two reservations recorded inside the same millisecond would otherwise
127
+ // collide on the v7-uuid timestamp prefix and tie on (reservation_at, id)
128
+ // keyset reads. The monotonic step guarantees strict-increase so the
129
+ // "newest first" customer-portal sort is deterministic without depending
130
+ // on the v7 sub-ms counter.
131
+ var _lastTs = 0;
132
+ function _now() {
133
+ var t = Date.now();
134
+ if (t <= _lastTs) { t = _lastTs + 1; }
135
+ _lastTs = t;
136
+ return t;
137
+ }
138
+
139
+ // ---- validators ---------------------------------------------------------
140
+
141
+ function _uuid(s, label) {
142
+ try {
143
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
144
+ } catch (e) {
145
+ throw new TypeError("preorder: " + label + " — " + (e && e.message || "invalid UUID"));
146
+ }
147
+ }
148
+
149
+ function _slug(s) {
150
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
151
+ throw new TypeError("preorder: slug must match /^[a-z0-9][a-z0-9-]*$/ (lowercase alnum + hyphen, ≤ 128 chars)");
152
+ }
153
+ }
154
+
155
+ function _sku(s) {
156
+ if (typeof s !== "string" || !SKU_RE.test(s)) {
157
+ throw new TypeError("preorder: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, ≤ 128 chars)");
158
+ }
159
+ }
160
+
161
+ function _currency(s) {
162
+ if (typeof s !== "string" || !CURRENCY_RE.test(s)) {
163
+ throw new TypeError("preorder: currency must be a 3-letter uppercase ISO 4217 code");
164
+ }
165
+ }
166
+
167
+ function _epochMs(n, label) {
168
+ if (!Number.isInteger(n) || n < 0) {
169
+ throw new TypeError("preorder: " + label + " must be a non-negative integer (epoch ms)");
170
+ }
171
+ }
172
+
173
+ function _epochMsOrNull(n, label) {
174
+ if (n === null || n === undefined) return null;
175
+ _epochMs(n, label);
176
+ return n;
177
+ }
178
+
179
+ function _positiveInt(n, label) {
180
+ if (!Number.isInteger(n) || n <= 0) {
181
+ throw new TypeError("preorder: " + label + " must be a positive integer");
182
+ }
183
+ }
184
+
185
+ function _nonNegInt(n, label) {
186
+ if (!Number.isInteger(n) || n < 0) {
187
+ throw new TypeError("preorder: " + label + " must be a non-negative integer");
188
+ }
189
+ }
190
+
191
+ function _nonNegIntOrNull(n, label) {
192
+ if (n === null || n === undefined) return null;
193
+ if (!Number.isInteger(n) || n < 0) {
194
+ throw new TypeError("preorder: " + label + " must be a non-negative integer or null");
195
+ }
196
+ return n;
197
+ }
198
+
199
+ function _shortText(s, label, max) {
200
+ if (s == null) return "";
201
+ if (typeof s !== "string" || s.length > max) {
202
+ throw new TypeError("preorder: " + label + " must be a string ≤ " + max + " chars");
203
+ }
204
+ return s;
205
+ }
206
+
207
+ function _optString(s, label, max) {
208
+ if (s == null) return null;
209
+ if (typeof s !== "string" || s.length === 0 || s.length > max) {
210
+ throw new TypeError("preorder: " + label + " must be a non-empty string ≤ " + max + " chars");
211
+ }
212
+ return s;
213
+ }
214
+
215
+ // ---- factory ------------------------------------------------------------
216
+
217
+ function create(opts) {
218
+ opts = opts || {};
219
+ var query = opts.query;
220
+ if (!query) {
221
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
222
+ }
223
+ // catalog handle is accepted-but-unused — reserved for future drift
224
+ // (e.g. cross-checking SKU against catalog.product.get at define
225
+ // time). Reading it here keeps the factory shape consistent with
226
+ // sibling primitives that DO require a catalog handle.
227
+ if (opts.catalog != null && typeof opts.catalog !== "object") {
228
+ throw new TypeError("preorder.create: opts.catalog must be an object when provided");
229
+ }
230
+ var orderHandle = opts.order || null;
231
+ if (orderHandle && typeof orderHandle.createFromCart !== "function") {
232
+ throw new TypeError("preorder.create: opts.order must expose createFromCart when provided");
233
+ }
234
+
235
+ async function _getCampaign(slug) {
236
+ var r = await query("SELECT * FROM preorder_campaigns WHERE slug = ?1", [slug]);
237
+ return r.rows[0] || null;
238
+ }
239
+
240
+ async function _getReservation(id) {
241
+ var r = await query("SELECT * FROM preorder_reservations WHERE id = ?1", [id]);
242
+ return r.rows[0] || null;
243
+ }
244
+
245
+ // Convert one active reservation. Composes the optional order handle
246
+ // when wired; otherwise records the status flip without populating
247
+ // `converted_order_id` (the caller can patch it later from whatever
248
+ // order surface they used). The per-campaign counter is NOT
249
+ // decremented on conversion — the reserved capacity is consumed by
250
+ // the order, not freed.
251
+ async function _convertOne(reservation, campaign, ts) {
252
+ var convertedOrderId = null;
253
+ if (orderHandle) {
254
+ var orderResult = await orderHandle.createFromCart({
255
+ customer_id: reservation.customer_id,
256
+ lines: [{
257
+ sku: campaign.sku,
258
+ variant_id: campaign.variant_id,
259
+ quantity: reservation.quantity,
260
+ unit_price_minor: campaign.full_price_minor,
261
+ currency: campaign.currency,
262
+ }],
263
+ preorder_reservation_id: reservation.id,
264
+ preorder_campaign_slug: campaign.slug,
265
+ });
266
+ convertedOrderId = orderResult && orderResult.id ? orderResult.id : null;
267
+ }
268
+ await query(
269
+ "UPDATE preorder_reservations SET status = 'converted', converted_at = ?1, " +
270
+ "converted_order_id = ?2 WHERE id = ?3 AND status = 'active'",
271
+ [ts, convertedOrderId, reservation.id],
272
+ );
273
+ return convertedOrderId;
274
+ }
275
+
276
+ return {
277
+ // Operator opens a campaign. Idempotent on `slug` via PK conflict
278
+ // — re-defining a slug that already exists is refused outright so
279
+ // the operator notices the typo (vs silently mutating an active
280
+ // campaign).
281
+ defineCampaign: async function (input) {
282
+ if (!input || typeof input !== "object") {
283
+ throw new TypeError("preorder.defineCampaign: input object required");
284
+ }
285
+ _slug(input.slug);
286
+ _sku(input.sku);
287
+ var variantId = input.variant_id == null ? null : _uuid(input.variant_id, "variant_id");
288
+ _epochMs(input.launch_at, "launch_at");
289
+ var chargeAt = input.charge_at == null ? input.launch_at : input.charge_at;
290
+ _epochMs(chargeAt, "charge_at");
291
+ var maxUnits = _nonNegIntOrNull(input.max_units_available, "max_units_available");
292
+ var deposit = input.deposit_minor == null ? 0 : input.deposit_minor;
293
+ _nonNegInt(deposit, "deposit_minor");
294
+ _nonNegInt(input.full_price_minor, "full_price_minor");
295
+ if (deposit > input.full_price_minor) {
296
+ throw new TypeError("preorder.defineCampaign: deposit_minor must not exceed full_price_minor");
297
+ }
298
+ _currency(input.currency);
299
+ var copy = _shortText(input.marketing_copy, "marketing_copy", MAX_COPY_LEN);
300
+
301
+ var existing = await _getCampaign(input.slug);
302
+ if (existing) {
303
+ throw new TypeError("preorder.defineCampaign: slug " + JSON.stringify(input.slug) + " already exists");
304
+ }
305
+ var ts = _now();
306
+ await query(
307
+ "INSERT INTO preorder_campaigns (slug, sku, variant_id, launch_at, charge_at, " +
308
+ "max_units_available, units_reserved, deposit_minor, full_price_minor, currency, " +
309
+ "marketing_copy, status, created_at, updated_at) " +
310
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, ?7, ?8, ?9, ?10, 'active', ?11, ?11)",
311
+ [input.slug, input.sku, variantId, input.launch_at, chargeAt, maxUnits,
312
+ deposit, input.full_price_minor, input.currency, copy, ts],
313
+ );
314
+ return await _getCampaign(input.slug);
315
+ },
316
+
317
+ getCampaign: async function (slug) {
318
+ _slug(slug);
319
+ return await _getCampaign(slug);
320
+ },
321
+
322
+ // Customer-driven reserve. Refuses when:
323
+ // - the campaign doesn't exist / isn't active
324
+ // - the requested quantity would push units_reserved past the
325
+ // configured cap
326
+ // - any input shape is wrong
327
+ // Increments the counter in the same logical operation so the
328
+ // next cap check is a single read.
329
+ reserve: async function (input) {
330
+ if (!input || typeof input !== "object") {
331
+ throw new TypeError("preorder.reserve: input object required");
332
+ }
333
+ _slug(input.campaign_slug);
334
+ _uuid(input.customer_id, "customer_id");
335
+ _positiveInt(input.quantity, "quantity");
336
+ var paymentIntentId = _optString(input.payment_intent_id, "payment_intent_id", 128);
337
+
338
+ var campaign = await _getCampaign(input.campaign_slug);
339
+ if (!campaign) {
340
+ throw new TypeError("preorder.reserve: campaign " + JSON.stringify(input.campaign_slug) + " not found");
341
+ }
342
+ if (campaign.status !== "active") {
343
+ throw new TypeError("preorder.reserve: campaign is " + campaign.status +
344
+ ", only active campaigns accept reservations");
345
+ }
346
+ if (campaign.max_units_available !== null && campaign.max_units_available !== undefined) {
347
+ if (campaign.units_reserved + input.quantity > campaign.max_units_available) {
348
+ throw new TypeError("preorder.reserve: would exceed max_units_available " +
349
+ "(cap=" + campaign.max_units_available + ", reserved=" + campaign.units_reserved +
350
+ ", requested=" + input.quantity + ")");
351
+ }
352
+ }
353
+
354
+ var ts = _now();
355
+ // Pass the monotonic ts into uuid.v7 so the 48-bit timestamp
356
+ // prefix is strictly increasing across consecutive reservations
357
+ // — the (id) keyset cursor used by reservationsForCustomer is
358
+ // monotonic without depending on Date.now() ticking between
359
+ // calls.
360
+ var id = _b().uuid.v7({ now: ts });
361
+ await query(
362
+ "INSERT INTO preorder_reservations (id, campaign_slug, customer_id, quantity, " +
363
+ "payment_intent_id, status, reservation_at) " +
364
+ "VALUES (?1, ?2, ?3, ?4, ?5, 'active', ?6)",
365
+ [id, input.campaign_slug, input.customer_id, input.quantity, paymentIntentId, ts],
366
+ );
367
+ await query(
368
+ "UPDATE preorder_campaigns SET units_reserved = units_reserved + ?1, updated_at = ?2 " +
369
+ "WHERE slug = ?3",
370
+ [input.quantity, ts, input.campaign_slug],
371
+ );
372
+ return await _getReservation(id);
373
+ },
374
+
375
+ getReservation: async function (reservationId) {
376
+ _uuid(reservationId, "reservation_id");
377
+ return await _getReservation(reservationId);
378
+ },
379
+
380
+ // Customer-portal read — every reservation a customer holds,
381
+ // newest first. The v7-uuid PK encodes the monotonic timestamp so
382
+ // `ORDER BY id DESC` matches `ORDER BY reservation_at DESC` without
383
+ // a second index.
384
+ reservationsForCustomer: async function (customerId, listOpts) {
385
+ _uuid(customerId, "customer_id");
386
+ listOpts = listOpts || {};
387
+ var sql, params;
388
+ if (listOpts.status != null) {
389
+ if (RESERVATION_STATUSES.indexOf(listOpts.status) === -1) {
390
+ throw new TypeError("preorder.reservationsForCustomer: status must be one of " +
391
+ RESERVATION_STATUSES.join(", "));
392
+ }
393
+ sql = "SELECT * FROM preorder_reservations WHERE customer_id = ?1 AND status = ?2 ORDER BY id DESC";
394
+ params = [customerId, listOpts.status];
395
+ } else {
396
+ sql = "SELECT * FROM preorder_reservations WHERE customer_id = ?1 ORDER BY id DESC";
397
+ params = [customerId];
398
+ }
399
+ var r = await query(sql, params);
400
+ return r.rows;
401
+ },
402
+
403
+ // Flip an active reservation to cancelled. Decrements the
404
+ // per-campaign counter so the freed capacity is immediately
405
+ // available to subsequent `reserve` calls. Refuses missing row or
406
+ // non-active status so the counter can never under-flow. MAX(0,
407
+ // ...) clamps the counter against out-of-band corruption.
408
+ cancelReservation: async function (input) {
409
+ if (!input || typeof input !== "object") {
410
+ throw new TypeError("preorder.cancelReservation: input object required");
411
+ }
412
+ _uuid(input.reservation_id, "reservation_id");
413
+ var reason = _shortText(input.reason, "reason", MAX_REASON_LEN);
414
+ var reservation = await _getReservation(input.reservation_id);
415
+ if (!reservation) {
416
+ throw new TypeError("preorder.cancelReservation: reservation " +
417
+ JSON.stringify(input.reservation_id) + " not found");
418
+ }
419
+ if (reservation.status !== "active") {
420
+ throw new TypeError("preorder.cancelReservation: reservation is " + reservation.status +
421
+ ", only active reservations can be cancelled");
422
+ }
423
+ var ts = _now();
424
+ await query(
425
+ "UPDATE preorder_reservations SET status = 'cancelled', cancelled_at = ?1, " +
426
+ "cancel_reason = ?2 WHERE id = ?3",
427
+ [ts, reason, reservation.id],
428
+ );
429
+ await query(
430
+ "UPDATE preorder_campaigns SET units_reserved = MAX(0, units_reserved - ?1), " +
431
+ "updated_at = ?2 WHERE slug = ?3",
432
+ [reservation.quantity, ts, reservation.campaign_slug],
433
+ );
434
+ return await _getReservation(reservation.id);
435
+ },
436
+
437
+ // Convert one reservation explicitly. The launch flow calls this
438
+ // verb in a loop via `launchCampaign`; operators can also call it
439
+ // out-of-band when a campaign is structured for rolling
440
+ // conversions ahead of the launch wall-clock.
441
+ convertReservationToOrder: async function (input) {
442
+ if (!input || typeof input !== "object") {
443
+ throw new TypeError("preorder.convertReservationToOrder: input object required");
444
+ }
445
+ _uuid(input.reservation_id, "reservation_id");
446
+ var reservation = await _getReservation(input.reservation_id);
447
+ if (!reservation) {
448
+ throw new TypeError("preorder.convertReservationToOrder: reservation " +
449
+ JSON.stringify(input.reservation_id) + " not found");
450
+ }
451
+ if (reservation.status !== "active") {
452
+ throw new TypeError("preorder.convertReservationToOrder: reservation is " + reservation.status +
453
+ ", only active reservations can be converted");
454
+ }
455
+ var campaign = await _getCampaign(reservation.campaign_slug);
456
+ if (!campaign) {
457
+ throw new TypeError("preorder.convertReservationToOrder: campaign " +
458
+ JSON.stringify(reservation.campaign_slug) + " not found");
459
+ }
460
+ var ts = _now();
461
+ var orderId = await _convertOne(reservation, campaign, ts);
462
+ return {
463
+ reservation_id: reservation.id,
464
+ converted_order_id: orderId,
465
+ status: "converted",
466
+ };
467
+ },
468
+
469
+ // Launch the campaign: walk every active reservation and convert
470
+ // each to a regular order. Flips the campaign to `launched`.
471
+ // Refuses if already launched / closed, or if now < launch_at —
472
+ // the caller passes the monotonic clock so tests can drive it
473
+ // deterministically.
474
+ launchCampaign: async function (input) {
475
+ if (!input || typeof input !== "object") {
476
+ throw new TypeError("preorder.launchCampaign: input object required");
477
+ }
478
+ _slug(input.slug);
479
+ _epochMs(input.now, "now");
480
+ var campaign = await _getCampaign(input.slug);
481
+ if (!campaign) {
482
+ throw new TypeError("preorder.launchCampaign: campaign " +
483
+ JSON.stringify(input.slug) + " not found");
484
+ }
485
+ if (campaign.status !== "active") {
486
+ throw new TypeError("preorder.launchCampaign: campaign is " + campaign.status +
487
+ ", only active campaigns can be launched");
488
+ }
489
+ if (input.now < campaign.launch_at) {
490
+ throw new TypeError("preorder.launchCampaign: now (" + input.now +
491
+ ") is before launch_at (" + campaign.launch_at + ")");
492
+ }
493
+ var activeRows = (await query(
494
+ "SELECT * FROM preorder_reservations WHERE campaign_slug = ?1 AND status = 'active' " +
495
+ "ORDER BY id ASC",
496
+ [input.slug],
497
+ )).rows;
498
+ var ts = _now();
499
+ var converted = [];
500
+ for (var i = 0; i < activeRows.length; i += 1) {
501
+ var orderId = await _convertOne(activeRows[i], campaign, ts);
502
+ converted.push({ reservation_id: activeRows[i].id, converted_order_id: orderId });
503
+ }
504
+ await query(
505
+ "UPDATE preorder_campaigns SET status = 'launched', launched_at = ?1, updated_at = ?1 " +
506
+ "WHERE slug = ?2",
507
+ [ts, input.slug],
508
+ );
509
+ return {
510
+ slug: input.slug,
511
+ launched_at: ts,
512
+ converted_count: converted.length,
513
+ conversions: converted,
514
+ };
515
+ },
516
+
517
+ // Operator-driven terminal flip without launching — used when a
518
+ // campaign is cancelled outright. Reservations remain in `active`
519
+ // so the operator can refund deposits through the regular
520
+ // order/refund surface and then cancel the reservations
521
+ // explicitly.
522
+ closeCampaign: async function (input) {
523
+ if (!input || typeof input !== "object") {
524
+ throw new TypeError("preorder.closeCampaign: input object required");
525
+ }
526
+ _slug(input.slug);
527
+ var reason = _shortText(input.reason, "reason", MAX_REASON_LEN);
528
+ var campaign = await _getCampaign(input.slug);
529
+ if (!campaign) {
530
+ throw new TypeError("preorder.closeCampaign: campaign " +
531
+ JSON.stringify(input.slug) + " not found");
532
+ }
533
+ if (campaign.status !== "active") {
534
+ throw new TypeError("preorder.closeCampaign: campaign is " + campaign.status +
535
+ ", only active campaigns can be closed");
536
+ }
537
+ var ts = _now();
538
+ await query(
539
+ "UPDATE preorder_campaigns SET status = 'closed', closed_at = ?1, close_reason = ?2, " +
540
+ "updated_at = ?1 WHERE slug = ?3",
541
+ [ts, reason, input.slug],
542
+ );
543
+ return await _getCampaign(input.slug);
544
+ },
545
+
546
+ // Pure read used by the marketing page. `remaining_units` is
547
+ // `max_units_available - units_reserved` (or `null` for unlimited
548
+ // campaigns); `days_until_launch` is `ceil((launch_at - now) /
549
+ // DAY_MS)` (clamped at 0 for launched / past-due campaigns).
550
+ availability: async function (input) {
551
+ if (!input || typeof input !== "object") {
552
+ throw new TypeError("preorder.availability: input object required");
553
+ }
554
+ _slug(input.slug);
555
+ var campaign = await _getCampaign(input.slug);
556
+ if (!campaign) {
557
+ return null;
558
+ }
559
+ var remaining;
560
+ if (campaign.max_units_available == null) {
561
+ remaining = null;
562
+ } else {
563
+ remaining = campaign.max_units_available - campaign.units_reserved;
564
+ if (remaining < 0) { remaining = 0; }
565
+ }
566
+ var now = _now();
567
+ var msUntil = campaign.launch_at - now;
568
+ var days;
569
+ if (msUntil <= 0) {
570
+ days = 0;
571
+ } else {
572
+ days = Math.ceil(msUntil / DAY_MS);
573
+ }
574
+ return {
575
+ slug: campaign.slug,
576
+ status: campaign.status,
577
+ remaining_units: remaining,
578
+ units_reserved: campaign.units_reserved,
579
+ max_units_available: campaign.max_units_available,
580
+ days_until_launch: days,
581
+ launch_at: campaign.launch_at,
582
+ charge_at: campaign.charge_at,
583
+ full_price_minor: campaign.full_price_minor,
584
+ deposit_minor: campaign.deposit_minor,
585
+ currency: campaign.currency,
586
+ };
587
+ },
588
+ };
589
+ }
590
+
591
+ module.exports = {
592
+ create: create,
593
+ CAMPAIGN_STATUSES: CAMPAIGN_STATUSES,
594
+ RESERVATION_STATUSES: RESERVATION_STATUSES,
595
+ };