Files
mercado.clube67.com/backend/controllers/raffleController.js
T

409 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (1100)' })
}
if (parseInt(duration_minutes) < 1 || parseInt(duration_minutes) > 1440) {
return res.status(400).json({ error: 'Duração inválida (11440 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