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.

- -
@@ -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} />
- {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.

+
+ +
+ {(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() {