Metered Billing in the AI Era: Designing and Reconciling a Credits Ledger
Your users' credits are not a balance column — they're a journal. This guide builds a double-entry credits ledger for AI SaaS: immutable journal schema you can copy, idempotent deduction with row locks, two-phase pre-auth/settle for unpredictable token usage, refund and expiry write-backs, a three-way daily reconciliation, negative-balance circuit breakers, and an anti-abuse checklist.

First, separate the three ledgers: which one is this guide about?
The most common mistake newcomers make with AI SaaS billing is mashing three ledgers into one. Take 30 seconds to split them apart — everything that follows builds on this separation:
- Ledger one: the payment rail — how much money Stripe collected. This is the "money coming in" ledger, owned by the payment gateway. Our site already has a Stripe payments guide covering checkout, webhooks, and dunning; this guide won't repeat it.
- Ledger two: the token cost ledger — how much I spent calling models. This is the "money going out" ledger, owned by cost accounting. Our token cost accounting guide covers that end: counting prompt/completion tokens and computing gross margin.
- Ledger three: the billing ledger — how many credits the user has left. This is "the user's money." It never equals what Stripe collected (there are grants, expirations, refunds) and never equals token cost (your pricing multiplier sits in between). This guide is only about ledger three: designing a credits ledger, deducting from it, writing refunds back, and reconciling it.
The three ledgers must tie out at every month-end: Stripe's actual receipts ≈ the face value of paid credits issued; token cost ÷ pricing ≈ credits consumed; billing-ledger balances = recomputation from the journal. Anywhere they disagree, the difference comes out of your real pocket. Let's start with why a single balance column is guaranteed to blow up.
1. Three ways a balance column dies
Almost every vibe project's billing starts with this line of code:
await db.user.update({
where: { id: userId },
data: { credits: { decrement: cost } },
});
Looks fine — Prisma's decrement is even atomic. But real-world blowups never happen on this line. They happen around it:
Death one: concurrent overdraft. Atomic decrement keeps the number from corrupting, but it can't enforce "is there enough to deduct?" A user with 100 credits fires 10 requests at once: each reads the balance (100, looks fine), each decrements 120 — Prisma won't stop you, and the balance lands at -1100. For AI products this is near-certain: five tabs hitting "generate" at once, or a script hammering your API. Every over-deducted credit is real money you pay the model vendor.
Death two: refunds never written back. A Stripe refund succeeds, the webhook reaches your server, your handler refunds the money but never adds the credits back — user complains. Or Stripe retries the webhook three times (it retries for 3 days), your handler isn't idempotent, and the credits get added three times — a direct loss. The sneakier case is partial refunds: half the money refunded, how many credits go back? Without journal entries, you're guessing.
Death three: month-end numbers that don't tie out. Stripe says it collected $12,400 this month; your ledger says it issued 1,310,000 credits. At 100 credits = $1, that should map to $13,100. Where did the $700 go? Grants? Expirations? Refunds? Overdrafts? With no immutable journal, you stare at three numbers, write it off — and the write-off itself leaves no record, so next month is worse.
All three share one root cause: treating "balance" as the truth, when balance is only a cache of the truth. The truth is the journal of every movement. The fix is a 500-year-old accounting invention: double-entry bookkeeping.
2. A double-entry ledger: balances are computed, not stored
The whole design in three sentences: every credits movement is one immutable journal entry; every business event writes a balanced debit/credit pair; the balance column is just a snapshot of the journal — lose it and you can recompute it.
Three tables. Here's a Prisma schema you can copy directly:
model LedgerEntry {
id String @id @default(uuid())
userId String
// entries of one business event share a txId; debits must equal credits
txId String
entryType LedgerEntryType // TOPUP | CONSUME | AUTH_HOLD | AUTH_RELEASE
// | SETTLE | AUTH_REVERSAL | REFUND
// | EXPIRE | ADJUST
direction Direction // DEBIT | CREDIT
// integer, smallest unit (1 = 1 credit). No floats, ever
amount BigInt
// user's available-balance snapshot at write time, handy for audits
balanceAfter BigInt
// idempotency key, unique constraint. Duplicate writes conflict and return
idempotencyKey String @unique
// reversal entries point at the transaction being reversed
reversesTxId String?
meta Json? // order id, model name, token counts, etc.
createdAt DateTime @default(now())
@@index([userId, createdAt])
@@index([txId])
// NOTE: no business code path ever UPDATEs or DELETEs this model
}
model CreditBalance {
userId String @id
available BigInt @default(0) // spendable
held BigInt @default(0) // frozen by pre-authorizations
version Int @default(0) // for optimistic locking (see §3)
updatedAt DateTime @updatedAt
}
model IdempotencyKey {
key String @id // the client-sent Idempotency-Key
userId String
status String // PROCESSING | SUCCEEDED | FAILED
response Json? // cached success result, returned on replay
createdAt DateTime @default(now())
expiresAt DateTime // safe to clean up after 24h
}
enum LedgerEntryType {
TOPUP
CONSUME
AUTH_HOLD
AUTH_RELEASE
SETTLE
AUTH_REVERSAL
REFUND
EXPIRE
ADJUST
}
enum Direction {
DEBIT
CREDIT
}
Direction conventions (for the user-side billing ledger only — the company's cash/deferred-revenue lives in the accounting system, never in this table; that's also why Figure 2 carries the red warning at the bottom):
- Top-up TOPUP: credit the user's credits account (+1000 for the user) + debit "prepaid credits liability" 1000. Balanced.
- Consumption CONSUME: debit the user's credits account 100 + credit "realized revenue" 100. Balanced.
- Refund REFUND: credit the user's credits account 100 (red-ink reversal) + debit "refund expense" 100, with
reversesTxIdpointing at the original consumption. The original entries stay untouched, character for character.
Why insist on "debits must equal credits"? Because it buys you a mathematical check you can run every night: SELECT txId FROM ledger_entries GROUP BY txId HAVING SUM(CASE WHEN direction='DEBIT' THEN amount ELSE -amount END) != 0 — any row returned means a bookkeeping bug in your code. An order of magnitude more effective than digging through logs after the fact.
3. Idempotent deduction: kill replays first, then races
A deduction pipeline has two enemies: retries (client timeout resends, Stripe webhook redeliveries) and concurrency (one user deducting from many requests at once). The order matters: make retries safe first, then make concurrency correct.
Against retries: unique idempotency-key constraint. The client generates a UUID per call (UUID v7 is recommended — time-ordered, index-friendly) and sends it in the Idempotency-Key header. The server's first step is to INSERT that key; a unique-constraint conflict means "already processed" → return the cached response. Key lifetime: 24 hours covers nearly every retry storm; clean up afterward so the table doesn't grow forever.
Against concurrency: row-lock selection guide.
- Pessimistic lock (SELECT … FOR UPDATE): lock the user's
credit_balancesrow, checkavailable - held >= estimated cost, return 402 immediately if short. Right for AI calls — the pipeline already calls an external model API, so the lock is held a while, but correctness wins. Set lock-wait timeout to 5 seconds; on timeout return 429 and let the client back off exponentially instead of queueing forever. - Optimistic lock (version column): right for purely internal, sub-second deductions with no external calls (e.g. per-query metering). Retry up to 3 times on conflict; three conflicts in a row means that user is being hammered — return 429.
One-line rule: external API call in the pipeline → pessimistic lock; pure-DB ops at high QPS → optimistic lock. 99% of AI vibe projects are the former. Don't pick optimistic locking "for performance" and then pay it all back at reconciliation time.
The core charge function (TypeScript + Prisma, adapt freely):
async function chargeUser(userId: string, idemKey: string, estCost: bigint) {
return await db.$transaction(async (tx) => {
// 1. idempotency: return cached result if key exists
const existing = await tx.idempotencyKey.findUnique({ where: { key: idemKey } });
if (existing?.status === 'SUCCEEDED') return existing.response;
if (existing?.status === 'PROCESSING') throw new ConflictError('request in flight, poll later');
await tx.idempotencyKey.create({
data: { key: idemKey, userId, status: 'PROCESSING',
expiresAt: new Date(Date.now() + 86400_000) },
});
// 2. row lock + balance check (frozen amounts count as occupied)
const bal = await tx.$queryRaw<CreditBalance[]>`
SELECT * FROM "CreditBalance" WHERE "userId" = ${userId} FOR UPDATE`;
const row = bal[0];
if (!row || row.available - row.held < estCost) {
throw new InsufficientCreditsError('insufficient credits');
}
// 3. write pre-auth entries + freeze (see §4 two-phase flow)
// ... auth hold entries ...
return { ok: true };
}, { timeout: 8000 });
}
Note the 8-second transaction timeout: row lock + journal writes must live in one transaction. On timeout everything rolls back — never leave a "locked but unwritten" half state.
4. Pre-auth / settle in two phases: the full charge lifecycle of one AI call
AI billing differs from e-commerce checkout in one fundamental way: you don't know the cost before the call. A 200-token prompt can come back as 200 tokens or 8,000. Both naive approaches hurt: deduct the full cap upfront and refund the difference (refund paths multiply into chaos), or deduct after the call (overdrafts under concurrency — and the model vendor charges you even when your call fails).
The correct pattern is the decades-old hotel/car-rental approach: pre-authorize, then settle — freeze an estimate, then true-up against actual usage. The diagram below shows the full flow; every step maps to a journal entry pair:

