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.
329 lines
13 KiB
TypeScript
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 });
|
|
}
|
|
}
|