Documentation J+SERVICES Guides Référence API

Contrat API — Revendeurs RADIUS

Pour l’équipe application mobile. Comment créer un revendeur RADIUS, lui donner le droit d’écrire ses tickets, et comment l’application doit le gérer quand RADIUS est actif — au lieu de le gérer comme un revendeur local.

Base : https://live.jmoai.net/v1/radius


1. Ce qui change quand RADIUS est actif

C’est le point le plus important de ce document, et il inverse une habitude.

En mode local, un revendeur est un compte MikroTik du groupe Mikhmon-Reseller. Il se connecte au routeur, il génère ses tickets, et l’attribution vit dans le commentaire de chaque utilisateur hotspot :

vc-478-08.18.26-Prudence _ 100F_4H (100F)_160916 #mikhmon-reseller:Prudence

L’application lit le routeur, extrait le tag, et compte.

En mode RADIUS, le routeur n’est plus la source. Les tickets vivent au central et sur le serveur RADIUS ; la base locale du MikroTik est destinée à être vidée. Une application qui continuerait de lire le routeur pour compter les ventes d’un revendeur trouverait zéro — sans erreur, sans message, juste des chiffres à plat.

L’application doit donc :

mode local mode RADIUS
identité du revendeur compte /user sur le MikroTik ligne radius_resellers au central
authentification mot de passe WinBox jeton porteur délivré par l’API
création de tickets Mikhmon écrit sur le routeur POST /vouchers/batch
attribution tag dans le commentaire champ reseller_name, posé par le serveur
comptabilité lecture du routeur GET /vouchers?reseller=… et le grand livre
plafonds aucun max_batch_size, max_users_total, solde

Le compte MikroTik reste possible (voir §7) mais il devient facultatif : il ne sert plus à générer des tickets, seulement à laisser un revendeur regarder le routeur.


2. Créer un revendeur

Réservé au gérant (jeton client, licence RADIUS active).

POST /v1/radius/resellers
Authorization: Bearer <jeton du gérant>
Content-Type: application/json

{
  "username": "prudence",
  "balance": 50000,
  "commission_rate": 10,
  "max_batch_size": 100,
  "max_users_total": 1000
}

username est normalisé en minuscules et sans espaces. C’est volontaire : sans cela, « Prudence », « prudence » et « prudence » deviennent trois revendeurs qui se partagent une seule caisse dans l’esprit du gérant.

champ défaut ce qu’il fait
username requis, unique par tenant
balance 0 crédit en francs. À zéro, le revendeur ne peut rien créer.
commission_rate 0 taux de commission, en pourcentage
max_batch_size 100 tickets par lot
max_users_total 1000 plafond cumulé, tous lots confondus

Réponse 201 : l’objet revendeur créé. Erreur 409 RESELLER_ALREADY_EXISTS si le nom est déjà pris pour ce tenant.


3. Lui donner le droit d’écrire — le jeton

POST /v1/radius/resellers/prudence/token
Authorization: Bearer <jeton du gérant>
{ "success": true, "token": "eyJhbGciOi...", "expires_in_days": 365 }

Ce jeton est le seul identifiant du revendeur. Il vaut un an. L’application le stocke dans le trousseau sécurisé de l’appareil, jamais en clair.

Un compte suspendu (disabled: true) ne peut plus obtenir de jeton : 403. Les jetons déjà délivrés, eux, restent valides jusqu’à leur expiration — pour couper un revendeur immédiatement, il faut le suspendre et considérer que ses lots seront refusés (§4).

Ce que le jeton impose, et que l’application n’a pas à gérer

Le serveur force l’identité du revendeur sur chaque requête portée par ce jeton :

Autrement dit : un revendeur ne peut pas créer de tickets au nom d’un autre, ni lire les tickets d’un autre, même si l’application envoie un autre nom dans le corps de la requête. L’application n’a donc aucun contrôle d’accès à écrire de son côté — et ne doit pas essayer de le contourner en envoyant un reseller_name : il sera ignoré.


4. Créer des tickets, côté revendeur

POST /v1/radius/vouchers/batch
Authorization: Bearer <jeton du REVENDEUR>
Content-Type: application/json

{
  "profile_id": "…",
  "quantity": 25,
  "comment": "Lot du 23/08"
}

Le serveur, dans cet ordre :

  1. vérifie quantity <= max_batch_size ;
  2. vérifie que le total du revendeur ne dépassera pas max_users_total ;
  3. vérifie le solde : 400 si insuffisant, avec le montant requis et le solde réel ;
  4. crée les tickets, chacun portant reseller_name ;
  5. débite le solde et écrit une ligne au grand livre — jamais l’un sans l’autre.

Erreurs à afficher telles quelles :

code sens ce que l’application propose
400 solde insuffisant crédit épuisé « demander une recharge au gérant »
400 limite globale atteinte max_users_total dépassé contacter le gérant
403 compte suspendu disabled: true contacter le gérant

