Parrainage et affiliation
Source de verite des termes : src/config/affiliate.ts. Le ledger : AffiliateCommission. Les gains d'un affilie sont la SOMME de ses lignes, jamais un solde denormalise — meme regle que les credits. Les chiffres de cette…
Source de verite des termes :
src/config/affiliate.ts. Le ledger :AffiliateCommission. Les gains d'un affilie sont la SOMME de ses lignes, jamais un solde denormalise — meme regle que les credits.Les chiffres de cette page sont sous garde :
pnpm docs:claimsechoue si le taux ou la duree ci-dessous cessent de correspondre au code.
Ce que le programme paie
| Taux | 30 % du montant net de la facture |
| Duree | 12 mois calendaires, a partir de la premiere facture payee du filleul |
| Fenetre d'attribution du lien | 60 jours apres le clic (AFFILIATE_ATTRIBUTION_DAYS) |
| Retention avant versement | 30 jours par commission |
| Seuil minimum de versement | 50 $ (AFFILIATE_MIN_PAYOUT_USD) : en dessous, la commission reste due et s'accumule |
| Mode de versement | En argent, via Stripe Connect |
| Qui peut parrainer | Tout compte, y compris Free |
| Tableau de bord detaille | Inclus a partir de Max 5x |
Pourquoi 12 mois et pas « recurrent »
Jusqu'au 30 aout 2026, /pricing vendait « 30% de commission recurrente »
sans duree — ce qui se lit « a vie » — pendant que /network/referral
parlait de la premiere annee. Les deux pages etaient en ligne, et aucune
n'avait raison : rien dans le code ne calculait de commission, donc il n'y
avait pas d'implementation pour trancher.
Tranche a 12 mois. 30 % a vie sur un Max 20x, c'est 90 $ par mois indefiniment pour une acquisition faite une fois : sur trois ans, plus que la premiere annee de revenu du client. Douze mois est la forme courante du marche et borne l'engagement a quelque chose qu'un modele financier peut tenir.
La duree se compte en mois calendaires depuis la premiere facture payee
du filleul, pas en factures (billing/2994, decision du proprietaire du
2026-10-02). Comptee en factures, elle laissait un abonne annuel payer
l'affilie douze ans : une facture par an, douze factures. En mois, la regle
est la meme pour toutes les cadences : un mensuel paie jusqu'a douze
commissions, un annuel exactement une (la facture de la premiere annee), et
une facture encaissee apres le douzieme mois ne paie rien. Un abonnement qui
s'interrompt ne prolonge pas la duree.
Le debut du terme est la createdAt de la premiere ligne
AffiliateCommission de l'organisation (isWithinCommissionTerm,
src/config/affiliate.ts), et la facture courante est datee par
status_transitions.paid_at. Une premiere facture remboursee ouvre quand
meme le terme : un remboursement ne deplace pas l'horloge.
Le seuil de 50 $
Le cron affiliate-payouts ne transfere rien en dessous de 50 $ net
payable (AFFILIATE_MIN_PAYOUT_USD). Rien n'est perdu : les lignes restent
due, non reclamees, et le run suivant les reprend avec ce qui s'est accru
depuis, jusqu'a ce que le total passe le seuil. Un virement Stripe Connect de
quelques dollars coute plus a deplacer et a rapprocher qu'il ne vaut. Un
payable nul (un clawback a tout absorbe) n'est pas « sous le seuil » : il est
nette comme avant.
Deux audiences, un seul jeu de conditions, une seule page
Le tableau ci-dessus vaut pour le programme qui paie, et il n'a qu'une
page : /network/affiliate, avec deux sections d'audience (les clients
qui parrainent un marchand qu'ils connaissent, les createurs qui publient).
Les memes conditions pour les deux publics sont une decision du
12 septembre 2026, prise sur billing/0369 et ecrite dans
l'ADR 0015.
La page unique est une decision du 25 septembre 2026, ecrite dans
l'ADR 0041, qui
remplace la 0015 sur ce seul point : /network/referral repond 308 vers
/network/affiliate. Deux pages indexables pour un seul ledger se
disputaient la meme requete, et la raison de les garder (« deja
indexees ») ne tenait pas sur un site encore peu indexe.
Le programme partenaire (ex-/network/agencies) est traite a part et ne
verse aucune commission : il echange une fiche d'annuaire et les
demandes entrantes qui y atterrissent, et le partenaire facture ses propres
clients. C'est l'ADR 0014.
Avant ces deux decisions, trois pages publiques indexees vendaient trois
jeux de conditions contradictoires pour ce seul ledger. /network/affiliate
promettait 20 % a vie avec des liens par asset et un dashboard de clics,
et /network/referral ajoutait « no clawback after month one », « 38 pays »
et des « formulaires fiscaux emis automatiquement ». Aucun de ces elements
n'a de chemin de code : il n'existe ni second taux, ni terme illimite, ni
lien par contenu, ni clic compte, ni liste de pays, ni 1099 dans le depot,
et le clawback suit la facture sans mois butoir.
Section de /network/affiliate | Public | Porte d'entree | Ce qu'elle ajoute |
|---|---|---|---|
| Clients | tout compte, Free inclus | lien immediat | la mise en relation par formulaire, lue par une personne |
| Createurs | createurs, newsletters, podcasts | lien immediat, aucune candidature | le meme lien dans chaque canal, et /contact?topic=network pour parler a l'equipe |
Le detail par filleul reste gate par le plan (admitsAffiliateDashboard,
a partir de Max 5x), pas par la section.
Ce qui rend la regle tenable plutot que declarative : la page lit ses
chiffres dans config/affiliate.ts, et le comparatif des deux audiences
vit dans un seul composant,
src/app/(marketing)/network/_components/program-terms.tsx. Le catalogue i18n ne porte plus que des formes
("{pct}%", "{days} days"). Et
src/test/affiliate-terms-match-the-ledger.test.ts refuse dans les six
locales tout pourcentage, toute duree, tout « a vie » et tout mecanisme
fantome (lien par asset, taux de clic) que le ledger ne porte pas.
Ce que les pages disent maintenant, et qu'elles ne disaient pas
Deux faits materiels manquaient a la page qui etait par ailleurs la plus juste :
- Un versement exige un compte Stripe Connect active.
payAffiliateCommissionssaute tout affilie dontStripeConnectAccount.statusn'est pasENABLED. Un affilie qui n'a jamais fait l'onboarding accumule duduesans rien recevoir, et rien ne le lui disait. - La retention de 30 jours. Elle est dans ce document depuis le debut ; elle n'etait sur aucune page publique, alors que c'est la reponse a « quand suis-je paye ».
Les trois regles qui rendent le programme honnete
Paiements observes uniquement. Une commission naît sur invoice.paid,
jamais sur une inscription, un essai ou une intention. Un programme qui paie
a l'inscription finance la fraude, et le redeem de code est un endpoint
public.
Net de taxe. Stripe Tax est actif : amount_total contient de la TVA
collectee pour le compte d'un Etat. La commission lit
invoice.subtotal_excluding_tax ?? invoice.subtotal. Meme regle que le
grant de credits, qui lit metadata.amount et jamais amount_total.
Reversible. Un remboursement (charge.refunded) ou un litige
(charge.dispute.created) reprend la commission. Sans ça le programme
finance exactement la fraude qu'il attire : je m'abonne via mon propre lien,
j'encaisse 30 %, je fais un chargeback.
Le chemin de l'argent
invoice.paid
└─ accrueCommissionForInvoice() → AffiliateCommission(status=due)
idempotent sur stripeInvoiceId
charge.refunded / charge.dispute.created
└─ reverseCommissionForInvoice() → status=reversed
cron affiliate-payouts (quotidien, 03:52)
└─ runAffiliatePayouts()
├─ selectionne les lignes `due` de plus de 30 jours
├─ soustrait les clawbacks non soldes (planPayout / netPayableUsd)
├─ sous 50 $ net : ne touche a rien, les lignes restent `due`
├─ CLAIM des lignes AVANT l'appel Stripe (anti double-versement)
├─ transferAffiliateCommission() → Stripe Connect
└─ status=paid (argent parti) ou status=netted (annule par un clawback)
La retention de 30 jours
Une commission est gagnee sur une facture qui peut encore etre remboursee. Verser le jour meme finance la fraude evidente. Trente jours couvre le remboursement ordinaire et garde un rythme mensuel pour l'affilie ; ça ne couvre pas un chargeback tardif, et c'est precisement pourquoi le clawback ci-dessous existe au lieu d'une esperance que ça n'arrive pas.
Le clawback, et pourquoi il coute quelque chose
Une commission annulee avant versement ne part simplement pas. Annulee
apres, l'argent est deja dehors. Elle est alors portee en negatif et
deduite du versement suivant de cet affilie (netPayableUsd), jamais
refacturee : on n'envoie pas de facture a un affilie, on attend ses
prochains gains.
Les clawbacks sont consommes par tranches, le plus petit reste d'abord.
La ligne porte clawbackApplied : combien de cette dette a deja ete
recuperee, et clawbackSettledAt une fois soldee.
La premiere version ne consommait que des lignes entieres, au motif qu'une ligne a moitie recuperee ne se reconcilie contre rien. Son propre test a trouve le trou : un clawback de 100 $ contre un run de 20 $ ne recuperait rien — l'affilie touchait ses 20 $ et devait toujours 100 $, et le mois suivant les memes 100 $ absorbaient un autre run. Une dette facturee plusieurs fois. La tranche est donc un fait ecrit sur la ligne, pas un chiffre que personne ne voit.
Le plus petit reste d'abord, pour qu'une file de petits clawbacks ne reste pas coincee derriere une grosse ligne qu'aucun run ne peut solder.
Un run dont le clawback mange toute la somme ne marque pas les lignes
paid : elles passent netted. Aucun transfert n'a eu lieu, et ecrire
paid mettrait dans les livres un versement qui n'existe pas.
Deux commissions portent le meme nom
| Bonus de redemption (existant) | Commission recurrente (0119) | |
|---|---|---|
| Declencheur | POST /api/credits/redeem | invoice.paid |
| Assiette | le grant de credits du code | la facture d'abonnement, nette |
| Forme | credits plateforme | argent, Stripe Connect |
| Frequence | une fois | 12 mois calendaires |
| Ligne | AffiliateRedemption.affiliateCommissionEarned | AffiliateCommission |
AffiliateCode.affiliateCommission porte le taux des deux. Il a ete ecrit
pour la premiere, ou 100 est une valeur raisonnable ; reutilise tel quel
pour la seconde, il verserait l'integralite de l'abonnement pendant un an.
Il est donc plafonne au taux publie : un code peut etre moins cher que
le programme, jamais plus cher que ce que /pricing promet.
Le lien de parrainage
Depuis billing/0125, un clic attribue vraiment. La chaine a trois
maillons et chacun etait deja a moitie ecrit :
| Maillon | Ou | Ce qui manquait |
|---|---|---|
| Capture | components/shared/refer/referral-capture.tsx -> modules/analytics/acquisition.ts | captureAcquisition() existait sans aucun appelant : le cookie bec_acq n'etait jamais ecrit |
| Attribution | services/billing/referral-link.ts, appele par POST /api/organizations | rien ne transformait un ref stocke en AffiliateRedemption |
| Commission | accrueCommissionForInvoice sur invoice.paid | deja construit par 0119, il lisait une table que personne ne remplissait |
Organization.acquisition etait donc toujours JSON null, et le refCode
que services/webhooks.ts en tire pour l'evenement subscription_started
ne pouvait valoir qu'undefined.
Le lien : https://boostecom.app/?ref=<CODE>, fabrique par
referralLinkFor() et affiche sur /account/settings/referrals a cote du
code. Un seul endroit le fabrique.
Le consentement. Le cookie d'attribution n'est pas strictement
necessaire : il ne sert pas a rendre la page demandee, il sert a payer un
tiers. Il passe donc par la meme porte que Datafast —
mayLoadThirdParty, ou le silence est un refus. Tant que la banniere n'a
pas ete acceptee, le code reste en memoire (un useRef, rien sur le
terminal) : sans ca le lien ne marcherait que pour les visiteurs ayant deja
accepte lors d'une visite precedente, ce qui est une difference invisible
entre deux personnes qui ont clique le meme lien. Si la banniere n'est
jamais acceptee, rien n'est stocke et le parrainage ne compte pas.
Les cinq refus, chacun avec son test qui rougit si on le retire :
| Refus | Pourquoi c'est de l'argent |
|---|---|
| Auto-parrainage | Ouvrir son propre lien en navigation privee, s'abonner, toucher 30 % de sa propre facture pendant un an. Plus facile par lien que par saisie : il n'y a rien a taper |
| Attribution deja posee | Une org garde le parrain sous lequel elle a ete creee. Combine au premier-contact (le cookie n'est pas ecrase), un parrain ne peut pas en deloger un autre |
| Fenetre de 60 jours | Mesuree sur at, l'horodatage du premier contact, contre AFFILIATE_ATTRIBUTION_DAYS. Pas sur l'expiration du cookie : une regle qu'aucun ledger ne voit n'est pas auditable |
| Compte pas neuf | « New customers only » : premiere organisation du compte, et pour un code newUsersOnly la meme fenetre de 30 jours que le redeem. Une seule regle publiee, deux chemins qui l'appliquent |
| Code qui accorde des credits | Ces credits sont indepensables en Free, donc le redeem les refuse jusqu'a l'abonnement. Ecrire la ligne ici brulerait l'@@unique(codeId, userId) dont le redeem depend, et le marchand n'aurait jamais ses credits |
L'idempotence est celle qui existait deja : AffiliateRedemption @@unique(codeId, userId). Le chemin par lien n'ouvre pas une seconde voie
d'attribution a cote du redeem, il ecrit dans la meme.
Le cout d'acquisition, lu depuis ce ledger
AffiliateCommission est la seule depense d'acquisition que la
plateforme enregistre automatiquement. Elle a donc une deuxieme vie, en
plus de payer les affilies : c'est le seul substrat sur lequel un CAC
mesure peut etre bati.
La jointure vit dans src/services/billing/cac.ts
(pur, teste par fixtures) et se lit sur /admin/revenue/cac. Elle joint le
cout — ce ledger — au canal, Organization.acquisition, la capture
first-touch ref / utm_* posee a la creation de l'org.
Ce que le calcul refuse d'impliquer, et pourquoi chacun compte :
| Refus | Raison |
|---|---|
| Ce n'est pas un CAC blende | L'acquisition payante (Meta, Google, sponsoring) n'a aucun chemin d'entree automatique : elle passe par PlatformCost, un registre a saisie manuelle. Une tuile CAC sans publicite dedans se lit comme un tres bon CAC |
revenuePerPayingOrg n'est pas une LTV | Une ligne de commission n'existe que pour les 12 premiers mois. Le revenu du mois 13 est invisible ICI, pas absent du business. Un ratio LTV:CAC calcule la sous-estimerait le vrai et presenterait la sous-estimation comme une mesure |
unattributed n'est pas direct | La capture attend le consentement cookie : un refus et une visite directe laissent la meme colonne vide |
| Il n'y a pas de tuile « payback » | Une commission est une part d'une facture deja encaissee, pas une avance. L'acquisition est cash-positive des la premiere facture, donc « mois pour rembourser le CAC » repondrait toujours zero |
Ce qui remplace le payback : la part effective, cout sur revenu. Elle doit atterrir a ou sous les 30 % publies. Au-dessus, c'est un defaut en amont — une borne de terme mal comptee, un clawback non applique, un taux par code au-dessus du plafond — et la page le signale au lieu de le rendre comme un prix.
Quatre etats, quatre couts
Le tableau que costOfRow() applique, parce que se tromper d'un seul
etat fausse le CAC dans le sens qui flatte :
| Statut | Cout cash | Engage | Pourquoi |
|---|---|---|---|
paid | amount | — | transfere |
due | — | amount | accru sur une facture encaissee, transfert pas encore passe |
netted | — | — | annule contre un clawback : aucun transfert n'a existe. Le compter double-compterait, le clawback qu'il solde etant deja porte a amount - clawbackApplied sur sa propre ligne |
reversed sans stripeTransferId | — | — | n'est jamais parti |
reversed avec stripeTransferId | amount - clawbackApplied | — | argent reellement sorti, recupere seulement a hauteur de ce que les runs suivants ont net |
Une ligne dont l'orgId n'existe plus n'est pas jetee : elle tombe
dans unattributed. AffiliateCommission.orgId est une VALEUR, pas une
cle etrangere — le ledger survit volontairement a l'org. La jeter
retrecirait le cote cout de la division et flatterait chaque CAC de la
page.
Ce qui n'existe pas encore
- Le rappel du chargeback tardif. Passe la retention, un chargeback a plus de 30 jours produit un clawback qui attend les gains suivants de l'affilie. S'il n'en a plus, le montant reste ouvert et se reconcilie a la main.