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

This commit is contained in:
2026-07-18 15:39:07 -03:00
parent f6e2bb8372
commit 8d74ae95c8
25 changed files with 2491 additions and 142 deletions
@@ -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>
);
}