الكتابات/tutorial/2026/08
Tutorial13 أغسطس 2026·28 دقيقة

الربط مع بوابات الدفع السعودية بلغة TypeScript: مدى ومُيسّر وتابي

دليل عملي لبناء صفحة دفع سعودية جاهزة للإنتاج بلغة TypeScript: التعامل الآمن مع الهللات، التحقق الإلزامي 3-D Secure على مدى، حماية رابط العودة من التلاعب، دورة التفويض والتحصيل في تابي، ومعالجة الويب هوك بشكل لا يتكرر أثره. كل الأمثلة مُختبَرة ومتوافقة مع الوضع الصارم.

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

هذا الدرس هو ذلك الدليل. ليس جولة داخل لوحة تحكم، بل الشيفرة التي تقف بين جدول الطلبات لديك وبين المال، مكتوبة بالطريقة التي يجب أن تُكتب بها حين يكون العميل يدفع ببطاقة مدى في الرياض.

ثلاثة أمور تجعل صفحة الدفع السعودية مختلفة عن نسخ دليل البدء السريع الخاص بـ Stripe:

  1. البوابتان الرئيسيتان لا تتفقان على معنى الرقم. مُيسّر تريد عددًا صحيحًا بالهللات، وتابي تريد نصًا عشريًا. أرسل أحدهما مكان الآخر وستكون قد حصّلت من العميل مئة ضعف المبلغ، أو واحدًا من مئة منه، دون أي رسالة خطأ في أي مكان.
  2. التحقق 3-D Secure ليس تحسينًا يمكن تأجيله. على شبكة مدى هو المسار الطبيعي لا الاستثناء، ما يعني أن دورة التحويل والعودة هي مسارك الأساسي وليست حالة هامشية.
  3. الدفع الآجل يقيّم المشتري لا البطاقة. تابي قد ترفض عميلًا كانت بوابة البطاقات لتقبله بلا تردد، وقبل ظهور أي صفحة دفع. هذه نتيجة طبيعية يجب أن تتعامل معها صفحتك بلطف.

ما الذي ستبنيه

نواة مدفوعات من أربع وحدات:

  • money.ts — نوع Halalas موسوم يحوّل عدم توافق الوحدات إلى خطأ في وقت الترجمة بدلًا من عملية استرداد
  • moyasar.ts — مدفوعات البطاقات مع 3-D Secure، إضافة إلى تحقق محصّن ضد التلاعب عند عودة المتصفح
  • tabby.ts — دفع آجل، ومعالجة الرفض، والتحصيل عند التسليم
  • webhook.ts — تحقق من التوقيع بزمن ثابت ومعالجة أحداث لا يتكرر أثرها

كل ما يلي يجتاز التحقق من الأنواع في الوضع strict مع noUncheckedIndexedAccess وexactOptionalPropertyTypes، ومغطّى بمجموعة اختبارات ناجحة.

المتطلبات المسبقة

  • Node.js إصدار 20 أو أحدث، وTypeScript 5.5 أو أحدث
  • حساب في مُيسّر مع مفاتيح تجريبية (pk_test_... وsk_test_...)
  • حساب تاجر في تابي مع مفتاح سري تجريبي ورمز التاجر
  • إلمام بـ async/await وواجهات HTTP
  • سجل تجاري ساري المفعول قبل أن تصدر أي من البوابتين مفاتيح الإنتاج — ابدأ هذه الإجراءات مبكرًا لأنها تحكم موعد الإطلاق لا موعد التطوير

تهيئة المشروع:

mkdir saudi-payments && cd saudi-payments
npm init -y
npm install -D typescript vitest @types/node
npx tsc --init

ثم فعّل الصرامة التي ستقوم بالعمل نيابة عنك:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}

الخطوة 1: اجعل خطأ الوحدات مستحيلًا

إليك الخلل الذي وُجدت هذه الخطوة بأكملها لمنعه.

