Le parrainage d’Asterias, en open source
Asterias est un produit fermé, mais l’une de ses briques ne l’est plus. Le programme de parrainage qui tourne ici en production a été extrait, réécrit et publié sous licence MIT. Voici ce qu’il fait, comment il le fait, et les trois pièges qu’il existe pour éviter.
- Licence
- MIT
- Bâti sur
- Next.js 15 · Prisma 6 · Stripe 18 · TypeScript
- Tests unitaires
- 89
- Langues
- EN · FR · DE · IT · ES
Ce que fait ce module
La plupart des programmes de parrainage récompensent une inscription. Une inscription ne coûte rien à fabriquer, alors ces programmes finissent soit farmés, soit enveloppés de règles anti-fraude que personne ne sait expliquer à un client de bonne foi.
Celui-ci récompense du chiffre d’affaires, et seulement tant qu’il dure. Chaque filleul devenu client payant retire une part de l’abonnement de son parrain. Le jour où il cesse de payer, la part revient sur la facture suivante.
- Sarah parraine Tom. Tom s’inscrit : Sarah ne gagne rien.
- Tom démarre un essai gratuit : Sarah ne gagne toujours rien.
- La carte de Tom est débitée : Sarah passe à 20 % de remise.
- Le paiement de Tom échoue : Sarah revient au plein tarif.
- Tom repaie : la remise revient.
Cinq filleuls payants et l’abonnement de Sarah est gratuit. Le pas et le plafond se règlent dans un fichier d’environnement.
Il n’y a ni cagnotte, ni journal de crédits, ni file de versements. La remise est une fonction du nombre de filleuls qui paient à cet instant, recalculée depuis la base et poussée chez Stripe. C’est ce qui empêche l’écran et la facture de dire deux choses différentes, puisque les deux lisent la même fonction.
Trois pièges, et ce que ce module en fait
Trois choses tournent mal dans presque tous les programmes de parrainage écrits à la main. Ce module est surtout la forme que prend le fait de les éviter.
| Le piège | Ce qui arrive | Ce que fait ce module |
|---|---|---|
| Un coupon à usage unique | C’est la façon qui semble la plus propre de rendre vrai le mot « recalculé chaque mois » : on attache un coupon, il tombe, on attache le suivant. La remise dépend alors de quelque chose qui doit se relancer avant chaque facture, et toute fenêtre où ça n’a pas tourné est un prélèvement plein tarif que rien ne signale. | duration: forever. La remise est un état durable, que le module retire quand il le décide. Une synchro en retard ne coûte rien. |
| Croire le contenu du webhook | Stripe ne garantit pas l’ordre de livraison. Un événement past_due peut arriver après l’active qui l’avait remplacé. Écrire ce que dit le payload, c’est déplacer la remise d’un compte tiers en se fondant sur un état qui n’est plus vrai. | Chaque événement déclenche une relecture de l’API Stripe. Deux traitements concurrents sont départagés par l’horodatage de leur lecture. |
| Croire le webhook tout court | Un déploiement, une erreur 500, un événement que Stripe a cessé de réessayer : le parrain se retrouve au mauvais palier, et une remise fausse ressemble exactement à une remise juste. | Un réconciliateur recalcule tous les paliers depuis la base, répare l’écart, et dit ce qu’il a réparé. |
Comment la remise est calculée
Il n’y a délibérément aucune « remise en cours » stockée. Le palier est une fonction pure du nombre de filleuls qui paient à cet instant.
discountPercent(activeReferrals) =
min(max(activeReferrals, 0), MAX_REFERRALS) × PERCENT_STEPLa même fonction alimente la page du client et la synchronisation Stripe, si bien que ce qu’on lui montre et ce qu’on prélève sur sa carte ne peuvent pas diverger parce que deux endroits se seraient désynchronisés.
Le client le ressent sur sa facture suivante : les remises sont écrites avec proration_behavior à none, donc un changement de palier ne génère jamais d’avoir ni de prélèvement immédiat pour un mois déjà servi.
| Statut Stripe | Compte ? | Pourquoi |
|---|---|---|
| active | Oui | Le filleul paie. |
| trialing | Selon le réglage | Uniquement si REFERRAL_COUNT_TRIALING vaut true. Lisez la section sur l’abus avant d’y toucher. |
| past_due | Non | Un renouvellement a échoué. C’est ce que veut dire « la remise s’arrête au premier impayé ». |
| unpaid | Non | La facture n’a jamais été réglée. |
| canceled | Non | L’abonnement est résilié. |
| paused | Non | L’abonnement est en pause, donc rien n’est prélevé. |
| incomplete | Non | Aucun paiement n’a jamais abouti. Idem pour un compte sans abonnement ou sur une formule gratuite. |
Configuration
Tout vit dans l’environnement et est validé au démarrage. Une configuration qui ne peut pas être honorée refuse de démarrer, plutôt que d’échouer le jour où un client atteint le palier le plus haut.
| Variable | Défaut | Ce qu’elle fait |
|---|---|---|
| REFERRAL_PERCENT_STEP | 20 | Pourcentage de remise par filleul payant. |
| REFERRAL_MAX_REFERRALS | 5 | Nombre de filleuls qui rapportent encore une remise. |
| REFERRAL_COUNT_TRIALING | false | Un filleul encore en essai gratuit compte-t-il ? Lisez la section sur l’abus avant de passer ça à true. |
| REFERRAL_COUPON_PREFIX | referral_off | Préfixe des coupons créés par l’application. Le pourcentage y est ajouté. |
| REFERRAL_COOKIE_DAYS | 30 | Durée de survie d’un code capturé avant l’inscription. |
| APP_URL | aucun | Origine publique. Les liens de parrainage en sont construits. |
| STRIPE_SECRET_KEY | aucun | Sans elle, les parrainages sont enregistrés et affichés, mais aucune remise n’est appliquée. |
| STRIPE_WEBHOOK_SECRET | aucun | Sans lui, l’endpoint refuse tout. Les deux sont nécessaires pour la moitié facturation. |
Choisir le pas et le plafond
REFERRAL_PERCENT_STEP multiplié par REFERRAL_MAX_REFERRALS doit rester inférieur ou égal à 100. Stripe n’a pas de coupon au-delà de 100 % de remise, et un palier qui en demanderait un retomberait silencieusement sur aucune remise du tout, pour le compte qui en a le plus mérité.
20 × 5 # a referral is worth 20%, five make it free (default)
10 × 5 # a gentler program that caps at half price
25 × 2 # two referrals, half price, nothing beyond
33 × 3 # 99% at the top; the last 1% keeps a card on fileÀ 100 % de remise, Stripe émet une facture à zéro et ne débite pas la carte. Elle cesse donc d’être éprouvée, et le jour où le palier redescend, ce premier vrai prélèvement peut échouer sur une carte expirée depuis des mois. C’est pour cela que la page de parrainage annonce le montant de la prochaine facture, et pour cela que 33 × 3 figure dans la liste : il plafonne à 99 % et garde un vrai paiement sur le compte.
Essayer en une minute
Le dépôt est une application Next.js complète : le module, une base Postgres, des comptes de démonstration et la page de parrainage.
git clone https://github.com/MaxenceLassus/nextjs-stripe-referrals
cd nextjs-stripe-referrals
pnpm install
docker compose up -d # Postgres on :5433
cp .env.example .env.local # works as-is without Stripe
pnpm db:migrate && pnpm db:seed
pnpm devSans clé Stripe, le programme enregistre les parrainages, les affiche, n’applique aucune remise, et le dit à l’écran. Une clé de test et un secret de webhook suffisent à allumer la moitié facturation.
Pour voir les deux côtés dans un seul navigateur : inscrivez-vous, copiez votre lien, ouvrez-le en navigation privée et inscrivez un second compte, abonnez ce second compte, puis regardez la page du premier passer à 20 % avec le montant de sa prochaine facture. Résiliez le second, et regardez la remise repartir.
L’installer dans votre application
Le module tient dans un dossier, src/referrals. On le copie, puis il reste trois choses à faire.
Les quatre modèles Prisma à ajouter à votre schéma ne portent aucune clé étrangère vers votre table d’utilisateurs : userId y est une chaîne opaque, ce que votre application appelle un identifiant de compte. C’est ce qui en fait un dossier à copier plutôt qu’une migration à fusionner. La contrepartie est que rien ne s’efface en cascade à la suppression d’un compte, d’où forgetUser(userId), à appeler depuis votre propre chemin de suppression.
1. Rattacher le compte à l’inscription
attachReferral ne lève jamais d’exception. Un parrainage est une amabilité commerciale, une inscription est le métier : un code périmé, mal tapé, auto-référent ou forgé renvoie un résultat que vous pouvez journaliser et ignorer, et le compte est créé dans tous les cas.
import { attachReferral, readReferralCookie, clearReferralCookie, accountLabel } from '@/referrals'
const user = await createYourAccount(...)
await attachReferral(user.id, await readReferralCookie(), {
label: accountLabel({ name: user.name, email: user.email }),
})
await clearReferralCookie()Les gens partagent la page qui les a convaincus, et c’est bien plus souvent la page des tarifs que le formulaire d’inscription. Une ligne dans le middleware que vous avez déjà attrape ?ref= sur toutes les pages.
2. Capturer le code partout, pas seulement sur /signup
Le cookie de capture survit trente jours par défaut, ce qui laisse le temps d’un aller-retour entre la page qui a convaincu et la décision de créer un compte.
// src/middleware.ts
export function middleware(request: NextRequest) {
return captureReferral(request, NextResponse.next())
}3. Brancher le webhook, et marquer votre Checkout
Le webhook est l’endroit où le programme apprend qu’un filleul a payé. Le Checkout, lui, doit dire à quel compte appartient le client Stripe qu’il vient de créer.
// src/app/api/referrals/webhook/route.ts
export { POST } from '@/referrals/stripe/webhook'
// wherever you create a Checkout session
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price, quantity: 1 }],
...referralCheckoutOptions(user.id),
})Et la page
ReferralPage reçoit un userId et ne cherche jamais elle-même qui est connecté, volontairement. L’authentification est la vôtre : un composant qui résout la session tout seul est un composant qu’on peut monter sur une route où personne n’a vérifié.
// src/app/dashboard/referrals/page.tsx
export default async function Page() {
const user = await requireUser() // your auth, not this module's
return <ReferralPage
userId={user.id}
locale={user.locale}
label={user.name}
price={{ amount: 2900, currency: 'eur' }}
/>
}L’abus, et pourquoi ce design y résiste
La ligne la plus importante du module est qu’un essai gratuit ne compte pas.
Si les essais comptaient, n’importe qui ouvrirait cinq comptes, démarrerait cinq essais, prendrait 100 % de remise sur son propre abonnement et annulerait les cinq avant qu’une carte soit touchée. Le programme paierait pour rien.
Les essais exclus, gagner une remise exige des abonnements réellement payés. Pour simuler cinq filleuls payants, il faut payer cinq abonnements afin d’en rendre un gratuit. C’est l’économie qui fait la police, et c’est pourquoi il n’y a ici aucun score de fraude à régler ni à expliquer dans un ticket de support.
Ce qui reste à votre charge : limiter le débit de votre route d’inscription. Le module ne voit pas votre trafic, et la création de compte est votre endpoint.
| L’attaque | Pourquoi elle échoue |
|---|---|
| Se parrainer soi-même | resolveReferrer refuse quand le propriétaire du code est le compte qui vient de naître. |
| Deux parrains pour un même filleul | referredUserId est unique. Le premier gagne, définitivement. |
| Forger le cookie ou la valeur de ?ref= | Les deux sont contrôlés par le visiteur et aucun n’est cru : le code est cherché en base au moment de l’inscription. Forger un code ne fait jamais que donner une remise au compte qu’il désigne, il n’y a donc rien à voler. |
| Réclamer des parrainages rétroactivement | attachReferral refuse un compte qui a déjà été client. |
| Rejouer un webhook Stripe | Chaque identifiant d’événement est enregistré avant traitement, et la clé primaire élimine le rejeu. |
| Lire les parrainages d’un autre compte | Chaque requête est cadenassée sur le userId que vous passez. Passez-en un authentifié. |
Exploitation
Quatre commandes, dont deux comptent vraiment.
pnpm referrals:doctor # will this deployment actually apply discounts?
pnpm referrals:reconcile # repair drift; run hourly
pnpm referrals:reconcile --dry-run # report it without writing
pnpm referrals:verify-flow # walk the whole program against a real databasereconcile ne parle que lorsqu’il a fait quelque chose. Un balayage qui journalise à chaque passage tranquille apprend à tout le monde à l’ignorer, et c’est le jour bruyant qui est raté ensuite. S’il répare vingt remises par jour, c’est qu’il y a un problème en amont, et il faut le savoir.
Le diagnostic, depuis le serveur
doctor répond à la question qui n’a pas de réponse dans un navigateur : un secret de webhook manquant, une clé qui pointe sur le mauvais compte Stripe, un coupon avec la mauvaise durée. Vus de l’extérieur, ces trois cas ressemblent exactement à un programme où personne n’a encore parrainé personne.
Configuration
ok tiers: 20% x 5 = 100% maximum
ok trials do not count
Stripe
ok key works: account acct_1234 (Your Company)
ok mode: test
Coupons
ok referral_off_20: 20% off, forever
FAIL referral_off_40 has duration "once", not "forever".
A `once` coupon falls off after one invoice and the discount silently stops.Ce qui est testé, et ce qui ne l’est pas
89 tests unitaires, sans base et sans réseau : l’arithmétique des paliers et ses bornes, la frappe et l’échappement des codes, la validation de la configuration, la correspondance des statuts, les garde-fous du rattachement, la signature, l’idempotence et l’aiguillage du webhook, et la synchronisation de la remise, y compris son chemin d’échec.
Huit d’entre eux sont des tests de fil : le vrai SDK Stripe, de vrais paramètres sérialisés, contre un enregistreur local. Ils vérifient les octets et non un mock : duration=forever sur le coupon, discounts[0][coupon] à l’application, discounts vide au retrait, proration_behavior=none dans les deux cas. Ce sont les trois choses les plus susceptibles d’être silencieusement fausses dans une intégration Stripe, et les plus invisibles pour un test mocké.
Ce qu’aucun de ces tests ne couvre : la façon dont Stripe répond vraiment, et le fait qu’une vraie facture sorte plus basse. Cela demande une clé de test et cinq minutes, et le dépôt donne la marche à suivre.
Cinq langues
Anglais, français, allemand, italien, espagnol. L’anglais est la langue par défaut et le repli de tout ce qui n’est pas reconnu ; fr-CA est résolu en fr. Les dictionnaires sont typés par langue, donc ajouter une langue, ou simplement une chaîne, ne compile plus tant que les cinq ne l’ont pas. Un test vérifie en plus qu’aucune traduction ne perd un paramètre entre accolades.
Ce que ça fait tourner ici
Ce n’est pas une abstraction de week-end. Ce module fait tourner le programme de parrainage d’Asterias, le logiciel de gestion des avis Google sur le site duquel vous êtes. Un commerçant qui parraine un confrère voit son abonnement baisser de 20 % dès que ce confrère paie vraiment, et remonter s’il cesse de payer.
La version publiée n’est pas une copie du code d’ici : c’est une réécriture, qui corrige au passage deux vrais défauts trouvés en l’extrayant. Si vous faites tourner quelque chose de comparable, ils valent dix minutes de lecture.
Licence
MIT. Copiez le dossier, modifiez-le, vendez ce que vous en faites. Si vous le déployez quelque part, un mot fait plaisir.
Questions fréquentes
Le filleul peut-il avoir une remise lui aussi ?
Pas tel quel. Une remise accordée au filleul l’est avant qu’il ait payé quoi que ce soit, ce qui est précisément ce que ce design évite : elle repose sur une promesse et non sur une preuve, et c’est le seul vrai vecteur d’abus du système. Si vous la voulez quand même, appliquez votre propre coupon au Checkout, rien ici n’interférera.
Le webhook est-il obligatoire ?
Oui, et contrairement à l’activation d’un abonnement, ce n’est pas négociable. Le changement de facturation d’un parrainage concerne un compte qui n’est pas celui devant l’écran. Personne ne regarde, il n’y a donc aucun rafraîchissement paresseux sur lequel se rabattre.
Est-ce que ça marche sans Stripe ?
Ça tourne, enregistre les parrainages, les affiche, et dit à l’écran qu’aucune remise n’est appliquée. Utile en développement local, et pour un déploiement dont la configuration Stripe n’est pas terminée.
Et si mon application n’est pas en Next.js ?
Les règles, les codes, les requêtes, la synchronisation Stripe, le webhook et le réconciliateur sont du TypeScript sans framework dedans. Seuls capture.ts, middleware.ts et ui/ sont spécifiques à Next, soit environ un tiers du dossier.
Pourquoi pas un paquet npm ?
Parce que les deux choses qu’il faut le plus installer sont un modèle Prisma et une page App Router, et npm ne sait livrer ni l’un ni l’autre sans que vous les copiiez de toute façon. Un dossier est honnête là-dessus.
Le code est sur GitHub
Sous licence MIT, avec le README qui détaille l’API complète, les modes de panne et la façon de vérifier, contre une vraie clé Stripe, qu’une facture sort bien plus basse.