- Expone subscription trial_expired en /despachos/me/plan e incluye planPrice. - Para Business Control/Enterprise (>$10k) genera pago anual único con MP Preference en lugar de preapproval recurrente; el webhook activa 1 año de suscripción. - Muestra CTA de pago en UI cuando la suscripción está trial/trial_expired. - Agrega campo mp_preference_id a subscriptions y mejora mensajes de error MP.
119 lines
4.6 KiB
TypeScript
119 lines
4.6 KiB
TypeScript
import type { Request, Response, NextFunction } from 'express';
|
|
import { z } from 'zod';
|
|
import { signupDespacho } from '../services/despacho.service.js';
|
|
import { AppError } from '../middlewares/error.middleware.js';
|
|
import { prisma } from '../config/database.js';
|
|
import { getPlanPrice } from '../services/payment/subscription.service.js';
|
|
|
|
const signupSchema = z.object({
|
|
despacho: z.object({
|
|
nombre: z.string().min(2, 'Nombre del despacho requerido'),
|
|
regimenFiscal: z.string().optional(),
|
|
codigoPostal: z.string().regex(/^\d{5}$/, 'Código postal inválido').optional(),
|
|
verticalProfile: z.enum(['CONTABLE', 'JURIDICO', 'ARQUITECTURA']),
|
|
// plan y frequency ya no se escogen en el registro — todos empiezan con trial genérico.
|
|
// Se mantienen opcionales para compatibilidad backward con clientes antiguos.
|
|
plan: z.enum(['trial', 'mi_empresa', 'mi_empresa_plus', 'business_control', 'business_cloud']).optional(),
|
|
frequency: z.enum(['monthly', 'annual']).optional(),
|
|
}),
|
|
owner: z.object({
|
|
nombre: z.string().min(2, 'Nombre del owner requerido'),
|
|
email: z.string().email('Email inválido'),
|
|
password: z.string().min(10, 'La contraseña debe tener al menos 10 caracteres'),
|
|
}),
|
|
});
|
|
|
|
export async function getMyPlan(req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
const tenantId = req.user!.tenantId;
|
|
const tenant = await prisma.tenant.findUnique({
|
|
where: { id: tenantId },
|
|
select: { dbMode: true, trialEndsAt: true, verticalProfile: true, plan: true },
|
|
});
|
|
|
|
if (!tenant) {
|
|
return next(new AppError(404, 'Tenant no encontrado'));
|
|
}
|
|
|
|
const now = new Date();
|
|
const isTrialActive = tenant.trialEndsAt ? tenant.trialEndsAt > now : false;
|
|
|
|
// Mapea según trialEndsAt + tenant.plan (no dbMode). dbMode era proxy
|
|
// antes de la introducción de Mi Empresa / Mi Empresa+ — para esos
|
|
// planes, dbMode también es MANAGED y reportar `business_cloud` daba
|
|
// mapeo equivocado. tenant.plan es la fuente de verdad post-migración
|
|
// 20260426073942 (que añadió mi_empresa y mi_empresa_plus al enum).
|
|
//
|
|
// FIX: Si hay una subscription en trial con un plan específico (ej.
|
|
// business_control desde una TrialInvitation), respetamos ese plan
|
|
// para que el feature-gate y los límites funcionen correctamente.
|
|
const subscription = await prisma.subscription.findFirst({
|
|
where: { tenantId, status: { in: ['authorized', 'pending', 'paused', 'trial', 'trial_expired'] } },
|
|
orderBy: { createdAt: 'desc' },
|
|
select: {
|
|
status: true, amount: true, plan: true,
|
|
currentPeriodStart: true, currentPeriodEnd: true,
|
|
},
|
|
});
|
|
|
|
let currentPlan: string;
|
|
if (subscription?.status === 'trial' && subscription.plan && subscription.plan !== 'trial') {
|
|
currentPlan = subscription.plan;
|
|
} else if (isTrialActive) {
|
|
currentPlan = 'trial';
|
|
} else {
|
|
currentPlan = String(tenant.plan);
|
|
}
|
|
|
|
// Precio de catálogo del plan actual (primer año, anual). La UI lo usa
|
|
// cuando la suscripción aún no tiene monto (trial/trial_expired) para
|
|
// mostrar el CTA de pago.
|
|
let planPrice: number | null = null;
|
|
if (currentPlan && currentPlan !== 'trial' && currentPlan !== 'custom') {
|
|
try {
|
|
planPrice = await getPlanPrice(currentPlan as any, 'annual', 'firstYear');
|
|
} catch {
|
|
planPrice = null;
|
|
}
|
|
}
|
|
|
|
// Estado de suscripción activa (si hay) — alimenta la UI con el monto
|
|
// recurrente actual, fecha de próxima renovación y si el primer pago
|
|
// (cuando aplica dualidad firstYear) ya fue completado.
|
|
return res.json({
|
|
plan: currentPlan,
|
|
dbMode: tenant.dbMode,
|
|
trialEndsAt: tenant.trialEndsAt?.toISOString() ?? null,
|
|
isTrialActive,
|
|
planPrice,
|
|
subscription: subscription
|
|
? {
|
|
status: subscription.status,
|
|
plan: subscription.plan,
|
|
amount: Number(subscription.amount),
|
|
currentPeriodStart: subscription.currentPeriodStart?.toISOString() ?? null,
|
|
currentPeriodEnd: subscription.currentPeriodEnd?.toISOString() ?? null,
|
|
}
|
|
: null,
|
|
});
|
|
} catch (error) {
|
|
return next(error);
|
|
}
|
|
}
|
|
|
|
export async function signup(req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
const data = signupSchema.parse(req.body);
|
|
const result = await signupDespacho(data);
|
|
return res.status(201).json(result);
|
|
} catch (error: any) {
|
|
if (error instanceof z.ZodError) {
|
|
return next(new AppError(400, error.errors[0].message));
|
|
}
|
|
if (error.message?.includes('Ya existe')) {
|
|
return next(new AppError(409, error.message));
|
|
}
|
|
return next(error);
|
|
}
|
|
}
|