العودة إلى المدونة

قواعد تقريب العملات للمطورين: الخانات العشرية والوحدات الصغرى وحسابات تحويل آمنة

V
Vlado Grigirov
August 21, 2026
Currency API Exchange Rates Currency Rounding Minor Units ISO 4217 Finexly Developer Guide

عميل في طوكيو يشتري اشتراكًا بقيمة 19.99 دولارًا. تضرب شيفرتك في سعر صرف USD/JPY فتحصل على 2942.82785، ثم تكتبها في قاعدة البيانات وترسلها إلى مزوّد الدفع. يرفضها المزوّد — أو الأسوأ، يقبلها ويحصّل مئة ضعف المبلغ. الين الياباني بلا خانات عشرية، وشيفرتك لم تسأل قط.

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

الوحدات الصغرى: كم خانة عشرية لكل عملة؟

الوحدة الصغرى لعملة ما هي أصغر تقسيم قابل للتداول فيها. بالنسبة للدولار الأمريكي هي السنت، فـ USD له خانتان عشريتان و19.99 دولارًا تساوي 1999 سنتًا. هذا هو التمثيل الذي يتوقعه مزوّدو الدفع، وهو ما ينبغي أن تستخدمه قاعدة بياناتك.

معيار ISO 4217 — ذاته الذي يمنحك رموزًا من ثلاثة أحرف مثل USD وJPY — يُسند أيضًا لكل عملة أُسّ وحدة صغرى. يفترض معظم المطورين أن هذا الأُسّ يساوي 2 دائمًا. ليس كذلك، وهذا الافتراض هو أغلى خطأ برمجي في هذا المجال.

عملات بلا خانات عشرية

هذه العملات لا تملك وحدة فرعية متداولة، فالمبلغ الذي ترسله هو عدد الوحدات الصحيح:

  • JPY — الين الياباني
  • KRW — الوون الكوري الجنوبي
  • VND — الدونغ الفيتنامي
  • CLP — البيزو التشيلي
  • ISK — الكرونا الأيسلندية
  • XAF / XOF / XPF — فرنكات CFA و CFP
  • UGX — الشلن الأوغندي
  • PYG — الغواراني الباراغوياني
  • RWF وGNF وKMF وDJF وVUV — وعدة عملات أخرى صغيرة الفئات

إن عاملت الين كعملة بخانتين عشريتين وضربت في 100 قبل الإرسال إلى المزوّد، فأنت للتو حصّلت من عميلك مئة ضعف المبلغ المقصود.

عملات بثلاث خانات عشرية

سبع عملات تنقسم إلى أجزاء من ألف بدلًا من أجزاء من مئة:

  • KWD — الدينار الكويتي (1000 فلس)
  • BHD — الدينار البحريني (1000 فلس)
  • OMR — الريال العماني (1000 بيسة)
  • JOD — الدينار الأردني (1000 فلس)
  • TND — الدينار التونسي (1000 مليم)
  • IQD — الدينار العراقي (1000 فلس)
  • LYD — الدينار الليبي (1000 درهم)

هنا يسير الخطأ في الاتجاه المعاكس: عامل الدينار الكويتي بخانتين وستحصّل عُشر ما قصدته. فاتورة بـ 12.500 KWD تصبح 1.250 KWD.

بل توجد في ISO 4217 مدخلات بأربع خانات عشرية — وحدة التنمية التشيلية (CLF) ووحدة التقاعد الأوروغوايية (UYW). هذه وحدات محاسبية مرتبطة بمؤشر لا نقودًا، لكن إن كان نظامك يقبل رموز ISO اعتباطية فعليه أن يصمد أمامها.

حين يختلف المعيار عن مزوّدك

هذا هو الفخ الذي يقع فيه الفريق الذي أتقن كل شيء آخر. يحيد مزوّدو الدفع أحيانًا عن ISO 4217 لأسباب تشغيلية. توثّق Adyen مثلًا أن CLP وCVE وIDR وISK تأخذ في واجهتها البرمجية عددًا من الخانات يختلف عمّا يحدده المعيار — فـ ISK بلا خانات عشرية وفق ISO 4217، لكن يجب إرسالها إلى Adyen بخانتين.

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

