import axios from "axios"; import PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons"; import { MP_SUBS_STATUS } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose"; import { connect } from "mongoose"; import dayjs from "dayjs"; import PlanPaymentsList from "../../Models/PlanPayments/PlanPayments"; import { IPlanSuscriptionDocument } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose"; import { VerifyPendingPlanPaymentResponse } from "../../Models/PlanSubscriptions/PlanSubscriptions.interface"; import PlansList from "../../Models/Plans/Plans"; import UserList from "../../Models/Users/Users"; import { NotificationsManager } from "../../Models/Notifications/Notifications"; type MercadoPagoPaymentData = { id: string | number; status: string; external_reference?: string; preference_id?: string; order?: { id?: string }; date_approved?: string; payer?: { id?: number; email?: string }; transaction_amount?: number; payment_method_id?: string; payment_type_id?: string; date_last_updated?: string; }; type MercadoPagoMerchantOrder = { id: string | number; preference_id?: string; external_reference?: string; payments?: MercadoPagoPaymentData[]; }; type MercadoPagoPaymentContext = { preferenceId?: string; externalReference?: string; }; type MercadoPagoPaymentVerification = { status: "approved" | "rejected" | "failed" | "cancelled" | "pending"; payment: MercadoPagoPaymentData | null; }; const REJECTED_PAYMENT_STATUSES = ["rejected", "cancelled", "failed"]; export class MercadoPagoWebhookService { private formatTemplateDate(date: Date): string { return new Intl.DateTimeFormat("es-AR", { day: "2-digit", month: "2-digit", year: "numeric", }).format(date); } private formatTemplateAmount(amount: number): string { return new Intl.NumberFormat("es-AR", { style: "currency", currency: "ARS", }).format(amount); } private async sendApprovedPaidPlanEmail( subscription: IPlanSuscriptionDocument, paymentData: MercadoPagoPaymentData, userId: string, approvedAt: Date, periodStart: Date ): Promise { try { const plan = await PlansList.findOne({ _id: String(subscription.planId) }); if (plan.price <= 0) { return; } const user = await UserList.users.findOne({ _id: userId }); if (!user) { return; } await NotificationsManager.sendEmail({ email: user.email, templateId: "6a85ebb1323720a53d0cfd3f", context: { username: user.firstName || user.email, nombre_plan: plan.name, importe: this.formatTemplateAmount(paymentData.transaction_amount || 0), fecha_pago: this.formatTemplateDate(approvedAt), periodo_desde: this.formatTemplateDate(periodStart), periodo_hasta: this.formatTemplateDate(subscription.endDate), }, }); } catch (e) { if (axios.isAxiosError(e)) { console.error("Error enviando email de pago de plan aprobado:", { status: e.response?.status, data: e.response?.data, }); return; } console.error("Error enviando email de pago de plan aprobado:", e); } } public async handleWebhook(body: any): Promise { await connect(`${process.env.DATABASE_CONNECTION}`); // MercadoPago sends 'type' or 'topic' as 'payment' const type = body.type || body.topic; const dataId = body.data?.id || (body.resource ? body.resource.split("/").pop() : null) || body.id; if (type === "payment" && dataId) { try { // Fetch the payment details from MP API const paymentResponse = await axios.get( `https://api.mercadopago.com/v1/payments/${dataId}`, { headers: { Authorization: `Bearer ${process.env.MP_ACCESS_TOKEN}`, "Content-Type": "application/json", }, } ); const paymentData: MercadoPagoPaymentData = paymentResponse.data; const status = paymentData.status; // approved, pending, rejected, etc. const paymentContext = await this.resolvePaymentContext(paymentData); const userId = paymentContext.externalReference || paymentData.external_reference; // We passed this when creating preference const preferenceId = paymentContext.preferenceId || paymentData.preference_id; const subscription = await this.findSubscriptionForPayment(userId, preferenceId); if (userId && preferenceId && status === "approved") { if (subscription) { await this.applyApprovedPayment(subscription, paymentData, userId, preferenceId); } } else if (userId && preferenceId && REJECTED_PAYMENT_STATUSES.includes(status)) { if (subscription) { await this.applyRejectedPayment(subscription, paymentData, userId, preferenceId); } } } catch (error) { console.error("Error handling MercadoPago Webhook:", error); } } } public async verifyPendingPlanPayment(sessionUser: string): Promise { const pendingSubscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({ userId: sessionUser, mpPreferenceId: { $exists: true, $ne: "" }, mpStatus: MP_SUBS_STATUS.PENDING, }).exec(); const pendingExistingSubscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({ userId: sessionUser, pendingPaymentPreferenceId: { $exists: true, $ne: "" }, mpStatus: MP_SUBS_STATUS.AUTHORIZED, }).exec(); const subscription = pendingSubscription || pendingExistingSubscription; if (!subscription) { return { status: "not_found", approved: false, message: "No se encontró un pago pendiente para verificar.", }; } const preferenceId = pendingSubscription ? subscription.mpPreferenceId : subscription.pendingPaymentPreferenceId; if (!preferenceId) { return { status: "not_found", approved: false, message: "No se encontró una preferencia de pago pendiente para verificar.", subscriptionId: String(subscription._id), }; } const paymentVerification = await this.findPaymentForPreference(sessionUser, preferenceId); if (!paymentVerification.payment || paymentVerification.status === "pending") { return { status: "pending", approved: false, message: "El pago todavía no figura confirmado.", subscriptionId: String(subscription._id), paymentType: this.getPaymentType(subscription, preferenceId), }; } const paymentType = this.getPaymentType(subscription, preferenceId); const payment = paymentVerification.payment; if (paymentVerification.status !== "approved") { await this.applyRejectedPayment(subscription, payment, sessionUser, preferenceId); return { status: paymentVerification.status, approved: false, message: "El pago fue rechazado. Tu plan actual no cambió y podés intentarlo nuevamente.", subscriptionId: String(subscription._id), paymentId: String(payment.id), paymentType, }; } await this.applyApprovedPayment(subscription, payment, sessionUser, preferenceId); return { status: "approved", approved: true, message: "El pago fue aprobado y la suscripción fue actualizada.", subscriptionId: String(subscription._id), paymentId: String(payment.id), paymentType, }; } private async findPaymentForPreference(sessionUser: string, preferenceId: string): Promise { const merchantOrders = await this.searchMerchantOrdersByPreferenceId(preferenceId); const merchantOrder = merchantOrders.find((order) => { return !order.external_reference || order.external_reference === sessionUser; }); const merchantOrderPayment = this.pickMostRelevantPayment(merchantOrder?.payments || []); if (merchantOrderPayment) { const payment = await this.getPaymentById(merchantOrderPayment.id); return { status: this.normalizePaymentStatus(payment.status), payment, }; } const response = await axios.get("https://api.mercadopago.com/v1/payments/search", { headers: this.getMercadoPagoHeaders(), params: { external_reference: sessionUser, sort: "date_created", criteria: "desc", limit: 20, }, }); const payments: MercadoPagoPaymentData[] = response.data?.results || []; const payment = this.pickMostRelevantPayment(payments.filter((payment) => payment.preference_id === preferenceId)); if (!payment) { return { status: "pending", payment: null }; } const fullPayment = await this.getPaymentById(payment.id); return { status: this.normalizePaymentStatus(fullPayment.status), payment: fullPayment, }; } private pickMostRelevantPayment(payments: MercadoPagoPaymentData[]): MercadoPagoPaymentData | null { return payments.find((payment) => payment.status === "approved") || payments.find((payment) => REJECTED_PAYMENT_STATUSES.includes(payment.status)) || payments[0] || null; } private normalizePaymentStatus(status: string): MercadoPagoPaymentVerification["status"] { if (status === "approved") return "approved"; if (status === "cancelled") return "cancelled"; if (status === "failed") return "failed"; if (status === "rejected") return "rejected"; return "pending"; } private async resolvePaymentContext(paymentData: MercadoPagoPaymentData): Promise { if (paymentData.preference_id) { return { preferenceId: paymentData.preference_id, externalReference: paymentData.external_reference, }; } if (!paymentData.order?.id) { return { externalReference: paymentData.external_reference, }; } const merchantOrder = await this.getMerchantOrderById(paymentData.order.id); return { preferenceId: merchantOrder?.preference_id, externalReference: merchantOrder?.external_reference || paymentData.external_reference, }; } private async searchMerchantOrdersByPreferenceId(preferenceId: string): Promise { const response = await axios.get("https://api.mercadopago.com/merchant_orders/search", { headers: this.getMercadoPagoHeaders(), params: { preference_id: preferenceId }, }); return response.data?.elements || []; } private async getMerchantOrderById(merchantOrderId: string): Promise { const response = await axios.get(`https://api.mercadopago.com/merchant_orders/${merchantOrderId}`, { headers: this.getMercadoPagoHeaders(), }); return response.data || null; } private async getPaymentById(paymentId: string | number): Promise { const response = await axios.get(`https://api.mercadopago.com/v1/payments/${paymentId}`, { headers: this.getMercadoPagoHeaders(), }); return response.data; } private getMercadoPagoHeaders(): Record { return { Authorization: `Bearer ${process.env.MP_ACCESS_TOKEN}`, "Content-Type": "application/json", }; } private async findSubscriptionForPayment(userId?: string, preferenceId?: string): Promise { if (!userId || !preferenceId) return null; let subscription = await PlanSubscriptionsList.planSuscriptions.findOne({ sessionUser: userId, mpPreferenceId: preferenceId, mpStatus: MP_SUBS_STATUS.PENDING, }); if (!subscription) { subscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({ userId, pendingPaymentPreferenceId: preferenceId, mpStatus: MP_SUBS_STATUS.AUTHORIZED, }).exec(); } return subscription; } private async applyApprovedPayment( subscription: IPlanSuscriptionDocument, paymentData: MercadoPagoPaymentData, userId: string, preferenceId: string ): Promise { const approvedAt = paymentData.date_approved ? new Date(paymentData.date_approved) : new Date(); const billingMonths = subscription.pendingPaymentBillingMonths || subscription.billingMonths || 1; const isExtensionPayment = subscription.pendingPaymentPreferenceId === preferenceId; const isUpgradePayment = isExtensionPayment && subscription.pendingPaymentType === "upgrade"; const periodStart = isExtensionPayment && dayjs(subscription.endDate).isAfter(approvedAt) ? subscription.endDate : approvedAt; const transactionId = String(paymentData.id); const existingPayments = await PlanPaymentsList.find({ transactionId }); const shouldSendApprovedPaymentEmail = existingPayments.length === 0; if (isUpgradePayment && subscription.pendingPaymentPlanId) { subscription.planId = subscription.pendingPaymentPlanId; } subscription.mpStatus = MP_SUBS_STATUS.AUTHORIZED; subscription.isActive = true; subscription.autoRenew = false; subscription.startDate = isExtensionPayment ? subscription.startDate : approvedAt; subscription.endDate = isUpgradePayment ? subscription.endDate : dayjs(periodStart).add(billingMonths, "months").toDate(); subscription.mpPayerId = paymentData.payer?.id || subscription.mpPayerId; subscription.mpPayerEmail = paymentData.payer?.email || subscription.mpPayerEmail; subscription.pendingPaymentInitPoint = ""; subscription.pendingPaymentPreferenceId = ""; subscription.pendingPaymentBillingMonths = undefined; subscription.pendingPaymentType = undefined; subscription.pendingPaymentPlanId = undefined; subscription.pendingPaymentCurrentPlanPrice = undefined; subscription.pendingPaymentRequestedPlanPrice = undefined; subscription.pendingPaymentProratedAmount = undefined; subscription.pendingPaymentRemainingDays = undefined; subscription.pendingPaymentPeriodEndDate = undefined; subscription.lastPaymentStatus = undefined; subscription.lastPaymentPreferenceId = undefined; subscription.lastPaymentAt = undefined; subscription.lastPaymentType = undefined; subscription.downgradedFromPlanId = undefined; subscription.downgradedFromPlanName = undefined; subscription.downgradedFromPlanCode = undefined; subscription.downgradedAt = undefined; subscription.downgradeReason = undefined; await subscription.save(); await PlanPaymentsList.createIfMissingByTransactionId({ userId, subscriptionId: String(subscription._id), amount: paymentData.transaction_amount || 0, paymentDate: approvedAt, paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago", status: "completed", transactionId, }); if (shouldSendApprovedPaymentEmail) { await this.sendApprovedPaidPlanEmail(subscription, paymentData, userId, approvedAt, periodStart); } } private async applyRejectedPayment( subscription: IPlanSuscriptionDocument, paymentData: MercadoPagoPaymentData, userId: string, preferenceId: string ): Promise { const isExtensionPayment = subscription.pendingPaymentPreferenceId === preferenceId; const paymentStatus = this.normalizePaymentStatus(paymentData.status); const paymentType = this.getPaymentType(subscription, preferenceId); if (isExtensionPayment) { subscription.pendingPaymentInitPoint = ""; subscription.pendingPaymentPreferenceId = ""; subscription.pendingPaymentBillingMonths = undefined; subscription.pendingPaymentType = undefined; subscription.pendingPaymentPlanId = undefined; subscription.pendingPaymentCurrentPlanPrice = undefined; subscription.pendingPaymentRequestedPlanPrice = undefined; subscription.pendingPaymentProratedAmount = undefined; subscription.pendingPaymentRemainingDays = undefined; subscription.pendingPaymentPeriodEndDate = undefined; } else { subscription.mpStatus = MP_SUBS_STATUS.CANCELLED; subscription.isActive = false; } if (paymentStatus !== "pending" && paymentStatus !== "approved") { subscription.lastPaymentStatus = paymentStatus; subscription.lastPaymentPreferenceId = preferenceId; subscription.lastPaymentAt = paymentData.date_last_updated ? new Date(paymentData.date_last_updated) : new Date(); subscription.lastPaymentType = paymentType; } await subscription.save(); await PlanPaymentsList.createIfMissingByTransactionId({ userId, subscriptionId: String(subscription._id), amount: paymentData.transaction_amount || 0, paymentDate: new Date(), paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago", status: "failed", transactionId: String(paymentData.id), }); } private getPaymentType(subscription: IPlanSuscriptionDocument, preferenceId: string): "new" | "extension" | "upgrade" { if (subscription.pendingPaymentPreferenceId === preferenceId) { return subscription.pendingPaymentType === "upgrade" ? "upgrade" : "extension"; } return "new"; } }