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