الأعمال الرقمية

دفع Mobile Money في Moodle: Wave وOrange Money: درس 2026

9 min de lecture

📍 المقالة الرئيسية للمجموعة: EdTech فرنكوفونية 2026.

الجامعة الخاصة أو منصة التكوين المهني تطلب رسوم. الطالب يتعامل مع PayPal أو بطاقة دولية = استبعاد 70% من الجمهور المحلي. تكامل Wave وOrange Money في Moodle = enrolment تلقائي عند الدفع، تجربة مستخدم سلسة، إدارة فواتير مؤتمتة. هذا الدرس يفصل التنفيذ الكامل: plugin Moodle Wave، webhooks، تكامل OM، تسوية يومية.

المتطلبات

Moodle 4.x في الإنتاج. حساب Wave Business (راجع دفع Wave). حساب Orange Money Business. plugin Moodle مخصص أو استخدم plugin مجتمعي. المستوى: متقدم. الوقت: 4-6 ساعات.

الخطوة 1 — تفعيل Enrolment payment

Moodle لديه enrolment method «PayPal» مدمج، لكن لا Wave/OM. يجب بناء plugin مخصص أو استخدام webhook.

Site administration → Plugins → Enrolments → Manage enrol plugins
Activate "Pay" (Moodle 4.0+)
أو "External database"

الخطوة 2 — Plugin enrol_wave

إنشاء plugin بسيط في /var/www/elearning/enrol/wave/:

// version.php
<?php
defined('MOODLE_INTERNAL') || die();
$plugin->component = 'enrol_wave';
$plugin->version = 2026042700;
$plugin->requires = 2024100100;  // Moodle 4.5

// lang/ar/enrol_wave.php
$string['pluginname'] = 'الدفع عبر Wave Mobile Money';
$string['enrolname'] = 'Wave';
$string['cost'] = 'تكلفة الدورة';
$string['currency'] = 'العملة';

// settings.php
$settings->add(new admin_setting_configtext('enrol_wave/api_key',
    'Wave API Key', 'مفتاح API من Wave Business', '', PARAM_TEXT));
$settings->add(new admin_setting_configtext('enrol_wave/webhook_secret',
    'Webhook Secret', '', '', PARAM_TEXT));

الخطوة 3 — صفحة الدفع

enrol/wave/pay.php:

require('../../config.php');
require_login();

$courseid = required_param('id', PARAM_INT);
$course = $DB->get_record('course', ['id' => $courseid], '*', MUST_EXIST);
$cost = $DB->get_field('enrol', 'cost', ['courseid' => $courseid, 'enrol' => 'wave']);

// إنشاء Wave checkout session
$ch = curl_init('https://api.wave.com/v1/checkout/sessions');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . get_config('enrol_wave', 'api_key'),
        'Content-Type: application/json',
        'idempotency-key: enrol-' . $USER->id . '-' . $courseid
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'amount' => (string)$cost,
        'currency' => 'XOF',
        'success_url' => $CFG->wwwroot . '/enrol/wave/success.php?course=' . $courseid,
        'error_url' => $CFG->wwwroot . '/enrol/wave/error.php',
        'client_reference' => 'user-' . $USER->id . '-course-' . $courseid
    ])
]);
$response = curl_exec($ch);
$session = json_decode($response);

// تسجيل في DB Moodle
$record = new stdClass();
$record->userid = $USER->id;
$record->courseid = $courseid;
$record->wave_session_id = $session->id;
$record->amount = $cost;
$record->status = 'pending';
$record->created_at = time();
$DB->insert_record('enrol_wave_payments', $record);

// إعادة توجيه إلى Wave
redirect($session->wave_launch_url);

الخطوة 4 — Webhook استقبال التأكيد

enrol/wave/webhook.php:

define('NO_MOODLE_COOKIES', true);
require('../../config.php');

$payload = file_get_contents('php://input');
$signature = isset($_SERVER['HTTP_WAVE_SIGNATURE']) ? $_SERVER['HTTP_WAVE_SIGNATURE'] : '';

