Files
HoruxDespachos/apps/api/src/controllers/subscription.controller.ts
2026-04-27 22:09:36 -06:00

420 lines
14 KiB
TypeScript

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<boolean> {
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<boolean> {
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);
}
}