درس للمطورين · متوسط · محدَّث 15 تشرين الأول 2026

ربط بوابة دفع المحفظة الإلكترونية بخادم Node.js دون أن تخسر طلباً

أغلب أخطاء الدفع الإلكتروني لا تقع عند إنشاء العملية، بل بعدها: زبون دفع ولم يصل الإشعار، أو إشعار وصل مرتين، أو صفحة «تم الدفع» فُتحت دون دفع. هذا الدرس يبني المسار كاملاً بطريقة تصمد أمام هذه الحالات.

قراءة 9 دقائقالمستوى: متوسطفريق Operon Studio · بغداد
Node.jsPostgreSQLOAuth2JWTWebhooks
في هذا الدرس
  • أين يجب أن يُحسب المبلغ، ولماذا ليس في المتصفح
  • تخزين رمز الدخول المؤقت بدل طلبه مع كل عملية
  • لماذا لا تكفي عودة الزبون إلى موقعك دليلاً على الدفع
  • إشعار يُنفَّذ مرة واحدة مهما تكرر، واستعلام دوري يلتقط ما فاته

خريطة المسار قبل الكود

المحافظ الإلكترونية في العراق تختلف في أسماء الحقول وعناوين الواجهة، لكن البنية تكاد تكون واحدة: رمز دخول بمعرّف وسر (OAuth2)، ثم إنشاء عملية من خادمك، ثم صفحة دفع يذهب إليها الزبون ويعود منها ومعه رمز موقّع، ثم إشعار من خادم البوابة إلى خادمك. طابق أسماء الحقول مع وثائق مزوّدك، والمنطق أدناه لا يتغير.

نبدأ بجدول يحفظ كل عملية برقمها لدى البوابة. هذا الجدول هو ذاكرتك حين تختلف روايتك عن رواية البوابة:

PostgreSQLpayments-schema.sql
CREATE TABLE payments (
  provider_tx_id text PRIMARY KEY,            -- رقم العملية لدى البوابة
  order_id       uuid   NOT NULL REFERENCES orders(id),
  amount_iqd     bigint NOT NULL,             -- بالدينار، عدد صحيح
  status         text   NOT NULL DEFAULT 'pending', -- pending | paid | failed | refunded
  created_at     timestamptz NOT NULL DEFAULT now(),
  paid_at        timestamptz
);

المبلغ بالدينار عدد صحيح (bigint). لا تستعمل الأعداد العشرية للنقود، ولا تخزّن المبلغ نصاً.

رمز الدخول: اطلبه مرة واحدة واحتفظ به

رمز الدخول قصير العمر، وطلبه مع كل عملية يبطئ الدفع ويستهلك حدود الطلبات. احتفظ به في الذاكرة حتى قبل انتهائه بنصف دقيقة:

Node.jspay-token.js
// رمز الدخول، نحتفظ به في الذاكرة حتى قبل انتهائه بقليل
let cached = { token: null, exp: 0 };

export async function getToken() {
  if (cached.token && Date.now() < cached.exp - 30_000) return cached.token;

  const res = await fetch(process.env.PAY_TOKEN_URL, {
    method: 'POST',
    headers: { 'content-type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: process.env.PAY_CLIENT_ID,
      client_secret: process.env.PAY_CLIENT_SECRET,
    }),
  });
  if (!res.ok) throw new Error('token request failed: ' + res.status);

  const data = await res.json();
  cached = { token: data.access_token, exp: Date.now() + data.expires_in * 1000 };
  return cached.token;
}

إن كان لديك أكثر من نسخة من الخادم، فكل نسخة ستطلب رمزها، وهذا مقبول. وإن طلبت نسختان الرمز في اللحظة نفسها فلا ضرر أيضاً، المهم ألا يُطلب مع كل زبون.

إنشاء العملية: المبلغ من قاعدة البيانات

الزبون يضغط «ادفع»، فيرسل المتصفح رقم الطلب فقط. أما المبلغ فيقرؤه الخادم من قاعدة البيانات. لو أخذته من المتصفح لاستطاع أي شخص أن يدفع ألف دينار عن طلب بخمسين ألفاً.

