ScaleRocket/Web

Credits System

How the credits system works — monthly allowance, deducting credits, checking balance, and integrating with your features.

Credits System

ScaleRocket includes a built-in credits system for usage-based billing. Each plan comes with a monthly credit allowance that resets automatically. You deduct credits when users perform actions (API calls, generations, exports, etc.).

How Credits Work

The credits system has four key concepts:

  1. Monthly allowance — Each plan grants a set number of credits per billing period
  2. Balance — The user's current available credits
  3. Deduction — Subtract credits when the user performs an action
  4. Reset — Credits refill at the start of each billing cycle

The credits table in your database tracks this:

CREATE TABLE public.credits (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES auth.users(id) NOT NULL,
  balance INTEGER NOT NULL DEFAULT 0,
  monthly_allowance INTEGER NOT NULL DEFAULT 0,
  last_reset_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

Monthly Allowance and Reset

When a user subscribes, their monthly allowance is set based on their plan:

// packages/config/src/pricing.ts
export const PLAN_CREDITS = {
  starter: 5_000,
  pro: 50_000,
  enterprise: 500_000,
} as const;

Credits reset automatically. The credit deduction logic checks if a reset is due before deducting:

// Reset logic
function shouldReset(lastResetAt: string): boolean {
  const lastReset = new Date(lastResetAt);
  const now = new Date();

  // Reset if we are in a new billing month
  return (
    now.getMonth() !== lastReset.getMonth() ||
    now.getFullYear() !== lastReset.getFullYear()
  );
}

When a reset is triggered, the balance is set back to the monthly_allowance value.

Deducting Credits

The use-credits Edge Function handles credit deduction atomically:

// supabase/functions/use-credits/index.ts
import { corsHeaders, supabaseAdmin } from "../_shared/supabase.ts";

Deno.serve(async (req) => {
  if (req.method === "OPTIONS") {
    return new Response("ok", { headers: corsHeaders });
  }

  try {
    const authHeader = req.headers.get("Authorization");
    if (!authHeader) throw new Error("No authorization header");

    const token = authHeader.replace("Bearer ", "");
    const { data: { user } } = await supabaseAdmin.auth.getUser(token);

    if (!user) {
      return new Response(
        JSON.stringify({ error: "Unauthorized" }),
        { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
      );
    }

    const { amount } = await req.json();

    if (!amount || amount <= 0) {
      return new Response(
        JSON.stringify({ error: "Invalid credit amount" }),
        { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
      );
    }

    // Get current credits
    const { data: credits, error } = await supabaseAdmin
      .from("credits")
      .select("*")
      .eq("user_id", user.id)
      .single();

    if (error || !credits) {
      return new Response(
        JSON.stringify({ error: "Credits not found" }),
        { status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
      );
    }

    // Check if reset is needed
    let balance = credits.balance;
    if (shouldReset(credits.last_reset_at)) {
      balance = credits.monthly_allowance;
      await supabaseAdmin
        .from("credits")
        .update({ balance, last_reset_at: new Date().toISOString() })
        .eq("user_id", user.id);
    }

    // Check sufficient balance
    if (balance < amount) {
      return new Response(
        JSON.stringify({ error: "Insufficient credits", balance }),
        { status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" } }
      );
    }

    // Deduct credits
    const newBalance = balance - amount;
    await supabaseAdmin
      .from("credits")
      .update({ balance: newBalance, updated_at: new Date().toISOString() })
      .eq("user_id", user.id);

    return new Response(
      JSON.stringify({ balance: newBalance }),
      { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
    );
  } catch (error) {
    return new Response(
      JSON.stringify({ error: error.message }),
      { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
    );
  }
});

The useCredits mutation handles credit deduction atomically:

// convex/credits.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";

export const useCredits = mutation({
  args: { amount: v.number() },
  handler: async (ctx, { amount }) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Unauthorized");
    if (amount <= 0) throw new Error("Invalid credit amount");

    const credits = await ctx.db
      .query("credits")
      .withIndex("by_userId", (q) => q.eq("userId", userId))
      .unique();

    if (!credits) throw new Error("Credits not found");

    // Check if reset is needed
    let balance = credits.balance;
    if (shouldReset(credits.lastResetAt)) {
      balance = credits.monthlyAllowance;
      await ctx.db.patch(credits._id, {
        balance,
        lastResetAt: Date.now(),
      });
    }

    // Check sufficient balance
    if (balance < amount) {
      throw new Error("Insufficient credits");
    }

    // Deduct credits
    const newBalance = balance - amount;
    await ctx.db.patch(credits._id, {
      balance: newBalance,
      updatedAt: Date.now(),
    });

    return { balance: newBalance };
  },
});

Checking Balance

From the client, query the credits table directly (RLS ensures users only see their own):

// apps/app/src/lib/credits.ts
import { supabase } from "./supabase";

export async function getCredits() {
  const { data, error } = await supabase
    .from("credits")
    .select("balance, monthly_allowance, last_reset_at")
    .single();

  if (error) throw error;
  return data;
}

Display it in the dashboard:

// apps/app/src/features/dashboard/CreditsDisplay.tsx
import { useEffect, useState } from "react";
import { getCredits } from "../../lib/credits";

export function CreditsDisplay() {
  const [credits, setCredits] = useState<{
    balance: number;
    monthly_allowance: number;
  } | null>(null);

  useEffect(() => {
    getCredits().then(setCredits);
  }, []);

  if (!credits) return null;

  const percentage = (credits.balance / credits.monthly_allowance) * 100;

  return (
    <div>
      <p>{credits.balance.toLocaleString()} / {credits.monthly_allowance.toLocaleString()} credits</p>
      <div className="w-full bg-gray-200 rounded-full h-2">
        <div
          className="bg-blue-600 h-2 rounded-full"
          style={{ width: `${percentage}%` }}
        />
      </div>
    </div>
  );
}

From the client, use a Convex query (authentication is handled automatically):

// convex/credits.ts
import { query } from "./_generated/server";
import { getAuthUserId } from "@convex-dev/auth/server";

export const getBalance = query({
  handler: async (ctx) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) return null;

    return await ctx.db
      .query("credits")
      .withIndex("by_userId", (q) => q.eq("userId", userId))
      .unique();
  },
});

Display it in the dashboard:

// apps/app/src/features/dashboard/CreditsDisplay.tsx
import { useQuery } from "convex/react";
import { api } from "../../../convex/_generated/api";

export function CreditsDisplay() {
  const credits = useQuery(api.credits.getBalance);

  if (!credits) return null;

  const percentage = (credits.balance / credits.monthlyAllowance) * 100;

  return (
    <div>
      <p>{credits.balance.toLocaleString()} / {credits.monthlyAllowance.toLocaleString()} credits</p>
      <div className="w-full bg-gray-200 rounded-full h-2">
        <div
          className="bg-blue-600 h-2 rounded-full"
          style={{ width: `${percentage}%` }}
        />
      </div>
    </div>
  );
}

Adding Credits to Your Features

To integrate credits into your own features, follow this pattern:

1. Define Credit Costs

// packages/config/src/credits.ts
export const CREDIT_COSTS = {
  generate_report: 10,
  export_pdf: 5,
  api_call: 1,
  ai_generation: 50,
} as const;

2. Deduct Before Processing

// In your Edge Function
import { CREDIT_COSTS } from "../_shared/credits.ts";

// Deduct credits first
const { data: credits } = await supabase
  .from("credits")
  .select("balance")
  .eq("user_id", user.id)
  .single();

if (credits.balance < CREDIT_COSTS.generate_report) {
  return new Response(
    JSON.stringify({ error: "Insufficient credits" }),
    { status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" } }
  );
}

// Deduct
await supabase
  .from("credits")
  .update({ balance: credits.balance - CREDIT_COSTS.generate_report })
  .eq("user_id", user.id);

// Then do the expensive operation
const report = await generateReport(params);
// In your Convex mutation
import { CREDIT_COSTS } from "./config";

export const generateReport = mutation({
  args: { params: v.any() },
  handler: async (ctx, { params }) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Unauthorized");

    // Deduct credits first (reuse the useCredits logic)
    await ctx.runMutation(api.credits.useCredits, {
      amount: CREDIT_COSTS.generate_report,
    });

    // Then do the expensive operation
    const report = await generateReport(params);
    return report;
  },
});

3. Call from the Client

import { callApi } from "../../lib/api";

async function handleGenerateReport() {
  try {
    const report = await callApi("generate-report", { params });
    // Handle success
  } catch (error) {
    if (error.message === "Insufficient credits") {
      // Show upgrade prompt
    }
  }
}
import { useMutation } from "convex/react";
import { api } from "../../../convex/_generated/api";

function ReportButton() {
  const generateReport = useMutation(api.reports.generateReport);

  async function handleGenerateReport() {
    try {
      const report = await generateReport({ params });
      // Handle success
    } catch (error) {
      if (error.message === "Insufficient credits") {
        // Show upgrade prompt
      }
    }
  }

  return <button onClick={handleGenerateReport}>Generate Report</button>;
}

Note: Always deduct credits before performing the action. If the action fails after deduction, you can refund credits in your error handling.

Low Credits Warning

You can send an email when a user's credits drop below a threshold:

// Inside your deduction logic
if (newBalance < credits.monthly_allowance * 0.1) {
  // Less than 10% remaining — send warning email
  await sendEmail({
    to: user.email,
    subject: "Your credits are running low",
    template: CreditsLowEmail({
      name: user.name,
      balance: newBalance,
      allowance: credits.monthly_allowance,
    }),
  });
}

Next Steps

Done reading? Mark this page as complete.

On this page