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")
|
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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user