Back to Explore
GuideVibeFix 编辑部Updated Oct 14, 2026

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.

Charge lifecycle flowchart for one AI API call: idempotency check, row lock, pre-auth hold, execution, then settle on success or rollback on failure

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 reversesTxId pointing 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_balances row, check available - 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:

Charge lifecycle of one AI API call: idempotency check, row lock, pre-auth hold, execution, settle on success or rollback on failure

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=true so 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 − held is 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:

Double-entry comparison: normal consumption debits the user account and credits realized revenue; refund write-back credits the user account, debits refund expense, and reverses the original transaction

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 < 0 fires instantly — all new requests from that user get 402 with an explicit INSUFFICIENT_CREDITS code (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.

Views 0Comments 0

Comments (0)

ME
0/1000
Loading comments...
Browse projectsPublish your project

Related articles

OIDC/SAML login sequence diagram: eight steps across user, app (SP), and corporate IdP, with the OIDC path in blue and SAML in purple
Guide
"Do You Support SSO?" Upgrading Your Vibe Project from Single-User to B2B Team-Ready

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.

Payments & MonetizationTeam CollaborationBackend Engineering
A padlock on a laptop keyboard, symbolizing web security protection
Guide
Don't Ship Your Vibe Project Naked: A Web Security Baseline Playbook (CSP, CSRF, XSS, SQL Injection)

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.

Backend EngineeringIndie DevelopmentFrontend Engineering
Approval state machine and sequence diagram: pending approval branches to approved, rejected or timed out, all ending at audited
Guide
Don't Let the Agent Drop the Database: Human-in-the-Loop Approval Flows for High-Risk Operations in Vibe Projects

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.

Developer WorkflowAI CodingSecurity & Privacy