feat: add manual subscription finalization functionality and support for expired plan notifications

This commit is contained in:
2026-07-19 13:20:33 -03:00
parent 78ee86ac48
commit af6c87acb6
20 changed files with 462 additions and 39 deletions
@@ -1,5 +1,4 @@
import dayjs from "dayjs";
import {
FindPlanSuscripcionsParams,
IPlanSuscriptionsAdapter,
@@ -53,6 +52,11 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
lastPaymentPreferenceId: { type: String, required: false },
lastPaymentAt: { type: Date, required: false },
lastPaymentType: { type: String, required: false, enum: ["new", "extension", "upgrade"] },
downgradedFromPlanId: { type: Schema.Types.ObjectId, required: false, ref: "Plan" },
downgradedFromPlanName: { type: String, required: false },
downgradedFromPlanCode: { type: String, required: false },
downgradedAt: { type: Date, required: false },
downgradeReason: { type: String, required: false, enum: ["expired"] },
});
this.planSuscriptionList = model<IPlanSuscriptionDocument>("PlanSuscription", this.schema);
@@ -89,12 +93,12 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
return null;
}
const dateNow = dayjs(new Date());
const dateEnd = dayjs(subscription.endDate);
const dateNow = new Date();
const dateEnd = new Date(subscription.endDate);
try {
if (subscription.mpPreferenceId) {
if (dateNow.isAfter(dateEnd)) {
if (dateNow > dateEnd) {
if (subscription.mpStatus == MP_SUBS_STATUS.CANCELLED) {
subscription.mpPreferenceId = "";
subscription.isActive = false;
@@ -36,6 +36,11 @@ export type CreatePlanSuscriptionParams = {
lastPaymentPreferenceId?: string;
lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType;
downgradedFromPlanId?: string;
downgradedFromPlanName?: string;
downgradedFromPlanCode?: string;
downgradedAt?: Date;
downgradeReason?: DowngradeReason;
};
export type CancellPlanSuscriptionParams = {
@@ -65,6 +70,7 @@ export type MPPreApprovalResponse = {
export type ToFreePlanParams = {
sessionUser: string;
downgradeReason?: DowngradeReason;
};
export type ToMpParams = {
@@ -101,6 +107,7 @@ export type VerifyPendingPlanPaymentResponse = {
export type LastPlanPaymentStatus = "rejected" | "failed" | "cancelled";
export type LastPlanPaymentType = "new" | "extension" | "upgrade";
export type DowngradeReason = "expired";
export interface GetSuscriptionInitPointResponse {
init_point: string;
@@ -137,6 +144,11 @@ export interface IPlanSuscription {
lastPaymentPreferenceId?: string;
lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType;
downgradedFromPlanId?: string;
downgradedFromPlanName?: string;
downgradedFromPlanCode?: string;
downgradedAt?: Date;
downgradeReason?: DowngradeReason;
}
export interface ISubscriptionInfo {
@@ -155,6 +167,11 @@ export interface ISubscriptionInfo {
lastPaymentPreferenceId?: string;
lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType;
downgradedFromPlanId?: string;
downgradedFromPlanName?: string;
downgradedFromPlanCode?: string;
downgradedAt?: Date;
downgradeReason?: DowngradeReason;
}
export interface IPlanSuscriptionsAdapter {
@@ -23,7 +23,6 @@ import {
import axios from "axios";
import PlansList from "../../Models/Plans/Plans";
import dayjs from "dayjs";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
import "dayjs/locale/es";
import { isNull } from "../../helpers/IsNull";
import { PlanFeatures } from "../Plans/Plans.interface";
@@ -34,7 +33,6 @@ import { NotificationType } from "../../Models/SystemNotifications/SystemNotific
import { calculateProratedUpgradeAmount } from "./PlanSubscriptions.pricing";
dayjs.locale("es");
dayjs.extend(isSameOrAfter);
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -195,6 +193,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
subscription.autoRenew = false;
subscription.mpStatus = "";
subscription.mpInitPoint = dashboardUrl || "";
subscription.downgradedFromPlanId = undefined;
subscription.downgradedFromPlanName = undefined;
subscription.downgradedFromPlanCode = undefined;
subscription.downgradedAt = undefined;
subscription.downgradeReason = undefined;
await subscription.save();
}
} else {
@@ -381,6 +384,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
currentSubscription.lastPaymentPreferenceId = undefined;
currentSubscription.lastPaymentAt = undefined;
currentSubscription.lastPaymentType = undefined;
currentSubscription.downgradedFromPlanId = undefined;
currentSubscription.downgradedFromPlanName = undefined;
currentSubscription.downgradedFromPlanCode = undefined;
currentSubscription.downgradedAt = undefined;
currentSubscription.downgradeReason = undefined;
currentSubscription.mpInitPoint = subscriptionData.init_point;
await currentSubscription.save();
@@ -409,6 +417,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
currentSubscription.lastPaymentPreferenceId = undefined;
currentSubscription.lastPaymentAt = undefined;
currentSubscription.lastPaymentType = undefined;
currentSubscription.downgradedFromPlanId = undefined;
currentSubscription.downgradedFromPlanName = undefined;
currentSubscription.downgradedFromPlanCode = undefined;
currentSubscription.downgradedAt = undefined;
currentSubscription.downgradeReason = undefined;
currentSubscription.mpInitPoint = subscriptionData.init_point;
await currentSubscription.save();
@@ -447,6 +460,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
lastPaymentPreferenceId: undefined,
lastPaymentAt: undefined,
lastPaymentType: undefined,
downgradedFromPlanId: undefined,
downgradedFromPlanName: undefined,
downgradedFromPlanCode: undefined,
downgradedAt: undefined,
downgradeReason: undefined,
});
await NotificationsManager.sendSystemNotification({
@@ -493,12 +511,13 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
// We just cancel it locally if not paid, or change to free plan if paid.
}
const dateNow = dayjs(new Date()).endOf("day");
const dateEnd = dayjs(subscription.endDate);
const dateNow = new Date();
const dateEnd = new Date(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd)) {
if (dateNow > dateEnd) {
await this.toFreePlan({
sessionUser: data.sessionUser,
downgradeReason: "expired",
});
} else {
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
@@ -573,6 +592,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
//console.log("guardando el plan gratuito...");
//try {
const downgradedFromPlan = data.downgradeReason === "expired"
? await PlansList.plans.findOne({ _id: String(subscription.planId) })
: null;
console.log("cambiando al free plan:", freePlan.id);
subscription.planId = freePlan.id;
subscription.mpStatus = "";
@@ -593,6 +616,19 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
subscription.pendingPaymentPeriodEndDate = undefined;
subscription.startDate = new Date();
subscription.endDate = dayjs(new Date()).add(10, "years").toDate();
if (data.downgradeReason === "expired" && downgradedFromPlan) {
subscription.downgradedFromPlanId = String(downgradedFromPlan.id || downgradedFromPlan._id);
subscription.downgradedFromPlanName = downgradedFromPlan.name;
subscription.downgradedFromPlanCode = downgradedFromPlan.code;
subscription.downgradedAt = new Date();
subscription.downgradeReason = "expired";
} else {
subscription.downgradedFromPlanId = undefined;
subscription.downgradedFromPlanName = undefined;
subscription.downgradedFromPlanCode = undefined;
subscription.downgradedAt = undefined;
subscription.downgradeReason = undefined;
}
await subscription.save();
// console.log("plan gratuito guardado:", freePlan.id);
@@ -621,6 +657,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
lastPaymentPreferenceId: subscription.lastPaymentPreferenceId,
lastPaymentAt: subscription.lastPaymentAt,
lastPaymentType: subscription.lastPaymentType,
downgradedFromPlanId: subscription.downgradedFromPlanId,
downgradedFromPlanName: subscription.downgradedFromPlanName,
downgradedFromPlanCode: subscription.downgradedFromPlanCode,
downgradedAt: subscription.downgradedAt,
downgradeReason: subscription.downgradeReason,
};
io.to(`user:${String(subscription.userId)}`).emit("suscription_status_updated", susInfo);
@@ -637,12 +678,13 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
return null;
}
const dateNow = dayjs(new Date()).endOf("day");
const dateEnd = dayjs(subscription.endDate);
const dateNow = new Date();
const dateEnd = new Date(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd) && this.shouldDowngradeExpiredSubscription(subscription)) {
if (dateNow > dateEnd && this.shouldDowngradeExpiredSubscription(subscription)) {
await this.toFreePlan({
sessionUser: data.sessionUser,
downgradeReason: "expired",
});
}
@@ -658,12 +700,13 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
return null;
}
const dateNow = dayjs(new Date()).endOf("day");
const dateEnd = dayjs(subscription.endDate);
const dateNow = new Date();
const dateEnd = new Date(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd) && this.shouldDowngradeExpiredSubscription(subscription)) {
if (dateNow > dateEnd && this.shouldDowngradeExpiredSubscription(subscription)) {
await this.toFreePlan({
sessionUser: data.sessionUser,
downgradeReason: "expired",
});
return this.getSubscriptionByUser(data);
}
@@ -701,6 +744,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
lastPaymentPreferenceId: subscription.lastPaymentPreferenceId,
lastPaymentAt: subscription.lastPaymentAt,
lastPaymentType: subscription.lastPaymentType,
downgradedFromPlanId: subscription.downgradedFromPlanId,
downgradedFromPlanName: subscription.downgradedFromPlanName,
downgradedFromPlanCode: subscription.downgradedFromPlanCode,
downgradedAt: subscription.downgradedAt,
downgradeReason: subscription.downgradeReason,
};
}
@@ -228,6 +228,18 @@ export type SysAdminExtendUserSubscriptionResult = {
transactionId?: string;
};
export type SysAdminFinalizeUserSubscriptionParams = {
userId: string;
reason?: string;
};
export type SysAdminFinalizeUserSubscriptionResult = {
userId: string;
subscriptionId: string;
previousEndDate: Date;
newEndDate: Date;
};
export type SysAdminUserSubscriptionPayment = {
id: string;
subscriptionId: string;
@@ -328,6 +328,11 @@ export class MercadoPagoWebhookService {
subscription.lastPaymentPreferenceId = undefined;
subscription.lastPaymentAt = undefined;
subscription.lastPaymentType = undefined;
subscription.downgradedFromPlanId = undefined;
subscription.downgradedFromPlanName = undefined;
subscription.downgradedFromPlanCode = undefined;
subscription.downgradedAt = undefined;
subscription.downgradeReason = undefined;
await subscription.save();
await PlanPaymentsList.createIfMissingByTransactionId({
@@ -4,6 +4,8 @@ import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth";
import {
SysAdminExtendUserSubscriptionParams,
SysAdminExtendUserSubscriptionResult,
SysAdminFinalizeUserSubscriptionParams,
SysAdminFinalizeUserSubscriptionResult,
SysAdminUserSubscriptionDetailsParams,
SysAdminUserSubscriptionDetailsResult,
} from "../../../Models/Users/Users.Interface";
@@ -29,6 +31,23 @@ export class SysAdminSubscriptionsController extends Controller {
}
}
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("finalize-user")
public async finalizeUserSubscription(
@Body() requestBody: SysAdminFinalizeUserSubscriptionParams
): Promise<SysAdminFinalizeUserSubscriptionResult | ApiValidationError> {
try {
const result = await new SysAdminSubscriptionsService().finalizeUserSubscription(requestBody);
this.setStatus(200);
return result;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("user-details")
@@ -6,6 +6,8 @@ import PlansList from "../../../Models/Plans/Plans";
import {
SysAdminExtendUserSubscriptionParams,
SysAdminExtendUserSubscriptionResult,
SysAdminFinalizeUserSubscriptionParams,
SysAdminFinalizeUserSubscriptionResult,
SysAdminUserSubscriptionDetailsParams,
SysAdminUserSubscriptionDetailsResult,
} from "../../../Models/Users/Users.Interface";
@@ -86,6 +88,42 @@ export class SysAdminSubscriptionsService {
};
}
public async finalizeUserSubscription(
params: SysAdminFinalizeUserSubscriptionParams
): Promise<SysAdminFinalizeUserSubscriptionResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
if (!params.userId || !isValidObjectId(params.userId)) {
throw new Error("User id is required");
}
const subscription = await PlanSubscriptionsList.findOne({ sessionUser: params.userId });
if (!subscription) {
throw new Error("No subscription found for user");
}
const previousEndDate = subscription.endDate ? new Date(subscription.endDate) : new Date();
const newEndDate = dayjs().subtract(1, "day").startOf("day").toDate();
const plan = await PlansList.plans.findOne({ _id: String(subscription.planId) });
subscription.endDate = newEndDate;
await subscription.save();
if (plan && plan.price > 0) {
await PlanSubscriptionsList.toFreePlan({
sessionUser: params.userId,
downgradeReason: "expired",
});
}
return {
userId: params.userId,
subscriptionId: String(subscription.id),
previousEndDate,
newEndDate,
};
}
public async getUserSubscriptionDetails(
params: SysAdminUserSubscriptionDetailsParams
): Promise<SysAdminUserSubscriptionDetailsResult> {