لا تخزّن النقود أبدًا كعدد عشري عائم

قبل أي نقاش عن التقريب، الأساس. لا تستطيع الفاصلة العائمة الثنائية تمثيل معظم الكسور العشرية بدقة:

0.1 + 0.2              // 0.30000000000000004
1.005 * 100            // 100.49999999999999
19.99 * 147.2150       // 2942.8278499999997

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

قاعدتان تغطيان كل الحالات تقريبًا:

  1. خزّن المبالغ كأعداد صحيحة بالوحدات الصغرى. عمود amount_minor BIGINT مع عمود currency CHAR(3). الصيغة { amount_minor: 1999, currency: "USD" } لا لبس فيها وتطابق ما تتوقعه Stripe وAdyen ومعظم المزوّدين أصلًا.
  2. نفّذ الحساب بأعداد صحيحة أو بنوع عشري دقيق. decimal.Decimal في بايثون، وBigDecimal في جافا، وNUMERIC في PostgreSQL، أو مكتبة نقود في جافاسكربت تغلّف الحساب الصحيح. اترك الأعداد العائمة لسعر الصرف نفسه، وحتى ذلك فقط حتى نقطة الضرب.

إن كنت تصمّم هذه الطبقة من الصفر، فدليلنا حول تصميم دفتر أستاذ متعدد العملات يتعمق في قرارات المخطط.

خط أنابيب التحويل: وحدات صغرى تدخل، وحدات صغرى تخرج

لتحويل العملات أربع خطوات بالضبط، والتقريب ينتمي إلى الخطوة الثالثة — مرة واحدة، في النهاية.

  1. حوّل مبلغ المصدر من الوحدات الصغرى إلى قيمة عشرية.
  2. اضرب في سعر الصرف بدقته الكاملة.
  3. قرّب إلى أُسّ الوحدة الصغرى للعملة الهدف.
  4. حوّل مجددًا إلى وحدات صغرى صحيحة.

إليك الشيفرة بجافاسكربت، حيث يقوم جدول العملات بالعمل:

// Minor unit exponents. Seed from ISO 4217, override per payment provider.
const MINOR_UNITS = {
  USD: 2, EUR: 2, GBP: 2, CHF: 2, CAD: 2, AUD: 2, CNY: 2, INR: 2,
  JPY: 0, KRW: 0, VND: 0, CLP: 0, ISK: 0, XAF: 0, XOF: 0, XPF: 0,
  KWD: 3, BHD: 3, OMR: 3, JOD: 3, TND: 3, IQD: 3, LYD: 3,
};

function exponentFor(currency) {
  const e = MINOR_UNITS[currency];
  if (e === undefined) throw new Error(`Unknown minor unit for ${currency}`);
  return e;
}

/**
 * Convert an integer minor-unit amount from one currency to another.
 * Returns an integer in the target currency's minor units.
 */
function convertMinor(amountMinor, from, to, rate) {
  const fromExp = exponentFor(from);
  const toExp = exponentFor(to);

  const decimalAmount = amountMinor / 10 ** fromExp;   // 1999 -> 19.99
  const converted = decimalAmount * rate;              // full precision, no rounding yet
  return Math.round(converted * 10 ** toExp);          // single rounding step
}

convertMinor(1999, "USD", "JPY", 147.2150);   // 2943      (¥2,943)
convertMinor(1999, "USD", "KWD", 0.30590);    // 6115      (KWD 6.115)
convertMinor(1999, "USD", "EUR", 0.9241);     // 1847      (€18.47)

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

اختيار وضع التقريب

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

الوضع2.5 ←3.5 ←−2.5 ←الاستخدام النموذجي
المنتصف للأعلى (half up)34−3أسعار المستهلك، إجماليات الدفع
المنتصف للزوجي (تقريب المصرفيين)24−2المحاسبة، الفوائد، الضرائب، التقارير
المنتصف للأسفل (half down)23−2نادر؛ أحيانًا في شيفرة مالية قديمة
للأعلى (ceiling)34−2رسوم يجب ألا تُحصَّل ناقصة أبدًا
للأسفل (floor / بتر)23−3مدفوعات يجب ألا تُصرف زائدة أبدًا
half up هو ما يقصده معظم الناس بـ«التقريب»، وهو ما تعطيه Math.round() للأعداد الموجبة. بديهي ومناسب لسعر يوشك العميل أن يراه.

