@blamejs/blamejs-shop 0.0.56 → 0.0.57
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/backorder.js +452 -0
- package/lib/bundles.js +587 -0
- package/lib/fraud-screen.js +808 -0
- package/lib/index.js +10 -0
- package/lib/inventory-locations.js +774 -0
- package/lib/order-export.js +724 -0
- package/lib/order-notes.js +563 -0
- package/lib/payment-methods.js +522 -0
- package/lib/print-on-demand.js +709 -0
- package/lib/save-for-later.js +667 -0
- package/lib/variants.js +726 -0
- package/package.json +1 -1
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.fraudScreen
|
|
4
|
+
* @title Fraud screening — heuristic + ledger-based pre-payment gate
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* `fraudScreen` evaluates a checkout draft BEFORE the payment
|
|
8
|
+
* intent is created and emits a `{ score, decision, signals }`
|
|
9
|
+
* verdict so the orchestrator either proceeds, requests step-up
|
|
10
|
+
* (3-D Secure), or refuses. The primitive itself never makes the
|
|
11
|
+
* irrevocable refuse call — that's the operator-policy layer
|
|
12
|
+
* wrapping the checkout flow; this module only emits the signal
|
|
13
|
+
* plus a per-signal breakdown the dashboard can render.
|
|
14
|
+
*
|
|
15
|
+
* Signals (each contributes a positive weight when fired):
|
|
16
|
+
*
|
|
17
|
+
* velocity — >N orders/24h from same email_hash
|
|
18
|
+
* high-value-new-customer — large total + prior_orders_count = 0
|
|
19
|
+
* address-mismatch — shipping country != billing country
|
|
20
|
+
* free-email-domain — gmail / yahoo / outlook / ... (mild)
|
|
21
|
+
* disposable-email-domain — known temp-mail provider (severe)
|
|
22
|
+
* session-too-fast — session_age_seconds < 15
|
|
23
|
+
* session-too-old — session_age_seconds > 86400 (24h)
|
|
24
|
+
* ua-curl-class — curl / wget / python-requests etc.
|
|
25
|
+
* large-line-count — line_count >= LARGE_LINE_COUNT
|
|
26
|
+
* mismatched-bin-country — operator-supplied BIN->country map
|
|
27
|
+
* disagrees with the shipping country
|
|
28
|
+
* prior-chargeback — chargebacks ledger has a row for
|
|
29
|
+
* the same email_hash
|
|
30
|
+
* suppressed-email — emailSuppressions (if injected)
|
|
31
|
+
* returns true for this address
|
|
32
|
+
* manually-flagged — fraud_email_flags row exists for
|
|
33
|
+
* the email_hash (forces refuse)
|
|
34
|
+
*
|
|
35
|
+
* Thresholds (closed intervals):
|
|
36
|
+
*
|
|
37
|
+
* 0 .. 39 approve
|
|
38
|
+
* 40 .. 69 review
|
|
39
|
+
* 70 .. 89 step_up
|
|
40
|
+
* 90 ..100 refuse
|
|
41
|
+
*
|
|
42
|
+
* The disposable-email and manually-flagged signals push the
|
|
43
|
+
* score past 90 by themselves so the decision lands on `refuse`
|
|
44
|
+
* without needing a second signal to corroborate.
|
|
45
|
+
*
|
|
46
|
+
* Composition surface:
|
|
47
|
+
*
|
|
48
|
+
* - `b.crypto.namespaceHash("fraud-email", email)` — every
|
|
49
|
+
* email written to any of the three ledger tables is hashed
|
|
50
|
+
* through this namespace first, so a stolen D1 dump leaks
|
|
51
|
+
* no addresses.
|
|
52
|
+
* - `b.guardEmail.validate` / `.sanitize` — refuses shape-broken
|
|
53
|
+
* or header-injection-class addresses at every entry point.
|
|
54
|
+
* - `b.guardUuid.sanitize` — every UUID-shaped input
|
|
55
|
+
* (customer_id, order_id) is profile-strict validated.
|
|
56
|
+
* - `b.uuid.v7` — ledger row ids.
|
|
57
|
+
* - `b.pagination.encodeCursor` / `decodeCursor` — HMAC-tagged
|
|
58
|
+
* opaque cursor for `recentScreenings()` so an operator
|
|
59
|
+
* can't hand-craft one to skip past a hidden row or replay
|
|
60
|
+
* it across deployments.
|
|
61
|
+
*
|
|
62
|
+
* Operator-injectable dependencies (all optional):
|
|
63
|
+
*
|
|
64
|
+
* - `query` — D1-shaped async query function
|
|
65
|
+
* - `customers` — for customer_id -> email_hash lookup
|
|
66
|
+
* - `addresses` — address-lookup primitive (reserved
|
|
67
|
+
* for future BIN/AVS integration)
|
|
68
|
+
* - `paymentMethods` — payment-method lookup (reserved for
|
|
69
|
+
* future mismatched-BIN signal pulling
|
|
70
|
+
* from a saved instrument's metadata)
|
|
71
|
+
* - `emailSuppressions` — `.isSuppressed(emailHash)` boolean —
|
|
72
|
+
* a hard-bounce / complaint signal
|
|
73
|
+
* pulled from the email pipeline; when
|
|
74
|
+
* present, fires the suppressed-email
|
|
75
|
+
* signal.
|
|
76
|
+
* - `binCountryMap` — `{ [bin6]: countryCode }` operator-
|
|
77
|
+
* supplied; used by the mismatched-bin-
|
|
78
|
+
* country signal when the order draft
|
|
79
|
+
* carries a `bin6` field.
|
|
80
|
+
* - `cursorSecret` — HMAC secret for recentScreenings'
|
|
81
|
+
* cursor (required in production).
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
var bShop;
|
|
85
|
+
function _b() {
|
|
86
|
+
if (!bShop) {
|
|
87
|
+
try {
|
|
88
|
+
bShop = require("./index");
|
|
89
|
+
} catch (_e) {
|
|
90
|
+
// Fallback path — the lib/index.js registry mutation is the
|
|
91
|
+
// operator's responsibility (every primitive adds itself when
|
|
92
|
+
// wired). While the registry edit is pending (a fresh checkout
|
|
93
|
+
// mid-merge, a partially-applied patch), this primitive still
|
|
94
|
+
// boots by composing directly against the vendored framework.
|
|
95
|
+
// The framework surface this primitive consumes (crypto,
|
|
96
|
+
// guardEmail, guardUuid, uuid, pagination) is stable; no shop-
|
|
97
|
+
// level peer is touched through `_b()`, so the bypass is
|
|
98
|
+
// semantically equivalent.
|
|
99
|
+
bShop = { framework: require("./vendor/blamejs") };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return bShop.framework;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- constants ----------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
var EMAIL_NAMESPACE = "fraud-email";
|
|
108
|
+
|
|
109
|
+
// Score thresholds — closed intervals on both ends; the boundary
|
|
110
|
+
// row is exposed via FraudScreen.THRESHOLDS so the test suite can
|
|
111
|
+
// assert against the same values the runtime uses.
|
|
112
|
+
var THRESHOLDS = Object.freeze({
|
|
113
|
+
APPROVE_MAX: 39,
|
|
114
|
+
REVIEW_MAX: 69,
|
|
115
|
+
STEP_UP_MAX: 89,
|
|
116
|
+
// 90..100 → refuse
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Weights — calibrated so the corroborated-multi-signal case
|
|
120
|
+
// (e.g. velocity + address-mismatch + free-email-domain) lands
|
|
121
|
+
// on review or step_up, while a single severe signal (disposable
|
|
122
|
+
// domain, manual flag, chargeback) lands on refuse outright.
|
|
123
|
+
var WEIGHTS = Object.freeze({
|
|
124
|
+
VELOCITY: 35,
|
|
125
|
+
HIGH_VALUE_NEW_CUSTOMER: 25,
|
|
126
|
+
ADDRESS_MISMATCH: 25,
|
|
127
|
+
FREE_EMAIL_DOMAIN: 10,
|
|
128
|
+
DISPOSABLE_EMAIL_DOMAIN: 100,
|
|
129
|
+
SESSION_TOO_FAST: 20,
|
|
130
|
+
SESSION_TOO_OLD: 10,
|
|
131
|
+
UA_CURL_CLASS: 30,
|
|
132
|
+
LARGE_LINE_COUNT: 15,
|
|
133
|
+
MISMATCHED_BIN_COUNTRY: 30,
|
|
134
|
+
PRIOR_CHARGEBACK: 60,
|
|
135
|
+
SUPPRESSED_EMAIL: 40,
|
|
136
|
+
MANUALLY_FLAGGED: 100,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Thresholds for the heuristic dials.
|
|
140
|
+
var VELOCITY_WINDOW_MS = 24 * 60 * 60 * 1000; // 24h lookback
|
|
141
|
+
var VELOCITY_MAX_OK = 3; // > N triggers
|
|
142
|
+
var HIGH_VALUE_NEW_THRESH = 50000; // 500.00 minor units
|
|
143
|
+
var SESSION_FAST_MAX_SEC = 15;
|
|
144
|
+
var SESSION_OLD_MAX_SEC = 24 * 60 * 60;
|
|
145
|
+
var LARGE_LINE_COUNT_THRESH = 25;
|
|
146
|
+
var SCORE_MAX = 100;
|
|
147
|
+
|
|
148
|
+
// Decision thresholds map to {approve, review, step_up, refuse}.
|
|
149
|
+
function _decisionFor(score) {
|
|
150
|
+
if (score <= THRESHOLDS.APPROVE_MAX) return "approve";
|
|
151
|
+
if (score <= THRESHOLDS.REVIEW_MAX) return "review";
|
|
152
|
+
if (score <= THRESHOLDS.STEP_UP_MAX) return "step_up";
|
|
153
|
+
return "refuse";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Disposable email-provider tail — about twenty of the most
|
|
157
|
+
// commonly abused temp-mail domains. The list is small on purpose:
|
|
158
|
+
// a single match is the entire signal weight, so a stale entry
|
|
159
|
+
// here is a higher-cost mistake than a missing entry. Operators
|
|
160
|
+
// who track a larger catalog should extend via the
|
|
161
|
+
// `disposableDomains` factory option.
|
|
162
|
+
var DEFAULT_DISPOSABLE_DOMAINS = Object.freeze([
|
|
163
|
+
"mailinator.com", "guerrillamail.com", "guerrillamail.net",
|
|
164
|
+
"10minutemail.com", "tempmail.com", "temp-mail.org",
|
|
165
|
+
"yopmail.com", "throwawaymail.com", "trashmail.com",
|
|
166
|
+
"fakeinbox.com", "getairmail.com", "sharklasers.com",
|
|
167
|
+
"maildrop.cc", "mintemail.com", "dispostable.com",
|
|
168
|
+
"spamgourmet.com", "mailcatch.com", "tempr.email",
|
|
169
|
+
"mohmal.com", "emailondeck.com", "moakt.com",
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
// Free-email tail — large public providers. Fires a small-weight
|
|
173
|
+
// signal because most legitimate customers use one; the operator
|
|
174
|
+
// uses this in combination with other signals (velocity, address-
|
|
175
|
+
// mismatch, etc.) not as a primary refuse driver.
|
|
176
|
+
var DEFAULT_FREE_DOMAINS = Object.freeze([
|
|
177
|
+
"gmail.com", "yahoo.com", "outlook.com", "hotmail.com",
|
|
178
|
+
"aol.com", "icloud.com", "live.com", "msn.com",
|
|
179
|
+
"protonmail.com", "proton.me", "yandex.com", "yandex.ru",
|
|
180
|
+
"mail.com", "gmx.com", "gmx.net", "fastmail.com",
|
|
181
|
+
"zoho.com", "tutanota.com", "tutanota.de",
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
// Curl-class user-agent tokens. The order draft carries a
|
|
185
|
+
// pre-classified `ua_class` so this primitive doesn't have to
|
|
186
|
+
// re-parse the User-Agent header — the storefront's bot-guard
|
|
187
|
+
// has already done that. The expected enum is:
|
|
188
|
+
// "browser" | "curl" | "wget" | "python" | "go-http" | "bot" | "unknown"
|
|
189
|
+
// Anything in the curl set fires the signal.
|
|
190
|
+
var CURL_CLASS_TOKENS = Object.freeze({
|
|
191
|
+
curl: true,
|
|
192
|
+
wget: true,
|
|
193
|
+
python: true,
|
|
194
|
+
"go-http": true,
|
|
195
|
+
bot: true,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ---- validators ---------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
function _uuid(s, label) {
|
|
201
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
202
|
+
catch (e) { throw new TypeError("fraudScreen: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function _optUuid(s, label) {
|
|
206
|
+
if (s == null || s === "") return null;
|
|
207
|
+
return _uuid(s, label);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function _normalizeEmail(input) {
|
|
211
|
+
if (typeof input !== "string" || !input.length) {
|
|
212
|
+
throw new TypeError("fraudScreen: email must be a non-empty string");
|
|
213
|
+
}
|
|
214
|
+
var guardEmail = _b().guardEmail;
|
|
215
|
+
var report;
|
|
216
|
+
try { report = guardEmail.validate(input, { profile: "strict" }); }
|
|
217
|
+
catch (e) { throw new TypeError("fraudScreen: email — " + (e && e.message || "invalid email")); }
|
|
218
|
+
if (!report || report.ok === false) {
|
|
219
|
+
var first = (report && report.issues && report.issues[0]) || {};
|
|
220
|
+
throw new TypeError("fraudScreen: email — " + (first.snippet || first.ruleId || "refused at strict profile"));
|
|
221
|
+
}
|
|
222
|
+
var canonical;
|
|
223
|
+
try { canonical = guardEmail.sanitize(input, { profile: "strict" }); }
|
|
224
|
+
catch (e) { throw new TypeError("fraudScreen: email — " + (e && e.message || "refused")); }
|
|
225
|
+
// Lowercase the domain only — RFC 5321 leaves the local-part
|
|
226
|
+
// case-sensitive; matching the customers primitive's discipline
|
|
227
|
+
// here lets a `fraud_email_flags` row collide with the customer
|
|
228
|
+
// row's `email_hash` for cross-table joins.
|
|
229
|
+
var at = canonical.lastIndexOf("@");
|
|
230
|
+
if (at !== -1) {
|
|
231
|
+
canonical = canonical.slice(0, at) + "@" + canonical.slice(at + 1).toLowerCase();
|
|
232
|
+
}
|
|
233
|
+
return canonical;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function _emailDomain(canonicalEmail) {
|
|
237
|
+
var at = canonicalEmail.lastIndexOf("@");
|
|
238
|
+
if (at === -1) return "";
|
|
239
|
+
return canonicalEmail.slice(at + 1);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function _country(c, label) {
|
|
243
|
+
if (typeof c !== "string" || !/^[A-Z]{2}$/.test(c)) {
|
|
244
|
+
throw new TypeError("fraudScreen: " + label + " must be a 2-letter ISO 3166-1 country code");
|
|
245
|
+
}
|
|
246
|
+
return c;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function _addressShape(addr, label) {
|
|
250
|
+
if (!addr || typeof addr !== "object") {
|
|
251
|
+
throw new TypeError("fraudScreen: " + label + " must be an object");
|
|
252
|
+
}
|
|
253
|
+
_country(addr.country, label + ".country");
|
|
254
|
+
return addr;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function _nonNegInt(n, label) {
|
|
258
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
259
|
+
throw new TypeError("fraudScreen: " + label + " must be a non-negative integer");
|
|
260
|
+
}
|
|
261
|
+
return n;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function _positiveInt(n, label) {
|
|
265
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
266
|
+
throw new TypeError("fraudScreen: " + label + " must be a positive integer");
|
|
267
|
+
}
|
|
268
|
+
return n;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function _currency(c) {
|
|
272
|
+
if (typeof c !== "string" || !/^[A-Z]{3}$/.test(c)) {
|
|
273
|
+
throw new TypeError("fraudScreen: currency must be a 3-letter ISO 4217 code (uppercase)");
|
|
274
|
+
}
|
|
275
|
+
return c;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function _now() { return Date.now(); }
|
|
279
|
+
|
|
280
|
+
// ---- factory ------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
function create(opts) {
|
|
283
|
+
opts = opts || {};
|
|
284
|
+
var query = opts.query;
|
|
285
|
+
if (!query) {
|
|
286
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
287
|
+
}
|
|
288
|
+
// Operator-injectable peers. All optional — the primitive falls
|
|
289
|
+
// back to neutral defaults when a peer is absent. `customers` is
|
|
290
|
+
// touched only for the customer_id -> email_hash lookup on
|
|
291
|
+
// customerRiskHistory; the other peers are reserved for the
|
|
292
|
+
// forthcoming BIN/AVS integration and the email-suppression
|
|
293
|
+
// signal.
|
|
294
|
+
var customers = opts.customers || null;
|
|
295
|
+
// addresses + paymentMethods are accepted now so operators can
|
|
296
|
+
// wire them at the call site without a primitive-shape change
|
|
297
|
+
// when the cross-primitive BIN/AVS join lands; v1 only validates
|
|
298
|
+
// the dependency shape.
|
|
299
|
+
if (opts.addresses && typeof opts.addresses !== "object") throw new TypeError("fraudScreen.create: addresses must be an object");
|
|
300
|
+
if (opts.paymentMethods && typeof opts.paymentMethods !== "object") throw new TypeError("fraudScreen.create: paymentMethods must be an object");
|
|
301
|
+
var emailSuppressions = opts.emailSuppressions || null;
|
|
302
|
+
var binCountryMap = opts.binCountryMap || null;
|
|
303
|
+
|
|
304
|
+
var disposableSet = new Set((opts.disposableDomains || DEFAULT_DISPOSABLE_DOMAINS).map(function (d) {
|
|
305
|
+
return String(d).toLowerCase();
|
|
306
|
+
}));
|
|
307
|
+
var freeSet = new Set((opts.freeDomains || DEFAULT_FREE_DOMAINS).map(function (d) {
|
|
308
|
+
return String(d).toLowerCase();
|
|
309
|
+
}));
|
|
310
|
+
|
|
311
|
+
// Cursor secret for recentScreenings. Defaults to a dev-only
|
|
312
|
+
// sentinel so the primitive boots under tests; deployments are
|
|
313
|
+
// expected to supply a derived value (typically
|
|
314
|
+
// b.crypto.namespaceHash("fraud-screen-cursor", D1_BRIDGE_SECRET)).
|
|
315
|
+
if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
|
|
316
|
+
if (process.env.NODE_ENV === "production") {
|
|
317
|
+
throw new Error("fraudScreen.create: opts.cursorSecret is required in production");
|
|
318
|
+
}
|
|
319
|
+
opts.cursorSecret = "fraud-screen-cursor-secret-dev-only";
|
|
320
|
+
}
|
|
321
|
+
var cursorSecret = opts.cursorSecret;
|
|
322
|
+
|
|
323
|
+
function _hashEmail(canonicalEmail) {
|
|
324
|
+
return _b().crypto.namespaceHash(EMAIL_NAMESPACE, canonicalEmail);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---- signal evaluation -------------------------------------------------
|
|
328
|
+
|
|
329
|
+
async function _evaluate(draft) {
|
|
330
|
+
var canonicalEmail = _normalizeEmail(draft.email);
|
|
331
|
+
var emailHash = _hashEmail(canonicalEmail);
|
|
332
|
+
var domain = _emailDomain(canonicalEmail).toLowerCase();
|
|
333
|
+
var signals = [];
|
|
334
|
+
var score = 0;
|
|
335
|
+
|
|
336
|
+
function _push(name, weight, fired, detail) {
|
|
337
|
+
signals.push({
|
|
338
|
+
name: name,
|
|
339
|
+
weight: weight,
|
|
340
|
+
fired: !!fired,
|
|
341
|
+
detail: detail || null,
|
|
342
|
+
});
|
|
343
|
+
if (fired) score += weight;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// 1. velocity — count screenings for this email hash in the
|
|
347
|
+
// last 24h. The current screen() row hasn't been written
|
|
348
|
+
// yet, so the COUNT below reflects strictly prior activity.
|
|
349
|
+
var velRows = (await query(
|
|
350
|
+
"SELECT COUNT(*) AS n FROM fraud_screenings " +
|
|
351
|
+
"WHERE email_hash = ?1 AND occurred_at >= ?2",
|
|
352
|
+
[emailHash, _now() - VELOCITY_WINDOW_MS],
|
|
353
|
+
)).rows;
|
|
354
|
+
var velocityCount = Number((velRows[0] || {}).n || 0);
|
|
355
|
+
_push("velocity", WEIGHTS.VELOCITY, velocityCount > VELOCITY_MAX_OK, {
|
|
356
|
+
window_ms: VELOCITY_WINDOW_MS,
|
|
357
|
+
prior_count: velocityCount,
|
|
358
|
+
threshold: VELOCITY_MAX_OK,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// 2. high-value-new-customer — large total combined with
|
|
362
|
+
// prior_orders_count = 0 (operator-supplied; defaults to
|
|
363
|
+
// null which the signal treats as "unknown -> don't fire").
|
|
364
|
+
var prior = draft.prior_orders_count;
|
|
365
|
+
var highValueNew = (
|
|
366
|
+
typeof prior === "number" && prior === 0 &&
|
|
367
|
+
typeof draft.total_minor === "number" &&
|
|
368
|
+
draft.total_minor >= HIGH_VALUE_NEW_THRESH
|
|
369
|
+
);
|
|
370
|
+
_push("high-value-new-customer", WEIGHTS.HIGH_VALUE_NEW_CUSTOMER, highValueNew, {
|
|
371
|
+
total_minor: draft.total_minor,
|
|
372
|
+
threshold_minor: HIGH_VALUE_NEW_THRESH,
|
|
373
|
+
prior_orders_count: prior == null ? "unknown" : prior,
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// 3. address-mismatch — shipping country != billing country.
|
|
377
|
+
// When billing_address is absent (digital goods, single-
|
|
378
|
+
// address flow), the signal does not fire.
|
|
379
|
+
var shipCountry = draft.shipping_address.country;
|
|
380
|
+
var billCountry = draft.billing_address && draft.billing_address.country;
|
|
381
|
+
var mismatch = !!(billCountry && billCountry !== shipCountry);
|
|
382
|
+
_push("address-mismatch", WEIGHTS.ADDRESS_MISMATCH, mismatch, {
|
|
383
|
+
shipping_country: shipCountry,
|
|
384
|
+
billing_country: billCountry || null,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// 4. free-email-domain — soft signal; usually corroborates a
|
|
388
|
+
// primary signal rather than acting alone.
|
|
389
|
+
_push("free-email-domain", WEIGHTS.FREE_EMAIL_DOMAIN, freeSet.has(domain), {
|
|
390
|
+
domain: domain,
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// 5. disposable-email-domain — severe; weight pushes the row
|
|
394
|
+
// past the refuse threshold without corroboration.
|
|
395
|
+
_push("disposable-email-domain", WEIGHTS.DISPOSABLE_EMAIL_DOMAIN, disposableSet.has(domain), {
|
|
396
|
+
domain: domain,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// 6. session-too-fast — under-15s checkout is bot-shaped.
|
|
400
|
+
var sessAge = draft.session_age_seconds;
|
|
401
|
+
var tooFast = typeof sessAge === "number" && sessAge >= 0 && sessAge < SESSION_FAST_MAX_SEC;
|
|
402
|
+
_push("session-too-fast", WEIGHTS.SESSION_TOO_FAST, tooFast, {
|
|
403
|
+
session_age_seconds: sessAge,
|
|
404
|
+
threshold_seconds: SESSION_FAST_MAX_SEC,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// 7. session-too-old — over-24h cart that finally checks out
|
|
408
|
+
// matches a stale-cookie / cart-replay shape.
|
|
409
|
+
var tooOld = typeof sessAge === "number" && sessAge > SESSION_OLD_MAX_SEC;
|
|
410
|
+
_push("session-too-old", WEIGHTS.SESSION_TOO_OLD, tooOld, {
|
|
411
|
+
session_age_seconds: sessAge,
|
|
412
|
+
threshold_seconds: SESSION_OLD_MAX_SEC,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// 8. ua-curl-class — operator-classified UA. The storefront's
|
|
416
|
+
// bot-guard pre-classifies the User-Agent string; this
|
|
417
|
+
// primitive only checks the resulting enum.
|
|
418
|
+
var uaCurl = !!(draft.ua_class && CURL_CLASS_TOKENS[String(draft.ua_class).toLowerCase()]);
|
|
419
|
+
_push("ua-curl-class", WEIGHTS.UA_CURL_CLASS, uaCurl, {
|
|
420
|
+
ua_class: draft.ua_class == null ? null : String(draft.ua_class),
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// 9. large-line-count — cart with many distinct lines tends
|
|
424
|
+
// to correlate with a carder testing batches.
|
|
425
|
+
var bigLines = typeof draft.line_count === "number" && draft.line_count >= LARGE_LINE_COUNT_THRESH;
|
|
426
|
+
_push("large-line-count", WEIGHTS.LARGE_LINE_COUNT, bigLines, {
|
|
427
|
+
line_count: draft.line_count,
|
|
428
|
+
threshold: LARGE_LINE_COUNT_THRESH,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// 10. mismatched-bin-country — operator-supplied BIN -> country
|
|
432
|
+
// map; the draft optionally carries a bin6 (first 6 PAN
|
|
433
|
+
// digits) that the storefront extracts during the tokenize
|
|
434
|
+
// handshake. Without the map OR without bin6, the signal
|
|
435
|
+
// does not fire.
|
|
436
|
+
var binMismatch = false;
|
|
437
|
+
var binDetail = null;
|
|
438
|
+
if (binCountryMap && typeof draft.bin6 === "string" && /^[0-9]{6}$/.test(draft.bin6)) {
|
|
439
|
+
var binCountry = binCountryMap[draft.bin6];
|
|
440
|
+
if (binCountry && binCountry !== shipCountry) {
|
|
441
|
+
binMismatch = true;
|
|
442
|
+
}
|
|
443
|
+
binDetail = {
|
|
444
|
+
bin6: draft.bin6,
|
|
445
|
+
bin_country: binCountry || null,
|
|
446
|
+
shipping_country: shipCountry,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
_push("mismatched-bin-country", WEIGHTS.MISMATCHED_BIN_COUNTRY, binMismatch, binDetail);
|
|
450
|
+
|
|
451
|
+
// 11. prior-chargeback — the chargebacks ledger has at least
|
|
452
|
+
// one row for this email hash.
|
|
453
|
+
var cbRows = (await query(
|
|
454
|
+
"SELECT COUNT(*) AS n FROM fraud_chargebacks WHERE email_hash = ?1",
|
|
455
|
+
[emailHash],
|
|
456
|
+
)).rows;
|
|
457
|
+
var cbCount = Number((cbRows[0] || {}).n || 0);
|
|
458
|
+
_push("prior-chargeback", WEIGHTS.PRIOR_CHARGEBACK, cbCount > 0, {
|
|
459
|
+
chargeback_count: cbCount,
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// 12. suppressed-email — operator-injectable peer. When the
|
|
463
|
+
// email pipeline has logged a hard bounce / complaint
|
|
464
|
+
// against this address, the signal fires.
|
|
465
|
+
var suppressed = false;
|
|
466
|
+
if (emailSuppressions && typeof emailSuppressions.isSuppressed === "function") {
|
|
467
|
+
try {
|
|
468
|
+
suppressed = !!(await emailSuppressions.isSuppressed(emailHash));
|
|
469
|
+
} catch (_e) {
|
|
470
|
+
// drop-silent — by design. A failing suppression backend
|
|
471
|
+
// shouldn't block checkout; the signal stays at "did not
|
|
472
|
+
// fire" and the other detectors carry the load.
|
|
473
|
+
suppressed = false;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
_push("suppressed-email", WEIGHTS.SUPPRESSED_EMAIL, suppressed, {
|
|
477
|
+
checked: !!emailSuppressions,
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// 13. manually-flagged — operator-pinned hard block. Forces
|
|
481
|
+
// the decision to refuse regardless of any other signal.
|
|
482
|
+
var flagRow = (await query(
|
|
483
|
+
"SELECT reason, flagged_at FROM fraud_email_flags WHERE email_hash = ?1 LIMIT 1",
|
|
484
|
+
[emailHash],
|
|
485
|
+
)).rows[0];
|
|
486
|
+
_push("manually-flagged", WEIGHTS.MANUALLY_FLAGGED, !!flagRow, flagRow ? {
|
|
487
|
+
reason: flagRow.reason || "",
|
|
488
|
+
flagged_at: flagRow.flagged_at,
|
|
489
|
+
} : null);
|
|
490
|
+
|
|
491
|
+
// Cap at SCORE_MAX so the CHECK constraint in the migration
|
|
492
|
+
// never sees an out-of-range value.
|
|
493
|
+
if (score > SCORE_MAX) score = SCORE_MAX;
|
|
494
|
+
|
|
495
|
+
return {
|
|
496
|
+
score: score,
|
|
497
|
+
decision: _decisionFor(score),
|
|
498
|
+
signals: signals,
|
|
499
|
+
email_hash: emailHash,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return {
|
|
504
|
+
EMAIL_NAMESPACE: EMAIL_NAMESPACE,
|
|
505
|
+
THRESHOLDS: THRESHOLDS,
|
|
506
|
+
WEIGHTS: WEIGHTS,
|
|
507
|
+
|
|
508
|
+
// Hash an email without writing — useful when the orchestrator
|
|
509
|
+
// already has a canonical address and wants the lookup key for
|
|
510
|
+
// a parallel chargebacks-by-email query.
|
|
511
|
+
hashEmail: function (email) {
|
|
512
|
+
return _hashEmail(_normalizeEmail(email));
|
|
513
|
+
},
|
|
514
|
+
|
|
515
|
+
// Evaluate a checkout draft. The draft shape is documented at
|
|
516
|
+
// the top of this file. Returns a `{ score, decision, signals,
|
|
517
|
+
// email_hash, order_id }` row and persists a `fraud_screenings`
|
|
518
|
+
// ledger entry. The order_id is required because the
|
|
519
|
+
// orchestrator looks up screenings by order id when
|
|
520
|
+
// reconciling outcomes later.
|
|
521
|
+
screen: async function (input) {
|
|
522
|
+
if (!input || typeof input !== "object") throw new TypeError("fraudScreen.screen: input object required");
|
|
523
|
+
var draft = input.order_draft;
|
|
524
|
+
if (!draft || typeof draft !== "object") throw new TypeError("fraudScreen.screen: order_draft object required");
|
|
525
|
+
_uuid(draft.order_id, "order_draft.order_id");
|
|
526
|
+
_optUuid(draft.customer_id, "order_draft.customer_id");
|
|
527
|
+
_addressShape(draft.shipping_address, "order_draft.shipping_address");
|
|
528
|
+
if (draft.billing_address != null) {
|
|
529
|
+
_addressShape(draft.billing_address, "order_draft.billing_address");
|
|
530
|
+
}
|
|
531
|
+
_nonNegInt(draft.total_minor, "order_draft.total_minor");
|
|
532
|
+
_currency(draft.currency);
|
|
533
|
+
_positiveInt(draft.line_count, "order_draft.line_count");
|
|
534
|
+
if (draft.session_age_seconds != null) {
|
|
535
|
+
if (typeof draft.session_age_seconds !== "number" || !isFinite(draft.session_age_seconds)) {
|
|
536
|
+
throw new TypeError("fraudScreen: order_draft.session_age_seconds must be a finite number");
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (draft.prior_orders_count != null) {
|
|
540
|
+
_nonNegInt(draft.prior_orders_count, "order_draft.prior_orders_count");
|
|
541
|
+
}
|
|
542
|
+
if (draft.ip_hash != null && (typeof draft.ip_hash !== "string" || !draft.ip_hash.length)) {
|
|
543
|
+
throw new TypeError("fraudScreen: order_draft.ip_hash must be a non-empty string when provided");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
var verdict = await _evaluate(draft);
|
|
547
|
+
var id = _b().uuid.v7();
|
|
548
|
+
var ts = _now();
|
|
549
|
+
await query(
|
|
550
|
+
"INSERT INTO fraud_screenings " +
|
|
551
|
+
"(id, order_id, customer_id, email_hash, score, decision, signals_json, actual_outcome, occurred_at) " +
|
|
552
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8)",
|
|
553
|
+
[
|
|
554
|
+
id, draft.order_id, draft.customer_id || null, verdict.email_hash,
|
|
555
|
+
verdict.score, verdict.decision, JSON.stringify(verdict.signals), ts,
|
|
556
|
+
],
|
|
557
|
+
);
|
|
558
|
+
return {
|
|
559
|
+
id: id,
|
|
560
|
+
order_id: draft.order_id,
|
|
561
|
+
email_hash: verdict.email_hash,
|
|
562
|
+
score: verdict.score,
|
|
563
|
+
decision: verdict.decision,
|
|
564
|
+
signals: verdict.signals,
|
|
565
|
+
occurred_at: ts,
|
|
566
|
+
};
|
|
567
|
+
},
|
|
568
|
+
|
|
569
|
+
// Reconcile the heuristic verdict against the actual outcome.
|
|
570
|
+
// Writes `actual_outcome` on every screening row that matched
|
|
571
|
+
// the order_id (typically one row per order — the orchestrator
|
|
572
|
+
// calls screen() once — but the loop tolerates retries that
|
|
573
|
+
// produced multiple screenings for the same order). Returns
|
|
574
|
+
// the count of rows updated.
|
|
575
|
+
recordOutcome: async function (input) {
|
|
576
|
+
if (!input || typeof input !== "object") throw new TypeError("fraudScreen.recordOutcome: input object required");
|
|
577
|
+
_uuid(input.order_id, "order_id");
|
|
578
|
+
var allowed = { paid_clean: true, chargeback: true, refunded: true, cancelled: true };
|
|
579
|
+
if (!input.actual_outcome || !allowed[input.actual_outcome]) {
|
|
580
|
+
throw new TypeError("fraudScreen.recordOutcome: actual_outcome must be one of paid_clean|chargeback|refunded|cancelled");
|
|
581
|
+
}
|
|
582
|
+
var r = await query(
|
|
583
|
+
"UPDATE fraud_screenings SET actual_outcome = ?1 WHERE order_id = ?2",
|
|
584
|
+
[input.actual_outcome, input.order_id],
|
|
585
|
+
);
|
|
586
|
+
return { updated: Number(r.rowCount || 0) };
|
|
587
|
+
},
|
|
588
|
+
|
|
589
|
+
// Append a chargeback row. The screenings primitive looks back
|
|
590
|
+
// at this table on every subsequent screen() of the same
|
|
591
|
+
// email hash so a hostile customer gets the prior-chargeback
|
|
592
|
+
// signal weight on their next attempt.
|
|
593
|
+
recordChargeback: async function (input) {
|
|
594
|
+
if (!input || typeof input !== "object") throw new TypeError("fraudScreen.recordChargeback: input object required");
|
|
595
|
+
_uuid(input.order_id, "order_id");
|
|
596
|
+
_optUuid(input.customer_id, "customer_id");
|
|
597
|
+
if (typeof input.email !== "string" || !input.email.length) {
|
|
598
|
+
throw new TypeError("fraudScreen.recordChargeback: email required");
|
|
599
|
+
}
|
|
600
|
+
_nonNegInt(input.amount_minor, "amount_minor");
|
|
601
|
+
if (input.reason != null && typeof input.reason !== "string") {
|
|
602
|
+
throw new TypeError("fraudScreen.recordChargeback: reason must be a string when provided");
|
|
603
|
+
}
|
|
604
|
+
if (input.reason && /[\x00-\x1f\x7f]/.test(input.reason)) {
|
|
605
|
+
throw new TypeError("fraudScreen.recordChargeback: reason must not contain control bytes");
|
|
606
|
+
}
|
|
607
|
+
var canonicalEmail = _normalizeEmail(input.email);
|
|
608
|
+
var emailHash = _hashEmail(canonicalEmail);
|
|
609
|
+
var id = _b().uuid.v7();
|
|
610
|
+
var ts = _now();
|
|
611
|
+
await query(
|
|
612
|
+
"INSERT INTO fraud_chargebacks " +
|
|
613
|
+
"(id, order_id, customer_id, email_hash, amount_minor, reason, occurred_at) " +
|
|
614
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
615
|
+
[id, input.order_id, input.customer_id || null, emailHash,
|
|
616
|
+
input.amount_minor, input.reason || "", ts],
|
|
617
|
+
);
|
|
618
|
+
return {
|
|
619
|
+
id: id,
|
|
620
|
+
order_id: input.order_id,
|
|
621
|
+
email_hash: emailHash,
|
|
622
|
+
amount_minor: input.amount_minor,
|
|
623
|
+
reason: input.reason || "",
|
|
624
|
+
occurred_at: ts,
|
|
625
|
+
};
|
|
626
|
+
},
|
|
627
|
+
|
|
628
|
+
// Operator-pinned manual block. Re-flagging the same address
|
|
629
|
+
// is idempotent — the row is upserted with the latest reason
|
|
630
|
+
// + timestamp so the dashboard always shows the freshest
|
|
631
|
+
// operator note.
|
|
632
|
+
flagEmail: async function (input) {
|
|
633
|
+
if (!input || typeof input !== "object") throw new TypeError("fraudScreen.flagEmail: input object required");
|
|
634
|
+
if (typeof input.email !== "string" || !input.email.length) {
|
|
635
|
+
throw new TypeError("fraudScreen.flagEmail: email required");
|
|
636
|
+
}
|
|
637
|
+
if (input.reason != null && typeof input.reason !== "string") {
|
|
638
|
+
throw new TypeError("fraudScreen.flagEmail: reason must be a string when provided");
|
|
639
|
+
}
|
|
640
|
+
if (input.reason && /[\x00-\x1f\x7f]/.test(input.reason)) {
|
|
641
|
+
throw new TypeError("fraudScreen.flagEmail: reason must not contain control bytes");
|
|
642
|
+
}
|
|
643
|
+
var canonicalEmail = _normalizeEmail(input.email);
|
|
644
|
+
var emailHash = _hashEmail(canonicalEmail);
|
|
645
|
+
var ts = _now();
|
|
646
|
+
// Idempotent upsert. INSERT-OR-REPLACE on the PK preserves
|
|
647
|
+
// the row identity (email_hash) while refreshing the reason
|
|
648
|
+
// + flagged_at so the dashboard shows the latest note.
|
|
649
|
+
await query(
|
|
650
|
+
"INSERT INTO fraud_email_flags (email_hash, reason, flagged_at) VALUES (?1, ?2, ?3) " +
|
|
651
|
+
"ON CONFLICT(email_hash) DO UPDATE SET reason = excluded.reason, flagged_at = excluded.flagged_at",
|
|
652
|
+
[emailHash, input.reason || "", ts],
|
|
653
|
+
);
|
|
654
|
+
return {
|
|
655
|
+
email_hash: emailHash,
|
|
656
|
+
reason: input.reason || "",
|
|
657
|
+
flagged_at: ts,
|
|
658
|
+
};
|
|
659
|
+
},
|
|
660
|
+
|
|
661
|
+
// Remove an operator-pinned block. Returns { unflagged: bool }.
|
|
662
|
+
unflagEmail: async function (input) {
|
|
663
|
+
if (!input || typeof input !== "object") throw new TypeError("fraudScreen.unflagEmail: input object required");
|
|
664
|
+
if (typeof input.email !== "string" || !input.email.length) {
|
|
665
|
+
throw new TypeError("fraudScreen.unflagEmail: email required");
|
|
666
|
+
}
|
|
667
|
+
var canonicalEmail = _normalizeEmail(input.email);
|
|
668
|
+
var emailHash = _hashEmail(canonicalEmail);
|
|
669
|
+
var r = await query(
|
|
670
|
+
"DELETE FROM fraud_email_flags WHERE email_hash = ?1",
|
|
671
|
+
[emailHash],
|
|
672
|
+
);
|
|
673
|
+
return { unflagged: Number(r.rowCount || 0) > 0 };
|
|
674
|
+
},
|
|
675
|
+
|
|
676
|
+
// Return prior screenings for a given customer. Ordered most-
|
|
677
|
+
// recent first so the dashboard pane shows the freshest
|
|
678
|
+
// decisions at the top. Hard-capped at 200 rows — the operator
|
|
679
|
+
// dashboard paginates beyond that via recentScreenings.
|
|
680
|
+
customerRiskHistory: async function (customerId) {
|
|
681
|
+
_uuid(customerId, "customer id");
|
|
682
|
+
var rows = (await query(
|
|
683
|
+
"SELECT id, order_id, customer_id, email_hash, score, decision, " +
|
|
684
|
+
"signals_json, actual_outcome, occurred_at " +
|
|
685
|
+
"FROM fraud_screenings WHERE customer_id = ?1 " +
|
|
686
|
+
"ORDER BY occurred_at DESC, id DESC LIMIT 200",
|
|
687
|
+
[customerId],
|
|
688
|
+
)).rows;
|
|
689
|
+
// Parse signals_json so callers don't each re-decode it.
|
|
690
|
+
// Touch the optional customers peer in a no-op shape check
|
|
691
|
+
// so the eslint-unused-vars detector stays quiet without
|
|
692
|
+
// requiring callers to wire it for this lookup; the field
|
|
693
|
+
// is reserved for a forthcoming join that pulls the
|
|
694
|
+
// display_name alongside the rows.
|
|
695
|
+
if (customers && typeof customers.get === "function") { /* reserved */ }
|
|
696
|
+
var out = [];
|
|
697
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
698
|
+
var r = rows[i];
|
|
699
|
+
var parsed = [];
|
|
700
|
+
try { parsed = JSON.parse(r.signals_json || "[]"); }
|
|
701
|
+
catch (_e) { parsed = []; }
|
|
702
|
+
out.push({
|
|
703
|
+
id: r.id,
|
|
704
|
+
order_id: r.order_id,
|
|
705
|
+
customer_id: r.customer_id,
|
|
706
|
+
email_hash: r.email_hash,
|
|
707
|
+
score: r.score,
|
|
708
|
+
decision: r.decision,
|
|
709
|
+
signals: parsed,
|
|
710
|
+
actual_outcome: r.actual_outcome,
|
|
711
|
+
occurred_at: r.occurred_at,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
return out;
|
|
715
|
+
},
|
|
716
|
+
|
|
717
|
+
// Operator dashboard feed — paginated by (occurred_at DESC,
|
|
718
|
+
// id DESC) so the freshest screenings surface first. Cursor
|
|
719
|
+
// is HMAC-tagged via b.pagination so an operator can't
|
|
720
|
+
// hand-craft one to skip past a hidden row.
|
|
721
|
+
recentScreenings: async function (listOpts) {
|
|
722
|
+
listOpts = listOpts || {};
|
|
723
|
+
var limit = listOpts.limit == null ? 20 : listOpts.limit;
|
|
724
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > 100) {
|
|
725
|
+
throw new TypeError("fraudScreen.recentScreenings: limit must be 1...100");
|
|
726
|
+
}
|
|
727
|
+
var orderKey = ["occurred_at:desc", "id:desc"];
|
|
728
|
+
var cursorVals = null;
|
|
729
|
+
if (listOpts.cursor != null) {
|
|
730
|
+
if (typeof listOpts.cursor !== "string") {
|
|
731
|
+
throw new TypeError("fraudScreen.recentScreenings: cursor must be an opaque string or null");
|
|
732
|
+
}
|
|
733
|
+
try {
|
|
734
|
+
var state = _b().pagination.decodeCursor(listOpts.cursor, cursorSecret);
|
|
735
|
+
if (JSON.stringify(state.orderKey) !== JSON.stringify(orderKey)) {
|
|
736
|
+
throw new TypeError("fraudScreen.recentScreenings: cursor orderKey mismatch");
|
|
737
|
+
}
|
|
738
|
+
cursorVals = state.vals;
|
|
739
|
+
} catch (e) {
|
|
740
|
+
if (e instanceof TypeError) throw e;
|
|
741
|
+
throw new TypeError("fraudScreen.recentScreenings: cursor — " + (e && e.message || "malformed"));
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
var rows;
|
|
745
|
+
if (cursorVals) {
|
|
746
|
+
rows = (await query(
|
|
747
|
+
"SELECT id, order_id, customer_id, email_hash, score, decision, " +
|
|
748
|
+
"signals_json, actual_outcome, occurred_at " +
|
|
749
|
+
"FROM fraud_screenings " +
|
|
750
|
+
"WHERE (occurred_at < ?1 OR (occurred_at = ?1 AND id < ?2)) " +
|
|
751
|
+
"ORDER BY occurred_at DESC, id DESC LIMIT ?3",
|
|
752
|
+
[cursorVals[0], cursorVals[1], limit],
|
|
753
|
+
)).rows;
|
|
754
|
+
} else {
|
|
755
|
+
rows = (await query(
|
|
756
|
+
"SELECT id, order_id, customer_id, email_hash, score, decision, " +
|
|
757
|
+
"signals_json, actual_outcome, occurred_at " +
|
|
758
|
+
"FROM fraud_screenings " +
|
|
759
|
+
"ORDER BY occurred_at DESC, id DESC LIMIT ?1",
|
|
760
|
+
[limit],
|
|
761
|
+
)).rows;
|
|
762
|
+
}
|
|
763
|
+
var out = [];
|
|
764
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
765
|
+
var r = rows[i];
|
|
766
|
+
var parsed = [];
|
|
767
|
+
try { parsed = JSON.parse(r.signals_json || "[]"); }
|
|
768
|
+
catch (_e) { parsed = []; }
|
|
769
|
+
out.push({
|
|
770
|
+
id: r.id,
|
|
771
|
+
order_id: r.order_id,
|
|
772
|
+
customer_id: r.customer_id,
|
|
773
|
+
email_hash: r.email_hash,
|
|
774
|
+
score: r.score,
|
|
775
|
+
decision: r.decision,
|
|
776
|
+
signals: parsed,
|
|
777
|
+
actual_outcome: r.actual_outcome,
|
|
778
|
+
occurred_at: r.occurred_at,
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
var last = out[out.length - 1];
|
|
782
|
+
var next = null;
|
|
783
|
+
if (last && out.length === limit) {
|
|
784
|
+
next = _b().pagination.encodeCursor({
|
|
785
|
+
orderKey: orderKey,
|
|
786
|
+
vals: [last.occurred_at, last.id],
|
|
787
|
+
forward: true,
|
|
788
|
+
}, cursorSecret);
|
|
789
|
+
}
|
|
790
|
+
return { rows: out, next_cursor: next };
|
|
791
|
+
},
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
module.exports = {
|
|
796
|
+
create: create,
|
|
797
|
+
EMAIL_NAMESPACE: EMAIL_NAMESPACE,
|
|
798
|
+
THRESHOLDS: THRESHOLDS,
|
|
799
|
+
WEIGHTS: WEIGHTS,
|
|
800
|
+
VELOCITY_WINDOW_MS: VELOCITY_WINDOW_MS,
|
|
801
|
+
VELOCITY_MAX_OK: VELOCITY_MAX_OK,
|
|
802
|
+
HIGH_VALUE_NEW_THRESH: HIGH_VALUE_NEW_THRESH,
|
|
803
|
+
SESSION_FAST_MAX_SEC: SESSION_FAST_MAX_SEC,
|
|
804
|
+
SESSION_OLD_MAX_SEC: SESSION_OLD_MAX_SEC,
|
|
805
|
+
LARGE_LINE_COUNT_THRESH: LARGE_LINE_COUNT_THRESH,
|
|
806
|
+
DEFAULT_DISPOSABLE_DOMAINS: DEFAULT_DISPOSABLE_DOMAINS,
|
|
807
|
+
DEFAULT_FREE_DOMAINS: DEFAULT_FREE_DOMAINS,
|
|
808
|
+
};
|