دراسة حالة · مدفوعات سوق إلكتروني
Al-Kout — مدفوعات تُسوّى مرة واحدة بالضبط
سوق إلكتروني للمتاجر والخدمات، يمكن فيه أن يبلّغ عن الدفعة نفسها webhook وإعادة محاولة من العميل وفحص مجدول.
المشكلة
Al-Kout سوق إلكتروني سعودي له جانبان. المتاجر تبيع منتجات: سلة، ثم طلب، ثم تأكيد من المتجر، ثم دفع العميل، ثم التوصيل. والفنيون يبيعون خدمات منزلية: يصف العميل العمل المطلوب، ويرسل المزوّدون عروض أسعار، فيقبل العميل أحدها ويدفع. وتحصل المنصة على عمولة من الجانبين.
ينتقل المال بين العملاء والمزوّدين والمنصة عبر المحافظ والنقد والدفع الإلكتروني. وكل مسار من هذه المسارات قد تُعاد محاولته، أو قد يصل مرتين.
دوري
كنت مطوّر الـ Backend الوحيد. بنيت API العملاء و API المزوّدين ولوحة الإدارة العربية: 123 ميزة محدّدة في المواصفات، سُلّمت في ثماني دورات مخطّطة.
المعمارية
الكود منظّم حسب المجال (domain): مجلد لـ API العملاء، ومجلد لـ API المزوّدين، ومجلد للوحة الإدارة. وكل model موضوع بجانب الـ interface الخاصة بالـ repository وتنفيذها.
- Controller
- FormRequest
- DTO
- Service
- Repository
- Resource
يسجّل العملاء والمزوّدون والمشرفون الدخول عبر ثلاثة guards منفصلة. كل استجابة تستخدم غلافًا واحدًا (envelope)، وكل exception يُوحَّد شكله في مكان واحد حتى لا تصل أسماء الـ classes الداخلية إلى الـ client.
القرارات الأساسية
باب واحد لنتائج الدفع. نتيجة الدفعة قد يبلّغ عنها الـ webhook الخاص ببوابة الدفع، أو التطبيق حين يطلب مزامنة، أو فحص مجدول. والثلاثة تستدعي الدالة نفسها.
حفظ محاولة الدفع قبل استدعاء بوابة الدفع
تأخذ معرّفها الخاص أولًا، فلا يترك أي timeout خصمًا لا يتتبعه أحد.
استدعاء بوابة الدفع خارج أي transaction في قاعدة البيانات
البوابة البطيئة لا تحجز أي lock.
تصل نتيجة: webhook أو مزامنة من التطبيق أو فحص مجدول
الثلاثة تذهب إلى الدالة نفسها.
أخذ lock على صف المحاولة وقراءة حالته
القرار يُبنى على الحالة المقروءة تحت الـ lock، لا على الـ payload الوارد وحده.
مقارنة المبلغ والعملة بمحاولة الدفع
عند عدم التطابق تُرفض النتيجة ولا يُضاف المبلغ أبدًا.
التنفيذ مرة واحدة: تحديد الدفعة كمدفوعة، تحريك الطلب، الكتابة في الـ ledger
قيود التفرّد (unique constraints) في قاعدة البيانات تدعم ذلك. وكل طلب يحرّك مالًا يحمل أيضًا idempotency key، فالنقرة المكررة أو الطلب المُعاد يُرجع النتيجة الأولى.
المحفظة ledger. كل إضافة وكل خصم يمر عبر service واحدة تأخذ lock على صف المحفظة أولًا. تُخزَّن المبالغ بالهللة أعدادًا صحيحة، لا أرقام float أبدًا.
خريطة واحدة لانتقالات الطلب. جدول واحد يحدّد أي حالة يمكن أن تتبع أي حالة، ومن يحق له تنفيذ الانتقال. والمخزون يتبع الخريطة نفسها: يُحجز عند إنشاء الطلب، ويُثبَّت عند الدفع، ويُعاد عند الإلغاء.
حالتان أصعب
مهلة الدفع أمام دفعة بطاقة قيد التنفيذ. الطلبات غير المدفوعة تُلغى عند انتهاء المهلة. وقد يكون العميل في صفحة الدفع في تلك اللحظة. قبل الإلغاء يسأل النظام بوابة الدفع. وإذا وصل نجاح بعد الإلغاء مع ذلك، يُستردّ المبلغ مرة واحدة.
استضافة مشتركة بلا workers في الخلفية. الخادم المستهدف لا يسمح بعمليات تعمل لمدة طويلة. أمر مجدول يفرّغ الـ queue، و endpoint للـ heartbeat يبيّن هل الـ scheduler يعمل، ومؤقّتات الطلبات تصلح نفسها عند القراءة التالية للطلب.
النتيجة
- جميع الميزات الـ 123 في تطبيق العملاء وتطبيق المزوّدين ولوحة الإدارة.
- نحو 1,800 دالة اختبار، فيها حالات للنجاح المتأخر والدفع المزدوج وترتيب أخذ الـ locks.
- 14 أمرًا مجدولًا للمهل والتذكيرات والمطابقة المالية (reconciliation).
ما الذي يأتي بعد ذلك
بوابة الدفع موضوعة خلف interface، وطُوّرت على تنفيذ وهمي (fake). ربط بوابة الإنتاج (Moyasar) وربط مزوّد الرسائل النصية (SMS) هما خطوتا التكامل المتبقيتان. بعد ذلك ينبغي التحقق من تصميم الـ locks على MySQL تحت تزامن حقيقي، لأن الاختبارات تعمل على SQLite.