02Producto7 min

He empezado con créditos tres veces y las tres he acabado con saldo en céntimos

Cobrar por uso con créditos se entiende muy bien en la landing y se complica en cuanto metes un segundo modelo. Este es el camino que he repetido, con la parte del dinero en coma flotante que todavía no tengo resuelta.

Por Salva Castells

Imagen de portada: Federico Candoni, CC BY-SA 4.0.
En este post

Me ha pasado en una app de transcripción, en un SaaS de generación de imágenes y en el boilerplate con el que empiezo proyectos. Todo producto que llama a un modelo de IA arranca con créditos: un entero en la tabla de perfil, se resta uno por operación y listo. Y en los tres casos, unos meses más tarde, tenía un saldo en céntimos con decimales y límites de gasto por clave de API.

Como me ha pasado tres veces, creo que ya puedo decir en qué momento se tuerce y qué haría si empezara hoy. Ojo, que esto no pretende ser una guía de pricing: sale de haberlo migrado a golpes.

Los créditos funcionan, hasta que dejan de funcionar

La primera versión siempre es algo así: un crédito gratis al día, veinte con la suscripción, y una comprobación de una línea antes de llamar al proveedor.

if (!profile || profile.subCredits < estimatedCost) {
  return NextResponse.json({ error: "Insufficient credits" }, { status: 402 });
}

Y no tengo nada en contra: se explica en la landing sin decir nada más, el usuario ve "te quedan 12" y no pregunta, no hay redondeos. Mientras cada operación cueste lo mismo, un crédito es una unidad perfectamente razonable.

La primera señal de que algo va mal no la da el código, la dan las facturas del proveedor. En la app de transcripción cobraba por caracteres. Dos semanas después de lanzar me puse a hacer cuentas: cuántos caracteres tiene de media una frase en inglés (unos 75) y en español (unos 90), cuántas frases entraban en un paquete de 2 dólares, cuánto me cobraba el proveedor por todo eso. Acabé multiplicando por seis el precio por carácter respecto al coste del proveedor. Nadie hace ese cálculo el día del lanzamiento, y yo tampoco lo hice. Lo hice cuando vi que el margen no era el que había supuesto.

Lo que rompe los créditos del todo es el segundo modelo. Si el usuario puede elegir entre una transcripción barata y otra precisa, ¿cuánto vale un crédito? Si cobras el modelo caro como el barato, pierdes dinero. Si lo cobras todo a precio del caro, el barato no tiene sentido. Puedes decir que el caro vale tres créditos, sí, pero entonces ya estás usando una moneda y fingiendo que no.

(También se rompe en cuanto tienes dos bolsas de créditos, la diaria gratis y la de la suscripción, y la comprobación solo mira una. Eso me pasó en el SaaS de imágenes y lo cuento en detalle en el post de contadores, así que aquí no me enrollo.)

Pasar a saldo, y el problema de los céntimos que no bajan

El saldo es un número en céntimos del que se descuenta el coste real de cada operación. Con eso ya puedes mezclar modelos, proveedores y unidades que no tienen nada que ver (minutos de audio y tokens de texto) en la misma cuenta, porque al final todo se convierte en céntimos.

Lo que no esperaba es que los céntimos enteros se quedaran cortos. Una transcripción corta con el modelo barato cuesta una fracción de céntimo, así que con integer cada llamada redondeaba a cero y el saldo no bajaba nunca. Tardé más de lo que me gustaría en verlo, porque no fallaba nada: simplemente el número no cambiaba. La migración fue esta:

ALTER TABLE "usage_logs"   ALTER COLUMN "cost_cents"        SET DATA TYPE real;
ALTER TABLE "user_billing" ALTER COLUMN "balance_cents"     SET DATA TYPE real;
ALTER TABLE "api_keys"     ALTER COLUMN "spend_limit_cents" SET DATA TYPE real;

Sí, ya lo sé. Dinero en coma flotante. Alguien en los comentarios me lo va a decir, y tendrá razón en parte, así que prefiero decirlo yo.

