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ôme | Cause | Correctif |
|---|---|---|
| Virement ×100 | euros attendus, centimes envoyés | une 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
- 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.
- Un nom de champ explicite :
amountCentsplutôt queamount. L’unité se lit dans le JSON, dans les logs et dans le diff. - Un DTO qui refuse l’ambigu :
@IsInt()plutôt que@IsNumber(), plus des bornes@Minet@Maxréalistes. - Arrondir, jamais tronquer, à l’unique endroit où l’on passe d’un affichage décimal à des centimes.
- 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
- Un montant doit porter son unité dans son nom.
amountlaisse chaque couche deviner ;amountCentsrend l’erreur visible dans le code, les logs et les revues. - Deux conversions correctes font un bug. Quand client et serveur convertissent chacun euros → centimes, chaque ligne est juste et le total est faux d’un facteur 100.
@IsNumber()n’est pas une validation de montant ni de quantité. Un DTO NestJS doit exiger@IsInt()et une borne haute, sinon 2,5 billets ou un virement ×100 passent.- Convertir un flottant en centimes exige un arrondi.
0.29 * 100vaut28.999999999999996:.toInt()donne 28,Math.rounddonne 29. - Un bug latent reste un bug. Une méthode appelée par aucun écran sera un jour branchée par quelqu’un qui la croit correcte ; c’est le bon moment pour la tester.
- Le test qui compte vérifie ce qui part sur le réseau. Affirmer sur le corps de la requête HTTP fige le contrat entre client et serveur.