ITSkillsCenter
Business Digital

Odoo et paiement mobile money : intégration Wave, Orange Money

14 دقائق للقراءة

Lecture : 16 minutes · Niveau : avancé · Mise à jour : avril 2026

Tutoriel pratique pour intégrer un agrégateur mobile money (PayDunya, CinetPay, Hub2 ou Wave Business) à Odoo en créant un payment provider custom : modèle Python qui hérite de payment.provider, contrôleur HTTP qui reçoit le webhook signé, vérification HMAC, mise à jour de la transaction, réconciliation comptable automatique. Tout est testé sur Odoo 17 et utilise un endpoint mock pour les tests sans frais réels.

Pourquoi un module custom plutôt qu’un module communautaire existant ? Plusieurs modules sont disponibles sur OCA et le Odoo Apps Store pour PayDunya, CinetPay, etc. Pour une PME démarrant et qui veut un support standard, ces modules suffisent souvent. Pour une PME qui souhaite logique métier custom (commission split, multi-comptes, anti-fraude maison, intégration avec un ERP custom), un module fait sur mesure permet de tout maîtriser. Ce tutoriel construit le second cas de figure et reste compatible avec un fork de module communautaire si vous préférez partir d’une base existante.

Sécurité avant tout. L’intégration paiement est l’un des points les plus sensibles d’un ERP. Une signature HMAC mal vérifiée permet à un attaquant de simuler n’importe quel paiement. Une clé API en clair dans Git compromet immédiatement le compte agrégateur. Un webhook accessible sans authentification couplé à une route CSRF désactivée ouvre une porte. Tous les patterns de sécurité présentés ici (HMAC compare_digest, secret en champ chiffré avec groups='base.group_system', vérification idempotente, logs structurés) sont indispensables et non négociables.

Voir aussi → Odoo PME Afrique : guide complet, Odoo modules essentiels pour PME, Odoo personnalisation et développement.


Sommaire

  1. Choisir un agrégateur
  2. Architecture de l’intégration
  3. Créer le module pme_mobilemoney
  4. Modèle payment.provider hérité
  5. Vue de configuration provider
  6. Bouton de paiement front (S2S)
  7. Contrôleur webhook signé
  8. Gestion des statuts et erreurs
  9. Réconciliation comptable automatique
  10. Tester en bout-en-bout (script mock)
  11. FAQ

1. Choisir un agrégateur

Agrégateur Pays couverts Mobile money Documentation
PayDunya Sénégal, Côte d’Ivoire, Mali, Burkina, Bénin, Togo Wave, Orange, Free, MTN, Moov, cartes paydunya.com/developers
CinetPay 13 pays Afrique de l’Ouest et Centrale OM, MTN, Moov, Wave, cartes cinetpay.com/api/docs
Hub2 CEDEAO étendue Idem + crypto hub2.io/docs
Wave Business API Sénégal, Côte d’Ivoire, Burkina Wave uniquement docs.wave.com

Choisir Wave Business API si Wave est dominant dans votre cible et que les frais sont vos priorité. Choisir PayDunya/CinetPay/Hub2 si vous voulez un seul SDK pour tous les opérateurs.

Le tutoriel ci-dessous est générique : remplacer l’URL d’API et la signature par celles de votre agrégateur.

Comment mesurer le bon agrégateur en pratique. Au-delà des taux affichés, plusieurs critères influent fortement sur le ROI réel : taux de succès (combien de paiements aboutissent réellement par rapport aux tentatives), latence du webhook (de quelques secondes à plusieurs minutes selon les agrégateurs et opérateurs), qualité du support (réactivité quand un paiement reste bloqué en pending), couverture des opérateurs émergents. Un agrégateur 0.5% moins cher mais avec 5% de transactions échouées coûte plus cher au global. Faire une période d’A/B testing sur 200-500 transactions réelles avec deux agrégateurs en parallèle est la méthode la plus fiable pour trancher.


2. Architecture de l’intégration

[Client Odoo Website]
       │ clic Payer
       ▼
[Odoo: payment.transaction created]
       │
       │ POST /api/checkout
       ▼
[Agrégateur Mobile Money]
       │
       ▼
[Page de saisie OTP / Push USSD]
       │
       ▼
[Confirmation utilisateur]
       │
       │ webhook signé HMAC
       ▼
[Odoo Controller: /payment/mobilemoney/webhook]
       │
       │ vérif signature, mise à jour state
       ▼
[payment.transaction → done]
       │
       ▼
[account.move créé + réconciliation]

3. Créer le module pme_mobilemoney

docker compose exec odoo \
  odoo scaffold pme_mobilemoney /mnt/extra-addons

__manifest__.py :

