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,280 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// CHARGEBACK GUARD — Stripe Integration (Full)
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import Stripe from 'stripe';
|
|
6
|
+
import { createLogger } from '../utils/logger';
|
|
7
|
+
import { stripeConfig } from '../config';
|
|
8
|
+
import {
|
|
9
|
+
Dispute,
|
|
10
|
+
DisputeReply,
|
|
11
|
+
DisputeStatus,
|
|
12
|
+
DisputeReason,
|
|
13
|
+
PaymentProvider,
|
|
14
|
+
} from '../types';
|
|
15
|
+
|
|
16
|
+
const log = createLogger('StripeIntegration');
|
|
17
|
+
|
|
18
|
+
// ────────────────────────────────────────────────────────────
|
|
19
|
+
// DISPUTE STATUS / REASON MAPS
|
|
20
|
+
// ────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const STATUS_MAP: Record<string, DisputeStatus> = {
|
|
23
|
+
needs_response: DisputeStatus.NEEDS_RESPONSE,
|
|
24
|
+
under_review: DisputeStatus.UNDER_REVIEW,
|
|
25
|
+
charge_refunded: DisputeStatus.CHARGE_REFUNDED,
|
|
26
|
+
won: DisputeStatus.WON,
|
|
27
|
+
lost: DisputeStatus.LOST,
|
|
28
|
+
warning_needs_response: DisputeStatus.WARNING_NEEDS_RESPONSE,
|
|
29
|
+
warning_under_review: DisputeStatus.WARNING_UNDER_REVIEW,
|
|
30
|
+
warning_closed: DisputeStatus.WARNING_CLOSED,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const REASON_MAP: Record<string, DisputeReason> = {
|
|
34
|
+
product_not_received: DisputeReason.PRODUCT_NOT_RECEIVED,
|
|
35
|
+
product_unacceptable: DisputeReason.PRODUCT_UNACCEPTABLE,
|
|
36
|
+
fraudulent: DisputeReason.FRAUDULENT,
|
|
37
|
+
unrecognized: DisputeReason.UNAUTHORIZED_TRANSACTION,
|
|
38
|
+
duplicate: DisputeReason.DUPLICATE_TRANSACTION,
|
|
39
|
+
credit_not_processed: DisputeReason.CREDIT_NOT_PROCESSED,
|
|
40
|
+
subscription_canceled: DisputeReason.SUBSCRIPTION_CANCELLED,
|
|
41
|
+
general: DisputeReason.GENERAL,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// ────────────────────────────────────────────────────────────
|
|
45
|
+
// STRIPE INTEGRATION CLASS
|
|
46
|
+
// ────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export class StripeIntegration {
|
|
49
|
+
private readonly client: Stripe;
|
|
50
|
+
|
|
51
|
+
constructor(apiKey?: string) {
|
|
52
|
+
const key = apiKey ?? stripeConfig.secretKey;
|
|
53
|
+
if (!key) {
|
|
54
|
+
throw new Error('Stripe API key is required');
|
|
55
|
+
}
|
|
56
|
+
this.client = new Stripe(key, {
|
|
57
|
+
apiVersion: '2023-10-16' as Stripe.LatestApiVersion,
|
|
58
|
+
typescript: true,
|
|
59
|
+
maxNetworkRetries: 3,
|
|
60
|
+
});
|
|
61
|
+
log.debug('Stripe client initialized');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ──────────────────────────────────────────
|
|
65
|
+
// WEBHOOK VERIFICATION
|
|
66
|
+
// ──────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
verifyWebhookSignature(payload: string | Buffer, signature: string): Stripe.Event {
|
|
69
|
+
return this.client.webhooks.constructEvent(
|
|
70
|
+
payload,
|
|
71
|
+
signature,
|
|
72
|
+
stripeConfig.webhookSecret
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ──────────────────────────────────────────
|
|
77
|
+
// GET DISPUTE
|
|
78
|
+
// ──────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
async getDispute(disputeId: string): Promise<Dispute> {
|
|
81
|
+
log.debug(`Fetching dispute: ${disputeId}`);
|
|
82
|
+
const d = await this.client.disputes.retrieve(disputeId);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
id: d.id,
|
|
86
|
+
orderId: (d.metadata as Record<string, string>)['order_id']
|
|
87
|
+
?? (d.metadata as Record<string, string>)['orderId']
|
|
88
|
+
?? undefined,
|
|
89
|
+
amount: d.amount,
|
|
90
|
+
currency: d.currency,
|
|
91
|
+
reason: REASON_MAP[d.reason] ?? DisputeReason.GENERAL,
|
|
92
|
+
status: STATUS_MAP[d.status] ?? DisputeStatus.NEEDS_RESPONSE,
|
|
93
|
+
provider: PaymentProvider.STRIPE,
|
|
94
|
+
evidenceDueBy: d.evidence_due_by
|
|
95
|
+
? new Date(d.evidence_due_by * 1000).toISOString()
|
|
96
|
+
: undefined,
|
|
97
|
+
createdAt: new Date(d.created * 1000).toISOString(),
|
|
98
|
+
metadata: d.metadata as Record<string, unknown>,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ──────────────────────────────────────────
|
|
103
|
+
// LIST DISPUTES
|
|
104
|
+
// ──────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
async listDisputes(params?: {
|
|
107
|
+
status?: string;
|
|
108
|
+
limit?: number;
|
|
109
|
+
startingAfter?: string;
|
|
110
|
+
}): Promise<Dispute[]> {
|
|
111
|
+
const list = await this.client.disputes.list({
|
|
112
|
+
limit: params?.limit ?? 100,
|
|
113
|
+
starting_after: params?.startingAfter,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return list.data.map(d => ({
|
|
117
|
+
id: d.id,
|
|
118
|
+
orderId: (d.metadata as Record<string, string>)['order_id'] ?? undefined,
|
|
119
|
+
amount: d.amount,
|
|
120
|
+
currency: d.currency,
|
|
121
|
+
reason: REASON_MAP[d.reason] ?? DisputeReason.GENERAL,
|
|
122
|
+
status: STATUS_MAP[d.status] ?? DisputeStatus.NEEDS_RESPONSE,
|
|
123
|
+
provider: PaymentProvider.STRIPE,
|
|
124
|
+
evidenceDueBy: d.evidence_due_by
|
|
125
|
+
? new Date(d.evidence_due_by * 1000).toISOString()
|
|
126
|
+
: undefined,
|
|
127
|
+
createdAt: new Date(d.created * 1000).toISOString(),
|
|
128
|
+
metadata: d.metadata as Record<string, unknown>,
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ──────────────────────────────────────────
|
|
133
|
+
// LIST ACTIONABLE DISPUTES
|
|
134
|
+
// ──────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
async listActionableDisputes(): Promise<Dispute[]> {
|
|
137
|
+
const [needs, warning] = await Promise.all([
|
|
138
|
+
this.listDisputes({ status: 'needs_response', limit: 100 }),
|
|
139
|
+
this.listDisputes({ status: 'warning_needs_response', limit: 100 }),
|
|
140
|
+
]);
|
|
141
|
+
return [...needs, ...warning];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ──────────────────────────────────────────
|
|
145
|
+
// SUBMIT EVIDENCE
|
|
146
|
+
// ──────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
async submitDisputeEvidence(disputeId: string, reply: DisputeReply): Promise<Stripe.Dispute> {
|
|
149
|
+
log.info(`Submitting evidence for dispute: ${disputeId}`);
|
|
150
|
+
|
|
151
|
+
const evidencePayload = this._buildEvidencePayload(reply);
|
|
152
|
+
|
|
153
|
+
const result = await this.client.disputes.update(disputeId, {
|
|
154
|
+
evidence: evidencePayload,
|
|
155
|
+
submit: true,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
log.info(`Evidence submitted for dispute: ${disputeId}`, { status: result.status });
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ──────────────────────────────────────────
|
|
163
|
+
// UPDATE DISPUTE METADATA
|
|
164
|
+
// ──────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
async updateDisputeMetadata(
|
|
167
|
+
disputeId: string,
|
|
168
|
+
metadata: Record<string, string>
|
|
169
|
+
): Promise<void> {
|
|
170
|
+
await this.client.disputes.update(disputeId, { metadata });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ──────────────────────────────────────────
|
|
174
|
+
// GET DISPUTE STATS
|
|
175
|
+
// ──────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
async getDisputeStats(): Promise<{
|
|
178
|
+
total: number;
|
|
179
|
+
byStatus: Record<string, number>;
|
|
180
|
+
byReason: Record<string, number>;
|
|
181
|
+
totalAmount: number;
|
|
182
|
+
}> {
|
|
183
|
+
const all = await this.client.disputes.list({ limit: 100 });
|
|
184
|
+
|
|
185
|
+
const stats = {
|
|
186
|
+
total: 0,
|
|
187
|
+
byStatus: {} as Record<string, number>,
|
|
188
|
+
byReason: {} as Record<string, number>,
|
|
189
|
+
totalAmount: 0,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
for (const d of all.data) {
|
|
193
|
+
stats.total++;
|
|
194
|
+
stats.totalAmount += d.amount;
|
|
195
|
+
stats.byStatus[d.status] = (stats.byStatus[d.status] ?? 0) + 1;
|
|
196
|
+
stats.byReason[d.reason] = (stats.byReason[d.reason] ?? 0) + 1;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return stats;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ──────────────────────────────────────────
|
|
203
|
+
// RETRIEVE CHARGE
|
|
204
|
+
// ──────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
async getCharge(chargeId: string): Promise<Stripe.Charge> {
|
|
207
|
+
return this.client.charges.retrieve(chargeId);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ──────────────────────────────────────────
|
|
211
|
+
// RETRIEVE PAYMENT INTENT
|
|
212
|
+
// ──────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
async getPaymentIntent(piId: string): Promise<Stripe.PaymentIntent> {
|
|
215
|
+
return this.client.paymentIntents.retrieve(piId, {
|
|
216
|
+
expand: ['payment_method', 'latest_charge'],
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ──────────────────────────────────────────
|
|
221
|
+
// EVIDENCE PAYLOAD BUILDER
|
|
222
|
+
// ──────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
private _buildEvidencePayload(reply: DisputeReply): Stripe.DisputeUpdateParams.Evidence {
|
|
225
|
+
const payload: Stripe.DisputeUpdateParams.Evidence = {
|
|
226
|
+
uncategorized_text: reply.caseArgument,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
for (const item of reply.evidenceItems) {
|
|
230
|
+
const d = item.data;
|
|
231
|
+
switch (item.type) {
|
|
232
|
+
case 'delivery_proof':
|
|
233
|
+
if (d['trackingNumber']) {
|
|
234
|
+
payload.shipping_tracking_number = String(d['trackingNumber']);
|
|
235
|
+
}
|
|
236
|
+
if (d['shippingAddress']) {
|
|
237
|
+
payload.shipping_address = JSON.stringify(d['shippingAddress']);
|
|
238
|
+
}
|
|
239
|
+
if (d['shippedAt']) {
|
|
240
|
+
payload.shipping_date = String(d['shippedAt']);
|
|
241
|
+
}
|
|
242
|
+
payload.shipping_documentation = JSON.stringify(d);
|
|
243
|
+
break;
|
|
244
|
+
|
|
245
|
+
case 'device_fingerprint':
|
|
246
|
+
case 'behavior_analysis':
|
|
247
|
+
payload.access_activity_log = JSON.stringify(d);
|
|
248
|
+
break;
|
|
249
|
+
|
|
250
|
+
case 'customer_communication':
|
|
251
|
+
case 'email_confirmation':
|
|
252
|
+
if (d['sentTo']) {
|
|
253
|
+
payload.customer_email_address = String(d['sentTo']);
|
|
254
|
+
}
|
|
255
|
+
payload.customer_communication = JSON.stringify(d);
|
|
256
|
+
break;
|
|
257
|
+
|
|
258
|
+
case 'customer_history':
|
|
259
|
+
payload.customer_name = String(d['customerId'] ?? '');
|
|
260
|
+
break;
|
|
261
|
+
|
|
262
|
+
case 'payment_log':
|
|
263
|
+
payload.duplicate_charge_explanation = JSON.stringify(d);
|
|
264
|
+
break;
|
|
265
|
+
|
|
266
|
+
case 'refund_policy':
|
|
267
|
+
payload.refund_policy = String(d['policy'] ?? '');
|
|
268
|
+
payload.refund_policy_disclosure = String(d['note'] ?? '');
|
|
269
|
+
break;
|
|
270
|
+
|
|
271
|
+
case 'subscription_terms':
|
|
272
|
+
payload.cancellation_policy = String(d['cancellationPolicy'] ?? '');
|
|
273
|
+
payload.cancellation_policy_disclosure = String(d['note'] ?? '');
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return payload;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// CHARGEBACK GUARD — Webhook Handler (Stripe + PayPal)
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import { Request, Response } from 'express';
|
|
6
|
+
import { createLogger } from '../utils/logger';
|
|
7
|
+
import { stripeConfig, paypalConfig } from '../config';
|
|
8
|
+
import {
|
|
9
|
+
WebhookEvent,
|
|
10
|
+
ProcessedWebhookResult,
|
|
11
|
+
PaymentProvider,
|
|
12
|
+
Dispute,
|
|
13
|
+
DisputeStatus,
|
|
14
|
+
NotificationEvent,
|
|
15
|
+
} from '../types';
|
|
16
|
+
import { verifySignature } from '../evidence/encryption';
|
|
17
|
+
|
|
18
|
+
const log = createLogger('WebhookHandler');
|
|
19
|
+
|
|
20
|
+
// ────────────────────────────────────────────────────────────
|
|
21
|
+
// EVENT PROCESSING CALLBACK TYPE
|
|
22
|
+
// ────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export type DisputeCallback = (dispute: Dispute) => Promise<void>;
|
|
25
|
+
export type DisputeResolvedCallback = (
|
|
26
|
+
disputeId: string,
|
|
27
|
+
status: DisputeStatus,
|
|
28
|
+
provider: PaymentProvider
|
|
29
|
+
) => Promise<void>;
|
|
30
|
+
|
|
31
|
+
// ────────────────────────────────────────────────────────────
|
|
32
|
+
// WEBHOOK HANDLER
|
|
33
|
+
// ────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export class WebhookHandler {
|
|
36
|
+
private disputeCallbacks: DisputeCallback[] = [];
|
|
37
|
+
private resolvedCallbacks: DisputeResolvedCallback[] = [];
|
|
38
|
+
private processedEventIds = new Set<string>();
|
|
39
|
+
|
|
40
|
+
// ──────────────────────────────────────────
|
|
41
|
+
// CALLBACK REGISTRATION
|
|
42
|
+
// ──────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
onDisputeDetected(cb: DisputeCallback): void {
|
|
45
|
+
this.disputeCallbacks.push(cb);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
onDisputeResolved(cb: DisputeResolvedCallback): void {
|
|
49
|
+
this.resolvedCallbacks.push(cb);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ──────────────────────────────────────────
|
|
53
|
+
// STRIPE WEBHOOK HANDLER (Express middleware)
|
|
54
|
+
// ──────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
handleStripe(): (req: Request, res: Response) => Promise<void> {
|
|
57
|
+
return async (req: Request, res: Response) => {
|
|
58
|
+
const signature = req.headers['stripe-signature'] as string;
|
|
59
|
+
const rawBody = (req as Request & { rawBody?: Buffer }).rawBody ?? req.body;
|
|
60
|
+
|
|
61
|
+
if (!signature) {
|
|
62
|
+
res.status(400).json({ error: 'Missing stripe-signature header' });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let event: import('stripe').Stripe.Event;
|
|
67
|
+
try {
|
|
68
|
+
const { StripeIntegration } = await import('./stripe');
|
|
69
|
+
const stripe = new StripeIntegration();
|
|
70
|
+
event = stripe.verifyWebhookSignature(rawBody, signature);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
log.warn('Stripe webhook signature verification failed', {
|
|
73
|
+
error: err instanceof Error ? err.message : String(err),
|
|
74
|
+
});
|
|
75
|
+
res.status(400).json({ error: 'Invalid signature' });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Idempotency check
|
|
80
|
+
if (this.processedEventIds.has(event.id)) {
|
|
81
|
+
log.debug(`Duplicate event ignored: ${event.id}`);
|
|
82
|
+
res.status(200).json({ received: true, duplicate: true });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this.processedEventIds.add(event.id);
|
|
86
|
+
if (this.processedEventIds.size > 10000) {
|
|
87
|
+
// Keep memory bounded
|
|
88
|
+
const oldest = this.processedEventIds.values().next().value;
|
|
89
|
+
if (oldest) { this.processedEventIds.delete(oldest); }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const result = await this._processStripeEvent(event);
|
|
93
|
+
res.status(200).json(result);
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ──────────────────────────────────────────
|
|
98
|
+
// PAYPAL WEBHOOK HANDLER
|
|
99
|
+
// ──────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
handlePayPal(): (req: Request, res: Response) => Promise<void> {
|
|
102
|
+
return async (req: Request, res: Response) => {
|
|
103
|
+
const body = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
|
|
104
|
+
|
|
105
|
+
// Verify PayPal signature
|
|
106
|
+
try {
|
|
107
|
+
const { PayPalIntegration } = await import('./paypal');
|
|
108
|
+
const paypal = new PayPalIntegration();
|
|
109
|
+
const valid = await paypal.verifyWebhookSignature(
|
|
110
|
+
req.headers as Record<string, string>,
|
|
111
|
+
body
|
|
112
|
+
);
|
|
113
|
+
if (!valid) {
|
|
114
|
+
res.status(400).json({ error: 'Invalid PayPal signature' });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
log.warn('PayPal webhook verification failed', {
|
|
119
|
+
error: err instanceof Error ? err.message : String(err),
|
|
120
|
+
});
|
|
121
|
+
// Allow in development
|
|
122
|
+
if (process.env.NODE_ENV === 'production') {
|
|
123
|
+
res.status(400).json({ error: 'Signature verification failed' });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const result = await this._processPayPalEvent(req.body as Record<string, unknown>);
|
|
129
|
+
res.status(200).json(result);
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ──────────────────────────────────────────
|
|
134
|
+
// PROCESS STRIPE EVENT
|
|
135
|
+
// ──────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
private async _processStripeEvent(
|
|
138
|
+
event: import('stripe').Stripe.Event
|
|
139
|
+
): Promise<ProcessedWebhookResult> {
|
|
140
|
+
log.info(`Processing Stripe event: ${event.type} (${event.id})`);
|
|
141
|
+
|
|
142
|
+
const obj = event.data.object as Record<string, unknown>;
|
|
143
|
+
|
|
144
|
+
switch (event.type) {
|
|
145
|
+
case 'charge.dispute.created': {
|
|
146
|
+
const dispute = this._parseStripeDispute(obj, DisputeStatus.NEEDS_RESPONSE);
|
|
147
|
+
await this._fireDisputeCallbacks(dispute);
|
|
148
|
+
return this._result(event.id, event.type, 'dispute_detected');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
case 'charge.dispute.updated': {
|
|
152
|
+
const dispute = this._parseStripeDispute(obj);
|
|
153
|
+
await this._fireDisputeCallbacks(dispute);
|
|
154
|
+
return this._result(event.id, event.type, 'dispute_updated');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
case 'charge.dispute.closed': {
|
|
158
|
+
const disputeId = obj['id'] as string;
|
|
159
|
+
const status = obj['status'] as string;
|
|
160
|
+
const resolved = this._mapStripeStatus(status);
|
|
161
|
+
await this._fireResolvedCallbacks(disputeId, resolved, PaymentProvider.STRIPE);
|
|
162
|
+
|
|
163
|
+
if (resolved === DisputeStatus.WON) {
|
|
164
|
+
log.info(`🏆 Dispute WON: ${disputeId}`);
|
|
165
|
+
} else if (resolved === DisputeStatus.LOST) {
|
|
166
|
+
log.warn(`❌ Dispute LOST: ${disputeId}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return this._result(event.id, event.type, `dispute_${status}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case 'charge.dispute.funds_withdrawn':
|
|
173
|
+
log.warn(`💸 Funds withdrawn for dispute: ${obj['id']}`);
|
|
174
|
+
return this._result(event.id, event.type, 'funds_withdrawn');
|
|
175
|
+
|
|
176
|
+
case 'charge.dispute.funds_reinstated':
|
|
177
|
+
log.info(`💰 Funds reinstated for dispute: ${obj['id']}`);
|
|
178
|
+
return this._result(event.id, event.type, 'funds_reinstated');
|
|
179
|
+
|
|
180
|
+
default:
|
|
181
|
+
log.debug(`Unhandled Stripe event: ${event.type}`);
|
|
182
|
+
return this._result(event.id, event.type, 'unhandled');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ──────────────────────────────────────────
|
|
187
|
+
// PROCESS PAYPAL EVENT
|
|
188
|
+
// ──────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
private async _processPayPalEvent(
|
|
191
|
+
body: Record<string, unknown>
|
|
192
|
+
): Promise<ProcessedWebhookResult> {
|
|
193
|
+
const eventType = String(body['event_type'] ?? '');
|
|
194
|
+
const eventId = String(body['id'] ?? Date.now());
|
|
195
|
+
const resource = (body['resource'] as Record<string, unknown>) ?? {};
|
|
196
|
+
|
|
197
|
+
log.info(`Processing PayPal event: ${eventType} (${eventId})`);
|
|
198
|
+
|
|
199
|
+
switch (eventType) {
|
|
200
|
+
case 'CUSTOMER.DISPUTE.CREATED': {
|
|
201
|
+
const dispute = this._parsePayPalDispute(resource, DisputeStatus.NEEDS_RESPONSE);
|
|
202
|
+
await this._fireDisputeCallbacks(dispute);
|
|
203
|
+
return this._result(eventId, eventType, 'dispute_detected');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
case 'CUSTOMER.DISPUTE.UPDATED': {
|
|
207
|
+
const dispute = this._parsePayPalDispute(resource);
|
|
208
|
+
await this._fireDisputeCallbacks(dispute);
|
|
209
|
+
return this._result(eventId, eventType, 'dispute_updated');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
case 'CUSTOMER.DISPUTE.RESOLVED': {
|
|
213
|
+
const disputeId = String(resource['dispute_id'] ?? '');
|
|
214
|
+
const status = String(resource['status'] ?? 'RESOLVED');
|
|
215
|
+
await this._fireResolvedCallbacks(
|
|
216
|
+
disputeId,
|
|
217
|
+
DisputeStatus.CHARGE_REFUNDED,
|
|
218
|
+
PaymentProvider.PAYPAL
|
|
219
|
+
);
|
|
220
|
+
return this._result(eventId, eventType, `dispute_${status.toLowerCase()}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
default:
|
|
224
|
+
return this._result(eventId, eventType, 'unhandled');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ──────────────────────────────────────────
|
|
229
|
+
// FIRE CALLBACKS
|
|
230
|
+
// ──────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
private async _fireDisputeCallbacks(dispute: Dispute): Promise<void> {
|
|
233
|
+
for (const cb of this.disputeCallbacks) {
|
|
234
|
+
try { await cb(dispute); }
|
|
235
|
+
catch (err) {
|
|
236
|
+
log.error('Dispute callback error', {
|
|
237
|
+
error: err instanceof Error ? err.message : String(err),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private async _fireResolvedCallbacks(
|
|
244
|
+
disputeId: string,
|
|
245
|
+
status: DisputeStatus,
|
|
246
|
+
provider: PaymentProvider
|
|
247
|
+
): Promise<void> {
|
|
248
|
+
for (const cb of this.resolvedCallbacks) {
|
|
249
|
+
try { await cb(disputeId, status, provider); }
|
|
250
|
+
catch (err) {
|
|
251
|
+
log.error('Resolved callback error', {
|
|
252
|
+
error: err instanceof Error ? err.message : String(err),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ──────────────────────────────────────────
|
|
259
|
+
// PARSERS
|
|
260
|
+
// ──────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
private _parseStripeDispute(
|
|
263
|
+
obj: Record<string, unknown>,
|
|
264
|
+
forceStatus?: DisputeStatus
|
|
265
|
+
): Dispute {
|
|
266
|
+
return {
|
|
267
|
+
id: String(obj['id'] ?? ''),
|
|
268
|
+
orderId: ((obj['metadata'] as Record<string, string>)?.['order_id']) ?? undefined,
|
|
269
|
+
amount: Number(obj['amount'] ?? 0),
|
|
270
|
+
currency: String(obj['currency'] ?? 'usd'),
|
|
271
|
+
reason: String(obj['reason'] ?? 'general') as Dispute['reason'],
|
|
272
|
+
status: forceStatus ?? this._mapStripeStatus(String(obj['status'] ?? 'needs_response')),
|
|
273
|
+
provider: PaymentProvider.STRIPE,
|
|
274
|
+
evidenceDueBy: obj['evidence_due_by']
|
|
275
|
+
? new Date(Number(obj['evidence_due_by']) * 1000).toISOString()
|
|
276
|
+
: undefined,
|
|
277
|
+
createdAt: new Date(Number(obj['created'] ?? Date.now() / 1000) * 1000).toISOString(),
|
|
278
|
+
metadata: (obj['metadata'] as Record<string, unknown>) ?? {},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private _parsePayPalDispute(
|
|
283
|
+
resource: Record<string, unknown>,
|
|
284
|
+
forceStatus?: DisputeStatus
|
|
285
|
+
): Dispute {
|
|
286
|
+
const amt = (resource['dispute_amount'] as Record<string, string> | undefined);
|
|
287
|
+
return {
|
|
288
|
+
id: String(resource['dispute_id'] ?? ''),
|
|
289
|
+
amount: Math.round(parseFloat(amt?.['value'] ?? '0') * 100),
|
|
290
|
+
currency: amt?.['currency_code'] ?? 'USD',
|
|
291
|
+
reason: String(resource['reason'] ?? 'OTHER') as Dispute['reason'],
|
|
292
|
+
status: forceStatus ?? DisputeStatus.NEEDS_RESPONSE,
|
|
293
|
+
provider: PaymentProvider.PAYPAL,
|
|
294
|
+
createdAt: String(resource['create_time'] ?? new Date().toISOString()),
|
|
295
|
+
metadata: { raw: resource },
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ──────────────────────────────────────────
|
|
300
|
+
// HELPERS
|
|
301
|
+
// ──────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
private _mapStripeStatus(status: string): DisputeStatus {
|
|
304
|
+
const map: Record<string, DisputeStatus> = {
|
|
305
|
+
needs_response: DisputeStatus.NEEDS_RESPONSE,
|
|
306
|
+
under_review: DisputeStatus.UNDER_REVIEW,
|
|
307
|
+
charge_refunded: DisputeStatus.CHARGE_REFUNDED,
|
|
308
|
+
won: DisputeStatus.WON,
|
|
309
|
+
lost: DisputeStatus.LOST,
|
|
310
|
+
warning_needs_response: DisputeStatus.WARNING_NEEDS_RESPONSE,
|
|
311
|
+
warning_under_review: DisputeStatus.WARNING_UNDER_REVIEW,
|
|
312
|
+
warning_closed: DisputeStatus.WARNING_CLOSED,
|
|
313
|
+
};
|
|
314
|
+
return map[status] ?? DisputeStatus.NEEDS_RESPONSE;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private _result(
|
|
318
|
+
eventId: string,
|
|
319
|
+
type: string,
|
|
320
|
+
action: string
|
|
321
|
+
): ProcessedWebhookResult {
|
|
322
|
+
return {
|
|
323
|
+
eventId,
|
|
324
|
+
type,
|
|
325
|
+
processed: true,
|
|
326
|
+
action,
|
|
327
|
+
processedAt: new Date().toISOString(),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export const webhookHandler = new WebhookHandler();
|