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 { import {
FindPlanSuscripcionsParams, FindPlanSuscripcionsParams,
IPlanSuscriptionsAdapter, IPlanSuscriptionsAdapter,
@@ -53,6 +52,11 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
lastPaymentPreferenceId: { type: String, required: false }, lastPaymentPreferenceId: { type: String, required: false },
lastPaymentAt: { type: Date, required: false }, lastPaymentAt: { type: Date, required: false },
lastPaymentType: { type: String, required: false, enum: ["new", "extension", "upgrade"] }, 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); this.planSuscriptionList = model<IPlanSuscriptionDocument>("PlanSuscription", this.schema);
@@ -89,12 +93,12 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
return null; return null;
} }
const dateNow = dayjs(new Date()); const dateNow = new Date();
const dateEnd = dayjs(subscription.endDate); const dateEnd = new Date(subscription.endDate);
try { try {
if (subscription.mpPreferenceId) { if (subscription.mpPreferenceId) {
if (dateNow.isAfter(dateEnd)) { if (dateNow > dateEnd) {
if (subscription.mpStatus == MP_SUBS_STATUS.CANCELLED) { if (subscription.mpStatus == MP_SUBS_STATUS.CANCELLED) {
subscription.mpPreferenceId = ""; subscription.mpPreferenceId = "";
subscription.isActive = false; subscription.isActive = false;
@@ -36,6 +36,11 @@ export type CreatePlanSuscriptionParams = {
lastPaymentPreferenceId?: string; lastPaymentPreferenceId?: string;
lastPaymentAt?: Date; lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType; lastPaymentType?: LastPlanPaymentType;
downgradedFromPlanId?: string;
downgradedFromPlanName?: string;
downgradedFromPlanCode?: string;
downgradedAt?: Date;
downgradeReason?: DowngradeReason;
}; };
export type CancellPlanSuscriptionParams = { export type CancellPlanSuscriptionParams = {
@@ -65,6 +70,7 @@ export type MPPreApprovalResponse = {
export type ToFreePlanParams = { export type ToFreePlanParams = {
sessionUser: string; sessionUser: string;
downgradeReason?: DowngradeReason;
}; };
export type ToMpParams = { export type ToMpParams = {
@@ -101,6 +107,7 @@ export type VerifyPendingPlanPaymentResponse = {
export type LastPlanPaymentStatus = "rejected" | "failed" | "cancelled"; export type LastPlanPaymentStatus = "rejected" | "failed" | "cancelled";
export type LastPlanPaymentType = "new" | "extension" | "upgrade"; export type LastPlanPaymentType = "new" | "extension" | "upgrade";
export type DowngradeReason = "expired";
export interface GetSuscriptionInitPointResponse { export interface GetSuscriptionInitPointResponse {
init_point: string; init_point: string;
@@ -137,6 +144,11 @@ export interface IPlanSuscription {
lastPaymentPreferenceId?: string; lastPaymentPreferenceId?: string;
lastPaymentAt?: Date; lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType; lastPaymentType?: LastPlanPaymentType;
downgradedFromPlanId?: string;
downgradedFromPlanName?: string;
downgradedFromPlanCode?: string;
downgradedAt?: Date;
downgradeReason?: DowngradeReason;
} }
export interface ISubscriptionInfo { export interface ISubscriptionInfo {
@@ -155,6 +167,11 @@ export interface ISubscriptionInfo {
lastPaymentPreferenceId?: string; lastPaymentPreferenceId?: string;
lastPaymentAt?: Date; lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType; lastPaymentType?: LastPlanPaymentType;
downgradedFromPlanId?: string;
downgradedFromPlanName?: string;
downgradedFromPlanCode?: string;
downgradedAt?: Date;
downgradeReason?: DowngradeReason;
} }
export interface IPlanSuscriptionsAdapter { export interface IPlanSuscriptionsAdapter {
@@ -23,7 +23,6 @@ import {
import axios from "axios"; import axios from "axios";
import PlansList from "../../Models/Plans/Plans"; import PlansList from "../../Models/Plans/Plans";
import dayjs from "dayjs"; import dayjs from "dayjs";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
import "dayjs/locale/es"; import "dayjs/locale/es";
import { isNull } from "../../helpers/IsNull"; import { isNull } from "../../helpers/IsNull";
import { PlanFeatures } from "../Plans/Plans.interface"; import { PlanFeatures } from "../Plans/Plans.interface";
@@ -34,7 +33,6 @@ import { NotificationType } from "../../Models/SystemNotifications/SystemNotific
import { calculateProratedUpgradeAmount } from "./PlanSubscriptions.pricing"; import { calculateProratedUpgradeAmount } from "./PlanSubscriptions.pricing";
dayjs.locale("es"); dayjs.locale("es");
dayjs.extend(isSameOrAfter);
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
@@ -195,6 +193,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
subscription.autoRenew = false; subscription.autoRenew = false;
subscription.mpStatus = ""; subscription.mpStatus = "";
subscription.mpInitPoint = dashboardUrl || ""; subscription.mpInitPoint = dashboardUrl || "";
subscription.downgradedFromPlanId = undefined;
subscription.downgradedFromPlanName = undefined;
subscription.downgradedFromPlanCode = undefined;
subscription.downgradedAt = undefined;
subscription.downgradeReason = undefined;
await subscription.save(); await subscription.save();
} }
} else { } else {
@@ -381,6 +384,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
currentSubscription.lastPaymentPreferenceId = undefined; currentSubscription.lastPaymentPreferenceId = undefined;
currentSubscription.lastPaymentAt = undefined; currentSubscription.lastPaymentAt = undefined;
currentSubscription.lastPaymentType = undefined; currentSubscription.lastPaymentType = undefined;
currentSubscription.downgradedFromPlanId = undefined;
currentSubscription.downgradedFromPlanName = undefined;
currentSubscription.downgradedFromPlanCode = undefined;
currentSubscription.downgradedAt = undefined;
currentSubscription.downgradeReason = undefined;
currentSubscription.mpInitPoint = subscriptionData.init_point; currentSubscription.mpInitPoint = subscriptionData.init_point;
await currentSubscription.save(); await currentSubscription.save();
@@ -409,6 +417,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
currentSubscription.lastPaymentPreferenceId = undefined; currentSubscription.lastPaymentPreferenceId = undefined;
currentSubscription.lastPaymentAt = undefined; currentSubscription.lastPaymentAt = undefined;
currentSubscription.lastPaymentType = undefined; currentSubscription.lastPaymentType = undefined;
currentSubscription.downgradedFromPlanId = undefined;
currentSubscription.downgradedFromPlanName = undefined;
currentSubscription.downgradedFromPlanCode = undefined;
currentSubscription.downgradedAt = undefined;
currentSubscription.downgradeReason = undefined;
currentSubscription.mpInitPoint = subscriptionData.init_point; currentSubscription.mpInitPoint = subscriptionData.init_point;
await currentSubscription.save(); await currentSubscription.save();
@@ -447,6 +460,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
lastPaymentPreferenceId: undefined, lastPaymentPreferenceId: undefined,
lastPaymentAt: undefined, lastPaymentAt: undefined,
lastPaymentType: undefined, lastPaymentType: undefined,
downgradedFromPlanId: undefined,
downgradedFromPlanName: undefined,
downgradedFromPlanCode: undefined,
downgradedAt: undefined,
downgradeReason: undefined,
}); });
await NotificationsManager.sendSystemNotification({ 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. // We just cancel it locally if not paid, or change to free plan if paid.
} }
const dateNow = dayjs(new Date()).endOf("day"); const dateNow = new Date();
const dateEnd = dayjs(subscription.endDate); const dateEnd = new Date(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd)) { if (dateNow > dateEnd) {
await this.toFreePlan({ await this.toFreePlan({
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
downgradeReason: "expired",
}); });
} else { } else {
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED; subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
@@ -573,6 +592,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
//console.log("guardando el plan gratuito..."); //console.log("guardando el plan gratuito...");
//try { //try {
const downgradedFromPlan = data.downgradeReason === "expired"
? await PlansList.plans.findOne({ _id: String(subscription.planId) })
: null;
console.log("cambiando al free plan:", freePlan.id); console.log("cambiando al free plan:", freePlan.id);
subscription.planId = freePlan.id; subscription.planId = freePlan.id;
subscription.mpStatus = ""; subscription.mpStatus = "";
@@ -593,6 +616,19 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
subscription.pendingPaymentPeriodEndDate = undefined; subscription.pendingPaymentPeriodEndDate = undefined;
subscription.startDate = new Date(); subscription.startDate = new Date();
subscription.endDate = dayjs(new Date()).add(10, "years").toDate(); 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(); await subscription.save();
// console.log("plan gratuito guardado:", freePlan.id); // console.log("plan gratuito guardado:", freePlan.id);
@@ -621,6 +657,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
lastPaymentPreferenceId: subscription.lastPaymentPreferenceId, lastPaymentPreferenceId: subscription.lastPaymentPreferenceId,
lastPaymentAt: subscription.lastPaymentAt, lastPaymentAt: subscription.lastPaymentAt,
lastPaymentType: subscription.lastPaymentType, 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); io.to(`user:${String(subscription.userId)}`).emit("suscription_status_updated", susInfo);
@@ -637,12 +678,13 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
return null; return null;
} }
const dateNow = dayjs(new Date()).endOf("day"); const dateNow = new Date();
const dateEnd = dayjs(subscription.endDate); const dateEnd = new Date(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd) && this.shouldDowngradeExpiredSubscription(subscription)) { if (dateNow > dateEnd && this.shouldDowngradeExpiredSubscription(subscription)) {
await this.toFreePlan({ await this.toFreePlan({
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
downgradeReason: "expired",
}); });
} }
@@ -658,12 +700,13 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
return null; return null;
} }
const dateNow = dayjs(new Date()).endOf("day"); const dateNow = new Date();
const dateEnd = dayjs(subscription.endDate); const dateEnd = new Date(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd) && this.shouldDowngradeExpiredSubscription(subscription)) { if (dateNow > dateEnd && this.shouldDowngradeExpiredSubscription(subscription)) {
await this.toFreePlan({ await this.toFreePlan({
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
downgradeReason: "expired",
}); });
return this.getSubscriptionByUser(data); return this.getSubscriptionByUser(data);
} }
@@ -701,6 +744,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
lastPaymentPreferenceId: subscription.lastPaymentPreferenceId, lastPaymentPreferenceId: subscription.lastPaymentPreferenceId,
lastPaymentAt: subscription.lastPaymentAt, lastPaymentAt: subscription.lastPaymentAt,
lastPaymentType: subscription.lastPaymentType, 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; transactionId?: string;
}; };
export type SysAdminFinalizeUserSubscriptionParams = {
userId: string;
reason?: string;
};
export type SysAdminFinalizeUserSubscriptionResult = {
userId: string;
subscriptionId: string;
previousEndDate: Date;
newEndDate: Date;
};
export type SysAdminUserSubscriptionPayment = { export type SysAdminUserSubscriptionPayment = {
id: string; id: string;
subscriptionId: string; subscriptionId: string;
@@ -328,6 +328,11 @@ export class MercadoPagoWebhookService {
subscription.lastPaymentPreferenceId = undefined; subscription.lastPaymentPreferenceId = undefined;
subscription.lastPaymentAt = undefined; subscription.lastPaymentAt = undefined;
subscription.lastPaymentType = undefined; subscription.lastPaymentType = undefined;
subscription.downgradedFromPlanId = undefined;
subscription.downgradedFromPlanName = undefined;
subscription.downgradedFromPlanCode = undefined;
subscription.downgradedAt = undefined;
subscription.downgradeReason = undefined;
await subscription.save(); await subscription.save();
await PlanPaymentsList.createIfMissingByTransactionId({ await PlanPaymentsList.createIfMissingByTransactionId({
@@ -4,6 +4,8 @@ import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth";
import { import {
SysAdminExtendUserSubscriptionParams, SysAdminExtendUserSubscriptionParams,
SysAdminExtendUserSubscriptionResult, SysAdminExtendUserSubscriptionResult,
SysAdminFinalizeUserSubscriptionParams,
SysAdminFinalizeUserSubscriptionResult,
SysAdminUserSubscriptionDetailsParams, SysAdminUserSubscriptionDetailsParams,
SysAdminUserSubscriptionDetailsResult, SysAdminUserSubscriptionDetailsResult,
} from "../../../Models/Users/Users.Interface"; } 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") @Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post("user-details") @Post("user-details")
@@ -6,6 +6,8 @@ import PlansList from "../../../Models/Plans/Plans";
import { import {
SysAdminExtendUserSubscriptionParams, SysAdminExtendUserSubscriptionParams,
SysAdminExtendUserSubscriptionResult, SysAdminExtendUserSubscriptionResult,
SysAdminFinalizeUserSubscriptionParams,
SysAdminFinalizeUserSubscriptionResult,
SysAdminUserSubscriptionDetailsParams, SysAdminUserSubscriptionDetailsParams,
SysAdminUserSubscriptionDetailsResult, SysAdminUserSubscriptionDetailsResult,
} from "../../../Models/Users/Users.Interface"; } 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( public async getUserSubscriptionDetails(
params: SysAdminUserSubscriptionDetailsParams params: SysAdminUserSubscriptionDetailsParams
): Promise<SysAdminUserSubscriptionDetailsResult> { ): Promise<SysAdminUserSubscriptionDetailsResult> {
+17
View File
@@ -109,6 +109,23 @@ export const extendUserSubscription = async (data: ExtendUserSubscriptionPayload
return res.data; 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<FinalizeUserSubscriptionResult> => {
const res = await sysadminApi.post('/users/finalize-subscription', data);
return res.data;
};
export type Plan = { export type Plan = {
id?: string; id?: string;
_id?: string; _id?: string;
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { CreditCard, X } from 'lucide-react'; 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'; import { formatDateTime } from '../../utils/formatters';
type Props = { type Props = {
@@ -17,6 +17,7 @@ export const SubscriptionDetailsModal = ({ user, onClose }: Props) => {
const [extensionLoading, setExtensionLoading] = useState(false); const [extensionLoading, setExtensionLoading] = useState(false);
const [extensionMessage, setExtensionMessage] = useState(''); const [extensionMessage, setExtensionMessage] = useState('');
const [extensionError, setExtensionError] = useState(''); const [extensionError, setExtensionError] = useState('');
const [finalizeLoading, setFinalizeLoading] = useState(false);
const formatAmount = (value?: number) => { const formatAmount = (value?: number) => {
if (typeof value !== 'number') return '-'; 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; const subscription = details?.currentSubscription;
return ( 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. Extiende la vigencia sin pago y registra un pago de bonificación por $0 asociado a esta suscripción.
</p> </p>
<div className="extension-actions"> <div className="extension-actions">
<button className="btn btn-secondary" disabled={extensionLoading} onClick={() => handleExtend({ mode: 'add_months', months: 1 })}> <button className="btn btn-secondary" disabled={extensionLoading || finalizeLoading} onClick={() => handleExtend({ mode: 'add_months', months: 1 })}>
Extender 1 mes Extender 1 mes
</button> </button>
<button className="btn btn-secondary" disabled={extensionLoading} onClick={() => handleExtend({ mode: 'add_months', months: 2 })}> <button className="btn btn-secondary" disabled={extensionLoading || finalizeLoading} onClick={() => handleExtend({ mode: 'add_months', months: 2 })}>
Extender 2 meses Extender 2 meses
</button> </button>
<div className="extension-date-action"> <div className="extension-date-action">
@@ -129,18 +153,27 @@ export const SubscriptionDetailsModal = ({ user, onClose }: Props) => {
type="date" type="date"
value={extensionDate} value={extensionDate}
onChange={(event) => setExtensionDate(event.target.value)} onChange={(event) => setExtensionDate(event.target.value)}
disabled={extensionLoading} disabled={extensionLoading || finalizeLoading}
/> />
<button <button
className="btn btn-primary" className="btn btn-primary"
disabled={extensionLoading || !extensionDate} disabled={extensionLoading || finalizeLoading || !extensionDate}
onClick={() => handleExtend({ mode: 'set_end_date', endDate: extensionDate })} onClick={() => handleExtend({ mode: 'set_end_date', endDate: extensionDate })}
> >
Establecer vencimiento Establecer vencimiento
</button> </button>
</div> </div>
</div> </div>
{extensionLoading && <p style={{ color: 'var(--text-muted)', marginTop: '0.75rem' }}>Aplicando ajuste...</p>} <div className="finalize-action">
<div>
<h4>Finalizar período</h4>
<p>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.</p>
</div>
<button className="btn btn-danger" disabled={extensionLoading || finalizeLoading} onClick={handleFinalize}>
Finalizar período
</button>
</div>
{(extensionLoading || finalizeLoading) && <p style={{ color: 'var(--text-muted)', marginTop: '0.75rem' }}>Aplicando ajuste...</p>}
{extensionMessage && <p style={{ color: 'var(--success)', marginTop: '0.75rem' }}>{extensionMessage}</p>} {extensionMessage && <p style={{ color: 'var(--success)', marginTop: '0.75rem' }}>{extensionMessage}</p>}
{extensionError && <p style={{ color: 'var(--danger)', marginTop: '0.75rem' }}>{extensionError}</p>} {extensionError && <p style={{ color: 'var(--danger)', marginTop: '0.75rem' }}>{extensionError}</p>}
</section> </section>
@@ -226,6 +259,30 @@ export const SubscriptionDetailsModal = ({ user, onClose }: Props) => {
color: var(--text-main); color: var(--text-main);
padding: 0.65rem 0.75rem; 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;
}
}
`}} /> `}} />
</motion.div> </motion.div>
</div> </div>
@@ -8,6 +8,7 @@ import {
SysAdminOrganizationsStatusParams, SysAdminOrganizationsStatusParams,
SysAdminRecalculatePlanUsageCycleParams, SysAdminRecalculatePlanUsageCycleParams,
SysAdminExtendUserSubscriptionParams, SysAdminExtendUserSubscriptionParams,
SysAdminFinalizeUserSubscriptionParams,
SysAdminUserSubscriptionDetailsParams, SysAdminUserSubscriptionDetailsParams,
} from "../models/Users.Model"; } from "../models/Users.Model";
@@ -101,4 +102,15 @@ export class UsersController {
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" }); res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
} }
}; };
public finalizeSubscription = async (req: Request, res: Response): Promise<void> => {
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" });
}
};
} }
+12
View File
@@ -92,6 +92,18 @@ export type SysAdminExtendUserSubscriptionResult = {
transactionId?: string; transactionId?: string;
}; };
export type SysAdminFinalizeUserSubscriptionParams = {
userId: string;
reason?: string;
};
export type SysAdminFinalizeUserSubscriptionResult = {
userId: string;
subscriptionId: string;
previousEndDate: string;
newEndDate: string;
};
export type SysAdminUserSubscriptionDetailsResult = { export type SysAdminUserSubscriptionDetailsResult = {
userId: string; userId: string;
currentSubscription: { currentSubscription: {
+1
View File
@@ -11,6 +11,7 @@ router.post("/organizations-status", usersController.organizationsStatus);
router.post("/recalculate-plan-usage-cycle", usersController.recalculatePlanUsageCycle); router.post("/recalculate-plan-usage-cycle", usersController.recalculatePlanUsageCycle);
router.post("/subscription-details", usersController.subscriptionDetails); router.post("/subscription-details", usersController.subscriptionDetails);
router.post("/extend-subscription", usersController.extendSubscription); router.post("/extend-subscription", usersController.extendSubscription);
router.post("/finalize-subscription", usersController.finalizeSubscription);
router.post("/delete", usersController.deleteUser); router.post("/delete", usersController.deleteUser);
export default router; export default router;
+16
View File
@@ -14,6 +14,8 @@ import {
SysAdminRecalculatePlanUsageCycleResult, SysAdminRecalculatePlanUsageCycleResult,
SysAdminExtendUserSubscriptionParams, SysAdminExtendUserSubscriptionParams,
SysAdminExtendUserSubscriptionResult, SysAdminExtendUserSubscriptionResult,
SysAdminFinalizeUserSubscriptionParams,
SysAdminFinalizeUserSubscriptionResult,
SysAdminUserSubscriptionDetailsParams, SysAdminUserSubscriptionDetailsParams,
SysAdminUserSubscriptionDetailsResult, SysAdminUserSubscriptionDetailsResult,
} from "../models/Users.Model"; } from "../models/Users.Model";
@@ -120,4 +122,18 @@ export class UsersService {
return response.data; return response.data;
} }
public async finalizeSubscription(
data: SysAdminFinalizeUserSubscriptionParams
): Promise<SysAdminFinalizeUserSubscriptionResult> {
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;
}
} }
+1
View File
@@ -12,3 +12,4 @@ NEXT_PUBLIC_DATAGRID_PAGE_SIZE = 10
NEXT_PUBLIC_SLOGAN = Tu tiempo en orden. NEXT_PUBLIC_SLOGAN = Tu tiempo en orden.
NEXT_PUBLIC_BASE_URL = https://turnosxpress.com.ar NEXT_PUBLIC_BASE_URL = https://turnosxpress.com.ar
NEXT_PUBLIC_INFO_EMAIL = info@turnosxpress.com.ar
+1
View File
@@ -12,3 +12,4 @@ NEXT_PUBLIC_DATAGRID_PAGE_SIZE = 10
NEXT_PUBLIC_SLOGAN = Tu tiempo en orden. NEXT_PUBLIC_SLOGAN = Tu tiempo en orden.
NEXT_PUBLIC_BASE_URL = https://turnosxpress.com.ar NEXT_PUBLIC_BASE_URL = https://turnosxpress.com.ar
NEXT_PUBLIC_INFO_EMAIL = info@turnosxpress.com.ar
@@ -63,6 +63,7 @@ export type VerifyPendingPlanPaymentResponse = {
export type LastPlanPaymentStatus = "rejected" | "failed" | "cancelled"; export type LastPlanPaymentStatus = "rejected" | "failed" | "cancelled";
export type LastPlanPaymentType = "new" | "extension" | "upgrade"; export type LastPlanPaymentType = "new" | "extension" | "upgrade";
export type DowngradeReason = "expired";
export interface GetSuscriptionInitPointResponse { export interface GetSuscriptionInitPointResponse {
init_point: string; init_point: string;
@@ -84,6 +85,11 @@ export interface ISubscriptionInfo {
lastPaymentPreferenceId?: string; lastPaymentPreferenceId?: string;
lastPaymentAt?: Date; lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType; lastPaymentType?: LastPlanPaymentType;
downgradedFromPlanId?: string;
downgradedFromPlanName?: string;
downgradedFromPlanCode?: string;
downgradedAt?: Date;
downgradeReason?: DowngradeReason;
} }
export const DefaultSubscriptionModel: ISubscriptionInfo = { export const DefaultSubscriptionModel: ISubscriptionInfo = {
id: "", id: "",
@@ -278,6 +278,43 @@
font-weight: 800; 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 { .section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -107,6 +107,11 @@ export default function CurrentPlanPage() {
const plan = subscription?.plan; const plan = subscription?.plan;
const isPaidPlan = Boolean(plan && plan.price > 0); const isPaidPlan = Boolean(plan && plan.price > 0);
const statusText = isPaidPlan ? getSubStatusText(subscription?.mpStatus) : "ACTIVO"; const statusText = isPaidPlan ? getSubStatusText(subscription?.mpStatus) : "ACTIVO";
const showExpiredDowngradeNotice = Boolean(
isOwner &&
subscription?.downgradeReason === "expired" &&
subscription.downgradedAt
);
const canRenew = Boolean( const canRenew = Boolean(
isOwner && isOwner &&
isPaidPlan && isPaidPlan &&
@@ -216,6 +221,17 @@ export default function CurrentPlanPage() {
</div> </div>
</div> </div>
{showExpiredDowngradeNotice && (
<div className={styles.expiredDowngradeNotice}>
<span>Plan vencido</span>
<strong>Tu plan pago venció y esta organización pasó al plan básico.</strong>
<p>
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}.`}
</p>
</div>
)}
{!isPaidPlan && ( {!isPaidPlan && (
<p className={styles.notice}>Estás usando el plan gratis. No tiene vencimiento pago.</p> <p className={styles.notice}>Estás usando el plan gratis. No tiene vencimiento pago.</p>
)} )}
+52 -16
View File
@@ -1,5 +1,4 @@
"use client"; "use client";
import MetricGraph from "@components/MetricGraph/MetricGraph";
import AnimatedContainer from "@core/app/components/AnimatedConainer/AnimatedContainer"; import AnimatedContainer from "@core/app/components/AnimatedConainer/AnimatedContainer";
import Button from "@core/app/components/Button/Button"; import Button from "@core/app/components/Button/Button";
import { useNavigation } from "@core/app/hooks/goto"; 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 HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
import classNames from "classnames"; import classNames from "classnames";
import { ScrollToTop } from "@core/app/components/ScrollTop"; import { ScrollToTop } from "@core/app/components/ScrollTop";
import { isNull } from "@core/app/helpers/IsNull";
import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined"; import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined";
import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined"; import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined";
@@ -56,15 +54,7 @@ function DashboardContent() {
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const alert = useAlert(); const alert = useAlert();
const confirm = useConfirmStore(); 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 [showOnlyMine, setShowOnlyMine] = useState(false);
const [isAgendaCollapsed, setIsAgendaCollapsed] = useState(true); const [isAgendaCollapsed, setIsAgendaCollapsed] = useState(true);
@@ -164,6 +154,7 @@ function DashboardContent() {
const loadCompanyMetrics = async (companyId: string) => { const loadCompanyMetrics = async (companyId: string) => {
setCompanySubscription(null); setCompanySubscription(null);
setCompanyMetrics(null); setCompanyMetrics(null);
setIsCompanyOwner(false);
try { try {
const res = await getCompanyMetrics({ sessionUser: SessionInfo.userId, companyId }); const res = await getCompanyMetrics({ sessionUser: SessionInfo.userId, companyId });
setCompanyMetrics(res.metrics); setCompanyMetrics(res.metrics);
@@ -315,6 +306,7 @@ function DashboardContent() {
}; };
const isUsingSessionSubscriptionFallback = !companySubscription && Boolean(subscription.plan.id); const isUsingSessionSubscriptionFallback = !companySubscription && Boolean(subscription.plan.id);
const selectedOrganization = organizations.find((organization) => organization.id === selectedOrgId);
const displayedSubscription = companySubscription ?? (subscription.plan.id ? subscription : null); const displayedSubscription = companySubscription ?? (subscription.plan.id ? subscription : null);
const displayedPlan = displayedSubscription?.plan; const displayedPlan = displayedSubscription?.plan;
const displayedPlanStatusText = displayedPlan?.price === 0 ? "ACTIVO" : getSubStatusText(displayedSubscription?.mpStatus); const displayedPlanStatusText = displayedPlan?.price === 0 ? "ACTIVO" : getSubStatusText(displayedSubscription?.mpStatus);
@@ -325,11 +317,30 @@ function DashboardContent() {
displayedSubscription.lastPaymentAt && displayedSubscription.lastPaymentAt &&
dayjs().diff(dayjs(displayedSubscription.lastPaymentAt), "day") <= 14 dayjs().diff(dayjs(displayedSubscription.lastPaymentAt), "day") <= 14
); );
const showPaymentAction = Boolean( const selectedCompanyHasPendingPayment = Boolean(
(displayedSubscription?.mpStatus === MP_SUBS_STATUS.PENDING || hasPendingPaymentMetadata) && companySubscription &&
(isCompanyOwner || isUsingSessionSubscriptionFallback) (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 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 showFreePlanStarterContent = Boolean(displayedPlan && displayedPlan.price === 0 && organizations.length === 0);
const agendaItems = dashboardData?.personalAgenda ?? []; const agendaItems = dashboardData?.personalAgenda ?? [];
const filteredAgendaItems = agendaItems.filter(item => !showOnlyMine || item.collaboratorId === SessionInfo.userId); const filteredAgendaItems = agendaItems.filter(item => !showOnlyMine || item.collaboratorId === SessionInfo.userId);
@@ -365,7 +376,12 @@ function DashboardContent() {
<div style={{ position: "absolute", top: "20px", right: "20px", display: "flex", alignItems: "center", gap: "10px" }}> <div style={{ position: "absolute", top: "20px", right: "20px", display: "flex", alignItems: "center", gap: "10px" }}>
<select <select
value={selectedOrgId} value={selectedOrgId}
onChange={e => 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" }} style={{ padding: "8px 12px", borderRadius: "8px", border: "1px solid #e5e7eb", fontSize: "14px", fontWeight: "500", backgroundColor: "#f9fafb", cursor: "pointer", outline: "none" }}
> >
{organizations.map(org => ( {organizations.map(org => (
@@ -435,6 +451,7 @@ function DashboardContent() {
<p> <p>
Si ya pagaste, puede tardar unos minutos en actualizarse porque dependemos de la notificación de MercadoPago. 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. 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 <a className={style.pendingPaymentLink} href={`mailto:${infoEmail}`}>{infoEmail}</a> y te ayudamos a revisarlo.
</p> </p>
</div> </div>
<div className={style.pendingPaymentActions}> <div className={style.pendingPaymentActions}>
@@ -482,6 +499,25 @@ function DashboardContent() {
</div> </div>
)} )}
{showExpiredDowngradeCard && (
<div className={style.expiredDowngradeCard}>
<div className={style.pendingPaymentContent}>
<span className={style.expiredDowngradeEyebrow}>Plan vencido</span>
<h2>Tu plan pago venció y pasaste al plan básico</h2>
<p>
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}.`}
</p>
</div>
<button
className={style.expiredDowngradeButton}
onClick={() => goTo(selectedOrgId ? `/landing/upgrade-plan?org=${selectedOrgId}` : "/landing/pricing")}
>
Volver a contratar un plan
</button>
</div>
)}
{showFreePlanStarterContent && ( {showFreePlanStarterContent && (
<div className={style.starterCard}> <div className={style.starterCard}>
<div className={style.starterContent}> <div className={style.starterContent}>
@@ -649,7 +685,7 @@ function DashboardContent() {
)} )}
{/* Repeats Pending Card */} {/* Repeats Pending Card */}
{dashboardData && selectedOrgId && ( {dashboardData && selectedOrgId && canLoadPendingRepeats && (
<PendingRepeatsCard <PendingRepeatsCard
companyId={selectedOrgId} companyId={selectedOrgId}
allowEmployeeFilter={true} allowEmployeeFilter={true}
@@ -185,6 +185,28 @@
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06); box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
} }
.expiredDowngradeCard {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 26px 30px;
border-radius: 18px;
background: #fff7ed;
border: 1px solid rgba(194, 65, 12, 0.24);
box-shadow: 0 12px 28px rgba(194, 65, 12, 0.08);
color: #7c2d12;
}
.expiredDowngradeCard .pendingPaymentContent h2 {
color: #7c2d12;
}
.expiredDowngradeCard .pendingPaymentContent p {
color: #92400e;
}
.pendingPaymentContent { .pendingPaymentContent {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -216,6 +238,35 @@
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.expiredDowngradeEyebrow {
width: fit-content;
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;
}
.expiredDowngradeButton {
flex: 0 0 auto;
border: 1px solid rgba(124, 45, 18, 0.22);
border-radius: 14px;
background: #7c2d12;
color: #fff7ed;
cursor: pointer;
font-weight: 900;
padding: 14px 18px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.expiredDowngradeButton:hover {
box-shadow: 0 10px 24px rgba(124, 45, 18, 0.18);
transform: translateY(-2px);
}
.pendingPaymentContent h2 { .pendingPaymentContent h2 {
margin: 0; margin: 0;
color: var(--black); color: var(--black);
@@ -228,6 +279,13 @@
line-height: 1.55; line-height: 1.55;
} }
.pendingPaymentLink {
color: #9a4b00;
font-weight: 800;
text-decoration: underline;
text-underline-offset: 3px;
}
.pendingPaymentActions { .pendingPaymentActions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -433,6 +491,16 @@
padding: 22px; padding: 22px;
} }
.expiredDowngradeCard {
align-items: stretch;
flex-direction: column;
padding: 22px;
}
.expiredDowngradeButton {
width: 100%;
}
.pendingPaymentActions { .pendingPaymentActions {
align-items: center; align-items: center;
justify-content: center; justify-content: center;