Aller au contenu
Sylvain Joffraud l4w
Retour

BCMath : `bcmul` tronque, il n’arrondit pas

BCMath ne fait pas d’arrondi : bcmul et bcdiv tronquent au nombre de décimales demandé. Dans le moteur de facturation d’un SaaS d’abonnements, le prorata de 17 jours sur 31 d’un abonnement à 1 000 € sortait à 548,38 € au lieu de 548,39 €. Le value object Money calculait avec bcdiv(..., 2), et le reste partait à la poubelle. Le constructeur arrondissait bien, avec number_format, mais trop tard : les décimales avaient déjà disparu.

Pourquoi bcmul tronque au lieu d’arrondir ?

BCMath est l’extension PHP de calcul décimal en précision arbitraire : les nombres sont des chaînes, sans erreur de flottant. Son paramètre scale désigne le nombre de décimales conservées, pas une précision d’arrondi. Le reste est coupé, comme le précise la documentation de bcmul.

Le Money d’origine (PHP 8.4, d’après composer.json) :

public function __construct(string|float|int $amount)
{
    if (is_float($amount) || is_int($amount)) {
        $amount = (string) $amount;
    }
    $this->amount = number_format((float) $amount, 2, '.', '');
}

public function multiply(string|float|int $multiplier): self
{
    // ... is_numeric($multiplier) ...
    return new self(bcmul($this->amount, $multiplier, 2)); // tronqué ici
}

bcmul('0.71', '0.5', 2) renvoie 0.35. Le produit exact vaut 0,355, et number_format n’a plus rien à arrondir quand il reçoit 0.35.

Le prorata qui perd un centime

Le calcul de prorata enchaîne multiply(jours)->divide(joursDuMois). Avec 1 000 € sur 17 jours de 31 :

ÉtapeExactAvec scale = 2
1000.00 × 1717 00017000.00
÷ 31548,387…548.38 au lieu de 548.39

Un audit du moteur a trouvé le même défaut sur une révision tarifaire en milieu de mois : 800 € découpés en 15 et 16 jours sur 31 donnent 387,09 € + 412,90 € tronqués, soit 799,99 € au lieu de 800,00 €. Un centime, mais sur chaque facture proratisée, et la somme des lignes du PDF ne vaut plus le total.

Avec BCMath, l’échelle n’est pas une précision d’arrondi : c’est l’endroit où l’on coupe.

Trois pièges de plus dans le même constructeur

En écrivant les tests, trois autres défauts sont sortis du même code :

EntréeCe qui se passe
'12345678901234567.89'number_format((float) ...) rend 12345678901234568.00 : le cast en float abîme les derniers chiffres
0.00001 (float)(string) 0.00001 vaut '1.0E-5', que BCMath rejette avec une ValueError
'1e3', ' 123'is_numeric() dit oui, BCMath dit non

Le cas du float minuscule est sournois : is_numeric('1.0E-5') est vrai, donc la garde de multiply() laissait passer, et c’est bcmul qui levait l’erreur.

Le correctif : calculer à l’échelle 10, arrondir une seule fois

Trois changements :

  1. Normaliser l’entrée : les floats via sprintf('%.10F'), sans notation scientifique ; toute chaîne validée par une regex stricte.
  2. Calculer les intermédiaires à l’échelle 10 dans multiply() et divide().
  3. Arrondir half-up dans le constructeur en ajoutant ±0,005 avant de tronquer à 2 décimales.
public function __construct(string|float|int $amount)
{
    $amount = self::normalize($amount, 'Amount');

    $halfUp = bccomp($amount, '0', 10) < 0 ? '-0.005' : '0.005';
    $this->amount = bcadd($amount, $halfUp, 2);
}

private static function normalize(string|float|int $value, string $label): string
{
    if (is_float($value)) {
        $value = sprintf('%.10F', $value);
    } elseif (is_int($value)) {
        $value = (string) $value;
    }

    if (!preg_match('/^[+-]?(\d+(\.\d*)?|\.\d+)$/', $value)) {
        throw new \InvalidArgumentException(sprintf('%s must be numeric, "%s" given', $label, $value));
    }

    return $value;
}

public function divide(string|float|int $divisor): self
{
    $divisor = self::normalize($divisor, 'Divisor');
    // ... division par zéro ...
    return new self(bcdiv($this->amount, $divisor, 10)); // le constructeur arrondit
}

L’astuce du bcadd : comme il tronque lui aussi, ajouter une demi-unité avant de couper donne un arrondi au plus proche. Le signe de la demi-unité suit celui du montant, donc -0.005 donne -0.01. Les tests figent les valeurs : 466.135 → 466.14, 0.71 × 0.5 → 0.36, 1000 × 17 ÷ 31 → 548.39, 0.00001 → 0.00, et '1e3' lève une InvalidArgumentException.

Ce correctif règle l’arrondi d’un calcul. Il ne garantit pas que des lignes arrondies séparément retombent sur le total : pour le prorata de la première période, un correctif séparé calcule la dernière ligne par soustraction du total. C’est le même réflexe que pour l’unité d’un montant d’API, où l’on arrondit au lieu de tronquer. Le même moteur cachait aussi une stratégie de facturation qui ne persistait rien.

Lessons learned

Questions fréquentes

Est-ce que <code>bcmul</code> et <code>bcdiv</code> arrondissent en PHP ?
Non, bcmul et bcdiv n’arrondissent pas : le paramètre scale fixe le nombre de décimales conservées, et les suivantes sont coupées. bcmul('0.71', '0.5', 2) renvoie 0.35, alors que le produit exact 0,355 s’arrondit à 0,36. Pour arrondir, il faut calculer avec plus de décimales puis appliquer une règle d’arrondi explicite.
Comment arrondir half-up avec BCMath ?
Un arrondi half-up avec BCMath s’obtient en ajoutant une demi-unité de la dernière décimale avant de tronquer : bcadd($x, '0.005', 2) pour un montant positif, bcadd($x, '-0.005', 2) pour un négatif. Comme bcadd tronque lui aussi, le résultat est arrondi au plus proche, les demis s’éloignant de zéro. PHP 8.4 ajoute aussi bcround().
Pourquoi BCMath lève une <code>ValueError</code> avec un float ?
BCMath n’accepte que des chaînes décimales bien formées. Or (string) 0.00001 vaut '1.0E-5' en PHP : une notation scientifique que BCMath rejette avec une ValueError. Il faut convertir les floats avec sprintf('%.10F', $value), qui produit 0.0000100000, ou mieux, ne jamais faire transiter un montant par un float.
Faut-il valider un montant avec <code>is_numeric()</code> avant BCMath ?
is_numeric() est trop permissif pour BCMath : il accepte '1e3' ou ' 123', que BCMath refuse. Une regex stricte comme /^[+-]?(\d+(\.\d*)?|\.\d+)$/ garantit une chaîne décimale simple et permet de lever une InvalidArgumentException maîtrisée au lieu d’une ValueError au milieu d’un calcul.

Partager cet article :

Article précédent
llama.cpp Snapdragon : le GPU Adreno génère 4× moins vite
Article suivant
Qwen 3.5 : « System message must be at the beginning »