Files
HoruxDespachosNuevo/apps/api/src/controllers/webhook.controller.ts
Horux Dev 284c7620a9 feat: mejoras SAT, auth, web y migración
SAT:
- Cron diario 6-10 AM con grupos de tenants (~20% por hora).
- Retries fijos de daily sync a 9 AM y 4 PM CDMX.
- Filtrado de contribuyentes con FIEL vigente en cron.
- Timeout de 5 min en cliente HTTP del SAT.
- Manejo defensivo de 404, EmptyResult (5004) y solicitudes agotadas.
- Fechas formateadas en zona horaria America/Mexico_City.
- Patch a @nodecfdi/sat-ws-descarga-masiva para evitar getResponse crash.
- Sweep de jobs stale con thresholds ajustados.

Auth/Web:
- Primer pago de suscripción define periodo activo en webhook.
- Rate limit de login: 25 intentos / 15 min.
- Recuperación de contraseña: 24h de validez.
- Soporte viewingTenantId en contribuyentes.
- Timeout y estado de carga al crear organización en Facturapi.
- Filtros por cliente y cartera en Mis Asignados.
- Orden alfabético en selector de contribuyente.

DB:
- Migración 057: unique index de declaraciones incluye impuestos.
2026-08-02 20:06:51 +00:00

329 lines
13 KiB
TypeScript

