@blamejs/blamejs-shop 0.0.64 → 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.
@@ -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
+ };