526 lines
20 KiB
JavaScript
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)
|
|
}
|
|
}
|