import type { Request, Response, NextFunction } from 'express'; import * as subscriptionService from '../services/payment/subscription.service.js'; import { listActiveAddons, subscribeAddon, cancelAddon } from '../services/payment/addon.service.js'; import { isGlobalAdmin } from '../utils/global-admin.js'; import { auditFromReq } from '../utils/audit.js'; async function requireGlobalAdmin(req: Request, res: Response): Promise { const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role); if (!isAdmin) { res.status(403).json({ message: 'Solo el administrador global puede gestionar suscripciones' }); } return isAdmin; } /** * Permite si el usuario es admin global O si está consultando su propio tenant. * Úsalo para endpoints de lectura/acción sobre la suscripción del mismo tenant * del usuario (ver estado, generar link de pago pendiente). */ async function requireOwnTenantOrGlobalAdmin(req: Request, res: Response, targetTenantId: string): Promise { if (targetTenantId === req.user!.tenantId) return true; const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role); if (!isAdmin) { res.status(403).json({ message: 'Solo puedes gestionar la suscripción de tu propio tenant' }); } return isAdmin; } /** * Devuelve los precios vigentes de los planes self-serve (excluye custom). * Cualquier admin/cfo puede consultarlo — no requiere admin global. */ export async function getPlans(_req: Request, res: Response, next: NextFunction) { try { const { prisma } = await import('../config/database.js'); const prices = await prisma.planPrice.findMany({ orderBy: [{ plan: 'asc' }, { frequency: 'asc' }], }); res.json(prices); } catch (error) { next(error); } } /** * Actualiza el precio de un plan (por id). Solo admin global. * El nuevo precio aplica solo a suscripciones **nuevas o renovaciones futuras** * — suscripciones ya vigentes conservan el precio al que las contrataron. */ export async function updatePlanPrice(req: Request, res: Response, next: NextFunction) { try { if (!(await requireGlobalAdmin(req, res))) return; const id = parseInt(String(req.params.id), 10); if (!Number.isFinite(id)) { return res.status(400).json({ message: 'ID inválido' }); } const amount = Number(req.body?.amount); if (!Number.isFinite(amount) || amount < 0) { return res.status(400).json({ message: 'El monto debe ser un número no negativo' }); } const { prisma } = await import('../config/database.js'); const before = await prisma.planPrice.findUnique({ where: { id } }); if (!before) return res.status(404).json({ message: 'Precio no encontrado' }); const updated = await prisma.planPrice.update({ where: { id }, data: { amount }, }); auditFromReq(req, 'price.updated', { entityType: 'PlanPrice', entityId: String(id), metadata: { plan: before.plan, frequency: before.frequency, fromAmount: Number(before.amount), toAmount: amount, }, }); res.json(updated); } catch (error: any) { if (error?.code === 'P2025') { return res.status(404).json({ message: 'Precio no encontrado' }); } next(error); } } export async function getAllSubscriptions(req: Request, res: Response, next: NextFunction) { try { if (!(await requireGlobalAdmin(req, res))) return; const { prisma } = await import('../config/database.js'); const subscriptions = await prisma.subscription.findMany({ include: { tenant: { select: { id: true, nombre: true, rfc: true, plan: true, active: true }, }, }, orderBy: { createdAt: 'desc' }, }); res.json(subscriptions); } catch (error) { next(error); } } export async function getSubscription(req: Request, res: Response, next: NextFunction) { try { const tenantId = String(req.params.tenantId); if (!(await requireOwnTenantOrGlobalAdmin(req, res, tenantId))) return; const subscription = await subscriptionService.getActiveSubscription(tenantId); if (!subscription) { return res.status(404).json({ message: 'No se encontró suscripción' }); } res.json(subscription); } catch (error) { next(error); } } export async function generatePaymentLink(req: Request, res: Response, next: NextFunction) { try { const tenantId = String(req.params.tenantId); if (!(await requireOwnTenantOrGlobalAdmin(req, res, tenantId))) return; const result = await subscriptionService.generatePaymentLink(tenantId); res.json(result); } catch (error) { next(error); } } export async function markAsPaid(req: Request, res: Response, next: NextFunction) { try { if (!(await requireGlobalAdmin(req, res))) return; const tenantId = String(req.params.tenantId); const { amount } = req.body; if (!amount || amount <= 0) { return res.status(400).json({ message: 'Monto inválido' }); } const payment = await subscriptionService.markAsPaidManually(tenantId, amount); res.json(payment); } catch (error) { next(error); } } export async function getPayments(req: Request, res: Response, next: NextFunction) { try { const tenantId = String(req.params.tenantId); if (!(await requireOwnTenantOrGlobalAdmin(req, res, tenantId))) return; const payments = await subscriptionService.getPaymentHistory(tenantId); res.json(payments); } catch (error) { next(error); } } // ============================================================================ // Self-serve endpoints (actúan sobre el tenant del usuario autenticado) // ============================================================================ type FrequencyInput = 'monthly' | 'annual'; const VALID_PLANS = [ 'starter', 'business', 'business_ia', 'enterprise', 'business_control', 'business_cloud', 'mi_empresa', 'mi_empresa_plus', ] as const; // Planes despacho que se cobran SOLO anual. Mi Empresa y Mi Empresa+ aceptan // monthly o annual (annual con descuento ~17% — paga 10 meses); Business // Control y Enterprise siguen exclusivamente anuales. const DESPACHO_ONLY_ANNUAL = new Set([ 'business_control', 'business_cloud', ]); function validatePlanFrequency(body: any): { plan: typeof VALID_PLANS[number]; frequency: FrequencyInput } | { error: string } { const plan = body?.plan; const frequency = body?.frequency; if (!plan || !VALID_PLANS.includes(plan)) { return { error: `plan inválido. Valores aceptados: ${VALID_PLANS.join(', ')}` }; } if (frequency !== 'monthly' && frequency !== 'annual') { return { error: `frequency inválida. Debe ser 'monthly' o 'annual'` }; } if (DESPACHO_ONLY_ANNUAL.has(plan) && frequency !== 'annual') { return { error: `El plan ${plan} solo está disponible con frecuencia anual` }; } return { plan, frequency }; } export async function startMyTrial(req: Request, res: Response, next: NextFunction) { try { const parsed = validatePlanFrequency(req.body); if ('error' in parsed) return res.status(400).json({ message: parsed.error }); const result = await subscriptionService.startTrial({ tenantId: req.user!.tenantId, plan: parsed.plan, frequency: parsed.frequency, ownerUserId: req.user!.userId, }); res.status(201).json(result); } catch (error: any) { if ( error.message?.includes('ya usó') || error.message?.includes('Ya existe') || error.message?.includes('no se puede') || error.message?.includes('Ya consumiste') || error.message?.includes('ya consumió') ) { return res.status(400).json({ message: error.message }); } next(error); } } export async function subscribeMe(req: Request, res: Response, next: NextFunction) { try { const parsed = validatePlanFrequency(req.body); if ('error' in parsed) return res.status(400).json({ message: parsed.error }); const result = await subscriptionService.subscribe({ tenantId: req.user!.tenantId, plan: parsed.plan, frequency: parsed.frequency, payerEmail: req.user!.email, }); res.status(201).json(result); } catch (error: any) { const msg: string = error?.message || ''; if (msg.includes('Ya existe') || msg.includes('custom')) { return res.status(400).json({ message: msg }); } if (msg.includes('MercadoPago no está configurado')) { return res.status(503).json({ message: msg }); } // Otros errores de MP al crear preapproval (monto inválido, email inválido, etc.) if (msg.includes('Unauthorized access') || error?.status === 401) { return res.status(503).json({ message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido y esté vigente.', }); } next(error); } } export async function changeMyPlan(req: Request, res: Response, next: NextFunction) { try { const parsed = validatePlanFrequency(req.body); if ('error' in parsed) return res.status(400).json({ message: parsed.error }); const result = await subscriptionService.scheduleChange({ tenantId: req.user!.tenantId, newPlan: parsed.plan, newFrequency: parsed.frequency, }); res.json(result); } catch (error: any) { if (error.message?.includes('iguales') || error.message?.includes('No hay') || error.message?.includes('custom')) { return res.status(400).json({ message: error.message }); } next(error); } } export async function cancelMySubscription(req: Request, res: Response, next: NextFunction) { try { const result = await subscriptionService.cancelSubscription(req.user!.tenantId); res.json(result); } catch (error: any) { if (error.message?.includes('No hay')) { return res.status(400).json({ message: error.message }); } next(error); } } /** * Reactiva suscripción cancelada que aún está dentro de su período pagado. * Crea un preapproval nuevo en MP con start_date al final del período actual. * Retorna paymentUrl para que el usuario autorice. */ export async function reactivateMe(req: Request, res: Response, next: NextFunction) { try { const result = await subscriptionService.reactivateSubscription({ tenantId: req.user!.tenantId, payerEmail: req.user!.email, }); res.status(201).json(result); } catch (error: any) { const msg: string = error?.message || ''; if (msg.includes('No hay') || msg.includes('vencido') || msg.includes('custom')) { return res.status(400).json({ message: msg }); } if (msg.includes('MercadoPago no está configurado')) { return res.status(503).json({ message: msg }); } if (msg.includes('Unauthorized access') || error?.status === 401) { return res.status(503).json({ message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido.', }); } next(error); } } /** * Inicia un upgrade con cobro prorateado inmediato. Body: `{ plan }`. * La frecuencia actual se preserva — para cambiar frecuencia usa `/me/change`. * Retorna `{ checkoutUrl, proratedAmount }` — el cliente debe abrir la URL para que * el usuario pague en MP. Al confirmarse el pago (webhook), se aplica el plan nuevo. */ export async function upgradeMe(req: Request, res: Response, next: NextFunction) { try { const plan = req.body?.plan; if (!plan || !VALID_PLANS.includes(plan)) { return res.status(400).json({ message: `plan inválido. Valores: ${VALID_PLANS.join(', ')}` }); } const result = await subscriptionService.initiateUpgrade({ tenantId: req.user!.tenantId, newPlan: plan, payerEmail: req.user!.email, }); res.status(201).json(result); } catch (error: any) { const msg: string = error?.message || ''; if ( msg.includes('No hay suscripción') || msg.includes('en curso') || msg.includes('no es un upgrade') || msg.includes('días restantes') || msg.includes('custom') || msg.includes('Precio no configurado') ) { return res.status(400).json({ message: msg }); } if (msg.includes('MercadoPago no está configurado')) { return res.status(503).json({ message: msg }); } if (msg.includes('Unauthorized access') || error?.status === 401) { return res.status(503).json({ message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido.', }); } next(error); } } export async function cancelMyPendingUpgrade(req: Request, res: Response, next: NextFunction) { try { await subscriptionService.cancelPendingUpgrade(req.user!.tenantId); res.json({ ok: true }); } catch (error: any) { if (error.message?.includes('No hay upgrade')) { return res.status(400).json({ message: error.message }); } next(error); } } // ============================================================================ // Addon endpoints (self-serve) // ============================================================================ export async function getMyAddons(req: Request, res: Response, next: NextFunction) { try { // Query param `contribuyenteId` opcional: filtra al contribuyente específico. // Sin param → retorna todos los add-ons del tenant (incluye los de todos los RFCs). const contribuyenteId = typeof req.query.contribuyenteId === 'string' && req.query.contribuyenteId ? req.query.contribuyenteId : undefined; const result = await listActiveAddons(req.user!.tenantId, contribuyenteId); return res.json(result); } catch (err) { return next(err); } } export async function addMyAddon(req: Request, res: Response, next: NextFunction) { try { const { addonCodename, quantity, contribuyenteId } = req.body; if (!addonCodename) return res.status(400).json({ message: 'addonCodename requerido' }); const result = await subscribeAddon({ tenantId: req.user!.tenantId, addonCodename, quantity: quantity || 1, payerEmail: req.user!.email, contribuyenteId: typeof contribuyenteId === 'string' ? contribuyenteId : null, }); return res.status(201).json(result); } catch (err: any) { if (err.message?.includes('no disponible') || err.message?.includes('Ya tienes')) { return res.status(409).json({ message: err.message }); } return next(err); } } export async function cancelMyAddon(req: Request, res: Response, next: NextFunction) { try { await cancelAddon(req.user!.tenantId, String(req.params.addonId)); return res.json({ message: 'Addon cancelado' }); } catch (err: any) { if (err.message?.includes('no encontrado')) { return res.status(404).json({ message: err.message }); } return next(err); } }