@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
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.promoBundles
|
|
4
|
+
* @title Time-limited promotional bundles — windowed, single-price
|
|
5
|
+
* multi-component offers distinct from kit-product bundles.
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* A promo bundle is a marketing campaign, not a virtual SKU. The
|
|
9
|
+
* operator stages a bundle ("Holiday 3-Pack: $99 for skus A+B+C —
|
|
10
|
+
* save 25% vs. individual"), the bundle exists only during a
|
|
11
|
+
* [starts_at, expires_at) wall-clock window, and cart-resolution
|
|
12
|
+
* recognizes the bundle when all components are present in the cart
|
|
13
|
+
* at the required quantities and swaps the matching line totals for
|
|
14
|
+
* the bundle's single fixed price.
|
|
15
|
+
*
|
|
16
|
+
* Distinct from the `bundles` primitive:
|
|
17
|
+
*
|
|
18
|
+
* - `bundles` ships a virtual SKU the customer adds as a single
|
|
19
|
+
* line — a kit product. The line resolves to N child SKUs at
|
|
20
|
+
* expand-time. The bundle price is derived from the children's
|
|
21
|
+
* prices plus an optional basis-points discount.
|
|
22
|
+
*
|
|
23
|
+
* - `promoBundles` is detected, not added. The customer adds the
|
|
24
|
+
* individual components; the cart-resolution layer scans active
|
|
25
|
+
* promo bundles for ones whose component set is satisfied by the
|
|
26
|
+
* current cart and offers the operator-chosen `bundle_price_minor`
|
|
27
|
+
* in place of the sum of individual line prices. Outside the
|
|
28
|
+
* window the bundle does not exist for the resolver.
|
|
29
|
+
*
|
|
30
|
+
* Lifecycle:
|
|
31
|
+
*
|
|
32
|
+
* - `defineBundle({ slug, title, components, bundle_price_minor,
|
|
33
|
+
* currency, starts_at, expires_at,
|
|
34
|
+
* max_redemptions_total?, max_per_customer? })`
|
|
35
|
+
* — operator-authored campaign. Components are an array of
|
|
36
|
+
* `{ sku, quantity }` rows; duplicates and non-positive
|
|
37
|
+
* quantities are refused. `starts_at < expires_at` is required;
|
|
38
|
+
* a zero-length or inverted window is refused at define time.
|
|
39
|
+
*
|
|
40
|
+
* - `detectInCart({ cart })` — pure read. Returns every active,
|
|
41
|
+
* not-yet-capped promo bundle whose every component sku appears
|
|
42
|
+
* in the cart at >= the required quantity, plus the
|
|
43
|
+
* `savings_minor` delta the bundle would apply (sum of the
|
|
44
|
+
* matched lines' `unit_amount_minor * quantity_consumed` minus
|
|
45
|
+
* the bundle's `bundle_price_minor`). Bundles whose savings are
|
|
46
|
+
* <= 0 are still returned; the caller decides whether to surface
|
|
47
|
+
* them.
|
|
48
|
+
*
|
|
49
|
+
* - `applyBundleToCart({ cart, bundle_slug })` — pure transform.
|
|
50
|
+
* Returns a NEW cart object with the matched lines consumed
|
|
51
|
+
* (their quantities reduced by the consumed amount; lines that
|
|
52
|
+
* hit zero are dropped) and a synthetic `bundle:<slug>` line
|
|
53
|
+
* added carrying the bundle's price + currency. Does NOT
|
|
54
|
+
* mutate the input cart and does NOT write a redemption row —
|
|
55
|
+
* the caller composes `recordRedemption` from the checkout /
|
|
56
|
+
* order finalize path so the redemption count only ticks when
|
|
57
|
+
* the cart actually converts.
|
|
58
|
+
*
|
|
59
|
+
* - `recordRedemption({ slug, order_id, customer_id?,
|
|
60
|
+
* savings_minor })` — writes the audit row
|
|
61
|
+
* and increments `redemptions_used`. Refuses past
|
|
62
|
+
* `max_redemptions_total` (the cap is checked under the same
|
|
63
|
+
* statement that increments the counter via a guarded UPDATE so
|
|
64
|
+
* two simultaneous checkouts cannot both succeed on the final
|
|
65
|
+
* slot). UNIQUE(slug, order_id) gives idempotency — replaying a
|
|
66
|
+
* redemption for the same order is a no-op that returns the
|
|
67
|
+
* existing row.
|
|
68
|
+
*
|
|
69
|
+
* - `updateBundle(slug, patch)` — operator can extend the window,
|
|
70
|
+
* relax caps, edit title; the component set and bundle price are
|
|
71
|
+
* immutable once defined (changing them mid-campaign would
|
|
72
|
+
* silently alter what past detect-but-not-yet-converted carts
|
|
73
|
+
* see). Operators rename + redefine if they need a structural
|
|
74
|
+
* change.
|
|
75
|
+
*
|
|
76
|
+
* - `archiveBundle(slug)` — soft retire. Archived bundles do not
|
|
77
|
+
* appear in `activeBundles` or `detectInCart`; historical
|
|
78
|
+
* redemption rows stay intact for reporting.
|
|
79
|
+
*
|
|
80
|
+
* - `activeBundles({ now })` — windowed read. Returns every
|
|
81
|
+
* defined, non-archived bundle where
|
|
82
|
+
* `starts_at <= now < expires_at`. Caller passes the clock so
|
|
83
|
+
* tests can drive the window deterministically and so the
|
|
84
|
+
* storefront-render path can pin a single "now" across a render
|
|
85
|
+
* sweep.
|
|
86
|
+
*
|
|
87
|
+
* - `metricsForBundle(slug)` — operator-facing rollup:
|
|
88
|
+
* `{ redemptions_used, total_savings_minor, distinct_customers,
|
|
89
|
+
* first_redeemed_at, last_redeemed_at }`.
|
|
90
|
+
*
|
|
91
|
+
* Storage: `promo_bundles` + `promo_bundle_redemptions`
|
|
92
|
+
* (migration 0140).
|
|
93
|
+
*
|
|
94
|
+
* Composition:
|
|
95
|
+
* - b.guardUuid — every customer_id / order_id is UUID-shape
|
|
96
|
+
* validated at the entry point (strict profile).
|
|
97
|
+
* - b.uuid.v7 — promo_bundle_redemptions.id (sortable so
|
|
98
|
+
* audit-listing ORDER BY id matches chronological order without
|
|
99
|
+
* a secondary index when occurred_at ties on the millisecond).
|
|
100
|
+
*
|
|
101
|
+
* @primitive promoBundles
|
|
102
|
+
* @related shop.bundles, shop.cart, shop.autoDiscount
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
var bShop;
|
|
106
|
+
function _b() {
|
|
107
|
+
if (!bShop) bShop = require("./index");
|
|
108
|
+
return bShop.framework;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- constants ----------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
var SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,79}$/;
|
|
114
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
115
|
+
var CURRENCY_RE = /^[A-Z]{3}$/;
|
|
116
|
+
|
|
117
|
+
var MAX_TITLE_LEN = 200;
|
|
118
|
+
var MAX_COMPONENTS = 50;
|
|
119
|
+
var MAX_LIST_LIMIT = 500;
|
|
120
|
+
var MAX_REDEMPTIONS_CAP = 1000000000;
|
|
121
|
+
var MAX_PRICE_MINOR = 1000000000000; // 1e12 minor units — same envelope b.money accepts
|
|
122
|
+
|
|
123
|
+
var ALLOWED_PATCH_COLUMNS = Object.freeze([
|
|
124
|
+
"title",
|
|
125
|
+
"starts_at",
|
|
126
|
+
"expires_at",
|
|
127
|
+
"max_redemptions_total",
|
|
128
|
+
"max_per_customer",
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
// ---- monotonic clock ----------------------------------------------------
|
|
132
|
+
//
|
|
133
|
+
// Two redemptions recorded inside the same millisecond would otherwise
|
|
134
|
+
// collide on the v7-uuid timestamp prefix and tie on (occurred_at, id)
|
|
135
|
+
// keyset reads. The monotonic step guarantees strict-increase so the
|
|
136
|
+
// audit-listing sort is deterministic without depending on the v7
|
|
137
|
+
// sub-millisecond counter.
|
|
138
|
+
var _lastTs = 0;
|
|
139
|
+
function _now() {
|
|
140
|
+
var t = Date.now();
|
|
141
|
+
if (t <= _lastTs) { t = _lastTs + 1; }
|
|
142
|
+
_lastTs = t;
|
|
143
|
+
return t;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---- validators ---------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
function _slug(s) {
|
|
149
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
150
|
+
throw new TypeError("promoBundles: slug must match /^[a-z0-9][a-z0-9._-]*$/ (<= 80 chars)");
|
|
151
|
+
}
|
|
152
|
+
return s;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function _sku(s, label) {
|
|
156
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
157
|
+
throw new TypeError("promoBundles: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= 128 chars)");
|
|
158
|
+
}
|
|
159
|
+
return s;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function _title(s) {
|
|
163
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
|
|
164
|
+
throw new TypeError("promoBundles: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
|
|
165
|
+
}
|
|
166
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) {
|
|
167
|
+
throw new TypeError("promoBundles: title must not contain control bytes");
|
|
168
|
+
}
|
|
169
|
+
return s;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function _currency(s) {
|
|
173
|
+
if (typeof s !== "string" || !CURRENCY_RE.test(s)) {
|
|
174
|
+
throw new TypeError("promoBundles: currency must be a 3-letter uppercase ISO 4217 code");
|
|
175
|
+
}
|
|
176
|
+
return s;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function _positiveInt(n, label) {
|
|
180
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
181
|
+
throw new TypeError("promoBundles: " + label + " must be a positive integer");
|
|
182
|
+
}
|
|
183
|
+
return n;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function _priceMinor(n) {
|
|
187
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_PRICE_MINOR) {
|
|
188
|
+
throw new TypeError("promoBundles: bundle_price_minor must be a non-negative integer <= " + MAX_PRICE_MINOR);
|
|
189
|
+
}
|
|
190
|
+
return n;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function _savingsMinor(n) {
|
|
194
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_PRICE_MINOR) {
|
|
195
|
+
throw new TypeError("promoBundles: savings_minor must be a non-negative integer <= " + MAX_PRICE_MINOR);
|
|
196
|
+
}
|
|
197
|
+
return n;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function _epochMs(n, label) {
|
|
201
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
202
|
+
throw new TypeError("promoBundles: " + label + " must be a non-negative integer (epoch ms)");
|
|
203
|
+
}
|
|
204
|
+
return n;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function _capOrNull(n, label) {
|
|
208
|
+
if (n == null) return null;
|
|
209
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_REDEMPTIONS_CAP) {
|
|
210
|
+
throw new TypeError("promoBundles: " + label + " must be a positive integer <= " + MAX_REDEMPTIONS_CAP + " (or null)");
|
|
211
|
+
}
|
|
212
|
+
return n;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function _uuid(s, label) {
|
|
216
|
+
try {
|
|
217
|
+
return _b().guardUuid.sanitize(s, { profile: "strict" });
|
|
218
|
+
} catch (e) {
|
|
219
|
+
throw new TypeError("promoBundles: " + label + " — " + (e && e.message || "invalid UUID"));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---- row hydration ------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
function _safeParseArray(s, fallback) {
|
|
226
|
+
if (s == null) return fallback;
|
|
227
|
+
try {
|
|
228
|
+
var parsed = JSON.parse(s);
|
|
229
|
+
if (Array.isArray(parsed)) return parsed;
|
|
230
|
+
return fallback;
|
|
231
|
+
} catch (_e) {
|
|
232
|
+
return fallback;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function _hydrateBundle(r) {
|
|
237
|
+
if (!r) return null;
|
|
238
|
+
return {
|
|
239
|
+
slug: r.slug,
|
|
240
|
+
title: r.title,
|
|
241
|
+
components: _safeParseArray(r.components_json, []),
|
|
242
|
+
bundle_price_minor: Number(r.bundle_price_minor),
|
|
243
|
+
currency: r.currency,
|
|
244
|
+
starts_at: Number(r.starts_at),
|
|
245
|
+
expires_at: Number(r.expires_at),
|
|
246
|
+
max_redemptions_total: r.max_redemptions_total == null ? null : Number(r.max_redemptions_total),
|
|
247
|
+
max_per_customer: r.max_per_customer == null ? null : Number(r.max_per_customer),
|
|
248
|
+
redemptions_used: Number(r.redemptions_used || 0),
|
|
249
|
+
archived_at: r.archived_at == null ? null : Number(r.archived_at),
|
|
250
|
+
created_at: Number(r.created_at),
|
|
251
|
+
updated_at: Number(r.updated_at),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function _hydrateRedemption(r) {
|
|
256
|
+
if (!r) return null;
|
|
257
|
+
return {
|
|
258
|
+
id: r.id,
|
|
259
|
+
slug: r.slug,
|
|
260
|
+
order_id: r.order_id,
|
|
261
|
+
customer_id: r.customer_id == null ? null : r.customer_id,
|
|
262
|
+
savings_minor: Number(r.savings_minor),
|
|
263
|
+
occurred_at: Number(r.occurred_at),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---- component validation ----------------------------------------------
|
|
268
|
+
|
|
269
|
+
// Returns a normalized, dedup-checked component list `[{ sku, quantity }]`.
|
|
270
|
+
// Refuses empty input, duplicate skus, non-positive quantities, and
|
|
271
|
+
// arrays longer than MAX_COMPONENTS.
|
|
272
|
+
function _normalizeComponents(components) {
|
|
273
|
+
if (!Array.isArray(components) || components.length === 0) {
|
|
274
|
+
throw new TypeError("promoBundles: components must be a non-empty array of { sku, quantity }");
|
|
275
|
+
}
|
|
276
|
+
if (components.length > MAX_COMPONENTS) {
|
|
277
|
+
throw new TypeError("promoBundles: components cap is " + MAX_COMPONENTS + " entries per bundle");
|
|
278
|
+
}
|
|
279
|
+
var seen = Object.create(null);
|
|
280
|
+
var out = [];
|
|
281
|
+
for (var i = 0; i < components.length; i += 1) {
|
|
282
|
+
var c = components[i];
|
|
283
|
+
if (!c || typeof c !== "object") {
|
|
284
|
+
throw new TypeError("promoBundles: components[" + i + "] must be an object");
|
|
285
|
+
}
|
|
286
|
+
_sku(c.sku, "components[" + i + "].sku");
|
|
287
|
+
_positiveInt(c.quantity, "components[" + i + "].quantity");
|
|
288
|
+
if (seen[c.sku]) {
|
|
289
|
+
throw new TypeError("promoBundles: duplicate component sku " + JSON.stringify(c.sku));
|
|
290
|
+
}
|
|
291
|
+
seen[c.sku] = true;
|
|
292
|
+
out.push({ sku: c.sku, quantity: c.quantity });
|
|
293
|
+
}
|
|
294
|
+
// Stable sort by sku so the stored components_json is canonical —
|
|
295
|
+
// operator edits that re-order their input array don't perturb the
|
|
296
|
+
// persisted row (and downstream JSON diff of the bundle row stays
|
|
297
|
+
// meaningful).
|
|
298
|
+
out.sort(function (a, b) { return a.sku < b.sku ? -1 : a.sku > b.sku ? 1 : 0; });
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---- cart shape validation ---------------------------------------------
|
|
303
|
+
|
|
304
|
+
// detectInCart / applyBundleToCart accept a duck-typed cart:
|
|
305
|
+
// { currency: "USD", lines: [{ sku, quantity, unit_amount_minor, ... }] }
|
|
306
|
+
// Anything else is refused at the entry point so a typo doesn't
|
|
307
|
+
// silently return an empty match list.
|
|
308
|
+
function _validateCart(cart, fn) {
|
|
309
|
+
if (!cart || typeof cart !== "object") {
|
|
310
|
+
throw new TypeError("promoBundles." + fn + ": cart object required");
|
|
311
|
+
}
|
|
312
|
+
if (!Array.isArray(cart.lines)) {
|
|
313
|
+
throw new TypeError("promoBundles." + fn + ": cart.lines must be an array");
|
|
314
|
+
}
|
|
315
|
+
if (typeof cart.currency !== "string" || !CURRENCY_RE.test(cart.currency)) {
|
|
316
|
+
throw new TypeError("promoBundles." + fn + ": cart.currency must be a 3-letter uppercase ISO 4217 code");
|
|
317
|
+
}
|
|
318
|
+
for (var i = 0; i < cart.lines.length; i += 1) {
|
|
319
|
+
var ln = cart.lines[i];
|
|
320
|
+
if (!ln || typeof ln !== "object") {
|
|
321
|
+
throw new TypeError("promoBundles." + fn + ": cart.lines[" + i + "] must be an object");
|
|
322
|
+
}
|
|
323
|
+
if (typeof ln.sku !== "string" || !ln.sku.length) {
|
|
324
|
+
throw new TypeError("promoBundles." + fn + ": cart.lines[" + i + "].sku must be a non-empty string");
|
|
325
|
+
}
|
|
326
|
+
if (!Number.isInteger(ln.quantity) || ln.quantity <= 0) {
|
|
327
|
+
throw new TypeError("promoBundles." + fn + ": cart.lines[" + i + "].quantity must be a positive integer");
|
|
328
|
+
}
|
|
329
|
+
if (!Number.isInteger(ln.unit_amount_minor) || ln.unit_amount_minor < 0) {
|
|
330
|
+
throw new TypeError("promoBundles." + fn + ": cart.lines[" + i + "].unit_amount_minor must be a non-negative integer");
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---- factory ------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
function create(opts) {
|
|
338
|
+
opts = opts || {};
|
|
339
|
+
var query = opts.query;
|
|
340
|
+
if (!query) {
|
|
341
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Optional catalog handle — when injected, defineBundle verifies
|
|
345
|
+
// every component sku exists in the catalog so a typo doesn't ship
|
|
346
|
+
// a bundle nobody can ever match. Absent the handle, the primitive
|
|
347
|
+
// accepts any well-shaped sku string and trusts the operator (the
|
|
348
|
+
// common case for headless operators driving the storefront with
|
|
349
|
+
// their own product feed).
|
|
350
|
+
var catalog = null;
|
|
351
|
+
if (opts.catalog != null) {
|
|
352
|
+
if (!opts.catalog.variants || typeof opts.catalog.variants.bySku !== "function") {
|
|
353
|
+
throw new TypeError("promoBundles.create: opts.catalog must expose variants.bySku(sku) when provided");
|
|
354
|
+
}
|
|
355
|
+
catalog = opts.catalog;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---- defineBundle --------------------------------------------------
|
|
359
|
+
|
|
360
|
+
async function defineBundle(input) {
|
|
361
|
+
if (!input || typeof input !== "object") {
|
|
362
|
+
throw new TypeError("promoBundles.defineBundle: input object required");
|
|
363
|
+
}
|
|
364
|
+
var slug = _slug(input.slug);
|
|
365
|
+
var title = _title(input.title);
|
|
366
|
+
var price = _priceMinor(input.bundle_price_minor);
|
|
367
|
+
var currency = _currency(input.currency);
|
|
368
|
+
var startsAt = _epochMs(input.starts_at, "starts_at");
|
|
369
|
+
var expiresAt = _epochMs(input.expires_at, "expires_at");
|
|
370
|
+
if (startsAt >= expiresAt) {
|
|
371
|
+
throw new TypeError("promoBundles.defineBundle: starts_at must be strictly less than expires_at");
|
|
372
|
+
}
|
|
373
|
+
var maxTotal = _capOrNull(input.max_redemptions_total, "max_redemptions_total");
|
|
374
|
+
var maxPerCust = _capOrNull(input.max_per_customer, "max_per_customer");
|
|
375
|
+
var components = _normalizeComponents(input.components);
|
|
376
|
+
|
|
377
|
+
// Optional catalog gate — refuse component skus that don't exist
|
|
378
|
+
// in the operator's catalog. The check is per-sku so the error
|
|
379
|
+
// surfaces the first missing one.
|
|
380
|
+
if (catalog) {
|
|
381
|
+
for (var i = 0; i < components.length; i += 1) {
|
|
382
|
+
var found = await catalog.variants.bySku(components[i].sku);
|
|
383
|
+
if (!found) {
|
|
384
|
+
throw new TypeError("promoBundles.defineBundle: component sku " + JSON.stringify(components[i].sku) + " not found in catalog");
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Refuse re-define of an existing slug — operators `updateBundle`
|
|
390
|
+
// to extend a window or relax caps; structural changes require a
|
|
391
|
+
// new slug.
|
|
392
|
+
var existing = (await query(
|
|
393
|
+
"SELECT slug FROM promo_bundles WHERE slug = ?1 LIMIT 1",
|
|
394
|
+
[slug],
|
|
395
|
+
)).rows[0];
|
|
396
|
+
if (existing) {
|
|
397
|
+
throw new TypeError("promoBundles.defineBundle: slug " + JSON.stringify(slug) + " already exists — use updateBundle / archiveBundle");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
var ts = _now();
|
|
401
|
+
await query(
|
|
402
|
+
"INSERT INTO promo_bundles (slug, title, components_json, bundle_price_minor, currency, " +
|
|
403
|
+
"starts_at, expires_at, max_redemptions_total, max_per_customer, redemptions_used, " +
|
|
404
|
+
"archived_at, created_at, updated_at) " +
|
|
405
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 0, NULL, ?10, ?10)",
|
|
406
|
+
[
|
|
407
|
+
slug, title, JSON.stringify(components), price, currency,
|
|
408
|
+
startsAt, expiresAt, maxTotal, maxPerCust, ts,
|
|
409
|
+
],
|
|
410
|
+
);
|
|
411
|
+
return await getBundle(slug);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ---- getBundle / activeBundles -------------------------------------
|
|
415
|
+
|
|
416
|
+
async function getBundle(slug) {
|
|
417
|
+
_slug(slug);
|
|
418
|
+
var r = (await query(
|
|
419
|
+
"SELECT * FROM promo_bundles WHERE slug = ?1 LIMIT 1",
|
|
420
|
+
[slug],
|
|
421
|
+
)).rows[0];
|
|
422
|
+
return _hydrateBundle(r);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function activeBundles(input) {
|
|
426
|
+
input = input || {};
|
|
427
|
+
var now = _epochMs(input.now, "now");
|
|
428
|
+
var limit = input.limit == null ? 50 : input.limit;
|
|
429
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
|
|
430
|
+
throw new TypeError("promoBundles.activeBundles: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
|
|
431
|
+
}
|
|
432
|
+
// Window-only — archived bundles never appear here.
|
|
433
|
+
var rows = (await query(
|
|
434
|
+
"SELECT * FROM promo_bundles " +
|
|
435
|
+
"WHERE archived_at IS NULL AND starts_at <= ?1 AND expires_at > ?1 " +
|
|
436
|
+
"ORDER BY starts_at ASC, slug ASC LIMIT ?2",
|
|
437
|
+
[now, limit],
|
|
438
|
+
)).rows;
|
|
439
|
+
var out = [];
|
|
440
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
441
|
+
var b = _hydrateBundle(rows[i]);
|
|
442
|
+
// Skip bundles that already hit their total redemption cap —
|
|
443
|
+
// they're in-window but no longer redeemable, so the resolver
|
|
444
|
+
// shouldn't surface them.
|
|
445
|
+
if (b.max_redemptions_total != null && b.redemptions_used >= b.max_redemptions_total) continue;
|
|
446
|
+
out.push(b);
|
|
447
|
+
}
|
|
448
|
+
return out;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ---- detectInCart --------------------------------------------------
|
|
452
|
+
|
|
453
|
+
async function detectInCart(input) {
|
|
454
|
+
if (!input || typeof input !== "object") {
|
|
455
|
+
throw new TypeError("promoBundles.detectInCart: input object required");
|
|
456
|
+
}
|
|
457
|
+
_validateCart(input.cart, "detectInCart");
|
|
458
|
+
var now = input.now == null ? _now() : _epochMs(input.now, "now");
|
|
459
|
+
var cart = input.cart;
|
|
460
|
+
|
|
461
|
+
// Index the cart by sku → { quantity, unit_amount_minor } for
|
|
462
|
+
// O(C * K) match — C = active bundles, K = components per bundle.
|
|
463
|
+
var byMatchSku = Object.create(null);
|
|
464
|
+
for (var i = 0; i < cart.lines.length; i += 1) {
|
|
465
|
+
var ln = cart.lines[i];
|
|
466
|
+
// Merge same-sku lines (rare in practice — the cart primitive
|
|
467
|
+
// collapses them — but the resolver must not silently miss a
|
|
468
|
+
// match because two lines split the required quantity).
|
|
469
|
+
if (!byMatchSku[ln.sku]) {
|
|
470
|
+
byMatchSku[ln.sku] = { quantity: 0, unit_amount_minor: ln.unit_amount_minor };
|
|
471
|
+
}
|
|
472
|
+
byMatchSku[ln.sku].quantity += ln.quantity;
|
|
473
|
+
// Keep the lowest unit price seen for this sku — the conservative
|
|
474
|
+
// choice for savings math (we never claim more savings than the
|
|
475
|
+
// cheapest line could give us).
|
|
476
|
+
if (ln.unit_amount_minor < byMatchSku[ln.sku].unit_amount_minor) {
|
|
477
|
+
byMatchSku[ln.sku].unit_amount_minor = ln.unit_amount_minor;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
var actives = await activeBundles({ now: now, limit: MAX_LIST_LIMIT });
|
|
482
|
+
var matches = [];
|
|
483
|
+
for (var k = 0; k < actives.length; k += 1) {
|
|
484
|
+
var bundle = actives[k];
|
|
485
|
+
if (bundle.currency !== cart.currency) continue; // mixed-currency carts never match
|
|
486
|
+
var ok = true;
|
|
487
|
+
var componentTotal = 0;
|
|
488
|
+
for (var c = 0; c < bundle.components.length; c += 1) {
|
|
489
|
+
var comp = bundle.components[c];
|
|
490
|
+
var have = byMatchSku[comp.sku];
|
|
491
|
+
if (!have || have.quantity < comp.quantity) { ok = false; break; }
|
|
492
|
+
componentTotal += have.unit_amount_minor * comp.quantity;
|
|
493
|
+
}
|
|
494
|
+
if (!ok) continue;
|
|
495
|
+
var savings = componentTotal - bundle.bundle_price_minor;
|
|
496
|
+
if (savings < 0) savings = 0;
|
|
497
|
+
matches.push({
|
|
498
|
+
slug: bundle.slug,
|
|
499
|
+
title: bundle.title,
|
|
500
|
+
bundle_price_minor: bundle.bundle_price_minor,
|
|
501
|
+
currency: bundle.currency,
|
|
502
|
+
components: bundle.components,
|
|
503
|
+
component_total_minor: componentTotal,
|
|
504
|
+
savings_minor: savings,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
// Sort by savings descending so the caller's "best deal first"
|
|
508
|
+
// surface is the natural order; ties broken by slug for stability.
|
|
509
|
+
matches.sort(function (a, b) {
|
|
510
|
+
if (a.savings_minor !== b.savings_minor) return b.savings_minor - a.savings_minor;
|
|
511
|
+
return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
|
|
512
|
+
});
|
|
513
|
+
return matches;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ---- applyBundleToCart ---------------------------------------------
|
|
517
|
+
|
|
518
|
+
async function applyBundleToCart(input) {
|
|
519
|
+
if (!input || typeof input !== "object") {
|
|
520
|
+
throw new TypeError("promoBundles.applyBundleToCart: input object required");
|
|
521
|
+
}
|
|
522
|
+
_validateCart(input.cart, "applyBundleToCart");
|
|
523
|
+
var slug = _slug(input.bundle_slug);
|
|
524
|
+
var now = input.now == null ? _now() : _epochMs(input.now, "now");
|
|
525
|
+
var cart = input.cart;
|
|
526
|
+
|
|
527
|
+
var bundle = await getBundle(slug);
|
|
528
|
+
if (!bundle) {
|
|
529
|
+
throw new TypeError("promoBundles.applyBundleToCart: bundle slug " + JSON.stringify(slug) + " not found");
|
|
530
|
+
}
|
|
531
|
+
if (bundle.archived_at != null) {
|
|
532
|
+
var arErr = new Error("promoBundles.applyBundleToCart: bundle " + JSON.stringify(slug) + " is archived");
|
|
533
|
+
arErr.code = "BUNDLE_NOT_REDEEMABLE";
|
|
534
|
+
throw arErr;
|
|
535
|
+
}
|
|
536
|
+
if (now < bundle.starts_at || now >= bundle.expires_at) {
|
|
537
|
+
var winErr = new Error("promoBundles.applyBundleToCart: bundle " + JSON.stringify(slug) + " is outside its active window");
|
|
538
|
+
winErr.code = "BUNDLE_NOT_REDEEMABLE";
|
|
539
|
+
throw winErr;
|
|
540
|
+
}
|
|
541
|
+
if (bundle.max_redemptions_total != null && bundle.redemptions_used >= bundle.max_redemptions_total) {
|
|
542
|
+
var capErr = new Error("promoBundles.applyBundleToCart: bundle " + JSON.stringify(slug) + " has hit max_redemptions_total");
|
|
543
|
+
capErr.code = "BUNDLE_CAP_REACHED";
|
|
544
|
+
throw capErr;
|
|
545
|
+
}
|
|
546
|
+
if (bundle.currency !== cart.currency) {
|
|
547
|
+
throw new TypeError("promoBundles.applyBundleToCart: bundle currency " + JSON.stringify(bundle.currency) +
|
|
548
|
+
" does not match cart currency " + JSON.stringify(cart.currency));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Walk the components; for each one, find a cart line (or set of
|
|
552
|
+
// same-sku lines) that supplies the required quantity. Refuse if
|
|
553
|
+
// the cart doesn't contain enough — the caller should have called
|
|
554
|
+
// detectInCart first.
|
|
555
|
+
//
|
|
556
|
+
// Deep-clone the cart line array so we never mutate the input.
|
|
557
|
+
var newLines = [];
|
|
558
|
+
for (var i = 0; i < cart.lines.length; i += 1) {
|
|
559
|
+
var src = cart.lines[i];
|
|
560
|
+
var clone = {};
|
|
561
|
+
for (var key in src) {
|
|
562
|
+
if (Object.prototype.hasOwnProperty.call(src, key)) clone[key] = src[key];
|
|
563
|
+
}
|
|
564
|
+
newLines.push(clone);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
var componentTotal = 0;
|
|
568
|
+
for (var c = 0; c < bundle.components.length; c += 1) {
|
|
569
|
+
var comp = bundle.components[c];
|
|
570
|
+
var remaining = comp.quantity;
|
|
571
|
+
for (var n = 0; n < newLines.length && remaining > 0; n += 1) {
|
|
572
|
+
var line = newLines[n];
|
|
573
|
+
if (line.sku !== comp.sku) continue;
|
|
574
|
+
if (line.quantity <= 0) continue;
|
|
575
|
+
var take = Math.min(line.quantity, remaining);
|
|
576
|
+
componentTotal += line.unit_amount_minor * take;
|
|
577
|
+
line.quantity -= take;
|
|
578
|
+
remaining -= take;
|
|
579
|
+
}
|
|
580
|
+
if (remaining > 0) {
|
|
581
|
+
var missErr = new Error("promoBundles.applyBundleToCart: cart does not contain enough of component sku " +
|
|
582
|
+
JSON.stringify(comp.sku) + " (short " + remaining + ")");
|
|
583
|
+
missErr.code = "BUNDLE_COMPONENTS_MISSING";
|
|
584
|
+
throw missErr;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Drop zero-quantity lines, then push a synthetic bundle line.
|
|
589
|
+
var keptLines = [];
|
|
590
|
+
for (var d = 0; d < newLines.length; d += 1) {
|
|
591
|
+
if (newLines[d].quantity > 0) keptLines.push(newLines[d]);
|
|
592
|
+
}
|
|
593
|
+
var savings = componentTotal - bundle.bundle_price_minor;
|
|
594
|
+
if (savings < 0) savings = 0;
|
|
595
|
+
keptLines.push({
|
|
596
|
+
sku: "bundle:" + bundle.slug,
|
|
597
|
+
quantity: 1,
|
|
598
|
+
unit_amount_minor: bundle.bundle_price_minor,
|
|
599
|
+
currency: bundle.currency,
|
|
600
|
+
is_promo_bundle: true,
|
|
601
|
+
promo_bundle_slug: bundle.slug,
|
|
602
|
+
promo_bundle_title: bundle.title,
|
|
603
|
+
savings_minor: savings,
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// Return a new cart object with the same top-level identity
|
|
607
|
+
// fields plus the rewritten lines. The caller (cart-resolution
|
|
608
|
+
// layer) is responsible for re-totalling.
|
|
609
|
+
var out = {};
|
|
610
|
+
for (var ck in cart) {
|
|
611
|
+
if (Object.prototype.hasOwnProperty.call(cart, ck)) out[ck] = cart[ck];
|
|
612
|
+
}
|
|
613
|
+
out.lines = keptLines;
|
|
614
|
+
out.applied_promo_bundle = {
|
|
615
|
+
slug: bundle.slug,
|
|
616
|
+
title: bundle.title,
|
|
617
|
+
savings_minor: savings,
|
|
618
|
+
currency: bundle.currency,
|
|
619
|
+
};
|
|
620
|
+
return out;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ---- recordRedemption ----------------------------------------------
|
|
624
|
+
|
|
625
|
+
async function recordRedemption(input) {
|
|
626
|
+
if (!input || typeof input !== "object") {
|
|
627
|
+
throw new TypeError("promoBundles.recordRedemption: input object required");
|
|
628
|
+
}
|
|
629
|
+
var slug = _slug(input.slug);
|
|
630
|
+
var orderId = _uuid(input.order_id, "order_id");
|
|
631
|
+
var customerId = input.customer_id == null ? null : _uuid(input.customer_id, "customer_id");
|
|
632
|
+
var savingsMinor = _savingsMinor(input.savings_minor);
|
|
633
|
+
|
|
634
|
+
var bundle = await getBundle(slug);
|
|
635
|
+
if (!bundle) {
|
|
636
|
+
throw new TypeError("promoBundles.recordRedemption: bundle slug " + JSON.stringify(slug) + " not found");
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Idempotency: a redemption for the same (slug, order_id) replays
|
|
640
|
+
// as a no-op returning the existing row. The UNIQUE constraint on
|
|
641
|
+
// the table backs this — but we check first so the caller doesn't
|
|
642
|
+
// see a misleading "cap reached" error when the cap was reached
|
|
643
|
+
// by THIS same order on a previous attempt.
|
|
644
|
+
var existing = (await query(
|
|
645
|
+
"SELECT * FROM promo_bundle_redemptions WHERE slug = ?1 AND order_id = ?2 LIMIT 1",
|
|
646
|
+
[slug, orderId],
|
|
647
|
+
)).rows[0];
|
|
648
|
+
if (existing) return _hydrateRedemption(existing);
|
|
649
|
+
|
|
650
|
+
// Cap check + increment in one guarded UPDATE so two concurrent
|
|
651
|
+
// checkouts cannot both claim the last slot. SQLite's statement
|
|
652
|
+
// atomicity guarantees the row update is all-or-nothing; the
|
|
653
|
+
// WHERE filters out a slot-exhausted race losing-side.
|
|
654
|
+
var ts = _now();
|
|
655
|
+
var redemptionId = _b().uuid.v7({ now: ts });
|
|
656
|
+
|
|
657
|
+
var updateSql, updateParams;
|
|
658
|
+
if (bundle.max_redemptions_total == null) {
|
|
659
|
+
updateSql = "UPDATE promo_bundles SET redemptions_used = redemptions_used + 1, updated_at = ?1 WHERE slug = ?2";
|
|
660
|
+
updateParams = [ts, slug];
|
|
661
|
+
} else {
|
|
662
|
+
updateSql = "UPDATE promo_bundles SET redemptions_used = redemptions_used + 1, updated_at = ?1 " +
|
|
663
|
+
"WHERE slug = ?2 AND redemptions_used < ?3";
|
|
664
|
+
updateParams = [ts, slug, bundle.max_redemptions_total];
|
|
665
|
+
}
|
|
666
|
+
var updateRes = await query(updateSql, updateParams);
|
|
667
|
+
if (Number(updateRes.rowCount || 0) === 0) {
|
|
668
|
+
// Lost the race for the last slot — re-read to confirm and
|
|
669
|
+
// surface the cap error with the row's actual state.
|
|
670
|
+
var after = await getBundle(slug);
|
|
671
|
+
if (after && after.max_redemptions_total != null && after.redemptions_used >= after.max_redemptions_total) {
|
|
672
|
+
var capErr = new Error("promoBundles.recordRedemption: bundle " + JSON.stringify(slug) + " has hit max_redemptions_total");
|
|
673
|
+
capErr.code = "BUNDLE_CAP_REACHED";
|
|
674
|
+
throw capErr;
|
|
675
|
+
}
|
|
676
|
+
// Anything else is an unexpected post-condition — re-raise so
|
|
677
|
+
// the caller sees it rather than silently dropping the row.
|
|
678
|
+
throw new Error("promoBundles.recordRedemption: failed to claim a redemption slot for " + JSON.stringify(slug));
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
await query(
|
|
682
|
+
"INSERT INTO promo_bundle_redemptions (id, slug, order_id, customer_id, savings_minor, occurred_at) " +
|
|
683
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
684
|
+
[redemptionId, slug, orderId, customerId, savingsMinor, ts],
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
return {
|
|
688
|
+
id: redemptionId,
|
|
689
|
+
slug: slug,
|
|
690
|
+
order_id: orderId,
|
|
691
|
+
customer_id: customerId,
|
|
692
|
+
savings_minor: savingsMinor,
|
|
693
|
+
occurred_at: ts,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ---- updateBundle --------------------------------------------------
|
|
698
|
+
|
|
699
|
+
async function updateBundle(slug, patch) {
|
|
700
|
+
_slug(slug);
|
|
701
|
+
if (!patch || typeof patch !== "object") {
|
|
702
|
+
throw new TypeError("promoBundles.updateBundle: patch object required");
|
|
703
|
+
}
|
|
704
|
+
var keys = Object.keys(patch);
|
|
705
|
+
if (!keys.length) {
|
|
706
|
+
throw new TypeError("promoBundles.updateBundle: patch must include at least one column");
|
|
707
|
+
}
|
|
708
|
+
var current = await getBundle(slug);
|
|
709
|
+
if (!current) {
|
|
710
|
+
throw new TypeError("promoBundles.updateBundle: slug " + JSON.stringify(slug) + " not found");
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
var sets = [];
|
|
714
|
+
var params = [];
|
|
715
|
+
var idx = 1;
|
|
716
|
+
var nextStartsAt = current.starts_at;
|
|
717
|
+
var nextExpiresAt = current.expires_at;
|
|
718
|
+
|
|
719
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
720
|
+
var col = keys[i];
|
|
721
|
+
if (ALLOWED_PATCH_COLUMNS.indexOf(col) === -1) {
|
|
722
|
+
throw new TypeError("promoBundles.updateBundle: unsupported patch column " + JSON.stringify(col) +
|
|
723
|
+
" — components + bundle_price_minor + currency are immutable; redefine under a new slug");
|
|
724
|
+
}
|
|
725
|
+
if (col === "title") {
|
|
726
|
+
sets.push("title = ?" + idx);
|
|
727
|
+
params.push(_title(patch[col]));
|
|
728
|
+
} else if (col === "starts_at") {
|
|
729
|
+
nextStartsAt = _epochMs(patch[col], "starts_at");
|
|
730
|
+
sets.push("starts_at = ?" + idx);
|
|
731
|
+
params.push(nextStartsAt);
|
|
732
|
+
} else if (col === "expires_at") {
|
|
733
|
+
nextExpiresAt = _epochMs(patch[col], "expires_at");
|
|
734
|
+
sets.push("expires_at = ?" + idx);
|
|
735
|
+
params.push(nextExpiresAt);
|
|
736
|
+
} else if (col === "max_redemptions_total") {
|
|
737
|
+
var cap = _capOrNull(patch[col], "max_redemptions_total");
|
|
738
|
+
// Refuse a cap that's already been exceeded — the
|
|
739
|
+
// operator-visible state would be incoherent (used > cap).
|
|
740
|
+
if (cap != null && cap < current.redemptions_used) {
|
|
741
|
+
throw new TypeError("promoBundles.updateBundle: max_redemptions_total " + cap +
|
|
742
|
+
" is below redemptions_used " + current.redemptions_used);
|
|
743
|
+
}
|
|
744
|
+
sets.push("max_redemptions_total = ?" + idx);
|
|
745
|
+
params.push(cap);
|
|
746
|
+
} else /* max_per_customer */ {
|
|
747
|
+
sets.push("max_per_customer = ?" + idx);
|
|
748
|
+
params.push(_capOrNull(patch[col], "max_per_customer"));
|
|
749
|
+
}
|
|
750
|
+
idx += 1;
|
|
751
|
+
}
|
|
752
|
+
if (nextStartsAt >= nextExpiresAt) {
|
|
753
|
+
throw new TypeError("promoBundles.updateBundle: starts_at must remain strictly less than expires_at after patch");
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
sets.push("updated_at = ?" + idx);
|
|
757
|
+
params.push(_now());
|
|
758
|
+
idx += 1;
|
|
759
|
+
params.push(slug);
|
|
760
|
+
|
|
761
|
+
var r = await query(
|
|
762
|
+
"UPDATE promo_bundles SET " + sets.join(", ") + " WHERE slug = ?" + idx,
|
|
763
|
+
params,
|
|
764
|
+
);
|
|
765
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
766
|
+
throw new TypeError("promoBundles.updateBundle: slug " + JSON.stringify(slug) + " not found");
|
|
767
|
+
}
|
|
768
|
+
return await getBundle(slug);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ---- archiveBundle -------------------------------------------------
|
|
772
|
+
|
|
773
|
+
async function archiveBundle(slug) {
|
|
774
|
+
_slug(slug);
|
|
775
|
+
var ts = _now();
|
|
776
|
+
var r = await query(
|
|
777
|
+
"UPDATE promo_bundles SET archived_at = ?1, updated_at = ?1 " +
|
|
778
|
+
"WHERE slug = ?2 AND archived_at IS NULL",
|
|
779
|
+
[ts, slug],
|
|
780
|
+
);
|
|
781
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
782
|
+
var existing = await getBundle(slug);
|
|
783
|
+
if (!existing) {
|
|
784
|
+
throw new TypeError("promoBundles.archiveBundle: slug " + JSON.stringify(slug) + " not found");
|
|
785
|
+
}
|
|
786
|
+
// Already archived — idempotent return.
|
|
787
|
+
return existing;
|
|
788
|
+
}
|
|
789
|
+
return await getBundle(slug);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ---- metricsForBundle ----------------------------------------------
|
|
793
|
+
|
|
794
|
+
async function metricsForBundle(slug) {
|
|
795
|
+
_slug(slug);
|
|
796
|
+
var bundle = await getBundle(slug);
|
|
797
|
+
if (!bundle) {
|
|
798
|
+
throw new TypeError("promoBundles.metricsForBundle: slug " + JSON.stringify(slug) + " not found");
|
|
799
|
+
}
|
|
800
|
+
var rollup = (await query(
|
|
801
|
+
"SELECT COUNT(*) AS n, COALESCE(SUM(savings_minor), 0) AS total_savings, " +
|
|
802
|
+
"COUNT(DISTINCT customer_id) AS distinct_customers, " +
|
|
803
|
+
"MIN(occurred_at) AS first_at, MAX(occurred_at) AS last_at " +
|
|
804
|
+
"FROM promo_bundle_redemptions WHERE slug = ?1",
|
|
805
|
+
[slug],
|
|
806
|
+
)).rows[0] || {};
|
|
807
|
+
var n = Number(rollup.n || rollup.N || 0);
|
|
808
|
+
return {
|
|
809
|
+
slug: slug,
|
|
810
|
+
redemptions_used: n,
|
|
811
|
+
total_savings_minor: Number(rollup.total_savings || 0),
|
|
812
|
+
distinct_customers: Number(rollup.distinct_customers || 0),
|
|
813
|
+
first_redeemed_at: rollup.first_at == null ? null : Number(rollup.first_at),
|
|
814
|
+
last_redeemed_at: rollup.last_at == null ? null : Number(rollup.last_at),
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return {
|
|
819
|
+
defineBundle: defineBundle,
|
|
820
|
+
getBundle: getBundle,
|
|
821
|
+
activeBundles: activeBundles,
|
|
822
|
+
detectInCart: detectInCart,
|
|
823
|
+
applyBundleToCart: applyBundleToCart,
|
|
824
|
+
recordRedemption: recordRedemption,
|
|
825
|
+
updateBundle: updateBundle,
|
|
826
|
+
archiveBundle: archiveBundle,
|
|
827
|
+
metricsForBundle: metricsForBundle,
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
module.exports = {
|
|
832
|
+
create: create,
|
|
833
|
+
MAX_COMPONENTS: MAX_COMPONENTS,
|
|
834
|
+
ALLOWED_PATCH_COLUMNS: ALLOWED_PATCH_COLUMNS,
|
|
835
|
+
};
|