import { MercadoPagoConfig, PreApproval, Payment as MPPayment, Preference } from 'mercadopago'; import { env } from '../../config/env.js'; import { createHmac } from 'crypto'; // Selección del token según MP_USE_SANDBOX. Si se pide sandbox pero no hay // MP_ACCESS_TOKEN_SANDBOX seteado, cae al de producción con warning — eso permite // detectar config faltante sin romper el arranque. const useSandbox = env.MP_USE_SANDBOX && !!env.MP_ACCESS_TOKEN_SANDBOX; if (env.MP_USE_SANDBOX && !env.MP_ACCESS_TOKEN_SANDBOX) { console.warn( '[MP] MP_USE_SANDBOX=true pero MP_ACCESS_TOKEN_SANDBOX no está configurado. ' + 'Cayendo al token de producción (MP_ACCESS_TOKEN). ' + 'Configura el TEST-... token en .env para usar sandbox real.' ); } else if (useSandbox) { console.log('[MP] Modo SANDBOX activo — usando MP_ACCESS_TOKEN_SANDBOX para todas las llamadas a MercadoPago.'); } const activeToken = useSandbox ? env.MP_ACCESS_TOKEN_SANDBOX! : (env.MP_ACCESS_TOKEN || ''); const config = new MercadoPagoConfig({ accessToken: activeToken, }); 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 * resoluble desde sus servidores con `400 Invalid value for back_url`. * * En dev (FRONTEND_URL=http://localhost:3000) sustituimos por la URL de * producción para que el preapproval/preference se cree exitosamente y MP * abra su flujo de pago. Después del pago, MP redirige al usuario a esa URL * de prod (no al local) — para retorno limpio al local hace falta tunnel * tipo ngrok. Pero el flujo de cobro funciona end-to-end en MP. * * En prod (FRONTEND_URL=https://horuxfin.com) es no-op. */ const PUBLIC_BACK_URL_FALLBACK = 'https://horuxfin.com'; let warnedLocalhost = false; function backUrlBase(): string { const fe = env.FRONTEND_URL; if (!fe || /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)/i.test(fe)) { if (!warnedLocalhost) { console.warn( `[MP] FRONTEND_URL=${fe} no es válida para back_url de MercadoPago. ` + `Usando fallback público ${PUBLIC_BACK_URL_FALLBACK}. Para retorno ` + `limpio al local usa ngrok y override FRONTEND_URL.` ); warnedLocalhost = true; } return PUBLIC_BACK_URL_FALLBACK; } return fe; } /** * Override del payer_email para entornos donde el owner del tenant tiene el * mismo correo vinculado al MP_ACCESS_TOKEN (vendedor) — MP rechaza con * "Payer and collector cannot be the same user". En ese caso seteas * `MP_TEST_PAYER_EMAIL` en `.env` y todas las llamadas a MP usan ese email * como pagador. Production: dejar sin setear → no-op. */ let warnedTestPayer = false; function resolvePayerEmail(callerEmail: string): string { if (env.MP_TEST_PAYER_EMAIL) { if (!warnedTestPayer) { console.warn( `[MP] Override de payer_email activo: usando ${env.MP_TEST_PAYER_EMAIL} ` + `(MP_TEST_PAYER_EMAIL) en lugar del email del owner del tenant. ` + `Quitar la variable en producción.` ); warnedTestPayer = true; } return env.MP_TEST_PAYER_EMAIL; } return callerEmail; } /** * Creates a recurring subscription (preapproval) in MercadoPago. * Soporta cadencia mensual (cada 1 mes) o anual (cada 12 meses). */ export async function createPreapproval(params: { tenantId: string; reason: string; amount: number; payerEmail: string; frequency?: 'monthly' | 'annual'; /** * Fecha del primer cobro. Si no se especifica, MP cobra al día siguiente. * Útil para reactivaciones: el cliente ya pagó hasta `currentPeriodEnd`, * queremos que MP empiece a cobrar desde ese momento, no mañana. */ startDate?: Date; /** * Referencia externa para el preapproval. Si no se especifica, se usa tenantId. * Usar `addon:{subscriptionAddonId}` para preapprovals de addons. */ externalReference?: string; }) { if (!env.MP_ACCESS_TOKEN) { throw new Error( 'MercadoPago no está configurado (falta MP_ACCESS_TOKEN en .env). ' + 'Pide al dueño de la cuenta que agregue el token de acceso para habilitar cobros.' ); } const freq = params.frequency === 'annual' ? { frequency: 12, frequency_type: 'months' as const } : { frequency: 1, frequency_type: 'months' as const }; // start_date sólo se envía si es en el futuro (MP rechaza fechas pasadas). const now = new Date(); const startDateIso = params.startDate && params.startDate.getTime() > now.getTime() ? params.startDate.toISOString() : undefined; const response = await preApprovalClient.create({ body: { reason: params.reason, external_reference: params.externalReference || params.tenantId, payer_email: resolvePayerEmail(params.payerEmail), auto_recurring: { ...freq, transaction_amount: params.amount, currency_id: 'MXN', ...(startDateIso ? { start_date: startDateIso } : {}), }, back_url: `${backUrlBase()}/configuracion/suscripcion`, }, }); return { preapprovalId: response.id!, initPoint: response.init_point!, status: response.status!, }; } /** * Cancela un preapproval en MercadoPago. No falla si ya está cancelado o no existe. */ export async function cancelPreapproval(preapprovalId: string): Promise { try { await preApprovalClient.update({ id: preapprovalId, body: { status: 'cancelled' }, }); } catch (error: any) { // No tiramos el error si el preapproval ya no existe o ya está cancelado console.warn(`[MP] cancelPreapproval(${preapprovalId}):`, error.message || error); } } /** * Actualiza el monto recurrente de un preapproval existente (usado en upgrades: * después de cobrar el prorateo vía Preference, subimos el monto del preapproval * para que el próximo cobro recurrente sea el del plan nuevo). */ export async function updatePreapprovalAmount( preapprovalId: string, newAmount: number, ): Promise { if (!env.MP_ACCESS_TOKEN) { throw new Error('MercadoPago no está configurado (falta MP_ACCESS_TOKEN)'); } await preApprovalClient.update({ id: preapprovalId, body: { auto_recurring: { transaction_amount: newAmount, currency_id: 'MXN', }, }, }); } /** * Crea una Preference (checkout de pago único) para cobrar el prorateo de un upgrade. * `externalReference` se prefija con `proration:` para que el webhook distinga este * pago del cobro recurrente del preapproval. */ export async function createProrationPreference(params: { tenantId: string; subscriptionId: string; amount: number; description: string; 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). ' + 'No es posible cobrar el prorateo del upgrade.' ); } const response = await preferenceClient.create({ body: { items: [ { id: `proration-${params.subscriptionId}`, title: params.description, quantity: 1, unit_price: params.amount, currency_id: 'MXN', }, ], payer: { email: resolvePayerEmail(params.payerEmail) }, // El prefijo proration: es el marcador que el webhook usa para ramificar external_reference: `proration:${params.tenantId}:${params.subscriptionId}`, back_urls: { success: `${backUrlBase()}/configuracion/suscripcion?upgrade=success`, failure: `${backUrlBase()}/configuracion/suscripcion?upgrade=failure`, pending: `${backUrlBase()}/configuracion/suscripcion?upgrade=pending`, }, auto_return: 'approved', payment_methods: { installments: 12, }, }, }); return { preferenceId: response.id!, checkoutUrl: response.init_point!, }; } /** * 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', payment_methods: { installments: 12, }, }, }); 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 * que el webhook ramifique al handler correspondiente (crea TimbrePaquete + * marca Payment approved + emite factura). */ export async function createTimbrePackPreference(params: { paymentId: string; // Payment.id del record pre-creado con status=pending tenantId: string; cantidad: number; amount: number; payerEmail: string; }): Promise<{ preferenceId: string; checkoutUrl: string }> { if (!env.MP_ACCESS_TOKEN) { throw new Error('MercadoPago no está configurado (MP_ACCESS_TOKEN faltante).'); } const title = `${params.cantidad.toLocaleString('es-MX')} timbres adicionales — Horux 360`; const response = await preferenceClient.create({ body: { items: [ { id: `timbres-pack-${params.paymentId}`, title, quantity: 1, unit_price: params.amount, currency_id: 'MXN', }, ], payer: { email: resolvePayerEmail(params.payerEmail) }, external_reference: `timbres-pack:${params.paymentId}`, back_urls: { success: `${backUrlBase()}/facturacion?timbres=success`, failure: `${backUrlBase()}/facturacion?timbres=failure`, pending: `${backUrlBase()}/facturacion?timbres=pending`, }, auto_return: 'approved', }, }); return { preferenceId: response.id!, checkoutUrl: response.init_point!, }; } /** * Gets subscription (preapproval) status from MercadoPago */ export async function getPreapproval(preapprovalId: string) { const response = await preApprovalClient.get({ id: preapprovalId }); return { id: response.id, status: response.status, payerEmail: response.payer_email, nextPaymentDate: response.next_payment_date, autoRecurring: response.auto_recurring, }; } /** * Gets payment details from MercadoPago */ export async function getPaymentDetails(paymentId: string) { const response = await paymentClient.get({ id: paymentId }); return { id: response.id, status: response.status, statusDetail: response.status_detail, transactionAmount: response.transaction_amount, currencyId: response.currency_id, payerEmail: response.payer?.email, dateApproved: response.date_approved, paymentMethodId: response.payment_method_id, externalReference: response.external_reference, }; } /** * Verifies MercadoPago webhook signature (HMAC-SHA256) */ export function verifyWebhookSignature( xSignature: string, xRequestId: string, dataId: string ): boolean { if (!env.MP_WEBHOOK_SECRET) { console.error('[WEBHOOK] MP_WEBHOOK_SECRET not configured - rejecting webhook'); return false; } // Parse x-signature header: "ts=...,v1=..." const parts: Record = {}; for (const part of xSignature.split(',')) { const [key, value] = part.split('='); if (!key || value === undefined) continue; parts[key.trim()] = value.trim(); } const ts = parts['ts']; const v1 = parts['v1']; if (!ts || !v1) return false; // Build the manifest string const manifest = `id:${dataId};request-id:${xRequestId};ts:${ts};`; const hmac = createHmac('sha256', env.MP_WEBHOOK_SECRET) .update(manifest) .digest('hex'); return hmac === v1; }