Contrat API — Portail Revendeur B2B (MSP)
Destinataire : développeur du portail revendeur
Base : https://live.jmoai.net · Enveloppe : { success: boolean, data: …, error?: { code, message } }
Authentification : cookie partner_token ou Authorization: Bearer <jwt partenaire>.
Les deux passent par la même porte (requirePartnerAuth), donc le portail web et un
intégrateur tiers utilisent le même contrat. Un compte partenaire suspendu reçoit 401.
⚠️ Une session valide ne suffit plus (14/08/2026). Un compte peut posséder un code de parrainage sans être revendeur : ce sont deux qualités distinctes, et la seconde ne se déduit pas de la première.
L’accès au portail exige désormais une candidature approuvée. Un compte authentifié mais non approuvé reçoit :
{ "success": false, "error": { "code": "RESELLER_NOT_APPROVED", "message": "…" } }
avec un 403. Si la vérification elle-même échoue, la réponse est 503
APPROVAL_CHECK_UNAVAILABLE — jamais un accès accordé par défaut.
Déploiement progressif : la garde démarre en mode
observe— elle trace sans bloquer. Son activation est décidée côté exploitation, après relevé des comptes concernés.
État au 2026-08-12 — §1 (CRM) et §2 (inventaire, wallet, achat, affectation) sont implémentés et testés. §3 reste partiel : deux emails sur huit sont en service.
Le rechargement B2B ouvre un checkout ; le solde n’est crédité qu’après le webhook de paiement confirmé. Un abandon ou un retour navigateur ne crédite donc jamais le wallet.
1. CRM — les clients du revendeur
Le rattachement d’un client à un revendeur vit dans partner_referrals. Il couvre aussi
bien les clients venus d’un code promo que ceux que le revendeur a créés lui-même : les
deux apparaissent dans la même liste.
1.1 GET /api/v1/resellers/clients
| Paramètre | Requis | Défaut | Rôle |
|---|---|---|---|
limit |
— | 50 | taille de page, plafonnée à 200 |
page |
— | 1 | page demandée, à partir de 1 |
{
"success": true,
"data": [
{
"id": "mgr_9f3a…",
"name": "CyberCafé Etoile",
"email": "contact@etoile.local",
"phone": "+229…",
"status": "ACTIVE",
"active_licenses": 2,
"created_at": "2026-07-01T10:00:00Z",
"affiliated_at": "2026-08-10T10:00:00Z",
"affiliation_status": "PENDING"
}
],
"pagination": { "page": 1, "limit": 50, "total": 128, "has_more": true }
}
Deux dates, et les confondre fausse l’ancienneté affichée :
created_at= date de création du compte client.affiliated_at= date de rattachement au revendeur. Un client peut exister depuis des mois et n’être affilié que d’hier.
active_licenses compte les licences ACTIVE dont l’échéance n’est pas passée. Le
statut en base n’est remis à jour qu’au passage du cron de cycle de vie : entre l’échéance
réelle et ce passage, compter sur le seul statut gonflerait le parc affiché.
Un rattachement dont le compte a été supprimé est écarté de la liste, jamais rendu avec
des champs vides. La liste peut donc contenir moins d’entrées que pagination.total.
1.2 POST /api/v1/resellers/clients
Le revendeur ouvre un compte J+SERVICES pour son client. Le compte est créé par le
chemin commun de la plateforme (ligne managers, app et site par défaut, identité liée,
utilisateur Supabase Auth), avec sa provenance réelle : RESELLER_PORTAL, auteur = le
revendeur. Le rattachement au revendeur est posé dans la foulée.
{ "name": "CyberCafé Etoile", "email": "contact@etoile.local", "phone": "+22990000000" }
name (2–120) et email sont requis, phone est optionnel. Tout champ hors de cette
liste est supprimé avant d’atteindre le serveur (stripUnknown), sans erreur : n’envoyez
pas de champ « en avance » en espérant qu’il soit stocké.
Réponse 201 :
{
"success": true,
"data": {
"id": "mgr_9f3a…",
"name": "CyberCafé Etoile",
"email": "contact@etoile.local",
"phone": "+22990000000",
"status": "ACTIVE",
"active_licenses": 0,
"created_at": "2026-08-12T09:00:00Z",
"auth_state": "READY",
"temporary_password": "Jplus-4f2a91c0d3e7!"
}
}
temporary_passwordn’est servi qu’ici, une seule fois. Aucune autre route ne le renvoie, et il ne réapparaît pas dansGET /clients. Il est également envoyé au client par email (§3, Client #1) ; le champ existe pour que le revendeur puisse le relayer si le mail n’arrive pas. Affichez-le une fois, ne le stockez pas.
| Code HTTP | error.code |
Cause |
|---|---|---|
| 400 | VALIDATION_ERROR |
name ou email absent/invalide (details[] liste les champs) |
| 400 | SELF_REFERRAL_FORBIDDEN |
l’email est celui du revendeur lui-même |
| 400 | PARTNER_NOT_FOUND |
le partenaire authentifié n’existe plus |
| 409 | CLIENT_EMAIL_ALREADY_EXISTS |
un compte J+SERVICES porte déjà cet email |
| 500 | CLIENT_LINK_FAILED |
compte créé mais rattachement impossible — le compte existe, ne réessayez pas à l’identique (vous obtiendriez un 409), remontez au support |
L’envoi de l’email n’est pas attendu par la requête : un incident SMTP ne fait jamais échouer une création de compte déjà effective en base.
2. Inventaire, wallet et licences
2.1 Deux soldes, jamais additionnés
- Portefeuille B2B prépayé (
balancedeGET /inventory) : ce qui paie le stock. - Solde de commissions (
resellers.balance, portail historique) : ce que la plateforme doit au revendeur, retirable dès 2 000 XOF.
Les afficher comme un seul chiffre serait faux : une commission gagnée est une dette envers le revendeur, pas du pouvoir d’achat de stock.
Le portefeuille prépayé est tenu dans le registre en partie double de la plateforme : journal immuable, clé d’idempotence par mouvement, solde tenu par trigger. Un découvert est refusé par la base elle-même, pas par le code applicatif.
2.2 GET /api/v1/resellers/inventory
{
"success": true,
"data": {
"balance": 150000,
"currency": "XOF",
"available_licenses": [
{
"id": "mikhmoai:vpn-6m",
"product": "mikhmoai",
"product_code": "mikhmoai",
"plan_code": "vpn-6m",
"duration_days": 180,
"quantity": 5,
"purchased_at": "2026-08-01T00:00:00Z",
"license_ids": ["…", "…"]
}
],
"catalog_prices": [
{
"product_id": "1cabff94-…",
"product_code": "mikhmoai",
"plan_code": "vpn-6m",
"name": "VPN 6 Mois",
"duration_days": 180,
"retail_price": 4000,
"reseller_price": 3600,
"currency": "XOF"
}
],
"price_tiers": [
{ "min_quantity": 1, "discount_pct": 10, "label": "Unité" },
{ "min_quantity": 5, "discount_pct": 20, "label": "Pack Bronze" },
{ "min_quantity": 20, "discount_pct": 30, "label": "Pack Gold" }
]
}
}
Le stock est regroupé par plan — un revendeur raisonne en « 5 licences Pro », pas en
cinq codes. license_ids reste fourni pour viser un code précis à l’affectation.
reseller_price est le prix à l’unité (palier de quantité 1). Affichez price_tiers :
le prix réel dépend de la quantité commandée. Les plans à prix nul (essais gratuits) ne
figurent pas au catalogue de gros.
2.3 POST /api/v1/resellers/inventory/topup
Ouvre un checkout pour créditer le portefeuille prépayé du revendeur authentifié. Cette route ne crédite aucun solde ; seuls les webhooks FedaPay ou GeniusPay, après confirmation du montant réellement encaissé, écrivent le mouvement au registre.
{ "amount": 16000, "provider": "fedapay", "phone": "+22990000000" }
amount est obligatoire et doit être compris entre les bornes configurées par la
plateforme (500–2 000 000 XOF par défaut). provider vaut fedapay par défaut ou
geniuspay. phone et return_url sont optionnels. L’identité du portefeuille ne vient
jamais du corps : elle est tirée de la session partenaire.
Réponse 201 : checkout_url, internal_tx_id, provider, amount, currency.
Conservez internal_tx_id pour suivre le paiement ; réessayer un même webhook ne peut pas
créditer deux fois car la clé d’idempotence est dérivée de sa référence prestataire.
| Code HTTP | error.code |
Cause |
|---|---|---|
| 400 | VALIDATION_ERROR / MONTANT_HORS_BORNES |
montant, prestataire ou URL de retour invalide |
| 401 | — | session partenaire absente ou suspendue |
| 429 | CLICK_SPAM_PROTECTION |
quota par revendeur (5/min) |
| 502 | LICENSE_CHECKOUT_PROVIDER_ERROR |
prestataire indisponible, aucun crédit effectué |
2.4 POST /api/v1/resellers/inventory/purchase
{ "plan_code": "vpn-6m", "quantity": 5 }
plan_code, pas product_id : un produit porte plusieurs plans à des prix différents,
donc le prix appartient au plan. Le product_id reste exposé au catalogue pour vos
regroupements d’affichage.
Réponse 201 : unit_price, total_amount, discount_pct, tier_label, balance
(solde après débit), license_ids (les codes créés), transfer_id et
purchase_reference.
L’argent bouge avant la livraison du stock. Si la génération des codes échoue après le débit, un mouvement inverse est écrit — le journal est immuable, une erreur se corrige par une écriture, jamais par une modification.
| Code HTTP | error.code |
Cause |
|---|---|---|
| 400 | VALIDATION_ERROR / QUANTITY_TOO_LARGE |
payload invalide, quantité > 500 |
| 400 | PLAN_NOT_FOUND |
plan inconnu ou inactif |
| 402 | WALLET_INSUFFICIENT_FUNDS |
solde insuffisant — details porte balance et required |
| 429 | CLICK_SPAM_PROTECTION |
quota par revendeur (5/min) sur cette route |
2.5 POST /api/v1/resellers/inventory/assign
{ "client_id": "mgr_9f3a…", "plan_code": "vpn-6m" }
plan_code sort le plus ancien code disponible (FIFO) ; license_id vise un code précis.
L’un des deux est requis.
Deux titres sont exigés, aucun ne suffit seul : le code doit appartenir au revendeur,
et le client doit lui être rattaché (partner_referrals). Sans le second contrôle, un
revendeur pourrait activer une licence sur le compte de n’importe qui.
| Code HTTP | error.code |
Cause |
|---|---|---|
| 400 | CLIENT_NOT_AFFILIATED |
ce client n’est pas dans votre portefeuille |
| 400 | LICENSE_NOT_IN_STOCK / LICENSE_ALREADY_ASSIGNED |
code absent de votre stock, ou déjà consommé |
| 400 | STOCK_DEPLETED |
aucune licence disponible pour ce plan |
| 429 | CLICK_SPAM_PROTECTION |
quota par revendeur (5/min) |
2.6 PATCH /marketing/promo — non implémenté
La modification du code promo existe aujourd’hui sur le portail historique
(POST /resellers/api/profile/promo-code), avec contrôle d’unicité et rejet des codes
trop proches d’un code existant. Le verbe PATCH versionné et le champ discount_pct
restent à faire.
3. Emails transactionnels
Branding : « J+SERVICES via Nom du Revendeur ». Le client n’a jamais démarché J+SERVICES, il a traité avec son partenaire : masquer le partenaire ferait passer le message pour du démarchage non sollicité.
| # | Déclencheur | État |
|---|---|---|
| Client #1 — accès créés par le revendeur | POST /clients |
✅ en service |
| Client #2 — licence activée par le revendeur | POST /inventory/assign |
à venir |
| Client #3 — expiration J-3 | cron | à venir |
| Revendeur #1 — bienvenue programme partenaire | validation du compte | ✅ déjà en service (sendPartnerWelcome) |
| Revendeur #2 — reçu d’achat de stock | POST /inventory/purchase |
à venir |
| Revendeur #3 — nouvelle commission | usage du code promo | à venir |
| Revendeur #4 — activation par un client affilié | activation client | à venir |
| Revendeur #5 — expiration d’un client (upsell) | cron J-3 | à venir |
4. Arbitrages tranchés
Wallet B2B = porte-monnaie prépayé séparé. resellers.balance reste le solde de
commissions, retirable à partir de 2 000 XOF. Le stock de licences se paie sur un
second registre, alimenté par paiement. Les deux ne sont jamais additionnés : une
commission gagnée est une dette de la plateforme envers le revendeur, pas du stock.
Prix de gros = barème volume en base. Aujourd’hui deux chemins facturent le même acte
à deux prix : /api/v1/referral/vouchers/buy applique un barème 10/20/30 % codé en dur
dans le contrôleur, tandis que /resellers/api/licenses/buy-pack facture le prix public
plein, sans aucune remise. Le barème est désormais en base (reseller_price_tiers) et les deux chemins le lisent.
Il est amorcé aux mêmes 10/20/30 % : rien n’a changé pour l’acheteur le jour du
déploiement, mais une remise se modifie maintenant sans redéploiement.