Figure 1: the full charge lifecycle of one AI API call. All 7 steps map to this section's code flow: idempotency keys stop replays, row locks stop concurrent overdrafts; every failure path rolls back via appended reversal entries — never UPDATE or delete written entries.
Estimating the hold: estimate = promptTokens × inputPrice + maxTokens × outputPrice × safety factor 1.2. The 1.2 factor is empirical: it covers tokenizer variance and stale price tables without freezing so much that users feel their money was swallowed. Be honest in the UI: spendable balance = available − held, with frozen amounts labeled explicitly — otherwise support tickets will teach you manners.
Three edge cases belong in code, not in "later":
- Streaming disconnect mid-output: settle on tokens actually returned (write SETTLE), release the rest (write AUTH_RELEASE). Don't skip the charge because "it didn't finish" — the model vendor billed you for actual tokens.
- Timeout / failure: write AUTH_REVERSAL to release the full hold. Distinguish "model returned an error" from "network timeout, outcome unknown": for the latter, release the hold but tag
meta.needsReview=trueso the reconciliation job can verify whether the vendor actually billed that call. - Concurrent pre-auths exceeding the balance: when the third concurrent request sees
available − heldis short, return 402 immediately — don't queue behind the earlier ones. AI calls run for tens of seconds; queueing is just a slower avalanche.
5. Write-back rules: refunds, expirations, and adjustments
Top-ups and consumption are the happy path; ledger quality shows in the reverse flows. One rule governs them all: every reverse movement appends a pair of reversing entries — never modify the originals, never UPDATE the balance directly. The diagram below puts normal consumption and refund write-back side by side:

