Aller au contenu

Un float n'est pas un montant : les centimes

Temps 2 · séance 2 · ~35 min

Les slides du chapitre (PDF)PDF

  • Expliquer pourquoi 0.1 + 0.2 ne vaut pas 0.3 en PHP.
  • Reproduire dans le magasin un achat refusé alors que le solde suffit.
  • Convertir les montants en centimes entiers, de la migration jusqu'à l'affichage.
  • Écrire un test unitaire pour la mise en forme d'un montant.
  • Activer l'extension intl quand Number::currency() la réclame.

Bob a 0,30 €, et le magasin refuse de lui vendre trois bonbons à 0,10 €. Le total est calculé avec un nombre qui ne vaut pas exactement un dixième.

1. Une erreur minuscule, répétée des milliers de fois

En janvier 1982, la Bourse de Vancouver lance un indice qui démarre à 1000. L'indice est recalculé à chaque transaction, environ 3 000 fois par jour, et chaque résultat est coupé à trois décimales au lieu d'être arrondi. Chaque coupure perd moins d'un millième de point, mais la perte se répète : l'indice baisse d'environ 25 points par mois sans que les cours aient baissé.

Le vendredi 25 novembre 1983, l'indice ferme à 524,811. Après un week-end de recalcul, il ouvre le lundi à 1098,892. L'erreur venait d'une règle d'arrondi, pas de PHP ni d'un type de nombre. Elle montre ce qui guette tout programme qui manipule de l'argent : une erreur invisible sur une opération devient visible après des milliers d'opérations.

2. 0.1 + 0.2 dans Tinker

Ouvrez un terminal dans votre copie du Tamagotchi et demandez à PHP une addition simple :

php artisan tinker --execute="echo 0.1 + 0.2;"
0.3

Le résultat paraît juste. Demandez maintenant une écriture plus précise avec var_dump(), qui affiche le type et assez de chiffres pour distinguer deux floats différents :

php artisan tinker --execute="var_dump(0.1 + 0.2, 0.1 * 3, 0.1 + 0.2 == 0.3);"
float(0.30000000000000004)
float(0.30000000000000004)
bool(false)

0.1 + 0.2 ne vaut pas 0.3, et la comparaison le confirme. Avec le réglage precision de PHP, qui vaut 14, echo affiche au plus quatorze chiffres significatifs et cache l'écart. var_dump() suit un autre réglage, serialize_precision, et affiche ici assez de chiffres pour distinguer le résultat de 0.3.

Un nombre à virgule s'appelle en PHP un float. PHP le range sur 64 bits, en binaire, avec un nombre fixe de chiffres. En décimal, un tiers s'écrit 0,3333… sans fin, et vous devez vous arrêter quelque part. En binaire, un dixième a le même défaut : il s'écrit 0,000110011…, avec le motif 0011 qui se répète sans fin. PHP arrondit 0,1 au nombre binaire le plus proche qu'un float sait écrire. printf() en montre vingt décimales :

php artisan tinker --execute="printf('%.20f', 0.1);"
0.10000000000000000555

Le 0.1 de PHP vaut donc un tout petit peu plus que 0,1. 0.2 et 0.3 ont aussi leur petit écart, et ces écarts ne s'annulent pas toujours dans un calcul.

En décimal, un tiers s'écrit 0,3333 sans fin, et arrêté à quatre chiffres il vaut un peu moins qu'un tiers. En binaire, un dixième s'écrit 0,000110011 sans fin, PHP l'arrondit au nombre le plus proche qu'il sait écrire et garde 0,1000000000000000055511. Les petites erreurs s'additionnent : 0.1 + 0.2 donne 0.30000000000000004, et la comparaison avec 0.3 rend false

3. Trois bonbons pour Bob

Dans le Tamagotchi, chaque montant est un float : le solde users.balance, le prix items.price, le total d'un achat purchases.total et la valeur d'un code cadeau gift_codes.amount. La migration des articles le déclare ainsi :

$table->float('price');

Voici le calcul de l'achat, dans app/Http/Controllers/PurchaseController.php, coupé à l'essentiel :

$quantity = $request->integer('quantity', 1);
$price = $request->float('price');
$total = $price * $quantity;

// …

if ($user->balance < $total) {
    return back()->withErrors(['quantity' => 'Votre solde est insuffisant.']);
}

