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,197 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// CHARGEBACK GUARD — Evidence Storage
|
|
3
|
+
// Persists, retrieves and manages evidence records securely
|
|
4
|
+
// ============================================================
|
|
5
|
+
|
|
6
|
+
import { createLogger } from '../utils/logger';
|
|
7
|
+
import { Evidence } from '../types';
|
|
8
|
+
import { encryptPIIFields, decryptPIIFields } from './encryption';
|
|
9
|
+
|
|
10
|
+
const log = createLogger('EvidenceStorage');
|
|
11
|
+
|
|
12
|
+
// In-memory store for standalone / testing usage.
|
|
13
|
+
// Production replaces this with the DatabaseManager.
|
|
14
|
+
interface EvidenceRow {
|
|
15
|
+
collectionId: string;
|
|
16
|
+
orderId: string;
|
|
17
|
+
evidenceJson: string;
|
|
18
|
+
trustScore: number;
|
|
19
|
+
riskLevel: string;
|
|
20
|
+
collectedAt: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ────────────────────────────────────────────────────────────
|
|
24
|
+
// EVIDENCE STORAGE CLASS
|
|
25
|
+
// ────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export class EvidenceStorage {
|
|
28
|
+
// Fallback in-memory store (used when DB not yet initialised)
|
|
29
|
+
private readonly memoryStore: Map<string, EvidenceRow> = new Map();
|
|
30
|
+
private db: unknown = null;
|
|
31
|
+
|
|
32
|
+
// Allow the core to inject the DB at runtime
|
|
33
|
+
setDatabase(db: unknown): void {
|
|
34
|
+
this.db = db;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ──────────────────────────────────────────
|
|
38
|
+
// STORE
|
|
39
|
+
// ──────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
async store(evidence: Evidence): Promise<string> {
|
|
42
|
+
log.debug(`Storing evidence: ${evidence.collectionId}`, {
|
|
43
|
+
orderId: evidence.orderId,
|
|
44
|
+
trustScore: evidence.trustScore,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Encrypt PII before persisting
|
|
48
|
+
const safeCopy = { ...evidence, data: encryptPIIFields(evidence.data as Record<string, unknown>) };
|
|
49
|
+
const json = JSON.stringify(safeCopy);
|
|
50
|
+
|
|
51
|
+
const row: EvidenceRow = {
|
|
52
|
+
collectionId: evidence.collectionId,
|
|
53
|
+
orderId: evidence.orderId,
|
|
54
|
+
evidenceJson: json,
|
|
55
|
+
trustScore: evidence.trustScore,
|
|
56
|
+
riskLevel: evidence.riskLevel,
|
|
57
|
+
collectedAt: evidence.timestamp,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (this.db) {
|
|
61
|
+
try {
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
63
|
+
await (this.db as any).evidence.create(row);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
log.warn('DB store failed, using memory fallback', {
|
|
66
|
+
error: err instanceof Error ? err.message : String(err),
|
|
67
|
+
});
|
|
68
|
+
this.memoryStore.set(evidence.orderId, row);
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
this.memoryStore.set(evidence.orderId, row);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
log.info(`Evidence stored: ${evidence.collectionId} for order: ${evidence.orderId}`);
|
|
75
|
+
return evidence.collectionId;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ──────────────────────────────────────────
|
|
79
|
+
// FIND BY ORDER ID
|
|
80
|
+
// ──────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
async findByOrderId(orderId: string): Promise<Evidence | null> {
|
|
83
|
+
log.debug(`Looking up evidence for order: ${orderId}`);
|
|
84
|
+
|
|
85
|
+
let row: EvidenceRow | null = null;
|
|
86
|
+
|
|
87
|
+
if (this.db) {
|
|
88
|
+
try {
|
|
89
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
90
|
+
row = await (this.db as any).evidence.findByOrderId(orderId);
|
|
91
|
+
} catch {
|
|
92
|
+
row = this.memoryStore.get(orderId) ?? null;
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
row = this.memoryStore.get(orderId) ?? null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!row) {
|
|
99
|
+
log.warn(`No evidence found for order: ${orderId}`);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return this._deserialize(row);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ──────────────────────────────────────────
|
|
107
|
+
// FIND BY COLLECTION ID
|
|
108
|
+
// ──────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
async findByCollectionId(collectionId: string): Promise<Evidence | null> {
|
|
111
|
+
if (this.db) {
|
|
112
|
+
try {
|
|
113
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
114
|
+
const row = await (this.db as any).evidence.findByCollectionId(collectionId);
|
|
115
|
+
return row ? this._deserialize(row) : null;
|
|
116
|
+
} catch {
|
|
117
|
+
// fall through to memory
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const row of this.memoryStore.values()) {
|
|
122
|
+
if (row.collectionId === collectionId) {
|
|
123
|
+
return this._deserialize(row);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ──────────────────────────────────────────
|
|
131
|
+
// UPDATE
|
|
132
|
+
// ──────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
async update(orderId: string, updates: Partial<Evidence>): Promise<boolean> {
|
|
135
|
+
const existing = await this.findByOrderId(orderId);
|
|
136
|
+
if (!existing) { return false; }
|
|
137
|
+
|
|
138
|
+
const merged: Evidence = { ...existing, ...updates };
|
|
139
|
+
await this.store(merged);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ──────────────────────────────────────────
|
|
144
|
+
// DELETE (GDPR right to erasure)
|
|
145
|
+
// ──────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
async delete(orderId: string): Promise<boolean> {
|
|
148
|
+
log.info(`Deleting evidence for order: ${orderId}`);
|
|
149
|
+
|
|
150
|
+
if (this.db) {
|
|
151
|
+
try {
|
|
152
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
153
|
+
await (this.db as any).evidence.deleteByOrderId(orderId);
|
|
154
|
+
} catch {
|
|
155
|
+
this.memoryStore.delete(orderId);
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
this.memoryStore.delete(orderId);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ──────────────────────────────────────────
|
|
165
|
+
// LIST (paginated)
|
|
166
|
+
// ──────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
async list(
|
|
169
|
+
page = 1,
|
|
170
|
+
limit = 20
|
|
171
|
+
): Promise<{ items: Evidence[]; total: number }> {
|
|
172
|
+
const all = Array.from(this.memoryStore.values());
|
|
173
|
+
const start = (page - 1) * limit;
|
|
174
|
+
const slice = all.slice(start, start + limit);
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
items: slice.map(r => this._deserialize(r)).filter(Boolean) as Evidence[],
|
|
178
|
+
total: all.length,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ──────────────────────────────────────────
|
|
183
|
+
// HELPERS
|
|
184
|
+
// ──────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
private _deserialize(row: EvidenceRow): Evidence {
|
|
187
|
+
const parsed: Evidence = JSON.parse(row.evidenceJson);
|
|
188
|
+
// Decrypt PII
|
|
189
|
+
parsed.data = decryptPIIFields(parsed.data as Record<string, unknown>) as Evidence['data'];
|
|
190
|
+
return parsed;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Expose store size for health checks
|
|
194
|
+
get size(): number {
|
|
195
|
+
return this.memoryStore.size;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// CHARGEBACK GUARD — Evidence Validator
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { createLogger } from '../utils/logger';
|
|
7
|
+
import { Evidence, RiskLevel } from '../types';
|
|
8
|
+
|
|
9
|
+
const log = createLogger('EvidenceValidator');
|
|
10
|
+
|
|
11
|
+
// ────────────────────────────────────────────────────────────
|
|
12
|
+
// VALIDATION SCHEMAS
|
|
13
|
+
// ────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const ipSchema = z.string().ip().optional().or(z.literal('0.0.0.0')).optional();
|
|
16
|
+
|
|
17
|
+
const evidenceDataSchema = z.object({
|
|
18
|
+
customerData: z.object({
|
|
19
|
+
ipAddress: z.string().default('0.0.0.0'),
|
|
20
|
+
userAgent: z.string().default('Unknown'),
|
|
21
|
+
deviceId: z.string(),
|
|
22
|
+
email: z.string().email().optional(),
|
|
23
|
+
ipReputation: z.number().min(0).max(1).optional(),
|
|
24
|
+
}),
|
|
25
|
+
transactionData: z.object({
|
|
26
|
+
timestamp: z.string(),
|
|
27
|
+
amount: z.number().min(0),
|
|
28
|
+
currency: z.string().length(3),
|
|
29
|
+
transactionId: z.string(),
|
|
30
|
+
}),
|
|
31
|
+
shippingData: z.object({
|
|
32
|
+
trackingNumber: z.string().optional(),
|
|
33
|
+
address: z.any().optional(),
|
|
34
|
+
}).optional(),
|
|
35
|
+
interactionData: z.object({
|
|
36
|
+
sessionDuration: z.number().min(0),
|
|
37
|
+
pageViews: z.array(z.any()).default([]),
|
|
38
|
+
clickTracking: z.array(z.any()).default([]),
|
|
39
|
+
formInteractions: z.array(z.any()).default([]),
|
|
40
|
+
timeOnCheckout: z.number().min(0),
|
|
41
|
+
}).optional(),
|
|
42
|
+
}).passthrough();
|
|
43
|
+
|
|
44
|
+
const evidenceSchema = z.object({
|
|
45
|
+
collectionId: z.string().min(1),
|
|
46
|
+
orderId: z.string().min(1),
|
|
47
|
+
timestamp: z.string(),
|
|
48
|
+
trustScore: z.number().min(0).max(100),
|
|
49
|
+
riskLevel: z.nativeEnum(RiskLevel),
|
|
50
|
+
data: evidenceDataSchema,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ────────────────────────────────────────────────────────────
|
|
54
|
+
// VALIDATION RESULT
|
|
55
|
+
// ────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export interface ValidationResult {
|
|
58
|
+
valid: boolean;
|
|
59
|
+
errors: string[];
|
|
60
|
+
warnings: string[];
|
|
61
|
+
sanitizedEvidence: Evidence;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ────────────────────────────────────────────────────────────
|
|
65
|
+
// EVIDENCE VALIDATOR CLASS
|
|
66
|
+
// ────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export class EvidenceValidator {
|
|
69
|
+
|
|
70
|
+
async validate(evidence: Evidence): Promise<Evidence> {
|
|
71
|
+
log.debug(`Validating evidence: ${evidence.collectionId}`);
|
|
72
|
+
|
|
73
|
+
const result = await this.validateDetailed(evidence);
|
|
74
|
+
|
|
75
|
+
if (!result.valid && result.errors.length > 0) {
|
|
76
|
+
log.warn(`Evidence validation issues: ${evidence.collectionId}`, {
|
|
77
|
+
errors: result.errors,
|
|
78
|
+
warnings: result.warnings,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return result.sanitizedEvidence;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async validateDetailed(evidence: Evidence): Promise<ValidationResult> {
|
|
86
|
+
const errors: string[] = [];
|
|
87
|
+
const warnings: string[] = [];
|
|
88
|
+
|
|
89
|
+
// ── Schema validation ──
|
|
90
|
+
try {
|
|
91
|
+
evidenceSchema.parse(evidence);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (err instanceof z.ZodError) {
|
|
94
|
+
err.errors.forEach(e => errors.push(`${e.path.join('.')}: ${e.message}`));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Business-rule validations ──
|
|
99
|
+
this._validateBusinessRules(evidence, errors, warnings);
|
|
100
|
+
|
|
101
|
+
// ── Sanitize ──
|
|
102
|
+
const sanitizedEvidence = this._sanitize(evidence);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
valid: errors.length === 0,
|
|
106
|
+
errors,
|
|
107
|
+
warnings,
|
|
108
|
+
sanitizedEvidence,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ──────────────────────────────────────────
|
|
113
|
+
// BUSINESS RULE VALIDATION
|
|
114
|
+
// ──────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
private _validateBusinessRules(
|
|
117
|
+
ev: Evidence,
|
|
118
|
+
errors: string[],
|
|
119
|
+
warnings: string[]
|
|
120
|
+
): void {
|
|
121
|
+
// Trust score range
|
|
122
|
+
if (ev.trustScore < 0 || ev.trustScore > 100) {
|
|
123
|
+
errors.push(`trustScore out of range: ${ev.trustScore}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Timestamp must be recent (within 24 hours)
|
|
127
|
+
const ts = new Date(ev.timestamp).getTime();
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
if (isNaN(ts)) {
|
|
130
|
+
errors.push('timestamp is not a valid date');
|
|
131
|
+
} else if (now - ts > 86400000 * 2) {
|
|
132
|
+
warnings.push('Evidence timestamp is older than 48 hours');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Customer IP warning
|
|
136
|
+
const ip = ev.data?.customerData?.ipAddress;
|
|
137
|
+
if (!ip || ip === '0.0.0.0') {
|
|
138
|
+
warnings.push('No customer IP address recorded — weakens defense');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Shipping tracking warning
|
|
142
|
+
if (!ev.data?.shippingData?.trackingNumber) {
|
|
143
|
+
warnings.push('No shipping tracking number — weakens physical delivery proof');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Low trust score warning
|
|
147
|
+
if (ev.trustScore < 40) {
|
|
148
|
+
warnings.push(`Low trust score (${ev.trustScore}) — consider manual review`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Critical trust score
|
|
152
|
+
if (ev.trustScore < 20) {
|
|
153
|
+
errors.push(`Critically low trust score (${ev.trustScore}) — evidence insufficient`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ──────────────────────────────────────────
|
|
158
|
+
// SANITIZER
|
|
159
|
+
// ──────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
private _sanitize(evidence: Evidence): Evidence {
|
|
162
|
+
const sanitized: Evidence = JSON.parse(JSON.stringify(evidence)); // deep clone
|
|
163
|
+
|
|
164
|
+
// Clamp score
|
|
165
|
+
sanitized.trustScore = Math.max(0, Math.min(100, sanitized.trustScore));
|
|
166
|
+
|
|
167
|
+
// Remove undefined values
|
|
168
|
+
this._removeUndefined(sanitized);
|
|
169
|
+
|
|
170
|
+
return sanitized;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private _removeUndefined(obj: unknown): void {
|
|
174
|
+
if (typeof obj !== 'object' || obj === null) { return; }
|
|
175
|
+
for (const key of Object.keys(obj as Record<string, unknown>)) {
|
|
176
|
+
const val = (obj as Record<string, unknown>)[key];
|
|
177
|
+
if (val === undefined) {
|
|
178
|
+
delete (obj as Record<string, unknown>)[key];
|
|
179
|
+
} else {
|
|
180
|
+
this._removeUndefined(val);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// CHARGEBACK GUARD — Public Package Entry Point
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
export { ChargebackGuard } from './core/chargebackGuard';
|
|
6
|
+
export { ChargebackEventEmitter } from './core/eventEmitter';
|
|
7
|
+
export { LifecycleManager } from './core/lifecycle';
|
|
8
|
+
|
|
9
|
+
// Types — everything consumers need
|
|
10
|
+
export * from './types';
|
|
11
|
+
|
|
12
|
+
// Config export (readonly)
|
|
13
|
+
export { default as config } from './config';
|
|
14
|
+
|
|
15
|
+
// Named re-exports for convenience
|
|
16
|
+
export type { RegistrationResult, DisputeHandlingResult } from './core/chargebackGuard';
|
|
17
|
+
export type { HealthCheck, LifecycleStatus } from './core/lifecycle';
|
|
18
|
+
|
|
19
|
+
// ────────────────────────────────────────────────────────────
|
|
20
|
+
// DEFAULT FACTORY (quick-start)
|
|
21
|
+
// ────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
import { ChargebackGuard } from './core/chargebackGuard';
|
|
24
|
+
import { ChargebackGuardConfig } from './types';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create and initialize a ChargebackGuard instance in one call.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* const guard = await createChargebackGuard({
|
|
31
|
+
* apiKey: process.env.STRIPE_SECRET_KEY!,
|
|
32
|
+
* webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
33
|
+
* });
|
|
34
|
+
*/
|
|
35
|
+
export async function createChargebackGuard(
|
|
36
|
+
options: Partial<ChargebackGuardConfig> = {}
|
|
37
|
+
): Promise<ChargebackGuard> {
|
|
38
|
+
const guard = new ChargebackGuard(options);
|
|
39
|
+
await guard.initialize();
|
|
40
|
+
return guard;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default ChargebackGuard;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// CHARGEBACK GUARD — PayPal Integration (Full)
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import axios, { AxiosInstance } from 'axios';
|
|
6
|
+
import { createLogger } from '../utils/logger';
|
|
7
|
+
import { paypalConfig } from '../config';
|
|
8
|
+
import {
|
|
9
|
+
Dispute,
|
|
10
|
+
DisputeReply,
|
|
11
|
+
DisputeReason,
|
|
12
|
+
DisputeStatus,
|
|
13
|
+
PaymentProvider,
|
|
14
|
+
} from '../types';
|
|
15
|
+
|
|
16
|
+
const log = createLogger('PayPalIntegration');
|
|
17
|
+
|
|
18
|
+
interface PayPalTokenResponse {
|
|
19
|
+
access_token: string;
|
|
20
|
+
token_type: string;
|
|
21
|
+
expires_in: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ────────────────────────────────────────────────────────────
|
|
25
|
+
// PAYPAL INTEGRATION CLASS
|
|
26
|
+
// ────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export class PayPalIntegration {
|
|
29
|
+
private readonly baseUrl: string;
|
|
30
|
+
private readonly clientId: string;
|
|
31
|
+
private readonly clientSecret: string;
|
|
32
|
+
private accessToken: string | null = null;
|
|
33
|
+
private tokenExpiry: number = 0;
|
|
34
|
+
private readonly http: AxiosInstance;
|
|
35
|
+
|
|
36
|
+
constructor(
|
|
37
|
+
clientId?: string,
|
|
38
|
+
clientSecret?: string,
|
|
39
|
+
mode?: 'sandbox' | 'live'
|
|
40
|
+
) {
|
|
41
|
+
this.clientId = clientId ?? paypalConfig.clientId;
|
|
42
|
+
this.clientSecret = clientSecret ?? paypalConfig.clientSecret;
|
|
43
|
+
const env = mode ?? paypalConfig.mode;
|
|
44
|
+
|
|
45
|
+
this.baseUrl = env === 'live'
|
|
46
|
+
? 'https://api.paypal.com'
|
|
47
|
+
: 'https://api.sandbox.paypal.com';
|
|
48
|
+
|
|
49
|
+
this.http = axios.create({
|
|
50
|
+
baseURL: this.baseUrl,
|
|
51
|
+
timeout: 30000,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
log.debug(`PayPal client initialized (${env})`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ──────────────────────────────────────────
|
|
58
|
+
// AUTH
|
|
59
|
+
// ──────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
async authenticate(): Promise<string> {
|
|
62
|
+
if (this.accessToken && Date.now() < this.tokenExpiry) {
|
|
63
|
+
return this.accessToken;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const response = await axios.post<PayPalTokenResponse>(
|
|
67
|
+
`${this.baseUrl}/v1/oauth2/token`,
|
|
68
|
+
'grant_type=client_credentials',
|
|
69
|
+
{
|
|
70
|
+
auth: { username: this.clientId, password: this.clientSecret },
|
|
71
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
this.accessToken = response.data.access_token;
|
|
76
|
+
this.tokenExpiry = Date.now() + (response.data.expires_in - 60) * 1000;
|
|
77
|
+
log.debug('PayPal access token refreshed');
|
|
78
|
+
return this.accessToken;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private async _authHeaders(): Promise<Record<string, string>> {
|
|
82
|
+
const token = await this.authenticate();
|
|
83
|
+
return {
|
|
84
|
+
Authorization: `Bearer ${token}`,
|
|
85
|
+
'Content-Type': 'application/json',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ──────────────────────────────────────────
|
|
90
|
+
// GET DISPUTE
|
|
91
|
+
// ──────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
async getDispute(disputeId: string): Promise<Dispute> {
|
|
94
|
+
log.debug(`Fetching PayPal dispute: ${disputeId}`);
|
|
95
|
+
const headers = await this._authHeaders();
|
|
96
|
+
const response = await this.http.get(`/v1/customer/disputes/${disputeId}`, { headers });
|
|
97
|
+
return this._mapDisputeResponse(response.data as Record<string, unknown>);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ──────────────────────────────────────────
|
|
101
|
+
// LIST DISPUTES
|
|
102
|
+
// ──────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
async listDisputes(params?: { status?: string; limit?: number }): Promise<Dispute[]> {
|
|
105
|
+
const headers = await this._authHeaders();
|
|
106
|
+
const response = await this.http.get('/v1/customer/disputes', {
|
|
107
|
+
headers,
|
|
108
|
+
params: {
|
|
109
|
+
dispute_state: params?.status ?? 'OPEN',
|
|
110
|
+
page_size: params?.limit ?? 50,
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const data = response.data as Record<string, unknown>;
|
|
115
|
+
const items = (data['items'] as Record<string, unknown>[]) ?? [];
|
|
116
|
+
return items.map(item => this._mapDisputeResponse(item));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ──────────────────────────────────────────
|
|
120
|
+
// SUBMIT EVIDENCE
|
|
121
|
+
// ──────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
async submitDisputeEvidence(disputeId: string, reply: DisputeReply): Promise<void> {
|
|
124
|
+
log.info(`Submitting PayPal dispute evidence: ${disputeId}`);
|
|
125
|
+
const headers = await this._authHeaders();
|
|
126
|
+
|
|
127
|
+
const evidencePayload = {
|
|
128
|
+
evidences: reply.evidenceItems.map(item => ({
|
|
129
|
+
evidence_type: this._mapEvidenceType(item.type as string),
|
|
130
|
+
notes: `${item.description}\n\n${JSON.stringify(item.data, null, 2)}`,
|
|
131
|
+
})),
|
|
132
|
+
note: reply.caseArgument,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
await this.http.post(
|
|
136
|
+
`/v1/customer/disputes/${disputeId}/provide-evidence`,
|
|
137
|
+
evidencePayload,
|
|
138
|
+
{ headers }
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
log.info(`PayPal evidence submitted: ${disputeId}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ──────────────────────────────────────────
|
|
145
|
+
// ACCEPT DISPUTE (when appropriate)
|
|
146
|
+
// ──────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
async acceptDispute(disputeId: string, note?: string): Promise<void> {
|
|
149
|
+
const headers = await this._authHeaders();
|
|
150
|
+
await this.http.post(
|
|
151
|
+
`/v1/customer/disputes/${disputeId}/accept-dispute`,
|
|
152
|
+
{ note: note ?? 'Accepting dispute per merchant decision.' },
|
|
153
|
+
{ headers }
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ──────────────────────────────────────────
|
|
158
|
+
// APPEAL DISPUTE
|
|
159
|
+
// ──────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
async appealDispute(disputeId: string, note: string): Promise<void> {
|
|
162
|
+
const headers = await this._authHeaders();
|
|
163
|
+
await this.http.post(
|
|
164
|
+
`/v1/customer/disputes/${disputeId}/appeal`,
|
|
165
|
+
{ evidences: [{ evidence_type: 'PROOF_OF_FULFILLMENT', notes: note }] },
|
|
166
|
+
{ headers }
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ──────────────────────────────────────────
|
|
171
|
+
// WEBHOOK VERIFICATION
|
|
172
|
+
// ──────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
async verifyWebhookSignature(
|
|
175
|
+
headers: Record<string, string>,
|
|
176
|
+
body: string
|
|
177
|
+
): Promise<boolean> {
|
|
178
|
+
const authToken = await this.authenticate();
|
|
179
|
+
try {
|
|
180
|
+
const response = await this.http.post(
|
|
181
|
+
'/v1/notifications/verify-webhook-signature',
|
|
182
|
+
{
|
|
183
|
+
auth_algo: headers['paypal-auth-algo'],
|
|
184
|
+
cert_url: headers['paypal-cert-url'],
|
|
185
|
+
transmission_id: headers['paypal-transmission-id'],
|
|
186
|
+
transmission_sig: headers['paypal-transmission-sig'],
|
|
187
|
+
transmission_time: headers['paypal-transmission-time'],
|
|
188
|
+
webhook_id: paypalConfig.webhookId,
|
|
189
|
+
webhook_event: JSON.parse(body),
|
|
190
|
+
},
|
|
191
|
+
{ headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' } }
|
|
192
|
+
);
|
|
193
|
+
return (response.data as Record<string, string>)['verification_status'] === 'SUCCESS';
|
|
194
|
+
} catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ──────────────────────────────────────────
|
|
200
|
+
// HELPERS
|
|
201
|
+
// ──────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
private _mapDisputeResponse(data: Record<string, unknown>): Dispute {
|
|
204
|
+
const disputeAmount = (data['dispute_amount'] as Record<string, string> | undefined);
|
|
205
|
+
const amountValue = disputeAmount?.['value'] ? parseFloat(disputeAmount.value) * 100 : 0;
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
id: String(data['dispute_id'] ?? ''),
|
|
209
|
+
orderId: this._extractOrderId(data),
|
|
210
|
+
amount: Math.round(amountValue),
|
|
211
|
+
currency: (disputeAmount?.['currency_code']) ?? 'USD',
|
|
212
|
+
reason: this._mapReason(String(data['reason'] ?? 'OTHER')),
|
|
213
|
+
status: this._mapStatus(String(data['status'] ?? 'OPEN')),
|
|
214
|
+
provider: PaymentProvider.PAYPAL,
|
|
215
|
+
createdAt: String(data['create_time'] ?? new Date().toISOString()),
|
|
216
|
+
metadata: { raw: data },
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private _extractOrderId(data: Record<string, unknown>): string | undefined {
|
|
221
|
+
const txns = data['disputed_transactions'] as Array<Record<string, string>> | undefined;
|
|
222
|
+
return txns?.[0]?.['seller_transaction_id'];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private _mapReason(reason: string): DisputeReason {
|
|
226
|
+
const map: Record<string, DisputeReason> = {
|
|
227
|
+
MERCHANDISE_OR_SERVICE_NOT_RECEIVED: DisputeReason.PRODUCT_NOT_RECEIVED,
|
|
228
|
+
MERCHANDISE_OR_SERVICE_NOT_AS_DESCRIBED: DisputeReason.PRODUCT_UNACCEPTABLE,
|
|
229
|
+
UNAUTHORISED: DisputeReason.UNAUTHORIZED_TRANSACTION,
|
|
230
|
+
CREDIT_NOT_PROCESSED: DisputeReason.CREDIT_NOT_PROCESSED,
|
|
231
|
+
DUPLICATE_TRANSACTION: DisputeReason.DUPLICATE_TRANSACTION,
|
|
232
|
+
CANCELED_RECURRING_BILLING: DisputeReason.SUBSCRIPTION_CANCELLED,
|
|
233
|
+
};
|
|
234
|
+
return map[reason] ?? DisputeReason.GENERAL;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private _mapStatus(status: string): DisputeStatus {
|
|
238
|
+
const map: Record<string, DisputeStatus> = {
|
|
239
|
+
OPEN: DisputeStatus.NEEDS_RESPONSE,
|
|
240
|
+
UNDER_REVIEW: DisputeStatus.UNDER_REVIEW,
|
|
241
|
+
RESOLVED: DisputeStatus.CHARGE_REFUNDED,
|
|
242
|
+
WAITING_FOR_BUYER_RESPONSE: DisputeStatus.UNDER_REVIEW,
|
|
243
|
+
WAITING_FOR_SELLER_RESPONSE: DisputeStatus.NEEDS_RESPONSE,
|
|
244
|
+
};
|
|
245
|
+
return map[status] ?? DisputeStatus.NEEDS_RESPONSE;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private _mapEvidenceType(type: string): string {
|
|
249
|
+
const map: Record<string, string> = {
|
|
250
|
+
delivery_proof: 'PROOF_OF_FULFILLMENT',
|
|
251
|
+
device_fingerprint: 'PROOF_OF_FULFILLMENT',
|
|
252
|
+
customer_history: 'PROOF_OF_REFUND',
|
|
253
|
+
payment_log: 'PROOF_OF_FULFILLMENT',
|
|
254
|
+
email_confirmation: 'PROOF_OF_FULFILLMENT',
|
|
255
|
+
};
|
|
256
|
+
return map[type] ?? 'PROOF_OF_FULFILLMENT';
|
|
257
|
+
}
|
|
258
|
+
}
|