Initial commit - Horux Despachos NL
This commit is contained in:
244
apps/api/src/controllers/webhook.controller.ts
Normal file
244
apps/api/src/controllers/webhook.controller.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
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';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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';
|
||||
await prisma.subscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: { status: 'authorized' },
|
||||
});
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user