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:
Horux Dev
2026-06-16 22:37:11 +00:00
parent 63908f9e9d
commit 3f3253d41b
8 changed files with 290 additions and 22 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "subscriptions" ADD COLUMN "mp_preference_id" TEXT;

View File

@@ -358,6 +358,7 @@ model Subscription {
tenantId String @map("tenant_id") tenantId String @map("tenant_id")
plan Plan plan Plan
mpPreapprovalId String? @map("mp_preapproval_id") mpPreapprovalId String? @map("mp_preapproval_id")
mpPreferenceId String? @map("mp_preference_id")
status String @default("pending") status String @default("pending")
amount Decimal @db.Decimal(10, 2) amount Decimal @db.Decimal(10, 2)
frequency String @default("monthly") frequency String @default("monthly")

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
import { signupDespacho } from '../services/despacho.service.js'; import { signupDespacho } from '../services/despacho.service.js';
import { AppError } from '../middlewares/error.middleware.js'; import { AppError } from '../middlewares/error.middleware.js';
import { prisma } from '../config/database.js'; import { prisma } from '../config/database.js';
import { getPlanPrice } from '../services/payment/subscription.service.js';
const signupSchema = z.object({ const signupSchema = z.object({
despacho: 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 // business_control desde una TrialInvitation), respetamos ese plan
// para que el feature-gate y los límites funcionen correctamente. // para que el feature-gate y los límites funcionen correctamente.
const subscription = await prisma.subscription.findFirst({ 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' }, orderBy: { createdAt: 'desc' },
select: { select: {
status: true, amount: true, plan: true, status: true, amount: true, plan: true,
@@ -64,6 +65,18 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
currentPlan = String(tenant.plan); 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 // 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 // recurrente actual, fecha de próxima renovación y si el primer pago
// (cuando aplica dualidad firstYear) ya fue completado. // (cuando aplica dualidad firstYear) ya fue completado.
@@ -72,6 +85,7 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
dbMode: tenant.dbMode, dbMode: tenant.dbMode,
trialEndsAt: tenant.trialEndsAt?.toISOString() ?? null, trialEndsAt: tenant.trialEndsAt?.toISOString() ?? null,
isTrialActive, isTrialActive,
planPrice,
subscription: subscription subscription: subscription
? { ? {
status: subscription.status, status: subscription.status,

View File

@@ -184,7 +184,18 @@ export async function subscribeMe(req: Request, res: Response, next: NextFunctio
if (msg.includes('MercadoPago no está configurado')) { if (msg.includes('MercadoPago no está configurado')) {
return res.status(503).json({ message: msg }); 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) { if (msg.includes('Unauthorized access') || error?.status === 401) {
return res.status(503).json({ return res.status(503).json({
message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido y esté vigente.', message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido y esté vigente.',

View File

@@ -174,6 +174,57 @@ async function handlePaymentNotification(paymentId: string) {
return; 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 // Flujo normal: pago recurrente del preapproval
const tenantId = payment.externalReference; const tenantId = payment.externalReference;
const subscription = await prisma.subscription.findFirst({ const subscription = await prisma.subscription.findFirst({

View File

@@ -25,6 +25,9 @@ const preApprovalClient = new PreApproval(config);
const paymentClient = new MPPayment(config); const paymentClient = new MPPayment(config);
const preferenceClient = new Preference(config); const preferenceClient = new Preference(config);
/** Límite de la API legacy de preapproval de MercadoPago para MXN. */
export const MP_PREAPPROVAL_MAX_AMOUNT = 10000;
/** /**
* Fallback público para `back_url` cuando `FRONTEND_URL` apunta a localhost. * Fallback público para `back_url` cuando `FRONTEND_URL` apunta a localhost.
* MercadoPago rechaza URLs `http://localhost...` o cualquier dominio no * MercadoPago rechaza URLs `http://localhost...` o cualquier dominio no
@@ -227,6 +230,51 @@ export async function createProrationPreference(params: {
}; };
} }
/**
* Crea una Preference (checkout de pago único) para el pago anual de una
* suscripción. Se usa cuando el monto supera el límite de preapproval ($10k).
* external_reference = `subscription:{tenantId}:{subscriptionId}` para que el
* webhook active el período anual al aprobarse.
*/
export async function createSubscriptionPreference(params: {
tenantId: string;
subscriptionId: string;
plan: string;
amount: number;
payerEmail: string;
}): Promise<{ preferenceId: string; checkoutUrl: string }> {
if (!env.MP_ACCESS_TOKEN) {
throw new Error('MercadoPago no está configurado (falta MP_ACCESS_TOKEN en .env).');
}
const response = await preferenceClient.create({
body: {
items: [
{
id: `subscription-${params.subscriptionId}`,
title: `Horux360 - Plan ${params.plan} - Año completo`,
quantity: 1,
unit_price: params.amount,
currency_id: 'MXN',
},
],
payer: { email: resolvePayerEmail(params.payerEmail) },
external_reference: `subscription:${params.tenantId}:${params.subscriptionId}`,
back_urls: {
success: `${backUrlBase()}/configuracion/suscripcion?subscription=success`,
failure: `${backUrlBase()}/configuracion/suscripcion?subscription=failure`,
pending: `${backUrlBase()}/configuracion/suscripcion?subscription=pending`,
},
auto_return: 'approved',
},
});
return {
preferenceId: response.id!,
checkoutUrl: response.init_point!,
};
}
/** /**
* Crea una Preference (checkout de pago único) para comprar un paquete de * Crea una Preference (checkout de pago único) para comprar un paquete de
* timbres adicionales. external_reference = `timbres-pack:${paymentId}` para * timbres adicionales. external_reference = `timbres-pack:${paymentId}` para

View File

@@ -243,25 +243,76 @@ export async function generatePaymentLink(tenantId: string) {
const ownerEmail = await getTenantOwnerEmail(tenantId); const ownerEmail = await getTenantOwnerEmail(tenantId);
if (!ownerEmail) throw new Error('No admin user found'); if (!ownerEmail) throw new Error('No admin user found');
const subscription = await getActiveSubscription(tenantId); let subscription = await getActiveSubscription(tenantId);
const plan = subscription?.plan || tenant.plan; const plan = (subscription?.plan || tenant.plan) as Plan;
const amount = subscription?.amount || 0; if (plan === 'custom' || plan === 'trial') {
throw new Error('No se puede generar link de pago para el plan actual');
}
if (!amount) throw new Error('No se encontró monto de suscripción'); const frequency = (subscription?.frequency as Frequency) || 'annual';
let amount = subscription?.amount ? Number(subscription.amount) : 0;
if (!amount) {
amount = await getPlanPrice(plan, frequency, 'firstYear');
}
// Los planes Business Control / Enterprise exceden el límite de cobro recurrente
// de MercadoPago ($10k). Para esos montos usamos una Preference de pago único
// anual; el webhook activa el período de 1 año al aprobarse.
if (amount > mpService.MP_PREAPPROVAL_MAX_AMOUNT) {
if (!subscription) {
subscription = await prisma.subscription.create({
data: {
tenantId,
plan: plan as any,
status: 'pending',
amount,
frequency,
},
});
invalidateSubscriptionCache(tenantId);
}
const mp = await mpService.createSubscriptionPreference({
tenantId,
subscriptionId: subscription.id,
plan,
amount,
payerEmail: ownerEmail,
});
await prisma.subscription.update({
where: { id: subscription.id },
data: { mpPreferenceId: mp.preferenceId, status: 'pending', amount },
});
return { paymentUrl: mp.checkoutUrl };
}
const mp = await mpService.createPreapproval({ const mp = await mpService.createPreapproval({
tenantId, tenantId,
reason: `Horux360 - Plan ${plan} - ${tenant.nombre}`, reason: `Horux360 - Plan ${plan} - ${tenant.nombre}`,
amount, amount,
payerEmail: ownerEmail, payerEmail: ownerEmail,
frequency,
}); });
// Update subscription with new MP preapproval ID
if (subscription) { if (subscription) {
await prisma.subscription.update({ await prisma.subscription.update({
where: { id: subscription.id }, where: { id: subscription.id },
data: { mpPreapprovalId: mp.preapprovalId }, data: { mpPreapprovalId: mp.preapprovalId, status: mp.status || 'pending' },
}); });
} else {
await prisma.subscription.create({
data: {
tenantId,
plan: plan as any,
status: mp.status || 'pending',
amount,
frequency,
mpPreapprovalId: mp.preapprovalId,
},
});
invalidateSubscriptionCache(tenantId);
} }
return { paymentUrl: mp.initPoint }; return { paymentUrl: mp.initPoint };
@@ -462,6 +513,54 @@ export async function subscribe(params: {
? `${tenant.nombre} - Plan ${params.plan} - $${amount.toLocaleString('es-MX')} primer año, $${renewalAmount.toLocaleString('es-MX')} renovaciones` ? `${tenant.nombre} - Plan ${params.plan} - $${amount.toLocaleString('es-MX')} primer año, $${renewalAmount.toLocaleString('es-MX')} renovaciones`
: `Horux360 - Plan ${params.plan} (${params.frequency}) - ${tenant.nombre}`; : `Horux360 - Plan ${params.plan} (${params.frequency}) - ${tenant.nombre}`;
// Planes Business Control / Enterprise superan el límite de cobro recurrente
// de MercadoPago ($10k). Se cobra el año completo vía Preference one-off; el
// webhook activa el período anual tras el primer pago aprobado.
if (amount > mpService.MP_PREAPPROVAL_MAX_AMOUNT) {
const subscription = await prisma.subscription.create({
data: {
tenantId: params.tenantId,
plan: params.plan,
status: 'pending',
amount,
frequency: params.frequency,
},
});
const mp = await mpService.createSubscriptionPreference({
tenantId: params.tenantId,
subscriptionId: subscription.id,
plan: params.plan,
amount,
payerEmail: params.payerEmail,
});
await prisma.subscription.update({
where: { id: subscription.id },
data: { mpPreferenceId: mp.preferenceId },
});
await prisma.subscription.updateMany({
where: { tenantId: params.tenantId, status: 'trial' },
data: { status: 'trial_converted' },
});
await prisma.tenant.update({
where: { id: params.tenantId },
data: { plan: params.plan },
});
invalidateSubscriptionCache(params.tenantId);
auditLog({
tenantId: params.tenantId,
action: 'subscription.created',
entityType: 'Subscription',
entityId: subscription.id,
metadata: { plan: params.plan, frequency: params.frequency, amount, paymentMethod: 'preference' },
});
return { subscription, paymentUrl: mp.checkoutUrl };
}
const mp = await mpService.createPreapproval({ const mp = await mpService.createPreapproval({
tenantId: params.tenantId, tenantId: params.tenantId,
reason, reason,
@@ -637,13 +736,20 @@ export async function applyApprovedUpgrade(subscriptionId: string): Promise<void
const newPlan = sub.upgradeTargetPlan as Plan; const newPlan = sub.upgradeTargetPlan as Plan;
const newAmount = Number(sub.upgradeTargetAmount); const newAmount = Number(sub.upgradeTargetAmount);
// Actualiza el monto del preapproval en MP (si existe) // Actualiza el monto del preapproval en MP (si existe). Si el nuevo monto
// supera el límite de cobro recurrente de MP ($10k), cancelamos el preapproval
// anterior: el plan alto se cobrará anualmente vía Preference one-off.
if (sub.mpPreapprovalId) { if (sub.mpPreapprovalId) {
try { if (newAmount > mpService.MP_PREAPPROVAL_MAX_AMOUNT) {
await mpService.updatePreapprovalAmount(sub.mpPreapprovalId, newAmount); await mpService.cancelPreapproval(sub.mpPreapprovalId);
} catch (error: any) { console.log(`[Upgrade] Preapproval ${sub.mpPreapprovalId} cancelado porque el nuevo monto $${newAmount} supera el límite de MP`);
console.error(`[Upgrade] Error actualizando preapproval ${sub.mpPreapprovalId}:`, error.message); } else {
throw error; // Re-lanza para que MP reintente el webhook try {
await mpService.updatePreapprovalAmount(sub.mpPreapprovalId, newAmount);
} catch (error: any) {
console.error(`[Upgrade] Error actualizando preapproval ${sub.mpPreapprovalId}:`, error.message);
throw error; // Re-lanza para que MP reintente el webhook
}
} }
} }

View File

@@ -24,6 +24,7 @@ interface PlanInfo {
dbMode: string; dbMode: string;
trialEndsAt: string | null; trialEndsAt: string | null;
isTrialActive: boolean; isTrialActive: boolean;
planPrice: number | null;
subscription: SubscriptionInfo | null; subscription: SubscriptionInfo | null;
} }
@@ -91,6 +92,12 @@ export default function PlanesDespachoPage() {
const hasActiveSub = subStatus != null const hasActiveSub = subStatus != null
&& subStatus !== 'cancelled' && subStatus !== 'cancelled'
&& subStatus !== 'trial_expired'; && subStatus !== 'trial_expired';
// Estados en los que se puede generar un link de pago (incluye trial y vencido).
const isPayableStatus = subStatus === 'trial'
|| subStatus === 'trial_expired'
|| hasActiveSub;
const isCurrentPlanPaid = currentPlan === planInfo?.subscription?.plan
&& (subStatus === 'authorized' || subStatus === 'pending');
/** Resuelve la frecuencia para un plan. Mi Empresa y Mi Empresa+ leen su /** Resuelve la frecuencia para un plan. Mi Empresa y Mi Empresa+ leen su
* propio toggle; el resto (business_*) siempre annual. */ * propio toggle; el resto (business_*) siempre annual. */
@@ -225,9 +232,25 @@ export default function PlanesDespachoPage() {
function PlanActionButton({ plan }: { plan: PaidPlan }) { function PlanActionButton({ plan }: { plan: PaidPlan }) {
const isCurrent = currentPlan === plan; const isCurrent = currentPlan === plan;
if (isCurrent) { if (isCurrent && isCurrentPlanPaid) {
return <Button disabled className="w-full">Plan actual</Button>; return <Button disabled className="w-full">Plan actual</Button>;
} }
if (isCurrent) {
return (
<Button
className="w-full"
onClick={() => handleContratar(plan)}
disabled={busy === plan}
>
{busy === plan ? 'Procesando...' : (
<>
<ExternalLink className="h-4 w-4 mr-2" />
Pagar este plan
</>
)}
</Button>
);
}
const label = hasActiveSub ? 'Cambiar a este plan' : 'Contratar'; const label = hasActiveSub ? 'Cambiar a este plan' : 'Contratar';
return ( return (
<Button <Button
@@ -302,7 +325,7 @@ export default function PlanesDespachoPage() {
)} )}
{/* Banner de suscripción activa */} {/* Banner de suscripción activa */}
{!loading && planInfo?.subscription && hasPaidPlan && (() => { {!loading && planInfo?.subscription && hasPaidPlan && (subStatus === 'authorized' || subStatus === 'pending') && (() => {
const sub = planInfo.subscription; const sub = planInfo.subscription;
const periodEndDate = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null; const periodEndDate = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
const fechaFormato = periodEndDate const fechaFormato = periodEndDate
@@ -329,18 +352,30 @@ export default function PlanesDespachoPage() {
); );
})()} })()}
{/* Botón "Pagar mi período actual" — visible cuando la sub corre y hay {/* Banner de trial vencido */}
un monto > 0 que cobrar. Crea una MP Preference one-off por el monto {!loading && subStatus === 'trial_expired' && hasPaidPlan && (
actual (custom $10, paid plan, lo que sea). Útil para pre-pagar antes <div className="flex items-start gap-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
del cobro automático o cuando no hay preapproval recurrente activo. */} <Clock className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
{!loading && hasActiveSub && planInfo?.subscription && Number(planInfo.subscription.amount) > 0 && (() => { <div className="text-sm">
<span className="font-semibold text-red-800 dark:text-red-300">Tu período de prueba terminó</span>
<span className="text-red-700 dark:text-red-400"> elige un plan o paga el plan actual para recuperar el acceso.</span>
</div>
</div>
)}
{/* Botón "Pagar mi período actual" — visible cuando se puede pagar y hay
un monto definido (subscription.amount > 0 o precio de catálogo).
Crea una MP Preference one-off por el monto actual. */}
{!loading && isPayableStatus && planInfo?.subscription && (() => {
const sub = planInfo.subscription!; const sub = planInfo.subscription!;
const effectiveAmount = Number(sub.amount) > 0 ? Number(sub.amount) : (planInfo.planPrice ?? 0);
if (!effectiveAmount) return null;
const periodEnd = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null; const periodEnd = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
const fechaFmt = periodEnd const fechaFmt = periodEnd
? periodEnd.toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' }) ? periodEnd.toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' })
: null; : null;
const dias = periodEnd ? Math.max(0, Math.ceil((periodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24))) : null; const dias = periodEnd ? Math.max(0, Math.ceil((periodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24))) : null;
const montoFmt = Number(sub.amount).toLocaleString('es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); const montoFmt = effectiveAmount.toLocaleString('es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
return ( return (
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg px-5 py-4 max-w-3xl mx-auto"> <div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg px-5 py-4 max-w-3xl mx-auto">
<CreditCard className="h-6 w-6 text-blue-600 dark:text-blue-400 flex-shrink-0" /> <CreditCard className="h-6 w-6 text-blue-600 dark:text-blue-400 flex-shrink-0" />