feat: implement plan upgrade logic with prorated pricing and MercadoPago integration

This commit is contained in:
2026-07-18 15:39:07 -03:00
parent f6e2bb8372
commit 8d74ae95c8
25 changed files with 2491 additions and 142 deletions
+1
View File
@@ -1,2 +1,3 @@
# turnosxpress # turnosxpress
ngrok http 3000
+1 -1
View File
@@ -45,7 +45,7 @@ MP_SUBSCRIPTION_FIND_URL = https://api.mercadopago.com/preapproval/
MP_ACCESS_TOKEN = APP_USR-8753424836448507-121412-731599daea1ffa0b278b5dcc78aa1ec8-1611134648 MP_ACCESS_TOKEN = APP_USR-8753424836448507-121412-731599daea1ffa0b278b5dcc78aa1ec8-1611134648
MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard
MP_CHECK_PAYMENT_MINUTES = 1 MP_CHECK_PAYMENT_MINUTES = 1
MP_WEBHOOK_URL = https://5747-186-132-168-62.ngrok-free.app/mercadopago/webhook MP_WEBHOOK_URL = https://9300-186-132-200-176.ngrok-free.app/mercadopago/webhook
DEFAULT_BOT_ADMIN_PORT = 3005 DEFAULT_BOT_ADMIN_PORT = 3005
@@ -22,6 +22,7 @@ export class PlanPaymentsAdapterMongoose implements IPlanPaymentsAdapter {
status: { type: String, required: true }, status: { type: String, required: true },
transactionId: { type: String, required: false }, transactionId: { type: String, required: false },
}); });
this.schema.index({ transactionId: 1 }, { unique: true, sparse: true });
this.planPaymentList = model<IPlanPaymentDocument>("PlanPayment", this.schema); this.planPaymentList = model<IPlanPaymentDocument>("PlanPayment", this.schema);
} }
@@ -33,4 +34,16 @@ export class PlanPaymentsAdapterMongoose implements IPlanPaymentsAdapter {
public async create(data: CreatePlanPaymentParams): Promise<IPlanPayment> { public async create(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
return this.planPaymentList.create(data); return this.planPaymentList.create(data);
} }
public async createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
if (!data.transactionId) {
return this.create(data);
}
return this.planPaymentList.findOneAndUpdate(
{ transactionId: data.transactionId },
{ $setOnInsert: data },
{ new: true, upsert: true, setDefaultsOnInsert: true }
).exec();
}
} }
@@ -21,10 +21,12 @@ export interface IPlanPayment {
export interface IPlanPaymentsAdapter { export interface IPlanPaymentsAdapter {
find(filters: FindPlanPaymentsParams): Promise<IPlanPayment[]>; find(filters: FindPlanPaymentsParams): Promise<IPlanPayment[]>;
create(data: CreatePlanPaymentParams): Promise<IPlanPayment>; create(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
} }
export interface IPlanPaymentsManager { export interface IPlanPaymentsManager {
planPayments: IPlanPaymentsAdapter; planPayments: IPlanPaymentsAdapter;
find(data: FindPlanPaymentsParams): Promise<IPlanPayment[]>; find(data: FindPlanPaymentsParams): Promise<IPlanPayment[]>;
create(data: CreatePlanPaymentParams): Promise<IPlanPayment>; create(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
} }
@@ -20,6 +20,10 @@ class PlanPaymentsManager implements IPlanPaymentsManager {
public async create(data: CreatePlanPaymentParams): Promise<IPlanPayment> { public async create(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
return this.planPayments.create(data); return this.planPayments.create(data);
} }
public async createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
return this.planPayments.createIfMissingByTransactionId(data);
}
} }
const PlanPaymentsList = new PlanPaymentsManager(); const PlanPaymentsList = new PlanPaymentsManager();
@@ -42,6 +42,17 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
pendingPaymentInitPoint: { type: String, required: false }, pendingPaymentInitPoint: { type: String, required: false },
pendingPaymentPreferenceId: { type: String, required: false }, pendingPaymentPreferenceId: { type: String, required: false },
pendingPaymentBillingMonths: { type: Number, required: false }, pendingPaymentBillingMonths: { type: Number, required: false },
pendingPaymentType: { type: String, required: false, enum: ["extension", "upgrade"] },
pendingPaymentPlanId: { type: Schema.Types.ObjectId, required: false, ref: "Plan" },
pendingPaymentCurrentPlanPrice: { type: Number, required: false },
pendingPaymentRequestedPlanPrice: { type: Number, required: false },
pendingPaymentProratedAmount: { type: Number, required: false },
pendingPaymentRemainingDays: { type: Number, required: false },
pendingPaymentPeriodEndDate: { type: Date, required: false },
lastPaymentStatus: { type: String, required: false, enum: ["rejected", "failed", "cancelled"] },
lastPaymentPreferenceId: { type: String, required: false },
lastPaymentAt: { type: Date, required: false },
lastPaymentType: { type: String, required: false, enum: ["new", "extension", "upgrade"] },
}); });
this.planSuscriptionList = model<IPlanSuscriptionDocument>("PlanSuscription", this.schema); this.planSuscriptionList = model<IPlanSuscriptionDocument>("PlanSuscription", this.schema);
@@ -25,6 +25,17 @@ export type CreatePlanSuscriptionParams = {
pendingPaymentInitPoint?: string; pendingPaymentInitPoint?: string;
pendingPaymentPreferenceId?: string; pendingPaymentPreferenceId?: string;
pendingPaymentBillingMonths?: number; pendingPaymentBillingMonths?: number;
pendingPaymentType?: "extension" | "upgrade";
pendingPaymentPlanId?: string;
pendingPaymentCurrentPlanPrice?: number;
pendingPaymentRequestedPlanPrice?: number;
pendingPaymentProratedAmount?: number;
pendingPaymentRemainingDays?: number;
pendingPaymentPeriodEndDate?: Date;
lastPaymentStatus?: LastPlanPaymentStatus;
lastPaymentPreferenceId?: string;
lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType;
}; };
export type CancellPlanSuscriptionParams = { export type CancellPlanSuscriptionParams = {
@@ -75,6 +86,22 @@ export type GetSubscriptionInitPointParams = {
sessionUser: string; sessionUser: string;
}; };
export type VerifyPendingPlanPaymentParams = {
sessionUser: string;
};
export type VerifyPendingPlanPaymentResponse = {
status: "approved" | "pending" | "not_found" | "rejected" | "failed" | "cancelled";
approved: boolean;
message: string;
subscriptionId?: string;
paymentId?: string;
paymentType?: "new" | "extension" | "upgrade";
};
export type LastPlanPaymentStatus = "rejected" | "failed" | "cancelled";
export type LastPlanPaymentType = "new" | "extension" | "upgrade";
export interface GetSuscriptionInitPointResponse { export interface GetSuscriptionInitPointResponse {
init_point: string; init_point: string;
} }
@@ -99,6 +126,17 @@ export interface IPlanSuscription {
pendingPaymentInitPoint?: string; // URL de pago pendiente para extender el plan actual. pendingPaymentInitPoint?: string; // URL de pago pendiente para extender el plan actual.
pendingPaymentPreferenceId?: string; // ID de preferencia pendiente para extender el plan actual. pendingPaymentPreferenceId?: string; // ID de preferencia pendiente para extender el plan actual.
pendingPaymentBillingMonths?: number; // Meses pendientes de acreditar al aprobarse la extensión. pendingPaymentBillingMonths?: number; // Meses pendientes de acreditar al aprobarse la extensión.
pendingPaymentType?: "extension" | "upgrade";
pendingPaymentPlanId?: string;
pendingPaymentCurrentPlanPrice?: number;
pendingPaymentRequestedPlanPrice?: number;
pendingPaymentProratedAmount?: number;
pendingPaymentRemainingDays?: number;
pendingPaymentPeriodEndDate?: Date;
lastPaymentStatus?: LastPlanPaymentStatus;
lastPaymentPreferenceId?: string;
lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType;
} }
export interface ISubscriptionInfo { export interface ISubscriptionInfo {
@@ -110,6 +148,13 @@ export interface ISubscriptionInfo {
autoRenew: boolean; autoRenew: boolean;
mpStatus: string; mpStatus: string;
mpDateCreated: Date; mpDateCreated: Date;
pendingPaymentInitPoint?: string;
pendingPaymentPreferenceId?: string;
pendingPaymentType?: "extension" | "upgrade";
lastPaymentStatus?: LastPlanPaymentStatus;
lastPaymentPreferenceId?: string;
lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType;
} }
export interface IPlanSuscriptionsAdapter { export interface IPlanSuscriptionsAdapter {
@@ -0,0 +1,11 @@
const DEFAULT_BILLING_PERIOD_DAYS = 30;
export function calculateProratedUpgradeAmount(
currentPlanPrice: number,
requestedPlanPrice: number,
remainingDays: number,
billingPeriodDays = DEFAULT_BILLING_PERIOD_DAYS
): number {
const dailyDifference = (requestedPlanPrice - currentPlanPrice) / billingPeriodDays;
return Math.round(dailyDifference * remainingDays * 100) / 100;
}
@@ -31,10 +31,13 @@ import { NotificationsManager } from "../Notifications/Notifications";
import WapServerList from "../WapServer/WapServer"; import WapServerList from "../WapServer/WapServer";
import CompaniesList from "../Companies/Companies"; import CompaniesList from "../Companies/Companies";
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface"; import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
import { calculateProratedUpgradeAmount } from "./PlanSubscriptions.pricing";
dayjs.locale("es"); dayjs.locale("es");
dayjs.extend(isSameOrAfter); dayjs.extend(isSameOrAfter);
const DAY_MS = 24 * 60 * 60 * 1000;
class PlanSuscriptionsManager implements IPlanSuscriptionsManager { class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
planSuscriptions: PlanSuscriptionsAdapterMongoose; planSuscriptions: PlanSuscriptionsAdapterMongoose;
@@ -46,6 +49,20 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
return subscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED || subscription.mpStatus === MP_SUBS_STATUS.CANCELLED; return subscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED || subscription.mpStatus === MP_SUBS_STATUS.CANCELLED;
} }
private buildMercadoPagoBackUrl(returnStatus: "success" | "failure" | "pending"): string {
const backUrl = process.env.MP_BACK_URL || "";
if (!backUrl) return backUrl;
try {
const url = new URL(backUrl);
url.searchParams.set("mp_return", returnStatus);
return url.toString();
} catch {
const separator = backUrl.includes("?") ? "&" : "?";
return `${backUrl}${separator}mp_return=${returnStatus}`;
}
}
public async getInitPoint( public async getInitPoint(
data: GetSubscriptionInitPointParams data: GetSubscriptionInitPointParams
): Promise<GetSuscriptionInitPointResponse> { ): Promise<GetSuscriptionInitPointResponse> {
@@ -190,6 +207,23 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
months: data.months || 1, months: data.months || 1,
}); });
} else { } else {
const isActivePaidSubscription =
actualPlan.price > 0 &&
(subscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
subscription.mpStatus === MP_SUBS_STATUS.CANCELLED) &&
dayjs(subscription.endDate).isAfter(new Date());
if (isActivePaidSubscription && actualPlan.id !== data.planId) {
if (plan.price <= actualPlan.price) {
throw new Error("No se puede cambiar a un plan igual o inferior desde esta ruta de actualización.");
}
subscription = await this.toMP({
planId: data.planId,
sessionUser: data.sessionUser,
months: data.months || 1,
});
} else {
if (subscription.mpStatus != MP_SUBS_STATUS.CANCELLED && actualPlan.id !== data.planId) { if (subscription.mpStatus != MP_SUBS_STATUS.CANCELLED && actualPlan.id !== data.planId) {
throw new Error( throw new Error(
"No se puede cambiar el plan, ya que el usuario ya tiene una suscripción activa. Para cambiarlo primero debes cancelar tu plan actual." "No se puede cambiar el plan, ya que el usuario ya tiene una suscripción activa. Para cambiarlo primero debes cancelar tu plan actual."
@@ -201,6 +235,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
months: data.months || 1, months: data.months || 1,
}); });
}
} }
} }
} }
@@ -249,6 +284,42 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
}); });
const currentPlan = currentSubscription
? await PlansList.plans.findOne({ _id: String(currentSubscription.planId) })
: null;
const isUpgradePayment =
currentSubscription &&
currentPlan &&
String(currentSubscription.planId) !== String(data.planId) &&
currentPlan.price > 0 &&
plan.price > currentPlan.price &&
(currentSubscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
currentSubscription.mpStatus === MP_SUBS_STATUS.CANCELLED) &&
dayjs(currentSubscription.endDate).isAfter(preferenceDate);
const remainingDays = isUpgradePayment
? Math.max(0, Math.ceil((new Date(currentSubscription.endDate).getTime() - preferenceDate.toDate().getTime()) / DAY_MS))
: 0;
const proratedAmount = isUpgradePayment
? calculateProratedUpgradeAmount(currentPlan.price, plan.price, remainingDays)
: 0;
if (isUpgradePayment && remainingDays <= 0) {
throw new Error("No se puede actualizar el plan porque la suscripción actual no tiene días restantes.");
}
if (
currentSubscription?.pendingPaymentPreferenceId &&
currentSubscription.pendingPaymentInitPoint
) {
if (String(currentSubscription.pendingPaymentPlanId || currentSubscription.planId) === String(data.planId)) {
return currentSubscription;
}
throw new Error("Ya tenés un pago pendiente para tu plan. Verificalo o esperá a que se actualice antes de intentar otro cambio.");
}
if ( if (
currentSubscription && currentSubscription &&
String(currentSubscription.planId) === String(data.planId) && String(currentSubscription.planId) === String(data.planId) &&
@@ -265,16 +336,18 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
{ {
items: [ items: [
{ {
title: "TurnosXpress.com.ar - Plan " + plan.name + " (" + data.months + " meses)", title: isUpgradePayment
? "TurnosXpress.com.ar - Upgrade al plan " + plan.name
: "TurnosXpress.com.ar - Plan " + plan.name + " (" + data.months + " meses)",
quantity: 1, quantity: 1,
currency_id: "ARS", currency_id: "ARS",
unit_price: finalPrice unit_price: isUpgradePayment ? proratedAmount : finalPrice
} }
], ],
back_urls: { back_urls: {
success: process.env.MP_BACK_URL, success: this.buildMercadoPagoBackUrl("success"),
failure: process.env.MP_BACK_URL, failure: this.buildMercadoPagoBackUrl("failure"),
pending: process.env.MP_BACK_URL pending: this.buildMercadoPagoBackUrl("pending")
}, },
auto_return: "approved", auto_return: "approved",
payer: { payer: {
@@ -293,6 +366,35 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
const subscriptionData = response.data; const subscriptionData = response.data;
if (isUpgradePayment && currentSubscription && currentPlan) {
currentSubscription.pendingPaymentInitPoint = subscriptionData.init_point;
currentSubscription.pendingPaymentPreferenceId = subscriptionData.id;
currentSubscription.pendingPaymentBillingMonths = undefined;
currentSubscription.pendingPaymentType = "upgrade";
currentSubscription.pendingPaymentPlanId = data.planId;
currentSubscription.pendingPaymentCurrentPlanPrice = currentPlan.price;
currentSubscription.pendingPaymentRequestedPlanPrice = plan.price;
currentSubscription.pendingPaymentProratedAmount = proratedAmount;
currentSubscription.pendingPaymentRemainingDays = remainingDays;
currentSubscription.pendingPaymentPeriodEndDate = currentSubscription.endDate;
currentSubscription.lastPaymentStatus = undefined;
currentSubscription.lastPaymentPreferenceId = undefined;
currentSubscription.lastPaymentAt = undefined;
currentSubscription.lastPaymentType = undefined;
currentSubscription.mpInitPoint = subscriptionData.init_point;
await currentSubscription.save();
await NotificationsManager.sendSystemNotification({
userId: String(data.sessionUser),
subject: "Se ha creado una actualización de suscripción",
message: `Se ha creado una actualización al plan ${plan.name}.`,
type: NotificationType.BILLING,
code: String(currentSubscription._id)
});
return currentSubscription;
}
if ( if (
currentSubscription && currentSubscription &&
String(currentSubscription.planId) === String(data.planId) && String(currentSubscription.planId) === String(data.planId) &&
@@ -302,6 +404,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
currentSubscription.pendingPaymentInitPoint = subscriptionData.init_point; currentSubscription.pendingPaymentInitPoint = subscriptionData.init_point;
currentSubscription.pendingPaymentPreferenceId = subscriptionData.id; currentSubscription.pendingPaymentPreferenceId = subscriptionData.id;
currentSubscription.pendingPaymentBillingMonths = data.months; currentSubscription.pendingPaymentBillingMonths = data.months;
currentSubscription.pendingPaymentType = "extension";
currentSubscription.lastPaymentStatus = undefined;
currentSubscription.lastPaymentPreferenceId = undefined;
currentSubscription.lastPaymentAt = undefined;
currentSubscription.lastPaymentType = undefined;
currentSubscription.mpInitPoint = subscriptionData.init_point; currentSubscription.mpInitPoint = subscriptionData.init_point;
await currentSubscription.save(); await currentSubscription.save();
@@ -335,6 +442,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
pendingPaymentInitPoint: "", pendingPaymentInitPoint: "",
pendingPaymentPreferenceId: "", pendingPaymentPreferenceId: "",
pendingPaymentBillingMonths: undefined, pendingPaymentBillingMonths: undefined,
pendingPaymentType: undefined,
lastPaymentStatus: undefined,
lastPaymentPreferenceId: undefined,
lastPaymentAt: undefined,
lastPaymentType: undefined,
}); });
await NotificationsManager.sendSystemNotification({ await NotificationsManager.sendSystemNotification({
@@ -385,7 +497,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
const dateEnd = dayjs(subscription.endDate); const dateEnd = dayjs(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd)) { if (dateNow.isSameOrAfter(dateEnd)) {
this.toFreePlan({ await this.toFreePlan({
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
}); });
} else { } else {
@@ -393,6 +505,16 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
subscription.mpInitPoint = ""; subscription.mpInitPoint = "";
subscription.mpPayerEmail = ""; subscription.mpPayerEmail = "";
subscription.mpPayerId = -1; subscription.mpPayerId = -1;
subscription.pendingPaymentInitPoint = "";
subscription.pendingPaymentPreferenceId = "";
subscription.pendingPaymentBillingMonths = undefined;
subscription.pendingPaymentType = undefined;
subscription.pendingPaymentPlanId = undefined;
subscription.pendingPaymentCurrentPlanPrice = undefined;
subscription.pendingPaymentRequestedPlanPrice = undefined;
subscription.pendingPaymentProratedAmount = undefined;
subscription.pendingPaymentRemainingDays = undefined;
subscription.pendingPaymentPeriodEndDate = undefined;
await subscription.save(); await subscription.save();
} }
} catch (error) { } catch (error) {
@@ -462,6 +584,13 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
subscription.pendingPaymentInitPoint = ""; subscription.pendingPaymentInitPoint = "";
subscription.pendingPaymentPreferenceId = ""; subscription.pendingPaymentPreferenceId = "";
subscription.pendingPaymentBillingMonths = undefined; subscription.pendingPaymentBillingMonths = undefined;
subscription.pendingPaymentType = undefined;
subscription.pendingPaymentPlanId = undefined;
subscription.pendingPaymentCurrentPlanPrice = undefined;
subscription.pendingPaymentRequestedPlanPrice = undefined;
subscription.pendingPaymentProratedAmount = undefined;
subscription.pendingPaymentRemainingDays = 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();
@@ -488,6 +617,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
autoRenew: subscription.autoRenew, autoRenew: subscription.autoRenew,
mpStatus: subscription.mpStatus, mpStatus: subscription.mpStatus,
mpDateCreated: subscription.mpDateCreated, mpDateCreated: subscription.mpDateCreated,
lastPaymentStatus: subscription.lastPaymentStatus,
lastPaymentPreferenceId: subscription.lastPaymentPreferenceId,
lastPaymentAt: subscription.lastPaymentAt,
lastPaymentType: subscription.lastPaymentType,
}; };
io.to(`user:${String(subscription.userId)}`).emit("suscription_status_updated", susInfo); io.to(`user:${String(subscription.userId)}`).emit("suscription_status_updated", susInfo);
@@ -539,7 +672,9 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
_id: subscription.planId, _id: subscription.planId,
}); });
if (!subscription.isActive || subscription.mpStatus === MP_SUBS_STATUS.PENDING) { const isPendingPaidSubscription = subscription.mpStatus === MP_SUBS_STATUS.PENDING && plan.price > 0;
if (!isPendingPaidSubscription && (!subscription.isActive || subscription.mpStatus === MP_SUBS_STATUS.PENDING)) {
const freePlan = await PlansList.findOne({ price: 0 }); const freePlan = await PlansList.findOne({ price: 0 });
if (freePlan) { if (freePlan) {
plan = freePlan; plan = freePlan;
@@ -559,6 +694,13 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
autoRenew: subscription.autoRenew, autoRenew: subscription.autoRenew,
mpStatus: subscription.mpStatus, mpStatus: subscription.mpStatus,
mpDateCreated: subscription.mpDateCreated, mpDateCreated: subscription.mpDateCreated,
pendingPaymentInitPoint: subscription.pendingPaymentInitPoint,
pendingPaymentPreferenceId: subscription.pendingPaymentPreferenceId,
pendingPaymentType: subscription.pendingPaymentType,
lastPaymentStatus: subscription.lastPaymentStatus,
lastPaymentPreferenceId: subscription.lastPaymentPreferenceId,
lastPaymentAt: subscription.lastPaymentAt,
lastPaymentType: subscription.lastPaymentType,
}; };
} }
@@ -1,4 +1,14 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { calculateProratedUpgradeAmount } from "../PlanSubscriptions.pricing";
import PlanSubscriptionsList from "../PlanSubscriptons";
import { MP_SUBS_STATUS } from "../PlanSubscriptions.Adapter.Mongoose";
import PlansList from "../../Plans/Plans";
jest.mock("../../../index", () => ({
io: {
to: jest.fn(() => ({ emit: jest.fn() })),
},
}));
describe("PlanSubscriptions Logic", () => { describe("PlanSubscriptions Logic", () => {
it("should calculate correct endDate for 1, 3, 6, 12 months", () => { it("should calculate correct endDate for 1, 3, 6, 12 months", () => {
@@ -40,4 +50,71 @@ describe("PlanSubscriptions Logic", () => {
const final12 = total12 - (total12 * (30 / 100)); // 12000 - 3600 = 8400 const final12 = total12 - (total12 * (30 / 100)); // 12000 - 3600 = 8400
expect(final12).toBe(8400); expect(final12).toBe(8400);
}); });
it("should calculate only the prorated difference for paid plan upgrades", () => {
const currentPlanPrice = 1000;
const requestedPlanPrice = 2500;
const remainingDays = 15;
const proratedAmount = calculateProratedUpgradeAmount(
currentPlanPrice,
requestedPlanPrice,
remainingDays
);
expect(proratedAmount).toBe(750);
});
it("should keep pending paid subscription plan instead of masking it as free", async () => {
const paidPlan = {
id: "paid-plan-id",
name: "Intermedio",
description: "",
features: [],
code: "intermediate",
price: 1000,
annualPrice: 0,
limitOrganizations: 1,
limitEmployees: 1,
limitServices: 1,
limitAppointments: 1,
limitClients: 1,
limitRepeats: 1,
mailNotifications: false,
smsNotifications: false,
wapNotifications: false,
bot: false,
active: true,
dateLimit: false,
payments: false,
};
const subscription = {
id: "subscription-id",
planId: paidPlan.id,
startDate: new Date(),
endDate: new Date(),
isActive: false,
autoRenew: false,
mpStatus: MP_SUBS_STATUS.PENDING,
mpDateCreated: new Date(),
};
const originalPlanSuscriptions = PlanSubscriptionsList.planSuscriptions;
const findOneSpy = jest.spyOn(PlansList, "findOne").mockResolvedValue(paidPlan);
PlanSubscriptionsList.planSuscriptions = {
findOne: jest.fn().mockResolvedValue(subscription),
} as any;
try {
const result = await PlanSubscriptionsList.getSubscriptionByUser({ sessionUser: "user-id" });
expect(result?.plan).toEqual(paidPlan);
expect(result?.mpStatus).toBe(MP_SUBS_STATUS.PENDING);
expect(findOneSpy).toHaveBeenCalledTimes(1);
expect(findOneSpy).toHaveBeenCalledWith({ _id: subscription.planId });
} finally {
PlanSubscriptionsList.planSuscriptions = originalPlanSuscriptions;
findOneSpy.mockRestore();
}
});
}); });
@@ -4,6 +4,41 @@ import { MP_SUBS_STATUS } from "../../Models/PlanSubscriptions/PlanSubscriptions
import { connect } from "mongoose"; import { connect } from "mongoose";
import dayjs from "dayjs"; import dayjs from "dayjs";
import PlanPaymentsList from "../../Models/PlanPayments/PlanPayments"; import PlanPaymentsList from "../../Models/PlanPayments/PlanPayments";
import { IPlanSuscriptionDocument } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose";
import { VerifyPendingPlanPaymentResponse } from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
type MercadoPagoPaymentData = {
id: string | number;
status: string;
external_reference?: string;
preference_id?: string;
order?: { id?: string };
date_approved?: string;
payer?: { id?: number; email?: string };
transaction_amount?: number;
payment_method_id?: string;
payment_type_id?: string;
date_last_updated?: string;
};
type MercadoPagoMerchantOrder = {
id: string | number;
preference_id?: string;
external_reference?: string;
payments?: MercadoPagoPaymentData[];
};
type MercadoPagoPaymentContext = {
preferenceId?: string;
externalReference?: string;
};
type MercadoPagoPaymentVerification = {
status: "approved" | "rejected" | "failed" | "cancelled" | "pending";
payment: MercadoPagoPaymentData | null;
};
const REJECTED_PAYMENT_STATUSES = ["rejected", "cancelled", "failed"];
export class MercadoPagoWebhookService { export class MercadoPagoWebhookService {
public async handleWebhook(body: any): Promise<void> { public async handleWebhook(body: any): Promise<void> {
@@ -26,78 +61,20 @@ export class MercadoPagoWebhookService {
} }
); );
const paymentData = paymentResponse.data; const paymentData: MercadoPagoPaymentData = paymentResponse.data;
const status = paymentData.status; // approved, pending, rejected, etc. const status = paymentData.status; // approved, pending, rejected, etc.
const userId = paymentData.external_reference; // We passed this when creating preference const paymentContext = await this.resolvePaymentContext(paymentData);
const preferenceId = paymentData.preference_id || paymentData.order?.id; const userId = paymentContext.externalReference || paymentData.external_reference; // We passed this when creating preference
const preferenceId = paymentContext.preferenceId || paymentData.preference_id;
const subscription = await this.findSubscriptionForPayment(userId, preferenceId);
let subscription = await PlanSubscriptionsList.planSuscriptions.findOne({ if (userId && preferenceId && status === "approved") {
sessionUser: userId,
mpPreferenceId: preferenceId,
mpStatus: MP_SUBS_STATUS.PENDING,
});
if (!subscription && userId && preferenceId) {
subscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({
userId,
pendingPaymentPreferenceId: preferenceId,
}).exec();
}
if (userId && status === "approved") {
if (subscription) { if (subscription) {
const approvedAt = paymentData.date_approved ? new Date(paymentData.date_approved) : new Date(); await this.applyApprovedPayment(subscription, paymentData, userId, preferenceId);
const billingMonths = subscription.pendingPaymentBillingMonths || subscription.billingMonths || 1;
const isExtensionPayment = subscription.pendingPaymentPreferenceId === preferenceId;
const periodStart = isExtensionPayment && dayjs(subscription.endDate).isAfter(approvedAt)
? subscription.endDate
: approvedAt;
subscription.mpStatus = MP_SUBS_STATUS.AUTHORIZED;
subscription.isActive = true;
subscription.autoRenew = false;
subscription.startDate = isExtensionPayment ? subscription.startDate : approvedAt;
subscription.endDate = dayjs(periodStart).add(billingMonths, "months").toDate();
subscription.mpPayerId = paymentData.payer?.id || subscription.mpPayerId;
subscription.mpPayerEmail = paymentData.payer?.email || subscription.mpPayerEmail;
subscription.pendingPaymentInitPoint = "";
subscription.pendingPaymentPreferenceId = "";
subscription.pendingPaymentBillingMonths = undefined;
await subscription.save();
await PlanPaymentsList.create({
userId,
subscriptionId: String(subscription._id),
amount: paymentData.transaction_amount || 0,
paymentDate: approvedAt,
paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago",
status: "completed",
transactionId: String(paymentData.id),
});
} }
} else if (userId && status === "rejected") { } else if (userId && preferenceId && REJECTED_PAYMENT_STATUSES.includes(status)) {
if (subscription) { if (subscription) {
const isExtensionPayment = subscription.pendingPaymentPreferenceId === preferenceId; await this.applyRejectedPayment(subscription, paymentData, userId, preferenceId);
if (isExtensionPayment) {
subscription.pendingPaymentInitPoint = "";
subscription.pendingPaymentPreferenceId = "";
subscription.pendingPaymentBillingMonths = undefined;
} else {
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
subscription.isActive = false;
}
await subscription.save();
await PlanPaymentsList.create({
userId,
subscriptionId: String(subscription._id),
amount: paymentData.transaction_amount || 0,
paymentDate: new Date(),
paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago",
status: "failed",
transactionId: String(paymentData.id),
});
} }
} }
} catch (error) { } catch (error) {
@@ -105,4 +82,315 @@ export class MercadoPagoWebhookService {
} }
} }
} }
public async verifyPendingPlanPayment(sessionUser: string): Promise<VerifyPendingPlanPaymentResponse> {
const pendingSubscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({
userId: sessionUser,
mpPreferenceId: { $exists: true, $ne: "" },
mpStatus: MP_SUBS_STATUS.PENDING,
}).exec();
const pendingExistingSubscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({
userId: sessionUser,
pendingPaymentPreferenceId: { $exists: true, $ne: "" },
mpStatus: MP_SUBS_STATUS.AUTHORIZED,
}).exec();
const subscription = pendingSubscription || pendingExistingSubscription;
if (!subscription) {
return {
status: "not_found",
approved: false,
message: "No se encontró un pago pendiente para verificar.",
};
}
const preferenceId = pendingSubscription
? subscription.mpPreferenceId
: subscription.pendingPaymentPreferenceId;
if (!preferenceId) {
return {
status: "not_found",
approved: false,
message: "No se encontró una preferencia de pago pendiente para verificar.",
subscriptionId: String(subscription._id),
};
}
const paymentVerification = await this.findPaymentForPreference(sessionUser, preferenceId);
if (!paymentVerification.payment || paymentVerification.status === "pending") {
return {
status: "pending",
approved: false,
message: "El pago todavía no figura confirmado.",
subscriptionId: String(subscription._id),
paymentType: this.getPaymentType(subscription, preferenceId),
};
}
const paymentType = this.getPaymentType(subscription, preferenceId);
const payment = paymentVerification.payment;
if (paymentVerification.status !== "approved") {
await this.applyRejectedPayment(subscription, payment, sessionUser, preferenceId);
return {
status: paymentVerification.status,
approved: false,
message: "El pago fue rechazado. Tu plan actual no cambió y podés intentarlo nuevamente.",
subscriptionId: String(subscription._id),
paymentId: String(payment.id),
paymentType,
};
}
await this.applyApprovedPayment(subscription, payment, sessionUser, preferenceId);
return {
status: "approved",
approved: true,
message: "El pago fue aprobado y la suscripción fue actualizada.",
subscriptionId: String(subscription._id),
paymentId: String(payment.id),
paymentType,
};
}
private async findPaymentForPreference(sessionUser: string, preferenceId: string): Promise<MercadoPagoPaymentVerification> {
const merchantOrders = await this.searchMerchantOrdersByPreferenceId(preferenceId);
const merchantOrder = merchantOrders.find((order) => {
return !order.external_reference || order.external_reference === sessionUser;
});
const merchantOrderPayment = this.pickMostRelevantPayment(merchantOrder?.payments || []);
if (merchantOrderPayment) {
const payment = await this.getPaymentById(merchantOrderPayment.id);
return {
status: this.normalizePaymentStatus(payment.status),
payment,
};
}
const response = await axios.get("https://api.mercadopago.com/v1/payments/search", {
headers: this.getMercadoPagoHeaders(),
params: {
external_reference: sessionUser,
sort: "date_created",
criteria: "desc",
limit: 20,
},
});
const payments: MercadoPagoPaymentData[] = response.data?.results || [];
const payment = this.pickMostRelevantPayment(payments.filter((payment) => payment.preference_id === preferenceId));
if (!payment) {
return { status: "pending", payment: null };
}
const fullPayment = await this.getPaymentById(payment.id);
return {
status: this.normalizePaymentStatus(fullPayment.status),
payment: fullPayment,
};
}
private pickMostRelevantPayment(payments: MercadoPagoPaymentData[]): MercadoPagoPaymentData | null {
return payments.find((payment) => payment.status === "approved")
|| payments.find((payment) => REJECTED_PAYMENT_STATUSES.includes(payment.status))
|| payments[0]
|| null;
}
private normalizePaymentStatus(status: string): MercadoPagoPaymentVerification["status"] {
if (status === "approved") return "approved";
if (status === "cancelled") return "cancelled";
if (status === "failed") return "failed";
if (status === "rejected") return "rejected";
return "pending";
}
private async resolvePaymentContext(paymentData: MercadoPagoPaymentData): Promise<MercadoPagoPaymentContext> {
if (paymentData.preference_id) {
return {
preferenceId: paymentData.preference_id,
externalReference: paymentData.external_reference,
};
}
if (!paymentData.order?.id) {
return {
externalReference: paymentData.external_reference,
};
}
const merchantOrder = await this.getMerchantOrderById(paymentData.order.id);
return {
preferenceId: merchantOrder?.preference_id,
externalReference: merchantOrder?.external_reference || paymentData.external_reference,
};
}
private async searchMerchantOrdersByPreferenceId(preferenceId: string): Promise<MercadoPagoMerchantOrder[]> {
const response = await axios.get("https://api.mercadopago.com/merchant_orders/search", {
headers: this.getMercadoPagoHeaders(),
params: { preference_id: preferenceId },
});
return response.data?.elements || [];
}
private async getMerchantOrderById(merchantOrderId: string): Promise<MercadoPagoMerchantOrder | null> {
const response = await axios.get(`https://api.mercadopago.com/merchant_orders/${merchantOrderId}`, {
headers: this.getMercadoPagoHeaders(),
});
return response.data || null;
}
private async getPaymentById(paymentId: string | number): Promise<MercadoPagoPaymentData> {
const response = await axios.get(`https://api.mercadopago.com/v1/payments/${paymentId}`, {
headers: this.getMercadoPagoHeaders(),
});
return response.data;
}
private getMercadoPagoHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${process.env.MP_ACCESS_TOKEN}`,
"Content-Type": "application/json",
};
}
private async findSubscriptionForPayment(userId?: string, preferenceId?: string): Promise<IPlanSuscriptionDocument | null> {
if (!userId || !preferenceId) return null;
let subscription = await PlanSubscriptionsList.planSuscriptions.findOne({
sessionUser: userId,
mpPreferenceId: preferenceId,
mpStatus: MP_SUBS_STATUS.PENDING,
});
if (!subscription) {
subscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({
userId,
pendingPaymentPreferenceId: preferenceId,
mpStatus: MP_SUBS_STATUS.AUTHORIZED,
}).exec();
}
return subscription;
}
private async applyApprovedPayment(
subscription: IPlanSuscriptionDocument,
paymentData: MercadoPagoPaymentData,
userId: string,
preferenceId: string
): Promise<void> {
const approvedAt = paymentData.date_approved ? new Date(paymentData.date_approved) : new Date();
const billingMonths = subscription.pendingPaymentBillingMonths || subscription.billingMonths || 1;
const isExtensionPayment = subscription.pendingPaymentPreferenceId === preferenceId;
const isUpgradePayment = isExtensionPayment && subscription.pendingPaymentType === "upgrade";
const periodStart = isExtensionPayment && dayjs(subscription.endDate).isAfter(approvedAt)
? subscription.endDate
: approvedAt;
if (isUpgradePayment && subscription.pendingPaymentPlanId) {
subscription.planId = subscription.pendingPaymentPlanId;
}
subscription.mpStatus = MP_SUBS_STATUS.AUTHORIZED;
subscription.isActive = true;
subscription.autoRenew = false;
subscription.startDate = isExtensionPayment ? subscription.startDate : approvedAt;
subscription.endDate = isUpgradePayment
? subscription.endDate
: dayjs(periodStart).add(billingMonths, "months").toDate();
subscription.mpPayerId = paymentData.payer?.id || subscription.mpPayerId;
subscription.mpPayerEmail = paymentData.payer?.email || subscription.mpPayerEmail;
subscription.pendingPaymentInitPoint = "";
subscription.pendingPaymentPreferenceId = "";
subscription.pendingPaymentBillingMonths = undefined;
subscription.pendingPaymentType = undefined;
subscription.pendingPaymentPlanId = undefined;
subscription.pendingPaymentCurrentPlanPrice = undefined;
subscription.pendingPaymentRequestedPlanPrice = undefined;
subscription.pendingPaymentProratedAmount = undefined;
subscription.pendingPaymentRemainingDays = undefined;
subscription.pendingPaymentPeriodEndDate = undefined;
subscription.lastPaymentStatus = undefined;
subscription.lastPaymentPreferenceId = undefined;
subscription.lastPaymentAt = undefined;
subscription.lastPaymentType = undefined;
await subscription.save();
await PlanPaymentsList.createIfMissingByTransactionId({
userId,
subscriptionId: String(subscription._id),
amount: paymentData.transaction_amount || 0,
paymentDate: approvedAt,
paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago",
status: "completed",
transactionId: String(paymentData.id),
});
}
private async applyRejectedPayment(
subscription: IPlanSuscriptionDocument,
paymentData: MercadoPagoPaymentData,
userId: string,
preferenceId: string
): Promise<void> {
const isExtensionPayment = subscription.pendingPaymentPreferenceId === preferenceId;
const paymentStatus = this.normalizePaymentStatus(paymentData.status);
const paymentType = this.getPaymentType(subscription, preferenceId);
if (isExtensionPayment) {
subscription.pendingPaymentInitPoint = "";
subscription.pendingPaymentPreferenceId = "";
subscription.pendingPaymentBillingMonths = undefined;
subscription.pendingPaymentType = undefined;
subscription.pendingPaymentPlanId = undefined;
subscription.pendingPaymentCurrentPlanPrice = undefined;
subscription.pendingPaymentRequestedPlanPrice = undefined;
subscription.pendingPaymentProratedAmount = undefined;
subscription.pendingPaymentRemainingDays = undefined;
subscription.pendingPaymentPeriodEndDate = undefined;
} else {
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
subscription.isActive = false;
}
if (paymentStatus !== "pending" && paymentStatus !== "approved") {
subscription.lastPaymentStatus = paymentStatus;
subscription.lastPaymentPreferenceId = preferenceId;
subscription.lastPaymentAt = paymentData.date_last_updated ? new Date(paymentData.date_last_updated) : new Date();
subscription.lastPaymentType = paymentType;
}
await subscription.save();
await PlanPaymentsList.createIfMissingByTransactionId({
userId,
subscriptionId: String(subscription._id),
amount: paymentData.transaction_amount || 0,
paymentDate: new Date(),
paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago",
status: "failed",
transactionId: String(paymentData.id),
});
}
private getPaymentType(subscription: IPlanSuscriptionDocument, preferenceId: string): "new" | "extension" | "upgrade" {
if (subscription.pendingPaymentPreferenceId === preferenceId) {
return subscription.pendingPaymentType === "upgrade" ? "upgrade" : "extension";
}
return "new";
}
} }
@@ -0,0 +1,74 @@
import { MercadoPagoWebhookService } from "../MercadoPagoWebhook.Service";
import PlanPaymentsList from "../../../Models/PlanPayments/PlanPayments";
jest.mock("../../../Models/PlanPayments/PlanPayments", () => ({
__esModule: true,
default: {
createIfMissingByTransactionId: jest.fn(),
},
}));
jest.mock("../../../Models/PlanSubscriptions/PlanSubscriptons", () => ({
__esModule: true,
default: {},
}));
describe("MercadoPagoWebhookService", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("should clear pending upgrade metadata without changing the current plan or dates when payment is rejected", async () => {
const startDate = new Date("2026-07-01T00:00:00.000Z");
const endDate = new Date("2026-08-01T00:00:00.000Z");
const subscription = {
_id: "subscription-id",
planId: "current-plan-id",
startDate,
endDate,
pendingPaymentInitPoint: "https://checkout.example",
pendingPaymentPreferenceId: "preference-id",
pendingPaymentBillingMonths: undefined,
pendingPaymentType: "upgrade",
pendingPaymentPlanId: "requested-plan-id",
pendingPaymentCurrentPlanPrice: 1000,
pendingPaymentRequestedPlanPrice: 2000,
pendingPaymentProratedAmount: 500,
pendingPaymentRemainingDays: 15,
pendingPaymentPeriodEndDate: endDate,
save: jest.fn().mockResolvedValue(undefined),
};
const service = new MercadoPagoWebhookService() as any;
await service.applyRejectedPayment(
subscription,
{
id: "payment-id",
status: "rejected",
transaction_amount: 500,
payment_method_id: "visa",
},
"user-id",
"preference-id"
);
expect(subscription.planId).toBe("current-plan-id");
expect(subscription.startDate).toBe(startDate);
expect(subscription.endDate).toBe(endDate);
expect(subscription.pendingPaymentInitPoint).toBe("");
expect(subscription.pendingPaymentPreferenceId).toBe("");
expect(subscription.pendingPaymentType).toBeUndefined();
expect(subscription.pendingPaymentPlanId).toBeUndefined();
expect(subscription.pendingPaymentProratedAmount).toBeUndefined();
expect(subscription.save).toHaveBeenCalledTimes(1);
expect(PlanPaymentsList.createIfMissingByTransactionId).toHaveBeenCalledWith({
userId: "user-id",
subscriptionId: "subscription-id",
amount: 500,
paymentDate: expect.any(Date),
paymentMethod: "visa",
status: "failed",
transactionId: "payment-id",
});
});
});
@@ -7,9 +7,12 @@ import {
GetSubscriptionInitPointParams, GetSubscriptionInitPointParams,
GetSuscriptionInitPointResponse, GetSuscriptionInitPointResponse,
MPPreApprovalResponse, MPPreApprovalResponse,
VerifyPendingPlanPaymentParams,
VerifyPendingPlanPaymentResponse,
} from "../../Models/PlanSubscriptions/PlanSubscriptions.interface"; } from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
import { authenticateMiddleware } from "../../middleware/authentication"; import { authenticateMiddleware } from "../../middleware/authentication";
import { ApiVoidResult } from "../../Models/Api.VoidResult.type"; import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
import { rateLimiter } from "../../middleware/ratelimiter";
@Route("suscriptions/get-init-point") @Route("suscriptions/get-init-point")
@Middlewares(authenticateMiddleware) @Middlewares(authenticateMiddleware)
@@ -75,3 +78,24 @@ export class PlanSubscriptionsCancellController extends Controller {
} }
} }
} }
@Route("suscriptions/verify-pending-payment")
@Middlewares([rateLimiter, authenticateMiddleware])
export class PlanSubscriptionsVerifyPendingPaymentController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async verifyPendingPayment(
@Body() requestBody: VerifyPendingPlanPaymentParams
): Promise<VerifyPendingPlanPaymentResponse | ApiValidationError> {
try {
const result = await new PlanSubscriptionsService().verifyPendingPayment(requestBody);
this.setStatus(200);
return result;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -5,8 +5,12 @@ import {
GetSubscriptionInitPointParams, GetSubscriptionInitPointParams,
GetSuscriptionInitPointResponse, GetSuscriptionInitPointResponse,
MPPreApprovalResponse, MPPreApprovalResponse,
VerifyPendingPlanPaymentParams,
VerifyPendingPlanPaymentResponse,
} from "../../Models/PlanSubscriptions/PlanSubscriptions.interface"; } from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
import { connect } from "mongoose"; import { connect } from "mongoose";
import { validateSessionUser } from "../../helpers/check";
import { MercadoPagoWebhookService } from "../MercadoPago/MercadoPagoWebhook.Service";
export class PlanSubscriptionsService { export class PlanSubscriptionsService {
public async getInitPoint( public async getInitPoint(
@@ -27,4 +31,10 @@ export class PlanSubscriptionsService {
await connect(`${process.env.DATABASE_CONNECTION}`); await connect(`${process.env.DATABASE_CONNECTION}`);
await PlanSubscriptionsList.cancell(data); await PlanSubscriptionsList.cancell(data);
} }
public async verifyPendingPayment(data: VerifyPendingPlanPaymentParams): Promise<VerifyPendingPlanPaymentResponse> {
await connect(`${process.env.DATABASE_CONNECTION}`);
await validateSessionUser({ sessionUser: data.sessionUser });
return new MercadoPagoWebhookService().verifyPendingPlanPayment(data.sessionUser);
}
} }
+23
View File
@@ -48,6 +48,22 @@ export type GetSubscriptionInitPointParams = {
sessionUser: string; sessionUser: string;
}; };
export type VerifyPendingPlanPaymentParams = {
sessionUser: string;
};
export type VerifyPendingPlanPaymentResponse = {
status: "approved" | "pending" | "not_found" | "rejected" | "failed" | "cancelled";
approved: boolean;
message: string;
subscriptionId?: string;
paymentId?: string;
paymentType?: "new" | "extension" | "upgrade";
};
export type LastPlanPaymentStatus = "rejected" | "failed" | "cancelled";
export type LastPlanPaymentType = "new" | "extension" | "upgrade";
export interface GetSuscriptionInitPointResponse { export interface GetSuscriptionInitPointResponse {
init_point: string; init_point: string;
} }
@@ -61,6 +77,13 @@ export interface ISubscriptionInfo {
autoRenew?: boolean; autoRenew?: boolean;
mpStatus?: string; mpStatus?: string;
mpDateCreated?: Date; mpDateCreated?: Date;
pendingPaymentInitPoint?: string;
pendingPaymentPreferenceId?: string;
pendingPaymentType?: "extension" | "upgrade";
lastPaymentStatus?: LastPlanPaymentStatus;
lastPaymentPreferenceId?: string;
lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType;
} }
export const DefaultSubscriptionModel: ISubscriptionInfo = { export const DefaultSubscriptionModel: ISubscriptionInfo = {
id: "", id: "",
@@ -54,20 +54,23 @@ export default function Login(props: LoginProps) {
SessionToken.setToken(userSession.userToken); SessionToken.setToken(userSession.userToken);
SessionInfo.setLoginState("success"); SessionInfo.setLoginState("success");
alert.showSuccess(msg); alert.showSuccess(msg);
manageLoginActions(userSession);
}; };
const manageLoginActions = () => { const manageLoginActions = (session: ISession = SessionInfo) => {
const toUrl = process.env.NEXT_PUBLIC_DEFAULT_PATH_AFTER_LOGIN || "/";
if (loginAction.action !== LOGIN_ACTIONS.NONE) { if (loginAction.action !== LOGIN_ACTIONS.NONE) {
goTo(loginAction.url); goTo(loginAction.url);
loginAction.clear(); loginAction.clear();
} else { } else {
if (SessionInfo.subscription && SessionInfo.subscription.plan) { const hasPlan = Boolean(session.subscription?.plan?.id);
const hasOrganization = Object.keys(session.organizationSubscriptions || {}).length > 0;
if (hasPlan && hasOrganization) {
goTo("/landing/dashboard"); goTo("/landing/dashboard");
return; return;
} }
goTo(toUrl); goTo("/landing/my-appointments");
} }
}; };
@@ -106,6 +106,7 @@ 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 canRenew = Boolean( const canRenew = Boolean(
isOwner && isOwner &&
isPaidPlan && isPaidPlan &&
@@ -155,7 +156,7 @@ export default function CurrentPlanPage() {
<h1 className={styles.title}>{plan?.name || "Cargando plan"}</h1> <h1 className={styles.title}>{plan?.name || "Cargando plan"}</h1>
<p className={styles.subtitle}>Gestioná la vigencia, límites y beneficios de tu suscripción.</p> <p className={styles.subtitle}>Gestioná la vigencia, límites y beneficios de tu suscripción.</p>
{subscription && ( {subscription && (
<span className={styles.status}>{getSubStatusText(subscription.mpStatus)}</span> <span className={styles.status}>{statusText}</span>
)} )}
</div> </div>
{organizations.length > 1 && ( {organizations.length > 1 && (
@@ -200,8 +201,8 @@ export default function CurrentPlanPage() {
</div> </div>
<div className={styles.summaryItem}> <div className={styles.summaryItem}>
<span>Vencimiento</span> <span>Vencimiento</span>
<strong>{formatDate(subscription.endDate)}</strong> <strong>{isPaidPlan ? formatDate(subscription.endDate) : "No tiene vencimiento"}</strong>
<small>{isPaidPlan ? "Período vigente" : "Sin vencimiento pago"}</small> <small>{isPaidPlan ? "Período vigente" : "Plan gratuito activo"}</small>
{isPaidPlan && ( {isPaidPlan && (
<button className={styles.inlineRenewLink} onClick={handleRenew}> <button className={styles.inlineRenewLink} onClick={handleRenew}>
Extender plan Extender plan
@@ -310,7 +311,7 @@ export default function CurrentPlanPage() {
text="Cambiar plan" text="Cambiar plan"
width="custom" width="custom"
style={{ width: "100%", padding: "13px 0", borderRadius: "12px", fontWeight: "800" }} style={{ width: "100%", padding: "13px 0", borderRadius: "12px", fontWeight: "800" }}
onClick={() => goTo("/landing/pricing")} onClick={() => goTo(`/landing/upgrade-plan?org=${selectedOrgId}`)}
/> />
</div> </div>
</div> </div>
@@ -5,6 +5,8 @@ import {
CancellPlanSuscriptionParams, CancellPlanSuscriptionParams,
GetSubscriptionInitPointParams, GetSubscriptionInitPointParams,
GetSuscriptionInitPointResponse, GetSuscriptionInitPointResponse,
VerifyPendingPlanPaymentParams,
VerifyPendingPlanPaymentResponse,
} from "@models/Subscription.Model"; } from "@models/Subscription.Model";
import { ApiError } from "@models/Server.Error.model"; import { ApiError } from "@models/Server.Error.model";
import { AppointmentEventByClient } from "@core/Models/Appointments.model"; import { AppointmentEventByClient } from "@core/Models/Appointments.model";
@@ -100,3 +102,9 @@ export const getSuscriptionInitPoint = async (
): Promise<GetSuscriptionInitPointResponse> => { ): Promise<GetSuscriptionInitPointResponse> => {
return ApiRequest.post<GetSuscriptionInitPointResponse>("suscriptions/get-init-point", data); return ApiRequest.post<GetSuscriptionInitPointResponse>("suscriptions/get-init-point", data);
}; };
export const verifyPendingPlanPayment = async (
data: VerifyPendingPlanPaymentParams
): Promise<VerifyPendingPlanPaymentResponse> => {
return ApiRequest.post<VerifyPendingPlanPaymentResponse>("suscriptions/verify-pending-payment", data);
};
+274 -40
View File
@@ -9,14 +9,16 @@ import { useSessionStore } from "@core/Store/Sesion.Store";
import { ThemeProvider } from "@emotion/react"; import { ThemeProvider } from "@emotion/react";
import SuspenseLoading from "@core/app/components/SuspendeLoading/SuspenseLoading"; import SuspenseLoading from "@core/app/components/SuspendeLoading/SuspenseLoading";
import Secure from "@components/Secure/Secure"; import Secure from "@components/Secure/Secure";
import { useEffect, useState, Suspense } from "react"; import { useEffect, useRef, useState, Suspense } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { import {
cancellSubscription, cancellSubscription,
getSuscriptionInitPoint, getSuscriptionInitPoint,
getDashboardSummary, getDashboardSummary,
DashboardSummaryResult, DashboardSummaryResult,
getCompanyMetrics, getCompanyMetrics,
DashboardMetrics DashboardMetrics,
verifyPendingPlanPayment
} from "./Dashboard.Service"; } from "./Dashboard.Service";
import { EVENT_TYPES, useEventHandlerStore } from "@core/Store/EventHandler.Store"; import { EVENT_TYPES, useEventHandlerStore } from "@core/Store/EventHandler.Store";
import { useAlert } from "@core/Store/Alert.Store"; import { useAlert } from "@core/Store/Alert.Store";
@@ -45,8 +47,11 @@ import FinancialWidget from "@components/FinancialWidget/FinancialWidget";
import PlanMetricsWidget from "@components/PlanMetricsWidget/PlanMetricsWidget"; import PlanMetricsWidget from "@components/PlanMetricsWidget/PlanMetricsWidget";
import PendingRepeatsCard from "@core/app/components/PendingRepeatsCard/PendingRepeatsCard"; import PendingRepeatsCard from "@core/app/components/PendingRepeatsCard/PendingRepeatsCard";
export default function DashboardPage() { function DashboardContent() {
const { goTo } = useNavigation(); const { goTo } = useNavigation();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const alert = useAlert(); const alert = useAlert();
@@ -104,12 +109,30 @@ export default function DashboardPage() {
const [companyMetrics, setCompanyMetrics] = useState<DashboardMetrics | null>(null); const [companyMetrics, setCompanyMetrics] = useState<DashboardMetrics | null>(null);
const [companySubscription, setCompanySubscription] = useState<ISubscriptionInfo | null>(null); const [companySubscription, setCompanySubscription] = useState<ISubscriptionInfo | null>(null);
const [isCompanyOwner, setIsCompanyOwner] = useState<boolean>(false); const [isCompanyOwner, setIsCompanyOwner] = useState<boolean>(false);
const [isVerifyingPayment, setIsVerifyingPayment] = useState(false);
const verifiedPaymentReturnKey = useRef<string | null>(null);
const getErrorMessage = (error: unknown): string => {
if (error && typeof error === "object" && "format" in error && typeof error.format === "function") {
return error.format();
}
if (error instanceof Error) {
return error.message;
}
return "Ha ocurrido un error inesperado";
};
useEffect(() => { useEffect(() => {
if (SessionInfo.userId && !SessionInfo.subscription) { if (SessionInfo.userId && !SessionInfo.subscription) {
goTo("/"); goTo("/");
} }
if (SessionInfo.subscription?.plan?.id) {
setSubscription(SessionInfo.subscription);
}
blurElementsOnLoad(); blurElementsOnLoad();
if (SessionInfo.userId) { if (SessionInfo.userId) {
@@ -134,25 +157,46 @@ export default function DashboardPage() {
useEffect(() => { useEffect(() => {
if (selectedOrgId && SessionInfo.userId) { if (selectedOrgId && SessionInfo.userId) {
setCompanySubscription(null); loadCompanyMetrics(selectedOrgId);
setCompanyMetrics(null);
getCompanyMetrics({ sessionUser: SessionInfo.userId, companyId: selectedOrgId })
.then(res => {
setCompanyMetrics(res.metrics);
setCompanySubscription(res.subscription);
setIsCompanyOwner(res.isOwner);
})
.catch(e => {
const errMsg = e.format ? e.format() : e.message;
console.warn("Acceso denegado a métricas de la empresa: ", errMsg);
setCompanyMetrics(null);
setCompanySubscription(null);
setIsCompanyOwner(false);
});
} }
}, [selectedOrgId, SessionInfo.userId]); }, [selectedOrgId, SessionInfo.userId]);
const loadCompanyMetrics = async (companyId: string) => {
setCompanySubscription(null);
setCompanyMetrics(null);
try {
const res = await getCompanyMetrics({ sessionUser: SessionInfo.userId, companyId });
setCompanyMetrics(res.metrics);
setCompanySubscription(res.subscription);
setSubscription(res.subscription);
setIsCompanyOwner(res.isOwner);
if (res.isOwner) {
SessionInfo.setSubscription(res.subscription);
}
} catch (e) {
const errMsg = getErrorMessage(e);
console.warn("Acceso denegado a métricas de la empresa: ", errMsg);
setCompanyMetrics(null);
setCompanySubscription(null);
setIsCompanyOwner(false);
}
};
const refreshDashboardSubscriptionData = async () => {
await loadDashboardData();
if (selectedOrgId) {
await loadCompanyMetrics(selectedOrgId);
}
};
const getPaymentLink = () => { const getPaymentLink = () => {
const pendingPaymentInitPoint = displayedSubscription?.pendingPaymentInitPoint;
if (pendingPaymentInitPoint) {
goTo(pendingPaymentInitPoint);
return;
}
eventHandler.setEventType(EVENT_TYPES.LOADING); eventHandler.setEventType(EVENT_TYPES.LOADING);
getSuscriptionInitPoint({ getSuscriptionInitPoint({
sessionUser: SessionInfo.userId, sessionUser: SessionInfo.userId,
@@ -168,6 +212,56 @@ export default function DashboardPage() {
}); });
}; };
const handleVerifyPendingPayment = async () => {
setIsVerifyingPayment(true);
eventHandler.setEventType(EVENT_TYPES.LOADING);
try {
const response = await verifyPendingPlanPayment({ sessionUser: SessionInfo.userId });
if (response.approved) {
alert.showSuccess(response.message);
await refreshDashboardSubscriptionData();
return;
}
if (["rejected", "failed", "cancelled"].includes(response.status)) {
alert.showWarning(response.message);
await refreshDashboardSubscriptionData();
return;
}
alert.showWarning(response.message);
} catch (error) {
alert.showError(getErrorMessage(error));
} finally {
setIsVerifyingPayment(false);
eventHandler.setEventType(EVENT_TYPES.SLEEP);
}
};
useEffect(() => {
if (!SessionInfo.userId) return;
const hasPaymentReturnParams = [
"mp_return",
"status",
"collection_status",
"payment_id",
"preference_id",
"merchant_order_id",
].some((param) => searchParams.has(param));
if (!hasPaymentReturnParams) return;
const paymentReturnKey = searchParams.toString();
if (verifiedPaymentReturnKey.current === paymentReturnKey) return;
verifiedPaymentReturnKey.current = paymentReturnKey;
handleVerifyPendingPayment().finally(() => {
router.replace(pathname, { scroll: false });
});
}, [SessionInfo.userId, searchParams, pathname, router]);
const cancelarSubscription = () => { const cancelarSubscription = () => {
confirm.show( confirm.show(
"Estas a punto de cancelar tu suscripción. Al realizar esta acción dejaras de tener acceso a la información de tus organizaciones cuando se termine el periodo contratado. Puedes volver a suscribirte a cualquier otro plan y recuperarlo en el futuro. Estas seguro que quieres cancelar tu suscripcion?", "Estas a punto de cancelar tu suscripción. Al realizar esta acción dejaras de tener acceso a la información de tus organizaciones cuando se termine el periodo contratado. Puedes volver a suscribirte a cualquier otro plan y recuperarlo en el futuro. Estas seguro que quieres cancelar tu suscripcion?",
@@ -220,7 +314,23 @@ export default function DashboardPage() {
goTo("/admin/org"); goTo("/admin/org");
}; };
const displayedSubscription = companySubscription; const isUsingSessionSubscriptionFallback = !companySubscription && Boolean(subscription.plan.id);
const displayedSubscription = companySubscription ?? (subscription.plan.id ? subscription : null);
const displayedPlan = displayedSubscription?.plan;
const displayedPlanStatusText = displayedPlan?.price === 0 ? "ACTIVO" : getSubStatusText(displayedSubscription?.mpStatus);
const hasPendingPaymentMetadata = Boolean(displayedSubscription?.pendingPaymentPreferenceId);
const hasRecentFailedPayment = Boolean(
displayedSubscription?.lastPaymentStatus &&
["rejected", "failed", "cancelled"].includes(displayedSubscription.lastPaymentStatus) &&
displayedSubscription.lastPaymentAt &&
dayjs().diff(dayjs(displayedSubscription.lastPaymentAt), "day") <= 14
);
const showPaymentAction = Boolean(
(displayedSubscription?.mpStatus === MP_SUBS_STATUS.PENDING || hasPendingPaymentMetadata) &&
(isCompanyOwner || isUsingSessionSubscriptionFallback)
);
const showFailedPaymentCard = Boolean(hasRecentFailedPayment && !showPaymentAction);
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);
@@ -238,14 +348,15 @@ export default function DashboardPage() {
style={{ style={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
justifyContent: "center", justifyContent: "flex-start",
alignItems: "center", alignItems: "center",
minHeight: "calc(100vh - 60px)",
}} }}
> >
{SessionInfo.userId != "" && subscription.plan.name == "" && ( {SessionInfo.userId != "" && !displayedPlan && (
<div className="homeCentered">No tiene una suscripción activa.</div> <div className="homeCentered">No tiene una suscripción activa.</div>
)} )}
{subscription.plan.name != "" && ( {displayedPlan && (
<div className={style.dashboardGrid}> <div className={style.dashboardGrid}>
{/* Welcome Header */} {/* Welcome Header */}
@@ -267,15 +378,14 @@ export default function DashboardPage() {
<div style={{display: 'flex', flexDirection: 'column', gap: '5px'}}> <div style={{display: 'flex', flexDirection: 'column', gap: '5px'}}>
<h3>Bienvenido, {SessionInfo.userFullName}</h3> <h3>Bienvenido, {SessionInfo.userFullName}</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<span style={{ fontWeight: 'bold' }}>{displayedSubscription?.plan.name || "Cargando plan..."}</span> <span style={{ fontWeight: 'bold' }}>{displayedPlan.name}</span>
{displayedSubscription && ( {displayedSubscription && (
<span <span
style={{ style={{
backgroundColor: backgroundColor:
getSubStatusText(displayedSubscription.mpStatus) === displayedPlanStatusText === "ACTIVO"
"ACTIVO" ? "var(--sub-status-active)"
? "var(--sub-status-active)" : "var(--sub-status-inactive)",
: "var(--sub-status-inactive)",
borderRadius: "12px", borderRadius: "12px",
padding: "4px 10px", padding: "4px 10px",
fontSize: "12px", fontSize: "12px",
@@ -283,7 +393,7 @@ export default function DashboardPage() {
fontWeight: "bold", fontWeight: "bold",
}} }}
> >
{getSubStatusText(displayedSubscription.mpStatus)} {displayedPlanStatusText}
</span> </span>
)} )}
{displayedSubscription && isCompanyOwner && ( {displayedSubscription && isCompanyOwner && (
@@ -297,14 +407,6 @@ export default function DashboardPage() {
)} )}
</div> </div>
</div> </div>
{subscription.mpStatus === MP_SUBS_STATUS.PENDING && (
<Button
text="Ir a pagar"
color="primary"
style={{ marginTop: "10px" }}
onClick={getPaymentLink}
/>
)}
</div> </div>
<div className={style.quickActions}> <div className={style.quickActions}>
<div className={classNames(style.actionButton, style.buttonOrganizations)} onClick={() => goTo("/admin/org")}> <div className={classNames(style.actionButton, style.buttonOrganizations)} onClick={() => goTo("/admin/org")}>
@@ -325,8 +427,132 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
{showPaymentAction && (
<div className={style.pendingPaymentCard}>
<div className={style.pendingPaymentContent}>
<span className={style.pendingPaymentEyebrow}>Pago pendiente</span>
<h2>Tu plan está esperando confirmación de MercadoPago</h2>
<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.
</p>
</div>
<div className={style.pendingPaymentActions}>
<Button
text="Ir a pagar"
color="plain"
width="custom"
style={{
margin: 0,
width: "220px",
backgroundColor: "#9a4b00",
color: "white",
border: "1px solid #9a4b00",
}}
onClick={getPaymentLink}
/>
<Button
text={isVerifyingPayment ? "Verificando..." : "Verificar pago"}
color="plain"
width="custom"
style={{
margin: 0,
width: "220px",
backgroundColor: "#fff3d6",
color: "#9a4b00",
border: "1px solid rgba(154, 75, 0, 0.28)",
}}
onClick={handleVerifyPendingPayment}
disabled={isVerifyingPayment}
/>
</div>
</div>
)}
{showFailedPaymentCard && (
<div className={style.failedPaymentCard}>
<div className={style.pendingPaymentContent}>
<span className={style.failedPaymentEyebrow}>Pago no aprobado</span>
<h2>Tu pago no se aprobó y tu plan sigue igual</h2>
<p>
El intento de pago fue rechazado, falló o se canceló. No cambiamos tu plan actual ni la fecha de finalización.
Cuando quieras, podés volver a intentarlo desde los detalles del plan.
</p>
</div>
</div>
)}
{showFreePlanStarterContent && (
<div className={style.starterCard}>
<div className={style.starterContent}>
<span className={style.starterEyebrow}>Tu plan gratuito ya está activo</span>
<h2>Ahora podés empezar a ofrecer turnos</h2>
<p>
Todavía no pertenecés a ninguna organización. Podés crear tu propio negocio con el asistente,
o esperar una invitación para sumarte al equipo de una organización existente.
</p>
</div>
<div className={style.starterActions}>
<button className={style.starterPrimaryButton} onClick={() => goTo("/admin/assistant?flow=onboarding-org&action=new-org")}>
Crear mi organización
</button>
<button className={style.starterSecondaryButton} onClick={() => goTo("/landing/my-appointments")}>
Ver mis turnos
</button>
</div>
</div>
)}
{showFreePlanStarterContent && (
<div className={style.starterGuideGrid}>
<section className={style.starterGuideCard}>
<span className={style.starterGuideNumber}>1</span>
<h3>Creá tu espacio de trabajo</h3>
<p>
El asistente te guía paso a paso para cargar tu negocio, servicios, horarios y colaboradores.
Es el camino recomendado si querés empezar a recibir reservas.
</p>
</section>
<section className={style.starterGuideCard}>
<span className={style.starterGuideNumber}>2</span>
<h3>Compartí tu enlace público</h3>
<p>
Cuando termines la configuración vas a tener una página pública para que tus clientes reserven
turnos sin escribirte por WhatsApp a cada rato.
</p>
</section>
<section className={style.starterGuideCard}>
<span className={style.starterGuideNumber}>3</span>
<h3>También podés sumarte a un equipo</h3>
<p>
Si trabajás para una organización existente, ahora pueden invitarte y vas a poder gestionar tus
turnos desde esta misma cuenta.
</p>
</section>
</div>
)}
{showFreePlanStarterContent && (
<div className={style.starterNextStepsPanel}>
<div>
<span className={style.starterEyebrow}>Qué podés hacer con tu plan básico</span>
<h3>Tu cuenta ya está lista para operar</h3>
<p>
Usá este primer paso para preparar la base de tu negocio. Cuando tengas tu organización creada,
vas a poder administrar servicios, horarios, reservas e invitaciones desde el dashboard.
</p>
</div>
<ul className={style.starterChecklist}>
<li>Crear una organización para tu negocio o actividad profesional.</li>
<li>Configurar servicios y horarios disponibles.</li>
<li>Recibir invitaciones de otras organizaciones y formar parte de su equipo.</li>
<li>Seguir usando Mis turnos para tus reservas como cliente.</li>
</ul>
</div>
)}
{/* Left Column (Sidebar) */} {/* Left Column (Sidebar) */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}> {!showFreePlanStarterContent && <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{/* Agenda del Día */} {/* Agenda del Día */}
{dashboardData && dashboardData.role !== "NONE" && ( {dashboardData && dashboardData.role !== "NONE" && (
<div className={style.agendaCard}> <div className={style.agendaCard}>
@@ -409,10 +635,10 @@ export default function DashboardPage() {
onCancel={cancelarSubscription} onCancel={cancelarSubscription}
/> />
)} )}
</div> </div>}
{/* Right Column (Main Content) */} {/* Right Column (Main Content) */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}> {!showFreePlanStarterContent && <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{/* Rendimiento Financiero (Only for ADMIN) */} {/* Rendimiento Financiero (Only for ADMIN) */}
{dashboardData && dashboardData.role === "ADMIN" && ( {dashboardData && dashboardData.role === "ADMIN" && (
<FinancialWidget <FinancialWidget
@@ -429,7 +655,7 @@ export default function DashboardPage() {
allowEmployeeFilter={true} allowEmployeeFilter={true}
/> />
)} )}
</div> </div>}
</div> </div>
)} )}
</AnimatedContainer> </AnimatedContainer>
@@ -438,3 +664,11 @@ export default function DashboardPage() {
</ThemeProvider> </ThemeProvider>
); );
} }
export default function DashboardPage() {
return (
<Suspense fallback={<SuspenseLoading />}>
<DashboardContent />
</Suspense>
);
}
@@ -159,6 +159,306 @@
.buttonNotification { background-color: var(--red); } .buttonNotification { background-color: var(--red); }
.buttonMessenger { background-color: var(--orange); } .buttonMessenger { background-color: var(--orange); }
.pendingPaymentCard {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 26px 30px;
border-radius: 18px;
background: #fff8e8;
border: 1px solid rgba(255, 151, 36, 0.28);
box-shadow: 0 12px 28px rgba(255, 151, 36, 0.1);
}
.failedPaymentCard {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 26px 30px;
border-radius: 18px;
background: #f8fafc;
border: 1px solid rgba(100, 116, 139, 0.24);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
}
.pendingPaymentContent {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 760px;
}
.pendingPaymentEyebrow {
width: fit-content;
padding: 5px 11px;
border-radius: 999px;
background: rgba(255, 151, 36, 0.16);
color: #9a4b00;
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.failedPaymentEyebrow {
width: fit-content;
padding: 5px 11px;
border-radius: 999px;
background: rgba(100, 116, 139, 0.12);
color: #475569;
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.pendingPaymentContent h2 {
margin: 0;
color: var(--black);
font-size: clamp(22px, 3vw, 30px);
}
.pendingPaymentContent p {
margin: 0;
color: var(--gray-dark);
line-height: 1.55;
}
.pendingPaymentActions {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: flex-end;
gap: 12px;
min-width: 260px;
}
.starterCard {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 32px;
border-radius: 20px;
background: linear-gradient(135deg, rgba(128, 0, 32, 0.96), rgba(58, 0, 120, 0.94));
color: var(--white);
box-shadow: 0 18px 45px rgba(58, 0, 120, 0.18);
}
.starterContent {
display: flex;
flex-direction: column;
gap: 10px;
max-width: 720px;
}
.starterEyebrow {
width: fit-content;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
font-size: 13px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.starterContent h2 {
margin: 0;
font-size: clamp(26px, 4vw, 38px);
line-height: 1.05;
}
.starterContent p {
margin: 0;
color: rgba(255, 255, 255, 0.86);
font-size: 16px;
line-height: 1.6;
}
.starterActions {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 230px;
}
.starterPrimaryButton,
.starterSecondaryButton {
border: 0;
border-radius: 14px;
padding: 14px 18px;
font-weight: 900;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.starterPrimaryButton:hover,
.starterSecondaryButton:hover {
transform: translateY(-2px);
}
.starterPrimaryButton {
background: var(--white);
color: var(--wine-red);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16);
}
.starterSecondaryButton {
background: rgba(255, 255, 255, 0.14);
color: var(--white);
border: 1px solid rgba(255, 255, 255, 0.32);
}
.starterGuideGrid {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.starterGuideCard {
min-height: 220px;
padding: 26px;
border-radius: 18px;
background: var(--white);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.06);
border: 1px solid rgba(128, 0, 32, 0.08);
}
.starterGuideNumber {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 999px;
background: rgba(128, 0, 32, 0.1);
color: var(--wine-red);
font-weight: 900;
margin-bottom: 18px;
}
.starterGuideCard h3 {
margin: 0 0 10px;
color: var(--black);
font-size: 20px;
}
.starterGuideCard p {
margin: 0;
color: var(--gray-dark);
line-height: 1.55;
font-size: 15px;
}
.starterNextStepsPanel {
grid-column: 1 / -1;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 420px);
gap: 28px;
align-items: start;
padding: 34px;
border-radius: 20px;
background: linear-gradient(180deg, #ffffff 0%, #faf7fb 100%);
box-shadow: 0 12px 34px rgba(0, 0, 0, 0.055);
border: 1px solid rgba(128, 0, 32, 0.08);
}
.starterNextStepsPanel h3 {
margin: 14px 0 10px;
color: var(--black);
font-size: clamp(24px, 3vw, 34px);
}
.starterNextStepsPanel p {
margin: 0;
max-width: 680px;
color: var(--gray-dark);
line-height: 1.65;
}
.starterChecklist {
display: flex;
flex-direction: column;
gap: 14px;
margin: 0;
padding: 0;
list-style: none;
}
.starterChecklist li {
position: relative;
padding: 14px 16px 14px 44px;
border-radius: 14px;
background: var(--white);
color: var(--black);
font-weight: 700;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.045);
}
.starterChecklist li::before {
content: "✓";
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 999px;
background: var(--green);
color: var(--white);
font-size: 12px;
font-weight: 900;
}
@media (max-width: 760px) {
.pendingPaymentCard {
align-items: stretch;
flex-direction: column;
padding: 22px;
}
.failedPaymentCard {
align-items: stretch;
flex-direction: column;
padding: 22px;
}
.pendingPaymentActions {
align-items: center;
justify-content: center;
min-width: 0;
}
.starterCard {
align-items: stretch;
flex-direction: column;
padding: 24px;
}
.starterActions {
min-width: 0;
}
.starterGuideGrid {
grid-template-columns: 1fr;
}
.starterNextStepsPanel {
grid-template-columns: 1fr;
padding: 24px;
}
}
.planMetricsBottonInfo { .planMetricsBottonInfo {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -17,6 +17,7 @@ import useContainerHeight from "@core/app/hooks/ContainerHeight";
import { ScrollToTop } from "@core/app/components/ScrollTop"; import { ScrollToTop } from "@core/app/components/ScrollTop";
import PendingRepeatsCard from "@core/app/components/PendingRepeatsCard/PendingRepeatsCard"; import PendingRepeatsCard from "@core/app/components/PendingRepeatsCard/PendingRepeatsCard";
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider"; import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
import EvangelizeBanner from "@components/EvangelizeBanner/EvangelizeBanner";
import { Button, Tabs, Tab, Box } from "@mui/material"; import { Button, Tabs, Tab, Box } from "@mui/material";
import { ThemeProvider } from "@emotion/react"; import { ThemeProvider } from "@emotion/react";
import turnosXpressTheme from "@core/app/theme/turnosXpress"; import turnosXpressTheme from "@core/app/theme/turnosXpress";
@@ -53,7 +54,15 @@ function a11yProps(index: number) {
}; };
} }
const AppointmentList = ({ appointments, containerHeight, emptyMessage, goTo, isProfessionalView }: { appointments: any, containerHeight: any, emptyMessage: string, goTo: any, isProfessionalView: boolean }) => { type AppointmentListProps = {
appointments: AppointmentEventByClient[];
containerHeight: string;
emptyMessage: string;
goTo: (path: string) => void;
isProfessionalView: boolean;
};
const AppointmentList = ({ appointments, containerHeight, emptyMessage, goTo, isProfessionalView }: AppointmentListProps) => {
if (appointments.length === 0) { if (appointments.length === 0) {
return <ExploreCategories containerHeight={containerHeight} message={emptyMessage} />; return <ExploreCategories containerHeight={containerHeight} message={emptyMessage} />;
} }
@@ -223,11 +232,13 @@ export default function MyAppointmentsPage() {
setTabValue(1); setTabValue(1);
} }
}) })
.catch((error: Error | any) => { .catch((error: unknown) => {
if (error.format) { if (error && typeof error === "object" && "format" in error && typeof error.format === "function") {
alert.showError(error.format()); alert.showError(error.format());
} else { } else if (error instanceof Error) {
alert.showError(error.message); alert.showError(error.message);
} else {
alert.showError("No pudimos cargar tus turnos.");
} }
console.error(error); console.error(error);
}) })
@@ -282,6 +293,8 @@ export default function MyAppointmentsPage() {
Mis Turnos Mis Turnos
</h1> </h1>
<EvangelizeBanner />
<Box sx={{ width: "100%", mt: 4 }}> <Box sx={{ width: "100%", mt: 4 }}>
{collaboratorAppointments.length > 0 ? ( {collaboratorAppointments.length > 0 ? (
<> <>
@@ -4,20 +4,41 @@ import Link from "next/link";
import { useSessionStore } from "@core/Store/Sesion.Store"; import { useSessionStore } from "@core/Store/Sesion.Store";
import style from "./style.module.css"; import style from "./style.module.css";
export default function CTABtn() { type CTABtnProps = {
className?: string;
secondaryClassName?: string;
signedOutText?: string;
signedInText?: string;
showSecondaryWhenSignedOut?: boolean;
};
export default function CTABtn({
className = style.ctaBtn,
secondaryClassName,
signedOutText = "Crear mi cuenta gratis",
signedInText = "Ver planes",
showSecondaryWhenSignedOut = false,
}: CTABtnProps) {
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
if (SessionInfo.loged) { if (SessionInfo.loged) {
return ( return (
<Link href="/landing/pricing" className={style.ctaBtn}> <Link href="/landing/pricing" className={className}>
Ver planes {signedInText}
</Link> </Link>
); );
} }
return ( return (
<Link href="/landing/signup" className={style.ctaBtn}> <>
Crear mi cuenta gratis <Link href="/landing/signup" className={className}>
</Link> {signedOutText}
</Link>
{showSecondaryWhenSignedOut && secondaryClassName && (
<Link href="/landing/pricing" className={secondaryClassName}>
Ver Planes
</Link>
)}
</>
); );
} }
@@ -1,7 +1,6 @@
import { Metadata } from "next"; import { Metadata } from "next";
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer"; import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
import style from "./style.module.css"; import style from "./style.module.css";
import Link from "next/link";
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider"; import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
import { ScrollToTop } from "@core/app/components/ScrollTop"; import { ScrollToTop } from "@core/app/components/ScrollTop";
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction"; import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
@@ -42,12 +41,13 @@ export default function OfferAppointmentsPage() {
Olvidate del teléfono y los mensajes a deshora. Con nuestra plataforma, tus clientes pueden reservar turnos 24/7 de forma automática. Optimizá tu tiempo, reducí ausencias y llevá tu negocio al siguiente nivel. Olvidate del teléfono y los mensajes a deshora. Con nuestra plataforma, tus clientes pueden reservar turnos 24/7 de forma automática. Optimizá tu tiempo, reducí ausencias y llevá tu negocio al siguiente nivel.
</p> </p>
<div className={style.btnGroup}> <div className={style.btnGroup}>
<Link href="/landing/signup" className={style.primaryBtn}> <CTABtn
¡Empezá Gratis Hoy! className={style.primaryBtn}
</Link> secondaryClassName={style.secondaryBtn}
<Link href="/landing/pricing" className={style.secondaryBtn}> signedOutText="¡Empezá Gratis Hoy!"
Ver Planes signedInText="Ver planes"
</Link> showSecondaryWhenSignedOut
/>
</div> </div>
</div> </div>
@@ -86,7 +86,7 @@ export default function OfferAppointmentsPage() {
<div className={style.stepNumber}>1</div> <div className={style.stepNumber}>1</div>
<div className={style.stepContent}> <div className={style.stepContent}>
<h3>Creá tu cuenta gratis</h3> <h3>Creá tu cuenta gratis</h3>
<p>Registrate en segundos. Podés probar la plataforma completa sin compromisos ni tarjetas de crédito. <Link href="/landing/signup">Registrate acá</Link>.</p> <p>Registrate en segundos. Podés probar la plataforma completa sin compromisos ni tarjetas de crédito.</p>
</div> </div>
</div> </div>
@@ -128,4 +128,3 @@ export default function OfferAppointmentsPage() {
</AnimatedContainer> </AnimatedContainer>
); );
} }
@@ -0,0 +1,580 @@
.page {
width: 100%;
max-width: 1120px;
margin: 34px auto;
padding: 0 20px 40px;
}
.card {
position: relative;
overflow: hidden;
background: linear-gradient(180deg, var(--white) 0%, var(--white-dark) 100%);
border: 1px solid var(--white-darkest);
border-radius: 28px;
padding: 22px;
box-shadow: 0 24px 70px color-mix(in srgb, var(--wine-darkest) 18%, transparent);
display: flex;
flex-direction: column;
gap: 24px;
}
.hero {
position: relative;
overflow: hidden;
border-radius: 24px;
padding: clamp(24px, 4vw, 40px);
background:
radial-gradient(circle at 86% 10%, var(--yellow-light), transparent 34%),
linear-gradient(135deg, var(--wine-superdark) 0%, var(--wine-darkest) 44%, var(--wine-dark) 100%);
color: var(--white);
}
.heroGlow {
position: absolute;
right: -90px;
bottom: -120px;
width: 280px;
height: 280px;
border-radius: 999px;
background: var(--wine-lighterX2);
filter: blur(4px);
}
.header {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 28px;
flex-wrap: wrap;
}
.titleBlock {
max-width: 680px;
}
.eyebrow,
.currentPlanPanel span,
.prorationNotice span,
.planCard span,
.billingPanel > span,
.breakdownHeader span,
.invoiceSection > span,
.actionPanel span {
color: var(--wine-red);
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.eyebrow {
color: var(--yellow);
}
.title {
margin: 10px 0;
color: var(--white);
font-size: clamp(34px, 6vw, 62px);
line-height: 0.95;
letter-spacing: -0.05em;
}
.subtitle {
max-width: 590px;
margin: 0;
color: var(--white-dark);
font-size: 16px;
line-height: 1.6;
}
.orgSelector {
display: flex;
flex-direction: column;
gap: 9px;
min-width: min(280px, 100%);
color: var(--white-dark);
font-size: 13px;
font-weight: 800;
}
.orgSelector select {
padding: 13px 14px;
border: 1px solid var(--wine-lighterX2);
border-radius: 14px;
background: var(--white);
color: var(--black);
font-size: 14px;
font-weight: 700;
outline: none;
box-shadow: 0 18px 34px color-mix(in srgb, var(--black-dark) 14%, transparent);
}
.message {
margin: 0;
color: var(--gray-dark);
line-height: 1.5;
}
.error {
margin: 0;
padding: 14px 16px;
border-radius: 16px;
background: var(--wine-lighterX2);
color: var(--wine-red);
font-weight: 800;
}
.currentPlanPanel {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
}
.currentPlanPanel > div,
.prorationNotice,
.billingPanel,
.breakdownPanel,
.actionPanel,
.emptyState {
border: 1px solid var(--wine-lighterX2);
border-radius: 22px;
background:
radial-gradient(circle at 100% 0%, var(--wine-lighterX2), transparent 30%),
linear-gradient(135deg, var(--white) 0%, var(--white-dark) 100%);
padding: 18px;
box-shadow: 0 14px 34px color-mix(in srgb, var(--wine-darkest) 9%, transparent);
}
.currentPlanPanel strong,
.actionPanel strong {
display: block;
margin-top: 8px;
color: var(--black);
font-size: clamp(22px, 3vw, 30px);
line-height: 1.1;
letter-spacing: -0.03em;
}
.currentPlanPanel small,
.actionPanel small {
display: block;
margin-top: 8px;
color: var(--gray-dark);
font-weight: 700;
}
.prorationNotice {
border-color: rgba(255, 151, 36, 0.28);
background: #fff8e8;
box-shadow: 0 12px 28px rgba(255, 151, 36, 0.1);
}
.prorationNotice span,
.prorationNotice p {
color: var(--gray-dark);
}
.prorationNotice span {
display: inline-flex;
width: fit-content;
padding: 5px 11px;
border-radius: 999px;
background: rgba(255, 151, 36, 0.16);
color: #9a4b00;
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.prorationNotice p {
max-width: 760px;
margin: 8px 0 0;
line-height: 1.6;
font-weight: 700;
}
.planGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.planCard {
display: flex;
flex-direction: column;
gap: 12px;
padding: 22px;
border: 1px solid var(--white-darkest);
border-radius: 24px;
background: var(--white);
color: inherit;
text-align: left;
cursor: pointer;
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
}
.planCard:hover,
.planCardSelected {
transform: translateY(-2px);
border-color: var(--wine-red);
box-shadow: 0 18px 36px color-mix(in srgb, var(--wine-darkest) 14%, transparent);
}
.planCardSelected {
background: linear-gradient(135deg, var(--wine-lighterX2) 0%, var(--white) 100%);
}
.planCard h2,
.emptyState h2 {
margin: 0;
color: var(--black);
font-size: 28px;
letter-spacing: -0.04em;
}
.planCard p,
.emptyState p {
margin: 0;
color: var(--gray-dark);
line-height: 1.5;
font-weight: 700;
}
.planCard strong {
color: var(--wine-darkest);
font-size: 32px;
line-height: 1;
}
.planCard strong small {
color: var(--gray-dark);
font-size: 14px;
}
.planCard ul {
display: flex;
flex-direction: column;
gap: 10px;
margin: 4px 0 0;
padding: 0;
list-style: none;
}
.planCard li {
color: var(--black);
font-size: 14px;
font-weight: 800;
}
.planCard li::before {
content: "✓";
margin-right: 8px;
color: var(--wine-red);
font-weight: 900;
}
.billingPanel {
display: flex;
flex-direction: column;
gap: 14px;
}
.billingOptions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.billingOption,
.billingOptionSelected {
padding: 14px;
border: 1px solid var(--white-darkest);
border-radius: 16px;
background: var(--white);
cursor: pointer;
text-align: left;
}
.billingOptionSelected {
border-color: var(--wine-red);
background: var(--wine-lighterX2);
}
.billingOption strong,
.billingOptionSelected strong,
.billingOption small,
.billingOptionSelected small {
display: block;
}
.billingOption strong,
.billingOptionSelected strong {
color: var(--black);
}
.billingOption small,
.billingOptionSelected small {
margin-top: 4px;
color: var(--gray-dark);
font-weight: 800;
}
.breakdownPanel {
display: flex;
flex-direction: column;
gap: 18px;
padding: 22px;
background:
radial-gradient(circle at 96% 0%, color-mix(in srgb, var(--yellow-light) 74%, transparent), transparent 27%),
linear-gradient(180deg, var(--white) 0%, color-mix(in srgb, var(--wine-lighterX2) 30%, var(--white)) 100%);
}
.breakdownHeader {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, auto);
align-items: stretch;
gap: 18px;
padding-bottom: 18px;
border-bottom: 1px solid var(--white-darkest);
}
.breakdownHeader h2 {
margin: 8px 0 6px;
color: var(--black);
font-size: clamp(26px, 3.5vw, 38px);
line-height: 1.02;
letter-spacing: -0.045em;
}
.breakdownHeader p,
.planComparison p,
.finalAmountBox p,
.breakdownNote,
.breakdownFormula {
margin: 0;
color: var(--gray-dark);
line-height: 1.55;
font-weight: 750;
}
.breakdownTotalBadge {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 220px;
padding: 18px;
border: 1px solid color-mix(in srgb, var(--yellow) 34%, var(--wine-lighterX2));
border-radius: 20px;
background:
radial-gradient(circle at 100% 0%, var(--yellow-light), transparent 42%),
linear-gradient(135deg, var(--wine-superdark), var(--wine-dark));
box-shadow: 0 16px 34px color-mix(in srgb, var(--wine-darkest) 18%, transparent);
text-align: right;
}
.breakdownTotalBadge small,
.finalAmountBox small,
.planComparison small,
.invoiceLines small {
color: var(--gray-dark);
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.breakdownTotalBadge small {
color: var(--yellow);
}
.breakdownTotalBadge strong {
margin-top: 8px;
color: var(--white);
font-size: clamp(30px, 4vw, 46px);
line-height: 0.95;
letter-spacing: -0.05em;
}
.invoiceSection {
display: flex;
flex-direction: column;
gap: 12px;
}
.planComparison {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: stretch;
gap: 12px;
}
.planComparison > div:not(.planArrow),
.invoiceLines > div {
padding: 16px;
border: 1px solid var(--white-darkest);
border-radius: 18px;
background: color-mix(in srgb, var(--white) 88%, transparent);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--white) 80%, transparent);
}
.planComparison strong,
.invoiceLines strong {
display: block;
margin-top: 8px;
color: var(--black);
font-size: 18px;
line-height: 1.2;
}
.planComparison p {
margin-top: 6px;
}
.planArrow {
display: grid;
place-items: center;
align-self: center;
width: 34px;
height: 34px;
border-radius: 999px;
background: var(--yellow);
color: var(--wine-darkest);
font-size: 18px;
font-weight: 900;
}
.invoiceLines {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 12px;
}
.discountLine {
background: color-mix(in srgb, var(--yellow-light) 34%, var(--white)) !important;
}
.discountLine strong {
color: var(--wine-red);
}
.breakdownFormula {
padding: 14px 16px;
border: 1px dashed color-mix(in srgb, var(--wine-red) 38%, var(--white-darkest));
border-radius: 16px;
background: color-mix(in srgb, var(--white) 64%, var(--yellow-light));
color: var(--wine-darkest);
}
.finalAmountBox {
padding: 20px;
border-radius: 22px;
background:
radial-gradient(circle at 92% 12%, var(--yellow-light), transparent 28%),
linear-gradient(135deg, var(--wine-superdark) 0%, var(--wine-darkest) 52%, var(--wine-dark) 100%);
box-shadow: 0 18px 38px color-mix(in srgb, var(--wine-darkest) 18%, transparent);
}
.finalAmountBox small {
color: var(--yellow);
}
.finalAmountBox strong {
display: block;
margin-top: 8px;
color: var(--white);
font-size: clamp(38px, 7vw, 62px);
line-height: 0.95;
letter-spacing: -0.06em;
}
.finalAmountBox p {
margin-top: 10px;
color: var(--white-dark);
}
.breakdownNote {
padding: 14px 16px;
border-left: 4px solid var(--yellow);
border-radius: 14px;
background: color-mix(in srgb, var(--white) 74%, var(--wine-lighterX2));
}
.actionPanel {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 420px);
gap: 22px;
align-items: center;
}
.actions {
display: grid;
grid-template-columns: 0.8fr 1.2fr;
gap: 12px;
}
.primaryButton,
.secondaryButton {
width: 100%;
padding: 14px 16px;
border-radius: 12px;
font-size: 15px;
font-weight: 900;
cursor: pointer;
}
.primaryButton {
border: 0;
background: linear-gradient(135deg, var(--wine-red), var(--wine-dark));
color: var(--white);
box-shadow: 0 12px 24px color-mix(in srgb, var(--wine-darkest) 20%, transparent);
}
.secondaryButton {
border: 1px solid var(--white-darkest);
background: var(--white);
color: var(--gray-dark);
}
.primaryButton:disabled,
.secondaryButton:disabled {
cursor: not-allowed;
opacity: 0.6;
}
@media (max-width: 760px) {
.page {
margin: 18px auto;
padding: 0 14px 28px;
}
.card {
padding: 14px;
border-radius: 22px;
}
.hero {
border-radius: 18px;
}
.actionPanel,
.actions,
.breakdownHeader {
grid-template-columns: 1fr;
}
.breakdownTotalBadge {
text-align: left;
}
.planComparison {
grid-template-columns: 1fr;
}
.planArrow {
transform: rotate(90deg);
justify-self: center;
}
}
@@ -0,0 +1,465 @@
"use client";
import ApiRequest from "@services/Api.Service";
import AnimatedContainer from "@core/app/components/AnimatedConainer/AnimatedContainer";
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
import Secure from "@components/Secure/Secure";
import SuspenseLoading from "@core/app/components/SuspendeLoading/SuspenseLoading";
import { ScrollToTop } from "@core/app/components/ScrollTop";
import { useNavigation } from "@core/app/hooks/goto";
import { loadOrganizationsByUser } from "@core/app/admin/org/Org.Service";
import { DashboardMetrics, getCompanyMetrics } from "../dashboard/Dashboard.Service";
import { MyOranizationsView } from "@core/Models/Company.model";
import { IPlan } from "@core/Models/Plans.model";
import {
CreateMPPreApprovalParams,
ISubscriptionInfo,
MP_PRE_APPROVAL_STATUS,
MPPreApprovalResponse,
MP_SUBS_STATUS,
} from "@core/Models/Subscription.Model";
import { useAlert } from "@core/Store/Alert.Store";
import { useSessionStore } from "@core/Store/Sesion.Store";
import { ThemeProvider } from "@emotion/react";
import { formatPrice } from "@helpers/Numbers";
import turnosXpressTheme from "@core/app/theme/turnosXpress";
import dayjs from "dayjs";
import { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import styles from "./page.module.css";
const billingOptions = [
{ months: 1, label: "1 mes" },
{ months: 3, label: "3 meses" },
{ months: 6, label: "6 meses" },
{ months: 12, label: "12 meses" },
];
const DAY_MS = 24 * 60 * 60 * 1000;
const formatDate = (date?: Date) => (date ? dayjs(date).format("DD/MM/YYYY") : "No disponible");
const getDiscount = (plan: IPlan, months: number) => {
if (months === 3) return plan.discount3Months || 0;
if (months === 6) return plan.discount6Months || 0;
if (months === 12) return plan.discount12Months || 0;
return 0;
};
const getPlanTotal = (plan: IPlan, months: number) => {
const discount = getDiscount(plan, months);
const total = plan.price * months;
return total - total * (discount / 100);
};
const roundPrice = (value: number) => Math.round(value * 100) / 100;
const getRemainingDays = (date?: Date) => {
if (!date) return 0;
return Math.max(0, Math.ceil((new Date(date).getTime() - new Date().getTime()) / DAY_MS));
};
function UpgradePlanContent() {
const searchParams = useSearchParams();
const requestedOrgId = searchParams.get("org") || "";
const { goTo } = useNavigation();
const alert = useAlert();
const SessionInfo = useSessionStore();
const [organizations, setOrganizations] = useState<MyOranizationsView[]>([]);
const [selectedOrgId, setSelectedOrgId] = useState("");
const [subscription, setSubscription] = useState<ISubscriptionInfo | null>(null);
const [metrics, setMetrics] = useState<DashboardMetrics | null>(null);
const [isOwner, setIsOwner] = useState(false);
const [plans, setPlans] = useState<IPlan[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState("");
const [selectedMonths, setSelectedMonths] = useState(1);
const [loading, setLoading] = useState(true);
const [plansLoading, setPlansLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [pageError, setPageError] = useState("");
const [plansError, setPlansError] = useState("");
useEffect(() => {
if (!SessionInfo.userId) return;
setLoading(true);
loadOrganizationsByUser(SessionInfo.userId)
.then((orgs) => {
setOrganizations(orgs);
const requestedOrg = orgs.find((org) => org.id === requestedOrgId);
setSelectedOrgId(requestedOrg?.id || orgs[0]?.id || "");
setPageError(orgs.length === 0 ? "No pertenecés a ninguna organización." : "");
})
.catch((e) => {
const message = e.format ? e.format() : e.message;
setPageError(message || "No pudimos cargar tus organizaciones.");
})
.finally(() => setLoading(false));
}, [SessionInfo.userId, requestedOrgId]);
useEffect(() => {
setPlansLoading(true);
ApiRequest.post<IPlan[]>("plans/find", {})
.then((res) => {
setPlans(res);
setPlansError("");
})
.catch((e) => {
const message = e.format ? e.format() : e.message;
setPlansError(message || "No pudimos cargar los planes disponibles.");
})
.finally(() => setPlansLoading(false));
}, []);
useEffect(() => {
if (!selectedOrgId || !SessionInfo.userId) return;
setLoading(true);
setSubscription(null);
setMetrics(null);
getCompanyMetrics({ sessionUser: SessionInfo.userId, companyId: selectedOrgId })
.then((res) => {
setSubscription(res.subscription);
setMetrics(res.metrics);
setIsOwner(res.isOwner);
setPageError("");
})
.catch((e) => {
const message = e.format ? e.format() : e.message;
setPageError(message || "No pudimos cargar el detalle del plan actual.");
setIsOwner(false);
})
.finally(() => setLoading(false));
}, [selectedOrgId, SessionInfo.userId]);
const currentPlan = subscription?.plan;
const isCurrentPaid = Boolean(currentPlan && currentPlan.price > 0);
const availablePlans = useMemo(() => {
if (!currentPlan) return [];
return [...plans]
.filter((plan) => plan.active && plan.price > 0 && plan.price > currentPlan.price)
.sort((a, b) => a.price - b.price);
}, [currentPlan, plans]);
useEffect(() => {
const hasSelectedPlan = availablePlans.some((plan) => plan.id === selectedPlanId);
if (!hasSelectedPlan) setSelectedPlanId(availablePlans[0]?.id || "");
}, [availablePlans, selectedPlanId]);
const selectedPlan = availablePlans.find((plan) => plan.id === selectedPlanId) || null;
const selectedDiscount = selectedPlan ? getDiscount(selectedPlan, selectedMonths) : 0;
const selectedSubtotal = selectedPlan ? selectedPlan.price * selectedMonths : 0;
const selectedDiscountAmount = roundPrice(selectedSubtotal * (selectedDiscount / 100));
const selectedTotal = selectedPlan ? roundPrice(getPlanTotal(selectedPlan, selectedMonths)) : 0;
const remainingDays = isCurrentPaid ? getRemainingDays(subscription?.endDate) : 0;
const monthlyDifference = selectedPlan && currentPlan ? selectedPlan.price - currentPlan.price : 0;
const proratedAmount = isCurrentPaid ? roundPrice((monthlyDifference / 30) * remainingDays) : 0;
const canSubmit = Boolean(
selectedPlan?.id &&
SessionInfo.userId &&
isOwner &&
subscription?.isActive &&
subscription?.mpStatus !== MP_SUBS_STATUS.PENDING &&
!submitting
);
const effectiveMonths = isCurrentPaid ? 1 : selectedMonths;
const error = plansError || pageError;
const handleSubmit = () => {
if (!canSubmit || !selectedPlan?.id) {
if (!isOwner) alert.showWarning("Solo el dueño de la organización puede cambiar el plan.");
else if (subscription?.mpStatus === MP_SUBS_STATUS.PENDING) alert.showWarning("Ya tenés un pago pendiente. Finalizalo antes de generar otro cambio de plan.");
else alert.showWarning("Elegí un plan superior para continuar.");
return;
}
const preApprovalData: CreateMPPreApprovalParams = {
planId: selectedPlan.id,
sessionUser: SessionInfo.userId,
months: effectiveMonths,
};
setSubmitting(true);
ApiRequest.post<MPPreApprovalResponse>("suscriptions/preapproval", preApprovalData)
.then((response) => {
if (response.status === MP_PRE_APPROVAL_STATUS.READY_TO_START) {
goTo(response.init_point);
}
})
.catch((e) => {
alert.showError(e.format ? e.format() : "No pudimos iniciar el pago del cambio de plan.");
})
.finally(() => setSubmitting(false));
};
return (
<ThemeProvider theme={turnosXpressTheme}>
<ScrollToTop />
<HeaderConfProvider />
<Secure>
<AnimatedContainer
color="#aeaeae"
minSize={150}
maxSize={500}
items={10}
style={{ display: "flex", justifyContent: "center", alignItems: "center" }}
>
<main className={styles.page}>
<section className={styles.card}>
<div className={styles.hero}>
<div className={styles.heroGlow} />
<div className={styles.header}>
<div className={styles.titleBlock}>
<span className={styles.eyebrow}>Cambiar plan</span>
<h1 className={styles.title}>Elegí un plan superior</h1>
<p className={styles.subtitle}>
Solo te mostramos planes pagos con mayor capacidad que tu plan actual.
</p>
</div>
{organizations.length > 1 && (
<label className={styles.orgSelector}>
Organización
<select value={selectedOrgId} onChange={(e) => setSelectedOrgId(e.target.value)}>
{organizations.map((org) => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
</label>
)}
</div>
</div>
{(loading || plansLoading) && <p className={styles.message}>Cargando opciones de cambio...</p>}
{error && !loading && !plansLoading && <p className={styles.error}>{error}</p>}
{subscription && currentPlan && !loading && !plansLoading && !error && (
<>
<div className={styles.currentPlanPanel}>
<div>
<span>Plan actual</span>
<strong>{currentPlan.name}</strong>
<small>{currentPlan.price > 0 ? `${formatPrice(currentPlan.price)} por mes` : "Plan gratis"}</small>
</div>
<div>
<span>Vencimiento</span>
<strong>{formatDate(subscription.endDate)}</strong>
<small>{metrics ? `${metrics.clientsCount} clientes cargados` : "Organización activa"}</small>
</div>
</div>
{isCurrentPaid && (
<div className={styles.prorationNotice}>
<span>Upgrade prorrateado</span>
<p>
Si subís a un plan superior, hoy pagás solo la diferencia proporcional por los días restantes. El vencimiento se mantiene y los nuevos límites se aplican cuando el pago quede aprobado.
</p>
</div>
)}
{!isOwner && <p className={styles.error}>Solo el dueño de la organización puede cambiar el plan.</p>}
{subscription.mpStatus === MP_SUBS_STATUS.PENDING && <p className={styles.error}>Ya tenés un pago pendiente. Finalizalo antes de generar otro cambio.</p>}
{availablePlans.length === 0 ? (
<div className={styles.emptyState}>
<h2>No hay planes superiores disponibles</h2>
<p>Tu plan actual ya está en el nivel más alto disponible para contratar.</p>
</div>
) : (
<div className={styles.planGrid}>
{availablePlans.map((plan) => {
const isSelected = selectedPlanId === plan.id;
return (
<button
key={plan.id}
className={`${styles.planCard} ${isSelected ? styles.planCardSelected : ""}`}
type="button"
onClick={() => setSelectedPlanId(plan.id || "")}
>
<span>{plan.featured ? "Recomendado" : "Plan superior"}</span>
<h2>{plan.name}</h2>
<p>{plan.description}</p>
<strong>{formatPrice(plan.price)} <small>/ mes</small></strong>
<ul>
{plan.features.slice(0, 4).map((feature) => <li key={feature}>{feature}</li>)}
</ul>
</button>
);
})}
</div>
)}
{selectedPlan && !isCurrentPaid && (
<div className={styles.billingPanel}>
<span>Duración del plan</span>
<div className={styles.billingOptions}>
{billingOptions.map((option) => {
const discount = getDiscount(selectedPlan, option.months);
return (
<button
key={option.months}
className={selectedMonths === option.months ? styles.billingOptionSelected : styles.billingOption}
type="button"
onClick={() => setSelectedMonths(option.months)}
>
<strong>{option.label}</strong>
<small>{discount > 0 ? `${discount}% OFF` : "Sin descuento"}</small>
</button>
);
})}
</div>
</div>
)}
{selectedPlan && (
<div className={styles.breakdownPanel}>
<div className={styles.breakdownHeader}>
<div>
<span>Detalle del importe</span>
<h2>Resumen de facturación</h2>
<p>
{isCurrentPaid
? "El importe se calcula sobre la diferencia entre planes y los días restantes del ciclo actual."
: "Importe final según el plan, duración y descuento seleccionados."}
</p>
</div>
</div>
{isCurrentPaid ? (
<>
<div className={styles.invoiceSection}>
<span>Contexto del cambio</span>
<div className={styles.planComparison}>
<div>
<small>Plan actual</small>
<strong>{currentPlan.name}</strong>
<p>{formatPrice(currentPlan.price)} / mes</p>
</div>
<div className={styles.planArrow}></div>
<div>
<small>Plan seleccionado</small>
<strong>{selectedPlan.name}</strong>
<p>{formatPrice(selectedPlan.price)} / mes</p>
</div>
</div>
</div>
<div className={styles.invoiceSection}>
<span>Cálculo proporcional</span>
<div className={styles.invoiceLines}>
<div>
<small>Diferencia mensual entre planes</small>
<strong>{formatPrice(monthlyDifference)} / mes</strong>
</div>
<div>
<small>Días restantes hasta el {formatDate(subscription.endDate)}</small>
<strong>{remainingDays} día{remainingDays === 1 ? "" : "s"}</strong>
</div>
<div>
<small>Importe proporcional</small>
<strong>{formatPrice(proratedAmount)}</strong>
</div>
</div>
<p className={styles.breakdownFormula}>
Se toma la diferencia mensual ({formatPrice(selectedPlan.price)} - {formatPrice(currentPlan.price)}) y se prorratea por 30 días × {remainingDays} día{remainingDays === 1 ? "" : "s"}.
</p>
</div>
<p className={styles.breakdownNote}>
TurnosXpress calcula este importe según las reglas de tu plan. El vencimiento no cambia: seguís con fecha {formatDate(subscription.endDate)}. Los nuevos límites se aplican cuando el pago quede aprobado.
</p>
</>
) : (
<>
<div className={styles.invoiceSection}>
<span>Contexto del cambio</span>
<div className={styles.planComparison}>
<div>
<small>Plan actual</small>
<strong>{currentPlan.name}</strong>
<p>Plan gratis</p>
</div>
<div className={styles.planArrow}></div>
<div>
<small>Plan seleccionado</small>
<strong>{selectedPlan.name}</strong>
<p>{formatPrice(selectedPlan.price)} / mes</p>
</div>
</div>
</div>
<div className={styles.invoiceSection}>
<span>Detalle de compra</span>
<div className={styles.invoiceLines}>
<div>
<small>Precio mensual</small>
<strong>{formatPrice(selectedPlan.price)}</strong>
</div>
<div>
<small>Duración seleccionada</small>
<strong>{selectedMonths} mes{selectedMonths === 1 ? "" : "es"}</strong>
</div>
<div>
<small>Subtotal</small>
<strong>{formatPrice(selectedSubtotal)}</strong>
</div>
<div className={styles.discountLine}>
<small>{selectedDiscount > 0 ? `Descuento ${selectedDiscount}%` : "Descuento"}</small>
<strong>{selectedDiscount > 0 ? `-${formatPrice(selectedDiscountAmount)}` : "No aplica"}</strong>
</div>
</div>
</div>
<p className={styles.breakdownNote}>
Este es el importe final definido por TurnosXpress. {selectedDiscount > 0
? `Incluye ${selectedDiscount}% de descuento por contratar ${selectedMonths} meses.`
: "No hay descuentos aplicados para esta duración."} El plan se activa cuando el pago quede aprobado.
</p>
</>
)}
</div>
)}
<div className={styles.actionPanel}>
<div>
<span>{isCurrentPaid ? "Total a pagar hoy" : "Total a pagar"}</span>
<strong>
{selectedPlan
? isCurrentPaid
? formatPrice(proratedAmount)
: formatPrice(selectedTotal)
: "Seleccioná un plan"}
</strong>
<small>
{isCurrentPaid
? "TurnosXpress cobra solo la diferencia proporcional restante."
: "Precio final según duración y descuentos disponibles."}
</small>
</div>
<div className={styles.actions}>
<button className={styles.secondaryButton} onClick={() => goTo("/landing/current-plan")} disabled={submitting}>
Volver
</button>
<button className={styles.primaryButton} onClick={handleSubmit} disabled={!canSubmit}>
{submitting ? "Generando pago..." : "Continuar al pago"}
</button>
</div>
</div>
</>
)}
</section>
</main>
</AnimatedContainer>
</Secure>
</ThemeProvider>
);
}
export default function UpgradePlanPage() {
return (
<Suspense fallback={<SuspenseLoading />}>
<UpgradePlanContent />
</Suspense>
);
}