ScaleRocket/Web

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 prompt

Credits 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.

On this page