@blamejs/blamejs-shop 0.0.70 → 0.0.75
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 +10 -0
- package/lib/announcement-bar.js +753 -0
- package/lib/banner-ab-tests.js +806 -0
- package/lib/bin-locations.js +791 -0
- package/lib/blog-articles.js +1173 -0
- package/lib/carrier-accounts.js +805 -0
- package/lib/cart-recovery.js +1133 -0
- package/lib/category-navigation.js +934 -0
- package/lib/consent-ledger.js +539 -0
- package/lib/customer-impersonation.js +743 -0
- package/lib/customer-merge.js +879 -0
- package/lib/demand-forecast.js +1121 -0
- package/lib/dispute-resolution.js +886 -0
- package/lib/email-ab-tests.js +918 -0
- package/lib/email-engagement-score.js +649 -0
- package/lib/event-log.js +713 -0
- package/lib/fulfillment-sla.js +791 -0
- package/lib/index.js +42 -0
- package/lib/inventory-audits.js +852 -0
- package/lib/line-gift-wrap.js +430 -0
- package/lib/loyalty-earn-rules.js +786 -0
- package/lib/marketing-budget.js +792 -0
- package/lib/operator-activity-feed.js +977 -0
- package/lib/operator-approvals.js +942 -0
- package/lib/operator-help-center.js +1020 -0
- package/lib/operator-inbox.js +889 -0
- package/lib/operator-sessions.js +701 -0
- package/lib/order-exchanges.js +602 -0
- package/lib/product-compare.js +804 -0
- package/lib/pwa-manifest.js +1005 -0
- package/lib/referral-leaderboard.js +612 -0
- package/lib/sales-tax-filings.js +807 -0
- package/lib/search-ranking.js +859 -0
- package/lib/shipping-insurance.js +757 -0
- package/lib/shrinkage-report.js +1182 -0
- package/lib/sidebar-widgets.js +952 -0
- package/lib/smart-restocking.js +1048 -0
- package/lib/split-shipments.js +7 -1
- package/lib/stock-receipts.js +834 -0
- package/lib/subscription-analytics.js +1032 -0
- package/lib/suggestion-box.js +921 -0
- package/lib/tax-remittance.js +625 -0
- package/lib/vendor-invoices.js +1021 -0
- package/lib/winback-campaigns.js +1350 -0
- package/lib/wishlist-digest.js +1133 -0
- package/package.json +1 -1
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.disputeResolution
|
|
4
|
+
* @title Dispute-resolution primitive — payment-processor chargeback
|
|
5
|
+
* / inquiry / pre-arbitration / arbitration lifecycle.
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Customer-initiated refund requests run through the storefront's
|
|
9
|
+
* own refund lane (`refundPolicy` / `refundAutomation` / `returns`).
|
|
10
|
+
* Processor-initiated disputes are a different beast: the customer
|
|
11
|
+
* (or their bank) opens a chargeback directly with the processor,
|
|
12
|
+
* the processor notifies the operator via webhook, and the operator
|
|
13
|
+
* has a fixed window — typically 7-21 days depending on the network
|
|
14
|
+
* and the dispute kind — to either accept the dispute (concede the
|
|
15
|
+
* funds) or respond with evidence and fight it. Missing the
|
|
16
|
+
* deadline is an automatic loss.
|
|
17
|
+
*
|
|
18
|
+
* This primitive holds the operator-facing state machine for that
|
|
19
|
+
* lane. It composes the optional `query` / `order` / `payment` /
|
|
20
|
+
* `customerRiskProfile` handles — none of them owned, all of them
|
|
21
|
+
* stubbable — and surfaces the operator-console reads:
|
|
22
|
+
*
|
|
23
|
+
* var dr = bShop.disputeResolution.create({
|
|
24
|
+
* query: q,
|
|
25
|
+
* order: orders, // optional — order lookup
|
|
26
|
+
* payment: pay, // optional — refund-on-accept
|
|
27
|
+
* customerRiskProfile: risk, // optional — risk band hint
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* await dr.recordDispute({
|
|
31
|
+
* dispute_id: "dp_stripe_1",
|
|
32
|
+
* order_id: "order-1",
|
|
33
|
+
* processor: "stripe",
|
|
34
|
+
* kind: "chargeback",
|
|
35
|
+
* amount_minor: 4500,
|
|
36
|
+
* currency: "USD",
|
|
37
|
+
* reason_code: "fraudulent",
|
|
38
|
+
* due_by: Date.now() + 7 * 24 * 60 * 60 * 1000,
|
|
39
|
+
* });
|
|
40
|
+
*
|
|
41
|
+
* await dr.addEvidence({
|
|
42
|
+
* dispute_id: "dp_stripe_1",
|
|
43
|
+
* kind: "signed_proof_of_delivery",
|
|
44
|
+
* blob_ref: "s3://evidence/order-1/pod.pdf",
|
|
45
|
+
* });
|
|
46
|
+
*
|
|
47
|
+
* await dr.submitResponse({
|
|
48
|
+
* dispute_id: "dp_stripe_1",
|
|
49
|
+
* narrative: "Order shipped to verified billing address ...",
|
|
50
|
+
* });
|
|
51
|
+
*
|
|
52
|
+
* await dr.recordProcessorDecision({
|
|
53
|
+
* dispute_id: "dp_stripe_1",
|
|
54
|
+
* outcome: "won",
|
|
55
|
+
* });
|
|
56
|
+
*
|
|
57
|
+
* FSM (on `disputes.status`):
|
|
58
|
+
* open recordDispute
|
|
59
|
+
* open → submitted submitResponse
|
|
60
|
+
* open → accepted recordProcessorDecision(accepted)
|
|
61
|
+
* open → lost recordProcessorDecision(lost) — deadline miss
|
|
62
|
+
* submitted → won recordProcessorDecision(won)
|
|
63
|
+
* submitted → lost recordProcessorDecision(lost)
|
|
64
|
+
* submitted → escalated recordProcessorDecision(escalated)
|
|
65
|
+
* lost → written_off markWriteoff
|
|
66
|
+
*
|
|
67
|
+
* Surface:
|
|
68
|
+
* - recordDispute({ dispute_id, order_id, processor, kind,
|
|
69
|
+
* amount_minor, currency, reason_code,
|
|
70
|
+
* opened_at?, due_by? })
|
|
71
|
+
* - addEvidence({ dispute_id, kind, blob_ref, notes? })
|
|
72
|
+
* - submitResponse({ dispute_id, narrative })
|
|
73
|
+
* - recordProcessorDecision({ dispute_id, outcome,
|
|
74
|
+
* processor_decision_at? })
|
|
75
|
+
* - markWriteoff({ dispute_id, operator_id, reason })
|
|
76
|
+
* - getDispute(dispute_id)
|
|
77
|
+
* - disputesForOrder(order_id)
|
|
78
|
+
* - openDisputes({ processor?, kind?, limit? })
|
|
79
|
+
* - historyForDispute(dispute_id)
|
|
80
|
+
* - metricsForProcessor({ processor, from, to })
|
|
81
|
+
*
|
|
82
|
+
* Storage:
|
|
83
|
+
* - disputes + dispute_evidence + dispute_responses
|
|
84
|
+
* (migration 0173_dispute_resolution.sql).
|
|
85
|
+
*
|
|
86
|
+
* Monotonic clock: a per-factory monotonic timestamp ensures that
|
|
87
|
+
* two writes against the same dispute in the same millisecond
|
|
88
|
+
* carry strictly-increasing timestamps. The
|
|
89
|
+
* `(dispute_id, recorded_at ASC)` and
|
|
90
|
+
* `(dispute_id, submitted_at ASC)` indexes then return evidence and
|
|
91
|
+
* responses in deterministic order.
|
|
92
|
+
*
|
|
93
|
+
* @primitive disputeResolution
|
|
94
|
+
* @related refundAutomation, returns, payment, customerRiskProfile
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
// ---- constants ----------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
var MAX_DISPUTE_ID_LEN = 128;
|
|
100
|
+
var MAX_ORDER_ID_LEN = 128;
|
|
101
|
+
var MAX_PROCESSOR_LEN = 64;
|
|
102
|
+
var MAX_REASON_CODE_LEN = 64;
|
|
103
|
+
var MAX_NARRATIVE_LEN = 16000;
|
|
104
|
+
var MAX_BLOB_REF_LEN = 1024;
|
|
105
|
+
var MAX_NOTES_LEN = 4000;
|
|
106
|
+
var MAX_OPERATOR_ID_LEN = 128;
|
|
107
|
+
var MAX_WRITEOFF_REASON_LEN = 1000;
|
|
108
|
+
var MAX_AMOUNT_MINOR = 1000000000; // $10,000,000.00 cap
|
|
109
|
+
var MAX_LIST_LIMIT = 200;
|
|
110
|
+
|
|
111
|
+
var DISPUTE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
112
|
+
var ORDER_ID_CTRL = /[\x00-\x1f\x7f]/;
|
|
113
|
+
var PROCESSOR_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
114
|
+
var REASON_CODE_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
|
|
115
|
+
var CURRENCY_RE = /^[A-Z]{3}$/;
|
|
116
|
+
var OPERATOR_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
117
|
+
var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
118
|
+
|
|
119
|
+
var KINDS = Object.freeze([
|
|
120
|
+
"chargeback",
|
|
121
|
+
"inquiry",
|
|
122
|
+
"pre_arbitration",
|
|
123
|
+
"arbitration",
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
var EVIDENCE_KINDS = Object.freeze([
|
|
127
|
+
"signed_proof_of_delivery",
|
|
128
|
+
"customer_communication",
|
|
129
|
+
"refund_policy",
|
|
130
|
+
"receipt",
|
|
131
|
+
"shipping_label",
|
|
132
|
+
"customer_signature",
|
|
133
|
+
"other",
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
var OUTCOMES = Object.freeze([
|
|
137
|
+
"won",
|
|
138
|
+
"lost",
|
|
139
|
+
"accepted",
|
|
140
|
+
"escalated",
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
var STATUSES = Object.freeze([
|
|
144
|
+
"open",
|
|
145
|
+
"submitted",
|
|
146
|
+
"won",
|
|
147
|
+
"lost",
|
|
148
|
+
"accepted",
|
|
149
|
+
"escalated",
|
|
150
|
+
"written_off",
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
var bShop;
|
|
154
|
+
function _b() {
|
|
155
|
+
if (!bShop) bShop = require("./index");
|
|
156
|
+
return bShop.framework;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---- validators ---------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
function _disputeId(s) {
|
|
162
|
+
if (typeof s !== "string" || !DISPUTE_ID_RE.test(s)) {
|
|
163
|
+
throw new TypeError(
|
|
164
|
+
"disputeResolution: dispute_id must match /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ (<= " +
|
|
165
|
+
MAX_DISPUTE_ID_LEN + " chars)"
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return s;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function _orderId(s) {
|
|
172
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_ORDER_ID_LEN) {
|
|
173
|
+
throw new TypeError(
|
|
174
|
+
"disputeResolution: order_id must be a non-empty string <= " +
|
|
175
|
+
MAX_ORDER_ID_LEN + " chars"
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
if (ORDER_ID_CTRL.test(s)) {
|
|
179
|
+
throw new TypeError("disputeResolution: order_id must not contain control bytes");
|
|
180
|
+
}
|
|
181
|
+
return s;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function _processor(s) {
|
|
185
|
+
if (typeof s !== "string" || !PROCESSOR_RE.test(s)) {
|
|
186
|
+
throw new TypeError(
|
|
187
|
+
"disputeResolution: processor must match /^[a-z0-9][a-z0-9_-]*$/ (<= " +
|
|
188
|
+
MAX_PROCESSOR_LEN + " chars)"
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return s;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function _kind(s) {
|
|
195
|
+
if (typeof s !== "string" || KINDS.indexOf(s) === -1) {
|
|
196
|
+
throw new TypeError("disputeResolution: kind must be one of " + KINDS.join(", "));
|
|
197
|
+
}
|
|
198
|
+
return s;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function _evidenceKind(s) {
|
|
202
|
+
if (typeof s !== "string" || EVIDENCE_KINDS.indexOf(s) === -1) {
|
|
203
|
+
throw new TypeError(
|
|
204
|
+
"disputeResolution: evidence kind must be one of " + EVIDENCE_KINDS.join(", ")
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return s;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function _outcome(s) {
|
|
211
|
+
if (typeof s !== "string" || OUTCOMES.indexOf(s) === -1) {
|
|
212
|
+
throw new TypeError("disputeResolution: outcome must be one of " + OUTCOMES.join(", "));
|
|
213
|
+
}
|
|
214
|
+
return s;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function _amountMinor(n) {
|
|
218
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 0 || n > MAX_AMOUNT_MINOR) {
|
|
219
|
+
throw new TypeError(
|
|
220
|
+
"disputeResolution: amount_minor must be an integer in [0, " +
|
|
221
|
+
MAX_AMOUNT_MINOR + "]"
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return n;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function _currency(s) {
|
|
228
|
+
if (typeof s !== "string" || !CURRENCY_RE.test(s)) {
|
|
229
|
+
throw new TypeError(
|
|
230
|
+
"disputeResolution: currency must be a 3-letter ISO-4217 alpha code (e.g. 'USD')"
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
return s;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function _reasonCode(s) {
|
|
237
|
+
if (typeof s !== "string" || !REASON_CODE_RE.test(s)) {
|
|
238
|
+
throw new TypeError(
|
|
239
|
+
"disputeResolution: reason_code must match /^[A-Za-z0-9][A-Za-z0-9_.-]*$/ (<= " +
|
|
240
|
+
MAX_REASON_CODE_LEN + " chars)"
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
return s;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function _narrative(s) {
|
|
247
|
+
if (typeof s !== "string" || !s.length) {
|
|
248
|
+
throw new TypeError("disputeResolution: narrative must be a non-empty string");
|
|
249
|
+
}
|
|
250
|
+
if (s.length > MAX_NARRATIVE_LEN) {
|
|
251
|
+
throw new TypeError(
|
|
252
|
+
"disputeResolution: narrative length " + s.length +
|
|
253
|
+
" exceeds cap " + MAX_NARRATIVE_LEN
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
257
|
+
throw new TypeError("disputeResolution: narrative must not contain control bytes");
|
|
258
|
+
}
|
|
259
|
+
return s;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function _blobRef(s) {
|
|
263
|
+
if (typeof s !== "string" || !s.length) {
|
|
264
|
+
throw new TypeError("disputeResolution: blob_ref must be a non-empty string");
|
|
265
|
+
}
|
|
266
|
+
if (s.length > MAX_BLOB_REF_LEN) {
|
|
267
|
+
throw new TypeError(
|
|
268
|
+
"disputeResolution: blob_ref length " + s.length +
|
|
269
|
+
" exceeds cap " + MAX_BLOB_REF_LEN
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
273
|
+
throw new TypeError("disputeResolution: blob_ref must not contain control bytes");
|
|
274
|
+
}
|
|
275
|
+
return s;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function _notesOpt(s) {
|
|
279
|
+
if (s == null) return null;
|
|
280
|
+
if (typeof s !== "string") {
|
|
281
|
+
throw new TypeError("disputeResolution: notes must be a string when provided");
|
|
282
|
+
}
|
|
283
|
+
if (s.length > MAX_NOTES_LEN) {
|
|
284
|
+
throw new TypeError(
|
|
285
|
+
"disputeResolution: notes length " + s.length + " exceeds cap " + MAX_NOTES_LEN
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
289
|
+
throw new TypeError("disputeResolution: notes must not contain control bytes");
|
|
290
|
+
}
|
|
291
|
+
return s;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function _operatorId(s) {
|
|
295
|
+
if (typeof s !== "string" || !OPERATOR_ID_RE.test(s)) {
|
|
296
|
+
throw new TypeError(
|
|
297
|
+
"disputeResolution: operator_id must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " +
|
|
298
|
+
MAX_OPERATOR_ID_LEN + " chars)"
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
return s;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function _writeoffReason(s) {
|
|
305
|
+
if (typeof s !== "string" || !s.length) {
|
|
306
|
+
throw new TypeError("disputeResolution: writeoff reason must be a non-empty string");
|
|
307
|
+
}
|
|
308
|
+
if (s.length > MAX_WRITEOFF_REASON_LEN) {
|
|
309
|
+
throw new TypeError(
|
|
310
|
+
"disputeResolution: writeoff reason length " + s.length +
|
|
311
|
+
" exceeds cap " + MAX_WRITEOFF_REASON_LEN
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
315
|
+
throw new TypeError("disputeResolution: writeoff reason must not contain control bytes");
|
|
316
|
+
}
|
|
317
|
+
return s;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function _epochMs(n, label) {
|
|
321
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
|
|
322
|
+
throw new TypeError(
|
|
323
|
+
"disputeResolution: " + label + " must be a non-negative integer epoch-ms"
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
return n;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function _epochMsOpt(n, label) {
|
|
330
|
+
if (n == null) return null;
|
|
331
|
+
return _epochMs(n, label);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function _limit(n) {
|
|
335
|
+
if (n == null) return 50;
|
|
336
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
337
|
+
throw new TypeError(
|
|
338
|
+
"disputeResolution: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]"
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
return n;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ---- row hydration ------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
function _hydrateDispute(r) {
|
|
347
|
+
if (!r) return null;
|
|
348
|
+
return {
|
|
349
|
+
dispute_id: r.dispute_id,
|
|
350
|
+
order_id: r.order_id,
|
|
351
|
+
processor: r.processor,
|
|
352
|
+
kind: r.kind,
|
|
353
|
+
amount_minor: Number(r.amount_minor),
|
|
354
|
+
currency: r.currency,
|
|
355
|
+
reason_code: r.reason_code,
|
|
356
|
+
status: r.status,
|
|
357
|
+
outcome: r.outcome == null ? null : r.outcome,
|
|
358
|
+
opened_at: Number(r.opened_at),
|
|
359
|
+
due_by: r.due_by == null ? null : Number(r.due_by),
|
|
360
|
+
submitted_at: r.submitted_at == null ? null : Number(r.submitted_at),
|
|
361
|
+
decided_at: r.decided_at == null ? null : Number(r.decided_at),
|
|
362
|
+
written_off_at: r.written_off_at == null ? null : Number(r.written_off_at),
|
|
363
|
+
written_off_reason: r.written_off_reason == null ? null : r.written_off_reason,
|
|
364
|
+
written_off_by: r.written_off_by == null ? null : r.written_off_by,
|
|
365
|
+
updated_at: Number(r.updated_at),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function _hydrateEvidence(r) {
|
|
370
|
+
if (!r) return null;
|
|
371
|
+
return {
|
|
372
|
+
id: r.id,
|
|
373
|
+
dispute_id: r.dispute_id,
|
|
374
|
+
kind: r.kind,
|
|
375
|
+
blob_ref: r.blob_ref,
|
|
376
|
+
notes: r.notes == null ? null : r.notes,
|
|
377
|
+
recorded_at: Number(r.recorded_at),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function _hydrateResponse(r) {
|
|
382
|
+
if (!r) return null;
|
|
383
|
+
return {
|
|
384
|
+
id: r.id,
|
|
385
|
+
dispute_id: r.dispute_id,
|
|
386
|
+
narrative: r.narrative,
|
|
387
|
+
submitted_at: Number(r.submitted_at),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ---- factory ------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
function create(opts) {
|
|
394
|
+
opts = opts || {};
|
|
395
|
+
var query = opts.query;
|
|
396
|
+
if (!query) {
|
|
397
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
398
|
+
}
|
|
399
|
+
// Optional composed handles. None owned; all stubbable. The
|
|
400
|
+
// `order` handle is held for forward-compat order-snapshot reads;
|
|
401
|
+
// `payment` is composed when the operator accepts a dispute and
|
|
402
|
+
// wants the refund issued through the same flow; `customerRiskProfile`
|
|
403
|
+
// is consulted by callers that want to enrich the dispute view with
|
|
404
|
+
// a risk band but the primitive itself doesn't gate any behavior on
|
|
405
|
+
// it. Keeping them in the factory signature locks the wiring shape
|
|
406
|
+
// across future feature work.
|
|
407
|
+
var orderHandle = opts.order || null;
|
|
408
|
+
var paymentHandle = opts.payment || null;
|
|
409
|
+
var riskHandle = opts.customerRiskProfile || null;
|
|
410
|
+
|
|
411
|
+
// Per-factory monotonic clock. Two writes against the same dispute
|
|
412
|
+
// (record + addEvidence, or back-to-back evidence rows) in the same
|
|
413
|
+
// wall-clock millisecond would otherwise tie on `recorded_at` and
|
|
414
|
+
// make the `(dispute_id, recorded_at ASC)` index ambiguous.
|
|
415
|
+
// Forward-leap when the wall clock outpaces the counter; otherwise
|
|
416
|
+
// bump by 1ms so the sequence is strictly increasing per primitive
|
|
417
|
+
// instance.
|
|
418
|
+
var _lastTs = 0;
|
|
419
|
+
function _monotonicTs() {
|
|
420
|
+
var wall = Date.now();
|
|
421
|
+
if (wall > _lastTs) _lastTs = wall;
|
|
422
|
+
else _lastTs += 1;
|
|
423
|
+
return _lastTs;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---- internal helpers ---------------------------------------------
|
|
427
|
+
|
|
428
|
+
async function _getDisputeRow(disputeId) {
|
|
429
|
+
var r = (await query(
|
|
430
|
+
"SELECT * FROM disputes WHERE dispute_id = ?1 LIMIT 1",
|
|
431
|
+
[disputeId],
|
|
432
|
+
)).rows[0];
|
|
433
|
+
return r || null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ---- recordDispute ------------------------------------------------
|
|
437
|
+
|
|
438
|
+
async function recordDispute(input) {
|
|
439
|
+
if (!input || typeof input !== "object") {
|
|
440
|
+
throw new TypeError("disputeResolution.recordDispute: input object required");
|
|
441
|
+
}
|
|
442
|
+
var disputeId = _disputeId(input.dispute_id);
|
|
443
|
+
var orderId = _orderId(input.order_id);
|
|
444
|
+
var processor = _processor(input.processor);
|
|
445
|
+
var kind = _kind(input.kind);
|
|
446
|
+
var amount = _amountMinor(input.amount_minor);
|
|
447
|
+
var currency = _currency(input.currency);
|
|
448
|
+
var reasonCd = _reasonCode(input.reason_code);
|
|
449
|
+
var openedAt = input.opened_at == null ? _monotonicTs() : _epochMs(input.opened_at, "opened_at");
|
|
450
|
+
var dueBy = _epochMsOpt(input.due_by, "due_by");
|
|
451
|
+
if (dueBy != null && dueBy < openedAt) {
|
|
452
|
+
throw new TypeError("disputeResolution.recordDispute: due_by must be >= opened_at");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Refuse redefine — disputes are identified by the processor's
|
|
456
|
+
// dispute_id; a duplicate webhook should be deduped at the webhook
|
|
457
|
+
// layer (use `getDispute` to test for existence before recording).
|
|
458
|
+
var existing = await _getDisputeRow(disputeId);
|
|
459
|
+
if (existing) {
|
|
460
|
+
throw new TypeError(
|
|
461
|
+
"disputeResolution.recordDispute: dispute_id " +
|
|
462
|
+
JSON.stringify(disputeId) + " already exists"
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
var ts = _monotonicTs();
|
|
467
|
+
if (ts < openedAt) ts = openedAt;
|
|
468
|
+
await query(
|
|
469
|
+
"INSERT INTO disputes (dispute_id, order_id, processor, kind, amount_minor, " +
|
|
470
|
+
"currency, reason_code, status, outcome, opened_at, due_by, submitted_at, " +
|
|
471
|
+
"decided_at, written_off_at, written_off_reason, written_off_by, updated_at) " +
|
|
472
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'open', NULL, ?8, ?9, NULL, NULL, NULL, " +
|
|
473
|
+
"NULL, NULL, ?10)",
|
|
474
|
+
[
|
|
475
|
+
disputeId, orderId, processor, kind, amount,
|
|
476
|
+
currency, reasonCd, openedAt, dueBy, ts,
|
|
477
|
+
],
|
|
478
|
+
);
|
|
479
|
+
return _hydrateDispute(await _getDisputeRow(disputeId));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ---- addEvidence --------------------------------------------------
|
|
483
|
+
|
|
484
|
+
async function addEvidence(input) {
|
|
485
|
+
if (!input || typeof input !== "object") {
|
|
486
|
+
throw new TypeError("disputeResolution.addEvidence: input object required");
|
|
487
|
+
}
|
|
488
|
+
var disputeId = _disputeId(input.dispute_id);
|
|
489
|
+
var kind = _evidenceKind(input.kind);
|
|
490
|
+
var blobRef = _blobRef(input.blob_ref);
|
|
491
|
+
var notes = _notesOpt(input.notes);
|
|
492
|
+
|
|
493
|
+
var dispute = await _getDisputeRow(disputeId);
|
|
494
|
+
if (!dispute) {
|
|
495
|
+
throw new TypeError(
|
|
496
|
+
"disputeResolution.addEvidence: dispute_id " +
|
|
497
|
+
JSON.stringify(disputeId) + " not found — call recordDispute first"
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
// Evidence can be added while the dispute is open OR after it has
|
|
501
|
+
// been submitted — operators sometimes find additional evidence
|
|
502
|
+
// mid-response window and the processor's surface allows
|
|
503
|
+
// supplementing. Refuse evidence on terminal states.
|
|
504
|
+
if (dispute.status !== "open" && dispute.status !== "submitted") {
|
|
505
|
+
throw new TypeError(
|
|
506
|
+
"disputeResolution.addEvidence: dispute " + JSON.stringify(disputeId) +
|
|
507
|
+
" is in terminal status " + JSON.stringify(dispute.status) +
|
|
508
|
+
" — evidence is only accepted in open / submitted"
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
var id = _b().uuid.v7();
|
|
513
|
+
var ts = _monotonicTs();
|
|
514
|
+
await query(
|
|
515
|
+
"INSERT INTO dispute_evidence (id, dispute_id, kind, blob_ref, notes, recorded_at) " +
|
|
516
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
517
|
+
[id, disputeId, kind, blobRef, notes, ts],
|
|
518
|
+
);
|
|
519
|
+
// Bump updated_at on the parent dispute so the dashboard's
|
|
520
|
+
// "recently touched" sort surfaces the dispute again.
|
|
521
|
+
await query(
|
|
522
|
+
"UPDATE disputes SET updated_at = ?1 WHERE dispute_id = ?2",
|
|
523
|
+
[ts, disputeId],
|
|
524
|
+
);
|
|
525
|
+
return {
|
|
526
|
+
id: id,
|
|
527
|
+
dispute_id: disputeId,
|
|
528
|
+
kind: kind,
|
|
529
|
+
blob_ref: blobRef,
|
|
530
|
+
notes: notes,
|
|
531
|
+
recorded_at: ts,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ---- submitResponse -----------------------------------------------
|
|
536
|
+
|
|
537
|
+
async function submitResponse(input) {
|
|
538
|
+
if (!input || typeof input !== "object") {
|
|
539
|
+
throw new TypeError("disputeResolution.submitResponse: input object required");
|
|
540
|
+
}
|
|
541
|
+
var disputeId = _disputeId(input.dispute_id);
|
|
542
|
+
var narrative = _narrative(input.narrative);
|
|
543
|
+
|
|
544
|
+
var dispute = await _getDisputeRow(disputeId);
|
|
545
|
+
if (!dispute) {
|
|
546
|
+
throw new TypeError(
|
|
547
|
+
"disputeResolution.submitResponse: dispute_id " +
|
|
548
|
+
JSON.stringify(disputeId) + " not found"
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
if (dispute.status !== "open") {
|
|
552
|
+
throw new TypeError(
|
|
553
|
+
"disputeResolution.submitResponse: dispute " + JSON.stringify(disputeId) +
|
|
554
|
+
" is in status " + JSON.stringify(dispute.status) +
|
|
555
|
+
" — submitResponse requires status=open"
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
var id = _b().uuid.v7();
|
|
560
|
+
var ts = _monotonicTs();
|
|
561
|
+
await query(
|
|
562
|
+
"INSERT INTO dispute_responses (id, dispute_id, narrative, submitted_at) " +
|
|
563
|
+
"VALUES (?1, ?2, ?3, ?4)",
|
|
564
|
+
[id, disputeId, narrative, ts],
|
|
565
|
+
);
|
|
566
|
+
await query(
|
|
567
|
+
"UPDATE disputes SET status = 'submitted', submitted_at = ?1, updated_at = ?1 " +
|
|
568
|
+
"WHERE dispute_id = ?2",
|
|
569
|
+
[ts, disputeId],
|
|
570
|
+
);
|
|
571
|
+
return {
|
|
572
|
+
id: id,
|
|
573
|
+
dispute_id: disputeId,
|
|
574
|
+
narrative: narrative,
|
|
575
|
+
submitted_at: ts,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ---- recordProcessorDecision --------------------------------------
|
|
580
|
+
//
|
|
581
|
+
// The processor finished adjudicating. The outcome dictates the FSM
|
|
582
|
+
// transition:
|
|
583
|
+
// submitted → won the operator's evidence carried the day
|
|
584
|
+
// submitted → lost the processor sided with the cardholder
|
|
585
|
+
// submitted → escalated the cardholder escalated to pre_arbitration /
|
|
586
|
+
// arbitration — caller spawns a new dispute
|
|
587
|
+
// row keyed on the escalated dispute_id
|
|
588
|
+
// open → accepted the operator never responded and explicitly
|
|
589
|
+
// conceded the dispute (or the deadline ran out
|
|
590
|
+
// and the operator wants the audit row to say
|
|
591
|
+
// "we accepted it" vs. "we lost")
|
|
592
|
+
// open → lost the deadline ran out without a response
|
|
593
|
+
async function recordProcessorDecision(input) {
|
|
594
|
+
if (!input || typeof input !== "object") {
|
|
595
|
+
throw new TypeError("disputeResolution.recordProcessorDecision: input object required");
|
|
596
|
+
}
|
|
597
|
+
var disputeId = _disputeId(input.dispute_id);
|
|
598
|
+
var outcome = _outcome(input.outcome);
|
|
599
|
+
var decidedAt = input.processor_decision_at == null
|
|
600
|
+
? _monotonicTs()
|
|
601
|
+
: _epochMs(input.processor_decision_at, "processor_decision_at");
|
|
602
|
+
|
|
603
|
+
var dispute = await _getDisputeRow(disputeId);
|
|
604
|
+
if (!dispute) {
|
|
605
|
+
throw new TypeError(
|
|
606
|
+
"disputeResolution.recordProcessorDecision: dispute_id " +
|
|
607
|
+
JSON.stringify(disputeId) + " not found"
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
var current = dispute.status;
|
|
612
|
+
var nextStatus;
|
|
613
|
+
if (outcome === "won") {
|
|
614
|
+
if (current !== "submitted") {
|
|
615
|
+
throw new TypeError(
|
|
616
|
+
"disputeResolution.recordProcessorDecision: outcome 'won' requires status=submitted " +
|
|
617
|
+
"(current=" + JSON.stringify(current) + ")"
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
nextStatus = "won";
|
|
621
|
+
} else if (outcome === "escalated") {
|
|
622
|
+
if (current !== "submitted") {
|
|
623
|
+
throw new TypeError(
|
|
624
|
+
"disputeResolution.recordProcessorDecision: outcome 'escalated' requires status=submitted " +
|
|
625
|
+
"(current=" + JSON.stringify(current) + ")"
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
nextStatus = "escalated";
|
|
629
|
+
} else if (outcome === "accepted") {
|
|
630
|
+
if (current !== "open") {
|
|
631
|
+
throw new TypeError(
|
|
632
|
+
"disputeResolution.recordProcessorDecision: outcome 'accepted' requires status=open " +
|
|
633
|
+
"(current=" + JSON.stringify(current) + ")"
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
nextStatus = "accepted";
|
|
637
|
+
} else /* lost */ {
|
|
638
|
+
if (current !== "open" && current !== "submitted") {
|
|
639
|
+
throw new TypeError(
|
|
640
|
+
"disputeResolution.recordProcessorDecision: outcome 'lost' requires status in " +
|
|
641
|
+
"{open, submitted} (current=" + JSON.stringify(current) + ")"
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
nextStatus = "lost";
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
var ts = _monotonicTs();
|
|
648
|
+
if (ts < decidedAt) ts = decidedAt;
|
|
649
|
+
await query(
|
|
650
|
+
"UPDATE disputes SET status = ?1, outcome = ?2, decided_at = ?3, updated_at = ?3 " +
|
|
651
|
+
"WHERE dispute_id = ?4",
|
|
652
|
+
[nextStatus, outcome, ts, disputeId],
|
|
653
|
+
);
|
|
654
|
+
return _hydrateDispute(await _getDisputeRow(disputeId));
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ---- markWriteoff -------------------------------------------------
|
|
658
|
+
//
|
|
659
|
+
// After a `lost` dispute, the operator closes the books on the
|
|
660
|
+
// uncollectable funds by writing them off. Terminal — moves the
|
|
661
|
+
// dispute to `written_off` and stamps operator + reason for the
|
|
662
|
+
// audit log.
|
|
663
|
+
async function markWriteoff(input) {
|
|
664
|
+
if (!input || typeof input !== "object") {
|
|
665
|
+
throw new TypeError("disputeResolution.markWriteoff: input object required");
|
|
666
|
+
}
|
|
667
|
+
var disputeId = _disputeId(input.dispute_id);
|
|
668
|
+
var operatorId = _operatorId(input.operator_id);
|
|
669
|
+
var reason = _writeoffReason(input.reason);
|
|
670
|
+
|
|
671
|
+
var dispute = await _getDisputeRow(disputeId);
|
|
672
|
+
if (!dispute) {
|
|
673
|
+
throw new TypeError(
|
|
674
|
+
"disputeResolution.markWriteoff: dispute_id " +
|
|
675
|
+
JSON.stringify(disputeId) + " not found"
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
if (dispute.status !== "lost") {
|
|
679
|
+
throw new TypeError(
|
|
680
|
+
"disputeResolution.markWriteoff: dispute " + JSON.stringify(disputeId) +
|
|
681
|
+
" is in status " + JSON.stringify(dispute.status) +
|
|
682
|
+
" — markWriteoff requires status=lost"
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
var ts = _monotonicTs();
|
|
687
|
+
await query(
|
|
688
|
+
"UPDATE disputes SET status = 'written_off', written_off_at = ?1, " +
|
|
689
|
+
"written_off_reason = ?2, written_off_by = ?3, updated_at = ?1 " +
|
|
690
|
+
"WHERE dispute_id = ?4",
|
|
691
|
+
[ts, reason, operatorId, disputeId],
|
|
692
|
+
);
|
|
693
|
+
return _hydrateDispute(await _getDisputeRow(disputeId));
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ---- getDispute ---------------------------------------------------
|
|
697
|
+
|
|
698
|
+
async function getDispute(disputeId) {
|
|
699
|
+
_disputeId(disputeId);
|
|
700
|
+
return _hydrateDispute(await _getDisputeRow(disputeId));
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ---- disputesForOrder ---------------------------------------------
|
|
704
|
+
|
|
705
|
+
async function disputesForOrder(orderId) {
|
|
706
|
+
_orderId(orderId);
|
|
707
|
+
var rows = (await query(
|
|
708
|
+
"SELECT * FROM disputes WHERE order_id = ?1 ORDER BY opened_at DESC",
|
|
709
|
+
[orderId],
|
|
710
|
+
)).rows;
|
|
711
|
+
var out = [];
|
|
712
|
+
for (var i = 0; i < rows.length; i += 1) out.push(_hydrateDispute(rows[i]));
|
|
713
|
+
return out;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ---- openDisputes -------------------------------------------------
|
|
717
|
+
//
|
|
718
|
+
// Surfaces disputes still requiring operator attention — status in
|
|
719
|
+
// {open, submitted}. Sorts by due_by ASC NULLS LAST so the soonest
|
|
720
|
+
// deadline floats to the top of the queue. Optional filters: processor,
|
|
721
|
+
// kind, limit. The operator console wires this to the "needs response"
|
|
722
|
+
// sidebar.
|
|
723
|
+
async function openDisputes(listOpts) {
|
|
724
|
+
listOpts = listOpts || {};
|
|
725
|
+
var processor = null;
|
|
726
|
+
if (listOpts.processor != null) processor = _processor(listOpts.processor);
|
|
727
|
+
var kind = null;
|
|
728
|
+
if (listOpts.kind != null) kind = _kind(listOpts.kind);
|
|
729
|
+
var limit = _limit(listOpts.limit);
|
|
730
|
+
|
|
731
|
+
var sql = "SELECT * FROM disputes WHERE status IN ('open', 'submitted')";
|
|
732
|
+
var params = [];
|
|
733
|
+
var idx = 1;
|
|
734
|
+
if (processor != null) {
|
|
735
|
+
sql += " AND processor = ?" + idx;
|
|
736
|
+
params.push(processor);
|
|
737
|
+
idx += 1;
|
|
738
|
+
}
|
|
739
|
+
if (kind != null) {
|
|
740
|
+
sql += " AND kind = ?" + idx;
|
|
741
|
+
params.push(kind);
|
|
742
|
+
idx += 1;
|
|
743
|
+
}
|
|
744
|
+
// SQLite sorts NULL first in ASC; coalesce to a far-future sentinel
|
|
745
|
+
// so disputes without a deadline sink below those that have one.
|
|
746
|
+
sql += " ORDER BY COALESCE(due_by, 9223372036854775807) ASC, opened_at ASC LIMIT ?" + idx;
|
|
747
|
+
params.push(limit);
|
|
748
|
+
|
|
749
|
+
var rows = (await query(sql, params)).rows;
|
|
750
|
+
var out = [];
|
|
751
|
+
for (var i = 0; i < rows.length; i += 1) out.push(_hydrateDispute(rows[i]));
|
|
752
|
+
return out;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// ---- historyForDispute --------------------------------------------
|
|
756
|
+
//
|
|
757
|
+
// Returns the dispute row + ordered evidence + ordered responses.
|
|
758
|
+
// Mirrors the operator-console "dispute detail" view.
|
|
759
|
+
async function historyForDispute(disputeId) {
|
|
760
|
+
_disputeId(disputeId);
|
|
761
|
+
var dispute = _hydrateDispute(await _getDisputeRow(disputeId));
|
|
762
|
+
if (!dispute) return null;
|
|
763
|
+
|
|
764
|
+
var ev = (await query(
|
|
765
|
+
"SELECT * FROM dispute_evidence WHERE dispute_id = ?1 ORDER BY recorded_at ASC",
|
|
766
|
+
[disputeId],
|
|
767
|
+
)).rows;
|
|
768
|
+
var evidence = [];
|
|
769
|
+
for (var i = 0; i < ev.length; i += 1) evidence.push(_hydrateEvidence(ev[i]));
|
|
770
|
+
|
|
771
|
+
var rs = (await query(
|
|
772
|
+
"SELECT * FROM dispute_responses WHERE dispute_id = ?1 ORDER BY submitted_at ASC",
|
|
773
|
+
[disputeId],
|
|
774
|
+
)).rows;
|
|
775
|
+
var responses = [];
|
|
776
|
+
for (var j = 0; j < rs.length; j += 1) responses.push(_hydrateResponse(rs[j]));
|
|
777
|
+
|
|
778
|
+
return {
|
|
779
|
+
dispute: dispute,
|
|
780
|
+
evidence: evidence,
|
|
781
|
+
responses: responses,
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ---- metricsForProcessor ------------------------------------------
|
|
786
|
+
//
|
|
787
|
+
// Rolls up dispute outcomes per processor over a [from, to] window.
|
|
788
|
+
// Win rate is the operator-meaningful metric: wins / decided. A
|
|
789
|
+
// decided dispute is one whose status is in {won, lost, accepted}
|
|
790
|
+
// — escalated rolls into its descendant dispute's count, not the
|
|
791
|
+
// original.
|
|
792
|
+
async function metricsForProcessor(input) {
|
|
793
|
+
if (!input || typeof input !== "object") {
|
|
794
|
+
throw new TypeError("disputeResolution.metricsForProcessor: input object required");
|
|
795
|
+
}
|
|
796
|
+
var processor = _processor(input.processor);
|
|
797
|
+
var from = _epochMs(input.from, "from");
|
|
798
|
+
var to = _epochMs(input.to, "to");
|
|
799
|
+
if (from > to) {
|
|
800
|
+
throw new TypeError("disputeResolution.metricsForProcessor: from must be <= to");
|
|
801
|
+
}
|
|
802
|
+
var rows = (await query(
|
|
803
|
+
"SELECT status, amount_minor FROM disputes " +
|
|
804
|
+
"WHERE processor = ?1 AND opened_at >= ?2 AND opened_at <= ?3",
|
|
805
|
+
[processor, from, to],
|
|
806
|
+
)).rows;
|
|
807
|
+
var openCount = 0;
|
|
808
|
+
var submittedCount = 0;
|
|
809
|
+
var wonCount = 0;
|
|
810
|
+
var lostCount = 0;
|
|
811
|
+
var acceptedCount = 0;
|
|
812
|
+
var escalatedCount = 0;
|
|
813
|
+
var writtenOffCount = 0;
|
|
814
|
+
var totalDisputedMinor = 0;
|
|
815
|
+
var totalLostMinor = 0;
|
|
816
|
+
var totalWrittenOffMinor = 0;
|
|
817
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
818
|
+
var r = rows[i];
|
|
819
|
+
var amt = Number(r.amount_minor || 0);
|
|
820
|
+
totalDisputedMinor += amt;
|
|
821
|
+
if (r.status === "open") openCount += 1;
|
|
822
|
+
else if (r.status === "submitted") submittedCount += 1;
|
|
823
|
+
else if (r.status === "won") wonCount += 1;
|
|
824
|
+
else if (r.status === "lost") { lostCount += 1; totalLostMinor += amt; }
|
|
825
|
+
else if (r.status === "accepted") { acceptedCount += 1; totalLostMinor += amt; }
|
|
826
|
+
else if (r.status === "escalated") escalatedCount += 1;
|
|
827
|
+
else if (r.status === "written_off") { writtenOffCount += 1; totalWrittenOffMinor += amt; }
|
|
828
|
+
}
|
|
829
|
+
var decidedCount = wonCount + lostCount + acceptedCount;
|
|
830
|
+
var winRate = decidedCount === 0 ? null : wonCount / decidedCount;
|
|
831
|
+
return {
|
|
832
|
+
processor: processor,
|
|
833
|
+
from: from,
|
|
834
|
+
to: to,
|
|
835
|
+
total_count: rows.length,
|
|
836
|
+
open_count: openCount,
|
|
837
|
+
submitted_count: submittedCount,
|
|
838
|
+
won_count: wonCount,
|
|
839
|
+
lost_count: lostCount,
|
|
840
|
+
accepted_count: acceptedCount,
|
|
841
|
+
escalated_count: escalatedCount,
|
|
842
|
+
written_off_count: writtenOffCount,
|
|
843
|
+
decided_count: decidedCount,
|
|
844
|
+
win_rate: winRate,
|
|
845
|
+
total_disputed_minor: totalDisputedMinor,
|
|
846
|
+
total_lost_minor: totalLostMinor,
|
|
847
|
+
total_written_off_minor: totalWrittenOffMinor,
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Composed handles held for forward-compatibility. The v1 surface
|
|
852
|
+
// doesn't read them; future feature work will compose the order
|
|
853
|
+
// snapshot into recordDispute (auto-derive amount + currency) and
|
|
854
|
+
// call payment.refund when an `accepted` outcome should also issue
|
|
855
|
+
// the funds back through the processor. Keeping the handles in the
|
|
856
|
+
// factory keeps the operator-facing wiring stable across those
|
|
857
|
+
// additions.
|
|
858
|
+
void orderHandle;
|
|
859
|
+
void paymentHandle;
|
|
860
|
+
void riskHandle;
|
|
861
|
+
|
|
862
|
+
return {
|
|
863
|
+
recordDispute: recordDispute,
|
|
864
|
+
addEvidence: addEvidence,
|
|
865
|
+
submitResponse: submitResponse,
|
|
866
|
+
recordProcessorDecision: recordProcessorDecision,
|
|
867
|
+
markWriteoff: markWriteoff,
|
|
868
|
+
getDispute: getDispute,
|
|
869
|
+
disputesForOrder: disputesForOrder,
|
|
870
|
+
openDisputes: openDisputes,
|
|
871
|
+
historyForDispute: historyForDispute,
|
|
872
|
+
metricsForProcessor: metricsForProcessor,
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
module.exports = {
|
|
877
|
+
create: create,
|
|
878
|
+
KINDS: KINDS.slice(),
|
|
879
|
+
EVIDENCE_KINDS: EVIDENCE_KINDS.slice(),
|
|
880
|
+
OUTCOMES: OUTCOMES.slice(),
|
|
881
|
+
STATUSES: STATUSES.slice(),
|
|
882
|
+
MAX_AMOUNT_MINOR: MAX_AMOUNT_MINOR,
|
|
883
|
+
MAX_NARRATIVE_LEN: MAX_NARRATIVE_LEN,
|
|
884
|
+
MAX_BLOB_REF_LEN: MAX_BLOB_REF_LEN,
|
|
885
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
886
|
+
};
|