feat: implement sysadmin subscription management and plan usage cycle recalculation tools

This commit is contained in:
2026-07-18 21:32:47 -03:00
parent 1c8c47cf76
commit 4f3b56784e
36 changed files with 2146 additions and 21 deletions
@@ -0,0 +1,29 @@
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
import { ApiValidationError } from "../../../Models/Server.Error.model";
import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth";
import {
SysAdminRecalculatePlanUsageCycleParams,
SysAdminRecalculatePlanUsageCycleResult,
} from "../../../Models/PlanUsageCycle/PlanUsageCycle.Interface";
import { SysAdminPlanUsageCycleService } from "./SysAdminPlanUsageCycle.Service";
@Route("sysadmin/plan-usage-cycle")
@Middlewares(sysAdminAuthMiddleware)
export class SysAdminPlanUsageCycleController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("recalculate-user")
public async recalculateUserUsageCycle(
@Body() requestBody: SysAdminRecalculatePlanUsageCycleParams
): Promise<SysAdminRecalculatePlanUsageCycleResult | ApiValidationError> {
try {
const result = await new SysAdminPlanUsageCycleService().recalculateUserUsageCycle(requestBody);
this.setStatus(200);
return result;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -0,0 +1,49 @@
import { connect } from "mongoose";
import dayjs from "dayjs";
import AppointmentList from "../../../Models/Appointments/Appointments";
import CompaniesList from "../../../Models/Companies/Companies";
import PlanUsageCycleList from "../../../Models/PlanUsageCycle/PlanUsageCycle";
import {
SysAdminRecalculatePlanUsageCycleParams,
SysAdminRecalculatePlanUsageCycleResult,
} from "../../../Models/PlanUsageCycle/PlanUsageCycle.Interface";
export class SysAdminPlanUsageCycleService {
public async recalculateUserUsageCycle(
params: SysAdminRecalculatePlanUsageCycleParams
): Promise<SysAdminRecalculatePlanUsageCycleResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
const usageCycle = await PlanUsageCycleList.getCurrentCycle({ userId: params.userId });
const companies = await CompaniesList.companies.find({ ownerId: params.userId });
const companyIds = companies.map((company) => String(company.id));
const appointmentsCount = companyIds.length === 0
? 0
: await AppointmentList.Appointments.AppointmentList.countDocuments({
companyId: { $in: companyIds },
creationDate: {
$gte: dayjs(usageCycle.cycleStart).toDate(),
$lt: dayjs(usageCycle.cycleEnd).toDate(),
},
});
const updatedCycle = await PlanUsageCycleList.setAppointmentsCount({
userId: params.userId,
appointmentsCount,
});
if (!updatedCycle) {
throw new Error("No se pudo recalcular el uso del plan.");
}
return {
userId: params.userId,
subscriptionId: String(updatedCycle.subscriptionId),
planId: String(updatedCycle.planId),
cycleStart: updatedCycle.cycleStart,
cycleEnd: updatedCycle.cycleEnd,
appointmentsCount: updatedCycle.appointmentsCount,
};
}
}
@@ -0,0 +1,44 @@
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
import { ApiValidationError } from "../../../Models/Server.Error.model";
import { ApiVoidResult } from "../../../Models/Api.VoidResult.type";
import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth";
import { IPlan, SysAdminUpdatePlanParams } from "../../../Models/Plans/Plans.interface";
import { SysAdminPlansService } from "./SysAdminPlans.Service";
@Route("sysadmin/plans")
@Middlewares(sysAdminAuthMiddleware)
export class SysAdminPlansController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("list")
public async listPlans(): Promise<IPlan[] | ApiValidationError> {
try {
const plans = await new SysAdminPlansService().listPlans();
this.setStatus(200);
return plans;
} 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, "El plan fue modificado con éxito")
@Post("update")
public async updatePlan(@Body() requestBody: SysAdminUpdatePlanParams): Promise<ApiVoidResult | ApiValidationError> {
try {
await new SysAdminPlansService().updatePlan(requestBody);
this.setStatus(200);
return {
success: true,
message: "Done",
};
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -0,0 +1,83 @@
import PlansList from "../../../Models/Plans/Plans";
import { IPlan, SysAdminUpdatePlanParams } from "../../../Models/Plans/Plans.interface";
import { connect } from "mongoose";
export class SysAdminPlansService {
public async listPlans(): Promise<IPlan[]> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await PlansList.find({});
}
public async updatePlan(data: SysAdminUpdatePlanParams): Promise<void> {
await connect(`${process.env.DATABASE_CONNECTION}`);
await PlansList.sysAdminUpdatePlan(this.sanitizeUpdate(data));
}
private sanitizeUpdate(data: SysAdminUpdatePlanParams): SysAdminUpdatePlanParams {
if (!data.planId) {
throw new Error("Plan id is required");
}
const sanitized: SysAdminUpdatePlanParams = { planId: data.planId };
const stringFields: (keyof SysAdminUpdatePlanParams)[] = ["name", "description", "code"];
const nonNegativeNumberFields: (keyof SysAdminUpdatePlanParams)[] = [
"price",
"annualPrice",
"discount3Months",
"discount6Months",
"discount12Months",
];
const limitFields: (keyof SysAdminUpdatePlanParams)[] = [
"limitOrganizations",
"limitEmployees",
"limitServices",
"limitAppointments",
"limitClients",
"limitRepeats",
];
const booleanFields: (keyof SysAdminUpdatePlanParams)[] = [
"mailNotifications",
"smsNotifications",
"wapNotifications",
"payments",
"bot",
"active",
"dateLimit",
"featured",
];
for (const field of stringFields) {
const value = data[field];
if (typeof value === "string") {
sanitized[field] = value.trim() as never;
}
}
for (const field of nonNegativeNumberFields) {
const value = data[field];
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
sanitized[field] = value as never;
}
}
for (const field of limitFields) {
const value = data[field];
if (typeof value === "number" && Number.isFinite(value)) {
sanitized[field] = value as never;
}
}
for (const field of booleanFields) {
const value = data[field];
if (typeof value === "boolean") {
sanitized[field] = value as never;
}
}
if (Array.isArray(data.features)) {
sanitized.features = data.features.filter((feature) => typeof feature === "string").map((feature) => feature.trim()).filter(Boolean);
}
return sanitized;
}
}
@@ -0,0 +1,48 @@
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
import { ApiValidationError } from "../../../Models/Server.Error.model";
import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth";
import {
SysAdminExtendUserSubscriptionParams,
SysAdminExtendUserSubscriptionResult,
SysAdminUserSubscriptionDetailsParams,
SysAdminUserSubscriptionDetailsResult,
} from "../../../Models/Users/Users.Interface";
import { SysAdminSubscriptionsService } from "./SysAdminSubscriptions.Service";
@Route("sysadmin/subscriptions")
@Middlewares(sysAdminAuthMiddleware)
export class SysAdminSubscriptionsController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("extend-user")
public async extendUserSubscription(
@Body() requestBody: SysAdminExtendUserSubscriptionParams
): Promise<SysAdminExtendUserSubscriptionResult | ApiValidationError> {
try {
const result = await new SysAdminSubscriptionsService().extendUserSubscription(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")
public async getUserSubscriptionDetails(
@Body() requestBody: SysAdminUserSubscriptionDetailsParams
): Promise<SysAdminUserSubscriptionDetailsResult | ApiValidationError> {
try {
const result = await new SysAdminSubscriptionsService().getUserSubscriptionDetails(requestBody);
this.setStatus(200);
return result;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -0,0 +1,147 @@
import { connect, isValidObjectId } from "mongoose";
import dayjs from "dayjs";
import PlanSubscriptionsList from "../../../Models/PlanSubscriptions/PlanSubscriptons";
import PlanPaymentsList from "../../../Models/PlanPayments/PlanPayments";
import PlansList from "../../../Models/Plans/Plans";
import {
SysAdminExtendUserSubscriptionParams,
SysAdminExtendUserSubscriptionResult,
SysAdminUserSubscriptionDetailsParams,
SysAdminUserSubscriptionDetailsResult,
} from "../../../Models/Users/Users.Interface";
export class SysAdminSubscriptionsService {
public async extendUserSubscription(
params: SysAdminExtendUserSubscriptionParams
): Promise<SysAdminExtendUserSubscriptionResult> {
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 now = new Date();
const previousEndDate = subscription.endDate ? new Date(subscription.endDate) : now;
const baseDate = previousEndDate.getTime() > now.getTime() ? previousEndDate : now;
let newEndDate: Date;
if (params.mode === "add_months") {
if (params.months !== 1 && params.months !== 2) {
throw new Error("Months must be 1 or 2");
}
newEndDate = dayjs(baseDate).add(params.months, "month").toDate();
} else if (params.mode === "set_end_date") {
if (!params.endDate) {
throw new Error("End date is required");
}
const parsedEndDate = dayjs(params.endDate);
if (!parsedEndDate.isValid()) {
throw new Error("End date is invalid");
}
newEndDate = parsedEndDate.endOf("day").toDate();
if (newEndDate.getTime() <= now.getTime()) {
throw new Error("End date must be in the future");
}
if (newEndDate.getTime() <= baseDate.getTime()) {
throw new Error("End date must be after the current effective subscription end date");
}
} else {
throw new Error("Invalid extension mode");
}
subscription.endDate = newEndDate;
subscription.isActive = true;
if (subscription.mpStatus) {
subscription.mpStatus = "authorized";
}
await subscription.save();
const transactionId = [
"sysadmin_bonus",
String(subscription.id),
now.getTime(),
].join(":");
const payment = await PlanPaymentsList.createIfMissingByTransactionId({
userId: params.userId,
subscriptionId: String(subscription.id),
amount: 0,
paymentDate: now,
paymentMethod: "sysadmin_bonus",
status: "completed",
transactionId,
});
return {
userId: params.userId,
subscriptionId: String(subscription.id),
previousEndDate,
newEndDate,
paymentId: String(payment.id),
transactionId,
};
}
public async getUserSubscriptionDetails(
params: SysAdminUserSubscriptionDetailsParams
): Promise<SysAdminUserSubscriptionDetailsResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
const subscription = await PlanSubscriptionsList.findOne({ sessionUser: params.userId });
const payments = await PlanPaymentsList.find({ userId: params.userId });
if (!subscription) {
return {
userId: params.userId,
currentSubscription: null,
payments: payments.map((payment) => ({
id: String(payment.id),
subscriptionId: String(payment.subscriptionId),
amount: payment.amount,
paymentDate: payment.paymentDate,
paymentMethod: payment.paymentMethod,
status: payment.status,
transactionId: payment.transactionId,
})),
};
}
const plan = await PlansList.plans.findOne({ _id: String(subscription.planId) });
return {
userId: params.userId,
currentSubscription: {
id: String(subscription.id),
plan: plan
? {
id: String(plan.id),
name: plan.name,
code: plan.code,
price: plan.price,
}
: null,
startDate: subscription.startDate,
endDate: subscription.endDate,
isActive: subscription.isActive,
mpStatus: subscription.mpStatus,
billingMonths: subscription.billingMonths,
pendingPaymentType: subscription.pendingPaymentType,
pendingPaymentPreferenceId: subscription.pendingPaymentPreferenceId,
lastPaymentStatus: subscription.lastPaymentStatus,
},
payments: payments.map((payment) => ({
id: String(payment.id),
subscriptionId: String(payment.subscriptionId),
amount: payment.amount,
paymentDate: payment.paymentDate,
paymentMethod: payment.paymentMethod,
status: payment.status,
transactionId: payment.transactionId,
})),
};
}
}