420 lines
14 KiB
TypeScript
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);
|
|
}
|
|
}
|