Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

View File

@@ -0,0 +1,340 @@
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);
/**
* 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',
},
});
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('=');
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;
}