{
    'name': 'PME Mobile Money',
    'version': '17.0.1.0.0',
    'summary': 'Provider de paiement mobile money (Wave, OM, MTN, etc.)',
    'category': 'Accounting/Payment',
    'depends': ['payment', 'account'],
    'data': [
        'data/payment_provider_data.xml',
        'views/payment_provider_views.xml',
        'views/payment_form_templates.xml',
    ],
    'license': 'LGPL-3',
    'application': False,
    'installable': True,
}

4. Modèle payment.provider hérité

models/payment_provider.py :

import hmac, hashlib, json, logging, requests
from odoo import api, fields, models, _
from odoo.exceptions import UserError

_logger = logging.getLogger(__name__)


class PaymentProvider(models.Model):
    _inherit = 'payment.provider'

    code = fields.Selection(
        selection_add=[('mobilemoney', 'Mobile Money (PayDunya/Hub2)')],
        ondelete={'mobilemoney': 'set default'})

    mobilemoney_master_key = fields.Char(string='Master Key',
        groups='base.group_system')
    mobilemoney_private_key = fields.Char(string='Private Key',
        groups='base.group_system')
    mobilemoney_token = fields.Char(string='Token',
        groups='base.group_system')
    mobilemoney_webhook_secret = fields.Char(string='Webhook Secret',
        groups='base.group_system')

    def _get_supported_currencies(self):
        all_currencies = super()._get_supported_currencies()
        if self.code == 'mobilemoney':
            return all_currencies.filtered(
                lambda c: c.name in ['XOF', 'XAF', 'EUR', 'USD'])
        return all_currencies

    def _mobilemoney_make_request(self, endpoint, payload):
        """Envoie une requête signée à l'agrégateur."""
        self.ensure_one()
        url = f"{self._get_api_base()}{endpoint}"
        headers = {
            'Content-Type': 'application/json',
            'PAYDUNYA-MASTER-KEY': self.mobilemoney_master_key,
            'PAYDUNYA-PRIVATE-KEY': self.mobilemoney_private_key,
            'PAYDUNYA-TOKEN': self.mobilemoney_token,
        }
        try:
            r = requests.post(url, headers=headers,
                              json=payload, timeout=30)
            r.raise_for_status()
            return r.json()
        except requests.HTTPError as e:
            _logger.exception("Mobile Money API error: %s", e)
            raise UserError(_(
                "Erreur de connexion à l'agrégateur de paiement."))

    def _get_api_base(self):
        return 'https://app.paydunya.com/api/v1' if self.state == 'enabled' \
            else 'https://app.paydunya.com/sandbox-api/v1'

5. Vue de configuration provider

views/payment_provider_views.xml :

<odoo>
  <record id="payment_provider_form_inherit" model="ir.ui.view">
    <field name="name">payment.provider.form.mobilemoney</field>
    <field name="model">payment.provider</field>
    <field name="inherit_id" ref="payment.payment_provider_form"/>
    <field name="arch" type="xml">
      <group name="provider_credentials" position="inside">
        <group invisible="code != 'mobilemoney'">
          <field name="mobilemoney_master_key"
                 password="True"
                 required="code == 'mobilemoney' and state == 'enabled'"/>
          <field name="mobilemoney_private_key" password="True"/>
          <field name="mobilemoney_token" password="True"/>
          <field name="mobilemoney_webhook_secret" password="True"/>
        </group>
      </group>
    </field>
  </record>
</odoo>

data/payment_provider_data.xml :

<odoo noupdate="1">
  <record id="payment_provider_mobilemoney" model="payment.provider">
    <field name="name">Mobile Money</field>
    <field name="code">mobilemoney</field>
    <field name="state">test</field>
    <field name="image_128"
           type="base64" file="pme_mobilemoney/static/description/icon.png"/>
  </record>
</odoo>

6. Bouton de paiement front (S2S)

models/payment_transaction.py :

from odoo import models, _


class PaymentTransaction(models.Model):
    _inherit = 'payment.transaction'

    def _get_specific_rendering_values(self, processing_values):
        res = super()._get_specific_rendering_values(processing_values)
        if self.provider_code != 'mobilemoney':
            return res

        # Créer une invoice agrégateur
        payload = {
            'invoice': {
                'total_amount': float(self.amount),
                'description': self.reference,
            },
            'store': {'name': self.company_id.name or 'PME'},
            'actions': {
                'cancel_url': self._get_default_cancel_url(),
                'return_url': self._get_default_return_url(),
                'callback_url':
                    f"{self.provider_id.get_base_url()}/payment/mobilemoney/webhook",
            },
            'custom_data': {
                'tx_reference': self.reference,
                'tx_id': self.id,
            },
        }

        result = self.provider_id._mobilemoney_make_request(
            '/checkout-invoice/create', payload)

        if result.get('response_code') != '00':
            raise UserError(result.get('response_text', 'Erreur'))

        return {
            'redirect_url': result['response_text']  # URL hosted page
        }