Le chapitre 2.08 s'occupe du prix qui vient de la requête. Ici, regardez seulement la multiplication et la comparaison.

Le seeder donne 0,30 € à Bob. Bob se connecte avec bob@example.com et le mot de passe password, ouvre « 04 Magasin », tape 3 dans la quantité du Bonbon, à 0,10 €, et clique sur « Acheter ». Dans l'onglet Réseau, la requête et la réponse sont celles d'un achat ordinaire :

POST /magasin/1/acheter
{"price":"0.1","creature_id":"5","quantity":"3"}
Réponse : 302, retour à /magasin

Le magasin répond « Votre solde est insuffisant. ». L'en-tête affiche pourtant « SOLDE 0.3 € ».

Le magasin vu par Bob : l'en-tête affiche SOLDE 0.3 €, les prix s'écrivent 3.2 €, 4.5 € et 0.1 €, et la carte du Bonbon, avec la quantité 3, affiche Votre solde est insuffisant.

Le contrôleur a calculé 0.1 * 3, soit 0.30000000000000004, et 0.3 < 0.30000000000000004 est vrai. Bob achète ensuite les bonbons un par un. Les trois achats passent, et son solde tombe à 0 €.

La capture montre un second défaut. Les montants s'écrivent 0.3 €, 3.2 € ou 19.9 €, avec un point et sans le zéro final. L'en-tête colle le signe euro derrière le nombre reçu, dans resources/js/components/AppHeader.vue :

{{ $page.props.auth.user.balance }} €

Ce que coûte l'erreur

Ici, personne ne vole personne. Le joueur dont le solde égale le total au centime près se voit refuser un achat qu'il peut payer. Un float ne garantit aucun montant au centime près, ni dans un calcul ni en base.

Le développeur ne l'a pas vu, car tout semble juste partout où il regarde. echo affiche 0.3, et le JSON de la page porte 0.3. Après un achat d'un bonbon, PHP calcule un solde de 0.19999999999999998, mais le solde relu en base vaut 0.2. Laravel passe le solde à PDO, l'extension de PHP qui parle à la base, et PDO l'écrit en texte avec quatorze chiffres, comme echo : la requête envoie 0.2. L'écart est caché au moment d'enregistrer, et il ne se voit que dans le cas limite où le total égale le solde.

4. À vous : refaites le calcul (5 min)

Sur votre copie, sur votre machine.

  1. Lancez php artisan migrate:fresh --seed, puis les trois commandes Tinker de la section 2.
  2. Connectez-vous comme Bob, bob@example.com, mot de passe password, et ouvrez « 04 Magasin ».
  3. Achetez trois bonbons d'un coup, et relevez le message sous la quantité.
  4. Achetez-les un par un, et regardez le solde de l'en-tête.
  5. Lancez php artisan test --filter=Flaw07 et lisez les noms des quatre tests rouges.

Bob a maintenant 0 €. Relancez php artisan migrate:fresh --seed pour retrouver les montants du seeder avant de commencer la correction.

5. La correction : compter en centimes

Un montant en euros contient au plus deux décimales. En centimes, il n'en a plus : 0,10 € devient 10, 0,30 € devient 30. Un nombre entier, un int en PHP, se range et se calcule sans écart :

php artisan tinker --execute="var_dump(10 * 3, 10 * 3 === 30);"
int(30)
bool(true)

La règle tient en trois lignes. Les montants sont rangés en centimes, dans des colonnes entières. Tous les calculs se font en centimes. La conversion en euros n'a lieu qu'au moment d'afficher, sur le serveur, qui envoie à la page une chaîne déjà écrite.

Bob, 0,30 €, achète trois bonbons à 0,10 €, deux fois. Avant : la base garde balance = 0.3, PHP calcule 0.1 * 3 = 0.30000000000000004, l'achat est refusé, et la page affiche le nombre suivi du signe euro, 0.3 €. Après : la base garde balance_cents = 30, PHP calcule 10 * 3 = 30, l'achat passe, le serveur transforme le montant en texte pour l'affichage, et la page affiche 0,00 €

Ce que vous devez obtenir :

Avant Après
users.balance, float users.balance_cents, entier
items.price, float items.price_cents, entier
purchases.total, float purchases.total_cents, entier
gift_codes.amount, float gift_codes.amount_cents, entier
la page affiche 0.3 € la page affiche 0,30 €

