const Raffle = require('../models/Raffle') const Ticket = require('../models/Ticket') const RaffleResult = require('../models/RaffleResult') const Order = require('../models/Order') const PriceRule = require('../models/PriceRule') const RaffleDiscount = require('../models/RaffleDiscount') const RaffleFlashPromo = require('../models/RaffleFlashPromo') const WhatsappCore = require('../services/whatsappService') const NotificationService = require('../services/notificationService') const AsaasService = require('../services/asaasService') const crypto = require('crypto') function validateCPF(cpf) { cpf = cpf.replace(/[^\d]+/g, '') if (cpf.length !== 11 || /^(\d)\1{10}$/.test(cpf)) return false let sum = 0 for (let i = 0; i < 9; i++) sum += parseInt(cpf.charAt(i)) * (10 - i) let rem = 11 - (sum % 11) if (rem === 10 || rem === 11) rem = 0 if (rem !== parseInt(cpf.charAt(9))) return false sum = 0 for (let i = 0; i < 10; i++) sum += parseInt(cpf.charAt(i)) * (11 - i) rem = 11 - (sum % 11) if (rem === 10 || rem === 11) rem = 0 return rem === parseInt(cpf.charAt(10)) } const raffleController = { async getRaffles(req, res) { try { const raffles = await Raffle.findActive(req.tenant?.id) return res.json(raffles) } catch (error) { return res.status(500).json({ error: 'Erro interno' }) } }, async getRaffleDetail(req, res) { try { const raffle = await Raffle.findById(req.params.id) if (!raffle) return res.status(404).json({ error: 'Sorteio não encontrado' }) const soldNumbers = await Ticket.getSoldNumbers(raffle.id) const totalSold = soldNumbers.length return res.json({ ...raffle, soldNumbers, totalSold, totalDisponivel: raffle.total_numeros - totalSold }) } catch (error) { return res.status(500).json({ error: 'Erro interno' }) } }, async buyTicket(req, res) { try { const { raffle_id, numero, usuario_nome, usuario_telefone, usuario_cpf } = req.body if (!raffle_id || !numero || !usuario_nome) { return res.status(400).json({ error: 'Dados incompletos' }) } if (usuario_cpf) { if (!validateCPF(usuario_cpf)) { return res.status(400).json({ error: 'CPF inválido' }) } } else { return res.status(400).json({ error: 'CPF é obrigatório para pagamento via PIX' }) } const raffle = await Raffle.findById(raffle_id) if (!raffle) throw new Error('Sorteio não encontrado') const localOrderId = crypto.randomUUID() const numeros = Array.isArray(numero) ? numero.map(n => parseInt(n)) : [parseInt(numero)] // ENGINE DE PRECIFICAÇÃO DINÂMICA const currentPriceData = await PriceRule.getCurrentPrice(raffle_id) const ticketPrice = currentPriceData.price // DESCONTO POR QUANTIDADE const bestDiscount = await RaffleDiscount.getBestForQty(raffle_id, numeros.length) const qtyDiscountPct = bestDiscount ? Number(bestDiscount.discount_pct) : 0 // PROMOÇÃO RELÂMPAGO (aplica o maior entre os dois) const flashPromo = await RaffleFlashPromo.getActive(raffle_id) const flashPct = flashPromo ? Number(flashPromo.discount_pct) : 0 const discountPct = Math.max(qtyDiscountPct, flashPct) const discountType = flashPct >= qtyDiscountPct && flashPct > 0 ? 'flash' : (qtyDiscountPct > 0 ? 'quantity' : 'none') const subtotal = ticketPrice * numeros.length const amount = discountPct > 0 ? Math.round(subtotal * (1 - discountPct / 100) * 100) / 100 : subtotal // 1. Cria Record de Order await Order.create({ id: localOrderId, user_cpf: usuario_cpf.replace(/\D/g, ''), total_amount: amount, payment_method: 'PIX' }) // 2. Tenta fazer a reserva — se falhar, remove a Order criada acima try { await Ticket.reserve({ raffle_id, numeros, usuario_nome, usuario_telefone: usuario_telefone || null, usuario_cpf: usuario_cpf.replace(/\D/g, ''), order_id: localOrderId, preco_pago: ticketPrice }) } catch (reserveErr) { await Order.delete(localOrderId).catch(() => {}) throw reserveErr } // 2.5 Registrar audit log do preço aplicado await PriceRule.logPrice({ raffle_id, order_id: localOrderId, user_cpf: usuario_cpf.replace(/\D/g, ''), price_applied: ticketPrice, rule_id: currentPriceData.rule_id }) // 3. Se chegou aqui, os tickets estão reservados. Vamos chamar a Asaas let asaasPayload = null try { const customerId = await AsaasService.getOrCreateCustomer( usuario_nome, usuario_cpf, usuario_telefone, null ) asaasPayload = await AsaasService.createPixCharge( customerId, amount, `Compra de ${numeros.length} rifa(s) no sorteio #${raffle_id}` ) } catch (asaasErr) { throw new Error('Falha na comunicação com gateway de pagamento: ' + asaasErr.message) } // 4. Salva os IDs e dados Pix asaas await Order.updateAsaasPayment( localOrderId, asaasPayload.orderId, asaasPayload.qrCodeUrl, asaasPayload.pixCopyPaste ) // Fase 7: Dispara notificação PIX Pendente via fila if (usuario_telefone && usuario_telefone.length >= 10) { WhatsappCore.sendNotification({ userId: null, phone: usuario_telefone, type: 'purchase_pending', variables: { pixCopiaECola: asaasPayload.pixCopyPaste, link: asaasPayload.qrCodeUrl } }).catch(console.error) } console.log(`🎟️ Tickets reservado para ${usuario_nome}. Custo: R$${amount.toFixed(2)}${discountPct > 0 ? ` (${discountPct}% off via ${discountType})` : ''} / PIX gerado`) return res.json({ success: true, orderId: localOrderId, number: numero, amount, discountPct, discountType, subtotal, qrCodeUrl: asaasPayload.qrCodeUrl, pixCopyPaste: asaasPayload.pixCopyPaste }) } catch (error) { if (error.message === 'Número já vendido') return res.status(400).json({ error: 'Número já vendido ou reservado' }) if (error.message === 'Sorteio não encontrado') return res.status(404).json({ error: 'Sorteio não encontrado' }) if (error.message === 'Sorteio encerrado') return res.status(400).json({ error: 'Sorteio encerrado' }) if (error.message.includes('Número inválido')) return res.status(400).json({ error: error.message }) if (error.code === '23505') return res.status(400).json({ error: 'Número já vendido ou reservado' }) console.error('Erro em buyTicket:', error) return res.status(500).json({ error: error.message || 'Erro interno' }) } }, async getOrderStatus(req, res) { try { const order = await Order.findById(req.params.id) if (!order) return res.status(404).json({ error: 'Pedido não encontrado' }) return res.json({ status: order.status }) } catch (error) { console.error('Erro ao buscar status do pedido:', error) return res.status(500).json({ error: 'Erro ao buscar status' }) } }, async getTickets(req, res) { try { const tickets = await Ticket.findByRaffle(req.params.raffle_id) return res.json(tickets) } catch (error) { return res.status(500).json({ error: 'Erro interno' }) } }, async getAllTickets(req, res) { try { const tickets = await Ticket.getAll() return res.json(tickets) } catch (error) { return res.status(500).json({ error: 'Erro interno' }) } }, async createRaffle(req, res) { try { const { titulo, descricao, preco_numero, total_numeros, start_at, end_at } = req.body if (!titulo) return res.status(400).json({ error: 'Título é obrigatório' }) const result = await Raffle.create({ titulo, descricao, preco_numero: parseFloat(preco_numero) || 5.0, total_numeros: parseInt(total_numeros) || 500, start_at: start_at || null, end_at: end_at || null, tenant_id: req.tenant?.id || 1, }) return res.json({ success: true, id: result.lastInsertRowid }) } catch (error) { return res.status(500).json({ error: 'Erro interno' }) } }, async deleteTicket(req, res) { try { const tenantId = req.tenant?.id || 1 await Ticket.deleteByIdAndTenant(req.params.id, tenantId) return res.json({ success: true }) } catch (error) { return res.status(500).json({ error: 'Erro interno' }) } }, async getMyTickets(req, res) { try { if (!req.user || !req.user.cpf) { return res.status(401).json({ error: 'Usuário não autenticado' }) } const tickets = await Ticket.findByUserCpf(req.user.cpf) return res.json(tickets) } catch (error) { console.error('Erro ao buscar meus tickets:', error) return res.status(500).json({ error: 'Erro interno' }) } }, async drawRaffle(req, res) { try { const { id } = req.params const ip = req.ip || '127.0.0.1' const result = await RaffleResult.draw(id, ip) return res.json({ success: true, result }) } catch (error) { console.error('Erro no draw:', error) return res.status(400).json({ error: error.message || 'Erro interno ao realizar sorteio' }) } }, async getResult(req, res) { try { const { id } = req.params const result = await RaffleResult.findByRaffleId(id) if (!result) return res.status(404).json({ error: 'Nenhum resultado encontrado para este sorteio' }) return res.json(result) } catch (error) { return res.status(500).json({ error: 'Erro interno' }) } }, // PREÇO DINÂMICO async getCurrentPrice(req, res) { try { const data = await PriceRule.getCurrentPrice(req.params.id) return res.json(data) } catch (error) { if (error.message === 'Sorteio não encontrado') return res.status(404).json({ error: error.message }) return res.status(500).json({ error: 'Erro interno ao consultar preço.' }) } }, async addPriceRule(req, res) { try { const { rule_type, price, start_at, end_at, priority } = req.body if (!rule_type || !price) return res.status(400).json({ error: 'Regra e Preço obrigatórios' }) // Captura preço atual antes de criar a nova regra const before = await PriceRule.getCurrentPrice(req.params.id).catch(() => null) const id = await PriceRule.create({ raffle_id: req.params.id, rule_type, price, start_at, end_at, priority, }) // Dispara notificações de queda de preço se o novo preço for menor if (before && parseFloat(price) < parseFloat(before.price)) { const raffle = await Raffle.findById(req.params.id) if (raffle) { // Broadcast para todos os opt-in NotificationService.notifyPriceDrop(raffle.id, raffle.titulo, price).catch(() => {}) // Notificação localizada para reservas pendentes de pagamento NotificationService.notifyPriceDropLocal(raffle.id, raffle.titulo, price).catch(() => {}) } } return res.json({ success: true, id: id.rows?.[0]?.id || id.lastInsertRowid }) } catch (error) { return res.status(500).json({ error: 'Erro interno ao criar regra de preço.' }) } }, async listPriceRules(req, res) { try { const rules = await PriceRule.listByRaffle(req.params.id) return res.json(rules.rows || rules) } catch (error) { return res.status(500).json({ error: 'Erro interno ao listar regras.' }) } }, async getDiscounts(req, res) { try { const discounts = await RaffleDiscount.listByRaffle(req.params.id) return res.json(discounts) } catch (error) { return res.status(500).json({ error: 'Erro ao listar descontos.' }) } }, async saveDiscounts(req, res) { try { const raffleId = parseInt(req.params.id) const tiers = req.body.tiers if (!Array.isArray(tiers)) return res.status(400).json({ error: 'tiers deve ser um array' }) const valid = tiers.filter(t => Number.isInteger(parseInt(t.min_qty)) && parseInt(t.min_qty) >= 2 && parseFloat(t.discount_pct) > 0 && parseFloat(t.discount_pct) <= 100 ) await RaffleDiscount.deleteByRaffle(raffleId) if (valid.length > 0) await RaffleDiscount.bulkInsert(raffleId, valid) return res.json({ success: true, saved: valid.length }) } catch (error) { return res.status(500).json({ error: 'Erro ao salvar descontos.' }) } }, async getFlashPromo(req, res) { try { const promo = await RaffleFlashPromo.getActive(req.params.id) return res.json(promo || null) } catch (error) { return res.status(500).json({ error: 'Erro ao buscar promoção.' }) } }, async createFlashPromo(req, res) { try { const { discount_pct, duration_minutes, label } = req.body if (!discount_pct || !duration_minutes) { return res.status(400).json({ error: 'discount_pct e duration_minutes são obrigatórios' }) } if (parseFloat(discount_pct) <= 0 || parseFloat(discount_pct) > 100) { return res.status(400).json({ error: 'Percentual inválido (1–100)' }) } if (parseInt(duration_minutes) < 1 || parseInt(duration_minutes) > 1440) { return res.status(400).json({ error: 'Duração inválida (1–1440 minutos)' }) } const promo = await RaffleFlashPromo.create({ raffle_id: parseInt(req.params.id), discount_pct, duration_minutes, label }) return res.json({ success: true, promo: promo.rows?.[0] || promo }) } catch (error) { return res.status(500).json({ error: 'Erro ao criar promoção.' }) } }, async cancelFlashPromo(req, res) { try { await RaffleFlashPromo.cancel(req.params.promo_id) return res.json({ success: true }) } catch (error) { return res.status(500).json({ error: 'Erro ao cancelar promoção.' }) } } } module.exports = raffleController