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 :
| Étape | Exact | Avec scale = 2 |
|---|---|---|
1000.00 × 17 | 17 000 | 17000.00 |
÷ 31 | 548,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ée | Ce 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 :
- Normaliser l’entrée : les floats via
sprintf('%.10F'), sans notation scientifique ; toute chaîne validée par une regex stricte. - Calculer les intermédiaires à l’échelle 10 dans
multiply()etdivide(). - 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
- Le
scalede BCMath tronque, il n’arrondit jamais.bcmul('0.71', '0.5', 2)vaut0.35; l’arrondi se fait soi-même, après un calcul à plus haute échelle. - Arrondir après une troncature ne rattrape rien. Le
number_formatdu constructeur recevait des valeurs déjà coupées : l’arrondi doit être la seule réduction de précision. - Un montant ne doit jamais passer par un float. Le cast abîme les grands nombres et
(string)produit de la notation scientifique que BCMath rejette. is_numeric()n’est pas un validateur pour BCMath. Une regex décimale stricte transforme uneValueErroren erreur métier claire.- Un test d’arrondi a besoin d’un cas à 5 sur la décimale suivante.
0.355ou466.135distinguent troncature et arrondi ;100.00ne prouve rien.