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

526 lines
20 KiB
JavaScript

'use strict'
const BenefitClub = require('../models/BenefitClub')
const ClubMember = require('../models/ClubMember')
const ClubPayment = require('../models/ClubPayment')
const ClubPartner = require('../models/ClubPartner')
const ClubAsaas = require('../services/clubAsaasService')
const ClubNotif = require('../services/clubNotificationService')
const { execute, queryOne, query } = require('../database/postgres')
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 = 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 = 0
return rem === parseInt(cpf.charAt(10))
}
// Formata próxima data de cobrança (hoje + 1 mês)
function nextBillingDate() {
const d = new Date()
d.setMonth(d.getMonth() + 1)
return d.toISOString().slice(0, 10)
}
// ── Público ──────────────────────────────────────────────────────────────────
exports.listClubs = async (req, res) => {
try {
const clubs = await BenefitClub.findAll(req.tenant.id)
for (const c of clubs) {
c.plans = await BenefitClub.getPlans(c.id)
}
res.json(clubs)
} catch (err) {
console.error('[clubController] listClubs:', err.message)
res.status(500).json({ error: 'Erro interno' })
}
}
exports.getClub = async (req, res) => {
try {
const club = await BenefitClub.findBySlug(req.params.slug, req.tenant.id)
if (!club) return res.status(404).json({ error: 'Clube não encontrado' })
club.plans = await BenefitClub.getPlans(club.id)
club.partners = await ClubPartner.findByClub(club.id)
// conditions e rules já vêm do findBySlug (colunas da tabela benefit_clubs)
res.json(club)
} catch (err) {
console.error('[clubController] getClub:', err.message)
res.status(500).json({ error: 'Erro interno' })
}
}
// ── Assinatura ────────────────────────────────────────────────────────────────
exports.subscribe = async (req, res) => {
try {
const { slug } = req.params
const { plan_id, user_cpf, user_nome, user_telefone, user_email } = req.body
if (!plan_id || !user_cpf || !user_nome)
return res.status(400).json({ error: 'Campos obrigatórios: plan_id, user_cpf, user_nome' })
if (!validateCPF(user_cpf)) return res.status(400).json({ error: 'CPF inválido' })
const club = await BenefitClub.findBySlug(slug, req.tenant.id)
if (!club) return res.status(404).json({ error: 'Clube não encontrado' })
const plan = await BenefitClub.getPlanById(plan_id)
if (!plan || plan.club_id !== club.id)
return res.status(404).json({ error: 'Plano não encontrado' })
// Bloqueia apenas se já há assinatura ativa — suspended/pending_payment podem reinscrever
const existing = await ClubMember.findByCpfAndClub(user_cpf, club.id)
if (existing?.status === 'active') return res.status(409).json({ error: 'CPF já possui assinatura ativa neste clube' })
// Cria membro com status pending_payment
const memberResult = await ClubMember.create({
tenant_id: req.tenant.id,
club_id: club.id,
plan_id,
user_cpf,
user_nome,
user_telefone: user_telefone || null,
user_email: user_email || null,
asaas_customer_id: null,
asaas_subscription_id: null,
next_billing_at: null,
})
const memberId = memberResult.rows?.[0]?.id
// Cria cliente + assinatura no Asaas em background (não bloqueia resposta)
const tenantAsaasKey = req.tenant?.asaas_key || process.env.ASAAS_API_KEY
setImmediate(async () => {
try {
const customer = await ClubAsaas.createCustomer({
cpf: user_cpf, name: user_nome, phone: user_telefone, email: user_email,
apiKey: tenantAsaasKey,
})
const subscription = await ClubAsaas.createSubscription({
customerId: customer.id,
value: plan.price_monthly,
description: `${club.name}${plan.name}`,
nextDueDate: nextBillingDate(),
apiKey: tenantAsaasKey,
})
await ClubMember.updateSubscription(memberId, {
asaas_customer_id: customer.id,
asaas_subscription_id: subscription.id,
next_billing_at: subscription.nextDueDate,
})
} catch (e) {
console.error('[clubController] Asaas subscribe error:', e.message)
}
})
// Notificação de boas-vindas
ClubNotif.notifyWelcome(user_cpf, user_nome, user_telefone, club.name, plan.name, req.tenant?.id)
.catch(() => {})
res.status(201).json({ ok: true, member_id: memberId })
} catch (err) {
console.error('[clubController] subscribe:', err.message)
res.status(500).json({ error: 'Erro interno' })
}
}
// ── Validação pública (recepção de clínica) ───────────────────────────────────
exports.validateMember = async (req, res) => {
try {
const cpf = req.params.cpf.replace(/\D/g, '')
const slug = req.params.slug // 'odonto' ou 'medico'
const club = await BenefitClub.findBySlug(slug, req.tenant.id)
if (!club) return res.status(404).json({ valid: false, error: 'Clube não encontrado' })
const member = await ClubMember.findByCpfAndClub(cpf, club.id)
if (!member) return res.json({ valid: false, message: 'CPF não encontrado neste clube' })
if (member.status !== 'active') {
return res.json({
valid: false,
message: `Plano ${member.status === 'suspended' ? 'suspenso por inadimplência' : 'inativo'}`,
status: member.status,
})
}
res.json({
valid: true,
user_nome: member.user_nome,
user_cpf: cpf.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4'),
plan_name: member.plan_name,
club_name: club.name,
next_billing: member.next_billing_at,
status: member.status,
})
} catch (err) {
console.error('[clubController] validateMember:', err.message)
res.status(500).json({ valid: false, error: 'Erro interno' })
}
}
// ── Parceiros por slug ────────────────────────────────────────────────────────
exports.listPartnersBySlug = async (req, res) => {
try {
const club = await BenefitClub.findBySlug(req.params.slug, req.tenant.id)
if (!club) return res.status(404).json({ error: 'Clube não encontrado' })
const partners = await ClubPartner.findByClub(club.id)
res.json(partners || [])
} catch (err) {
res.status(500).json({ error: 'Erro interno' })
}
}
// ── Carteirinha ───────────────────────────────────────────────────────────────
exports.getCard = async (req, res) => {
try {
const cpf = req.params.cpf.replace(/\D/g, '')
if (!validateCPF(cpf)) return res.status(400).json({ error: 'CPF inválido' })
const members = await ClubMember.findAllByCpf(cpf)
if (!members?.length) return res.json([])
res.json(members)
} catch (err) {
console.error('[clubController] getCard:', err.message)
res.status(500).json({ error: 'Erro interno' })
}
}
// ── Cancelamento ──────────────────────────────────────────────────────────────
exports.cancelMembership = async (req, res) => {
try {
const user = req.user
const { member_id } = req.body
const member = await ClubMember.findById(member_id)
if (!member) return res.status(404).json({ error: 'Assinatura não encontrada' })
if (member.user_cpf !== user.cpf?.replace(/\D/g, ''))
return res.status(403).json({ error: 'Acesso negado' })
if (member.asaas_subscription_id) {
await ClubAsaas.cancelSubscription(member.asaas_subscription_id).catch(console.error)
}
await ClubMember.updateStatus(member_id, 'canceled')
res.json({ ok: true })
} catch (err) {
console.error('[clubController] cancelMembership:', err.message)
res.status(500).json({ error: 'Erro interno' })
}
}
// ── Admin ─────────────────────────────────────────────────────────────────────
exports.adminListMembers = async (req, res) => {
try {
const { club_id, status, limit = 100, offset = 0 } = req.query
if (!club_id) return res.status(400).json({ error: 'club_id obrigatório' })
const members = await ClubMember.listByClub(club_id, { status, limit: +limit, offset: +offset })
res.json(members)
} catch (err) {
console.error('[clubController] adminListMembers:', err.message)
res.status(500).json({ error: 'Erro interno' })
}
}
exports.adminUpdateMemberStatus = async (req, res) => {
try {
const { id } = req.params
const { status } = req.body
const allowed = ['active', 'suspended', 'canceled', 'pending_payment']
if (!allowed.includes(status))
return res.status(400).json({ error: 'Status inválido' })
const member = await ClubMember.findById(id)
if (!member) return res.status(404).json({ error: 'Membro não encontrado' })
// Sincroniza no Asaas se houver assinatura
if (member.asaas_subscription_id) {
if (status === 'canceled')
await ClubAsaas.cancelSubscription(member.asaas_subscription_id).catch(console.error)
else if (status === 'suspended')
await ClubAsaas.suspendSubscription(member.asaas_subscription_id).catch(console.error)
else if (status === 'active' && member.status === 'suspended')
await ClubAsaas.reactivateSubscription(member.asaas_subscription_id).catch(console.error)
}
await ClubMember.updateStatus(id, status)
res.json({ ok: true })
} catch (err) {
console.error('[clubController] adminUpdateMemberStatus:', err.message)
res.status(500).json({ error: 'Erro interno' })
}
}
exports.adminListPartners = async (req, res) => {
try {
const { club_id } = req.query
if (!club_id) return res.status(400).json({ error: 'club_id obrigatório' })
res.json(await ClubPartner.findByClub(club_id))
} catch (err) {
res.status(500).json({ error: 'Erro interno' })
}
}
exports.adminCreatePartner = async (req, res) => {
try {
const result = await ClubPartner.create({ ...req.body, tenant_id: req.tenant.id })
res.status(201).json({ id: result.rows?.[0]?.id })
} catch (err) {
res.status(500).json({ error: 'Erro interno' })
}
}
exports.adminUpdatePartner = async (req, res) => {
try {
await ClubPartner.update(req.params.id, req.body)
res.json({ ok: true })
} catch (err) {
res.status(500).json({ error: 'Erro interno' })
}
}
exports.adminDeletePartner = async (req, res) => {
try {
await ClubPartner.delete(req.params.id)
res.json({ ok: true })
} catch (err) {
res.status(500).json({ error: 'Erro interno' })
}
}
// ── Admin — Planos ────────────────────────────────────────────────────────────
exports.adminListPlans = async (req, res) => {
try {
const { club_id } = req.query
if (!club_id) return res.status(400).json({ error: 'club_id obrigatório' })
const plans = await query(
`SELECT * FROM club_plans WHERE club_id=$1 ORDER BY id ASC`,
[club_id]
)
res.json(plans || [])
} catch (err) {
console.error('[clubController] adminListPlans:', err.message)
res.status(500).json({ error: 'Erro interno' })
}
}
exports.adminCreatePlan = async (req, res) => {
try {
const { club_id, name, description, price_monthly, max_dependents, features } = req.body
if (!club_id || !name || price_monthly == null)
return res.status(400).json({ error: 'Campos obrigatórios: club_id, name, price_monthly' })
const result = await execute(
`INSERT INTO club_plans (club_id, name, description, price_monthly, max_dependents, features)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
[club_id, name, description || null, price_monthly, max_dependents || 0, JSON.stringify(features || [])]
)
res.status(201).json({ id: result.rows?.[0]?.id })
} catch (err) {
console.error('[clubController] adminCreatePlan:', err.message)
res.status(500).json({ error: 'Erro interno' })
}
}
exports.adminUpdatePlan = async (req, res) => {
try {
const { id } = req.params
const { name, description, price_monthly, max_dependents, features, active } = req.body
await execute(
`UPDATE club_plans SET
name=$1, description=$2, price_monthly=$3, max_dependents=$4, features=$5, active=$6
WHERE id=$7`,
[name, description || null, price_monthly, max_dependents ?? 0, JSON.stringify(features || []), active ?? true, id]
)
res.json({ ok: true })
} catch (err) {
console.error('[clubController] adminUpdatePlan:', err.message)
res.status(500).json({ error: 'Erro interno' })
}
}
exports.adminDeletePlan = async (req, res) => {
try {
const { id } = req.params
// Verifica se há membros ativos neste plano
const active = await queryOne(
`SELECT id FROM club_members WHERE plan_id=$1 AND status IN ('active','pending_payment','suspended') LIMIT 1`,
[id]
)
if (active) return res.status(409).json({ error: 'Plano possui membros ativos. Desative-o primeiro.' })
await execute(`DELETE FROM club_plans WHERE id=$1`, [id])
res.json({ ok: true })
} catch (err) {
console.error('[clubController] adminDeletePlan:', err.message)
res.status(500).json({ error: 'Erro interno' })
}
}
// ── Admin — Conteúdo (condições + regras) ────────────────────────────────────
exports.adminSaveContent = async (req, res) => {
try {
const { id } = req.params
const tid = req.tenant?.id || req.admin?.tenant_id
const { conditions, rules } = req.body
const result = await execute(
`UPDATE benefit_clubs SET conditions=$1, rules=$2 WHERE id=$3 AND tenant_id=$4`,
[conditions || null, rules || null, id, tid]
)
if (result.rowCount === 0) return res.status(404).json({ error: 'Clube não encontrado' })
res.json({ ok: true })
} catch (err) {
console.error('[clubController] adminSaveContent:', err.message)
res.status(500).json({ error: 'Erro interno' })
}
}
// ── Public — FAQs por slug ────────────────────────────────────────────────────
exports.listFaqsBySlug = async (req, res) => {
try {
const club = await BenefitClub.findBySlug(req.params.slug, req.tenant.id)
if (!club) return res.status(404).json({ error: 'Clube não encontrado' })
const faqs = await query(
`SELECT id, question, answer, sort_order FROM club_faqs
WHERE club_id=$1 AND active=true ORDER BY sort_order ASC, id ASC`,
[club.id]
)
res.json(faqs || [])
} catch (err) {
console.error('[clubController] listFaqsBySlug:', err.message)
res.status(500).json({ error: 'Erro interno' })
}
}
// ── Admin — FAQs ─────────────────────────────────────────────────────────────
exports.adminListFaqs = async (req, res) => {
try {
const { club_id } = req.query
if (!club_id) return res.status(400).json({ error: 'club_id obrigatório' })
const faqs = await query(
`SELECT * FROM club_faqs WHERE club_id=$1 ORDER BY sort_order ASC, id ASC`,
[club_id]
)
res.json(faqs || [])
} catch (err) {
console.error('[clubController] adminListFaqs:', err.message)
res.status(500).json({ error: 'Erro interno' })
}
}
exports.adminCreateFaq = async (req, res) => {
try {
const { club_id, question, answer, sort_order, active } = req.body
if (!club_id || !question || !answer)
return res.status(400).json({ error: 'Campos obrigatórios: club_id, question, answer' })
const result = await execute(
`INSERT INTO club_faqs (club_id, tenant_id, question, answer, sort_order, active)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
[club_id, req.tenant.id, question, answer, sort_order ?? 0, active ?? true]
)
res.status(201).json({ id: result.rows?.[0]?.id })
} catch (err) {
console.error('[clubController] adminCreateFaq:', err.message)
res.status(500).json({ error: 'Erro interno' })
}
}
exports.adminUpdateFaq = async (req, res) => {
try {
const { id } = req.params
const { question, answer, sort_order, active } = req.body
await execute(
`UPDATE club_faqs SET question=$1, answer=$2, sort_order=$3, active=$4 WHERE id=$5`,
[question, answer, sort_order ?? 0, active ?? true, id]
)
res.json({ ok: true })
} catch (err) {
console.error('[clubController] adminUpdateFaq:', err.message)
res.status(500).json({ error: 'Erro interno' })
}
}
exports.adminDeleteFaq = async (req, res) => {
try {
await execute(`DELETE FROM club_faqs WHERE id=$1`, [req.params.id])
res.json({ ok: true })
} catch (err) {
console.error('[clubController] adminDeleteFaq:', err.message)
res.status(500).json({ error: 'Erro interno' })
}
}
// ── Webhook Asaas (clube) ─────────────────────────────────────────────────────
const ASAAS_WEBHOOK_TOKEN = process.env.ASAAS_WEBHOOK_TOKEN
exports.asaasClubWebhook = async (req, res) => {
try {
if (!ASAAS_WEBHOOK_TOKEN) {
console.error('[clubController] ASAAS_WEBHOOK_TOKEN não configurado')
return res.status(500).json({ error: 'Configuração incompleta' })
}
const incomingToken = req.headers['asaas-access-token']
if (!incomingToken || incomingToken !== ASAAS_WEBHOOK_TOKEN) {
console.warn('[clubController] asaasClubWebhook: token inválido rejeitado')
return res.status(401).json({ error: 'Não autorizado' })
}
const { event, payment } = req.body
if (!payment?.subscription) return res.sendStatus(200)
const member = await ClubMember.findBySubscriptionId(payment.subscription)
if (!member) return res.sendStatus(200)
if (event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED') {
await ClubMember.updateStatus(member.id, 'active')
await ClubPayment.create({
member_id: member.id,
plan_id: member.plan_id,
amount: payment.value,
status: 'paid',
asaas_payment_id: payment.id,
reference_month: payment.dueDate?.slice(0, 7) || null,
})
// Atualiza próxima cobrança
if (payment.billingType) {
await ClubMember.updateSubscription(member.id, {
asaas_subscription_id: member.asaas_subscription_id,
asaas_customer_id: member.asaas_customer_id,
next_billing_at: payment.originalDueDate || null,
})
}
ClubNotif.notifyPaymentConfirmed(
member.user_cpf, member.user_nome, member.user_telefone, member.club_name, member.tenant_id
).catch(() => {})
}
if (event === 'PAYMENT_OVERDUE') {
await ClubMember.updateStatus(member.id, 'suspended')
ClubNotif.notifyPaymentOverdue(
member.user_cpf, member.user_nome, member.user_telefone, member.club_name, member.tenant_id
).catch(() => {})
}
if (event === 'PAYMENT_DELETED' || event === 'SUBSCRIPTION_DELETED') {
await ClubMember.updateStatus(member.id, 'canceled')
}
res.sendStatus(200)
} catch (err) {
console.error('[clubController] asaasClubWebhook:', err.message)
res.sendStatus(500)
}
}