Credits
Credit system with monthly allowance, atomic deduction, balance checks, and reset logic.
Overview
ScaleRocket includes a credit system that lets you gate features behind a usage quota. Users receive a monthly credit allowance based on their plan, and credits are deducted when they use specific features.
Architecture
User Action -> Check Balance -> Deduct Credits (atomic) -> Execute Feature
|
Insufficient? -> Show upgrade promptCredits are stored in the credits table and managed via atomic operations to ensure data consistency.
Monthly Allowance
Each plan defines a credit allowance in packages/config/src/pricing.ts:
export const plans = [
{
id: "starter",
credits: 100, // 100 credits per month
// ...
},
{
id: "pro",
credits: 1000, // 1000 credits per month
// ...
},
];When a subscription is created or renewed, the webhook handler sets the user's credit balance:
// In stripe-webhook Edge Function
async function handleInvoiceSucceeded(invoice: Stripe.Invoice) {
const userId = invoice.metadata.user_id;
const plan = plans.find((p) => p.id === invoice.metadata.plan_id);
await supabaseAdmin
.from("credits")
.upsert({
user_id: userId,
balance: plan.credits,
monthly_allowance: plan.credits,
last_reset_at: new Date().toISOString(),
});
}// convex/stripe.ts (webhook handler)
async function handleInvoiceSucceeded(ctx: ActionCtx, invoice: Stripe.Invoice) {
const userId = invoice.metadata.user_id;
const plan = plans.find((p) => p.id === invoice.metadata.plan_id);
await ctx.runMutation(internal.credits.resetCredits, {
userId,
balance: plan.credits,
monthlyAllowance: plan.credits,
});
}Deducting Credits
Credits are deducted using an atomic database operation to prevent race conditions:
-- supabase/migrations/xxx_add_deduct_credits.sql
CREATE OR REPLACE FUNCTION deduct_credits(p_user_id uuid, p_amount integer)
RETURNS integer AS $$
DECLARE
new_balance integer;
BEGIN
UPDATE credits
SET balance = balance - p_amount,
updated_at = now()
WHERE user_id = p_user_id
AND balance >= p_amount
RETURNING balance INTO new_balance;
IF NOT FOUND THEN
RAISE EXCEPTION 'Insufficient credits';
END IF;
RETURN new_balance;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;Call it from the use-credits Edge Function:
// supabase/functions/use-credits/index.ts
const { data, error } = await supabaseAdmin.rpc("deduct_credits", {
p_user_id: user.id,
p_amount: 5,
});
if (error) {
return new Response(JSON.stringify({ error: "Insufficient credits" }), {
status: 402,
});
}Credits are deducted using a Convex mutation which is automatically transactional:
// convex/credits.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
export const deductCredits = mutation({
args: { amount: v.number() },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthorized");
const credits = await ctx.db
.query("credits")
.withIndex("by_userId", (q) => q.eq("userId", userId))
.unique();
if (!credits || credits.balance < args.amount) {
throw new Error("Insufficient credits");
}
await ctx.db.patch(credits._id, {
balance: credits.balance - args.amount,
});
return credits.balance - args.amount;
},
});Convex mutations are automatically atomic -- no race conditions possible.
Checking Balance
From the client
const { data: credits } = await supabase
.from("credits")
.select("balance, monthly_allowance")
.eq("user_id", user.id)
.single();
const hasCredits = credits.balance >= requiredAmount;import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
const credits = useQuery(api.credits.getBalance);
const hasCredits = credits && credits.balance >= requiredAmount;The query updates in real-time -- the UI automatically reflects credit changes.
Display in the UI
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Credits:</span>
<span className="font-semibold">
{credits.balance} / {credits.monthly_allowance}
</span>
</div>Reset Logic
Credits reset when a new billing period starts. This is handled by the invoice.payment_succeeded webhook event:
case "invoice.payment_succeeded":
// Only reset on renewal, not first payment
if (invoice.billing_reason === "subscription_cycle") {
await supabaseAdmin
.from("credits")
.update({
balance: monthlyAllowance,
last_reset_at: new Date().toISOString(),
})
.eq("user_id", userId);
}
break;// convex/stripe.ts (webhook handler)
case "invoice.payment_succeeded":
if (invoice.billing_reason === "subscription_cycle") {
await ctx.runMutation(internal.credits.resetCredits, {
userId,
balance: monthlyAllowance,
monthlyAllowance,
});
}
break;Credits do not roll over. Unused credits expire at the end of each billing cycle.
Adding Credits to Features
To gate a feature behind credits:
1. Define the cost
// packages/config/src/credits.ts
export const creditCosts = {
generateReport: 5,
aiCompletion: 1,
exportData: 10,
};2. Check and deduct in the backend
import { creditCosts } from "@saas/config";
// In your Edge Function handler
const cost = creditCosts.generateReport;
// Check balance first (optional, for better UX)
const { data: credits } = await supabaseAdmin
.from("credits")
.select("balance")
.eq("user_id", user.id)
.single();
if (credits.balance < cost) {
return new Response(
JSON.stringify({ error: "Insufficient credits", required: cost, balance: credits.balance }),
{ status: 402 }
);
}
// Deduct atomically
const { error } = await supabaseAdmin.rpc("deduct_credits", {
p_user_id: user.id,
p_amount: cost,
});
if (error) {
return new Response(JSON.stringify({ error: "Credit deduction failed" }), { status: 402 });
}
// Proceed with feature logic// convex/features.ts
import { creditCosts } from "@saas/config";
import { mutation } from "./_generated/server";
import { getAuthUserId } from "@convex-dev/auth/server";
export const generateReport = mutation({
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthorized");
const cost = creditCosts.generateReport;
const credits = await ctx.db
.query("credits")
.withIndex("by_userId", (q) => q.eq("userId", userId))
.unique();
if (!credits || credits.balance < cost) {
throw new Error("Insufficient credits");
}
await ctx.db.patch(credits._id, {
balance: credits.balance - cost,
});
// Proceed with feature logic
},
});3. Show credit cost in the UI
<Button onClick={handleGenerate}>
Generate Report ({creditCosts.generateReport} credits)
</Button>Done reading? Mark this page as complete.