Cuando lo hice, mi razonamiento fue que es un saldo prepago que solo se descuenta y se recarga, que no facturo a posteriori, y que cualquier error de redondeo es menor que perder la llamada entera por truncar a cero. Eso sigue siendo cierto. Pero me quedé corto, porque real en Postgres es de 4 bytes y tiene unos siete dígitos significativos. Con un saldo de 5000 céntimos, el valor representable más cercano está a unas cinco diezmilésimas de céntimo. Si descuentas 0,003 céntimos, el error de cada resta puede ser cerca de un diez por ciento de lo que estás restando. Uno a uno no importa, pero sumados en miles de llamadas ya no es tan despreciable. Y SUM(cost_cents) sobre real devuelve real, así que el límite por clave se compara con una suma que también arrastra error.

Si empezara hoy no usaría ni integer en céntimos ni real. Usaría bigint en una unidad mucho más pequeña, por ejemplo millonésimas de euro, o directamente numeric. Aún no lo he migrado. Está en la lista.

El saldo también me cambió el pricing: pasé de un plan a tres (4,99, 14,99 y 49,99 al mes, expresados en horas de transcripción y tokens) más recargas sueltas de 5, 10 y 25 euros. Para el usuario son productos distintos. Por dentro todo es sumar céntimos a la misma columna, y eso fue un alivio.

Tres límites, uno de sobra

En la app de transcripción llegué a tener tres mecanismos a la vez para decir "hasta aquí". El saldo: si llega a cero o menos, no se sirve nada. Un límite mensual por usuario, monthly_limit_cents, con 4,99 por defecto y editable desde la cuenta. Y un límite de gasto por clave de API, spend_limit_cents con un periodo (daily, monthly o total), que se compara con la suma de los logs de uso de esa clave.

El límite mensual del usuario lo quité. ALTER TABLE "user_billing" DROP COLUMN "monthly_limit_cents", y con él el selector, la server action y las traducciones en cinco idiomas. Tardé demasiado en darme cuenta de que en un modelo prepago el saldo ya es el límite. Si alguien recarga 20 euros, no necesita además decirme que no quiere gastar más de 20 euros. Era otra pantalla que explicar y otra condición que comprobar en cada petición, y no protegía de nada.

Supongo que lo metí porque lo tienen los proveedores grandes. Pero ellos facturan a posteriori, y ahí un límite de gasto tiene todo el sentido. Lo copié sin pensar si en mi modelo hacía falta.

El límite por clave sí se quedó, porque protege de algo que pasa de verdad: meter una clave en un script, que el script entre en un bucle y te gaste el saldo en diez minutos. Y aquí hay dos cosas que aprendí por las malas.

La primera: antes de comparar hay que volver a leer la clave de la base de datos por su id, aunque ya tengas el objeto que devolvió la autenticación. Ese objeto puede venir de antes de que el usuario cambiara el límite, y entonces estás sirviendo peticiones con un spendLimitCents desactualizado.

La segunda: el guard va en todos los endpoints, también en los que no cuestan nada. Listar modelos o presets también devuelve 429 si la clave ha llegado a su límite. Al principio me parecía demasiado estricto, porque listar modelos no me cuesta nada. Pero si alguien tiene un script desbocado, prefiero que le falle todo y lo vea enseguida a que le funcione la mitad y crea que el problema es otro.

Lo que decidiría el primer día

Si el producto tiene una sola operación y cuesta siempre lo mismo, créditos, y sin sentirme culpable. Se entienden mejor y venden mejor.

Si hay más de un modelo, o el coste depende del tamaño de la entrada (duración del audio, longitud del texto, resolución de la imagen), entonces saldo desde el primer commit, en una unidad suficientemente pequeña y con un tipo que no pierda precisión. Migrar después cuesta una tarde de esquema y, lo que es peor, explicar a los usuarios que lo que antes eran créditos ahora es dinero.

Con los límites, empezaría por el de por clave y no añadiría ninguno más hasta que un usuario me lo pidiera.

Lo que no sé todavía es si los créditos son un error técnico o una buena decisión de marketing con un coste técnico que conviene conocer. Creo que es lo segundo. Pero la próxima vez que empiece con créditos voy a guardar por debajo el coste real en céntimos de cada operación, aunque no se lo enseñe a nadie, para que cuando me toque migrar no tenga que reconstruir nada.