Implement 5 minutes PIX expiration timer, frontend countdown, backend cancel endpoint and auto-cleanup worker
Build and Deploy (Gitea) / build-and-deploy (push) Has been cancelled Details

This commit is contained in:
Sidney Gomes 2026-07-23 23:24:02 -03:00
parent c3bffcbc65
commit c7ba765b80
3 changed files with 140 additions and 2 deletions

View File

@ -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 // 6. Lesson Progress tracking
app.post('/api/lessons/:id/progress', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { app.post('/api/lessons/:id/progress', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const { watchedPercentage } = req.body; const { watchedPercentage } = req.body;
@ -2184,6 +2213,37 @@ async function startServer() {
app.listen(PORT, '0.0.0.0', () => { app.listen(PORT, '0.0.0.0', () => {
console.log(`DevFlix Server running on port ${PORT}`); 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(); startServer();

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; 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'; import { Course, Plan } from '../types';
interface CourseUnlockModalProps { interface CourseUnlockModalProps {
@ -18,6 +18,39 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [paymentData, setPaymentData] = useState<any>(null); const [paymentData, setPaymentData] = useState<any>(null);
const [timeLeft, setTimeLeft] = useState<number | null>(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 // Form states
const [ccNumber, setCcNumber] = useState(''); const [ccNumber, setCcNumber] = useState('');
@ -34,6 +67,12 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
const coursePrice = course.price || 49.90; 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 = () => { const handleCopyPix = () => {
if (paymentData?.pixData?.payload) { if (paymentData?.pixData?.payload) {
navigator.clipboard.writeText(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'); throw new Error(data.error || data.message || 'Erro ao processar pagamento com o Asaas');
} }
if (paymentMethod === 'PIX' || paymentMethod === 'BOLETO') { if (paymentMethod === 'PIX') {
setPaymentData(data); setPaymentData(data);
setTimeLeft(300); // 5 minutes countdown
} else if (paymentMethod === 'BOLETO') {
setPaymentData(data);
setTimeLeft(null);
} else { } else {
setUnlocked(true); // Credit Card assumes instant in sandbox setUnlocked(true); // Credit Card assumes instant in sandbox
} }
@ -341,6 +384,14 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
<span>{pixCopied ? 'Copiado!' : 'Copiar'}</span> <span>{pixCopied ? 'Copiado!' : 'Copiar'}</span>
</button> </button>
</div> </div>
{timeLeft !== null && (
<div className="flex items-center justify-center space-x-1.5 text-xs font-semibold text-zinc-400 bg-zinc-900/60 border border-white/5 py-2 px-4 rounded-xl w-fit mx-auto mt-3">
<Clock className="w-3.5 h-3.5 text-[#E50914] animate-pulse" />
<span>O QR Code expira em:</span>
<span className="text-white font-mono font-bold">{formatTime(timeLeft)}</span>
</div>
)}
</div> </div>
</> </>
)} )}

View File

@ -360,6 +360,33 @@ export class AsaasService {
} catch (err: any) { } catch (err: any) {
console.error('Asaas createPayment error:', err); console.error('Asaas createPayment error:', err);
throw err; throw err;
}
/**
* Delete/Cancel a payment in Asaas
*/
public static async deletePayment(paymentId: string): Promise<boolean> {
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;
} }
} }
} }