ScaleRocket/Web

Système de crédits

Comment fonctionne le système de crédits — allocation mensuelle, déduction de crédits, vérification du solde et intégration avec vos fonctionnalités.

Système de crédits

ScaleRocket inclut un système de crédits intégré pour la facturation à l'usage. Chaque plan est accompagné d'une allocation mensuelle de crédits qui se réinitialise automatiquement. Vous déduisez des crédits lorsque les utilisateurs effectuent des actions (appels API, générations, exports, etc.).

Comment fonctionnent les crédits

Le système de crédits repose sur quatre concepts clés :

  1. Allocation mensuelle — Chaque plan accorde un nombre défini de crédits par période de facturation
  2. Solde — Les crédits actuellement disponibles de l'utilisateur
  3. Déduction — Soustraire des crédits lorsque l'utilisateur effectue une action
  4. Réinitialisation — Les crédits sont rechargés au début de chaque cycle de facturation

La table credits dans votre base de données suit cela :

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

Allocation mensuelle et réinitialisation

Lorsqu'un utilisateur s'abonne, son allocation mensuelle est définie en fonction de son plan :

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

Les crédits se réinitialisent automatiquement. La logique de déduction vérifie si une réinitialisation est due avant de déduire :

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

Lorsqu'une réinitialisation est déclenchée, le solde est remis à la valeur de monthly_allowance.

Déduire des crédits

L'Edge Function use-credits gère la déduction de crédits de manière atomique :

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

La mutation useCredits gère la déduction de crédits de manière atomique :

// 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");

    // Vérifier si une réinitialisation est nécessaire
    let balance = credits.balance;
    if (shouldReset(credits.lastResetAt)) {
      balance = credits.monthlyAllowance;
      await ctx.db.patch(credits._id, {
        balance,
        lastResetAt: Date.now(),
      });
    }

    // Vérifier le solde suffisant
    if (balance < amount) {
      throw new Error("Insufficient credits");
    }

    // Déduire les crédits
    const newBalance = balance - amount;
    await ctx.db.patch(credits._id, {
      balance: newBalance,
      updatedAt: Date.now(),
    });

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

Vérifier le solde

Depuis le client, interrogez directement la table credits (le RLS garantit que les utilisateurs ne voient que leurs propres données) :

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

Affichez-le dans le tableau de bord :

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

Depuis le client, utilisez une query Convex (l'authentification est gérée automatiquement) :

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

Affichez-le dans le tableau de bord :

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

Ajouter des crédits à vos fonctionnalités

Pour intégrer les crédits dans vos propres fonctionnalités, suivez ce modèle :

1. Définir les coûts en crédits

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

2. Déduire avant le traitement

// 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);
// Dans votre mutation Convex
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");

    // Déduire les crédits d'abord (réutiliser la logique useCredits)
    await ctx.runMutation(api.credits.useCredits, {
      amount: CREDIT_COSTS.generate_report,
    });

    // Puis effectuer l'opération coûteuse
    const report = await generateReport(params);
    return report;
  },
});

3. Appeler depuis le 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 });
      // Gérer le succès
    } catch (error) {
      if (error.message === "Insufficient credits") {
        // Afficher une invitation à upgrader
      }
    }
  }

  return <button onClick={handleGenerateReport}>Générer le rapport</button>;
}

Note : Déduisez toujours les crédits avant d'effectuer l'action. Si l'action échoue après la déduction, vous pouvez rembourser les crédits dans votre gestion d'erreur.

Avertissement de crédits faibles

Vous pouvez envoyer un email lorsque les crédits d'un utilisateur descendent en dessous d'un seuil :

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

Prochaines étapes

Fini ? Marquez cette page comme terminée.

On this page