Le suffixe _cents dit l'unité dans le nom de la colonne. Personne ne peut plus croire que 30 veut dire trente euros. La correction touche une quinzaine de fichiers. Avancez par étapes, et relancez php artisan test --filter=Flaw07 après chacune.

Étape 1 : la migration

Votre base contient déjà des joueurs, des articles et des achats. Une nouvelle migration doit ajouter les colonnes en centimes, y recopier les montants, puis supprimer les anciennes colonnes. Créez-la :

php artisan make:migration convert_amounts_to_cents
   INFO  Migration […\database\migrations\…_convert_amounts_to_cents.php] created successfully.

Le fichier généré contient Schema::table('cents', …). Laravel a deviné une table à partir de la fin du nom, to_cents. Il n'existe pas de table cents : remplacez ce squelette. La migration traite les quatre tables de la même façon. Voici la première :

Schema::table('users', fn (Blueprint $table) => $table->integer('balance_cents')->default(5000));
DB::table('users')->update(['balance_cents' => DB::raw('ROUND(balance * 100)')]);
Schema::table('users', fn (Blueprint $table) => $table->dropColumn('balance'));

La première ligne ajoute la colonne entière, avec 5000 centimes par défaut, les 50 € d'un nouveau joueur. La deuxième recopie chaque solde multiplié par 100. DB::raw() passe une expression SQL telle quelle, et la base calcule elle-même, ligne par ligne. La troisième supprime l'ancienne colonne.

ROUND n'est pas là par prudence. Multiplier un float par 100 recrée un écart, et convertir en entier coupe au lieu d'arrondir :

php artisan tinker --execute="var_dump(0.29 * 100, (int) (0.29 * 100), round(0.29 * 100));"
float(28.999999999999996)
int(28)
float(29)

Sans arrondi, 0,29 € deviendrait 28 centimes. SQLite se comporte de même : CAST(0.29 * 100 AS INTEGER) y vaut 28, ROUND(0.29 * 100) vaut 29. C'est l'erreur de Vancouver, en plus petit.

La méthode down() fait le chemin inverse, balance_cents / 100.0 vers une colonne float. Avant de lancer la migration, regardez les montants dans votre base :

php artisan tinker --execute="echo json_encode(DB::table('users')->pluck('balance', 'name'), JSON_UNESCAPED_UNICODE);"
{"Alice":120,"Mallory":5,"Bob":0.3}

Lancez la migration :

php artisan migrate
   INFO  Running migrations.

  …_convert_amounts_to_cents .. 72.32ms DONE

Relancez la commande Tinker avec balance_cents à la place de balance :

{"Alice":12000,"Mallory":500,"Bob":30}

Les prix suivent le même chemin : le Coussin doré passe de 19.9 à 1990.

Étape 2 : les modèles, les fabriques et le seeder

Dans les quatre modèles, le montant change de nom dans le commentaire @property et dans casts(), avec le cast integer au lieu de float. Dans Item, Purchase et GiftCode, renommez aussi le champ dans #[Fillable]. Dans User, le solde n'est plus dans cette liste depuis le chapitre 2.03, et il n'y revient pas. Les fabriques et le seeder écrivent les montants en centimes : 'balance' => 120 devient 'balance_cents' => 12000.

La fabrique des joueurs donne 50 € à chaque nouveau joueur : 'balance_cents' => 5000. Après cette étape, deux tests de la faille passent :

  ✓ amounts are stored in cents
  ✓ a new player starts with 50 €
  ⨯ three sweets at 0,10 € cost exactly 0,30 €
  ⨯ the shop displays prices in euros
  …
  Tests:    2 failed, 2 passed (19 assertions)

Étape 3 : les contrôleurs

PurchaseController et GiftCodeController calculent encore avec balance, price, total et amount. Cherchez ces mots dans tout le projet avec la recherche de votre éditeur, et passez chaque calcul en centimes. Le formulaire du magasin envoie son prix dans un champ caché : il doit s'appeler price_cents et porter item.price_cents, sinon le contrôleur lit 0 et l'article devient gratuit. Le test des trois bonbons passe :

  ✓ three sweets at 0,10 € cost exactly 0,30 €
  …
  Tests:    1 failed, 3 passed (20 assertions)

