02Produit5 min

Crédits, solde, plafonds de dépense

Dans cinq produits, j'ai monté la facturation à l'usage de la même façon : je commence avec des crédits, je finis avec un solde en centimes et des plafonds par clé. Je sais désormais à quelle étape commencer.

Par Salva Castells

Image de couverture : Federico Candoni, CC BY-SA 4.0.
Dans cet article

Quand un produit consomme de l'IA, la première chose que je monte, ce sont toujours des crédits. Un entier dans la table de profil, on en retire un par génération, et en avant. Je l'ai fait dans une app de transcription, dans un SaaS de génération d'images et dans le boilerplate avec lequel je démarre mes projets. Les trois fois, j'ai fini au même endroit : un solde en centimes avec décimales et des plafonds de dépense par clé d'API.

Le parcours est toujours le même, alors je sais maintenant à quelle étape il vaut mieux commencer.

Pourquoi les crédits paraissent toujours suffisants au début

Parce qu'ils le sont, tant que le coût par opération est constant. Une image coûte une image. Un crédit gratuit par jour, vingt avec l'abonnement, et le check tient en une ligne :

if (!profile || profile.subCredits < estimatedCost) {
  return NextResponse.json({ error: "Insufficient credits" }, { status: 402 });
}

Ça se comprend sur la landing sans rien expliquer. Et comme nombre entier, c'est confortable : pas d'arrondis, l'utilisateur voit « il t'en reste 12 » et ne pose pas plus de questions.

Le premier signe que le modèle ne suffit plus n'est pas technique, il est tarifaire. Dans un projet de transcription, je facturais au caractère et deux semaines après le lancement j'ai multiplié par six le prix par caractère par rapport au coût du fournisseur, après avoir calculé combien de caractères compte une phrase en moyenne en anglais (environ 75) et en espagnol (environ 90), et combien de phrases sortaient d'un pack à 2 dollars. Ce calcul, tu ne le fais pas au début. Tu le fais quand tu as déjà des factures du fournisseur et que tu vois que la marge n'était pas celle que tu croyais.

Où ça casse dès que le coût réel entre en jeu

Ça casse au moment où un produit a plus d'un modèle. Si l'utilisateur peut choisir entre un modèle de transcription bon marché et un autre précis, un crédit cesse de vouloir dire quelque chose. Soit tu factures le cher comme s'il était bon marché et tu perds de l'argent, soit tu factures tout au prix du cher et le bon marché n'a plus de sens.

Et ça casse encore avec deux poches de crédits. Dans le SaaS d'images, j'ai ajouté des crédits quotidiens gratuits par-dessus ceux de l'abonnement et le jour même j'ai dû corriger le fait qu'on ne pouvait plus rien générer : la vérification ne regardait toujours que subCredits. Le correctif a été d'additionner les deux poches avant de comparer.

const totalAvailableCredits = (profile?.dailyCredits || 0) + (profile?.subCredits || 0);

Ça paraît évident à la lecture. Ça ne l'est pas quand la nouvelle poche est ajoutée dans un commit et que la vérification vit dans un autre fichier.

Ce que le solde résout et que les crédits ne résolvaient pas

Le solde est un nombre en centimes qui se décompte au coût réel de chaque opération. Ça permet de mélanger modèles, fournisseurs et unités différentes (minutes d'audio et tokens de texte) sur le même compte.

Le détail que je n'attendais pas : les centimes entiers ne suffisent pas. Une transcription courte avec un modèle bon marché coûte une fraction de centime, et avec integer chaque appel arrondissait à zéro et le solde ne baissait jamais. J'ai dû migrer les colonnes en virgule flottante :

ALTER TABLE "usage_logs"   ALTER COLUMN "cost_cents"    SET DATA TYPE real;
ALTER TABLE "user_billing" ALTER COLUMN "balance_cents" SET DATA TYPE real;
ALTER TABLE "api_keys"     ALTER COLUMN "spend_limit_cents" SET DATA TYPE real;

Oui, de l'argent en real. Pour un solde prépayé qui ne fait que se décompter et se recharger, ça me va : l'erreur d'arrondi est négligeable comparée au fait de perdre le montant entier par troncature. Si je facturais a posteriori, j'utiliserais des entiers dans la plus petite unité existante.

Le solde change aussi le pricing : on passe d'« un plan » à trois (4,99, 14,99 et 49,99 par mois, exprimés en heures de transcription et en tokens) plus des recharges ponctuelles de 5, 10 et 25 euros. À l'intérieur, tout est pareil : additionner des centimes.

Le cas extrême : trois modèles de facturation dans le même produit

Dans l'app de transcription, j'ai fini par avoir trois choses en même temps, et toutes n'ont pas survécu :

  • Le solde. Ce qui reste. S'il est à zéro ou moins, on ne sert rien.
  • Le plafond mensuel de l'utilisateur. Un champ monthly_limit_cents à 4,99 par défaut, modifiable depuis le compte.
  • Le plafond de dépense par clé d'API. spend_limit_cents plus une période (daily, monthly, total), comparé à la somme des logs d'usage de cette clé.

Le plafond mensuel de l'utilisateur, je l'ai supprimé. ALTER TABLE "user_billing" DROP COLUMN "monthly_limit_cents" et dehors le sélecteur, l'action serveur et les traductions en cinq langues. Ça n'apportait rien : le solde prépayé est déjà le plafond mensuel. Un utilisateur qui met 20 euros n'a pas besoin de dire en plus qu'il ne veut pas dépenser plus de 20 euros. C'était un écran de plus à expliquer et une condition de plus à vérifier.

Ce qui reste, en revanche, c'est le plafond par clé, parce que là il y a un cas réel : une clé mise dans un script qui part en vrille. Et il y a un détail que j'ai appris à la dure : il faut relire la clé en base par son id avant de comparer, même si tu as déjà l'objet issu de l'authentification. Sinon, tu sers des requêtes contre un spendLimitCents périmé.

Le garde doit être mis sur tous les endpoints, pas seulement sur ceux qui dépensent. Ceux qui listent les modèles et les presets l'ont aussi, en renvoyant 429. Si quelqu'un est à zéro, que l'API entière lui dise non, pas seulement la partie chère.

Comment je décide aujourd'hui, au jour un

Si le produit a une seule opération à coût fixe, des crédits. S'il y a plus d'un modèle, ou si le coût dépend de la taille de l'entrée, un solde en centimes avec décimales dès le premier commit. Migrer après coûte une après-midi de schéma et un mois de mails pour expliquer aux utilisateurs que leurs crédits sont maintenant des euros.

Et une chose de plus, indépendante du modèle : le code promo du checkout se met dans une constante et se teste. Un jour, j'ai changé le couponId de « -100 euros » à « -100 % » le matin et je l'ai annulé l'après-midi. Deux commits, le même fichier, une ligne.

Les crédits sont une décision marketing déguisée en décision technique. Le solde, c'est l'inverse.