feat: add manual subscription finalization functionality and support for expired plan notifications
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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(
|
||||
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) &&
|
||||
(isCompanyOwner || isUsingSessionSubscriptionFallback)
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user