Hace poco repasé el historial de una app de control de horas que escribí hace un año, buscando otra cosa, y me hizo gracia lo que encontré. Primer commit a las 15:48. A las 16:55 el siguiente commit con chicha: "remove Stripe API routes for checkout, portal and webhook". Fuera 68 líneas de checkout, 48 de portal y 222 de webhook. La pantalla principal de la app todavía no existía.
Luego miré otros repos y resulta que lo hago siempre. Clono el boilerplate y, antes de escribir una sola línea del producto, me pongo a desmontar los pagos. No lo había pensado como una regla hasta verlo repetido, así que este post es un intento de explicarme a mí mismo por qué lo hago y cuándo no debería.
Lo que se va por el desagüe cuando tiras del hilo
El boilerplate trae Stripe montado y encendido. Me parece bien que lo traiga, porque el día que hace falta te ahorra una jornada. Lo que pasa es que "encendido" no quiere decir tres rutas: quiere decir que los pagos se han metido en sitios que no ves hasta que intentas sacarlos.
En una plantilla interna que empecé esta primavera, "remove Stripe integration and related billing features" fue el segundo commit del repo, el mismo día que el init project. Se llevó la tabla user_billing completa, con customer_id, price_id, sub_credits, otp_credits, initial_sub_date, last_otp_date, has_sub y cancel_next_month, más un índice sobre user_id. Todo eso para gestionar un cobro que no existía. Se llevó también cinco variables de entorno (USE_STRIPE, NEXT_PUBLIC_USE_STRIPE, STRIPE_PUBLIC_KEY, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET), que son cinco líneas del .env.example por las que alguien acabará preguntando. Y como la tabla de billing iba en la primera migración, hubo que borrarla y regenerarla.
Lo que más me preocupaba eran dos planes de ejemplo en la config con priceId reales (price_1R3Rhw...) copiados de otro proyecto. Esos identificadores son de los que nadie borra porque "ya estaban", y un día alguien despliega con ellos y el checkout de la app nueva cobra contra los precios de la vieja. No me ha pasado. Me he quitado de en medio para que no me pase.
Y hay un efecto secundario que no esperaba y que me gusta mucho: el tipo ConfigFile declaraba [key: string]: string | string[] | PricingConfig | PricingConfig[] y, sin planes de precios, se quedó en string | string[]. Parece una tontería, pero es un tipo que tocas cada dos por tres, y cada vez que lo lees con dos variantes menos piensas un poco menos.
El criterio, que es lo único que vale de este post
No tengo nada contra los pagos. De hecho hay proyectos donde Stripe entró pronto y ahí se quedó, y comparándolos con los otros me salió la regla que uso ahora:
Si cada uso te cuesta dinero, el cobro es infraestructura y va desde el primer día. Si el coste por uso es cero, el cobro es producto y puede esperar a que sepas qué vendes.
En un SaaS de generación de imágenes el checkout estuvo habilitado casi desde el principio, y seis días después ya funcionaban el webhook y los créditos diarios. No lo hice para validar el negocio. Lo hice porque cada vez que alguien pulsa "generar" me llega una factura del proveedor del modelo, y sin un límite lo que tienes es una forma muy rápida de perder dinero. En una API de transcripción pasó lo mismo: el billing y los límites del plan gratuito se escribieron cuando la cuenta de usuario estaba todavía a medias, porque cada minuto de audio procesado cuesta desde la primera petición.
Ahora compáralo con una herramienta de horas, un gestor de incidencias interno o una plantilla. Un usuario más no me cuesta nada. Ahí montar pagos es contestar a "¿cuánto cobro?" antes de saber si a alguien le sirve lo que hago, y mientras tanto ese webhook de 222 líneas es código que mantengo y no uso nunca.
La objeción que ya me imagino (en Reddit sale siempre) es que la única validación real es que alguien pague, y que cuanto antes cobres antes lo sabrás. Estoy bastante de acuerdo, pero para saber si alguien está dispuesto a pagar no hace falta la integración. Un Payment Link de Stripe y una hoja de cálculo donde apuntas a mano quién ha pagado te lo dicen igual de bien. El webhook con verificación de firma, idempotencia y tres tipos de evento sirve para cuando ya cobras en serio y a volumen. No para averiguar si alguien cobrará.
La otra objeción es que luego da pereza volver a montarlo. Un poco sí. Pero cuando lo reintegro ya sé cuál es el modelo de precios y no arrastro dos planes de ejemplo heredados. Me sale una tarde de trabajo, y además con el código escrito para el caso real.
Stripe nunca viene solo
Cuando empiezas a quitar pagos te das cuenta de que en el boilerplate hay otras cosas en la misma situación.
En un MVP de tres días para gestionar incidencias el segundo commit se llama, sin más, "remove useless things", y llegó dos horas y once minutos después del primero. Se llevó el panel de admin entero (client-page.tsx con 250 líneas, la tabla de usuarios con 380, un gráfico con 114), las rutas de feedback y su tabla. A las rutas de Stripe les hice algo que reconozco que es un apaño: las renombré de route.ts a route.txt. Next deja de verlas, no compilan, no se exponen y siguen ahí por si acaso. Ya sé que para eso está git. Pero tener el fichero a la vista en el árbol me ahorra el git log -- ruta y me recuerda que existe, que en un proyecto de tres días me compensa. En uno largo no lo haría.
Al día siguiente, en ese mismo proyecto, tocó quitar el magic link. La página de auth tenía Google y además email con código, cada uno con sus estados: nonce, magicLinkSent, magicLinkCode, handleMagicLinkCode. El commit "eliminar magic link (solo Google)" borró 116 líneas de esa página. Era una app interna para una empresa donde todo el mundo tiene cuenta de Google, así que el segundo camino no aportaba nada y había que mantenerlo igual.
Pero el caso que más me convenció salió de otra plantilla, donde me cargué el admin: /admin, /account, hasAdminRole(), ADMIN_EMAILS, y del schema de usuarios role, banned, ban_reason y ban_expires. Y en la tabla de sesiones había un campo impersonated_by.
Ese campo me hizo parar. Si una sesión puede estar suplantada por otro usuario, eso es una decisión de seguridad, y cualquiera que toque la autenticación tiene que entender cómo funciona y en qué casos se activa. En un proyecto sin panel de admin nadie lo va a ejercitar, nadie lo va a revisar y probablemente nadie sepa que está ahí. Lo que me da miedo del código muerto de auth no es que ocupe sitio. Es que, si algún día alguien lo activa sin querer, tiene permisos de verdad.
Lo que sobrevive
Si comparo esos repos después de la primera hora, casi siempre queda lo mismo: auth con un solo proveedor, las tablas del dominio y las rutas que la app necesita para su trabajo. A eso le llamo infraestructura. Lo demás son apuestas sobre lo que el producto será algún día, y el mantenimiento de esas apuestas lo pagas aunque nunca salgan.
Lo que no tengo claro es si el problema es mío o del boilerplate. Si cada vez que lo clono lo primero que hago es quitarle cosas, a lo mejor esas cosas no deberían venir encendidas. Tengo pendiente probar a dejar los pagos detrás de un flag que por defecto no genere ni la tabla. Aunque USE_STRIPE ya existía y aun así acabé borrándolo todo, así que igual la respuesta es que prefiero no verlo.
