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 :
en_depot(fee_origin=DEFAULT_STORE) — encaissé sur notre compte pour le compte du tenancier. Cet argent est chez nous, et nous le lui devons : c’est un passif.en_direct(fee_origin=MERCHANT_ACCOUNT) — versé directement sur le compte du tenancier. Il n’a jamais transité par notre caisse. L’afficher comme un encaissement serait une invention pure.
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 :
notre_chiffre_affairesest le seul total « à nous ». Ne recomposez jamais un total global en additionnant les registres — c’est précisément l’erreur que cette structure empêche.en_attenten’est du chiffre d’affaires dans aucun registre. Ce sont des paiements non aboutis : panier abandonné, ou encaissé jamais livré.du_aux_tenanciersest un passif, en net, frais déduits. Affichez-le comme une dette, jamais comme une recette.non_classeest de l’argent qu’aucun registre ne réclame. Il n’est pas rangé d’office chez nous : s’attribuer une somme dont on ignore le propriétaire serait la pire des approximations. C’est un écran de tri, pas une recette.
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.
-
traitement_manuel— paiements bloqués hors cycle, qu’un humain doit trancher. -
sessions_non_confirmees— le point le plus important de ce document. Le hub d’achat enregistre la transaction enstatus: SUCCESSau moment où il fabrique l’URL de paiement, avant que le client ait payé. Un simple clic sur « acheter » ressemble donc à un encaissement dans la table. Ces lignes sont exclues du revenu et regroupées ici. Elles représentent la majorité des montantsLICENSE_PURCHASEen base : les compter surestimait le chiffre d’affaires SaaS d’un facteur supérieur à 10.Une session n’est reconnue comme revenu que si
metadata.claimed === true. Ne contournez pas cette règle côté interface : unSUCCESSsur un identifiantcs_live_*ne prouve aucun paiement.
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
- Le montant d’un retrait vendeur est calculé côté serveur à partir des ventes réellement éligibles. Un montant envoyé par le client ne peut pas le gonfler.
- Réservation atomique : une vente déjà réservée par une demande ne peut pas être retirée deux fois.
payest idempotent, y compris via le lien e-mail.- Le portefeuille technicien est débité avant la création de la demande, en compare-and-swap : deux demandes simultanées ne peuvent pas vider le même solde.
- Les totaux de
summarysont calculés sur l’intégralité des lignes, pas sur un échantillon — etcompletle dit explicitement quand ce n’est pas le cas.
N’implémentez pas ces protections côté interface.
10. Ce qui n’est PAS une erreur
- Liste vide : aucune demande en attente. Affichez un état calme, pas un échec.
sales_count: 0avec un solde nul : le vendeur n’a rien à retirer, c’est normal.sortiesà zéro : aucun versement n’a encore été exécuté. C’est un fait, pas une panne de l’agrégat.- Un statut qui change sans votre action : le bouton de l’e-mail admin fait le même travail. Rafraîchissez au lieu de signaler un conflit.
error_messagerenseigné sur un reversement : c’est un refus opérateur, une information à afficher — pas une panne de la plateforme.
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
- Référence complète :
https://live.jmoai.net/api-docs - Source Markdown :
https://live.jmoai.net/docs/CONTRAT-API-ADMIN-FINANCE/raw