Documentation J+SERVICES Guides Référence API

Contrat API — État de la boutique TiketMOMO (app mobile)

Date : 2026-08-13 Pour : l’app mobile Endpoint : GET /api/v1/clients/store/tiketmomo/readiness


1. À quoi ça sert

Répondre à une seule question, avant que le gérant ne se déplace : sa boutique peut-elle vendre et livrer, maintenant ?

Sans cet appel, l’app pose le portail, essaie, échoue, recommence — et personne ne sait quelle pièce manque. Cet état donne la liste complète des pièces manquantes en un appel.

2. Quand l’appeler

Ne pas le mettre en cache long : le stock bouge à chaque vente.

3. Réponse

Toujours 200, y compris quand la boutique n’est pas prête — ce n’est pas une erreur d’appel, c’est un constat.

{
  "success": true,
  "data": {
    "can_sell": false,
    "state": "NO_STOCK",
    "blocking_reason": "Stock vide : créez des tickets avant d’encaisser, sinon vos clients paieront sans rien recevoir.",

    "delivery": {
      "possible": false,
      "source": "NONE", // STOCK | RADIUS | DIRECT | NONE
      "reason": "tunnel disconnected : la frappe directe est impossible",
    },

    "checks": [
      { "code": "NO_LICENSE", "ok": true, "detail": null, "action": null },
      { "code": "NO_STORE_PROFILE", "ok": true, "detail": null, "action": null },
      { "code": "NO_SLUG", "ok": true, "detail": null, "action": null },
      { "code": "NO_OFFERS", "ok": true, "detail": null, "action": null },
      { "code": "ROUTER_NOT_LINKED", "ok": true, "detail": null, "action": null },
      {
        "code": "NO_STOCK",
        "ok": false,
        "detail": "stock vide",
        "action": "Stock vide : créez des tickets…",
      },
      {
        "code": "DELIVERY_IMPOSSIBLE",
        "ok": false,
        "detail": "tunnel disconnected…",
        "action": "Stock vide et routeur injoignable…",
      },
    ],

    "store_slug": "chez-moussa",
    "plan_code": "TIKETMOMO_PRO",
    "expires_at": "2027-01-31T23:59:59Z",
    "offers_count": 5,
    "vouchers_available": 0,
    "access_mode": "VPN",
    "radius_refill_available": false,
    "router": {
      "allocation_id": "uuid",
      "serial": "HH40AF7YVNP",
      "tunnel_status": "DISCONNECTED",
      "tunnel_last_seen_at": "2026-08-13T14:02:11Z",
    },
  },
}

4. La règle d’affichage qui compte

Afficher checks en entier, pas seulement blocking_reason.

Montrer un problème à la fois est exactement ce qui fait recommencer trois fois : le gérant corrige, réessaie, découvre le suivant, se redéplace. Une liste à cocher, tout de suite, lui évite les allers-retours.

blocking_reason sert au bandeau d’en-tête ; checks[].action remplit la liste. Chaque action est déjà rédigée en français, prête à afficher — ne pas la reformuler à partir du code.

5. delivery — la seule question qui décide si encaisser est sûr

Une vente encaissée mais non livrable laisse un client payé sans rien recevoir. delivery.source dit par où le ticket arrivera :

source Sens
STOCK des tickets sont déjà en réserve
RADIUS le serveur RADIUS regarnira le stock, sans passer par le routeur
DIRECT le ticket sera frappé sur le MikroTik au moment de la vente
NONE aucune voie — ne pas encaisser

Un vouchers_available: 0 n’est pas un blocage en soi : avec un tunnel connecté ou une licence RADIUS, le ticket sera créé à la volée. C’est delivery.possible qui fait foi, pas le compteur de stock.

6. Les états

state Ce que le gérant doit faire
READY rien, la boutique vend
NO_LICENSE aucune licence n’autorise la vente de tickets
NO_STORE_PROFILE ouvrir l’éditeur de portail et enregistrer la boutique une première fois
NO_SLUG choisir le nom public de la boutique
NO_OFFERS ajouter des forfaits, ou générer des tickets (ils servent alors de catalogue)
ROUTER_NOT_LINKED rattacher un routeur
NO_STOCK générer des tickets — aucune autre voie de livraison n’est disponible
DELIVERY_IMPOSSIBLE remettre le routeur en ligne, ou créer du stock