واجهة مُيسّر تستقبل الحقل amount كعدد صحيح بأصغر وحدة للعملة. الريال السعودي الواحد يساوي 100 هللة، إذًا مبلغ 100.00 ريال يُرسل بالقيمة 10000. أما واجهة تابي فتستقبل amount كنص عشري بالوحدة الكبرى، فالمبلغ نفسه يُرسل بالقيمة "100.00".

الحقلان يحملان الاسم نفسه. وكل بوابة تقبل ما ترسله الأخرى دون اعتراض — القيمة 10000 مبلغ صالح تمامًا لدى تابي، غير أنه يعني عشرة آلاف ريال. لا شيء يرمي استثناءً. تعرف بالأمر من العميل.

الحل هو ألا تسمح أبدًا لعدد number مجرد بالوصول إلى البوابة. استخدم نوعًا موسومًا:

// money.ts
 
/** عدد صحيح موسوم من الهللات. الريال الواحد = 100 هللة. */
export type Halalas = number & { readonly __brand: 'Halalas' };
 
export class MoneyError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'MoneyError';
  }
}
 
export function halalas(value: number): Halalas {
  if (!Number.isInteger(value)) {
    throw new MoneyError(`Halalas must be an integer, received ${value}`);
  }
  if (value < 0) {
    throw new MoneyError(`Halalas must not be negative, received ${value}`);
  }
  if (!Number.isSafeInteger(value)) {
    throw new MoneyError(`Halalas exceeds safe integer range: ${value}`);
  }
  return value as Halalas;
}

الوسم خيال يعيش في وقت الترجمة فقط — أثناء التشغيل تبقى القيمة عددًا عاديًا بلا أي كلفة إضافية. لكن الدالة التي تطلب Halalas لا يمكن تمرير ناتج price * quantity إليها، وهذا هو المقصود بالكامل.

الآن تحليل الأسعار. لاحظ أن الدالة تستقبل نصًا عن قصد:

/**
 * تحويل نص ريال عشري ("100.00"، "9.5"، "1,250.75") إلى هللات.
 *
 * النص أولًا عن قصد: `Math.round(19.99 * 100)` تعطي 1999 اليوم،
 * وتذكرة دعم فني يوم تصادف قيمة تسيء الفاصلة العائمة التعامل معها.
 */
export function sarToHalalas(input: string): Halalas {
  const raw = input.trim().replace(/,/g, '');
  const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(raw);
  if (!match) {
    throw new MoneyError(
      `Invalid SAR amount "${input}" — expected digits with at most 2 decimals`,
    );
  }
  const major = match[1] ?? '0';
  const minor = (match[2] ?? '').padEnd(2, '0');
  return halalas(Number(major) * 100 + Number(minor));
}
 
/** إخراج الهللات كنص من رقمين عشريين كما تتوقعه تابي. */
export function halalasToSar(amount: Halalas): string {
  const major = Math.trunc(amount / 100);
  const minor = amount % 100;
  return `${major}.${String(minor).padStart(2, '0')}`;
}
 
/** جمع بنود السلة دون مغادرة الحساب الصحيح إطلاقًا. */
export function sumHalalas(amounts: readonly Halalas[]): Halalas {
  return halalas(amounts.reduce<number>((total, value) => total + value, 0));
}

لماذا تحليل النص بدلًا من Math.round(price * 100)؟ لأن 19.99 * 100 تساوي 1998.9999999999998 في حساب الفاصلة العائمة IEEE 754. صحيح أن Math.round تنقذ هذه الحالة بالذات، لكن النمط عادة ستقابل يومًا قيمة لا تنقذها، وعندها يكون خطأ التقريب قد توزّع على تقرير تسوية كامل. حلّل التمثيل العشري مباشرة ويختفي نمط الفشل بدل أن يصبح نادرًا.

كما يرفض التعبير النمطي المدخلات ذات ثلاث خانات عشرية بدل اقتطاعها بصمت. إن سلّمك ملف مورّد القيمة "1.005" فأنت تريد استثناءً وقت الاستيراد، لا نصف هللة تقرَّب بهدوء في الاتجاه الذي تفضّله قاعدة بياناتك.

الخطوة 2: ضريبة القيمة المضافة وتقسيم الأقساط

عمليتان حسابيتان تحتاجهما كل صفحة دفع سعودية.

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

export function extractVat(grossInclusive: Halalas, ratePercent = 15): Halalas {
  const net = Math.round((grossInclusive * 100) / (100 + ratePercent));
  return halalas(grossInclusive - net);
}

حساب الصافي أولًا ثم الطرح يضمن أن net + vat تساوي gross تمامًا. أما حساب الضريبة أولًا وطرحها فلا يضمن ذلك، لأن عمليتَي تقريب مستقلتين قد تنحرف كل منهما بنصف هللة في الاتجاه نفسه.

وتقسيم الأقساط يخضع للانضباط ذاته. منتج تابي القياسي أربع دفعات، ومبلغ 100.01 ريال لا يقبل القسمة على أربعة:

/**
 * تقسيم الإجمالي على n قسطًا (أربعة في تابي) بحيث يكون مجموع
 * الأجزاء مساويًا للإجمالي بدقة. الباقي يذهب إلى القسط الأول،
 * وهو القسط الذي يدفعه العميل عند إتمام الطلب.
 */
export function splitInstallments(
  total: Halalas,
  parts: number,
): readonly Halalas[] {
  if (!Number.isInteger(parts) || parts < 1) {
    throw new MoneyError(`Installment count must be a positive integer`);
  }
  const base = Math.floor(total / parts);
  const remainder = total - base * parts;
  return Array.from({ length: parts }, (_unused, index) =>
    halalas(index === 0 ? base + remainder : base),
  );
}

تحتاج هذا للعرض فقط — تابي تحسب جدولها الخاص — لكن العملاء يقارنون ملخص طلبك بما يظهر في تطبيق تابي، وفارق هللة واحدة يولّد من تذاكر الدعم ما لا يتناسب مع حجمه إطلاقًا.

الخطوة 3: مدفوعات البطاقات مع تحقق 3-D Secure الإلزامي

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

لذلك لا يمكن لتكاملك أن يعامل 3-D Secure كفرع نادر التنفيذ. التحويل هو المسار.

// moyasar.ts
import { halalasToSar, type Halalas } from './money.js';
 
const MOYASAR_API = 'https://api.moyasar.com/v1';
 
export type MoyasarStatus =
  | 'initiated'
  | 'paid'
  | 'authorized'
  | 'captured'
  | 'refunded'
  | 'failed'
  | 'voided';
 
export interface MoyasarPayment {
  readonly id: string;
  readonly status: MoyasarStatus;
  /** هللات كعدد صحيح، كما تعيدها مُيسّر. */
  readonly amount: number;
  readonly currency: string;
  readonly metadata?: Record<string, string>;
}
 
export class GatewayError extends Error {
  constructor(
    message: string,
    readonly status?: number,
  ) {
    super(message);
    this.name = 'GatewayError';
  }
}
 
/** مصادقة أساسية: المفتاح هو اسم المستخدم وكلمة المرور فارغة. */
function authHeader(key: string): string {
  return `Basic ${Buffer.from(`${key}:`).toString('base64')}`;
}

وإنشاء عملية الدفع نفسها:

export interface CreatePaymentInput {
  readonly amount: Halalas;
  readonly orderId: string;
  readonly description: string;
  readonly callbackUrl: string;
  /** رمز مميز من نموذج مُيسّر. رقم البطاقة الخام لا يمر بنا أبدًا. */
  readonly cardToken: string;
}
 
