fix(pagos): permitir pagar plan actual trial_expired y soportar planes >$10k via Preference

- Expone subscription trial_expired en /despachos/me/plan e incluye planPrice.
- Para Business Control/Enterprise (>$10k) genera pago anual único con MP Preference
  en lugar de preapproval recurrente; el webhook activa 1 año de suscripción.
- Muestra CTA de pago en UI cuando la suscripción está trial/trial_expired.
- Agrega campo mp_preference_id a subscriptions y mejora mensajes de error MP.
This commit is contained in:
Horux Dev
2026-06-16 22:37:11 +00:00
parent 63908f9e9d
commit 3f3253d41b
8 changed files with 290 additions and 22 deletions

View File

@@ -24,6 +24,7 @@ interface PlanInfo {
dbMode: string;
trialEndsAt: string | null;
isTrialActive: boolean;
planPrice: number | null;
subscription: SubscriptionInfo | null;
}
@@ -91,6 +92,12 @@ export default function PlanesDespachoPage() {
const hasActiveSub = subStatus != null
&& subStatus !== 'cancelled'
&& subStatus !== 'trial_expired';
// Estados en los que se puede generar un link de pago (incluye trial y vencido).
const isPayableStatus = subStatus === 'trial'
|| subStatus === 'trial_expired'
|| hasActiveSub;
const isCurrentPlanPaid = currentPlan === planInfo?.subscription?.plan
&& (subStatus === 'authorized' || subStatus === 'pending');
/** Resuelve la frecuencia para un plan. Mi Empresa y Mi Empresa+ leen su
* propio toggle; el resto (business_*) siempre annual. */
@@ -225,9 +232,25 @@ export default function PlanesDespachoPage() {
function PlanActionButton({ plan }: { plan: PaidPlan }) {
const isCurrent = currentPlan === plan;
if (isCurrent) {
if (isCurrent && isCurrentPlanPaid) {
return <Button disabled className="w-full">Plan actual</Button>;
}
if (isCurrent) {
return (
<Button
className="w-full"
onClick={() => handleContratar(plan)}
disabled={busy === plan}
>
{busy === plan ? 'Procesando...' : (
<>
<ExternalLink className="h-4 w-4 mr-2" />
Pagar este plan
</>
)}
</Button>
);
}
const label = hasActiveSub ? 'Cambiar a este plan' : 'Contratar';
return (
<Button
@@ -302,7 +325,7 @@ export default function PlanesDespachoPage() {
)}
{/* Banner de suscripción activa */}
{!loading && planInfo?.subscription && hasPaidPlan && (() => {
{!loading && planInfo?.subscription && hasPaidPlan && (subStatus === 'authorized' || subStatus === 'pending') && (() => {
const sub = planInfo.subscription;
const periodEndDate = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
const fechaFormato = periodEndDate
@@ -329,18 +352,30 @@ export default function PlanesDespachoPage() {
);
})()}
{/* Botón "Pagar mi período actual" — visible cuando la sub corre y hay
un monto > 0 que cobrar. Crea una MP Preference one-off por el monto
actual (custom $10, paid plan, lo que sea). Útil para pre-pagar antes
del cobro automático o cuando no hay preapproval recurrente activo. */}
{!loading && hasActiveSub && planInfo?.subscription && Number(planInfo.subscription.amount) > 0 && (() => {
{/* Banner de trial vencido */}
{!loading && subStatus === 'trial_expired' && hasPaidPlan && (
<div className="flex items-start gap-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
<Clock className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<span className="font-semibold text-red-800 dark:text-red-300">Tu período de prueba terminó</span>
<span className="text-red-700 dark:text-red-400"> elige un plan o paga el plan actual para recuperar el acceso.</span>
</div>
</div>
)}
{/* Botón "Pagar mi período actual" — visible cuando se puede pagar y hay
un monto definido (subscription.amount > 0 o precio de catálogo).
Crea una MP Preference one-off por el monto actual. */}
{!loading && isPayableStatus && planInfo?.subscription && (() => {
const sub = planInfo.subscription!;
const effectiveAmount = Number(sub.amount) > 0 ? Number(sub.amount) : (planInfo.planPrice ?? 0);
if (!effectiveAmount) return null;
const periodEnd = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
const fechaFmt = periodEnd
? periodEnd.toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' })
: null;
const dias = periodEnd ? Math.max(0, Math.ceil((periodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24))) : null;
const montoFmt = Number(sub.amount).toLocaleString('es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
const montoFmt = effectiveAmount.toLocaleString('es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
return (
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg px-5 py-4 max-w-3xl mx-auto">
<CreditCard className="h-6 w-6 text-blue-600 dark:text-blue-400 flex-shrink-0" />