BlogMetering

How to build a credits system for your AI app (ledger design, rollover, refunds)

A user emails: "I had 40 credits this morning and now I have 12, and I only made one image." If your balance is a column you mutated, you cannot answer that email. Here is the ledger design that can.

Why credits at all

Your app does three expensive things: it generates images, it answers chat with an LLM, and it renders short video clips. Price each on its own axis and your pricing page becomes a spreadsheet - 50 images, 200k tokens, 90 video-seconds per month - that nobody can compare to a competitor or to their own usage. Credits collapse the mess into one currency: an image costs 5 credits, a thousand tokens cost 1, a video-second costs 20, and the plan is simply "500 credits a month." Two things make this more than cosmetics. First, it decouples your packaging from provider pricing - when you swap one image model for one that costs a third as much, you adjust an internal cost map and your plans, your Stripe products, and your pricing page do not move. Second, users already have a mental model for wallets: a number that goes down when they do things and tops up when they pay. The catch is that "a number that goes down" is exactly the part most teams build wrong, and the wrongness only shows up under concurrency and in support tickets.

The cardinal rule: a balance is not a column

The instinct is a credits column on the users table: read it, subtract, write it back. Resist it. A credit balance is a derived value - the SUM over an append-only ledger - and the ledger row is the unit of truth. Every grant is a positive row, every spend a negative row, every refund a positive row that points at the spend it reverses, and nothing is ever UPDATEd or DELETEd. The reasons compound. Auditability: when a user asks where their credits went, you read their rows back to them instead of shrugging at a mutated integer. Refund safety: undoing a charge is inserting a row, not racing an UPDATE against concurrent spends. Correctness: read-modify-write on a column loses updates the moment two requests interleave; an insert cannot clobber another insert. And analytics fall out for free - revenue per feature is a GROUP BY on reason.

