396 lines
13 KiB
TypeScript
396 lines
13 KiB
TypeScript
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<void> {
|
|
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<void> {
|
|
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<string, string> = {};
|
|
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;
|
|
}
|