Un plan con límite de uso parece la funcionalidad más simple de un SaaS: sumas lo consumido y lo comparas con el tope. Esa cuenta aguanta hasta el día que vendes tu primera recarga, y a partir de ahí toca migrar en caliente. Aquí verás cómo se modela desde el principio para que eso no pase, dónde están las carreras entre peticiones concurrentes y cómo se resuelven. Los ejemplos van en Laravel, que es una de nuestras especialidades, pero el modelo de datos es el mismo en cualquier stack.
El saldo no se calcula, se guarda
La primera versión que escribe todo el mundo suma el consumo del periodo y lo resta del límite del plan. Funciona perfectamente hasta el día que vendes una recarga.
Porque una recarga no es consumo, así que no se puede expresar como una resta de lo gastado. En cuanto un cliente puede comprar créditos sueltos además de los que trae su plan, el saldo deja de ser una operación aritmética sobre el histórico y pasa a ser un estado que hay que almacenar.
Eso no es una optimización, es el modelo. Y cuanto antes lo asumas, menos migraciones dolorosas.
Cuatro tablas, y cada una responde una pregunta distinta
La estructura que nos funciona separa cuatro cosas que la gente suele mezclar en una:
La cuenta guarda lo que no caduca: en qué plan está y cuántos créditos comprados le quedan.
Las ventanas guardan lo que el plan ya ha cubierto en cada periodo. Una fila por cuenta y por ventana.
Los depósitos son las entradas: compras, regalos, reembolsos, correcciones.
El consumo son las salidas, y es una tabla que solo crece. Nunca se edita ni se borra.
La separación importa porque una recarga y un consumo son eventos de naturaleza distinta, y meterlos en la misma tabla con el signo cambiado te obliga a interpretar cada fila para saber qué era.
Un plan concede una cifra, y cada ventana toma su parte
Un plan casi nunca tiene un solo límite: tiene uno mensual, otro diario y a veces uno por sesión. Cómo se relacionen entre sí decide si subir a un cliente de plan es cambiar un número o revisar tres tablas.
Lo intuitivo es declarar un límite por periodo: tanto al mes, tanto a la semana, tanto por sesión. El problema aparece al subir de plan, porque hay que acordarse de subir los tres, y el día que se olvida uno, el cliente ha pagado más y sigue topado por donde estaba.
Lo que funciona mejor es una sola cifra y un porcentaje por ventana. Cincuenta mil al mes son doce mil quinientos a la semana y dos mil por sesión, y subir el plan sube las tres a la vez. Un plan nuevo se declara con un número.
Un detalle que sorprende la primera vez: los porcentajes no tienen por qué sumar uno. Cada ventana calcula su techo por su cuenta sobre la misma cifra base. El mensual puede ser el cien por cien y el semanal un veinticinco, y no se contradicen: son dos topes distintos aplicados a la vez.
Las ventanas se reinician solas, sin cron
Casi todas las implementaciones caseras acaban con una tarea programada que resetea contadores a medianoche. Es una pieza más que puede fallar, y cuando falla nadie se entera hasta que un cliente reclama.
No hace falta. Basta con guardar cuándo empezó la ventana y comprobarlo en el momento de cobrar: si ya ha vencido, se reinicia ahí mismo y se sigue. El reinicio es perezoso. No hay nada que sincronizar, ningún webhook, ninguna tarea que se quede colgada.
Y hay dos comportamientos que conviene distinguir. Una ventana anclada avanza en rejilla fija: el mes empieza cuando le toca, hayas entrado o no. Una ventana rodante empieza a contar desde que el usuario vuelve, que es lo que quieres para un tope por sesión.
Consultar el saldo no debe abrir una ventana. Suena obvio y es fácil equivocarse: si el objeto que representa la ventana se crea al leerlo, un usuario que entra a mirar cuánto le queda arranca su ventana de sesión sin haber consumido nada. Las lecturas tienen que responder sin escribir una sola fila.
Primero el plan, después lo comprado, y eso cambia la pantalla
El orden de cobro parece un detalle contable y tiene una consecuencia visible.
Se cobra primero contra la asignación del plan y solo cuando esta se agota se tira de los créditos comprados. Y lo importante: lo que pagan los créditos comprados no cuenta contra las ventanas. Sobreviven a todos los reinicios y no consumen el porcentaje del periodo.
El efecto práctico es que el contador mensual no refleja el consumo total. Un cliente que ha gastado su asignación y sigue trabajando con recargas ve su barra al cien por cien mientras sigue produciendo. Es correcto, y hay que explicarlo en la interfaz o generas soporte.
Un segundo efecto, también correcto y también sorprendente: la ventana más estrecha manda. Si el tope semanal deja cien créditos y el mensual cinco mil, un cargo de trescientos solo puede sacar cien del plan, y el resto va contra lo comprado. El mensual solo avanza cien.
El cobro no debe rechazar, la comprobación previa sí
Hay dos momentos en los que se puede decir que no: antes de empezar y al terminar. Parece que dé igual y no lo da, porque entre uno y otro el trabajo ya se ha hecho y alguien lo ha pagado.
Cuando llega el momento de cobrar, la operación ya está a medias. El documento se ha generado, la llamada al modelo ha salido, el correo se ha enviado. Rechazar el cobro ahí no deshace nada, solo deja el sistema sin registro de algo que sí ocurrió.
Así que cobrar no rechaza nunca: registra el consumo, y si no había saldo lo apunta como descubierto. El sitio para frenar es antes, con una comprobación explícita que además puede decirle algo útil al usuario.
Eso convierte "preguntar primero" en la parte que tiene que ser fácil de escribir. Si comprobar el saldo es incómodo, la gente se lo salta, y entonces el descubierto deja de ser una red y pasa a ser el camino normal.
Dónde están las carreras, que es lo que nadie prevé
Si vendes esto, dos peticiones simultáneas no pueden gastar el mismo crédito. Y ahí solo hay un sitio donde poner el candado: la fila de la cuenta.
El cobro abre transacción, bloquea esa fila en exclusiva, relee las ventanas dentro de la transacción y solo entonces calcula cuánto cabe. El segundo proceso espera al primero y ve el saldo ya movido. Sin la relectura dentro del bloqueo, el segundo calcularía sobre datos de antes y tendrías la actualización perdida de manual.
Hay una segunda carrera que casi todo el mundo pasa por alto: la creación de la propia cuenta. Dos peticiones concurrentes del primer día de un cliente pueden intentar crearla a la vez. Se resuelve con un índice único y releyendo cuando el insert choca, no comprobando antes si existe.
Y una que conviene aceptar en vez de cerrar: comprobar y cobrar no son atómicos. Dos peticiones pueden pasar ambas la comprobación y cobrar ambas. Cerrar esa ventana exigiría mantener el bloqueo desde antes de empezar la operación hasta después de terminarla, y eso serializa toda tu aplicación. Como cobrar no rechaza, el resultado es un pequeño descubierto registrado, no una operación a medias. Es el compromiso correcto.
Un reembolso no devuelve lo mismo que se gastó
Devolver créditos parece la operación inversa de gastarlos, y con un solo tipo de saldo lo sería. En cuanto conviven la asignación del plan y las recargas compradas, deshacer un cobro obliga a decidir a cuál de las dos vuelve el dinero.
Si una operación se pagó con la asignación mensual y falla, el reembolso natural es un depósito. Pero un depósito entra en el saldo comprado, no descuenta el contador de la ventana. El cliente recupera sus créditos en el bucket que no caduca, y su barra de consumo mensual sigue marcando el gasto.
No es un error, es la consecuencia de que las dos cosas vivan en sitios distintos. Pero si tu interfaz habla de porcentajes, ahí hay una conversación con el usuario que conviene prever.
Qué hay en el mercado y en qué se diferencia
En Laravel la referencia para cobrar es Cashier, y no compite con esto: son piezas complementarias. Cashier responde qué ha pagado el cliente. Un medidor responde cuánto ha gastado de lo que pagó. En nuestros proyectos conviven en el mismo modelo.
Hay una diferencia de enfoque que sí importa. La facturación medida de las pasarelas reporta el uso al proveedor y factura después. Un medidor local frena antes, sin salir a la red, que es lo que necesitas cuando el coste de la operación lo pones tú por adelantado.
Y hay un motivo para desacoplar el reloj de facturación del reloj de cuota: una suscripción anual normalmente concede una asignación mensual. Una ventana anclada al periodo de facturación la reiniciaría una vez al año.
Larameter, que es el que mantenemos, salió de esto. En Crowd Legal sustituyó a un medidor hecho a mano, y el reparto semanal del veinticinco por ciento se eligió precisamente para reproducir lo que aquel ya hacía. No tiene nada que ver con IA: ahí se cobran créditos por crear un formulario, generar un documento, lanzar una encuesta, verificar una identidad y enviar un correo, y ninguna de esas cosas llama a un modelo.
Y si no trabajas con Laravel
Las decisiones son las mismas y ninguna depende del lenguaje:
Guarda el saldo, no lo calcules, en cuanto vendas recargas. Separa entradas de salidas en tablas distintas. Una cifra por plan y un porcentaje por ventana, no un número por periodo. Reinicia al cobrar, no con un cron. Bloquea la fila de la cuenta y relee dentro de la transacción. Y que cobrar no rechace, poniendo la puerta antes de empezar la operación.
Si todo eso está, el paquete que uses da bastante igual.
Dónde está el código
El paquete está publicado en Packagist, el código en GitHub y la documentación del paquete recoge todas las opciones. Está en producción en Crowd Legal y en Abodara, esta última vendiendo recargas con pasarela.
Si estás montando un producto por suscripción con límites de uso, es de las partes que más caro sale rehacer. Lo trabajamos en SaaS a medida y aplicaciones web, y si nos cuentas el modelo te decimos dónde están las trampas.