تنصّ المادة الخامسة من نظام الأوراق التجارية (المرسوم الملكي م/37) على قاعدة واحدة تكفي لإعادة تصنيف هذا الخطأ بالكامل:
إذا كتب مبلغ الكمبيالة بالحروف وبالأرقام معاً فتكون العبرة عند الاختلاف بالمكتوب بالحروف، وإذا كتب المبلغ عدة مرات بالحروف أو بالأرقام فتكون العبرة عند الاختلاف بالمبلغ الأقل.
اقرأ الجملة مرّة ثانية من زاوية المبرمج. الحقل الذي تُخرجه دالة التفقيط ليس زينةً على المستند ولا نصّاً مساعداً للقارئ؛ هو النصّ الحاكم. إذا طبع نظامك «ثلاث آلاف ريال» بدل «ثلاثة آلاف ريال» فأنت لم تُنتج نصّاً قبيحاً، بل مستنداً قابلاً للردّ. وإذا ابتلعت الفاصلة العشرية هللةً في طريقها من قاعدة البيانات إلى الورقة، فالمبلغ الأقل هو ما يسري.
هذا الدرس يبني محرّك تفقيط كاملاً في TypeScript — من قواعد العدد والمعدود حتى طبقة العملة — ويشرح المواضع الخمسة التي تسقط فيها أغلب التنفيذات المنشورة.
ما الذي ستبنيه
دالة واحدة، tafqit(value, options)، تأخذ رقماً أو نصّاً وتُخرج المبلغ مكتوباً بالحروف كما يُكتب في الشيك والفاتورة والعقد:
tafqit(1520, { currency: 'SAR' });
// ألف وخمسمائة وعشرون ريال سعودي فقط لا غير
tafqit('1.15', { currency: 'SAR' });
// واحد ريال سعودي وخمس عشرة هللة فقط لا غير
tafqit('1.5', { currency: 'KWD' });
// واحد دينار كويتي وخمسمائة فلس فقط لا غير
tafqit(234000, { currency: 'SAR' });
// مائتان وأربعة وثلاثون ألفًا ريال سعودي فقط لا غيرانظر إلى المثال الثالث. مبلغ 1.5 بالدينار الكويتي هو خمسمائة فلس، لا خمسون. الدينار الكويتي والبحريني والعماني والأردني والعراقي والتونسي والليبي تنقسم إلى ألف وحدة صغرى لا مائة. أي تنفيذ يضرب الكسر في 100 بشكل ثابت يخطئ في سبع عملات عربية دفعةً واحدة، ويخطئ بمقدار عشرة أضعاف.
المتطلبات المسبقة
- Node.js إصدار 20 أو أحدث
- إلمام بـ TypeScript على مستوى الدوال والأنواع
- معرفة أساسية بقواعد العدد والمعدود — سنشرحها، لكن الإلمام المسبق يسرّع الفهم
- محرّر يدعم الكتابة من اليمين إلى اليسار داخل السلاسل النصية
الخطوة الأولى: القواعد الخمس التي يجب ترميزها
قبل أي سطر برمجي، هذه هي القواعد التي تفصل بين تنفيذ صحيح وآخر «شبه صحيح». والشبه صحيح على شيك أسوأ من العديم، لأنه يمرّ من المراجعة.
١. المخالفة في الأعداد من ٣ إلى ١٠. العدد يأخذ العلامة المعاكسة لجنس المعدود: «ثلاثة رجال» لأن رجال مذكّر فالعدد يحمل التاء، و«ثلاث نساء» لأن نساء مؤنّث فالعدد يتجرّد منها. هذه أكثر قاعدة يخطئ فيها المطوّرون، لأن الحدس يقول بالمطابقة.
٢. المطابقة في ١ و٢ و١١ و١٢. هنا ينقلب الحكم: «ريال واحد»، «امرأة واحدة»، «أحد عشر»، «إحدى عشرة».
٣. الانقسام في ١٣ إلى ١٩. الجزء الأول يخالف، وكلمة عشر تطابق: «ثلاثة عشر» للمذكّر و«ثلاث عشرة» للمؤنّث. لاحظ أن هذا معاكس لحكم العشرة المفردة، حيث يقال «عشرة رجال» و«عشر نساء». لذلك لا يمكن للجدولين أن يكونا جدولاً واحداً — وهذا بالضبط ما تفعله أغلب المكتبات الجاهزة، فتخطئ في كل عدد بين ١٣ و١٩.
٤. التمييز يتبع آخر جزء لا حجم العدد. يقال «مائة ألف» لأن العدد ينتهي بمائة، و«مائتان وأربعة وثلاثون ألفًا» لأنه ينتهي بأربعة وثلاثين. قراءة حجم المجموعة وحدها تُنتج «مائة ألفًا»، وهي خطأ.
٥. العملة تحمل جنسها. الريال مذكّر والليرة مؤنّثة، فيقال «ثلاثة ريالات» و«ثلاث ليرات». والهللة مؤنّثة رغم أن الريال مذكّر، فيقال «خمس وسبعون هللة» لا «خمسة وسبعون هللة». عملة واحدة بجنسين داخل المبلغ الواحد.
الخطوة الثانية: تجهيز المشروع وبوابة تطبيع الأرقام
mkdir tafqit && cd tafqit
npm init -y
npm install --save-dev typescript @types/node
npx tsc --init --target es2020 --module nodenext --strict
mkdir srcأول ما يدخل الدالة ليس رقماً بالضرورة. المبالغ تُلصق من جداول ومن أنظمة محاسبية، وتصل أحياناً بأرقام هندية ٠١٢٣٤٥٦٧٨٩ أو فارسية، وبفواصل آلاف عربية. التطبيع قبل التحليل يوفّر فئة كاملة من الأخطاء:
// src/normalize.ts
/** Normalise Arabic-Indic and Eastern digits so pasted amounts just work. */
export function normalizeDigits(s: string): string {
return s.replace(/[٠-٩۰-۹]/g, (d) => {
const code = d.charCodeAt(0);
const base = code >= 0x06f0 ? 0x06f0 : 0x0660;
return String(code - base);
});
}النطاقان مختلفان: الأرقام العربية الهندية تبدأ عند U+0660 والأرقام الفارسية الممتدة عند U+06F0. معالجة أحدهما فقط تترك نصف حالات اللصق تفشل بصمت.
الخطوة الثالثة: من ١ إلى ٩٩
هنا تعيش المخالفة والانقسام. لاحظ أن gender في كل ما يلي يعني جنس المعدود، لا جنس كلمة العدد:
// src/numbers.ts
/** Gender of the noun being counted — not of the number word. */
export type Gender = 'm' | 'f';
/** Both spellings are in live use; Gulf official documents tend to مائة. */
export type HundredsForm = 'مائة' | 'مئة';
/** 1–9 as they appear when counting a masculine noun (3–9 carry the ة). */
const ONES_M = ['', 'واحد', 'اثنان', 'ثلاثة', 'أربعة', 'خمسة', 'ستة', 'سبعة', 'ثمانية', 'تسعة'];
/** 1–9 as they appear when counting a feminine noun (3–9 are bare). */
const ONES_F = ['', 'واحدة', 'اثنتان', 'ثلاث', 'أربع', 'خمس', 'ست', 'سبع', 'ثماني', 'تسع'];
/** The unit half of 11–19. 11 and 12 are irregular and agree rather than reverse. */
const TEEN_UNIT_M = ['عشرة', 'أحد', 'اثنا', 'ثلاثة', 'أربعة', 'خمسة', 'ستة', 'سبعة', 'ثمانية', 'تسعة'];
const TEEN_UNIT_F = ['عشر', 'إحدى', 'اثنتا', 'ثلاث', 'أربع', 'خمس', 'ست', 'سبع', 'ثماني', 'تسع'];
const TENS = ['', '', 'عشرون', 'ثلاثون', 'أربعون', 'خمسون', 'ستون', 'سبعون', 'ثمانون', 'تسعون'];
export const ZERO = 'صفر';
/** 1–99, given the gender of the noun being counted. */
function underHundred(n: number, gender: Gender): string {
const ones = gender === 'm' ? ONES_M : ONES_F;
if (n < 10) return ones[n];
if (n < 20) {
const unit = (gender === 'm' ? TEEN_UNIT_M : TEEN_UNIT_F)[n - 10];
if (n === 10) return unit;
// The عشر half agrees with the noun — the inverse of standalone ten.
return `${unit} ${gender === 'm' ? 'عشر' : 'عشرة'}`;
}
const ten = TENS[Math.floor(n / 10)];
const unit = n % 10;
// Arabic puts the unit before the ten: خمسة وعشرون, not عشرون وخمسة.
return unit ? `${ones[unit]} و${ten}` : ten;
}الفهرس صفر في TEEN_UNIT_M يحمل «عشرة» وفي TEEN_UNIT_F يحمل «عشر». هذا هو موضع الانقسام: العشرة المفردة تخالف، بينما «عشر» داخل المركّب تطابق. اختصار الجدولين في جدول واحد هو الخطأ الأشيع في مكتبات npm، وهو خطأ لا يظهر إلا في نطاق ضيّق من الأعداد.
الخطوة الرابعة: المئات، ولماذا مائة مؤنّثة
/**
* Hundreds 100–900.
*
* 300–900 are conventionally written as one word (ثلاثمائة), and the unit uses
* the feminine-noun column because مائة is feminine. 800 contracts to ثمان +
* مائة rather than taking the standalone ثماني form with a yaa.
*/
function hundredsToWords(h: number, form: HundredsForm, construct = false): string {
if (h === 0) return '';
if (h === 1) return form;
if (h === 2) {
// The dual loses its nūn in the construct state: مائتا ألف, not مائتان ألف.
const base = form === 'مائة' ? 'مائت' : 'مئت';
return base + (construct ? 'ا' : 'ان');
}
// 800 contracts — ثمانمائة, not ثمانيمائة.
return (h === 8 ? 'ثمان' : ONES_F[h]) + form;
}
/** 0–999. Returns '' for 0 so callers can drop empty groups. */
export function tripletToWords(
n: number,
gender: Gender,
form: HundredsForm = 'مائة',
construct = false,
): string {
if (n === 0) return '';
const parts: string[] = [];
// The construct form only applies when the hundreds are the final component,
// i.e. nothing follows them inside the group.
const h = hundredsToWords(Math.floor(n / 100), form, construct && n % 100 === 0);
if (h) parts.push(h);
const rest = underHundred(n % 100, gender);
if (rest) parts.push(rest);
return parts.join(' و');
}استعمال ONES_F في المئات ليس سهواً. كلمة مائة مؤنّثة، فالعدد الذي يعدّها يخالفها إلى التذكير — أي يتجرّد من التاء. لذلك «ثلاثمائة» لا «ثلاثةمائة». والوسيط construct يعالج المثنّى في الإضافة: «مائتا ألف» بحذف النون، مقابل «مائتان» حين تقف وحدها.
الخطوة الخامسة: كلمات المقياس، وقاعدة آخر جزء
هنا القاعدة الرابعة. صورة كلمة المقياس (ألف، مليون) لا يحكمها حجم المجموعة بل آخر جزء منها:
/** Scale words, in singular / dual / plural / accusative-singular forms. */
const SCALES: { one: string; two: string; plural: string; accusative: string }[] = [
{ one: '', two: '', plural: '', accusative: '' }, // units — no scale word
{ one: 'ألف', two: 'ألفان', plural: 'آلاف', accusative: 'ألفًا' },
{ one: 'مليون', two: 'مليونان', plural: 'ملايين', accusative: 'مليونًا' },
{ one: 'مليار', two: 'ملياران', plural: 'مليارات', accusative: 'مليارًا' },
{ one: 'تريليون', two: 'تريليونان', plural: 'تريليونات', accusative: 'تريليونًا' },
];
/** Largest value this can express, one short of the next unnamed scale. */
export const MAX_VALUE = 1_000 ** SCALES.length - 1;
function scaleGroup(group: number, level: number, form: HundredsForm): string {
const s = SCALES[level];
if (group === 1) return s.one;
if (group === 2) return s.two;
const tail = group % 100;
// The scale word is a masculine noun, so its multiplier follows the masculine
// column whatever the final counted noun happens to be.
const count = tripletToWords(group, 'm', form, tail === 0);
if (tail === 0) return `${count} ${s.one}`; // مائة ألف
if (tail <= 2 && group < 100) return `${count} ${s.one}`;
if (tail <= 10) return `${count} ${s.plural}`; // ثلاثة آلاف
return `${count} ${s.accusative}`; // أحد عشر ألفًا
}جرّب القيم الثلاث بنفسك: 100000 تُخرج «مائة ألف»، و3000 تُخرج «ثلاثة آلاف»، و11000 تُخرج «أحد عشر ألفًا». ثلاث صور مختلفة لكلمة واحدة، تحدّدها آخر خانتين فقط.
والملاحظة الثانية في هذا المقطع أهمّ ممّا تبدو: tripletToWords(group, 'm', ...) تمرّر المذكّر دائماً. لماذا؟ لأن المعدود هنا ليس الريال ولا الليرة، بل كلمة ألف نفسها وهي مذكّرة. يقال «ثلاثة آلاف امرأة» لا «ثلاث آلاف امرأة». تمرير جنس العملة إلى هذه الطبقة خطأ خفيّ لا يظهر إلا مع العملات المؤنّثة.
الخطوة السادسة: تجميع العدد الصحيح
export function integerToWords(
value: number,
gender: Gender = 'm',
form: HundredsForm = 'مائة',
): string {
if (!Number.isFinite(value)) throw new RangeError('Not a finite number');
const negative = value < 0;
let n = Math.abs(Math.trunc(value));
if (n > MAX_VALUE) throw new RangeError(`Number too large for tafqit: max ${MAX_VALUE}`);
if (n === 0) return ZERO;
// Split into groups of three, least significant first. MAX_VALUE is below
// Number.MAX_SAFE_INTEGER, so ordinary arithmetic stays exact and there is
// no need for BigInt.
const groups: number[] = [];
while (n > 0) {
groups.push(n % 1000);
n = Math.floor(n / 1000);
}
const parts: string[] = [];
for (let level = groups.length - 1; level >= 0; level -= 1) {
const g = groups[level];
if (g === 0) continue;
parts.push(level === 0 ? tripletToWords(g, gender, form) : scaleGroup(g, level, form));
}
const words = parts.join(' و');
return negative ? `سالب ${words}` : words;
}المجموعة الأخيرة وحدها هي التي تتلقّى gender، لأنها وحدها التي تعدّ المعدود الحقيقي؛ وما فوقها يعدّ كلمة مقياس.
الخطوة السابعة: العملات، والوحدة الصغرى التي ليست مائة دائماً
// src/currencies.ts
import type { Gender } from './numbers';
export type Currency = {
code: string;
/** Singular major unit, e.g. ريال سعودي. */
name: string;
gender: Gender;
/** Singular minor unit, e.g. هللة. Absent for currencies with no minor unit. */
minorName?: string;
minorGender?: Gender;
/** Minor units per major. */
minor: number;
};
export const CURRENCIES: Record<string, Currency> = {
SAR: { code: 'SAR', name: 'ريال سعودي', gender: 'm', minorName: 'هللة', minorGender: 'f', minor: 100 },
AED: { code: 'AED', name: 'درهم إماراتي', gender: 'm', minorName: 'فلس', minorGender: 'm', minor: 100 },
KWD: { code: 'KWD', name: 'دينار كويتي', gender: 'm', minorName: 'فلس', minorGender: 'm', minor: 1000 },
BHD: { code: 'BHD', name: 'دينار بحريني', gender: 'm', minorName: 'فلس', minorGender: 'm', minor: 1000 },
OMR: { code: 'OMR', name: 'ريال عماني', gender: 'm', minorName: 'بيسة', minorGender: 'f', minor: 1000 },
JOD: { code: 'JOD', name: 'دينار أردني', gender: 'm', minorName: 'فلس', minorGender: 'm', minor: 1000 },
TND: { code: 'TND', name: 'دينار تونسي', gender: 'm', minorName: 'مليم', minorGender: 'm', minor: 1000 },
SYP: { code: 'SYP', name: 'ليرة سورية', gender: 'f', minorName: 'قرش', minorGender: 'm', minor: 100 },
EGP: { code: 'EGP', name: 'جنيه مصري', gender: 'm', minorName: 'قرش', minorGender: 'm', minor: 100 },
};
export function getCurrency(code: string): Currency | undefined {
return CURRENCIES[code.toUpperCase()];
}حقلان فقط هما ما تحتاجه القواعد: جنس اسم الوحدة، لأنه يقود قاعدة المخالفة، وعدد الوحدات الصغرى. الأول يفصل بين «ثلاثة» و«ثلاث»، والثاني بين خمسمائة فلس وخمسين.
وانتبه إلى minorGender في الريال السعودي: الهللة مؤنّثة بينما الريال مذكّر. تمرير جنس الوحدة الكبرى إلى الوحدة الصغرى يُنتج «خمسة وسبعون هللة» — خطأ يظهر في أكثر من نصف الفواتير، لأن الكسور أشيع من الأعداد المدوّرة.
الخطوة الثامنة: فخّ الفاصلة العشرية
هذا هو الموضع الذي يفقد فيه المال قيمته فعلياً:
Math.round(parseFloat('1.005') * 100); // 100 — not 101
Math.round(parseFloat('8.165') * 100); // 816 — not 817
Math.round(parseFloat('1.15') * 100); // 115 — this one survivesالقيمتان 1.005 و8.165 لا تُمثَّلان تماماً في العدد ثنائي الدقة: حاصل ضرب الأولى في مائة هو 100.49999999999999 وحاصل ضرب الثانية 816.4999999999999. كلتاهما تقع تحت النصف بفارق لا يُرى، فيقرّبها Math.round إلى أسفل بينما التقريب العشري يرفعها. النتيجة هللة مفقودة على مبالغ عادية تماماً.
انتبه إلى السطر الثالث تحديداً: القيمة 1.15 تنجو. حاصل ضربها هو 114.99999999999999 ويقرّبه Math.round إلى 115 صحيحة. ولهذا يمرّ هذا العيب من المراجعة — أوّل قيمة يجرّبها المطوّر عادةً هي من النوع الناجي، ولا يظهر الخطأ إلا على فاتورة بعينها بعد أشهر.
الحلّ هو عدم المرور بالعدد العشري مرّتين: اقرأ الخانات من النصّ مباشرة.
// src/split.ts
/**
* Split a decimal amount into major and minor units without going through
* floating point twice. Reading the digits from the string keeps the minor
* part exact.
*/
export function splitAmount(input: string, minorPer: number): { major: number; minor: number } {
const [wholeRaw, fracRaw = ''] = input.split('.');
const digits = String(minorPer).length - 1; // 100 -> 2, 1000 -> 3
const padded = (fracRaw + '0'.repeat(digits)).slice(0, digits);
let minor = padded === '' ? 0 : Number(padded);
if ((wholeRaw || '').replace(/^0+/, '').length > 16) {
throw new RangeError('Number too large for tafqit');
}
let major = Number(wholeRaw || '0');
// A fraction longer than the currency's precision rounds into the minor unit,
// and can carry all the way into the major one: 1.999 SAR is 2 riyals.
const next = fracRaw[digits];
if (next && Number(next) >= 5) {
minor += 1;
if (minor >= minorPer) {
minor = 0;
major += 1;
}
}
return { major, minor };
}الحشو بالأصفار ثم القصّ هو ما يجعل 1.5 تعطي 50 هللة مع الريال و500 فلس مع الدينار الكويتي، من الشيفرة نفسها ودون شرط خاص لكل عملة. والحمل من الوحدة الصغرى إلى الكبرى ضروري: 1.999 بالريال هي ريالان، لا ريال ومائة هللة.
الخطوة التاسعة: طبقة المال و«فقط لا غير»
// src/index.ts
import { integerToWords, MAX_VALUE, ZERO, type Gender, type HundredsForm } from './numbers';
import { getCurrency, type Currency } from './currencies';
import { normalizeDigits } from './normalize';
import { splitAmount } from './split';
const CLOSING = 'فقط لا غير';
export type TafqitOptions = {
/** Gender of the counted noun. Ignored when `currency` is set. */
gender?: Gender;
/** مائة (default, usual in Gulf official documents) or مئة. */
hundreds?: HundredsForm;
/** ISO code, e.g. 'SAR'. Adds the unit names and the closing formula. */
currency?: string;
/** Override the closing formula. Defaults to on for currency amounts. */
closing?: boolean | string;
};
function unitPhrase(count: number, words: string, name: string): string {
// The convention in cheques and invoices is to append the unit name
// uninflected — «ألف وخمسمائة وعشرون ريال سعودي» — rather than decline it.
return count === 0 ? '' : `${words} ${name}`;
}
export function tafqit(value: number | string, options: TafqitOptions = {}): string {
const { gender = 'm', hundreds = 'مائة' } = options;
const currency: Currency | undefined = options.currency ? getCurrency(options.currency) : undefined;
if (options.currency && !currency) {
throw new Error(`Unknown currency: ${options.currency}`);
}
const raw = normalizeDigits(String(value).trim()).replace(/[,\s٬]/g, '');
if (!/^-?\d*(\.\d*)?$/.test(raw) || raw === '' || raw === '-') {
throw new Error(`Not a number: ${value}`);
}
const negative = raw.startsWith('-');
const body = negative ? raw.slice(1) : raw;
const closingText =
typeof options.closing === 'string'
? options.closing
: (options.closing ?? Boolean(currency))
? CLOSING
: '';
if (!currency) {
const [whole] = body.split('.');
const n = Number(whole || '0');
if (n > MAX_VALUE) throw new RangeError('Number too large for tafqit');
const words = integerToWords(negative ? -n : n, gender, hundreds);
return closingText ? `${words} ${closingText}` : words;
}
const { major, minor } = splitAmount(body, currency.minor);
if (major > MAX_VALUE) throw new RangeError('Number too large for tafqit');
const parts: string[] = [];
if (major > 0 || minor === 0) {
parts.push(unitPhrase(major, integerToWords(major, currency.gender, hundreds), currency.name));
}
if (minor > 0 && currency.minorName) {
const minorWords = integerToWords(minor, currency.minorGender ?? 'm', hundreds);
parts.push(unitPhrase(minor, minorWords, currency.minorName));
}
let out = parts.filter(Boolean).join(' و');
if (negative) out = `سالب ${out}`;
return closingText ? `${out} ${closingText}` : out;
}
export { ZERO, MAX_VALUE };لاحظ unitPhrase: اسم العملة يُلحق مفرداً غير معرَب لا مجموعاً، فتخرج «ألف وخمسمائة وعشرون ريال سعودي» و«ثلاث ليرة سورية». هذا هو العرف المتّبع في الشيكات والفواتير، وهو مقصود لا سهو: الجمع والإعراب يفتحان باب اجتهاد لا يريده مستند مالي. أمّا جنس العملة فما زال يعمل — «ثلاث» لا «ثلاثة» لأن الليرة مؤنّثة.
وعبارة «فقط لا غير» ليست زخرفة. سبب كتابة المبلغ بالحروف أصلاً هو منع تغييره بعد التوقيع، والعبارة الختامية تُغلق الجملة حتى لا يُلحق بها شيء. لذلك يجعلها هذا التنفيذ افتراضية حين تُذكر عملة، وقابلة للإطفاء حين يكون التفقيط لعدد مجرّد في نصّ تعليمي.
الخطوة العاشرة: الوصل بخط الفوترة
الخطأ المعماري الذي يُفسد كل ما سبق هو تمرير عدد عشري من قاعدة البيانات إلى الدالة. احتفظ بالمبالغ كوحدات صغرى صحيحة داخل النظام كلّه، وحوّلها إلى نصّ عشري مرّة واحدة فقط عند الحافة:
// src/invoice.ts
import { tafqit } from './index';
type InvoiceLine = { description: string; amountMinor: number };
/**
* Amounts live as integer minor units everywhere inside the system, and are
* turned into a decimal string exactly once, at the edge, for tafqit. That
* single boundary is what keeps the words and the figure in agreement.
*/
export function amountInWords(totalMinor: number, currency: string, minorPer = 100): string {
const major = Math.trunc(totalMinor / minorPer);
const minor = Math.abs(totalMinor % minorPer);
const decimals = String(minorPer).length - 1;
const asString = `${major}.${String(minor).padStart(decimals, '0')}`;
return tafqit(asString, { currency });
}
export function renderTotals(lines: InvoiceLine[], currency = 'SAR', minorPer = 100) {
const totalMinor = lines.reduce((sum, l) => sum + l.amountMinor, 0);
const decimals = String(minorPer).length - 1;
return {
figure: (totalMinor / minorPer).toFixed(decimals),
words: amountInWords(totalMinor, currency, minorPer),
};
}هذه الحدود الواحدة هي ما يضمن أن الرقم المطبوع والحروف المطبوعة يعبّران عن القيمة نفسها. إذا حسب النظام الرقم من مسار والحروف من مسار آخر، فأنت تكتب على المستند مبلغين قد يختلفان — وهي الحالة التي كُتبت المادة الخامسة لحسمها أصلاً.
اختبار التنفيذ
الاختبارات هنا ليست إجراءً شكلياً. كل حالة فيها تمثّل قاعدة نحوية تسقط بصمت:
// src/tafqit.test.ts
import test from 'node:test';
import assert from 'node:assert/strict';
import { tafqit } from './index';
import { integerToWords } from './numbers';
test('3–10 reverse the gender of the counted noun', () => {
assert.equal(integerToWords(3, 'm'), 'ثلاثة');
assert.equal(integerToWords(3, 'f'), 'ثلاث');
});
test('standalone ten is the inverse of the عشر inside a teen', () => {
assert.equal(integerToWords(10, 'm'), 'عشرة');
assert.equal(integerToWords(10, 'f'), 'عشر');
assert.equal(integerToWords(13, 'm'), 'ثلاثة عشر');
assert.equal(integerToWords(13, 'f'), 'ثلاث عشرة');
});
test('the scale word form follows the last component, not the size', () => {
assert.equal(integerToWords(100000), 'مائة ألف');
assert.equal(integerToWords(3000), 'ثلاثة آلاف');
assert.equal(integerToWords(11000), 'أحد عشر ألفًا');
assert.equal(integerToWords(234000), 'مائتان وأربعة وثلاثون ألفًا');
});
test('the minor unit is not always 100', () => {
assert.match(tafqit('1.5', { currency: 'SAR' }), /خمسون هللة/);
assert.match(tafqit('1.5', { currency: 'KWD' }), /خمسمائة فلس/);
});
test('the decimal does not swallow a halala', () => {
assert.match(tafqit('1.15', { currency: 'SAR' }), /خمس عشرة هللة/);
assert.match(tafqit('1.005', { currency: 'SAR' }), /هللة/);
assert.match(tafqit('8.165', { currency: 'SAR' }), /سبع عشرة هللة/);
});
test('the minor unit carries into the major one', () => {
assert.equal(tafqit('1.999', { currency: 'SAR' }), 'اثنان ريال سعودي فقط لا غير');
});
test('the currency carries its own gender', () => {
assert.equal(tafqit(3, { currency: 'SYP' }), 'ثلاث ليرة سورية فقط لا غير');
});
test('Arabic-Indic digits are accepted as pasted', () => {
assert.equal(tafqit('١٢٣'), tafqit('123'));
});شغّلها بـ node --test بعد الترجمة. الحالتان 1.005 و8.165 هما بالذات ما يفضح مسار parseFloat، بينما تنجو منه القيم التي يجرّبها المطوّر عادةً. وهذه المجموعة، لا الدالة نفسها، هي الجزء الذي يستحقّ النسخ إلى مشروعك.
معالجة المشكلات الشائعة
النصّ يظهر مقلوباً أو مقطّع الحروف في ملف PDF. المشكلة ليست في التفقيط بل في محرّك العرض: أغلب مولّدات PDF لا تطبّق خوارزمية الاتجاه الثنائي ولا تشكيل الحروف العربية المتّصلة. تحقّق من دعم المكتبة للنصّ العربي قبل اتّهام الدالة.
«مائة» أم «مئة»؟ كلتاهما صحيحة. المستندات الرسمية الخليجية تميل إلى «مائة» والكتابة الحديثة إلى «مئة». الوسيط hundreds يتيح المطابقة مع بقيّة مستنداتك، والأهمّ هو الثبات على صورة واحدة داخل النظام الواحد.
مبالغ تتجاوز الحدّ. MAX_VALUE يقف عند حدود التريليونات لأن ما بعدها لا اسم متّفقاً عليه. الرمي باستثناء أفضل من إخراج نصّ مخترع على مستند مالي.
هل تطلب هيئة الزكاة والضريبة والجمارك المبلغ بالحروف على الفاتورة؟ لا. حقول الفاتورة الضريبية المطلوبة لا تتضمّن التفقيط؛ هو التزام مصرفي وتعاقدي لا ضريبي. تفاصيل ما تطلبه الهيئة فعلاً في دليل الفاتورة الضريبية المبسّطة والمادة 53.
الخطوة التالية
- جرّب النتائج مباشرة في أداة تفقيط الأرقام والمبالغ وقارنها بمخرجات تنفيذك.
- لدمج التفقيط داخل نظام فوترة أو محاسبة قائم دون صيانة الخوارزمية اللغوية بنفسك، انظر الواجهة البرمجية للتفقيط.
- إن كان المستند الذي تكتب عليه المبلغ فاتورة إلكترونية، فالخطوة الطبيعية بعد هذه هي تكامل الفوترة الإلكترونية المرحلة الثانية في TypeScript.
الخلاصة
التفقيط يبدو مسألة تنسيق ويُعامَل غالباً على هذا الأساس: سطر واحد في نهاية القالب، يكتبه أحدهم على عجل ولا يراجعه أحد. لكن المادة الخامسة من نظام الأوراق التجارية تضع هذا السطر في موضع النصّ الحاكم، وتجعل الخطأ فيه خطأً في المبلغ لا في المظهر.
والقواعد التي بنيناها ليست كثيرة: المخالفة في ٣–١٠، وانقسام العشرات المركّبة، وتبعية التمييز لآخر جزء، وجنس اسم العملة، وعدد الوحدات الصغرى — ومعها حدّ عشري واحد لا يُعبر مرّتين. لكن كل واحدة منها تسقط بصمت وتُنتج نصّاً عربياً سليم المظهر خاطئ الحكم. ولهذا فإن أهمّ ما في هذا الدرس ليس الدالة، بل مجموعة الاختبارات التي تمنعها من الانحدار.
تكتب هذا داخل نظام فوترة أو رواتب قائم؟ إن كان مستند واحد على الأقل يخرج من نظامك بمبلغ مكتوب بالحروف، فيستحقّ الأمر مراجعة نصف ساعة على مخرجات التفقيط ومنطق التقريب لديك قبل أن يكتشفه بنك. راسلنا بمثال واحد على المستند، ونقول لك أين تقف الأخطاء الخمسة أعلاه من نظامك.