When a product burns AI, the first thing I always build is credits. An integer in the profile table, subtract one per generation, done. I've done it in a transcription app, in an image generation SaaS and in the boilerplate I start projects from. All three times I ended up in the same place: a balance in cents with decimals and per-API-key spend limits.
The path is always the same, so by now I know which stage to start at.
Why credits always look like enough at the start
Because they are, as long as the cost per operation is constant. One image costs one image. One free credit a day, twenty with the subscription, and the check is one line:
if (!profile || profile.subCredits < estimatedCost) {
return NextResponse.json({ error: "Insufficient credits" }, { status: 402 });
}
It reads fine on the landing page with no explanation. And as an integer it's convenient: no rounding, the user sees "12 left" and doesn't ask anything else.
The first sign the model is too thin isn't technical, it's pricing. In a transcription project I charged per character, and two weeks after launch I raised the price per character to six times the provider's cost, after working out how many characters an average sentence has in English (about 75) and in Spanish (about 90), and how many sentences came out of a $2 pack. You don't do that math at the start. You do it once you have provider invoices and see the margin wasn't what you thought.
Where it breaks as soon as real cost enters the picture
It breaks the moment a product has more than one model. If the user can pick between a cheap transcription model and an accurate one, a credit stops meaning anything. Either you charge the expensive one as if it were cheap and lose money, or you charge everything at the expensive price and the cheap one makes no sense.
And it breaks again with two credit buckets. In the image SaaS I added free daily credits on top of subscription credits, and that same day I had to fix that nothing could be generated: the check still looked only at subCredits. The fix was adding both buckets before comparing.
const totalAvailableCredits = (profile?.dailyCredits || 0) + (profile?.subCredits || 0);
Looks obvious reading it. It isn't when the new bucket is added in one commit and the check lives in another file.
What a balance solves that credits didn't
A balance is a number in cents, debited by the real cost of each operation. That lets you mix models, providers and different units (minutes of audio and text tokens) in the same account.
The detail I didn't expect: whole cents aren't enough. A short transcription with a cheap model costs a fraction of a cent, and with integer every call rounded to zero and the balance never went down. I had to migrate the columns to floating point:
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;
Yes, money in real. For a prepaid balance that only gets debited and topped up it's worth it: the rounding error is irrelevant compared to losing the whole amount to truncation. If I billed after the fact I'd use integers in the smallest unit that exists.
The balance also changes pricing: from "one plan" to three (4.99, 14.99 and 49.99 a month, expressed in hours of transcription and tokens) plus one-off top-ups of 5, 10 and 25 euros. Underneath it's all the same thing: adding cents.
The extreme case: three billing models in the same product
In the transcription app I ended up with three things at once, and not all of them survived:
- Balance. What's left. If it's zero or less, nothing gets served.
- User monthly limit. A
monthly_limit_centsfield defaulting to 4.99, editable from the account. - Per-API-key spend limit.
spend_limit_centsplus a period (daily,monthly,total), against the sum of that key's usage logs.
I deleted the user monthly limit. ALTER TABLE "user_billing" DROP COLUMN "monthly_limit_cents" and out went the selector, the server action and the translations in five languages. It added nothing: the prepaid balance already is the monthly limit. A user who puts in 20 euros doesn't also need to say they don't want to spend more than 20 euros. It was one more screen to explain and one more condition to check.
What does stay is the per-key limit, because there's a real case there: a key dropped into a script that gets out of hand. And there's a detail I learned the hard way: you have to re-read the key from the database by its id before comparing, even if you already have the object from authentication. Otherwise you serve requests against a stale spendLimitCents.
The guard goes on every endpoint, not just the ones that spend. The model and preset listing endpoints have it too, returning 429. If someone is at zero, the whole API should say no, not just the expensive part.
How I decide on day one now
If the product has a single operation with a fixed cost, credits. If there's more than one model, or the cost depends on input size, balance in cents with decimals from the first commit. Migrating later costs an afternoon of schema work and a month of emails explaining to users that their credits are now euros.
And one more thing, aside from the model: the checkout discount coupon goes in a constant and gets tested. One day I changed the couponId from "-100 euros" to "-100%" in the morning and reverted it in the afternoon. Two commits, same file, one line.
Credits are a marketing decision dressed up as a technical one. A balance is the other way round.