half even، أو تقريب المصرفيين، يرسل الأنصاف الدقيقة إلى أقرب رقم زوجي. عبر عدد كبير من المعاملات يلغي الانحياز الصعودي المنهجي الذي يُدخله half up، ولهذا هو الوضع الافتراضي في أنظمة المحاسبة، وفي وحدة decimal في بايثون، وفي معيار IEEE 754 نفسه. إن كنت تجمّع آلاف المبالغ المحوّلة في تقرير إيرادات، فسينفخ half up الإجمالي بصمت؛ أما half even فلا.

التقريب للأعلى وللأسفل موجودان للمخاطر غير المتماثلة. قد يقرّب سوق إلكتروني يدفع للبائعين كل دفعة للأسفل كي لا يوزّع أكثر مما يملك؛ ويذهب الفارق إلى حساب فروق التقريب.

بايثون يجعل الاختيار صريحًا، وهذا هو التصميم الصحيح:

from decimal import Decimal, ROUND_HALF_EVEN, ROUND_HALF_UP

MINOR_UNITS = {"USD": 2, "EUR": 2, "JPY": 0, "KWD": 3}

def convert_minor(amount_minor: int, src: str, dst: str,
                  rate: str, mode=ROUND_HALF_EVEN) -> int:
    """Convert integer minor units to integer minor units, exactly once."""
    src_exp, dst_exp = MINOR_UNITS[src], MINOR_UNITS[dst]

    amount = Decimal(amount_minor) / (Decimal(10) ** src_exp)
    converted = amount * Decimal(rate)          # rate passed as a string, not a float

    quantum = Decimal(1).scaleb(-dst_exp)       # 0.01, 1, or 0.001
    rounded = converted.quantize(quantum, rounding=mode)
    return int(rounded.scaleb(dst_exp))

convert_minor(1999, "USD", "JPY", "147.2150")   # 2943
convert_minor(1999, "USD", "KWD", "0.30590")    # 6115

تمرير السعر إلى Decimal كـسلسلة نصية أمر مهم. فـ Decimal(0.9241) يرث خطأ العدد العائم، بينما Decimal("0.9241") لا يرثه.

ثلاثة أخطاء تقريب تكلّف مالًا حقيقيًا

1. تقريب السعر قبل الضرب

تحمل أسعار الصرف عادةً من أربع إلى ست خانات عشرية معنوية، وبترها ليس بلا ضرر. خذ USD/JPY عند 147.2150 وتحويلًا بقيمة 10,000 دولار:

  • السعر الكامل: 10000 × 147.2150 = ¥1,472,150
  • السعر مقرَّبًا إلى خانتين (147.21): 10000 × 147.21 = ¥1,472,100

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

2. التقريب مرتين في تحويل متعدد المراحل

إن مررت عبر USD ← EUR ← JPY وقرّبت عند مرحلة اليورو، فقد تخلصت من دقة تضخّمها عملية الضرب الثانية. تحويل 12.34 دولارًا بسعر USD/EUR يساوي 0.9241 وسعر EUR/JPY يساوي 159.3063:

  • مباشرة: 12.34 × 147.2150 = 1816.631,817 ينًا
  • عبر مرحلة يورو مقرَّبة: 12.34 × 0.9241 = 11.4034 ← مقرَّبًا إلى 11.40 يورو ← 11.40 × 159.3063 = 1816.091,816 ينًا

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

3. بنود لا يساوي مجموعها الإجمالي

قرّب كل بند في الفاتورة على حدة ولن تساوي الأجزاء دائمًا الإجمالي المقرَّب. الحالة الكلاسيكية هي التقسيم:

$10.00 split three ways
  10.00 / 3 = 3.3333...
  → 3.33 + 3.33 + 3.33 = 9.99   ✗ one cent missing

الحل هو التوزيع لا التقريب. قرّب الإجمالي مرة واحدة، ثم وزّعه على الأجزاء، موزّعًا الباقي وحدةً صغرى تلو الأخرى:

/**
 * Split an integer minor-unit total into `n` parts whose sum is exactly the total.
 * Remainder units are distributed to the earliest parts (largest-remainder method).
 */