Figure 2: double-entry comparison. Left: consuming 100 credits — debit user account, credit realized revenue. Right: refunding 100 credits — credit user account, debit refund expense, with reverses_tx_id pointing back at the original transaction. Each pair balances on its own; the originals stay untouched.
Refund write-back SOP: Stripe's charge.refunded webhook arrives → use the Stripe event id as the idempotencyKey (naturally idempotent across 3 days of retries) → write the REFUND pair (credit user / debit refund expense) → update the balance snapshot. Partial refunds return proportional credits: refund 50% of the money, return 50% of the credits — never the whole grant, or users learn that "buy 1000, refund half, keep 1000 credits at half price."
Expiration policy decision table (the piece solo teams most often skip):
- Never expire = a black hole on the balance sheet. Unused credits are your liability (deferred revenue) in accounting terms; infinite accumulation buys you an extra page of explanations in every fundraise diligence. More practically: zombie accounts from three years ago still holding 500 credits that you daren't delete — and not deleting them is the liability.
- Recommended policy: promo/granted credits expire in 30 or 90 days, deducted first-in-first-out; paid credits expire on a 12-month rolling window (each top-up refreshes the expiry clock on the whole paid balance — simple to implement, easy for users to understand). Write an EXPIRE entry pair on expiry (debit user / credit expiration gains) and send reminder emails 7 days and 1 day before — those emails get shockingly high open rates and double as a reactivation channel for dormant users.
Compensating adjustments: manual credit grants by support (e.g. outage apologies) must go through ADJUST entries with mandatory meta.reason and operator identity, sampled in monthly audits. No "just tweak the balance in the admin panel" — the backdoor you open for support today is tomorrow's loss vector.
6. Month-end reconciliation: making the three ledgers tie out
The prettiest ledger is worthless without reconciliation. Run the reconciliation job daily at small hours (not at month-end) with three check groups:
Check one: Stripe receipts vs paid credits issued. Sum the day's Stripe charge.succeeded amounts (minus charge.refunded), convert to credits at your pricing, and compare against the day's ledger entryType=TOPUP AND meta.source='stripe' issuance. Alert past 0.5% divergence — the usual root cause is dropped webhook events (those minutes your server was returning 500).
-- Check one: one day of Stripe top-ups vs ledger issuance (PostgreSQL)
WITH stripe AS (
SELECT date_trunc('day', created)::date AS d,
SUM(amount_captured - amount_refunded) AS cents
FROM stripe_charges_local -- your persisted webhook detail table
WHERE created >= now() - interval '1 day'
GROUP BY 1
),
ledger AS (
SELECT date_trunc('day', "createdAt")::date AS d,
SUM(amount)::bigint AS credits
FROM "LedgerEntry"
WHERE "entryType" = 'TOPUP' AND "meta"->>'source' = 'stripe'
AND "createdAt" >= now() - interval '1 day'
GROUP BY 1
)
SELECT s.d, s.cents, l.credits,
(l.credits - s.cents * 10) AS diff_credits -- assumes $1 = 1000 credits; adjust
FROM stripe s FULL JOIN ledger l USING (d)
WHERE abs(l.credits - s.cents * 10) > s.cents;
Check two: token cost vs credits consumed. Take the model vendors' invoices (token-metered, from OpenAI/Anthropic), multiply by your pricing multiplier into "credits that should have been collected," and compare with the day's ledger CONSUME+SETTLE totals. A single-day margin swing beyond 5 points usually means your price table missed a model price cut (or a new model's output-token price was misconfigured) — that's real money leaking.
Check three: balance snapshots vs journal recomputation. SUM(ledger credits − debits) == credit_balances.available + held, run both per-user sampled and whole-table aggregated. Alert on a 1-credit difference — snapshot drift means some code path bypassed the ledger and touched balances directly, which is a P0-grade code incident.
Graded response: P0 (money disagrees: check one/two divergence) → pause automatic top-up issuance, human on it, closed the same day; P1 (snapshot drift) → recompute snapshots from the journal and lock down the offending code path; P2 (rounding, timezone edges) → log in the variance ledger, review monthly. The variance handling itself gets ADJUST entries — adjustments leave a trail too.
7. Negative-balance circuit breaker: losses are fine, silent losses are not
Even with airtight design, negative balances happen: actual usage exceeding the pre-auth on long streaming outputs, refunds against long-spent consumption, limit-case races in the concurrency window. The goal isn't "zero negative balances" — it's making every negative balance scream immediately.
- The tripwire:
available < 0fires instantly — all new requests from that user get 402 with an explicitINSUFFICIENT_CREDITScode (never a 500, never make the user guess). In-flight requests finish and settle normally, avoiding the twin worst cases of "paid the vendor but collected nothing" and "collected but cut the service." - Escalation: accounts still negative after 24 hours without top-up drop to read-only (export data yes, new tasks no) — keep the door open; the ops console gets a "write off bad debt" button that zeroes the balance via an ADJUST entry with mandatory reason.
- Breaker thresholds: cap per-user negative balance at −500 credits or a per-user daily spend cap. Negative balances past the threshold auto-escalate to P0 — legitimate users never get there; whoever does is either a bug or abuse.
Anti-pattern warning: never write "auto-grant 100 credits when the balance runs dry so the flow completes." That kindness bypasses every control and gives abuse scripts infinite refills — and your model-vendor invoice will remember every cup.
8. Anti-abuse checklist: credits are cash in an attacker's eyes
To abusers, credits are indistinguishable from cash: an AI app granting 500 credits on signup is "a $5 API allowance for registering." Run this checklist before launch:
- Bulk-registration detection: more than 5 signups in 24h from one IP / device fingerprint → granted credits drop to 0 or enter manual review. False-positive rate is manageable: real users don't register five accounts a day.
- Delayed referral payouts: referral credits vest after 7 days and only if the invitee shows genuine usage. Otherwise it's infinite self-dealing.
- Separate trial and paid pools: track them separately (
meta.pool='trial'|'paid'), deduct trial first. Trial pools expire fast with strict rules and never pollute paid-pool reconciliation. - Usage-spike alerts: one user burning 10× their 30-day daily average, or one key calling from many IPs at high frequency → freeze the key and alert. Legitimate usage curves are smooth; vertical takeoffs are scripts 99% of the time.
- Non-transferable, non-withdrawable: in the terms of service and in code — don't ship a transfer endpoint in the first place. The day transfers exist is the day a secondary credits market opens.
9. Pre-launch checklist
- Confirm no business code UPDATEs or DELETEs LedgerEntry (grep it; delete any you find).
- Every entryType has at least one test entry, and the in-tx debit/credit balance check passes.
- Concurrency test: 50 concurrent deductions from one user → final balance = start − 50 × unit price, not one credit off.
- Idempotency test: same Idempotency-Key sent 10 times → charged once, all 10 return identical results.
- Full Stripe webhook test-mode run: charge.succeeded, charge.refunded, charge.dispute end to end, event-id idempotency verified.
- Reconciliation job runs 7 days on staging with zero P0 divergence across all three checks.
- Expiry reminder email templates ready (7-day and 1-day).
- Negative-balance breaker actually tripped once on staging; the alert channel has a confirmed human watching.
- Manual adjustments in the ops console go through ADJUST entries with mandatory reason and access control.
Closing: a ledger is a trust machine
Users convert money into a number inside your system — an act built on trust: that you deduct correctly, refund reliably, and account clearly. Double-entry bookkeeping, idempotent deduction, pre-auth settlement, refund write-back, daily reconciliation — the five-piece set sounds "heavy," but each piece maps to a loss incident that really happened. For solo teams my advice is: create the ledger_entries table on day one, even if it only has TOPUP and CONSUME types at first. Table schemas are cheap; rebuilding a ledger is expensive — with 100k users, no one can backfill the journal for you.
Further reading: the money-in side in our Stripe payments guide, cost accounting in token cost accounting — the three together cover the complete money trail of an AI SaaS.
Comments (0)
Related articles

Five must-answer items from the enterprise security questionnaire; OIDC vs SAML decision table with a SAML trigger formula; a copy-pasteable Prisma schema for the org-team-member model; SP-initiated login, JIT provisioning, domain claim, instant revocation; SCIM trade-offs, a 12-item go-live checklist with questionnaire answers; SSO pricing strategy.

AI writes code fast but never writes secure code by default: running locally is not the same as being safe in production. This hands-on playbook fills the web security baseline for your vibe project — CSP headers, CSRF tokens, XSS defense, SQL parameterization, the cookie triple, dependency supply-chain gates, plus a copy-ready 10-item pre-launch acceptance checklist.

The more autonomous the agent, the larger the blast radius. A "human nods before it runs" gate on deletions, refunds, broadcasts, and permission changes is the cheapest insurance. This guide gives you a risk×reversibility quadrant, a minimal ticket schema, deny-by-default timeouts, audit trails, and an emergency lane — shippable in a weekend.