Credits System
NextShip includes a credit/token system for usage-based billing, commonly used for AI API access, file storage limits, or any metered feature.
Features
- Credit balance per user
- Transaction history
- SKU-based credit packages
- Purchase via Stripe/Creem
- Usage deduction with logging
Database Schema
User Credits
// src/lib/db/schema.ts
export const userCredits = pgTable("user_credits", {
id: text("id").primaryKey(),
userId: text("user_id").references(() => users.id).unique(),
balance: integer("balance").default(0),
updatedAt: timestamp("updated_at").defaultNow(),
});Credit Transactions
export const creditTransactions = pgTable("credit_transactions", {
id: text("id").primaryKey(),
userId: text("user_id").references(() => users.id),
amount: integer("amount").notNull(), // positive = add, negative = deduct
type: text("type").notNull(), // "purchase", "usage", "refund", "admin"
description: text("description"),
referenceId: text("reference_id"), // payment ID, usage log ID, etc.
createdAt: timestamp("created_at").defaultNow(),
});Credit Packages (SKUs)
export const skus = pgTable("skus", {
id: text("id").primaryKey(),
name: text("name").notNull(),
description: text("description"),
credits: integer("credits").notNull(),
price: integer("price").notNull(), // in cents
currency: text("currency").default("usd"),
stripePriceId: text("stripe_price_id"),
creemProductId: text("creem_product_id"),
isActive: boolean("is_active").default(true),
createdAt: timestamp("created_at").defaultNow(),
});Server Actions
Get User Balance
// src/server/actions/credits.ts
export async function getUserCredits() {
const session = await requireAuth();
const credits = await db.query.userCredits.findFirst({
where: eq(userCredits.userId, session.user.id),
});
return { balance: credits?.balance ?? 0 };
}Add Credits
export async function addCredits(
userId: string,
amount: number,
options?: {
type?: string;
description?: string;
referenceId?: string;
}
) {
const { type = "admin", description, referenceId } = options ?? {};
// Update or create balance
await db
.insert(userCredits)
.values({
id: crypto.randomUUID(),
userId,
balance: amount,
})
.onConflictDoUpdate({
target: userCredits.userId,
set: {
balance: sql`${userCredits.balance} + ${amount}`,
updatedAt: new Date(),
},
});
// Record transaction
await db.insert(creditTransactions).values({
id: crypto.randomUUID(),
userId,
amount,
type,
description,
referenceId,
});
// Get new balance
const credits = await db.query.userCredits.findFirst({
where: eq(userCredits.userId, userId),
});
return { newBalance: credits?.balance ?? 0 };
}Deduct Credits
export async function deductCredits(
userId: string,
amount: number,
options?: {
description?: string;
referenceId?: string;
}
) {
const { description, referenceId } = options ?? {};
// Check balance
const credits = await db.query.userCredits.findFirst({
where: eq(userCredits.userId, userId),
});
if (!credits || credits.balance < amount) {
throw new Error("Insufficient credits");
}
// Deduct
await db
.update(userCredits)
.set({
balance: sql`${userCredits.balance} - ${amount}`,
updatedAt: new Date(),
})
.where(eq(userCredits.userId, userId));
// Record transaction
await db.insert(creditTransactions).values({
id: crypto.randomUUID(),
userId,
amount: -amount,
type: "usage",
description,
referenceId,
});
return { newBalance: credits.balance - amount };
}Get Transaction History
export async function getCreditTransactions(params: {
page: number;
limit: number;
}) {
const session = await requireAuth();
const transactions = await db.query.creditTransactions.findMany({
where: eq(creditTransactions.userId, session.user.id),
orderBy: desc(creditTransactions.createdAt),
limit: params.limit,
offset: (params.page - 1) * params.limit,
});
return { items: transactions };
}Purchasing Credits
Available Packages
// src/server/actions/skus.ts
export async function getActiveSkus() {
const skuList = await db.query.skus.findMany({
where: eq(skus.isActive, true),
orderBy: asc(skus.price),
});
return { data: skuList };
}Create Checkout
// src/server/actions/credit-purchase.ts
export async function createCreditCheckoutSession(params: {
skuId: string;
successUrl: string;
cancelUrl: string;
}) {
const session = await requireAuth();
const sku = await getSkuById(params.skuId);
const checkoutSession = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price: sku.stripePriceId, quantity: 1 }],
success_url: params.successUrl,
cancel_url: params.cancelUrl,
metadata: {
userId: session.user.id,
skuId: sku.id,
credits: sku.credits.toString(),
type: "credit_purchase",
},
});
return { url: checkoutSession.url };
}Webhook Handler
// In Stripe webhook handler
case "checkout.session.completed":
const session = event.data.object;
if (session.metadata?.type === "credit_purchase") {
await addCredits(
session.metadata.userId,
Number(session.metadata.credits),
{
type: "purchase",
description: `Purchased credit package`,
referenceId: session.id,
}
);
}
break;Usage Example
AI Gateway Integration
// src/app/api/v1/chat/completions/route.ts
export async function POST(req: Request) {
// ... validate API key, get userId
// Check balance
const { balance } = await getUserCredits(userId);
if (balance <= 0) {
return Response.json({ error: "Insufficient credits" }, { status: 402 });
}
// Forward to AI provider
const response = await forwardToProvider(body);
// Calculate cost based on usage
const cost = calculateCost(body.model, response.usage);
// Deduct credits
await deductCredits(userId, cost, {
description: `API call: ${body.model}`,
referenceId: response.id,
});
return Response.json(response);
}Admin Management
The SKUs page (/skus) allows admins to:
- Create credit packages
- Set pricing
- Configure Stripe/Creem product IDs
- Enable/disable packages
Credits Page
Users can view their credits in /credits:
- Current balance
- Purchase history
- Usage breakdown
- Buy more credits
Best Practices
- Atomic operations - Use database transactions for balance updates
- Audit trail - Always log transactions with references
- Balance checks - Verify balance before expensive operations
- Refund handling - Implement admin refund functionality
- Low balance alerts - Notify users when credits are low