@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.
- package/CHANGELOG.md +10 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/business-hours.js +980 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +45 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/pixel-events.js +995 -0
- package/lib/preorder.js +595 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/site-redirects.js +690 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/theme-assets.js +711 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- package/package.json +1 -1
package/lib/preorder.js
ADDED
|
@@ -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
|
+
};
|