function allocate(totalMinor, ratios) {
  const sum = ratios.reduce((a, b) => a + b, 0);
  const shares = ratios.map(r => Math.floor((totalMinor * r) / sum));
  let remainder = totalMinor - shares.reduce((a, b) => a + b, 0);

  for (let i = 0; remainder > 0; i = (i + 1) % shares.length, remainder--) {
    shares[i] += 1;
  }
  return shares;
}

allocate(1000, [1, 1, 1]);   // [334, 333, 333]        → sums to exactly 1000
allocate(9247, [3, 2, 1]);   // [4624, 3082, 1541]     → sums to exactly 9247

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

تقريب النقد قاعدة منفصلة

تخبرك الوحدة الصغرى للعملة بأصغر مبلغ يمكن قيده. ولا تخبرك دائمًا بأصغر مبلغ يمكن دفعه نقدًا. سحبت عدة دول أصغر عملاتها المعدنية وصارت تقرّب المدفوعات النقدية عند الصندوق:

  • سويسرا — يُقرَّب النقد إلى أقرب 0.05 فرنك
  • كندا — سُحبت قطعة السنت الواحد عام 2013؛ ويُقرَّب النقد إلى أقرب 5 سنتات
  • السويد — يُقرَّب النقد إلى أقرب كرونة كاملة
  • هولندا — يُقرَّب النقد إلى أقرب 5 سنتات

والمهم أن هذا ينطبق على الدفع النقدي لا على الفاتورة. فاتورة سويسرية بـ 12.32 فرنكًا تبقى مقيَّدة بـ 12.32؛ والتسوية النقدية وحدها تُقرَّب إلى 12.30، ويُقيَّد فارق الـ 0.02 كتسوية تقريب. إن كنت تبني برنامج نقاط بيع، فاجعل تقريب النقد خطوة منفصلة لاحقة تُطبَّق على الدفعة — ولا تدمجه أبدًا في المبلغ المخزَّن، وإلا اختلفت معاملاتك الإلكترونية عن النقدية.

التنسيق آخر خطوة، وليس الحساب

بعد إتمام الحساب، سلّم العرض إلى مُنسّق يعي المحليّة. Intl.NumberFormat يعرف مسبقًا عدد الخانات وموضع الرمز والفواصل لكل عملة:

function formatMinor(amountMinor, currency, locale = "en-US") {
  const exp = exponentFor(currency);
  return new Intl.NumberFormat(locale, {
    style: "currency",
    currency,
  }).format(amountMinor / 10 ** exp);
}

formatMinor(1999, "USD");            // "$19.99"
formatMinor(2943, "JPY", "ja-JP");   // "¥2,943"
formatMinor(6115, "KWD");            // "KWD 6.115"
formatMinor(1847, "EUR", "de-DE");   // "18,47 €"

ملاحظتان عمليتان. أولًا، إنشاء نسخ من Intl.NumberFormat مكلف — خزّن نسخة لكل زوج (محليّة/عملة) بدل إنشاء واحدة لكل صف. ثانيًا، القسمة على 10 ** exp في السطر الأخير هي المكان الوحيد الذي ينبغي أن يلمس فيه عدد عائم قيمة نقدية، وذلك فقط لأن الناتج يتحول فورًا إلى نص.

الجمع بين ذلك كله مع واجهة Finexly

اجلب السعر بدقته الكاملة، وحوّل مرة واحدة، وقرّب مرة واحدة، واحفظ السعر الذي استخدمته:

curl "https://api.finexly.com/v1/latest?base=USD&symbols=JPY,KWD,EUR" \
  -H "Authorization: Bearer YOUR_API_KEY"
{
  "success": true,
  "base": "USD",
  "timestamp": 1755244800,
  "rates": {
    "JPY": 147.2150,
    "KWD": 0.30590,
    "EUR": 0.9241
  }
}
async function quote(amountMinor, from, to) {
  const res = await fetch(
    `https://api.finexly.com/v1/latest?base=${from}&symbols=${to}`,
    { headers: { Authorization: `Bearer ${process.env.FINEXLY_API_KEY}` } }
  );
  const data = await res.json();
  const rate = data.rates[to];

  return {
    amount_minor: convertMinor(amountMinor, from, to, rate),
    currency: to,
    rate,                              // persist the exact rate used
    rate_timestamp: data.timestamp,    // and when it was captured
  };
}

