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

This commit is contained in:
2026-07-19 13:20:33 -03:00
parent 78ee86ac48
commit af6c87acb6
20 changed files with 462 additions and 39 deletions
@@ -1,5 +1,4 @@
import dayjs from "dayjs";
import {
FindPlanSuscripcionsParams,
IPlanSuscriptionsAdapter,
@@ -53,6 +52,11 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
lastPaymentPreferenceId: { type: String, required: false },
lastPaymentAt: { type: Date, required: false },
lastPaymentType: { type: String, required: false, enum: ["new", "extension", "upgrade"] },
downgradedFromPlanId: { type: Schema.Types.ObjectId, required: false, ref: "Plan" },
downgradedFromPlanName: { type: String, required: false },
downgradedFromPlanCode: { type: String, required: false },
downgradedAt: { type: Date, required: false },
downgradeReason: { type: String, required: false, enum: ["expired"] },
});
this.planSuscriptionList = model<IPlanSuscriptionDocument>("PlanSuscription", this.schema);
@@ -89,12 +93,12 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
return null;
}
const dateNow = dayjs(new Date());
const dateEnd = dayjs(subscription.endDate);
const dateNow = new Date();
const dateEnd = new Date(subscription.endDate);
try {
if (subscription.mpPreferenceId) {
if (dateNow.isAfter(dateEnd)) {
if (dateNow > dateEnd) {
if (subscription.mpStatus == MP_SUBS_STATUS.CANCELLED) {
subscription.mpPreferenceId = "";
subscription.isActive = false;
@@ -36,6 +36,11 @@ export type CreatePlanSuscriptionParams = {
lastPaymentPreferenceId?: string;
lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType;
downgradedFromPlanId?: string;
downgradedFromPlanName?: string;
downgradedFromPlanCode?: string;
downgradedAt?: Date;
downgradeReason?: DowngradeReason;
};
export type CancellPlanSuscriptionParams = {
@@ -65,6 +70,7 @@ export type MPPreApprovalResponse = {
export type ToFreePlanParams = {
sessionUser: string;
downgradeReason?: DowngradeReason;
};
export type ToMpParams = {
@@ -101,6 +107,7 @@ export type VerifyPendingPlanPaymentResponse = {
export type LastPlanPaymentStatus = "rejected" | "failed" | "cancelled";
export type LastPlanPaymentType = "new" | "extension" | "upgrade";
export type DowngradeReason = "expired";
export interface GetSuscriptionInitPointResponse {
init_point: string;
@@ -137,6 +144,11 @@ export interface IPlanSuscription {
lastPaymentPreferenceId?: string;
lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType;
downgradedFromPlanId?: string;
downgradedFromPlanName?: string;
downgradedFromPlanCode?: string;
downgradedAt?: Date;
downgradeReason?: DowngradeReason;
}
export interface ISubscriptionInfo {
@@ -155,6 +167,11 @@ export interface ISubscriptionInfo {
lastPaymentPreferenceId?: string;
lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType;
downgradedFromPlanId?: string;
downgradedFromPlanName?: string;
downgradedFromPlanCode?: string;
downgradedAt?: Date;
downgradeReason?: DowngradeReason;
}
export interface IPlanSuscriptionsAdapter {
@@ -23,7 +23,6 @@ import {
import axios from "axios";
import PlansList from "../../Models/Plans/Plans";
import dayjs from "dayjs";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
import "dayjs/locale/es";
import { isNull } from "../../helpers/IsNull";
import { PlanFeatures } from "../Plans/Plans.interface";
@@ -34,7 +33,6 @@ import { NotificationType } from "../../Models/SystemNotifications/SystemNotific
import { calculateProratedUpgradeAmount } from "./PlanSubscriptions.pricing";
dayjs.locale("es");
dayjs.extend(isSameOrAfter);
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -195,6 +193,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
subscription.autoRenew = false;
subscription.mpStatus = "";
subscription.mpInitPoint = dashboardUrl || "";
subscription.downgradedFromPlanId = undefined;
subscription.downgradedFromPlanName = undefined;
subscription.downgradedFromPlanCode = undefined;
subscription.downgradedAt = undefined;
subscription.downgradeReason = undefined;
await subscription.save();
}
} else {
@@ -381,6 +384,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
currentSubscription.lastPaymentPreferenceId = undefined;
currentSubscription.lastPaymentAt = undefined;
currentSubscription.lastPaymentType = undefined;
currentSubscription.downgradedFromPlanId = undefined;
currentSubscription.downgradedFromPlanName = undefined;
currentSubscription.downgradedFromPlanCode = undefined;
currentSubscription.downgradedAt = undefined;
currentSubscription.downgradeReason = undefined;
currentSubscription.mpInitPoint = subscriptionData.init_point;
await currentSubscription.save();
@@ -409,6 +417,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
currentSubscription.lastPaymentPreferenceId = undefined;
currentSubscription.lastPaymentAt = undefined;
currentSubscription.lastPaymentType = undefined;
currentSubscription.downgradedFromPlanId = undefined;
currentSubscription.downgradedFromPlanName = undefined;
currentSubscription.downgradedFromPlanCode = undefined;
currentSubscription.downgradedAt = undefined;
currentSubscription.downgradeReason = undefined;
currentSubscription.mpInitPoint = subscriptionData.init_point;
await currentSubscription.save();
@@ -447,6 +460,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
lastPaymentPreferenceId: undefined,
lastPaymentAt: undefined,
lastPaymentType: undefined,
downgradedFromPlanId: undefined,
downgradedFromPlanName: undefined,
downgradedFromPlanCode: undefined,
downgradedAt: undefined,
downgradeReason: undefined,
});
await NotificationsManager.sendSystemNotification({
@@ -493,12 +511,13 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
// We just cancel it locally if not paid, or change to free plan if paid.
}
const dateNow = dayjs(new Date()).endOf("day");
const dateEnd = dayjs(subscription.endDate);
const dateNow = new Date();
const dateEnd = new Date(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd)) {
if (dateNow > dateEnd) {
await this.toFreePlan({
sessionUser: data.sessionUser,
downgradeReason: "expired",
});
} else {
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
@@ -573,6 +592,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
//console.log("guardando el plan gratuito...");
//try {
const downgradedFromPlan = data.downgradeReason === "expired"
? await PlansList.plans.findOne({ _id: String(subscription.planId) })
: null;
console.log("cambiando al free plan:", freePlan.id);
subscription.planId = freePlan.id;
subscription.mpStatus = "";
@@ -593,6 +616,19 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
subscription.pendingPaymentPeriodEndDate = undefined;
subscription.startDate = new Date();
subscription.endDate = dayjs(new Date()).add(10, "years").toDate();
if (data.downgradeReason === "expired" && downgradedFromPlan) {
subscription.downgradedFromPlanId = String(downgradedFromPlan.id || downgradedFromPlan._id);
subscription.downgradedFromPlanName = downgradedFromPlan.name;
subscription.downgradedFromPlanCode = downgradedFromPlan.code;
subscription.downgradedAt = new Date();
subscription.downgradeReason = "expired";
} else {
subscription.downgradedFromPlanId = undefined;
subscription.downgradedFromPlanName = undefined;
subscription.downgradedFromPlanCode = undefined;
subscription.downgradedAt = undefined;
subscription.downgradeReason = undefined;
}
await subscription.save();
// console.log("plan gratuito guardado:", freePlan.id);
@@ -621,6 +657,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
lastPaymentPreferenceId: subscription.lastPaymentPreferenceId,
lastPaymentAt: subscription.lastPaymentAt,
lastPaymentType: subscription.lastPaymentType,
downgradedFromPlanId: subscription.downgradedFromPlanId,
downgradedFromPlanName: subscription.downgradedFromPlanName,
downgradedFromPlanCode: subscription.downgradedFromPlanCode,
downgradedAt: subscription.downgradedAt,
downgradeReason: subscription.downgradeReason,
};
io.to(`user:${String(subscription.userId)}`).emit("suscription_status_updated", susInfo);
@@ -637,12 +678,13 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
return null;
}
const dateNow = dayjs(new Date()).endOf("day");
const dateEnd = dayjs(subscription.endDate);
const dateNow = new Date();
const dateEnd = new Date(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd) && this.shouldDowngradeExpiredSubscription(subscription)) {
if (dateNow > dateEnd && this.shouldDowngradeExpiredSubscription(subscription)) {
await this.toFreePlan({
sessionUser: data.sessionUser,
downgradeReason: "expired",
});
}
@@ -658,12 +700,13 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
return null;
}
const dateNow = dayjs(new Date()).endOf("day");
const dateEnd = dayjs(subscription.endDate);
const dateNow = new Date();
const dateEnd = new Date(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd) && this.shouldDowngradeExpiredSubscription(subscription)) {
if (dateNow > dateEnd && this.shouldDowngradeExpiredSubscription(subscription)) {
await this.toFreePlan({
sessionUser: data.sessionUser,
downgradeReason: "expired",
});
return this.getSubscriptionByUser(data);
}
@@ -701,6 +744,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
lastPaymentPreferenceId: subscription.lastPaymentPreferenceId,
lastPaymentAt: subscription.lastPaymentAt,
lastPaymentType: subscription.lastPaymentType,
downgradedFromPlanId: subscription.downgradedFromPlanId,
downgradedFromPlanName: subscription.downgradedFromPlanName,
downgradedFromPlanCode: subscription.downgradedFromPlanCode,
downgradedAt: subscription.downgradedAt,
downgradeReason: subscription.downgradeReason,
};
}
@@ -228,6 +228,18 @@ export type SysAdminExtendUserSubscriptionResult = {
transactionId?: string;
};
export type SysAdminFinalizeUserSubscriptionParams = {
userId: string;
reason?: string;
};
export type SysAdminFinalizeUserSubscriptionResult = {
userId: string;
subscriptionId: string;
previousEndDate: Date;
newEndDate: Date;
};
export type SysAdminUserSubscriptionPayment = {
id: string;
subscriptionId: string;
@@ -328,6 +328,11 @@ export class MercadoPagoWebhookService {
subscription.lastPaymentPreferenceId = undefined;
subscription.lastPaymentAt = undefined;
subscription.lastPaymentType = undefined;
subscription.downgradedFromPlanId = undefined;
subscription.downgradedFromPlanName = undefined;
subscription.downgradedFromPlanCode = undefined;
subscription.downgradedAt = undefined;
subscription.downgradeReason = undefined;
await subscription.save();
await PlanPaymentsList.createIfMissingByTransactionId({
@@ -4,6 +4,8 @@ import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth";
import {
SysAdminExtendUserSubscriptionParams,
SysAdminExtendUserSubscriptionResult,
SysAdminFinalizeUserSubscriptionParams,
SysAdminFinalizeUserSubscriptionResult,
SysAdminUserSubscriptionDetailsParams,
SysAdminUserSubscriptionDetailsResult,
} from "../../../Models/Users/Users.Interface";
@@ -29,6 +31,23 @@ export class SysAdminSubscriptionsController extends Controller {
}
}
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("finalize-user")
public async finalizeUserSubscription(
@Body() requestBody: SysAdminFinalizeUserSubscriptionParams
): Promise<SysAdminFinalizeUserSubscriptionResult | ApiValidationError> {
try {
const result = await new SysAdminSubscriptionsService().finalizeUserSubscription(requestBody);
this.setStatus(200);
return result;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("user-details")
@@ -6,6 +6,8 @@ import PlansList from "../../../Models/Plans/Plans";
import {
SysAdminExtendUserSubscriptionParams,
SysAdminExtendUserSubscriptionResult,
SysAdminFinalizeUserSubscriptionParams,
SysAdminFinalizeUserSubscriptionResult,
SysAdminUserSubscriptionDetailsParams,
SysAdminUserSubscriptionDetailsResult,
} from "../../../Models/Users/Users.Interface";
@@ -86,6 +88,42 @@ export class SysAdminSubscriptionsService {
};
}
public async finalizeUserSubscription(
params: SysAdminFinalizeUserSubscriptionParams
): Promise<SysAdminFinalizeUserSubscriptionResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
if (!params.userId || !isValidObjectId(params.userId)) {
throw new Error("User id is required");
}
const subscription = await PlanSubscriptionsList.findOne({ sessionUser: params.userId });
if (!subscription) {
throw new Error("No subscription found for user");
}
const previousEndDate = subscription.endDate ? new Date(subscription.endDate) : new Date();
const newEndDate = dayjs().subtract(1, "day").startOf("day").toDate();
const plan = await PlansList.plans.findOne({ _id: String(subscription.planId) });
subscription.endDate = newEndDate;
await subscription.save();
if (plan && plan.price > 0) {
await PlanSubscriptionsList.toFreePlan({
sessionUser: params.userId,
downgradeReason: "expired",
});
}
return {
userId: params.userId,
subscriptionId: String(subscription.id),
previousEndDate,
newEndDate,
};
}
public async getUserSubscriptionDetails(
params: SysAdminUserSubscriptionDetailsParams
): Promise<SysAdminUserSubscriptionDetailsResult> {
+17
View File
@@ -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<FinalizeUserSubscriptionResult> => {
const res = await sysadminApi.post('/users/finalize-subscription', data);
return res.data;
};
export type Plan = {
id?: string;
_id?: string;
@@ -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.
</p>
<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
</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
</button>
<div className="extension-date-action">
@@ -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}
/>
<button
className="btn btn-primary"
disabled={extensionLoading || !extensionDate}
disabled={extensionLoading || finalizeLoading || !extensionDate}
onClick={() => handleExtend({ mode: 'set_end_date', endDate: extensionDate })}
>
Establecer vencimiento
</button>
</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>}
{extensionError && <p style={{ color: 'var(--danger)', marginTop: '0.75rem' }}>{extensionError}</p>}
</section>
@@ -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;
}
}
`}} />
</motion.div>
</div>
@@ -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<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;
};
export type SysAdminFinalizeUserSubscriptionParams = {
userId: string;
reason?: string;
};
export type SysAdminFinalizeUserSubscriptionResult = {
userId: string;
subscriptionId: string;
previousEndDate: string;
newEndDate: string;
};
export type SysAdminUserSubscriptionDetailsResult = {
userId: string;
currentSubscription: {
+1
View File
@@ -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;
+16
View File
@@ -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<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_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_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 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: "",
@@ -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;
@@ -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() {
</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 && (
<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";
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,15 +54,7 @@ function DashboardContent() {
const eventHandler = useEventHandlerStore();
const alert = useAlert();
const confirm = useConfirmStore();
const [metrics, setMetrics] = useState({
organizationsCount: 0,
employeesCount: 0,
clientsCount: 0,
appointmentsCount: 0,
servicesCount: 0,
repeatsCount: 0,
});
const infoEmail = process.env.NEXT_PUBLIC_INFO_EMAIL || "info@turnosxpress.com.ar";
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() {
<div style={{ position: "absolute", top: "20px", right: "20px", display: "flex", alignItems: "center", gap: "10px" }}>
<select
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" }}
>
{organizations.map(org => (
@@ -435,6 +451,7 @@ function DashboardContent() {
<p>
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 <a className={style.pendingPaymentLink} href={`mailto:${infoEmail}`}>{infoEmail}</a> y te ayudamos a revisarlo.
</p>
</div>
<div className={style.pendingPaymentActions}>
@@ -482,6 +499,25 @@ function DashboardContent() {
</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 && (
<div className={style.starterCard}>
<div className={style.starterContent}>
@@ -649,7 +685,7 @@ function DashboardContent() {
)}
{/* Repeats Pending Card */}
{dashboardData && selectedOrgId && (
{dashboardData && selectedOrgId && canLoadPendingRepeats && (
<PendingRepeatsCard
companyId={selectedOrgId}
allowEmployeeFilter={true}
@@ -185,6 +185,28 @@
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 {
display: flex;
flex-direction: column;
@@ -216,6 +238,35 @@
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 {
margin: 0;
color: var(--black);
@@ -228,6 +279,13 @@
line-height: 1.55;
}
.pendingPaymentLink {
color: #9a4b00;
font-weight: 800;
text-decoration: underline;
text-underline-offset: 3px;
}
.pendingPaymentActions {
display: flex;
flex-direction: column;
@@ -433,6 +491,16 @@
padding: 22px;
}
.expiredDowngradeCard {
align-items: stretch;
flex-direction: column;
padding: 22px;
}
.expiredDowngradeButton {
width: 100%;
}
.pendingPaymentActions {
align-items: center;
justify-content: center;