feat: implement plan upgrade logic with prorated pricing and MercadoPago integration
This commit is contained in:
@@ -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