// التحقق HMAC
$expected = hash_hmac('sha256', $payload, get_config('enrol_wave', 'webhook_secret'));
if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    die('Invalid signature');
}

$event = json_decode($payload);

if ($event->type === 'checkout.session.completed') {
    $session_id = $event->data->id;
    
    // ابحث في DB
    $payment = $DB->get_record('enrol_wave_payments', ['wave_session_id' => $session_id]);
    if (!$payment) {
        http_response_code(404);
        die('Payment not found');
    }
    
    // تحديث حالة
    $payment->status = 'paid';
    $payment->paid_at = time();
    $DB->update_record('enrol_wave_payments', $payment);
    
    // التسجيل التلقائي في الدورة
    $enrol = enrol_get_plugin('wave');
    $instance = $DB->get_record('enrol', ['courseid' => $payment->courseid, 'enrol' => 'wave']);
    $enrol->enrol_user($instance, $payment->userid, 5, time(), time() + 31536000);  // 1 سنة
    
    // إرسال SMS تأكيد
    $user = $DB->get_record('user', ['id' => $payment->userid]);
    send_sms_confirmation($user->phone1, $payment->courseid);
}

http_response_code(200);
echo 'OK';

الخطوة 5 — صفحة النجاح

enrol/wave/success.php:

require('../../config.php');
require_login();
$courseid = required_param('course', PARAM_INT);

// تحقق من التسجيل
$is_enrolled = is_enrolled(context_course::instance($courseid), $USER);

if ($is_enrolled) {
    redirect(new moodle_url('/course/view.php', ['id' => $courseid]),
        'تم التسجيل بنجاح. مرحباً في الدورة!', null, \core\output\notification::NOTIFY_SUCCESS);
} else {
    // الدفع قيد المعالجة (webhook لم يصل بعد)
    echo $OUTPUT->header();
    echo '<p>دفعك قيد المعالجة. ستتلقى تأكيداً في غضون 5 دقائق.</p>';
    echo $OUTPUT->footer();
}

الخطوة 6 — تكامل Orange Money

OM API مماثل لـ Wave لكن مع OAuth2 token. تكوين plugin مماثل:

// pay.php Orange Money
$token_response = curl_post('https://api.orange.com/oauth/v3/token', [
    'grant_type' => 'client_credentials'
], [
    'Authorization: Basic ' . base64_encode($client_id . ':' . $client_secret)
]);
$token = json_decode($token_response)->access_token;

$payment_response = curl_post('https://api.orange.com/orange-money-webpay/v1/webpayment', [
    'merchant_key' => $merchant_key,
    'currency' => 'XOF',
    'order_id' => 'course-' . $courseid . '-user-' . $USER->id,
    'amount' => $cost,
    'return_url' => $success_url,
    'cancel_url' => $cancel_url,
    'notif_url' => $webhook_url,
    'lang' => 'ar'
], [
    'Authorization: Bearer ' . $token,
    'Content-Type: application/json'
]);

$payment_data = json_decode($payment_response);
redirect($payment_data->payment_url);

الخطوة 7 — اختيار طريقة الدفع

عرض زرين «دفع بـ Wave» و «دفع بـ Orange Money» للطالب. كل واحد يولد session منفصل.

// في صفحة enrolment
<a href="enrol/wave/pay.php?id={$course->id}" class="btn btn-primary">
  <img src="/wave-logo.png" /> دفع بـ Wave
</a>
<a href="enrol/orange/pay.php?id={$course->id}" class="btn btn-warning">
  <img src="/om-logo.png" /> دفع بـ Orange Money
</a>

الخطوة 8 — تسوية يومية

سكربت Moodle يقارن دفعات Wave/OM مع enrolments DB. يكشف الانحرافات.

// admin/cli/wave_reconciliation.php
define('CLI_SCRIPT', true);
require(__DIR__.'/../../config.php');

$date = date('Y-m-d', strtotime('-1 day'));

// جلب transactions Wave من API
$wave_txs = curl_get('https://api.wave.com/v1/transactions?date=' . $date);

