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:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "subscriptions" ADD COLUMN "mp_preference_id" TEXT;
|
||||
@@ -358,6 +358,7 @@ model Subscription {
|
||||
tenantId String @map("tenant_id")
|
||||
plan Plan
|
||||
mpPreapprovalId String? @map("mp_preapproval_id")
|
||||
mpPreferenceId String? @map("mp_preference_id")
|
||||
status String @default("pending")
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
frequency String @default("monthly")
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -25,6 +25,9 @@ const preApprovalClient = new PreApproval(config);
|
||||
const paymentClient = new MPPayment(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.
|
||||
* 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
|
||||
* timbres adicionales. external_reference = `timbres-pack:${paymentId}` para
|
||||
|
||||
@@ -243,25 +243,76 @@ export async function generatePaymentLink(tenantId: string) {
|
||||
const ownerEmail = await getTenantOwnerEmail(tenantId);
|
||||
if (!ownerEmail) throw new Error('No admin user found');
|
||||
|
||||
const subscription = await getActiveSubscription(tenantId);
|
||||
const plan = subscription?.plan || tenant.plan;
|
||||
const amount = subscription?.amount || 0;
|
||||
let subscription = await getActiveSubscription(tenantId);
|
||||
const plan = (subscription?.plan || tenant.plan) as Plan;
|
||||
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({
|
||||
tenantId,
|
||||
reason: `Horux360 - Plan ${plan} - ${tenant.nombre}`,
|
||||
amount,
|
||||
payerEmail: ownerEmail,
|
||||
frequency,
|
||||
});
|
||||
|
||||
// Update subscription with new MP preapproval ID
|
||||
if (subscription) {
|
||||
await prisma.subscription.update({
|
||||
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 };
|
||||
@@ -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`
|
||||
: `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({
|
||||
tenantId: params.tenantId,
|
||||
reason,
|
||||
@@ -637,13 +736,20 @@ export async function applyApprovedUpgrade(subscriptionId: string): Promise<void
|
||||
const newPlan = sub.upgradeTargetPlan as Plan;
|
||||
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) {
|
||||
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
|
||||
if (newAmount > mpService.MP_PREAPPROVAL_MAX_AMOUNT) {
|
||||
await mpService.cancelPreapproval(sub.mpPreapprovalId);
|
||||
console.log(`[Upgrade] Preapproval ${sub.mpPreapprovalId} cancelado porque el nuevo monto $${newAmount} supera el límite de MP`);
|
||||
} else {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ interface PlanInfo {
|
||||
dbMode: string;
|
||||
trialEndsAt: string | null;
|
||||
isTrialActive: boolean;
|
||||
planPrice: number | null;
|
||||
subscription: SubscriptionInfo | null;
|
||||
}
|
||||
|
||||
@@ -91,6 +92,12 @@ export default function PlanesDespachoPage() {
|
||||
const hasActiveSub = subStatus != null
|
||||
&& subStatus !== 'cancelled'
|
||||
&& 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
|
||||
* propio toggle; el resto (business_*) siempre annual. */
|
||||
@@ -225,9 +232,25 @@ export default function PlanesDespachoPage() {
|
||||
|
||||
function PlanActionButton({ plan }: { plan: PaidPlan }) {
|
||||
const isCurrent = currentPlan === plan;
|
||||
if (isCurrent) {
|
||||
if (isCurrent && isCurrentPlanPaid) {
|
||||
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';
|
||||
return (
|
||||
<Button
|
||||
@@ -302,7 +325,7 @@ export default function PlanesDespachoPage() {
|
||||
)}
|
||||
|
||||
{/* Banner de suscripción activa */}
|
||||
{!loading && planInfo?.subscription && hasPaidPlan && (() => {
|
||||
{!loading && planInfo?.subscription && hasPaidPlan && (subStatus === 'authorized' || subStatus === 'pending') && (() => {
|
||||
const sub = planInfo.subscription;
|
||||
const periodEndDate = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
|
||||
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
|
||||
un monto > 0 que cobrar. Crea una MP Preference one-off por el monto
|
||||
actual (custom $10, paid plan, lo que sea). Útil para pre-pagar antes
|
||||
del cobro automático o cuando no hay preapproval recurrente activo. */}
|
||||
{!loading && hasActiveSub && planInfo?.subscription && Number(planInfo.subscription.amount) > 0 && (() => {
|
||||
{/* Banner de trial vencido */}
|
||||
{!loading && subStatus === 'trial_expired' && hasPaidPlan && (
|
||||
<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">
|
||||
<Clock className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<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 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 fechaFmt = periodEnd
|
||||
? periodEnd.toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
: 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 (
|
||||
<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" />
|
||||
|
||||
Reference in New Issue
Block a user