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
Build and Deploy (Gitea) / build-and-deploy (push) Has been cancelled
Details
This commit is contained in:
parent
c3bffcbc65
commit
c7ba765b80
60
server.ts
60
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
|
// 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();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -361,5 +361,32 @@ export class AsaasService {
|
||||||
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue