L'article 5 de la loi saoudienne sur les effets de commerce (décret royal M/37) énonce une règle qui suffit à reclasser tout ce type de bug :
Si le montant de l'effet est écrit à la fois en lettres et en chiffres, c'est le montant en lettres qui fait foi en cas de divergence ; et si le montant est écrit plusieurs fois en lettres ou en chiffres, c'est le montant le plus faible qui fait foi en cas de divergence.
Relisez cette phrase en développeur. Le champ que produit votre fonction de tafqit n'est ni un ornement sur le document ni une aide à la lecture : c'est le texte qui fait foi. Si votre système imprime «ثلاث آلاف ريال» au lieu de «ثلاثة آلاف ريال», vous n'avez pas produit un texte laid, vous avez produit un document susceptible d'être rejeté. Et si une décimale perd discrètement un halala entre la base de données et la page, c'est le montant le plus faible qui s'applique.
Ce tutoriel construit un moteur de tafqit complet en TypeScript — de l'accord du nombre et du nom jusqu'à la couche devise — et détaille les cinq endroits où la plupart des implémentations publiées échouent.
Ce que vous allez construire
Une seule fonction, tafqit(value, options), qui accepte un nombre ou une chaîne et rend le montant en toutes lettres tel qu'il s'écrit sur un chèque, une facture ou un contrat :
tafqit(1520, { currency: 'SAR' });
// ألف وخمسمائة وعشرون ريال سعودي فقط لا غير
tafqit('1.15', { currency: 'SAR' });
// واحد ريال سعودي وخمس عشرة هللة فقط لا غير
tafqit('1.5', { currency: 'KWD' });
// واحد دينار كويتي وخمسمائة فلس فقط لا غير
tafqit(234000, { currency: 'SAR' });
// مائتان وأربعة وثلاثون ألفًا ريال سعودي فقط لا غيرRegardez le troisième exemple. 1.5 dinar koweïtien vaut cinq cents fils, et non cinquante. Les dinars koweïtien, bahreïni, jordanien, irakien, tunisien et libyen, ainsi que le rial omanais, se divisent en mille unités mineures et non en cent. Toute implémentation qui multiplie la partie décimale par 100 en dur se trompe sur sept devises arabes d'un coup, et se trompe d'un facteur dix.
Prérequis
- Node.js 20 ou plus récent
- TypeScript au niveau des fonctions et des types
- Des notions d'accord du nombre et du nom en arabe — nous les expliquons, mais une familiarité préalable aide
- Un éditeur qui gère le texte de droite à gauche dans les chaînes de caractères
Étape 1 : les cinq règles qu'une implémentation doit encoder
Avant toute ligne de code, voici les règles qui séparent une implémentation correcte d'une implémentation presque correcte. Presque correct sur un chèque est pire qu'inutile, parce que cela passe la relecture.
1. L'inversion (المخالفة) de 3 à 10. Le nombre prend la marque de genre opposée à celle du nom qu'il compte : «ثلاثة رجال» parce que رجال est masculin, donc le nombre porte le ة, et «ثلاث نساء» parce que نساء est féminin, donc le nombre en est dépourvu. C'est la règle que les développeurs manquent le plus souvent, car l'intuition dit l'accord.
2. L'accord pour 1, 2, 11 et 12. Ici la règle s'inverse : «ريال واحد», «امرأة واحدة», «أحد عشر», «إحدى عشرة».
3. La scission de 13 à 19. La première moitié s'inverse, tandis que عشر s'accorde : «ثلاثة عشر» au masculin, «ثلاث عشرة» au féminin. Notez que c'est l'inverse de dix isolé, qui donne «عشرة رجال» et «عشر نساء». Les deux tables ne peuvent donc pas n'en faire qu'une — et c'est exactement ce que font la plupart des bibliothèques, d'où leur erreur sur chaque nombre entre 13 et 19.
4. La forme du nom compté suit le dernier composant, non l'ordre de grandeur. On dit «مائة ألف» parce que le nombre finit sur une centaine ronde, et «مائتان وأربعة وثلاثون ألفًا» parce qu'il finit sur trente-quatre. Ne lire que la taille du groupe produit «مائة ألفًا», qui est faux.
5. La devise porte son propre genre. ريال est masculin et ليرة est féminin, d'où «ثلاثة ريالات» face à «ثلاث ليرات». Et هللة est féminin alors même que ريال est masculin : on écrit «خمس وسبعون هللة», jamais «خمسة وسبعون هللة». Une devise, deux genres, à l'intérieur d'un même montant.
Étape 2 : mise en place et normalisation des chiffres
mkdir tafqit && cd tafqit
npm init -y
npm install --save-dev typescript @types/node
npx tsc --init --target es2020 --module nodenext --strict
mkdir srcCe qui arrive à la fonction n'est pas nécessairement un nombre. Les montants sont collés depuis des tableurs et des systèmes comptables, et arrivent parfois en chiffres arabes orientaux ٠١٢٣٤٥٦٧٨٩ ou persans, avec des séparateurs de milliers arabes. Normaliser avant d'analyser élimine toute une classe d'erreurs :
// 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);
});
}Les deux plages sont distinctes : les chiffres arabo-indiens commencent à U+0660 et les chiffres arabo-indiens étendus (persans) à U+06F0. N'en traiter qu'une laisse la moitié des collages échouer silencieusement.
Étape 3 : de 1 à 99
C'est ici que vivent l'inversion et la scission. Notez que gender signifie partout le genre du nom compté, jamais celui du mot-nombre :
// 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;
}L'indice zéro de TEEN_UNIT_M porte «عشرة» tandis que celui de TEEN_UNIT_F porte «عشر». C'est là la scission : dix isolé s'inverse, alors que le عشر d'un composé s'accorde. Fusionner les deux tables est le bug le plus répandu dans les paquets npm, et c'est un bug qui n'apparaît que sur une bande étroite de nombres.
Étape 4 : les centaines, et pourquoi مائة est féminin
/**
* 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(' و');
}L'usage de ONES_F pour les centaines n'est pas une distraction. Le mot مائة est lui-même féminin : le nombre qui le compte s'inverse donc vers le masculin, c'est-à-dire perd le ة. D'où «ثلاثمائة» et non «ثلاثةمائة». Le paramètre construct traite le duel à l'état construit : «مائتا ألف» avec chute du nūn, contre «مائتان» en position isolée.
Étape 5 : les mots d'échelle et la règle du dernier composant
Voici la règle quatre. La forme que prend un mot d'échelle (ألف, مليون) n'est pas dictée par la taille du groupe mais par son dernier composant :
/** 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}`; // أحد عشر ألفًا
}Essayez les trois valeurs : 100000 donne «مائة ألف», 3000 donne «ثلاثة آلاف», et 11000 donne «أحد عشر ألفًا». Trois formes d'un même mot, décidées par les deux derniers chiffres seulement.
Le second point de cet extrait compte plus qu'il n'y paraît : tripletToWords(group, 'm', ...) passe toujours le masculin. Pourquoi ? Parce que le nom compté ici n'est ni le rial ni la livre, c'est le mot ألف lui-même, qui est masculin. On dit «ثلاثة آلاف امرأة» et non «ثلاث آلاف امرأة». Faire descendre le genre de la devise dans cette couche est un bug discret qui ne se manifeste qu'avec les devises féminines.
Étape 6 : assembler le nombre entier
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;
}Seul le dernier groupe reçoit gender, car lui seul compte le nom réel ; tout ce qui est au-dessus compte un mot d'échelle.
Étape 7 : les devises, et l'unité mineure qui ne vaut pas toujours 100
// 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()];
}La grammaire n'a besoin que de deux choses : le genre du nom de l'unité, qui pilote la règle d'inversion, et le nombre d'unités mineures par unité majeure. Le premier sépare «ثلاثة» de «ثلاث» ; le second sépare cinq cents fils de cinquante.
Regardez en particulier minorGender sur le rial saoudien : هللة est féminin alors que ريال est masculin. Faire descendre le genre de l'unité majeure vers l'unité mineure produit «خمسة وسبعون هللة» — une erreur visible sur plus de la moitié des factures, les montants fractionnaires étant plus fréquents que les montants ronds.
Étape 8 : le piège décimal
C'est ici que l'argent disparaît réellement :
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 survives1.005 et 8.165 ne sont pas représentables exactement en virgule flottante binaire : multipliés par cent, ils donnent 100.49999999999999 et 816.4999999999999. Tous deux passent juste sous la moitié, d'une marge invisible, si bien que Math.round arrondit vers le bas là où l'arrondi décimal arrondirait vers le haut. Résultat : un halala perdu sur des montants parfaitement ordinaires.
Prêtez attention à la troisième ligne en particulier : 1.15 survit. Son produit vaut 114.99999999999999, que Math.round remonte correctement à 115. C'est pour cela que ce défaut passe la relecture — la première valeur qu'un développeur essaie fait généralement partie des survivantes, et le bug ne se révèle que sur une facture précise, des mois plus tard.
Le correctif consiste à ne pas traverser deux fois la virgule flottante : lire les chiffres directement dans la chaîne.
// 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 };
}Compléter par des zéros puis tronquer est ce qui fait que 1.5 donne 50 halalas pour le rial et 500 fils pour le dinar koweïtien, avec le même code et sans cas particulier par devise. La retenue de l'unité mineure vers l'unité majeure est nécessaire elle aussi : 1.999 SAR vaut deux rials, et non un rial et cent halalas.
Étape 9 : la couche monétaire et «فقط لا غير»
// 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 };Notez unitPhrase : le nom de la devise est ajouté au singulier et non décliné, sans mise au pluriel, ce qui donne «ألف وخمسمائة وعشرون ريال سعودي» et «ثلاث ليرة سورية». C'est la convention des chèques et des factures, et c'est délibéré et non un raccourci : mettre au pluriel et décliner ouvre une marge d'interprétation dont un document financier ne veut pas. Le genre de la devise continue d'opérer — «ثلاث» et non «ثلاثة», puisque ليرة est féminin.
Et «فقط لا غير» n'est pas un ornement. Si le montant est écrit en lettres, c'est d'abord pour empêcher qu'un chiffre soit modifié après signature, et la formule de clôture scelle la phrase pour qu'on ne puisse rien y ajouter. D'où son activation par défaut dès qu'une devise est fournie, et la possibilité de la désactiver lorsqu'on écrit un nombre nu dans un support pédagogique.
Étape 10 : le branchement dans une chaîne de facturation
L'erreur d'architecture qui ruine tout ce qui précède consiste à passer un nombre à virgule flottante de la base de données à la fonction. Conservez les montants en unités mineures entières dans tout le système, et convertissez-les en chaîne décimale une seule fois, à la frontière :
// 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),
};
}Cette frontière unique est ce qui garantit que le chiffre imprimé et les lettres imprimées expriment la même valeur. Si le système dérive le chiffre par un chemin et les lettres par un autre, vous inscrivez sur le document deux montants susceptibles de diverger — c'est-à-dire exactement la situation que l'article 5 a été écrit pour trancher.
Tester votre implémentation
Ces tests ne sont pas une formalité. Chaque cas représente une règle grammaticale qui échoue en silence :
// 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'));
});Lancez-les avec node --test après compilation. Les cas 1.005 et 8.165 sont précisément ceux qui démasquent le chemin parseFloat, alors que les valeurs qu'un développeur essaie habituellement y survivent. C'est cette suite, plutôt que la fonction elle-même, qui mérite d'être copiée dans votre projet.
Résolution des problèmes courants
Le texte s'affiche inversé ou avec des lettres détachées dans un PDF. Ce n'est pas le tafqit, c'est le moteur de rendu : la plupart des générateurs de PDF n'implémentent ni l'algorithme bidirectionnel ni la ligature des lettres arabes. Vérifiez que votre bibliothèque gère l'arabe connecté avant d'accuser la fonction.
«مائة» ou «مئة» ? Les deux sont corrects. Les documents officiels du Golfe penchent pour «مائة», l'écriture moderne pour «مئة». L'option hundreds permet de s'aligner sur le reste de vos documents ; l'essentiel est de rester cohérent à l'intérieur d'un même système.
Montants au-delà du plafond. MAX_VALUE s'arrête aux billions faute de nom consensuel au-delà. Lever une exception vaut mieux qu'inscrire un mot inventé sur un document financier.
La ZATCA exige-t-elle le montant en lettres sur la facture ? Non. Les champs obligatoires de la facture fiscale n'incluent pas le tafqit ; c'est une exigence bancaire et contractuelle, non fiscale. Ce que la ZATCA demande réellement est traité dans le guide de la facture fiscale simplifiée et de l'article 53.
Étape suivante
- Comparez vos résultats à ceux de l'outil gratuit de tafqit.
- Pour intégrer le tafqit dans un système de facturation ou de comptabilité existant sans maintenir vous-même l'algorithme linguistique, voyez l'API tafqit.
- Si le document qui porte le montant est une facture électronique, l'étape naturelle suivante est l'intégration de la facturation électronique ZATCA phase 2 en TypeScript.
Conclusion
Le tafqit ressemble à une question de mise en forme et est traité comme tel la plupart du temps : une ligne au bas du gabarit, écrite à la hâte et relue par personne. Mais l'article 5 de la loi sur les effets de commerce place cette ligne en position de texte faisant foi, ce qui transforme une erreur de rendu en erreur sur le montant.
Les règles que nous avons encodées ne sont pas nombreuses : inversion de 3 à 10, scission des dizaines composées, forme du nom compté suivant le dernier composant, genre du nom de la devise, nombre d'unités mineures — plus une frontière décimale que l'on ne franchit jamais deux fois. Mais chacune échoue en silence et produit de l'arabe à l'apparence juste et au sens faux. C'est pourquoi le plus précieux dans ce tutoriel n'est pas la fonction, mais la suite de tests qui l'empêche de régresser.
Vous écrivez cela dans un système de facturation ou de paie existant ? S'il sort de votre système ne serait-ce qu'un document portant un montant en lettres, cela vaut une demi-heure de revue sur votre tafqit et votre logique d'arrondi avant qu'une banque ne trouve le problème à votre place. Écrivez-nous avec un exemple de document et nous vous dirons où se situent les cinq erreurs ci-dessus dans votre système.