Les états sont ordonnés par dépendance : on nomme la cause la plus profonde. Inutile de reprocher un stock vide à quelqu’un qui n’a pas encore de boutique.

7. À ne pas faire

8. Le portefeuille — deux populations de vendeurs

GET /api/v1/store/tiketmomo/metrics renvoie, sous withdrawal_status, de quoi construire l’écran du portefeuille :

"withdrawal_status": {
  "available_balance": 0,
  "pending_balance": 0,
  "minimum_withdrawal": 3000,
  "withdrawals_enabled": true,      // gel administrateur — indépendant du mode ci-dessous
  "payout_mode": "MERCHANT",        // "PLATFORM" | "MERCHANT" | "NONE"
  "can_request_withdrawal": false,
  "payout_provider": "fedapay",     // slug de l'agrégateur, ou null si inconnu
  "payout_badge": "FedaPay",        // libellé prêt à afficher
  "payout_dashboard_url": "https://live.fedapay.com",   // peut être null
  "payout_notice": "Vos ventes sont encaissées directement sur votre compte FedaPay. Le retrait se fait depuis votre tableau de bord FedaPay.",
  "withdrawal_fee_schedule": {
    "provider": "fedapay",
    "minimum": 1000,
    "free_threshold": 5000,
    "transfer_cost": 100,
    "aggregator_rate": 0.018,
    "currency": "XOF",
    "examples": [
      { "amount": 1000, "fee": 100, "net": 900 },
      { "amount": 1500, "fee": 88, "net": 1412 },
      { "amount": 2000, "fee": 75, "net": 1925 },
      { "amount": 3000, "fee": 50, "net": 2950 },
      { "amount": 4000, "fee": 25, "net": 3975 },
      { "amount": 5000, "fee": 0, "net": 5000 }
    ]
  }
}

Ce que chaque mode veut dire

payout_mode L’argent est… Écran attendu
PLATFORM sur notre compte : nous le détenons, nous le lui devons bouton de retrait normal
MERCHANT déjà chez lui, sur son compte d’agrégateur badge + renvoi, pas de bouton
NONE aucune vente réussie : rien à trancher encore bouton normal

Ventes, solde et comptabilité (jour / semaine / mois / total) s’affichent pour tout le monde, quel que soit le mode. Seule l’action de retrait change.

En mode MERCHANT : remplacer le bouton, ne pas le griser

Un bouton grisé signifie « bloqué par eux » et déclenche un appel au support. Il n’y a rien à débloquer : l’argent n’est jamais passé chez nous. Affichez payout_badge sur le portefeuille, et payout_notice à la place du bouton — avec un lien vers payout_dashboard_url quand il est présent.

Si la demande est tentée quand même, elle est refusée avec un code dédié :

Code Sens
WITHDRAWAL_MERCHANT_ACCOUNT encaissement direct — le retrait se fait chez son agrégateur
INSUFFICIENT_FUNDS mode PLATFORM, mais le solde n’atteint pas le minimum

⚠️ Ne jamais confondre les deux. Un vendeur en encaissement direct a un solde retirable nul chez nous tout en ayant des centaines de ventes réussies : lui répondre « solde insuffisant » lui fait croire que nous retenons son argent.

Refus avant encaissement : point de vente non relié

Depuis le 2026-09-25, POST /api/v1/store/sale-init refuse une vente avant paiement quand le portail envoie un nasid qui ne correspond à aucune allocation active du vendeur résolu (store/client_id), alors que ce vendeur possède des allocations VPN.

Exception de réconciliation matérielle : si la déclaration NAS mobile a déjà prouvé que ce nasid est un alias observé du même équipement physique (router_serial + allocation du vendeur), la vente est rattachée à l’allocation canonique et n’est pas refusée. Le matériel est le point canonique ; le nom NAS reste une observation locale. Si deux allocations du même vendeur revendiquent le même alias, le refus reste obligatoire.

Réponse :

