← Volver al blog
IngenieríaBuild in PublicPrecios

Vendimos un plan que no funcionaba

Lanzamos un plan de 4,90 €/mes. La opción anual habría cobrado 58,80 € cada mes, el plan no concedía ningún derecho y el worker ignoraba su límite de duración. Los tres se encontraron comprándolo nosotros mismos.

4 de octubre de 2026

Vendimos un plan que no funcionaba

Creado el 4 de octubre de 2026 · Actualizado el 4 de octubre de 2026

Un plan de 4,90 €/mes se publicó en nuestra página de precios. A quien eligiera la opción anual se le habrían cobrado 58,80 € cada mes.

No le pasó a nadie.

Lo detectamos antes de que un solo cliente comprara el plan anual, y no leyendo el código, sino comprando el producto nosotros mismos con una tarjeta real.

Eso importa. La compilación estaba en verde. TypeScript estaba contento. La API devolvía 200. Nada de eso significaba que el producto funcionara.

Encontramos tres defectos esa mañana. Nuestro primer cliente Solo de pago se suscribió veintisiete minutos después del último de esos tres arreglos. Media hora en el otro sentido y habría sido él quien pagara por un plan que no le concedía nada.

Defecto 1: el precio era correcto, el intervalo no

Nuestro nuevo plan de entrada, Solo, cuesta 4,90 €/mes con facturación anual, u 8,90 € al mes. 4,90 € × 12 = 58,80 € una vez al año.

El precio de Stripe detrás de la opción anual se había creado con un intervalo de facturación mensual.

Stripe estaba por tanto listo para cobrar 58,80 € cada mes: doce veces el precio previsto.

La página de precios era correcta. El código usaba el identificador de precio correcto. El error vivía fuera del repositorio, en un objeto de Stripe creado desde el panel.

Eso es justamente lo que lo hacía fácil de pasar por alto.

Stripe no permite cambiar el intervalo de facturación de un precio existente: se crea un precio nuevo y se cambia el identificador. Así que añadimos una comprobación antes de crear la sesión de pago:

const expected = effectivePeriod === "annual" ? "year" : "month";
const price = await stripe.prices.retrieve(priceId);

if (price.recurring?.interval !== expected) {
  throw new Error("PRICE_INTERVAL_MISMATCH");
}

Una llamada a la API por cada pago.

Si Stripe y la página de precios se contradicen, nos negamos a vender.

El objeto defectuoso de Stripe puede seguir existiendo. Simplemente ya no puede atravesar el pago sin que nos demos cuenta.

Defecto 2: el plan existía comercialmente, no funcionalmente

El segundo error fallaba en la dirección opuesta: un cliente podía pagarnos y seguir siendo tratado como gratuito.

Añadir un plan parece un cambio comercial. En la práctica, toca cada lugar del producto que se pregunta: «¿qué tiene permitido hacer esta cuenta?»

En nuestro código, esa pregunta se respondía en 29 archivos.

Esas comprobaciones se habían acumulado con el tiempo en las exportaciones, las subidas, la cola de procesamiento, los puntos de API, la facturación y el panel. La mayoría eran comparaciones de cadenas escritas contra los planes que existían cuando se añadió ese código.

Solo era nuevo.

Así que comprobaciones del tipo ¿es pro, creator o studio? caían en la rama por defecto. Y la rama por defecto era la gratuita.

Eso significaba que una cuenta Solo de pago podía recibir la cuota gratuita, los límites de subida del plan gratuito, la marca de agua y las demás restricciones del nivel gratuito.

El instinto detrás de ese valor por defecto no era tonto. En seguridad, fallar cerrado suele ser lo que quieres.

En facturación, sin embargo, crea otro modo de fallo: el cliente ya ha pagado, y tu respaldo más seguro se convierte precisamente en lo que le retiene lo que compró.

El arreglo no fue añadir "solo" a 29 condicionales. Lo sustituimos por un único lugar donde se describe el comportamiento de un plan:

export type Plan = "free" | "solo" | "pro" | "creator" | "studio";

export function isPaid(plan): boolean
export function hasDubbing(plan): boolean
export function hasScheduling(plan): boolean
export function videoQuota(plan, status): number
export function maxDurationMinutes(plan): number
export function maxUploadSize(plan): number
export function retentionHours(plan): number | null

Los nombres de arriba están simplificados para el artículo, pero la estructura es la que desplegamos.