Étape 4 : un test unitaire pour Money::format()

Il reste l'affichage, le second défaut vu à la section 3 : la page écrit 0.3 € au lieu de 0,30 €. Les montants sont maintenant des centimes, et une seule méthode les mettra en forme pour toutes les pages : App\Support\Money::format(). Elle reçoit des centimes et rend une chaîne : 1050 donne 10,50 €. À l'étape 5, le magasin et l'en-tête l'appellent. Si elle se trompe, chaque montant affiché est faux. Vous écrivez donc d'abord son test, qui fixe ce qu'elle doit rendre, puis la méthode.

Au chapitre 1.17, vos tests de tests/Feature envoyaient une requête à l'application et lisaient la réponse. Un test unitaire vérifie une seule méthode, sans requête ni base de données : il l'appelle avec une valeur et compare le résultat. Dans tests/Pest.php, TestCase et RefreshDatabase ne s'appliquent qu'au dossier Feature. Un test de tests/Unit ne démarre donc pas Laravel, et il tourne en quelques millisecondes.

php artisan make:test MoneyTest --unit
php artisan make:class Support/Money

La première commande crée tests/Unit/MoneyTest.php, la seconde app/Support/Money.php avec un constructeur vide. Remplacez le test généré par celui-ci :

<?php

use App\Support\Money;

test('cents are displayed as euros', function (int $cents, string $displayed) {
    expect(Money::format($cents))->toBe($displayed);
})->with([
    [1050, '10,50 €'],
    [10, '0,10 €'],
    [0, '0,00 €'],
    [123456, '1 234,56 €'],
]);

with() donne au test une liste de cas. Pest lance la fonction une fois par ligne, avec les deux valeurs en arguments, et affiche chaque cas comme un test. toBe() compare avec === : même type, mêmes caractères. Lancez-le :

php artisan test tests/Unit/MoneyTest.php
   FAIL  Tests\Unit\MoneyTest
  ⨯ cents are displayed as euros with (1050, '10,50 €')
  …
  Call to undefined method App\Support\Money::format()

Le test est rouge, car la méthode n'existe pas. Laravel fournit Number::currency(), de la classe Illuminate\Support\Number, qui écrit un nombre comme un montant dans la langue choisie. in: donne la devise, locale: la langue. Écrivez la méthode dans app/Support/Money.php :

<?php

namespace App\Support;

use Illuminate\Support\Number;

class Money
{
    /**
     * Turn a whole number of cents into an amount to display: 1050 gives "10,50 €".
     */
    public static function format(int $cents): string
    {
        return (string) Number::currency($cents / 100, in: 'EUR', locale: 'fr');
    }
}

La division par 100 rend un float, mais seulement pour l'affichage, et Number::currency() arrondit à deux décimales. Aucun calcul ne réutilise ce résultat. Number::currency() peut aussi rendre false en cas d'échec. (string) transforme ce false en chaîne vide : le type de retour est respecté, mais un échec afficherait un montant vide, sans erreur.

Astuce

« The "intl" PHP extension is required to use the [currency] method. » veut dire que votre PHP n'a pas l'extension intl, la bibliothèque qui connaît les règles d'écriture des nombres de chaque langue. Le test échoue avec ce message, et, dans le navigateur, toute page d'un joueur connecté rend une erreur 500, car l'en-tête affiche le solde.

  1. Vérifiez : php -m | grep intl, ou php -m | Select-String intl dans PowerShell. Rien ne s'affiche si l'extension manque.
  2. Trouvez le fichier de configuration avec php --ini, à la ligne Loaded Configuration File. Dans Laragon, Menu, puis PHP, puis php.ini ouvre le même fichier.
  3. Cherchez la ligne ;extension=intl et retirez le point-virgule du début : extension=intl.
  4. Arrêtez puis relancez Laragon ou Herd, et composer run dev. php -m doit maintenant lister intl.

Relancez le test. Il reste rouge, et la sortie est déroutante :

   FAILED  Tests\Unit\MoneyTest > cents are displayed as euros with (1050, '10,50 €')
  Failed asserting that two strings are identical.
--- Expected
+++ Actual
@@ @@
-'10,50 €'
+'10,50 €'
…
  Tests:    4 failed (4 assertions)

