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 ---
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;
// 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) {
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 || '';
// 4. Lógica de Liberação de Acesso / Curso
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_', '');
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 {