Documentation J+SERVICES Guides Référence API

Contrat API — Finance & retraits (dashboard admin)

Destinataire : développeur du dashboard admin Base : https://live.jmoai.net · préfixe /admin/api Enveloppe : { success: boolean, data: …, error?: { code, message } }

Authentification — trois voies acceptées, une seule suffit :

Voie En-tête / support
Clé maître x-admin-token: <ADMIN_DASHBOARD_PASSWORD>
Session admin cookie de session posé par la page de connexion
Compte admin Authorization: Bearer <jwt> d’un compte listé dans ADMIN_EMAILS

Toutes les valeurs des exemples ci-dessous sont fictives : elles illustrent la forme de la réponse, jamais un montant ou un identifiant réel.


1. Ce que couvre ce document

L’argent circule dans trois systèmes distincts, chacun avec sa table et son vocabulaire. Le journal (§5) les traverse tous, la vue consolidée (§6) les additionne.

   VENDEURS TiketMOMO ──► store_withdrawal_requests ──► §2
   REVENDEURS/parrainage ─► payout_requests ──────────► §3
   TECHNICIENS (Hub) ────► withdrawal_requests ───────► §7
   JOURNAL ──────────────► transactions ─────────────► §5
   VUE CONSOLIDÉE ───────► agrégat serveur ───────────► §6

Tout ce document décrit des routes en production.


2. Retraits vendeurs TiketMOMO

Un vendeur encaisse ses ventes sur notre compte ; il demande à être reversé. Le versement est manuel — l’API ne paie pas, elle trace la décision.

PENDING ──confirm──► CONFIRMED ──pay──► PAID
   └──────────────reject──────────────► REJECTED   (le solde redevient disponible)

GET /admin/api/store-withdrawals

Query Rôle
status filtre exact, vocabulaire base (voir l’avertissement)

⚠️ Le vocabulaire diffère de l’app cliente. Cette route renvoie les lignes brutes : le statut vaut CONFIRMED. L’app vendeur, elle, reçoit PROCESSING pour le même état — une traduction appliquée côté client uniquement. Filtrez sur CONFIRMED, et affichez « en cours de traitement » si vous voulez parler la même langue que le vendeur.

{
  "success": true,
  "data": [
    {
      "id": "WDR-XXXXXXXX",
      "manager_id": "client_…",
      "status": "PENDING",
      "amount": 5000.0,
      "currency": "XOF",
      "sales_count": 12,
      "phone": "01XXXXXXXX",
      "country": "BJ",
      "operator": "Mtn",
      "covered_tx_ids": ["…"],
      "requested_at": "2026-01-01T00:00:00Z",
    },
  ],
}

covered_tx_ids est la liste exacte des ventes que ce retrait solde : c’est la pièce justificative, gardez-la accessible depuis le détail.

Actions

Route Effet
POST /admin/api/store-withdrawals/:id/confirm prise en charge → CONFIRMED
POST /admin/api/store-withdrawals/:id/pay corps { "note": "…" }PAID, et les ventes couvertes passent en SETTLED
POST /admin/api/store-withdrawals/:id/reject corps { "reason": "…" }REJECTED, réservation libérée

pay est l’action comptable : elle solde les ventes. Elle est idempotente — rejouer sur une demande déjà payée ne double rien.

Un second chemin existe : le bouton « payé » de l’e-mail admin, un lien signé qui appelle GET /api/v1/store/withdrawal/confirm-paid?id=…&token=… sans authentification. Même effet, même idempotence. Prévoyez que le statut puisse changer sans passer par votre page.


3. Reversements revendeurs & parrainage

GET /admin/api/payouts

Query Défaut
status tous
search — (nom / e-mail du revendeur)
limit 100
{
  "success": true,
  "data": [
    {
      "id": "…",
      "reseller_id": "…",
      "amount": 5000,
      "provider": "…",
      "provider_reference": "…",
      "status": "PENDING",
      "payout_status": "PENDING",
      "phone_number": "…",
      "operator": "…",
      "error_message": null,
      "reseller_name": "…",
      "reseller_email": "…",
      "created_at": "…",
    },
  ],
}

