@omnizap-system/omnizap 2.6.2 → 2.6.3
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/.env.example +24 -0
- package/app/config/index.js +4 -0
- package/app/configParts/adminIdentity.js +29 -0
- package/app/configParts/baileysConfig.js +116 -0
- package/app/configParts/groupUtils.js +221 -0
- package/app/configParts/loggerConfig.js +185 -0
- package/app/configParts/messagePersistenceService.js +169 -7
- package/app/configParts/sessionConfig.js +85 -0
- package/app/connection/baileysCompatibility.test.js +9 -0
- package/app/connection/baileysDbAuthState.js +205 -9
- package/app/connection/baileysLibsignalPatch.js +210 -0
- package/app/connection/groupOwnerWriteStateResolver.js +53 -21
- package/app/connection/socketController.js +95 -25
- package/app/connection/socketController.multiSession.test.js +20 -0
- package/app/controllers/messagePipeline/preProcessingMiddlewares.js +17 -3
- package/app/controllers/messageProcessingPipeline.js +2 -0
- package/app/controllers/messageProcessingPipeline.test.js +15 -13
- package/app/services/multiSession/assignmentBalancerService.js +1 -6
- package/app/services/multiSession/groupOwnershipRepository.js +9 -44
- package/app/services/multiSession/groupOwnershipService.js +9 -90
- package/app/services/multiSession/groupOwnershipService.test.js +12 -4
- package/app/services/multiSession/sessionRegistryService.js +6 -60
- package/app/utils/antiLink/antiLinkModule.js +54 -24
- package/docs/security/omnizap-static-security-headers.conf +3 -3
- package/package.json +3 -2
- package/public/comandos/commands-catalog.json +1 -1
- package/public/css/payments-react.css +478 -0
- package/public/js/apps/homeReactApp.js +2 -2
- package/public/js/apps/paymentsCancelReactApp.js +45 -0
- package/public/js/apps/paymentsReactApp.js +399 -0
- package/public/js/apps/paymentsSuccessReactApp.js +148 -0
- package/public/pages/pagamentos-cancelado.html +21 -0
- package/public/pages/pagamentos-sucesso.html +21 -0
- package/public/pages/pagamentos.html +30 -0
- package/scripts/deploy.sh +3 -0
- package/scripts/new-whatsapp-session.sh +247 -0
- package/server/controllers/admin/systemAdminController.js +4 -17
- package/server/controllers/payments/paymentsController.js +731 -0
- package/server/controllers/system/systemController.js +4 -30
- package/server/email/emailAutomationRuntime.js +36 -1
- package/server/email/emailAutomationService.js +42 -1
- package/server/email/emailTemplateService.js +137 -31
- package/server/http/httpRequestUtils.js +18 -14
- package/server/middleware/securityHeaders.js +15 -2
- package/server/routes/indexRouter.js +27 -7
- package/server/routes/payments/paymentsRouter.js +47 -0
- package/server/routes/static/staticPageRouter.js +3 -0
- package/vite.config.mjs +3 -0
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import axios from 'axios';
|
|
4
|
+
|
|
5
|
+
import logger from '#logger';
|
|
6
|
+
import premiumUserStore from '../../../app/store/premiumUserStore.js';
|
|
7
|
+
import { isRequestSecure, readJsonBody, readRawBody, sendJson } from '../../http/httpRequestUtils.js';
|
|
8
|
+
|
|
9
|
+
const parseEnvBool = (value, fallback) => {
|
|
10
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
11
|
+
const normalized = String(value).trim().toLowerCase();
|
|
12
|
+
if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) return true;
|
|
13
|
+
if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) return false;
|
|
14
|
+
return fallback;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const parseEnvInt = (value, fallback, min = 0, max = Number.MAX_SAFE_INTEGER) => {
|
|
18
|
+
const parsed = Number(value);
|
|
19
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
20
|
+
return Math.max(min, Math.min(max, Math.floor(parsed)));
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const normalizeBasePath = (value, fallback) => {
|
|
24
|
+
const raw = String(value || '').trim() || fallback;
|
|
25
|
+
const withLeadingSlash = raw.startsWith('/') ? raw : `/${raw}`;
|
|
26
|
+
const withoutTrailingSlash = withLeadingSlash.length > 1 && withLeadingSlash.endsWith('/') ? withLeadingSlash.slice(0, -1) : withLeadingSlash;
|
|
27
|
+
return withoutTrailingSlash || fallback;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const toHttpOrigin = (value) => {
|
|
31
|
+
const raw = String(value || '').trim();
|
|
32
|
+
if (!raw) return '';
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const parsed = new URL(raw);
|
|
36
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) return '';
|
|
37
|
+
return parsed.origin;
|
|
38
|
+
} catch {
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const toHttpUrl = (value) => {
|
|
44
|
+
const raw = String(value || '').trim();
|
|
45
|
+
if (!raw) return '';
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const parsed = new URL(raw);
|
|
49
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) return '';
|
|
50
|
+
return parsed.toString();
|
|
51
|
+
} catch {
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const isValidEmail = (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value || '').trim());
|
|
57
|
+
|
|
58
|
+
const sanitizePlainString = (value, maxLength) =>
|
|
59
|
+
String(value || '')
|
|
60
|
+
.trim()
|
|
61
|
+
.replace(/\s+/g, ' ')
|
|
62
|
+
.slice(0, maxLength);
|
|
63
|
+
|
|
64
|
+
const STRIPE_PAYMENTS_ENABLED = parseEnvBool(process.env.STRIPE_PAYMENTS_ENABLED, true);
|
|
65
|
+
const STRIPE_SECRET_KEY = sanitizePlainString(process.env.STRIPE_SECRET_KEY, 255);
|
|
66
|
+
const STRIPE_WEBHOOK_SECRET = sanitizePlainString(process.env.STRIPE_WEBHOOK_SECRET, 255);
|
|
67
|
+
const STRIPE_PRICE_ID = sanitizePlainString(process.env.STRIPE_PRICE_ID, 255);
|
|
68
|
+
const STRIPE_CHECKOUT_MODE = sanitizePlainString(process.env.STRIPE_CHECKOUT_MODE || 'subscription', 24).toLowerCase() === 'payment' ? 'payment' : 'subscription';
|
|
69
|
+
const STRIPE_API_BASE_URL = sanitizePlainString(process.env.STRIPE_API_BASE_URL, 255) || 'https://api.stripe.com/v1';
|
|
70
|
+
const STRIPE_API_TIMEOUT_MS = parseEnvInt(process.env.STRIPE_API_TIMEOUT_MS, 10000, 1000, 60000);
|
|
71
|
+
const STRIPE_ALLOW_PROMOTION_CODES = parseEnvBool(process.env.STRIPE_ALLOW_PROMOTION_CODES, true);
|
|
72
|
+
const STRIPE_WEBHOOK_TOLERANCE_SECONDS = parseEnvInt(process.env.STRIPE_WEBHOOK_TOLERANCE_SECONDS, 300, 30, 7200);
|
|
73
|
+
const STRIPE_AUTO_REVOKE_ON_CANCELLATION = parseEnvBool(process.env.STRIPE_AUTO_REVOKE_ON_CANCELLATION, false);
|
|
74
|
+
|
|
75
|
+
const PAYMENTS_API_BASE_PATH = normalizeBasePath(process.env.PAYMENTS_API_BASE_PATH || process.env.STRIPE_PAYMENT_API_BASE_PATH, '/api/payments');
|
|
76
|
+
const PAYMENTS_WEB_PATH = normalizeBasePath(process.env.PAYMENTS_WEB_PATH || process.env.STRIPE_PAYMENT_WEB_PATH, '/pagamentos');
|
|
77
|
+
const STRIPE_CHECKOUT_SUCCESS_URL = toHttpUrl(process.env.STRIPE_CHECKOUT_SUCCESS_URL);
|
|
78
|
+
const STRIPE_CHECKOUT_CANCEL_URL = toHttpUrl(process.env.STRIPE_CHECKOUT_CANCEL_URL);
|
|
79
|
+
const STRIPE_PLAN_NAME = sanitizePlainString(process.env.STRIPE_PLAN_NAME, 120) || 'Plano Premium';
|
|
80
|
+
const STRIPE_PLAN_PRICE_LABEL = sanitizePlainString(process.env.STRIPE_PLAN_PRICE_LABEL, 120) || 'Assinatura recorrente';
|
|
81
|
+
|
|
82
|
+
const stripeHttpClient = axios.create({
|
|
83
|
+
baseURL: STRIPE_API_BASE_URL,
|
|
84
|
+
timeout: STRIPE_API_TIMEOUT_MS,
|
|
85
|
+
validateStatus: () => true,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
class HttpError extends Error {
|
|
89
|
+
constructor(message, statusCode = 500, code = null) {
|
|
90
|
+
super(message);
|
|
91
|
+
this.name = 'HttpError';
|
|
92
|
+
this.statusCode = statusCode;
|
|
93
|
+
this.code = code;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const createHttpError = (message, statusCode = 500, code = null) => new HttpError(message, statusCode, code);
|
|
98
|
+
|
|
99
|
+
const assertPaymentsEnabled = () => {
|
|
100
|
+
if (STRIPE_PAYMENTS_ENABLED) return;
|
|
101
|
+
throw createHttpError('Pagamentos Stripe estao desativados no servidor.', 503, 'stripe_payments_disabled');
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const assertStripeCheckoutReady = () => {
|
|
105
|
+
assertPaymentsEnabled();
|
|
106
|
+
if (!STRIPE_SECRET_KEY) {
|
|
107
|
+
throw createHttpError('STRIPE_SECRET_KEY nao configurada.', 503, 'stripe_secret_key_missing');
|
|
108
|
+
}
|
|
109
|
+
if (!STRIPE_PRICE_ID) {
|
|
110
|
+
throw createHttpError('STRIPE_PRICE_ID nao configurado.', 503, 'stripe_price_id_missing');
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const assertStripeWebhookReady = () => {
|
|
115
|
+
assertPaymentsEnabled();
|
|
116
|
+
if (!STRIPE_WEBHOOK_SECRET) {
|
|
117
|
+
throw createHttpError('STRIPE_WEBHOOK_SECRET nao configurado.', 503, 'stripe_webhook_secret_missing');
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const resolveRequestOrigin = (req) => {
|
|
122
|
+
const envOrigin = toHttpOrigin(process.env.SITE_ORIGIN) || toHttpOrigin(process.env.PUBLIC_WEB_BASE_URL) || toHttpOrigin(process.env.APP_BASE_URL);
|
|
123
|
+
if (envOrigin) return envOrigin;
|
|
124
|
+
|
|
125
|
+
const host = sanitizePlainString(req?.headers?.host, 255);
|
|
126
|
+
if (!host) return 'http://localhost';
|
|
127
|
+
|
|
128
|
+
const protocol = isRequestSecure(req) ? 'https' : 'http';
|
|
129
|
+
return `${protocol}://${host}`;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const withCheckoutSessionPlaceholder = (url) => {
|
|
133
|
+
const raw = toHttpUrl(url);
|
|
134
|
+
if (!raw) return '';
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const parsed = new URL(raw);
|
|
138
|
+
if (!parsed.searchParams.has('session_id')) {
|
|
139
|
+
parsed.searchParams.set('session_id', '{CHECKOUT_SESSION_ID}');
|
|
140
|
+
}
|
|
141
|
+
return parsed.toString();
|
|
142
|
+
} catch {
|
|
143
|
+
return raw;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const resolveCheckoutSuccessUrl = (req) => {
|
|
148
|
+
if (STRIPE_CHECKOUT_SUCCESS_URL) {
|
|
149
|
+
return withCheckoutSessionPlaceholder(STRIPE_CHECKOUT_SUCCESS_URL);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const origin = resolveRequestOrigin(req);
|
|
153
|
+
return `${origin}${PAYMENTS_WEB_PATH}/sucesso?session_id={CHECKOUT_SESSION_ID}`;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const resolveCheckoutCancelUrl = (req) => {
|
|
157
|
+
if (STRIPE_CHECKOUT_CANCEL_URL) {
|
|
158
|
+
return STRIPE_CHECKOUT_CANCEL_URL;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const origin = resolveRequestOrigin(req);
|
|
162
|
+
return `${origin}${PAYMENTS_WEB_PATH}/cancelado`;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const normalizeWhatsappIdentity = (value) => {
|
|
166
|
+
const raw = sanitizePlainString(value, 120).toLowerCase();
|
|
167
|
+
if (!raw) {
|
|
168
|
+
throw createHttpError('Informe um numero WhatsApp para ativar o Premium.', 400, 'whatsapp_required');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const normalizedJidInput = raw.endsWith('@c.us') ? raw.replace('@c.us', '@s.whatsapp.net') : raw;
|
|
172
|
+
if (normalizedJidInput.includes('@')) {
|
|
173
|
+
const jid = normalizedJidInput.replace(/\s+/g, '');
|
|
174
|
+
if (!/^[a-z0-9._-]{5,80}@s\.whatsapp\.net$/.test(jid)) {
|
|
175
|
+
throw createHttpError('WhatsApp invalido. Use numero com DDI/DD ou JID valido.', 400, 'whatsapp_invalid');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const ownerPhone = jid.split('@')[0].replace(/\D+/g, '').slice(0, 20);
|
|
179
|
+
return {
|
|
180
|
+
ownerJid: jid,
|
|
181
|
+
ownerPhone,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const digits = raw.replace(/\D+/g, '').slice(0, 20);
|
|
186
|
+
if (digits.length < 10) {
|
|
187
|
+
throw createHttpError('WhatsApp invalido. Envie o numero completo com DDI e DDD.', 400, 'whatsapp_invalid');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
ownerJid: `${digits}@s.whatsapp.net`,
|
|
192
|
+
ownerPhone: digits,
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const normalizeWhatsappIdentitySafe = (value) => {
|
|
197
|
+
try {
|
|
198
|
+
return normalizeWhatsappIdentity(value);
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const normalizeCheckoutEmail = (value) => {
|
|
205
|
+
const email = sanitizePlainString(value, 255).toLowerCase();
|
|
206
|
+
if (!email) return '';
|
|
207
|
+
if (!isValidEmail(email)) {
|
|
208
|
+
throw createHttpError('E-mail invalido. Verifique e tente novamente.', 400, 'email_invalid');
|
|
209
|
+
}
|
|
210
|
+
return email;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const normalizeCustomerName = (value) => sanitizePlainString(value, 120);
|
|
214
|
+
|
|
215
|
+
const callStripeApi = async ({ method, path, formData = null }) => {
|
|
216
|
+
if (!STRIPE_SECRET_KEY) {
|
|
217
|
+
throw createHttpError('STRIPE_SECRET_KEY nao configurada.', 503, 'stripe_secret_key_missing');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const headers = {
|
|
221
|
+
Authorization: `Bearer ${STRIPE_SECRET_KEY}`,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
let payload = undefined;
|
|
225
|
+
if (formData) {
|
|
226
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
227
|
+
payload = formData.toString();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const response = await stripeHttpClient.request({
|
|
231
|
+
method,
|
|
232
|
+
url: path,
|
|
233
|
+
data: payload,
|
|
234
|
+
headers,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (response.status >= 200 && response.status < 300) {
|
|
238
|
+
return response.data;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const upstreamStatus = Number(response.status || 0);
|
|
242
|
+
const stripeErrorMessage = sanitizePlainString(response?.data?.error?.message, 255) || 'Falha na API Stripe.';
|
|
243
|
+
const statusCode = upstreamStatus >= 400 && upstreamStatus < 500 ? 400 : 502;
|
|
244
|
+
|
|
245
|
+
logger.warn('Stripe API retornou erro.', {
|
|
246
|
+
action: 'stripe_api_request_failed',
|
|
247
|
+
method,
|
|
248
|
+
path,
|
|
249
|
+
stripe_status: upstreamStatus,
|
|
250
|
+
stripe_error_type: response?.data?.error?.type || null,
|
|
251
|
+
stripe_error_code: response?.data?.error?.code || null,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
throw createHttpError(stripeErrorMessage, statusCode, 'stripe_api_error');
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const createStripeCheckoutSession = async ({ req, ownerJid, ownerPhone, customerEmail, customerName }) => {
|
|
258
|
+
const formData = new URLSearchParams();
|
|
259
|
+
formData.set('mode', STRIPE_CHECKOUT_MODE);
|
|
260
|
+
formData.set('line_items[0][price]', STRIPE_PRICE_ID);
|
|
261
|
+
formData.set('line_items[0][quantity]', '1');
|
|
262
|
+
formData.set('success_url', resolveCheckoutSuccessUrl(req));
|
|
263
|
+
formData.set('cancel_url', resolveCheckoutCancelUrl(req));
|
|
264
|
+
formData.set('client_reference_id', ownerJid);
|
|
265
|
+
formData.set('metadata[owner_jid]', ownerJid);
|
|
266
|
+
if (ownerPhone) formData.set('metadata[owner_phone]', ownerPhone);
|
|
267
|
+
|
|
268
|
+
if (customerEmail) formData.set('customer_email', customerEmail);
|
|
269
|
+
if (customerName) formData.set('metadata[customer_name]', customerName);
|
|
270
|
+
if (STRIPE_ALLOW_PROMOTION_CODES) formData.set('allow_promotion_codes', 'true');
|
|
271
|
+
|
|
272
|
+
if (STRIPE_CHECKOUT_MODE === 'subscription') {
|
|
273
|
+
formData.set('subscription_data[metadata][owner_jid]', ownerJid);
|
|
274
|
+
if (ownerPhone) formData.set('subscription_data[metadata][owner_phone]', ownerPhone);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return callStripeApi({
|
|
278
|
+
method: 'POST',
|
|
279
|
+
path: '/checkout/sessions',
|
|
280
|
+
formData,
|
|
281
|
+
});
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const getStripeCheckoutSession = async (sessionId) => {
|
|
285
|
+
const normalizedSessionId = sanitizePlainString(sessionId, 255);
|
|
286
|
+
if (!normalizedSessionId) {
|
|
287
|
+
throw createHttpError('session_id e obrigatorio.', 400, 'session_id_required');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return callStripeApi({
|
|
291
|
+
method: 'GET',
|
|
292
|
+
path: `/checkout/sessions/${encodeURIComponent(normalizedSessionId)}`,
|
|
293
|
+
});
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const normalizeFromMetadata = (metadata = {}, key) => sanitizePlainString(metadata?.[key], 255);
|
|
297
|
+
|
|
298
|
+
const extractOwnerJidFromStripeObject = (object = {}) => {
|
|
299
|
+
if (!object || typeof object !== 'object') return '';
|
|
300
|
+
|
|
301
|
+
const metadata = object?.metadata || {};
|
|
302
|
+
const directCandidates = [normalizeFromMetadata(metadata, 'owner_jid'), sanitizePlainString(object?.client_reference_id, 255), normalizeFromMetadata(object?.subscription_details?.metadata, 'owner_jid')].filter(Boolean);
|
|
303
|
+
|
|
304
|
+
for (const candidate of directCandidates) {
|
|
305
|
+
const normalized = normalizeWhatsappIdentitySafe(candidate);
|
|
306
|
+
if (normalized?.ownerJid) return normalized.ownerJid;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const phoneCandidates = [normalizeFromMetadata(metadata, 'owner_phone'), normalizeFromMetadata(object?.subscription_details?.metadata, 'owner_phone')].filter(Boolean);
|
|
310
|
+
|
|
311
|
+
for (const phoneCandidate of phoneCandidates) {
|
|
312
|
+
const normalized = normalizeWhatsappIdentitySafe(phoneCandidate);
|
|
313
|
+
if (normalized?.ownerJid) return normalized.ownerJid;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const lines = Array.isArray(object?.lines?.data) ? object.lines.data : [];
|
|
317
|
+
for (const line of lines) {
|
|
318
|
+
const lineMetadata = line?.metadata || {};
|
|
319
|
+
const lineJid = normalizeWhatsappIdentitySafe(normalizeFromMetadata(lineMetadata, 'owner_jid'));
|
|
320
|
+
if (lineJid?.ownerJid) return lineJid.ownerJid;
|
|
321
|
+
const linePhone = normalizeWhatsappIdentitySafe(normalizeFromMetadata(lineMetadata, 'owner_phone'));
|
|
322
|
+
if (linePhone?.ownerJid) return linePhone.ownerJid;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return '';
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const parseStripeSignatureHeader = (headerValue) => {
|
|
329
|
+
const parts = String(headerValue || '')
|
|
330
|
+
.split(',')
|
|
331
|
+
.map((part) => String(part || '').trim())
|
|
332
|
+
.filter(Boolean);
|
|
333
|
+
|
|
334
|
+
const result = {
|
|
335
|
+
timestamp: null,
|
|
336
|
+
signatures: [],
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
for (const part of parts) {
|
|
340
|
+
const separatorIndex = part.indexOf('=');
|
|
341
|
+
if (separatorIndex <= 0) continue;
|
|
342
|
+
const key = part.slice(0, separatorIndex).trim();
|
|
343
|
+
const value = part.slice(separatorIndex + 1).trim();
|
|
344
|
+
if (!key || !value) continue;
|
|
345
|
+
|
|
346
|
+
if (key === 't') {
|
|
347
|
+
const parsedTs = Number(value);
|
|
348
|
+
if (Number.isFinite(parsedTs)) {
|
|
349
|
+
result.timestamp = Math.floor(parsedTs);
|
|
350
|
+
}
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (key === 'v1') {
|
|
355
|
+
result.signatures.push(value);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return result;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const timingSafeHexEquals = (left, right) => {
|
|
363
|
+
const leftHex = String(left || '').trim();
|
|
364
|
+
const rightHex = String(right || '').trim();
|
|
365
|
+
if (!leftHex || !rightHex || leftHex.length !== rightHex.length) return false;
|
|
366
|
+
if (!/^[0-9a-f]+$/i.test(leftHex) || !/^[0-9a-f]+$/i.test(rightHex)) return false;
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
const leftBuffer = Buffer.from(leftHex, 'hex');
|
|
370
|
+
const rightBuffer = Buffer.from(rightHex, 'hex');
|
|
371
|
+
if (leftBuffer.length !== rightBuffer.length) return false;
|
|
372
|
+
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
|
373
|
+
} catch {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const verifyStripeWebhookSignature = (rawBodyBuffer, signatureHeader) => {
|
|
379
|
+
assertStripeWebhookReady();
|
|
380
|
+
|
|
381
|
+
const parsed = parseStripeSignatureHeader(signatureHeader);
|
|
382
|
+
if (!parsed.timestamp || parsed.signatures.length === 0) {
|
|
383
|
+
throw createHttpError('Stripe-Signature invalido.', 400, 'stripe_signature_invalid');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
387
|
+
if (Math.abs(nowSeconds - parsed.timestamp) > STRIPE_WEBHOOK_TOLERANCE_SECONDS) {
|
|
388
|
+
throw createHttpError('Webhook Stripe expirado.', 400, 'stripe_signature_expired');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const signedPayload = `${parsed.timestamp}.${rawBodyBuffer.toString('utf8')}`;
|
|
392
|
+
const expectedSignature = crypto.createHmac('sha256', STRIPE_WEBHOOK_SECRET).update(signedPayload).digest('hex');
|
|
393
|
+
|
|
394
|
+
const valid = parsed.signatures.some((candidate) => timingSafeHexEquals(candidate, expectedSignature));
|
|
395
|
+
if (!valid) {
|
|
396
|
+
throw createHttpError('Assinatura Stripe invalida.', 400, 'stripe_signature_mismatch');
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const activatePremiumOwner = async ({ ownerJid, eventType, eventId }) => {
|
|
401
|
+
if (!ownerJid) {
|
|
402
|
+
return {
|
|
403
|
+
action: 'ignored',
|
|
404
|
+
reason: 'owner_jid_missing',
|
|
405
|
+
ownerJid: '',
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
await premiumUserStore.addPremiumUsers([ownerJid]);
|
|
410
|
+
|
|
411
|
+
logger.info('Premium ativado via Stripe webhook.', {
|
|
412
|
+
action: 'stripe_premium_activated',
|
|
413
|
+
event_id: eventId,
|
|
414
|
+
event_type: eventType,
|
|
415
|
+
owner_jid: ownerJid,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
action: 'premium_activated',
|
|
420
|
+
reason: 'ok',
|
|
421
|
+
ownerJid,
|
|
422
|
+
};
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const revokePremiumOwner = async ({ ownerJid, eventType, eventId }) => {
|
|
426
|
+
if (!ownerJid) {
|
|
427
|
+
return {
|
|
428
|
+
action: 'ignored',
|
|
429
|
+
reason: 'owner_jid_missing',
|
|
430
|
+
ownerJid: '',
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
await premiumUserStore.removePremiumUsers([ownerJid]);
|
|
435
|
+
|
|
436
|
+
logger.info('Premium removido via Stripe webhook.', {
|
|
437
|
+
action: 'stripe_premium_revoked',
|
|
438
|
+
event_id: eventId,
|
|
439
|
+
event_type: eventType,
|
|
440
|
+
owner_jid: ownerJid,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
action: 'premium_revoked',
|
|
445
|
+
reason: 'ok',
|
|
446
|
+
ownerJid,
|
|
447
|
+
};
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const shouldActivateCheckoutEvent = (sessionObject) => {
|
|
451
|
+
const paymentStatus = sanitizePlainString(sessionObject?.payment_status, 32).toLowerCase();
|
|
452
|
+
return paymentStatus === 'paid' || paymentStatus === 'no_payment_required';
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const isCheckoutSessionComplete = (sessionObject) => sanitizePlainString(sessionObject?.status, 32).toLowerCase() === 'complete';
|
|
456
|
+
|
|
457
|
+
const buildCheckoutSessionPayload = (session = {}) => ({
|
|
458
|
+
id: session?.id || null,
|
|
459
|
+
status: session?.status || null,
|
|
460
|
+
payment_status: session?.payment_status || null,
|
|
461
|
+
customer_email: session?.customer_details?.email || session?.customer_email || null,
|
|
462
|
+
mode: session?.mode || null,
|
|
463
|
+
owner_jid: extractOwnerJidFromStripeObject(session) || null,
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const finalizePremiumFromCheckoutSession = async (sessionObject = {}, source = 'finalize_api') => {
|
|
467
|
+
if (!isCheckoutSessionComplete(sessionObject) || !shouldActivateCheckoutEvent(sessionObject)) {
|
|
468
|
+
return {
|
|
469
|
+
action: 'ignored',
|
|
470
|
+
reason: 'checkout_not_paid',
|
|
471
|
+
ownerJid: '',
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const ownerJid = extractOwnerJidFromStripeObject(sessionObject);
|
|
476
|
+
const eventId = sanitizePlainString(sessionObject?.id, 120);
|
|
477
|
+
const eventType = `checkout.session.${source}`;
|
|
478
|
+
return activatePremiumOwner({ ownerJid, eventType, eventId });
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const shouldActivateSubscriptionStatus = (status) => ['active', 'trialing', 'past_due'].includes(status);
|
|
482
|
+
const shouldDeactivateSubscriptionStatus = (status) => ['canceled', 'incomplete_expired', 'unpaid'].includes(status);
|
|
483
|
+
|
|
484
|
+
const processStripeWebhookEvent = async (event) => {
|
|
485
|
+
const eventType = sanitizePlainString(event?.type, 120);
|
|
486
|
+
const eventId = sanitizePlainString(event?.id, 120);
|
|
487
|
+
const object = event?.data?.object || {};
|
|
488
|
+
|
|
489
|
+
if (!eventType) {
|
|
490
|
+
return {
|
|
491
|
+
action: 'ignored',
|
|
492
|
+
reason: 'event_type_missing',
|
|
493
|
+
ownerJid: '',
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (eventType === 'checkout.session.completed' || eventType === 'checkout.session.async_payment_succeeded') {
|
|
498
|
+
if (eventType === 'checkout.session.completed' && !shouldActivateCheckoutEvent(object)) {
|
|
499
|
+
return {
|
|
500
|
+
action: 'ignored',
|
|
501
|
+
reason: 'checkout_not_paid',
|
|
502
|
+
ownerJid: '',
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const ownerJid = extractOwnerJidFromStripeObject(object);
|
|
507
|
+
return activatePremiumOwner({ ownerJid, eventType, eventId });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (eventType === 'invoice.paid' || eventType === 'invoice.payment_succeeded') {
|
|
511
|
+
const ownerJid = extractOwnerJidFromStripeObject(object);
|
|
512
|
+
return activatePremiumOwner({ ownerJid, eventType, eventId });
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (eventType === 'customer.subscription.created' || eventType === 'customer.subscription.updated') {
|
|
516
|
+
const status = sanitizePlainString(object?.status, 48).toLowerCase();
|
|
517
|
+
const ownerJid = extractOwnerJidFromStripeObject(object);
|
|
518
|
+
|
|
519
|
+
if (shouldActivateSubscriptionStatus(status)) {
|
|
520
|
+
return activatePremiumOwner({ ownerJid, eventType, eventId });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (STRIPE_AUTO_REVOKE_ON_CANCELLATION && shouldDeactivateSubscriptionStatus(status)) {
|
|
524
|
+
return revokePremiumOwner({ ownerJid, eventType, eventId });
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
action: 'ignored',
|
|
529
|
+
reason: 'subscription_status_not_supported',
|
|
530
|
+
ownerJid: ownerJid || '',
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (eventType === 'customer.subscription.deleted') {
|
|
535
|
+
if (!STRIPE_AUTO_REVOKE_ON_CANCELLATION) {
|
|
536
|
+
return {
|
|
537
|
+
action: 'ignored',
|
|
538
|
+
reason: 'auto_revoke_disabled',
|
|
539
|
+
ownerJid: '',
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const ownerJid = extractOwnerJidFromStripeObject(object);
|
|
544
|
+
return revokePremiumOwner({ ownerJid, eventType, eventId });
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
action: 'ignored',
|
|
549
|
+
reason: 'event_not_handled',
|
|
550
|
+
ownerJid: '',
|
|
551
|
+
};
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
const buildPublicConfigPayload = () => ({
|
|
555
|
+
ok: true,
|
|
556
|
+
enabled: STRIPE_PAYMENTS_ENABLED,
|
|
557
|
+
api_base_path: PAYMENTS_API_BASE_PATH,
|
|
558
|
+
web_path: PAYMENTS_WEB_PATH,
|
|
559
|
+
checkout_mode: STRIPE_CHECKOUT_MODE,
|
|
560
|
+
plan_name: STRIPE_PLAN_NAME,
|
|
561
|
+
plan_price_label: STRIPE_PLAN_PRICE_LABEL,
|
|
562
|
+
stripe_ready: Boolean(STRIPE_SECRET_KEY && STRIPE_PRICE_ID && STRIPE_WEBHOOK_SECRET),
|
|
563
|
+
auto_revoke_on_cancellation: STRIPE_AUTO_REVOKE_ON_CANCELLATION,
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
const handlePaymentsControllerError = (req, res, error, { pathname }) => {
|
|
567
|
+
const statusCode = Number(error?.statusCode || 500);
|
|
568
|
+
const code = sanitizePlainString(error?.code, 80) || null;
|
|
569
|
+
const message = sanitizePlainString(error?.message, 255) || 'Falha interna no modulo de pagamentos.';
|
|
570
|
+
|
|
571
|
+
if (statusCode >= 500) {
|
|
572
|
+
logger.error('Falha no controller de pagamentos Stripe.', {
|
|
573
|
+
action: 'stripe_payments_controller_failed',
|
|
574
|
+
path: pathname,
|
|
575
|
+
method: req?.method,
|
|
576
|
+
error: error?.message,
|
|
577
|
+
stack: error?.stack,
|
|
578
|
+
code,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return sendJson(req, res, statusCode, {
|
|
583
|
+
error: message,
|
|
584
|
+
code,
|
|
585
|
+
});
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
export const getPaymentsRouteConfig = () => ({
|
|
589
|
+
apiBasePath: PAYMENTS_API_BASE_PATH,
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
export const maybeHandlePaymentsRequest = async (req, res, { pathname, url }) => {
|
|
593
|
+
if (!['GET', 'HEAD', 'POST'].includes(req.method || '')) return false;
|
|
594
|
+
|
|
595
|
+
const healthPath = `${PAYMENTS_API_BASE_PATH}/health`;
|
|
596
|
+
const configPath = `${PAYMENTS_API_BASE_PATH}/config`;
|
|
597
|
+
const checkoutPath = `${PAYMENTS_API_BASE_PATH}/checkout-session`;
|
|
598
|
+
const webhookPath = `${PAYMENTS_API_BASE_PATH}/webhook`;
|
|
599
|
+
const sessionStatusPath = `${PAYMENTS_API_BASE_PATH}/session-status`;
|
|
600
|
+
const finalizeSessionPath = `${PAYMENTS_API_BASE_PATH}/finalize-session`;
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
if (pathname === healthPath) {
|
|
604
|
+
if (!['GET', 'HEAD'].includes(req.method || '')) {
|
|
605
|
+
return sendJson(req, res, 405, { error: 'Method Not Allowed' });
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return sendJson(req, res, 200, {
|
|
609
|
+
ok: true,
|
|
610
|
+
payments_enabled: STRIPE_PAYMENTS_ENABLED,
|
|
611
|
+
stripe_checkout_ready: Boolean(STRIPE_SECRET_KEY && STRIPE_PRICE_ID),
|
|
612
|
+
stripe_webhook_ready: Boolean(STRIPE_WEBHOOK_SECRET),
|
|
613
|
+
api_base_path: PAYMENTS_API_BASE_PATH,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (pathname === configPath) {
|
|
618
|
+
if (!['GET', 'HEAD'].includes(req.method || '')) {
|
|
619
|
+
return sendJson(req, res, 405, { error: 'Method Not Allowed' });
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return sendJson(req, res, 200, buildPublicConfigPayload());
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (pathname === checkoutPath) {
|
|
626
|
+
if (req.method !== 'POST') {
|
|
627
|
+
return sendJson(req, res, 405, { error: 'Method Not Allowed' });
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
assertStripeCheckoutReady();
|
|
631
|
+
const body = await readJsonBody(req, { maxBytes: 32 * 1024 });
|
|
632
|
+
const identity = normalizeWhatsappIdentity(body?.whatsapp || body?.owner_jid || body?.ownerPhone || body?.phone);
|
|
633
|
+
const customerEmail = normalizeCheckoutEmail(body?.email);
|
|
634
|
+
const customerName = normalizeCustomerName(body?.name);
|
|
635
|
+
|
|
636
|
+
const checkoutSession = await createStripeCheckoutSession({
|
|
637
|
+
req,
|
|
638
|
+
ownerJid: identity.ownerJid,
|
|
639
|
+
ownerPhone: identity.ownerPhone,
|
|
640
|
+
customerEmail,
|
|
641
|
+
customerName,
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
if (!checkoutSession?.url) {
|
|
645
|
+
throw createHttpError('Stripe nao retornou URL de checkout.', 502, 'stripe_checkout_url_missing');
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
return sendJson(req, res, 201, {
|
|
649
|
+
ok: true,
|
|
650
|
+
checkout_url: checkoutSession.url,
|
|
651
|
+
session_id: checkoutSession.id || null,
|
|
652
|
+
owner_jid: identity.ownerJid,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (pathname === sessionStatusPath) {
|
|
657
|
+
if (!['GET', 'HEAD'].includes(req.method || '')) {
|
|
658
|
+
return sendJson(req, res, 405, { error: 'Method Not Allowed' });
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
assertStripeCheckoutReady();
|
|
662
|
+
const sessionId = sanitizePlainString(url?.searchParams?.get('session_id') || '', 255);
|
|
663
|
+
if (!sessionId) {
|
|
664
|
+
throw createHttpError('session_id e obrigatorio.', 400, 'session_id_required');
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const session = await getStripeCheckoutSession(sessionId);
|
|
668
|
+
|
|
669
|
+
return sendJson(req, res, 200, {
|
|
670
|
+
ok: true,
|
|
671
|
+
session: buildCheckoutSessionPayload(session),
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (pathname === finalizeSessionPath) {
|
|
676
|
+
if (req.method !== 'POST') {
|
|
677
|
+
return sendJson(req, res, 405, { error: 'Method Not Allowed' });
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
assertStripeCheckoutReady();
|
|
681
|
+
const body = await readJsonBody(req, { maxBytes: 32 * 1024 });
|
|
682
|
+
const sessionId = sanitizePlainString(body?.session_id || '', 255);
|
|
683
|
+
if (!sessionId) {
|
|
684
|
+
throw createHttpError('session_id e obrigatorio.', 400, 'session_id_required');
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const session = await getStripeCheckoutSession(sessionId);
|
|
688
|
+
const processing = await finalizePremiumFromCheckoutSession(session, 'finalize_api');
|
|
689
|
+
|
|
690
|
+
return sendJson(req, res, 200, {
|
|
691
|
+
ok: true,
|
|
692
|
+
finalized: processing?.action === 'premium_activated',
|
|
693
|
+
action: processing?.action || 'ignored',
|
|
694
|
+
reason: processing?.reason || null,
|
|
695
|
+
owner_jid: processing?.ownerJid || null,
|
|
696
|
+
session: buildCheckoutSessionPayload(session),
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (pathname === webhookPath) {
|
|
701
|
+
if (req.method !== 'POST') {
|
|
702
|
+
return sendJson(req, res, 405, { error: 'Method Not Allowed' });
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
assertStripeWebhookReady();
|
|
706
|
+
const rawBody = await readRawBody(req, { maxBytes: 1024 * 1024 });
|
|
707
|
+
const signatureHeader = sanitizePlainString(req.headers?.['stripe-signature'], 1024);
|
|
708
|
+
verifyStripeWebhookSignature(rawBody, signatureHeader);
|
|
709
|
+
|
|
710
|
+
let eventPayload;
|
|
711
|
+
try {
|
|
712
|
+
eventPayload = JSON.parse(rawBody.toString('utf8'));
|
|
713
|
+
} catch {
|
|
714
|
+
throw createHttpError('Payload JSON do webhook invalido.', 400, 'stripe_webhook_json_invalid');
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const processing = await processStripeWebhookEvent(eventPayload);
|
|
718
|
+
return sendJson(req, res, 200, {
|
|
719
|
+
received: true,
|
|
720
|
+
event_id: sanitizePlainString(eventPayload?.id, 120) || null,
|
|
721
|
+
action: processing?.action || 'ignored',
|
|
722
|
+
reason: processing?.reason || null,
|
|
723
|
+
owner_jid: processing?.ownerJid || null,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return false;
|
|
728
|
+
} catch (error) {
|
|
729
|
+
return handlePaymentsControllerError(req, res, error, { pathname });
|
|
730
|
+
}
|
|
731
|
+
};
|