From c7ba765b806757943bcc1ff5d223e1fb7451039f Mon Sep 17 00:00:00 2001 From: Sidney Gomes Date: Thu, 23 Jul 2026 23:24:02 -0300 Subject: [PATCH] Implement 5 minutes PIX expiration timer, frontend countdown, backend cancel endpoint and auto-cleanup worker --- server.ts | 60 ++++++++++++++++++++++++++++ src/components/CourseUnlockModal.tsx | 55 ++++++++++++++++++++++++- src/services/asaas.ts | 27 +++++++++++++ 3 files changed, 140 insertions(+), 2 deletions(-) diff --git a/server.ts b/server.ts index 196c6fb..049ebea 100644 --- a/server.ts +++ b/server.ts @@ -727,6 +727,35 @@ app.post('/api/courses/:id/unlock', authenticateToken, checkoutRateLimiter, asyn } }); +// Route to cancel a pending payment +app.post('/api/payments/:id/cancel', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { + const paymentId = req.params.id; + try { + const payment = await prisma.payment.findUnique({ where: { id: paymentId } }); + if (!payment) { + res.status(404).json({ error: 'Pagamento não encontrado.' }); + return; + } + + if (payment.status === 'PENDING') { + const deleted = await AsaasService.deletePayment(paymentId); + if (deleted) { + await prisma.payment.update({ + where: { id: paymentId }, + data: { status: 'CANCELED' } + }); + res.json({ success: true, message: 'Pagamento cancelado com sucesso.' }); + return; + } + } + + res.status(400).json({ error: 'Não foi possível cancelar este pagamento.' }); + } catch (err: any) { + console.error('Erro ao cancelar pagamento:', err.message); + res.status(500).json({ error: 'Erro no servidor.' }); + } +}); + // 6. Lesson Progress tracking app.post('/api/lessons/:id/progress', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const { watchedPercentage } = req.body; @@ -2184,6 +2213,37 @@ async function startServer() { app.listen(PORT, '0.0.0.0', () => { console.log(`DevFlix Server running on port ${PORT}`); }); + + // Background job to clean up expired PIX payments (older than 5 minutes) + setInterval(async () => { + try { + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); + + const expiredPayments = await prisma.payment.findMany({ + where: { + billingType: 'PIX', + status: 'PENDING', + createdAt: { lt: fiveMinutesAgo } + } + }); + + for (const payment of expiredPayments) { + console.log(`[PIX Expire Job] Expirando e deletando pagamento ${payment.id}...`); + + const deletedInAsaas = await AsaasService.deletePayment(payment.id); + + if (deletedInAsaas) { + await prisma.payment.update({ + where: { id: payment.id }, + data: { status: 'CANCELED' } + }); + console.log(`[PIX Expire Job] Pagamento ${payment.id} cancelado com sucesso.`); + } + } + } catch (err: any) { + console.error('[PIX Expire Job] Erro ao limpar pagamentos expirados:', err.message); + } + }, 60 * 1000); // Check every 60 seconds (1 minute) } startServer(); diff --git a/src/components/CourseUnlockModal.tsx b/src/components/CourseUnlockModal.tsx index ab6e10c..7d38b7e 100644 --- a/src/components/CourseUnlockModal.tsx +++ b/src/components/CourseUnlockModal.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { X, Check, CreditCard, QrCode, FileText, Sparkles, Loader2, Play, Layers } from 'lucide-react'; +import { X, Check, CreditCard, QrCode, FileText, Sparkles, Loader2, Play, Layers, Clock } from 'lucide-react'; import { Course, Plan } from '../types'; interface CourseUnlockModalProps { @@ -18,6 +18,39 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to const [error, setError] = useState(null); const [paymentData, setPaymentData] = useState(null); + const [timeLeft, setTimeLeft] = useState(null); + + useEffect(() => { + if (timeLeft === null) return; + + if (timeLeft <= 0) { + const cancelPayment = async () => { + if (!paymentData?.payment?.id) return; + try { + await fetch(`/api/payments/${paymentData.payment.id}/cancel`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + } catch (e) { + console.error('Failed to cancel expired payment:', e); + } + }; + + cancelPayment(); + setPaymentData(null); + setError('Tempo limite do PIX esgotado (5 minutos). O pagamento foi cancelado automaticamente.'); + setTimeLeft(null); + return; + } + + const interval = setInterval(() => { + setTimeLeft(prev => (prev !== null ? prev - 1 : null)); + }, 1000); + + return () => clearInterval(interval); + }, [timeLeft, paymentData, token]); // Form states const [ccNumber, setCcNumber] = useState(''); @@ -34,6 +67,12 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to const coursePrice = course.price || 49.90; + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + }; + const handleCopyPix = () => { if (paymentData?.pixData?.payload) { navigator.clipboard.writeText(paymentData.pixData.payload); @@ -147,8 +186,12 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to throw new Error(data.error || data.message || 'Erro ao processar pagamento com o Asaas'); } - if (paymentMethod === 'PIX' || paymentMethod === 'BOLETO') { + if (paymentMethod === 'PIX') { setPaymentData(data); + setTimeLeft(300); // 5 minutes countdown + } else if (paymentMethod === 'BOLETO') { + setPaymentData(data); + setTimeLeft(null); } else { setUnlocked(true); // Credit Card assumes instant in sandbox } @@ -341,6 +384,14 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to {pixCopied ? 'Copiado!' : 'Copiar'} + + {timeLeft !== null && ( +
+ + O QR Code expira em: + {formatTime(timeLeft)} +
+ )} )} diff --git a/src/services/asaas.ts b/src/services/asaas.ts index 2ec7039..1a3045a 100644 --- a/src/services/asaas.ts +++ b/src/services/asaas.ts @@ -360,6 +360,33 @@ export class AsaasService { } catch (err: any) { console.error('Asaas createPayment error:', err); throw err; + } + + /** + * Delete/Cancel a payment in Asaas + */ + public static async deletePayment(paymentId: string): Promise { + const apiKey = await this.getApiKey(); + if (!apiKey || paymentId.startsWith('pay_sim_') || paymentId.startsWith('pay_fb_')) { + return true; // Ignore simulated/fallback payments + } + + try { + const response = await fetch(`${await this.getApiUrl()}/payments/${paymentId}`, { + method: 'DELETE', + headers: await this.getHeaders() + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + console.error('Failed to delete payment in Asaas:', errorData.errors?.[0]?.description); + return false; + } + + return true; + } catch (err) { + console.error('Asaas deletePayment error:', err); + return false; } } }