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:
- Monthly allowance — Each plan grants a set number of credits per billing period
- Balance — The user's current available credits
- Deduction — Subtract credits when the user performs an action
- 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
- Set up Stripe — credits are allocated based on subscription plan
- Create API calls — deduct credits in Edge Functions
- Configure emails — send low-credit warnings
Done reading? Mark this page as complete.