export async function createPayment(
  secretKey: string,
  input: CreatePaymentInput,
): Promise<MoyasarPayment> {
  const response = await fetch(`${MOYASAR_API}/payments`, {
    method: 'POST',
    headers: {
      Authorization: authHeader(secretKey),
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      amount: input.amount,
      currency: 'SAR',
      description: input.description,
      callback_url: input.callbackUrl,
      source: {
        type: 'token',
        token: input.cardToken,
        // القيمة الافتراضية لـ 3ds هي true. إبقاؤها مفعّلة ليس اختياريًا
        // عمليًا: مدى تفرض المصادقة القوية، وتعطيلها ينقل مسؤولية
        // الاحتيال إليك.
        '3ds': true,
      },
      metadata: { order_id: input.orderId },
    }),
  });
 
  if (!response.ok) {
    throw new GatewayError(
      `Moyasar create payment failed: ${await response.text()}`,
      response.status,
    );
  }
  return (await response.json()) as MoyasarPayment;
}

لاحظ أن amount: input.amount تمر مباشرة دون أي تحويل. وهذا آمن تحديدًا لأن نظام الأنواع أثبت مسبقًا أنها بالهللات.

بخصوص تعطيل 3DS: الواجهة تقبل فعلًا "3ds": false، لكن للحسابات المفعّل لها الطلب عبر البريد أو الهاتف فقط، وهي تنقل مسؤولية الاحتيال من مُصدر البطاقة إليك. في صفحة دفع اعتيادية على الويب، تعامل مع هذا الخيار كأنه غير موجود.

أمران آخران يستحقان الضبط الصحيح هنا. استخدم رمزًا مميزًا للبطاقة يُنتجه نموذج مُيسّر المستضاف بدل تمرير رقم بطاقة خام عبر خادمك — الشيفرة أعلاه مكتوبة للرموز المميزة، وهي ما يبقي بيانات البطاقات خارج نطاق التزامك بمعيار PCI. وضع order_id داخل metadata، لأن الخطوة الرابعة تعتمد عليه.

الخطوة 4: لا تثق برابط العودة أبدًا

هذه هي النواة الأمنية للتكامل، وهي الخطوة التي تتجاوزها أغلب الأدلة.

حين ينهي العميل المصادقة، تحوّل مُيسّر المتصفح إلى callback_url مع إضافة معاملات في الرابط: id وstatus وmessage. هذه المعاملات تمر عبر شريط العنوان. يستطيع العميل قراءتها. ويستطيع أيضًا تعديلها.

لذا فإن هذا المعالج، الذي يبدو منطقيًا تمامًا، وسيلة لإهداء مخزونك مجانًا:

// لا تفعل هذا إطلاقًا
app.get('/callback', async (req, res) => {
  if (req.query.status === 'paid') {
    await markOrderPaid(req.query.id);  // ثقة بشريط العنوان
  }
});

يستطيع أي شخص إلحاق ?status=paid بذلك الرابط. بدلًا من ذلك، أعد جلب عملية الدفع من الواجهة باستخدام مفتاحك السري، وتحقق من كل حقل مهم:

export async function fetchPayment(
  secretKey: string,
  paymentId: string,
): Promise<MoyasarPayment> {
  const response = await fetch(`${MOYASAR_API}/payments/${paymentId}`, {
    headers: { Authorization: authHeader(secretKey) },
  });
  if (!response.ok) {
    throw new GatewayError(
      `Moyasar fetch payment failed: ${await response.text()}`,
      response.status,
    );
  }
  return (await response.json()) as MoyasarPayment;
}
 
export type VerdictReason =
  | 'ok'
  | 'not_paid'
  | 'amount_mismatch'
  | 'currency_mismatch'
  | 'order_mismatch';
 
export interface Verdict {
  readonly settled: boolean;
  readonly reason: VerdictReason;
}
 
export function verifyPayment(
  payment: MoyasarPayment,
  expected: { readonly amount: Halalas; readonly orderId: string },
): Verdict {
  if (payment.status !== 'paid' && payment.status !== 'captured') {
    return { settled: false, reason: 'not_paid' };
  }
  if (payment.currency !== 'SAR') {
    return { settled: false, reason: 'currency_mismatch' };
  }
  if (payment.amount !== expected.amount) {
    return { settled: false, reason: 'amount_mismatch' };
  }
  if (payment.metadata?.['order_id'] !== expected.orderId) {
    return { settled: false, reason: 'order_mismatch' };
  }
  return { settled: true, reason: 'ok' };
}

