From 3f3253d41bb8ee7ae71c6d6856265135548eb379 Mon Sep 17 00:00:00 2001 From: Horux Dev Date: Tue, 16 Jun 2026 22:37:11 +0000 Subject: [PATCH] fix(pagos): permitir pagar plan actual trial_expired y soportar planes >$10k via Preference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../migration.sql | 2 + apps/api/prisma/schema.prisma | 1 + .../src/controllers/despacho.controller.ts | 16 ++- .../controllers/subscription.controller.ts | 13 +- .../api/src/controllers/webhook.controller.ts | 51 +++++++ .../services/payment/mercadopago.service.ts | 48 +++++++ .../services/payment/subscription.service.ts | 130 ++++++++++++++++-- .../configuracion/planes-despacho/page.tsx | 51 +++++-- 8 files changed, 290 insertions(+), 22 deletions(-) create mode 100644 apps/api/prisma/migrations/20260616220436_add_subscription_mp_preference_id/migration.sql diff --git a/apps/api/prisma/migrations/20260616220436_add_subscription_mp_preference_id/migration.sql b/apps/api/prisma/migrations/20260616220436_add_subscription_mp_preference_id/migration.sql new file mode 100644 index 0000000..cd5bd24 --- /dev/null +++ b/apps/api/prisma/migrations/20260616220436_add_subscription_mp_preference_id/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "subscriptions" ADD COLUMN "mp_preference_id" TEXT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 30273b5..3eb8733 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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") diff --git a/apps/api/src/controllers/despacho.controller.ts b/apps/api/src/controllers/despacho.controller.ts index e138fd8..b6ca6ed 100644 --- a/apps/api/src/controllers/despacho.controller.ts +++ b/apps/api/src/controllers/despacho.controller.ts @@ -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, diff --git a/apps/api/src/controllers/subscription.controller.ts b/apps/api/src/controllers/subscription.controller.ts index 698553d..46b6561 100644 --- a/apps/api/src/controllers/subscription.controller.ts +++ b/apps/api/src/controllers/subscription.controller.ts @@ -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.', diff --git a/apps/api/src/controllers/webhook.controller.ts b/apps/api/src/controllers/webhook.controller.ts index bbbad28..72328d7 100644 --- a/apps/api/src/controllers/webhook.controller.ts +++ b/apps/api/src/controllers/webhook.controller.ts @@ -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({ diff --git a/apps/api/src/services/payment/mercadopago.service.ts b/apps/api/src/services/payment/mercadopago.service.ts index cff28eb..e11a127 100644 --- a/apps/api/src/services/payment/mercadopago.service.ts +++ b/apps/api/src/services/payment/mercadopago.service.ts @@ -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 diff --git a/apps/api/src/services/payment/subscription.service.ts b/apps/api/src/services/payment/subscription.service.ts index f3c1865..56b9634 100644 --- a/apps/api/src/services/payment/subscription.service.ts +++ b/apps/api/src/services/payment/subscription.service.ts @@ -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 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 + } } } diff --git a/apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx b/apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx index a8a9334..75f3bce 100644 --- a/apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx +++ b/apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx @@ -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 ; } + if (isCurrent) { + return ( + + ); + } const label = hasActiveSub ? 'Cambiar a este plan' : 'Contratar'; return (