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) ouOFFLINE(ex: paiement à la livraison / cash).countryId(String?) :null= disponible dans tous les pays (même convention queProduct.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 avecOrder.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,
Orderporte aussicurrencyCode,subtotal,shippingFee,vatAmountettotalAmount(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)
| Endpoint | Méthode | Rôle Requis | Description / Règle métier |
|---|---|---|---|
/payments/gateway-status | GET | Administrateur (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/methods | POST | Administrateur (ADMIN) | Créer un nouveau moyen de paiement. |
/payments/methods | GET | Tous les authentifiés | Lister les moyens de paiement (actifs uniquement pour non-admin ; actifs + inactifs pour ADMIN), filtrables par pays. |
/payments/methods/:id | PATCH | Administrateur (ADMIN) | Modifier/activer/désactiver un moyen de paiement. |
/payments | POST | Acheteur (BUYER) | Initier un paiement sur une commande PENDING dont il est propriétaire. |
/payments/by-transaction/:transactionId | GET | Acheteur | Vendeur | Admin | Consulter un paiement par son transactionId (utilisé par la page de retour CinetPay). |
/payments/:id | GET | Acheteur | Vendeur | Admin | Consulter les détails d’un paiement (accès restreint aux participants de la commande). |
/payments/:id/process | POST | Acheteur | Admin | Faire transitionner manuellement un paiement OFFLINE (ou le faire échouer) — voir restriction ci-dessous pour ONLINE. |
/payments/webhook/cinetpay | POST | Public (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 statutPAIDviaPOST /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 :initCheckoutne fait aucun appel réseau et renvoie directement une URL de retour locale ;verifyTransactionrenvoie systématiquementPAID.CinetPayGatewayService— appelle réellement l’API Checkout CinetPay (POST {baseUrl}/paymentpour initier,POST {baseUrl}/payment/checkpour 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) :
| Variable | Rôle |
|---|---|
PAYMENT_GATEWAY_MODE | "fake" (défaut) ou "cinetpay". |
CINETPAY_API_KEY / CINETPAY_SITE_ID | Identifiants marchand CinetPay, envoyés à chaque appel API. |
CINETPAY_BASE_URL | Base de l’API Checkout CinetPay (https://api-checkout.cinetpay.com/v2 par défaut). |
CINETPAY_NOTIFY_URL | URL de notification serveur-à-serveur transmise à CinetPay à l’initialisation — doit pointer vers POST /payments/webhook/cinetpay. |
CINETPAY_RETURN_URL | URL de retour navigateur transmise à CinetPay — pointe vers /payment-return côté Store. |
[!WARNING] Dans
apps/api/.env,CINETPAY_API_KEYetCINETPAY_SITE_IDsont actuellement des valeurs placeholder ("placeholder-api-key","placeholder-site-id") etPAYMENT_GATEWAY_MODEvaut"fake". Tant que ces valeurs ne sont pas remplacées par de vrais identifiants sandbox/production,POST /paymentsavec une méthodeONLINEne déclenche aucune transaction réelle :FakeGatewayServicesimule un paiement immédiatement considéré comme réussi. L’endpointGET /payments/gateway-statuspermet à 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
CinetPayGatewayServicesont 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, lePaymentdéjà créé est marquéFAILEDplutôt que laisséPENDINGorphelin — le prochainPOST /paymentssur la même commande supprime cette tentative et repart à zéro. - Le webhook est idempotent : si le paiement est déjà
PAIDouREFUNDED, 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_idpour retrouver le paiement, puis rappellegateway.verifyTransaction(transactionId)avant d’appliquer un changement de statut (fonction interneapplyVerifiedStatus). - Après une transition vérifiée, un événement est journalisé via
AuditService.log(acteurSYSTEM_ACTOR, sévéritéINFOsiPAID/WARNINGsiFAILED) et un évènement temps réel est poussé à l’acheteur viaSseService.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
- 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 estPAID, il passe automatiquement àREFUNDED, et le stock des produits est réincrémenté (le produit est réactivé si nécessaire). - Livraison d’une commande hors-ligne :
Lorsqu’une commande dont le paiement est
OFFLINEetPENDINGpasse au statutDELIVERED, le paiement est automatiquement marquéPAID(le livreur a récupéré les fonds). - Traitement manuel via
POST /payments/:id/process: Réservé aux méthodesOFFLINE(ou pour marquer un paiementONLINEen échec — mais jamais enPAID). Rejette la requête si le paiement est déjàPAIDouREFUNDED.
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/FAILEDau chargement, affiche l’état final directement. - Sinon (
PENDING), ouvre une connexionEventSource('/api/events')et attend les évènements SSEpayment.confirmed/payment.failedpoussé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) — laredirectUrlpointe vers la page hébergée par la passerelle (CinetPay, ou une URL locale simulée en modefake) :{ "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_idest 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
ONLINEetstatus: "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" } }