Quando un prodotto consuma IA, la prima cosa che monto sono sempre i crediti. Un intero nella tabella del profilo, se ne sottrae uno per generazione, e via. L'ho fatto in un'app di trascrizione, in un SaaS di generazione di immagini e nel boilerplate con cui avvio i progetti. Tutte e tre le volte sono finito nello stesso posto: un saldo in centesimi con decimali e limiti di spesa per chiave API.
Il percorso è sempre lo stesso, quindi ormai so da quale tappa conviene partire.
Perché i crediti sembrano sempre sufficienti all'inizio
Perché lo sono, finché il costo per operazione è costante. Un'immagine costa un'immagine. Un credito al giorno gratis, venti con l'abbonamento, e il check è una riga:
if (!profile || profile.subCredits < estimatedCost) {
return NextResponse.json({ error: "Insufficient credits" }, { status: 402 });
}
Si capisce nella landing senza spiegare niente. E come numero intero è comodo: non ci sono arrotondamenti, l'utente vede "te ne restano 12" e non chiede altro.
Il primo segnale che il modello è troppo stretto non è tecnico, è di prezzo. In un progetto di trascrizione addebitavo per caratteri e due settimane dopo il lancio ho alzato il prezzo per carattere moltiplicandolo per sei rispetto al costo del provider, dopo aver calcolato quanti caratteri ha una frase in media in inglese (circa 75) e in spagnolo (circa 90), e quante frasi uscivano per pacchetto da 2 dollari. Quel calcolo non lo fai all'inizio. Lo fai quando hai già le fatture del provider e vedi che il margine non era quello che pensavi.
Dove si rompe appena entra in gioco il costo reale
Si rompe nel momento in cui un prodotto ha più di un modello. Se l'utente può scegliere tra un modello di trascrizione economico e uno preciso, un credito smette di significare qualcosa. O addebiti il caro come se fosse economico e perdi soldi, o addebiti tutto a prezzo del caro e quello economico non ha senso.
E si rompe di nuovo con due sacche di crediti. Nel SaaS di immagini ho aggiunto crediti giornalieri gratuiti sopra quelli dell'abbonamento e quello stesso giorno ho dovuto sistemare il fatto che non si potesse generare niente: il controllo continuava a guardare solo subCredits. La correzione è stata sommare le due sacche prima di confrontare.
const totalAvailableCredits = (profile?.dailyCredits || 0) + (profile?.subCredits || 0);
Sembra ovvio a leggerlo. Non lo è quando la sacca nuova si aggiunge in un commit e il controllo vive in un altro file.
Cosa risolve il saldo che i crediti non risolvevano
Il saldo è un numero in centesimi che si scala in base al costo reale di ogni operazione. Questo permette di mescolare modelli, provider e unità diverse (minuti di audio e token di testo) nello stesso conto.
Il dettaglio che non mi aspettavo: i centesimi interi non bastano. Una trascrizione corta con un modello economico costa una frazione di centesimo, e con integer ogni chiamata arrotondava a zero e il saldo non scendeva mai. Ho dovuto migrare le colonne a virgola mobile:
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ì, soldi in real. Per un saldo prepagato che si scala e si ricarica soltanto mi conviene: l'errore di arrotondamento è irrilevante rispetto a perdere l'importo intero per troncamento. Se fatturassi a posteriori userei interi nell'unità più piccola che esiste.
Il saldo cambia anche il pricing: da "un piano" a tre (4,99, 14,99 e 49,99 al mese, espressi in ore di trascrizione e token) più ricariche singole da 5, 10 e 25 euro. Dentro è tutto la stessa cosa: sommare centesimi.
Il caso estremo: tre modelli di addebito nello stesso prodotto
Nell'app di trascrizione sono arrivato ad avere tre cose insieme, e non tutte sono sopravvissute:
- Saldo. Quello che resta. Se è zero o meno, non si serve niente.
- Limite mensile dell'utente. Un campo
monthly_limit_centscon 4,99 di default, modificabile dall'account. - Limite di spesa per chiave API.
spend_limit_centspiù un periodo (daily,monthly,total), sulla somma dei log di utilizzo di quella chiave.
Il limite mensile dell'utente l'ho cancellato. ALTER TABLE "user_billing" DROP COLUMN "monthly_limit_cents" e via il selettore, la server action e le traduzioni in cinque lingue. Non portava niente: il saldo prepagato è già il limite mensile. Un utente che mette 20 euro non ha anche bisogno di dire che non vuole spendere più di 20 euro. Era una schermata in più da spiegare e una condizione in più da controllare.
Quello che resta è il limite per chiave, perché lì un caso reale c'è: una chiave finita in uno script che ti sfugge di mano. E c'è un dettaglio che ho imparato a mie spese: bisogna rileggere la chiave dal database per id prima di confrontare, anche se hai già l'oggetto dall'autenticazione. Altrimenti servi richieste contro uno spendLimitCents scaduto.
Il guard va messo su tutti gli endpoint, non solo su quelli che spendono. Anche quelli di elenco modelli e preset ce l'hanno, restituendo 429. Se qualcuno è a zero, che tutta l'API gli dica di no, non solo la parte cara.
Come decido oggi al giorno uno
Se il prodotto ha una sola operazione con costo fisso, crediti. Se c'è più di un modello, o il costo dipende dalla dimensione dell'input, saldo in centesimi con decimali dal primo commit. Migrare dopo costa un pomeriggio di schema e un mese di email per spiegare agli utenti che i loro crediti adesso sono euro.
E un'altra cosa, a parte il modello: il coupon di sconto del checkout si mette in una costante e si prova. Un giorno ho cambiato il couponId da "-100 euro" a "-100%" la mattina e l'ho annullato il pomeriggio. Due commit, lo stesso file, una riga.
I crediti sono una decisione di marketing travestita da decisione tecnica. Il saldo è il contrario.
