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
Build and Deploy (Gitea) / build-and-deploy (push) Failing after 28s
Details
This commit is contained in:
parent
d9a67f60ff
commit
97b0a0e880
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
39
server.ts
39
server.ts
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue