Skip to content

Missions / Prestations

Le type GraphQL exposé par l'API s'appelle Mission, mais dans le produit et l'UX cette entité est nommée « collaboration » — les deux vocabulaires désignent exactement la même chose. Une intégration doit s'attendre à voir Mission partout dans le schéma (types, mutations, enums) alors que l'interface utilisateur parle de « collaboration » ou de « prestation ». Cette page décrit le cycle de vie complet : création, négociation par devis/facture, paiement Stripe en deux temps (acompte + solde), litige, et les trois notions de visibilité distinctes qui s'appliquent à une mission.

Parties prenantes

Une Mission relie deux entreprises :

  • Émetteur : company (companyId) / user (userId) — celui qui initie la demande.
  • Cible : partnerCompany (partnerCompanyId) / partnerService (partnerServiceId) — l'entreprise/service sollicité. partnerUser (partnerUserId) n'est renseigné qu'au moment où un membre du service cible accepte la proposition (voir plus bas).

Une mission peut optionnellement être rattachée à un Project (projectId) et à un Service précis de l'émetteur (serviceId).

Cycle de vie

mermaid
stateDiagram-v2
    [*] --> PROPOSITION : createMission
    PROPOSITION --> NEGOTIATION : acceptMission
    PROPOSITION --> REFUSED : refuseMission
    PROPOSITION --> CANCELED : updateMissionStatus (émetteur) / cron 14j
    NEGOTIATION --> CANCELED : updateMissionStatus (émetteur)
    NEGOTIATION --> REFUSED : refuseMission
    NEGOTIATION --> IN_PROGRESS : paiement acompte Stripe
    IN_PROGRESS --> COMPLETED : paiement solde Stripe
    IN_PROGRESS --> COMPLETED : updateMissionStatus (partenaire)
    IN_PROGRESS --> CANCELED : updateMissionStatus (partenaire)
    IN_PROGRESS --> DISPUTED : openMissionDispute
    DISPUTED --> REFUNDED : résolution admin
    DISPUTED --> IN_PROGRESS : résolution admin
    COMPLETED --> [*]
    REFUSED --> [*]
    CANCELED --> [*]
    REFUNDED --> [*]

MissionStatus : PROPOSITION, NEGOTIATION, IN_PROGRESS, COMPLETED, REFUSED, CANCELED, DISPUTED, REFUNDED.

La machine à états est appliquée côté serveur : toute transition non listée ci-dessous est rejetée avec une UnauthorizedException :

  • Depuis PROPOSITION : l'émetteur (créateur de la mission) ne peut que passer à CANCELED. Un membre non-pending du partnerService cible peut passer à NEGOTIATION (via acceptMission, voir ci-dessous) ou REFUSED (via refuseMission).
  • Depuis NEGOTIATION : seul l'émetteur peut agir, et uniquement vers CANCELED. Il n'existe aucune transition manuelle de NEGOTIATION vers IN_PROGRESS — voir la section paiement plus bas.
  • Depuis IN_PROGRESS : seul le partnerUser (celui qui a accepté la proposition) peut agir, uniquement vers COMPLETED ou CANCELED.
  • Depuis COMPLETED, CANCELED, REFUSED : aucune transition manuelle n'est autorisée, quel que soit l'appelant.
  • DISPUTED et REFUNDED ne sont jamais atteints via updateMissionStatus — ce sont des transitions système (voir litiges plus bas).

status passe respectivement à NEGOTIATION/IN_PROGRESS/COMPLETED en même temps que les horodatages negotiatedAt/startedAt/finishedAt.

Création et acceptation

createMission

Crée la Mission en statut PROPOSITION, avec isBlurred: true par défaut.

acceptMission

Fait passer la mission de PROPOSITION à NEGOTIATION. Seul un membre non-pending du partnerService cible peut accepter — les membres en attente (isPending: true) sont exclus. L'appel échoue si la mission n'est pas en PROPOSITION.