Le message d’erreur contient le montant requis et le solde réel : l’afficher mot pour mot évite au revendeur de deviner combien il lui manque.


5. La comptabilité — ce que l’application affiche

Ne plus lire le routeur. Trois appels remplacent la lecture des commentaires.

Les tickets d’un revendeur

GET /v1/radius/vouchers?reseller=prudence&status=ACTIVE&limit=100&offset=0

Avec un jeton de revendeur, le paramètre reseller est inutile : il est imposé.

Chaque ticket porte reseller_name, comment, profile_id, status, created_at, used_at.

status :

valeur sens
ACTIVE en stock, jamais authentifié
USED première authentification faite, le compteur tourne
EXHAUSTED quota consommé jusqu’au bout — ne s’authentifie plus
DISABLED suspendu
DELETED retiré du catalogue

⚠️ EXHAUSTED et USED ne sont pas synonymes. Un ticket USED est encore vendable et encore utilisable ; un ticket EXHAUSTED est mort. Les compter ensemble fausse le stock.

Le grand livre

GET /v1/radius/resellers/prudence/transactions?limit=100&offset=0

Chaque ligne : type, amount, reason, balance_after, created_at. C’est l’historique des recharges et des débits — la seule source pour « combien a-t-il consommé ce mois-ci ».

Sa fiche

GET /v1/radius/resellers/self
Authorization: Bearer <jeton du REVENDEUR>

Rend le revendeur porteur du jeton, avec son solde et ses dix dernières transactions. C’est l’appel de l’écran d’accueil.

Le gérant, lui, utilise GET /v1/radius/resellers pour la liste complète.

Les champs de la fiche revendeur

Depuis la reprise du 23/08, l’API rend tout ce que l’application allait chercher dans le commentaire du routeur :

champ source remarque
username central l’identifiant, en minuscules
first_name, last_name repris du routeur l’identité affichée
phone, email repris du routeur souvent partiels sur l’historique
balance central crédit disponible, en francs
commission_rate repris du routeur en pourcentage
max_batch_size, max_users_total central plafonds
total_sales repris du routeur voir l’avertissement ci-dessous
admin_message repris du routeur message du gérant au revendeur
disabled central compte suspendu
created_on_router_at repris du routeur date de création côté application
imported_from_router_at central date de la reprise

Le mot de passe n’est jamais renvoyé, ni en listage ni en fiche.

⚠️ total_sales n’est pas un chiffre d’affaires

Malgré son nom, il vaut le cumul des débits : ce que le revendeur a acheté au gérant, pas ce que ses clients ont payé. Vérifié sur cinq comptes où les deux nombres coïncident au franc près.

L’écart est loin d’être anecdotique : pour un revendeur, total_sales annonçait 778 760 XOF quand ses ventes réelles étaient de 455 900 XOF. L’afficher comme du chiffre d’affaires le compte une deuxième fois.

Le chiffre d’affaires réel se lit dans le registre des ventes (§10), jamais ici.


6. Recharger, modifier, suspendre — côté gérant

POST /v1/radius/resellers/prudence/topup
{ "amount": 25000, "reason": "Recharge du 23/08" }

Le solde n’est pas modifiable par PUT : il passe par /topup, qui laisse une trace au grand livre. Un solde modifié sans trace rend l’écart inexplicable trois mois plus tard.

PUT /v1/radius/resellers/prudence
{ "max_batch_size": 200, "commission_rate": 12, "disabled": false }
DELETE /v1/radius/resellers/prudence

Suppression douce : le grand livre et les tickets vendus référencent ce revendeur, une suppression dure effacerait l’historique des ventes en cascade.


7. Le compte MikroTik — facultatif

PUT /v1/radius/resellers/prudence/winbox
{ "allow_winbox_login": true, "mikrotik_group": "read", "winbox_password": "…" }

Cet appel enregistre l’intention au central. Il ne crée pas le compte sur le routeur : seule l’application mobile provisionne les routeurs, le backend ne les reconfigure pas.

À l’application, donc, de créer le compte /user correspondant lorsqu’elle est sur le LAN, si le gérant le demande. Et à ne le faire que si allow_winbox_login est vrai.

En mode RADIUS, ce compte ne sert plus à générer des tickets — seulement à consulter le routeur. Un revendeur qui n’a pas besoin de voir le routeur n’a pas besoin de compte.


8. Migration d’un parc déjà en service

Sur un routeur passé en RADIUS, les tickets existants ont été importés avec leur commentaire d’origine. L’attribution a été extraite du tag et posée dans reseller_name : elle est donc lisible par l’API sans lire le routeur.

Ce que l’application doit faire au moment de la bascule :

  1. appeler GET /v1/radius/resellers — la liste fait foi, pas les comptes du routeur ;
  2. déclarer les revendeurs manquants. Un compte Mikhmon-Reseller présent sur le routeur mais absent de cette liste vend sans exister au central : ses tickets sont bien attribués, mais il n’a ni solde, ni plafond, ni grand livre ;
  3. ne plus lire #mikhmon-reseller: sur le routeur. Le tag reste écrit dans comment pour consultation, mais reseller_name est le champ qui fait foi.