{
  "success": false,
  "code": "ROUTER_POINT_UNRESOLVED",
  "error": "Le point de vente demandé n'est relié à aucune allocation active de ce vendeur.",
  "nasid": "jwifi-TM20"
}

Le checkout FedaPay hébergé affiche la même situation en page HTML “Point WiFi non relié” et ne lance pas l’encaissement. Ce n’est pas une erreur réseau : l’app doit demander au vendeur de redéployer/régénérer le portail depuis l’allocation correcte.

Le badge n’est pas toujours « FedaPay »

Plusieurs agrégateurs sont pris en charge (FedaPay, Kkiapay, CinetPay, Chario, Flutterwave, MyCoolPay, Wave, GeniusPay). payout_badge est dérivé de l’agrégateur réellement lu sur les ventes du marchand : affichez-le tel quel, ne le déduisez pas côté client.

Deux replis à gérer :

Un vendeur qui a basculé d’un mode à l’autre reste PLATFORM tant qu’il existe des ventes encaissées chez nous : cette part-là lui est due, quoi qu’il encaisse par ailleurs.

8 bis. Devis et frais de retrait vendeur

Avant d’afficher la confirmation finale, appeler :

GET /api/v1/clients/store-withdrawal/quote?amount=<montant>

Le backend calcule le devis sur les ventes réellement retirables du vendeur, sans réserver :

{
  "amount": 2000,
  "fee": 75,
  "withdrawal_fee": 75,
  "net": 1925,
  "net_payout_amount": 1925,
  "free_threshold": 5000,
  "minimum": 1000,
  "transfer_cost": 100,
  "provider": "fedapay",
  "warning": "Retrait de 2 000 F : 75 F de frais de transfert, vous recevrez 1 925 F. Gratuit à partir de 5 000 F."
}

POST /api/v1/clients/store-withdrawal renvoie ensuite une demande avec les mêmes champs. Le client ne doit jamais envoyer ni faire confiance à ses propres frais : seuls amount, phone, country et operator sont utiles dans le body. amount est le brut des ventes couvertes ; net_payout_amount est le montant réellement envoyé au vendeur.

8 ter. Ventes payées NON LIVRÉES — ventes_non_livrees

Depuis le 2026-09-24, une vente payée que le système n’a pas pu livrer (routeur rattaché à un autre compte, point de vente non relié, stock épuisé, routeur injoignable…) n’est plus invisible. Après 30 min sans livraison possible :

"ventes_non_livrees": [
  {
    "transaction_id": "TM_bebc4b97de67",
    "date": "2026-09-24T05:48:11Z",
    "montant": 100,
    "montant_net": 90,
    "profil": "100F-4H",
    "point_de_vente": "jwifi-TM20",
    "acheteur_telephone": "+229…",          // null si introuvable
    "reference_paiement": "113296966",       // référence FedaPay, à citer au support
    "cause_code": "ROUTEUR_AUTRE_COMPTE",    // ROUTEUR_AUTRE_COMPTE | ROUTEUR_NON_RELIE | STOCK_EPUISE | ROUTEUR_INJOIGNABLE | INCONNUE
    "cause": "Le routeur « jwifi-TM20 » est rattaché à un autre compte : …",  // prête à afficher
    "precision": "PRECISE"                   // PRECISE (établie par un fait) | PROBABLE
  }
]

Ce que l’app doit afficher : une carte par vente, avec la cause (cause, déjà rédigée) et un bouton Appeler l’acheteur (acheteur_telephone). Liste vide = rien à faire — ce n’est pas une erreur.

Ce que le serveur garantit : la vente reste rattrapable. Si la cause disparaît (routeur rattaché, stock réapprovisionné), le rattrapage automatique la livre et elle sort de la liste (SUCCESS). Aucune notification en double.

9. Ce que cet état ne couvre pas encore

Il ne dit pas si le portail captif est correctement déployé sur le routeur, ni si le walled-garden autorise les hôtes de paiement. Ces deux contrôles exigent d’interroger le routeur, ce qui est incompatible avec un état demandé à chaque ouverture d’écran.

Autrement dit : un READY garantit que la vente et la livraison sont possibles côté plateforme. Il ne garantit pas encore que le client capté par le hotspot atteindra la page de paiement.