Appearance
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 dupartnerServicecible peut passer àNEGOTIATION(viaacceptMission, voir ci-dessous) ouREFUSED(viarefuseMission). - Depuis
NEGOTIATION: seul l'émetteur peut agir, et uniquement versCANCELED. Il n'existe aucune transition manuelle deNEGOTIATIONversIN_PROGRESS— voir la section paiement plus bas. - Depuis
IN_PROGRESS: seul lepartnerUser(celui qui a accepté la proposition) peut agir, uniquement versCOMPLETEDouCANCELED. - Depuis
COMPLETED,CANCELED,REFUSED: aucune transition manuelle n'est autorisée, quel que soit l'appelant. DISPUTEDetREFUNDEDne sont jamais atteints viaupdateMissionStatus— 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 :
isBlurredrepasse àfalse— l'émetteur n'est plus anonymisé aux yeux du partenaire (et réciproquement, voir section visibilité).partnerUserIdest fixé sur l'utilisateur qui accepte — c'est lui qui devient le seul habilité aux transitions ultérieures depuisIN_PROGRESS.- Un
Chatest 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 leMissionDocumenten statutSENTpar défaut. Un contrôle d'accès vérifie que l'appelant a accès à laMissionciblée.acceptMissionDocument: ne peut être appelé que par l'utilisateur ayant le rôleTARGETsur le document (MissionDocumentUserRole:AUTHOR,TARGET,PARTNER), et uniquement si le document est enSENT. Fait passer le document àACCEPTED.refuseMissionDocument: même contrainte de rôle (TARGET) et de statut (SENT). Requiert unreasonnon vide. Accepte optionnellement une contre-offre —counterOfferAmount(centimes,BigInt) et/oucounterOfferDays— stockée sur le document refusé, à charge pour l'auteur de créer un nouveauMissionDocument(parentDocumentIdpointant 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 NEGOTIATION → IN_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 :
- La confirmation du paiement du solde Stripe côté serveur (webhook) — décrit ci-dessous.
- Le partenaire (
partnerUser, celui qui a accepté la proposition viaacceptMission) peut faire passer la mission àCOMPLETED— ouCANCELED— manuellement viaupdateMissionStatus, 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é :
PaymentType | Transition appliquée |
|---|---|
SERVICE_DEPOSIT | Mission.status → IN_PROGRESS |
SERVICE_BALANCE | Mission.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 enNEGOTIATIONet qu'un devis (QUOTE) ait été accepté au préalable — sinonBadRequestException. Le montant de l'acompte estprice × depositPercentage / 100, arrondi ;depositPercentagevaut30par défaut.createServiceBalanceCheckout(missionId, successUrl, cancelUrl): réservé également à l'émetteur, exige que la mission soit enIN_PROGRESS. Le montant facturé estprice - (somme des acomptes déjà réglés)— pas nécessairementprice × 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é viaDisputeOpenedByRole. Refuse si la mission n'est pasIN_PROGRESS, ou si un litigeOPENexiste déjà pour cette mission (une mission porte au plus un litige). À l'ouverture,Mission.statuspasse systématiquement àDISPUTED(transition système, pas viaupdateMissionStatus).
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 etMission.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
| Champ | Portée | Effet |
|---|---|---|
Mission.isBlurred | Par mission, tant que non acceptée | Anonymise l'identité de l'émetteur aux yeux du partenaire cible tant que la mission est en PROPOSITION — passe à false dès acceptMission. |
HideMission | Par (mission, utilisateur) | Table de jointure permettant à un utilisateur de masquer une mission de sa propre liste, sans affecter les autres parties. |
Company.isHidden | Par entreprise, transverse à tous les modules | Hors 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 estuserIdoupartnerUserId— 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.