Documentation J+SERVICES Guides Référence API

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 :

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_password n’est servi qu’ici, une seule fois. Aucune autre route ne le renvoie, et il ne réapparaît pas dans GET /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

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/promonon 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.