diff --git a/CHANGELOG.md b/CHANGELOG.md index fb5fd85..4a9a994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - `AbstractMoney::getAmount()` now has a return type - `CurrencyConverter`'s constructor does not accept a default `$context` anymore - `CurrencyConverter::convert()` now requires the `$context` previously accepted by the constructor as third parameter +- `Money::allocateWithRemainder()` now refuses to allocate a portion of the amount that cannot be spread over all ratios, and instead adds that amount to the remainder (#55) ✨ **New ISO currencies** diff --git a/composer.json b/composer.json index 028050b..87f2003 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,7 @@ "license": "MIT", "require": { "php": "^7.4 || ^8.0", - "brick/math": "~0.7.3 || ~0.8.0 || ~0.9.0 || ~0.10.0" + "brick/math": "~0.10.1" }, "require-dev": { "ext-dom": "*", diff --git a/src/Money.php b/src/Money.php index 22d4140..e158c7e 100644 --- a/src/Money.php +++ b/src/Money.php @@ -16,6 +16,7 @@ use Brick\Math\Exception\MathException; use Brick\Math\Exception\NumberFormatException; use Brick\Math\Exception\RoundingNecessaryException; +use InvalidArgumentException; /** * A monetary value in a given currency. This class is immutable. @@ -592,21 +593,49 @@ public function allocateWithRemainder(int ...$ratios) : array throw new \InvalidArgumentException('Cannot allocateWithRemainder() to zero ratios only.'); } - $monies = []; + $ratios = $this->simplifyRatios(array_values($ratios)); + $total = array_sum($ratios); - $remainder = $this; + [, $remainder] = $this->quotientAndRemainder($total); - foreach ($ratios as $ratio) { - $money = $this->multipliedBy($ratio)->quotient($total); - $remainder = $remainder->minus($money); - $monies[] = $money; - } + $toAllocate = $this->minus($remainder); + + $monies = array_map( + fn (int $ratio) => $toAllocate->multipliedBy($ratio)->dividedBy($total), + $ratios, + ); $monies[] = $remainder; return $monies; } + /** + * @param int[] $ratios + * @psalm-param non-empty-list $ratios + * + * @return int[] + * @psalm-return non-empty-list + */ + private function simplifyRatios(array $ratios): array + { + $gcd = $this->gcdOfMultipleInt($ratios); + + return array_map(fn (int $ratio) => intdiv($ratio, $gcd), $ratios); + } + + /** + * @param int[] $values + * + * @psalm-param non-empty-list $values + */ + private function gcdOfMultipleInt(array $values): int + { + $values = array_map(fn (int $value) => BigInteger::of($value), $values); + + return BigInteger::gcdMultiple(...$values)->toInt(); + } + /** * Splits this Money into a number of parts. * diff --git a/tests/MoneyTest.php b/tests/MoneyTest.php index 9581671..12ca4fc 100644 --- a/tests/MoneyTest.php +++ b/tests/MoneyTest.php @@ -376,11 +376,13 @@ public function providerAllocate() : array [['99.99', 'USD'], [100, 100], ['USD 50.00', 'USD 49.99']], [[100, 'USD'], [30, 20, 40], ['USD 33.34', 'USD 22.22', 'USD 44.44']], [[100, 'USD'], [30, 20, 40, 40], ['USD 23.08', 'USD 15.39', 'USD 30.77', 'USD 30.76']], + [[100, 'USD'], [30, 20, 40, 0, 40, 0], ['USD 23.08', 'USD 15.39', 'USD 30.77', 'USD 0.00', 'USD 30.76', 'USD 0.00']], [[100, 'CHF', new CashContext(5)], [1, 2, 3, 7], ['CHF 7.70', 'CHF 15.40', 'CHF 23.10', 'CHF 53.80']], [['100.123', 'EUR', new AutoContext()], [2, 3, 1, 1], ['EUR 28.607', 'EUR 42.91', 'EUR 14.303', 'EUR 14.303']], [['0.02', 'EUR'], [1, 1, 1, 1], ['EUR 0.01', 'EUR 0.01', 'EUR 0.00', 'EUR 0.00']], [['0.02', 'EUR'], [1, 1, 3, 1], ['EUR 0.01', 'EUR 0.00', 'EUR 0.01', 'EUR 0.00']], [[-100, 'USD'], [30, 20, 40, 40], ['USD -23.08', 'USD -15.39', 'USD -30.77', 'USD -30.76']], + [['0.03', 'GBP'], [75, 25], ['GBP 0.03', 'GBP 0.00']], ]; } @@ -431,11 +433,13 @@ public function providerAllocateWithRemainder() : array [['99.99', 'USD'], [100, 100], ['USD 49.99', 'USD 49.99', 'USD 0.01']], [[100, 'USD'], [30, 20, 40], ['USD 33.33', 'USD 22.22', 'USD 44.44', 'USD 0.01']], [[100, 'USD'], [30, 20, 40, 40], ['USD 23.07', 'USD 15.38', 'USD 30.76', 'USD 30.76', 'USD 0.03']], - [[100, 'CHF', new CashContext(5)], [1, 2, 3, 7], ['CHF 7.65', 'CHF 15.35', 'CHF 23.05', 'CHF 53.80', 'CHF 0.15']], + [[100, 'USD'], [30, 20, 40, 0, 0, 40], ['USD 23.07', 'USD 15.38', 'USD 30.76', 'USD 0.00', 'USD 0.00', 'USD 30.76', 'USD 0.03']], + [[100, 'CHF', new CashContext(5)], [1, 2, 3, 7], ['CHF 7.65', 'CHF 15.30', 'CHF 22.95', 'CHF 53.55', 'CHF 0.55']], [['100.123', 'EUR', new AutoContext()], [2, 3, 1, 1], ['EUR 28.606', 'EUR 42.909', 'EUR 14.303', 'EUR 14.303', 'EUR 0.002']], [['0.02', 'EUR'], [1, 1, 1, 1], ['EUR 0.00', 'EUR 0.00', 'EUR 0.00', 'EUR 0.00', 'EUR 0.02']], - [['0.02', 'EUR'], [1, 1, 3, 1], ['EUR 0.00', 'EUR 0.00', 'EUR 0.01', 'EUR 0.00', 'EUR 0.01']], + [['0.02', 'EUR'], [1, 1, 3, 1], ['EUR 0.00', 'EUR 0.00', 'EUR 0.00', 'EUR 0.00', 'EUR 0.02']], [[-100, 'USD'], [30, 20, 40, 40], ['USD -23.07', 'USD -15.38', 'USD -30.76', 'USD -30.76', 'USD -0.03']], + [['0.03', 'GBP'], [75, 25], ['GBP 0.00', 'GBP 0.00', 'GBP 0.03']], ]; } @@ -512,7 +516,7 @@ public function providerSplitWithRemainder() : array [['99.99', 'USD'], 4, ['USD 24.99', 'USD 24.99', 'USD 24.99', 'USD 24.99', 'USD 0.03']], [[100, 'CHF', new CashContext(5)], 3, ['CHF 33.30', 'CHF 33.30', 'CHF 33.30', 'CHF 0.10']], [[100, 'CHF', new CashContext(5)], 7, ['CHF 14.25','CHF 14.25', 'CHF 14.25', 'CHF 14.25', 'CHF 14.25', 'CHF 14.25', 'CHF 14.25', 'CHF 0.25']], - [['100.123', 'EUR', new AutoContext()], 4, ['EUR 25.030', 'EUR 25.030', 'EUR 25.030', 'EUR 25.030', 'EUR 0.003']], + [['100.123', 'EUR', new AutoContext()], 4, ['EUR 25.03', 'EUR 25.03', 'EUR 25.03', 'EUR 25.03', 'EUR 0.003']], [['0.02', 'EUR'], 4, ['EUR 0.00', 'EUR 0.00', 'EUR 0.00', 'EUR 0.00', 'EUR 0.02']], ]; }