@blamejs/blamejs-shop 0.0.66 → 0.0.70
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 +8 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +35 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/pixel-events.js +995 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- package/package.json +1 -1
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.customerRiskProfile
|
|
4
|
+
* @title Customer risk profile — long-running per-customer signal
|
|
5
|
+
* aggregation that qualifies B2B credit, gates VIP perks, and
|
|
6
|
+
* flags accounts for hand-review.
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Distinct from `fraudScreen` (migration 0038) which scores a single
|
|
10
|
+
* order draft pre-payment. The risk profile is the long-term picture
|
|
11
|
+
* built from observed bad signals over time:
|
|
12
|
+
*
|
|
13
|
+
* - chargeback (severe: a card-network reversal landed)
|
|
14
|
+
* - chargeback_dispute_lost (severe: representment came back against us)
|
|
15
|
+
* - fraud_score_high (per-order fraudScreen scored above the step-up threshold)
|
|
16
|
+
* - refund_request (a refund was requested, not necessarily fraud)
|
|
17
|
+
* - refund_to_credit (refund issued as store credit instead of cash — refund-policy verdict)
|
|
18
|
+
* - late_payment (B2B credit account paid past payment_terms_days)
|
|
19
|
+
* - address_mismatch (order's shipping country differed from billing country)
|
|
20
|
+
* - device_diversity_high (this account has logged in from an unusually wide device fingerprint set)
|
|
21
|
+
*
|
|
22
|
+
* `severity` is operator-chosen on a 1..10 scale per signal — the
|
|
23
|
+
* integrator picks a calibration that fits their risk appetite. The
|
|
24
|
+
* recent-window default is 90 days: `risk_score` is the sum of
|
|
25
|
+
* severities of non-cleared signals occurring in the last 90 days.
|
|
26
|
+
* Lifetime counts are tracked too but don't feed the score (a
|
|
27
|
+
* 5-year-old chargeback shouldn't keep a recovered customer at
|
|
28
|
+
* `critical` forever).
|
|
29
|
+
*
|
|
30
|
+
* Risk bands (closed intervals on score):
|
|
31
|
+
*
|
|
32
|
+
* 0 .. 9 → low
|
|
33
|
+
* 10 .. 24 → moderate
|
|
34
|
+
* 25 .. 49 → high
|
|
35
|
+
* 50+ → critical
|
|
36
|
+
*
|
|
37
|
+
* The thresholds are exposed on `customerRiskProfile.BANDS` so the
|
|
38
|
+
* integrator's dashboard and the test suite assert against the same
|
|
39
|
+
* numbers the runtime uses.
|
|
40
|
+
*
|
|
41
|
+
* Composition surface:
|
|
42
|
+
*
|
|
43
|
+
* var risk = bShop.customerRiskProfile.create({ query: q });
|
|
44
|
+
* await risk.recordSignal({
|
|
45
|
+
* customer_id: cid,
|
|
46
|
+
* kind: "chargeback",
|
|
47
|
+
* severity: 10,
|
|
48
|
+
* detail_json: { order_id: oid, amount_minor: 12500 },
|
|
49
|
+
* });
|
|
50
|
+
* var prof = await risk.getProfile(cid);
|
|
51
|
+
* // { customer_id, risk_score, risk_band, recent_signals, lifetime_signals }
|
|
52
|
+
*
|
|
53
|
+
* The summary table is denormalized: `recordSignal` updates the
|
|
54
|
+
* summary row in lockstep so getProfile + tier + topRiskCustomers
|
|
55
|
+
* all answer from a single-row read. `recompute({ customer_id })`
|
|
56
|
+
* re-derives the summary from the signal log when an operator
|
|
57
|
+
* clears a signal or the integrator wants to age out signals past
|
|
58
|
+
* the recent-window boundary. `recomputeAll({ since })` walks every
|
|
59
|
+
* customer whose summary's `recomputed_at` is older than the cutoff
|
|
60
|
+
* so the dashboard's pre-rendered ordering doesn't drift.
|
|
61
|
+
*
|
|
62
|
+
* `clearSignal({ signal_id, reason, cleared_by })` is the operator-
|
|
63
|
+
* override entry point for false positives — sets cleared_at +
|
|
64
|
+
* cleared_by + cleared_reason, then recomputes the affected
|
|
65
|
+
* customer's summary so the band/score reflects the clearing
|
|
66
|
+
* immediately. Re-clearing an already-cleared signal refuses.
|
|
67
|
+
*
|
|
68
|
+
* Monotonic per-customer `occurred_at`: two writes against the same
|
|
69
|
+
* customer in the same millisecond would tie on `occurred_at` and
|
|
70
|
+
* make the "latest signal" read ambiguous in the `(customer_id,
|
|
71
|
+
* occurred_at DESC)` index. `_resolveOccurredAt` bumps the requested
|
|
72
|
+
* timestamp to `prior + 1` on collision, guaranteeing strict
|
|
73
|
+
* monotonicity (same discipline as credit_transactions).
|
|
74
|
+
*
|
|
75
|
+
* Surface:
|
|
76
|
+
* - recordSignal({ customer_id, kind, severity, detail_json?, occurred_at? })
|
|
77
|
+
* - getProfile(customer_id)
|
|
78
|
+
* - tier(customer_id)
|
|
79
|
+
* - historyForCustomer(customer_id)
|
|
80
|
+
* - recompute({ customer_id })
|
|
81
|
+
* - recomputeAll({ since })
|
|
82
|
+
* - topRiskCustomers({ limit, band? })
|
|
83
|
+
* - clearSignal({ signal_id, reason, cleared_by })
|
|
84
|
+
*
|
|
85
|
+
* Storage:
|
|
86
|
+
* - customer_risk_signals (migration 0142)
|
|
87
|
+
* - customer_risk_summaries (migration 0142)
|
|
88
|
+
*
|
|
89
|
+
* Optional handles (all injectable on create()):
|
|
90
|
+
* - query — D1-shaped async query function (required)
|
|
91
|
+
* - fraudScreen — used by callers to translate per-order
|
|
92
|
+
* fraudScreen verdicts into recordSignal calls;
|
|
93
|
+
* the primitive itself never reaches in
|
|
94
|
+
* - returns — caller correlator for refund_request signals
|
|
95
|
+
* - dunning — caller correlator for late_payment signals
|
|
96
|
+
* - creditLimits — caller correlator for late_payment signals
|
|
97
|
+
*
|
|
98
|
+
* The optional handles are stored for callers that compose this
|
|
99
|
+
* primitive with the upstream sources; this primitive does not call
|
|
100
|
+
* into them. Recording a signal is a single explicit operator
|
|
101
|
+
* action, never an implicit cross-primitive side effect.
|
|
102
|
+
*
|
|
103
|
+
* @primitive customerRiskProfile
|
|
104
|
+
* @related b.uuid.v7, b.guardUuid, shop.fraudScreen, shop.creditLimits, shop.dunning, shop.returns
|
|
105
|
+
*/
|
|
106
|
+
|
|
107
|
+
var bShop;
|
|
108
|
+
function _b() {
|
|
109
|
+
if (!bShop) {
|
|
110
|
+
try {
|
|
111
|
+
bShop = require("./index");
|
|
112
|
+
} catch (_e) {
|
|
113
|
+
// Fallback path — the lib/index.js registry mutation is the
|
|
114
|
+
// operator's responsibility. The framework surface this primitive
|
|
115
|
+
// consumes (guardUuid, uuid) is stable; no shop-level peer is
|
|
116
|
+
// touched through `_b()`, so the bypass is semantically
|
|
117
|
+
// equivalent until the registry edit lands.
|
|
118
|
+
bShop = { framework: require("./vendor/blamejs") };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return bShop.framework;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- constants ----------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
var KINDS = [
|
|
127
|
+
"chargeback",
|
|
128
|
+
"refund_request",
|
|
129
|
+
"fraud_score_high",
|
|
130
|
+
"chargeback_dispute_lost",
|
|
131
|
+
"late_payment",
|
|
132
|
+
"address_mismatch",
|
|
133
|
+
"device_diversity_high",
|
|
134
|
+
"refund_to_credit",
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
var BAND_NAMES = ["low", "moderate", "high", "critical"];
|
|
138
|
+
|
|
139
|
+
// Closed intervals on risk_score. Score 0..9 → low, 10..24 → moderate,
|
|
140
|
+
// 25..49 → high, 50+ → critical. Exposed so the test suite + the
|
|
141
|
+
// dashboard renderer pull the same numbers the runtime uses.
|
|
142
|
+
var BANDS = Object.freeze({
|
|
143
|
+
LOW_MAX: 9,
|
|
144
|
+
MODERATE_MAX: 24,
|
|
145
|
+
HIGH_MAX: 49,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Recent-window — signals older than this don't contribute to the
|
|
149
|
+
// score (their lifetime count still reflects them). 90 days matches
|
|
150
|
+
// the typical card-network chargeback-evidence cycle: anything older
|
|
151
|
+
// has already gone through dispute and is settled.
|
|
152
|
+
var RECENT_WINDOW_MS = 90 * 86400 * 1000;
|
|
153
|
+
|
|
154
|
+
// detail_json byte ceiling. Operators routinely embed order ids,
|
|
155
|
+
// amounts, a few human-readable notes — 8 KiB after JSON-encoding
|
|
156
|
+
// leaves room for that without becoming a payload-smuggling channel.
|
|
157
|
+
var MAX_DETAIL_JSON_BYTES = 8192;
|
|
158
|
+
|
|
159
|
+
// Plain-text refuses control bytes on the short fields. cleared_by /
|
|
160
|
+
// cleared_reason are short correlation strings — log-injection bytes
|
|
161
|
+
// have no place in a one-line column.
|
|
162
|
+
var MAX_REF_LEN = 128;
|
|
163
|
+
var PRINTABLE_RE = /^[^\x00-\x1f\x7f]*$/;
|
|
164
|
+
|
|
165
|
+
// ---- validators ---------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
function _uuid(s, label) {
|
|
168
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
169
|
+
catch (e) { throw new TypeError("customerRiskProfile: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function _kind(s) {
|
|
173
|
+
if (typeof s !== "string" || KINDS.indexOf(s) === -1) {
|
|
174
|
+
throw new TypeError("customerRiskProfile: kind must be one of " + KINDS.join(", "));
|
|
175
|
+
}
|
|
176
|
+
return s;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function _severity(n) {
|
|
180
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > 10) {
|
|
181
|
+
throw new TypeError("customerRiskProfile: severity must be an integer in [1, 10]");
|
|
182
|
+
}
|
|
183
|
+
return n;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function _epochMs(ts, label) {
|
|
187
|
+
if (ts == null) return null;
|
|
188
|
+
if (typeof ts !== "number" || !Number.isInteger(ts) || ts < 0) {
|
|
189
|
+
throw new TypeError("customerRiskProfile: " + label + " must be a non-negative integer epoch-ms");
|
|
190
|
+
}
|
|
191
|
+
return ts;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function _band(s) {
|
|
195
|
+
if (typeof s !== "string" || BAND_NAMES.indexOf(s) === -1) {
|
|
196
|
+
throw new TypeError("customerRiskProfile: band must be one of " + BAND_NAMES.join(", "));
|
|
197
|
+
}
|
|
198
|
+
return s;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function _ref(s, label, required) {
|
|
202
|
+
if (s == null) {
|
|
203
|
+
if (required) {
|
|
204
|
+
throw new TypeError("customerRiskProfile: " + label + " is required");
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
if (typeof s !== "string") {
|
|
209
|
+
throw new TypeError("customerRiskProfile: " + label + " must be a string");
|
|
210
|
+
}
|
|
211
|
+
if (!s.length) {
|
|
212
|
+
throw new TypeError("customerRiskProfile: " + label + " must be a non-empty string when provided");
|
|
213
|
+
}
|
|
214
|
+
if (s.length > MAX_REF_LEN) {
|
|
215
|
+
throw new TypeError("customerRiskProfile: " + label + " must be <= " + MAX_REF_LEN + " chars");
|
|
216
|
+
}
|
|
217
|
+
if (!PRINTABLE_RE.test(s)) {
|
|
218
|
+
throw new TypeError("customerRiskProfile: " + label + " must not contain control bytes");
|
|
219
|
+
}
|
|
220
|
+
return s;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function _detailJson(v) {
|
|
224
|
+
if (v == null) return null;
|
|
225
|
+
if (typeof v !== "object") {
|
|
226
|
+
throw new TypeError("customerRiskProfile: detail_json must be a plain object when provided");
|
|
227
|
+
}
|
|
228
|
+
var encoded;
|
|
229
|
+
try { encoded = JSON.stringify(v); }
|
|
230
|
+
catch (_e) { throw new TypeError("customerRiskProfile: detail_json must be JSON-serializable"); }
|
|
231
|
+
if (encoded == null) {
|
|
232
|
+
throw new TypeError("customerRiskProfile: detail_json must be JSON-serializable");
|
|
233
|
+
}
|
|
234
|
+
if (Buffer.byteLength(encoded, "utf8") > MAX_DETAIL_JSON_BYTES) {
|
|
235
|
+
throw new TypeError("customerRiskProfile: detail_json must be <= " + MAX_DETAIL_JSON_BYTES + " bytes when JSON-encoded");
|
|
236
|
+
}
|
|
237
|
+
return encoded;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function _positiveInt(n, label) {
|
|
241
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
|
|
242
|
+
throw new TypeError("customerRiskProfile: " + label + " must be a positive integer");
|
|
243
|
+
}
|
|
244
|
+
return n;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function _now() { return Date.now(); }
|
|
248
|
+
|
|
249
|
+
function _bandFor(score) {
|
|
250
|
+
if (score <= BANDS.LOW_MAX) return "low";
|
|
251
|
+
if (score <= BANDS.MODERATE_MAX) return "moderate";
|
|
252
|
+
if (score <= BANDS.HIGH_MAX) return "high";
|
|
253
|
+
return "critical";
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---- factory ------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
function create(opts) {
|
|
259
|
+
opts = opts || {};
|
|
260
|
+
var query = opts.query;
|
|
261
|
+
if (!query) {
|
|
262
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
263
|
+
}
|
|
264
|
+
// Optional caller-correlator handles. Stored so an integrator can
|
|
265
|
+
// recover them from the instance for orchestration; not consumed
|
|
266
|
+
// by this primitive directly. Recording a signal is always an
|
|
267
|
+
// explicit operator call, never an implicit cross-primitive
|
|
268
|
+
// side effect — that's how the test suite isolates each signal
|
|
269
|
+
// source and the dashboard can show the audit trail.
|
|
270
|
+
var fraudScreen = opts.fraudScreen || null;
|
|
271
|
+
var returns = opts.returns || null;
|
|
272
|
+
var dunning = opts.dunning || null;
|
|
273
|
+
var creditLimits = opts.creditLimits || null;
|
|
274
|
+
|
|
275
|
+
async function _readLatestSignalTs(customerId) {
|
|
276
|
+
var r = await query(
|
|
277
|
+
"SELECT occurred_at FROM customer_risk_signals " +
|
|
278
|
+
"WHERE customer_id = ?1 ORDER BY occurred_at DESC LIMIT 1",
|
|
279
|
+
[customerId],
|
|
280
|
+
);
|
|
281
|
+
return r.rows.length ? r.rows[0].occurred_at : null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Same monotonic-clock discipline as credit_transactions: two
|
|
285
|
+
// writes against the same customer in the same millisecond would
|
|
286
|
+
// tie on `occurred_at` and corrupt the `(customer_id, occurred_at
|
|
287
|
+
// DESC)` index ordering. Bump the second write to `prior + 1`.
|
|
288
|
+
function _resolveOccurredAt(requestedTs, latestTs) {
|
|
289
|
+
if (latestTs == null) return requestedTs;
|
|
290
|
+
if (requestedTs > latestTs) return requestedTs;
|
|
291
|
+
return latestTs + 1;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function _readSummary(customerId) {
|
|
295
|
+
var r = await query(
|
|
296
|
+
"SELECT customer_id, risk_score, risk_band, lifetime_signal_count, recent_signal_count, recomputed_at " +
|
|
297
|
+
"FROM customer_risk_summaries WHERE customer_id = ?1 LIMIT 1",
|
|
298
|
+
[customerId],
|
|
299
|
+
);
|
|
300
|
+
return r.rows.length ? r.rows[0] : null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Recompute the denormalized summary row for one customer from the
|
|
304
|
+
// signal log. Pulls every non-cleared signal, partitions into
|
|
305
|
+
// recent-window (contributes to risk_score) vs older (lifetime
|
|
306
|
+
// count only), and upserts the summary row. Returns the fresh
|
|
307
|
+
// summary shape so callers don't re-read.
|
|
308
|
+
async function _recomputeOne(customerId, now) {
|
|
309
|
+
var cutoff = now - RECENT_WINDOW_MS;
|
|
310
|
+
|
|
311
|
+
var r = await query(
|
|
312
|
+
"SELECT severity, occurred_at FROM customer_risk_signals " +
|
|
313
|
+
"WHERE customer_id = ?1 AND cleared_at IS NULL",
|
|
314
|
+
[customerId],
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
var lifetimeCount = r.rows.length;
|
|
318
|
+
var recentCount = 0;
|
|
319
|
+
var score = 0;
|
|
320
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
321
|
+
var row = r.rows[i];
|
|
322
|
+
if (row.occurred_at >= cutoff) {
|
|
323
|
+
recentCount += 1;
|
|
324
|
+
score += row.severity;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
var band = _bandFor(score);
|
|
328
|
+
|
|
329
|
+
var existing = await _readSummary(customerId);
|
|
330
|
+
if (existing) {
|
|
331
|
+
await query(
|
|
332
|
+
"UPDATE customer_risk_summaries SET risk_score = ?1, risk_band = ?2, " +
|
|
333
|
+
"lifetime_signal_count = ?3, recent_signal_count = ?4, recomputed_at = ?5 " +
|
|
334
|
+
"WHERE customer_id = ?6",
|
|
335
|
+
[score, band, lifetimeCount, recentCount, now, customerId],
|
|
336
|
+
);
|
|
337
|
+
} else {
|
|
338
|
+
await query(
|
|
339
|
+
"INSERT INTO customer_risk_summaries " +
|
|
340
|
+
"(customer_id, risk_score, risk_band, lifetime_signal_count, recent_signal_count, recomputed_at) " +
|
|
341
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
342
|
+
[customerId, score, band, lifetimeCount, recentCount, now],
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
customer_id: customerId,
|
|
347
|
+
risk_score: score,
|
|
348
|
+
risk_band: band,
|
|
349
|
+
lifetime_signal_count: lifetimeCount,
|
|
350
|
+
recent_signal_count: recentCount,
|
|
351
|
+
recomputed_at: now,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
KINDS: KINDS.slice(),
|
|
357
|
+
BAND_NAMES: BAND_NAMES.slice(),
|
|
358
|
+
BANDS: { LOW_MAX: BANDS.LOW_MAX, MODERATE_MAX: BANDS.MODERATE_MAX, HIGH_MAX: BANDS.HIGH_MAX },
|
|
359
|
+
RECENT_WINDOW_MS: RECENT_WINDOW_MS,
|
|
360
|
+
|
|
361
|
+
// Stored optional handles — exposed read-only so an integrator
|
|
362
|
+
// can recover them off the instance for cross-primitive
|
|
363
|
+
// orchestration. The primitive itself never reaches in.
|
|
364
|
+
handles: {
|
|
365
|
+
fraudScreen: fraudScreen,
|
|
366
|
+
returns: returns,
|
|
367
|
+
dunning: dunning,
|
|
368
|
+
creditLimits: creditLimits,
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
recordSignal: async function (input) {
|
|
372
|
+
if (!input || typeof input !== "object") {
|
|
373
|
+
throw new TypeError("customerRiskProfile.recordSignal: input object required");
|
|
374
|
+
}
|
|
375
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
376
|
+
var kind = _kind(input.kind);
|
|
377
|
+
var severity = _severity(input.severity);
|
|
378
|
+
var detail = _detailJson(input.detail_json);
|
|
379
|
+
var requested = _epochMs(input.occurred_at, "occurred_at");
|
|
380
|
+
if (requested == null) requested = _now();
|
|
381
|
+
|
|
382
|
+
var latest = await _readLatestSignalTs(customerId);
|
|
383
|
+
var ts = _resolveOccurredAt(requested, latest);
|
|
384
|
+
|
|
385
|
+
var id = _b().uuid.v7();
|
|
386
|
+
await query(
|
|
387
|
+
"INSERT INTO customer_risk_signals " +
|
|
388
|
+
"(id, customer_id, kind, severity, detail_json, occurred_at, cleared_at, cleared_by, cleared_reason) " +
|
|
389
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, NULL, NULL)",
|
|
390
|
+
[id, customerId, kind, severity, detail, ts],
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
// Refresh the denormalized summary in lockstep so getProfile /
|
|
394
|
+
// tier / topRiskCustomers answer from a single-row read on the
|
|
395
|
+
// hot path.
|
|
396
|
+
var summary = await _recomputeOne(customerId, _now());
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
id: id,
|
|
400
|
+
customer_id: customerId,
|
|
401
|
+
kind: kind,
|
|
402
|
+
severity: severity,
|
|
403
|
+
detail_json: detail,
|
|
404
|
+
occurred_at: ts,
|
|
405
|
+
risk_score: summary.risk_score,
|
|
406
|
+
risk_band: summary.risk_band,
|
|
407
|
+
};
|
|
408
|
+
},
|
|
409
|
+
|
|
410
|
+
getProfile: async function (customerId) {
|
|
411
|
+
_uuid(customerId, "customer_id");
|
|
412
|
+
var summary = await _readSummary(customerId);
|
|
413
|
+
if (!summary) {
|
|
414
|
+
// Customer never had a signal — return the conservative
|
|
415
|
+
// shape so the dashboard can render "no signals on file" the
|
|
416
|
+
// same way as "we recomputed and there's nothing." `risk_band`
|
|
417
|
+
// is `low` because score 0 lands in the low band; the
|
|
418
|
+
// bucketization is the same function the runtime uses
|
|
419
|
+
// elsewhere.
|
|
420
|
+
return {
|
|
421
|
+
customer_id: customerId,
|
|
422
|
+
risk_score: 0,
|
|
423
|
+
risk_band: "low",
|
|
424
|
+
lifetime_signal_count: 0,
|
|
425
|
+
recent_signal_count: 0,
|
|
426
|
+
recomputed_at: null,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
customer_id: summary.customer_id,
|
|
431
|
+
risk_score: summary.risk_score,
|
|
432
|
+
risk_band: summary.risk_band,
|
|
433
|
+
lifetime_signal_count: summary.lifetime_signal_count,
|
|
434
|
+
recent_signal_count: summary.recent_signal_count,
|
|
435
|
+
recomputed_at: summary.recomputed_at,
|
|
436
|
+
};
|
|
437
|
+
},
|
|
438
|
+
|
|
439
|
+
tier: async function (customerId) {
|
|
440
|
+
_uuid(customerId, "customer_id");
|
|
441
|
+
var summary = await _readSummary(customerId);
|
|
442
|
+
if (!summary) return "low";
|
|
443
|
+
return summary.risk_band;
|
|
444
|
+
},
|
|
445
|
+
|
|
446
|
+
historyForCustomer: async function (customerId) {
|
|
447
|
+
_uuid(customerId, "customer_id");
|
|
448
|
+
var r = await query(
|
|
449
|
+
"SELECT id, customer_id, kind, severity, detail_json, occurred_at, " +
|
|
450
|
+
"cleared_at, cleared_by, cleared_reason " +
|
|
451
|
+
"FROM customer_risk_signals WHERE customer_id = ?1 ORDER BY occurred_at DESC",
|
|
452
|
+
[customerId],
|
|
453
|
+
);
|
|
454
|
+
return r.rows.map(function (row) {
|
|
455
|
+
return {
|
|
456
|
+
id: row.id,
|
|
457
|
+
customer_id: row.customer_id,
|
|
458
|
+
kind: row.kind,
|
|
459
|
+
severity: row.severity,
|
|
460
|
+
detail_json: row.detail_json == null ? null : JSON.parse(row.detail_json),
|
|
461
|
+
occurred_at: row.occurred_at,
|
|
462
|
+
cleared_at: row.cleared_at,
|
|
463
|
+
cleared_by: row.cleared_by,
|
|
464
|
+
cleared_reason: row.cleared_reason,
|
|
465
|
+
};
|
|
466
|
+
});
|
|
467
|
+
},
|
|
468
|
+
|
|
469
|
+
recompute: async function (input) {
|
|
470
|
+
if (!input || typeof input !== "object") {
|
|
471
|
+
throw new TypeError("customerRiskProfile.recompute: input object required");
|
|
472
|
+
}
|
|
473
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
474
|
+
return _recomputeOne(customerId, _now());
|
|
475
|
+
},
|
|
476
|
+
|
|
477
|
+
// Refresh every summary whose `recomputed_at` is older than the
|
|
478
|
+
// operator-supplied cutoff. The integrator wires this on a cron
|
|
479
|
+
// so signals naturally age out of the recent-window as wall-clock
|
|
480
|
+
// advances — without the cron, a customer with a 90-day-old
|
|
481
|
+
// chargeback would keep showing the same score until a fresh
|
|
482
|
+
// signal forced a recompute.
|
|
483
|
+
recomputeAll: async function (input) {
|
|
484
|
+
if (!input || typeof input !== "object") {
|
|
485
|
+
throw new TypeError("customerRiskProfile.recomputeAll: input object required");
|
|
486
|
+
}
|
|
487
|
+
var since = _epochMs(input.since, "since");
|
|
488
|
+
if (since == null) {
|
|
489
|
+
throw new TypeError("customerRiskProfile.recomputeAll: since is required");
|
|
490
|
+
}
|
|
491
|
+
// Pick up every customer with EITHER a stale summary OR a
|
|
492
|
+
// signal recorded since the cutoff. The union ensures a
|
|
493
|
+
// never-summarized customer who's accumulated signals also
|
|
494
|
+
// surfaces — recomputeAll is the integrator's "reconcile
|
|
495
|
+
// everything observable" entry point.
|
|
496
|
+
var r = await query(
|
|
497
|
+
"SELECT DISTINCT customer_id FROM customer_risk_summaries WHERE recomputed_at < ?1 " +
|
|
498
|
+
"UNION " +
|
|
499
|
+
"SELECT DISTINCT customer_id FROM customer_risk_signals WHERE occurred_at >= ?1",
|
|
500
|
+
[since],
|
|
501
|
+
);
|
|
502
|
+
var now = _now();
|
|
503
|
+
var refreshed = [];
|
|
504
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
505
|
+
var customerId = r.rows[i].customer_id;
|
|
506
|
+
var summary = await _recomputeOne(customerId, now);
|
|
507
|
+
refreshed.push(summary);
|
|
508
|
+
}
|
|
509
|
+
return { recomputed_count: refreshed.length, recomputed_at: now };
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
topRiskCustomers: async function (input) {
|
|
513
|
+
if (!input || typeof input !== "object") {
|
|
514
|
+
throw new TypeError("customerRiskProfile.topRiskCustomers: input object required");
|
|
515
|
+
}
|
|
516
|
+
var limit = _positiveInt(input.limit, "limit");
|
|
517
|
+
var band = null;
|
|
518
|
+
if (input.band != null) band = _band(input.band);
|
|
519
|
+
|
|
520
|
+
var sql = "SELECT customer_id, risk_score, risk_band, lifetime_signal_count, recent_signal_count, recomputed_at " +
|
|
521
|
+
"FROM customer_risk_summaries";
|
|
522
|
+
var params = [];
|
|
523
|
+
if (band != null) {
|
|
524
|
+
sql += " WHERE risk_band = ?1";
|
|
525
|
+
params.push(band);
|
|
526
|
+
}
|
|
527
|
+
sql += " ORDER BY risk_score DESC, customer_id ASC";
|
|
528
|
+
// SQLite parameter slot for LIMIT — use a fresh slot index so
|
|
529
|
+
// the predicate parameter (if present) doesn't collide.
|
|
530
|
+
sql += " LIMIT ?" + (params.length + 1);
|
|
531
|
+
params.push(limit);
|
|
532
|
+
|
|
533
|
+
var r = await query(sql, params);
|
|
534
|
+
return r.rows;
|
|
535
|
+
},
|
|
536
|
+
|
|
537
|
+
clearSignal: async function (input) {
|
|
538
|
+
if (!input || typeof input !== "object") {
|
|
539
|
+
throw new TypeError("customerRiskProfile.clearSignal: input object required");
|
|
540
|
+
}
|
|
541
|
+
var signalId = _uuid(input.signal_id, "signal_id");
|
|
542
|
+
var reason = _ref(input.reason, "reason", true);
|
|
543
|
+
var clearedBy = _ref(input.cleared_by, "cleared_by", true);
|
|
544
|
+
|
|
545
|
+
var r = await query(
|
|
546
|
+
"SELECT id, customer_id, cleared_at FROM customer_risk_signals WHERE id = ?1 LIMIT 1",
|
|
547
|
+
[signalId],
|
|
548
|
+
);
|
|
549
|
+
if (!r.rows.length) {
|
|
550
|
+
var notFound = new Error("customerRiskProfile.clearSignal: signal " + JSON.stringify(signalId) + " not found");
|
|
551
|
+
notFound.code = "RISK_SIGNAL_NOT_FOUND";
|
|
552
|
+
throw notFound;
|
|
553
|
+
}
|
|
554
|
+
var row = r.rows[0];
|
|
555
|
+
if (row.cleared_at != null) {
|
|
556
|
+
var already = new Error("customerRiskProfile.clearSignal: signal " + JSON.stringify(signalId) + " already cleared");
|
|
557
|
+
already.code = "RISK_SIGNAL_ALREADY_CLEARED";
|
|
558
|
+
throw already;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
var now = _now();
|
|
562
|
+
await query(
|
|
563
|
+
"UPDATE customer_risk_signals SET cleared_at = ?1, cleared_by = ?2, cleared_reason = ?3 " +
|
|
564
|
+
"WHERE id = ?4",
|
|
565
|
+
[now, clearedBy, reason, signalId],
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
// Cleared signals drop their contribution to the score
|
|
569
|
+
// immediately — recompute the affected customer's summary so
|
|
570
|
+
// the band/score reflect the clearing on the next dashboard
|
|
571
|
+
// read without waiting for a recomputeAll cron tick.
|
|
572
|
+
var summary = await _recomputeOne(row.customer_id, now);
|
|
573
|
+
|
|
574
|
+
return {
|
|
575
|
+
signal_id: signalId,
|
|
576
|
+
customer_id: row.customer_id,
|
|
577
|
+
cleared_at: now,
|
|
578
|
+
cleared_by: clearedBy,
|
|
579
|
+
cleared_reason: reason,
|
|
580
|
+
risk_score: summary.risk_score,
|
|
581
|
+
risk_band: summary.risk_band,
|
|
582
|
+
};
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
module.exports = {
|
|
588
|
+
create: create,
|
|
589
|
+
KINDS: KINDS,
|
|
590
|
+
BAND_NAMES: BAND_NAMES,
|
|
591
|
+
BANDS: BANDS,
|
|
592
|
+
RECENT_WINDOW_MS: RECENT_WINDOW_MS,
|
|
593
|
+
};
|