Contrat API — Panier et commande (solde J+SERVICES)
Version 1 — 22/08/2026. Base : https://live.jmoai.net
Authorization: Bearer <jeton client> obligatoire : on débite un portefeuille, il faut donc
savoir celui de qui. Le client vient du jeton, jamais du corps de la requête.
Répond à la note « Panier & Facturation » de l’équipe frontend. Les noms de champs demandés
(status, error_code, order, invoice_ref…) sont respectés : l’écran est déjà écrit, le
faire changer pour satisfaire une convention n’apporterait rien à l’utilisateur.
1. La règle qui décide de tout : qui vend ?
Le mode de paiement n’est pas un choix de l’application, c’est une propriété du vendeur :
| Article vendu par | Paiement | Pourquoi |
|---|---|---|
| J+SERVICES | solde J+SERVICES (ce contrat) | nous vendons, donc nous encaissons |
| un partenaire | son agrégateur (action_behavior) |
l’argent doit tomber chez LUI |
Un article de partenaire mis au panier est refusé avec VENDEUR_EXTERNE. Ce n’est pas une
limite technique : encaisser sa vente sur notre solde ferait de nous son débiteur — séquestre,
reversement, trésorerie à surveiller. C’est un métier, pas une ligne de code.
Côté interface : n’affichez le bouton « Ajouter au panier » que sur les articles dont
action_behavior.mode vaut in_app_store et dont le vendeur est J+SERVICES. Les autres
gardent « Acheter chez le vendeur ».
2. Vérifier le panier — avant de débiter
POST /api/v1/store/orders/verifier
{ "currency": "XOF",
"items": [ { "product_id": "hw-hap-ax4", "quantity": 2 } ] }
{
"status": "success",
"panier": {
"lignes": [
{
"designation": "MikroTik hAP ax4",
"quantite": 2,
"prix_unitaire": 85000,
"total": 170000,
"product_id": "hw-hap-ax4"
}
],
"total": 170000,
"currency": "XOF",
"payment_method": "J_SERVICES_BALANCE"
}
}
Appelez-la à l’ouverture du tiroir. Elle ne débite rien et rend le vrai total : une surprise au moment du débit est la pire des surprises.
3. Payer
POST /api/v1/store/orders/checkout
{ "currency": "XOF",
"items": [ { "product_id": "hw-hap-ax4", "quantity": 2, "unit_price": 85000 } ],
"reference": "ord_optionnel" }
{
"status": "success",
"order": {
"order_id": "ord_1396345dc835",
"invoice_ref": "FAC-2026-000002-AE4A",
"total_paid": 170000,
"currency": "XOF",
"payment_method": "J_SERVICES_BALANCE",
"transaction_date": "2026-08-22T21:05:00Z",
"invoice_pdf_url": "https://live.jmoai.net/api/v1/marketplace/factures/FAC-2026-000002-AE4A?t=…",
"invoice_pdf_expire_le": "2026-12-20T…",
"solde_restant": 30000
}
}
Quatre points qui diffèrent de la note initiale — et pourquoi
unit_price est ignoré. Le serveur facture le prix du catalogue. Un panier ouvert depuis
vingt minutes, une page en cache ou un appel forgé reviennent au même : faire confiance au client
sur le prix, c’est le laisser fixer nos prix. Si l’écart existe, la commande est refusée avec
PRIX_MODIFIE et le nouveau total — à l’acheteur de confirmer. Débiter un montant différent de
celui affiché à l’écran est la définition de « prix affiché ≠ prix facturé ».
invoice_ref a la forme FAC-<année>-<séquence>-<vendeur>, pas INV-784512. C’est la même
série continue que toutes nos factures : elle doit rester justifiable devant un contrôle.
invoice_pdf_url est un lien SIGNÉ et daté, pas /invoices/INV-784512.pdf. Les numéros de
facture sont séquentiels, donc devinables : une URL non signée laisserait n’importe qui parcourir
les factures des autres. Le lien vaut pour une facture et une seule, et il expire — mais il
n’exige aucune session, donc le bouton « Télécharger » marche même hors connexion à l’app.
solde_restant est rendu en plus : c’est la question suivante de l’utilisateur, autant y
répondre tout de suite.
Réponse en cas d’échec
{
"status": "error",
"error_code": "INSUFFICIENT_FUNDS",
"message": "Solde insuffisant pour effectuer cet achat.",
"details": { "solde": 50000, "requis": 170000, "manquant": 120000, "currency": "XOF" }
}
| HTTP | error_code |
Ce que l’écran doit faire |
|---|---|---|
| 402 | INSUFFICIENT_FUNDS |
proposer de recharger — details.manquant donne le montant exact |
| 409 | PRIX_MODIFIE |
afficher le nouveau total (details.total_actuel) et redemander confirmation |
| 409 | OUT_OF_STOCK |
retirer la ligne, details.product_id la désigne |
| 409 | VENDEUR_EXTERNE |
cet article s’achète chez le vendeur, pas au panier |
| 404 | PRODUCT_NOT_FOUND |
l’article n’existe plus — rafraîchir le catalogue |
| 401 | — | session expirée |
4. Ce que le serveur garantit
- Un double appui ne débite qu’une fois. Passez la même
referenceet le second appel rend la commande existante sans nouveau débit. Sansreference, chaque appel est une commande distincte — envoyez-en une si le bouton peut être pressé deux fois. - Le portefeuille reste équilibré. Chaque achat est un mouvement en partie double :
somme des soldes = 0, vérifiable à tout instant. Aucun achat ne crée ni ne détruit d’argent. - La facture est émise dans la foulée, avec la même numérotation que les ventes encaissées chez les agrégateurs. Un seul registre de chiffre d’affaires, pas deux.
- Le client est notifié (push + e-mail) avec le lien de sa facture. Ne bloquez pas l’écran là-dessus : l’envoi est asynchrone et réessayé.
5. Stock
GET /api/v1/marketplace/hardware renvoie désormais inStock (booléen) à côté de
stockStatus — le badge « en rupture » n’a plus à interpréter quatre valeurs.
stock_quantity n’est pas renvoyé : nous ne tenons pas de compteur d’unités sur ce
catalogue. Annoncer « 45 en stock » sans savoir le décrémenter à la vente afficherait un chiffre
faux au deuxième acheteur — pire que pas de chiffre du tout. Le jour où les quantités seront
tenues (boutiques partenaires), le champ apparaîtra.
stockStatus |
inStock |
|---|---|
in_stock, low_stock, preorder |
true |
out_of_stock |
false |