Documentation J+SERVICES Guides Référence API

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.

⚠️ count compte des ALLOCATIONS, pas des personnes, et la clé users est 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, balance et status ont disparu : ils n’ont jamais existé. Cet endpoint lisait une table store_profiles absente 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ésormais display_name (depuis managers) ; 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_id peut 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 par manager_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 :

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_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 renvoyait count: 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