import type { Request, Response, NextFunction } from 'express';
import * as mpService from '../services/payment/mercadopago.service.js';
import * as subscriptionService from '../services/payment/subscription.service.js';
import * as invoicingService from '../services/payment/invoicing.service.js';
import * as facturapiService from '../services/facturapi.service.js';
import { handleAddonPayment } from '../services/payment/addon.service.js';
import { prisma } from '../config/database.js';
import { isDespachoPaidPlan } from '@horux/shared';
import { despachoPlanTieneDualidadDb } from '../services/plan-catalogo.service.js';
import { emailService } from '../services/email/email.service.js';
import { getTenantOwnerEmail } from '../utils/memberships.js';
/**
* Calcula la siguiente fecha de fin de período según la frecuencia.
* Usa el mismo algoritmo que Mercado Pago: mismo día del mes siguiente,
* ajustando al último día si el mes destino tiene menos días.
*/
function computeNextPeriodEnd(date: Date, frequency: string): Date {
const d = new Date(date);
if (frequency === 'monthly') {
d.setMonth(d.getMonth() + 1);
} else if (frequency === 'annual' || frequency === 'yearly') {
d.setFullYear(d.getFullYear() + 1);
}
return d;
}
export async function handleMercadoPagoWebhook(req: Request, res: Response, next: NextFunction) {
try {
const { type, data } = req.body;
const xSignature = req.headers['x-signature'] as string;
const xRequestId = req.headers['x-request-id'] as string;
// Verify webhook signature (mandatory)
if (!xSignature || !xRequestId || !data?.id) {
console.warn('[WEBHOOK] Missing signature headers');
return res.status(401).json({ message: 'Missing signature headers' });
}
const isValid = mpService.verifyWebhookSignature(xSignature, xRequestId, String(data.id));
if (!isValid) {
console.warn('[WEBHOOK] Invalid MercadoPago signature');
return res.status(401).json({ message: 'Invalid signature' });
}
if (type === 'payment') {
await handlePaymentNotification(String(data.id));
} else if (type === 'subscription_preapproval') {
await handlePreapprovalNotification(String(data.id));
}
// Always respond 200 to acknowledge receipt
res.status(200).json({ received: true });
} catch (error) {
console.error('[WEBHOOK] Error processing MercadoPago webhook:', error);
// Still respond 200 to prevent retries for processing errors
res.status(200).json({ received: true, error: 'processing_error' });
}
}
async function handlePaymentNotification(paymentId: string) {
const payment = await mpService.getPaymentDetails(paymentId);
if (!payment.externalReference) {
console.warn('[WEBHOOK] Payment without external_reference:', paymentId);
return;
}
// Detecta compras de paquete de timbres. external_reference = `timbres-pack:{paymentId}`
if (payment.externalReference.startsWith('timbres-pack:')) {
const localPaymentId = payment.externalReference.split(':')[1];
if (!localPaymentId) {
console.warn('[WEBHOOK] external_reference timbres-pack malformado:', payment.externalReference);
return;
}
// Capturar estado previo para detectar transición (idempotencia: MP puede
// mandar el mismo webhook múltiples veces — solo notificamos en cambio real).
const before = await prisma.payment.findUnique({
where: { id: localPaymentId },
select: { status: true, tenantId: true, amount: true },
});
const previousStatus = before?.status ?? null;
await prisma.payment.update({
where: { id: localPaymentId },
data: {
status: payment.status || 'unknown',
mpPaymentId: paymentId,
paidAt: payment.status === 'approved' ? new Date() : null,
},
});
if (payment.status === 'approved') {
try {
await facturapiService.activarPaqueteTrasPago(localPaymentId);
} catch (error: any) {
console.error('[WEBHOOK] Error activando paquete de timbres:', error.message);
throw error; // que MP reintente
}
// Auto-emisión de factura (fail-soft)
await invoicingService.emitInvoiceIfApplicable(localPaymentId);
} else if (
(payment.status === 'rejected' || payment.status === 'cancelled') &&
previousStatus !== payment.status &&
before
) {
// Compra de paquete de timbres falló — el owner pagó y MP rechazó. Aviso fail-soft.
const tenant = await prisma.tenant.findUnique({ where: { id: before.tenantId }, select: { nombre: true } });
const ownerEmail = await getTenantOwnerEmail(before.tenantId);
if (tenant && ownerEmail) {
emailService.sendPaymentFailed(ownerEmail, {
nombre: tenant.nombre,
amount: Number(before.amount),
plan: 'Paquete de timbres',
}).catch(err => console.error('[EMAIL] timbres-pack failed notification:', err));
}
}
if (typeof process.send === 'function') {
const pay = await prisma.payment.findUnique({ where: { id: localPaymentId }, select: { tenantId: true } });
if (pay) process.send({ type: 'invalidate-tenant-cache', tenantId: pay.tenantId });
}
return;
}
// Detecta pagos de prorateo (upgrade). external_reference = `proration:${tenantId}:${subscriptionId}`
if (payment.externalReference.startsWith('proration:')) {
const parts = payment.externalReference.split(':');
const tenantId = parts[1];
const subscriptionId = parts[2];
if (!tenantId || !subscriptionId) {
console.warn('[WEBHOOK] external_reference de proration malformado:', payment.externalReference);
return;
}
const paymentRecord = await subscriptionService.recordPayment({
tenantId,
subscriptionId,
mpPaymentId: paymentId,
amount: payment.transactionAmount || 0,
status: payment.status || 'unknown',
paymentMethod: `proration-${payment.paymentMethodId || 'unknown'}`,
});
if (payment.status === 'approved') {
try {
await subscriptionService.applyApprovedUpgrade(subscriptionId);
} catch (error: any) {
// Re-lanza para que MP reintente el webhook
console.error('[WEBHOOK] Error aplicando upgrade:', error.message);
throw error;
}
// Auto-emisión de factura (fail-soft, no bloquea ni tira)
await invoicingService.emitInvoiceIfApplicable(paymentRecord.id);
}
if (typeof process.send === 'function') {
process.send({ type: 'invalidate-tenant-cache', tenantId });
}
return;
}
// Detecta pagos de addon. external_reference = `addon:{subscriptionAddonId}`
if (payment.externalReference.startsWith('addon:')) {
const addonId = payment.externalReference.replace('addon:', '');
if (!addonId) {
console.warn('[WEBHOOK] external_reference addon malformado:', payment.externalReference);
return;
}
await handleAddonPayment(addonId, String(paymentId), payment.status || 'unknown');
// Continue to normal flow only if we have a subscription to record against.
// Addon payments are fully handled by handleAddonPayment; no further action needed.
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({
where: { tenantId },
orderBy: { createdAt: 'desc' },
});
if (!subscription) {
console.warn('[WEBHOOK] No subscription found for tenant:', tenantId);
return;
}
const paymentRecord = await subscriptionService.recordPayment({
tenantId,
subscriptionId: subscription.id,
mpPaymentId: paymentId,
amount: payment.transactionAmount || 0,
status: payment.status || 'unknown',
paymentMethod: payment.paymentMethodId || 'unknown',
});
if (payment.status === 'approved') {
// Transición pending → authorized es el momento del *primer* pago aprobado.
// En planes despacho con dualidad de precio (firstYear > renewal), bajamos
// el monto recurrente del preapproval para que las renovaciones cobren el
// precio de renewal. Se detecta comparando el monto cobrado contra lo que
// `getPlanPrice(phase='firstYear')` devolvería para este plan.
const esPrimerPago = subscription.status === 'pending';
const updateData: { status: string; currentPeriodStart?: Date; currentPeriodEnd?: Date } = { status: 'authorized' };
if (esPrimerPago) {
// El primer pago aprobado define el inicio del período activo.
// Algunos flujos (cambio de plan, creación manual) dejan currentPeriodEnd
// en null, así que lo establecemos aquí para evitar que la suscripción
// aparezca vencida aunque esté authorized.
const periodStart = payment.dateApproved ? new Date(payment.dateApproved) : new Date();
updateData.currentPeriodStart = periodStart;
updateData.currentPeriodEnd = computeNextPeriodEnd(periodStart, subscription.frequency);
console.log(`[WEBHOOK] Subscription ${subscription.id} primer pago aprobado: período ${updateData.currentPeriodStart.toISOString()}${updateData.currentPeriodEnd.toISOString()} (${subscription.frequency})`);
} else if (subscription.currentPeriodEnd) {
// Extender currentPeriodEnd para renovaciones recurrentes.
const nextPeriodEnd = computeNextPeriodEnd(subscription.currentPeriodEnd, subscription.frequency);
updateData.currentPeriodEnd = nextPeriodEnd;
console.log(`[WEBHOOK] Subscription ${subscription.id} extended to ${nextPeriodEnd.toISOString()} (${subscription.frequency})`);
}
await prisma.subscription.update({
where: { id: subscription.id },
data: updateData,
});
subscriptionService.invalidateSubscriptionCache(tenantId);
if (
esPrimerPago &&
subscription.mpPreapprovalId &&
isDespachoPaidPlan(subscription.plan) &&
await despachoPlanTieneDualidadDb(subscription.plan)
) {
try {
const renewalAmount = await subscriptionService.getPlanPrice(
subscription.plan as any,
subscription.frequency as any,
'renewal',
);
await mpService.updatePreapprovalAmount(subscription.mpPreapprovalId, renewalAmount);
await prisma.subscription.update({
where: { id: subscription.id },
data: { amount: renewalAmount },
});
subscriptionService.invalidateSubscriptionCache(tenantId);
console.log(`[WEBHOOK] Preapproval ${subscription.mpPreapprovalId} bajado a $${renewalAmount} (renewal) tras primer pago`);
} catch (err: any) {
// No fallar el webhook — el cobro ya pasó. Logear para intervención manual.
console.error(`[WEBHOOK] Error bajando preapproval a renewal:`, err?.message || err);
}
}
// Auto-emisión de factura (fail-soft, no bloquea ni tira)
await invoicingService.emitInvoiceIfApplicable(paymentRecord.id);
}
if (typeof process.send === 'function') {
process.send({ type: 'invalidate-tenant-cache', tenantId });
}
}
async function handlePreapprovalNotification(preapprovalId: string) {
const preapproval = await mpService.getPreapproval(preapprovalId);
if (preapproval.status) {
await subscriptionService.updateSubscriptionStatus(preapprovalId, preapproval.status);
}
// Broadcast cache invalidation
const subscription = await prisma.subscription.findFirst({
where: { mpPreapprovalId: preapprovalId },
});
if (subscription && typeof process.send === 'function') {
process.send({ type: 'invalidate-tenant-cache', tenantId: subscription.tenantId });
}
}