DecimalMath.php
PHP
Path: src/Util/Math/DecimalMath.php
<?php
namespace mini\Util\Math;
/**
* Arbitrary precision decimal math using integer math with scaling
*
* Stores decimals as strings (e.g., "123.456") and internally converts
* to scaled integers for arithmetic, then converts back.
*
* Usage:
* DecimalMath::add('10.50', '3.25') // "13.75"
* DecimalMath::multiply('10.5', '2') // "21"
* DecimalMath::divide('10', '3', 4) // "3.3333"
*/
final class DecimalMath
{
/**
* Add two decimals
*/
public static function add(string $a, string $b): string
{
[$aInt, $bInt, $scale] = self::alignScale($a, $b);
$result = (string) BigInt::of($aInt)->add($bInt);
return self::insertDecimal($result, $scale);
}
/**
* Subtract b from a
*/
public static function subtract(string $a, string $b): string
{
[$aInt, $bInt, $scale] = self::alignScale($a, $b);
$result = (string) BigInt::of($aInt)->subtract($bInt);
return self::insertDecimal($result, $scale);
}
/**
* Multiply two decimals
*/
public static function multiply(string $a, string $b): string
{
[$aInt, $aScale] = self::toScaledInt($a);
[$bInt, $bScale] = self::toScaledInt($b);
$result = (string) BigInt::of($aInt)->multiply($bInt);
return self::insertDecimal($result, $aScale + $bScale);
}
/**
* Divide a by b with specified precision
*
* @param int $scale Number of decimal places in result
*/
public static function divide(string $a, string $b, int $scale = 10): string
{
if (self::isZero($b)) {
throw new \DivisionByZeroError('Division by zero');
}
[$aInt, $aScale] = self::toScaledInt($a);
[$bInt, $bScale] = self::toScaledInt($b);
// Scale up dividend to get desired precision
// result_scale = aScale - bScale + extra_scale
// We want result_scale = $scale, so extra_scale = scale - aScale + bScale
$extraScale = $scale - $aScale + $bScale;
if ($extraScale > 0) {
$aInt = self::padRight($aInt, $extraScale);
}
$result = (string) BigInt::of($aInt)->divide($bInt);
return self::insertDecimal($result, $scale);
}
/**
* Negate: -x
*/
public static function negate(string $a): string
{
if (self::isZero($a)) {
return '0';
}
return str_starts_with($a, '-') ? substr($a, 1) : '-' . $a;
}
/**
* Absolute value
*/
public static function absolute(string $a): string
{
return ltrim($a, '-');
}
/**
* Modulus (remainder after integer division)
*/
public static function modulus(string $a, string $b): string
{
if (self::isZero($b)) {
throw new \DivisionByZeroError('Modulus by zero');
}
[$aInt, $bInt, $scale] = self::alignScale($a, $b);
$result = (string) BigInt::of($aInt)->modulus($bInt);
return self::insertDecimal($result, $scale);
}
/**
* Compare two decimals: -1 if a < b, 0 if equal, 1 if a > b
*/
public static function compare(string $a, string $b): int
{
[$aInt, $bInt, $_] = self::alignScale($a, $b);
return BigInt::of($aInt)->compare($bInt);
}
/**
* Check if value is zero
*/
public static function isZero(string $value): bool
{
$clean = ltrim($value, '-+');
$clean = ltrim($clean, '0');
$clean = trim($clean, '.');
$clean = rtrim($clean, '0');
return $clean === '';
}
/**
* Round a decimal to specified scale
*/
public static function round(string $value, int $scale, int $mode = PHP_ROUND_HALF_UP): string
{
[$intPart, $currentScale] = self::toScaledInt($value);
if ($currentScale <= $scale) {
// No rounding needed, just pad with zeros
return self::insertDecimal($intPart . str_repeat('0', $scale - $currentScale), $scale);
}
// Need to round
$dropDigits = $currentScale - $scale;
$divisor = str_pad('1', $dropDigits + 1, '0');
// Get the quotient and remainder
$intVal = BigInt::of($intPart);
$quotient = (string) $intVal->divide($divisor);
$remainder = (string) $intVal->modulus($divisor);
// Check if we need to round up
$halfDivisor = str_pad('5', $dropDigits, '0');
$absRemainder = BigInt::of($remainder)->absolute();
$cmp = $absRemainder->compare($halfDivisor);
$negative = str_starts_with($intPart, '-');
$shouldRoundUp = match ($mode) {
PHP_ROUND_HALF_UP => $cmp >= 0,
PHP_ROUND_HALF_DOWN => $cmp > 0,
PHP_ROUND_HALF_EVEN => $cmp > 0 || ($cmp === 0 && ((int) substr($quotient, -1) % 2 === 1)),
PHP_ROUND_HALF_ODD => $cmp > 0 || ($cmp === 0 && ((int) substr($quotient, -1) % 2 === 0)),
default => $cmp >= 0,
};
if ($shouldRoundUp && !self::isZero($remainder)) {
$quotient = $negative
? (string) BigInt::of($quotient)->subtract(1)
: (string) BigInt::of($quotient)->add(1);
}
return self::insertDecimal($quotient, $scale);
}
/**
* Convert decimal string to scaled integer and its scale
*
* "123.456" -> ["123456", 3]
* "-10.5" -> ["-105", 1]
* "42" -> ["42", 0]
*/
private static function toScaledInt(string $value): array
{
$negative = str_starts_with($value, '-');
$value = ltrim($value, '-+');
$pos = strpos($value, '.');
if ($pos === false) {
$intPart = ltrim($value, '0') ?: '0';
return [$negative && $intPart !== '0' ? '-' . $intPart : $intPart, 0];
}
$intPart = substr($value, 0, $pos);
$decPart = substr($value, $pos + 1);
$scale = strlen($decPart);
// Combine integer and decimal parts
$combined = ltrim($intPart . $decPart, '0') ?: '0';
return [$negative && $combined !== '0' ? '-' . $combined : $combined, $scale];
}
/**
* Align two decimals to the same scale, returning their scaled integer forms
*
* Returns [aScaledInt, bScaledInt, commonScale]
*/
private static function alignScale(string $a, string $b): array
{
[$aInt, $aScale] = self::toScaledInt($a);
[$bInt, $bScale] = self::toScaledInt($b);
$maxScale = max($aScale, $bScale);
// Pad with zeros to align scales
if ($aScale < $maxScale) {
$aInt = self::padRight($aInt, $maxScale - $aScale);
}
if ($bScale < $maxScale) {
$bInt = self::padRight($bInt, $maxScale - $bScale);
}
return [$aInt, $bInt, $maxScale];
}
/**
* Pad integer string with zeros on the right (handles negatives)
*/
private static function padRight(string $value, int $zeros): string
{
if ($zeros <= 0) {
return $value;
}
$negative = str_starts_with($value, '-');
$abs = ltrim($value, '-');
$padded = $abs . str_repeat('0', $zeros);
return $negative ? '-' . $padded : $padded;
}
/**
* Insert decimal point into scaled integer
*
* insertDecimal("123456", 3) -> "123.456"
* insertDecimal("5", 2) -> "0.05"
* insertDecimal("-100", 1) -> "-10"
*/
private static function insertDecimal(string $value, int $scale): string
{
if ($scale <= 0) {
return $value;
}
$negative = str_starts_with($value, '-');
$abs = ltrim($value, '-');
// Pad with leading zeros if needed
if (strlen($abs) <= $scale) {
$abs = str_pad($abs, $scale + 1, '0', STR_PAD_LEFT);
}
$intPart = substr($abs, 0, -$scale);
$decPart = substr($abs, -$scale);
// Normalize: remove trailing zeros and unnecessary decimal point
$decPart = rtrim($decPart, '0');
if ($decPart === '') {
$result = $intPart;
} else {
$result = $intPart . '.' . $decPart;
}
return $negative && $result !== '0' ? '-' . $result : $result;
}
}