feat: implement plan upgrade logic with prorated pricing and MercadoPago integration
This commit is contained in:
@@ -4,6 +4,41 @@ import { MP_SUBS_STATUS } from "../../Models/PlanSubscriptions/PlanSubscriptions
|
||||
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> {
|
||||
@@ -26,78 +61,20 @@ export class MercadoPagoWebhookService {
|
||||
}
|
||||
);
|
||||
|
||||
const paymentData = paymentResponse.data;
|
||||
const paymentData: MercadoPagoPaymentData = paymentResponse.data;
|
||||
const status = paymentData.status; // approved, pending, rejected, etc.
|
||||
const userId = paymentData.external_reference; // We passed this when creating preference
|
||||
const preferenceId = paymentData.preference_id || paymentData.order?.id;
|
||||
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);
|
||||
|
||||
let subscription = await PlanSubscriptionsList.planSuscriptions.findOne({
|
||||
sessionUser: userId,
|
||||
mpPreferenceId: preferenceId,
|
||||
mpStatus: MP_SUBS_STATUS.PENDING,
|
||||
});
|
||||
|
||||
if (!subscription && userId && preferenceId) {
|
||||
subscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({
|
||||
userId,
|
||||
pendingPaymentPreferenceId: preferenceId,
|
||||
}).exec();
|
||||
}
|
||||
|
||||
if (userId && status === "approved") {
|
||||
if (userId && preferenceId && status === "approved") {
|
||||
if (subscription) {
|
||||
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 periodStart = isExtensionPayment && dayjs(subscription.endDate).isAfter(approvedAt)
|
||||
? subscription.endDate
|
||||
: approvedAt;
|
||||
|
||||
subscription.mpStatus = MP_SUBS_STATUS.AUTHORIZED;
|
||||
subscription.isActive = true;
|
||||
subscription.autoRenew = false;
|
||||
subscription.startDate = isExtensionPayment ? subscription.startDate : approvedAt;
|
||||
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;
|
||||
await subscription.save();
|
||||
|
||||
await PlanPaymentsList.create({
|
||||
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),
|
||||
});
|
||||
await this.applyApprovedPayment(subscription, paymentData, userId, preferenceId);
|
||||
}
|
||||
} else if (userId && status === "rejected") {
|
||||
} else if (userId && preferenceId && REJECTED_PAYMENT_STATUSES.includes(status)) {
|
||||
if (subscription) {
|
||||
const isExtensionPayment = subscription.pendingPaymentPreferenceId === preferenceId;
|
||||
|
||||
if (isExtensionPayment) {
|
||||
subscription.pendingPaymentInitPoint = "";
|
||||
subscription.pendingPaymentPreferenceId = "";
|
||||
subscription.pendingPaymentBillingMonths = undefined;
|
||||
} else {
|
||||
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
|
||||
subscription.isActive = false;
|
||||
}
|
||||
await subscription.save();
|
||||
|
||||
await PlanPaymentsList.create({
|
||||
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),
|
||||
});
|
||||
await this.applyRejectedPayment(subscription, paymentData, userId, preferenceId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -105,4 +82,315 @@ export class MercadoPagoWebhookService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user