الفحوص الأربعة جميعها تستحق مكانها:

  • الحالة هي الفحص البديهي، والوحيد الذي تنفذه معظم التطبيقات.
  • المبلغ يمنع الهجوم الكلاسيكي: ضع صنفًا رخيصًا في السلة، وادفع ريالًا واحدًا، ثم أعد استخدام معرّف تلك العملية على طلب باهظ.
  • العملة تمنع التسوية بعملة ليست الريال.
  • معرّف الطلب يمنع إعادة استخدام عملية دفع حقيقية واحدة على عدة طلبات — بدون هذا الفحص، عملية دفع مشروعة بقيمة 500 ريال تستطيع تعليم خمسة طلبات مختلفة بقيمة 500 ريال كمدفوعة.

ويصبح الربط على هذا النحو:

const payment = await fetchPayment(process.env.MOYASAR_SECRET_KEY!, paymentId);
const order = await loadOrder(orderId);
const verdict = verifyPayment(payment, {
  amount: order.totalHalalas,
  orderId: order.id,
});
 
if (!verdict.settled) {
  logger.warn({ paymentId, reason: verdict.reason }, 'payment rejected');
  return res.redirect('/checkout/failed');
}
await markOrderPaid(order.id, payment.id);

سجّل قيمة verdict.reason. ظهور amount_mismatch في الإنتاج يعني إما خللًا في حساب إجماليات السلة أو شخصًا يتحسس ثغراتك، وأنت تريد أن تعرف أيهما.

الخطوة 5: الدفع الآجل كائن مختلف تمامًا

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

تستدعي POST /api/v2/checkout فيخبرك الرد ما إذا كان هذا العميل مؤهلًا لاستخدام الدفع الآجل لهذه السلة أصلًا. الحالة rejected ليست خطأً ولا تقبل إعادة المحاولة — إنها قرار ائتماني. مهمتك أن تتراجع إلى الدفع بالبطاقة دون أن تشعر العميل بأنه مرفوض.

// tabby.ts
import { halalasToSar, type Halalas } from './money.js';
import { GatewayError } from './moyasar.js';
 
const TABBY_API = 'https://api.tabby.ai/api/v2';
 
export type TabbySessionStatus = 'created' | 'rejected' | 'expired';
export type TabbyPaymentStatus =
  | 'NEW'
  | 'AUTHORIZED'
  | 'CLOSED'
  | 'REJECTED'
  | 'EXPIRED';
 
export interface TabbyCheckoutResponse {
  readonly status: TabbySessionStatus;
  readonly payment: { readonly id: string };
  readonly configuration?: {
    readonly available_products?: {
      readonly installments?: readonly { readonly web_url: string }[];
    };
  };
}
 
export type CheckoutOutcome =
  | { readonly kind: 'redirect'; readonly url: string; readonly paymentId: string }
  | { readonly kind: 'rejected'; readonly paymentId: string };

والطلب، مع حصر التحويل في استدعاء واحد بالضبط:

