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 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") asaasWebhookSecret String @default("") @map("asaas_webhook_secret")
helpText String @db.Text @default("# Central de Ajuda") @map("help_text") helpText String @db.Text @default("# Central de Ajuda") @map("help_text")
enableBoleto Boolean @default(true) @map("enable_boleto") 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") evolutionApiUrl String @default("https://evolution.microtecinformaticacurso.com.br") @map("evolution_api_url")
evolutionApiKey String @default("") @map("evolution_api_key") 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, trialDurationDays: settings?.trialDurationDays || 7,
helpText: settings?.helpText || '', helpText: settings?.helpText || '',
enableBoleto: settings?.enableBoleto !== false, enableBoleto: settings?.enableBoleto !== false,
enableInstallmentBoleto: settings?.enableInstallmentBoleto === true,
maxBoletoInstallments: settings?.maxBoletoInstallments || 3,
brandName: settings?.brandName || 'MICROTEC', brandName: settings?.brandName || 'MICROTEC',
brandSlogan: settings?.brandSlogan || 'Acelere seus estudos em informática e pacote Office no estilo Netflix.', brandSlogan: settings?.brandSlogan || 'Acelere seus estudos em informática e pacote Office no estilo Netflix.',
cnpj: settings?.cnpj || '', cnpj: settings?.cnpj || '',
@ -2214,12 +2216,14 @@ async function startServer() {
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) // Background job to clean up expired PIX payments (older than 5 minutes) and Boleto payments (older than 2 days)
setInterval(async () => { setInterval(async () => {
try { try {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); 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: { where: {
billingType: 'PIX', billingType: 'PIX',
status: 'PENDING', status: 'PENDING',
@ -2227,21 +2231,40 @@ async function startServer() {
} }
}); });
for (const payment of expiredPayments) { for (const payment of expiredPix) {
console.log(`[PIX Expire Job] Expirando e deletando pagamento ${payment.id}...`); console.log(`[PIX Expire Job] Expirando e deletando PIX ${payment.id}...`);
const deletedInAsaas = await AsaasService.deletePayment(payment.id); const deletedInAsaas = await AsaasService.deletePayment(payment.id);
if (deletedInAsaas) { if (deletedInAsaas) {
await prisma.payment.update({ await prisma.payment.update({
where: { id: payment.id }, where: { id: payment.id },
data: { status: 'CANCELED' } 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) { } 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) }, 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"> <div className="flex flex-col">
<span className="text-xs font-bold text-white">Habilitar Boleto Bancário</span> <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> </div>
</label> </label>
</div> </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>
</div> </div>

View File

@ -88,6 +88,8 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
}; };
const [enableBoleto, setEnableBoleto] = useState<boolean>(true); const [enableBoleto, setEnableBoleto] = useState<boolean>(true);
const [enableInstallmentBoleto, setEnableInstallmentBoleto] = useState<boolean>(false);
const [maxBoletoInstallments, setMaxBoletoInstallments] = useState<number>(3);
useEffect(() => { useEffect(() => {
if (token) { if (token) {
@ -96,8 +98,16 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
}) })
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
if (data && data.enableBoleto === false) { if (data) {
setEnableBoleto(false); if (data.enableBoleto === false) {
setEnableBoleto(false);
}
if (data.enableInstallmentBoleto === true) {
setEnableInstallmentBoleto(true);
}
if (data.maxBoletoInstallments) {
setMaxBoletoInstallments(data.maxBoletoInstallments);
}
} }
}) })
.catch(() => {}); .catch(() => {});
@ -160,7 +170,7 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
bodyData.planId = selectedPlanId; bodyData.planId = selectedPlanId;
} else { } else {
endpoint = `/api/courses/${course.id}/unlock`; 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, { 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`}> <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 <button
type="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'}`} 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" /> <QrCode className="w-4 h-4 mb-1" />
@ -312,7 +322,7 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
</button> </button>
<button <button
type="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'}`} 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" /> <CreditCard className="w-4 h-4 mb-1" />
@ -321,7 +331,7 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
{enableBoleto && ( {enableBoleto && (
<button <button
type="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'}`} 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" /> <FileText className="w-4 h-4 mb-1" />
@ -481,9 +491,37 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm font-bold text-white">Boleto Bancário</p> <p className="text-sm font-bold text-white">Boleto Bancário</p>
<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. {!paymentData ? (
</p> <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"> <div className="pt-2">
{paymentData?.payment?.invoiceUrl ? ( {paymentData?.payment?.invoiceUrl ? (
<button <button