Skip to Content
Gestion des Paiements

Gestion des Paiements

Ce document présente l’architecture, la logique métier et la spécification des API du module de gestion des paiements (apps/api/src/payments). Ce module gère la configuration dynamique des moyens de paiement (Mobile Money, paiement à la livraison…) ainsi que l’intégration réelle d’une passerelle de paiement en ligne (CinetPay), via une abstraction qui permet de basculer entre un mode de développement simulé et la passerelle réelle.


1. Modèle de Données et Relations

Le cycle de paiement s’articule autour des modèles Payment et PaymentMethod, liés à la commande (Order) et, pour les méthodes réservées à un pays, à Country.

Description des Champs Principaux

  • PaymentMethod (Configuration) :
    • id (String, UUID) : Identifiant unique de la méthode.
    • name (String) : Nom affiché aux clients (ex: “Orange Money Cameroun”).
    • slug (String, unique) : Identifiant utilisé dans les appels d’API (ex: "orange-money-cm").
    • type (PaymentMethodType) : ONLINE (passe par la passerelle CinetPay) ou OFFLINE (ex: paiement à la livraison / cash).
    • countryId (String?) : null = disponible dans tous les pays (même convention que Product.countryId) ; renseigné = réservé aux acheteurs de ce pays.
    • isActive (Boolean) : Active ou désactive le moyen de paiement.
  • Payment (Transactions) :
    • id (String, UUID) : Identifiant unique de la transaction.
    • orderId (String, unique) : Référence de la commande associée — relation 1:1 avec Order.
    • methodId (String) : Référence de la méthode de paiement utilisée.
    • transactionId (String, unique) : Identifiant de transaction généré côté API pour le suivi (txn_<slug>_<random>).
    • gatewayReference (String?) : Référence de la transaction chez la passerelle (CinetPay…), renseignée après l’initialisation d’un paiement en ligne.
    • gatewayRawPayload (Json?) : Dernière réponse brute de vérification de la passerelle, conservée pour le debug/audit — jamais utilisée seule comme source de vérité sur le statut.
    • status (PaymentStatus) : PENDING, PAID, FAILED, REFUNDED.

[!NOTE] Depuis l’ajout du support multi-pays, Order porte aussi currencyCode, subtotal, shippingFee, vatAmount et totalAmount (au lieu d’un simple montant total). totalAmount = subtotal + shippingFee + vatAmount, figé au moment de la commande — c’est ce montant que le module de paiement transmet à la passerelle. Voir orders.md pour le détail du calcul.


2. Rôles et Autorisations (RBAC)

EndpointMéthodeRôle RequisDescription / Règle métier
/payments/gateway-statusGETAdministrateur (ADMIN)Lire l’état de configuration des passerelles (mode actif, clés CinetPay renseignées ou non) — ne renvoie jamais les secrets eux-mêmes.
/payments/methodsPOSTAdministrateur (ADMIN)Créer un nouveau moyen de paiement.
/payments/methodsGETTous les authentifiésLister les moyens de paiement (actifs uniquement pour non-admin ; actifs + inactifs pour ADMIN), filtrables par pays.
/payments/methods/:idPATCHAdministrateur (ADMIN)Modifier/activer/désactiver un moyen de paiement.
/paymentsPOSTAcheteur (BUYER)Initier un paiement sur une commande PENDING dont il est propriétaire.
/payments/by-transaction/:transactionIdGETAcheteur | Vendeur | AdminConsulter un paiement par son transactionId (utilisé par la page de retour CinetPay).
/payments/:idGETAcheteur | Vendeur | AdminConsulter les détails d’un paiement (accès restreint aux participants de la commande).
/payments/:id/processPOSTAcheteur | AdminFaire transitionner manuellement un paiement OFFLINE (ou le faire échouer) — voir restriction ci-dessous pour ONLINE.
/payments/webhook/cinetpayPOSTPublic (aucun guard JWT)Notification serveur-à-serveur de CinetPay. La sécurité vient d’une re-vérification de la transaction auprès de CinetPay, jamais du contenu de la requête.

[!IMPORTANT] Pour une méthode ONLINE, un client ne peut jamais poser lui-même le statut PAID via POST /payments/:id/process : le service (payments.service.ts) rejette explicitement cette combinaison (ForbiddenException). Seul le webhook de la passerelle, après vérification serveur-à-serveur, peut confirmer un paiement en ligne.


3. Logiques Métier Clés et Flux

A. Abstraction de la passerelle (PaymentGateway)

Le service ne dépend que de l’interface PaymentGateway (initCheckout, verifyTransaction), injectée via le token PAYMENT_GATEWAY. Deux implémentations coexistent :

  • FakeGatewayService — passerelle de secours pour le développement : initCheckout ne fait aucun appel réseau et renvoie directement une URL de retour locale ; verifyTransaction renvoie systématiquement PAID.
  • CinetPayGatewayService — appelle réellement l’API Checkout CinetPay (POST {baseUrl}/payment pour initier, POST {baseUrl}/payment/check pour vérifier).

