feat: implement plan upgrade logic with prorated pricing and MercadoPago integration
This commit is contained in:
@@ -22,6 +22,7 @@ export class PlanPaymentsAdapterMongoose implements IPlanPaymentsAdapter {
|
||||
status: { type: String, required: true },
|
||||
transactionId: { type: String, required: false },
|
||||
});
|
||||
this.schema.index({ transactionId: 1 }, { unique: true, sparse: true });
|
||||
|
||||
this.planPaymentList = model<IPlanPaymentDocument>("PlanPayment", this.schema);
|
||||
}
|
||||
@@ -33,4 +34,16 @@ export class PlanPaymentsAdapterMongoose implements IPlanPaymentsAdapter {
|
||||
public async create(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
|
||||
return this.planPaymentList.create(data);
|
||||
}
|
||||
|
||||
public async createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
|
||||
if (!data.transactionId) {
|
||||
return this.create(data);
|
||||
}
|
||||
|
||||
return this.planPaymentList.findOneAndUpdate(
|
||||
{ transactionId: data.transactionId },
|
||||
{ $setOnInsert: data },
|
||||
{ new: true, upsert: true, setDefaultsOnInsert: true }
|
||||
).exec();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,12 @@ export interface IPlanPayment {
|
||||
export interface IPlanPaymentsAdapter {
|
||||
find(filters: FindPlanPaymentsParams): Promise<IPlanPayment[]>;
|
||||
create(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
|
||||
createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
|
||||
}
|
||||
|
||||
export interface IPlanPaymentsManager {
|
||||
planPayments: IPlanPaymentsAdapter;
|
||||
find(data: FindPlanPaymentsParams): Promise<IPlanPayment[]>;
|
||||
create(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
|
||||
createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ class PlanPaymentsManager implements IPlanPaymentsManager {
|
||||
public async create(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
|
||||
return this.planPayments.create(data);
|
||||
}
|
||||
|
||||
public async createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
|
||||
return this.planPayments.createIfMissingByTransactionId(data);
|
||||
}
|
||||
}
|
||||
|
||||
const PlanPaymentsList = new PlanPaymentsManager();
|
||||
|
||||
@@ -42,6 +42,17 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
|
||||
pendingPaymentInitPoint: { type: String, required: false },
|
||||
pendingPaymentPreferenceId: { type: String, required: false },
|
||||
pendingPaymentBillingMonths: { type: Number, required: false },
|
||||
pendingPaymentType: { type: String, required: false, enum: ["extension", "upgrade"] },
|
||||
pendingPaymentPlanId: { type: Schema.Types.ObjectId, required: false, ref: "Plan" },
|
||||
pendingPaymentCurrentPlanPrice: { type: Number, required: false },
|
||||
pendingPaymentRequestedPlanPrice: { type: Number, required: false },
|
||||
pendingPaymentProratedAmount: { type: Number, required: false },
|
||||
pendingPaymentRemainingDays: { type: Number, required: false },
|
||||
pendingPaymentPeriodEndDate: { type: Date, required: false },
|
||||
lastPaymentStatus: { type: String, required: false, enum: ["rejected", "failed", "cancelled"] },
|
||||
lastPaymentPreferenceId: { type: String, required: false },
|
||||
lastPaymentAt: { type: Date, required: false },
|
||||
lastPaymentType: { type: String, required: false, enum: ["new", "extension", "upgrade"] },
|
||||
});
|
||||
|
||||
this.planSuscriptionList = model<IPlanSuscriptionDocument>("PlanSuscription", this.schema);
|
||||
|
||||
@@ -25,6 +25,17 @@ export type CreatePlanSuscriptionParams = {
|
||||
pendingPaymentInitPoint?: string;
|
||||
pendingPaymentPreferenceId?: string;
|
||||
pendingPaymentBillingMonths?: number;
|
||||
pendingPaymentType?: "extension" | "upgrade";
|
||||
pendingPaymentPlanId?: string;
|
||||
pendingPaymentCurrentPlanPrice?: number;
|
||||
pendingPaymentRequestedPlanPrice?: number;
|
||||
pendingPaymentProratedAmount?: number;
|
||||
pendingPaymentRemainingDays?: number;
|
||||
pendingPaymentPeriodEndDate?: Date;
|
||||
lastPaymentStatus?: LastPlanPaymentStatus;
|
||||
lastPaymentPreferenceId?: string;
|
||||
lastPaymentAt?: Date;
|
||||
lastPaymentType?: LastPlanPaymentType;
|
||||
};
|
||||
|
||||
export type CancellPlanSuscriptionParams = {
|
||||
@@ -75,6 +86,22 @@ export type GetSubscriptionInitPointParams = {
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type VerifyPendingPlanPaymentParams = {
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type VerifyPendingPlanPaymentResponse = {
|
||||
status: "approved" | "pending" | "not_found" | "rejected" | "failed" | "cancelled";
|
||||
approved: boolean;
|
||||
message: string;
|
||||
subscriptionId?: string;
|
||||
paymentId?: string;
|
||||
paymentType?: "new" | "extension" | "upgrade";
|
||||
};
|
||||
|
||||
export type LastPlanPaymentStatus = "rejected" | "failed" | "cancelled";
|
||||
export type LastPlanPaymentType = "new" | "extension" | "upgrade";
|
||||
|
||||
export interface GetSuscriptionInitPointResponse {
|
||||
init_point: string;
|
||||
}
|
||||
@@ -99,6 +126,17 @@ export interface IPlanSuscription {
|
||||
pendingPaymentInitPoint?: string; // URL de pago pendiente para extender el plan actual.
|
||||
pendingPaymentPreferenceId?: string; // ID de preferencia pendiente para extender el plan actual.
|
||||
pendingPaymentBillingMonths?: number; // Meses pendientes de acreditar al aprobarse la extensión.
|
||||
pendingPaymentType?: "extension" | "upgrade";
|
||||
pendingPaymentPlanId?: string;
|
||||
pendingPaymentCurrentPlanPrice?: number;
|
||||
pendingPaymentRequestedPlanPrice?: number;
|
||||
pendingPaymentProratedAmount?: number;
|
||||
pendingPaymentRemainingDays?: number;
|
||||
pendingPaymentPeriodEndDate?: Date;
|
||||
lastPaymentStatus?: LastPlanPaymentStatus;
|
||||
lastPaymentPreferenceId?: string;
|
||||
lastPaymentAt?: Date;
|
||||
lastPaymentType?: LastPlanPaymentType;
|
||||
}
|
||||
|
||||
export interface ISubscriptionInfo {
|
||||
@@ -110,6 +148,13 @@ export interface ISubscriptionInfo {
|
||||
autoRenew: boolean;
|
||||
mpStatus: string;
|
||||
mpDateCreated: Date;
|
||||
pendingPaymentInitPoint?: string;
|
||||
pendingPaymentPreferenceId?: string;
|
||||
pendingPaymentType?: "extension" | "upgrade";
|
||||
lastPaymentStatus?: LastPlanPaymentStatus;
|
||||
lastPaymentPreferenceId?: string;
|
||||
lastPaymentAt?: Date;
|
||||
lastPaymentType?: LastPlanPaymentType;
|
||||
}
|
||||
|
||||
export interface IPlanSuscriptionsAdapter {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
const DEFAULT_BILLING_PERIOD_DAYS = 30;
|
||||
|
||||
export function calculateProratedUpgradeAmount(
|
||||
currentPlanPrice: number,
|
||||
requestedPlanPrice: number,
|
||||
remainingDays: number,
|
||||
billingPeriodDays = DEFAULT_BILLING_PERIOD_DAYS
|
||||
): number {
|
||||
const dailyDifference = (requestedPlanPrice - currentPlanPrice) / billingPeriodDays;
|
||||
return Math.round(dailyDifference * remainingDays * 100) / 100;
|
||||
}
|
||||
@@ -31,10 +31,13 @@ import { NotificationsManager } from "../Notifications/Notifications";
|
||||
import WapServerList from "../WapServer/WapServer";
|
||||
import CompaniesList from "../Companies/Companies";
|
||||
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
|
||||
import { calculateProratedUpgradeAmount } from "./PlanSubscriptions.pricing";
|
||||
|
||||
dayjs.locale("es");
|
||||
dayjs.extend(isSameOrAfter);
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
planSuscriptions: PlanSuscriptionsAdapterMongoose;
|
||||
|
||||
@@ -46,6 +49,20 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
return subscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED || subscription.mpStatus === MP_SUBS_STATUS.CANCELLED;
|
||||
}
|
||||
|
||||
private buildMercadoPagoBackUrl(returnStatus: "success" | "failure" | "pending"): string {
|
||||
const backUrl = process.env.MP_BACK_URL || "";
|
||||
if (!backUrl) return backUrl;
|
||||
|
||||
try {
|
||||
const url = new URL(backUrl);
|
||||
url.searchParams.set("mp_return", returnStatus);
|
||||
return url.toString();
|
||||
} catch {
|
||||
const separator = backUrl.includes("?") ? "&" : "?";
|
||||
return `${backUrl}${separator}mp_return=${returnStatus}`;
|
||||
}
|
||||
}
|
||||
|
||||
public async getInitPoint(
|
||||
data: GetSubscriptionInitPointParams
|
||||
): Promise<GetSuscriptionInitPointResponse> {
|
||||
@@ -190,6 +207,23 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
months: data.months || 1,
|
||||
});
|
||||
} else {
|
||||
const isActivePaidSubscription =
|
||||
actualPlan.price > 0 &&
|
||||
(subscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
|
||||
subscription.mpStatus === MP_SUBS_STATUS.CANCELLED) &&
|
||||
dayjs(subscription.endDate).isAfter(new Date());
|
||||
|
||||
if (isActivePaidSubscription && actualPlan.id !== data.planId) {
|
||||
if (plan.price <= actualPlan.price) {
|
||||
throw new Error("No se puede cambiar a un plan igual o inferior desde esta ruta de actualización.");
|
||||
}
|
||||
|
||||
subscription = await this.toMP({
|
||||
planId: data.planId,
|
||||
sessionUser: data.sessionUser,
|
||||
months: data.months || 1,
|
||||
});
|
||||
} else {
|
||||
if (subscription.mpStatus != MP_SUBS_STATUS.CANCELLED && actualPlan.id !== data.planId) {
|
||||
throw new Error(
|
||||
"No se puede cambiar el plan, ya que el usuario ya tiene una suscripción activa. Para cambiarlo primero debes cancelar tu plan actual."
|
||||
@@ -201,6 +235,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
sessionUser: data.sessionUser,
|
||||
months: data.months || 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,6 +284,42 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
sessionUser: data.sessionUser,
|
||||
});
|
||||
|
||||
const currentPlan = currentSubscription
|
||||
? await PlansList.plans.findOne({ _id: String(currentSubscription.planId) })
|
||||
: null;
|
||||
|
||||
const isUpgradePayment =
|
||||
currentSubscription &&
|
||||
currentPlan &&
|
||||
String(currentSubscription.planId) !== String(data.planId) &&
|
||||
currentPlan.price > 0 &&
|
||||
plan.price > currentPlan.price &&
|
||||
(currentSubscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
|
||||
currentSubscription.mpStatus === MP_SUBS_STATUS.CANCELLED) &&
|
||||
dayjs(currentSubscription.endDate).isAfter(preferenceDate);
|
||||
|
||||
const remainingDays = isUpgradePayment
|
||||
? Math.max(0, Math.ceil((new Date(currentSubscription.endDate).getTime() - preferenceDate.toDate().getTime()) / DAY_MS))
|
||||
: 0;
|
||||
const proratedAmount = isUpgradePayment
|
||||
? calculateProratedUpgradeAmount(currentPlan.price, plan.price, remainingDays)
|
||||
: 0;
|
||||
|
||||
if (isUpgradePayment && remainingDays <= 0) {
|
||||
throw new Error("No se puede actualizar el plan porque la suscripción actual no tiene días restantes.");
|
||||
}
|
||||
|
||||
if (
|
||||
currentSubscription?.pendingPaymentPreferenceId &&
|
||||
currentSubscription.pendingPaymentInitPoint
|
||||
) {
|
||||
if (String(currentSubscription.pendingPaymentPlanId || currentSubscription.planId) === String(data.planId)) {
|
||||
return currentSubscription;
|
||||
}
|
||||
|
||||
throw new Error("Ya tenés un pago pendiente para tu plan. Verificalo o esperá a que se actualice antes de intentar otro cambio.");
|
||||
}
|
||||
|
||||
if (
|
||||
currentSubscription &&
|
||||
String(currentSubscription.planId) === String(data.planId) &&
|
||||
@@ -265,16 +336,18 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
{
|
||||
items: [
|
||||
{
|
||||
title: "TurnosXpress.com.ar - Plan " + plan.name + " (" + data.months + " meses)",
|
||||
title: isUpgradePayment
|
||||
? "TurnosXpress.com.ar - Upgrade al plan " + plan.name
|
||||
: "TurnosXpress.com.ar - Plan " + plan.name + " (" + data.months + " meses)",
|
||||
quantity: 1,
|
||||
currency_id: "ARS",
|
||||
unit_price: finalPrice
|
||||
unit_price: isUpgradePayment ? proratedAmount : finalPrice
|
||||
}
|
||||
],
|
||||
back_urls: {
|
||||
success: process.env.MP_BACK_URL,
|
||||
failure: process.env.MP_BACK_URL,
|
||||
pending: process.env.MP_BACK_URL
|
||||
success: this.buildMercadoPagoBackUrl("success"),
|
||||
failure: this.buildMercadoPagoBackUrl("failure"),
|
||||
pending: this.buildMercadoPagoBackUrl("pending")
|
||||
},
|
||||
auto_return: "approved",
|
||||
payer: {
|
||||
@@ -293,6 +366,35 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
|
||||
const subscriptionData = response.data;
|
||||
|
||||
if (isUpgradePayment && currentSubscription && currentPlan) {
|
||||
currentSubscription.pendingPaymentInitPoint = subscriptionData.init_point;
|
||||
currentSubscription.pendingPaymentPreferenceId = subscriptionData.id;
|
||||
currentSubscription.pendingPaymentBillingMonths = undefined;
|
||||
currentSubscription.pendingPaymentType = "upgrade";
|
||||
currentSubscription.pendingPaymentPlanId = data.planId;
|
||||
currentSubscription.pendingPaymentCurrentPlanPrice = currentPlan.price;
|
||||
currentSubscription.pendingPaymentRequestedPlanPrice = plan.price;
|
||||
currentSubscription.pendingPaymentProratedAmount = proratedAmount;
|
||||
currentSubscription.pendingPaymentRemainingDays = remainingDays;
|
||||
currentSubscription.pendingPaymentPeriodEndDate = currentSubscription.endDate;
|
||||
currentSubscription.lastPaymentStatus = undefined;
|
||||
currentSubscription.lastPaymentPreferenceId = undefined;
|
||||
currentSubscription.lastPaymentAt = undefined;
|
||||
currentSubscription.lastPaymentType = undefined;
|
||||
currentSubscription.mpInitPoint = subscriptionData.init_point;
|
||||
await currentSubscription.save();
|
||||
|
||||
await NotificationsManager.sendSystemNotification({
|
||||
userId: String(data.sessionUser),
|
||||
subject: "Se ha creado una actualización de suscripción",
|
||||
message: `Se ha creado una actualización al plan ${plan.name}.`,
|
||||
type: NotificationType.BILLING,
|
||||
code: String(currentSubscription._id)
|
||||
});
|
||||
|
||||
return currentSubscription;
|
||||
}
|
||||
|
||||
if (
|
||||
currentSubscription &&
|
||||
String(currentSubscription.planId) === String(data.planId) &&
|
||||
@@ -302,6 +404,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
currentSubscription.pendingPaymentInitPoint = subscriptionData.init_point;
|
||||
currentSubscription.pendingPaymentPreferenceId = subscriptionData.id;
|
||||
currentSubscription.pendingPaymentBillingMonths = data.months;
|
||||
currentSubscription.pendingPaymentType = "extension";
|
||||
currentSubscription.lastPaymentStatus = undefined;
|
||||
currentSubscription.lastPaymentPreferenceId = undefined;
|
||||
currentSubscription.lastPaymentAt = undefined;
|
||||
currentSubscription.lastPaymentType = undefined;
|
||||
currentSubscription.mpInitPoint = subscriptionData.init_point;
|
||||
await currentSubscription.save();
|
||||
|
||||
@@ -335,6 +442,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
pendingPaymentInitPoint: "",
|
||||
pendingPaymentPreferenceId: "",
|
||||
pendingPaymentBillingMonths: undefined,
|
||||
pendingPaymentType: undefined,
|
||||
lastPaymentStatus: undefined,
|
||||
lastPaymentPreferenceId: undefined,
|
||||
lastPaymentAt: undefined,
|
||||
lastPaymentType: undefined,
|
||||
});
|
||||
|
||||
await NotificationsManager.sendSystemNotification({
|
||||
@@ -385,7 +497,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
const dateEnd = dayjs(subscription.endDate);
|
||||
|
||||
if (dateNow.isSameOrAfter(dateEnd)) {
|
||||
this.toFreePlan({
|
||||
await this.toFreePlan({
|
||||
sessionUser: data.sessionUser,
|
||||
});
|
||||
} else {
|
||||
@@ -393,6 +505,16 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
subscription.mpInitPoint = "";
|
||||
subscription.mpPayerEmail = "";
|
||||
subscription.mpPayerId = -1;
|
||||
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;
|
||||
await subscription.save();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -462,6 +584,13 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
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.startDate = new Date();
|
||||
subscription.endDate = dayjs(new Date()).add(10, "years").toDate();
|
||||
|
||||
@@ -488,6 +617,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
autoRenew: subscription.autoRenew,
|
||||
mpStatus: subscription.mpStatus,
|
||||
mpDateCreated: subscription.mpDateCreated,
|
||||
lastPaymentStatus: subscription.lastPaymentStatus,
|
||||
lastPaymentPreferenceId: subscription.lastPaymentPreferenceId,
|
||||
lastPaymentAt: subscription.lastPaymentAt,
|
||||
lastPaymentType: subscription.lastPaymentType,
|
||||
};
|
||||
|
||||
io.to(`user:${String(subscription.userId)}`).emit("suscription_status_updated", susInfo);
|
||||
@@ -539,7 +672,9 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
_id: subscription.planId,
|
||||
});
|
||||
|
||||
if (!subscription.isActive || subscription.mpStatus === MP_SUBS_STATUS.PENDING) {
|
||||
const isPendingPaidSubscription = subscription.mpStatus === MP_SUBS_STATUS.PENDING && plan.price > 0;
|
||||
|
||||
if (!isPendingPaidSubscription && (!subscription.isActive || subscription.mpStatus === MP_SUBS_STATUS.PENDING)) {
|
||||
const freePlan = await PlansList.findOne({ price: 0 });
|
||||
if (freePlan) {
|
||||
plan = freePlan;
|
||||
@@ -559,6 +694,13 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
autoRenew: subscription.autoRenew,
|
||||
mpStatus: subscription.mpStatus,
|
||||
mpDateCreated: subscription.mpDateCreated,
|
||||
pendingPaymentInitPoint: subscription.pendingPaymentInitPoint,
|
||||
pendingPaymentPreferenceId: subscription.pendingPaymentPreferenceId,
|
||||
pendingPaymentType: subscription.pendingPaymentType,
|
||||
lastPaymentStatus: subscription.lastPaymentStatus,
|
||||
lastPaymentPreferenceId: subscription.lastPaymentPreferenceId,
|
||||
lastPaymentAt: subscription.lastPaymentAt,
|
||||
lastPaymentType: subscription.lastPaymentType,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import dayjs from "dayjs";
|
||||
import { calculateProratedUpgradeAmount } from "../PlanSubscriptions.pricing";
|
||||
import PlanSubscriptionsList from "../PlanSubscriptons";
|
||||
import { MP_SUBS_STATUS } from "../PlanSubscriptions.Adapter.Mongoose";
|
||||
import PlansList from "../../Plans/Plans";
|
||||
|
||||
jest.mock("../../../index", () => ({
|
||||
io: {
|
||||
to: jest.fn(() => ({ emit: jest.fn() })),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("PlanSubscriptions Logic", () => {
|
||||
it("should calculate correct endDate for 1, 3, 6, 12 months", () => {
|
||||
@@ -40,4 +50,71 @@ describe("PlanSubscriptions Logic", () => {
|
||||
const final12 = total12 - (total12 * (30 / 100)); // 12000 - 3600 = 8400
|
||||
expect(final12).toBe(8400);
|
||||
});
|
||||
|
||||
it("should calculate only the prorated difference for paid plan upgrades", () => {
|
||||
const currentPlanPrice = 1000;
|
||||
const requestedPlanPrice = 2500;
|
||||
const remainingDays = 15;
|
||||
|
||||
const proratedAmount = calculateProratedUpgradeAmount(
|
||||
currentPlanPrice,
|
||||
requestedPlanPrice,
|
||||
remainingDays
|
||||
);
|
||||
|
||||
expect(proratedAmount).toBe(750);
|
||||
});
|
||||
|
||||
it("should keep pending paid subscription plan instead of masking it as free", async () => {
|
||||
const paidPlan = {
|
||||
id: "paid-plan-id",
|
||||
name: "Intermedio",
|
||||
description: "",
|
||||
features: [],
|
||||
code: "intermediate",
|
||||
price: 1000,
|
||||
annualPrice: 0,
|
||||
limitOrganizations: 1,
|
||||
limitEmployees: 1,
|
||||
limitServices: 1,
|
||||
limitAppointments: 1,
|
||||
limitClients: 1,
|
||||
limitRepeats: 1,
|
||||
mailNotifications: false,
|
||||
smsNotifications: false,
|
||||
wapNotifications: false,
|
||||
bot: false,
|
||||
active: true,
|
||||
dateLimit: false,
|
||||
payments: false,
|
||||
};
|
||||
const subscription = {
|
||||
id: "subscription-id",
|
||||
planId: paidPlan.id,
|
||||
startDate: new Date(),
|
||||
endDate: new Date(),
|
||||
isActive: false,
|
||||
autoRenew: false,
|
||||
mpStatus: MP_SUBS_STATUS.PENDING,
|
||||
mpDateCreated: new Date(),
|
||||
};
|
||||
|
||||
const originalPlanSuscriptions = PlanSubscriptionsList.planSuscriptions;
|
||||
const findOneSpy = jest.spyOn(PlansList, "findOne").mockResolvedValue(paidPlan);
|
||||
PlanSubscriptionsList.planSuscriptions = {
|
||||
findOne: jest.fn().mockResolvedValue(subscription),
|
||||
} as any;
|
||||
|
||||
try {
|
||||
const result = await PlanSubscriptionsList.getSubscriptionByUser({ sessionUser: "user-id" });
|
||||
|
||||
expect(result?.plan).toEqual(paidPlan);
|
||||
expect(result?.mpStatus).toBe(MP_SUBS_STATUS.PENDING);
|
||||
expect(findOneSpy).toHaveBeenCalledTimes(1);
|
||||
expect(findOneSpy).toHaveBeenCalledWith({ _id: subscription.planId });
|
||||
} finally {
|
||||
PlanSubscriptionsList.planSuscriptions = originalPlanSuscriptions;
|
||||
findOneSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { MercadoPagoWebhookService } from "../MercadoPagoWebhook.Service";
|
||||
import PlanPaymentsList from "../../../Models/PlanPayments/PlanPayments";
|
||||
|
||||
jest.mock("../../../Models/PlanPayments/PlanPayments", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
createIfMissingByTransactionId: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../Models/PlanSubscriptions/PlanSubscriptons", () => ({
|
||||
__esModule: true,
|
||||
default: {},
|
||||
}));
|
||||
|
||||
describe("MercadoPagoWebhookService", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should clear pending upgrade metadata without changing the current plan or dates when payment is rejected", async () => {
|
||||
const startDate = new Date("2026-07-01T00:00:00.000Z");
|
||||
const endDate = new Date("2026-08-01T00:00:00.000Z");
|
||||
const subscription = {
|
||||
_id: "subscription-id",
|
||||
planId: "current-plan-id",
|
||||
startDate,
|
||||
endDate,
|
||||
pendingPaymentInitPoint: "https://checkout.example",
|
||||
pendingPaymentPreferenceId: "preference-id",
|
||||
pendingPaymentBillingMonths: undefined,
|
||||
pendingPaymentType: "upgrade",
|
||||
pendingPaymentPlanId: "requested-plan-id",
|
||||
pendingPaymentCurrentPlanPrice: 1000,
|
||||
pendingPaymentRequestedPlanPrice: 2000,
|
||||
pendingPaymentProratedAmount: 500,
|
||||
pendingPaymentRemainingDays: 15,
|
||||
pendingPaymentPeriodEndDate: endDate,
|
||||
save: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = new MercadoPagoWebhookService() as any;
|
||||
|
||||
await service.applyRejectedPayment(
|
||||
subscription,
|
||||
{
|
||||
id: "payment-id",
|
||||
status: "rejected",
|
||||
transaction_amount: 500,
|
||||
payment_method_id: "visa",
|
||||
},
|
||||
"user-id",
|
||||
"preference-id"
|
||||
);
|
||||
|
||||
expect(subscription.planId).toBe("current-plan-id");
|
||||
expect(subscription.startDate).toBe(startDate);
|
||||
expect(subscription.endDate).toBe(endDate);
|
||||
expect(subscription.pendingPaymentInitPoint).toBe("");
|
||||
expect(subscription.pendingPaymentPreferenceId).toBe("");
|
||||
expect(subscription.pendingPaymentType).toBeUndefined();
|
||||
expect(subscription.pendingPaymentPlanId).toBeUndefined();
|
||||
expect(subscription.pendingPaymentProratedAmount).toBeUndefined();
|
||||
expect(subscription.save).toHaveBeenCalledTimes(1);
|
||||
expect(PlanPaymentsList.createIfMissingByTransactionId).toHaveBeenCalledWith({
|
||||
userId: "user-id",
|
||||
subscriptionId: "subscription-id",
|
||||
amount: 500,
|
||||
paymentDate: expect.any(Date),
|
||||
paymentMethod: "visa",
|
||||
status: "failed",
|
||||
transactionId: "payment-id",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,12 @@ import {
|
||||
GetSubscriptionInitPointParams,
|
||||
GetSuscriptionInitPointResponse,
|
||||
MPPreApprovalResponse,
|
||||
VerifyPendingPlanPaymentParams,
|
||||
VerifyPendingPlanPaymentResponse,
|
||||
} from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
|
||||
import { authenticateMiddleware } from "../../middleware/authentication";
|
||||
import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
|
||||
import { rateLimiter } from "../../middleware/ratelimiter";
|
||||
|
||||
@Route("suscriptions/get-init-point")
|
||||
@Middlewares(authenticateMiddleware)
|
||||
@@ -75,3 +78,24 @@ export class PlanSubscriptionsCancellController extends Controller {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Route("suscriptions/verify-pending-payment")
|
||||
@Middlewares([rateLimiter, authenticateMiddleware])
|
||||
export class PlanSubscriptionsVerifyPendingPaymentController extends Controller {
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async verifyPendingPayment(
|
||||
@Body() requestBody: VerifyPendingPlanPaymentParams
|
||||
): Promise<VerifyPendingPlanPaymentResponse | ApiValidationError> {
|
||||
try {
|
||||
const result = await new PlanSubscriptionsService().verifyPendingPayment(requestBody);
|
||||
this.setStatus(200);
|
||||
return result;
|
||||
} catch (e) {
|
||||
const errorOccurred: Error = e as Error;
|
||||
this.setStatus(500);
|
||||
return new ApiValidationError(500, errorOccurred.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@ import {
|
||||
GetSubscriptionInitPointParams,
|
||||
GetSuscriptionInitPointResponse,
|
||||
MPPreApprovalResponse,
|
||||
VerifyPendingPlanPaymentParams,
|
||||
VerifyPendingPlanPaymentResponse,
|
||||
} from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
|
||||
import { connect } from "mongoose";
|
||||
import { validateSessionUser } from "../../helpers/check";
|
||||
import { MercadoPagoWebhookService } from "../MercadoPago/MercadoPagoWebhook.Service";
|
||||
|
||||
export class PlanSubscriptionsService {
|
||||
public async getInitPoint(
|
||||
@@ -27,4 +31,10 @@ export class PlanSubscriptionsService {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
await PlanSubscriptionsList.cancell(data);
|
||||
}
|
||||
|
||||
public async verifyPendingPayment(data: VerifyPendingPlanPaymentParams): Promise<VerifyPendingPlanPaymentResponse> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
await validateSessionUser({ sessionUser: data.sessionUser });
|
||||
return new MercadoPagoWebhookService().verifyPendingPlanPayment(data.sessionUser);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user