Als een product AI verbruikt, is het eerste wat ik bouw altijd credits. Een integer in de profieltabel, er gaat er één af per generatie, en gaan. Ik heb dat gedaan in een transcriptie-app, in een SaaS voor beeldgeneratie en in de boilerplate waarmee ik projecten start. Alle drie de keren kwam ik op dezelfde plek uit: een saldo in centen met decimalen en uitgavenlimieten per API-sleutel.
Het traject is altijd hetzelfde, dus inmiddels weet ik in welke fase je maar beter kunt beginnen.
Waarom credits in het begin altijd genoeg lijken
Omdat ze dat ook zijn, zolang de kosten per operatie constant zijn. Een afbeelding kost één afbeelding. Eén credit per dag gratis, twintig met het abonnement, en de check is één regel:
if (!profile || profile.subCredits < estimatedCost) {
return NextResponse.json({ error: "Insufficient credits" }, { status: 402 });
}
Je begrijpt het op de landingspagina zonder uitleg. En als geheel getal is het comfortabel: geen afrondingen, de gebruiker ziet "je hebt er nog 12" en vraagt niet verder.
Het eerste teken dat het model tekortschiet is niet technisch, het is prijsgerelateerd. In een transcriptieproject rekende ik per teken en twee weken na de lancering verhoogde ik de prijs per teken met een factor zes ten opzichte van de kosten van de provider, nadat ik had uitgerekend hoeveel tekens een zin gemiddeld heeft in het Engels (zo'n 75) en in het Spaans (zo'n 90), en hoeveel zinnen er uit een pakket van 2 dollar kwamen. Die berekening maak je niet aan het begin. Die maak je als je al facturen van de provider hebt en ziet dat de marge niet was wat je dacht.
Waar het breekt zodra de echte kosten meespelen
Het breekt op het moment dat een product meer dan één model heeft. Als de gebruiker kan kiezen tussen een goedkoop transcriptiemodel en een nauwkeurig model, betekent een credit niets meer. Of je rekent het dure af alsof het goedkoop is en verliest geld, of je rekent alles af tegen de dure prijs en dan heeft het goedkope geen zin.
En het breekt nog een keer met twee credit-potten. In de beeld-SaaS voegde ik gratis dagelijkse credits toe bovenop die van het abonnement en diezelfde dag moest ik repareren dat er niets meer gegenereerd kon worden: de controle keek nog steeds alleen naar subCredits. De fix was de twee potten optellen vóór de vergelijking.
const totalAvailableCredits = (profile?.dailyCredits || 0) + (profile?.subCredits || 0);
Het lijkt vanzelfsprekend als je het leest. Dat is het niet als de nieuwe pot in de ene commit wordt toegevoegd en de controle in een ander bestand leeft.
Wat het saldo oplost wat credits niet oplosten
Het saldo is een getal in centen dat wordt afgeschreven met de werkelijke kosten van elke operatie. Daarmee kun je modellen, providers en verschillende eenheden (minuten audio en teksttokens) op dezelfde rekening mengen.
Het detail dat ik niet had verwacht: hele centen zijn niet genoeg. Een korte transcriptie met een goedkoop model kost een fractie van een cent, en met integer rondde elke aanroep af naar nul en daalde het saldo nooit. Ik moest de kolommen migreren naar drijvende komma:
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;
Ja, geld in real. Voor een prepaid saldo dat alleen wordt afgeschreven en opgewaardeerd, loont dat voor mij: de afrondingsfout is verwaarloosbaar vergeleken met het hele bedrag verliezen door afkapping. Als ik achteraf zou factureren, zou ik gehele getallen in de kleinst bestaande eenheid gebruiken.
Het saldo verandert ook de pricing: van "één plan" naar drie (4,99, 14,99 en 49,99 per maand, uitgedrukt in transcriptie-uren en tokens) plus losse opwaarderingen van 5, 10 en 25 euro. Vanbinnen is het allemaal hetzelfde: centen optellen.
Het extreme geval: drie factureringsmodellen in hetzelfde product
In de transcriptie-app had ik op een gegeven moment drie dingen tegelijk, en niet alles heeft het overleefd:
- Saldo. Wat er over is. Als het nul of minder is, wordt er niets geleverd.
- Maandlimiet van de gebruiker. Een veld
monthly_limit_centsmet 4,99 als standaard, aanpasbaar vanuit het account. - Uitgavenlimiet per API-sleutel.
spend_limit_centsplus een periode (daily,monthly,total), tegen de som van de gebruikslogs van die sleutel.
De maandlimiet van de gebruiker heb ik verwijderd. ALTER TABLE "user_billing" DROP COLUMN "monthly_limit_cents" en weg met de selector, de serveractie en de vertalingen in vijf talen. Het voegde niets toe: het prepaid saldo is al de maandlimiet. Een gebruiker die 20 euro inlegt, hoeft er niet bij te zeggen dat hij niet meer dan 20 euro wil uitgeven. Het was één scherm extra om uit te leggen en één voorwaarde extra om te controleren.
Wat wel blijft is de limiet per sleutel, want daar is wel een echt geval: een sleutel in een script dat uit de hand loopt. En er is een detail dat ik op de harde manier leerde: je moet de sleutel opnieuw uit de database lezen op zijn id voordat je vergelijkt, ook al heb je het object al uit de authenticatie. Anders serveer je verzoeken tegen een verlopen spendLimitCents.
De guard moet op alle endpoints staan, niet alleen op die welke geld kosten. Die voor het opsommen van modellen en presets hebben hem ook, met een 429. Als iemand op nul staat, dan moet de hele API nee zeggen, niet alleen het dure deel.
Hoe ik het vandaag op dag één beslis
Als het product één operatie met vaste kosten heeft: credits. Als er meer dan één model is, of de kosten afhangen van de grootte van de invoer: saldo in centen met decimalen vanaf de eerste commit. Later migreren kost een middag schema en een maand e-mails om gebruikers uit te leggen dat hun credits nu euro's zijn.
En nog één ding, los van het model: de kortingscoupon van de checkout zet je in een constante en test je. Op een dag veranderde ik in de ochtend de couponId van "-100 euro" naar "-100%" en draaide het 's middags terug. Twee commits, hetzelfde bestand, één regel.
Credits zijn een marketingbeslissing vermomd als technische beslissing. Bij het saldo is het andersom.
