Un client à Tokyo souscrit un abonnement à 19,99 $. Votre code multiplie par le taux USD/JPY, obtient 2942,82785, l'écrit en base et l'envoie à votre prestataire de paiement. Le prestataire le rejette — ou pire, l'accepte et facture 100 fois trop cher. Le yen n'a pas de décimales, et votre code n'a jamais posé la question.
L'arrondi monétaire fait partie de ces problèmes qui semblent triviaux jusqu'à leur arrivée en production. Ce n'est pas une question de formatage, c'est une question d'exactitude. Chaque devise a son propre nombre de décimales, l'arithmétique en virgule flottante corrompt silencieusement les montants, et dès que vous convertissez entre devises vous introduisez une décision d'arrondi qui doit être prise délibérément. Ce guide couvre les règles qui comptent vraiment : combien de décimales possède chaque devise, pourquoi stocker les montants en entiers, quel mode d'arrondi choisir, et comment arrondir une conversion de change pour que votre grand livre soit encore équilibré en fin de mois.
Unités mineures : combien de décimales par devise ?
L'unité mineure d'une devise est sa plus petite subdivision transactionnelle. Pour le dollar américain c'est le cent, donc l'USD a deux décimales et 19,99 $ valent 1999 cents. C'est la représentation qu'attendent les prestataires de paiement, et celle que votre base de données devrait utiliser.
La norme ISO 4217 — celle-là même qui fournit les codes à trois lettres comme USD et JPY — attribue aussi à chaque devise un exposant d'unité mineure. La plupart des développeurs supposent que cet exposant vaut toujours 2. Ce n'est pas le cas, et cette hypothèse est le bug le plus coûteux de cette catégorie.
Devises sans décimales
Ces devises n'ont pas de subdivision en circulation : le montant que vous envoyez est le nombre entier d'unités.
- JPY — yen japonais
- KRW — won sud-coréen
- VND — dong vietnamien
- CLP — peso chilien
- ISK — couronne islandaise
- XAF / XOF / XPF — francs CFA et CFP
- UGX — shilling ougandais
- PYG — guarani paraguayen
- RWF, GNF, KMF, DJF, VUV — et plusieurs autres devises de petite dénomination
Si vous traitez le JPY comme une devise à deux décimales et multipliez par 100 avant l'envoi au prestataire, vous venez de facturer à votre client 100 fois le montant prévu.
Devises à trois décimales
Sept devises se subdivisent en millièmes plutôt qu'en centièmes :
- KWD — dinar koweïtien (1000 fils)
- BHD — dinar bahreïni (1000 fils)
- OMR — rial omanais (1000 baisa)
- JOD — dinar jordanien (1000 fils)
- TND — dinar tunisien (1000 millimes)
- IQD — dinar irakien (1000 fils)
- LYD — dinar libyen (1000 dirhams)
Ici l'erreur va dans l'autre sens : traitez le KWD comme deux décimales et vous facturez un dixième de ce que vous vouliez. Une facture de KWD 12,500 devient KWD 1,250.
Il existe même des entrées à quatre décimales dans l'ISO 4217 — l'unidad de fomento chilienne (CLF) et l'unidad previsional uruguayenne (UYW). Ce sont des unités comptables indexées plutôt que des espèces, mais si votre système accepte des codes ISO arbitraires, il doit y survivre.
Quand la norme et votre prestataire divergent
C'est le piège qui attrape les équipes ayant tout fait correctement par ailleurs. Les prestataires de paiement s'écartent parfois de l'ISO 4217 pour des raisons opérationnelles. Adyen, par exemple, documente que CLP, CVE, IDR et ISK utilisent dans son API un nombre de décimales différent de celui de la norme : l'ISK est à zéro décimale selon l'ISO 4217, mais doit être soumise avec deux décimales à Adyen.
La règle : votre table d'arrondi est une propriété du système auquel vous parlez, pas une constante universelle. Tenez une table par intégration, initialisez-la depuis l'ISO 4217, et surchargez par prestataire là où leur documentation l'exige. Ne codez jamais 100 en dur.
Ne stockez jamais l'argent en float
Avant toute discussion sur l'arrondi, les fondations. La virgule flottante binaire ne peut pas représenter exactement la plupart des fractions décimales :
0.1 + 0.2 // 0.30000000000000004
1.005 * 100 // 100.49999999999999
19.99 * 147.2150 // 2942.8278499999997Ces derniers chiffres ne sont pas cosmétiques. Passez-les dans Math.round() au mauvais moment et vous obtenez un montant faux d'une unité mineure, ce qui suffit à faire échouer un rapprochement.
Deux règles couvrent presque tous les cas :
- Stockez les montants en entiers, en unités mineures. Une colonne
amount_minor BIGINTplus une colonnecurrency CHAR(3).{ amount_minor: 1999, currency: "USD" }est sans ambiguïté et correspond à ce qu'attendent déjà Stripe, Adyen et la plupart des prestataires. - Faites l'arithmétique en entiers ou avec un type décimal.
decimal.Decimalen Python,BigDecimalen Java,NUMERICen PostgreSQL, ou une bibliothèque monétaire JavaScript qui encapsule l'arithmétique entière. Réservez les floats au taux de change lui-même, et encore, seulement jusqu'à la multiplication.
Si vous concevez cette couche de zéro, notre guide sur la conception d'un grand livre multidevise approfondit les décisions de schéma.
Le pipeline de conversion : unités mineures en entrée, unités mineures en sortie
La conversion de devises comporte exactement quatre étapes, et l'arrondi appartient à l'étape trois — une seule fois, à la fin.
- Convertissez le montant source des unités mineures vers une valeur décimale.
- Multipliez par le taux de change en pleine précision.
- Arrondissez à l'exposant d'unité mineure de la devise cible.
- Reconvertissez en unités mineures entières.
Voici la version JavaScript, où la table par devise fait le travail :
// 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)Notez ce que la fonction ne fait pas : elle n'arrondit jamais le taux, jamais une valeur intermédiaire, et ne suppose jamais deux décimales. Math.round est ici un arrondi « moitié vers le haut » sur les positifs — parfait pour un tunnel d'achat, mais lisez la section suivante avant de l'utiliser pour quoi que ce soit de réglementé.
Choisir un mode d'arrondi
« Arrondir à deux décimales » n'est pas une spécification. Il existe au moins cinq façons défendables de départager une égalité, et les systèmes financiers se soucient de celle que vous choisissez.
| Mode | 2,5 → | 3,5 → | −2,5 → | Usage typique |
|---|---|---|---|---|
| Moitié vers le haut (half up) | 3 | 4 | −3 | Prix grand public, totaux de paiement |
| Moitié au pair (arrondi du banquier) | 2 | 4 | −2 | Comptabilité, intérêts, fiscalité, reporting |
| Moitié vers le bas (half down) | 2 | 3 | −2 | Rare ; parfois dans du code financier hérité |
| Plafond (ceiling) | 3 | 4 | −2 | Frais qu'il ne faut jamais sous-facturer |
| Plancher (floor / troncature) | 2 | 3 | −3 | Reversements qu'il ne faut jamais surpayer |
Math.round() sur les positifs. C'est intuitif et adapté à un prix que le client s'apprête à voir.Half even, aussi appelé arrondi du banquier, envoie les moitiés exactes vers le chiffre pair le plus proche. Sur de nombreuses transactions, il annule le biais haussier systématique introduit par le half-up, raison pour laquelle il est la valeur par défaut des systèmes comptables, du module decimal de Python et de l'IEEE 754 lui-même. Si vous agrégez des milliers de montants convertis dans un rapport de revenus, le half-up gonflera discrètement le total ; le half-even non.
Plafond et plancher existent pour les risques asymétriques. Une marketplace qui reverse aux vendeurs peut arrondir chaque reversement vers le bas afin de ne jamais distribuer plus qu'elle ne détient ; l'écart part dans un compte d'arrondi.
Python rend le choix explicite, ce qui est la bonne ergonomie :
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") # 6115Passer le taux sous forme de chaîne à Decimal a son importance. Decimal(0.9241) hérite de l'erreur du float ; Decimal("0.9241") non.
Trois bugs d'arrondi qui coûtent vraiment de l'argent
1. Arrondir le taux avant de multiplier
Les taux de change portent couramment quatre à six décimales significatives, et les tronquer n'est pas anodin. Prenez USD/JPY à 147,2150 et un transfert de 10 000 $ :
- Taux complet :
10000 × 147.2150 = ¥1 472 150 - Taux arrondi à deux décimales (147,21) :
10000 × 147.21 = ¥1 472 100
Un écart de 50 ¥ sur une seule transaction, uniquement parce que le taux a été formaté avant d'être utilisé. Stockez le taux à la précision que renvoie votre fournisseur, n'arrondissez que le montant résultant, et conservez le taux exact utilisé avec la transaction pour l'audit. Notre guide sur d'où les API de taux de change tirent leurs données explique pourquoi cette précision a un sens dès le départ.
2. Arrondir deux fois sur une conversion à plusieurs jambes
Si vous routez USD → EUR → JPY et arrondissez à l'étape EUR, vous jetez une précision que la seconde multiplication amplifie ensuite. Conversion de 12,34 $ avec USD/EUR à 0,9241 et EUR/JPY à 159,3063 :
- Direct :
12.34 × 147.2150 = 1816.63→ 1 817 ¥ - Via une jambe EUR arrondie :
12.34 × 0.9241 = 11.4034→ arrondi à 11,40 € →11.40 × 159.3063 = 1816.09→ 1 816 ¥
Un yen, à cause d'une étape d'arrondi superflue. Sur un lot de reversements de cinquante mille transactions, cela devient un ticket de rapprochement. Dès qu'une paire directe est disponible, utilisez-la ; quand vous devez trianguler, gardez l'intermédiaire en pleine précision. Voyez les taux croisés expliqués pour la mécanique.
3. Des lignes qui ne totalisent pas
Arrondissez chaque ligne d'une facture indépendamment et les parties ne feront pas toujours le total arrondi. Le cas classique est une répartition :
$10.00 split three ways
10.00 / 3 = 3.3333...
→ 3.33 + 3.33 + 3.33 = 9.99 ✗ one cent missingLa solution est l'allocation, pas l'arrondi. Arrondissez le total une fois, puis répartissez-le entre les parties en distribuant le reste une unité mineure à la fois :
/**
* 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 9247Appliquez le même schéma après une conversion de change : convertissez et arrondissez le total de la facture, puis allouez ce total aux lignes. Les lignes se rapprocheront toujours, parce qu'elles dérivent du total au lieu d'être calculées indépendamment. Cela compte surtout en facturation multidevise et en facturation SaaS au prorata, où une dérive d'un centime apparaît sur un PDF vu par le client.
L'arrondi des espèces est une règle distincte
L'unité mineure d'une devise indique le plus petit montant qui peut être enregistré. Elle n'indique pas toujours le plus petit montant qui peut être payé en espèces. Plusieurs pays ont retiré leurs plus petites pièces et arrondissent les paiements en liquide en caisse :
- Suisse — les espèces sont arrondies aux 0,05 CHF les plus proches
- Canada — la pièce d'un cent a été retirée en 2013 ; les espèces sont arrondies aux 5 cents les plus proches
- Suède — les espèces sont arrondies à la couronne entière la plus proche
- Pays-Bas — les espèces sont arrondies aux 5 centimes les plus proches
Point crucial : cela s'applique au règlement en espèces, pas à la facture. Une facture suisse de 12,32 CHF reste enregistrée à 12,32 ; seul le règlement en liquide est arrondi à 12,30, et l'écart de 0,02 est comptabilisé en ajustement d'arrondi. Si vous développez un logiciel de point de vente, modélisez l'arrondi des espèces comme une étape distincte et ultérieure appliquée au paiement — ne l'intégrez jamais au montant stocké, sinon vos transactions électroniques et en espèces divergeront.
Le formatage est la dernière étape, pas le calcul
Une fois l'arithmétique faite, confiez l'affichage à un formateur sensible à la locale. Intl.NumberFormat connaît déjà le nombre de décimales, la position du symbole et les séparateurs de chaque devise :
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 €"Deux remarques pratiques. D'abord, les instances de Intl.NumberFormat sont coûteuses à construire : mettez-en une en cache par couple locale-devise plutôt que d'en créer une par ligne. Ensuite, la division par 10 ** exp de la dernière ligne est le seul endroit où un float devrait toucher une valeur monétaire, et uniquement parce que le résultat devient immédiatement une chaîne.
Tout assembler avec l'API Finexly
Récupérez le taux en pleine précision, convertissez une fois, arrondissez une fois, et conservez le taux utilisé :
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 }Stocker rate et rate_timestamp sur la ligne de transaction, c'est ce qui rend un litige défendable six mois plus tard. Tous les détails d'endpoints et de paramètres sont dans la documentation de l'API Finexly, et si vous mettez les taux en cache entre les requêtes, nos notes sur le cache et la gestion des erreurs couvrent les compromis de fraîcheur.
Une checklist de tests
Les bugs monétaires se cachent dans les cas que personne ne teste. Au minimum, couvrez :
- Une cible sans décimales — convertissez vers JPY ou KRW et vérifiez que le résultat n'a pas de partie fractionnaire.
- Une cible à trois décimales — convertissez vers KWD ou BHD et vérifiez que les trois décimales survivent.
- Des moitiés exactes — vérifiez votre mode d'arrondi, dans les deux sens, négatifs compris.
- La dérive aller-retour — convertissez USD → EUR → USD et vérifiez que le résultat est à une unité mineure près, et non égal.
- L'invariance d'allocation — vérifiez que les parts réparties totalisent toujours exactement le total, de 1 à 100 parts.
- Des codes devise inconnus — vérifiez que le code lève une erreur au lieu de retomber silencieusement sur deux décimales.
- Des montants très élevés — vérifiez qu'il n'y a pas de perte de précision au-delà de
Number.MAX_SAFE_INTEGERen JavaScript ; utilisezBigIntsi vous traitez de l'IDR ou du VND à grande échelle.
Questions fréquentes
Combien de décimales possède chaque devise ?
La plupart en ont deux. Une vingtaine n'en ont aucune — dont JPY, KRW, VND, CLP et ISK — et sept en ont trois : KWD, BHD, OMR, JOD, TND, IQD et LYD. L'ISO 4217 fait autorité, mais vérifiez aussi la table de votre prestataire de paiement, car certains s'en écartent pour des raisons opérationnelles.
Faut-il arrondir les taux de change ou les montants convertis ?
Les montants convertis uniquement. Conservez le taux à la pleine précision renvoyée par votre fournisseur, multipliez, puis arrondissez le résultat une seule fois à l'unité mineure de la devise cible. Arrondir un taux avant la multiplication introduit une erreur proportionnelle à la taille de la transaction.
Quelle différence entre half-up et arrondi du banquier ?
Le half-up éloigne toujours une moitié exacte de zéro (2,5 → 3). L'arrondi du banquier — moitié au pair — l'envoie vers le chiffre pair le plus proche (2,5 → 2, 3,5 → 4), ce qui supprime le biais haussier systématique lorsqu'on agrège beaucoup de montants. Utilisez le half-up pour les prix affichés aux clients, le half-even pour la comptabilité et le reporting.
Pourquoi mes lignes converties ne totalisent-elles pas le total converti ?
Parce que chaque ligne a été arrondie indépendamment et que les erreurs s'accumulent. Arrondissez le total une fois, puis allouez ce total aux lignes avec une répartition au plus fort reste. Les parties totaliseront alors le tout par construction.
Puis-je simplement stocker l'argent en float à deux décimales ?
Non. La virgule flottante binaire ne peut pas représenter exactement des valeurs comme 0,1, donc les erreurs s'accumulent au fil des additions et multiplications et finissent par inverser une décision d'arrondi. Stockez des entiers en unités mineures, ou utilisez un type décimal exact. Ce n'est pas une préoccupation théorique : c'est la cause première la plus fréquente des échecs de rapprochement à un centime près.
Obtenez des taux en pleine précision
Un arrondi correct commence par un taux fiable et une précision que vous n'avez pas jetée. Obtenez votre clé API Finexly gratuite — sans carte bancaire. Commencez avec 1 000 requêtes gratuites par mois sur plus de 170 devises, testez le convertisseur de devises pour vérifier vos calculs, et consultez les formules tarifaires quand votre volume grandit.
Explore More
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 →