Pest compare la valeur attendue, écrite dans le test, à la valeur obtenue, rendue par Money::format(). À l'écran, les deux s'écrivent 10,50 €. Pourtant toBe() les dit différentes : un caractère diffère, et il ne se voit pas. Pour le voir, demandez à json_encode(), qui écrit chaque caractère spécial avec son code :

php artisan tinker --execute="echo json_encode(App\Support\Money::format(123456));"
"1\u202f234,56\u00a0\u20ac"

La différence est dans les espaces. Votre clavier tape une espace ordinaire, de code U+0020. Number::currency() en met d'autres. Avant le signe €, il met une espace insécable, de code U+00A0 : un caractère qui ressemble à une espace, mais qui interdit de couper la ligne à cet endroit. Le montant et son signe € restent ainsi toujours sur la même ligne. Entre les milliers, il met une espace fine insécable, U+202F, plus étroite, qui empêche aussi la coupure. Les règles d'écriture du français demandent ces espaces, et l'extension intl les applique. Pour toBe(), ce sont trois caractères différents.

À l'écran, rien ne change : un montant s'affiche bien, comme 10,50 €, et l'espace insécable y fait son travail. Le piège ne concerne que la comparaison stricte d'un test.

La chaîne 1 234,56 € de Money::format(123456), case par case : une espace fine insécable U+202F entre 1 et 234, une espace insécable U+00A0 entre 56 et le signe euro. Le test les écrit \u{202F} et \u{00A0} entre guillemets doubles. Une espace tapée au clavier, U+0020, est un autre caractère

Ne touchez pas à Money::format() : c'est la valeur attendue du test qui doit changer. Dans la valeur attendue, écrivez le caractère par son code, \u{00A0} avant le signe €, et \u{202F} entre les milliers, entre guillemets doubles. Dans une chaîne PHP entre guillemets doubles, \u{00A0} désigne le caractère de code U+00A0. Entre guillemets simples, PHP laisse les huit caractères tels quels. Quand un test de montant reste rouge sans différence visible, json_encode() vous montre quel caractère la fonction a rendu. Le test final, tests/Unit/MoneyTest.php :

<?php

use App\Support\Money;

test('cents are displayed as euros', function (int $cents, string $displayed) {
    expect(Money::format($cents))->toBe($displayed);
})->with([
    [1050, "10,50\u{00A0}€"],
    [10, "0,10\u{00A0}€"],
    [0, "0,00\u{00A0}€"],
    [123456, "1\u{202F}234,56\u{00A0}€"],
]);
   PASS  Tests\Unit\MoneyTest
  ✓ cents are displayed as euros with (1050, '10,50 €')
  ✓ cents are displayed as euros with (10, '0,10 €')
  ✓ cents are displayed as euros with (0, '0,00 €')
  ✓ cents are displayed as euros with (123456, '1 234,56 €')

  Tests:    4 passed (4 assertions)

Étape 5 : le serveur envoie des montants écrits

La page ne fait plus aucun calcul ni aucune mise en forme. Elle affiche une chaîne reçue en prop.

  • Dans ShopController@index, chaque article devient un tableau explicite, comme au chapitre 2.04, avec une clé price qui vaut Money::format($item->price_cents).
  • Dans app/Http/Middleware/HandleInertiaRequests.php, la clé auth reçoit balance, le solde déjà écrit, ou null pour un visiteur.
  • L'en-tête affiche $page.props.auth.balance, et le magasin item.price, sans ajouter le signe €.

Cherchez dix minutes, puis ouvrez la correction.