export async function createCheckout(
  secretKey: string,
  merchantCode: string,
  input: TabbyCheckoutInput,
): Promise<CheckoutOutcome> {
  const response = await fetch(`${TABBY_API}/checkout`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${secretKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      payment: {
        // تابي تريد نصًا عشريًا، بينما أرادت مُيسّر عددًا صحيحًا.
        // `halalasToSar` هي الموضع الوحيد المسموح فيه بهذا التحويل.
        amount: halalasToSar(input.amount),
        currency: 'SAR',
        buyer: input.buyer,
        order: { reference_id: input.orderId },
      },
      lang: input.lang,
      merchant_code: merchantCode,
      merchant_urls: {
        success: input.successUrl,
        cancel: input.cancelUrl,
        failure: input.failureUrl,
      },
    }),
  });
 
  if (!response.ok) {
    throw new GatewayError(
      `Tabby checkout failed: ${await response.text()}`,
      response.status,
    );
  }
 
  const session = (await response.json()) as TabbyCheckoutResponse;
  const url =
    session.configuration?.available_products?.installments?.[0]?.web_url;
 
  if (session.status !== 'created' || url === undefined) {
    return { kind: 'rejected', paymentId: session.payment.id };
  }
  return { kind: 'redirect', url, paymentId: session.payment.id };
}

الاتحاد المميَّز هنا يؤدي عملًا حقيقيًا. لا سبيل لقراءة url دون تضييق النوع على kind أولًا، فلا يمكن نسيان مسار الرفض — ومع تفعيل noUncheckedIndexedAccess يصبح نوع installments?.[0] قابلًا لـ undefined، ما يجبرك على معالجة حالة المصفوفة الفارغة التي تعيدها الجلسة المرفوضة فعليًا.

مرّر lang بأمانة. إن كان متجرك عربيًا فأرسل "ar" لتتطابق صفحة تابي معه؛ فقذف مشترٍ عربي إلى صفحة دفع إنجليزية انخفاض قابل للقياس في معدل الإتمام.

الخطوة 6: التحصيل عند التسليم لا عند الطلب

عملية تابي المفوَّضة وعد لا مال. تتحول إلى مال حين تحصّلها، واللحظة الصحيحة للتحصيل هي لحظة شحن البضاعة.

حصّل عند إتمام الطلب وسيتحول كل إلغاء اعتيادي إلى عملية استرداد — مال غادر حساب العميل ويجب إعادته، والعميل يطاردك في الأثناء.

export async function capturePayment(
  secretKey: string,
  paymentId: string,
  amount: Halalas,
  idempotencyKey: string,
): Promise<void> {
  const response = await fetch(`${TABBY_API}/payments/${paymentId}/captures`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${secretKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      amount: halalasToSar(amount),
      reference_id: idempotencyKey,
    }),
  });
  if (!response.ok) {
    throw new GatewayError(
      `Tabby capture failed: ${await response.text()}`,
      response.status,
    );
  }
}
 
/** هاتان الحالتان فقط تعنيان جواز تسليم الطلب. */
export function isSettled(status: TabbyPaymentStatus): boolean {
  return status === 'AUTHORIZED' || status === 'CLOSED';
}

الحقل reference_id هو مفتاح عدم التكرار لا وصفًا نصيًا. اشتقّه من شيء ثابت — معرّف الشحنة خيار جيد — حتى لا تسحب إعادةُ محاولةِ تحصيل بعد انقطاع شبكة المالَ مرتين.

التحصيل الجزئي مدعوم، وهو أسلوب معالجة الطلب المشحون جزئيًا: حصّل ما شُحن، وتبقى العملية مفوَّضة للباقي.

الخطوة 7: الويب هوك — التوقيع وعدم تكرار الأثر

عمليات التحويل تبذل قصارى جهدها فحسب. العميل يغلق التبويب، والهاتف يفقد الإشارة في طريق العودة من تطبيق البنك، والمتصفح يستعيد جلسة من الذاكرة المؤقتة. الويب هوك هي القناة التي تخبرك بالحقيقة في النهاية، ما يعني أن معالجك يجب أن يكون آمنًا عند تنفيذه أكثر من مرة.

تختلف البوابات في طريقة توثيق الويب هوك — بعضها يرسل ترويسة توقيع HMAC، وبعضها يضمّن رمزًا سريًا مشتركًا داخل الحمولة. راجع لوحة تحكم كل بوابة لتعرف أيهما. وأيًا كان الأسلوب، تبقى قاعدتان: قارن بزمن ثابت، وامنع التكرار. الوحدة أدناه تطبّق أسلوب ترويسة HMAC؛ فإن كانت بوابتك تستخدم رمزًا مشتركًا، استبدل جسم verifySignature بمقارنة ذلك الرمز بزمن ثابت وأبقِ كل شيء آخر كما هو تمامًا.

