Parrainage (referral)¶
Extrait du domain map API, qui était chargé dans chaque session agent. La rule garde l'index module → contexte ; le détail vit ici, à côté du code.
Modules : investor-referral, referrals. Les cartes cadeaux ont leur propre doc :
gift-card/docs/.
Referral - subtilites :
- Le lien parrain est cree automatiquement a la validation KYC (pas a l'inscription)
- Le code parrain est utilisable X jours apres validation KYC (post-KYC). Avant KYC : pas de deadline
- Campagnes avec priorites : Birthday Bricks gagne si sa valeur est >= Classic/Premium (ties en faveur de Birthday Bricks). Classic est teste avant Premium. Premium utilise 365j de durabilite au lieu de la duree du lien
- Le cap annuel bloque les rewards au moment du paiement, pas a la creation
- L'unblocking par invoice doit matcher exactement le cumul des montants bloques (pas de partiel)
- Le compte company peut etre referrer mais ne recoit pas de reward
- Referrer supprime → pas de reward referrer cree. Les rewards existants ne sont pas annules
- Refund d'un achat confirme → annulation de tous les rewards (tous statuts) associes a ce purchaseWtId. Ne s'applique que si purchase.status === 'confirmed'
Referral payout automatique (cron referral-primary-purchase-auto-payout) :
- Cron quotidien a 07:00 UTC qui paie les parrainages des projets eligibles, sans intervention admin (1h avant le cron de remboursement de capital investor-payout-capital-repayment a 08:00 UTC). Il n'existe plus de route admin pour declencher un payout manuel : si le cron rencontre un probleme, on le corrige et le rattrapage se fait au prochain run (les paiements de parrainage ne sont pas urgents)
- Source de verite "deja traite" : properties."referralPaidAt", set dans la meme transaction que les WT refer_referrer / refer_referee. Toujours set apres traitement par le cron, meme s'il n'y avait rien a verser (cas no-op : early return avec log, notification Slack referralPayoutNoOp, skip customer.io) — semantique "0 paye reste paye". Backfille en migration pour les projets ayant deja recu un payout via la route admin historique
- Hold temporaire (ops) : properties."isReferralPayoutBlocked" (defaut false). Si true, le cron skippe le projet (referralPaidAt NULL, rewards pending, pas de WT / Lemonway / mail, cap journalier intact). La migration de hold remet referralPaidAt a NULL si NULL ou sentinelle 2000-01-01 (ancien hold), jamais un vrai payout. Retool reste sur payout-to-be-done. Recap Slack : section dediee si le projet est encore eligible (funding success + statut decaissement + delai primaryAutoPayoutDaysAfterFirstRevenue / primaryAutoPayoutDaysAfterFundingSuccessWithoutFirstRevenue) et qu'il reste des rewards pending (parrainage non annule). Debloquer : SET "isReferralPayoutBlocked" = false via le BO Projets (PATCH /administration/property/:projectId/referral-payout-block, menu Actions) ou SQL. Canon SQL historique : V202609021400__hold-ef260-referral-payout.sql. Le BO n'autorise le hold que s'il reste des rewards primary pending, referralPaidAt IS NULL, collecte reussie, statut de decaissement, et soit un premier property_dividends existe, soit la cloture funding remonte a primaryAutoPayoutRecapDaysWithoutFirstRevenueMin jours. Lever le hold n'exige plus la fenetre ni des rewards pending.
- Eligibilite : funding.ended.type = 'success', financialStatus ∈ disbursementAllowedStatuses (repayment-ongoing, repayment-delay, repayment-default, repayment-finished), referralPaidAt IS NULL, et soit (1) au moins une ligne property_dividends avec MIN(createdAt) >= businessRules.referral.primaryAutoPayoutDaysAfterFirstRevenue jours (premier versement revenus investisseur effectif, independamment du sequestre), soit (2) aucune ligne property_dividends et funding.ended.at <= NOW() - businessRules.referral.primaryAutoPayoutDaysAfterFundingSuccessWithoutFirstRevenue jours. Pas de filtre hold ni EXISTS pending reward : getEligibleProjectsForAutomaticReferralPayout renvoie aussi les rows isReferralPayoutBlocked (le cron paie seulement false). Un projet eligible sans reward a verser est process une fois pour flag referralPaidAt, jamais re-pris ensuite
- Ordre : oldest-first par funding.propertyPublishDate (date de publication du projet, pas la date de reception des fonds par le porteur)
- Cap journalier : maximum 2 projets payes par jour calendaire Paris (Europe/Paris), compte derive de COUNT(*) FROM properties WHERE ("referralPaidAt" AT TIME ZONE 'Europe/Paris')::date = (NOW() AT TIME ZONE 'Europe/Paris')::date. Une retry Graphile dans la meme journee ne peut pas depasser le cap (les projets deja payes sont filtres par la query d'eligibilite, et leur referralPaidAt compte vers le cap)
- Retry : property-not-found et referral-payout-not-allowed sont skip (perte d'eligibilite entre fetch et lock). referral-payout-provision-transfer-failed (Lemonway pre-commit) ou throw inattendu avant commit → throw final pour que Graphile retry. Les echecs post-commit (Customer.io, Slack final) sont best-effort dans le service : le payout reste reussi, alerte Slack sur le thread, pas de retry Graphile (le projet a deja referralPaidAt set)
- Provision Lemonway synchrone : transferFundsFromMainAccountToReferralSubaccount reste dans la meme transaction DB qu'avant (cf. ReferralPayoutService.triggerPayoutOfReferralsForProject). Idempotence garantie par attemptP2PAtLemonway qui detecte les references dupliquees (referral-rewards-${businessId}-${projectId.slice(0, 8)}, le businessId rend la ref lisible cote Lemonway)
- Recap Slack post-run (#suivi-versement-parrainage, id C0850H45JTD) : query getEligibleProjectsForAutomaticReferralPayout au debut (inclut les rows isReferralPayoutBlocked) ; payout / recap eligibles en attente sur les non-bloqués (slice cap) ; recap blocked = mêmes rows isReferralPayoutBlocked avec pendingRewardsSumCents > 0 (parrainage non annule), libelle 1er revenu versé : oui (JJ/MM/AAAA) ou non selon firstRevenueAt (MIN(property_dividends.createdAt)). Query dediee getProjectsNearingFallbackPayoutWithoutFirstRevenue : sans property_dividends, fenetre J+50–J+59 (primaryAutoPayoutRecapDaysWithoutFirstRevenueMin / primaryAutoPayoutDaysAfterFundingSuccessWithoutFirstRevenue, borne haute exclusive), referralPaidAt IS NULL, isReferralPayoutBlocked = false — disjointe des eligibles J+60+ fallback. Query dediee getProjectsWaitingDelayAfterFirstRevenue : premier property_dividends plus recent que primaryAutoPayoutDaysAfterFirstRevenue jours, referralPaidAt IS NULL, isReferralPayoutBlocked = false — libelle versement à venir le JJ/MM/AAAA (date = 1er revenu + 7j, fuseau Paris). Message envoye si au moins une liste non vide. Skip recap si cap journalier deja atteint en debut de run