fix: refactor asaas webhook for reliability and security
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 54s Details

This commit is contained in:
Sidney Gomes 2026-07-21 20:40:35 -03:00
parent a8f439c1cd
commit 094b9e5707
1 changed files with 53 additions and 42 deletions

View File

@ -1127,70 +1127,81 @@ app.post('/api/admin/reorder', authenticateToken, requireAdmin, (req: Authentica
// --- ASAAS WEBHOOK HANDLER --- // --- ASAAS WEBHOOK HANDLER ---
app.post('/api/webhooks/asaas', (req: Request, res: Response) => { app.post('/api/webhooks/asaas', (req: Request, res: Response) => {
const db = loadDb(); const db = loadDb();
const configuredSecret = db.settings?.asaasWebhookSecret || process.env.ASAAS_WEBHOOK_TOKEN || 'devflix_webhook_secret_key'; const { event, payment } = req.body;
// 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'];
const tokenHeader = req.headers['asaas-token'] || req.headers['authorization'];
if (configuredSecret && tokenHeader !== configuredSecret) { if (configuredSecret && tokenHeader !== configuredSecret) {
console.warn(`[WEBHOOK SEGURANÇA] Token inválido bloqueado: ${tokenHeader}`); 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.' }); res.status(401).json({ error: 'Não autorizado - Token de Webhook do Asaas é inválido ou ausente.' });
return; return;
} }
const { event, payment } = req.body;
if (!event || !payment) { if (!event || !payment) {
res.status(400).json({ error: 'Formato do Webhook inválido' }); res.status(400).json({ error: 'Formato do Webhook inválido' });
return; return;
} }
// Log the webhook event // 3. Processamento do Pagamento
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
const customerId = payment.customer; const customerId = payment.customer;
const user = db.users.find(u => u.asaasCustomerId === customerId); const user = db.users.find(u => u.asaasCustomerId === customerId);
if (user) { if (user) {
// Process payment updates // Busca APENAS pelo ID exato da fatura
let userPaymentIdx = db.payments.findIndex(p => p.id === payment.id || (p.userId === user.id && p.status === 'PENDING')); 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) { if (userPaymentIdx >= 0) {
db.payments[userPaymentIdx].status = if (newStatus) {
event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED' ? 'CONFIRMED' : db.payments[userPaymentIdx].status = newStatus as any;
event === 'PAYMENT_OVERDUE' ? 'OVERDUE' : }
event === 'PAYMENT_DELETED' ? 'DELETED' : db.payments[userPaymentIdx].status; if (isPaid) {
if (event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED') {
db.payments[userPaymentIdx].paidAt = new Date().toISOString(); db.payments[userPaymentIdx].paidAt = new Date().toISOString();
} }
} else if (event !== 'PAYMENT_DELETED') { } else if (!isRefunded) {
// Register a new payment log (unless it's a deletion of an unknown payment) // Se não achou e não é estorno/deleção, registra a fatura enviada pelo Asaas
db.payments.push({ 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, userId: user.id,
value: payment.value || 49.90, value: payment.value || 49.90,
status: (event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED') ? 'CONFIRMED' : status: (newStatus || 'PENDING') as any,
event === 'PAYMENT_OVERDUE' ? 'OVERDUE' : 'PENDING',
billingType: payment.billingType || 'PIX', billingType: payment.billingType || 'PIX',
invoiceUrl: payment.invoiceUrl || `https://sandbox.asaas.com/i/${payment.id}`, invoiceUrl: payment.invoiceUrl || `https://sandbox.asaas.com/i/${payment.id}`,
dueDate: payment.dueDate || new Date().toISOString().split('T')[0], 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() createdAt: new Date().toISOString()
}); });
} }
// Handle subscription access control // 4. Lógica de Liberação de Acesso / Curso
if (event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED') { const description = payment.description || '';
const description = payment.description || ''; const isCoursePayment = description.startsWith('unlock_course_');
if (description.startsWith('unlock_course_')) { if (isPaid) {
if (isCoursePayment) {
const courseId = description.replace('unlock_course_', ''); const courseId = description.replace('unlock_course_', '');
if (!user.unlockedCourses) user.unlockedCourses = []; if (!user.unlockedCourses) user.unlockedCourses = [];
if (!user.unlockedCourses.includes(courseId)) { 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}`); console.log(`Webhook: Curso ${courseId} LIBERADO para usuário ${user.email}`);
} else { } else {
// Liberar / manter acesso
user.subscriptionStatus = 'ACTIVE'; 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') { } else if (isOverdue || isRefunded || event === 'SUBSCRIPTION_DELETED') {
// Bloquear acesso automaticamente se for assinatura if (!isCoursePayment) {
const description = payment.description || ''; // Bloqueia acesso da assinatura
if (!description.startsWith('unlock_course_')) { user.subscriptionStatus = isOverdue ? 'OVERDUE' : 'CANCELED';
user.subscriptionStatus = event === 'PAYMENT_OVERDUE' ? 'OVERDUE' : 'CANCELED'; console.log(`Webhook: Assinatura BLOQUEADA para usuário ${user.email} - Motivo: ${event}`);
console.log(`Webhook: Acesso BLOQUEADO para usuário ${user.email} (${user.name}) - Motivo: ${event}`); } else if (isRefunded) {
console.log(`Webhook: Pagamento de Curso REEMBOLSADO/DELETADO para usuário ${user.email}`);
} }
} }
} else { } else {