409 lines
14 KiB
JavaScript
409 lines
14 KiB
JavaScript
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
|