@blamejs/blamejs-shop 0.0.61 → 0.0.64

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,651 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.geolocation
4
+ * @title Geolocation primitive — country/region resolution from
5
+ * request hints + operator-managed per-country defaults
6
+ *
7
+ * @intro
8
+ * The shop never performs an in-process IP-to-country lookup. The
9
+ * operator hands the primitive a bundle of pre-resolved hints
10
+ * (typically Cloudflare's `CF-IPCountry` + `cf-region` edge headers,
11
+ * the browser's `Accept-Language` and `Intl.DateTimeFormat()
12
+ * .resolvedOptions().timeZone`, and an optional buyer-supplied
13
+ * country from a country picker) and the primitive merges them with
14
+ * the per-country settings stored in `country_settings`. The merged
15
+ * result drives currency display, locale-aware formatting, the
16
+ * checkout's payment-method + shipping-method menus, and the
17
+ * sanctions / no-coverage geo-block gate.
18
+ *
19
+ * Resolution precedence (highest -> lowest):
20
+ *
21
+ * 1. `customer_supplied_country` — wins ONLY when a row exists for
22
+ * the supplied code. Buyers who explicitly pick a country in a
23
+ * UI override the edge-resolved guess (a traveler on a US IP
24
+ * shipping to their home address in DE expects DE pricing).
25
+ * An undefined supplied country falls through to the next hint
26
+ * rather than throwing, so a stale dropdown value doesn't break
27
+ * checkout.
28
+ * 2. `cf_country` — the Cloudflare edge's IP-to-country answer.
29
+ * Required.
30
+ *
31
+ * Region: from `cf_region` when supplied. Returned only when both
32
+ * the country resolves and a region was hinted; never invented.
33
+ *
34
+ * Locale: by default the row's `default_locale`. When `accept_language`
35
+ * is supplied the resolver walks the q-sorted tag list and returns
36
+ * the first tag whose primary subtag matches the row default's
37
+ * primary subtag (e.g. an "en-US" default upgrades to "en-GB" when
38
+ * the browser prefers `en-GB,en;q=0.9`); when no tag matches the
39
+ * primary subtag the row default wins.
40
+ *
41
+ * Timezone: passthrough when the request hint is supplied (validated
42
+ * as IANA shape but not against a tzdata catalog — the framework
43
+ * does not vendor IANA data and the operator's downstream renderers
44
+ * tolerate unknown ids). When the hint is absent, the row's
45
+ * `default_timezone` is returned, which may itself be null for
46
+ * countries that straddle zones (US, RU, AU, BR, CA).
47
+ *
48
+ * Composition:
49
+ * var geo = bShop.geolocation.create({ query: q });
50
+ * await geo.defineCountry({
51
+ * code: "DE",
52
+ * currency: "EUR",
53
+ * default_locale: "de-DE",
54
+ * geo_blocked: false,
55
+ * allowed_payment_kinds: ["card", "sepa_debit", "klarna"],
56
+ * allowed_shipping_kinds: ["standard", "express"],
57
+ * default_timezone: "Europe/Berlin",
58
+ * });
59
+ * var resolved = await geo.resolve({
60
+ * cf_country: "DE",
61
+ * cf_region: "BE",
62
+ * accept_language: "de-DE,de;q=0.9,en;q=0.5",
63
+ * timezone: "Europe/Berlin",
64
+ * });
65
+ * // -> { country: "DE", region: "BE", currency: "EUR",
66
+ * // locale: "de-DE", timezone: "Europe/Berlin",
67
+ * // geo_blocked: false,
68
+ * // allowed_payment_kinds: ["card", "sepa_debit", "klarna"],
69
+ * // allowed_shipping_kinds: ["standard", "express"] }
70
+ *
71
+ * Storage:
72
+ * - `country_settings` (migration `0092_geolocation.sql`).
73
+ *
74
+ * @primitive geolocation
75
+ */
76
+
77
+ var bShop;
78
+ function _b() {
79
+ if (!bShop) bShop = require("./index");
80
+ return bShop.framework;
81
+ }
82
+
83
+ // ISO 3166-1 alpha-2 — exactly two uppercase letters. The schema
84
+ // CHECK enforces the same shape; the regex is the strict gate at the
85
+ // app tier so a typo throws at the call site rather than producing a
86
+ // SQLITE_CONSTRAINT error one stack frame deeper.
87
+ var COUNTRY_CODE_RE = /^[A-Z]{2}$/;
88
+
89
+ // ISO 4217 — three uppercase letters. Same posture as country code.
90
+ var CURRENCY_RE = /^[A-Z]{3}$/;
91
+
92
+ // BCP-47 locale — primary language subtag (2-3 letters) optionally
93
+ // followed by region / script / variant subtags joined by `-`. We
94
+ // don't try to round-trip the full RFC 5646 grammar — practical
95
+ // storefront locales fit the `lang(-Region)?` envelope, with the
96
+ // schema CHECK bounding total length at 35 characters (the IETF
97
+ // language-tag practical max).
98
+ var LOCALE_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8})*$/;
99
+
100
+ // Region: ISO 3166-2 subdivision code without the country prefix —
101
+ // what Cloudflare's `cf-region` header returns (e.g. "CA" for
102
+ // California, "BE" for Berlin, "ENG" for England). 1-3 uppercase
103
+ // alphanumerics covers the practical world.
104
+ var REGION_RE = /^[A-Z0-9]{1,3}$/;
105
+
106
+ // IANA timezone id — `Area/Location` (with optional sub-location for
107
+ // Indianapolis-style entries). Validated for shape only — we don't
108
+ // vendor tzdata; the operator's downstream renderers tolerate
109
+ // unknown ids.
110
+ var TIMEZONE_RE = /^[A-Z][A-Za-z_+\-]{0,32}(?:\/[A-Za-z0-9_+\-]{1,32}){1,2}$/;
111
+
112
+ // Kind strings (operator-defined payment + shipping menu vocabulary).
113
+ // Lowercase alphanumeric + underscore + dash, 1-32 chars. Bounded so
114
+ // "<script>" / control bytes / huge payloads can't slip in via a UI
115
+ // that wasn't validating its checkout config.
116
+ var KIND_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
117
+ var KIND_MAX_COUNT = 32;
118
+
119
+ var REASON_MAX_LEN = 512;
120
+
121
+ // Patch-update vocabulary. `code` is the primary key — to "rename" a
122
+ // country, define the new code and delete (or block) the old one.
123
+ var ALLOWED_UPDATE_COLUMNS = Object.freeze([
124
+ "currency",
125
+ "default_locale",
126
+ "allowed_payment_kinds",
127
+ "allowed_shipping_kinds",
128
+ "default_timezone",
129
+ // geo_blocked + geo_block_reason flow through setGeoBlock so the
130
+ // two columns can never drift out of sync (blocked=1 without a
131
+ // reason / blocked=0 with a stale reason).
132
+ ]);
133
+
134
+ // ---- validators ---------------------------------------------------------
135
+
136
+ function _countryCode(s, label) {
137
+ label = label || "code";
138
+ if (typeof s !== "string" || !COUNTRY_CODE_RE.test(s)) {
139
+ throw new TypeError(
140
+ "geolocation: " + label + " must be an ISO 3166-1 alpha-2 country code " +
141
+ "(two uppercase letters), got " + JSON.stringify(s)
142
+ );
143
+ }
144
+ return s;
145
+ }
146
+
147
+ function _currency(s) {
148
+ if (typeof s !== "string" || !CURRENCY_RE.test(s)) {
149
+ throw new TypeError(
150
+ "geolocation: currency must be an ISO 4217 three-letter code " +
151
+ "(uppercase), got " + JSON.stringify(s)
152
+ );
153
+ }
154
+ return s;
155
+ }
156
+
157
+ function _locale(s, label) {
158
+ label = label || "default_locale";
159
+ if (typeof s !== "string" || !LOCALE_RE.test(s) || s.length > 35) {
160
+ throw new TypeError(
161
+ "geolocation: " + label + " must be a BCP-47 language tag " +
162
+ "(e.g. 'en-US', 'de-DE'), got " + JSON.stringify(s)
163
+ );
164
+ }
165
+ return s;
166
+ }
167
+
168
+ function _region(s) {
169
+ if (typeof s !== "string" || !REGION_RE.test(s)) {
170
+ throw new TypeError(
171
+ "geolocation: region must be 1-3 uppercase alphanumerics " +
172
+ "(ISO 3166-2 subdivision without country prefix), got " +
173
+ JSON.stringify(s)
174
+ );
175
+ }
176
+ return s;
177
+ }
178
+
179
+ function _timezone(s, label) {
180
+ label = label || "timezone";
181
+ if (typeof s !== "string" || !TIMEZONE_RE.test(s)) {
182
+ throw new TypeError(
183
+ "geolocation: " + label + " must be an IANA timezone id " +
184
+ "(e.g. 'Europe/Berlin', 'America/Los_Angeles'), got " +
185
+ JSON.stringify(s)
186
+ );
187
+ }
188
+ return s;
189
+ }
190
+
191
+ function _kindList(arr, label) {
192
+ if (!Array.isArray(arr)) {
193
+ throw new TypeError("geolocation: " + label + " must be an array of strings");
194
+ }
195
+ if (arr.length > KIND_MAX_COUNT) {
196
+ throw new TypeError(
197
+ "geolocation: " + label + " must contain at most " + KIND_MAX_COUNT +
198
+ " entries, got " + arr.length
199
+ );
200
+ }
201
+ var seen = {};
202
+ var out = [];
203
+ for (var i = 0; i < arr.length; i += 1) {
204
+ var k = arr[i];
205
+ if (typeof k !== "string" || !KIND_RE.test(k)) {
206
+ throw new TypeError(
207
+ "geolocation: " + label + "[" + i + "] must match " +
208
+ "/^[a-z0-9][a-z0-9_-]{0,31}$/, got " + JSON.stringify(k)
209
+ );
210
+ }
211
+ if (seen[k]) {
212
+ throw new TypeError(
213
+ "geolocation: " + label + " must not contain duplicates, got " +
214
+ JSON.stringify(k) + " twice"
215
+ );
216
+ }
217
+ seen[k] = true;
218
+ out.push(k);
219
+ }
220
+ return out;
221
+ }
222
+
223
+ function _reason(s) {
224
+ if (s == null) return null;
225
+ if (typeof s !== "string") {
226
+ throw new TypeError("geolocation: reason must be a string or null");
227
+ }
228
+ if (!s.length) return null;
229
+ if (s.length > REASON_MAX_LEN) {
230
+ throw new TypeError(
231
+ "geolocation: reason must be <= " + REASON_MAX_LEN + " characters"
232
+ );
233
+ }
234
+ // Refuse control bytes — operator-display field, gets rendered
235
+ // verbatim in dashboards + audit log lines; embedded newlines are
236
+ // a log-injection vector.
237
+ if (/[\x00-\x1F\x7F]/.test(s)) {
238
+ throw new TypeError("geolocation: reason must not contain control bytes");
239
+ }
240
+ return s;
241
+ }
242
+
243
+ function _bool(v, label) {
244
+ if (typeof v !== "boolean") {
245
+ throw new TypeError("geolocation: " + label + " must be a boolean");
246
+ }
247
+ return v;
248
+ }
249
+
250
+ function _now() { return Date.now(); }
251
+
252
+ // ---- accept-language parsing -------------------------------------------
253
+
254
+ // Parse an RFC 7231 `Accept-Language` header into a list of
255
+ // `{ tag, q }` entries sorted by q descending (stable on ties — first
256
+ // occurrence wins). Garbage entries are silently dropped; the worst
257
+ // case is a header whose every entry is malformed, which falls
258
+ // through to the country's default_locale.
259
+ function _parseAcceptLanguage(raw) {
260
+ if (typeof raw !== "string" || !raw.length) return [];
261
+ var entries = raw.split(",");
262
+ var parsed = [];
263
+ for (var i = 0; i < entries.length; i += 1) {
264
+ var part = entries[i].trim();
265
+ if (!part) continue;
266
+ var semi = part.indexOf(";");
267
+ var tag = (semi === -1 ? part : part.slice(0, semi)).trim();
268
+ var q = 1.0;
269
+ if (semi !== -1) {
270
+ var attrs = part.slice(semi + 1).split(";");
271
+ for (var j = 0; j < attrs.length; j += 1) {
272
+ var a = attrs[j].trim();
273
+ if (a.slice(0, 2).toLowerCase() === "q=") {
274
+ var n = parseFloat(a.slice(2));
275
+ if (isFinite(n) && n >= 0 && n <= 1) q = n;
276
+ }
277
+ }
278
+ }
279
+ if (tag === "*") continue;
280
+ if (!LOCALE_RE.test(tag)) continue;
281
+ parsed.push({ tag: tag, q: q, order: i });
282
+ }
283
+ // Stable q-desc sort (preserve original order on ties).
284
+ parsed.sort(function (a, b) {
285
+ if (a.q !== b.q) return b.q - a.q;
286
+ return a.order - b.order;
287
+ });
288
+ return parsed;
289
+ }
290
+
291
+ function _primarySubtag(tag) {
292
+ var dash = tag.indexOf("-");
293
+ return (dash === -1 ? tag : tag.slice(0, dash)).toLowerCase();
294
+ }
295
+
296
+ // Choose the locale the storefront should render in. The row's
297
+ // `default_locale` is the baseline; if the request's accept-language
298
+ // list contains a tag whose primary subtag matches the row default's
299
+ // primary subtag, that tag wins (preserving region preferences — a
300
+ // `de-DE` default upgrades to `de-AT` when the browser asks for it).
301
+ // When no tag matches, the row default wins.
302
+ function _pickLocale(rowDefault, acceptLanguage) {
303
+ if (!acceptLanguage) return rowDefault;
304
+ var entries = _parseAcceptLanguage(acceptLanguage);
305
+ if (!entries.length) return rowDefault;
306
+ var defaultPrimary = _primarySubtag(rowDefault);
307
+ for (var i = 0; i < entries.length; i += 1) {
308
+ if (_primarySubtag(entries[i].tag) === defaultPrimary) {
309
+ return entries[i].tag;
310
+ }
311
+ }
312
+ return rowDefault;
313
+ }
314
+
315
+ // ---- row <-> wire conversions ------------------------------------------
316
+
317
+ function _parseKindsJson(raw, label) {
318
+ if (raw == null) return [];
319
+ if (typeof raw !== "string") return [];
320
+ var parsed;
321
+ try { parsed = JSON.parse(raw); }
322
+ catch (_e) {
323
+ // A row whose JSON column is malformed is a storage-corruption
324
+ // signal — surface it as a clear app-tier error rather than a
325
+ // silent empty list (which would let a broken row look like an
326
+ // intentionally empty menu).
327
+ throw new Error(
328
+ "geolocation: " + label + " column is malformed JSON — storage corruption"
329
+ );
330
+ }
331
+ if (!Array.isArray(parsed)) {
332
+ throw new Error(
333
+ "geolocation: " + label + " column must be a JSON array — storage corruption"
334
+ );
335
+ }
336
+ return parsed;
337
+ }
338
+
339
+ function _rowToCountry(row) {
340
+ if (!row) return null;
341
+ return {
342
+ code: row.code,
343
+ currency: row.currency,
344
+ default_locale: row.default_locale,
345
+ geo_blocked: Number(row.geo_blocked) === 1,
346
+ geo_block_reason: row.geo_block_reason == null ? null : row.geo_block_reason,
347
+ allowed_payment_kinds: _parseKindsJson(row.allowed_payment_kinds_json, "allowed_payment_kinds_json"),
348
+ allowed_shipping_kinds: _parseKindsJson(row.allowed_shipping_kinds_json, "allowed_shipping_kinds_json"),
349
+ default_timezone: row.default_timezone == null ? null : row.default_timezone,
350
+ created_at: Number(row.created_at),
351
+ updated_at: Number(row.updated_at),
352
+ };
353
+ }
354
+
355
+ // ---- factory -----------------------------------------------------------
356
+
357
+ function create(opts) {
358
+ opts = opts || {};
359
+ var query = opts.query;
360
+ if (!query) {
361
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
362
+ }
363
+
364
+ async function _getRow(code) {
365
+ var r = await query(
366
+ "SELECT * FROM country_settings WHERE code = ?1",
367
+ [code],
368
+ );
369
+ return r.rows[0] || null;
370
+ }
371
+
372
+ return {
373
+ COUNTRY_CODE_RE: COUNTRY_CODE_RE,
374
+ CURRENCY_RE: CURRENCY_RE,
375
+ LOCALE_RE: LOCALE_RE,
376
+ REGION_RE: REGION_RE,
377
+ KIND_MAX_COUNT: KIND_MAX_COUNT,
378
+
379
+ // Write one country's settings. Refuses if a row already exists
380
+ // for the code — operators mutating an existing country go
381
+ // through `updateCountry` so the patch surface is the single
382
+ // mutation entry point (and `code` stays immutable). Set
383
+ // geo_block via the dedicated `setGeoBlock` call to keep
384
+ // blocked + reason in sync.
385
+ defineCountry: async function (input) {
386
+ if (!input || typeof input !== "object") {
387
+ throw new TypeError("geolocation.defineCountry: input object required");
388
+ }
389
+ var code = _countryCode(input.code, "code");
390
+ var currency = _currency(input.currency);
391
+ var defaultLocale = _locale(input.default_locale);
392
+ var geoBlocked = _bool(input.geo_blocked, "geo_blocked");
393
+ var paymentKinds = _kindList(input.allowed_payment_kinds, "allowed_payment_kinds");
394
+ var shippingKinds = _kindList(input.allowed_shipping_kinds, "allowed_shipping_kinds");
395
+ var defaultTz = input.default_timezone == null ? null
396
+ : _timezone(input.default_timezone, "default_timezone");
397
+ var reason = input.geo_block_reason == null ? null : _reason(input.geo_block_reason);
398
+
399
+ // geo_block_reason without geo_blocked=true is a misconfig —
400
+ // operators write the reason at the same moment they flip the
401
+ // block. Cleared the other way (blocked without a reason) is
402
+ // allowed: some operators block-by-default without a published
403
+ // rationale and surface a UI placeholder.
404
+ if (reason != null && !geoBlocked) {
405
+ throw new TypeError(
406
+ "geolocation.defineCountry: geo_block_reason requires geo_blocked = true"
407
+ );
408
+ }
409
+
410
+ var existing = await _getRow(code);
411
+ if (existing) {
412
+ var dupe = new Error(
413
+ "geolocation.defineCountry: row already exists for " + code +
414
+ " — use updateCountry / setGeoBlock to mutate"
415
+ );
416
+ dupe.code = "GEO_COUNTRY_EXISTS";
417
+ throw dupe;
418
+ }
419
+
420
+ var ts = _now();
421
+ await query(
422
+ "INSERT INTO country_settings " +
423
+ "(code, currency, default_locale, geo_blocked, geo_block_reason, " +
424
+ " allowed_payment_kinds_json, allowed_shipping_kinds_json, " +
425
+ " default_timezone, created_at, updated_at) " +
426
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)",
427
+ [
428
+ code, currency, defaultLocale, geoBlocked ? 1 : 0, reason,
429
+ JSON.stringify(paymentKinds), JSON.stringify(shippingKinds),
430
+ defaultTz, ts,
431
+ ],
432
+ );
433
+ return _rowToCountry(await _getRow(code));
434
+ },
435
+
436
+ getCountry: async function (code) {
437
+ var c = _countryCode(code, "code");
438
+ return _rowToCountry(await _getRow(c));
439
+ },
440
+
441
+ // List every defined country, optionally filtered by block state.
442
+ listCountries: async function (input) {
443
+ input = input || {};
444
+ var sql, params;
445
+ if (Object.prototype.hasOwnProperty.call(input, "geo_blocked")) {
446
+ var flag = _bool(input.geo_blocked, "geo_blocked");
447
+ sql = "SELECT * FROM country_settings WHERE geo_blocked = ?1 " +
448
+ "ORDER BY code ASC";
449
+ params = [flag ? 1 : 0];
450
+ } else {
451
+ sql = "SELECT * FROM country_settings ORDER BY code ASC";
452
+ params = [];
453
+ }
454
+ var r = await query(sql, params);
455
+ var out = [];
456
+ for (var i = 0; i < r.rows.length; i += 1) {
457
+ out.push(_rowToCountry(r.rows[i]));
458
+ }
459
+ return out;
460
+ },
461
+
462
+ // Patch-style update for the columns operators routinely tune.
463
+ // `code` is the primary key (immutable post-creation); `geo_blocked`
464
+ // + `geo_block_reason` flow through setGeoBlock so the two
465
+ // columns can never drift out of sync.
466
+ updateCountry: async function (code, patch) {
467
+ var c = _countryCode(code, "code");
468
+ if (!patch || typeof patch !== "object") {
469
+ throw new TypeError("geolocation.updateCountry: patch object required");
470
+ }
471
+ var keys = Object.keys(patch);
472
+ if (!keys.length) {
473
+ throw new TypeError("geolocation.updateCountry: patch must contain at least one column");
474
+ }
475
+ for (var i = 0; i < keys.length; i += 1) {
476
+ if (ALLOWED_UPDATE_COLUMNS.indexOf(keys[i]) === -1) {
477
+ throw new TypeError(
478
+ "geolocation.updateCountry: column '" + keys[i] + "' not updatable " +
479
+ "(use setGeoBlock for the geo_blocked / geo_block_reason pair)"
480
+ );
481
+ }
482
+ }
483
+ var current = await _getRow(c);
484
+ if (!current) return null;
485
+
486
+ var sets = [];
487
+ var params = [];
488
+ var idx = 1;
489
+ function _set(col, val) {
490
+ sets.push(col + " = ?" + idx);
491
+ params.push(val);
492
+ idx += 1;
493
+ }
494
+ if (patch.currency != null) _set("currency", _currency(patch.currency));
495
+ if (patch.default_locale != null) _set("default_locale", _locale(patch.default_locale));
496
+ if (patch.allowed_payment_kinds != null) {
497
+ _set("allowed_payment_kinds_json",
498
+ JSON.stringify(_kindList(patch.allowed_payment_kinds, "allowed_payment_kinds")));
499
+ }
500
+ if (patch.allowed_shipping_kinds != null) {
501
+ _set("allowed_shipping_kinds_json",
502
+ JSON.stringify(_kindList(patch.allowed_shipping_kinds, "allowed_shipping_kinds")));
503
+ }
504
+ if (Object.prototype.hasOwnProperty.call(patch, "default_timezone")) {
505
+ var tz = patch.default_timezone == null
506
+ ? null
507
+ : _timezone(patch.default_timezone, "default_timezone");
508
+ _set("default_timezone", tz);
509
+ }
510
+
511
+ var ts = _now();
512
+ _set("updated_at", ts);
513
+ params.push(c);
514
+ var sql = "UPDATE country_settings SET " + sets.join(", ") +
515
+ " WHERE code = ?" + idx;
516
+ await query(sql, params);
517
+ return _rowToCountry(await _getRow(c));
518
+ },
519
+
520
+ // Flip the geo-block state for one country. blocked=true requires
521
+ // / accepts an operator-facing `reason`; blocked=false clears
522
+ // any prior reason so the dashboard doesn't show stale text
523
+ // alongside an unblocked country.
524
+ setGeoBlock: async function (input) {
525
+ if (!input || typeof input !== "object") {
526
+ throw new TypeError("geolocation.setGeoBlock: input object required");
527
+ }
528
+ var code = _countryCode(input.country_code, "country_code");
529
+ var blocked = _bool(input.blocked, "blocked");
530
+ var reason = input.reason == null ? null : _reason(input.reason);
531
+
532
+ if (!blocked && reason != null) {
533
+ throw new TypeError(
534
+ "geolocation.setGeoBlock: reason is meaningless when blocked=false"
535
+ );
536
+ }
537
+
538
+ var current = await _getRow(code);
539
+ if (!current) return null;
540
+
541
+ var ts = _now();
542
+ // blocked=false clears any prior reason so the row never carries
543
+ // a justification for a block it doesn't have.
544
+ var nextReason = blocked ? reason : null;
545
+ await query(
546
+ "UPDATE country_settings " +
547
+ "SET geo_blocked = ?1, geo_block_reason = ?2, updated_at = ?3 " +
548
+ "WHERE code = ?4",
549
+ [blocked ? 1 : 0, nextReason, ts, code],
550
+ );
551
+ return _rowToCountry(await _getRow(code));
552
+ },
553
+
554
+ // Convenience read — every country currently sitting on the
555
+ // block list. Sorted by code so the dashboard renders
556
+ // deterministically.
557
+ blockedRoster: async function () {
558
+ var r = await query(
559
+ "SELECT * FROM country_settings WHERE geo_blocked = 1 ORDER BY code ASC",
560
+ [],
561
+ );
562
+ var out = [];
563
+ for (var i = 0; i < r.rows.length; i += 1) {
564
+ out.push(_rowToCountry(r.rows[i]));
565
+ }
566
+ return out;
567
+ },
568
+
569
+ // Resolve a request's hints into a country / region / currency /
570
+ // locale / timezone / kinds tuple. The buyer-supplied override
571
+ // wins ONLY when the supplied country has a row; a stale dropdown
572
+ // value falls through to the edge hint rather than throwing.
573
+ //
574
+ // Returns null when the resolved country has no row — checkout
575
+ // refuses the order in that case (the operator hasn't declared
576
+ // settings for the buyer's country yet).
577
+ resolve: async function (input) {
578
+ if (!input || typeof input !== "object") {
579
+ throw new TypeError("geolocation.resolve: input object required");
580
+ }
581
+ var cfCountry = _countryCode(input.cf_country, "cf_country");
582
+ var cfRegion = input.cf_region == null ? null : _region(input.cf_region);
583
+ var acceptLanguage = null;
584
+ if (input.accept_language != null) {
585
+ if (typeof input.accept_language !== "string") {
586
+ throw new TypeError("geolocation.resolve: accept_language must be a string");
587
+ }
588
+ acceptLanguage = input.accept_language;
589
+ }
590
+ var requestTz = null;
591
+ if (input.timezone != null) {
592
+ requestTz = _timezone(input.timezone, "timezone");
593
+ }
594
+ var supplied = null;
595
+ if (input.customer_supplied_country != null) {
596
+ supplied = _countryCode(input.customer_supplied_country, "customer_supplied_country");
597
+ }
598
+
599
+ // Buyer-supplied wins when a row exists; falls through to
600
+ // cf_country otherwise.
601
+ var row = null;
602
+ if (supplied) {
603
+ row = await _getRow(supplied);
604
+ }
605
+ if (!row) {
606
+ row = await _getRow(cfCountry);
607
+ }
608
+ if (!row) {
609
+ // No settings for the resolved country — the operator hasn't
610
+ // declared this market yet. Return null so the caller can
611
+ // surface a "we don't ship to your country" notice rather
612
+ // than rendering a half-resolved checkout.
613
+ return null;
614
+ }
615
+
616
+ var country = _rowToCountry(row);
617
+ var locale = _pickLocale(country.default_locale, acceptLanguage);
618
+ var timezone = requestTz != null ? requestTz : country.default_timezone;
619
+
620
+ var out = {
621
+ country: country.code,
622
+ currency: country.currency,
623
+ locale: locale,
624
+ geo_blocked: country.geo_blocked,
625
+ allowed_payment_kinds: country.allowed_payment_kinds,
626
+ allowed_shipping_kinds: country.allowed_shipping_kinds,
627
+ };
628
+ // Region is only returned when the cf_region hint was supplied
629
+ // AND the resolved country matches the cf_country (a buyer-
630
+ // supplied override discards the cf_region — the region is
631
+ // meaningful only when the country it sits inside is the same
632
+ // country the region was reported for).
633
+ if (cfRegion != null && country.code === cfCountry) {
634
+ out.region = cfRegion;
635
+ }
636
+ if (timezone != null) {
637
+ out.timezone = timezone;
638
+ }
639
+ return out;
640
+ },
641
+ };
642
+ }
643
+
644
+ module.exports = {
645
+ create: create,
646
+ COUNTRY_CODE_RE: COUNTRY_CODE_RE,
647
+ CURRENCY_RE: CURRENCY_RE,
648
+ LOCALE_RE: LOCALE_RE,
649
+ REGION_RE: REGION_RE,
650
+ KIND_MAX_COUNT: KIND_MAX_COUNT,
651
+ };