@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +42 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/loyalty-earn-rules.js +786 -0
  22. package/lib/marketing-budget.js +792 -0
  23. package/lib/operator-activity-feed.js +977 -0
  24. package/lib/operator-approvals.js +942 -0
  25. package/lib/operator-help-center.js +1020 -0
  26. package/lib/operator-inbox.js +889 -0
  27. package/lib/operator-sessions.js +701 -0
  28. package/lib/order-exchanges.js +602 -0
  29. package/lib/product-compare.js +804 -0
  30. package/lib/pwa-manifest.js +1005 -0
  31. package/lib/referral-leaderboard.js +612 -0
  32. package/lib/sales-tax-filings.js +807 -0
  33. package/lib/search-ranking.js +859 -0
  34. package/lib/shipping-insurance.js +757 -0
  35. package/lib/shrinkage-report.js +1182 -0
  36. package/lib/sidebar-widgets.js +952 -0
  37. package/lib/smart-restocking.js +1048 -0
  38. package/lib/split-shipments.js +7 -1
  39. package/lib/stock-receipts.js +834 -0
  40. package/lib/subscription-analytics.js +1032 -0
  41. package/lib/suggestion-box.js +921 -0
  42. package/lib/tax-remittance.js +625 -0
  43. package/lib/vendor-invoices.js +1021 -0
  44. package/lib/winback-campaigns.js +1350 -0
  45. package/lib/wishlist-digest.js +1133 -0
  46. 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
+ };