تخزين المبالغ المالية في قاعدة البيانات: DECIMAL لا float

خزّن المبالغ المالية في عمود DECIMAL داخل قاعدة البيانات، ومرّرها عبر الحدود الخارجية عدداً صحيحاً بأصغر وحدة للعملة أو نصاً، واحتفظ بجانب كل مبلغ بعملته وعدد منازلها العشرية. هذه الخلاصة. والباقي تفصيلٌ لسؤال واحد: لماذا يعمل عمود FLOAT في تطبيق المدفوعات شهوراً بلا مشكلة ظاهرة، ثم يبدأ بإسقاط صفوف من الاستعلامات وإضافة هللات غامضة إلى التقارير؟ الجواب يمر عبر فروق MySQL وPostgreSQL وSQLite، وفخّ العملات الخليجية ثلاثية المنازل، وأربع بوابات دفع تطلب المبلغ نفسه بأربع صيغ متناقضة.
عمود FLOAT يعمل شهوراً ثم يفشل عند أول مساواة
الخطأ لا يظهر يوم إنشاء الجدول. يظهر يوم تكتب WHERE price = 19.99 فلا يعود الصف الذي تراه بعينك في نتائج SELECT. توثيق MySQL نفسه يعرض مثالاً لعمودَين من نوع DOUBLE يبدوان متطابقين تماماً عند العرض، ومع ذلك يعيدهما HAVING a <> b لأن القيمتين المخزنتين تختلفان حول المنزلة العاشرة. والحل الذي يقترحه التوثيق؟ المقارنة بهامش سماح: ABS(a - b) > 0.0001.
توقف عند هذه الجملة. حين تجد نفسك تقارن أموال العملاء «بهامش سماح» فالمشكلة ليست في الاستعلام، بل في نوع العمود من الأساس.
ما الذي يخزَّن فعلاً عندما تكتب 0.1؟
يمثّل float وdouble الأرقام بكسور ثنائية، و0.1 لا يملك تمثيلاً ثنائياً منتهياً. جرّب أن تكتب ناتج قسمة 1 على 3 عشرياً: ستتوقف عند 0.333 في منزلةٍ ما ويبقى وراءها باقٍ لا ينتهي. هذا نفسه ما يصيب 0.1 في النظام الثنائي، فالقيمة المخزنة فعلياً عندما تكتبه هي:
0.1000000000000000055511151231257827021181583404541015625
من هذه الذيول جاء المثال المعروف: جمع 0.1 + 0.2 في بايثون يعيد 0.30000000000000004، والمقارنة 0.1 + 0.1 + 0.1 == 0.3 تعيد False. لكن العلة ليست محصورة في لغات البرمجة — العمود داخل قاعدة البيانات يرتكب التقريب نفسه.
توثيق SQLite يبيّن أن 47.49 يُخزَّن فعلياً 47.49000000000000198951966012828052043914794921875، وأن القيم الوحيدة القابلة للتمثيل الدقيق في المنزلتين الأخيرتين هي .00 و.25 و.50 و.75. أي أن 4 فقط من كل 100 سعر ممكن تُخزَّن بدقة، والباقي تقريبي قبل أي عملية حسابية. والانحراف الضئيل لا يبقى ضئيلاً: كل جمع وضرب يراكمه. في جدول فواتير، يظهر في تقرير نهاية الشهر فرقُ هللة أو أكثر لا يعرف أحد مصدره.
DECIMAL في MySQL وPostgreSQL: دقة كاملة بثمن معلوم
DECIMAL يخزّن الرقم العشري كما هو، بلا تمثيل ثنائي تقريبي. في MySQL يصل أقصى عدد للخانات إلى 65 وأقصى منازل عشرية إلى 30. والتخزين مضغوط: كل 9 خانات في 4 بايت، فعمود DECIMAL(18,9) يشغل 8 بايت — حجم double نفسه لكن بدقة تامة. انتبه فقط إلى أن كتابة DECIMAL بلا أقواس تعني DECIMAL(10,0): عمود بلا منازل عشرية إطلاقاً. حدّد المنازل دائماً بنفسك.
في MySQL تحذيران يغيبان عن كثيرين. الأول: تقريب الإدخال يجري بأسلوب النصف بعيداً عن الصفر، ولا يُعد خطأ حتى في strict mode — تحذير فقط. إن أدخلت منازل أكثر مما يتسع العمود قُرِّبت القيمة بصمت رغم كل إعداداتك، والثقة بأن strict mode سيمنع ذلك خطأ شائع. الثاني: دالة التقريب نفسها تتصرف بحسب نوع مدخلها. فـROUND(2.5) تعيد 3 لأن 2.5 قيمة دقيقة، بينما ROUND(25E-1) تعيد 2 لأنها float — الرقم نفسه ظاهرياً ونتيجتان مختلفتان.
في PostgreSQL يقبل النوع numeric دقة مصرّحاً بها حتى 1000 خانة. التوثيق ينص حرفياً على أن الحسابات عليه «بطيئة جداً مقارنة بالأعداد الصحيحة والفاصلة العائمة»، ومع ذلك ينصح به لتخزين المال — ثمن مقبول في مسار فاتورة تُحسب مرة وتُقرأ ألف مرة. وفيه فخ خفي: numeric يقرّب المنتصف بعيداً عن الصفر بينما double precision يقرّبه إلى الزوجي على معظم الأنظمة. خلط النوعين في استعلام واحد يعطي تقريبين مختلفين للقيمة نفسها.
قرارات المخطط هذه من النوع الذي تدفع كلفته مرة عند التصميم أو تدفعها يومياً بعد الإطلاق، شأنها شأن اختيار المفتاح الأساسي بين UUID وbigint.
SQLite يقبل DECIMAL ثم يخزّنه تقريبياً
إن كان تطبيقك على SQLite — تطبيق جوال أو أداة سطح مكتب — فالوضع مختلف جذرياً. أنواع الأعمدة هناك إيحاءات لا قيود: جملة DECIMAL(10,2) تُقبل في CREATE TABLE، ثم تُخزَّن القيمة بنوع REAL تقريبي كأنك كتبت float حرفياً. الحل في SQLite هو تخزين المبلغ عدداً صحيحاً بأصغر وحدة، أو استخدام امتداد decimal الذي يجري الحساب العشري على نصوص.
أصغر وحدة بعدد صحيح: أين يتفوق وأين يتعثر
البديل الشائع عن DECIMAL هو تخزين 100.50 ريال على أنها 10050 هللة في عمود bigint — 8 بايت بمدى يصل إلى 9223372036854775807. الجمع دقيق دائماً، والأداء أعلى، والقيمة تعبر JSON بلا تشويه. وهو الخيار الوحيد المعقول حيث لا يوجد DECIMAL أصلاً: SQLite وجافاسكريبت.
في جافاسكريبت تحديداً كل الأرقام double، وأكبر عدد صحيح آمن هو Number.MAX_SAFE_INTEGER أي 9007199254740991 — نحو 90 تريليون ريال بالهللة، وهو ما يكفي أي تطبيق فواتير. لكن MDN توثق أن MAX_SAFE_INTEGER + 1 === MAX_SAFE_INTEGER + 2 تعيد true. إذا تجاوزت الحد في تجميعات ضخمة انهارت الدقة بصمت، والملاذ حينها BigInt.
أين يتعثر هذا الأسلوب؟ عند القسمة والنسب: توزيع 10050 هللة على 3 أقساط يفرض قراراً صريحاً بمصير الهللات المتبقية، وحساب نسبة مئوية يعيدك إلى الكسور التي هربت منها. وعند تعدد العملات يظهر فخ أكبر.
الضرب الثابت في 100 يكسر عملات الخليج الثلاثية
معظم الشيفرات المنقولة من أمثلة أجنبية تفترض أن أصغر وحدة تساوي المبلغ مضروباً في 100. القائمة الرسمية لمعيار ISO 4217 تقول غير ذلك. الريال السعودي والدرهم الإماراتي منزلتان فعلاً، لكن الدينار الكويتي والدينار البحريني والريال العماني والدينار الأردني والدينار التونسي ثلاث منازل — أساسها 1000 — والين الياباني بلا منازل أصلاً. الكود الذي يضرب في 100 دائماً يرسل عُشر المبلغ الكويتي، و100 ضعف المبلغ الياباني.
القاعدة: خزّن مع كل مبلغ عمود عملة وعدد منازلها، واضرب في 10 مرفوعةً إلى عدد المنازل، ولا تكتب 100 ثابتةً في أي سطر.
أربع بوابات وأربع صيغ للمبلغ نفسه
عند الخروج من قاعدة بياناتك إلى بوابة الدفع، لكل واجهة اصطلاحها الخاص في حقل المبلغ. والخلط بينها خطأ لا يوقفه مترجم ولا اختبار وحدات — يظهر خصماً خاطئاً في عملية حقيقية:
| البوابة | صيغة حقل amount | مثال موثّق |
|---|---|---|
| Stripe | عدد صحيح بأصغر وحدة | 1000 تعني 10 دولارات، و10 تعني 10 ينات |
| Moyasar | عدد صحيح موجب بأصغر وحدة | 1.00 ريال = 100 هللة، و1.00 دينار كويتي = 1000 فلس |
| Tap | رقم عشري بمنازل ISO | 100.5 تعني 100.50 دولار، والحد الأدنى 0.100 |
| PayPal | سلسلة نصية داخل كائن | {"currency_code":"USD","value":"100.00"} |
نقطة الخطر الأولى واضحة من الجدول: نقل كود Moyasar إلى Tap دون تعديل. الأولى تريد هللات صحيحة والثانية تريد ريالات عشرية، فالقيمة 10050 التي تعني 100.50 ريال عند Moyasar تعني 10050 ريالاً عند Tap — خطأ يضخّم المبلغ 100 مرة. والثانية: PayPal لا يدرج الدينار الكويتي والبحريني والريال العماني ضمن عملاته المدعومة أصلاً. ويوثق أن تمرير قيمة عشرية لعملة لا تقبل المنازل مثل الين يسبب خطأً في الطلب.
ولاحظ لماذا يمرر PayPal المبلغ نصاً لا رقماً: الرقم العشري في JSON يفكّه الطرف المستقبِل قيمةَ float64، فيلتقط خطأ التمثيل نفسه الذي هربت منه في قاعدة البيانات. النص يصل كما أُرسل. طبّق الفكرة نفسها في واجهاتك الداخلية. وأياً كانت البوابة، أرسل طلب الدفع مع مفتاح idempotency يمنع تكرار الخصم، وعند استقبال إشعار نجاح الدفع تحقّق من توقيع الويب هوك قبل تحديث الفاتورة — فخطأ المبلغ ليس الخطر الوحيد على الحدود.
ضريبة 15%: التقريب الذي تفرضه زاتكا
ضريبة القيمة المضافة في السعودية 15% منذ 1 يوليو 2020، وحسابها هو الموضع الذي تفترق فيه افتراضيات لغات البرمجة عن المتطلب النظامي. الافتراضي في وحدة decimal ببايثون هو ROUND_HALF_EVEN بدقة 28 خانة. أما مواصفة الفوترة الإلكترونية لدى هيئة الزكاة والضريبة والجمارك السعودية (زاتكا) فتعتمد تقريب المنتصف إلى أعلى، وتنص على أن مبلغ الضريبة يُقرَّب على مستوى الفاتورة لا بجمع أسطر مقرَّبة، وأن الإجماليات تُكتب بمنزلتين.
فمنتصفٌ مثل 37.485 يقرّبه أسلوب النصف إلى الزوجي إلى 37.48، بينما تريد المواصفة 37.49. هللة واحدة تكفي لمخالفة فاتورة إلكترونية. لذلك مرّر وضع التقريب صراحة في كل عملية، واحسب الضريبة على إجمالي الفاتورة:
from decimal import Decimal, ROUND_HALF_UP
subtotal = Decimal("249.90")
vat = (subtotal * Decimal("0.15")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
total = subtotal + vat
دالة quantize تثبّت عدد المنازل وتفرض وضع التقريب معاً. توثيق بايثون نفسه يعرض أن Decimal('7.325').quantize(Decimal('.01'), rounding=ROUND_DOWN) تعيد 7.32.
وتتكرر في أنظمة الفواتير أخطاء تقريب بعينها: جمع أسطر مقرَّبة بدل تقريب الإجمالي، فيخالف المجموع قيمة مستوى الفاتورة بهللة أو أكثر؛ وتقريب القيمة نفسها مرتين في مرحلتين مختلفتين، والصواب مرة واحدة في نهاية الحساب؛ والبدء من float ثم «تحويله» إلى نوع دقيق: في جافا new BigDecimal(0.1) يلتقط خطأ double كاملاً داخل قيمة تبدو دقيقة، والصواب new BigDecimal("0.1"). ابنِ القيم العشرية من نصوص دائماً، في كل لغة.
ثلاث قواعد تحسم تصميم عمود المبلغ
داخل قاعدة البيانات: DECIMAL أو numeric بمنازل محددة صراحة — 3 منازل على الأقل إن كنت تستقبل عملات خليجية ثلاثية، ومنازل إضافية للقيم الوسيطة قبل التقريب النهائي. عند الحدود الخارجية — JSON وواجهات البوابات — أصغر وحدة عدداً صحيحاً أو مبلغ نصي كما يفعل PayPal، ولا ترسل عشرياً عائماً إلا حيث تفرض البوابة صيغتها كما تفعل Tap. وبجانب كل مبلغ: عمود العملة وعدد منازلها من ISO 4217.
مخطط يجمع القواعد الثلاث في PostgreSQL:
CREATE TABLE payments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
amount numeric(18, 6) NOT NULL,
currency char(3) NOT NULL,
currency_exponent smallint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
وعمود created_at من نوع timestamptz ليس تفصيلاً جانبياً؛ فاختيار نوع عمود الوقت قرار تصميمي شقيق فصّلناه في تخزين التاريخ والوقت في قاعدة البيانات: UTC أولاً.
يبقى لـfloat مكانه المشروع: الإحصاءات والرسوم البيانية والحسابات العلمية التي يُحتمل فيها انحراف في المنزلة الخامسة عشرة. أما المبلغ الذي سيظهر في فاتورة عميل أو يُخصم من بطاقته، فلا يمر عبر float في أي طبقة — لا في العمود، ولا في JSON، ولا في حساب الضريبة.