← Retour au blog
IngénierieBuild in PublicTarification

Nous avons vendu un forfait qui ne fonctionnait pas

Nous avons lancé un forfait à 4,90 €/mois. L'option annuelle aurait prélevé 58,80 € chaque mois, le forfait n'accordait aucun droit, et le worker ignorait son plafond de durée. Les trois trouvés en l'achetant nous-mêmes.

4 octobre 2026

Nous avons vendu un forfait qui ne fonctionnait pas

Créé le 4 octobre 2026 · Mis à jour le 4 octobre 2026

Un forfait à 4,90 €/mois est passé en ligne sur notre page de prix. Qui choisissait l'option annuelle se serait vu prélever 58,80 € chaque mois.

Personne ne l'a été.

Nous l'avons attrapé avant qu'un seul client n'achète l'offre annuelle — non pas en relisant le code, mais en achetant la chose nous-mêmes avec une vraie carte.

Ce détail compte. Le build était vert. TypeScript était content. L'API renvoyait 200. Rien de tout cela ne prouvait que le produit fonctionnait.

Nous avons trouvé trois défauts ce matin-là. Notre premier client Solo payant s'est abonné vingt-sept minutes après le dernier de ces trois correctifs. Une demi-heure dans l'autre sens, et c'est lui qui payait un forfait ne lui accordant rien.

Défaut 1 : le prix était juste, l'intervalle non

Notre nouvelle offre d'entrée, Solo, est à 4,90 €/mois en facturation annuelle, ou 8,90 € au mois. 4,90 € × 12 = 58,80 € une fois par an.

Le tarif Stripe derrière l'option annuelle avait été créé avec un intervalle de facturation mensuel.

Stripe était donc prêt à prélever 58,80 € chaque mois — douze fois le prix prévu.

La page de prix était correcte. Le code utilisait le bon identifiant de tarif. Le défaut vivait en dehors du dépôt, dans un objet Stripe créé au tableau de bord.

C'est précisément ce qui le rendait facile à manquer.

Stripe n'autorise pas la modification de l'intervalle d'un tarif existant : on crée un nouveau tarif et on remplace l'identifiant. Nous avons donc ajouté un contrôle avant la création de la session de paiement :

const expected = effectivePeriod === "annual" ? "year" : "month";
const price = await stripe.prices.retrieve(priceId);

if (price.recurring?.interval !== expected) {
  throw new Error("PRICE_INTERVAL_MISMATCH");
}

Un appel d'API par paiement.

Si Stripe et la page de prix se contredisent, nous refusons de vendre.

Le mauvais objet Stripe peut toujours exister. Il ne peut simplement plus traverser le paiement sans qu'on le remarque.

Défaut 2 : le forfait existait commercialement, pas fonctionnellement

Le second défaut échouait dans l'autre sens : un client pouvait nous payer et rester traité comme un compte gratuit.

Ajouter une offre ressemble à un changement commercial. En pratique, cela touche chaque endroit du produit qui pose la question : « qu'est-ce que ce compte a le droit de faire ? »

Dans notre base de code, cette question recevait une réponse dans 29 fichiers.

Ces contrôles s'étaient accumulés au fil du temps dans les exports, les téléversements, la file de traitement, les points d'API, la facturation et le tableau de bord. La plupart étaient des comparaisons de chaînes écrites contre les forfaits qui existaient au moment où le code avait été ajouté.

Solo était nouveau.

Des contrôles du type est-ce pro, creator ou studio ? tombaient donc dans la branche par défaut. Et la branche par défaut, c'était le gratuit.

Un compte Solo payant pouvait ainsi recevoir le quota gratuit, les limites de téléversement du gratuit, le filigrane et les autres restrictions de l'offre gratuite.

L'intention derrière ce défaut n'était pas sotte. En sécurité, échouer de façon fermée est généralement ce qu'on veut.

En facturation, cela crée un tout autre mode de défaillance : le client a déjà payé, et votre repli le plus sûr devient précisément ce qui lui retient ce qu'il a acheté.

Le correctif n'a pas été d'ajouter "solo" à 29 conditions. Nous avons remplacé tout cela par un seul endroit où le comportement d'un forfait est décrit :

export type Plan = "free" | "solo" | "pro" | "creator" | "studio";

export function isPaid(plan): boolean
export function hasDubbing(plan): boolean
export function hasScheduling(plan): boolean
export function videoQuota(plan, status): number
export function maxDurationMinutes(plan): number
export function maxUploadSize(plan): number
export function retentionHours(plan): number | null

Les noms ci-dessus sont simplifiés pour l'article, mais la structure est celle que nous avons déployée.

La règle en tête de ce fichier dit maintenant : un forfait n'existe pas tant qu'il n'est pas ici.

