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 && (
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. +

+
+
+ + +
+
+ )} + + {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 && ( + + )} +
+
+ + {(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 ( + + ); + })} +
+ )} + + {selectedPlan && !isCurrentPaid && ( +
+ Duración del plan +
+ {billingOptions.map((option) => { + const discount = getDiscount(selectedPlan, option.months); + return ( + + ); + })} +
+
+ )} + + {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."} + +
+
+ + +
+
+ + )} +
+
+
+
+
+ ); +} + +export default function UpgradePlanPage() { + return ( + }> + + + ); +}