397 lines
16 KiB
TypeScript
397 lines
16 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";
|
|
|
|
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 {
|
|
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;
|
|
|
|
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;
|
|
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: String(paymentData.id),
|
|
});
|
|
}
|
|
|
|
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";
|
|
}
|
|
}
|