Transferts, mercato et premium
Cette page documente les règles vérifiées côté backend (Convex) pour les mouvements de joueurs.
Sources principales :
convex/social/transfers.ts
convex/social/transfers_core.ts
convex/social/transfers_queries.ts
convex/social/transfers_effects.ts
convex/social/recruitment_policy.ts
convex/social/contracts.ts
convex/lib/transfer_windows.ts
convex/_generated/ai/guidelines.md (lu avant toute modification backend)
Types et statuts
Types de mouvement :
INVITATION
JOIN_REQUEST
TRANSFER_REQUEST
KICK
LEAVE
Statuts possibles :
PENDING
APPROVED
EXECUTED
REJECTED
CANCELLED
EXPIRED
Matrice d’autorisation des flux recrutement
Règles de base côté mut. createInvitation
- acteur authentifié requis.
- autorisation sur le club cible via
assertActorCanManageClub(..., includeCoach: true).
- rôles acceptés : GM,
MANAGER, CO_MANAGER, COACH, ADMIN.
Règles de base côté mut. createJoinRequest
- acteur authentifié requis.
- joueur cible résolu par
playerProfileId ou joueur connecté.
- si acteur non-admin, l’acteur doit être le joueur visé.
- pas de validation de staff requis côté création, uniquement restriction joueur/adversaire.
respond (INVITATION / JOIN_REQUEST)
INVITATION : seul le joueur invité (actor.id === playerProfile.userId) ou ADMIN.
JOIN_REQUEST : staff de club cible via assertActorCanManageClub(..., includeCoach: true).
cancel (INVITATION / JOIN_REQUEST)
- le joueur concerné peut annuler sa demande/invitation.
- le staff de gestion recrutement du club (GM + managers + co-managers + coaches + admin via autorisation) peut annuler.
Vue compacte :
| Action | Acteur joueur visé | GM | MANAGER | CO_MANAGER | COACH | ADMIN | Note |
|---|
createInvitation | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | includeCoach: true |
createJoinRequest | ✅ | ⚪ | ⚪ | ⚪ | ⚪ | ✅ | acteur joueur lui-même ou admin |
respond sur INVITATION | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | seul invité/admin |
respond sur JOIN_REQUEST | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | via gestion recrutement |
cancel | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | joueur concerné ou staff |
(⚪ : non requis par ce flux en création ; non une exemption générale.)
Différence Invitation vs Join Request
createInvitation (club → joueur)
- Vérifie :
- profil joueur actif (compte actif).
- pas déjà actif dans le club cible.
- pas de mouvement
INVITATION déjà en attente pour ce club.
- capacité d’effectif.
- Cas joueur déjà en activité ailleurs :
- bloqué sauf si joueur premium +
openToOffers === true + staff invitant pas issu du club actuel du joueur.
- Calcule TTL via
resolveMovementTtlHours('INVITATION', leagueId) + borne fenêtre (si active).
- Crée
INVITATION en PENDING.
sourceManagerDecision reste undefined ; playerDecision à PENDING.
createJoinRequest (joueur → club)
- Vérifie :
- acteur peut agir sur ce joueur.
- le joueur ne doit pas avoir de membership actif.
- un seul
JOIN_REQUEST en attente par joueur+club.
- capacité d’effectif.
- Calcule TTL via
resolveMovementTtlHours('JOIN_REQUEST', leagueId) + borne fenêtre.
- Crée
JOIN_REQUEST en PENDING.
playerDecision absent ; sourceManagerDecision = PENDING.
Réponse
INVITATION : acceptation ou refus côté joueur.
JOIN_REQUEST : acceptation/ refus côté staff du club cible.
Cap effectif + passage de plafond
- Vérification centrale
assertClubRosterCapacityAllowsJoin :
- cap =
league.maxActiveRoster (+10 si club premium).
- compte seulement les memberships actifs (
IN_CLUB, leftAt absent), puis soustrait le joueur concerné.
- Rejet
CLUB_ROSTER_LIMIT_REACHED si plafond atteint.
- Champ
premiumBonus fourni en erreur pour distinguer cap premium (10) vs non premium (0).
Annulation / expiration
Mécanisme d’expiration
ensureTransferPendingAndNotExpired :
- si
expiresAt <= now sur un transfert PENDING ⇒ patch status = EXPIRED.
- envoi évènements
transfer.invitation.expired ou transfer.join_request.expired.
respond vérifie d’abord l’expiration (timestamp), puis la fonction ensure... (sécurité complémentaire).
cancel appelle aussi ensure....
respond
status !== PENDING : retourne le mouvement tel quel.
- refus :
status = REJECTED,
- décision
playerDecision ou sourceManagerDecision mise à jour selon type,
- audit
REJECT_TRANSFER.
- acceptation :
- vérifie fenêtre de mercato (sauf conditions de bypass détaillées ci-dessous),
- vérifie capacité,
- ferme membership source si joueur actif ailleurs + openToOffers premium,
- crée membership
IN_CLUB + role = MEMBER,
- passe mouvement en
EXECUTED,
- annule les autres mouvements en attente du joueur (
CANCELLED) avec événements de cascade.
cancel
- statut final
CANCELLED,
- acteur : joueur visé ou staff club,
- événement dédié, audit
REJECT_TRANSFER avec decision: CANCELLED.
Règles hors fenêtre, premium, openToOffers
Politique centralisée dans recruitment_policy.ts via assertOffWindowRecruitmentAllowed :
- pendant fenêtre de ligue : autorisation directe.
- hors fenêtre, selon le nombre de passages de saison (invité/jouable, exécutions
INVITATION + JOIN_REQUEST) :
- 0 passage : autorisé.
- 1 passage :
- joueur premium : autorisé.
- joueur non-premium :
- club non premium : refus,
- club premium : autorisé si quota club non utilisé (
< 1 recrutement non-premium hors fenêtre dans la saison).
- 2+ passages : refus (
OFF_WINDOW_PLAYER_TOO_MANY_CLUBS_THIS_SEASON).
openToOffers (joueur) :
- utilisé en création d’invitation quand joueur actif ailleurs :
- inviteur doit pouvoir agir et ne pas être staff du club actuel du joueur,
- joueur doit être premium.
- lors de l’acceptation d’invitation sans
sourceClubId, la vérif off-window s’applique toujours via assertOffWindowRecruitmentAllowed.
Conséquence observée :
- un joueur premium actif ailleurs peut recevoir une invitation hors fenêtre selon ces règles, puis être libéré de son ancien(s) club(s) après acceptation.
- Listing free agents (
/mercato → onglet « Recherche », social/freeAgents:list) : un joueur non recrutable hors fenêtre (typiquement 2+ passages, OFF_WINDOW_PLAYER_TOO_MANY_CLUBS_THIS_SEASON) n’est plus masqué — il est affiché avec recruitable: false + recruitmentBlockReason (le message de refus). La carte le marque « NON RECRUTABLE » et toute tentative d’invitation déclenche un toast explicatif ; le recrutement reste refusé côté serveur via assertOffWindowRecruitmentAllowed. Seul le refus pour contexte ligue manquant (OFF_WINDOW_LEAGUE_CONTEXT_REQUIRED) reste masqué (erreur de configuration, pas une propriété du joueur).
- La recherche du listing est poussée au serveur (nom d’affichage + username + gamertag EA) et bornée (cap + « Charger plus »), afin qu’un agent libre reste trouvable au-delà de la première page.
Premium contract break
Mutation premiumBreak (convex/social/contracts.ts) :
- acteur doit être un joueur premium (
users.isPremium).
- nécessite un club actif et un contexte de saison de ligue.
- une seule utilisation max par saison (
premium_contract_breaks unique playerProfileId + season).
- fermeture de memberships actifs du club actuel (
IN_CLUB -> FREE_AGENT).
- création d’un transfert
LEAVE en EXECUTED (reason = PREMIUM_BREAK_CONTRACT).
- création
premium_contract_breaks et audit.
- événement
transfer.premium_break.executed.
Anomalies front/back observées (non règles produit)
useClubInvitations interroge listForClubInbox avec status: 'PENDING'; côté backend, la query peut retourner d’autres statuts.
-> le suivi recrutement ne montre pas les mouvements EXPIRED/REJECTED/CANCELLED (non vérifié comme règle produit).
ClubRecruitment.tsx reconstruit certains objets club avec logo au lieu de logoUrl attendu par le modèle d’enrichissement.
ClubRecruitment force localement type (JOIN_REQUEST / INVITATION) au moment du rendu ; ça masque la valeur venue du backend.
- Déviation mineure de contrat front-end : enum de status front contient
ACCEPTED alors que les statuts persistés validés sont ceux de TRANSFER_STATUS_VALUES (PENDING, APPROVED, EXECUTED, REJECTED, CANCELLED, EXPIRED).
- Les utilitaires
isInvitationExpired / filterActiveInvitations existent côté front mais ne sont pas systématiquement injectés dans tous les parcours listage (non vérifié par la couverture UI complète).
Premium Stripe (portée)
L’état premium global du club/joueur peut passer par synchronisation Stripe (users.isPremium, clubs.isPremium) ; les statuts Premium éligibles côté Stripe reconnus côté code existent (trialing, active), les autres se comportent comme non-premium selon implémentation.
Remarque
L’écart entre logique backend (TRANSFERS) et représentation front doit être traité au cas par cas, sans en faire une règle métier tant que l’alignement n’est pas décidé.Last modified on June 26, 2026