Le choix entre les deux est piloté par la variable d’environnement PAYMENT_GATEWAY_MODE ("fake" ou "cinetpay"), lue dans payments.module.ts :

useFactory: (fake: FakeGatewayService, cinetpay: CinetPayGatewayService) => process.env.PAYMENT_GATEWAY_MODE === 'cinetpay' ? cinetpay : fake,

Variables d’environnement associées (apps/api/.env) :

VariableRôle
PAYMENT_GATEWAY_MODE"fake" (défaut) ou "cinetpay".
CINETPAY_API_KEY / CINETPAY_SITE_IDIdentifiants marchand CinetPay, envoyés à chaque appel API.
CINETPAY_BASE_URLBase de l’API Checkout CinetPay (https://api-checkout.cinetpay.com/v2 par défaut).
CINETPAY_NOTIFY_URLURL de notification serveur-à-serveur transmise à CinetPay à l’initialisation — doit pointer vers POST /payments/webhook/cinetpay.
CINETPAY_RETURN_URLURL de retour navigateur transmise à CinetPay — pointe vers /payment-return côté Store.

[!WARNING] Dans apps/api/.env, CINETPAY_API_KEY et CINETPAY_SITE_ID sont actuellement des valeurs placeholder ("placeholder-api-key", "placeholder-site-id") et PAYMENT_GATEWAY_MODE vaut "fake". Tant que ces valeurs ne sont pas remplacées par de vrais identifiants sandbox/production, POST /payments avec une méthode ONLINE ne déclenche aucune transaction réelle : FakeGatewayService simule un paiement immédiatement considéré comme réussi. L’endpoint GET /payments/gateway-status permet à un admin de vérifier depuis le dashboard si les clés sont renseignées, sans jamais les exposer.

[!NOTE] Les champs de requête/réponse CinetPay dans CinetPayGatewayService sont posés d’après la documentation publique CinetPay au moment de l’écriture du code, et sont explicitement marqués comme à re-confirmer dès que de vrais identifiants sandbox sont disponibles.

B. Flux de Paiement En Ligne (ONLINE, ex. CinetPay)

La commande reste à l’état PENDING tant que le paiement n’est pas confirmé. POST /payments initialise la transaction chez la passerelle et renvoie une redirectUrl vers la page de paiement hébergée par CinetPay. Le statut PAID n’est jamais posé à l’initiation : il n’arrive que via le webhook, après vérification serveur-à-serveur.

Points clés côté payments.service.ts :

  • Si l’appel initCheckout échoue, le Payment déjà créé est marqué FAILED plutôt que laissé PENDING orphelin — le prochain POST /payments sur la même commande supprime cette tentative et repart à zéro.
  • Le webhook est idempotent : si le paiement est déjà PAID ou REFUNDED, la requête est acquittée sans rien réappliquer.
  • Le webhook ne fait jamais confiance au corps de la requête pour le statut : il n’utilise que le transaction_id / cpm_trans_id pour retrouver le paiement, puis rappelle gateway.verifyTransaction(transactionId) avant d’appliquer un changement de statut (fonction interne applyVerifiedStatus).
  • Après une transition vérifiée, un événement est journalisé via AuditService.log (acteur SYSTEM_ACTOR, sévérité INFO si PAID / WARNING si FAILED) et un évènement temps réel est poussé à l’acheteur via SseService.sendToUser (payment.confirmed / payment.failed).

C. Flux de Paiement Hors-Ligne (OFFLINE, ex. paiement à la livraison)

Pour un paiement à la livraison, la commande passe automatiquement en CONFIRMED dès l’initiation du paiement (le vendeur peut préparer l’envoi), tandis que le Payment reste PENDING jusqu’à la livraison effective.

D. Interactions Commande / Paiement

  1. Annulation et Remboursement (orders.service.ts) : Si un vendeur ou un administrateur annule une commande (PATCH /orders/:id/status { status: "CANCELLED" }), le service recherche le paiement associé. S’il est PAID, il passe automatiquement à REFUNDED, et le stock des produits est réincrémenté (le produit est réactivé si nécessaire).
  2. Livraison d’une commande hors-ligne : Lorsqu’une commande dont le paiement est OFFLINE et PENDING passe au statut DELIVERED, le paiement est automatiquement marqué PAID (le livreur a récupéré les fonds).
  3. Traitement manuel via POST /payments/:id/process : Réservé aux méthodes OFFLINE (ou pour marquer un paiement ONLINE en échec — mais jamais en PAID). Rejette la requête si le paiement est déjà PAID ou REFUNDED.

4. Flux Front-end (Store)

Le déclenchement du paiement se fait depuis CheckoutModal.tsx : après création de la commande (createOrderAction) puis initiation du paiement (initiatePaymentAction), si la méthode choisie est ONLINE, le panier est vidé et le navigateur est redirigé directement (window.location.href = payment.redirectUrl) vers la page hébergée par la passerelle (CinetPay, ou l’URL de retour simulée en mode fake).

Après paiement (ou échec) sur la page hébergée, la passerelle redirige le navigateur vers CINETPAY_RETURN_URL (/payment-return?transactionId=...). La page serveur payment-return/page.tsx récupère le paiement via GET /payments/by-transaction/:transactionId, puis PaymentReturnClient.tsx :

  • Si le paiement est déjà PAID/FAILED au chargement, affiche l’état final directement.
  • Sinon (PENDING), ouvre une connexion EventSource('/api/events') et attend les évènements SSE payment.confirmed / payment.failed poussés par le webhook (voir §3.B) pour mettre à jour l’affichage en temps réel, avec un délai d’abandon de 2 minutes au-delà duquel l’utilisateur est invité à vérifier manuellement depuis ses commandes.

5. Guide des Endpoints et Exemples

Lire l’état des passerelles (GET /payments/gateway-status)

  • Réponse (200 OK, ADMIN requis) :
    { "activeGateway": "fake", "cinetpay": { "baseUrl": "https://api-checkout.cinetpay.com/v2", "apiKeyConfigured": false, "siteIdConfigured": false, "notifyUrlConfigured": true, "returnUrlConfigured": true } }

Enregistrer une Méthode de Paiement (POST /payments/methods)

  • Requête (ADMIN requis) :
    { "name": "Orange Money Cameroun", "slug": "orange-money-cm", "type": "ONLINE", "logoUrl": "https://cdn.example.com/orange-money.png", "countryId": "cty-cm-uuid-0001" }
  • Réponse (201 Created) :
    { "id": "meth-om-uuid-1234", "name": "Orange Money Cameroun", "slug": "orange-money-cm", "type": "ONLINE", "logoUrl": "https://cdn.example.com/orange-money.png", "isActive": true, "countryId": "cty-cm-uuid-0001", "country": { "id": "cty-cm-uuid-0001", "name": "Cameroun", "isoCode": "CM" }, "createdAt": "2026-09-01T10:00:00.000Z", "updatedAt": "2026-09-01T10:00:00.000Z" }

Initier un Paiement en Ligne (POST /payments)

  • Requête :
    { "orderId": "95020812-6622-4016-b5ca-80bc4726a0e7", "methodSlug": "orange-money-cm" }
  • Réponse (201 Created) — la redirectUrl pointe vers la page hébergée par la passerelle (CinetPay, ou une URL locale simulée en mode fake) :
    { "id": "pay-txn-uuid-5678", "orderId": "95020812-6622-4016-b5ca-80bc4726a0e7", "methodId": "meth-om-uuid-1234", "transactionId": "txn_orange-money-cm_889F82A0B", "gatewayReference": "fake_txn_orange-money-cm_889F82A0B", "status": "PENDING", "createdAt": "2026-09-01T10:05:00.000Z", "updatedAt": "2026-09-01T10:05:01.000Z", "method": { "id": "meth-om-uuid-1234", "name": "Orange Money Cameroun", "slug": "orange-money-cm", "type": "ONLINE" }, "redirectUrl": "http://localhost:3002/payment-return?transactionId=txn_orange-money-cm_889F82A0B&fake=1" }

Notification de la Passerelle (POST /payments/webhook/cinetpay)

  • Requête (envoyée par CinetPay, non authentifiée par JWT) — le corps exact dépend de la passerelle, seul transaction_id/cpm_trans_id est exploité, le reste est ignoré et le statut est re-vérifié côté serveur :
    { "cpm_trans_id": "txn_orange-money-cm_889F82A0B", "cpm_site_id": "placeholder-site-id" }
  • Réponse (200 OK), toujours acquittée pour éviter les tentatives en boucle de CinetPay :
    { "received": true }

Consulter un Paiement par Transaction (GET /payments/by-transaction/:transactionId)

  • Réponse (200 OK) — utilisé par la page /payment-return :
    { "id": "pay-txn-uuid-5678", "orderId": "95020812-6622-4016-b5ca-80bc4726a0e7", "transactionId": "txn_orange-money-cm_889F82A0B", "status": "PAID", "gatewayReference": "fake_txn_orange-money-cm_889F82A0B" }

Traiter Manuellement un Paiement Hors-Ligne (POST /payments/:id/process)

  • Requête (Acheteur propriétaire ou Admin ; interdit si la méthode est ONLINE et status: "PAID") :
    { "status": "FAILED" }
  • Réponse (200 OK) :
    { "id": "pay-txn-uuid-5678", "orderId": "95020812-6622-4016-b5ca-80bc4726a0e7", "methodId": "meth-cash-uuid-9999", "transactionId": "txn_cash_112C93B4D", "status": "FAILED", "createdAt": "2026-09-01T10:05:00.000Z", "updatedAt": "2026-09-01T10:12:00.000Z", "method": { "id": "meth-cash-uuid-9999", "name": "Paiement à la livraison", "slug": "cash", "type": "OFFLINE" } }
Last updated on