La regla al principio de ese archivo dice ahora: un plan no existe hasta que está aquí.

Hubo dos cuasi-accidentes útiles durante la corrección.

Primero, casi usamos esa única comprobación isPaid() para todas las puertas, incluidas las específicas de cada función. Eso habría dado a los clientes Solo cosas que no pagan, como el doblaje con IA y la programación de publicaciones. «De pago» y «tiene derecho a esta función» no son la misma pregunta.

Segundo, creímos brevemente que la programación no tenía ninguna comprobación de plan, porque buscar "plan" en la ruta no devolvía nada. La puerta estaba dentro de una función compartida, a una llamada de distancia.

Un grep que vuelve vacío no es una respuesta.

Defecto 3: el límite que anunciamos pero nunca aplicamos

La página de precios dice que Solo acepta vídeos de origen de hasta 60 minutos. El worker que descarga y procesa los vídeos solo conocía dos límites:

Solo es de pago. Así que Solo obtuvo 90.

Este error se escapaba en la otra dirección. A nadie se le cobró de más y nadie perdió acceso. Simplemente regalábamos más cómputo del que el plan debía incluir.

Para un producto de vídeo, eso importa. La duración del origen es uno de los factores más directamente ligados al coste de transcripción, renderizado y ancho de banda.

El worker lleva ahora el mismo techo que la aplicación:

def _max_minutes(plan: str | None) -> int:
    """Espejo de los limites de plan que usa la aplicacion.

    Lo que la pagina de precios anuncia y lo que el worker aplica
    deben ser el mismo numero.

    Un limite anunciado sin aplicarse es margen que se va;
    aplicado sin anunciarse es una trampa.
    """

Un límite que solo existe en la página de precios no es un límite. Es una frase en una página.

Qué encontró realmente estos errores

No el verificador de tipos. No la suite de pruebas. No una revisión de código.

Lo que los encontró fue abrir el sitio fingiendo no haberlo visto nunca, pulsar la opción más barata e introducir una tarjeta real.

El desajuste de facturación apareció en el propio resumen de pago de Stripe. Los derechos ausentes aparecieron cuando la cuenta de pago siguió comportándose como gratuita. El problema de duración apareció cuando preguntamos al worker qué límite aplicaría de verdad.

Seguimos reaprendiendo la misma lección: una compilación que pasa no demuestra que un producto funcione.

Cada error vivía en el hueco entre dos sistemas que, por separado, hacían exactamente lo que se les había dicho:

La forma económica de encontrar esta familia de errores es sorprendentemente aburrida: usar el producto como lo hará la persona que paga por él.

Un epílogo sobre la atribución

Nuestro primer suscriptor Solo llegó esa misma mañana.

Durante una hora nos contamos una historia bonita: Solo acababa de salir, alguien lo encontró por su cuenta, llegó al límite del plan gratuito y convirtió.

El registro de eventos contaba algo menos pulcro.

Esa persona había encontrado Katto a través de ChatGPT, se registró, usó mucho el plan gratuito, llegó a la cuota, abrió el pago varias veces y se fue sin pagar.

A la mañana siguiente salió un correo de recuperación de pago abandonado. Unas horas después, se suscribió.

Eligió la opción de 8,90 € al mes en lugar de 4,90 € con facturación anual, pagando un 82 % más al mes para evitar un compromiso de doce meses. La oferta de fundador que había pulsado y abandonado el día anterior era de 6 €.

También habíamos supuesto que el límite de 60 minutos en el vídeo de origen era lo que le había empujado hacia Solo. No era así. Con lo que chocaba una y otra vez era con la cuota de dos vídeos al mes.

Así que dos suposiciones murieron a la vez. La conversión no fue simplemente orgánica: el correo de recuperación probablemente contó. Y el límite que generaba más presión para suscribirse no era el que creíamos.

Tiene exactamente la misma forma que los tres errores anteriores. Desde fuera, era fácil contar una historia limpia. Luego miramos lo que había pasado de verdad.


Katto convierte vídeos largos en clips verticales. Publicamos las cifras que medimos, incluidas las que nos dejan en mal lugar.

Artículos relacionados

¿Listo para convertir tus vídeos en clips virales?

Katto recorta, subtitula y reencuadra automáticamente tus vídeos largos en contenido de formato corto.

Prueba Katto gratis →
Vendimos un plan que no funcionaba | Katto