From 094b9e5707981988ade826324c2849b74846fb40 Mon Sep 17 00:00:00 2001 From: Sidney Gomes Date: Tue, 21 Jul 2026 20:40:35 -0300 Subject: [PATCH] fix: refactor asaas webhook for reliability and security --- server.ts | 95 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 53 insertions(+), 42 deletions(-) diff --git a/server.ts b/server.ts index 21491dc..6bf8c09 100644 --- a/server.ts +++ b/server.ts @@ -1127,70 +1127,81 @@ app.post('/api/admin/reorder', authenticateToken, requireAdmin, (req: Authentica // --- ASAAS WEBHOOK HANDLER --- app.post('/api/webhooks/asaas', (req: Request, res: Response) => { const db = loadDb(); - const configuredSecret = db.settings?.asaasWebhookSecret || process.env.ASAAS_WEBHOOK_TOKEN || 'devflix_webhook_secret_key'; + const { event, payment } = req.body; - const tokenHeader = req.headers['asaas-token'] || req.headers['authorization']; + // 1. Logging Antecipado (para debuggar mesmo se der 401) + const logId = `wh_log_${Math.random().toString(36).substring(2, 9)}`; + if (event) { + db.webhookLogs.push({ + id: logId, + event, + payload: JSON.stringify(req.body), + receivedAt: new Date().toISOString() + }); + saveDb(db); // Salva o log imediatamente para não perdê-lo se der return early + } + + // 2. Validação de Segurança + const configuredSecret = db.settings?.asaasWebhookSecret || process.env.ASAAS_WEBHOOK_TOKEN || 'devflix_webhook_secret_key'; + // O Asaas usa asaas-access-token por padrão + const tokenHeader = req.headers['asaas-access-token'] || req.headers['asaas-token'] || req.headers['authorization']; + if (configuredSecret && tokenHeader !== configuredSecret) { console.warn(`[WEBHOOK SEGURANÇA] Token inválido bloqueado: ${tokenHeader}`); res.status(401).json({ error: 'Não autorizado - Token de Webhook do Asaas é inválido ou ausente.' }); return; } - const { event, payment } = req.body; - if (!event || !payment) { res.status(400).json({ error: 'Formato do Webhook inválido' }); return; } - // Log the webhook event - const logId = `wh_log_${Math.random().toString(36).substring(2, 9)}`; - db.webhookLogs.push({ - id: logId, - event, - payload: JSON.stringify(req.body), - receivedAt: new Date().toISOString() - }); - - // Find user by Asaas Customer ID + // 3. Processamento do Pagamento const customerId = payment.customer; const user = db.users.find(u => u.asaasCustomerId === customerId); if (user) { - // Process payment updates - let userPaymentIdx = db.payments.findIndex(p => p.id === payment.id || (p.userId === user.id && p.status === 'PENDING')); + // Busca APENAS pelo ID exato da fatura + let userPaymentIdx = db.payments.findIndex(p => p.id === payment.id); + + const isPaid = event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED'; + const isOverdue = event === 'PAYMENT_OVERDUE'; + const isRefunded = event === 'PAYMENT_REFUNDED' || event === 'PAYMENT_DELETED'; + + const newStatus = isPaid ? 'CONFIRMED' : + isOverdue ? 'OVERDUE' : + isRefunded ? 'REFUNDED' : + event === 'PAYMENT_RESTORED' ? 'PENDING' : null; - // If we have a matching or pending payment, let's update or insert if (userPaymentIdx >= 0) { - db.payments[userPaymentIdx].status = - event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED' ? 'CONFIRMED' : - event === 'PAYMENT_OVERDUE' ? 'OVERDUE' : - event === 'PAYMENT_DELETED' ? 'DELETED' : db.payments[userPaymentIdx].status; - - if (event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED') { + if (newStatus) { + db.payments[userPaymentIdx].status = newStatus as any; + } + if (isPaid) { db.payments[userPaymentIdx].paidAt = new Date().toISOString(); } - } else if (event !== 'PAYMENT_DELETED') { - // Register a new payment log (unless it's a deletion of an unknown payment) + } else if (!isRefunded) { + // Se não achou e não é estorno/deleção, registra a fatura enviada pelo Asaas db.payments.push({ - id: payment.id || `pay_wh_${Math.random().toString(36).substring(2, 9)}`, + id: payment.id, // Usa o ID exato userId: user.id, value: payment.value || 49.90, - status: (event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED') ? 'CONFIRMED' : - event === 'PAYMENT_OVERDUE' ? 'OVERDUE' : 'PENDING', + status: (newStatus || 'PENDING') as any, billingType: payment.billingType || 'PIX', invoiceUrl: payment.invoiceUrl || `https://sandbox.asaas.com/i/${payment.id}`, dueDate: payment.dueDate || new Date().toISOString().split('T')[0], - paidAt: (event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED') ? new Date().toISOString() : null, + paidAt: isPaid ? new Date().toISOString() : null, createdAt: new Date().toISOString() }); } - // Handle subscription access control - if (event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED') { - const description = payment.description || ''; - - if (description.startsWith('unlock_course_')) { + // 4. Lógica de Liberação de Acesso / Curso + const description = payment.description || ''; + const isCoursePayment = description.startsWith('unlock_course_'); + + if (isPaid) { + if (isCoursePayment) { const courseId = description.replace('unlock_course_', ''); if (!user.unlockedCourses) user.unlockedCourses = []; if (!user.unlockedCourses.includes(courseId)) { @@ -1198,16 +1209,16 @@ app.post('/api/webhooks/asaas', (req: Request, res: Response) => { } console.log(`Webhook: Curso ${courseId} LIBERADO para usuário ${user.email}`); } else { - // Liberar / manter acesso user.subscriptionStatus = 'ACTIVE'; - console.log(`Webhook: Acesso LIBERADO para usuário ${user.email} (${user.name})`); + console.log(`Webhook: Assinatura LIBERADA para usuário ${user.email}`); } - } else if (event === 'PAYMENT_OVERDUE' || event === 'SUBSCRIPTION_DELETED') { - // Bloquear acesso automaticamente se for assinatura - const description = payment.description || ''; - if (!description.startsWith('unlock_course_')) { - user.subscriptionStatus = event === 'PAYMENT_OVERDUE' ? 'OVERDUE' : 'CANCELED'; - console.log(`Webhook: Acesso BLOQUEADO para usuário ${user.email} (${user.name}) - Motivo: ${event}`); + } else if (isOverdue || isRefunded || event === 'SUBSCRIPTION_DELETED') { + if (!isCoursePayment) { + // Bloqueia acesso da assinatura + user.subscriptionStatus = isOverdue ? 'OVERDUE' : 'CANCELED'; + console.log(`Webhook: Assinatura BLOQUEADA para usuário ${user.email} - Motivo: ${event}`); + } else if (isRefunded) { + console.log(`Webhook: Pagamento de Curso REEMBOLSADO/DELETADO para usuário ${user.email}`); } } } else {