// webhook.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
 
export function verifySignature(
  rawBody: string,
  receivedSignature: string,
  secret: string,
): boolean {
  const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = receivedSignature.trim().toLowerCase();
  // timingSafeEqual ترمي استثناءً عند اختلاف الطول، لذا نتحقق أولًا.
  if (received.length !== expected.length) return false;
  return timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}

وقّع على الجسم الخام. تحقق من البايتات نفسها التي استلمتها، قبل أي تحليل JSON. فإن حلّلت ثم أعدت التسلسل، تغيّر ترتيب المفاتيح والمسافات، ولم يعد التوقيع مطابقًا، وستمضي فترة طويلة مقتنعًا بأن البوابة معطلة. في Express يعني ذلك استخدام express.raw() على مسار الويب هوك تحديدًا، لا express.json() على مستوى التطبيق.

والآن عدم تكرار الأثر:

export interface EventStore {
  /** ترجع true إن كانت هذه أول مرة نرى فيها `eventId`. */
  claim(eventId: string): Promise<boolean>;
}
 
export class InMemoryEventStore implements EventStore {
  private readonly seen = new Set<string>();
 
  async claim(eventId: string): Promise<boolean> {
    if (this.seen.has(eventId)) return false;
    this.seen.add(eventId);
    return true;
  }
}
 
export type WebhookResult = 'processed' | 'duplicate' | 'invalid_signature';
 
export async function handleWebhook(
  rawBody: string,
  signature: string,
  secret: string,
  store: EventStore,
  process: (event: unknown) => Promise<void>,
): Promise<WebhookResult> {
  if (!verifySignature(rawBody, signature, secret)) {
    return 'invalid_signature';
  }
 
  const event = JSON.parse(rawBody) as { id?: string };
  const eventId = event.id;
  if (typeof eventId !== 'string' || eventId.length === 0) {
    throw new Error('Webhook payload has no event id — cannot deduplicate');
  }
 
  if (!(await store.claim(eventId))) {
    // عولج مسبقًا. أعد 200 لتتوقف البوابة عن إعادة المحاولة.
    return 'duplicate';
  }
 
  await process(event);
  return 'processed';
}

المخزن في الذاكرة للاختبارات فقط. في الإنتاج تكون claim عملية INSERT في جدول processed_events مع قيد UNIQUE على معرّف الحدث، ويجب أن تُنفَّذ داخل معاملة قاعدة البيانات نفسها التي تحدّث الطلب. افصلهما وسيعيدك أي انهيار بينهما إلى نقطة البداية: إما حدث مُعلَّم كمعالَج ولم يُعالَج، أو طلب حُدِّث مرتين.

أعد 200 للأحداث المكررة. أي رد خارج نطاق 2xx يخبر البوابة بإعادة المحاولة، وإعادة محاولة حدث مكرر إلى الأبد حرمان خدمة تفرضه على نفسك.

اختبار تنفيذك

كل ادعاء في هذا الدرس مغطى باختبار. وأبرزها:

describe('SAR money conversion', () => {
  it('avoids the float trap that Math.round(x * 100) walks into', () => {
    // الخلل الكلاسيكي: 19.99 * 100 === 1998.9999999999998
    expect(19.99 * 100).not.toBe(1999);
    expect(sarToHalalas('19.99')).toBe(1999);
  });
 
  it('rejects more than two decimals rather than silently truncating', () => {
    expect(() => sarToHalalas('1.005')).toThrow(MoneyError);
  });
});
 