7. Contrôleur webhook signé

controllers/main.py :

import hashlib, hmac, json, logging
from odoo import http
from odoo.http import request

_logger = logging.getLogger(__name__)


class MobileMoneyController(http.Controller):

    @http.route('/payment/mobilemoney/webhook',
                type='http', auth='public', methods=['POST'], csrf=False)
    def webhook(self):
        raw = request.httprequest.data
        signature = request.httprequest.headers.get('X-Signature', '')

        # 1. Récupère le provider
        provider = request.env['payment.provider'].sudo().search(
            [('code', '=', 'mobilemoney')], limit=1)
        if not provider:
            return 'Provider not configured'

        # 2. Vérifie HMAC SHA-256
        secret = provider.mobilemoney_webhook_secret.encode()
        expected = hmac.new(secret, raw, hashlib.sha256).hexdigest()
        if not hmac.compare_digest(signature, expected):
            _logger.warning("Webhook signature invalid: %s", signature)
            return 'Invalid signature'

        # 3. Décoder
        try:
            data = json.loads(raw)
        except ValueError:
            return 'Invalid JSON'

        # 4. Récupérer transaction
        tx_ref = data.get('custom_data', {}).get('tx_reference')
        tx = request.env['payment.transaction'].sudo().search(
            [('reference', '=', tx_ref)], limit=1)
        if not tx:
            _logger.warning("Webhook tx not found: %s", tx_ref)
            return 'TX not found'

        # 5. Mettre à jour
        tx._handle_notification_data('mobilemoney', data)
        return 'OK'

models/payment_transaction.py (suite) :

def _process_notification_data(self, notification_data):
    super()._process_notification_data(notification_data)
    if self.provider_code != 'mobilemoney':
        return

    status = notification_data.get('status')
    receipt = notification_data.get('receipt_number') or self.reference

    if status == 'completed':
        self.provider_reference = receipt
        self._set_done()
    elif status == 'pending':
        self._set_pending()
    elif status in ('failed', 'cancelled'):
        self._set_canceled()

8. Gestion des statuts et erreurs

Statut agrégateur Action Odoo
completed / success _set_done() → facture payée auto
pending / processing _set_pending()
failed / declined _set_canceled()
cancelled by user _set_canceled()
timeout _set_canceled() + notification mail

Idempotence du webhook : un agrégateur peut retenter le webhook plusieurs fois. Odoo _handle_notification_data est déjà idempotent : si la transaction est done, retraiter ne fait rien. Toujours vérifier ce comportement avec votre version.

Retry serveur down : configurer côté agrégateur des retries exponentiels (1 min → 5 min → 30 min → 1 h) et surveiller les transactions pending > 1h.


9. Réconciliation comptable automatique

Quand _set_done() est appelé sur une payment.transaction, Odoo :
1. Crée un account.payment lié
2. Si la transaction est rattachée à une sale.order, valide la commande
3. Crée la account.move (facture) si paiement avant livraison
4. Lance le matching contre les écritures bancaires

Pour automatiser le rapprochement bancaire quand le relevé arrive (CSV via banque ou import API) :

# models/account_bank_statement_line.py
from odoo import api, models


class AccountBankStatementLine(models.Model):
    _inherit = 'account.bank.statement.line'

    @api.model
    def _auto_match_mobilemoney(self):
        """Cherche les transactions mobile money matching le label."""
        unreconciled = self.search([
            ('is_reconciled', '=', False),
            ('payment_ref', 'ilike', 'WAVE-')
        ])
        for line in unreconciled:
            # Extrait référence WAVE-XXXX du libellé
            import re
            m = re.search(r'WAVE-([A-Z0-9]+)', line.payment_ref)
            if not m:
                continue
            ref = m.group(1)
            tx = self.env['payment.transaction'].search(
                [('provider_reference', '=', ref)], limit=1)
            if tx and tx.state == 'done':
                line.partner_id = tx.partner_id
                # Match auto via _try_auto_reconcile
                line.with_context(auto_reconcile=True)._auto_reconcile()

Ajouter en cron :

<record id="cron_match_mobilemoney" model="ir.cron">
  <field name="name">Auto-match Mobile Money</field>
  <field name="model_id" ref="model_account_bank_statement_line"/>
  <field name="state">code</field>
  <field name="code">model._auto_match_mobilemoney()</field>
  <field name="interval_number">1</field>
  <field name="interval_type">hours</field>
</record>

10. Tester en bout-en-bout (script mock)

Mock webhook (script Python) :

import hashlib, hmac, json, requests

ODOO_URL = "http://localhost:8069"
SECRET = "VOTRE_WEBHOOK_SECRET"  # même valeur que dans Odoo

payload = {
    "status": "completed",
    "receipt_number": "WAVE-TEST-001",
    "custom_data": {
        "tx_reference": "S00042",  # ref de transaction Odoo
        "tx_id": 42
    },
    "amount": 25000,
    "currency": "XOF"
}

