@blamejs/blamejs-shop 0.0.62 → 0.0.65
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 +6 -0
- package/lib/address-validation.js +529 -0
- package/lib/auto-discount.js +1133 -0
- package/lib/captcha-gate.js +961 -0
- package/lib/catalog-drafts.js +1614 -0
- package/lib/compliance-export.js +614 -0
- package/lib/cookie-consent.js +605 -0
- package/lib/customer-roles.js +640 -0
- package/lib/cycle-counting.js +802 -0
- package/lib/delivery-estimate.js +1113 -0
- package/lib/email-warmup.js +795 -0
- package/lib/error-log.js +525 -0
- package/lib/index.js +25 -0
- package/lib/invoice-renderer.js +618 -0
- package/lib/live-chat.js +714 -0
- package/lib/metered-usage.js +782 -0
- package/lib/price-display.js +699 -0
- package/lib/product-bulk-ops.js +797 -0
- package/lib/purchase-orders.js +923 -0
- package/lib/quotes.js +944 -0
- package/lib/recommendations.js +850 -0
- package/lib/reorder-thresholds.js +678 -0
- package/lib/shipping-zones.js +621 -0
- package/lib/split-shipments.js +773 -0
- package/lib/store-credit.js +565 -0
- package/lib/trust-badges.js +721 -0
- package/lib/webhook-receiver.js +1034 -0
- package/package.json +1 -1
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.shippingZones
|
|
4
|
+
* @title Shipping zones — operator-defined regional rate tables
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Distinct sibling of `carrierRates` (which caches live carrier-API
|
|
8
|
+
* quotes for a single parcel) and of `shipping` (which does per-order
|
|
9
|
+
* cost math at the till). A zone is the flat-rate / table-rate
|
|
10
|
+
* option an operator reaches for when carrier shopping is overkill
|
|
11
|
+
* or unwanted — domestic US flat rate, EU zone-1 table rate, APAC
|
|
12
|
+
* weight-bucket rate.
|
|
13
|
+
*
|
|
14
|
+
* A zone has two operator-authored parts:
|
|
15
|
+
*
|
|
16
|
+
* 1. `regions` — array of `{country, region?}` entries the zone
|
|
17
|
+
* covers. Country is ISO 3166-1 alpha-2 (2 uppercase letters);
|
|
18
|
+
* region (when present) is the ISO 3166-2 subdivision code
|
|
19
|
+
* WITHOUT the country prefix (e.g. `CA` for California, `BAV`
|
|
20
|
+
* for Bavaria). A region-less entry covers the whole country.
|
|
21
|
+
* Most-specific match wins at lookup time — a `{country: "US",
|
|
22
|
+
* region: "CA"}` entry beats a `{country: "US"}` entry for a
|
|
23
|
+
* California destination.
|
|
24
|
+
*
|
|
25
|
+
* 2. `rates` — array of bucketed rate rows. Each row carries
|
|
26
|
+
* optional `[min_weight_grams, max_weight_grams)` half-open
|
|
27
|
+
* bounds (the weight bucket), optional `[min_order_minor,
|
|
28
|
+
* max_order_minor)` half-open bounds (the cart-value bucket),
|
|
29
|
+
* a non-negative `rate_minor`, a 3-letter ISO 4217 `currency`,
|
|
30
|
+
* and an operator-facing `service_label`. A rate carrying both
|
|
31
|
+
* kinds of bounds matches only when both the parcel weight AND
|
|
32
|
+
* the order value fall inside. A rate carrying NO bounds is the
|
|
33
|
+
* unconditional fallback (matches every shipment).
|
|
34
|
+
*
|
|
35
|
+
* Lookups:
|
|
36
|
+
*
|
|
37
|
+
* - `zoneForDestination({country, region?})` returns the most-
|
|
38
|
+
* specific covering zone's slug (or null). Country+region
|
|
39
|
+
* beats country-only; ties between two equally-specific zones
|
|
40
|
+
* resolve by slug ASC (deterministic).
|
|
41
|
+
*
|
|
42
|
+
* - `rateFor({destination_country, destination_region?,
|
|
43
|
+
* weight_grams, order_minor, currency})` walks every
|
|
44
|
+
* ACTIVE zone whose regions cover the destination, gathers all
|
|
45
|
+
* matching rate rows in the requested currency, and returns the
|
|
46
|
+
* list sorted by `rate_minor` ASC then `service_label` ASC.
|
|
47
|
+
* Multiple service labels (Standard / Express / Free-over-N) can
|
|
48
|
+
* all match a single shipment — the till renders them as a
|
|
49
|
+
* menu.
|
|
50
|
+
*
|
|
51
|
+
* Composes:
|
|
52
|
+
* - `b.uuid` (not exposed externally — zones key on slug, not
|
|
53
|
+
* uuid; no random ids in the surface)
|
|
54
|
+
*
|
|
55
|
+
* Surface:
|
|
56
|
+
* defineZone({ slug, title, regions, rates, active? })
|
|
57
|
+
* getZone(slug)
|
|
58
|
+
* listZones({ active_only? })
|
|
59
|
+
* updateZone(slug, patch)
|
|
60
|
+
* archiveZone(slug)
|
|
61
|
+
* rateFor({ destination_country, destination_region?,
|
|
62
|
+
* weight_grams, order_minor, currency })
|
|
63
|
+
* zoneForDestination({ country, region? })
|
|
64
|
+
*
|
|
65
|
+
* Storage:
|
|
66
|
+
* - `shipping_zones` (migration `0106_shipping_zones.sql`).
|
|
67
|
+
*
|
|
68
|
+
* @primitive shippingZones
|
|
69
|
+
* @related shop.carrierRates, shop.shipping
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
var MAX_SLUG_LEN = 64;
|
|
73
|
+
var SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
74
|
+
|
|
75
|
+
var MAX_TITLE_LEN = 200;
|
|
76
|
+
|
|
77
|
+
var MAX_REGIONS = 1024;
|
|
78
|
+
var COUNTRY_RE = /^[A-Z]{2}$/;
|
|
79
|
+
var REGION_RE = /^[A-Z0-9]{1,3}$/;
|
|
80
|
+
|
|
81
|
+
var MAX_RATES = 256;
|
|
82
|
+
var MAX_SERVICE_LABEL_LEN = 128;
|
|
83
|
+
|
|
84
|
+
var MAX_WEIGHT_GRAMS = 1000 * 1000; // 1 t — generous, refuses absurd input
|
|
85
|
+
var MAX_ORDER_MINOR = 1e12; // 10 billion in the minor unit — generous
|
|
86
|
+
|
|
87
|
+
var ALLOWED_UPDATE_COLUMNS = Object.freeze([
|
|
88
|
+
"title", "regions", "rates", "active",
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
// Lazy framework handle — matches the pattern every other shop
|
|
92
|
+
// primitive uses; avoids the require cycle that would arise from
|
|
93
|
+
// importing `./index` at module-eval time.
|
|
94
|
+
var bShop;
|
|
95
|
+
function _b() {
|
|
96
|
+
if (!bShop) bShop = require("./index");
|
|
97
|
+
return bShop.framework;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- validators ---------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
function _hasControlByte(s) {
|
|
103
|
+
for (var i = 0; i < s.length; i += 1) {
|
|
104
|
+
var cc = s.charCodeAt(i);
|
|
105
|
+
if (cc <= 0x1f || cc === 0x7f) return true;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function _slug(s) {
|
|
111
|
+
if (typeof s !== "string" || !s.length) {
|
|
112
|
+
throw new TypeError("shippingZones: slug must be a non-empty string");
|
|
113
|
+
}
|
|
114
|
+
if (s.length > MAX_SLUG_LEN) {
|
|
115
|
+
throw new TypeError("shippingZones: slug must be <= " + MAX_SLUG_LEN + " characters");
|
|
116
|
+
}
|
|
117
|
+
if (!SLUG_RE.test(s)) {
|
|
118
|
+
throw new TypeError(
|
|
119
|
+
"shippingZones: slug must match /^[a-z0-9][a-z0-9_-]{0,63}$/ — " +
|
|
120
|
+
"lowercase alphanumerics with `_`/`-`, must not start with separator"
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return s;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function _title(s) {
|
|
127
|
+
if (typeof s !== "string" || !s.length) {
|
|
128
|
+
throw new TypeError("shippingZones: title must be a non-empty string");
|
|
129
|
+
}
|
|
130
|
+
if (s.length > MAX_TITLE_LEN) {
|
|
131
|
+
throw new TypeError("shippingZones: title must be <= " + MAX_TITLE_LEN + " characters");
|
|
132
|
+
}
|
|
133
|
+
if (_hasControlByte(s)) {
|
|
134
|
+
throw new TypeError("shippingZones: title must not contain control characters");
|
|
135
|
+
}
|
|
136
|
+
return s;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function _country(s, label) {
|
|
140
|
+
if (typeof s !== "string" || !COUNTRY_RE.test(s)) {
|
|
141
|
+
throw new TypeError(
|
|
142
|
+
"shippingZones: " + label + " must be ISO 3166-1 alpha-2 (2 uppercase letters), got " +
|
|
143
|
+
JSON.stringify(s)
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return s;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function _region(s, label) {
|
|
150
|
+
if (s == null || s === "") return null;
|
|
151
|
+
if (typeof s !== "string" || !REGION_RE.test(s)) {
|
|
152
|
+
throw new TypeError(
|
|
153
|
+
"shippingZones: " + label + " must match /^[A-Z0-9]{1,3}$/ — ISO 3166-2 " +
|
|
154
|
+
"subdivision code without the country prefix, got " + JSON.stringify(s)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return s;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function _regions(arr) {
|
|
161
|
+
if (!Array.isArray(arr) || arr.length === 0) {
|
|
162
|
+
throw new TypeError("shippingZones: regions must be a non-empty array");
|
|
163
|
+
}
|
|
164
|
+
if (arr.length > MAX_REGIONS) {
|
|
165
|
+
throw new TypeError("shippingZones: regions must be <= " + MAX_REGIONS + " entries");
|
|
166
|
+
}
|
|
167
|
+
var seen = {};
|
|
168
|
+
var out = [];
|
|
169
|
+
for (var i = 0; i < arr.length; i += 1) {
|
|
170
|
+
var r = arr[i];
|
|
171
|
+
if (!r || typeof r !== "object" || Array.isArray(r)) {
|
|
172
|
+
throw new TypeError("shippingZones: regions[" + i + "] must be an object {country, region?}");
|
|
173
|
+
}
|
|
174
|
+
var country = _country(r.country, "regions[" + i + "].country");
|
|
175
|
+
var region = _region(r.region, "regions[" + i + "].region");
|
|
176
|
+
var key = country + "/" + (region || "*");
|
|
177
|
+
if (Object.prototype.hasOwnProperty.call(seen, key)) {
|
|
178
|
+
throw new TypeError(
|
|
179
|
+
"shippingZones: regions[" + i + "] duplicates an earlier (country" +
|
|
180
|
+
(region ? "+region" : "") + ") entry: " + key
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
seen[key] = true;
|
|
184
|
+
out.push({ country: country, region: region });
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function _currency(c, label) {
|
|
190
|
+
if (typeof c !== "string" || !/^[A-Z]{3}$/.test(c)) {
|
|
191
|
+
throw new TypeError("shippingZones: " + label + " must be 3-letter uppercase ISO 4217");
|
|
192
|
+
}
|
|
193
|
+
return c;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function _serviceLabel(s) {
|
|
197
|
+
if (typeof s !== "string" || !s.length) {
|
|
198
|
+
throw new TypeError("shippingZones: rates[].service_label must be a non-empty string");
|
|
199
|
+
}
|
|
200
|
+
if (s.length > MAX_SERVICE_LABEL_LEN) {
|
|
201
|
+
throw new TypeError("shippingZones: rates[].service_label must be <= " + MAX_SERVICE_LABEL_LEN + " characters");
|
|
202
|
+
}
|
|
203
|
+
if (_hasControlByte(s)) {
|
|
204
|
+
throw new TypeError("shippingZones: rates[].service_label must not contain control characters");
|
|
205
|
+
}
|
|
206
|
+
return s;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function _rateMinor(n) {
|
|
210
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
211
|
+
throw new TypeError("shippingZones: rates[].rate_minor must be a non-negative integer");
|
|
212
|
+
}
|
|
213
|
+
return n;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function _weightBound(v, label) {
|
|
217
|
+
if (v == null) return null;
|
|
218
|
+
if (!Number.isInteger(v) || v < 0 || v > MAX_WEIGHT_GRAMS) {
|
|
219
|
+
throw new TypeError(
|
|
220
|
+
"shippingZones: " + label + " must be a non-negative integer <= " + MAX_WEIGHT_GRAMS + " when provided"
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return v;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function _orderBound(v, label) {
|
|
227
|
+
if (v == null) return null;
|
|
228
|
+
if (!Number.isInteger(v) || v < 0 || v > MAX_ORDER_MINOR) {
|
|
229
|
+
throw new TypeError(
|
|
230
|
+
"shippingZones: " + label + " must be a non-negative integer <= " + MAX_ORDER_MINOR + " when provided"
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
return v;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function _rates(arr) {
|
|
237
|
+
if (!Array.isArray(arr) || arr.length === 0) {
|
|
238
|
+
throw new TypeError("shippingZones: rates must be a non-empty array");
|
|
239
|
+
}
|
|
240
|
+
if (arr.length > MAX_RATES) {
|
|
241
|
+
throw new TypeError("shippingZones: rates must be <= " + MAX_RATES + " entries");
|
|
242
|
+
}
|
|
243
|
+
var out = [];
|
|
244
|
+
for (var i = 0; i < arr.length; i += 1) {
|
|
245
|
+
var r = arr[i];
|
|
246
|
+
if (!r || typeof r !== "object" || Array.isArray(r)) {
|
|
247
|
+
throw new TypeError("shippingZones: rates[" + i + "] must be an object");
|
|
248
|
+
}
|
|
249
|
+
var minW = _weightBound(r.min_weight_grams, "rates[" + i + "].min_weight_grams");
|
|
250
|
+
var maxW = _weightBound(r.max_weight_grams, "rates[" + i + "].max_weight_grams");
|
|
251
|
+
if (minW != null && maxW != null && minW >= maxW) {
|
|
252
|
+
throw new TypeError(
|
|
253
|
+
"shippingZones: rates[" + i + "] — min_weight_grams must be strictly less than max_weight_grams"
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
var minO = _orderBound(r.min_order_minor, "rates[" + i + "].min_order_minor");
|
|
257
|
+
var maxO = _orderBound(r.max_order_minor, "rates[" + i + "].max_order_minor");
|
|
258
|
+
if (minO != null && maxO != null && minO >= maxO) {
|
|
259
|
+
throw new TypeError(
|
|
260
|
+
"shippingZones: rates[" + i + "] — min_order_minor must be strictly less than max_order_minor"
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
var rateMinor = _rateMinor(r.rate_minor);
|
|
264
|
+
var currency = _currency(r.currency, "rates[" + i + "].currency");
|
|
265
|
+
var serviceLabel = _serviceLabel(r.service_label);
|
|
266
|
+
out.push({
|
|
267
|
+
min_weight_grams: minW,
|
|
268
|
+
max_weight_grams: maxW,
|
|
269
|
+
min_order_minor: minO,
|
|
270
|
+
max_order_minor: maxO,
|
|
271
|
+
rate_minor: rateMinor,
|
|
272
|
+
currency: currency,
|
|
273
|
+
service_label: serviceLabel,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function _active(a) {
|
|
280
|
+
if (typeof a !== "boolean") {
|
|
281
|
+
throw new TypeError("shippingZones: active must be a boolean");
|
|
282
|
+
}
|
|
283
|
+
return a ? 1 : 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function _now() { return Date.now(); }
|
|
287
|
+
|
|
288
|
+
// Match-fit test for one rate row against a (weight_grams, order_minor,
|
|
289
|
+
// currency) lookup. `null` bounds are open on that side. The currency
|
|
290
|
+
// MUST match exactly — mixing USD against an EUR-priced bucket would
|
|
291
|
+
// silently mis-charge the operator.
|
|
292
|
+
function _rateMatches(rate, weightGrams, orderMinor, currency) {
|
|
293
|
+
if (rate.currency !== currency) return false;
|
|
294
|
+
if (rate.min_weight_grams != null && weightGrams < rate.min_weight_grams) return false;
|
|
295
|
+
if (rate.max_weight_grams != null && weightGrams >= rate.max_weight_grams) return false;
|
|
296
|
+
if (rate.min_order_minor != null && orderMinor < rate.min_order_minor) return false;
|
|
297
|
+
if (rate.max_order_minor != null && orderMinor >= rate.max_order_minor) return false;
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Specificity score for a destination match against a zone's regions:
|
|
302
|
+
// 2 — country + region matches exactly
|
|
303
|
+
// 1 — country-only entry matches (region omitted on either side)
|
|
304
|
+
// 0 — no entry matches
|
|
305
|
+
// Most-specific wins at lookup time.
|
|
306
|
+
function _zoneSpecificity(zoneRegions, country, region) {
|
|
307
|
+
var best = 0;
|
|
308
|
+
for (var i = 0; i < zoneRegions.length; i += 1) {
|
|
309
|
+
var entry = zoneRegions[i];
|
|
310
|
+
if (entry.country !== country) continue;
|
|
311
|
+
if (entry.region == null) {
|
|
312
|
+
// Country-only entry — covers every destination in that country.
|
|
313
|
+
if (best < 1) best = 1;
|
|
314
|
+
} else if (region != null && entry.region === region) {
|
|
315
|
+
// Country + region exact — strongest match, can short-circuit.
|
|
316
|
+
return 2;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return best;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---- factory ------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
function create(opts) {
|
|
325
|
+
opts = opts || {};
|
|
326
|
+
var query = opts.query;
|
|
327
|
+
if (!query) {
|
|
328
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function _hydrate(row) {
|
|
332
|
+
if (!row) return null;
|
|
333
|
+
var regions = [];
|
|
334
|
+
var rates = [];
|
|
335
|
+
try { regions = JSON.parse(row.regions_json || "[]"); }
|
|
336
|
+
catch (_e) { regions = []; /* drop-silent — by design; stored shape is primitive-owned */ }
|
|
337
|
+
try { rates = JSON.parse(row.rates_json || "[]"); }
|
|
338
|
+
catch (_e) { rates = []; /* drop-silent — by design; stored shape is primitive-owned */ }
|
|
339
|
+
return {
|
|
340
|
+
slug: row.slug,
|
|
341
|
+
title: row.title,
|
|
342
|
+
regions: regions,
|
|
343
|
+
rates: rates,
|
|
344
|
+
active: Number(row.active) === 1,
|
|
345
|
+
archived_at: row.archived_at == null ? null : Number(row.archived_at),
|
|
346
|
+
created_at: Number(row.created_at),
|
|
347
|
+
updated_at: Number(row.updated_at),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function _getRaw(slug) {
|
|
352
|
+
var r = await query("SELECT * FROM shipping_zones WHERE slug = ?1", [slug]);
|
|
353
|
+
return r.rows[0] || null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Read every live (non-archived) zone into memory. Zones are read-
|
|
357
|
+
// mostly with a small working set — an operator typically registers
|
|
358
|
+
// 5-20 — so the lookup walks the lot in JS rather than encoding
|
|
359
|
+
// region matching into SQL. `active_only` filters out paused zones.
|
|
360
|
+
async function _liveZones(activeOnly) {
|
|
361
|
+
var sql = "SELECT * FROM shipping_zones WHERE archived_at IS NULL";
|
|
362
|
+
if (activeOnly) sql += " AND active = 1";
|
|
363
|
+
sql += " ORDER BY slug ASC";
|
|
364
|
+
var r = await query(sql, []);
|
|
365
|
+
var out = [];
|
|
366
|
+
for (var i = 0; i < r.rows.length; i += 1) out.push(_hydrate(r.rows[i]));
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
SLUG_RE: SLUG_RE,
|
|
372
|
+
COUNTRY_RE: COUNTRY_RE,
|
|
373
|
+
REGION_RE: REGION_RE,
|
|
374
|
+
MAX_REGIONS: MAX_REGIONS,
|
|
375
|
+
MAX_RATES: MAX_RATES,
|
|
376
|
+
MAX_WEIGHT_GRAMS: MAX_WEIGHT_GRAMS,
|
|
377
|
+
MAX_ORDER_MINOR: MAX_ORDER_MINOR,
|
|
378
|
+
|
|
379
|
+
// Write a zone. Re-calling with the same slug refuses — operators
|
|
380
|
+
// mutating a zone go through `updateZone` so the audit trail
|
|
381
|
+
// (created_at + updated_at) stays threaded by slug. Archived zones
|
|
382
|
+
// also block re-defining the same slug — the archive is the
|
|
383
|
+
// intentional one-way exit; rotating the same name back into
|
|
384
|
+
// service would silently shadow the historical record.
|
|
385
|
+
defineZone: async function (input) {
|
|
386
|
+
if (!input || typeof input !== "object") {
|
|
387
|
+
throw new TypeError("shippingZones.defineZone: input object required");
|
|
388
|
+
}
|
|
389
|
+
var slug = _slug(input.slug);
|
|
390
|
+
var title = _title(input.title);
|
|
391
|
+
var regions = _regions(input.regions);
|
|
392
|
+
var rates = _rates(input.rates);
|
|
393
|
+
// `active` defaults to true — the common case is "I've defined
|
|
394
|
+
// the zone, turn it on." Operators staging a zone pass `active:
|
|
395
|
+
// false` and flip later via `updateZone`.
|
|
396
|
+
var active = input.active == null ? 1 : _active(input.active);
|
|
397
|
+
|
|
398
|
+
var existing = await _getRaw(slug);
|
|
399
|
+
if (existing) {
|
|
400
|
+
var err = new Error(
|
|
401
|
+
"shippingZones.defineZone: refused — zone '" + slug + "' already exists" +
|
|
402
|
+
(existing.archived_at != null ? " (archived)" : "") +
|
|
403
|
+
". Use updateZone to mutate an existing zone, or pick a different slug"
|
|
404
|
+
);
|
|
405
|
+
err.code = "SHIPPING_ZONE_EXISTS";
|
|
406
|
+
throw err;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
var ts = _now();
|
|
410
|
+
await query(
|
|
411
|
+
"INSERT INTO shipping_zones " +
|
|
412
|
+
"(slug, title, regions_json, rates_json, active, archived_at, created_at, updated_at) " +
|
|
413
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?6)",
|
|
414
|
+
[slug, title, JSON.stringify(regions), JSON.stringify(rates), active, ts],
|
|
415
|
+
);
|
|
416
|
+
return _hydrate(await _getRaw(slug));
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
// Read one by slug. Returns the hydrated row (regions/rates parsed
|
|
420
|
+
// out of the JSON columns) or null when no such zone exists.
|
|
421
|
+
// Archived zones are returned too — operator dashboards rendering
|
|
422
|
+
// history want them; the lookup paths (`rateFor`, `zoneFor-
|
|
423
|
+
// Destination`) filter archived rows themselves.
|
|
424
|
+
getZone: async function (slug) {
|
|
425
|
+
var s = _slug(slug);
|
|
426
|
+
return _hydrate(await _getRaw(s));
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
// List zones. Default returns every non-archived zone (active +
|
|
430
|
+
// paused both); `active_only: true` returns only active.
|
|
431
|
+
listZones: async function (listOpts) {
|
|
432
|
+
listOpts = listOpts || {};
|
|
433
|
+
var activeOnly = listOpts.active_only === true;
|
|
434
|
+
return _liveZones(activeOnly);
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
// Patch-style update — only ALLOWED_UPDATE_COLUMNS can be set.
|
|
438
|
+
// slug is immutable (it's the primary key + the operator-facing
|
|
439
|
+
// stable identifier; renaming would break every saved customer
|
|
440
|
+
// reference). To "rename" a zone, archive the existing zone and
|
|
441
|
+
// define a new one — the archived row stays in the audit history.
|
|
442
|
+
//
|
|
443
|
+
// Archived zones refuse all mutations — the archive is the
|
|
444
|
+
// intentional one-way exit. The operator un-archives by archiving
|
|
445
|
+
// their reference to the old slug and defining a fresh zone.
|
|
446
|
+
updateZone: async function (slug, patch) {
|
|
447
|
+
var s = _slug(slug);
|
|
448
|
+
if (!patch || typeof patch !== "object") {
|
|
449
|
+
throw new TypeError("shippingZones.updateZone: patch object required");
|
|
450
|
+
}
|
|
451
|
+
var keys = Object.keys(patch);
|
|
452
|
+
if (!keys.length) {
|
|
453
|
+
throw new TypeError("shippingZones.updateZone: patch must contain at least one column");
|
|
454
|
+
}
|
|
455
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
456
|
+
if (ALLOWED_UPDATE_COLUMNS.indexOf(keys[i]) === -1) {
|
|
457
|
+
throw new TypeError("shippingZones.updateZone: column '" + keys[i] + "' not updatable");
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
var current = await _getRaw(s);
|
|
461
|
+
if (!current) return null;
|
|
462
|
+
if (current.archived_at != null) {
|
|
463
|
+
var refused = new Error("shippingZones.updateZone: refused — zone is archived");
|
|
464
|
+
refused.code = "SHIPPING_ZONE_ARCHIVED";
|
|
465
|
+
throw refused;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
var sets = [];
|
|
469
|
+
var params = [];
|
|
470
|
+
var idx = 1;
|
|
471
|
+
function _set(col, val) {
|
|
472
|
+
sets.push(col + " = ?" + idx);
|
|
473
|
+
params.push(val);
|
|
474
|
+
idx += 1;
|
|
475
|
+
}
|
|
476
|
+
if (Object.prototype.hasOwnProperty.call(patch, "title")) {
|
|
477
|
+
_set("title", _title(patch.title));
|
|
478
|
+
}
|
|
479
|
+
if (Object.prototype.hasOwnProperty.call(patch, "regions")) {
|
|
480
|
+
_set("regions_json", JSON.stringify(_regions(patch.regions)));
|
|
481
|
+
}
|
|
482
|
+
if (Object.prototype.hasOwnProperty.call(patch, "rates")) {
|
|
483
|
+
_set("rates_json", JSON.stringify(_rates(patch.rates)));
|
|
484
|
+
}
|
|
485
|
+
if (Object.prototype.hasOwnProperty.call(patch, "active")) {
|
|
486
|
+
_set("active", _active(patch.active));
|
|
487
|
+
}
|
|
488
|
+
var ts = _now();
|
|
489
|
+
_set("updated_at", ts);
|
|
490
|
+
params.push(s);
|
|
491
|
+
var sql = "UPDATE shipping_zones SET " + sets.join(", ") + " WHERE slug = ?" + idx;
|
|
492
|
+
await query(sql, params);
|
|
493
|
+
return _hydrate(await _getRaw(s));
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
// Soft-delete — stamp archived_at + force active=0 so the zone
|
|
497
|
+
// stops participating in lookups. The row stays on disk for audit
|
|
498
|
+
// (region + rate snapshot at the moment of archive is preserved).
|
|
499
|
+
// Idempotent — archiving an already-archived zone returns it
|
|
500
|
+
// unchanged.
|
|
501
|
+
archiveZone: async function (slug) {
|
|
502
|
+
var s = _slug(slug);
|
|
503
|
+
var current = await _getRaw(s);
|
|
504
|
+
if (!current) return null;
|
|
505
|
+
if (current.archived_at != null) return _hydrate(current);
|
|
506
|
+
var ts = _now();
|
|
507
|
+
await query(
|
|
508
|
+
"UPDATE shipping_zones SET archived_at = ?1, active = 0, updated_at = ?1 WHERE slug = ?2",
|
|
509
|
+
[ts, s],
|
|
510
|
+
);
|
|
511
|
+
return _hydrate(await _getRaw(s));
|
|
512
|
+
},
|
|
513
|
+
|
|
514
|
+
// Find the most-specific covering zone for a destination. Returns
|
|
515
|
+
// the zone slug (string) or null when no active zone covers it.
|
|
516
|
+
// Country+region beats country-only; ties between two equally-
|
|
517
|
+
// specific zones resolve by slug ASC for determinism. Only ACTIVE
|
|
518
|
+
// (active=1, archived_at IS NULL) zones participate — paused or
|
|
519
|
+
// archived zones drop out of the routing pool without operator
|
|
520
|
+
// scrubbing the rate tables.
|
|
521
|
+
zoneForDestination: async function (input) {
|
|
522
|
+
if (!input || typeof input !== "object") {
|
|
523
|
+
throw new TypeError("shippingZones.zoneForDestination: input object required");
|
|
524
|
+
}
|
|
525
|
+
var country = _country(input.country, "country");
|
|
526
|
+
var region = _region(input.region, "region");
|
|
527
|
+
var zones = await _liveZones(true);
|
|
528
|
+
var best = null;
|
|
529
|
+
var bestScore = 0;
|
|
530
|
+
for (var i = 0; i < zones.length; i += 1) {
|
|
531
|
+
var z = zones[i];
|
|
532
|
+
var score = _zoneSpecificity(z.regions, country, region);
|
|
533
|
+
if (score > bestScore) {
|
|
534
|
+
best = z;
|
|
535
|
+
bestScore = score;
|
|
536
|
+
}
|
|
537
|
+
// Ties resolved by slug ASC — zones are already iterated in
|
|
538
|
+
// slug order, so the first hit at a given score wins.
|
|
539
|
+
}
|
|
540
|
+
return best ? best.slug : null;
|
|
541
|
+
},
|
|
542
|
+
|
|
543
|
+
// Gather every rate row in the requested currency from every
|
|
544
|
+
// active zone covering the destination, then return them sorted by
|
|
545
|
+
// rate_minor ASC then service_label ASC. Empty array when no zone
|
|
546
|
+
// covers the destination OR no rate row matches the (weight,
|
|
547
|
+
// order_value, currency) tuple.
|
|
548
|
+
//
|
|
549
|
+
// The walk visits ALL covering zones — an operator with both a
|
|
550
|
+
// "domestic-us" zone (country: US) AND a "domestic-us-ca" zone
|
|
551
|
+
// (country: US, region: CA) sees rates from both for a California
|
|
552
|
+
// destination, because each zone may carry a different service
|
|
553
|
+
// tier (CA same-day vs nation-wide standard). The till renders the
|
|
554
|
+
// sorted list as a menu.
|
|
555
|
+
//
|
|
556
|
+
// Each returned row carries the originating `zone_slug` so the
|
|
557
|
+
// till can attribute the rate back to its source zone.
|
|
558
|
+
rateFor: async function (input) {
|
|
559
|
+
if (!input || typeof input !== "object") {
|
|
560
|
+
throw new TypeError("shippingZones.rateFor: input object required");
|
|
561
|
+
}
|
|
562
|
+
var country = _country(input.destination_country, "destination_country");
|
|
563
|
+
var region = _region(input.destination_region, "destination_region");
|
|
564
|
+
if (!Number.isInteger(input.weight_grams) || input.weight_grams < 0 || input.weight_grams > MAX_WEIGHT_GRAMS) {
|
|
565
|
+
throw new TypeError(
|
|
566
|
+
"shippingZones.rateFor: weight_grams must be a non-negative integer <= " + MAX_WEIGHT_GRAMS
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
var weightGrams = input.weight_grams;
|
|
570
|
+
if (!Number.isInteger(input.order_minor) || input.order_minor < 0 || input.order_minor > MAX_ORDER_MINOR) {
|
|
571
|
+
throw new TypeError(
|
|
572
|
+
"shippingZones.rateFor: order_minor must be a non-negative integer <= " + MAX_ORDER_MINOR
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
var orderMinor = input.order_minor;
|
|
576
|
+
var currency = _currency(input.currency, "currency");
|
|
577
|
+
|
|
578
|
+
var zones = await _liveZones(true);
|
|
579
|
+
var out = [];
|
|
580
|
+
for (var i = 0; i < zones.length; i += 1) {
|
|
581
|
+
var z = zones[i];
|
|
582
|
+
var specificity = _zoneSpecificity(z.regions, country, region);
|
|
583
|
+
if (specificity === 0) continue;
|
|
584
|
+
for (var j = 0; j < z.rates.length; j += 1) {
|
|
585
|
+
var r = z.rates[j];
|
|
586
|
+
if (!_rateMatches(r, weightGrams, orderMinor, currency)) continue;
|
|
587
|
+
out.push({
|
|
588
|
+
zone_slug: z.slug,
|
|
589
|
+
rate_minor: r.rate_minor,
|
|
590
|
+
currency: r.currency,
|
|
591
|
+
service_label: r.service_label,
|
|
592
|
+
min_weight_grams: r.min_weight_grams,
|
|
593
|
+
max_weight_grams: r.max_weight_grams,
|
|
594
|
+
min_order_minor: r.min_order_minor,
|
|
595
|
+
max_order_minor: r.max_order_minor,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
out.sort(function (a, b) {
|
|
600
|
+
if (a.rate_minor !== b.rate_minor) return a.rate_minor - b.rate_minor;
|
|
601
|
+
if (a.service_label < b.service_label) return -1;
|
|
602
|
+
if (a.service_label > b.service_label) return 1;
|
|
603
|
+
if (a.zone_slug < b.zone_slug) return -1;
|
|
604
|
+
if (a.zone_slug > b.zone_slug) return 1;
|
|
605
|
+
return 0;
|
|
606
|
+
});
|
|
607
|
+
return out;
|
|
608
|
+
},
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
module.exports = {
|
|
613
|
+
create: create,
|
|
614
|
+
SLUG_RE: SLUG_RE,
|
|
615
|
+
COUNTRY_RE: COUNTRY_RE,
|
|
616
|
+
REGION_RE: REGION_RE,
|
|
617
|
+
MAX_REGIONS: MAX_REGIONS,
|
|
618
|
+
MAX_RATES: MAX_RATES,
|
|
619
|
+
MAX_WEIGHT_GRAMS: MAX_WEIGHT_GRAMS,
|
|
620
|
+
MAX_ORDER_MINOR: MAX_ORDER_MINOR,
|
|
621
|
+
};
|