Node.jspay-create.js
// POST /orders/:id/pay — المبلغ من قاعدة البيانات، لا من المتصفح
app.post('/orders/:id/pay', async (req, reply) => {
  const { rows: [order] } = await db.query(
    'SELECT id, total_iqd, status FROM orders WHERE id = $1', [req.params.id]);
  if (!order || order.status !== 'awaiting_payment') return reply.code(409).send();

  const res = await fetch(process.env.PAY_API + '/transactions', {
    method: 'POST',
    headers: {
      authorization: 'Bearer ' + await getToken(),
      'content-type': 'application/json',
    },
    body: JSON.stringify({
      amount: order.total_iqd,       // بالدينار
      currency: 'IQD',
      externalReference: order.id,   // رقم طلبك ليعود معك في الإشعار
      successUrl: process.env.SITE + '/pay/return',
      failureUrl: process.env.SITE + '/pay/return',
    }),
  });
  if (!res.ok) return reply.code(502).send({ error: 'gateway' });
  const tx = await res.json();

  await db.query(
    `INSERT INTO payments (provider_tx_id, order_id, amount_iqd)
     VALUES ($1, $2, $3)
     ON CONFLICT (provider_tx_id) DO NOTHING`,
    [tx.id, order.id, order.total_iqd]);

  return { redirectUrl: tx.redirectUrl };
});

نحفظ العملية بحالة «معلّقة» قبل أن نحوّل الزبون. فإن انقطع اتصاله في الطريق، تبقى العملية عندك وتستطيع أن تسأل عنها البوابة لاحقاً.

عودة الزبون ليست دليلاً على الدفع

الزبون يعود إلى رابطك ومعه رمز موقّع (JWT). تحقّق من توقيعه بالمفتاح الذي تعطيك إياه البوابة وبالخوارزمية المذكورة في وثائقها، ثم اعرض له الحالة كما هي في قاعدة بياناتك. لا تغيّر حالة الطلب في هذه الصفحة: يكفي أن يحفظ شخص رابط «النجاح» ويفتحه مرة أخرى.

Node.jspay-return.js
import jwt from 'jsonwebtoken';

// عودة الزبون: نتحقق من التوقيع ونعرض الحالة — لا نغيّر حالة الطلب هنا
app.get('/pay/return', async (req, reply) => {
  let claims;
  try {
    claims = jwt.verify(req.query.token, process.env.PAY_JWT_SECRET, { algorithms: ['HS256'] });
  } catch {
    return reply.redirect('/checkout?pay=invalid');
  }

  const { rows: [p] } = await db.query(
    'SELECT order_id, status FROM payments WHERE provider_tx_id = $1', [claims.transactionId]);
  if (!p) return reply.redirect('/checkout?pay=unknown');

  // مدفوع فعلاً؟ أكّد. وإلا صفحة «جارٍ التأكيد» تسأل الخادم كل بضع ثوانٍ
  return reply.redirect('/orders/' + p.order_id + (p.status === 'paid' ? '?paid=1' : '?pending=1'));
});

إن لم يصل الإشعار بعد، تعرض الصفحة «جارٍ تأكيد الدفع» وتسأل خادمك كل بضع ثوانٍ. الزبون يرى انتظاراً قصيراً، وأنت لا تنفّذ طلباً لم يُدفع.

الإشعار: يُنفَّذ مرة واحدة مهما تكرر

البوابات ترسل الإشعار أكثر من مرة إن تأخر ردّك أو انقطع الاتصال. الحماية هنا في شرط التحديث نفسه: نحوّل العملية إلى «مدفوعة» فقط إن كانت «معلّقة» وكان المبلغ مطابقاً. الإشعار الثاني يجد العملية مدفوعة فلا يغيّر شيئاً، ولا يُنفَّذ الطلب مرتين.

Node.jspay-webhook.js
// تُستدعى من الإشعار ومن الاستعلام الدوري: تحوّل العملية إلى «مدفوعة» مرة واحدة فقط
async function markPaid(txId, amountFromGateway) {
  const { rowCount } = await db.query(
    `UPDATE payments SET status = 'paid', paid_at = now()
      WHERE provider_tx_id = $1 AND status = 'pending' AND amount_iqd = $2`,
    [txId, amountFromGateway]);
  if (rowCount === 1) await queue.add('order-paid', { txId }); // تنفيذ الطلب يحدث هنا فقط
}

