ÉconomieParrainage et affiliation

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:claims echoue si le taux ou la duree ci-dessous cessent de correspondre au code.


Ce que le programme paie

Taux30 % du montant net de la facture
Duree12 mois calendaires, a partir de la premiere facture payee du filleul
Fenetre d'attribution du lien60 jours apres le clic (AFFILIATE_ATTRIBUTION_DAYS)
Retention avant versement30 jours par commission
Seuil minimum de versement50 $ (AFFILIATE_MIN_PAYOUT_USD) : en dessous, la commission reste due et s'accumule
Mode de versementEn argent, via Stripe Connect
Qui peut parrainerTout compte, y compris Free
Tableau de bord detailleInclus 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/affiliatePublicPorte d'entreeCe qu'elle ajoute
Clientstout compte, Free incluslien immediatla mise en relation par formulaire, lue par une personne
Createurscreateurs, newsletters, podcastslien immediat, aucune candidaturele 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. payAffiliateCommissions saute tout affilie dont StripeConnectAccount.status n'est pas ENABLED. Un affilie qui n'a jamais fait l'onboarding accumule du due sans 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)
DeclencheurPOST /api/credits/redeeminvoice.paid
Assiettele grant de credits du codela facture d'abonnement, nette
Formecredits plateformeargent, Stripe Connect
Frequenceune fois12 mois calendaires
LigneAffiliateRedemption.affiliateCommissionEarnedAffiliateCommission

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 :

MaillonOuCe qui manquait
Capturecomponents/shared/refer/referral-capture.tsx -> modules/analytics/acquisition.tscaptureAcquisition() existait sans aucun appelant : le cookie bec_acq n'etait jamais ecrit
Attributionservices/billing/referral-link.ts, appele par POST /api/organizationsrien ne transformait un ref stocke en AffiliateRedemption
CommissionaccrueCommissionForInvoice sur invoice.paiddeja 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 :

RefusPourquoi c'est de l'argent
Auto-parrainageOuvrir 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 poseeUne 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 joursMesuree 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 creditsCes 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 :

RefusRaison
Ce n'est pas un CAC blendeL'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 LTVUne 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 directLa 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 :

StatutCout cashEngagePourquoi
paidamount—transfere
due—amountaccru 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 stripeTransferIdamount - 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.