// جلب payments Moodle
$moodle_payments = $DB->get_records_sql(
    "SELECT * FROM {enrol_wave_payments} WHERE DATE(FROM_UNIXTIME(paid_at)) = ?",
    [$date]
);

$matched = 0;
$unmatched_wave = [];
$unmatched_moodle = [];

foreach ($wave_txs as $tx) {
    $found = false;
    foreach ($moodle_payments as $mp) {
        if ($mp->wave_session_id === $tx->session_id) {
            $matched++;
            $found = true;
            break;
        }
    }
    if (!$found) $unmatched_wave[] = $tx;
}

echo "تطابق: $matched\n";
echo "Wave غير متطابق: " . count($unmatched_wave) . "\n";

// إرسال تقرير email للمحاسب
mail('comptable@universite.com', 'تسوية Wave ' . $date, ...);

الخطوة 9 — الفواتير الإلكترونية

توليد PDF فاتورة بعد الدفع. إرسال بالبريد إلى الطالب.

require_once($CFG->libdir.'/pdflib.php');

$pdf = new pdf();
$pdf->AddPage();
$pdf->SetFont('helvetica', 'B', 16);
$pdf->Cell(0, 10, 'فاتورة - Université Cheikh Anta Diop', 0, 1);

$pdf->SetFont('helvetica', '', 12);
$pdf->Cell(0, 10, 'الطالب: ' . $user->firstname . ' ' . $user->lastname, 0, 1);
$pdf->Cell(0, 10, 'الدورة: ' . $course->fullname, 0, 1);
$pdf->Cell(0, 10, 'المبلغ: ' . $payment->amount . ' XOF', 0, 1);
$pdf->Cell(0, 10, 'طريقة الدفع: ' . ($payment->method === 'wave' ? 'Wave' : 'Orange Money'), 0, 1);
$pdf->Cell(0, 10, 'تاريخ: ' . date('Y-m-d', $payment->paid_at), 0, 1);

$pdf->Output('facture.pdf', 'F');

// إرسال بالبريد
email_to_user($user, get_admin(), 'فاتورة دورتك', 'مرفقة بهذا البريد', '', $invoice_path);

الخطوة 10 — Reporting

Dashboard Moodle يعرض إحصائيات الدفعات: إجمالي الإيرادات، % نجاح، أكثر الدورات شعبية.

SELECT 
    c.fullname,
    COUNT(p.id) as enrolments,
    SUM(p.amount) as total_revenue
FROM mdl_enrol_wave_payments p
JOIN mdl_course c ON c.id = p.courseid
WHERE p.status = 'paid'
  AND p.paid_at > UNIX_TIMESTAMP() - 30*86400
GROUP BY c.id
ORDER BY total_revenue DESC
LIMIT 10;

الأخطاء الشائعة

الخطأ السبب الحل
Webhook 403 signature خاطئة تحقق raw body + HMAC
التسجيل مكرر webhook مرسَل مرتين idempotency check
Wave session expire 30 دقيقة default تجديد عند العودة
OM token expire 1h default cache + refresh
SMS لا يصل Twilio rate limit queue + retry
تسوية انحراف طلب pending قديم cleanup > 24h

التكيف مع السياق

أربع توضيحات. التوقيع التجاري الجامعي. الجامعة العمومية لا تطلب رسوم. الخاصة تطلب 50,000-500,000 XOF/سيمستر. تكامل بطاقات بنكية أيضاً (CMI، CIB) للوالدين بدون Wave/OM. التسوية المحاسبية. Wave/OM يحوّلون المبالغ يومياً. ربط حساب البنك = تجنب أن يكون رصيد كبير على مزود الدفع. Refunds. الانسحاب من الدورة قبل 7 أيام = استرداد كامل. التسوية مع Wave Refund API. Promotion codes. الجامعة تنشئ codes خصم 20% للأقدم. تكامل في Moodle Coupons plugin.

دروس الإخوة

الأسئلة المتكررة

Plugin مجاني؟ هذا الدرس يقدم plugin مفتوح المصدر مخصص. اكتبه مرة، استخدمه دائماً.