raw = json.dumps(payload).encode()
sig = hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()

r = requests.post(
    f"{ODOO_URL}/payment/mobilemoney/webhook",
    data=raw,
    headers={
        "Content-Type": "application/json",
        "X-Signature": sig
    },
    timeout=10)

print(r.status_code, r.text)
python mock_webhook.py
# 200 OK

Dans Odoo : Sales → Quotations → S00042 → vérifier statut « Paid ».

Tests unitaires Python (tests/test_provider.py) :

from odoo.tests import TransactionCase
from unittest.mock import patch


class TestMobileMoneyProvider(TransactionCase):

    def setUp(self):
        super().setUp()
        self.provider = self.env.ref(
            'pme_mobilemoney.payment_provider_mobilemoney')
        self.provider.write({
            'mobilemoney_master_key': 'mk_test',
            'mobilemoney_private_key': 'pk_test',
            'mobilemoney_token': 'tk_test',
            'mobilemoney_webhook_secret': 'whsec_test',
        })

    @patch('requests.post')
    def test_create_invoice_success(self, mock_post):
        mock_post.return_value.json.return_value = {
            'response_code': '00',
            'response_text': 'https://paydunya.com/checkout/abc'
        }
        mock_post.return_value.raise_for_status = lambda: None
        res = self.provider._mobilemoney_make_request(
            '/checkout-invoice/create', {'invoice': {'total_amount': 1000}})
        self.assertEqual(res['response_code'], '00')
docker compose exec odoo \
  odoo --test-enable -d pme-test -i pme_mobilemoney --stop-after-init

FAQ

Quel agrégateur choisir entre PayDunya, CinetPay et Hub2 ?

Tous offrent une couverture multi-opérateurs et une API REST. PayDunya est très utilisé au Sénégal/CI, doc claire. CinetPay couvre plus de pays. Hub2 plus moderne. Tester les 3 sur vos volumes pendant 1 mois en parallèle pour comparer les frais effectifs et la stabilité du webhook.

Wave Business API directe ou via agrégateur ?

Wave Business directe : frais plus bas, mais Wave uniquement (pas Orange Money). Via agrégateur : un seul SDK pour tous les opérateurs, frais marginaux plus élevés. Pour PME 100% Wave : direct. Pour PME multi-opérateurs : agrégateur.

Comment tester sans dépenser ?

Tous les agrégateurs proposent un environnement sandbox gratuit (URL de test, master keys de test, transactions virtuelles). Toujours développer et qualifier en sandbox avant de basculer le provider Odoo en state=enabled.

Le webhook arrive en double parfois, comment éviter le double-paiement ?

L’idempotence native d’Odoo suffit : _handle_notification_data ne re-traite pas une transaction déjà done. Vérifier néanmoins provider_reference unique. Si nécessaire ajouter un index unique sur ce champ.

Comment gérer un remboursement ?

Selon agrégateur : API refund (PayDunya /refund, CinetPay équivalent) à appeler depuis Odoo via méthode action_refund héritée. Créer une account.move de type out_refund et la lier à la transaction d’origine pour la traçabilité.

Les frais agrégateur sont-ils gérés automatiquement par Odoo ?

Non automatiquement. Soit configurer un compte 627x « Frais bancaires » et créer un script qui split chaque paiement en montant net + frais à chaque webhook, soit faire un rapprochement mensuel manuel via account.move ajustement.

Comment sécuriser les clés API en production ?

Jamais en clair dans le code ou Git. Utiliser :
– Variables d’environnement Docker (environment dans compose.yml)
– Secrets manager (Vault, AWS Secrets Manager, Doppler)
– Champ chiffré Odoo : restreint via groups='base.group_system'

Rotation annuelle minimum, immédiate si compromis suspecté.

Comment monitorer les paiements en temps réel ?

Configurer dans Odoo : alerte mail/SMS automatique pour paiements > X FCFA, dashboard Sales → Payments avec filtre transactions du jour. Pour suivi externe : exporter via webhook custom Odoo vers Slack/WhatsApp Business API. Voir WhatsApp Business API guide pratique.


Articles liés (cluster Odoo)

Voir aussi : WordPress Wave Orange Money WooCommerce pour la même intégration côté WordPress, Sécuriser WooCommerce checklist pour les bonnes pratiques webhook signé.


Article mis à jour le 25 avril 2026. Pour signaler une erreur ou suggérer une amélioration, écrivez-nous.

Besoin d'un site web ?

Confiez-nous la Création de Votre Site Web

Site vitrine, e-commerce ou application web — nous transformons votre vision en réalité digitale. Accompagnement personnalisé de A à Z.

À partir de 250.000 FCFA
Parlons de Votre Projet
Publicité