La migration, database/migrations/…_convert_amounts_to_cents.php :

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Every amount in euros becomes a whole number of cents.
     */
    public function up(): void
    {
        Schema::table('users', fn (Blueprint $table) => $table->integer('balance_cents')->default(5000));
        DB::table('users')->update(['balance_cents' => DB::raw('ROUND(balance * 100)')]);
        Schema::table('users', fn (Blueprint $table) => $table->dropColumn('balance'));

        Schema::table('items', fn (Blueprint $table) => $table->integer('price_cents')->default(0));
        DB::table('items')->update(['price_cents' => DB::raw('ROUND(price * 100)')]);
        Schema::table('items', fn (Blueprint $table) => $table->dropColumn('price'));

        Schema::table('purchases', fn (Blueprint $table) => $table->integer('total_cents')->default(0));
        DB::table('purchases')->update(['total_cents' => DB::raw('ROUND(total * 100)')]);
        Schema::table('purchases', fn (Blueprint $table) => $table->dropColumn('total'));

        Schema::table('gift_codes', fn (Blueprint $table) => $table->integer('amount_cents')->default(0));
        DB::table('gift_codes')->update(['amount_cents' => DB::raw('ROUND(amount * 100)')]);
        Schema::table('gift_codes', fn (Blueprint $table) => $table->dropColumn('amount'));
    }

    public function down(): void
    {
        Schema::table('users', fn (Blueprint $table) => $table->float('balance')->default(50));
        DB::table('users')->update(['balance' => DB::raw('balance_cents / 100.0')]);
        Schema::table('users', fn (Blueprint $table) => $table->dropColumn('balance_cents'));

        Schema::table('items', fn (Blueprint $table) => $table->float('price')->default(0));
        DB::table('items')->update(['price' => DB::raw('price_cents / 100.0')]);
        Schema::table('items', fn (Blueprint $table) => $table->dropColumn('price_cents'));

        Schema::table('purchases', fn (Blueprint $table) => $table->float('total')->default(0));
        DB::table('purchases')->update(['total' => DB::raw('total_cents / 100.0')]);
        Schema::table('purchases', fn (Blueprint $table) => $table->dropColumn('total_cents'));

        Schema::table('gift_codes', fn (Blueprint $table) => $table->float('amount')->default(0));
        DB::table('gift_codes')->update(['amount' => DB::raw('amount_cents / 100.0')]);
        Schema::table('gift_codes', fn (Blueprint $table) => $table->dropColumn('amount_cents'));
    }
};

Les modèles. Dans app/Models/User.php, @property float $balance devient @property int $balance_cents, et dans casts() :

'balance_cents' => 'integer',

Dans app/Models/Item.php :

#[Fillable(['name', 'description', 'effect', 'effect_value', 'price_cents', 'stock'])]
'price_cents' => 'integer',

Dans app/Models/Purchase.php :

#[Fillable(['user_id', 'item_id', 'creature_id', 'quantity', 'total_cents'])]
'total_cents' => 'integer',

Dans app/Models/GiftCode.php :

#[Fillable(['code', 'amount_cents', 'redeemed_by', 'redeemed_at'])]
'amount_cents' => 'integer',

Les fabriques : 'balance_cents' => 5000 dans UserFactory, 'price_cents' => 250 dans ItemFactory, 'amount_cents' => 500 dans GiftCodeFactory. Dans DatabaseSeeder, chaque montant est multiplié par 100 : 'balance_cents' => 12000 pour Alice, 500 pour Mallory, 30 pour Bob, 'price_cents' => 10 pour le Bonbon, 1990 pour le Coussin doré, 'amount_cents' => 500 pour le code 4821, et ainsi de suite.

app/Http/Controllers/PurchaseController.php, dans store() :

$quantity = $request->integer('quantity', 1);
$price = $request->integer('price_cents');
$total = $price * $quantity;

if ($item->stock < $quantity) {
    return back()->withErrors(['quantity' => 'Il n\'en reste pas assez en stock.']);
}

if ($user->balance_cents < $total) {
    return back()->withErrors(['quantity' => 'Votre solde est insuffisant.']);
}

$item->update(['stock' => $item->stock - $quantity]);

$user->balance_cents -= $total;
$user->save();

Purchase::create([
    'user_id' => $user->id,
    'item_id' => $item->id,
    'creature_id' => $creature->id,
    'quantity' => $quantity,
    'total_cents' => $total,
]);

app/Http/Controllers/GiftCodeController.php :

$user->balance_cents += $giftCode->amount_cents;
$user->save();

app/Http/Controllers/ShopController.php, avec use App\Support\Money; en tête :

'items' => $items->orderBy('name')->get()->map(fn (Item $item) => [
    ...$item->only('id', 'name', 'description', 'effect', 'effect_value', 'stock', 'price_cents'),
    'price' => Money::format($item->price_cents),
]),

app/Http/Middleware/HandleInertiaRequests.php, avec use App\Support\Money; en tête :

