02Producte5 min

Crèdits, saldo, límits de despesa

En cinc productes he muntat el cobrament per ús igual: començo amb crèdits, acabo amb saldo en cèntims i límits per clau. Ja sé en quina etapa començar.

Per Salva Castells

Imatge de portada: Federico Candoni, CC BY-SA 4.0.
En aquest post

Quan un producte consumeix IA, el primer que munto sempre són crèdits. Un enter a la taula de perfil, se'n resta un per generació, i au. Ho he fet en una app de transcripció, en un SaaS de generació d'imatges i en el boilerplate amb què arrenco projectes. Les tres vegades vaig acabar al mateix lloc: un saldo en cèntims amb decimals i límits de despesa per clau d'API.

El recorregut és sempre el mateix, així que ja sé en quina etapa convé començar.

Per què els crèdits sempre semblen suficients al principi

Perquè ho són, mentre el cost per operació sigui constant. Una imatge costa una imatge. Un crèdit al dia gratis, vint amb la subscripció, i el check és una línia:

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

S'entén a la landing sense explicar res. I com a número enter és còmode: no hi ha arrodoniments, l'usuari veu "te'n queden 12" i no pregunta res més.

El primer avís que el model es queda curt no és tècnic, és de preu. En un projecte de transcripció cobrava per caràcters i dues setmanes després de llançar vaig apujar el preu per caràcter multiplicant-lo per sis respecte al cost del proveïdor, després de calcular quants caràcters té una frase de mitjana en anglès (uns 75) i en castellà (uns 90), i quantes frases sortien per paquet de 2 dòlars. Aquest càlcul no el fas al principi. El fas quan ja tens factures del proveïdor i veus que el marge no era el que et pensaves.

On es trenca així que el cost real entra en joc

Es trenca en el moment en què un producte té més d'un model. Si l'usuari pot triar entre un model de transcripció barat i un de precís, un crèdit deixa de significar res. O cobres el car com si fos barat i perds diners, o cobres tot a preu de car i el barat no té sentit.

I es trenca un altre cop amb dues bosses de crèdits. Al SaaS d'imatges vaig afegir crèdits diaris gratuïts a sobre dels de subscripció i aquell mateix dia vaig haver d'arreglar que no es pogués generar res: la comprovació continuava mirant només subCredits. L'arreglada va ser sumar les dues bosses abans de comparar.

const totalAvailableCredits = (profile?.dailyCredits || 0) + (profile?.subCredits || 0);

Sembla obvi llegint-ho. No ho és quan la bossa nova s'afegeix en un commit i la comprovació viu en un altre fitxer.

Què resol el saldo que els crèdits no resolien

El saldo és un número en cèntims que es descompta pel cost real de cada operació. Això permet barrejar models, proveïdors i unitats diferents (minuts d'àudio i tokens de text) al mateix compte.

El detall que no m'esperava: els cèntims enters no basten. Una transcripció curta amb un model barat costa una fracció de cèntim, i amb integer cada crida arrodonia a zero i el saldo no baixava mai. Vaig haver de migrar les columnes a coma flotant:

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í, diners en real. Per a un saldo prepagament que només es descompta i es recarrega em compensa: l'error d'arrodoniment és irrellevant comparat amb perdre l'import sencer per truncament. Si facturés a posteriori faria servir enters en la unitat més petita que existeixi.

El saldo canvia també el pricing: de "un pla" a tres (4,99, 14,99 i 49,99 al mes, expressats en hores de transcripció i tokens) més recàrregues soltes de 5, 10 i 25 euros. Per dins tot és el mateix: sumar cèntims.

El cas extrem: tres models de cobrament al mateix producte

A l'app de transcripció vaig arribar a tenir tres coses alhora, i no totes van sobreviure:

  • Saldo. El que queda. Si és zero o menys, no se serveix res.
  • Límit mensual de l'usuari. Un camp monthly_limit_cents amb 4,99 per defecte, editable des del compte.
  • Límit de despesa per clau d'API. spend_limit_cents més un període (daily, monthly, total), contra la suma dels logs d'ús d'aquella clau.

El límit mensual de l'usuari el vaig esborrar. ALTER TABLE "user_billing" DROP COLUMN "monthly_limit_cents" i fora el selector, l'acció de servidor i les traduccions en cinc idiomes. No aportava res: el saldo prepagament ja és el límit mensual. Un usuari que hi posa 20 euros no necessita, a més, dir que no vol gastar més de 20 euros. Era una pantalla més per explicar i una condició més per comprovar.

El que sí que es queda és el límit per clau, perquè aquí sí que hi ha un cas real: una clau ficada en un script que se't va de les mans. I hi ha un detall que vaig aprendre a la mala: cal rellegir la clau de la base de dades pel seu id abans de comparar, encara que ja tinguis l'objecte de l'autenticació. Si no, serveixes peticions contra un spendLimitCents caducat.

El guard cal posar-lo a tots els endpoints, no només als que gasten. Els de llistar models i presets també el duen, retornant 429. Si algú està a zero, que l'API sencera li digui que no, no només la part cara.

Com decideixo avui el dia u

Si el producte té una sola operació amb cost fix, crèdits. Si hi ha més d'un model, o el cost depèn de la mida de l'entrada, saldo en cèntims amb decimals des del primer commit. Migrar després costa una tarda d'esquema i un mes de correus explicant als usuaris que els seus crèdits ara són euros.

I una cosa més, a banda del model: el cupó de descompte del checkout es posa en una constant i es prova. Un dia vaig canviar el couponId de "-100 euros" a "-100%" al matí i ho vaig desfer a la tarda. Dos commits, el mateix fitxer, una línia.

Els crèdits són una decisió de màrqueting disfressada de decisió tècnica. El saldo és a l'inrevés.