Contrat API — Cockpit admin (analytics, santé, push)
Destinataire : développeur du dashboard admin
Base : https://live.jmoai.net · préfixe /admin/api
Enveloppe : { success: boolean, data: …, error?: { code, message } }
Authentification : x-admin-token, cookie de session, ou Authorization: Bearer <jwt>
Les exemples des sections 1 à 6 portent des valeurs fictives. Ceux des sections 7 et 8 sont des réponses réelles capturées en production, parce que les ordres de grandeur y sont eux-mêmes l’information : c’est en les voyant qu’on comprend pourquoi une tuile ne doit pas s’intituler « clients ».
Ce qui change au 2026-08-16
Pour le développeur qui a déjà intégré une version précédente. Détail dans les sections indiquées.
| Changement | Section | Action côté frontend |
|---|---|---|
Nouveau vocabulaire utilisateur / client |
§0 bis | Relire les libellés de toutes les tuiles. Un compte créé n’est pas un client. |
Nouvelle route …/analytics/customers |
§7 | Ajouter la tuile « Clients » à côté de « Utilisateurs » |
…/analytics/tiketmomo change de forme |
§7 | store_name, balance, status disparaissent ; la route est paginée et porte l’état de gel |
| Nouvelles routes kill-switch boutique | §8 | Écran de gel/reprise d’une boutique |
| Les 4 routes analytiques peuvent renvoyer 500 | §7 | Afficher « indisponible », plus jamais 0 |
Rien d’autre n’a bougé : les sections 1 à 6 sont inchangées.
0. À lire avant de migrer
Ces quatre surfaces étaient mockées côté serveur, pas seulement côté frontend. Le
backend renvoyait mrr: 4500, totalRevenue: 15400 et trois consommateurs nommés
mgr_123 / mgr_456 / mgr_789 — des constantes écrites dans le code. La santé
annonçait FedaPay ONLINE, 120 ms sans jamais appeler FedaPay, et criticalAlerts était un
tableau vide en dur : le cockpit ne pouvait pas virer au rouge.
Les valeurs sont maintenant mesurées. La forme change donc, et deux champs du mock disparaissent parce qu’ils n’étaient mesurables nulle part :
| Champ du mock | Devient | Pourquoi |
|---|---|---|
financials.totalRevenue |
financials.recette_periode |
borné à la période demandée, plus un total flottant |
financials.growthPct |
financials.croissance_pct |
calculé sur la période précédente ; null si elle est vide |
financials.revenueData[].target |
(supprimé) | aucun objectif commercial n’existe en base |
infrastructure.routersCount |
infrastructure.routeurs |
— |
infrastructure.infraData[] |
en_ligne / hors_ligne / en_erreur |
trois nombres valent mieux qu’un tableau à colorier côté serveur |
topConsumers[].usageGB |
top_consommateurs[].ventes + .montant |
aucune donnée de trafic n’existe : ni accounting RADIUS, ni compteur d’octets. Le « Go » du mock ne mesurait rien. N’affichez pas d’unité de volume. |
0 bis. Vocabulaire : utilisateur ≠ client
C’est la règle la plus importante de ce document. Elle décide de ce que vous écrivez sous chaque grand nombre du cockpit.
Un utilisateur est un compte créé. Un client est un utilisateur dont le matériel a parlé au moins une fois (handshake WireGuard ou SSTP). Un utilisateur sans équipement branché est un passant, pas un client.
Le parcours réel :
compte créé → allocation VPN → PREMIER HANDSHAKE → première vente
1 683 853 207 16
(ne suffit pas) = les clients
La frontière est le handshake, jamais l’allocation. Une allocation est créée par la plateforme au provisioning : elle prouve qu’un emplacement est réservé, pas qu’un routeur est branché. 853 comptes en ont une, 207 seulement ont fait parler un tunnel. Prendre l’allocation pour critère multiplierait le nombre de clients par quatre.
Conséquences directes sur l’affichage :
| Ne pas écrire | Écrire | Pourquoi |
|---|---|---|
| « 1 683 clients » | « 1 683 comptes » ou « utilisateurs » | 12,3 % seulement sont des clients |
| « 831 utilisateurs RADIUS » | « 831 allocations protégées » | ce nombre compte des ALLOCATIONS, pas des personnes (§7) |
| « X % de nos clients… » sur 1 683 | rapporter à clients |
tout taux rapporté aux comptes est divisé par 8 |
Le chiffre de référence vient d’une seule route, GET /admin/api/v2/analytics/customers
(§7). Ne le recalculez jamais côté frontend à partir d’une autre liste : la définition
vit à un seul endroit côté serveur, et deux calculs finiraient par diverger.
1. GET /admin/api/v2/analytics
Query : period = 7d · 30d (défaut) · 12m.
{
"success": true,
"data": {
"periode": { "code": "30d", "jours": 30, "debut": "…", "fin": "…" },
"complet": true,
"financials": {
"mrr": 12000,
"mrr_licences_comptees": 20,
"mrr_ecartees": { "sans_echeance": 3, "transaction_absente": 1, "montant_nul": 0 },
"recette_periode": 400000,
"recette_periode_precedente": 350000,
"croissance_pct": 14.3,
"recette_par_jour": [
{ "date": "2026-01-01", "montant": 12000, "ventes": 8, "non_classe": 0 },
],
},
"churn": {
"taux_pct": 4.2,
"base_titulaires": 48,
"titulaires_perdus": 2,
"licences_echues": 3,
"fiable": true,
},
"licences": {
"actives": 900,
"payantes_actives": 20,
"par_plan": [{ "nom": "PLAN_A", "valeur": 700 }],
"par_origine": [{ "nom": "PROMO", "valeur": 600 }],
},
"infrastructure": { "routeurs": 800, "en_ligne": 80, "hors_ligne": 660, "en_erreur": 60 },
"top_consommateurs": [{ "id": "client_…", "nom": "…", "ventes": 300, "montant": 60000 }],
},
}
Quatre lectures à ne pas se tromper
mrr ne compte que les abonnements réellement payés. La table licenses n’a pas de
colonne de prix : le montant vient de transactions via source_tx_id. Une licence PROMO,
TRIAL ou ADMIN n’a pas ce lien, donc elle vaut 0. La grande majorité du parc est dans ce
cas. Le MRR sera donc très inférieur à ce que le nombre de licences laisse imaginer — ce
n’est pas un bug, c’est la structure du parc. Affichez mrr_licences_comptees à côté du
montant, sinon le chiffre paraîtra absurde.
mrr_ecartees n’est pas du bruit. transaction_absente compte les licences dont le
lien de paiement pointe vers une transaction qui n’existe plus : la trace est rompue. Une
valeur qui grimpe mérite un signalement, pas un masquage.
churn.fiable: false veut dire « n’affichez pas ce taux ». En dessous de 30 titulaires
payants, une seule résiliation déplace le taux de plusieurs points. Montrez
titulaires_perdus / base_titulaires en clair plutôt qu’un pourcentage trompeur.
croissance_pct peut valoir null — période précédente vide. Affichez un tiret, jamais
+0 %, qui laisserait croire à une stagnation.
recette_par_jour et croissance_pct excluent les sessions de paiement non
réclamées. Le hub d’achat écrit status: SUCCESS dès la génération de l’URL de paiement,
avant tout versement : un clic sur « acheter » ressemble à un encaissement. Seules les
sessions portant metadata.claimed === true entrent dans la recette. Sans cette règle, la
courbe et le pourcentage de croissance changent d’ordre de grandeur — et de signe.
recette_par_jour[].montant est NOTRE chiffre d’affaires, pas le volume qui transite.
Trois flux passent par la même table et n’appartiennent pas aux mêmes personnes : nos
licences, les ventes de tickets des tenanciers, les soldes du hub. Sur une vente TiketMOMO,
seule notre commission est à nous — pas le montant du ticket. Ne reconstituez jamais un
total en additionnant ce champ avec un autre registre.
recette_par_jour[].non_classe porte l’encaissé qu’aucun registre ne réclame : les
transactions sans type, 416 935 XOF au 11/08. Il est publié pour qu’il ne disparaisse pas
en silence — une courbe qui baisse sans explication se lit comme une chute d’activité. Il ne
s’additionne PAS à montant : c’est de l’argent à rattacher à la main.
complet: false signale un agrégat tronqué : avertissez au lieu de présenter un total
comme exhaustif.
2. GET /admin/api/health/saas
Alias de GET /admin/api/v2/health. Query forcer=1 pour ignorer le cache.
{
"success": true,
"data": {
"etat_global": "OPERATIONNEL",
"mesure_a": "2026-01-01T00:00:00Z",
"services": [
{ "nom": "Supabase", "statut": "EN_LIGNE", "latence_ms": 40 },
{ "nom": "FedaPay", "statut": "EN_LIGNE", "latence_ms": 300 },
{ "nom": "Passerelle VPN", "statut": "DELAI_DEPASSE", "latence_ms": 3000 },
{ "nom": "Firebase", "statut": "EN_LIGNE", "latence_ms": null },
],
"moteur_synchro": { "en_attente": 0, "echecs_24h": 0, "termines_24h": 12, "taux_succes": 100 },
"parc": { "routeurs": 800, "muets_prolonges": 100, "seuil_heures": 48 },
"alertes_critiques": [
{
"code": "ROUTEURS_MUETS_PROLONGES",
"gravite": "AVERTISSEMENT",
"message": "…",
"valeur": 100,
},
],
"depuis_cache": false,
},
}
etat_global |
Quand |
|---|---|
OPERATIONNEL |
tout répond, aucune alerte |
DEGRADE |
une sonde en délai dépassé, ou au moins une alerte non critique |
CRITIQUE |
un service hors ligne, ou une alerte de gravité CRITIQUE |
statut d’un service vaut EN_LIGNE · HORS_LIGNE · DELAI_DEPASSE. Un 401/403
renvoyé par un prestataire compte comme en ligne : ce qu’on mesure, c’est qu’il répond.
latence_ms: null sur Firebase est normal : FCM n’offre pas de sonde gratuite, et un
envoi à blanc exigerait un jeton d’appareil réel. On rapporte que le SDK est initialisé —
une latence inventée serait pire qu’une absence.
Codes d’alerte : ROUTEURS_MUETS_PROLONGES (> 48 h sans signe de vie ; CRITIQUE au-delà
d’un quart du parc), SYNCHRONISATION_BLOQUEE (> 30 min en file — toujours CRITIQUE),
SYNCHRONISATION_EN_ECHEC, PAIEMENTS_TRAITEMENT_MANUEL.
Cache de 30 secondes. Le rafraîchissement de votre écran ne doit pas taper sur l’API de
FedaPay à chaque ouverture d’onglet ; depuis_cache vous dit si la mesure est fraîche.
3. GET /admin/api/health/backend
{
"success": true,
"data": {
"process": {
"uptime_s": 3600,
"version_node": "v22.x",
"pid": 1234,
"memoire_utilisee_mo": 100,
"tas_utilise_mo": 15,
},
"hote": {
"uptime_s": 400000,
"coeurs": 6,
"charge": { "m1": 1.2, "m5": 1.0, "m15": 0.9 },
"charge_pct": 20.0,
"memoire_totale_mo": 12000,
"memoire_libre_mo": 5000,
"memoire_utilisee_pct": 58.3,
},
"base": { "latence_ms": 11, "statut": "EN_LIGNE" },
},
}
⚠️ charge n’est pas un pourcentage. C’est la moyenne système Unix : sur 6 cœurs, une
charge de 6 vaut 100 %. charge_pct fait déjà cette division — utilisez-le pour une jauge,
et sachez qu’il peut dépasser 100 % : c’est le sens de la mesure, pas un bug d’affichage.
4. POST /admin/api/marketing/push-notifications/broadcast
La simulation est le comportement par défaut. Sans confirm: true, la route calcule la
cible et rend le décompte sans envoyer quoi que ce soit. Câblez votre écran, vérifiez vos
filtres, relisez le texte — puis seulement confirmez.
// Corps
{
"titre": "…",
"corps": "…",
"donnees": { "ecran": "promotions" },
"cible": { "actifs_jours": 7 },
"confirm": false,
}
Une seule forme de cible, au choix :
| Cible | Effet |
|---|---|
{ "manager_ids": ["…"] } |
liste explicite |
{ "actifs_jours": 7 } |
appareils vus dans les 7 derniers jours |
{ "tous": true } |
tous les appareils enregistrés |
segment_id n’est pas accepté : la table des segments existe mais elle est vide et son
moteur de règles n’est pas défini. Construire dessus reviendrait à bâtir contre du vide.
// Réponse — simulation
{ "success": true, "data": {
"mode_cible": "ACTIFS", "destinataires": 400, "appareils": 420,
"titre": "…", "corps": "…", "simulation": true, "envoyes": 0, "echecs": 0 } }
// Réponse — envoi réel (confirm: true)
{ "success": true, "data": {
"mode_cible": "ACTIFS", "destinataires": 400, "appareils": 420,
"simulation": false, "envoyes": 415, "echecs": 5,
"echecs_transitoires": 3, "purges": 2 } }
purges compte les jetons supprimés parce que FCM les a déclarés définitivement
invalides (application désinstallée). echecs_transitoires compte les échecs passagers :
ces jetons sont conservés, l’envoi suivant réessaiera. Ne proposez pas de « nettoyer »
ces derniers — les purger couperait des clients joignables de toute notification future.
| Code | HTTP | Sens |
|---|---|---|
PUSH_CONTENT_REQUIRED |
400 | titre ou corps manquant |
PUSH_CONTENT_TOO_LONG |
400 | titre > 120 ou corps > 400 caractères |
PUSH_TARGET_REQUIRED |
400 | aucune cible exploitable |
PUSH_TARGET_INCOMPLETE |
400 | cible trop large pour être énumérée de façon fiable |
PUSH_BROADCAST_TOO_LARGE |
400 | au-delà du plafond par appel — découpez |
PUSH_FCM_UNAVAILABLE |
400 | Firebase non configuré sur ce serveur |
Cette route porte un quota d’action : elle touche des téléphones réels.
5. GET /admin/api/marketing/push-stats
Parc mobile : total_installs, active_24h, active_7d, top_models, recent_tokens.
⚠️ recent_tokens ne contient plus fcm_token. Un jeton FCM permet d’envoyer une
notification à l’appareil : c’est un identifiant d’envoi, pas une donnée d’affichage. Vous
disposez de token_suffix (6 caractères) pour distinguer deux terminaux à l’œil.
5 bis. GET /admin/api/v2/dashboard — changement de sens de stats.volume
⚠️ stats.volume a changé de signification le 14/08/2026. Le nombre affiché BAISSE ;
c’est la correction, pas une régression.
Il portait la somme de amount sur toutes les transactions status = 'SUCCESS'. Trois
défauts cumulés : les encaissements COMPLETED et PAID étaient ignorés (GeniusPay écrit
completed), l’argent des tenanciers TiketMOMO était additionné au nôtre, et les sessions
de paiement jamais réglées comptaient comme des recettes.
stats.volume vaut désormais le seul total qu’on ait le droit d’appeler le nôtre : nos
licences encaissées plus notre commission TiketMOMO. Il vient du même agrégat que
GET /admin/api/finance/summary — les deux écrans ne peuvent plus se contredire.
| Champ | Sens |
|---|---|
stats.volume |
notre chiffre d’affaires, tous canaux confondus |
stats.volume_complet |
false = agrégat tronqué, affichez un avertissement plutôt qu’un total |
finance.* |
ventilation par registre (saas, tiketmomo, non_classe, anomalies) |
charts.revenue est ventilée de la même façon, et rend un point par jour sur 7 jours,
zéro compris. Une semaine sans vente vaut sept zéros : ne comblez pas les creux.
charts.transactions ne compte que les encaissements. Elle incluait les FAILED,
CANCELED et PENDING : un pic d’échecs de paiement s’y lisait comme un pic d’activité.
charts.payouts ne somme que les reversements PAID/COMPLETED, plus les PENDING
et REJECTED.
Les courbes vides ne sont plus remplacées par des valeurs de démonstration. Si un graphe est plat, c’est l’état réel.
6. Quota
100 requêtes / 15 min par IP, partagées par TOUTES les routes. Un cockpit qui sonde en
minuterie courte consomme le quota de toute l’application — et ce sont les autres appels
qui échouent en 429. Rafraîchissez à l’ouverture et sur action.
7. Listes analytiques (tableaux de suivi)
Tableaux de suivi d’activité. Les exemples ci-dessous sont des réponses réelles, capturées en production le 2026-08-16 — les ordres de grandeur sont donc les vrais.
GET /admin/api/v2/analytics/customers — la tuile « Clients »
La tuile à afficher à côté de « Utilisateurs », pas à sa place. Voir §0 bis pour la définition. C’est la seule source du nombre de clients.
{
"success": true,
"data": {
"utilisateurs": 1683, // comptes créés — des passants tant qu'ils ne branchent rien
"clients": 207, // au moins un handshake tunnel : les VRAIS clients
"routeurs_actifs_7j": 130, // matériel vu ces 7 jours (compté par ROUTEUR)
"routeurs_connectes": 89, // tunnel monté à l'instant (compté par ROUTEUR)
"vendeurs": 16, // clients ayant encaissé au moins une vente
"taux": {
"conversion_client_pct": 12.3, // clients / utilisateurs
"activation_vente_pct": 7.7, // vendeurs / CLIENTS (jamais / utilisateurs)
},
"definition": "…", // phrase à afficher en infobulle, pour couper court aux débats
},
}
routeurs_actifs_7j et routeurs_connectes comptent des routeurs, pas des clients :
un client peut en avoir plusieurs. Ne les intitulez pas « clients connectés ».
GET /admin/api/v2/analytics/radius
Allocations dont le droit RADIUS est PROTECTED.
⚠️
countcompte des ALLOCATIONS, pas des personnes, et la cléusersest un abus de langage hérité. 831 allocations protégées coexistent avec 207 clients réels : afficher « 831 utilisateurs RADIUS » ferait croire à un parc quatre fois plus grand qu’il n’est. Intitulez la tuile « allocations protégées ».
{
"success": true,
"data": {
"count": 831,
"users": [
{
"id": "6a3ee928-0289-4b91-8403-a063132f8cff",
"client_id": "client_b77c61d2c149481783c4c175b265b048",
"nas_id": "hotspot1", // NON unique sur le parc : jamais une clé
"locked_router_identity": "MikroTik",
"router_tunnel_ip": "10.255.2.176",
"public_host": "vpn.mikhmoai.com",
"status": "ACTIVE",
"created_at": "2026-08-05T00:58:54.819611+00:00",
},
],
},
}
nas_id vaut « hotspot1 » ou « MikroTik » chez des dizaines de routeurs : ne l’utilisez
jamais comme identifiant de ligne. La clé est id (l’allocation).
GET /admin/api/v2/analytics/vpn
Allocations vues récemment (fenêtre 12 h), tous transports confondus.
{
"success": true,
"data": {
"count": 84,
"users": [
{
"id": "72e8e6f7-f1cd-4268-b07a-a810c6146ec3",
"client_id": "client_6b32aeaa6b464082a696393b4349144c",
"tunnel_status": "CONNECTED", // ou DISCONNECTED
"tunnel_last_seen_at": "2026-08-16T00:07:01.938+00:00",
"active_transport": "WIREGUARD", // ou SSTP, ou null si jamais résolu
"locked_router_identity": "MikroTik",
},
],
},
}
Même remarque : ce sont des allocations. WireGuard et SSTP comptent à égalité.
GET /admin/api/v2/analytics/tiketmomo
Liste les boutiques TiketMOMO, avec leur état de gel.
PAGINÉ — la table dépasse 13 000 lignes. Paramètres : page (défaut 1), limit
(défaut 100, max 500), suspended=true pour ne retourner que les boutiques gelées.
count est le total en base, pas la taille de la page.
⚠️ Rupture de contrat assumée. Les champs
store_name,balanceetstatusont disparu : ils n’ont jamais existé. Cet endpoint lisait une tablestore_profilesabsente de la base, l’erreur PostgREST était ignorée, et la réponse était donc toujours{"count": 0, "stores": []}. Aucun client ne peut avoir dépendu de données qui n’ont jamais été servies. Le nom d’enseigne est désormaisdisplay_name(depuismanagers) ; le solde retirable reste servi par boutique via/docs/CONTRAT-API-ADMIN-FINANCE— l’agréger ici imposerait une requête par boutique.
{
"success": true,
"data": {
"count": 1287,
"page": 1,
"limit": 100,
"stores": [
{
"id": "00000000-0000-4000-8000-000000000009", // client_store_profiles.id
"manager_id": "client_00000000000000000000000000000009",
"store_slug": "boutique-exemple-client-9", // UNIQUE : la vraie clé
"display_name": "Boutique Exemple",
"email": "client@example.invalid",
"manager_status": "ACTIVE",
"license_id": null,
"created_at": "2026-08-15T23:52:34.041679+00:00",
"suspension": { "suspended": false, "blocksSales": false, "blocksPayouts": false },
},
],
},
}
⚠️ Un même
manager_idpeut apparaître sur plusieurs lignes. La colonne n’est pas unique et le parc porte des milliers de lignes en double, héritées d’un défaut corrigé le 2026-08-15 : la recherche de boutique échouait en silence et en recréait une à chaque passage. Dédoublonnez parmanager_idà l’affichage, et ne prenez jamais le nombre de lignes pour un nombre de boutiques.store_slug, lui, est unique.
8. Kill-switch boutique TiketMOMO
Gel administratif d’une boutique : fraude, abus, réquisition, compte de retrait
compromis. Le gel est immédiat (aucun cache sur le chemin d’achat) et journalisé
sans effacement dans store_suspension_events.
Portées (scope)
| Portée | Ventes | Retraits | Quand l’utiliser |
|---|---|---|---|
SALES |
bloquées | autorisés | Litige sur le service rendu — le marchand récupère l’argent déjà gagné |
PAYOUTS |
autorisées | bloqués | Numéro de retrait suspect, identité à vérifier |
SALES_AND_PAYOUTS |
bloquées | bloqués | Défaut. Fraude : aucune fuite pendant l’enquête |
Ce que « bloqué » veut dire, concrètement :
- Ventes — refus posé dans la résolution de vente commune, donc les deux chemins
d’encaissement sont couverts : checkout FedaPay hébergé (page HTML
Ventes suspendues, HTTP 200 pour ne pas déclencher l’écran d’erreur du navigateur derrière un portail captif) etPOST /api/v1/store/sale-init(HTTP 403,code: "STORE_SUSPENDED"). Aucune transactionPENDINGn’est créée — sinon le réconciliateur la reprendrait et contournerait le gel. - Retraits — refusés à la demande, à la prise en charge et au versement. Une demande déposée avant le gel reste donc bloquée, y compris via le bouton « Confirmer le paiement » de l’email admin. Le rejet d’une demande reste possible : il rend les fonds au solde disponible, il ne les fait pas sortir.
Côté marchand, GET /api/v1/store/tiketmomo/metrics expose
withdrawal_status.withdrawals_enabled et system_status.suspension (sans le motif
interne) pour que l’app désactive le bouton au lieu de laisser le vendeur buter sur un 403.
Deux populations de vendeurs — ne pas leur proposer le même écran de retrait
Le retrait ne concerne que l’argent que nous détenons. withdrawal_status porte donc
aussi le mode d’encaissement :
"withdrawal_status": {
"available_balance": 0,
"minimum_withdrawal": 1000,
"withdrawals_enabled": true, // gel administrateur — sans rapport avec le mode
"payout_mode": "MERCHANT", // "PLATFORM" | "MERCHANT" | "NONE"
"can_request_withdrawal": false,
// 🏷️ Badge à afficher sur le portefeuille
"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."
}
Le badge n’est pas toujours « FedaPay ». Plusieurs agrégateurs sont pris en charge
(FedaPay, Kkiapay, CinetPay, Chario, Flutterwave, MyCoolPay, Wave, GeniusPay) et le badge est
dérivé de l’agrégateur réellement lu sur les ventes du marchand. Affichez payout_badge tel
quel, ne le déduisez pas côté client.
Deux replis à gérer :
payout_provider: null— agrégateur inconnu ou non enregistré.payout_badgevaut alors"Encaissement direct". Ne jamais afficher un nom d’agrégateur dans ce cas.payout_dashboard_url: null— le portail marchand de cet agrégateur n’est pas encore confirmé de notre côté. Affichez le badge sans lien : envoyer un marchand vers un tableau de bord qui n’est pas le sien est pire que ne pas proposer de lien.
payout_mode |
Ce que ça veut dire | Écran attendu |
|---|---|---|
PLATFORM |
les ventes transitent par notre compte FedaPay — nous détenons l’argent | bouton de retrait normal |
MERCHANT |
l’acheteur paie directement sur le compte FedaPay du vendeur | remplacer le bouton par payout_notice |
NONE |
aucune vente réussie : rien à trancher encore | bouton normal |
Ventes, solde et comptabilité (jour / semaine / mois / total) restent affichés à tous, quel que soit le mode. Seule l’action de retrait change.
En mode MERCHANT, l’application doit remplacer le bouton par le renvoi vers FedaPay,
et non l’afficher grisé : il n’y a rien à débloquer, l’argent est déjà chez le vendeur. Un
bouton grisé lui fait croire à une restriction de notre part et déclenche un appel au
support.
Si la demande est tentée malgré tout, elle est refusée avec un code dédié :
| Code | Sens |
|---|---|
WITHDRAWAL_MERCHANT_ACCOUNT |
encaissement direct — retrait à faire sur le tableau de bord FedaPay du vendeur |
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.
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.
GET /admin/api/services/tiketmomo/stores/suspended
Inventaire des boutiques actuellement gelées.
GET /admin/api/services/tiketmomo/stores/:storeId/suspension
État courant + historique des décisions. :storeId accepte indifféremment l’id de la
boutique, le manager_id ou le store_slug.
{
"success": true,
"data": {
"store": { "id": "b3f1…", "manager_id": "mgr_789", "store_slug": "hotspot-cafe-mgr789" },
"suspension": {
"suspended": true,
"scope": "SALES_AND_PAYOUTS",
"reason": "Signalements de tickets non délivrés",
"by": "admin@jservices.io",
"at": "2026-08-15T10:04:00Z",
"blocksSales": true,
"blocksPayouts": true,
},
"history": [
{
"id": "…",
"action": "SUSPEND",
"scope": "SALES_AND_PAYOUTS",
"reason": "Signalements de tickets non délivrés",
"actor": "admin@jservices.io",
"created_at": "2026-08-15T10:04:00Z",
},
],
},
}
POST /admin/api/services/tiketmomo/stores/:storeId/suspend
Body : { "reason": "…", "scope": "SALES_AND_PAYOUTS" } — scope optionnel (défaut
SALES_AND_PAYOUTS), reason obligatoire.
Idempotent : re-suspendre ajuste portée et motif mais conserve la date du gel initial (c’est elle qui borne la période contestable, pas le dernier ajustement).
POST /admin/api/services/tiketmomo/stores/:storeId/resume
Body : { "reason": "…" } — obligatoire. Réponse : already_active: true si la boutique
n’était pas gelée.
Codes d’erreur
| Code | HTTP | Sens |
|---|---|---|
STORE_NOT_FOUND |
400/404 | Aucune boutique pour cet identifiant |
SUSPENSION_REASON_REQUIRED |
400 | Motif vide — sans lui le journal d’audit ne vaut rien |
RESUME_REASON_REQUIRED |
400 | Motif de réouverture vide |
INVALID_SUSPENSION_SCOPE |
400 | Portée hors des trois valeurs admises |
SUSPENSION_ACTOR_REQUIRED |
400 | Aucune identité admin résolue |
STORE_SUSPENDED |
403 | Renvoyé aux clients (vente, retrait) sur boutique gelée |
Limite connue : l’accès par jeton maître (x-admin-token) est une identité
partagée. Un gel signé master_key prouve qu’un porteur du jeton a agi, pas lequel.
Tant que le panel admin n’a qu’une identité, aucune signature ne peut faire mieux.
GET /admin/api/v2/analytics/provisioning-failures
Allocations en échec d’association routeur (binding_status = FAILED ou ERROR), et
taux de réussite associé.
{
"success": true,
"data": {
"total_failures": 0,
"success_rate_pct": 100,
"failures_list": [
// vide au 2026-08-16 — cet écran DOIT pouvoir afficher zéro sans ressembler à une panne
{
"id": "…",
"client_id": "client_…",
"binding_status": "FAILED",
"binding_failure_code": "ROUTER_SLOT_OCCUPIED",
"binding_failure_message": "…",
"updated_at": "…",
},
],
},
}
⚠️ Un
success_rate_pctà 100 se lit désormais littéralement. Jusqu’au 2026-08-15, ces quatre routes analytiques avalaient les erreurs de lecture : une base injoignable renvoyaitcount: 0/100 %au lieu d’échouer, et l’écran affichait « tout va bien » en pleine panne. Elles remontent maintenant une HTTP 500 en cas d’erreur. Traitez donc un 500 comme « données indisponibles », et n’affichez jamais 0 à sa place.
9. Voir aussi
- Flux financiers et retraits :
/docs/CONTRAT-API-ADMIN-FINANCE - État de la boutique côté gérant :
/docs/CONTRAT-API-BOUTIQUE-TIKETMOMO - Référence complète générée depuis le code :
https://live.jmoai.net/api-docs