Files
turnosxpress/server/src/api/MercadoPago/MercadoPagoWebhook.Service.ts
T

472 lines
19 KiB
TypeScript

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<void> {
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<void> {
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<VerifyPendingPlanPaymentResponse> {
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<MercadoPagoPaymentVerification> {
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<MercadoPagoPaymentContext> {
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<MercadoPagoMerchantOrder[]> {
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<MercadoPagoMerchantOrder | null> {
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<MercadoPagoPaymentData> {
const response = await axios.get(`https://api.mercadopago.com/v1/payments/${paymentId}`, {
headers: this.getMercadoPagoHeaders(),
});
return response.data;
}
private getMercadoPagoHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${process.env.MP_ACCESS_TOKEN}`,
"Content-Type": "application/json",
};
}
private async findSubscriptionForPayment(userId?: string, preferenceId?: string): Promise<IPlanSuscriptionDocument | null> {
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<void> {
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<void> {
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";
}
}