@blamejs/blamejs-shop 0.0.61 → 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 +2 -0
- package/lib/email-campaigns.js +844 -0
- package/lib/geolocation.js +651 -0
- package/lib/gift-registry.js +820 -0
- package/lib/index.js +10 -0
- package/lib/loyalty-redemption.js +673 -0
- package/lib/plan-changes.js +508 -0
- package/lib/refund-policy.js +965 -0
- package/lib/sms-dispatcher.js +7 -1
- package/lib/stock-transfers.js +777 -0
- package/lib/storefront-dashboards.js +863 -0
- package/lib/vendors.js +797 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|