Deux quasi-accidents, utiles, pendant la correction.

D'abord, nous avons failli utiliser ce seul contrôle isPaid() pour toutes les portes, y compris celles propres à une fonctionnalité. Cela aurait offert aux clients Solo des choses qu'ils ne paient pas, dont le doublage IA et la planification de publication. « Payant » et « a droit à cette fonctionnalité » ne sont pas la même question.

Ensuite, nous avons brièvement cru que la planification n'avait aucun contrôle de forfait, parce que chercher "plan" dans la route ne renvoyait rien. La porte était dans une fonction partagée, à un appel de distance.

Un grep qui revient vide n'est pas une réponse.

Défaut 3 : la limite annoncée mais jamais appliquée

La page de prix annonce que Solo accepte des vidéos sources jusqu'à 60 minutes. Le worker qui télécharge et traite les vidéos ne connaissait que deux limites :

Solo est payant. Solo a donc eu 90.

Ce défaut fuyait dans l'autre sens. Personne n'a été surfacturé et personne n'a perdu d'accès. Nous donnions simplement plus de calcul que le forfait n'était censé en contenir.

Pour un produit vidéo, cela compte. La durée de la source est l'un des paramètres les plus directement liés au coût de transcription, de rendu et de bande passante.

Le worker porte désormais le même plafond que l'application :

def _max_minutes(plan: str | None) -> int:
    """Miroir des plafonds de forfait utilises par l'application.

    Ce que la page de prix annonce et ce que le worker applique
    doivent etre le meme chiffre.

    Un plafond annonce sans etre applique, c'est de la marge qui part ;
    applique sans etre annonce, c'est un piege.
    """

Une limite qui n'existe que sur la page de prix n'est pas une limite. C'est une phrase sur une page.

Ce qui a réellement trouvé ces défauts

Pas le vérificateur de types. Pas la suite de tests. Pas une revue de code.

Ce qui les a trouvés, c'est d'ouvrir le site en faisant comme si on ne l'avait jamais vu, de cliquer sur l'option la moins chère et d'entrer une vraie carte.

L'écart de facturation est apparu dans le récapitulatif de paiement de Stripe lui-même. Les droits manquants sont apparus quand le compte payant a continué de se comporter comme un compte gratuit. Le plafond de durée est apparu quand nous avons demandé au worker quelle limite il appliquerait réellement.

Nous réapprenons sans cesse la même leçon : un build qui passe ne prouve pas qu'un produit fonctionne.

Chaque défaut vivait dans l'écart entre deux systèmes qui faisaient chacun exactement ce qu'on leur avait dit de faire :

La façon économique de trouver cette famille de défauts est étonnamment ennuyeuse : utiliser le produit comme le fera la personne qui paie.

Un épilogue sur l'attribution

Notre premier abonné Solo est arrivé le même matin.

Pendant une heure environ, nous nous sommes raconté une jolie histoire : Solo venait de sortir, quelqu'un l'avait trouvé tout seul, avait atteint la limite du gratuit et avait converti.

Le journal d'événements racontait quelque chose de moins net.

Cette personne avait trouvé Katto via ChatGPT, s'était inscrite, avait beaucoup utilisé le gratuit, avait atteint le quota, avait ouvert le paiement plusieurs fois et était repartie sans payer.

Le lendemain matin, un mail de relance de panier abandonné est parti. Quelques heures plus tard, elle s'abonnait.

Elle a choisi l'option à 8,90 € au mois plutôt que 4,90 € en facturation annuelle, soit 82 % de plus par mois, pour éviter un engagement de douze mois. L'offre fondateur qu'elle avait cliquée puis abandonnée la veille était à 6 €.

Nous avions aussi supposé que c'était la limite de 60 minutes sur la source qui l'avait poussée vers Solo. Faux. Ce sur quoi elle butait sans cesse, c'était le quota de deux vidéos par mois.

Deux hypothèses sont donc mortes d'un coup. La conversion n'était pas simplement organique — le mail de relance a probablement compté. Et la limite qui créait la plus forte pression à l'abonnement n'était pas celle que nous croyions.

C'est exactement la forme des trois défauts ci-dessus. Vue de l'extérieur, une histoire propre était facile à raconter. Puis nous avons regardé ce qui s'était réellement passé.


Katto transforme les vidéos longues en clips verticaux. Nous publions les chiffres que nous mesurons, y compris ceux qui ne nous flattent pas.

Articles liés

Prêt à transformer vos vidéos en clips viraux ?

Katto découpe, sous-titre et recadre automatiquement vos vidéos longues en contenu court.

Essayer Katto gratuitement →
Nous avons vendu un forfait qui ne fonctionnait pas | Katto