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 :
- en écriture,
reseller_nameest écrasé par le nom du porteur ; - en lecture, le filtre
resellerest imposé.
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 :
- vérifie
quantity <= max_batch_size; - vérifie que le total du revendeur ne dépassera pas
max_users_total; - vérifie le solde :
400si insuffisant, avec le montant requis et le solde réel ; - crée les tickets, chacun portant
reseller_name; - 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_salesn’est pas un chiffre d’affairesMalgré 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_salesannonç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 :
- appeler
GET /v1/radius/resellers— la liste fait foi, pas les comptes du routeur ; - déclarer les revendeurs manquants. Un compte
Mikhmon-Resellerpré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 ; - ne plus lire
#mikhmon-reseller:sur le routeur. Le tag reste écrit danscommentpour consultation, maisreseller_nameest 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 :
- Cesser d’écrire le solde et le grand livre dans le commentaire. Ils sont désormais tenus au central, où ils survivent au routeur.
- Lire le solde par l’API —
GET /resellers/selfpour un revendeur,GET /resellerspour le gérant. - 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 = soldene 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.
⚠️
typesé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:38Mê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 lignesCONNEXIONsont 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.