Skip to main content

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 :
ActionActeur joueur viséGMMANAGERCO_MANAGERCOACHADMINNote
createInvitationincludeCoach: true
createJoinRequestacteur joueur lui-même ou admin
respond sur INVITATIONseul invité/admin
respond sur JOIN_REQUESTvia gestion recrutement
canceljoueur 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