عمولة؟ Wave 1%، OM 1.5%. للدورة 50,000 XOF = 500-750 XOF عمولة. مقابل بطاقة 3% = 1,500 XOF.

اختبار قبل الإنتاج؟ Wave + OM لديهم sandbox. اختبر التدفق الكامل قبل publishing.

Refunds جزئية؟ Wave يدعم. OM يتطلب طلبات معقدة.

للاستزادة

مقالات ذات صلة

Hardening production : check-list avant go-live

Le tutoriel ci-dessus décrit le flow nominal et la sécurité de base. Avant la première transaction réelle sur ce code, huit points doivent être verrouillés — chaque omission est documentée comme cause d’incident sur des intégrations en production. La même liste est appliquée par les équipes paiement matures sur les sites en zone CEDEAO.

  1. Secrets jamais en base de données ni en clair en code. Clé API et secret webhook stockés dans un secret manager (HashiCorp Vault, AWS Secrets Manager, Doppler) ou a minima dans le fichier .env hors du repo (avec .gitignore strict) et chmod 600. Vérifier qu’aucune clé prod n’apparaît dans l’historique git via git log -p | grep -i "prod_\|sk_live\|api_key".
  2. Vérification HMAC sur raw body uniquement. Ne jamais re-stringifier le body parsé : les whitespaces, l’ordre des clés JSON et l’encodage UTF-8 doivent rester intacts. Utiliser express.raw() en Node, request.get_data() en Flask avant tout get_json(), file_get_contents("php://input") en PHP (jamais $_POST).
  3. Comparaison signature en temps constant. crypto.timingSafeEqual (Node, vérifier la longueur des buffers avant), hmac.compare_digest (Python), hash_equals (PHP), hmac.Equal (Go). Une comparaison == classique laisse fuir des bits par timing attack.
  4. Idempotence atomique. Contrainte unique en base sur l’ID d’événement provider (event_id Wave, notif_token Orange, X-Reference-Id MTN, id CinetPay/PayDunya/Flutterwave). Pattern INSERT … ON CONFLICT DO NOTHING qui revoie 200 immédiatement sur doublon, sans réappliquer l’effet métier (provisionnement, livraison, email).
  5. Fenêtre anti-replay sur le timestamp. Rejeter tout webhook dont le t= diffère de l’heure serveur de plus de 5 minutes. Évite la replay attack avec une signature historique interceptée. Synchroniser l’heure serveur via NTP (chrony ou systemd-timesyncd) pour éviter les rejets dûs à une dérive d’horloge.
  6. Timeout HTTP explicites séparés. Connect timeout 5 secondes, read timeout 15-30 secondes selon le provider. Jamais d’appel sans timeout — un connect bloqué peut faire monter votre PHP-FPM ou Node worker pool à saturation en quelques secondes.
  7. Retry exponentiel uniquement pour 5xx et 429. Base 2 (1s, 2s, 4s, 8s), plafond 60 secondes, maximum 4 tentatives. Les 4xx (sauf 429) sont des erreurs de configuration qui ne se corrigent pas en rejouant — propager immédiatement à l’opérateur. Utiliser un identifiant de retry stable côté provider (Idempotency-Key Stripe, client_reference Wave, externalId MTN) pour ne pas créer de doublons.
  8. Monitoring + alerting + réconciliation J+1. Métriques Prometheus ou équivalent : taux 401/403/429 sur appels sortants, taux de signatures invalides, latence p95 par provider, échec de réconciliation J-1. Page-out sur seuils stricts. Job cron quotidien 02h00 qui confronte la table interne aux exports providers — trois sorties scénarisées (100 % match, écart minoritaire = rapport finance, écart majoritaire = page-out + suspension nouvelles transactions).

La version exhaustive de cette check-list, avec un exemple de chaque fix en code, est dans le guide Wave Business API en production : KYC, clés live, IP whitelisting et HMAC. Les principes y sont génériques et s’appliquent identiquement à Orange Money, MTN MoMo, Flutterwave, CinetPay, PayDunya et Paystack.

Partager