Asterias
Commencer

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ègeCe qui arriveCe que fait ce module
Un coupon à usage uniqueC’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 webhookStripe 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 courtUn 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_STEP

La 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 StripeCompte ?Pourquoi
activeOuiLe filleul paie.
trialingSelon le réglageUniquement si REFERRAL_COUNT_TRIALING vaut true. Lisez la section sur l’abus avant d’y toucher.
past_dueNonUn renouvellement a échoué. C’est ce que veut dire « la remise s’arrête au premier impayé ».
unpaidNonLa facture n’a jamais été réglée.
canceledNonL’abonnement est résilié.
pausedNonL’abonnement est en pause, donc rien n’est prélevé.
incompleteNonAucun 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.

VariableDéfautCe qu’elle fait
REFERRAL_PERCENT_STEP20Pourcentage de remise par filleul payant.
REFERRAL_MAX_REFERRALS5Nombre de filleuls qui rapportent encore une remise.
REFERRAL_COUNT_TRIALINGfalseUn filleul encore en essai gratuit compte-t-il ? Lisez la section sur l’abus avant de passer ça à true.
REFERRAL_COUPON_PREFIXreferral_offPréfixe des coupons créés par l’application. Le pourcentage y est ajouté.
REFERRAL_COOKIE_DAYS30Durée de survie d’un code capturé avant l’inscription.
APP_URLaucunOrigine publique. Les liens de parrainage en sont construits.
STRIPE_SECRET_KEYaucunSans elle, les parrainages sont enregistrés et affichés, mais aucune remise n’est appliquée.
STRIPE_WEBHOOK_SECRETaucunSans 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 dev

Sans 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’attaquePourquoi elle échoue
Se parrainer soi-mêmeresolveReferrer refuse quand le propriétaire du code est le compte qui vient de naître.
Deux parrains pour un même filleulreferredUserId 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étroactivementattachReferral refuse un compte qui a déjà été client.
Rejouer un webhook StripeChaque identifiant d’événement est enregistré avant traitement, et la clé primaire élimine le rejeu.
Lire les parrainages d’un autre compteChaque 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 database

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