feat: plan management

This commit is contained in:
2026-07-17 21:28:41 -03:00
parent d6a4aab52b
commit 8d9a03cd23
21 changed files with 1355 additions and 131 deletions
@@ -0,0 +1,36 @@
import { Document, Model, Schema, model } from "mongoose";
import {
CreatePlanPaymentParams,
FindPlanPaymentsParams,
IPlanPayment,
IPlanPaymentsAdapter,
} from "./PlanPayments.interface";
export interface IPlanPaymentDocument extends Omit<IPlanPayment, "id">, Document {}
export class PlanPaymentsAdapterMongoose implements IPlanPaymentsAdapter {
schema: Schema;
planPaymentList: Model<IPlanPaymentDocument>;
constructor() {
this.schema = new Schema({
userId: { type: Schema.Types.ObjectId, required: true, ref: "User" },
subscriptionId: { type: Schema.Types.ObjectId, required: true, ref: "PlanSuscription" },
amount: { type: Number, required: true, default: 0 },
paymentDate: { type: Date, required: true, default: Date.now },
paymentMethod: { type: String, required: false },
status: { type: String, required: true },
transactionId: { type: String, required: false },
});
this.planPaymentList = model<IPlanPaymentDocument>("PlanPayment", this.schema);
}
public async find(filters: FindPlanPaymentsParams): Promise<IPlanPayment[]> {
return this.planPaymentList.find(filters).exec();
}
public async create(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
return this.planPaymentList.create(data);
}
}
@@ -1,7 +1,12 @@
export type FindPlanPaymentsParams = {
_id?: string;
userId?: string;
subscriptionId?: string;
transactionId?: string;
};
export type CreatePlanPaymentParams = Omit<IPlanPayment, "id">;
export interface IPlanPayment {
id?: string;
userId: string; // Referencia al ID del usuario
@@ -15,9 +20,11 @@ export interface IPlanPayment {
export interface IPlanPaymentsAdapter {
find(filters: FindPlanPaymentsParams): Promise<IPlanPayment[]>;
create(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
}
export interface IPlanPaymentsManager {
planPayments: IPlanPaymentsAdapter;
find(data: FindPlanPaymentsParams): Promise<IPlanPayment[]>;
create(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
}
@@ -0,0 +1,27 @@
import { PlanPaymentsAdapterMongoose } from "./PlanPayments.Adapter.Mongoose";
import {
CreatePlanPaymentParams,
FindPlanPaymentsParams,
IPlanPayment,
IPlanPaymentsManager,
} from "./PlanPayments.interface";
class PlanPaymentsManager implements IPlanPaymentsManager {
planPayments: PlanPaymentsAdapterMongoose;
constructor() {
this.planPayments = new PlanPaymentsAdapterMongoose();
}
public async find(data: FindPlanPaymentsParams): Promise<IPlanPayment[]> {
return this.planPayments.find(data);
}
public async create(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
return this.planPayments.create(data);
}
}
const PlanPaymentsList = new PlanPaymentsManager();
export default PlanPaymentsList;
@@ -38,6 +38,10 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
mpDateCreated: { type: Date, required: false },
mpInitPoint: { type: String, required: false },
mpPreferenceId: { type: String, required: false },
billingMonths: { type: Number, required: false },
pendingPaymentInitPoint: { type: String, required: false },
pendingPaymentPreferenceId: { type: String, required: false },
pendingPaymentBillingMonths: { type: Number, required: false },
});
this.planSuscriptionList = model<IPlanSuscriptionDocument>("PlanSuscription", this.schema);
@@ -54,9 +58,21 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
public async findOne(
filters: FindPlanSuscripcionsParams
): Promise<IPlanSuscriptionDocument | null> {
let subscription = await this.planSuscriptionList
.findOne({ userId: filters.sessionUser })
.exec();
const searchCriteria: Record<string, unknown> = {};
if (filters.sessionUser) {
searchCriteria.userId = filters.sessionUser;
}
if (filters.mpPreferenceId) {
searchCriteria.mpPreferenceId = filters.mpPreferenceId;
}
if (filters.mpStatus) {
searchCriteria.mpStatus = filters.mpStatus;
}
let subscription = await this.planSuscriptionList.findOne(searchCriteria).exec();
if (!subscription) {
return null;
@@ -77,17 +93,7 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
await subscription.save();
return subscription;
}
// Without subscriptions, we wait for the webhook to update the status.
// If it's expired and not updated by webhook, we just cancel it.
if (subscription.mpStatus === MP_SUBS_STATUS.PENDING) {
subscription.mpPreferenceId = "";
subscription.isActive = false;
subscription.autoRenew = false;
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
subscription.mpInitPoint = "";
await subscription.save();
return subscription;
}
// Pending Checkout Preferences do not consume paid time; webhook decides the final status.
}
}
} catch (e) {
@@ -3,6 +3,8 @@ import { IPlanSuscriptionDocument } from "./PlanSubscriptions.Adapter.Mongoose";
export type FindPlanSuscripcionsParams = {
sessionUser: string;
mpPreferenceId?: string;
mpStatus?: string;
};
export type CreatePlanSuscriptionParams = {
@@ -11,6 +13,7 @@ export type CreatePlanSuscriptionParams = {
mpPlanId: string;
startDate: Date;
endDate: Date;
isActive?: boolean;
autoRenew: boolean;
mpPayerId?: number;
mpPayerEmail?: string;
@@ -18,6 +21,10 @@ export type CreatePlanSuscriptionParams = {
mpDateCreated?: Date;
mpInitPoint?: string;
mpPreferenceId?: string;
billingMonths?: number;
pendingPaymentInitPoint?: string;
pendingPaymentPreferenceId?: string;
pendingPaymentBillingMonths?: number;
};
export type CancellPlanSuscriptionParams = {
@@ -88,6 +95,10 @@ export interface IPlanSuscription {
mpDateCreated: Date; // Fecha de creación de la suscripción en MercadoPago.
mpInitPoint: string; // URL de inicio de la suscripción en MercadoPago.
mpPreferenceId: string; // ID de la preferencia en MercadoPago.
billingMonths?: number; // Cantidad de meses comprados para pagos únicos.
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.
}
export interface ISubscriptionInfo {
@@ -42,6 +42,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
this.planSuscriptions = new PlanSuscriptionsAdapterMongoose();
}
private shouldDowngradeExpiredSubscription(subscription: IPlanSuscriptionDocument): boolean {
return subscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED || subscription.mpStatus === MP_SUBS_STATUS.CANCELLED;
}
public async getInitPoint(
data: GetSubscriptionInitPointParams
): Promise<GetSuscriptionInitPointResponse> {
@@ -135,8 +139,9 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
planId: data.planId,
mpPlanId: data.planId,
startDate: subscriptionStartDate.toDate(),
endDate: subscriptionStartDate.clone().add(data.months || 1, "months").toDate(),
autoRenew: true,
endDate: subscriptionStartDate.clone().add(10, "years").toDate(),
autoRenew: false,
mpStatus: "",
mpInitPoint: "/landing/dashboard",
});
} else if (plan.price > 0) {
@@ -168,7 +173,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
if (subscription) {
const subscriptionStartDate = dayjs(new Date());
subscription.endDate = subscriptionStartDate.clone().add(data.months || 1, "months").toDate();
subscription.startDate = subscriptionStartDate.toDate();
subscription.endDate = subscriptionStartDate.clone().add(10, "years").toDate();
subscription.autoRenew = false;
subscription.mpStatus = "";
subscription.mpInitPoint = dashboardUrl || "";
await subscription.save();
}
@@ -227,7 +235,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
}
try {
const subscriptionStartDate = dayjs(new Date());
const preferenceDate = dayjs(new Date());
let discount = 0;
if (data.months === 3) discount = plan.discount3Months || 0;
@@ -237,6 +245,21 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
const totalPrice = plan.price * data.months;
const finalPrice = totalPrice - (totalPrice * (discount / 100));
const currentSubscription = await this.planSuscriptions.findOne({
sessionUser: data.sessionUser,
});
if (
currentSubscription &&
String(currentSubscription.planId) === String(data.planId) &&
(currentSubscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
currentSubscription.mpStatus === MP_SUBS_STATUS.CANCELLED) &&
currentSubscription.pendingPaymentPreferenceId &&
currentSubscription.pendingPaymentInitPoint
) {
return currentSubscription;
}
const response = await axios.post(
"https://api.mercadopago.com/checkout/preferences",
{
@@ -270,20 +293,48 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
const subscriptionData = response.data;
this.planSuscriptions.delete({ sessionUser: data.sessionUser });
if (
currentSubscription &&
String(currentSubscription.planId) === String(data.planId) &&
(currentSubscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
currentSubscription.mpStatus === MP_SUBS_STATUS.CANCELLED)
) {
currentSubscription.pendingPaymentInitPoint = subscriptionData.init_point;
currentSubscription.pendingPaymentPreferenceId = subscriptionData.id;
currentSubscription.pendingPaymentBillingMonths = data.months;
currentSubscription.mpInitPoint = subscriptionData.init_point;
await currentSubscription.save();
await NotificationsManager.sendSystemNotification({
userId: String(data.sessionUser),
subject: "Se ha creado una nueva extensión de suscripción",
message: `Se ha creado una nueva extensión para el plan ${plan.name}.`,
type: NotificationType.BILLING,
code: String(currentSubscription._id)
});
return currentSubscription;
}
await this.planSuscriptions.delete({ sessionUser: data.sessionUser });
const subscription = await this.planSuscriptions.create({
userId: data.sessionUser,
planId: data.planId,
mpPlanId: data.planId,
startDate: subscriptionStartDate.toDate(),
endDate: subscriptionStartDate.clone().add(data.months, "months").toDate(),
startDate: preferenceDate.toDate(),
endDate: preferenceDate.toDate(),
isActive: false,
autoRenew: false,
mpPayerEmail: sessionUser.mpPayerEmail,
mpStatus: MP_SUBS_STATUS.PENDING,
mpDateCreated: new Date(),
mpInitPoint: subscriptionData.init_point,
mpPreferenceId: subscriptionData.id,
billingMonths: data.months,
pendingPaymentInitPoint: "",
pendingPaymentPreferenceId: "",
pendingPaymentBillingMonths: undefined,
});
await NotificationsManager.sendSystemNotification({
@@ -404,8 +455,15 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
subscription.planId = freePlan.id;
subscription.mpStatus = "";
subscription.isActive = true;
subscription.autoRenew = false;
subscription.mpInitPoint = "";
subscription.mpPreferenceId = "";
subscription.billingMonths = undefined;
subscription.pendingPaymentInitPoint = "";
subscription.pendingPaymentPreferenceId = "";
subscription.pendingPaymentBillingMonths = undefined;
subscription.startDate = new Date();
subscription.endDate = dayjs(new Date()).add(10, "years").toDate();
await subscription.save();
// console.log("plan gratuito guardado:", freePlan.id);
@@ -449,12 +507,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
const dateNow = dayjs(new Date()).endOf("day");
const dateEnd = dayjs(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd)) {
if (subscription.mpStatus === MP_SUBS_STATUS.CANCELLED) {
this.toFreePlan({
sessionUser: data.sessionUser,
});
}
if (dateNow.isSameOrAfter(dateEnd) && this.shouldDowngradeExpiredSubscription(subscription)) {
await this.toFreePlan({
sessionUser: data.sessionUser,
});
}
return await this.planSuscriptions.findOne(data);
@@ -472,23 +528,24 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
const dateNow = dayjs(new Date()).endOf("day");
const dateEnd = dayjs(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd)) {
//Esta condicion era la culpable de que todo el tiempo se queria aplicar el free plan.
//Ya que la fecha de finalizacion queda guardada en la base de datos a pesar de que ya no se use.
if (
subscription.mpStatus !== MP_SUBS_STATUS.AUTHORIZED &&
subscription.mpStatus !== ""
) {
this.toFreePlan({
sessionUser: data.sessionUser,
});
}
if (dateNow.isSameOrAfter(dateEnd) && this.shouldDowngradeExpiredSubscription(subscription)) {
await this.toFreePlan({
sessionUser: data.sessionUser,
});
return this.getSubscriptionByUser(data);
}
const plan = await PlansList.plans.findOne({
let plan = await PlansList.findOne({
_id: subscription.planId,
});
if (!subscription.isActive || subscription.mpStatus === MP_SUBS_STATUS.PENDING) {
const freePlan = await PlansList.findOne({ price: 0 });
if (freePlan) {
plan = freePlan;
}
}
if (!plan) {
throw new Error("No se ha encontrado el plan");
}
@@ -514,6 +571,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
}
const plan = subscriptionInfo.plan;
if (!subscriptionInfo.isActive) {
return false;
}
if (!plan) {
throw new Error("No se encontro el plan de la suscripcion");
}
@@ -553,7 +614,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
if (!subscriptionInfo) {
throw new Error("No se encontro una suscripcion activa");
}
return subscriptionInfo.isActive;
return subscriptionInfo.isActive && subscriptionInfo.mpStatus !== MP_SUBS_STATUS.PENDING;
}
}