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
|
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")
|
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")
|
||||||
|
|
|
||||||
39
server.ts
39
server.ts
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue