▤ blog // Article

cat article.md
AI · Backend · Jul 9, 2026 9 min read

Building a Credit-Based Billing System for AI SaaS Apps

How to design token-based billing for LLM-powered SaaS products — plan tiers, cost math, and the edge cases that break production.

By Abubakkar Sajid · Full Stack Developer

Most tutorials on AI SaaS billing stop at "charge per API call." That breaks the moment you have multiple LLM providers, embedding costs, and tiered plans with different markup ratios. This guide covers the actual architecture — and the production bugs I found and fixed — while building a credit system for a RAG (Retrieval-Augmented Generation) application.

Why Per-Call Billing Doesn't Work

LLM cost isn't flat. A single request can consume:

  • Input tokens – priced differently across models (e.g., Gemini Flash-Lite vs. GPT-4o)
  • Output tokens – usually 2–4x the cost of input tokens
  • Embedding tokens – separate pricing tier for RAG/vector search calls
  • Retries and function calls – multi-turn tool use multiplies token consumption per user action

Charging a flat "1 credit per request" either underprices heavy users or overprices light ones. The fix is a token-to-credit conversion layer sitting between your provider bill and your user-facing pricing.

1. Core Schema Design

Track usage at the token level, not the request level:

// models/UsageLog.js
const usageLogSchema = new mongoose.Schema({
  userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, index: true },
  provider: { type: String, enum: ["gemini", "openai", "claude"], required: true },
  model: { type: String, required: true },
  inputTokens: { type: Number, required: true },
  outputTokens: { type: Number, required: true },
  embeddingTokens: { type: Number, default: 0 },
  creditsCharged: { type: Number, required: true },
  createdAt: { type: Date, default: Date.now, index: true }
});

usageLogSchema.index({ userId: 1, createdAt: -1 });

2. The creditConfig.js Pattern (and Its Bugs)

Centralize your pricing logic in one config file — never scatter cost math across routes. Here's the pattern, along with five bugs that commonly slip into production versions of this exact file:

// config/creditConfig.js
const PLAN_LIMITS = {
  free: { monthlyCredits: 100, maxCreditsPerRequest: 5 },
  pro: { monthlyCredits: 5000, maxCreditsPerRequest: 50 },
  enterprise: { monthlyCredits: Infinity, maxCreditsPerRequest: Infinity }
};

const TOKENS_PER_CREDIT = {
  free: 1000,   // 1 credit = 1000 tokens
  pro: 1500,    // cheaper per-token rate at higher tier
  enterprise: 2000
};

function tokensToCredits(totalTokens, plan) {
  const ratio = TOKENS_PER_CREDIT[plan] ?? TOKENS_PER_CREDIT.free;
  return Math.ceil(totalTokens / ratio);
}

module.exports = { PLAN_LIMITS, TOKENS_PER_CREDIT, tokensToCredits };

Bugs to check for in your own implementation:

  1. Negative env var handling – if MONTHLY_CREDITS_OVERRIDE is read from process.env without validation, a misconfigured negative value silently grants unlimited usage instead of throwing.
  2. Infinity serializationInfinity doesn't survive JSON.stringify() (becomes null). If plan limits are cached in Redis or sent to the frontend, enterprise limits appear as null instead of unlimited.
  3. Mutable plan budgets – if PLAN_LIMITS objects are passed by reference and mutated per-request (e.g., decrementing a shared object instead of a per-user copy), one user's usage can corrupt another's limit.
  4. Non-monotonic tokens-per-credit ratio – pricing that gets cheaper per token at higher tiers (as shown above) needs to be intentional. If it's accidental, downgrading users mid-cycle can make their remaining credits worth less than expected, causing support tickets.
  5. Rounding direction – always round token-to-credit conversion up (Math.ceil), never down. Rounding down means you absorb the cost of partial credits at scale.

3. Enforcing Limits Before the LLM Call

Check and reserve credits before calling the provider API — not after — or a slow user can burn through their limit with concurrent requests before the deduction lands:

// middleware/checkCredits.js
const User = require("../models/User");
const { PLAN_LIMITS } = require("../config/creditConfig");

async function checkCredits(req, res, next) {
  const user = await User.findById(req.user.id);
  const limit = PLAN_LIMITS[user.plan];

  if (user.creditsUsedThisMonth >= limit.monthlyCredits) {
    return res.status(429).json({
      error: "CREDIT_LIMIT_EXCEEDED",
      message: "Monthly credit limit reached. Upgrade your plan to continue.",
      creditsRemaining: 0
    });
  }

  // Optimistic reservation — reconcile actual cost after the LLM response
  req.creditReservation = Math.min(limit.maxCreditsPerRequest, limit.monthlyCredits - user.creditsUsedThisMonth);
  next();
}

module.exports = checkCredits;

Reconcile the reservation with actual token usage in the response handler, and use findOneAndUpdate with $inc for atomic credit deduction — never read-modify-write on the user document, which race-conditions under concurrent requests.

4. Cost Analysis Before You Set Prices

Before launching a plan, model your margin per tier using real provider pricing:

Plan Monthly Price Credits Est. Tokens Provider Cost Gross Margin
Free $0 100 ~100K ~$0.02 Loss leader
Pro $19 5,000 ~7.5M ~$1.10 ~94%
Enterprise Custom Unlimited Variable Metered Negotiated

Run this analysis per-provider (Gemini Flash-Lite, GPT-4o-mini, Claude Haiku) since embedding and inference pricing shifts frequently — hard-coding a single provider's rates into your margin model is a common mistake when providers update pricing without notice.

FAQ: AI SaaS Billing Systems

Should I bill by tokens or by requests?

Tokens. Request-based billing works only if every request has near-identical cost, which isn't true once you support multiple models, tool calls, or RAG retrieval steps.

How do I handle users who exceed their limit mid-request (e.g., during a long tool-use chain)?

Reserve an estimated credit budget before the call starts, cap it at maxCreditsPerRequest, and hard-stop the chain if the reservation is exhausted — don't let a single request go uncapped even for paying tiers, or one runaway agent loop can wipe out your margin on that user for the month.

Is Redis or MongoDB better for tracking real-time credit balances?

Redis for the hot path (checking/decrementing balance on every request — sub-millisecond reads), MongoDB for the source-of-truth ledger (usage logs, monthly resets, billing reconciliation). Sync Redis from MongoDB on a scheduled job, not the other way around.

Need help architecting a billing system for your AI product? View my SaaS development services or contact me — I build credit-based billing, usage metering, and LLM cost optimization for SaaS founders.