Chapter 12: SAAS Pricing: Credits and Metering System
In Chapter 11, we successfully integrated Stripe Checkout and Webhooks, establishing the 'subscription' foundation for our SAAS. Users can now pay for the 'Pro' plan. However, for a modern SAAS, especially an AI SAAS, simply distinguishing between 'free' and 'paid' is far from sufficient.
Chapter 12: SAAS Pricing: Credits and Metering System
In Chapter 11, we successfully integrated Stripe Checkout and Webhooks, establishing the "subscription" foundation for our SAAS. Users can now pay for the "Pro" plan. However, for a modern SAAS, especially an AI SAAS, simply distinguishing between "free" and "paid" is far from sufficient.
Consider this scenario: Your "Pro" users pay $20 per month. If one user makes 10,000 AI API calls in a month while another makes only 10, yet they pay the same amount, is that fair? Can your cost model sustain this?
This is where Metering and Credits systems come into play. In this chapter, we'll explore how to upgrade from a "fixed subscription" architecture to a hybrid "subscription + metering" architecture, providing your SAAS with unparalleled flexibility and scalability.
12.1. Architecture: Why Do We Need a Credits System? (Decoupling and Flexibility)
For many Python developers, a metering system might imply complex Celery tasks, Redis counters, and periodic reconciliation scripts. In Next.js architecture, we can leverage Drizzle's atomic operations and Server Actions to build a cleaner, real-time system.
Introducing a "credits" layer (a virtual currency) offers four major architectural advantages:
- Decoupling Features from Pricing:
- Without a credits system: When the marketing team wants to change the price of AI calls, you (the engineer) need to modify all
if (user.plan === 'pro')checks in the code and hardcode new limits. - With a credits system: You only need to adjust the number of credits consumed per AI call (e.g., from 1 credit to 2 credits). Your feature code (
consumeCredits(userId, 2)) remains unchanged.
- Without a credits system: When the marketing team wants to change the price of AI calls, you (the engineer) need to modify all
- Flexible Business Models:
- Subscription: Pro subscription automatically "recharges" 1000 credits monthly.
- One-Time Purchase: Users can purchase additional 500-credit "top-up" packs.
- Lifetime Deal (LTD): One-time payment of $200 for 100,000 credits (or 5000 credits monthly).
- Free Trial: New users receive 50 credits upon registration.
- Granular Metering:
- Different features can consume different amounts of credits.
generateImage()(calling DALL-E 3): Consumes 10 credits.summarizeText()(calling GPT-4o): Consumes 2 credits.simpleApiLookup(): Consumes 0.1 credits.
- Clear Value Proposition:
- Users can clearly see how much "ammo" they have left. This is far clearer than a vague "Pro plan" limit, greatly improving transparency and user experience.
Our SAAS template will implement a Drizzle-based credits system, tightly integrated with the Stripe subscriptions from Chapter 11.
12.2. [Code Analysis]: Analyzing the src/credits/ Directory
The src/credits/ directory contains the core logic of the credits metering system. It works closely with src/db/ and src/actions/.
File 1: src/db/schema.ts (Extension)
First, we need to track credits in the database. We won't just put a credits field on the users table, as this makes it difficult to track the source and expiration of credits. We'll create a separate credits table.
// src/db/schema.ts
// ... imports and 'users', 'subscriptions' tables ...
import { sql } from 'drizzle-orm';
// ... users table ...
export const users = pgTable('users', {
id: text('id').primaryKey(),
// ... other fields ...
stripeCustomerId: text('stripe_customer_id'),
subscriptionStatus: text('subscription_status'),
// We keep a "total credits" field on the user table for fast reads
// This is a "redundant" field, kept in sync via triggers or Server Actions
totalCredits: integer('total_credits').default(0).notNull(),
});
// Credit transaction table: tracks every credit addition and deduction
export const creditTransactions = pgTable('credit_transactions', {
id: text('id').primaryKey().default(sql`gen_random_uuid()`),
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
amount: integer('amount').notNull(), // Positive for additions, negative for consumption
description: text('description'), // e.g., "Monthly Pro Plan", "Consumed: AI Summary"
expiresAt: timestamp('expires_at'), // NULL means never expires
createdAt: timestamp('created_at').defaultNow().notNull(),
});- Architectural Decision: We use a
creditTransactionstable (ledger) instead of a singlecreditstable. This is a more robust "Event Sourcing" pattern. Theusers.totalCreditsfield is derived by calculating this table (or a more optimized method). For simplicity, our actions will update both places.
File 2: src/actions/credit.actions.ts
This is the "engine" of the credits system. All functions here are Server Actions, ensuring they execute securely only on the server.
'use server';
import { db } from '@/db';
import { users, creditTransactions } from '@/db/schema';
import { auth } from '@/lib/auth';
import { eq, sql, and, gt, sum } from 'drizzle-orm';
// Core function: consume credits
// This is a critical "atomic" operation
export async function consumeCredits(userId: string, amountToConsume: number, description: string) {
// Note: amountToConsume should be positive, e.g., 2
if (amountToConsume <= 0) {
return { error: 'Invalid amount' };
}
// Use Drizzle transaction to ensure atomicity
try {
const result = await db.transaction(async (tx) => {
// 1. Get current user
const user = await tx.query.users.findFirst({
where: eq(users.id, userId),
columns: { totalCredits: true },
});
if (!user) {
throw new Error('User not found');
}
// 2. Check if user has enough credits
if (user.totalCredits < amountToConsume) {
return { error: 'Insufficient credits' };
}
// 3. Update total credits in users table
// Use sql`...` to perform atomic update, preventing race conditions
await tx.update(users)
.set({ totalCredits: sql`${users.totalCredits} - ${amountToConsume}` })
.where(eq(users.id, userId));
// 4. Insert a consumption transaction
await tx.insert(creditTransactions).values({
userId: userId,
amount: -amountToConsume, // Consumption, so negative
description: description,
});
return { success: true, newTotal: user.totalCredits - amountToConsume };
});
return result;
} catch (err: any) {
console.error('Credit consumption failed:', err.message);
return { error: 'Transaction failed' };
}
}
// Internal function: add credits (called by Webhook or after successful purchase)
export async function grantCredits(
userId: string,
amount: number,
description: string,
expiresInDays: number | null = null
) {
const expiresAt = expiresInDays ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000) : null;
try {
const result = await db.transaction(async (tx) => {
// 1. Update users table total
await tx.update(users)
.set({ totalCredits: sql`${users.totalCredits} + ${amount}` })
.where(eq(users.id, userId));
// 2. Insert an addition transaction
await tx.insert(creditTransactions).values({
userId: userId,
amount: amount,
description: description,
expiresAt: expiresAt,
});
});
return { success: true };
} catch (err: any) {
return { error: 'Failed to grant credits' };
}
}
// (Cron Job) Daily task: clear expired credits
export async function expireCreditsCronJob() {
// This is a complex query, pseudocode as follows:
// 1. Find all expired creditTransactions (amount > 0 and expiresAt < now())
// 2. Calculate total expired credits per user
// 3. Subtract corresponding amount in users table
// 4. (Optional) Insert a negative "expired" transaction
console.log('Running daily credit expiration job...');
// ... configure this route as a cron job in Vercel.json ...
}How to Integrate with Stripe Webhooks?
In src/app/api/stripe/webhook/route.ts (from Chapter 11), we can now call grantCredits:
// ... in handleCheckoutSessionCompleted or handleSubscriptionUpdated ...
// Before:
// await db.update(users).set({ subscriptionStatus: subscription.status })...
// Now (when invoice.paid occurs):
const userId = ...; // Get from session metadata or database query
const plan = 'pro'; // ... determine the plan the user purchased ...
if (plan === 'pro') {
// Business logic: Pro plan grants 1000 credits monthly, valid for 30 days
await grantCredits(
userId,
1000,
"Monthly Pro Plan Credits",
30
);
}
// ... update subscriptionStatus, etc. ...12.3. [Code Analysis]: Analyzing src/config/price-config.tsx
Now we have the backend logic, but how do users choose? src/config/price-config.tsx defines all the data needed for the frontend pricing page. This is a "single source of truth" that drives the UI.
Note: This is a .tsx file because it may contain React/JSX elements (e.g., icons for "feature" lists), though using it as a pure .ts file would also work.
// src/config/price-config.ts (or .tsx)
import { CheckIcon, XIcon } from 'lucide-react'; // Example
export type PlanId = 'free' | 'pro' | 'lifetime';
export interface PlanFeature {
text: string;
icon?: React.ReactNode;
available: boolean;
}
export interface PricePlan {
id: PlanId;
name: string;
description: string;
// Subscription-related
stripePriceId: string | null;
monthlyPrice: number | null;
// One-time purchase (for Lifetime)
stripeOneTimePriceId: string | null;
oneTimePrice: number | null;
// Credits
credits: number; // Credits granted monthly/one-time
// UI feature list
features: PlanFeature[];
}
// Free plan
const freePlan: PricePlan = {
id: 'free',
name: 'Free',
description: 'Start for free, no credit card required.',
stripePriceId: null,
monthlyPrice: 0,
stripeOneTimePriceId: null,
oneTimePrice: null,
credits: 10, // 10 credits upon registration (one-time)
features: [
{ text: '5 AI Summaries per month', available: true },
{ text: 'Basic Support', available: true },
{ text: 'Advanced Features', available: false },
],
};
// Pro plan
const proPlan: PricePlan = {
id: 'pro',
name: 'Pro',
description: 'For professionals and teams.',
stripePriceId: process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID!, // From .env
monthlyPrice: 20,
stripeOneTimePriceId: null,
oneTimePrice: null,
credits: 1000, // 1000 credits monthly
features: [
{ text: '1000 AI Credits per month', available: true },
{ text: 'Priority Support', available: true },
{ text: 'Advanced Features', available: true },
],
};
// Lifetime Deal (LTD)
const lifetimePlan: PricePlan = {
id: 'lifetime',
name: 'Lifetime',
description: 'Pay once, use forever.',
stripePriceId: null,
monthlyPrice: null,
stripeOneTimePriceId: process.env.NEXT_PUBLIC_STRIPE_LIFETIME_PRICE_ID!,
oneTimePrice: 299,
credits: 50000, // 50,000 one-time credits (never expire)
features: [
{ text: '50,000 One-time Credits', available: true },
{ text: 'All Pro Features', available: true },
{ text: 'Lifetime Updates', available: true },
],
};
// Export configuration
export const priceConfig = {
plans: [freePlan, proPlan, lifetimePlan],
// (Optional) Additional credit purchase packs
topUpPacks: [
{
name: "Credit Pack 500",
price: 10,
credits: 500,
stripePriceId: process.env.NEXT_PUBLIC_STRIPE_TOPUP_500_ID!,
}
]
};How to Use This Configuration File?
In the src/components/payment/PricingTable.tsx from Chapter 11, we can now dynamically render all plans:
// src/components/payment/PricingTable.tsx
'use client';
import { useState } from 'react';
import { createCheckoutSession } from '@/actions/payment.actions';
import { Button } from '@/components/ui/button';
import { priceConfig, PricePlan } from '@/config/price-config'; // Import configuration
// ...
// Single plan card
function PlanCard({ plan }: { plan: PricePlan }) {
const [isLoading, setIsLoading] = useState(false);
const handleUpgrade = async () => {
setIsLoading(true);
let priceId: string | null = null;
if (plan.stripePriceId) {
priceId = plan.stripePriceId;
} else if (plan.stripeOneTimePriceId) {
priceId = plan.stripeOneTimePriceId;
} else {
console.error("No price ID for this plan");
setIsLoading(false);
return;
}
// Call Server Action (from Ch 11)
const result = await createCheckoutSession(priceId);
if (result.url) {
window.location.href = result.url;
} else {
console.error(result.error);
}
setIsLoading(false);
};
return (
<div className="border p-4 rounded-lg">
<h2>{plan.name}</h2>
<p>{plan.description}</p>
<b>{plan.credits} Credits</b>
<ul>
{plan.features.map(feature => (
<li key={feature.text}>{feature.text}</li>
))}
</ul>
{plan.id !== 'free' && (
<Button onClick={handleUpgrade} disabled={isLoading}>
{isLoading ? 'Processing...' : `Get ${plan.name}`}
</Button>
)}
</div>
);
}
// Render all plans
export function PricingTable() {
return (
<div className="flex gap-4">
{priceConfig.plans.map(plan => (
<PlanCard key={plan.id} plan={plan} />
))}
</div>
);
}Through this approach, our pricing, credits system, and payment flow are perfectly layered and decoupled. The marketing team can now adjust pricing by modifying price-config.ts and the Stripe dashboard without touching any core feature code.
Chapter 12: SAAS Pricing: Credits and Metering System - Summary
In this chapter, we built a more flexible "metering and credits" system on top of the "subscription" foundation from Chapter 11. We explored why a credits system is needed: it decouples features from pricing, allowing us to implement flexible business models (such as monthly subscriptions, one-time purchases, lifetime deals) and granular metering (different features consume different credits).
At the code level, we:
- Extended the Database (
db/schema.ts): AddedtotalCreditsto theuserstable and created acreditTransactionsledger table for robust credit tracking. - Created Atomic Operations (
credit.actions.ts): Leveraged Drizzle transactions to create two core Server Actions -consumeCredits(consume credits) andgrantCredits(grant credits), ensuring data consistency. - Centralized Configuration (
config/price-config.tsx): Defined all pricing plans (free, Pro, lifetime) with their corresponding Stripe Price IDs and credit amounts in a single configuration file, enabling the frontendPricingTable.tsxcomponent to render dynamically, achieving "configuration-driven UI".
Categories
src/db/schema.ts (Extension)File 2: src/actions/credit.actions.tsHow to Integrate with Stripe Webhooks?12.3. [Code Analysis]: Analyzing src/config/price-config.tsxHow to Use This Configuration File?Chapter 12: SAAS Pricing: Credits and Metering System - SummaryMore Posts
Chapter 17: Performance Monitoring & Observability
Our SAAS application is now feature-complete, having passed through rigorous CI/CD pipelines (Chapter 14) and automated testing (Chapter 16). It's deployed to production on Vercel. But once our app 'leaves' our development and testing environment and enters the unpredictable devices and networks of real users, how do we know it's running well?
Chapter 19: Integrating AI Capabilities (Vercel AI SDK)
In the first 18 chapters of this book, we've built an extremely solid SAAS foundation. We have all the core components for payment (Stripe), authentication (better-auth), database (Drizzle), DevOps (GitHub Actions), and feature releases (Feature Flags). Now, it's time to inject 'intelligence' into our SAAS...
Chapter 8: SAAS Authentication: Auth Solution Comparison
In Chapter 7, we chose the ORM path, deciding to use Drizzle to control our database. This decision directly impacts our Authentication solution.