Docs/modules/Credits System

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

  1. Atomic operations - Use database transactions for balance updates
  2. Audit trail - Always log transactions with references
  3. Balance checks - Verify balance before expensive operations
  4. Refund handling - Implement admin refund functionality
  5. Low balance alerts - Notify users when credits are low