ScaleRocket/Web

Crédits

Système de crédits avec allocation mensuelle, déduction atomique, vérification du solde et logique de réinitialisation.

Vue d'ensemble

ScaleRocket inclut un système de crédits qui vous permet de conditionner l'accès à des fonctionnalités derrière un quota d'utilisation. Les utilisateurs reçoivent une allocation mensuelle de crédits en fonction de leur plan, et les crédits sont déduits lorsqu'ils utilisent des fonctionnalités spécifiques.

Architecture

User Action -> Check Balance -> Deduct Credits (atomic) -> Execute Feature
                    |
              Insufficient? -> Show upgrade prompt

Les crédits sont stockés dans la table credits et gérés via des opérations atomiques pour garantir la cohérence des données.

Allocation mensuelle

Chaque plan définit une allocation de crédits dans packages/config/src/pricing.ts :

export const plans = [
  {
    id: "starter",
    credits: 100,   // 100 credits per month
    // ...
  },
  {
    id: "pro",
    credits: 1000,  // 1000 credits per month
    // ...
  },
];

Lorsqu'un abonnement est créé ou renouvelé, le gestionnaire de webhook définit le solde de crédits de l'utilisateur :

// 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,
  });
}

Déduction de crédits

Les crédits sont déduits en utilisant une opération atomique de base de données pour éviter les conditions de concurrence :

-- 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;

Appelez-la depuis la Edge Function use-credits :

// 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,
  });
}

Les crédits sont déduits en utilisant une mutation Convex qui est automatiquement transactionnelle :

// 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;
  },
});

Les mutations Convex sont automatiquement atomiques -- aucune condition de concurrence possible.

Vérification du solde

Depuis le 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;

La query se met à jour en temps réel -- l'interface reflète automatiquement les changements de crédits.

Affichage dans l'interface

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

Logique de réinitialisation

Les crédits sont réinitialisés lorsqu'une nouvelle période de facturation commence. Cela est géré par l'événement webhook invoice.payment_succeeded :

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;

Les crédits ne sont pas cumulables. Les crédits non utilisés expirent à la fin de chaque cycle de facturation.

Ajouter des crédits aux fonctionnalités

Pour conditionner une fonctionnalité à des crédits :

1. Définir le coût

// packages/config/src/credits.ts
export const creditCosts = {
  generateReport: 5,
  aiCompletion: 1,
  exportData: 10,
};

2. Vérifier et déduire dans le 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. Afficher le coût en crédits dans l'interface

<Button onClick={handleGenerate}>
  Generate Report ({creditCosts.generateReport} credits)
</Button>

Fini ? Marquez cette page comme terminée.

On this page