CREATE TABLE credit_ledger (
  id         TEXT PRIMARY KEY,   -- lg_<nanoid>
  user_id    TEXT NOT NULL,
  delta      INTEGER NOT NULL,   -- +grant / -spend / +refund / -expiry
  reason     TEXT NOT NULL,      -- 'monthly_grant' | 'pack_purchase' | 'spend'
                                 -- | 'refund' | 'expiry' | 'admin_adjust'
  ref_id     TEXT,               -- idempotency key, or the debit a refund reverses
  expires_at TEXT,               -- grants only; NULL = never
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_ledger_user ON credit_ledger (user_id, created_at);

-- the balance is a query, not a column
SELECT COALESCE(SUM(delta), 0) AS balance
FROM credit_ledger WHERE user_id = ?;

Integer math only

Store credits as integers, and if money enters the picture anywhere, store it as integer cents. Floats accumulate representation error - after a few thousand debits of 0.1 credits your wallet reads 117.99999999999883 and your "balance >= cost" check starts failing for users who visibly have enough. There is no debugging that; there is only never doing it. If your pricing genuinely needs fractional credits - say a chat message costs 0.25 - do not reach for REAL or float. Multiply the unit by 100 and meter centi-credits: the message costs 25, the monthly grant is 50000, and the UI divides by 100 at render time. Display is the only layer allowed to know about decimals. The same rule applies when you map credits to money for refunds or proration: cents in the ledger, dollars only in the template. Every arithmetic operation in the spend path - cost lookup, multiplication by token count or seconds, the SUM itself - should be closed over integers, which also means rounding is an explicit, documented decision (Math.ceil on token costs, usually) instead of an accident of IEEE 754.

Grants, expiry, and what "rollover" actually means

Not all credits are equal. The 500 credits from a monthly allowance should expire when the cycle ends; the 1,000-credit pack a user bought should not, or should last a year. Model this as different grant rows with different expires_at values - never as separate balances. Two rules follow. Spend order: debits consume the soonest-expiring grant first (expiring-first FIFO), so a user with 100 allowance credits expiring Friday and 1,000 purchased credits burns the allowance first - anything else quietly steals value from them. Expiry: do not filter expired grants out of the balance query, because the spends that consumed them have no expiry and the sum goes wrong. Instead run a daily job that, for each grant past its expires_at, computes the unspent remainder under your FIFO rule and writes a negative expiry row for it. The balance stays a plain SUM with no WHERE clauses, and the expiry itself is visible in the user's history. This framing also dissolves the rollover debate: rollover is a grant policy, not a balance mutation. "No rollover" means this month's grant expires at cycle end; "rollover up to 2x" means at renewal you grant the new allowance and extend or re-issue the unspent remainder, capped. Either way you only ever insert rows.

The spend path is the race-condition path

Here is where the column-based design actually loses money. A user with 5 credits fires two image requests at once; both handlers read balance 5, both decide 5 >= 5, both subtract, and you have done 10 credits of work for 5 - or written -5 into the column. The check and the debit must be one atomic operation: a conditional insert whose WHERE clause re-evaluates the balance inside the same statement (or, in Postgres, a transaction with the user's rows locked). If the condition fails, zero rows are written and you reject the request before touching the AI provider. The second half is just as important: the AI call happens after the debit, and AI calls fail - timeouts, content filters, provider 500s. A failed call must trigger an automatic refund row referencing the debit, or your users pay for errors. If this shape feels familiar, it should: it is reserve/commit/release wearing a different hat. The conditional insert is reserve (a provisional debit), letting it stand after the call succeeds is commit, and the refund row is release. You also want a sweeper for the crash case - a debit whose request died before refunding needs a timeout-driven release, which is exactly why hosted versions of this pattern put a TTL on reservations.

The single-statement trick is atomic in SQLite because writes serialize. On Postgres with concurrent transactions, wrap the check and insert in a transaction that takes a per-user advisory lock, or use SERIALIZABLE - a plain READ COMMITTED transaction still races.
async function spendCredits(userId: string, cost: number, refId: string) {
  // check + debit in ONE statement - no read-then-write gap
  const res = await db.execute({
    sql:
      "INSERT INTO credit_ledger (id, user_id, delta, reason, ref_id) " +
      "SELECT ?, ?, ?, 'spend', ? " +
      "WHERE (SELECT COALESCE(SUM(delta), 0) FROM credit_ledger " +
      "       WHERE user_id = ?) >= ?",
    args: [newId('lg'), userId, -cost, refId, userId, cost],
  });
  if (res.rowsAffected === 0) throw new InsufficientCreditsError();
}

async function refund(userId: string, debitId: string, amount: number) {
  // a refund is a new row pointing at the debit it reverses
  await db.execute({
    sql:
      "INSERT INTO credit_ledger (id, user_id, delta, reason, ref_id) " +
      "VALUES (?, ?, ?, 'refund', ?)",
    args: [newId('lg'), userId, amount, debitId],
  });
}

Idempotency: retries must not double-charge

Networks retry. Your client retries on timeout, your queue redelivers, your user double-clicks the generate button. If each attempt inserts a fresh debit, a flaky connection charges someone three times for one image - and unlike a duplicate row in an analytics table, this one shows up as missing money. The fix costs one column and one index: every debit carries a client-generated idempotency key in ref_id, and a unique index on (user_id, ref_id) WHERE reason = 'spend' makes the second insert a no-op (INSERT ... ON CONFLICT DO NOTHING, then treat conflict as success and return the original result). The key should be generated where the user action originates - one key per generate-click, reused across retries of that click - not per HTTP attempt, or it protects nothing. Refunds get the same discipline from the other direction: a refund row stores the id of the debit it reverses in its own ref_id, and a unique index on refunded debit ids guarantees a debit is reversed at most once even if the failure handler runs twice. Between the two rules, every credit movement in the system is traceable to exactly one cause and can happen exactly once.

Pricing the actions

Somewhere a function has to answer "what does this event cost in credits," and that function must live on the server - a cost shipped in the client request is a cost the user sets. Keep it a small, boring, static map from event type to integer cost, with metadata as the only input that modulates it: the premium image model costs 12 where the default costs 5, token-based events charge per 1k tokens rounded up, video charges per second. The part teams get wrong is time. Provider prices change, your margins change, and you will revise this map - but the ledger rows already written were priced under the old map, and they must stay true. So version the pricing: tag each spend row (or the map itself) with a version, add v4 as a new file when prices change, and never retro-edit v3. When a user disputes a January charge, you can reproduce it with January's prices.

// pricing/v3.ts - frozen once shipped; price changes become pricing/v4.ts
export const PRICING_VERSION = 3;

export function creditCost(e: MeteredEvent): number {
  switch (e.type) {
    case 'image.generate':
      return e.meta.model === 'flux-pro' ? 12 : 5;
    case 'chat.completion':
      return Math.ceil(e.meta.totalTokens / 1000); // 1 credit per 1k tokens
    case 'video.render':
      return e.meta.seconds * 20;
  }
}

What this costs to own

None of the pieces above is hard in isolation, which is why every team underestimates the whole. Budget a real week for the first working version, and then accept that you have adopted a small financial system with a forever-maintenance contract - because unlike most internal tools, this one corrupts money when it drifts. The standing inventory:

  • The ledger itself: schema, indexes, and the balance query everyone must use instead of caching a number somewhere convenient.
  • The atomic spend path with automatic refunds on AI failure, plus the sweeper for debits orphaned by crashed requests.
  • The expiry job: FIFO remainder math, a daily run, and alerting for when it silently stops.
  • Spend-order rules that stay correct as you add grant types (allowance, pack, promo, referral bonus).
  • Admin tooling: support will need "adjust balance" within the first month, and it must write admin_adjust rows with an operator id - not poke the database.
  • A per-user history UI, because "where did my credits go" is the top billing ticket and the ledger is only useful if someone can read it.

If you only want the enforcement half

Everything above is buildable with a weekend, a database, and discipline - and if credits are core to your product, build it. But notice that the dangerous parts are the enforcement mechanics, not your business rules. Vevee's metering covers exactly that half: limit groups with quotas in count or cents stand in for the wallet, reserve(userId, eventType) is the atomic provisional debit, commit and release are the finalize and the refund, unconfirmed reservations auto-release after 60 seconds so crashed requests cannot leak, and idempotency is handled for you. You keep your own grant logic and pricing map; the race conditions stop being your problem.

See your own numbers in two minutes.Add the SDK and your users, costs and revenue fill in on the next request.
Join waitlist

Keep reading

ShareCopy a link to this post.

Set your first limit in about five minutes.

50k operations free every month. No card required.
Join waitlist