chargeback-guard 2.0.0
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/LICENSE +21 -0
- package/README.md +311 -0
- package/docs/api.md +278 -0
- package/docs/architecture.md +281 -0
- package/docs/configuration.md +292 -0
- package/docs/getting-started.md +155 -0
- package/examples/advancedConfig.ts +123 -0
- package/examples/basicUsage.ts +98 -0
- package/examples/stripeIntegration.ts +106 -0
- package/package.json +181 -0
- package/src/ai/fraudDetection.ts +261 -0
- package/src/ai/patternRecognition.ts +218 -0
- package/src/analytics/dashboard.ts +195 -0
- package/src/analytics/metrics.ts +175 -0
- package/src/analytics/predictions.ts +135 -0
- package/src/analytics/reports.ts +221 -0
- package/src/api/controllers.ts +339 -0
- package/src/api/middleware.ts +172 -0
- package/src/api/routes.ts +141 -0
- package/src/config.ts +231 -0
- package/src/core/chargebackGuard.ts +616 -0
- package/src/core/eventEmitter.ts +118 -0
- package/src/core/lifecycle.ts +215 -0
- package/src/database/schema.ts +392 -0
- package/src/dispute/analyzer.ts +317 -0
- package/src/dispute/bankIntegration.ts +274 -0
- package/src/dispute/detector.ts +239 -0
- package/src/dispute/responseEngine.ts +440 -0
- package/src/evidence/collector.ts +426 -0
- package/src/evidence/encryption.ts +168 -0
- package/src/evidence/storage.ts +197 -0
- package/src/evidence/validator.ts +184 -0
- package/src/index.ts +43 -0
- package/src/integrations/paypal.ts +258 -0
- package/src/integrations/stripe.ts +280 -0
- package/src/integrations/webhook.ts +332 -0
- package/src/notifications/email.ts +161 -0
- package/src/notifications/inApp.ts +319 -0
- package/src/notifications/sms.ts +58 -0
- package/src/security/auth.ts +153 -0
- package/src/security/rateLimit.ts +77 -0
- package/src/security/validation.ts +166 -0
- package/src/server.ts +122 -0
- package/src/types/index.ts +790 -0
- package/src/utils/formatters.ts +72 -0
- package/src/utils/helpers.ts +193 -0
- package/src/utils/logger.ts +88 -0
- package/src/utils/validators.ts +39 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// CHARGEBACK GUARD — Dispute Detector
|
|
3
|
+
// Parses incoming webhook events and identifies disputes
|
|
4
|
+
// ============================================================
|
|
5
|
+
|
|
6
|
+
import { createLogger } from '../utils/logger';
|
|
7
|
+
import {
|
|
8
|
+
Dispute,
|
|
9
|
+
DisputeReason,
|
|
10
|
+
DisputeStatus,
|
|
11
|
+
PaymentProvider,
|
|
12
|
+
WebhookEvent,
|
|
13
|
+
} from '../types';
|
|
14
|
+
|
|
15
|
+
const log = createLogger('DisputeDetector');
|
|
16
|
+
|
|
17
|
+
// ────────────────────────────────────────────────────────────
|
|
18
|
+
// STRIPE REASON MAP
|
|
19
|
+
// ────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const STRIPE_REASON_MAP: Record<string, DisputeReason> = {
|
|
22
|
+
'product_not_received': DisputeReason.PRODUCT_NOT_RECEIVED,
|
|
23
|
+
'product_unacceptable': DisputeReason.PRODUCT_UNACCEPTABLE,
|
|
24
|
+
'fraudulent': DisputeReason.FRAUDULENT,
|
|
25
|
+
'unrecognized': DisputeReason.UNAUTHORIZED_TRANSACTION,
|
|
26
|
+
'duplicate': DisputeReason.DUPLICATE_TRANSACTION,
|
|
27
|
+
'credit_not_processed': DisputeReason.CREDIT_NOT_PROCESSED,
|
|
28
|
+
'subscription_canceled': DisputeReason.SUBSCRIPTION_CANCELLED,
|
|
29
|
+
'general': DisputeReason.GENERAL,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const STRIPE_STATUS_MAP: Record<string, DisputeStatus> = {
|
|
33
|
+
'needs_response': DisputeStatus.NEEDS_RESPONSE,
|
|
34
|
+
'under_review': DisputeStatus.UNDER_REVIEW,
|
|
35
|
+
'charge_refunded': DisputeStatus.CHARGE_REFUNDED,
|
|
36
|
+
'won': DisputeStatus.WON,
|
|
37
|
+
'lost': DisputeStatus.LOST,
|
|
38
|
+
'warning_needs_response': DisputeStatus.WARNING_NEEDS_RESPONSE,
|
|
39
|
+
'warning_under_review': DisputeStatus.WARNING_UNDER_REVIEW,
|
|
40
|
+
'warning_closed': DisputeStatus.WARNING_CLOSED,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// ────────────────────────────────────────────────────────────
|
|
44
|
+
// PAYPAL REASON MAP
|
|
45
|
+
// ────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const PAYPAL_REASON_MAP: Record<string, DisputeReason> = {
|
|
48
|
+
'MERCHANDISE_OR_SERVICE_NOT_RECEIVED': DisputeReason.PRODUCT_NOT_RECEIVED,
|
|
49
|
+
'MERCHANDISE_OR_SERVICE_NOT_AS_DESCRIBED': DisputeReason.PRODUCT_UNACCEPTABLE,
|
|
50
|
+
'UNAUTHORISED': DisputeReason.UNAUTHORIZED_TRANSACTION,
|
|
51
|
+
'CREDIT_NOT_PROCESSED': DisputeReason.CREDIT_NOT_PROCESSED,
|
|
52
|
+
'DUPLICATE_TRANSACTION': DisputeReason.DUPLICATE_TRANSACTION,
|
|
53
|
+
'INCORRECT_AMOUNT': DisputeReason.GENERAL,
|
|
54
|
+
'PAYMENT_BY_OTHER_MEANS': DisputeReason.GENERAL,
|
|
55
|
+
'CANCELED_RECURRING_BILLING': DisputeReason.SUBSCRIPTION_CANCELLED,
|
|
56
|
+
'PROBLEM_WITH_REMITTANCE': DisputeReason.GENERAL,
|
|
57
|
+
'OTHER': DisputeReason.GENERAL,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// ────────────────────────────────────────────────────────────
|
|
61
|
+
// DISPUTE DETECTOR
|
|
62
|
+
// ────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
export class DisputeDetector {
|
|
65
|
+
|
|
66
|
+
// ──────────────────────────────────────────
|
|
67
|
+
// DETECT FROM WEBHOOK EVENT
|
|
68
|
+
// ──────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
detect(event: WebhookEvent): Dispute | null {
|
|
71
|
+
log.debug(`Detecting dispute from event: ${event.type}`);
|
|
72
|
+
|
|
73
|
+
switch (event.provider) {
|
|
74
|
+
case PaymentProvider.STRIPE:
|
|
75
|
+
return this._detectStripe(event);
|
|
76
|
+
case PaymentProvider.PAYPAL:
|
|
77
|
+
return this._detectPayPal(event);
|
|
78
|
+
default:
|
|
79
|
+
log.warn(`Unsupported provider for dispute detection: ${event.provider}`);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ──────────────────────────────────────────
|
|
85
|
+
// STRIPE DETECTION
|
|
86
|
+
// ──────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
private _detectStripe(event: WebhookEvent): Dispute | null {
|
|
89
|
+
const disputeEventTypes = [
|
|
90
|
+
'charge.dispute.created',
|
|
91
|
+
'charge.dispute.updated',
|
|
92
|
+
'charge.dispute.closed',
|
|
93
|
+
'charge.dispute.funds_withdrawn',
|
|
94
|
+
'charge.dispute.funds_reinstated',
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
if (!disputeEventTypes.includes(event.type)) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const obj = event.data.object as Record<string, unknown>;
|
|
102
|
+
|
|
103
|
+
const rawReason = (obj['reason'] as string) ?? 'general';
|
|
104
|
+
const rawStatus = (obj['status'] as string) ?? 'needs_response';
|
|
105
|
+
const orderId = (obj['metadata'] as Record<string, string>)?.['order_id']
|
|
106
|
+
?? (obj['metadata'] as Record<string, string>)?.['orderId']
|
|
107
|
+
?? undefined;
|
|
108
|
+
|
|
109
|
+
const dispute: Dispute = {
|
|
110
|
+
id: obj['id'] as string,
|
|
111
|
+
orderId,
|
|
112
|
+
amount: (obj['amount'] as number) ?? 0,
|
|
113
|
+
currency: (obj['currency'] as string) ?? 'usd',
|
|
114
|
+
reason: STRIPE_REASON_MAP[rawReason] ?? DisputeReason.GENERAL,
|
|
115
|
+
status: STRIPE_STATUS_MAP[rawStatus] ?? DisputeStatus.NEEDS_RESPONSE,
|
|
116
|
+
provider: PaymentProvider.STRIPE,
|
|
117
|
+
evidenceDueBy: obj['evidence_due_by']
|
|
118
|
+
? new Date((obj['evidence_due_by'] as number) * 1000).toISOString()
|
|
119
|
+
: undefined,
|
|
120
|
+
createdAt: new Date((obj['created'] as number) * 1000).toISOString(),
|
|
121
|
+
metadata: obj['metadata'] as Record<string, unknown>,
|
|
122
|
+
evidence: obj['evidence'] as Record<string, unknown>,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
log.info(`Stripe dispute detected: ${dispute.id}`, {
|
|
126
|
+
reason: dispute.reason,
|
|
127
|
+
status: dispute.status,
|
|
128
|
+
amount: dispute.amount,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return dispute;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ──────────────────────────────────────────
|
|
135
|
+
// PAYPAL DETECTION
|
|
136
|
+
// ──────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
private _detectPayPal(event: WebhookEvent): Dispute | null {
|
|
139
|
+
const disputeEventTypes = [
|
|
140
|
+
'CUSTOMER.DISPUTE.CREATED',
|
|
141
|
+
'CUSTOMER.DISPUTE.UPDATED',
|
|
142
|
+
'CUSTOMER.DISPUTE.RESOLVED',
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
if (!disputeEventTypes.includes(event.type)) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const resource = event.data.object as Record<string, unknown>;
|
|
150
|
+
const disputeInfo = (resource['dispute'] as Record<string, unknown>) ?? resource;
|
|
151
|
+
|
|
152
|
+
const rawReason = (disputeInfo['reason'] as string) ?? 'OTHER';
|
|
153
|
+
const rawStatus = (disputeInfo['status'] as string) ?? 'OPEN';
|
|
154
|
+
|
|
155
|
+
const dispute: Dispute = {
|
|
156
|
+
id: disputeInfo['dispute_id'] as string ?? event.id,
|
|
157
|
+
orderId: this._extractPayPalOrderId(disputeInfo),
|
|
158
|
+
amount: this._extractPayPalAmount(disputeInfo),
|
|
159
|
+
currency: 'USD',
|
|
160
|
+
reason: PAYPAL_REASON_MAP[rawReason] ?? DisputeReason.GENERAL,
|
|
161
|
+
status: this._mapPayPalStatus(rawStatus),
|
|
162
|
+
provider: PaymentProvider.PAYPAL,
|
|
163
|
+
createdAt: (disputeInfo['create_time'] as string) ?? new Date().toISOString(),
|
|
164
|
+
metadata: { raw: disputeInfo },
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
log.info(`PayPal dispute detected: ${dispute.id}`, {
|
|
168
|
+
reason: dispute.reason,
|
|
169
|
+
amount: dispute.amount,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
return dispute;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ──────────────────────────────────────────
|
|
176
|
+
// HELPERS
|
|
177
|
+
// ──────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
private _extractPayPalOrderId(resource: Record<string, unknown>): string | undefined {
|
|
180
|
+
const transactions = resource['disputed_transactions'] as Array<Record<string, unknown>>;
|
|
181
|
+
if (transactions?.length) {
|
|
182
|
+
return transactions[0]['seller_transaction_id'] as string;
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private _extractPayPalAmount(resource: Record<string, unknown>): number {
|
|
188
|
+
const amount = (resource['dispute_amount'] as Record<string, string>)?.['value'];
|
|
189
|
+
return amount ? parseFloat(amount) * 100 : 0; // convert to cents
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private _mapPayPalStatus(status: string): DisputeStatus {
|
|
193
|
+
const map: Record<string, DisputeStatus> = {
|
|
194
|
+
'OPEN': DisputeStatus.NEEDS_RESPONSE,
|
|
195
|
+
'WAITING_FOR_BUYER_RESPONSE': DisputeStatus.UNDER_REVIEW,
|
|
196
|
+
'WAITING_FOR_SELLER_RESPONSE': DisputeStatus.NEEDS_RESPONSE,
|
|
197
|
+
'UNDER_REVIEW': DisputeStatus.UNDER_REVIEW,
|
|
198
|
+
'RESOLVED': DisputeStatus.CHARGE_REFUNDED,
|
|
199
|
+
'OTHER': DisputeStatus.NEEDS_RESPONSE,
|
|
200
|
+
};
|
|
201
|
+
return map[status] ?? DisputeStatus.NEEDS_RESPONSE;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ──────────────────────────────────────────
|
|
205
|
+
// IS DISPUTE EVENT
|
|
206
|
+
// ──────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
isDisputeEvent(event: WebhookEvent): boolean {
|
|
209
|
+
return this.detect(event) !== null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ──────────────────────────────────────────
|
|
213
|
+
// IS ACTIONABLE
|
|
214
|
+
// ──────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
isActionable(dispute: Dispute): boolean {
|
|
217
|
+
return (
|
|
218
|
+
dispute.status === DisputeStatus.NEEDS_RESPONSE ||
|
|
219
|
+
dispute.status === DisputeStatus.WARNING_NEEDS_RESPONSE
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ──────────────────────────────────────────
|
|
224
|
+
// IS DEADLINE CLOSE
|
|
225
|
+
// ──────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
isDeadlineClose(dispute: Dispute, hoursThreshold = 48): boolean {
|
|
228
|
+
if (!dispute.evidenceDueBy) { return false; }
|
|
229
|
+
const dueMs = new Date(dispute.evidenceDueBy).getTime();
|
|
230
|
+
const remaining = dueMs - Date.now();
|
|
231
|
+
return remaining > 0 && remaining < hoursThreshold * 3600000;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
getRemainingHours(dispute: Dispute): number | null {
|
|
235
|
+
if (!dispute.evidenceDueBy) { return null; }
|
|
236
|
+
const remaining = new Date(dispute.evidenceDueBy).getTime() - Date.now();
|
|
237
|
+
return Math.max(0, Math.floor(remaining / 3600000));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// CHARGEBACK GUARD — Response Engine
|
|
3
|
+
// Builds the bank submission package for each dispute type
|
|
4
|
+
// ============================================================
|
|
5
|
+
|
|
6
|
+
import { createLogger } from '../utils/logger';
|
|
7
|
+
import {
|
|
8
|
+
Dispute,
|
|
9
|
+
DisputeAnalysis,
|
|
10
|
+
Evidence,
|
|
11
|
+
DisputeReply,
|
|
12
|
+
EvidenceItem,
|
|
13
|
+
ReplySummary,
|
|
14
|
+
BankReplyFormat,
|
|
15
|
+
Attachment,
|
|
16
|
+
DisputeReason,
|
|
17
|
+
EvidenceType,
|
|
18
|
+
ConfidenceLevel,
|
|
19
|
+
} from '../types';
|
|
20
|
+
|
|
21
|
+
const log = createLogger('ResponseEngine');
|
|
22
|
+
|
|
23
|
+
// ────────────────────────────────────────────────────────────
|
|
24
|
+
// RESPONSE ENGINE
|
|
25
|
+
// ────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export class ResponseEngine {
|
|
28
|
+
|
|
29
|
+
async generateReply(ctx: {
|
|
30
|
+
dispute: Dispute;
|
|
31
|
+
evidence: Evidence | null;
|
|
32
|
+
analysis: DisputeAnalysis;
|
|
33
|
+
}): Promise<DisputeReply> {
|
|
34
|
+
const { dispute, evidence, analysis } = ctx;
|
|
35
|
+
log.debug(`Generating reply for dispute: ${dispute.id}`, { reason: dispute.reason });
|
|
36
|
+
|
|
37
|
+
const evidenceItems = this._buildEvidenceItems(dispute, evidence);
|
|
38
|
+
const summary = this._buildSummary(evidence, analysis);
|
|
39
|
+
const caseArgument = this._buildCaseArgument(dispute, evidence, analysis);
|
|
40
|
+
const attachments = this._prepareAttachments(evidenceItems);
|
|
41
|
+
|
|
42
|
+
const reply: DisputeReply = {
|
|
43
|
+
disputeId: dispute.id,
|
|
44
|
+
orderId: dispute.orderId,
|
|
45
|
+
generatedAt: new Date().toISOString(),
|
|
46
|
+
evidenceItems,
|
|
47
|
+
summary,
|
|
48
|
+
caseArgument,
|
|
49
|
+
attachments,
|
|
50
|
+
bankFormat: this._formatForBank(dispute, evidenceItems, caseArgument, reply as DisputeReply),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
log.info(`Reply generated: ${dispute.id}`, {
|
|
54
|
+
evidenceItems: evidenceItems.length,
|
|
55
|
+
confidenceLevel: summary.confidenceLevel,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return reply;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ──────────────────────────────────────────
|
|
62
|
+
// BUILD EVIDENCE ITEMS BY DISPUTE TYPE
|
|
63
|
+
// ──────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
private _buildEvidenceItems(dispute: Dispute, ev: Evidence | null): EvidenceItem[] {
|
|
66
|
+
const items: EvidenceItem[] = [];
|
|
67
|
+
|
|
68
|
+
// Common items for all dispute types
|
|
69
|
+
items.push(...this._commonItems(ev));
|
|
70
|
+
|
|
71
|
+
// Type-specific items
|
|
72
|
+
switch (dispute.reason) {
|
|
73
|
+
case DisputeReason.PRODUCT_NOT_RECEIVED:
|
|
74
|
+
items.push(...this._deliveryProofItems(ev));
|
|
75
|
+
break;
|
|
76
|
+
|
|
77
|
+
case DisputeReason.UNAUTHORIZED_TRANSACTION:
|
|
78
|
+
case DisputeReason.FRAUDULENT:
|
|
79
|
+
items.push(...this._unauthorizedItems(ev));
|
|
80
|
+
break;
|
|
81
|
+
|
|
82
|
+
case DisputeReason.DUPLICATE_TRANSACTION:
|
|
83
|
+
items.push(...this._duplicateItems(ev));
|
|
84
|
+
break;
|
|
85
|
+
|
|
86
|
+
case DisputeReason.PRODUCT_UNACCEPTABLE:
|
|
87
|
+
items.push(...this._productQualityItems(ev));
|
|
88
|
+
break;
|
|
89
|
+
|
|
90
|
+
case DisputeReason.CREDIT_NOT_PROCESSED:
|
|
91
|
+
items.push(...this._creditItems(ev));
|
|
92
|
+
break;
|
|
93
|
+
|
|
94
|
+
case DisputeReason.SUBSCRIPTION_CANCELLED:
|
|
95
|
+
items.push(...this._subscriptionItems(ev));
|
|
96
|
+
break;
|
|
97
|
+
|
|
98
|
+
default:
|
|
99
|
+
items.push(...this._generalItems(ev));
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Sort by weight descending
|
|
104
|
+
return items.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ──────────────────────────────────────────
|
|
108
|
+
// COMMON ITEMS (always included)
|
|
109
|
+
// ──────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
private _commonItems(ev: Evidence | null): EvidenceItem[] {
|
|
112
|
+
const items: EvidenceItem[] = [];
|
|
113
|
+
|
|
114
|
+
if (!ev) { return items; }
|
|
115
|
+
|
|
116
|
+
// Transaction log
|
|
117
|
+
items.push({
|
|
118
|
+
type: EvidenceType.TRANSACTION_LOG,
|
|
119
|
+
description: 'Detailed transaction record showing the charge is legitimate',
|
|
120
|
+
weight: 0.5,
|
|
121
|
+
data: {
|
|
122
|
+
orderId: ev.orderId,
|
|
123
|
+
amount: ev.data?.transactionData?.amount,
|
|
124
|
+
currency: ev.data?.transactionData?.currency,
|
|
125
|
+
paymentMethod: ev.data?.transactionData?.paymentMethod,
|
|
126
|
+
cardLastFour: ev.data?.transactionData?.cardLastFour,
|
|
127
|
+
timestamp: ev.data?.transactionData?.timestamp,
|
|
128
|
+
transactionId: ev.data?.transactionData?.transactionId,
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Customer communication (email)
|
|
133
|
+
const emailConf = ev.data?.emailConfirmation;
|
|
134
|
+
if (emailConf?.sentTo) {
|
|
135
|
+
items.push({
|
|
136
|
+
type: EvidenceType.EMAIL_CONFIRMATION,
|
|
137
|
+
description: 'Email order confirmation was sent to customer email',
|
|
138
|
+
weight: 0.4,
|
|
139
|
+
data: {
|
|
140
|
+
sentTo: emailConf.sentTo,
|
|
141
|
+
sentAt: emailConf.sentAt,
|
|
142
|
+
opened: emailConf.opened,
|
|
143
|
+
openedAt: emailConf.openedAt,
|
|
144
|
+
clickedLinks: emailConf.clickedLinks,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return items;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ──────────────────────────────────────────
|
|
153
|
+
// DELIVERY PROOF ITEMS
|
|
154
|
+
// ──────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
private _deliveryProofItems(ev: Evidence | null): EvidenceItem[] {
|
|
157
|
+
const items: EvidenceItem[] = [];
|
|
158
|
+
const shipping = ev?.data?.shippingData;
|
|
159
|
+
|
|
160
|
+
items.push({
|
|
161
|
+
type: EvidenceType.DELIVERY_PROOF,
|
|
162
|
+
description: 'Shipping documentation and delivery confirmation',
|
|
163
|
+
weight: 0.95,
|
|
164
|
+
data: {
|
|
165
|
+
trackingNumber: shipping?.trackingNumber ?? 'Not available',
|
|
166
|
+
carrier: (shipping as Record<string, unknown>)?.['carrier'] ?? 'N/A',
|
|
167
|
+
shippedAt: (shipping as Record<string, unknown>)?.['shippingDate'],
|
|
168
|
+
deliveredAt: shipping?.deliveryDate,
|
|
169
|
+
signature: shipping?.signature ?? false,
|
|
170
|
+
gpsCoordinates: shipping?.gpsCoordinates,
|
|
171
|
+
shippingAddress: shipping?.address,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return items;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ──────────────────────────────────────────
|
|
179
|
+
// UNAUTHORIZED ITEMS
|
|
180
|
+
// ──────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
private _unauthorizedItems(ev: Evidence | null): EvidenceItem[] {
|
|
183
|
+
const items: EvidenceItem[] = [];
|
|
184
|
+
|
|
185
|
+
// Device fingerprint
|
|
186
|
+
items.push({
|
|
187
|
+
type: EvidenceType.DEVICE_FINGERPRINT,
|
|
188
|
+
description: 'Device fingerprint and IP consistency proving authorized access',
|
|
189
|
+
weight: 0.90,
|
|
190
|
+
data: {
|
|
191
|
+
ipAddress: ev?.data?.customerData?.ipAddress,
|
|
192
|
+
userAgent: ev?.data?.customerData?.userAgent,
|
|
193
|
+
deviceId: ev?.data?.customerData?.deviceId,
|
|
194
|
+
fingerprint: ev?.data?.deviceFingerprint?.fingerprint,
|
|
195
|
+
screenResolution: ev?.data?.deviceFingerprint?.screenResolution,
|
|
196
|
+
timezone: ev?.data?.deviceFingerprint?.timezone,
|
|
197
|
+
language: ev?.data?.deviceFingerprint?.language,
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Behavior analysis
|
|
202
|
+
const interaction = ev?.data?.interactionData;
|
|
203
|
+
items.push({
|
|
204
|
+
type: EvidenceType.BEHAVIOR_ANALYSIS,
|
|
205
|
+
description: 'User behavior analysis showing natural browsing and checkout patterns',
|
|
206
|
+
weight: 0.80,
|
|
207
|
+
data: {
|
|
208
|
+
sessionDuration: interaction?.sessionDuration,
|
|
209
|
+
pageViews: interaction?.pageViews?.length,
|
|
210
|
+
timeOnCheckout: interaction?.timeOnCheckout,
|
|
211
|
+
formInteractions: interaction?.formInteractions?.length,
|
|
212
|
+
scrollDepth: interaction?.scrollDepth,
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Customer history
|
|
217
|
+
const history = ev?.data?.customerHistory;
|
|
218
|
+
items.push({
|
|
219
|
+
type: EvidenceType.CUSTOMER_HISTORY,
|
|
220
|
+
description: 'Customer account history and reputation score',
|
|
221
|
+
weight: 0.75,
|
|
222
|
+
data: {
|
|
223
|
+
accountCreatedAt: history?.accountCreatedAt,
|
|
224
|
+
accountAgeDays: history?.accountAge,
|
|
225
|
+
previousPurchases: history?.previousPurchases,
|
|
226
|
+
previousDisputes: history?.previousDisputes,
|
|
227
|
+
riskScore: history?.riskScore,
|
|
228
|
+
trustScore: history?.trustScore,
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Geolocation
|
|
233
|
+
const geo = ev?.data?.geolocation;
|
|
234
|
+
if (geo) {
|
|
235
|
+
items.push({
|
|
236
|
+
type: EvidenceType.GEOLOCATION,
|
|
237
|
+
description: 'IP geolocation matches customer registered location',
|
|
238
|
+
weight: 0.65,
|
|
239
|
+
data: {
|
|
240
|
+
ip: geo.ip,
|
|
241
|
+
country: geo.country,
|
|
242
|
+
city: geo.city,
|
|
243
|
+
timezone: geo.timezone,
|
|
244
|
+
isp: geo.isp,
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return items;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ──────────────────────────────────────────
|
|
253
|
+
// DUPLICATE ITEMS
|
|
254
|
+
// ──────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
private _duplicateItems(ev: Evidence | null): EvidenceItem[] {
|
|
257
|
+
return [{
|
|
258
|
+
type: EvidenceType.PAYMENT_LOG,
|
|
259
|
+
description: 'Payment log proving this was a single unique transaction',
|
|
260
|
+
weight: 0.95,
|
|
261
|
+
data: {
|
|
262
|
+
attempts: ev?.data?.paymentLog?.attempts,
|
|
263
|
+
successful: ev?.data?.paymentLog?.successful,
|
|
264
|
+
failed: ev?.data?.paymentLog?.failed,
|
|
265
|
+
lastAttempt: ev?.data?.paymentLog?.lastAttemptAt,
|
|
266
|
+
conclusion: 'Only one successful charge was made for this order',
|
|
267
|
+
},
|
|
268
|
+
}];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ──────────────────────────────────────────
|
|
272
|
+
// PRODUCT QUALITY ITEMS
|
|
273
|
+
// ──────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
private _productQualityItems(ev: Evidence | null): EvidenceItem[] {
|
|
276
|
+
return [{
|
|
277
|
+
type: 'product_description',
|
|
278
|
+
description: 'Product description and specifications at time of purchase',
|
|
279
|
+
weight: 0.80,
|
|
280
|
+
data: {
|
|
281
|
+
orderId: ev?.orderId,
|
|
282
|
+
note: 'Product was delivered as described per order confirmation',
|
|
283
|
+
},
|
|
284
|
+
}];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ──────────────────────────────────────────
|
|
288
|
+
// CREDIT NOT PROCESSED ITEMS
|
|
289
|
+
// ──────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
private _creditItems(ev: Evidence | null): EvidenceItem[] {
|
|
292
|
+
return [{
|
|
293
|
+
type: 'refund_policy',
|
|
294
|
+
description: 'Merchant refund policy and credit processing timeline',
|
|
295
|
+
weight: 0.85,
|
|
296
|
+
data: {
|
|
297
|
+
policy: 'Refund policy was clearly disclosed at time of purchase',
|
|
298
|
+
note: 'If a refund was agreed, see attached credit documentation',
|
|
299
|
+
orderId: ev?.orderId,
|
|
300
|
+
},
|
|
301
|
+
}];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ──────────────────────────────────────────
|
|
305
|
+
// SUBSCRIPTION ITEMS
|
|
306
|
+
// ──────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
private _subscriptionItems(ev: Evidence | null): EvidenceItem[] {
|
|
309
|
+
return [{
|
|
310
|
+
type: 'subscription_terms',
|
|
311
|
+
description: 'Subscription terms and cancellation policy accepted by customer',
|
|
312
|
+
weight: 0.85,
|
|
313
|
+
data: {
|
|
314
|
+
note: 'Customer agreed to subscription terms during signup',
|
|
315
|
+
cancellationPolicy: 'Cancel anytime — no active cancellation request was received',
|
|
316
|
+
orderId: ev?.orderId,
|
|
317
|
+
},
|
|
318
|
+
}];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ──────────────────────────────────────────
|
|
322
|
+
// GENERAL ITEMS
|
|
323
|
+
// ──────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
private _generalItems(ev: Evidence | null): EvidenceItem[] {
|
|
326
|
+
return [{
|
|
327
|
+
type: 'general_rebuttal',
|
|
328
|
+
description: 'General rebuttal with all available supporting evidence',
|
|
329
|
+
weight: 0.60,
|
|
330
|
+
data: {
|
|
331
|
+
note: 'All available evidence has been submitted. The charge is valid and service/product was delivered.',
|
|
332
|
+
orderId: ev?.orderId,
|
|
333
|
+
trustScore: ev?.trustScore,
|
|
334
|
+
},
|
|
335
|
+
}];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ──────────────────────────────────────────
|
|
339
|
+
// SUMMARY
|
|
340
|
+
// ──────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
private _buildSummary(ev: Evidence | null, analysis: DisputeAnalysis): ReplySummary {
|
|
343
|
+
const confidenceLevel = this._scoreToConfidence(analysis.confidenceScore);
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
trustScore: ev?.trustScore ?? 0,
|
|
347
|
+
confidenceLevel,
|
|
348
|
+
recommendation: 'APPROVE_CUSTOMER_CHARGE',
|
|
349
|
+
keyPoints: analysis.analysisReasons,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ──────────────────────────────────────────
|
|
354
|
+
// CASE ARGUMENT
|
|
355
|
+
// ──────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
private _buildCaseArgument(
|
|
358
|
+
dispute: Dispute,
|
|
359
|
+
ev: Evidence | null,
|
|
360
|
+
analysis: DisputeAnalysis
|
|
361
|
+
): string {
|
|
362
|
+
const lines = [
|
|
363
|
+
'─────────────────────────────────────────────────',
|
|
364
|
+
'MERCHANT REBUTTAL — CHARGEBACK DEFENSE SUBMISSION',
|
|
365
|
+
'─────────────────────────────────────────────────',
|
|
366
|
+
'',
|
|
367
|
+
`Dispute ID : ${dispute.id}`,
|
|
368
|
+
`Order ID : ${dispute.orderId ?? 'N/A'}`,
|
|
369
|
+
`Dispute Type : ${dispute.reason}`,
|
|
370
|
+
`Amount : ${(dispute.amount / 100).toFixed(2)} ${(dispute.currency ?? 'USD').toUpperCase()}`,
|
|
371
|
+
`Generated At : ${new Date().toISOString()}`,
|
|
372
|
+
'',
|
|
373
|
+
'EXECUTIVE SUMMARY',
|
|
374
|
+
'─────────────────',
|
|
375
|
+
'This chargeback is invalid. The attached evidence conclusively demonstrates',
|
|
376
|
+
'that the transaction was authorized by the cardholder and the order was',
|
|
377
|
+
'fulfilled per the original purchase agreement.',
|
|
378
|
+
'',
|
|
379
|
+
];
|
|
380
|
+
|
|
381
|
+
analysis.analysisReasons.forEach((reason, i) => {
|
|
382
|
+
lines.push(`${i + 1}. ${reason}`);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
lines.push('');
|
|
386
|
+
lines.push(`Evidence Confidence Score : ${(analysis.confidenceScore * 100).toFixed(1)}%`);
|
|
387
|
+
lines.push(`Fraud Probability : ${(analysis.fraudProbability * 100).toFixed(1)}%`);
|
|
388
|
+
lines.push('');
|
|
389
|
+
lines.push('Based on all submitted evidence, we respectfully request that this');
|
|
390
|
+
lines.push('chargeback be ruled in favor of the merchant.');
|
|
391
|
+
lines.push('');
|
|
392
|
+
lines.push('─── GENERATED BY CHARGEBACKGUARD v2.0 ───');
|
|
393
|
+
|
|
394
|
+
return lines.join('\n');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ──────────────────────────────────────────
|
|
398
|
+
// ATTACHMENTS
|
|
399
|
+
// ──────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
private _prepareAttachments(items: EvidenceItem[]): Attachment[] {
|
|
402
|
+
return items.map((item, i) => ({
|
|
403
|
+
type: item.type as string,
|
|
404
|
+
name: `evidence_${String(i + 1).padStart(2, '0')}_${item.type}.pdf`,
|
|
405
|
+
mimeType: 'application/pdf',
|
|
406
|
+
description: item.description,
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ──────────────────────────────────────────
|
|
411
|
+
// BANK FORMAT
|
|
412
|
+
// ──────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
private _formatForBank(
|
|
415
|
+
dispute: Dispute,
|
|
416
|
+
evidenceItems: EvidenceItem[],
|
|
417
|
+
caseArgument: string,
|
|
418
|
+
reply: DisputeReply
|
|
419
|
+
): BankReplyFormat {
|
|
420
|
+
return {
|
|
421
|
+
dispute_id: dispute.id,
|
|
422
|
+
merchant_reference: dispute.orderId ?? '',
|
|
423
|
+
submitted_at: new Date().toISOString(),
|
|
424
|
+
evidence_items: evidenceItems,
|
|
425
|
+
case_argument: caseArgument,
|
|
426
|
+
attachment_files: reply.attachments,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ──────────────────────────────────────────
|
|
431
|
+
// HELPERS
|
|
432
|
+
// ──────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
private _scoreToConfidence(score: number): ConfidenceLevel {
|
|
435
|
+
if (score >= 0.85) { return ConfidenceLevel.VERY_HIGH; }
|
|
436
|
+
if (score >= 0.65) { return ConfidenceLevel.HIGH; }
|
|
437
|
+
if (score >= 0.45) { return ConfidenceLevel.MEDIUM; }
|
|
438
|
+
return ConfidenceLevel.LOW;
|
|
439
|
+
}
|
|
440
|
+
}
|