Fix checkout payment: add CPF validation, update Asaas customer and add CPF field in checkout modal
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 56s Details

This commit is contained in:
Sidney Gomes 2026-07-23 11:44:32 -03:00
parent 772a308667
commit 7dcd2cfa69
4 changed files with 99 additions and 6 deletions

View File

@ -474,7 +474,7 @@ const checkoutRateLimiter = (req: Request, res: Response, next: Function) => {
app.post('/api/courses/:id/unlock', authenticateToken, checkoutRateLimiter, async (req: AuthenticatedRequest, res: Response) => { app.post('/api/courses/:id/unlock', authenticateToken, checkoutRateLimiter, async (req: AuthenticatedRequest, res: Response) => {
const courseId = req.params.id; const courseId = req.params.id;
const userId = req.user!.id; const userId = req.user!.id;
const { billingType, creditCardInfo, installmentCount } = req.body; const { billingType, creditCardInfo, installmentCount, cpf } = req.body;
const user = await prisma.user.findUnique({ where: { id: userId } }); const user = await prisma.user.findUnique({ where: { id: userId } });
const course = await prisma.course.findUnique({ where: { id: courseId } }); const course = await prisma.course.findUnique({ where: { id: courseId } });
@ -489,18 +489,35 @@ app.post('/api/courses/:id/unlock', authenticateToken, checkoutRateLimiter, asyn
} }
try { try {
const activeCpf = cpf || user.cpf;
if (!activeCpf || activeCpf === '000.000.000-00') {
res.status(400).json({ error: 'Para realizar o pagamento é obrigatório fornecer um CPF ou CNPJ válido.' });
return;
}
if (activeCpf && activeCpf !== user.cpf) {
await prisma.user.update({
where: { id: userId },
data: { cpf: activeCpf }
});
user.cpf = activeCpf;
}
let customerId = user.asaasCustomerId; let customerId = user.asaasCustomerId;
const apiKey = await AsaasService.getApiKey(); const apiKey = await AsaasService.getApiKey();
if (apiKey && (customerId?.startsWith('cus_sim_') || customerId?.startsWith('cus_fallback_') || ['cus_test', 'cus_00001', 'cus_00002', 'cus_00003', 'cus_00004'].includes(customerId || ''))) { if (apiKey && (customerId?.startsWith('cus_sim_') || customerId?.startsWith('cus_fallback_') || ['cus_test', 'cus_00001', 'cus_00002', 'cus_00003', 'cus_00004'].includes(customerId || ''))) {
customerId = null; customerId = null;
} }
if (!customerId) { if (!customerId) {
customerId = await AsaasService.createCustomer(user.name, user.email, user.cpf); customerId = await AsaasService.createCustomer(user.name, user.email, activeCpf);
await prisma.user.update({ await prisma.user.update({
where: { id: userId }, where: { id: userId },
data: { asaasCustomerId: customerId } data: { asaasCustomerId: customerId }
}); });
user.asaasCustomerId = customerId; user.asaasCustomerId = customerId;
} else {
await AsaasService.updateCustomer(customerId, { cpfCnpj: activeCpf });
} }
let processedCardInfo = creditCardInfo; let processedCardInfo = creditCardInfo;
@ -672,7 +689,7 @@ app.get('/api/subscription/status', authenticateToken, async (req: Authenticated
// 9. Subscribe to plan // 9. Subscribe to plan
app.post('/api/subscription/subscribe', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { app.post('/api/subscription/subscribe', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const { billingType, creditCardInfo, planId } = req.body; const { billingType, creditCardInfo, planId, cpf } = req.body;
const userId = req.user!.id; const userId = req.user!.id;
if (!billingType) { if (!billingType) {
@ -687,18 +704,35 @@ app.post('/api/subscription/subscribe', authenticateToken, async (req: Authentic
} }
try { try {
const activeCpf = cpf || user.cpf;
if (!activeCpf || activeCpf === '000.000.000-00') {
res.status(400).json({ error: 'Para realizar a assinatura é obrigatório fornecer um CPF ou CNPJ válido.' });
return;
}
if (activeCpf && activeCpf !== user.cpf) {
await prisma.user.update({
where: { id: userId },
data: { cpf: activeCpf }
});
user.cpf = activeCpf;
}
let customerId = user.asaasCustomerId; let customerId = user.asaasCustomerId;
const apiKey = await AsaasService.getApiKey(); const apiKey = await AsaasService.getApiKey();
if (apiKey && (customerId?.startsWith('cus_sim_') || customerId?.startsWith('cus_fallback_') || ['cus_test', 'cus_00001', 'cus_00002', 'cus_00003', 'cus_00004'].includes(customerId || ''))) { if (apiKey && (customerId?.startsWith('cus_sim_') || customerId?.startsWith('cus_fallback_') || ['cus_test', 'cus_00001', 'cus_00002', 'cus_00003', 'cus_00004'].includes(customerId || ''))) {
customerId = null; customerId = null;
} }
if (!customerId) { if (!customerId) {
customerId = await AsaasService.createCustomer(user.name, user.email, user.cpf); customerId = await AsaasService.createCustomer(user.name, user.email, activeCpf);
await prisma.user.update({ await prisma.user.update({
where: { id: userId }, where: { id: userId },
data: { asaasCustomerId: customerId } data: { asaasCustomerId: customerId }
}); });
user.asaasCustomerId = customerId; user.asaasCustomerId = customerId;
} else {
await AsaasService.updateCustomer(customerId, { cpfCnpj: activeCpf });
} }
let plan = planId ? await prisma.plan.findUnique({ where: { id: planId } }) : null; let plan = planId ? await prisma.plan.findUnique({ where: { id: planId } }) : null;

View File

@ -303,6 +303,7 @@ export default function App() {
{unlockingCourse && ( {unlockingCourse && (
<CourseUnlockModal <CourseUnlockModal
course={unlockingCourse} course={unlockingCourse}
user={user}
onClose={() => setUnlockingCourse(null)} onClose={() => setUnlockingCourse(null)}
onSuccess={() => { onSuccess={() => {
setUnlockingCourse(null); setUnlockingCourse(null);

View File

@ -4,12 +4,13 @@ import { Course, Plan } from '../types';
interface CourseUnlockModalProps { interface CourseUnlockModalProps {
course: Course & { price?: number }; course: Course & { price?: number };
user: any;
onClose: () => void; onClose: () => void;
onSuccess: () => void; onSuccess: () => void;
token: string; token: string;
} }
export default function CourseUnlockModal({ course, onClose, onSuccess, token }: CourseUnlockModalProps) { export default function CourseUnlockModal({ course, user, onClose, onSuccess, token }: CourseUnlockModalProps) {
const [paymentMethod, setPaymentMethod] = useState<'PIX' | 'CREDIT_CARD' | 'BOLETO'>('PIX'); const [paymentMethod, setPaymentMethod] = useState<'PIX' | 'CREDIT_CARD' | 'BOLETO'>('PIX');
const [isPaying, setIsPaying] = useState(false); const [isPaying, setIsPaying] = useState(false);
const [pixCopied, setPixCopied] = useState(false); const [pixCopied, setPixCopied] = useState(false);
@ -24,6 +25,7 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
const [ccExpiry, setCcExpiry] = useState(''); const [ccExpiry, setCcExpiry] = useState('');
const [ccCvv, setCcCvv] = useState(''); const [ccCvv, setCcCvv] = useState('');
const [installments, setInstallments] = useState(1); const [installments, setInstallments] = useState(1);
const [checkoutCpf, setCheckoutCpf] = useState(user?.cpf && user?.cpf !== '000.000.000-00' ? user.cpf : '');
// Pricing & Plans Selection // Pricing & Plans Selection
const [availablePlans, setAvailablePlans] = useState<Plan[]>([]); const [availablePlans, setAvailablePlans] = useState<Plan[]>([]);
@ -100,11 +102,18 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
} }
} : undefined; } : undefined;
if (!checkoutCpf || checkoutCpf.replace(/\D/g, '').length < 11) {
setError('Por favor, informe um CPF ou CNPJ válido.');
setIsPaying(false);
return;
}
try { try {
let endpoint = ''; let endpoint = '';
let bodyData: any = { let bodyData: any = {
billingType: paymentMethod, billingType: paymentMethod,
creditCardInfo creditCardInfo,
cpf: checkoutCpf
}; };
if (selectedPlanId) { if (selectedPlanId) {
@ -441,6 +450,22 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
</div> </div>
)} )}
{/* CPF/CNPJ Input (Required for all Asaas payments) */}
{!paymentData && (
<div className="space-y-1 mt-4">
<label className="text-zinc-400 text-xs font-bold block">CPF ou CNPJ do Titular</label>
<input
type="text"
placeholder="000.000.000-00"
value={checkoutCpf}
onChange={(e) => setCheckoutCpf(e.target.value)}
className="w-full bg-zinc-900 border border-white/10 rounded-xl py-3 px-4 text-xs text-white focus:border-[#E50914] focus:outline-none transition-colors"
required
/>
<p className="text-[10px] text-zinc-500">Necessário para emissão do Pix, boleto ou cobrança de cartão via Asaas.</p>
</div>
)}
{/* Submit button */} {/* Submit button */}
{!paymentData && ( {!paymentData && (
<div className="pt-4 mt-auto"> <div className="pt-4 mt-auto">

View File

@ -112,6 +112,39 @@ export class AsaasService {
} }
} }
/**
* Update an existing customer in Asaas
*/
public static async updateCustomer(customerId: string, data: { name?: string, email?: string, cpfCnpj?: string }): Promise<void> {
const apiKey = await this.getApiKey();
if (!apiKey || customerId.startsWith('cus_sim_') || customerId.startsWith('cus_fb_') || customerId === 'cus_test' || ['cus_00001', 'cus_00002', 'cus_00003', 'cus_00004'].includes(customerId)) {
return;
}
const payload: any = {};
if (data.name) payload.name = data.name;
if (data.email) payload.email = data.email;
if (data.cpfCnpj && data.cpfCnpj !== '000.000.000-00') {
payload.cpfCnpj = data.cpfCnpj.replace(/\D/g, '');
}
try {
const response = await fetch(`${await this.getApiUrl()}/customers/${customerId}`, {
method: 'POST',
headers: await this.getHeaders(),
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const desc = errorData.errors?.[0]?.description || `HTTP ${response.status}`;
console.error(`Falha ao atualizar cliente no Asaas: ${desc}`);
}
} catch (err) {
console.error('Asaas updateCustomer error:', err);
}
}
/** /**
* Create a recurring subscription in Asaas * Create a recurring subscription in Asaas
*/ */