⚠️ Tous les noms tagués ne sont pas des revendeurs. Sur un parc réel, deux des huit noms trouvés dans les tags étaient la plateforme elle-même et le compte administrateur du gérant. Se fier au tag seul pour créer des revendeurs en fabriquerait de faux.

Le solde et le grand livre sont repris automatiquement

L’application stocke aujourd’hui l’état du revendeur en JSON, dans le commentaire de son compte /user MikroTik — solde, identité, mouvements, taux, plafonds. Le backend sait maintenant le lire et le reprendre, sans que l’application ait quoi que ce soit à envoyer.

Un parc réel : 9 revendeurs, 301 440 XOF de crédit, 90 mouvements repris.

Ce que l’application doit changer, dans cet ordre :

  1. Cesser d’écrire le solde et le grand livre dans le commentaire. Ils sont désormais tenus au central, où ils survivent au routeur.
  2. Lire le solde par l’APIGET /resellers/self pour un revendeur, GET /resellers pour le gérant.
  3. Recharger par POST /topup, jamais en réécrivant le commentaire. Le grand livre central ne peut être alimenté que par là.

⚠️ Le grand livre du routeur est tronqué aux 20 derniers mouvements

Constaté sur un parc réel : les comptes portent 20, 20, 19, 12, 10, 6, 3, 0 et 0 lignes — deux exactement à 20, aucun au-dessus.

Pour le revendeur le plus actif, 778 760 XOF de débits cumulés contre 114 900 encore retrouvables : 663 860 XOF de mouvements n’existent plus nulle part. C’est aussi pourquoi l’arithmétique crédits − débits = solde ne se referme pas sur les comptes anciens : il manque le début.

Ce qui a été perdu l’est. À partir de la reprise, le central garde tout — et c’est la raison pour laquelle l’application ne doit plus tenir ce registre elle-même.

L’identifiant de mouvement produit par l’application (txn_<horodatage>_<aléa>) est repris tel quel comme clé : une reprise rejouée ne double pas l’historique. Attention, certains de ces identifiants portent une espace finale — l’application les concatène sans la retirer. Le backend applique un trim, mais mieux vaut la supprimer à la source.


9. Pagination — le piège à connaître

Toutes les listes sont paginées par limit et offset. Une réponse de 1 000 éléments signifie presque toujours qu’il y en a davantage, et rien ne le signale. Une application qui lit une seule page pour compter les ventes d’un revendeur affichera un chiffre faux et crédible, ce qui est pire qu’une erreur.

Boucler jusqu’à recevoir moins d’éléments que limit.


10. Le chiffre d’affaires réel — le registre des ventes

C’est ici, et uniquement ici, que se lit ce que les clients ont payé.

Sur un routeur Mikhmon, chaque vente est un /system script dont le nom porte la donnée. Cet historique a été rapatrié : 10 348 ventes, 1 912 750 XOF, de janvier à août sur un seul parc. Il est désormais interrogeable au central, et il survivra au routeur.

Chaque enregistrement porte : type, sold_at, voucher_code, price_xof, client_ip, client_mac, duration_label, profile_name, comment, reseller_name.

⚠️ type sépare une vente d’une reconnexion — et l’ignorer gonfle le chiffre de 70 %

Le routeur écrit deux choses dans le même format :

VENTE        2026-01-13-|-02:51:55-|-A+782i-|-100-|-…-|-vc-492-…-Achat wifi momo
RECONNEXION            -|-07:18:11-|-B+685r-|-500-|-…-|-2026-03-19 19:35:38

Même prix, même profil, même apparence. La seconde forme a la première case vide et une date d’expiration en dernière : c’est une reconnexion, pas une vente.

Un seul ticket à 500 XOF apparaît 155 fois. Sur le parc mesuré : 2 685 reconnexions pour 167 tickets. Les additionner porte le total de 1 912 750 à 3 258 550 XOF.

Ne sommer que type = 'VENTE'. Les lignes CONNEXION sont conservées parce qu’elles disent l’usage réel — combien de fois, depuis quelles machines — mais elles ne se comptent jamais en argent.

164 ventes ont perdu leur date côté routeur : sold_at est nul et seule l’heure subsiste. Ne pas leur inventer une date — elles se comptent dans les totaux, pas dans les mensuels.

Les trois registres ne s’additionnent jamais

registre ce qu’il mesure
ventes de la plateforme ce que la plateforme a vendu elle-même
portefeuille les mouvements d’argent entre comptes
ventes locales (§10) l’historique d’un routeur Mikhmon

Trois natures différentes. Les additionner produit un chiffre d’affaires qui n’a jamais existé. Chaque écran doit dire lequel il affiche.