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,161 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// CHARGEBACK GUARD — Email Notifications (Nodemailer)
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import nodemailer from 'nodemailer';
|
|
6
|
+
import { createLogger } from '../utils/logger';
|
|
7
|
+
import { emailConfig } from '../config';
|
|
8
|
+
|
|
9
|
+
const log = createLogger('Email');
|
|
10
|
+
|
|
11
|
+
// ────────────────────────────────────────────────────────────
|
|
12
|
+
// EMAIL SERVICE
|
|
13
|
+
// ────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export class EmailService {
|
|
16
|
+
private transporter: nodemailer.Transporter;
|
|
17
|
+
|
|
18
|
+
constructor() {
|
|
19
|
+
this.transporter = nodemailer.createTransport({
|
|
20
|
+
host: emailConfig.host,
|
|
21
|
+
port: emailConfig.port,
|
|
22
|
+
secure: emailConfig.secure,
|
|
23
|
+
auth: {
|
|
24
|
+
user: emailConfig.user,
|
|
25
|
+
pass: emailConfig.pass,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ──────────────────────────────────────────
|
|
31
|
+
// DISPUTE DETECTED
|
|
32
|
+
// ──────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
async sendDisputeAlert(opts: {
|
|
35
|
+
to: string;
|
|
36
|
+
disputeId: string;
|
|
37
|
+
orderId?: string;
|
|
38
|
+
amount: number;
|
|
39
|
+
reason: string;
|
|
40
|
+
dueBy?: string;
|
|
41
|
+
}): Promise<void> {
|
|
42
|
+
const subject = `⚠️ New Chargeback Dispute: ${opts.disputeId}`;
|
|
43
|
+
const html = `
|
|
44
|
+
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;">
|
|
45
|
+
<div style="background:#c0392b;padding:20px;border-radius:8px 8px 0 0;">
|
|
46
|
+
<h2 style="color:white;margin:0;">⚠️ New Chargeback Dispute</h2>
|
|
47
|
+
</div>
|
|
48
|
+
<div style="background:#f9f9f9;padding:24px;border-radius:0 0 8px 8px;border:1px solid #ddd;">
|
|
49
|
+
<p>A new chargeback dispute has been filed. ChargebackGuard is processing it automatically.</p>
|
|
50
|
+
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
|
|
51
|
+
<tr><td style="padding:8px;font-weight:bold;background:#eee;">Dispute ID</td><td style="padding:8px;">${opts.disputeId}</td></tr>
|
|
52
|
+
<tr><td style="padding:8px;font-weight:bold;background:#eee;">Order ID</td><td style="padding:8px;">${opts.orderId ?? 'N/A'}</td></tr>
|
|
53
|
+
<tr><td style="padding:8px;font-weight:bold;background:#eee;">Amount</td><td style="padding:8px;">$${(opts.amount / 100).toFixed(2)}</td></tr>
|
|
54
|
+
<tr><td style="padding:8px;font-weight:bold;background:#eee;">Reason</td><td style="padding:8px;">${opts.reason}</td></tr>
|
|
55
|
+
${opts.dueBy ? `<tr><td style="padding:8px;font-weight:bold;background:#eee;">Evidence Due</td><td style="padding:8px;color:#c0392b;">${new Date(opts.dueBy).toLocaleDateString()}</td></tr>` : ''}
|
|
56
|
+
</table>
|
|
57
|
+
<p style="color:#666;font-size:12px;">ChargebackGuard is building your defense automatically. You will receive an update when the reply is submitted.</p>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
await this._send({ to: opts.to, subject, html });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ──────────────────────────────────────────
|
|
66
|
+
// DISPUTE WON
|
|
67
|
+
// ──────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
async sendDisputeWon(opts: {
|
|
70
|
+
to: string;
|
|
71
|
+
disputeId: string;
|
|
72
|
+
amount: number;
|
|
73
|
+
}): Promise<void> {
|
|
74
|
+
const subject = `🏆 Chargeback WON: $${(opts.amount / 100).toFixed(2)} Recovered`;
|
|
75
|
+
const html = `
|
|
76
|
+
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;">
|
|
77
|
+
<div style="background:#27ae60;padding:20px;border-radius:8px 8px 0 0;">
|
|
78
|
+
<h2 style="color:white;margin:0;">🏆 Chargeback Won!</h2>
|
|
79
|
+
</div>
|
|
80
|
+
<div style="background:#f9f9f9;padding:24px;border-radius:0 0 8px 8px;border:1px solid #ddd;">
|
|
81
|
+
<p style="font-size:18px;">ChargebackGuard successfully defended dispute <strong>${opts.disputeId}</strong>.</p>
|
|
82
|
+
<p style="font-size:24px;color:#27ae60;text-align:center;font-weight:bold;">$${(opts.amount / 100).toFixed(2)} Recovered</p>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
`;
|
|
86
|
+
await this._send({ to: opts.to, subject, html });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ──────────────────────────────────────────
|
|
90
|
+
// WEEKLY REPORT
|
|
91
|
+
// ──────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
async sendWeeklyReport(opts: {
|
|
94
|
+
to: string;
|
|
95
|
+
totalOrders: number;
|
|
96
|
+
disputes: number;
|
|
97
|
+
won: number;
|
|
98
|
+
recovered: number;
|
|
99
|
+
}): Promise<void> {
|
|
100
|
+
const winRate = opts.disputes > 0 ? ((opts.won / opts.disputes) * 100).toFixed(1) : '0';
|
|
101
|
+
const subject = `📊 ChargebackGuard Weekly Report`;
|
|
102
|
+
const html = `
|
|
103
|
+
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;">
|
|
104
|
+
<div style="background:#2c3e50;padding:20px;border-radius:8px 8px 0 0;">
|
|
105
|
+
<h2 style="color:white;margin:0;">📊 Weekly Report</h2>
|
|
106
|
+
</div>
|
|
107
|
+
<div style="background:#f9f9f9;padding:24px;border-radius:0 0 8px 8px;border:1px solid #ddd;">
|
|
108
|
+
<div style="display:flex;gap:16px;justify-content:space-around;text-align:center;margin:16px 0;">
|
|
109
|
+
<div style="background:white;padding:16px;border-radius:8px;border:1px solid #ddd;flex:1;">
|
|
110
|
+
<div style="font-size:28px;font-weight:bold;color:#2c3e50;">${opts.totalOrders}</div>
|
|
111
|
+
<div style="color:#666;">Orders Protected</div>
|
|
112
|
+
</div>
|
|
113
|
+
<div style="background:white;padding:16px;border-radius:8px;border:1px solid #ddd;flex:1;">
|
|
114
|
+
<div style="font-size:28px;font-weight:bold;color:#e74c3c;">${opts.disputes}</div>
|
|
115
|
+
<div style="color:#666;">Disputes Filed</div>
|
|
116
|
+
</div>
|
|
117
|
+
<div style="background:white;padding:16px;border-radius:8px;border:1px solid #ddd;flex:1;">
|
|
118
|
+
<div style="font-size:28px;font-weight:bold;color:#27ae60;">${winRate}%</div>
|
|
119
|
+
<div style="color:#666;">Win Rate</div>
|
|
120
|
+
</div>
|
|
121
|
+
<div style="background:white;padding:16px;border-radius:8px;border:1px solid #ddd;flex:1;">
|
|
122
|
+
<div style="font-size:28px;font-weight:bold;color:#27ae60;">$${(opts.recovered / 100).toFixed(0)}</div>
|
|
123
|
+
<div style="color:#666;">Recovered</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
`;
|
|
129
|
+
await this._send({ to: opts.to, subject, html });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ──────────────────────────────────────────
|
|
133
|
+
// GENERIC SEND
|
|
134
|
+
// ──────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
private async _send(opts: { to: string; subject: string; html: string }): Promise<void> {
|
|
137
|
+
try {
|
|
138
|
+
await this.transporter.sendMail({
|
|
139
|
+
from: `"${emailConfig.fromName}" <${emailConfig.fromAddress}>`,
|
|
140
|
+
to: opts.to,
|
|
141
|
+
subject: opts.subject,
|
|
142
|
+
html: opts.html,
|
|
143
|
+
});
|
|
144
|
+
log.info(`Email sent to ${opts.to}: ${opts.subject}`);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
log.error(`Email failed to ${opts.to}`, {
|
|
147
|
+
error: err instanceof Error ? err.message : String(err),
|
|
148
|
+
});
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async verifyConnection(): Promise<boolean> {
|
|
154
|
+
try {
|
|
155
|
+
await this.transporter.verify();
|
|
156
|
+
return true;
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// CHARGEBACK GUARD — Notification Service (Unified)
|
|
3
|
+
// Dispatches email, SMS, webhook, and in-app notifications
|
|
4
|
+
// ============================================================
|
|
5
|
+
|
|
6
|
+
import { createLogger } from '../utils/logger';
|
|
7
|
+
import {
|
|
8
|
+
NotificationPayload,
|
|
9
|
+
NotificationResult,
|
|
10
|
+
NotificationChannel,
|
|
11
|
+
NotificationEvent,
|
|
12
|
+
} from '../types';
|
|
13
|
+
import axios from 'axios';
|
|
14
|
+
import { webhookConfig } from '../config';
|
|
15
|
+
import { signPayload } from '../evidence/encryption';
|
|
16
|
+
|
|
17
|
+
const log = createLogger('NotificationService');
|
|
18
|
+
|
|
19
|
+
// ────────────────────────────────────────────────────────────
|
|
20
|
+
// IN-APP NOTIFICATION STORE
|
|
21
|
+
// ────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export interface InAppNotification {
|
|
24
|
+
id: string;
|
|
25
|
+
merchantId?: string;
|
|
26
|
+
event: string;
|
|
27
|
+
title: string;
|
|
28
|
+
message: string;
|
|
29
|
+
severity: 'info' | 'warning' | 'success' | 'error';
|
|
30
|
+
read: boolean;
|
|
31
|
+
data?: Record<string, unknown>;
|
|
32
|
+
createdAt: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const inAppStore: InAppNotification[] = [];
|
|
36
|
+
|
|
37
|
+
// ────────────────────────────────────────────────────────────
|
|
38
|
+
// NOTIFICATION SERVICE
|
|
39
|
+
// ────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
export class NotificationService {
|
|
42
|
+
private registeredWebhooks: Array<{ url: string; secret?: string; events?: string[] }> = [];
|
|
43
|
+
|
|
44
|
+
registerWebhook(url: string, secret?: string, events?: string[]): void {
|
|
45
|
+
this.registeredWebhooks.push({ url, secret, events });
|
|
46
|
+
log.debug(`Webhook registered: ${url}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ──────────────────────────────────────────
|
|
50
|
+
// DISPATCH (routes to correct channel)
|
|
51
|
+
// ──────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
async dispatch(payload: NotificationPayload): Promise<NotificationResult[]> {
|
|
54
|
+
const results: NotificationResult[] = [];
|
|
55
|
+
const channels = payload.channel ? [payload.channel] : [NotificationChannel.IN_APP];
|
|
56
|
+
|
|
57
|
+
for (const channel of channels) {
|
|
58
|
+
let result: NotificationResult;
|
|
59
|
+
|
|
60
|
+
switch (channel) {
|
|
61
|
+
case NotificationChannel.EMAIL:
|
|
62
|
+
result = await this._sendEmail(payload);
|
|
63
|
+
break;
|
|
64
|
+
case NotificationChannel.SMS:
|
|
65
|
+
result = await this._sendSMS(payload);
|
|
66
|
+
break;
|
|
67
|
+
case NotificationChannel.WEBHOOK:
|
|
68
|
+
result = await this._sendWebhook(payload);
|
|
69
|
+
break;
|
|
70
|
+
case NotificationChannel.IN_APP:
|
|
71
|
+
default:
|
|
72
|
+
result = this._storeInApp(payload);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
results.push(result);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return results;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ──────────────────────────────────────────
|
|
83
|
+
// EMAIL
|
|
84
|
+
// ──────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
private async _sendEmail(payload: NotificationPayload): Promise<NotificationResult> {
|
|
87
|
+
try {
|
|
88
|
+
const { EmailService } = await import('./email');
|
|
89
|
+
const emailSvc = new EmailService();
|
|
90
|
+
|
|
91
|
+
const to = payload.recipient.email;
|
|
92
|
+
if (!to) {
|
|
93
|
+
return this._fail(NotificationChannel.EMAIL, 'No email address provided');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (payload.event === NotificationEvent.DISPUTE_DETECTED) {
|
|
97
|
+
await emailSvc.sendDisputeAlert({
|
|
98
|
+
to,
|
|
99
|
+
disputeId: String(payload.data['disputeId'] ?? ''),
|
|
100
|
+
orderId: payload.data['orderId'] as string,
|
|
101
|
+
amount: Number(payload.data['amount'] ?? 0),
|
|
102
|
+
reason: String(payload.data['reason'] ?? 'unknown'),
|
|
103
|
+
dueBy: payload.data['dueBy'] as string,
|
|
104
|
+
});
|
|
105
|
+
} else if (payload.event === NotificationEvent.DISPUTE_WON) {
|
|
106
|
+
await emailSvc.sendDisputeWon({
|
|
107
|
+
to,
|
|
108
|
+
disputeId: String(payload.data['disputeId'] ?? ''),
|
|
109
|
+
amount: Number(payload.data['amount'] ?? 0),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { success: true, channel: NotificationChannel.EMAIL, sentAt: new Date().toISOString() };
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return this._fail(NotificationChannel.EMAIL, err instanceof Error ? err.message : String(err));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ──────────────────────────────────────────
|
|
120
|
+
// SMS (Twilio)
|
|
121
|
+
// ──────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
private async _sendSMS(payload: NotificationPayload): Promise<NotificationResult> {
|
|
124
|
+
try {
|
|
125
|
+
const { smsConfig } = await import('../config');
|
|
126
|
+
const phone = payload.recipient.phone;
|
|
127
|
+
if (!phone) {
|
|
128
|
+
return this._fail(NotificationChannel.SMS, 'No phone number provided');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
132
|
+
const twilio = require('twilio')(smsConfig.accountSid, smsConfig.authToken);
|
|
133
|
+
const body = this._buildSMSMessage(payload);
|
|
134
|
+
|
|
135
|
+
await twilio.messages.create({
|
|
136
|
+
body,
|
|
137
|
+
from: smsConfig.phoneNumber,
|
|
138
|
+
to: phone,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return { success: true, channel: NotificationChannel.SMS, sentAt: new Date().toISOString() };
|
|
142
|
+
} catch (err) {
|
|
143
|
+
return this._fail(NotificationChannel.SMS, err instanceof Error ? err.message : String(err));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ──────────────────────────────────────────
|
|
148
|
+
// WEBHOOK
|
|
149
|
+
// ──────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
private async _sendWebhook(payload: NotificationPayload): Promise<NotificationResult> {
|
|
152
|
+
const url = payload.recipient.webhookUrl;
|
|
153
|
+
if (!url) {
|
|
154
|
+
return this._fail(NotificationChannel.WEBHOOK, 'No webhook URL configured');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const bodyStr = JSON.stringify({
|
|
158
|
+
event: payload.event,
|
|
159
|
+
timestamp: new Date().toISOString(),
|
|
160
|
+
data: payload.data,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
let retries = webhookConfig.maxRetries;
|
|
164
|
+
|
|
165
|
+
while (retries > 0) {
|
|
166
|
+
try {
|
|
167
|
+
const headers: Record<string, string> = {
|
|
168
|
+
'Content-Type': 'application/json',
|
|
169
|
+
'X-ChargebackGuard-Event': payload.event,
|
|
170
|
+
'X-ChargebackGuard-Version': '2.0',
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Sign payload if secret configured
|
|
174
|
+
const secret = (payload.data['webhookSecret'] as string) ?? undefined;
|
|
175
|
+
if (secret) {
|
|
176
|
+
headers['X-ChargebackGuard-Signature'] = `sha256=${signPayload(bodyStr, secret)}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await axios.post(url, bodyStr, {
|
|
180
|
+
headers,
|
|
181
|
+
timeout: webhookConfig.timeout,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
success: true,
|
|
186
|
+
channel: NotificationChannel.WEBHOOK,
|
|
187
|
+
sentAt: new Date().toISOString(),
|
|
188
|
+
};
|
|
189
|
+
} catch (err) {
|
|
190
|
+
retries--;
|
|
191
|
+
if (retries === 0) {
|
|
192
|
+
return this._fail(NotificationChannel.WEBHOOK, err instanceof Error ? err.message : String(err));
|
|
193
|
+
}
|
|
194
|
+
await new Promise(r => setTimeout(r, webhookConfig.retryDelay));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return this._fail(NotificationChannel.WEBHOOK, 'Max retries exceeded');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ──────────────────────────────────────────
|
|
202
|
+
// IN-APP
|
|
203
|
+
// ──────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
private _storeInApp(payload: NotificationPayload): NotificationResult {
|
|
206
|
+
const notification: InAppNotification = {
|
|
207
|
+
id: `notif-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
208
|
+
merchantId: payload.recipient.userId,
|
|
209
|
+
event: payload.event,
|
|
210
|
+
title: this._buildTitle(payload),
|
|
211
|
+
message: this._buildMessage(payload),
|
|
212
|
+
severity: this._getSeverity(payload.event),
|
|
213
|
+
read: false,
|
|
214
|
+
data: payload.data,
|
|
215
|
+
createdAt: new Date().toISOString(),
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
inAppStore.unshift(notification);
|
|
219
|
+
if (inAppStore.length > 1000) { inAppStore.pop(); }
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
success: true,
|
|
223
|
+
channel: NotificationChannel.IN_APP,
|
|
224
|
+
messageId: notification.id,
|
|
225
|
+
sentAt: new Date().toISOString(),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ──────────────────────────────────────────
|
|
230
|
+
// IN-APP GETTERS
|
|
231
|
+
// ──────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
getNotifications(merchantId?: string, unreadOnly = false): InAppNotification[] {
|
|
234
|
+
let list = merchantId
|
|
235
|
+
? inAppStore.filter(n => n.merchantId === merchantId || !n.merchantId)
|
|
236
|
+
: inAppStore;
|
|
237
|
+
|
|
238
|
+
if (unreadOnly) { list = list.filter(n => !n.read); }
|
|
239
|
+
return list;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
markAsRead(notificationId: string): boolean {
|
|
243
|
+
const n = inAppStore.find(n => n.id === notificationId);
|
|
244
|
+
if (!n) { return false; }
|
|
245
|
+
n.read = true;
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
markAllAsRead(merchantId?: string): void {
|
|
250
|
+
inAppStore.forEach(n => {
|
|
251
|
+
if (!merchantId || n.merchantId === merchantId) { n.read = true; }
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
getUnreadCount(merchantId?: string): number {
|
|
256
|
+
return inAppStore.filter(n =>
|
|
257
|
+
!n.read && (!merchantId || n.merchantId === merchantId)
|
|
258
|
+
).length;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ──────────────────────────────────────────
|
|
262
|
+
// HELPERS
|
|
263
|
+
// ──────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
private _buildTitle(payload: NotificationPayload): string {
|
|
266
|
+
switch (payload.event) {
|
|
267
|
+
case NotificationEvent.DISPUTE_DETECTED: return '⚠️ New Dispute Detected';
|
|
268
|
+
case NotificationEvent.DISPUTE_REPLIED: return '📤 Reply Submitted';
|
|
269
|
+
case NotificationEvent.DISPUTE_WON: return '🏆 Dispute Won!';
|
|
270
|
+
case NotificationEvent.DISPUTE_LOST: return '❌ Dispute Lost';
|
|
271
|
+
case NotificationEvent.FRAUD_DETECTED: return '🔴 Fraud Alert';
|
|
272
|
+
case NotificationEvent.PAYMENT_REGISTERED: return '✅ Payment Protected';
|
|
273
|
+
default: return 'Notification';
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private _buildMessage(payload: NotificationPayload): string {
|
|
278
|
+
const d = payload.data;
|
|
279
|
+
switch (payload.event) {
|
|
280
|
+
case NotificationEvent.DISPUTE_DETECTED:
|
|
281
|
+
return `Dispute ${d['disputeId']} filed for $${((Number(d['amount']) || 0) / 100).toFixed(2)}. Auto-reply in progress.`;
|
|
282
|
+
case NotificationEvent.DISPUTE_WON:
|
|
283
|
+
return `Chargeback for $${((Number(d['amount']) || 0) / 100).toFixed(2)} successfully defended!`;
|
|
284
|
+
case NotificationEvent.FRAUD_DETECTED:
|
|
285
|
+
return `High fraud probability (${((Number(d['probability']) || 0) * 100).toFixed(0)}%) detected on order ${d['orderId']}.`;
|
|
286
|
+
default:
|
|
287
|
+
return JSON.stringify(d);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private _buildSMSMessage(payload: NotificationPayload): string {
|
|
292
|
+
const d = payload.data;
|
|
293
|
+
switch (payload.event) {
|
|
294
|
+
case NotificationEvent.DISPUTE_DETECTED:
|
|
295
|
+
return `ChargebackGuard Alert: New dispute ${d['disputeId']} for $${((Number(d['amount']) || 0) / 100).toFixed(2)}. Auto-handling...`;
|
|
296
|
+
case NotificationEvent.DISPUTE_WON:
|
|
297
|
+
return `ChargebackGuard: You WON dispute ${d['disputeId']}! $${((Number(d['amount']) || 0) / 100).toFixed(2)} recovered.`;
|
|
298
|
+
default:
|
|
299
|
+
return `ChargebackGuard: ${payload.event}`;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private _getSeverity(event: string): InAppNotification['severity'] {
|
|
304
|
+
const severityMap: Record<string, InAppNotification['severity']> = {
|
|
305
|
+
[NotificationEvent.DISPUTE_DETECTED]: 'warning',
|
|
306
|
+
[NotificationEvent.DISPUTE_WON]: 'success',
|
|
307
|
+
[NotificationEvent.DISPUTE_LOST]: 'error',
|
|
308
|
+
[NotificationEvent.FRAUD_DETECTED]: 'error',
|
|
309
|
+
[NotificationEvent.PAYMENT_REGISTERED]: 'info',
|
|
310
|
+
[NotificationEvent.ERROR]: 'error',
|
|
311
|
+
};
|
|
312
|
+
return severityMap[event] ?? 'info';
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private _fail(channel: NotificationChannel, error: string): NotificationResult {
|
|
316
|
+
log.warn(`Notification failed: ${channel}`, { error });
|
|
317
|
+
return { success: false, channel, error, sentAt: new Date().toISOString() };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// CHARGEBACK GUARD — SMS Service (Twilio)
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import { createLogger } from '../utils/logger';
|
|
6
|
+
import { smsConfig } from '../config';
|
|
7
|
+
|
|
8
|
+
const log = createLogger('SMS');
|
|
9
|
+
|
|
10
|
+
export class SMSService {
|
|
11
|
+
private client: unknown;
|
|
12
|
+
|
|
13
|
+
constructor() {
|
|
14
|
+
if (smsConfig.accountSid && smsConfig.authToken) {
|
|
15
|
+
try {
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
17
|
+
this.client = require('twilio')(smsConfig.accountSid, smsConfig.authToken);
|
|
18
|
+
} catch {
|
|
19
|
+
log.warn('Twilio not available — SMS disabled');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async send(to: string, message: string): Promise<boolean> {
|
|
25
|
+
if (!this.client) {
|
|
26
|
+
log.warn('SMS client not initialized');
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
32
|
+
await (this.client as any).messages.create({
|
|
33
|
+
body: message,
|
|
34
|
+
from: smsConfig.phoneNumber,
|
|
35
|
+
to,
|
|
36
|
+
});
|
|
37
|
+
log.info(`SMS sent to ${to}`);
|
|
38
|
+
return true;
|
|
39
|
+
} catch (err) {
|
|
40
|
+
log.error('SMS send failed', { error: err instanceof Error ? err.message : String(err) });
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async sendDisputeAlert(to: string, disputeId: string, amount: number): Promise<boolean> {
|
|
46
|
+
return this.send(
|
|
47
|
+
to,
|
|
48
|
+
`[ChargebackGuard] ⚠️ New dispute ${disputeId} for $${(amount / 100).toFixed(2)}. Auto-reply in progress. Check dashboard.`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async sendDisputeWon(to: string, disputeId: string, amount: number): Promise<boolean> {
|
|
53
|
+
return this.send(
|
|
54
|
+
to,
|
|
55
|
+
`[ChargebackGuard] 🏆 Dispute ${disputeId} WON! $${(amount / 100).toFixed(2)} recovered to your account.`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// CHARGEBACK GUARD — Authentication & Authorization
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import { Request, Response, NextFunction } from 'express';
|
|
6
|
+
import jwt from 'jsonwebtoken';
|
|
7
|
+
import bcrypt from 'bcryptjs';
|
|
8
|
+
import { createLogger } from '../utils/logger';
|
|
9
|
+
import { securityConfig } from '../config';
|
|
10
|
+
import { generateApiKey } from '../evidence/encryption';
|
|
11
|
+
|
|
12
|
+
const log = createLogger('Auth');
|
|
13
|
+
|
|
14
|
+
// ────────────────────────────────────────────────────────────
|
|
15
|
+
// TYPES
|
|
16
|
+
// ────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export interface JwtPayload {
|
|
19
|
+
merchantId: string;
|
|
20
|
+
email: string;
|
|
21
|
+
plan: string;
|
|
22
|
+
iat?: number;
|
|
23
|
+
exp?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AuthenticatedRequest extends Request {
|
|
27
|
+
merchant?: JwtPayload;
|
|
28
|
+
merchantId?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ────────────────────────────────────────────────────────────
|
|
32
|
+
// TOKEN UTILITIES
|
|
33
|
+
// ────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export function signToken(payload: Omit<JwtPayload, 'iat' | 'exp'>): string {
|
|
36
|
+
return jwt.sign(payload, securityConfig.jwtSecret, {
|
|
37
|
+
expiresIn: securityConfig.jwtExpiresIn,
|
|
38
|
+
issuer: 'chargeback-guard',
|
|
39
|
+
audience: 'cbg-api',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function verifyToken(token: string): JwtPayload {
|
|
44
|
+
return jwt.verify(token, securityConfig.jwtSecret, {
|
|
45
|
+
issuer: 'chargeback-guard',
|
|
46
|
+
audience: 'cbg-api',
|
|
47
|
+
}) as JwtPayload;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function hashPassword(password: string): Promise<string> {
|
|
51
|
+
return bcrypt.hash(password, securityConfig.bcryptRounds);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
|
55
|
+
return bcrypt.compare(password, hash);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function generateMerchantApiKey(): string {
|
|
59
|
+
return generateApiKey(securityConfig.apiKeyPrefix);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ────────────────────────────────────────────────────────────
|
|
63
|
+
// JWT MIDDLEWARE
|
|
64
|
+
// ────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export function requireAuth(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
|
|
67
|
+
const authHeader = req.headers.authorization;
|
|
68
|
+
|
|
69
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
70
|
+
res.status(401).json({
|
|
71
|
+
success: false,
|
|
72
|
+
error: { code: 'UNAUTHORIZED', message: 'Bearer token required' },
|
|
73
|
+
});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const token = authHeader.slice(7);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const payload = verifyToken(token);
|
|
81
|
+
req.merchant = payload;
|
|
82
|
+
req.merchantId = payload.merchantId;
|
|
83
|
+
next();
|
|
84
|
+
} catch (err) {
|
|
85
|
+
const message = err instanceof Error ? err.message : 'Invalid token';
|
|
86
|
+
log.warn('JWT verification failed', { error: message });
|
|
87
|
+
res.status(401).json({
|
|
88
|
+
success: false,
|
|
89
|
+
error: { code: 'TOKEN_INVALID', message },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ────────────────────────────────────────────────────────────
|
|
95
|
+
// API KEY MIDDLEWARE
|
|
96
|
+
// ────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export function requireApiKey(
|
|
99
|
+
lookupFn: (key: string) => Promise<JwtPayload | null>
|
|
100
|
+
): (req: AuthenticatedRequest, res: Response, next: NextFunction) => void {
|
|
101
|
+
return (req: AuthenticatedRequest, res: Response, next: NextFunction): void => {
|
|
102
|
+
const key =
|
|
103
|
+
(req.headers['x-api-key'] as string) ??
|
|
104
|
+
(req.query['api_key'] as string);
|
|
105
|
+
|
|
106
|
+
if (!key) {
|
|
107
|
+
res.status(401).json({
|
|
108
|
+
success: false,
|
|
109
|
+
error: { code: 'API_KEY_MISSING', message: 'API key required' },
|
|
110
|
+
});
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
lookupFn(key)
|
|
115
|
+
.then(merchant => {
|
|
116
|
+
if (!merchant) {
|
|
117
|
+
res.status(401).json({
|
|
118
|
+
success: false,
|
|
119
|
+
error: { code: 'API_KEY_INVALID', message: 'Invalid API key' },
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
req.merchant = merchant;
|
|
124
|
+
req.merchantId = merchant.merchantId;
|
|
125
|
+
next();
|
|
126
|
+
})
|
|
127
|
+
.catch(err => {
|
|
128
|
+
log.error('API key lookup failed', { error: err.message });
|
|
129
|
+
res.status(500).json({
|
|
130
|
+
success: false,
|
|
131
|
+
error: { code: 'INTERNAL_ERROR', message: 'Authentication failed' },
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ────────────────────────────────────────────────────────────
|
|
138
|
+
// OPTIONAL AUTH (sets merchant if token present)
|
|
139
|
+
// ────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
export function optionalAuth(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {
|
|
142
|
+
const authHeader = req.headers.authorization;
|
|
143
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
144
|
+
try {
|
|
145
|
+
const payload = verifyToken(authHeader.slice(7));
|
|
146
|
+
req.merchant = payload;
|
|
147
|
+
req.merchantId = payload.merchantId;
|
|
148
|
+
} catch {
|
|
149
|
+
// silently ignore
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
next();
|
|
153
|
+
}
|