fix(pagos): permitir pagar plan actual trial_expired y soportar planes >$10k via Preference
- 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.
This commit is contained in:
@@ -3,6 +3,7 @@ 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({
|
||||
@@ -47,7 +48,7 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
|
||||
// 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'] } },
|
||||
where: { tenantId, status: { in: ['authorized', 'pending', 'paused', 'trial', 'trial_expired'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
status: true, amount: true, plan: true,
|
||||
@@ -64,6 +65,18 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
|
||||
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.
|
||||
@@ -72,6 +85,7 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
|
||||
dbMode: tenant.dbMode,
|
||||
trialEndsAt: tenant.trialEndsAt?.toISOString() ?? null,
|
||||
isTrialActive,
|
||||
planPrice,
|
||||
subscription: subscription
|
||||
? {
|
||||
status: subscription.status,
|
||||
|
||||
@@ -184,7 +184,18 @@ export async function subscribeMe(req: Request, res: Response, next: NextFunctio
|
||||
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.)
|
||||
// Errores de negocio de MP (monto fuera de límites, payer igual collector, etc.)
|
||||
if (msg.includes('Cannot pay an amount greater than')) {
|
||||
return res.status(400).json({
|
||||
message: 'El monto del plan supera el límite de cobro recurrente de MercadoPago ($10,000 MXN). Usa el pago anual único o contacta a soporte.',
|
||||
});
|
||||
}
|
||||
if (msg.includes('Payer and collector cannot be the same user')) {
|
||||
return res.status(400).json({
|
||||
message: 'El correo del pagador no puede ser el mismo que el de la cuenta de MercadoPago del vendedor.',
|
||||
});
|
||||
}
|
||||
// Otros errores de MP al crear preapproval/preference
|
||||
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.',
|
||||
|
||||
@@ -174,6 +174,57 @@ async function handlePaymentNotification(paymentId: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detecta pagos únicos de suscripción anual (planes >$10k). external_reference = `subscription:${tenantId}:${subscriptionId}`
|
||||
if (payment.externalReference.startsWith('subscription:')) {
|
||||
const parts = payment.externalReference.split(':');
|
||||
const tenantId = parts[1];
|
||||
const subscriptionId = parts[2];
|
||||
if (!tenantId || !subscriptionId) {
|
||||
console.warn('[WEBHOOK] external_reference de subscription malformado:', payment.externalReference);
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentRecord = await subscriptionService.recordPayment({
|
||||
tenantId,
|
||||
subscriptionId,
|
||||
mpPaymentId: paymentId,
|
||||
amount: payment.transactionAmount || 0,
|
||||
status: payment.status || 'unknown',
|
||||
paymentMethod: payment.paymentMethodId || 'unknown',
|
||||
});
|
||||
|
||||
if (payment.status === 'approved') {
|
||||
const subscription = await prisma.subscription.findUnique({ where: { id: subscriptionId } });
|
||||
if (subscription) {
|
||||
const now = new Date();
|
||||
const periodEnd = computeNextPeriodEnd(now, 'annual');
|
||||
await prisma.$transaction([
|
||||
prisma.subscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: {
|
||||
status: 'authorized',
|
||||
currentPeriodStart: now,
|
||||
currentPeriodEnd: periodEnd,
|
||||
},
|
||||
}),
|
||||
prisma.tenant.update({
|
||||
where: { id: tenantId },
|
||||
data: { plan: subscription.plan },
|
||||
}),
|
||||
]);
|
||||
subscriptionService.invalidateSubscriptionCache(tenantId);
|
||||
console.log(`[WEBHOOK] Suscripción ${subscriptionId} activada por pago único anual hasta ${periodEnd.toISOString()}`);
|
||||
}
|
||||
// Auto-emisión de factura (fail-soft)
|
||||
await invoicingService.emitInvoiceIfApplicable(paymentRecord.id);
|
||||
}
|
||||
|
||||
if (typeof process.send === 'function') {
|
||||
process.send({ type: 'invalidate-tenant-cache', tenantId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Flujo normal: pago recurrente del preapproval
|
||||
const tenantId = payment.externalReference;
|
||||
const subscription = await prisma.subscription.findFirst({
|
||||
|
||||
Reference in New Issue
Block a user