From 8d74ae95c8997c3a334e8c1d24714b3fdaf37541 Mon Sep 17 00:00:00 2001
From: Horacio Daniel Ros
Date: Sat, 18 Jul 2026 15:39:07 -0300
Subject: [PATCH] feat: implement plan upgrade logic with prorated pricing and
MercadoPago integration
---
README.md | 1 +
server/.env | 2 +-
.../PlanPayments.Adapter.Mongoose.ts | 13 +
.../PlanPayments/PlanPayments.interface.ts | 2 +
.../src/Models/PlanPayments/PlanPayments.ts | 4 +
.../PlanSubscriptions.Adapter.Mongoose.ts | 11 +
.../PlanSubscriptions.interface.ts | 45 ++
.../PlanSubscriptions.pricing.ts | 11 +
.../PlanSubscriptions/PlanSubscriptons.ts | 156 ++++-
.../__tests__/PlanSubscriptons.test.ts | 77 +++
.../MercadoPago/MercadoPagoWebhook.Service.ts | 422 +++++++++++--
.../MercadoPagoWebhook.Service.test.ts | 74 +++
.../PlanSubscriptions.Controller.ts | 24 +
.../PlanSubscriptions.Service.ts | 10 +
txclient/src/Models/Subscription.Model.ts | 23 +
.../src/app/components/Home/Login/Login.tsx | 11 +-
.../src/app/landing/current-plan/page.tsx | 9 +-
.../landing/dashboard/Dashboard.Service.ts | 8 +
txclient/src/app/landing/dashboard/page.tsx | 314 ++++++++--
.../app/landing/dashboard/style.module.css | 300 +++++++++
.../src/app/landing/my-appointments/page.tsx | 21 +-
.../app/landing/offer-appointments/CTABtn.tsx | 33 +-
.../app/landing/offer-appointments/page.tsx | 17 +-
.../app/landing/upgrade-plan/page.module.css | 580 ++++++++++++++++++
.../src/app/landing/upgrade-plan/page.tsx | 465 ++++++++++++++
25 files changed, 2491 insertions(+), 142 deletions(-)
create mode 100644 server/src/Models/PlanSubscriptions/PlanSubscriptions.pricing.ts
create mode 100644 server/src/api/MercadoPago/__tests__/MercadoPagoWebhook.Service.test.ts
create mode 100644 txclient/src/app/landing/upgrade-plan/page.module.css
create mode 100644 txclient/src/app/landing/upgrade-plan/page.tsx
diff --git a/README.md b/README.md
index 7b22880..ed23ec7 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,3 @@
# turnosxpress
+ngrok http 3000
diff --git a/server/.env b/server/.env
index 4e65049..c040181 100644
--- a/server/.env
+++ b/server/.env
@@ -45,7 +45,7 @@ MP_SUBSCRIPTION_FIND_URL = https://api.mercadopago.com/preapproval/
MP_ACCESS_TOKEN = APP_USR-8753424836448507-121412-731599daea1ffa0b278b5dcc78aa1ec8-1611134648
MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard
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
diff --git a/server/src/Models/PlanPayments/PlanPayments.Adapter.Mongoose.ts b/server/src/Models/PlanPayments/PlanPayments.Adapter.Mongoose.ts
index 6539cd7..a9c3a69 100644
--- a/server/src/Models/PlanPayments/PlanPayments.Adapter.Mongoose.ts
+++ b/server/src/Models/PlanPayments/PlanPayments.Adapter.Mongoose.ts
@@ -22,6 +22,7 @@ export class PlanPaymentsAdapterMongoose implements IPlanPaymentsAdapter {
status: { type: String, required: true },
transactionId: { type: String, required: false },
});
+ this.schema.index({ transactionId: 1 }, { unique: true, sparse: true });
this.planPaymentList = model("PlanPayment", this.schema);
}
@@ -33,4 +34,16 @@ export class PlanPaymentsAdapterMongoose implements IPlanPaymentsAdapter {
public async create(data: CreatePlanPaymentParams): Promise {
return this.planPaymentList.create(data);
}
+
+ public async createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise {
+ if (!data.transactionId) {
+ return this.create(data);
+ }
+
+ return this.planPaymentList.findOneAndUpdate(
+ { transactionId: data.transactionId },
+ { $setOnInsert: data },
+ { new: true, upsert: true, setDefaultsOnInsert: true }
+ ).exec();
+ }
}
diff --git a/server/src/Models/PlanPayments/PlanPayments.interface.ts b/server/src/Models/PlanPayments/PlanPayments.interface.ts
index fa69f8f..d305b9d 100644
--- a/server/src/Models/PlanPayments/PlanPayments.interface.ts
+++ b/server/src/Models/PlanPayments/PlanPayments.interface.ts
@@ -21,10 +21,12 @@ export interface IPlanPayment {
export interface IPlanPaymentsAdapter {
find(filters: FindPlanPaymentsParams): Promise;
create(data: CreatePlanPaymentParams): Promise;
+ createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise;
}
export interface IPlanPaymentsManager {
planPayments: IPlanPaymentsAdapter;
find(data: FindPlanPaymentsParams): Promise;
create(data: CreatePlanPaymentParams): Promise;
+ createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise;
}
diff --git a/server/src/Models/PlanPayments/PlanPayments.ts b/server/src/Models/PlanPayments/PlanPayments.ts
index fae6018..52368c6 100644
--- a/server/src/Models/PlanPayments/PlanPayments.ts
+++ b/server/src/Models/PlanPayments/PlanPayments.ts
@@ -20,6 +20,10 @@ class PlanPaymentsManager implements IPlanPaymentsManager {
public async create(data: CreatePlanPaymentParams): Promise {
return this.planPayments.create(data);
}
+
+ public async createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise {
+ return this.planPayments.createIfMissingByTransactionId(data);
+ }
}
const PlanPaymentsList = new PlanPaymentsManager();
diff --git a/server/src/Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose.ts b/server/src/Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose.ts
index 718af36..14bcd48 100644
--- a/server/src/Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose.ts
+++ b/server/src/Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose.ts
@@ -42,6 +42,17 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
pendingPaymentInitPoint: { type: String, required: false },
pendingPaymentPreferenceId: { type: String, 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("PlanSuscription", this.schema);
diff --git a/server/src/Models/PlanSubscriptions/PlanSubscriptions.interface.ts b/server/src/Models/PlanSubscriptions/PlanSubscriptions.interface.ts
index b117b3b..e34ead6 100644
--- a/server/src/Models/PlanSubscriptions/PlanSubscriptions.interface.ts
+++ b/server/src/Models/PlanSubscriptions/PlanSubscriptions.interface.ts
@@ -25,6 +25,17 @@ export type CreatePlanSuscriptionParams = {
pendingPaymentInitPoint?: string;
pendingPaymentPreferenceId?: string;
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 = {
@@ -75,6 +86,22 @@ export type GetSubscriptionInitPointParams = {
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 {
init_point: string;
}
@@ -99,6 +126,17 @@ export interface IPlanSuscription {
pendingPaymentInitPoint?: string; // URL de pago 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.
+ 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 {
@@ -110,6 +148,13 @@ export interface ISubscriptionInfo {
autoRenew: boolean;
mpStatus: string;
mpDateCreated: Date;
+ pendingPaymentInitPoint?: string;
+ pendingPaymentPreferenceId?: string;
+ pendingPaymentType?: "extension" | "upgrade";
+ lastPaymentStatus?: LastPlanPaymentStatus;
+ lastPaymentPreferenceId?: string;
+ lastPaymentAt?: Date;
+ lastPaymentType?: LastPlanPaymentType;
}
export interface IPlanSuscriptionsAdapter {
diff --git a/server/src/Models/PlanSubscriptions/PlanSubscriptions.pricing.ts b/server/src/Models/PlanSubscriptions/PlanSubscriptions.pricing.ts
new file mode 100644
index 0000000..5c15fc1
--- /dev/null
+++ b/server/src/Models/PlanSubscriptions/PlanSubscriptions.pricing.ts
@@ -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;
+}
diff --git a/server/src/Models/PlanSubscriptions/PlanSubscriptons.ts b/server/src/Models/PlanSubscriptions/PlanSubscriptons.ts
index 48a2ddf..16851db 100644
--- a/server/src/Models/PlanSubscriptions/PlanSubscriptons.ts
+++ b/server/src/Models/PlanSubscriptions/PlanSubscriptons.ts
@@ -31,10 +31,13 @@ import { NotificationsManager } from "../Notifications/Notifications";
import WapServerList from "../WapServer/WapServer";
import CompaniesList from "../Companies/Companies";
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
+import { calculateProratedUpgradeAmount } from "./PlanSubscriptions.pricing";
dayjs.locale("es");
dayjs.extend(isSameOrAfter);
+const DAY_MS = 24 * 60 * 60 * 1000;
+
class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
planSuscriptions: PlanSuscriptionsAdapterMongoose;
@@ -46,6 +49,20 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
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(
data: GetSubscriptionInitPointParams
): Promise {
@@ -190,6 +207,23 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
months: data.months || 1,
});
} 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) {
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."
@@ -201,6 +235,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
sessionUser: data.sessionUser,
months: data.months || 1,
});
+ }
}
}
}
@@ -249,6 +284,42 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
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 (
currentSubscription &&
String(currentSubscription.planId) === String(data.planId) &&
@@ -265,16 +336,18 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
{
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,
currency_id: "ARS",
- unit_price: finalPrice
+ unit_price: isUpgradePayment ? proratedAmount : finalPrice
}
],
back_urls: {
- success: process.env.MP_BACK_URL,
- failure: process.env.MP_BACK_URL,
- pending: process.env.MP_BACK_URL
+ success: this.buildMercadoPagoBackUrl("success"),
+ failure: this.buildMercadoPagoBackUrl("failure"),
+ pending: this.buildMercadoPagoBackUrl("pending")
},
auto_return: "approved",
payer: {
@@ -293,6 +366,35 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
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 (
currentSubscription &&
String(currentSubscription.planId) === String(data.planId) &&
@@ -302,6 +404,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
currentSubscription.pendingPaymentInitPoint = subscriptionData.init_point;
currentSubscription.pendingPaymentPreferenceId = subscriptionData.id;
currentSubscription.pendingPaymentBillingMonths = data.months;
+ currentSubscription.pendingPaymentType = "extension";
+ currentSubscription.lastPaymentStatus = undefined;
+ currentSubscription.lastPaymentPreferenceId = undefined;
+ currentSubscription.lastPaymentAt = undefined;
+ currentSubscription.lastPaymentType = undefined;
currentSubscription.mpInitPoint = subscriptionData.init_point;
await currentSubscription.save();
@@ -335,6 +442,11 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
pendingPaymentInitPoint: "",
pendingPaymentPreferenceId: "",
pendingPaymentBillingMonths: undefined,
+ pendingPaymentType: undefined,
+ lastPaymentStatus: undefined,
+ lastPaymentPreferenceId: undefined,
+ lastPaymentAt: undefined,
+ lastPaymentType: undefined,
});
await NotificationsManager.sendSystemNotification({
@@ -385,7 +497,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
const dateEnd = dayjs(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd)) {
- this.toFreePlan({
+ await this.toFreePlan({
sessionUser: data.sessionUser,
});
} else {
@@ -393,6 +505,16 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
subscription.mpInitPoint = "";
subscription.mpPayerEmail = "";
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();
}
} catch (error) {
@@ -462,6 +584,13 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
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.startDate = new Date();
subscription.endDate = dayjs(new Date()).add(10, "years").toDate();
@@ -488,6 +617,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
autoRenew: subscription.autoRenew,
mpStatus: subscription.mpStatus,
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);
@@ -539,7 +672,9 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
_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 });
if (freePlan) {
plan = freePlan;
@@ -559,6 +694,13 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
autoRenew: subscription.autoRenew,
mpStatus: subscription.mpStatus,
mpDateCreated: subscription.mpDateCreated,
+ pendingPaymentInitPoint: subscription.pendingPaymentInitPoint,
+ pendingPaymentPreferenceId: subscription.pendingPaymentPreferenceId,
+ pendingPaymentType: subscription.pendingPaymentType,
+ lastPaymentStatus: subscription.lastPaymentStatus,
+ lastPaymentPreferenceId: subscription.lastPaymentPreferenceId,
+ lastPaymentAt: subscription.lastPaymentAt,
+ lastPaymentType: subscription.lastPaymentType,
};
}
diff --git a/server/src/Models/PlanSubscriptions/__tests__/PlanSubscriptons.test.ts b/server/src/Models/PlanSubscriptions/__tests__/PlanSubscriptons.test.ts
index 465ae74..071ffdf 100644
--- a/server/src/Models/PlanSubscriptions/__tests__/PlanSubscriptons.test.ts
+++ b/server/src/Models/PlanSubscriptions/__tests__/PlanSubscriptons.test.ts
@@ -1,4 +1,14 @@
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", () => {
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
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();
+ }
+ });
});
diff --git a/server/src/api/MercadoPago/MercadoPagoWebhook.Service.ts b/server/src/api/MercadoPago/MercadoPagoWebhook.Service.ts
index 6848593..ab78e60 100644
--- a/server/src/api/MercadoPago/MercadoPagoWebhook.Service.ts
+++ b/server/src/api/MercadoPago/MercadoPagoWebhook.Service.ts
@@ -4,6 +4,41 @@ import { MP_SUBS_STATUS } from "../../Models/PlanSubscriptions/PlanSubscriptions
import { connect } from "mongoose";
import dayjs from "dayjs";
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 {
public async handleWebhook(body: any): Promise {
@@ -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 userId = paymentData.external_reference; // We passed this when creating preference
- const preferenceId = paymentData.preference_id || paymentData.order?.id;
+ const paymentContext = await this.resolvePaymentContext(paymentData);
+ 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({
- 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 (userId && preferenceId && status === "approved") {
if (subscription) {
- 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 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),
- });
+ await this.applyApprovedPayment(subscription, paymentData, userId, preferenceId);
}
- } else if (userId && status === "rejected") {
+ } else if (userId && preferenceId && REJECTED_PAYMENT_STATUSES.includes(status)) {
if (subscription) {
- const isExtensionPayment = subscription.pendingPaymentPreferenceId === 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),
- });
+ await this.applyRejectedPayment(subscription, paymentData, userId, preferenceId);
}
}
} catch (error) {
@@ -105,4 +82,315 @@ export class MercadoPagoWebhookService {
}
}
}
+
+ public async verifyPendingPlanPayment(sessionUser: string): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ const response = await axios.get(`https://api.mercadopago.com/v1/payments/${paymentId}`, {
+ headers: this.getMercadoPagoHeaders(),
+ });
+
+ return response.data;
+ }
+
+ private getMercadoPagoHeaders(): Record {
+ return {
+ Authorization: `Bearer ${process.env.MP_ACCESS_TOKEN}`,
+ "Content-Type": "application/json",
+ };
+ }
+
+ private async findSubscriptionForPayment(userId?: string, preferenceId?: string): Promise {
+ 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 {
+ 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 {
+ 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";
+ }
}
diff --git a/server/src/api/MercadoPago/__tests__/MercadoPagoWebhook.Service.test.ts b/server/src/api/MercadoPago/__tests__/MercadoPagoWebhook.Service.test.ts
new file mode 100644
index 0000000..d26b2bc
--- /dev/null
+++ b/server/src/api/MercadoPago/__tests__/MercadoPagoWebhook.Service.test.ts
@@ -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",
+ });
+ });
+});
diff --git a/server/src/api/PlanSubscriptions/PlanSubscriptions.Controller.ts b/server/src/api/PlanSubscriptions/PlanSubscriptions.Controller.ts
index dbc62f0..f358cf0 100644
--- a/server/src/api/PlanSubscriptions/PlanSubscriptions.Controller.ts
+++ b/server/src/api/PlanSubscriptions/PlanSubscriptions.Controller.ts
@@ -7,9 +7,12 @@ import {
GetSubscriptionInitPointParams,
GetSuscriptionInitPointResponse,
MPPreApprovalResponse,
+ VerifyPendingPlanPaymentParams,
+ VerifyPendingPlanPaymentResponse,
} from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
import { authenticateMiddleware } from "../../middleware/authentication";
import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
+import { rateLimiter } from "../../middleware/ratelimiter";
@Route("suscriptions/get-init-point")
@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(500, "Ha ocurrido un error")
+ @SuccessResponse(200, "Done")
+ @Post()
+ public async verifyPendingPayment(
+ @Body() requestBody: VerifyPendingPlanPaymentParams
+ ): Promise {
+ 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);
+ }
+ }
+}
diff --git a/server/src/api/PlanSubscriptions/PlanSubscriptions.Service.ts b/server/src/api/PlanSubscriptions/PlanSubscriptions.Service.ts
index 1236e20..705973d 100644
--- a/server/src/api/PlanSubscriptions/PlanSubscriptions.Service.ts
+++ b/server/src/api/PlanSubscriptions/PlanSubscriptions.Service.ts
@@ -5,8 +5,12 @@ import {
GetSubscriptionInitPointParams,
GetSuscriptionInitPointResponse,
MPPreApprovalResponse,
+ VerifyPendingPlanPaymentParams,
+ VerifyPendingPlanPaymentResponse,
} from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
import { connect } from "mongoose";
+import { validateSessionUser } from "../../helpers/check";
+import { MercadoPagoWebhookService } from "../MercadoPago/MercadoPagoWebhook.Service";
export class PlanSubscriptionsService {
public async getInitPoint(
@@ -27,4 +31,10 @@ export class PlanSubscriptionsService {
await connect(`${process.env.DATABASE_CONNECTION}`);
await PlanSubscriptionsList.cancell(data);
}
+
+ public async verifyPendingPayment(data: VerifyPendingPlanPaymentParams): Promise {
+ await connect(`${process.env.DATABASE_CONNECTION}`);
+ await validateSessionUser({ sessionUser: data.sessionUser });
+ return new MercadoPagoWebhookService().verifyPendingPlanPayment(data.sessionUser);
+ }
}
diff --git a/txclient/src/Models/Subscription.Model.ts b/txclient/src/Models/Subscription.Model.ts
index 5b52014..d317962 100644
--- a/txclient/src/Models/Subscription.Model.ts
+++ b/txclient/src/Models/Subscription.Model.ts
@@ -48,6 +48,22 @@ export type GetSubscriptionInitPointParams = {
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 {
init_point: string;
}
@@ -61,6 +77,13 @@ export interface ISubscriptionInfo {
autoRenew?: boolean;
mpStatus?: string;
mpDateCreated?: Date;
+ pendingPaymentInitPoint?: string;
+ pendingPaymentPreferenceId?: string;
+ pendingPaymentType?: "extension" | "upgrade";
+ lastPaymentStatus?: LastPlanPaymentStatus;
+ lastPaymentPreferenceId?: string;
+ lastPaymentAt?: Date;
+ lastPaymentType?: LastPlanPaymentType;
}
export const DefaultSubscriptionModel: ISubscriptionInfo = {
id: "",
diff --git a/txclient/src/app/components/Home/Login/Login.tsx b/txclient/src/app/components/Home/Login/Login.tsx
index 1a4c20e..9858ad5 100644
--- a/txclient/src/app/components/Home/Login/Login.tsx
+++ b/txclient/src/app/components/Home/Login/Login.tsx
@@ -54,20 +54,23 @@ export default function Login(props: LoginProps) {
SessionToken.setToken(userSession.userToken);
SessionInfo.setLoginState("success");
alert.showSuccess(msg);
+ manageLoginActions(userSession);
};
- const manageLoginActions = () => {
- const toUrl = process.env.NEXT_PUBLIC_DEFAULT_PATH_AFTER_LOGIN || "/";
+ const manageLoginActions = (session: ISession = SessionInfo) => {
if (loginAction.action !== LOGIN_ACTIONS.NONE) {
goTo(loginAction.url);
loginAction.clear();
} 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");
return;
}
- goTo(toUrl);
+ goTo("/landing/my-appointments");
}
};
diff --git a/txclient/src/app/landing/current-plan/page.tsx b/txclient/src/app/landing/current-plan/page.tsx
index 5464768..531f43d 100644
--- a/txclient/src/app/landing/current-plan/page.tsx
+++ b/txclient/src/app/landing/current-plan/page.tsx
@@ -106,6 +106,7 @@ export default function CurrentPlanPage() {
const plan = subscription?.plan;
const isPaidPlan = Boolean(plan && plan.price > 0);
+ const statusText = isPaidPlan ? getSubStatusText(subscription?.mpStatus) : "ACTIVO";
const canRenew = Boolean(
isOwner &&
isPaidPlan &&
@@ -155,7 +156,7 @@ export default function CurrentPlanPage() {
{plan?.name || "Cargando plan"}
Gestioná la vigencia, límites y beneficios de tu suscripción.
{subscription && (
- {getSubStatusText(subscription.mpStatus)}
+ {statusText}
)}
{organizations.length > 1 && (
@@ -200,8 +201,8 @@ export default function CurrentPlanPage() {
Vencimiento
- {formatDate(subscription.endDate)}
- {isPaidPlan ? "Período vigente" : "Sin vencimiento pago"}
+ {isPaidPlan ? formatDate(subscription.endDate) : "No tiene vencimiento"}
+ {isPaidPlan ? "Período vigente" : "Plan gratuito activo"}
{isPaidPlan && (
Extender plan
@@ -310,7 +311,7 @@ export default function CurrentPlanPage() {
text="Cambiar plan"
width="custom"
style={{ width: "100%", padding: "13px 0", borderRadius: "12px", fontWeight: "800" }}
- onClick={() => goTo("/landing/pricing")}
+ onClick={() => goTo(`/landing/upgrade-plan?org=${selectedOrgId}`)}
/>
diff --git a/txclient/src/app/landing/dashboard/Dashboard.Service.ts b/txclient/src/app/landing/dashboard/Dashboard.Service.ts
index 3902829..f566f26 100644
--- a/txclient/src/app/landing/dashboard/Dashboard.Service.ts
+++ b/txclient/src/app/landing/dashboard/Dashboard.Service.ts
@@ -5,6 +5,8 @@ import {
CancellPlanSuscriptionParams,
GetSubscriptionInitPointParams,
GetSuscriptionInitPointResponse,
+ VerifyPendingPlanPaymentParams,
+ VerifyPendingPlanPaymentResponse,
} from "@models/Subscription.Model";
import { ApiError } from "@models/Server.Error.model";
import { AppointmentEventByClient } from "@core/Models/Appointments.model";
@@ -100,3 +102,9 @@ export const getSuscriptionInitPoint = async (
): Promise => {
return ApiRequest.post("suscriptions/get-init-point", data);
};
+
+export const verifyPendingPlanPayment = async (
+ data: VerifyPendingPlanPaymentParams
+): Promise => {
+ return ApiRequest.post("suscriptions/verify-pending-payment", data);
+};
diff --git a/txclient/src/app/landing/dashboard/page.tsx b/txclient/src/app/landing/dashboard/page.tsx
index dd709d0..f7a59ea 100644
--- a/txclient/src/app/landing/dashboard/page.tsx
+++ b/txclient/src/app/landing/dashboard/page.tsx
@@ -9,14 +9,16 @@ import { useSessionStore } from "@core/Store/Sesion.Store";
import { ThemeProvider } from "@emotion/react";
import SuspenseLoading from "@core/app/components/SuspendeLoading/SuspenseLoading";
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 {
cancellSubscription,
getSuscriptionInitPoint,
getDashboardSummary,
DashboardSummaryResult,
getCompanyMetrics,
- DashboardMetrics
+ DashboardMetrics,
+ verifyPendingPlanPayment
} from "./Dashboard.Service";
import { EVENT_TYPES, useEventHandlerStore } from "@core/Store/EventHandler.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 PendingRepeatsCard from "@core/app/components/PendingRepeatsCard/PendingRepeatsCard";
-export default function DashboardPage() {
+function DashboardContent() {
const { goTo } = useNavigation();
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
const SessionInfo = useSessionStore();
const eventHandler = useEventHandlerStore();
const alert = useAlert();
@@ -104,11 +109,29 @@ export default function DashboardPage() {
const [companyMetrics, setCompanyMetrics] = useState(null);
const [companySubscription, setCompanySubscription] = useState(null);
const [isCompanyOwner, setIsCompanyOwner] = useState(false);
+ const [isVerifyingPayment, setIsVerifyingPayment] = useState(false);
+ const verifiedPaymentReturnKey = useRef(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(() => {
if (SessionInfo.userId && !SessionInfo.subscription) {
goTo("/");
}
+
+ if (SessionInfo.subscription?.plan?.id) {
+ setSubscription(SessionInfo.subscription);
+ }
blurElementsOnLoad();
@@ -134,25 +157,46 @@ export default function DashboardPage() {
useEffect(() => {
if (selectedOrgId && SessionInfo.userId) {
- setCompanySubscription(null);
- 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);
- });
+ loadCompanyMetrics(selectedOrgId);
}
}, [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 pendingPaymentInitPoint = displayedSubscription?.pendingPaymentInitPoint;
+
+ if (pendingPaymentInitPoint) {
+ goTo(pendingPaymentInitPoint);
+ return;
+ }
+
eventHandler.setEventType(EVENT_TYPES.LOADING);
getSuscriptionInitPoint({
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 = () => {
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?",
@@ -220,7 +314,23 @@ export default function DashboardPage() {
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 filteredAgendaItems = agendaItems.filter(item => !showOnlyMine || item.collaboratorId === SessionInfo.userId);
@@ -238,14 +348,15 @@ export default function DashboardPage() {
style={{
display: "flex",
flexDirection: "column",
- justifyContent: "center",
+ justifyContent: "flex-start",
alignItems: "center",
+ minHeight: "calc(100vh - 60px)",
}}
>
- {SessionInfo.userId != "" && subscription.plan.name == "" && (
+ {SessionInfo.userId != "" && !displayedPlan && (
No tiene una suscripción activa.
)}
- {subscription.plan.name != "" && (
+ {displayedPlan && (
{/* Welcome Header */}
@@ -267,15 +378,14 @@ export default function DashboardPage() {
Bienvenido, {SessionInfo.userFullName}
- {displayedSubscription?.plan.name || "Cargando plan..."}
+ {displayedPlan.name}
{displayedSubscription && (
- {getSubStatusText(displayedSubscription.mpStatus)}
+ {displayedPlanStatusText}
)}
{displayedSubscription && isCompanyOwner && (
@@ -297,14 +407,6 @@ export default function DashboardPage() {
)}
- {subscription.mpStatus === MP_SUBS_STATUS.PENDING && (
-
- )}
goTo("/admin/org")}>
@@ -325,8 +427,132 @@ export default function DashboardPage() {
+ {showPaymentAction && (
+
+
+
Pago pendiente
+
Tu plan está esperando confirmación de MercadoPago
+
+ Si ya pagaste, puede tardar unos minutos en actualizarse porque dependemos de la notificación de MercadoPago.
+ No te preocupes: podés volver al checkout si quedó incompleto o pedirnos que verifiquemos el pago ahora mismo.
+
+
+
+
+
+
+
+ )}
+
+ {showFailedPaymentCard && (
+
+
+
Pago no aprobado
+
Tu pago no se aprobó y tu plan sigue igual
+
+ 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.
+
+
+
+ )}
+
+ {showFreePlanStarterContent && (
+
+
+
Tu plan gratuito ya está activo
+
Ahora podés empezar a ofrecer turnos
+
+ 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.
+
+
+
+ goTo("/admin/assistant?flow=onboarding-org&action=new-org")}>
+ Crear mi organización
+
+ goTo("/landing/my-appointments")}>
+ Ver mis turnos
+
+
+
+ )}
+
+ {showFreePlanStarterContent && (
+
+
+ 1
+ Creá tu espacio de trabajo
+
+ 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.
+
+
+
+ 2
+ Compartí tu enlace público
+
+ 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.
+
+
+
+ 3
+ También podés sumarte a un equipo
+
+ Si trabajás para una organización existente, ahora pueden invitarte y vas a poder gestionar tus
+ turnos desde esta misma cuenta.
+
+
+
+ )}
+
+ {showFreePlanStarterContent && (
+
+
+
Qué podés hacer con tu plan básico
+
Tu cuenta ya está lista para operar
+
+ 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.
+
+
+
+ Crear una organización para tu negocio o actividad profesional.
+ Configurar servicios y horarios disponibles.
+ Recibir invitaciones de otras organizaciones y formar parte de su equipo.
+ Seguir usando “Mis turnos” para tus reservas como cliente.
+
+
+ )}
+
{/* Left Column (Sidebar) */}
-
+ {!showFreePlanStarterContent &&
{/* Agenda del Día */}
{dashboardData && dashboardData.role !== "NONE" && (
@@ -409,10 +635,10 @@ export default function DashboardPage() {
onCancel={cancelarSubscription}
/>
)}
-
+
}
{/* Right Column (Main Content) */}
-
+ {!showFreePlanStarterContent &&
{/* Rendimiento Financiero (Only for ADMIN) */}
{dashboardData && dashboardData.role === "ADMIN" && (
)}
-
+
}
)}
@@ -438,3 +664,11 @@ export default function DashboardPage() {
);
}
+
+export default function DashboardPage() {
+ return (
+ }>
+
+
+ );
+}
diff --git a/txclient/src/app/landing/dashboard/style.module.css b/txclient/src/app/landing/dashboard/style.module.css
index 85a1642..7c0e985 100644
--- a/txclient/src/app/landing/dashboard/style.module.css
+++ b/txclient/src/app/landing/dashboard/style.module.css
@@ -159,6 +159,306 @@
.buttonNotification { background-color: var(--red); }
.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 {
display: flex;
flex-direction: column;
diff --git a/txclient/src/app/landing/my-appointments/page.tsx b/txclient/src/app/landing/my-appointments/page.tsx
index d515ef0..27ab580 100644
--- a/txclient/src/app/landing/my-appointments/page.tsx
+++ b/txclient/src/app/landing/my-appointments/page.tsx
@@ -17,6 +17,7 @@ import useContainerHeight from "@core/app/hooks/ContainerHeight";
import { ScrollToTop } from "@core/app/components/ScrollTop";
import PendingRepeatsCard from "@core/app/components/PendingRepeatsCard/PendingRepeatsCard";
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
+import EvangelizeBanner from "@components/EvangelizeBanner/EvangelizeBanner";
import { Button, Tabs, Tab, Box } from "@mui/material";
import { ThemeProvider } from "@emotion/react";
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) {
return ;
}
@@ -223,11 +232,13 @@ export default function MyAppointmentsPage() {
setTabValue(1);
}
})
- .catch((error: Error | any) => {
- if (error.format) {
+ .catch((error: unknown) => {
+ if (error && typeof error === "object" && "format" in error && typeof error.format === "function") {
alert.showError(error.format());
- } else {
+ } else if (error instanceof Error) {
alert.showError(error.message);
+ } else {
+ alert.showError("No pudimos cargar tus turnos.");
}
console.error(error);
})
@@ -282,6 +293,8 @@ export default function MyAppointmentsPage() {
Mis Turnos
+
+
{collaboratorAppointments.length > 0 ? (
<>
diff --git a/txclient/src/app/landing/offer-appointments/CTABtn.tsx b/txclient/src/app/landing/offer-appointments/CTABtn.tsx
index 0f85a9a..5123487 100644
--- a/txclient/src/app/landing/offer-appointments/CTABtn.tsx
+++ b/txclient/src/app/landing/offer-appointments/CTABtn.tsx
@@ -4,20 +4,41 @@ import Link from "next/link";
import { useSessionStore } from "@core/Store/Sesion.Store";
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();
if (SessionInfo.loged) {
return (
-
- Ver planes
+
+ {signedInText}
);
}
return (
-
- Crear mi cuenta gratis
-
+ <>
+
+ {signedOutText}
+
+ {showSecondaryWhenSignedOut && secondaryClassName && (
+
+ Ver Planes
+
+ )}
+ >
);
}
diff --git a/txclient/src/app/landing/offer-appointments/page.tsx b/txclient/src/app/landing/offer-appointments/page.tsx
index 8881469..167fa3f 100644
--- a/txclient/src/app/landing/offer-appointments/page.tsx
+++ b/txclient/src/app/landing/offer-appointments/page.tsx
@@ -1,7 +1,6 @@
import { Metadata } from "next";
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
import style from "./style.module.css";
-import Link from "next/link";
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
import { ScrollToTop } from "@core/app/components/ScrollTop";
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.
-
- ¡Empezá Gratis Hoy!
-
-
- Ver Planes
-
+
@@ -86,7 +86,7 @@ export default function OfferAppointmentsPage() {
1
Creá tu cuenta gratis
-
Registrate en segundos. Podés probar la plataforma completa sin compromisos ni tarjetas de crédito. Registrate acá.
+
Registrate en segundos. Podés probar la plataforma completa sin compromisos ni tarjetas de crédito.
@@ -128,4 +128,3 @@ export default function OfferAppointmentsPage() {
);
}
-
diff --git a/txclient/src/app/landing/upgrade-plan/page.module.css b/txclient/src/app/landing/upgrade-plan/page.module.css
new file mode 100644
index 0000000..3a9eacc
--- /dev/null
+++ b/txclient/src/app/landing/upgrade-plan/page.module.css
@@ -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;
+ }
+}
diff --git a/txclient/src/app/landing/upgrade-plan/page.tsx b/txclient/src/app/landing/upgrade-plan/page.tsx
new file mode 100644
index 0000000..3ec37e6
--- /dev/null
+++ b/txclient/src/app/landing/upgrade-plan/page.tsx
@@ -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([]);
+ const [selectedOrgId, setSelectedOrgId] = useState("");
+ const [subscription, setSubscription] = useState(null);
+ const [metrics, setMetrics] = useState(null);
+ const [isOwner, setIsOwner] = useState(false);
+ const [plans, setPlans] = useState([]);
+ 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("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("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 (
+
+
+
+
+
+
+
+
+
+
+
+
Cambiar plan
+
Elegí un plan superior
+
+ Solo te mostramos planes pagos con mayor capacidad que tu plan actual.
+
+
+ {organizations.length > 1 && (
+
+ Organización
+ setSelectedOrgId(e.target.value)}>
+ {organizations.map((org) => (
+ {org.name}
+ ))}
+
+
+ )}
+
+
+
+ {(loading || plansLoading) && Cargando opciones de cambio...
}
+ {error && !loading && !plansLoading && {error}
}
+
+ {subscription && currentPlan && !loading && !plansLoading && !error && (
+ <>
+
+
+ Plan actual
+ {currentPlan.name}
+ {currentPlan.price > 0 ? `${formatPrice(currentPlan.price)} por mes` : "Plan gratis"}
+
+
+ Vencimiento
+ {formatDate(subscription.endDate)}
+ {metrics ? `${metrics.clientsCount} clientes cargados` : "Organización activa"}
+
+
+
+ {isCurrentPaid && (
+
+
Upgrade prorrateado
+
+ 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.
+
+
+ )}
+
+ {!isOwner && Solo el dueño de la organización puede cambiar el plan.
}
+ {subscription.mpStatus === MP_SUBS_STATUS.PENDING && Ya tenés un pago pendiente. Finalizalo antes de generar otro cambio.
}
+
+ {availablePlans.length === 0 ? (
+
+
No hay planes superiores disponibles
+
Tu plan actual ya está en el nivel más alto disponible para contratar.
+
+ ) : (
+
+ {availablePlans.map((plan) => {
+ const isSelected = selectedPlanId === plan.id;
+ return (
+
setSelectedPlanId(plan.id || "")}
+ >
+ {plan.featured ? "Recomendado" : "Plan superior"}
+ {plan.name}
+ {plan.description}
+ {formatPrice(plan.price)} / mes
+
+ {plan.features.slice(0, 4).map((feature) => {feature} )}
+
+
+ );
+ })}
+
+ )}
+
+ {selectedPlan && !isCurrentPaid && (
+
+
Duración del plan
+
+ {billingOptions.map((option) => {
+ const discount = getDiscount(selectedPlan, option.months);
+ return (
+ setSelectedMonths(option.months)}
+ >
+ {option.label}
+ {discount > 0 ? `${discount}% OFF` : "Sin descuento"}
+
+ );
+ })}
+
+
+ )}
+
+ {selectedPlan && (
+
+
+
+
Detalle del importe
+
Resumen de facturación
+
+ {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."}
+
+
+
+
+ {isCurrentPaid ? (
+ <>
+
+
Contexto del cambio
+
+
+
Plan actual
+
{currentPlan.name}
+
{formatPrice(currentPlan.price)} / mes
+
+
→
+
+
Plan seleccionado
+
{selectedPlan.name}
+
{formatPrice(selectedPlan.price)} / mes
+
+
+
+
+
+
Cálculo proporcional
+
+
+ Diferencia mensual entre planes
+ {formatPrice(monthlyDifference)} / mes
+
+
+ Días restantes hasta el {formatDate(subscription.endDate)}
+ {remainingDays} día{remainingDays === 1 ? "" : "s"}
+
+
+ Importe proporcional
+ {formatPrice(proratedAmount)}
+
+
+
+ Se toma la diferencia mensual ({formatPrice(selectedPlan.price)} - {formatPrice(currentPlan.price)}) y se prorratea por 30 días × {remainingDays} día{remainingDays === 1 ? "" : "s"}.
+
+
+
+
+ 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.
+
+ >
+ ) : (
+ <>
+
+
Contexto del cambio
+
+
+
Plan actual
+
{currentPlan.name}
+
Plan gratis
+
+
→
+
+
Plan seleccionado
+
{selectedPlan.name}
+
{formatPrice(selectedPlan.price)} / mes
+
+
+
+
+
+
Detalle de compra
+
+
+ Precio mensual
+ {formatPrice(selectedPlan.price)}
+
+
+ Duración seleccionada
+ {selectedMonths} mes{selectedMonths === 1 ? "" : "es"}
+
+
+ Subtotal
+ {formatPrice(selectedSubtotal)}
+
+
+ {selectedDiscount > 0 ? `Descuento ${selectedDiscount}%` : "Descuento"}
+ {selectedDiscount > 0 ? `-${formatPrice(selectedDiscountAmount)}` : "No aplica"}
+
+
+
+
+
+ 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.
+
+ >
+ )}
+
+ )}
+
+
+
+ {isCurrentPaid ? "Total a pagar hoy" : "Total a pagar"}
+
+ {selectedPlan
+ ? isCurrentPaid
+ ? formatPrice(proratedAmount)
+ : formatPrice(selectedTotal)
+ : "Seleccioná un plan"}
+
+
+ {isCurrentPaid
+ ? "TurnosXpress cobra solo la diferencia proporcional restante."
+ : "Precio final según duración y descuentos disponibles."}
+
+
+
+ goTo("/landing/current-plan")} disabled={submitting}>
+ Volver
+
+
+ {submitting ? "Generando pago..." : "Continuar al pago"}
+
+
+
+ >
+ )}
+
+
+
+
+
+ );
+}
+
+export default function UpgradePlanPage() {
+ return (
+ }>
+
+
+ );
+}