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

@@ -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
}
}
}