@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,1113 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.deliveryEstimate
4
+ * @title Delivery-estimate primitive — PDP + cart "Get it by Thursday"
5
+ * window calculator
6
+ *
7
+ * @intro
8
+ * A customer landing on a product detail page or opening the cart
9
+ * wants one answer: "If I order now, when will it arrive?" The
10
+ * storefront composes the answer out of four operator-author
11
+ * tables:
12
+ *
13
+ * 1. Carrier transits — for each (from_zone -> to_zone, carrier,
14
+ * service_level) tuple, the carrier-declared transit_days
15
+ * budget (business days). Operators author this from the
16
+ * carrier's published service guide; the primitive walks the
17
+ * rows when an estimate request lands.
18
+ *
19
+ * 2. Origin cutoffs — per inventory-location, the daily local-
20
+ * time cutoff after which today's orders ship tomorrow. The
21
+ * primitive resolves the request's `now` against the cutoff
22
+ * in the origin's IANA timezone. A request before cutoff
23
+ * ships today (a business day); on / after cutoff rolls to
24
+ * the next business day at the origin.
25
+ *
26
+ * 3. Holidays — per-region calendar dates (YYYY-MM-DD) that drop
27
+ * out of the business-day count for that region. The primitive
28
+ * applies the origin region's holidays to the ship date and
29
+ * the destination region's holidays to the delivery transit.
30
+ * The author registers a region's observed holidays once a
31
+ * year and the framework skips them automatically.
32
+ *
33
+ * 4. Postal-prefix -> zone mappings — for destination_postal
34
+ * lookup. Operators author one row per prefix-bucket they
35
+ * care about (`"902"` -> `"us-west"`, `"100"` -> `"us-east"`).
36
+ * Longest-prefix wins. A request whose destination_postal
37
+ * doesn't match any prefix in its country falls through with
38
+ * a typed reason — the storefront renders a generic transit-
39
+ * time bracket rather than guessing.
40
+ *
41
+ * The math is calendar-day-deterministic. `estimate` returns
42
+ * {ship_by_date, est_min_delivery_date, est_max_delivery_date,
43
+ * service_levels: [...]} where each service-level row carries its
44
+ * own ship_by + deliver_by + business_days count. Dates are
45
+ * YYYY-MM-DD strings in the origin's timezone so the storefront
46
+ * renders them without re-doing the timezone math.
47
+ *
48
+ * Composes:
49
+ * - `b.uuid.v7` — id mint for carrier_transits + holidays
50
+ * - `b.guardUuid` — id-shape gate on archive
51
+ * - Optional `inventoryLocations` handle to resolve a default
52
+ * `origin_location` when the caller omits it
53
+ * - Optional `shippingZones` handle to resolve a
54
+ * (destination_country, destination_region) -> zone slug when
55
+ * the operator authored zones but skipped postal-prefix rows
56
+ *
57
+ * Surface:
58
+ * defineCarrierTransit({ from_zone, to_zone, carrier,
59
+ * service_level, transit_days })
60
+ * defineCutoff({ origin_location, daily_cutoff_local_time,
61
+ * timezone })
62
+ * defineHoliday({ region, date, name })
63
+ * definePostalZone({ country, postal_prefix, zone })
64
+ * estimate({ origin_location?, destination_postal,
65
+ * destination_country, destination_region?,
66
+ * weight_grams?, requested_service_level?, now? })
67
+ * estimateForCart({ cart, destination, now? })
68
+ * listTransits({ carrier? })
69
+ * listCutoffs()
70
+ * listHolidays({ region?, from?, to? })
71
+ * listPostalZones({ country? })
72
+ * archiveTransit(transit_id)
73
+ * archiveHoliday(holiday_id)
74
+ *
75
+ * Storage:
76
+ * - `carrier_transits` (migration `0117_delivery_estimate.sql`)
77
+ * - `shipping_cutoffs` (migration `0117_delivery_estimate.sql`)
78
+ * - `shipping_holidays` (migration `0117_delivery_estimate.sql`)
79
+ * - `delivery_postal_zones` (migration `0117_delivery_estimate.sql`)
80
+ *
81
+ * @primitive deliveryEstimate
82
+ * @related b.uuid, shop.shippingZones, shop.carrierRates,
83
+ * shop.inventoryLocations
84
+ */
85
+
86
+ // ---- constants ----------------------------------------------------------
87
+
88
+ var MAX_ZONE_LEN = 64;
89
+ var ZONE_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
90
+
91
+ var CARRIERS = Object.freeze(["ups", "fedex", "usps", "dhl", "flat_rate", "custom"]);
92
+
93
+ var MAX_SERVICE_LEVEL_LEN = 64;
94
+ var SERVICE_LEVEL_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
95
+
96
+ var MAX_TRANSIT_DAYS = 365;
97
+
98
+ var MAX_LOCATION_LEN = 80;
99
+ var LOCATION_RE = /^[a-z0-9][a-z0-9_-]{0,79}$/;
100
+
101
+ var MAX_REGION_LEN = 16;
102
+ // region is operator-author shorthand (`us`, `us-ca`, `gb`, `eu`); kept
103
+ // tight enough to refuse control bytes / arbitrary input but loose
104
+ // enough to admit the shapes the caller already carries in cart /
105
+ // customer state.
106
+ var REGION_RE = /^[a-z0-9][a-z0-9_-]{0,15}$/;
107
+
108
+ var DATE_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
109
+
110
+ var TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
111
+
112
+ // IANA TZ name — area/city plus an optional second segment (Indian/Maldives,
113
+ // America/Argentina/Buenos_Aires). Refuses control bytes + plain offsets
114
+ // like "+0500" so the framework's Date.toLocaleString call doesn't
115
+ // silently coerce to UTC when handed garbage.
116
+ var TZ_RE = /^[A-Z][A-Za-z]+\/[A-Z][A-Za-z_]+(\/[A-Z][A-Za-z_]+)?$/;
117
+
118
+ var MAX_NAME_LEN = 200;
119
+
120
+ var MAX_POSTAL_PREFIX_LEN = 16;
121
+ var POSTAL_PREFIX_RE = /^[A-Za-z0-9][A-Za-z0-9 -]{0,15}$/;
122
+ var MAX_POSTAL_LEN = 16;
123
+ var POSTAL_RE = /^[A-Za-z0-9][A-Za-z0-9 -]{0,15}$/;
124
+
125
+ var COUNTRY_RE = /^[A-Z]{2}$/;
126
+
127
+ var MAX_WEIGHT_GRAMS = 1000 * 1000;
128
+
129
+ var DAY_MS = 24 * 60 * 60 * 1000;
130
+
131
+ // Lazy framework handle — matches the convention every other shop
132
+ // primitive uses; avoids the require cycle that would arise from
133
+ // importing `./index` at module-eval time.
134
+ var bShop;
135
+ function _b() {
136
+ if (!bShop) bShop = require("./index");
137
+ return bShop.framework;
138
+ }
139
+
140
+ // ---- validators ---------------------------------------------------------
141
+
142
+ function _hasControlByte(s) {
143
+ for (var i = 0; i < s.length; i += 1) {
144
+ var cc = s.charCodeAt(i);
145
+ if (cc <= 0x1f || cc === 0x7f) return true;
146
+ }
147
+ return false;
148
+ }
149
+
150
+ function _zone(s, label) {
151
+ if (typeof s !== "string" || !s.length) {
152
+ throw new TypeError("deliveryEstimate: " + label + " must be a non-empty string");
153
+ }
154
+ if (s.length > MAX_ZONE_LEN) {
155
+ throw new TypeError("deliveryEstimate: " + label + " must be <= " + MAX_ZONE_LEN + " characters");
156
+ }
157
+ if (!ZONE_RE.test(s)) {
158
+ throw new TypeError(
159
+ "deliveryEstimate: " + label + " must match /^[a-z0-9][a-z0-9_-]{0,63}$/"
160
+ );
161
+ }
162
+ return s;
163
+ }
164
+
165
+ function _carrier(c) {
166
+ if (typeof c !== "string" || CARRIERS.indexOf(c) === -1) {
167
+ throw new TypeError("deliveryEstimate: carrier must be one of " + CARRIERS.join(", "));
168
+ }
169
+ return c;
170
+ }
171
+
172
+ function _serviceLevel(s) {
173
+ if (typeof s !== "string" || !s.length) {
174
+ throw new TypeError("deliveryEstimate: service_level must be a non-empty string");
175
+ }
176
+ if (s.length > MAX_SERVICE_LEVEL_LEN) {
177
+ throw new TypeError("deliveryEstimate: service_level must be <= " + MAX_SERVICE_LEVEL_LEN + " characters");
178
+ }
179
+ if (!SERVICE_LEVEL_RE.test(s)) {
180
+ throw new TypeError(
181
+ "deliveryEstimate: service_level must match /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/"
182
+ );
183
+ }
184
+ return s;
185
+ }
186
+
187
+ function _transitDays(n) {
188
+ if (!Number.isInteger(n) || n < 0 || n > MAX_TRANSIT_DAYS) {
189
+ throw new TypeError(
190
+ "deliveryEstimate: transit_days must be an integer 0.." + MAX_TRANSIT_DAYS
191
+ );
192
+ }
193
+ return n;
194
+ }
195
+
196
+ function _location(s, label) {
197
+ if (typeof s !== "string" || !s.length) {
198
+ throw new TypeError("deliveryEstimate: " + label + " must be a non-empty string");
199
+ }
200
+ if (s.length > MAX_LOCATION_LEN) {
201
+ throw new TypeError("deliveryEstimate: " + label + " must be <= " + MAX_LOCATION_LEN + " characters");
202
+ }
203
+ if (!LOCATION_RE.test(s)) {
204
+ throw new TypeError(
205
+ "deliveryEstimate: " + label + " must match /^[a-z0-9][a-z0-9_-]{0,79}$/"
206
+ );
207
+ }
208
+ return s;
209
+ }
210
+
211
+ function _region(s, label) {
212
+ if (typeof s !== "string" || !s.length) {
213
+ throw new TypeError("deliveryEstimate: " + label + " must be a non-empty string");
214
+ }
215
+ if (s.length > MAX_REGION_LEN) {
216
+ throw new TypeError("deliveryEstimate: " + label + " must be <= " + MAX_REGION_LEN + " characters");
217
+ }
218
+ if (!REGION_RE.test(s)) {
219
+ throw new TypeError(
220
+ "deliveryEstimate: " + label + " must match /^[a-z0-9][a-z0-9_-]{0,15}$/"
221
+ );
222
+ }
223
+ return s;
224
+ }
225
+
226
+ function _date(s, label) {
227
+ if (typeof s !== "string" || !DATE_RE.test(s)) {
228
+ throw new TypeError(
229
+ "deliveryEstimate: " + label + " must be a YYYY-MM-DD string"
230
+ );
231
+ }
232
+ // Verify the date actually exists — DATE_RE admits 2026-02-30 etc.
233
+ // _parseYMD throws when the constructed Date doesn't round-trip.
234
+ _parseYMD(s, label);
235
+ return s;
236
+ }
237
+
238
+ function _time(s, label) {
239
+ if (typeof s !== "string" || !TIME_RE.test(s)) {
240
+ throw new TypeError(
241
+ "deliveryEstimate: " + label + " must be a HH:MM 24-hour string"
242
+ );
243
+ }
244
+ return s;
245
+ }
246
+
247
+ function _tz(s, label) {
248
+ if (typeof s !== "string" || !TZ_RE.test(s)) {
249
+ throw new TypeError(
250
+ "deliveryEstimate: " + label + " must be an IANA timezone name (Area/City)"
251
+ );
252
+ }
253
+ // Validate against Intl — TZ_RE is a syntactic gate, Intl is the
254
+ // semantic gate. An invalid zone (e.g. "Made/Up_Place") throws here
255
+ // so a typo at config time surfaces loud.
256
+ try {
257
+ new Intl.DateTimeFormat("en-US", { timeZone: s });
258
+ } catch (_e) {
259
+ throw new TypeError(
260
+ "deliveryEstimate: " + label + " is not a known IANA timezone (got " + JSON.stringify(s) + ")"
261
+ );
262
+ }
263
+ return s;
264
+ }
265
+
266
+ function _name(s, label) {
267
+ if (typeof s !== "string" || !s.length) {
268
+ throw new TypeError("deliveryEstimate: " + label + " must be a non-empty string");
269
+ }
270
+ if (s.length > MAX_NAME_LEN) {
271
+ throw new TypeError("deliveryEstimate: " + label + " must be <= " + MAX_NAME_LEN + " characters");
272
+ }
273
+ if (_hasControlByte(s)) {
274
+ throw new TypeError("deliveryEstimate: " + label + " must not contain control characters");
275
+ }
276
+ return s;
277
+ }
278
+
279
+ function _country(s, label) {
280
+ if (typeof s !== "string" || !COUNTRY_RE.test(s)) {
281
+ throw new TypeError(
282
+ "deliveryEstimate: " + label + " must be ISO 3166-1 alpha-2 (2 uppercase letters)"
283
+ );
284
+ }
285
+ return s;
286
+ }
287
+
288
+ function _postalPrefix(s, label) {
289
+ if (typeof s !== "string" || !s.length) {
290
+ throw new TypeError("deliveryEstimate: " + label + " must be a non-empty string");
291
+ }
292
+ if (s.length > MAX_POSTAL_PREFIX_LEN) {
293
+ throw new TypeError("deliveryEstimate: " + label + " must be <= " + MAX_POSTAL_PREFIX_LEN + " characters");
294
+ }
295
+ if (!POSTAL_PREFIX_RE.test(s)) {
296
+ throw new TypeError(
297
+ "deliveryEstimate: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9 -]{0,15}$/"
298
+ );
299
+ }
300
+ return s;
301
+ }
302
+
303
+ function _postal(s, label) {
304
+ if (typeof s !== "string" || !s.length) {
305
+ throw new TypeError("deliveryEstimate: " + label + " must be a non-empty string");
306
+ }
307
+ if (s.length > MAX_POSTAL_LEN) {
308
+ throw new TypeError("deliveryEstimate: " + label + " must be <= " + MAX_POSTAL_LEN + " characters");
309
+ }
310
+ if (!POSTAL_RE.test(s)) {
311
+ throw new TypeError(
312
+ "deliveryEstimate: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9 -]{0,15}$/"
313
+ );
314
+ }
315
+ return s;
316
+ }
317
+
318
+ function _weightGrams(n, label) {
319
+ if (n == null) return null;
320
+ if (!Number.isInteger(n) || n < 0 || n > MAX_WEIGHT_GRAMS) {
321
+ throw new TypeError(
322
+ "deliveryEstimate: " + label + " must be a non-negative integer <= " + MAX_WEIGHT_GRAMS
323
+ );
324
+ }
325
+ return n;
326
+ }
327
+
328
+ function _epochMs(n, label) {
329
+ if (!Number.isInteger(n) || n <= 0) {
330
+ throw new TypeError("deliveryEstimate: " + label + " must be a positive integer epoch-ms");
331
+ }
332
+ return n;
333
+ }
334
+
335
+ function _now() { return Date.now(); }
336
+
337
+ // ---- date math ----------------------------------------------------------
338
+ //
339
+ // All math is "calendar day" against an IANA timezone. The hot path uses
340
+ // Intl.DateTimeFormat to decompose an epoch-ms into the wall-clock parts
341
+ // for the origin's timezone, then walks day-by-day forward applying
342
+ // weekend + holiday skips. The arithmetic is integer days; no floats,
343
+ // no DST drift (Intl handles the wall-clock conversion).
344
+
345
+ function _parseYMD(s, label) {
346
+ var y = Number(s.slice(0, 4));
347
+ var mo = Number(s.slice(5, 7));
348
+ var d = Number(s.slice(8, 10));
349
+ if (!Number.isInteger(y) || !Number.isInteger(mo) || !Number.isInteger(d)) {
350
+ throw new TypeError("deliveryEstimate: " + label + " — invalid YYYY-MM-DD digits");
351
+ }
352
+ // Round-trip via Date.UTC; if month or day overflow (e.g. Feb 30 ->
353
+ // Mar 02) the constructed parts won't match the input.
354
+ var ts = Date.UTC(y, mo - 1, d);
355
+ var dt = new Date(ts);
356
+ if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== (mo - 1) || dt.getUTCDate() !== d) {
357
+ throw new TypeError("deliveryEstimate: " + label + " — not a real calendar date");
358
+ }
359
+ return { y: y, m: mo, d: d, ts: ts };
360
+ }
361
+
362
+ function _formatYMD(parts) {
363
+ var y = String(parts.y);
364
+ var m = parts.m < 10 ? "0" + parts.m : String(parts.m);
365
+ var d = parts.d < 10 ? "0" + parts.d : String(parts.d);
366
+ return y + "-" + m + "-" + d;
367
+ }
368
+
369
+ // Decompose an epoch-ms into {y, m, d, hh, mm, weekday(0..6 Sun..Sat)}
370
+ // at the requested IANA timezone. Uses Intl.DateTimeFormat parts so the
371
+ // result is DST-correct without manual offset bookkeeping.
372
+ function _wallClockIn(tz, epochMs) {
373
+ var fmt = new Intl.DateTimeFormat("en-US", {
374
+ timeZone: tz,
375
+ year: "numeric",
376
+ month: "2-digit",
377
+ day: "2-digit",
378
+ hour: "2-digit",
379
+ minute: "2-digit",
380
+ hour12: false,
381
+ weekday: "short",
382
+ });
383
+ var parts = fmt.formatToParts(new Date(epochMs));
384
+ var out = { y: 0, m: 0, d: 0, hh: 0, mm: 0, weekday: 0 };
385
+ for (var i = 0; i < parts.length; i += 1) {
386
+ var p = parts[i];
387
+ if (p.type === "year") out.y = Number(p.value);
388
+ if (p.type === "month") out.m = Number(p.value);
389
+ if (p.type === "day") out.d = Number(p.value);
390
+ if (p.type === "hour") out.hh = Number(p.value) === 24 ? 0 : Number(p.value);
391
+ if (p.type === "minute") out.mm = Number(p.value);
392
+ if (p.type === "weekday") {
393
+ // Sun, Mon, Tue, Wed, Thu, Fri, Sat -> 0..6
394
+ var map = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
395
+ out.weekday = map[p.value] != null ? map[p.value] : 0;
396
+ }
397
+ }
398
+ return out;
399
+ }
400
+
401
+ // Add `n` calendar days to {y, m, d} parts. Returns fresh parts at the
402
+ // resulting wall-clock date. Walks via Date.UTC so month / year roll
403
+ // over naturally.
404
+ function _addDays(parts, n) {
405
+ var ts = Date.UTC(parts.y, parts.m - 1, parts.d) + n * DAY_MS;
406
+ var dt = new Date(ts);
407
+ return {
408
+ y: dt.getUTCFullYear(),
409
+ m: dt.getUTCMonth() + 1,
410
+ d: dt.getUTCDate(),
411
+ };
412
+ }
413
+
414
+ // JS Date.UTC weekday is Sun=0..Sat=6 — same convention as the Intl
415
+ // `weekday: short` map above so the two clocks agree.
416
+ function _weekdayOfYMD(parts) {
417
+ var ts = Date.UTC(parts.y, parts.m - 1, parts.d);
418
+ return new Date(ts).getUTCDay();
419
+ }
420
+
421
+ function _isWeekend(parts) {
422
+ var wd = _weekdayOfYMD(parts);
423
+ return wd === 0 || wd === 6;
424
+ }
425
+
426
+ // Step `parts` forward by `n` business days, skipping weekends and any
427
+ // date present in `holidaySet` (a Set of YYYY-MM-DD strings). `n` is
428
+ // non-negative; 0 advances to the next business day only if `parts`
429
+ // itself lands on a non-business day (so the ship date math can pre-
430
+ // roll a Saturday cutoff to Monday before adding transit).
431
+ function _addBusinessDays(parts, n, holidaySet) {
432
+ // Pre-roll: if start is non-business, move forward to the next
433
+ // business day before counting transit. This is how carriers price
434
+ // weekend-handed parcels.
435
+ var cur = { y: parts.y, m: parts.m, d: parts.d };
436
+ while (_isNonBusiness(cur, holidaySet)) {
437
+ cur = _addDays(cur, 1);
438
+ }
439
+ for (var i = 0; i < n; i += 1) {
440
+ cur = _addDays(cur, 1);
441
+ while (_isNonBusiness(cur, holidaySet)) {
442
+ cur = _addDays(cur, 1);
443
+ }
444
+ }
445
+ return cur;
446
+ }
447
+
448
+ function _isNonBusiness(parts, holidaySet) {
449
+ if (_isWeekend(parts)) return true;
450
+ if (holidaySet && holidaySet[_formatYMD(parts)]) return true;
451
+ return false;
452
+ }
453
+
454
+ // ---- factory ------------------------------------------------------------
455
+
456
+ function create(opts) {
457
+ opts = opts || {};
458
+ var query = opts.query;
459
+ if (!query) {
460
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
461
+ }
462
+ var inventoryLocations = opts.inventoryLocations || null;
463
+ var shippingZones = opts.shippingZones || null;
464
+
465
+ function _hydrateTransit(row) {
466
+ if (!row) return null;
467
+ return {
468
+ id: row.id,
469
+ from_zone: row.from_zone,
470
+ to_zone: row.to_zone,
471
+ carrier: row.carrier,
472
+ service_level: row.service_level,
473
+ transit_days: Number(row.transit_days),
474
+ archived_at: row.archived_at == null ? null : Number(row.archived_at),
475
+ created_at: Number(row.created_at),
476
+ updated_at: Number(row.updated_at),
477
+ };
478
+ }
479
+
480
+ function _hydrateCutoff(row) {
481
+ if (!row) return null;
482
+ return {
483
+ origin_location: row.origin_location,
484
+ daily_cutoff_local_time: row.daily_cutoff_local_time,
485
+ timezone: row.timezone,
486
+ created_at: Number(row.created_at),
487
+ updated_at: Number(row.updated_at),
488
+ };
489
+ }
490
+
491
+ function _hydrateHoliday(row) {
492
+ if (!row) return null;
493
+ return {
494
+ id: row.id,
495
+ region: row.region,
496
+ date: row.date,
497
+ name: row.name,
498
+ archived_at: row.archived_at == null ? null : Number(row.archived_at),
499
+ created_at: Number(row.created_at),
500
+ };
501
+ }
502
+
503
+ function _hydratePostalZone(row) {
504
+ if (!row) return null;
505
+ return {
506
+ country: row.country,
507
+ postal_prefix: row.postal_prefix,
508
+ zone: row.zone,
509
+ created_at: Number(row.created_at),
510
+ updated_at: Number(row.updated_at),
511
+ };
512
+ }
513
+
514
+ // Read every live transit row that matches the requested zone pair
515
+ // (and optional service_level). Archived rows drop out.
516
+ async function _liveTransitsFor(fromZone, toZone, serviceLevel) {
517
+ var sql = "SELECT * FROM carrier_transits WHERE archived_at IS NULL " +
518
+ "AND from_zone = ?1 AND to_zone = ?2";
519
+ var params = [fromZone, toZone];
520
+ if (serviceLevel != null) {
521
+ sql += " AND service_level = ?3";
522
+ params.push(serviceLevel);
523
+ }
524
+ sql += " ORDER BY transit_days ASC, carrier ASC, service_level ASC, id ASC";
525
+ var r = await query(sql, params);
526
+ var out = [];
527
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_hydrateTransit(r.rows[i]));
528
+ return out;
529
+ }
530
+
531
+ async function _holidaysForRegion(region) {
532
+ var r = await query(
533
+ "SELECT date FROM shipping_holidays WHERE region = ?1 AND archived_at IS NULL",
534
+ [region],
535
+ );
536
+ var set = {};
537
+ for (var i = 0; i < r.rows.length; i += 1) set[r.rows[i].date] = true;
538
+ return set;
539
+ }
540
+
541
+ async function _resolvePostalToZone(country, postal) {
542
+ // Longest postal_prefix match wins. The table is small (one row
543
+ // per prefix-bucket the operator cares about) so the walk is fine
544
+ // in JS — ORDER BY length(postal_prefix) DESC keeps SQL portable.
545
+ var r = await query(
546
+ "SELECT * FROM delivery_postal_zones WHERE country = ?1 " +
547
+ "ORDER BY length(postal_prefix) DESC, postal_prefix ASC",
548
+ [country],
549
+ );
550
+ for (var i = 0; i < r.rows.length; i += 1) {
551
+ var row = r.rows[i];
552
+ if (postal.indexOf(row.postal_prefix) === 0) return row.zone;
553
+ }
554
+ return null;
555
+ }
556
+
557
+ return {
558
+ CARRIERS: CARRIERS,
559
+ MAX_TRANSIT_DAYS: MAX_TRANSIT_DAYS,
560
+ MAX_WEIGHT_GRAMS: MAX_WEIGHT_GRAMS,
561
+ ZONE_RE: ZONE_RE,
562
+ DATE_RE: DATE_RE,
563
+ TIME_RE: TIME_RE,
564
+ COUNTRY_RE: COUNTRY_RE,
565
+
566
+ // Register a carrier transit row. The UNIQUE
567
+ // (from_zone, to_zone, carrier, service_level) constraint dedups
568
+ // — re-calling with the same tuple refreshes transit_days in
569
+ // place rather than stacking duplicates. Operators rotating a
570
+ // service-level offering call defineCarrierTransit again with
571
+ // the fresh number; the row id stays stable so dashboards
572
+ // threaded by id don't churn.
573
+ defineCarrierTransit: async function (input) {
574
+ if (!input || typeof input !== "object") {
575
+ throw new TypeError("deliveryEstimate.defineCarrierTransit: input object required");
576
+ }
577
+ var fromZone = _zone(input.from_zone, "from_zone");
578
+ var toZone = _zone(input.to_zone, "to_zone");
579
+ var carrier = _carrier(input.carrier);
580
+ var serviceLevel = _serviceLevel(input.service_level);
581
+ var transitDays = _transitDays(input.transit_days);
582
+
583
+ var ts = _now();
584
+ var existing = (await query(
585
+ "SELECT id, created_at FROM carrier_transits " +
586
+ "WHERE from_zone = ?1 AND to_zone = ?2 AND carrier = ?3 AND service_level = ?4",
587
+ [fromZone, toZone, carrier, serviceLevel],
588
+ )).rows[0];
589
+ var id = existing ? existing.id : _b().uuid.v7();
590
+ var createdAt = existing ? Number(existing.created_at) : ts;
591
+
592
+ await query(
593
+ "INSERT INTO carrier_transits " +
594
+ "(id, from_zone, to_zone, carrier, service_level, transit_days, " +
595
+ " archived_at, created_at, updated_at) " +
596
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8) " +
597
+ "ON CONFLICT(from_zone, to_zone, carrier, service_level) DO UPDATE SET " +
598
+ " transit_days = excluded.transit_days, " +
599
+ " archived_at = NULL, " +
600
+ " updated_at = excluded.updated_at",
601
+ [id, fromZone, toZone, carrier, serviceLevel, transitDays, createdAt, ts],
602
+ );
603
+ var r = await query(
604
+ "SELECT * FROM carrier_transits WHERE from_zone = ?1 AND to_zone = ?2 " +
605
+ "AND carrier = ?3 AND service_level = ?4",
606
+ [fromZone, toZone, carrier, serviceLevel],
607
+ );
608
+ return _hydrateTransit(r.rows[0]);
609
+ },
610
+
611
+ // Upsert a cutoff per origin location. PK is origin_location —
612
+ // each location has exactly one cutoff (operators wanting time-
613
+ // band cutoffs author multiple locations rather than overloading
614
+ // one). Re-calling rotates the cutoff time / timezone in place.
615
+ defineCutoff: async function (input) {
616
+ if (!input || typeof input !== "object") {
617
+ throw new TypeError("deliveryEstimate.defineCutoff: input object required");
618
+ }
619
+ var origin = _location(input.origin_location, "origin_location");
620
+ var time = _time(input.daily_cutoff_local_time, "daily_cutoff_local_time");
621
+ var timezone = _tz(input.timezone, "timezone");
622
+
623
+ var ts = _now();
624
+ var existing = (await query(
625
+ "SELECT created_at FROM shipping_cutoffs WHERE origin_location = ?1",
626
+ [origin],
627
+ )).rows[0];
628
+ var createdAt = existing ? Number(existing.created_at) : ts;
629
+
630
+ await query(
631
+ "INSERT INTO shipping_cutoffs " +
632
+ "(origin_location, daily_cutoff_local_time, timezone, created_at, updated_at) " +
633
+ "VALUES (?1, ?2, ?3, ?4, ?5) " +
634
+ "ON CONFLICT(origin_location) DO UPDATE SET " +
635
+ " daily_cutoff_local_time = excluded.daily_cutoff_local_time, " +
636
+ " timezone = excluded.timezone, " +
637
+ " updated_at = excluded.updated_at",
638
+ [origin, time, timezone, createdAt, ts],
639
+ );
640
+ var r = await query(
641
+ "SELECT * FROM shipping_cutoffs WHERE origin_location = ?1",
642
+ [origin],
643
+ );
644
+ return _hydrateCutoff(r.rows[0]);
645
+ },
646
+
647
+ // Register one observed holiday per (region, date). Repeating
648
+ // the same (region, date) refreshes the name in place — operators
649
+ // re-loading the year's calendar don't churn ids. Each row carries
650
+ // its own uuid so an archive sweep can target a single holiday
651
+ // without scrubbing the whole region's set.
652
+ defineHoliday: async function (input) {
653
+ if (!input || typeof input !== "object") {
654
+ throw new TypeError("deliveryEstimate.defineHoliday: input object required");
655
+ }
656
+ var region = _region(input.region, "region");
657
+ var date = _date(input.date, "date");
658
+ var name = _name(input.name, "name");
659
+
660
+ var ts = _now();
661
+ var existing = (await query(
662
+ "SELECT id, created_at FROM shipping_holidays " +
663
+ "WHERE region = ?1 AND date = ?2",
664
+ [region, date],
665
+ )).rows[0];
666
+ var id = existing ? existing.id : _b().uuid.v7();
667
+ var createdAt = existing ? Number(existing.created_at) : ts;
668
+
669
+ await query(
670
+ "INSERT INTO shipping_holidays " +
671
+ "(id, region, date, name, archived_at, created_at) " +
672
+ "VALUES (?1, ?2, ?3, ?4, NULL, ?5) " +
673
+ "ON CONFLICT(region, date) DO UPDATE SET " +
674
+ " name = excluded.name, " +
675
+ " archived_at = NULL",
676
+ [id, region, date, name, createdAt],
677
+ );
678
+ var r = await query(
679
+ "SELECT * FROM shipping_holidays WHERE region = ?1 AND date = ?2",
680
+ [region, date],
681
+ );
682
+ return _hydrateHoliday(r.rows[0]);
683
+ },
684
+
685
+ // Operator-author postal-prefix -> zone mapping. The PK is
686
+ // (country, postal_prefix); repeating refreshes the zone slug
687
+ // in place. Longest-prefix wins at lookup time, so the operator
688
+ // pads in finer-grained rows over time without scrubbing the
689
+ // coarse-grained fallback.
690
+ definePostalZone: async function (input) {
691
+ if (!input || typeof input !== "object") {
692
+ throw new TypeError("deliveryEstimate.definePostalZone: input object required");
693
+ }
694
+ var country = _country(input.country, "country");
695
+ var prefix = _postalPrefix(input.postal_prefix, "postal_prefix");
696
+ var zone = _zone(input.zone, "zone");
697
+
698
+ var ts = _now();
699
+ var existing = (await query(
700
+ "SELECT created_at FROM delivery_postal_zones " +
701
+ "WHERE country = ?1 AND postal_prefix = ?2",
702
+ [country, prefix],
703
+ )).rows[0];
704
+ var createdAt = existing ? Number(existing.created_at) : ts;
705
+
706
+ await query(
707
+ "INSERT INTO delivery_postal_zones " +
708
+ "(country, postal_prefix, zone, created_at, updated_at) " +
709
+ "VALUES (?1, ?2, ?3, ?4, ?5) " +
710
+ "ON CONFLICT(country, postal_prefix) DO UPDATE SET " +
711
+ " zone = excluded.zone, " +
712
+ " updated_at = excluded.updated_at",
713
+ [country, prefix, zone, createdAt, ts],
714
+ );
715
+ var r = await query(
716
+ "SELECT * FROM delivery_postal_zones WHERE country = ?1 AND postal_prefix = ?2",
717
+ [country, prefix],
718
+ );
719
+ return _hydratePostalZone(r.rows[0]);
720
+ },
721
+
722
+ // Estimate the delivery window for a single destination. Returns
723
+ //
724
+ // {
725
+ // ok: bool,
726
+ // reason?: string, // when !ok
727
+ // origin_location: string,
728
+ // origin_zone: string,
729
+ // destination_zone: string,
730
+ // ship_by_date: "YYYY-MM-DD",
731
+ // est_min_delivery_date:"YYYY-MM-DD",
732
+ // est_max_delivery_date:"YYYY-MM-DD",
733
+ // service_levels: [
734
+ // { code, label, carrier, ship_by, deliver_by, business_days }
735
+ // ]
736
+ // }
737
+ //
738
+ // `ship_by_date` is the date today's order will physically leave
739
+ // the origin (today when `now` is before cutoff and today is a
740
+ // business day; the next business day otherwise). Each service-
741
+ // level row carries its own `deliver_by` after applying transit
742
+ // business-days from `ship_by` against the destination region's
743
+ // holidays + weekends.
744
+ //
745
+ // When the destination_postal doesn't resolve to a zone (no
746
+ // matching postal-prefix row in the destination country), the
747
+ // result carries `ok: false, reason: "no_destination_zone"` so
748
+ // the storefront can fall back to a generic transit-time bracket
749
+ // instead of guessing.
750
+ estimate: async function (input) {
751
+ if (!input || typeof input !== "object") {
752
+ throw new TypeError("deliveryEstimate.estimate: input object required");
753
+ }
754
+ var destCountry = _country(input.destination_country, "destination_country");
755
+ var destPostal = _postal(input.destination_postal, "destination_postal");
756
+ var destRegion = null;
757
+ if (input.destination_region != null) {
758
+ destRegion = _region(input.destination_region, "destination_region");
759
+ }
760
+ var requestedSvc = null;
761
+ if (input.requested_service_level != null) {
762
+ requestedSvc = _serviceLevel(input.requested_service_level);
763
+ }
764
+ _weightGrams(input.weight_grams, "weight_grams");
765
+ var now = input.now == null ? _now() : _epochMs(input.now, "now");
766
+
767
+ // Resolve origin_location — explicit > inventoryLocations.default()
768
+ // > refuse.
769
+ var origin = null;
770
+ if (input.origin_location != null) {
771
+ origin = _location(input.origin_location, "origin_location");
772
+ } else if (inventoryLocations && typeof inventoryLocations.defaultLocation === "function") {
773
+ var def = await inventoryLocations.defaultLocation();
774
+ if (def && typeof def === "object" && typeof def.slug === "string") {
775
+ origin = _location(def.slug, "inventoryLocations.defaultLocation().slug");
776
+ }
777
+ }
778
+ if (!origin) {
779
+ throw new TypeError(
780
+ "deliveryEstimate.estimate: origin_location required " +
781
+ "(pass explicitly OR wire inventoryLocations with a defaultLocation())"
782
+ );
783
+ }
784
+
785
+ // Resolve cutoff for the origin. Without a cutoff row the
786
+ // primitive refuses — every estimate is timezone-dependent, no
787
+ // implicit default exists. The error is config-time-style: it
788
+ // surfaces at the first estimate against a fresh origin so the
789
+ // operator gets a loud "register a cutoff for this location."
790
+ var cutoffRow = (await query(
791
+ "SELECT * FROM shipping_cutoffs WHERE origin_location = ?1",
792
+ [origin],
793
+ )).rows[0];
794
+ if (!cutoffRow) {
795
+ throw new TypeError(
796
+ "deliveryEstimate.estimate: no shipping_cutoffs row for origin_location '" + origin +
797
+ "' — call defineCutoff first"
798
+ );
799
+ }
800
+ var cutoff = _hydrateCutoff(cutoffRow);
801
+
802
+ // Resolve destination_zone. Postal-prefix table is the primary
803
+ // lookup; when shippingZones is wired AND the postal table
804
+ // misses, fall back to the country+region zone-router.
805
+ var destZone = await _resolvePostalToZone(destCountry, destPostal);
806
+ if (!destZone && shippingZones && typeof shippingZones.zoneForDestination === "function") {
807
+ destZone = await shippingZones.zoneForDestination({
808
+ country: destCountry,
809
+ region: destRegion ? destRegion.toUpperCase() : null,
810
+ });
811
+ }
812
+ if (!destZone) {
813
+ return {
814
+ ok: false,
815
+ reason: "no_destination_zone",
816
+ origin_location: origin,
817
+ origin_zone: null,
818
+ destination_zone: null,
819
+ ship_by_date: null,
820
+ est_min_delivery_date: null,
821
+ est_max_delivery_date: null,
822
+ service_levels: [],
823
+ };
824
+ }
825
+
826
+ // Resolve origin zone via the same postal-zone lookup keyed on
827
+ // the origin location's slug treated as its own zone (operators
828
+ // typically register `defineCarrierTransit({from_zone: "<origin>",
829
+ // ...})`). The convention is: when no separate origin_zone is
830
+ // registered, use the origin_location as the from_zone.
831
+ var originZone = origin;
832
+
833
+ // Compute ship_by_date — the calendar day the parcel physically
834
+ // leaves the origin. If `now` is before today's cutoff in the
835
+ // origin TZ AND today is a business day, ship today; else push
836
+ // to the next origin-region business day.
837
+ var nowWall = _wallClockIn(cutoff.timezone, now);
838
+ var cutoffHH = Number(cutoff.daily_cutoff_local_time.slice(0, 2));
839
+ var cutoffMM = Number(cutoff.daily_cutoff_local_time.slice(3, 5));
840
+ var beforeCut = nowWall.hh < cutoffHH || (nowWall.hh === cutoffHH && nowWall.mm < cutoffMM);
841
+
842
+ // Origin holidays — keyed by the origin's region. The operator
843
+ // either passes an explicit `origin_region` via inventoryLocations
844
+ // (when wired) or the framework reads the country halves of
845
+ // origin_zone slugs by convention; in the absence of either, the
846
+ // origin region defaults to a globally empty set (no holiday
847
+ // skip on the ship side). Destination holidays follow the same
848
+ // pattern, keyed by `destination_country` (default) OR an explicit
849
+ // override.
850
+ //
851
+ // The operator-facing knob is `defineHoliday({region: <slug>})`:
852
+ // they register against whatever region key matches the location's
853
+ // documented region.
854
+ var originRegion = await _resolveOriginRegion(origin);
855
+ var destRegionKey = destRegion ? destRegion : destCountry.toLowerCase();
856
+ var originHolidays = originRegion ? await _holidaysForRegion(originRegion) : {};
857
+ var destHolidays = await _holidaysForRegion(destRegionKey);
858
+
859
+ var shipBy = { y: nowWall.y, m: nowWall.m, d: nowWall.d };
860
+ if (!beforeCut) {
861
+ // On or after cutoff — push one calendar day before applying
862
+ // the business-day roll. Without the +1 step, a Mon-1pm
863
+ // request with a noon cutoff would re-land on Mon after the
864
+ // weekend skip (which does nothing on a weekday).
865
+ shipBy = _addDays(shipBy, 1);
866
+ }
867
+ shipBy = _addBusinessDays(shipBy, 0, originHolidays);
868
+ var shipByStr = _formatYMD(shipBy);
869
+
870
+ // Transit rows for the (origin_zone, dest_zone) pair. Filter by
871
+ // requested_service_level when supplied; the result is the menu
872
+ // of service levels the storefront renders.
873
+ var transits = await _liveTransitsFor(originZone, destZone, requestedSvc);
874
+ var serviceLevels = [];
875
+ var minDeliver = null;
876
+ var maxDeliver = null;
877
+ for (var i = 0; i < transits.length; i += 1) {
878
+ var t = transits[i];
879
+ var deliverParts = _addBusinessDays(shipBy, t.transit_days, destHolidays);
880
+ var deliverStr = _formatYMD(deliverParts);
881
+ serviceLevels.push({
882
+ code: t.service_level,
883
+ label: t.carrier + " " + t.service_level,
884
+ carrier: t.carrier,
885
+ ship_by: shipByStr,
886
+ deliver_by: deliverStr,
887
+ business_days: t.transit_days,
888
+ });
889
+ var deliverTs = Date.UTC(deliverParts.y, deliverParts.m - 1, deliverParts.d);
890
+ if (minDeliver == null || deliverTs < minDeliver.ts) minDeliver = { ts: deliverTs, str: deliverStr };
891
+ if (maxDeliver == null || deliverTs > maxDeliver.ts) maxDeliver = { ts: deliverTs, str: deliverStr };
892
+ }
893
+
894
+ return {
895
+ ok: serviceLevels.length > 0,
896
+ reason: serviceLevels.length === 0 ? "no_transit_rows" : null,
897
+ origin_location: origin,
898
+ origin_zone: originZone,
899
+ destination_zone: destZone,
900
+ ship_by_date: shipByStr,
901
+ est_min_delivery_date: minDeliver ? minDeliver.str : null,
902
+ est_max_delivery_date: maxDeliver ? maxDeliver.str : null,
903
+ service_levels: serviceLevels,
904
+ };
905
+ },
906
+
907
+ // Per-line cart estimates. `cart.lines` is an array; each line
908
+ // carries an optional `origin_location` (split-shipment case) and
909
+ // weight_grams. The result mirrors the line shape with an
910
+ // `estimate` field per line; the cart-level fields are the
911
+ // intersection (max of mins, max of maxes) so the storefront can
912
+ // render a "all items by ___" summary.
913
+ estimateForCart: async function (input) {
914
+ if (!input || typeof input !== "object") {
915
+ throw new TypeError("deliveryEstimate.estimateForCart: input object required");
916
+ }
917
+ var cart = input.cart;
918
+ if (!cart || typeof cart !== "object" || !Array.isArray(cart.lines)) {
919
+ throw new TypeError("deliveryEstimate.estimateForCart: cart.lines must be an array");
920
+ }
921
+ if (!input.destination || typeof input.destination !== "object") {
922
+ throw new TypeError("deliveryEstimate.estimateForCart: destination object required");
923
+ }
924
+ var dest = input.destination;
925
+ var now = input.now == null ? _now() : _epochMs(input.now, "now");
926
+
927
+ var perLine = [];
928
+ var slowestMin = null;
929
+ var slowestMax = null;
930
+ var anyMissing = false;
931
+ for (var i = 0; i < cart.lines.length; i += 1) {
932
+ var line = cart.lines[i];
933
+ if (!line || typeof line !== "object") {
934
+ throw new TypeError("deliveryEstimate.estimateForCart: cart.lines[" + i + "] must be an object");
935
+ }
936
+ var est = await this.estimate({
937
+ origin_location: line.origin_location,
938
+ destination_postal: dest.postal,
939
+ destination_country: dest.country,
940
+ destination_region: dest.region,
941
+ weight_grams: line.weight_grams,
942
+ requested_service_level: input.requested_service_level,
943
+ now: now,
944
+ });
945
+ perLine.push({ line_id: line.id, estimate: est });
946
+ if (!est.ok) { anyMissing = true; continue; }
947
+ if (slowestMin == null || est.est_min_delivery_date > slowestMin) slowestMin = est.est_min_delivery_date;
948
+ if (slowestMax == null || est.est_max_delivery_date > slowestMax) slowestMax = est.est_max_delivery_date;
949
+ }
950
+ return {
951
+ ok: !anyMissing && perLine.length > 0,
952
+ reason: anyMissing ? "line_unresolved" : (perLine.length === 0 ? "empty_cart" : null),
953
+ cart_min_delivery_date: slowestMin,
954
+ cart_max_delivery_date: slowestMax,
955
+ lines: perLine,
956
+ };
957
+ },
958
+
959
+ // Operator dashboards. Filter by carrier when supplied; otherwise
960
+ // returns every live transit row in (from_zone, to_zone,
961
+ // transit_days, carrier, service_level) order so the result is
962
+ // dashboard-renderable as-is.
963
+ listTransits: async function (listOpts) {
964
+ listOpts = listOpts || {};
965
+ var sql = "SELECT * FROM carrier_transits WHERE archived_at IS NULL";
966
+ var params = [];
967
+ if (listOpts.carrier != null) {
968
+ _carrier(listOpts.carrier);
969
+ sql += " AND carrier = ?1";
970
+ params.push(listOpts.carrier);
971
+ }
972
+ sql += " ORDER BY from_zone ASC, to_zone ASC, transit_days ASC, carrier ASC, service_level ASC";
973
+ var r = await query(sql, params);
974
+ var out = [];
975
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_hydrateTransit(r.rows[i]));
976
+ return out;
977
+ },
978
+
979
+ listCutoffs: async function () {
980
+ var r = await query(
981
+ "SELECT * FROM shipping_cutoffs ORDER BY origin_location ASC",
982
+ [],
983
+ );
984
+ var out = [];
985
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_hydrateCutoff(r.rows[i]));
986
+ return out;
987
+ },
988
+
989
+ listHolidays: async function (listOpts) {
990
+ listOpts = listOpts || {};
991
+ var where = ["archived_at IS NULL"];
992
+ var params = [];
993
+ var idx = 1;
994
+ if (listOpts.region != null) {
995
+ var region = _region(listOpts.region, "region");
996
+ where.push("region = ?" + idx);
997
+ params.push(region);
998
+ idx += 1;
999
+ }
1000
+ if (listOpts.from != null) {
1001
+ _date(listOpts.from, "from");
1002
+ where.push("date >= ?" + idx);
1003
+ params.push(listOpts.from);
1004
+ idx += 1;
1005
+ }
1006
+ if (listOpts.to != null) {
1007
+ _date(listOpts.to, "to");
1008
+ where.push("date <= ?" + idx);
1009
+ params.push(listOpts.to);
1010
+ idx += 1;
1011
+ }
1012
+ var sql = "SELECT * FROM shipping_holidays WHERE " + where.join(" AND ") +
1013
+ " ORDER BY date ASC, region ASC";
1014
+ var r = await query(sql, params);
1015
+ var out = [];
1016
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_hydrateHoliday(r.rows[i]));
1017
+ return out;
1018
+ },
1019
+
1020
+ listPostalZones: async function (listOpts) {
1021
+ listOpts = listOpts || {};
1022
+ var sql = "SELECT * FROM delivery_postal_zones";
1023
+ var params = [];
1024
+ if (listOpts.country != null) {
1025
+ var country = _country(listOpts.country, "country");
1026
+ sql += " WHERE country = ?1";
1027
+ params.push(country);
1028
+ }
1029
+ sql += " ORDER BY country ASC, postal_prefix ASC";
1030
+ var r = await query(sql, params);
1031
+ var out = [];
1032
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_hydratePostalZone(r.rows[i]));
1033
+ return out;
1034
+ },
1035
+
1036
+ // Soft-delete a transit row by id. Idempotent — archiving an
1037
+ // already-archived row returns it unchanged. Archived rows drop
1038
+ // out of estimate() math but stay on disk for the audit trail.
1039
+ archiveTransit: async function (transitId) {
1040
+ try { _b().guardUuid.sanitize(transitId, { profile: "strict" }); }
1041
+ catch (_e) { throw new TypeError("deliveryEstimate.archiveTransit: transit_id must be a valid uuid"); }
1042
+ var current = (await query(
1043
+ "SELECT * FROM carrier_transits WHERE id = ?1",
1044
+ [transitId],
1045
+ )).rows[0];
1046
+ if (!current) return null;
1047
+ if (current.archived_at != null) return _hydrateTransit(current);
1048
+ var ts = _now();
1049
+ await query(
1050
+ "UPDATE carrier_transits SET archived_at = ?1, updated_at = ?1 WHERE id = ?2",
1051
+ [ts, transitId],
1052
+ );
1053
+ var r = await query(
1054
+ "SELECT * FROM carrier_transits WHERE id = ?1",
1055
+ [transitId],
1056
+ );
1057
+ return _hydrateTransit(r.rows[0]);
1058
+ },
1059
+
1060
+ // Same shape as archiveTransit — soft-delete a single holiday.
1061
+ archiveHoliday: async function (holidayId) {
1062
+ try { _b().guardUuid.sanitize(holidayId, { profile: "strict" }); }
1063
+ catch (_e) { throw new TypeError("deliveryEstimate.archiveHoliday: holiday_id must be a valid uuid"); }
1064
+ var current = (await query(
1065
+ "SELECT * FROM shipping_holidays WHERE id = ?1",
1066
+ [holidayId],
1067
+ )).rows[0];
1068
+ if (!current) return null;
1069
+ if (current.archived_at != null) return _hydrateHoliday(current);
1070
+ var ts = _now();
1071
+ await query(
1072
+ "UPDATE shipping_holidays SET archived_at = ?1 WHERE id = ?2",
1073
+ [ts, holidayId],
1074
+ );
1075
+ var r = await query(
1076
+ "SELECT * FROM shipping_holidays WHERE id = ?1",
1077
+ [holidayId],
1078
+ );
1079
+ return _hydrateHoliday(r.rows[0]);
1080
+ },
1081
+ };
1082
+
1083
+ // Helper: resolve the origin's region for holiday-skip math. Prefers
1084
+ // an injected inventoryLocations resolver (`regionFor(slug)`) and
1085
+ // falls back to null (no origin-side holidays applied). Keeping the
1086
+ // resolver injected leaves the holiday-region naming convention to
1087
+ // the operator — `us-ca` / `us` / `eu-de` are equally valid keys
1088
+ // depending on the operator's policy.
1089
+ async function _resolveOriginRegion(origin) {
1090
+ if (inventoryLocations && typeof inventoryLocations.regionFor === "function") {
1091
+ try {
1092
+ var r = await inventoryLocations.regionFor(origin);
1093
+ if (typeof r === "string" && r.length) return r;
1094
+ } catch (_e) {
1095
+ // drop-silent — by design: the holiday-region lookup is a hint,
1096
+ // a missing region just means no origin-side holiday skips.
1097
+ return null;
1098
+ }
1099
+ }
1100
+ return null;
1101
+ }
1102
+ }
1103
+
1104
+ module.exports = {
1105
+ create: create,
1106
+ CARRIERS: CARRIERS,
1107
+ MAX_TRANSIT_DAYS: MAX_TRANSIT_DAYS,
1108
+ MAX_WEIGHT_GRAMS: MAX_WEIGHT_GRAMS,
1109
+ ZONE_RE: ZONE_RE,
1110
+ DATE_RE: DATE_RE,
1111
+ TIME_RE: TIME_RE,
1112
+ COUNTRY_RE: COUNTRY_RE,
1113
+ };