From af6c87acb69ac6cc97e610220b48a626f4f47a41 Mon Sep 17 00:00:00 2001
From: Horacio Daniel Ros
Date: Sun, 19 Jul 2026 13:20:33 -0300
Subject: [PATCH] feat: add manual subscription finalization functionality and
support for expired plan notifications
---
.../PlanSubscriptions.Adapter.Mongoose.ts | 12 ++--
.../PlanSubscriptions.interface.ts | 17 +++++
.../PlanSubscriptions/PlanSubscriptons.ts | 70 ++++++++++++++++---
server/src/Models/Users/Users.Interface.ts | 12 ++++
.../MercadoPago/MercadoPagoWebhook.Service.ts | 5 ++
.../SysAdminSubscriptions.Controller.ts | 19 +++++
.../SysAdminSubscriptions.Service.ts | 38 ++++++++++
sysadmin-cli/src/api/sysadmin.ts | 17 +++++
.../ui/SubscriptionDetailsModal.tsx | 69 ++++++++++++++++--
sysadmin/src/controllers/users.controller.ts | 12 ++++
sysadmin/src/models/Users.Model.ts | 12 ++++
sysadmin/src/routes/users.routes.ts | 1 +
sysadmin/src/services/users.service.ts | 16 +++++
txclient/.env | 3 +-
txclient/.env.prod | 3 +-
txclient/src/Models/Subscription.Model.ts | 6 ++
.../app/landing/current-plan/page.module.css | 37 ++++++++++
.../src/app/landing/current-plan/page.tsx | 16 +++++
txclient/src/app/landing/dashboard/page.tsx | 68 +++++++++++++-----
.../app/landing/dashboard/style.module.css | 68 ++++++++++++++++++
20 files changed, 462 insertions(+), 39 deletions(-)
diff --git a/server/src/Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose.ts b/server/src/Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose.ts
index 14bcd48..2b88e29 100644
--- a/server/src/Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose.ts
+++ b/server/src/Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose.ts
@@ -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("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;
diff --git a/server/src/Models/PlanSubscriptions/PlanSubscriptions.interface.ts b/server/src/Models/PlanSubscriptions/PlanSubscriptions.interface.ts
index e34ead6..af3bf4f 100644
--- a/server/src/Models/PlanSubscriptions/PlanSubscriptions.interface.ts
+++ b/server/src/Models/PlanSubscriptions/PlanSubscriptions.interface.ts
@@ -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 {
diff --git a/server/src/Models/PlanSubscriptions/PlanSubscriptons.ts b/server/src/Models/PlanSubscriptions/PlanSubscriptons.ts
index 16851db..ba2078d 100644
--- a/server/src/Models/PlanSubscriptions/PlanSubscriptons.ts
+++ b/server/src/Models/PlanSubscriptions/PlanSubscriptons.ts
@@ -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,
};
}
diff --git a/server/src/Models/Users/Users.Interface.ts b/server/src/Models/Users/Users.Interface.ts
index 30f05a5..ce60881 100644
--- a/server/src/Models/Users/Users.Interface.ts
+++ b/server/src/Models/Users/Users.Interface.ts
@@ -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;
diff --git a/server/src/api/MercadoPago/MercadoPagoWebhook.Service.ts b/server/src/api/MercadoPago/MercadoPagoWebhook.Service.ts
index ab78e60..976d6df 100644
--- a/server/src/api/MercadoPago/MercadoPagoWebhook.Service.ts
+++ b/server/src/api/MercadoPago/MercadoPagoWebhook.Service.ts
@@ -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({
diff --git a/server/src/api/SysAdmin/Subscriptions/SysAdminSubscriptions.Controller.ts b/server/src/api/SysAdmin/Subscriptions/SysAdminSubscriptions.Controller.ts
index 9a0052f..0e36928 100644
--- a/server/src/api/SysAdmin/Subscriptions/SysAdminSubscriptions.Controller.ts
+++ b/server/src/api/SysAdmin/Subscriptions/SysAdminSubscriptions.Controller.ts
@@ -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(500, "Ha ocurrido un error")
+ @SuccessResponse(200, "Done")
+ @Post("finalize-user")
+ public async finalizeUserSubscription(
+ @Body() requestBody: SysAdminFinalizeUserSubscriptionParams
+ ): Promise {
+ 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(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("user-details")
diff --git a/server/src/api/SysAdmin/Subscriptions/SysAdminSubscriptions.Service.ts b/server/src/api/SysAdmin/Subscriptions/SysAdminSubscriptions.Service.ts
index 2e5bb82..14ecc7f 100644
--- a/server/src/api/SysAdmin/Subscriptions/SysAdminSubscriptions.Service.ts
+++ b/server/src/api/SysAdmin/Subscriptions/SysAdminSubscriptions.Service.ts
@@ -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 {
+ 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 {
diff --git a/sysadmin-cli/src/api/sysadmin.ts b/sysadmin-cli/src/api/sysadmin.ts
index f60f255..56b1fe9 100644
--- a/sysadmin-cli/src/api/sysadmin.ts
+++ b/sysadmin-cli/src/api/sysadmin.ts
@@ -109,6 +109,23 @@ export const extendUserSubscription = async (data: ExtendUserSubscriptionPayload
return res.data;
};
+export type FinalizeUserSubscriptionPayload = {
+ userId: string;
+ reason?: string;
+};
+
+export type FinalizeUserSubscriptionResult = {
+ userId: string;
+ subscriptionId: string;
+ previousEndDate: string;
+ newEndDate: string;
+};
+
+export const finalizeUserSubscription = async (data: FinalizeUserSubscriptionPayload): Promise => {
+ const res = await sysadminApi.post('/users/finalize-subscription', data);
+ return res.data;
+};
+
export type Plan = {
id?: string;
_id?: string;
diff --git a/sysadmin-cli/src/components/ui/SubscriptionDetailsModal.tsx b/sysadmin-cli/src/components/ui/SubscriptionDetailsModal.tsx
index 185672f..8abee7e 100644
--- a/sysadmin-cli/src/components/ui/SubscriptionDetailsModal.tsx
+++ b/sysadmin-cli/src/components/ui/SubscriptionDetailsModal.tsx
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { CreditCard, X } from 'lucide-react';
-import { extendUserSubscription, getUserSubscriptionDetails, type UserSubscriptionDetails } from '../../api/sysadmin';
+import { extendUserSubscription, finalizeUserSubscription, getUserSubscriptionDetails, type UserSubscriptionDetails } from '../../api/sysadmin';
import { formatDateTime } from '../../utils/formatters';
type Props = {
@@ -17,6 +17,7 @@ export const SubscriptionDetailsModal = ({ user, onClose }: Props) => {
const [extensionLoading, setExtensionLoading] = useState(false);
const [extensionMessage, setExtensionMessage] = useState('');
const [extensionError, setExtensionError] = useState('');
+ const [finalizeLoading, setFinalizeLoading] = useState(false);
const formatAmount = (value?: number) => {
if (typeof value !== 'number') return '-';
@@ -61,6 +62,29 @@ export const SubscriptionDetailsModal = ({ user, onClose }: Props) => {
}
};
+ const handleFinalize = async () => {
+ if (!details?.currentSubscription) return;
+
+ const confirmed = window.confirm('Esto establecerá el vencimiento de la suscripción a ayer para forzar el comportamiento de plan vencido. No elimina datos ni cambia el plan directamente. ¿Continuar?');
+ if (!confirmed) return;
+
+ setFinalizeLoading(true);
+ setExtensionError('');
+ setExtensionMessage('');
+ try {
+ const result = await finalizeUserSubscription({
+ userId: user._id,
+ reason: 'Finalización manual desde sysadmin-cli para probar expiración de plan',
+ });
+ setExtensionMessage(`Período finalizado. El vencimiento quedó en ${formatDateTime(result.newEndDate)} para forzar la expiración en la próxima lectura.`);
+ await loadDetails();
+ } catch (e: any) {
+ setExtensionError(e.response?.data?.message || e.message || 'Error finalizando período');
+ } finally {
+ setFinalizeLoading(false);
+ }
+ };
+
const subscription = details?.currentSubscription;
return (
@@ -118,10 +142,10 @@ export const SubscriptionDetailsModal = ({ user, onClose }: Props) => {
Extiende la vigencia sin pago y registra un pago de bonificación por $0 asociado a esta suscripción.
-
handleExtend({ mode: 'add_months', months: 1 })}>
+ handleExtend({ mode: 'add_months', months: 1 })}>
Extender 1 mes
- handleExtend({ mode: 'add_months', months: 2 })}>
+ handleExtend({ mode: 'add_months', months: 2 })}>
Extender 2 meses
@@ -129,18 +153,27 @@ export const SubscriptionDetailsModal = ({ user, onClose }: Props) => {
type="date"
value={extensionDate}
onChange={(event) => setExtensionDate(event.target.value)}
- disabled={extensionLoading}
+ disabled={extensionLoading || finalizeLoading}
/>
handleExtend({ mode: 'set_end_date', endDate: extensionDate })}
>
Establecer vencimiento
- {extensionLoading && Aplicando ajuste...
}
+
+
+
Finalizar período
+
Establece el vencimiento en ayer para forzar el comportamiento de suscripción vencida en la próxima lectura. No elimina datos ni cambia el plan directamente.
+
+
+ Finalizar período
+
+
+ {(extensionLoading || finalizeLoading) && Aplicando ajuste...
}
{extensionMessage && {extensionMessage}
}
{extensionError && {extensionError}
}
@@ -226,6 +259,30 @@ export const SubscriptionDetailsModal = ({ user, onClose }: Props) => {
color: var(--text-main);
padding: 0.65rem 0.75rem;
}
+ .finalize-action {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: center;
+ margin-top: 1rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--glass-border);
+ }
+ .finalize-action h4 {
+ color: var(--danger);
+ font-size: 1rem;
+ margin-bottom: 0.35rem;
+ }
+ .finalize-action p {
+ color: var(--text-muted);
+ margin: 0;
+ }
+ @media (max-width: 640px) {
+ .finalize-action {
+ align-items: stretch;
+ flex-direction: column;
+ }
+ }
`}} />
diff --git a/sysadmin/src/controllers/users.controller.ts b/sysadmin/src/controllers/users.controller.ts
index e162fb1..77da000 100644
--- a/sysadmin/src/controllers/users.controller.ts
+++ b/sysadmin/src/controllers/users.controller.ts
@@ -8,6 +8,7 @@ import {
SysAdminOrganizationsStatusParams,
SysAdminRecalculatePlanUsageCycleParams,
SysAdminExtendUserSubscriptionParams,
+ SysAdminFinalizeUserSubscriptionParams,
SysAdminUserSubscriptionDetailsParams,
} from "../models/Users.Model";
@@ -101,4 +102,15 @@ export class UsersController {
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
}
};
+
+ public finalizeSubscription = async (req: Request, res: Response): Promise => {
+ try {
+ const data: SysAdminFinalizeUserSubscriptionParams = req.body;
+ const result = await usersService.finalizeSubscription(data);
+ res.json(result);
+ } catch (error: any) {
+ console.error("Error finalizing subscription:", error?.response?.data || error);
+ res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
+ }
+ };
}
diff --git a/sysadmin/src/models/Users.Model.ts b/sysadmin/src/models/Users.Model.ts
index 40e2ea2..de2e3bb 100644
--- a/sysadmin/src/models/Users.Model.ts
+++ b/sysadmin/src/models/Users.Model.ts
@@ -92,6 +92,18 @@ export type SysAdminExtendUserSubscriptionResult = {
transactionId?: string;
};
+export type SysAdminFinalizeUserSubscriptionParams = {
+ userId: string;
+ reason?: string;
+};
+
+export type SysAdminFinalizeUserSubscriptionResult = {
+ userId: string;
+ subscriptionId: string;
+ previousEndDate: string;
+ newEndDate: string;
+};
+
export type SysAdminUserSubscriptionDetailsResult = {
userId: string;
currentSubscription: {
diff --git a/sysadmin/src/routes/users.routes.ts b/sysadmin/src/routes/users.routes.ts
index ff67449..1d44f11 100644
--- a/sysadmin/src/routes/users.routes.ts
+++ b/sysadmin/src/routes/users.routes.ts
@@ -11,6 +11,7 @@ router.post("/organizations-status", usersController.organizationsStatus);
router.post("/recalculate-plan-usage-cycle", usersController.recalculatePlanUsageCycle);
router.post("/subscription-details", usersController.subscriptionDetails);
router.post("/extend-subscription", usersController.extendSubscription);
+router.post("/finalize-subscription", usersController.finalizeSubscription);
router.post("/delete", usersController.deleteUser);
export default router;
diff --git a/sysadmin/src/services/users.service.ts b/sysadmin/src/services/users.service.ts
index 7cbcd01..e06c0e4 100644
--- a/sysadmin/src/services/users.service.ts
+++ b/sysadmin/src/services/users.service.ts
@@ -14,6 +14,8 @@ import {
SysAdminRecalculatePlanUsageCycleResult,
SysAdminExtendUserSubscriptionParams,
SysAdminExtendUserSubscriptionResult,
+ SysAdminFinalizeUserSubscriptionParams,
+ SysAdminFinalizeUserSubscriptionResult,
SysAdminUserSubscriptionDetailsParams,
SysAdminUserSubscriptionDetailsResult,
} from "../models/Users.Model";
@@ -120,4 +122,18 @@ export class UsersService {
return response.data;
}
+
+ public async finalizeSubscription(
+ data: SysAdminFinalizeUserSubscriptionParams
+ ): Promise {
+ const nonce = await getSysAdminNonce();
+ const postData = { ...data, ...{ payload: getPayload(nonce) } };
+
+ const response = await axios.post(`${getApiHost()}/sysadmin/subscriptions/finalize-user`, postData, {
+ headers: { "Content-Type": "application/json" },
+ timeout: 10000,
+ });
+
+ return response.data;
+ }
}
diff --git a/txclient/.env b/txclient/.env
index 5742aef..801abc2 100644
--- a/txclient/.env
+++ b/txclient/.env
@@ -11,4 +11,5 @@ NEXT_PUBLIC_DATAGRID_PAGE_SIZE = 10
NEXT_PUBLIC_SLOGAN = Tu tiempo en orden.
-NEXT_PUBLIC_BASE_URL = https://turnosxpress.com.ar
\ No newline at end of file
+NEXT_PUBLIC_BASE_URL = https://turnosxpress.com.ar
+NEXT_PUBLIC_INFO_EMAIL = info@turnosxpress.com.ar
\ No newline at end of file
diff --git a/txclient/.env.prod b/txclient/.env.prod
index 1cd3191..1859da8 100644
--- a/txclient/.env.prod
+++ b/txclient/.env.prod
@@ -11,4 +11,5 @@ NEXT_PUBLIC_DATAGRID_PAGE_SIZE = 10
NEXT_PUBLIC_SLOGAN = Tu tiempo en orden.
-NEXT_PUBLIC_BASE_URL = https://turnosxpress.com.ar
\ No newline at end of file
+NEXT_PUBLIC_BASE_URL = https://turnosxpress.com.ar
+NEXT_PUBLIC_INFO_EMAIL = info@turnosxpress.com.ar
\ No newline at end of file
diff --git a/txclient/src/Models/Subscription.Model.ts b/txclient/src/Models/Subscription.Model.ts
index d317962..e7f9235 100644
--- a/txclient/src/Models/Subscription.Model.ts
+++ b/txclient/src/Models/Subscription.Model.ts
@@ -63,6 +63,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;
@@ -84,6 +85,11 @@ export interface ISubscriptionInfo {
lastPaymentPreferenceId?: string;
lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType;
+ downgradedFromPlanId?: string;
+ downgradedFromPlanName?: string;
+ downgradedFromPlanCode?: string;
+ downgradedAt?: Date;
+ downgradeReason?: DowngradeReason;
}
export const DefaultSubscriptionModel: ISubscriptionInfo = {
id: "",
diff --git a/txclient/src/app/landing/current-plan/page.module.css b/txclient/src/app/landing/current-plan/page.module.css
index ad0d6eb..f601738 100644
--- a/txclient/src/app/landing/current-plan/page.module.css
+++ b/txclient/src/app/landing/current-plan/page.module.css
@@ -278,6 +278,43 @@
font-weight: 800;
}
+.expiredDowngradeNotice {
+ margin: 0;
+ padding: 18px 20px;
+ border: 1px solid rgba(194, 65, 12, 0.24);
+ border-radius: 18px;
+ background: #fff7ed;
+ color: #7c2d12;
+ box-shadow: 0 12px 28px rgba(194, 65, 12, 0.08);
+}
+
+.expiredDowngradeNotice span {
+ display: inline-flex;
+ width: fit-content;
+ margin-bottom: 8px;
+ padding: 5px 11px;
+ border-radius: 999px;
+ background: rgba(194, 65, 12, 0.12);
+ color: #9a3412;
+ font-size: 12px;
+ font-weight: 900;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.expiredDowngradeNotice strong {
+ display: block;
+ color: #7c2d12;
+ font-size: 18px;
+}
+
+.expiredDowngradeNotice p {
+ margin: 8px 0 0;
+ color: #9a3412;
+ line-height: 1.55;
+ font-weight: 700;
+}
+
.section {
display: flex;
flex-direction: column;
diff --git a/txclient/src/app/landing/current-plan/page.tsx b/txclient/src/app/landing/current-plan/page.tsx
index 6a59d8f..3686275 100644
--- a/txclient/src/app/landing/current-plan/page.tsx
+++ b/txclient/src/app/landing/current-plan/page.tsx
@@ -107,6 +107,11 @@ export default function CurrentPlanPage() {
const plan = subscription?.plan;
const isPaidPlan = Boolean(plan && plan.price > 0);
const statusText = isPaidPlan ? getSubStatusText(subscription?.mpStatus) : "ACTIVO";
+ const showExpiredDowngradeNotice = Boolean(
+ isOwner &&
+ subscription?.downgradeReason === "expired" &&
+ subscription.downgradedAt
+ );
const canRenew = Boolean(
isOwner &&
isPaidPlan &&
@@ -216,6 +221,17 @@ export default function CurrentPlanPage() {
+ {showExpiredDowngradeNotice && (
+
+
Plan vencido
+
Tu plan pago venció y esta organización pasó al plan básico.
+
+ No borramos tu organización ni tus datos. Los límites cambiaron y las nuevas acciones se validan con el plan básico.
+ {subscription.downgradedFromPlanName && ` Tu plan anterior era ${subscription.downgradedFromPlanName}.`}
+
+
+ )}
+
{!isPaidPlan && (
Estás usando el plan gratis. No tiene vencimiento pago.
)}
diff --git a/txclient/src/app/landing/dashboard/page.tsx b/txclient/src/app/landing/dashboard/page.tsx
index f7a59ea..3b04c95 100644
--- a/txclient/src/app/landing/dashboard/page.tsx
+++ b/txclient/src/app/landing/dashboard/page.tsx
@@ -1,5 +1,4 @@
"use client";
-import MetricGraph from "@components/MetricGraph/MetricGraph";
import AnimatedContainer from "@core/app/components/AnimatedConainer/AnimatedContainer";
import Button from "@core/app/components/Button/Button";
import { useNavigation } from "@core/app/hooks/goto";
@@ -36,7 +35,6 @@ import { blurElementsOnLoad } from "@core/helpers/blur";
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
import classNames from "classnames";
import { ScrollToTop } from "@core/app/components/ScrollTop";
-import { isNull } from "@core/app/helpers/IsNull";
import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined";
import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined";
@@ -56,16 +54,8 @@ function DashboardContent() {
const eventHandler = useEventHandlerStore();
const alert = useAlert();
const confirm = useConfirmStore();
+ const infoEmail = process.env.NEXT_PUBLIC_INFO_EMAIL || "info@turnosxpress.com.ar";
- const [metrics, setMetrics] = useState({
- organizationsCount: 0,
- employeesCount: 0,
- clientsCount: 0,
- appointmentsCount: 0,
- servicesCount: 0,
- repeatsCount: 0,
- });
-
const [showOnlyMine, setShowOnlyMine] = useState(false);
const [isAgendaCollapsed, setIsAgendaCollapsed] = useState(true);
@@ -164,6 +154,7 @@ function DashboardContent() {
const loadCompanyMetrics = async (companyId: string) => {
setCompanySubscription(null);
setCompanyMetrics(null);
+ setIsCompanyOwner(false);
try {
const res = await getCompanyMetrics({ sessionUser: SessionInfo.userId, companyId });
setCompanyMetrics(res.metrics);
@@ -315,6 +306,7 @@ function DashboardContent() {
};
const isUsingSessionSubscriptionFallback = !companySubscription && Boolean(subscription.plan.id);
+ const selectedOrganization = organizations.find((organization) => organization.id === selectedOrgId);
const displayedSubscription = companySubscription ?? (subscription.plan.id ? subscription : null);
const displayedPlan = displayedSubscription?.plan;
const displayedPlanStatusText = displayedPlan?.price === 0 ? "ACTIVO" : getSubStatusText(displayedSubscription?.mpStatus);
@@ -325,11 +317,30 @@ function DashboardContent() {
displayedSubscription.lastPaymentAt &&
dayjs().diff(dayjs(displayedSubscription.lastPaymentAt), "day") <= 14
);
- const showPaymentAction = Boolean(
- (displayedSubscription?.mpStatus === MP_SUBS_STATUS.PENDING || hasPendingPaymentMetadata) &&
- (isCompanyOwner || isUsingSessionSubscriptionFallback)
+ const selectedCompanyHasPendingPayment = Boolean(
+ companySubscription &&
+ (companySubscription.mpStatus === MP_SUBS_STATUS.PENDING || companySubscription.pendingPaymentPreferenceId)
);
+ const showPaymentAction = selectedOrgId
+ ? Boolean(selectedCompanyHasPendingPayment && isCompanyOwner)
+ : Boolean(
+ (displayedSubscription?.mpStatus === MP_SUBS_STATUS.PENDING || hasPendingPaymentMetadata) &&
+ isUsingSessionSubscriptionFallback
+ );
const showFailedPaymentCard = Boolean(hasRecentFailedPayment && !showPaymentAction);
+ const showExpiredDowngradeCard = Boolean(
+ displayedSubscription?.downgradeReason === "expired" &&
+ displayedSubscription.downgradedAt &&
+ isCompanyOwner
+ );
+ const selectedCompanyPlanSupportsRepeats = Boolean(
+ companySubscription?.plan &&
+ (companySubscription.plan.limitRepeats === -1 || companySubscription.plan.limitRepeats > 0)
+ );
+ const canLoadPendingRepeats = Boolean(
+ selectedCompanyPlanSupportsRepeats &&
+ (isCompanyOwner || selectedOrganization?.isAdmin)
+ );
const showFreePlanStarterContent = Boolean(displayedPlan && displayedPlan.price === 0 && organizations.length === 0);
const agendaItems = dashboardData?.personalAgenda ?? [];
const filteredAgendaItems = agendaItems.filter(item => !showOnlyMine || item.collaboratorId === SessionInfo.userId);
@@ -365,7 +376,12 @@ function DashboardContent() {
setSelectedOrgId(e.target.value)}
+ onChange={e => {
+ setCompanySubscription(null);
+ setCompanyMetrics(null);
+ setIsCompanyOwner(false);
+ setSelectedOrgId(e.target.value);
+ }}
style={{ padding: "8px 12px", borderRadius: "8px", border: "1px solid #e5e7eb", fontSize: "14px", fontWeight: "500", backgroundColor: "#f9fafb", cursor: "pointer", outline: "none" }}
>
{organizations.map(org => (
@@ -435,6 +451,7 @@ function DashboardContent() {
Si ya pagaste, puede tardar unos minutos en actualizarse porque dependemos de la notificación de MercadoPago.
No te preocupes: podés volver al checkout si quedó incompleto o pedirnos que verifiquemos el pago ahora mismo.
+ {" "}Si el problema continúa, escribinos a {infoEmail} y te ayudamos a revisarlo.
@@ -482,6 +499,25 @@ function DashboardContent() {
)}
+ {showExpiredDowngradeCard && (
+
+
+
Plan vencido
+
Tu plan pago venció y pasaste al plan básico
+
+ No borramos tu organización ni tus datos. Desde ahora las nuevas acciones usan los límites del plan básico.
+ {displayedSubscription?.downgradedFromPlanName && ` Tu plan anterior era ${displayedSubscription.downgradedFromPlanName}.`}
+
+
+
goTo(selectedOrgId ? `/landing/upgrade-plan?org=${selectedOrgId}` : "/landing/pricing")}
+ >
+ Volver a contratar un plan
+
+
+ )}
+
{showFreePlanStarterContent && (
@@ -649,7 +685,7 @@ function DashboardContent() {
)}
{/* Repeats Pending Card */}
- {dashboardData && selectedOrgId && (
+ {dashboardData && selectedOrgId && canLoadPendingRepeats && (