describe('Saudi VAT extraction', () => {
  it('extracts 15% from a VAT-inclusive price', () => {
    // 115.00 ريال شاملة => 100.00 ريال صافي + 15.00 ريال ضريبة
    expect(extractVat(sarToHalalas('115.00'))).toBe(1500);
  });
 
  it('produces a net that grosses back up to the original price', () => {
    for (const price of ['9.99', '19.99', '33.33', '250.75', '1.01']) {
      const gross = sarToHalalas(price);
      const net = gross - extractVat(gross);
      expect(Math.round(net * 1.15)).toBe(gross);
    }
  });
});
 
describe('Moyasar callback verification', () => {
  const expected = { amount: sarToHalalas('100.00'), orderId: 'ORD-1' };
 
  it('rejects a tampered amount', () => {
    // الهجوم: ادفع ريالًا واحدًا ثم عدّل رابط العودة.
    expect(verifyPayment(payment({ amount: 100 }), expected)).toEqual({
      settled: false,
      reason: 'amount_mismatch',
    });
  });
 
  it('rejects a payment belonging to another order', () => {
    expect(
      verifyPayment(payment({ metadata: { order_id: 'ORD-999' } }), expected),
    ).toEqual({ settled: false, reason: 'order_mismatch' });
  });
});
 
describe('webhook handling', () => {
  it('processes an event once and ignores the retry', async () => {
    const store = new InMemoryEventStore();
    let calls = 0;
    const run = () =>
      handleWebhook(body, sign(body), secret, store, async () => {
        calls += 1;
      });
 
    expect(await run()).toBe('processed');
    expect(await run()).toBe('duplicate');
    expect(calls).toBe(1);
  });
});

نفّذها بـ npx vitest run، وتحقق من الأنواع بـ npx tsc --noEmit.

وإلى جانب اختبارات الوحدة، جرّب البيئات التجريبية الحقيقية قبل الإطلاق. البوابتان تنشران بطاقات اختبار وهويات عملاء اختبارية تنتج رفضًا بشكل حتمي، ومسارات الرفض هي ما سيصادفه مستخدموك في الثالثة فجرًا. تحديدًا، جرّب: تحدي 3-D Secure يتخلى عنه العميل، وجلسة تابي تعود بحالة rejected، وويب هوك يُسلَّم مرتين، وتحصيلًا يُعاد بعد انقطاع.

حل المشكلات الشائعة

المبالغ مختلفة بمقدار 100 ضعف بالضبط. أرسلت هللات إلى تابي أو نصًا عشريًا إلى مُيسّر. إن اعتمدت نوع Halalas يصبح هذا خطأ في وقت الترجمة؛ وإن وصل إلى الإنتاج فذلك يعني أن عددًا خامًا جرى تحويله قسريًا في مكان ما.

التحقق من التوقيع يفشل دائمًا. أنت تحسب البصمة على الجسم بعد تحليله وإعادة تسلسله. التقط البايتات الخام في طبقة الوسيط قبل تحليل JSON.

عمليات الدفع تبقى في حالة initiated إلى الأبد. العميل لم يكمل تحدي 3-D Secure. هذا طبيعي وشائع على الجوال — عامله كسلة متروكة لا كعملية فاشلة، واحرص على أن يكون الويب هوك (لا رابط العودة) هو ما يغلق الطلب في النهاية.

تابي ترفض كل طلب تجريبي. استخدم هويات المشترين التجريبية من وثائق تابي؛ فأرقام الهواتف والبُرد الإلكترونية العشوائية سيرفضها نموذج التقييم المسبق بحكم التصميم.

بعض الطلبات تُعلَّم كمدفوعة مرتين. عملية حجز معرّف الحدث وتحديث الطلب في معاملتين منفصلتين.

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

الخطوات التالية

الخلاصة

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

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

ابنِها من اليوم الأول.


تحتاج تكامل مدفوعات سعودية جاهزًا بدل تصحيحه؟ نبني ونشغّل صفحات دفع تدعم مدى والدفع الآجل ومتوافقة مع متطلبات الفوترة الإلكترونية للتجار في السعودية والخليج. تواصل معنا.