Effets de bord de acceptMission :

  • isBlurred repasse à false — l'émetteur n'est plus anonymisé aux yeux du partenaire (et réciproquement, voir section visibilité).
  • partnerUserId est fixé sur l'utilisateur qui accepte — c'est lui qui devient le seul habilité aux transitions ultérieures depuis IN_PROGRESS.
  • Un Chat est créé (ou réutilisé s'il existe déjà entre les deux mêmes utilisateurs/entreprises) et rattaché à la mission (chatId) — c'est le canal de discussion pour la suite de la négociation.

refuseMission

Fait passer la mission de PROPOSITION ou de NEGOTIATION à REFUSED. La validation appliquée est distincte de celle de la mutation updateMissionStatus. Deux conditions, indépendantes du rôle de créateur de la mission : la mission doit être en PROPOSITION ou NEGOTIATION, et l'appelant doit être un membre non-pending du partnerService cible. L'émetteur de la mission n'est jamais autorisé à refuser, y compris depuis NEGOTIATION.

Négociation par devis/facture — MissionDocument

Une fois en NEGOTIATION, les parties échangent des MissionDocument (devis ou facture) plutôt que de modifier directement le prix de la Mission.

MissionDocumentType : QUOTE, INVOICE, OTHER. MissionDocumentStatus : DRAFT, SENT, ACCEPTED, REFUSED, EXPIRED.

  • createMissionDocument : uploade un fichier (compressé puis stocké via le service d'upload) et crée le MissionDocument en statut SENT par défaut. Un contrôle d'accès vérifie que l'appelant a accès à la Mission ciblée.
  • acceptMissionDocument : ne peut être appelé que par l'utilisateur ayant le rôle TARGET sur le document (MissionDocumentUserRole : AUTHOR, TARGET, PARTNER), et uniquement si le document est en SENT. Fait passer le document à ACCEPTED.
  • refuseMissionDocument : même contrainte de rôle (TARGET) et de statut (SENT). Requiert un reason non vide. Accepte optionnellement une contre-offrecounterOfferAmount (centimes, BigInt) et/ou counterOfferDays — stockée sur le document refusé, à charge pour l'auteur de créer un nouveau MissionDocument (parentDocumentId pointant vers le précédent) intégrant cette contre-offre.

Le versionnement se fait via parentDocumentId/versions et un champ version incrémental — un nouveau document envoyé en réponse à un refus référence son prédécesseur plutôt que de le modifier en place.

Le fichier du document (fileName/fileUrl) n'est jamais exposé en clair : fileUrl est une URL S3 pré-signée, résolue dynamiquement à chaque lecture — ne stockez jamais l'URL retournée pour un usage différé, elle expire.

Paiement — le moteur exclusif de NEGOTIATIONIN_PROGRESS, un des deux chemins vers COMPLETED

Il n'existe aucune mutation permettant de faire passer une mission de NEGOTIATION à IN_PROGRESS directement — depuis NEGOTIATION, seule la transition NEGOTIATION → CANCELED par l'émetteur est autorisée : le passage à IN_PROGRESS est exclusivement déclenché par la confirmation du paiement de l'acompte Stripe côté serveur (webhook).

En revanche IN_PROGRESS → COMPLETED a deux chemins distincts, tous deux légitimes :

  1. La confirmation du paiement du solde Stripe côté serveur (webhook) — décrit ci-dessous.
  2. Le partenaire (partnerUser, celui qui a accepté la proposition via acceptMission) peut faire passer la mission à COMPLETED — ou CANCELED — manuellement via updateMissionStatus, sans paiement requis (voir aussi la section « Cycle de vie » plus haut).

Les deux paiements (acompte et solde) sont traités côté serveur selon le PaymentType du paiement confirmé :

PaymentTypeTransition appliquée
SERVICE_DEPOSITMission.status → IN_PROGRESS
SERVICE_BALANCEMission.status → COMPLETED

Concrètement, une intégration doit déclencher ces paiements via deux mutations dédiées, puis attendre la confirmation asynchrone (webhook Stripe) plutôt que de tenter de forcer le statut :

  • createServiceDepositCheckout(missionId, successUrl, cancelUrl) : réservé à l'émetteur de la mission. Exige que la mission soit en NEGOTIATION et qu'un devis (QUOTE) ait été accepté au préalable — sinon BadRequestException. Le montant de l'acompte est price × depositPercentage / 100, arrondi ; depositPercentage vaut 30 par défaut.
  • createServiceBalanceCheckout(missionId, successUrl, cancelUrl) : réservé également à l'émetteur, exige que la mission soit en IN_PROGRESS. Le montant facturé est price - (somme des acomptes déjà réglés) — pas nécessairement price × 70 %, si plusieurs paiements partiels ont eu lieu.

Les deux mutations retournent { checkoutUrl, paymentId } — l'intégration redirige l'acheteur vers checkoutUrl (session Stripe Checkout côté compte connecté du partenaire, Stripe Connect). La confirmation de la commande côté Mission n'arrive pas en retour direct de ces mutations : elle est asynchrone, déclenchée par le webhook Stripe. Un client doit donc re-interroger la mission (ou écouter les notifications applicables) après retour sur successUrl, plutôt que de supposer que le statut a déjà changé.

Une commission plateforme de 5 % est prélevée comme application_fee Stripe Connect sur chaque paiement (acompte et solde) — elle réduit le montant reversé au partenaire, elle n'est pas ajoutée au-dessus du prix facturé à l'acheteur.

Les montants (price, counterOfferAmount, refundAmount) sont des BigInt en centimes ; voir les conventions générales de sérialisation BigInt/GraphQLBigInt sur la page Conventions API. Exception notée sur cette même page : depositPercentage est un Float (pourcentage), pas un montant en centimes.

Litige — MissionDispute

Un litige ne peut être ouvert que sur une mission en IN_PROGRESS :

  • openMissionDispute(missionId, reason, message) : ouvrable par l'émetteur (BUYER) ou par un membre de l'entreprise partenaire (PARTNER) — déterminé via DisputeOpenedByRole. Refuse si la mission n'est pas IN_PROGRESS, ou si un litige OPEN existe déjà pour cette mission (une mission porte au plus un litige). À l'ouverture, Mission.status passe systématiquement à DISPUTED (transition système, pas via updateMissionStatus).

DisputeStatus : OPEN, RESOLVED. DisputeOutcome : REFUND_BUYER, FAVOR_PARTNER. DisputeOpenedByRole : BUYER, PARTNER.

Le litige est résolu par une opération d'administration séparée, non accessible aux clients intégrateurs — un administrateur tranche entre les deux issues possibles :

  • REFUND_BUYER : les paiements de la mission sont remboursés et Mission.status → REFUNDED.
  • FAVOR_PARTNER : aucun remboursement, Mission.status → IN_PROGRESS (la mission reprend son cours).

Cette opération n'est pas accessible via une mutation USER — une intégration côté client ne peut pas résoudre un litige elle-même, seulement en ouvrir un et en consulter le statut résultant sur la Mission/MissionDispute.

Visibilité — trois notions distinctes à ne pas confondre

ChampPortéeEffet
Mission.isBlurredPar mission, tant que non acceptéeAnonymise l'identité de l'émetteur aux yeux du partenaire cible tant que la mission est en PROPOSITION — passe à false dès acceptMission.
HideMissionPar (mission, utilisateur)Table de jointure permettant à un utilisateur de masquer une mission de sa propre liste, sans affecter les autres parties.
Company.isHiddenPar entreprise, transverse à tous les modulesHors périmètre de ce module — rend une entreprise entièrement invisible côté plateforme, indépendamment de ses missions.

Ces trois mécanismes sont indépendants : une mission peut être isBlurred: false (déjà acceptée) mais masquée dans la liste d'un utilisateur particulier via HideMission, par exemple.

Requêtes

  • myMissions : missions où l'utilisateur courant est impliqué (émetteur ou partenaire), toutes entreprises confondues pour cet utilisateur.
  • myMission(id) : une mission précise, scoping identique.
  • mission(where) / missions(where) : accès générique protégé par contrôle d'accès par entité.
  • missionDocument(where) / missionDocuments(where) : automatiquement filtrées aux missions où l'utilisateur courant est userId ou partnerUserId — un appelant ne peut pas lister les documents d'une mission à laquelle il n'est pas partie, même en connaissant l'ID.

Nettoyage automatique

Une proposition (PROPOSITION) restée sans réponse plus de 14 jours est automatiquement annulée par une tâche quotidienne côté serveur. L'annulation applique la même transition que l'annulation manuelle, avec l'émetteur comme acteur — une intégration ne doit donc pas supposer qu'une PROPOSITION ancienne reste éternellement actionnable.