payout_status est status normalisé en majuscules — utilisez-le pour l’affichage, status reste la valeur brute.

POST /admin/api/payouts/process

Déclenche le traitement. error_message porte le motif d’un échec opérateur : affichez-le, c’est ce qui évite un appel au support.


4. Portefeuille en partie double

Route Rôle
POST /admin/api/wallet/credit crédite un compte (corps JSON)
GET /admin/api/wallet/integrity contrôle d’invariant

integrity vérifie que sum(balance) = 0 et que la table de réconciliation est vide. C’est le seul indicateur qui dit si la comptabilité est saine. Mettez-le en évidence sur le tableau de bord : un écart signale une écriture perdue, pas un détail d’affichage.


5. Journal des transactions

GET /admin/api/finance/transactions

Le registre complet. Paginé, jamais entier — la table se compte en milliers de lignes et ne fait que croître.

Query Valeurs Défaut
page ≥ 1 1
limit 1 → 200 (borné) 50
type VOUCHER_SALE, LICENSE_PURCHASE, NULL tous
status SUCCESS, PENDING, EXPIRED, FAILED, MANUAL_REQUIRED tous
provider CHARIO, fedapay, GENIUSPAY tous
settlement PENDING, SETTLED, NOT_APPLICABLE tous
manager_id identifiant vendeur tous
search id, external_id ou téléphone client
from / to dates ISO

search est une recherche littérale sur les trois colonnes à la fois : virgules et points y sont du texte ordinaire, pas des opérateurs. Le terme est tronqué à 128 caractères — au-delà, inutile de renvoyer l’erreur à l’utilisateur, la requête part tronquée. Un terme vide équivaut à l’absence de filtre.

type=NULL est un filtre à part : il isole les encaissements sans catégorie, ceux que rien ne rattache à une vente ni à une licence. C’est le premier écran à construire — demandez le volume courant à l’équipe plateforme, il n’est pas nul.

{
  "success": true,
  "data": {
    "transactions": [
      {
        "id": "CH_SALE…",
        "manager_id": "client_…",
        "amount": 14000,
        "currency": "XOF",
        "status": "SUCCESS",
        "type": null,
        "payment_provider": "CHARIO",
        "fee_amount": 0,
        "net_amount": null,
        "net_calcule": 14000,
        "settlement_status": "NOT_APPLICABLE",
        "withdrawal_reserved_by": null,
        "settled_by_withdrawal_id": null,
        "created_at": "2026-01-01T00:00:00Z",
      },
    ],
    "pagination": { "page": 1, "limit": 50, "total": 1234, "pages": 25 },
  },
}

net_calcule est toujours renseigné : la colonne net_amount quand elle existe, sinon amount − fee_amount. Affichez-le plutôt que de refaire le calcul.


6. Vue consolidée — TROIS REGISTRES SÉPARÉS

GET /admin/api/finance/summary

Accepte from / to. Tout est agrégé côté serveur.

⚠️ Lisez ce paragraphe avant d’afficher le moindre total. Trois flux d’argent transitent par la même table, et ils n’appartiennent pas aux mêmes personnes :

Registre À qui est l’argent Où il vit
saas à nous — ventes de licences transactions, type=LICENSE_PURCHASE
tiketmomo aux tenanciers — ventes de tickets transactions, type=VOUCHER_SALE
hub_assistance aux professionnels — soldes et retraits expert_wallets, hors transactions

Le registre TiketMOMO se subdivise encore, et la distinction est financièrement décisive :

Il n’existe volontairement AUCUN champ « total encaissé ». Additionner ces registres gonflerait notre chiffre d’affaires de l’argent d’autrui. Le seul agrégat que vous avez le droit de présenter comme le nôtre est notre_chiffre_affaires = licences encaissées + notre commission sur les tickets. Rien d’autre.

