> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cel-eleague.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Transferts mercato premium

# 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é.