await quote(1999, "USD", "JPY");
// { amount_minor: 2943, currency: "JPY", rate: 147.215, rate_timestamp: 1755244800 }

حفظ rate وrate_timestamp في سجل المعاملة هو ما يجعل نزاعًا بعد ستة أشهر قابلًا للإجابة. تفاصيل نقاط النهاية والمعاملات كاملةً في وثائق واجهة Finexly، وإن كنت تخزّن الأسعار مؤقتًا بين الطلبات فملاحظاتنا عن التخزين المؤقت ومعالجة الأخطاء تغطي مقايضات حداثة البيانات.

قائمة تحقق للاختبارات

تختبئ أخطاء المبالغ في الحالات التي لا يكتب لها أحد اختبارات. غطِّ على الأقل:

  1. عملة هدف بلا خانات عشرية — حوّل إلى JPY أو KRW وتحقق من خلو الناتج من جزء كسري.
  2. عملة هدف بثلاث خانات — حوّل إلى KWD أو BHD وتحقق من بقاء الخانات الثلاث.
  3. أنصاف دقيقة — تحقق من وضع التقريب المختار في الاتجاهين، بما في ذلك السالب.
  4. انحراف الذهاب والإياب — حوّل USD ← EUR ← USD وتحقق من أن الناتج ضمن وحدة صغرى واحدة، لا أنه مساوٍ.
  5. ثبات التوزيع — تحقق من أن الأجزاء المقسَّمة تساوي الإجمالي بالضبط دائمًا، من جزء واحد إلى مئة جزء.
  6. رموز عملات غير معروفة — تحقق من أن الشيفرة ترمي خطأً بدل الرجوع صامتةً إلى خانتين.
  7. مبالغ ضخمة جدًا — تحقق من عدم فقدان الدقة بعد Number.MAX_SAFE_INTEGER في جافاسكربت؛ واستخدم BigInt إن تعاملت مع IDR أو VND على نطاق واسع.

الأسئلة الشائعة

كم خانة عشرية لكل عملة؟

معظمها خانتان. نحو أربع وعشرين عملة بلا خانات — منها JPY وKRW وVND وCLP وISK — وسبع لها ثلاث: KWD وBHD وOMR وJOD وTND وIQD وLYD. معيار ISO 4217 هو المرجع، لكن راجع أيضًا جدول مزوّد الدفع لديك لأن بعضهم يحيد لأسباب تشغيلية.

هل أقرّب أسعار الصرف أم المبالغ المحوَّلة؟

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

ما الفرق بين half-up وتقريب المصرفيين؟

يبعد half-up النصف الدقيق عن الصفر دائمًا (2.5 ← 3). أما تقريب المصرفيين — المنتصف إلى الزوجي — فيرسله إلى أقرب رقم زوجي (2.5 ← 2، و3.5 ← 4)، وهو ما يزيل الانحياز الصعودي المنهجي عند تجميع مبالغ كثيرة. استخدم half-up للأسعار المعروضة على العملاء، وhalf-even للمحاسبة والتقارير.

لماذا لا يساوي مجموع بنودي المحوَّلة الإجمالي المحوَّل؟

لأن كل بند قُرِّب على حدة، والأخطاء تتراكم. قرّب الإجمالي مرة واحدة، ثم وزّعه على البنود بطريقة أكبر باقٍ. عندئذٍ يساوي مجموع الأجزاء الكلَّ بحكم البناء.

ألا يمكنني ببساطة تخزين النقود كعدد عائم بخانتين؟

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

احصل على أسعار بدقة كاملة

يبدأ التقريب الصحيح بسعر تثق به وبدقة لم تتخلص منها. احصل على مفتاح Finexly المجاني — دون بطاقة ائتمان. ابدأ بـ 1,000 طلب مجاني شهريًا عبر أكثر من 170 عملة، وجرّب محوّل العملات للتحقق من حساباتك، وراجع خطط الأسعار حين ينمو حجمك.

Vlado Grigirov

Senior Currency Markets Analyst & Financial Strategist

Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...

View full profile →