{
  "success": true,
  "data": {
    "periode": { "from": null, "to": null },
    "complet": true,
    "lignes_agregees": 1234,

    "saas": {
      "encaisse": { "count": 100, "montant": 500000 },
      "en_attente": { "count": 80, "montant": 400000 },
      "echoue": { "count": 5, "montant": 9000 },
      "expire": { "count": 20, "montant": 30000 },
    },

    "tiketmomo": {
      "en_depot": { "count": 400, "montant": 60000 },
      "en_direct": { "count": 600, "montant": 90000 },
      "notre_commission": { "count": 1000, "montant": 5000 },
      "du_aux_tenanciers": { "count": 400, "montant": 55000 },
      "en_attente": { "count": 50, "montant": 7000 },
    },

    "hub_assistance": {
      "portefeuilles_professionnels": { "count": 2, "disponible": 0, "en_attente": 0 },
      "retraits_demandes": { "count": 0, "montant": 0 },
      "retraits_verses": { "count": 0, "montant": 0 },
      "retraits_rejetes": { "count": 0, "montant": 0 },
    },

    "non_classe": {
      "encaisse": { "count": 10, "montant": 30000 },
      "en_attente": { "count": 2, "montant": 4000 },
    },

    "anomalies": {
      "sans_type": { "count": 10, "montant": 30000 },
      "traitement_manuel": { "count": 2, "montant": 15000 },
    },

    "notre_chiffre_affaires": 505000,

    "sorties": {
      "vendeurs_tiketmomo": { "count": 0, "montant": 0 },
      "techniciens_hub": { "count": 0, "montant": 0 },
      "revendeurs": { "count": 0, "montant": 0 },
      "total": { "count": 0, "montant": 0 },
    },
  },
}

Quatre lectures à ne pas se tromper :

complet: false signale un agrégat tronqué : affichez un avertissement au lieu d’un total que l’utilisateur croirait exhaustif.

⚠️ anomalies mérite son propre écran.

7. Retraits des techniciens du Hub

GET /admin/api/expert-withdrawals

status optionnel : PENDING · PROCESSING · COMPLETED · REJECTED.

{
  "success": true,
  "data": [
    {
      "id": "uuid",
      "expert_id": "uuid",
      "amount": 15000,
      "status": "PENDING",
      "payout_method": "MOBILE_MONEY",
      "payout_details": {},
      "solde_restant": 3500,
      "created_at": "…",
    },
  ],
}

solde_restant est joint pour vous éviter un appel par ligne.

Route Effet
POST /admin/api/expert-withdrawals/:id/pay corps { "note": "…" }COMPLETED
POST /admin/api/expert-withdrawals/:id/reject corps { "reason": "…" }REJECTED et le portefeuille est recrédité

Le montant est débité au moment de la demande. Rejeter sans rembourser ferait donc disparaître l’argent du technicien : le serveur s’en charge, ne le faites pas côté interface.

pay est idempotent (deja_paye: true si déjà versé). Un 400 WITHDRAWAL_CONCURRENT_UPDATE signifie qu’un autre administrateur vient d’agir : rafraîchissez au lieu d’insister.

Ces deux routes portent un quota d’action : elles versent de l’argent. Ne les appelez pas en boucle.


8. Codes d’erreur

Code HTTP Sens
WITHDRAWAL_NOT_FOUND 404 identifiant inconnu
WITHDRAWAL_ALREADY_PAID 400 rejet impossible, la demande est versée
WITHDRAWAL_ALREADY_REJECTED 400 versement impossible, la demande est rejetée
WITHDRAWAL_CONCURRENT_UPDATE 400 l’état a changé entre votre lecture et votre action

9. Ce que le serveur garantit

N’implémentez pas ces protections côté interface.


10. Ce qui n’est PAS une erreur


11. Quota

100 requêtes / 15 min par IP, partagées par TOUTES les routes. Un tableau de bord qui rafraîchit plusieurs compteurs en boucle consomme le quota de toute l’application — et ce sont les autres appels qui échouent en 429, pas le vôtre. Rafraîchissez à l’ouverture et sur action, jamais en minuterie courte.


12. Voir aussi