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 }); } }