app.post('/webhooks/payments', async (req, reply) => {
  if (!verifyWebhook(req)) return reply.code(401).send(); // توقيع أو رمز حسب وثائق المزوّد

  // لا نثق بجسم الإشعار وحده: نسأل البوابة عن العملية
  const tx = await fetchTransaction(req.body.transactionId);
  if (tx.status === 'PAID') await markPaid(tx.id, tx.amount);

  return reply.code(200).send(); // ردّ سريع؛ العمل الثقيل في الطابور
});

ولاحظ أننا لا نثق بجسم الإشعار وحده، بل نسأل البوابة عن العملية برقمها. وردّ على الإشعار بسرعة، وانقل العمل الثقيل كإرسال الطلب إلى المطبخ أو الرسائل إلى طابور منفصل.

الاستعلام الدوري: شبكة الأمان

إشعار ضاع، أو خادمك كان متوقفاً دقيقة أثناء التحديث، أو بيئة الاختبار لا ترسل إشعارات أصلاً، وهذا يحدث لدى بعض المزودين. في كل هذه الحالات يلتقط الاستعلام الدوري العملية المعلّقة:

Node.jspay-reconcile.js
// كل دقيقة: العمليات المعلّقة أكثر من دقيقتين نسأل عنها البوابة بأنفسنا
async function reconcile() {
  const { rows } = await db.query(
    `SELECT provider_tx_id FROM payments
      WHERE status = 'pending' AND created_at < now() - interval '2 minutes'
      ORDER BY created_at LIMIT 50`);

  for (const { provider_tx_id: id } of rows) {
    const tx = await fetchTransaction(id);
    if (tx.status === 'PAID') await markPaid(id, tx.amount);
    else if (tx.status === 'EXPIRED' || tx.status === 'FAILED') {
      await db.query(
        `UPDATE payments SET status = 'failed' WHERE provider_tx_id = $1 AND status = 'pending'`, [id]);
    }
  }
}

setInterval(() => reconcile().catch(console.error), 60_000);

دالة markPaid نفسها تُستعمل هنا وفي الإشعار، فالنتيجة واحدة أياً كان الطريق الذي وصلت منه المعلومة أولاً.

الإرجاع

  • احفظ كل عملية إرجاع في جدول مستقل برقمها لدى البوابة ومبلغها.
  • لا يتجاوز مجموع الإرجاعات مبلغ العملية الأصلي. تحقّق من ذلك عندك قبل أن تسأل البوابة.
  • إن فشل طلب الإرجاع بسبب الشبكة، فلا تكرره مباشرة: اسأل البوابة أولاً إن كان قد نُفّذ.
  • غيّر حالة الطلب إلى «مُرجَع» من ردّ البوابة، لا من ضغطة الزر.

قبل الإطلاق

  • المفاتيح السرية في متغيرات البيئة على الخادم، وليست في مستودع الكود أو تطبيق الموبايل.
  • رابط الإشعارات يعمل عبر HTTPS ومسجّل لدى البوابة.
  • دفعة حقيقية صغيرة جُرّبت كاملة، مع إرجاعها.
  • تقرير يومي يطابق العمليات المدفوعة عندك مع كشف البوابة.

وللجانب غير التقني، من الوثائق والعمولات إلى اختيار المحفظة، راجع دليل ربط الدفع الإلكتروني في العراق.

08 — START

خلّينا نبدأ بمشروعك.

اترك اسم شركتك ورقمك، ونتصل بك خلال 24 ساعة لنفهم احتياجك ونرسل لك خطة وعرض سعر واضحاً.

  • استشارة أولى مجانية
  • عرض سعر ثابت ومكتوب
  • بدون أي التزام
أو راسلنا على واتساب ←
📍 بغداد، المنصور، عمارة بلازا، الطابق الثاني📞 +964 775 112 2244✉️ operoniraq@gmail.com

بخصوص: درس للمطورين: ربط بوابة دفع المحفظة الإلكترونية بخادم Node.js دون أن تخسر طلباً

نستخدم رقمك للتواصل معك بخصوص طلبك فقط. الخصوصية