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