Support installment boleto configuration in admin and checkout with auto-cancellation after 2 days
Build and Deploy (Gitea) / build-and-deploy (push) Failing after 28s Details

This commit is contained in:
Sidney Gomes 2026-07-23 23:45:17 -03:00
parent d9a67f60ff
commit 97b0a0e880
5 changed files with 112 additions and 19 deletions

View File

@ -24,4 +24,4 @@ COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.cjs"]
CMD ["sh", "-c", "npx prisma db push && node dist/server.cjs"]

View File

@ -256,6 +256,8 @@ model SystemSettings {
asaasWebhookSecret String @default("") @map("asaas_webhook_secret")
helpText String @db.Text @default("# Central de Ajuda") @map("help_text")
enableBoleto Boolean @default(true) @map("enable_boleto")
enableInstallmentBoleto Boolean @default(false) @map("enable_installment_boleto")
maxBoletoInstallments Int @default(3) @map("max_boleto_installments")
evolutionApiUrl String @default("https://evolution.microtecinformaticacurso.com.br") @map("evolution_api_url")
evolutionApiKey String @default("") @map("evolution_api_key")

View File

@ -1218,6 +1218,8 @@ app.get('/api/settings', authenticateToken, async (req: AuthenticatedRequest, re
trialDurationDays: settings?.trialDurationDays || 7,
helpText: settings?.helpText || '',
enableBoleto: settings?.enableBoleto !== false,
enableInstallmentBoleto: settings?.enableInstallmentBoleto === true,
maxBoletoInstallments: settings?.maxBoletoInstallments || 3,
brandName: settings?.brandName || 'MICROTEC',
brandSlogan: settings?.brandSlogan || 'Acelere seus estudos em informática e pacote Office no estilo Netflix.',
cnpj: settings?.cnpj || '',
@ -2214,12 +2216,14 @@ async function startServer() {
console.log(`DevFlix Server running on port ${PORT}`);
});
// Background job to clean up expired PIX payments (older than 5 minutes)
// Background job to clean up expired PIX payments (older than 5 minutes) and Boleto payments (older than 2 days)
setInterval(async () => {
try {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
const expiredPayments = await prisma.payment.findMany({
// Clean PIX
const expiredPix = await prisma.payment.findMany({
where: {
billingType: 'PIX',
status: 'PENDING',
@ -2227,21 +2231,40 @@ async function startServer() {
}
});
for (const payment of expiredPayments) {
console.log(`[PIX Expire Job] Expirando e deletando pagamento ${payment.id}...`);
for (const payment of expiredPix) {
console.log(`[PIX Expire Job] Expirando e deletando PIX ${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.`);
console.log(`[PIX Expire Job] PIX ${payment.id} cancelado com sucesso.`);
}
}
// Clean BOLETO
const expiredBoleto = await prisma.payment.findMany({
where: {
billingType: 'BOLETO',
status: 'PENDING',
createdAt: { lt: twoDaysAgo }
}
});
for (const payment of expiredBoleto) {
console.log(`[Boleto Expire Job] Expirando e deletando Boleto ${payment.id}...`);
const deletedInAsaas = await AsaasService.deletePayment(payment.id);
if (deletedInAsaas) {
await prisma.payment.update({
where: { id: payment.id },
data: { status: 'CANCELED' }
});
console.log(`[Boleto Expire Job] Boleto ${payment.id} cancelado com sucesso.`);
}
}
} catch (err: any) {
console.error('[PIX Expire Job] Erro ao limpar pagamentos expirados:', err.message);
console.error('[Expire Job] Erro ao limpar pagamentos expirados:', err.message);
}
}, 60 * 1000); // Check every 60 seconds (1 minute)
}

View File

@ -980,10 +980,40 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash
/>
<div className="flex flex-col">
<span className="text-xs font-bold text-white">Habilitar Boleto Bancário</span>
<span className="text-[10px] text-zinc-400">Permite pagamentos via boleto.</span>
<span className="text-[10px] text-zinc-400">Permite pagamentos à vista via boleto bancário.</span>
</div>
</label>
</div>
<div className="pt-2 md:col-span-2 space-y-1">
<label className="flex items-center space-x-3 cursor-pointer bg-black/40 p-3 rounded-lg border border-white/10 hover:border-white/20 transition-colors">
<input
type="checkbox"
checked={settings.enableInstallmentBoleto === true}
onChange={(e) => setSettings({ ...settings, enableInstallmentBoleto: e.target.checked })}
className="rounded bg-zinc-900 border-white/10 text-[#E50914] focus:ring-[#E50914] focus:ring-offset-0 w-4 h-4 cursor-pointer"
/>
<div className="flex flex-col">
<span className="text-xs font-bold text-white">Habilitar Boleto Parcelado</span>
<span className="text-[10px] text-zinc-400">Permite gerar boletos parcelados para compra de cursos.</span>
</div>
</label>
</div>
{settings.enableInstallmentBoleto && (
<div className="space-y-1 md:col-span-2">
<label className="text-xs text-zinc-400 font-bold">Limite Máximo de Parcelas no Boleto</label>
<input
type="number"
min={2}
max={12}
value={settings.maxBoletoInstallments || 3}
onChange={(e) => setSettings({ ...settings, maxBoletoInstallments: parseInt(e.target.value) || 3 })}
className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-[#E50914]"
placeholder="Ex: 3"
/>
</div>
)}
</div>
</div>

View File

@ -88,6 +88,8 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
};
const [enableBoleto, setEnableBoleto] = useState<boolean>(true);
const [enableInstallmentBoleto, setEnableInstallmentBoleto] = useState<boolean>(false);
const [maxBoletoInstallments, setMaxBoletoInstallments] = useState<number>(3);
useEffect(() => {
if (token) {
@ -96,9 +98,17 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
})
.then(res => res.json())
.then(data => {
if (data && data.enableBoleto === false) {
if (data) {
if (data.enableBoleto === false) {
setEnableBoleto(false);
}
if (data.enableInstallmentBoleto === true) {
setEnableInstallmentBoleto(true);
}
if (data.maxBoletoInstallments) {
setMaxBoletoInstallments(data.maxBoletoInstallments);
}
}
})
.catch(() => {});
@ -160,7 +170,7 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
bodyData.planId = selectedPlanId;
} else {
endpoint = `/api/courses/${course.id}/unlock`;
bodyData.installmentCount = paymentMethod === 'CREDIT_CARD' ? installments : 1;
bodyData.installmentCount = (paymentMethod === 'CREDIT_CARD' || (paymentMethod === 'BOLETO' && enableInstallmentBoleto)) ? installments : 1;
}
const res = await fetch(endpoint, {
@ -304,7 +314,7 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
<div className={`grid ${enableBoleto ? 'grid-cols-3' : 'grid-cols-2'} gap-2 p-1 bg-zinc-900/80 rounded-xl border border-white/5 mb-6`}>
<button
type="button"
onClick={() => setPaymentMethod('PIX')}
onClick={() => { setPaymentMethod('PIX'); setInstallments(1); }}
className={`flex flex-col items-center justify-center py-2.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${paymentMethod === 'PIX' ? 'bg-[#E50914] text-white shadow-lg' : 'text-zinc-400 hover:text-white'}`}
>
<QrCode className="w-4 h-4 mb-1" />
@ -312,7 +322,7 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
</button>
<button
type="button"
onClick={() => setPaymentMethod('CREDIT_CARD')}
onClick={() => { setPaymentMethod('CREDIT_CARD'); setInstallments(1); }}
className={`flex flex-col items-center justify-center py-2.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${paymentMethod === 'CREDIT_CARD' ? 'bg-[#E50914] text-white shadow-lg' : 'text-zinc-400 hover:text-white'}`}
>
<CreditCard className="w-4 h-4 mb-1" />
@ -321,7 +331,7 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
{enableBoleto && (
<button
type="button"
onClick={() => setPaymentMethod('BOLETO')}
onClick={() => { setPaymentMethod('BOLETO'); setInstallments(1); }}
className={`flex flex-col items-center justify-center py-2.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${paymentMethod === 'BOLETO' ? 'bg-[#E50914] text-white shadow-lg' : 'text-zinc-400 hover:text-white'}`}
>
<FileText className="w-4 h-4 mb-1" />
@ -481,9 +491,37 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
</div>
<div className="space-y-2">
<p className="text-sm font-bold text-white">Boleto Bancário</p>
{!paymentData ? (
<p className="text-xs text-zinc-400 max-w-sm mx-auto">
O boleto será gerado no valor de R$ {currentPrice.toFixed(2)}. O prazo de compensação é de 1 a 2 dias úteis.
</p>
) : (
<p className="text-xs text-zinc-400 max-w-sm mx-auto">
Boleto gerado com sucesso no valor de R$ {currentPrice.toFixed(2)} {paymentData.payment?.installmentCount > 1 ? `parcelado em ${paymentData.payment.installmentCount}x` : ''}.
</p>
)}
{!paymentData && enableInstallmentBoleto && (!selectedPlanObj || selectedPlanObj.cycle === 'LIFETIME') && (
<div className="space-y-1 max-w-xs mx-auto text-left mt-2 pb-2">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider block mb-1">Opções de Parcelamento</label>
<select
value={installments}
onChange={(e) => setInstallments(Number(e.target.value))}
className="w-full glass-input rounded-lg p-2.5 text-xs text-white focus:outline-none cursor-pointer bg-zinc-900 border-white/10"
>
{Array.from({ length: maxBoletoInstallments }, (_, i) => i + 1).map(n => {
const val = (currentPrice / n).toFixed(2);
return (
<option key={n} value={n} className="bg-zinc-900 text-white">
{n}x de R$ {val} {n === 1 ? '(À vista)' : '(parcelado)'}
</option>
);
})}
</select>
</div>
)}
<div className="pt-2">
{paymentData?.payment?.invoiceUrl ? (
<button