Ir al contenido

Plans y billing (Stripe)

CÉNIT tiene tres planes — esencial, pro y enterprise — más un estado expired. La definición vive en lib/plans.ts (23 features + límites por recurso). El webhook de Stripe está operativo y es idempotente. La página /upgrade existe y muestra los planes, pero el handler GET /api/stripe/checkout que invoca el botón no está implementado (el directorio app/api/stripe/checkout/ no existe — solo app/api/stripe/webhook/). Activar el flow self-service requiere implementar el handler y configurar STRIPE_PRICE_ID_PRO / STRIPE_PRICE_ID_ENTERPRISE en Workers Secrets. Hay además gaps reales entre lo que se vende y lo que está construido — documentados acá honestamente.

PlanAtletasEquiposColaboradoresHistorial
esencial402524 meses
proilimitadoilimitadoilimitadoilimitado
enterpriseilimitadoilimitadoilimitadoilimitado
expired0000

7 features prometidas pero NO implementadas (solo en pro/enterprise): custom_dashboard, excel_export, ai_post_injury_analysis, ai_anomaly_detection, ai_natural_language_insights, wearables_integration, api_read. Vender Pro/Enterprise hoy prometiendo estas implica deuda con el cliente.

Pro y Enterprise son idénticos en códigolib/plans.ts define exactamente las mismas 23 features en true para ambos planes. No hay diferenciador funcional. Si Enterprise debe tener algo más (SSO, audit logs, white-label fase 2, soporte dedicado) requiere decisión de producto.

2 features sin gate (esencial las puede usar gratis): season_comparisons y custom_branding. Está identificado y queda como deuda técnica corta.

import { canUse } from '@/lib/plans'
if (!canUse(plan, 'risk_advisor')) return null

El plan vive en user_profiles.plan. Si es expired, el layout del dashboard redirige a /upgrade?expired=true.

  1. /upgrade muestra los tres planes con precios — listo.
  2. El botón “Elegir plan” linkea a /api/stripe/checkout?plan=<key>handler NO existe, hoy devuelve 404.
  3. Cuando el handler exista deberá crear la sesión de Stripe Checkout con metadata.plan y redirigir al checkout hosted.
  4. Stripe redirige a /upgrade/success o /upgrade/cancel.
  5. Stripe dispara webhook a /api/stripe/webhook → el plan se actualiza en user_profiles. Esto sí está operativo.

POST /api/stripe/webhook (app/api/stripe/webhook/route.ts) verifica firma con STRIPE_WEBHOOK_SECRET y procesa cuatro eventos:

  • checkout.session.completed → actualiza plan, stripe_customer_id, stripe_subscription_id. Devuelve 404 explícito si el email no matchea ningún user_profiles (antes silencioso — ahora visible en el dashboard de Stripe).
  • customer.subscription.updated → guarda plan_expires_at.
  • customer.subscription.deleted → setea plan = 'expired'.
  • invoice.payment_failed → setea plan = 'expired'. El layout redirige al usuario a /upgrade de inmediato.

Cada event.id se claimea en stripe_processed_events (PK event_id). Si Stripe reintenta, el segundo POST retorna { received: true, idempotent: true } sin re-mutar. La unique violation 23505 es la garantía atómica.

  • user_profilesplan, stripe_customer_id, stripe_subscription_id, plan_expires_at.
  • stripe_processed_events — lock de idempotencia.
  • Sentry — todo error del webhook se captura con tags route: stripe/webhook + event + eventId.
  • Multi-tenant — el plan es por-usuario (en user_profiles), no por-organización.
  • Checkout self-service incompletoapp/upgrade/page.tsx existe en main, pero el handler app/api/stripe/checkout/route.ts que el botón invoca no está creado. Falta también configurar STRIPE_PRICE_ID_PRO y STRIPE_PRICE_ID_ENTERPRISE en Workers Secrets.
  • 7 features inexistentes — decisión de producto: construir, sacar de la oferta o documentar como roadmap H2 2026.
  • Diferenciador Pro/Enterprise — pendiente.
  • Gating de season_comparisons + custom_branding — ~2-3 h de trabajo, pendiente.
  • Bug menor: dashboard/medico/actions.ts gatea con module_physio (debería ser module_medical, que no existe en la lista de features).