Aller au contenu
Sylvain Joffraud l4w
Retour

Montant d’API en euros ou en centimes : un virement ×100

Montant d’API en centimes ou en euros : sur une billetterie événementielle, l’endpoint de versement attendait des euros et convertissait lui-même avec Math.round(amount * 100), pendant que le client Flutter convertissait déjà en centimes. Demander 19,50 € postait donc 1950, que le serveur relisait comme 1 950 € : un virement cent fois trop grand. Rien dans le DTO ne l’arrêtait, seulement un @Min(1). Honnêteté oblige : le bug était latent, la méthode fautive n’était appelée par aucun écran, mais une refonte du circuit de retrait allait la brancher en la croyant correcte.

Deux conversions pour un seul montant

Le contrat de l’API, c’est l’ensemble des règles implicites ou écrites sur la forme des données échangées : ici, l’unité du champ amount n’était écrite nulle part ailleurs que dans le code serveur.

// Serveur NestJS : le montant arrive en euros
async createPayout(userId: string, amount: number) {
  const balance = await this.getBalance(userId);
  if (balance.available < amount) throw new BadRequestException('Insufficient balance');

  const amountInCents = Math.round(amount * 100);   // conversion n° 1
  return this.stripe.payouts.create({ amount: amountInCents, currency: 'eur' });
}
// Client Flutter : il pensait parler en centimes
Future<void> requestPayout(double amount) => _dio.post('/payouts', data: {
  'amount': (amount * 100).toInt(), // conversion n° 2
  'currency': 'eur',
});

Chaque couche, lue seule, a l’air juste. Le bug n’existe que dans la somme des deux. Le seul garde-fou restant était le contrôle de solde : il refuse si le solde est insuffisant, mais laisse passer le ×100 dès que le compte contient assez.

Un montant sans unité dans son nom est un contrat que chaque couche relit à sa façon.

Pourquoi 0.29 * 100 donne 28 centimes ?

Le même client cachait un deuxième piège. 0,29 n’a pas de représentation exacte en flottant binaire (IEEE 754), et .toInt() tronque au lieu d’arrondir :

$ node -e 'console.log(0.29 * 100, Math.trunc(0.29 * 100), Math.round(0.29 * 100))'
28.999999999999996 28 29

Dart se comporte à l’identique. Une conversion euros → centimes doit donc toujours arrondir (Math.round, .round()), jamais tronquer. Le correctif retenu a été minimal : le client envoie désormais des euros, comme le contrat existant l’attend, et la troncature disparaît avec la conversion.

Le même défaut de contrat, deux fois ailleurs

En corrigeant ce virement, deux bugs voisins sont sortis de la même famille : une donnée numérique dont la forme n’est pas verrouillée.

SymptômeCauseCorrectif
Virement ×100euros attendus, centimes envoyésune seule conversion, testée sur le réseau
Solde toujours à 0 €le parseur client attendait { available: [{ amount, currency }] } (format Stripe, en centimes), l’API renvoyait { available: 12.5 } (euros)lire la forme réelle, afficher « solde indisponible » plutôt qu’un zéro
3 billets pour une quantité de 2,5@IsNumber() accepte les décimales, puis for (let i = 0; i < 2.5; i++) fait trois tours@IsInt(), @Min(1), @Max(…)

La quantité de billets n’avait en plus aucun plafond côté serveur : l’app bornait, un appel direct à l’API non.

Montant d’API en centimes : les règles que j’applique maintenant

  1. Une seule unité sur le réseau : des entiers dans la plus petite unité monétaire, comme Stripe le fait pour tous ses montants. 1950 veut dire 19,50 €, partout.
  2. Un nom de champ explicite : amountCents plutôt que amount. L’unité se lit dans le JSON, dans les logs et dans le diff.
  3. Un DTO qui refuse l’ambigu : @IsInt() plutôt que @IsNumber(), plus des bornes @Min et @Max réalistes.
  4. Arrondir, jamais tronquer, à l’unique endroit où l’on passe d’un affichage décimal à des centimes.
  5. Un test qui lit ce qui part sur le réseau : un adaptateur HTTP qui enregistre la requête, et une assertion sur le corps envoyé, avec 19,50 € et 0,29 € comme cas.
test('poste le montant attendu par le serveur', () async {
  await api.requestPayout(19.5);
  expect(adapter.lastBody?['amount'], 19.5); // pas 1950
});

Ce test ne vérifie pas une méthode : il fige le contrat entre deux dépôts qui ne partagent aucun type.

Lessons learned

Questions fréquentes

Faut-il envoyer un montant en centimes ou en euros dans une API ?
Un montant d’API gagne à circuler en entier dans la plus petite unité monétaire, donc en centimes pour l’euro. C’est la convention de Stripe : amount: 1950 signifie 19,50 €. Un entier évite les flottants, et un nom de champ explicite comme amountCents dit l’unité sans lire la documentation. L’essentiel est qu’une seule couche convertisse, jamais les deux.
Pourquoi <code>0.29 * 100</code> ne donne pas 29 en JavaScript ou en Dart ?
0,29 n’a pas de représentation exacte en virgule flottante binaire (IEEE 754). En JavaScript comme en Dart, 0.29 * 100 vaut 28.999999999999996. Un .toInt() ou Math.trunc tronque et donne 28 centimes. Il faut arrondir avec Math.round ou .round() pour obtenir 29, ou mieux, ne jamais passer par un flottant pour stocker un montant.
Comment valider un montant en centimes avec class-validator dans NestJS ?
Un DTO NestJS qui reçoit un montant en centimes doit utiliser @IsInt() plutôt que @IsNumber(), qui accepte les décimales, puis borner avec @Min(1) et @Max(…). Sans borne haute, un montant cent fois trop grand passe la validation. La borne n’a pas besoin d’être précise : elle doit juste rendre une erreur d’unité impossible à confondre avec une valeur plausible.
Comment tester qu’un client mobile envoie le bon montant à l’API ?
Le test utile vérifie le corps de la requête qui part sur le réseau, pas la valeur passée à la méthode. Avec Dio en Flutter, on injecte un HttpClientAdapter qui enregistre la requête sans l’envoyer, puis on affirme que le champ amount vaut exactement ce que le serveur attend. Un cas limite comme 0,29 € attrape aussi les troncatures.

Partager cet article :

Article précédent
Runners GitHub : `_temp` en tmpfs, `IOWeight` inutile
Article suivant
Ollama : l’heure en tête du prompt casse le cache