'auth' => [
    'user' => $request->user(),
    'balance' => $request->user() ? Money::format($request->user()->balance_cents) : null,
],

resources/js/components/AppHeader.vue, le solde :

<span class="font-mono text-lg font-bold sm:text-[22px]">{{
    $page.props.auth.balance
}}</span>

resources/js/pages/shop/Index.vue, l'étiquette du prix, sans le signe € :

<span
    class="block font-mono text-[clamp(2.25rem,4vw,2.75rem)] leading-none font-bold whitespace-nowrap"
    >{{ item.price }}</span
>

Puis le champ caché du formulaire :

<input
    type="hidden"
    name="price_cents"
    :value="item.price_cents"
/>

Enfin, dans resources/js/types/auth.ts, un fichier TypeScript du kit, le type Auth reçoit une ligne sous user: User; : balance: string | null;.

Lancez php artisan migrate:fresh --seed pour repartir du seeder écrit en centimes, puis npm run build ou composer run dev pour la page.

6. La preuve

Avant la correction, les quatre tests de la faille sont rouges :

php artisan test --filter=Flaw07
   FAIL  Tests\Feature\Flaws\Flaw07CentsTest
  ⨯ amounts are stored in cents                                      0.47s
  ⨯ a new player starts with 50 €                                    0.06s
  ⨯ three sweets at 0,10 € cost exactly 0,30 €                       0.03s
  ⨯ the shop displays prices in euros                                0.03s
  ────────────────────────────────…
   FAILED  Tests\Feature\Flaws\Flaw07CentsTest > amounts are stored in cents
  Failed asserting that false is true.
  …
   FAILED  Tests\Feature\Flaws\Flaw07CentsTest > three sweets at 0,10 € cost exactly 0,30 €   QueryException
  SQLSTATE[HY000]: General error: 1 table users has no column named balance_cents …
  …
  Tests:    4 failed (2 assertions)

Les tests de la séance 2 sont écrits en centimes. Tant que la colonne balance_cents n'existe pas, ceux des chapitres 2.08 et 2.09 échouent aussi sur ce message. Après la correction :

   PASS  Tests\Feature\Flaws\Flaw07CentsTest
  ✓ amounts are stored in cents                                      0.51s
  ✓ a new player starts with 50 €                                    0.06s
  ✓ three sweets at 0,10 € cost exactly 0,30 €                       0.09s
  ✓ the shop displays prices in euros                                0.09s

  Tests:    4 passed (20 assertions)

Lancez ensuite toute la suite avec php artisan test. Le tableau de bord passe de 14 tests rouges à 8, et vos quatre cas de MoneyTest s'ajoutent aux tests verts :

  Tests:    8 failed, 81 passed (347 assertions)

Rejouez enfin l'achat de Bob. Trois bonbons d'un coup passent, la notification « Achat effectué pour Noisette. » s'affiche, et l'en-tête dit « SOLDE 0,00 € ».

Le magasin vu par Bob après la correction : l'en-tête affiche SOLDE 0,00 €, les prix s'écrivent 3,20 €, 4,50 € et 0,10 €, le Bonbon affiche 197 en stock, et la notification Achat effectué pour Noisette. apparaît en bas à droite

7. La règle

Recopiez cette phrase dans votre carnet des failles :

Un montant se range et se calcule en centimes entiers, et le serveur ne le met en forme qu'au moment de l'afficher.

Votre portfolio ne manipule pas d'argent. Notez quand même où vous rangeriez un prix, et le nom de sa colonne. Le chapitre 2.08 s'occupe du prix lui-même : d'où le serveur doit-il le lire ?

À retenir

  • Un float range 0,1 en binaire avec un petit écart, et 0.1 + 0.2 == 0.3 rend false en PHP.
  • echo affiche au plus quatorze chiffres significatifs et cache l'écart, alors que var_dump() en affiche assez pour le montrer.
  • Un montant se range dans une colonne entière en centimes, avec _cents dans son nom, et tous les calculs restent en entiers.
  • Une migration qui convertit des euros en centimes utilise ROUND(x * 100), car la conversion en entier coupe au lieu d'arrondir.
  • Number::currency() demande l'extension intl et écrit une espace insécable avant le signe €, qu'un test écrit \u{00A0} entre guillemets doubles.

Pour aller plus loin