@blamejs/blamejs-shop 0.0.60 → 0.0.62
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 +4 -0
- package/lib/carrier-rates.js +683 -0
- package/lib/cart-bulk-ops.js +711 -0
- package/lib/cms-blocks.js +651 -0
- package/lib/code-minter.js +535 -0
- package/lib/customer-import.js +590 -0
- package/lib/discount-analytics.js +548 -0
- package/lib/dunning.js +700 -0
- package/lib/email-campaigns.js +844 -0
- package/lib/geolocation.js +651 -0
- package/lib/gift-card-ledger.js +483 -0
- package/lib/gift-registry.js +820 -0
- package/lib/index.js +21 -0
- package/lib/loyalty-redemption.js +673 -0
- package/lib/operator-audit-log.js +621 -0
- package/lib/plan-changes.js +508 -0
- package/lib/refund-policy.js +965 -0
- package/lib/search-facets.js +825 -0
- package/lib/sms-dispatcher.js +951 -0
- package/lib/stock-transfers.js +777 -0
- package/lib/storefront-dashboards.js +863 -0
- package/lib/storefront-forms.js +884 -0
- package/lib/vendors.js +797 -0
- package/package.json +1 -1
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.carrierRates
|
|
4
|
+
* @title Carrier rates — at-checkout shipping rate shopping
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Operator registers N rate sources (UPS / FedEx / USPS / DHL / a
|
|
8
|
+
* flat-rate fallback). For a (cart + ship-to) tuple the primitive
|
|
9
|
+
* returns sorted-by-cost rates with delivery-window estimates.
|
|
10
|
+
*
|
|
11
|
+
* The framework does NOT call carrier APIs directly. Each carrier
|
|
12
|
+
* has its own auth + endpoint shape; the operator's worker drains a
|
|
13
|
+
* rate request, calls the carrier, and posts each quote back via
|
|
14
|
+
* `recordRateQuote`. The primitive caches every quote until the
|
|
15
|
+
* carrier-declared `valid_until` expires, then `ratesForShipment`
|
|
16
|
+
* returns the live cached rates sorted ascending by cost. When no
|
|
17
|
+
* live carrier rate covers the request, the primitive composes a
|
|
18
|
+
* synthetic flat-rate fallback from the registered `flat_rate`
|
|
19
|
+
* carrier (if one exists) so the checkout never stalls.
|
|
20
|
+
*
|
|
21
|
+
* Distinct from sibling primitives:
|
|
22
|
+
* - `shipping` — per-order cost math at the till (the rate
|
|
23
|
+
* the till charges; this primitive shops
|
|
24
|
+
* the carrier menu that feeds it).
|
|
25
|
+
* - `shippingLabels` — post-checkout broker-purchased label
|
|
26
|
+
* artefact (tracking + PDF).
|
|
27
|
+
*
|
|
28
|
+
* Composes:
|
|
29
|
+
* - `b.guardUuid` — UUID-shape validation on every id input
|
|
30
|
+
* - `b.uuid.v7` — quote row PK
|
|
31
|
+
* - `b.safeUrl.parse` — https-only `rate_endpoint` validation
|
|
32
|
+
* - `b.pagination` — HMAC-tagged cursor for `recentQuotes`
|
|
33
|
+
*
|
|
34
|
+
* Surface:
|
|
35
|
+
* registerCarrier({ slug, carrier, service_levels, rate_endpoint?, active })
|
|
36
|
+
* recordRateQuote({ carrier_slug, service_code, origin_postal,
|
|
37
|
+
* dest_postal, weight_grams, dimensions,
|
|
38
|
+
* rate_minor, currency, valid_until })
|
|
39
|
+
* ratesForShipment({ origin_postal, dest_postal, weight_grams,
|
|
40
|
+
* dimensions })
|
|
41
|
+
* cleanupExpiredQuotes()
|
|
42
|
+
* recentQuotes({ carrier_slug?, from, to, limit, cursor? })
|
|
43
|
+
* metricsForCarrier({ slug, from, to })
|
|
44
|
+
*
|
|
45
|
+
* Storage:
|
|
46
|
+
* - `carriers` (migration `0080_carrier_rates.sql`)
|
|
47
|
+
* - `carrier_rate_quotes` (migration `0080_carrier_rates.sql`)
|
|
48
|
+
*
|
|
49
|
+
* @primitive carrierRates
|
|
50
|
+
* @related b.guardUuid, b.uuid, b.safeUrl, b.pagination, shop.shipping
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
var CARRIERS = Object.freeze(["ups", "fedex", "usps", "dhl", "flat_rate"]);
|
|
54
|
+
|
|
55
|
+
var MAX_SLUG_LEN = 64;
|
|
56
|
+
var SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
57
|
+
|
|
58
|
+
var MAX_SERVICE_CODE_LEN = 64;
|
|
59
|
+
var SERVICE_CODE_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
|
|
60
|
+
|
|
61
|
+
var MAX_SERVICE_LABEL_LEN = 128;
|
|
62
|
+
var MAX_SERVICE_LEVELS = 32;
|
|
63
|
+
|
|
64
|
+
var MAX_POSTAL_LEN = 16;
|
|
65
|
+
var POSTAL_RE = /^[A-Za-z0-9][A-Za-z0-9 -]{0,15}$/;
|
|
66
|
+
|
|
67
|
+
var MAX_ENDPOINT_URL_LEN = 2048;
|
|
68
|
+
|
|
69
|
+
var DEFAULT_RECENT_LIMIT = 25;
|
|
70
|
+
var MAX_RECENT_LIMIT = 200;
|
|
71
|
+
|
|
72
|
+
var MAX_DIMENSION_MM = 5000; // 5 m — generous, catches absurd input
|
|
73
|
+
var MAX_WEIGHT_GRAMS = 1000 * 1000; // 1 t — generous, catches absurd input
|
|
74
|
+
|
|
75
|
+
// Lazy framework handle — matches the pattern every other shop
|
|
76
|
+
// primitive uses; avoids the require cycle that would arise from
|
|
77
|
+
// importing `./index` at module-eval time.
|
|
78
|
+
var bShop;
|
|
79
|
+
function _b() {
|
|
80
|
+
if (!bShop) bShop = require("./index");
|
|
81
|
+
return bShop.framework;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- validators ---------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
function _hasControlByte(s) {
|
|
87
|
+
for (var i = 0; i < s.length; i += 1) {
|
|
88
|
+
var cc = s.charCodeAt(i);
|
|
89
|
+
if (cc <= 0x1f || cc === 0x7f) return true;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function _slug(s) {
|
|
95
|
+
if (typeof s !== "string" || !s.length) {
|
|
96
|
+
throw new TypeError("carrierRates: slug must be a non-empty string");
|
|
97
|
+
}
|
|
98
|
+
if (s.length > MAX_SLUG_LEN) {
|
|
99
|
+
throw new TypeError("carrierRates: slug must be <= " + MAX_SLUG_LEN + " characters");
|
|
100
|
+
}
|
|
101
|
+
if (!SLUG_RE.test(s)) {
|
|
102
|
+
throw new TypeError(
|
|
103
|
+
"carrierRates: slug must match /^[a-z0-9][a-z0-9_-]{0,63}$/ — " +
|
|
104
|
+
"lowercase alphanumerics with `_`/`-`, must not start with separator"
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return s;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function _carrier(c) {
|
|
111
|
+
if (typeof c !== "string" || CARRIERS.indexOf(c) === -1) {
|
|
112
|
+
throw new TypeError("carrierRates: carrier must be one of " + CARRIERS.join(", "));
|
|
113
|
+
}
|
|
114
|
+
return c;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function _serviceCode(c, label) {
|
|
118
|
+
if (typeof c !== "string" || !c.length) {
|
|
119
|
+
throw new TypeError("carrierRates: " + label + " must be a non-empty string");
|
|
120
|
+
}
|
|
121
|
+
if (c.length > MAX_SERVICE_CODE_LEN) {
|
|
122
|
+
throw new TypeError("carrierRates: " + label + " must be <= " + MAX_SERVICE_CODE_LEN + " characters");
|
|
123
|
+
}
|
|
124
|
+
if (!SERVICE_CODE_RE.test(c)) {
|
|
125
|
+
throw new TypeError(
|
|
126
|
+
"carrierRates: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/"
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return c;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function _serviceLevels(arr) {
|
|
133
|
+
if (!Array.isArray(arr) || arr.length === 0) {
|
|
134
|
+
throw new TypeError("carrierRates: service_levels must be a non-empty array");
|
|
135
|
+
}
|
|
136
|
+
if (arr.length > MAX_SERVICE_LEVELS) {
|
|
137
|
+
throw new TypeError("carrierRates: service_levels must be <= " + MAX_SERVICE_LEVELS + " entries");
|
|
138
|
+
}
|
|
139
|
+
var seen = {};
|
|
140
|
+
var out = [];
|
|
141
|
+
for (var i = 0; i < arr.length; i += 1) {
|
|
142
|
+
var lvl = arr[i];
|
|
143
|
+
if (!lvl || typeof lvl !== "object" || Array.isArray(lvl)) {
|
|
144
|
+
throw new TypeError("carrierRates: service_levels[" + i + "] must be an object");
|
|
145
|
+
}
|
|
146
|
+
var code = _serviceCode(lvl.code, "service_levels[" + i + "].code");
|
|
147
|
+
if (Object.prototype.hasOwnProperty.call(seen, code)) {
|
|
148
|
+
throw new TypeError("carrierRates: service_levels[" + i + "].code duplicates an earlier entry");
|
|
149
|
+
}
|
|
150
|
+
seen[code] = true;
|
|
151
|
+
if (typeof lvl.label !== "string" || !lvl.label.length) {
|
|
152
|
+
throw new TypeError("carrierRates: service_levels[" + i + "].label must be a non-empty string");
|
|
153
|
+
}
|
|
154
|
+
if (lvl.label.length > MAX_SERVICE_LABEL_LEN) {
|
|
155
|
+
throw new TypeError("carrierRates: service_levels[" + i + "].label must be <= " + MAX_SERVICE_LABEL_LEN + " characters");
|
|
156
|
+
}
|
|
157
|
+
if (_hasControlByte(lvl.label)) {
|
|
158
|
+
throw new TypeError("carrierRates: service_levels[" + i + "].label must not contain control characters");
|
|
159
|
+
}
|
|
160
|
+
if (!Number.isInteger(lvl.sla_days) || lvl.sla_days < 0 || lvl.sla_days > 365) {
|
|
161
|
+
throw new TypeError("carrierRates: service_levels[" + i + "].sla_days must be an integer 0..365");
|
|
162
|
+
}
|
|
163
|
+
out.push({ code: code, label: lvl.label, sla_days: lvl.sla_days });
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function _rateEndpoint(url) {
|
|
169
|
+
if (url == null || url === "") return null;
|
|
170
|
+
if (typeof url !== "string") {
|
|
171
|
+
throw new TypeError("carrierRates: rate_endpoint must be a string when provided");
|
|
172
|
+
}
|
|
173
|
+
if (url.length > MAX_ENDPOINT_URL_LEN) {
|
|
174
|
+
throw new TypeError("carrierRates: rate_endpoint must be <= " + MAX_ENDPOINT_URL_LEN + " characters");
|
|
175
|
+
}
|
|
176
|
+
if (_hasControlByte(url)) {
|
|
177
|
+
throw new TypeError("carrierRates: rate_endpoint must not contain control characters");
|
|
178
|
+
}
|
|
179
|
+
// safeUrl.parse defaults to ALLOW_HTTP_TLS (https only). The framework
|
|
180
|
+
// refuses http:// + non-http schemes + user:pass@ userinfo + bracketed
|
|
181
|
+
// raw-IP forms at this gate — operators forwarding rate requests over
|
|
182
|
+
// cleartext would leak the shopping pattern; refuse at the door.
|
|
183
|
+
try {
|
|
184
|
+
_b().safeUrl.parse(url, { allowedProtocols: ["https:"] });
|
|
185
|
+
} catch (e) {
|
|
186
|
+
throw new TypeError("carrierRates: rate_endpoint — " + (e && e.message || "must be a valid https:// URL"));
|
|
187
|
+
}
|
|
188
|
+
return url;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function _active(a) {
|
|
192
|
+
if (typeof a !== "boolean") {
|
|
193
|
+
throw new TypeError("carrierRates: active must be a boolean");
|
|
194
|
+
}
|
|
195
|
+
return a ? 1 : 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function _postal(s, label) {
|
|
199
|
+
if (typeof s !== "string" || !s.length) {
|
|
200
|
+
throw new TypeError("carrierRates: " + label + " must be a non-empty string");
|
|
201
|
+
}
|
|
202
|
+
if (s.length > MAX_POSTAL_LEN) {
|
|
203
|
+
throw new TypeError("carrierRates: " + label + " must be <= " + MAX_POSTAL_LEN + " characters");
|
|
204
|
+
}
|
|
205
|
+
if (!POSTAL_RE.test(s)) {
|
|
206
|
+
throw new TypeError(
|
|
207
|
+
"carrierRates: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9 -]{0,15}$/ " +
|
|
208
|
+
"(alphanumerics with embedded space or `-`)"
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return s;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function _weightGrams(n) {
|
|
215
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_WEIGHT_GRAMS) {
|
|
216
|
+
throw new TypeError(
|
|
217
|
+
"carrierRates: weight_grams must be a positive integer <= " + MAX_WEIGHT_GRAMS
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return n;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function _dimensions(d) {
|
|
224
|
+
if (!d || typeof d !== "object" || Array.isArray(d)) {
|
|
225
|
+
throw new TypeError("carrierRates: dimensions must be an object {length_mm, width_mm, height_mm}");
|
|
226
|
+
}
|
|
227
|
+
var keys = ["length_mm", "width_mm", "height_mm"];
|
|
228
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
229
|
+
var k = keys[i];
|
|
230
|
+
var v = d[k];
|
|
231
|
+
if (!Number.isInteger(v) || v <= 0 || v > MAX_DIMENSION_MM) {
|
|
232
|
+
throw new TypeError(
|
|
233
|
+
"carrierRates: dimensions." + k + " must be a positive integer <= " + MAX_DIMENSION_MM
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
length_mm: d.length_mm,
|
|
239
|
+
width_mm: d.width_mm,
|
|
240
|
+
height_mm: d.height_mm,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function _rateMinor(n) {
|
|
245
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
246
|
+
throw new TypeError("carrierRates: rate_minor must be a non-negative integer");
|
|
247
|
+
}
|
|
248
|
+
return n;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function _currency(c) {
|
|
252
|
+
if (typeof c !== "string" || !/^[A-Z]{3}$/.test(c)) {
|
|
253
|
+
throw new TypeError("carrierRates: currency must be 3-letter uppercase ISO 4217");
|
|
254
|
+
}
|
|
255
|
+
return c;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function _epochMs(n, label) {
|
|
259
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
260
|
+
throw new TypeError("carrierRates: " + label + " must be a positive integer epoch-ms");
|
|
261
|
+
}
|
|
262
|
+
return n;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function _limit(n) {
|
|
266
|
+
if (n == null) return DEFAULT_RECENT_LIMIT;
|
|
267
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_RECENT_LIMIT) {
|
|
268
|
+
throw new TypeError("carrierRates: limit must be an integer 1..." + MAX_RECENT_LIMIT);
|
|
269
|
+
}
|
|
270
|
+
return n;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function _now() { return Date.now(); }
|
|
274
|
+
|
|
275
|
+
// ---- factory ------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
function create(opts) {
|
|
278
|
+
opts = opts || {};
|
|
279
|
+
var query = opts.query;
|
|
280
|
+
if (!query) {
|
|
281
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
282
|
+
}
|
|
283
|
+
var cursorSecret = opts.cursorSecret || null;
|
|
284
|
+
if (cursorSecret != null && (typeof cursorSecret !== "string" || !cursorSecret.length)) {
|
|
285
|
+
throw new TypeError("carrierRates.create: opts.cursorSecret must be a non-empty string when provided");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function _hydrateCarrier(row) {
|
|
289
|
+
if (!row) return null;
|
|
290
|
+
var levels = [];
|
|
291
|
+
try { levels = JSON.parse(row.service_levels_json || "[]"); }
|
|
292
|
+
catch (_e) { levels = []; }
|
|
293
|
+
return {
|
|
294
|
+
slug: row.slug,
|
|
295
|
+
carrier: row.carrier,
|
|
296
|
+
service_levels: levels,
|
|
297
|
+
rate_endpoint: row.rate_endpoint == null ? null : row.rate_endpoint,
|
|
298
|
+
active: Number(row.active) === 1,
|
|
299
|
+
created_at: Number(row.created_at),
|
|
300
|
+
updated_at: Number(row.updated_at),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function _hydrateQuote(row) {
|
|
305
|
+
if (!row) return null;
|
|
306
|
+
var dims = { length_mm: 0, width_mm: 0, height_mm: 0 };
|
|
307
|
+
try { dims = JSON.parse(row.dimensions_json || "{}"); }
|
|
308
|
+
catch (_e) { /* drop-silent — by design; row content is operator-supplied JSON */ }
|
|
309
|
+
return {
|
|
310
|
+
id: row.id,
|
|
311
|
+
carrier_slug: row.carrier_slug,
|
|
312
|
+
service_code: row.service_code,
|
|
313
|
+
origin_postal: row.origin_postal,
|
|
314
|
+
dest_postal: row.dest_postal,
|
|
315
|
+
weight_grams: Number(row.weight_grams),
|
|
316
|
+
dimensions: dims,
|
|
317
|
+
rate_minor: Number(row.rate_minor),
|
|
318
|
+
currency: row.currency,
|
|
319
|
+
valid_until: Number(row.valid_until),
|
|
320
|
+
queried_at: Number(row.queried_at),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function _getCarrierRow(slug) {
|
|
325
|
+
var r = await query("SELECT * FROM carriers WHERE slug = ?1", [slug]);
|
|
326
|
+
return r.rows[0] || null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
CARRIERS: CARRIERS,
|
|
331
|
+
MAX_SERVICE_LEVELS: MAX_SERVICE_LEVELS,
|
|
332
|
+
MAX_RECENT_LIMIT: MAX_RECENT_LIMIT,
|
|
333
|
+
|
|
334
|
+
// Register (or update) an operator's rate source. Upsert on slug
|
|
335
|
+
// — repeating with the same slug replaces the carrier shape +
|
|
336
|
+
// service-level menu in place so an operator paused-then-rotated
|
|
337
|
+
// an account doesn't accumulate stale rows.
|
|
338
|
+
registerCarrier: async function (input) {
|
|
339
|
+
if (!input || typeof input !== "object") {
|
|
340
|
+
throw new TypeError("carrierRates.registerCarrier: input object required");
|
|
341
|
+
}
|
|
342
|
+
var slug = _slug(input.slug);
|
|
343
|
+
var carrier = _carrier(input.carrier);
|
|
344
|
+
var serviceLevels = _serviceLevels(input.service_levels);
|
|
345
|
+
var rateEndpoint = _rateEndpoint(input.rate_endpoint);
|
|
346
|
+
var active = _active(input.active);
|
|
347
|
+
|
|
348
|
+
var ts = _now();
|
|
349
|
+
var existing = await _getCarrierRow(slug);
|
|
350
|
+
var createdAt = existing ? Number(existing.created_at) : ts;
|
|
351
|
+
|
|
352
|
+
await query(
|
|
353
|
+
"INSERT INTO carriers " +
|
|
354
|
+
"(slug, carrier, service_levels_json, rate_endpoint, active, created_at, updated_at) " +
|
|
355
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) " +
|
|
356
|
+
"ON CONFLICT(slug) DO UPDATE SET " +
|
|
357
|
+
" carrier = excluded.carrier, " +
|
|
358
|
+
" service_levels_json = excluded.service_levels_json, " +
|
|
359
|
+
" rate_endpoint = excluded.rate_endpoint, " +
|
|
360
|
+
" active = excluded.active, " +
|
|
361
|
+
" updated_at = excluded.updated_at",
|
|
362
|
+
[
|
|
363
|
+
slug, carrier, JSON.stringify(serviceLevels), rateEndpoint,
|
|
364
|
+
active, createdAt, ts,
|
|
365
|
+
],
|
|
366
|
+
);
|
|
367
|
+
return _hydrateCarrier(await _getCarrierRow(slug));
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
// Cache one carrier-minted quote. The UNIQUE
|
|
371
|
+
// (carrier_slug, service_code, origin_postal, dest_postal,
|
|
372
|
+
// weight_grams) key dedups — a fresh quote with the same shape
|
|
373
|
+
// refreshes rate + valid_until in place, never stacks.
|
|
374
|
+
recordRateQuote: async function (input) {
|
|
375
|
+
if (!input || typeof input !== "object") {
|
|
376
|
+
throw new TypeError("carrierRates.recordRateQuote: input object required");
|
|
377
|
+
}
|
|
378
|
+
var slug = _slug(input.carrier_slug);
|
|
379
|
+
var serviceCode = _serviceCode(input.service_code, "service_code");
|
|
380
|
+
var originPostal = _postal(input.origin_postal, "origin_postal");
|
|
381
|
+
var destPostal = _postal(input.dest_postal, "dest_postal");
|
|
382
|
+
var weightGrams = _weightGrams(input.weight_grams);
|
|
383
|
+
var dimensions = _dimensions(input.dimensions);
|
|
384
|
+
var rateMinor = _rateMinor(input.rate_minor);
|
|
385
|
+
var currency = _currency(input.currency);
|
|
386
|
+
var validUntil = _epochMs(input.valid_until, "valid_until");
|
|
387
|
+
|
|
388
|
+
var carrierRow = await _getCarrierRow(slug);
|
|
389
|
+
if (!carrierRow) {
|
|
390
|
+
throw new TypeError("carrierRates.recordRateQuote: carrier '" + slug + "' not registered");
|
|
391
|
+
}
|
|
392
|
+
// Service code must appear in the carrier's registered menu.
|
|
393
|
+
// Operators rotating service-level offerings re-call
|
|
394
|
+
// `registerCarrier` first; a quote for an unmenued code is the
|
|
395
|
+
// worker drifting against the operator's intent — refuse loud.
|
|
396
|
+
var menu;
|
|
397
|
+
try { menu = JSON.parse(carrierRow.service_levels_json || "[]"); }
|
|
398
|
+
catch (_e) { menu = []; }
|
|
399
|
+
var menued = false;
|
|
400
|
+
for (var i = 0; i < menu.length; i += 1) {
|
|
401
|
+
if (menu[i] && menu[i].code === serviceCode) { menued = true; break; }
|
|
402
|
+
}
|
|
403
|
+
if (!menued) {
|
|
404
|
+
throw new TypeError(
|
|
405
|
+
"carrierRates.recordRateQuote: service_code '" + serviceCode +
|
|
406
|
+
"' not in carrier '" + slug + "' service_levels menu"
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
var ts = _now();
|
|
411
|
+
// Pre-resolve the existing row id (if any) so the upsert keeps a
|
|
412
|
+
// stable id across refreshes — the audit trail in `recentQuotes`
|
|
413
|
+
// stays threaded by id rather than churning a new uuid per
|
|
414
|
+
// refresh. INSERT OR REPLACE without the lookup would re-mint.
|
|
415
|
+
var existing = (await query(
|
|
416
|
+
"SELECT id FROM carrier_rate_quotes " +
|
|
417
|
+
"WHERE carrier_slug = ?1 AND service_code = ?2 " +
|
|
418
|
+
" AND origin_postal = ?3 AND dest_postal = ?4 AND weight_grams = ?5",
|
|
419
|
+
[slug, serviceCode, originPostal, destPostal, weightGrams],
|
|
420
|
+
)).rows[0];
|
|
421
|
+
var id = existing ? existing.id : _b().uuid.v7();
|
|
422
|
+
|
|
423
|
+
await query(
|
|
424
|
+
"INSERT INTO carrier_rate_quotes " +
|
|
425
|
+
"(id, carrier_slug, service_code, origin_postal, dest_postal, " +
|
|
426
|
+
" weight_grams, dimensions_json, rate_minor, currency, " +
|
|
427
|
+
" valid_until, queried_at) " +
|
|
428
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) " +
|
|
429
|
+
"ON CONFLICT(carrier_slug, service_code, origin_postal, dest_postal, weight_grams) " +
|
|
430
|
+
"DO UPDATE SET " +
|
|
431
|
+
" dimensions_json = excluded.dimensions_json, " +
|
|
432
|
+
" rate_minor = excluded.rate_minor, " +
|
|
433
|
+
" currency = excluded.currency, " +
|
|
434
|
+
" valid_until = excluded.valid_until, " +
|
|
435
|
+
" queried_at = excluded.queried_at",
|
|
436
|
+
[
|
|
437
|
+
id, slug, serviceCode, originPostal, destPostal,
|
|
438
|
+
weightGrams, JSON.stringify(dimensions), rateMinor, currency,
|
|
439
|
+
validUntil, ts,
|
|
440
|
+
],
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
var r = await query("SELECT * FROM carrier_rate_quotes WHERE id = ?1", [id]);
|
|
444
|
+
return _hydrateQuote(r.rows[0]);
|
|
445
|
+
},
|
|
446
|
+
|
|
447
|
+
// Read live cached rates for a shipment. ORDER BY rate_minor ASC
|
|
448
|
+
// so the cheapest live option surfaces first; the operator
|
|
449
|
+
// checkout typically renders the top N. The `dimensions` input is
|
|
450
|
+
// operator-reported for transparency but not part of the lookup
|
|
451
|
+
// key (operators packing a 30cm + 60cm box for the same address
|
|
452
|
+
// typically run two recordRateQuote calls with different weights;
|
|
453
|
+
// dimensions are stored on each quote so the checkout can pick
|
|
454
|
+
// the right one out of band when needed).
|
|
455
|
+
//
|
|
456
|
+
// When no live carrier rate exists, the primitive composes a
|
|
457
|
+
// synthetic fallback row per registered `flat_rate` carrier so
|
|
458
|
+
// the checkout never stalls on an empty rate pool. The synthetic
|
|
459
|
+
// row uses each flat_rate carrier's first service level + a
|
|
460
|
+
// documented FLAT_RATE_FALLBACK rate_minor of 0 (the operator
|
|
461
|
+
// overrides via a stored quote when they care about a non-zero
|
|
462
|
+
// baseline). Synthetic rows carry `synthetic: true` so the
|
|
463
|
+
// checkout can label them distinctly.
|
|
464
|
+
ratesForShipment: async function (input) {
|
|
465
|
+
if (!input || typeof input !== "object") {
|
|
466
|
+
throw new TypeError("carrierRates.ratesForShipment: input object required");
|
|
467
|
+
}
|
|
468
|
+
var originPostal = _postal(input.origin_postal, "origin_postal");
|
|
469
|
+
var destPostal = _postal(input.dest_postal, "dest_postal");
|
|
470
|
+
var weightGrams = _weightGrams(input.weight_grams);
|
|
471
|
+
_dimensions(input.dimensions);
|
|
472
|
+
|
|
473
|
+
var now = _now();
|
|
474
|
+
// Join in carrier_slug -> carriers.active so a paused carrier
|
|
475
|
+
// drops out of the rate pool without operator scrubbing the
|
|
476
|
+
// cached quotes (resuming the carrier surfaces the cache again).
|
|
477
|
+
var rows = (await query(
|
|
478
|
+
"SELECT q.*, c.active AS carrier_active, c.service_levels_json AS carrier_levels_json " +
|
|
479
|
+
"FROM carrier_rate_quotes q " +
|
|
480
|
+
"JOIN carriers c ON c.slug = q.carrier_slug " +
|
|
481
|
+
"WHERE q.origin_postal = ?1 AND q.dest_postal = ?2 " +
|
|
482
|
+
" AND q.weight_grams = ?3 AND q.valid_until > ?4 " +
|
|
483
|
+
" AND c.active = 1 " +
|
|
484
|
+
"ORDER BY q.rate_minor ASC, q.queried_at DESC, q.id ASC",
|
|
485
|
+
[originPostal, destPostal, weightGrams, now],
|
|
486
|
+
)).rows;
|
|
487
|
+
|
|
488
|
+
var out = [];
|
|
489
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
490
|
+
var hydrated = _hydrateQuote(rows[i]);
|
|
491
|
+
// Decorate with the matching service_level entry so the
|
|
492
|
+
// checkout can render label + sla_days without a second
|
|
493
|
+
// lookup.
|
|
494
|
+
var levels = [];
|
|
495
|
+
try { levels = JSON.parse(rows[i].carrier_levels_json || "[]"); }
|
|
496
|
+
catch (_e) { levels = []; }
|
|
497
|
+
var serviceLevel = null;
|
|
498
|
+
for (var j = 0; j < levels.length; j += 1) {
|
|
499
|
+
if (levels[j] && levels[j].code === hydrated.service_code) {
|
|
500
|
+
serviceLevel = levels[j];
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
hydrated.service_label = serviceLevel ? serviceLevel.label : null;
|
|
505
|
+
hydrated.sla_days = serviceLevel ? serviceLevel.sla_days : null;
|
|
506
|
+
hydrated.synthetic = false;
|
|
507
|
+
out.push(hydrated);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (out.length > 0) return out;
|
|
511
|
+
|
|
512
|
+
// Empty pool — compose synthetic flat-rate fallbacks. The
|
|
513
|
+
// operator registers a `flat_rate` carrier with at least one
|
|
514
|
+
// service level; the synthetic row borrows that menu so the
|
|
515
|
+
// checkout sees a consistent shape (label + sla_days + service
|
|
516
|
+
// code). The synthetic rate_minor is 0 — operators wanting a
|
|
517
|
+
// non-zero baseline post a real recordRateQuote against the
|
|
518
|
+
// flat_rate slug with the desired rate.
|
|
519
|
+
var flatRows = (await query(
|
|
520
|
+
"SELECT * FROM carriers WHERE carrier = 'flat_rate' AND active = 1 " +
|
|
521
|
+
"ORDER BY slug ASC",
|
|
522
|
+
[],
|
|
523
|
+
)).rows;
|
|
524
|
+
for (var k = 0; k < flatRows.length; k += 1) {
|
|
525
|
+
var flat = _hydrateCarrier(flatRows[k]);
|
|
526
|
+
if (!flat.service_levels.length) continue;
|
|
527
|
+
var first = flat.service_levels[0];
|
|
528
|
+
out.push({
|
|
529
|
+
id: null,
|
|
530
|
+
carrier_slug: flat.slug,
|
|
531
|
+
service_code: first.code,
|
|
532
|
+
service_label: first.label,
|
|
533
|
+
sla_days: first.sla_days,
|
|
534
|
+
origin_postal: originPostal,
|
|
535
|
+
dest_postal: destPostal,
|
|
536
|
+
weight_grams: weightGrams,
|
|
537
|
+
dimensions: _dimensions(input.dimensions),
|
|
538
|
+
rate_minor: 0,
|
|
539
|
+
currency: null,
|
|
540
|
+
valid_until: null,
|
|
541
|
+
queried_at: null,
|
|
542
|
+
synthetic: true,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
return out;
|
|
546
|
+
},
|
|
547
|
+
|
|
548
|
+
// Prune every row whose carrier-declared `valid_until` has
|
|
549
|
+
// already passed. Operator-side housekeeping — the rate-lookup
|
|
550
|
+
// path filters expired rows out at read time, so this is a disk-
|
|
551
|
+
// reclaim hint, not a correctness gate. Returns the count pruned.
|
|
552
|
+
cleanupExpiredQuotes: async function () {
|
|
553
|
+
var now = _now();
|
|
554
|
+
var r = await query(
|
|
555
|
+
"DELETE FROM carrier_rate_quotes WHERE valid_until <= ?1",
|
|
556
|
+
[now],
|
|
557
|
+
);
|
|
558
|
+
return { pruned: Number(r.rowCount || 0) };
|
|
559
|
+
},
|
|
560
|
+
|
|
561
|
+
// Operator dashboard feed — paginated by (queried_at DESC, id
|
|
562
|
+
// DESC). Cursor is HMAC-tagged via b.pagination so an operator
|
|
563
|
+
// can't hand-craft one to skip past a hidden row. Filters by
|
|
564
|
+
// `carrier_slug` when supplied + the [from, to] queried_at
|
|
565
|
+
// window (inclusive on both ends).
|
|
566
|
+
recentQuotes: async function (listOpts) {
|
|
567
|
+
if (!listOpts || typeof listOpts !== "object") {
|
|
568
|
+
throw new TypeError("carrierRates.recentQuotes: opts object required");
|
|
569
|
+
}
|
|
570
|
+
var from = _epochMs(listOpts.from, "from");
|
|
571
|
+
var to = _epochMs(listOpts.to, "to");
|
|
572
|
+
if (to < from) {
|
|
573
|
+
throw new TypeError("carrierRates.recentQuotes: to must be >= from");
|
|
574
|
+
}
|
|
575
|
+
var limit = _limit(listOpts.limit);
|
|
576
|
+
var carrierFilter = null;
|
|
577
|
+
if (listOpts.carrier_slug != null) {
|
|
578
|
+
carrierFilter = _slug(listOpts.carrier_slug);
|
|
579
|
+
}
|
|
580
|
+
var orderKey = ["queried_at:desc", "id:desc"];
|
|
581
|
+
var cursorVals = null;
|
|
582
|
+
if (listOpts.cursor != null) {
|
|
583
|
+
if (typeof listOpts.cursor !== "string") {
|
|
584
|
+
throw new TypeError("carrierRates.recentQuotes: cursor must be an opaque string or null");
|
|
585
|
+
}
|
|
586
|
+
try {
|
|
587
|
+
var state = _b().pagination.decodeCursor(listOpts.cursor, cursorSecret);
|
|
588
|
+
if (JSON.stringify(state.orderKey) !== JSON.stringify(orderKey)) {
|
|
589
|
+
throw new TypeError("carrierRates.recentQuotes: cursor orderKey mismatch");
|
|
590
|
+
}
|
|
591
|
+
cursorVals = state.vals;
|
|
592
|
+
} catch (e) {
|
|
593
|
+
if (e instanceof TypeError) throw e;
|
|
594
|
+
throw new TypeError("carrierRates.recentQuotes: cursor — " + (e && e.message || "malformed"));
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
var where = ["queried_at >= ?1", "queried_at <= ?2"];
|
|
599
|
+
var params = [from, to];
|
|
600
|
+
var idx = 3;
|
|
601
|
+
if (carrierFilter) {
|
|
602
|
+
where.push("carrier_slug = ?" + idx);
|
|
603
|
+
params.push(carrierFilter);
|
|
604
|
+
idx += 1;
|
|
605
|
+
}
|
|
606
|
+
if (cursorVals) {
|
|
607
|
+
where.push("(queried_at < ?" + idx + " OR (queried_at = ?" + idx + " AND id < ?" + (idx + 1) + "))");
|
|
608
|
+
params.push(cursorVals[0]);
|
|
609
|
+
params.push(cursorVals[1]);
|
|
610
|
+
idx += 2;
|
|
611
|
+
}
|
|
612
|
+
var sql =
|
|
613
|
+
"SELECT * FROM carrier_rate_quotes WHERE " + where.join(" AND ") +
|
|
614
|
+
" ORDER BY queried_at DESC, id DESC LIMIT ?" + idx;
|
|
615
|
+
params.push(limit);
|
|
616
|
+
|
|
617
|
+
var rows = (await query(sql, params)).rows;
|
|
618
|
+
var out = [];
|
|
619
|
+
for (var i = 0; i < rows.length; i += 1) out.push(_hydrateQuote(rows[i]));
|
|
620
|
+
var last = out[out.length - 1];
|
|
621
|
+
var next = null;
|
|
622
|
+
if (last && out.length === limit) {
|
|
623
|
+
next = _b().pagination.encodeCursor({
|
|
624
|
+
orderKey: orderKey,
|
|
625
|
+
vals: [last.queried_at, last.id],
|
|
626
|
+
forward: true,
|
|
627
|
+
}, cursorSecret);
|
|
628
|
+
}
|
|
629
|
+
return { rows: out, next_cursor: next };
|
|
630
|
+
},
|
|
631
|
+
|
|
632
|
+
// Per-carrier aggregate over a queried_at window. Counts the
|
|
633
|
+
// quotes recorded + reports min/max/avg rate_minor per currency
|
|
634
|
+
// (mixing USD + EUR into a single avg is silently lossy, so the
|
|
635
|
+
// group key includes currency). Operators use the report to spot
|
|
636
|
+
// a carrier whose rates drifted up week-over-week or a
|
|
637
|
+
// service code that's silently degenerating.
|
|
638
|
+
metricsForCarrier: async function (input) {
|
|
639
|
+
if (!input || typeof input !== "object") {
|
|
640
|
+
throw new TypeError("carrierRates.metricsForCarrier: input object required");
|
|
641
|
+
}
|
|
642
|
+
var slug = _slug(input.slug);
|
|
643
|
+
var from = _epochMs(input.from, "from");
|
|
644
|
+
var to = _epochMs(input.to, "to");
|
|
645
|
+
if (to < from) {
|
|
646
|
+
throw new TypeError("carrierRates.metricsForCarrier: to must be >= from");
|
|
647
|
+
}
|
|
648
|
+
var rows = (await query(
|
|
649
|
+
"SELECT service_code, currency, " +
|
|
650
|
+
" COUNT(*) AS quote_count, " +
|
|
651
|
+
" MIN(rate_minor) AS min_minor, " +
|
|
652
|
+
" MAX(rate_minor) AS max_minor, " +
|
|
653
|
+
" AVG(rate_minor) AS avg_minor " +
|
|
654
|
+
"FROM carrier_rate_quotes " +
|
|
655
|
+
"WHERE carrier_slug = ?1 AND queried_at >= ?2 AND queried_at <= ?3 " +
|
|
656
|
+
"GROUP BY service_code, currency " +
|
|
657
|
+
"ORDER BY service_code ASC, currency ASC",
|
|
658
|
+
[slug, from, to],
|
|
659
|
+
)).rows;
|
|
660
|
+
var out = [];
|
|
661
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
662
|
+
var r = rows[i];
|
|
663
|
+
out.push({
|
|
664
|
+
carrier_slug: slug,
|
|
665
|
+
service_code: r.service_code,
|
|
666
|
+
currency: r.currency,
|
|
667
|
+
quote_count: Number(r.quote_count),
|
|
668
|
+
min_minor: Number(r.min_minor),
|
|
669
|
+
max_minor: Number(r.max_minor),
|
|
670
|
+
avg_minor: Number(r.avg_minor),
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
return out;
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
module.exports = {
|
|
679
|
+
create: create,
|
|
680
|
+
CARRIERS: CARRIERS,
|
|
681
|
+
MAX_SERVICE_LEVELS: MAX_SERVICE_LEVELS,
|
|
682
|
+
MAX_RECENT_LIMIT: MAX_RECENT_LIMIT,
|
|
683
|
+
};
|