📍 المقالة الرئيسية للمجموعة: 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 يتطلب طلبات معقدة.
للاستزادة
- 🔝 المرجع: EdTech 2026
- Moodle plugin development: docs.moodle.org/dev
مقالات ذات صلة
- دمج مدفوعات Wave وOrange Money وStripe في QloApps: درس 2026
- دفع Wave عند التوصيل: تكامل API + Webhooks: درس 2026
- EdTech فرنكوفونية مفتوحة المصدر 2026: الدليل الكامل (Moodle، BBB، H5P، دفع Mobile Money)
- نشر Moodle على Hetzner للجامعات الإفريقية: درس 2026
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.
- 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
.envhors du repo (avec.gitignorestrict) et chmod 600. Vérifier qu’aucune clé prod n’apparaît dans l’historique git viagit log -p | grep -i "prod_\|sk_live\|api_key". - 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 toutget_json(),file_get_contents("php://input")en PHP (jamais$_POST). - 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. - Idempotence atomique. Contrainte unique en base sur l’ID d’événement provider (
event_idWave,notif_tokenOrange,X-Reference-IdMTN,idCinetPay/PayDunya/Flutterwave). PatternINSERT … ON CONFLICT DO NOTHINGqui revoie 200 immédiatement sur doublon, sans réappliquer l’effet métier (provisionnement, livraison, email). - 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. - 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.
- 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-KeyStripe,client_referenceWave,externalIdMTN) pour ne pas créer de doublons. - 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.