diff --git a/spec/Calculator/CalculatorBehavior.php b/spec/Calculator/CalculatorBehavior.php index 109d47c6..ec4c46e7 100644 --- a/spec/Calculator/CalculatorBehavior.php +++ b/spec/Calculator/CalculatorBehavior.php @@ -40,7 +40,7 @@ function it_multiplies_a_value_by_another() function it_divides_a_value_by_another() { - $this->divide(rand(-100, 100), rand(-100, 100))->shouldBeString(); + $this->divide(rand(-100, 100), rand(1, 100))->shouldBeString(); } function it_ceils_a_value() diff --git a/spec/PreciseMoneySpec.php b/spec/PreciseMoneySpec.php new file mode 100644 index 00000000..5d107b19 --- /dev/null +++ b/spec/PreciseMoneySpec.php @@ -0,0 +1,327 @@ +setAccessible(true); + $reflection->setValue(null, $calculator->getWrappedObject()); + + $this->beConstructedWith(self::AMOUNT, new Currency(self::CURRENCY)); + } + + function it_is_initializable() + { + $this->shouldHaveType(PreciseMoney::class); + } + + function it_is_json_serializable() + { + $this->shouldImplement(\JsonSerializable::class); + } + + function it_has_an_amount() + { + $this->getAmount()->shouldBeLike(self::AMOUNT); + } + + function it_has_a_currency() + { + $currency = $this->getCurrency(); + + $currency->shouldHaveType(Currency::class); + $currency->equals(new Currency(self::CURRENCY))->shouldReturn(true); + } + + function it_throws_an_exception_when_amount_is_not_numeric() + { + $this->beConstructedWith('ONE', new Currency(self::CURRENCY)); + + $this->shouldThrow(\InvalidArgumentException::class)->duringInstantiation(); + } + + function it_constructs_integer() + { + $this->beConstructedWith(5, new Currency(self::CURRENCY)); + } + + function it_constructs_decimals() + { + $this->beConstructedWith('1.5', new Currency(self::CURRENCY)); + } + + function it_constructs_string() + { + $this->beConstructedWith('5', new Currency(self::CURRENCY)); + } + + function it_constructs_integer_with_decimals_of_zero() + { + $this->beConstructedWith('5.00', new Currency(self::CURRENCY)); + } + + function it_tests_currency_equality() + { + $this->isSameCurrency(new PreciseMoney(self::AMOUNT, new Currency(self::CURRENCY)))->shouldReturn(true); + $this->isSameCurrency(new PreciseMoney(self::AMOUNT, new Currency(self::OTHER_CURRENCY)))->shouldReturn(false); + } + + function it_equals_to_another_money() + { + $this->equals(new PreciseMoney(self::AMOUNT, new Currency(self::CURRENCY)))->shouldReturn(true); + } + + function it_compares_two_amounts(Calculator $calculator) + { + $calculator->compare((string) self::AMOUNT, (string) self::AMOUNT)->willReturn(0); + $money = new PreciseMoney(self::AMOUNT, new Currency(self::CURRENCY)); + + $this->compare($money)->shouldReturn(0); + $this->greaterThan($money)->shouldReturn(false); + $this->greaterThanOrEqual($money)->shouldReturn(true); + $this->lessThan($money)->shouldReturn(false); + $this->lessThanOrEqual($money)->shouldReturn(true); + } + + function it_throws_an_exception_when_currency_is_different_during_comparison(Calculator $calculator) + { + $calculator->compare(Argument::type('string'), Argument::type('string'))->shouldNotBeCalled(); + + $money = new PreciseMoney(self::AMOUNT + 1, new Currency(self::OTHER_CURRENCY)); + + $this->shouldThrow(\InvalidArgumentException::class)->duringCompare($money); + $this->shouldThrow(\InvalidArgumentException::class)->duringGreaterThan($money); + $this->shouldThrow(\InvalidArgumentException::class)->duringGreaterThanOrEqual($money); + $this->shouldThrow(\InvalidArgumentException::class)->duringLessThan($money); + $this->shouldThrow(\InvalidArgumentException::class)->duringLessThanOrEqual($money); + } + + function it_adds_an_other_money(Calculator $calculator) + { + $result = self::AMOUNT + self::OTHER_AMOUNT; + $calculator->add((string) self::AMOUNT, (string) self::OTHER_AMOUNT)->willReturn((string) $result); + $money = $this->add(new PreciseMoney(self::OTHER_AMOUNT, new Currency(self::CURRENCY))); + + $money->shouldHaveType(PreciseMoney::class); + $money->getAmount()->shouldBe((string) $result); + } + + function it_throws_an_exception_when_currency_is_different_during_addition(Calculator $calculator) + { + $calculator->add((string) self::AMOUNT, (string) self::AMOUNT)->shouldNotBeCalled(); + + $this->shouldThrow(\InvalidArgumentException::class)->duringAdd(new PreciseMoney(self::AMOUNT, new Currency(self::OTHER_CURRENCY))); + } + + function it_subtracts_an_other_money(Calculator $calculator) + { + $result = self::AMOUNT - self::OTHER_AMOUNT; + + $calculator->subtract((string) self::AMOUNT, (string) self::OTHER_AMOUNT)->willReturn((string) $result); + $money = $this->subtract(new PreciseMoney(self::OTHER_AMOUNT, new Currency(self::CURRENCY))); + + $money->shouldHaveType(PreciseMoney::class); + $money->getAmount()->shouldBe((string) $result); + } + + function it_throws_an_exception_if_currency_is_different_during_subtractition(Calculator $calculator) + { + $calculator->subtract((string) self::AMOUNT, (string) self::AMOUNT)->shouldNotBeCalled(); + + $this->shouldThrow(\InvalidArgumentException::class)->duringSubtract(new PreciseMoney(self::AMOUNT, new Currency(self::OTHER_CURRENCY))); + } + + function it_multiplies_the_amount(Calculator $calculator) + { + $this->beConstructedWith(1, new Currency(self::CURRENCY)); + + $calculator->multiply('1', 5)->willReturn(5); + $calculator->round(5, PreciseMoney::ROUND_HALF_UP)->willReturn(5); + + $money = $this->multiply(5); + + $money->shouldHaveType(PreciseMoney::class); + $money->getAmount()->shouldBe('5'); + } + + public function it_throws_an_exception_when_operand_is_invalid_during_multiplication(Calculator $calculator) + { + $calculator->multiply(Argument::type('string'), Argument::type('numeric'))->shouldNotBeCalled(); + $calculator->round(Argument::type('string'), Argument::type('integer'))->shouldNotBeCalled(); + + $this->shouldThrow(\InvalidArgumentException::class)->duringMultiply('INVALID_OPERAND'); + } + + function it_divides_the_amount(Calculator $calculator) + { + $this->beConstructedWith(4, new Currency(self::CURRENCY)); + + $calculator->compare((string) (1 / 2), '0')->willReturn(1 / 2 > 1); + $calculator->divide('4', 1 / 2)->willReturn(2); + $calculator->round(2, PreciseMoney::ROUND_HALF_UP)->willReturn(2); + + $money = $this->divide(1 / 2, PreciseMoney::ROUND_HALF_UP); + + $money->shouldHaveType(PreciseMoney::class); + $money->getAmount()->shouldBeLike(2); + } + + public function it_throws_an_exception_when_operand_is_invalid_during_division(Calculator $calculator) + { + $calculator->compare(Argument::type('string'), Argument::type('string'))->shouldNotBeCalled(); + $calculator->divide(Argument::type('string'), Argument::type('numeric'))->shouldNotBeCalled(); + $calculator->round(Argument::type('string'), Argument::type('integer'))->shouldNotBeCalled(); + + $this->shouldThrow(\InvalidArgumentException::class)->duringDivide('INVALID_OPERAND'); + } + + function it_throws_an_exception_when_divisor_is_zero(Calculator $calculator) + { + $calculator->compare(0, '0')->willThrow(\InvalidArgumentException::class); + $calculator->divide(Argument::type('string'), Argument::type('numeric'))->shouldNotBeCalled(); + $calculator->round(Argument::type('string'), Argument::type('integer'))->shouldNotBeCalled(); + + $this->shouldThrow(\InvalidArgumentException::class)->duringDivide(0); + } + + function it_allocates_amount(Calculator $calculator) + { + $this->beConstructedWith(100, new Currency(self::CURRENCY)); + + $calculator->share(Argument::type('numeric'), Argument::type('int'), Argument::type('int'))->will(function($args) { + return (int) floor($args[0] * $args[1] / $args[2]); + }); + + $calculator->subtract(Argument::type('numeric'), Argument::type('int'))->will(function($args) { + return (string) $args[0] - $args[1]; + }); + + $calculator->add(Argument::type('numeric'), Argument::type('int'))->will(function($args) { + return (string) ($args[0] + $args[1]); + }); + + $calculator->compare(Argument::type('numeric'), Argument::type('int'))->will(function($args) { + return ($args[0] < $args[1]) ? -1 : (($args[0] > $args[1]) ? 1 : 0); + }); + + $calculator->absolute(Argument::type('numeric'))->will(function($args) { + return ltrim($args[0], '-'); + }); + + $calculator->multiply(Argument::type('numeric'), Argument::type('int'))->will(function($args) { + return (string) $args[0] * $args[1]; + }); + + $allocated = $this->allocate([1, 1, 1]); + $allocated->shouldBeArray(); + $allocated->shouldEqualAllocation([34, 33, 33]); + } + + function it_allocates_amount_to_n_targets(Calculator $calculator) + { + $this->beConstructedWith(15, new Currency(self::CURRENCY)); + + $calculator->share(Argument::type('numeric'), Argument::type('int'), Argument::type('int'))->will(function($args) { + return (int) floor($args[0] * $args[1] / $args[2]); + }); + + $calculator->subtract(Argument::type('numeric'), Argument::type('int'))->will(function($args) { + return $args[0] - $args[1]; + }); + + $calculator->add(Argument::type('numeric'), Argument::type('int'))->will(function($args) { + return $args[0] + $args[1]; + }); + + $calculator->compare(Argument::type('numeric'), Argument::type('int'))->will(function($args) { + return ($args[0] < $args[1]) ? -1 : (($args[0] > $args[1]) ? 1 : 0); + }); + + $allocated = $this->allocateTo(2); + $allocated->shouldBeArray(); + + $allocated->shouldEqualAllocation([8, 7]); + } + + function it_throws_an_exception_when_allocation_target_is_not_integer() + { + $this->shouldThrow(\InvalidArgumentException::class)->duringAllocateTo('two'); + } + + function it_throws_an_exception_when_allocation_target_is_empty() + { + $this->shouldThrow(\InvalidArgumentException::class)->duringAllocate([]); + } + + function it_throws_an_exception_when_allocation_ratio_is_negative() + { + $this->shouldThrow(\InvalidArgumentException::class)->duringAllocate([-1]); + } + + function it_throws_an_exception_when_allocation_total_is_zero() + { + $this->shouldThrow(\InvalidArgumentException::class)->duringAllocate([0, 0]); + } + + function it_throws_an_exception_when_allocate_to_target_is_less_than_or_equals_zero() + { + $this->shouldThrow(\InvalidArgumentException::class)->duringAllocateTo(-1); + } + + function it_has_comparators(Calculator $calculator) + { + $this->beConstructedWith(1, new Currency(self::CURRENCY)); + + $calculator->compare(Argument::type('numeric'), Argument::type('int'))->will(function($args) { + return ($args[0] < $args[1]) ? -1 : (($args[0] > $args[1]) ? 1 : 0); + }); + + $this->isZero()->shouldReturn(false); + $this->isPositive()->shouldReturn(true); + $this->isNegative()->shouldReturn(false); + } + + function it_calculates_the_absolute_amount(Calculator $calculator) + { + $this->beConstructedWith(-1, new Currency(self::CURRENCY)); + + $calculator->absolute(-1)->willReturn(1); + + $money = $this->absolute(); + + $money->shouldHaveType(PreciseMoney::class); + $money->getAmount()->shouldBeLike(1); + } + + public function getMatchers() + { + return [ + 'equalAllocation' => function ($subject, $value) { + /** @var PreciseMoney $money */ + foreach ($subject as $key => $money) { + $compareTo = new PreciseMoney($value[$key], $money->getCurrency()); + if ($money->equals($compareTo) === false) { + return false; + } + } + + return true; + }, + ]; + } +} diff --git a/src/Calculator/BcMathCalculator.php b/src/Calculator/BcMathCalculator.php index 074464a6..bd56b4c0 100644 --- a/src/Calculator/BcMathCalculator.php +++ b/src/Calculator/BcMathCalculator.php @@ -37,7 +37,7 @@ public static function supported() */ public function compare($a, $b) { - return bccomp($a, $b); + return bccomp($a, $b, $this->scale); } /** @@ -45,7 +45,7 @@ public function compare($a, $b) */ public function add($amount, $addend) { - return bcadd($amount, $addend, 0); + return (string) Number::fromString(bcadd($amount, $addend, $this->scale)); } /** @@ -58,7 +58,7 @@ public function add($amount, $addend) */ public function subtract($amount, $subtrahend) { - return bcsub($amount, $subtrahend, 0); + return (string) Number::fromString(bcsub($amount, $subtrahend, $this->scale)); } /** diff --git a/src/Calculator/GmpCalculator.php b/src/Calculator/GmpCalculator.php index e5d72600..33df23b0 100644 --- a/src/Calculator/GmpCalculator.php +++ b/src/Calculator/GmpCalculator.php @@ -37,7 +37,19 @@ public static function supported() */ public function compare($a, $b) { - return gmp_cmp($a, $b); + $a = Number::fromString($a); + $b = Number::fromString($b); + + if ($a->isInteger() && $b->isInteger()) { + return gmp_cmp((string) $a, (string) $b); + } + + $compareIntegers = gmp_cmp($a->getIntegerPart(), $b->getIntegerPart()); + if ($compareIntegers !== '0') { + return $compareIntegers; + } + + return gmp_cmp($a->getFractionalPart(), $b->getFractionalPart()); } /** @@ -45,7 +57,36 @@ public function compare($a, $b) */ public function add($amount, $addend) { - return gmp_strval(gmp_add($amount, $addend)); + $amount = Number::fromString($amount); + $addend = Number::fromString($addend); + + if ($amount->isInteger() && $addend->isInteger()) { + return gmp_strval(gmp_add((string) $amount, (string) $addend)); + } + + $integer = gmp_add($amount->getIntegerPart(), $addend->getIntegerPart()); + if ($amount->isInteger()) { + return gmp_strval($integer).'.'.$addend->getFractionalPart(); + } + + if ($addend->isInteger()) { + return gmp_strval($integer).'.'.$amount->getFractionalPart(); + } + + $largestDigits = max(strlen($amount->getFractionalPart()), strlen($addend->getFractionalPart())); + $basedAmount = $amount->getIntegerPart().str_pad($amount->getFractionalPart(), $largestDigits, '0'); + $basedAddend = $addend->getIntegerPart().str_pad($addend->getFractionalPart(), $largestDigits, '0'); + + $basedResult = gmp_strval(gmp_add($basedAmount, $basedAddend)); + $integerPart = substr($basedResult, 0, $largestDigits * -1); + if ($integerPart === '-') { + $integerPart = '-0'; + } + + return (string) (new Number( + $integerPart, + rtrim(substr($basedResult, $largestDigits * -1), '0') + )); } /** @@ -53,7 +94,55 @@ public function add($amount, $addend) */ public function subtract($amount, $subtrahend) { - return gmp_strval(gmp_sub($amount, $subtrahend)); + $amount = Number::fromString($amount); + $subtrahend = Number::fromString($subtrahend); + + if ($amount->isInteger() && $subtrahend->isInteger()) { + return gmp_strval(gmp_sub((string) $amount, (string) $subtrahend)); + } + + $largestDigits = max(strlen($amount->getFractionalPart()), strlen($subtrahend->getFractionalPart())); + + $basedAmount = $this->trimLeadingZeros( + $amount->getIntegerPart().str_pad($amount->getFractionalPart(), $largestDigits, '0') + ); + + $basedSubtrahend = $this->trimLeadingZeros( + $subtrahend->getIntegerPart().str_pad($subtrahend->getFractionalPart(), $largestDigits, '0') + ); + + $basedResult = $this->trimLeadingZeros(gmp_strval(gmp_sub($basedAmount, $basedSubtrahend))); + + $leadingZeros = str_pad('', max(strlen($basedAmount), strlen($basedSubtrahend)), '0'); + if ($basedResult[0] === '-') { + $basedResult = '-'.$leadingZeros.substr($basedResult, 1); + } else { + $basedResult = $leadingZeros.$basedResult; + } + + $integerPart = $this->trimLeadingZeros(substr($basedResult, 0, $largestDigits * -1)); + if ($integerPart === '-') { + $integerPart = '-0'; + } + + return (string) (new Number( + $integerPart, + rtrim(substr($basedResult, $largestDigits * -1), '0') + )); + } + + /** + * @param $value + * + * @return string + */ + private function trimLeadingZeros($value) + { + if ($value[0] === '-') { + return '-'.ltrim(substr($value, 1), '0'); + } + + return ltrim($value, '0'); } /** diff --git a/src/Calculator/PhpCalculator.php b/src/Calculator/PhpCalculator.php index b9e35c96..2b265d96 100644 --- a/src/Calculator/PhpCalculator.php +++ b/src/Calculator/PhpCalculator.php @@ -32,11 +32,38 @@ public function compare($a, $b) */ public function add($amount, $addend) { - $result = $amount + $addend; + $amount = Number::fromString($amount); + $addend = Number::fromString($addend); - $this->assertInteger($result); + if ($amount->isInteger() && $addend->isInteger()) { + $result = (string) $amount + (string) $addend; - return (string) $result; + return (string) $result; + } + + $integer = $amount->getIntegerPart() + $addend->getIntegerPart(); + if ($amount->isInteger()) { + return $integer.'.'.$addend->getFractionalPart(); + } + + if ($addend->isInteger()) { + return $integer.'.'.$amount->getFractionalPart(); + } + + $largestDigits = max(strlen($amount->getFractionalPart()), strlen($addend->getFractionalPart())); + $basedAmount = $amount->getIntegerPart().str_pad($amount->getFractionalPart(), $largestDigits, '0'); + $basedAddend = $addend->getIntegerPart().str_pad($addend->getFractionalPart(), $largestDigits, '0'); + + $basedResult = $basedAmount + $basedAddend; + $integerPart = substr($basedResult, 0, $largestDigits * -1); + if ($integerPart === '-') { + $integerPart = '-0'; + } + + return (string) (new Number( + $integerPart, + rtrim(substr($basedResult, $largestDigits * -1), '0') + )); } /** @@ -44,11 +71,57 @@ public function add($amount, $addend) */ public function subtract($amount, $subtrahend) { - $result = $amount - $subtrahend; + $amount = Number::fromString($amount); + $subtrahend = Number::fromString($subtrahend); - $this->assertInteger($result); + if ($amount->isInteger() && $subtrahend->isInteger()) { + $result = (string) $amount - (string) $subtrahend; - return (string) $result; + return (string) $result; + } + + $largestDigits = max(strlen($amount->getFractionalPart()), strlen($subtrahend->getFractionalPart())); + + $basedAmount = $this->trimLeadingZeros( + $amount->getIntegerPart().str_pad($amount->getFractionalPart(), $largestDigits, '0') + ); + + $basedSubtrahend = $this->trimLeadingZeros( + $subtrahend->getIntegerPart().str_pad($subtrahend->getFractionalPart(), $largestDigits, '0') + ); + + $basedResult = $this->trimLeadingZeros($basedAmount - $basedSubtrahend); + + $leadingZeros = str_pad('', max(strlen($basedAmount), strlen($basedSubtrahend)), '0'); + if ($basedResult[0] === '-') { + $basedResult = '-'.$leadingZeros.substr($basedResult, 1); + } else { + $basedResult = $leadingZeros.$basedResult; + } + + $integerPart = $this->trimLeadingZeros(substr($basedResult, 0, $largestDigits * -1)); + if ($integerPart === '-') { + $integerPart = '-0'; + } + + return (string) (new Number( + $integerPart, + rtrim(substr($basedResult, $largestDigits * -1), '0') + )); + } + + /** + * @param $value + * + * @return string + */ + private function trimLeadingZeros($value) + { + if ($value[0] === '-') { + return '-'.ltrim(substr($value, 1), '0'); + } + + return ltrim($value, '0'); } /** @@ -170,16 +243,4 @@ private function castInteger($amount) return (string) intval($amount); } - - /** - * Asserts that integer remains integer after arithmetic operations. - * - * @param int $amount - */ - private function assertInteger($amount) - { - if (filter_var($amount, FILTER_VALIDATE_INT) === false) { - throw new \UnexpectedValueException('The result of arithmetic operation is not an integer'); - } - } } diff --git a/src/Number.php b/src/Number.php index 8ed96158..9c34d4a3 100644 --- a/src/Number.php +++ b/src/Number.php @@ -26,15 +26,15 @@ final class Number public function __construct($integerPart, $fractionalPart = '') { if ($this->validateNumberAsInteger($integerPart) === false) { - throw new \InvalidArgumentException('Invalid number'); + throw new \InvalidArgumentException('Invalid number'.$integerPart); } if ($fractionalPart !== '' && $this->validateNumberAsInteger($fractionalPart) === false) { throw new \InvalidArgumentException('Invalid number'); } - $this->integerPart = $integerPart ? $integerPart : '0'; - $this->fractionalPart = $fractionalPart; + $this->integerPart = (string) ($integerPart ? $integerPart : '0'); + $this->fractionalPart = (string) $fractionalPart; } /** diff --git a/src/PreciseMoney.php b/src/PreciseMoney.php new file mode 100644 index 00000000..b22938cb --- /dev/null +++ b/src/PreciseMoney.php @@ -0,0 +1,455 @@ +amount = (string) Number::fromString((string) $amount); + $this->currency = $currency; + } + + /** + * Convenience factory method for a Money object. + * + * + * $fiveDollar = Money::USD(500); + * + * + * @param string $method + * @param array $arguments + * + * @return Money + * + * @throws \InvalidArgumentException If amount is not integer + */ + public static function __callStatic($method, $arguments) + { + return new self($arguments[0], new Currency($method)); + } + + /** + * Returns a new Money instance based on the current one using the Currency. + * + * @param string $amount + * + * @return Money + * + * @throws \InvalidArgumentException If amount is not integer + */ + private function newInstance($amount) + { + return new self($amount, $this->currency); + } + + /** + * Checks whether a Money has the same Currency as this. + * + * @param PreciseMoney $other + * + * @return bool + */ + public function isSameCurrency(PreciseMoney $other) + { + return $this->currency->equals($other->currency); + } + + /** + * Asserts that a Money has the same currency as this. + * + * @param PreciseMoney $other + * + * @throws \InvalidArgumentException If $other has a different currency + */ + private function assertSameCurrency(PreciseMoney $other) + { + if (!$this->isSameCurrency($other)) { + throw new \InvalidArgumentException('Currencies must be identical'); + } + } + + /** + * Checks whether the value represented by this object equals to the other. + * + * @param PreciseMoney $other + * + * @return bool + */ + public function equals(PreciseMoney $other) + { + return $this->isSameCurrency($other) && $this->amount === $other->amount; + } + + /** + * Returns an integer less than, equal to, or greater than zero + * if the value of this object is considered to be respectively + * less than, equal to, or greater than the other. + * + * @param PreciseMoney $other + * + * @return int + */ + public function compare(PreciseMoney $other) + { + $this->assertSameCurrency($other); + + return $this->getCalculator()->compare($this->amount, $other->amount); + } + + /** + * Checks whether the value represented by this object is greater than the other. + * + * @param PreciseMoney $other + * + * @return bool + */ + public function greaterThan(PreciseMoney $other) + { + return $this->compare($other) === 1; + } + + /** + * @param PreciseMoney $other + * + * @return bool + */ + public function greaterThanOrEqual(PreciseMoney $other) + { + return $this->compare($other) >= 0; + } + + /** + * Checks whether the value represented by this object is less than the other. + * + * @param PreciseMoney $other + * + * @return bool + */ + public function lessThan(PreciseMoney $other) + { + return $this->compare($other) === -1; + } + + /** + * @param PreciseMoney $other + * + * @return bool + */ + public function lessThanOrEqual(PreciseMoney $other) + { + return $this->compare($other) <= 0; + } + + /** + * Returns the value represented by this object. + * + * @return string + */ + public function getAmount() + { + return $this->amount; + } + + /** + * Returns the currency of this object. + * + * @return Currency + */ + public function getCurrency() + { + return $this->currency; + } + + /** + * Returns a new Money object that represents + * the sum of this and an other Money object. + * + * @param PreciseMoney $addend + * + * @return Money + */ + public function add(PreciseMoney $addend) + { + $this->assertSameCurrency($addend); + + return new self($this->getCalculator()->add($this->amount, $addend->amount), $this->currency); + } + + /** + * Returns a new Money object that represents + * the difference of this and an other Money object. + * + * @param PreciseMoney $subtrahend + * + * @return PreciseMoney + */ + public function subtract(PreciseMoney $subtrahend) + { + $this->assertSameCurrency($subtrahend); + + return new self($this->getCalculator()->subtract($this->amount, $subtrahend->amount), $this->currency); + } + + /** + * Asserts that the operand is integer or float. + * + * @param float|int|string $operand + * + * @throws \InvalidArgumentException If $operand is neither integer nor float + */ + private function assertOperand($operand) + { + if (!is_numeric($operand)) { + throw new \InvalidArgumentException(sprintf( + 'Operand should be a numeric value, "%s" given.', + is_object($operand) ? get_class($operand) : gettype($operand) + )); + } + } + + /** + * Returns a new Money object that represents + * the multiplied value by the given factor. + * + * @param float|int|string $multiplier + * + * @return Money + */ + public function multiply($multiplier) + { + $this->assertOperand($multiplier); + + $product = $this->getCalculator()->multiply($this->amount, $multiplier); + + return $this->newInstance($product); + } + + /** + * Returns a new Money object that represents + * the divided value by the given factor. + * + * @param float|int|string $divisor + * + * @return Money + */ + public function divide($divisor) + { + $this->assertOperand($divisor); + + if ($this->getCalculator()->compare((string) $divisor, '0') === 0) { + throw new \InvalidArgumentException('Division by zero'); + } + + $quotient = $this->getCalculator()->divide($this->amount, $divisor); + + return $this->newInstance($quotient); + } + + /** + * Allocate the money according to a list of ratios. + * + * @param array $ratios + * + * @return Money[] + */ + public function allocate(array $ratios) + { + if (count($ratios) === 0) { + throw new \InvalidArgumentException('Cannot allocate to none, ratios cannot be an empty array'); + } + + $remainder = $this->amount; + $results = []; + $total = array_sum($ratios); + + if ($total <= 0) { + throw new \InvalidArgumentException('Cannot allocate to none, sum of ratios must be greater than zero'); + } + + foreach ($ratios as $ratio) { + if ($ratio < 0) { + throw new \InvalidArgumentException('Cannot allocate to none, ratio must be zero or positive'); + } + + $share = $this->getCalculator()->share($this->amount, $ratio, $total); + $results[] = $this->newInstance($share); + $remainder = $this->getCalculator()->subtract($remainder, $share); + } + + for ($i = 0; $this->getCalculator()->compare($remainder, 0) === 1; ++$i) { + $results[$i]->amount = (string) $this->getCalculator()->add($results[$i]->amount, 1); + $remainder = $this->getCalculator()->subtract($remainder, 1); + } + + return $results; + } + + /** + * Allocate the money among N targets. + * + * @param int $n + * + * @return Money[] + * + * @throws \InvalidArgumentException If number of targets is not an integer + */ + public function allocateTo($n) + { + if (!is_int($n)) { + throw new \InvalidArgumentException('Number of targets must be an integer'); + } + + if ($n <= 0) { + throw new \InvalidArgumentException('Cannot allocate to none, target must be greater than zero'); + } + + return $this->allocate(array_fill(0, $n, 1)); + } + + /** + * @return Money + */ + public function absolute() + { + return $this->newInstance($this->getCalculator()->absolute($this->amount)); + } + + /** + * Checks if the value represented by this object is zero. + * + * @return bool + */ + public function isZero() + { + return $this->getCalculator()->compare($this->amount, 0) === 0; + } + + /** + * Checks if the value represented by this object is positive. + * + * @return bool + */ + public function isPositive() + { + return $this->getCalculator()->compare($this->amount, 0) === 1; + } + + /** + * Checks if the value represented by this object is negative. + * + * @return bool + */ + public function isNegative() + { + return $this->getCalculator()->compare($this->amount, 0) === -1; + } + + /** + * {@inheritdoc} + * + * @return array + */ + public function jsonSerialize() + { + return [ + 'amount' => $this->amount, + 'currency' => $this->currency, + ]; + } + + /** + * @param string $calculator + */ + public static function registerCalculator($calculator) + { + if (is_a($calculator, Calculator::class, true) === false) { + throw new \InvalidArgumentException('Calculator must implement '.Calculator::class); + } + + array_unshift(self::$calculators, $calculator); + } + + /** + * @return Calculator + * + * @throws \RuntimeException If cannot find calculator for money calculations + */ + private static function initializeCalculator() + { + $calculators = self::$calculators; + + foreach ($calculators as $calculator) { + /** @var Calculator $calculator */ + if ($calculator::supported()) { + return new $calculator(); + } + } + + throw new \RuntimeException('Cannot find calculator for money calculations'); + } + + /** + * @return Calculator + */ + private function getCalculator() + { + if (null === self::$calculator) { + self::$calculator = self::initializeCalculator(); + } + + return self::$calculator; + } +} diff --git a/tests/Calculator/CalculatorTestCase.php b/tests/Calculator/CalculatorTestCase.php index ba6c440c..8d9ed037 100644 --- a/tests/Calculator/CalculatorTestCase.php +++ b/tests/Calculator/CalculatorTestCase.php @@ -20,7 +20,7 @@ abstract protected function getCalculator(); */ public function it_adds_two_values($value1, $value2, $expected) { - $this->assertEquals($expected, $this->getCalculator()->add($value1, $value2)); + $this->assertSame($expected, $this->getCalculator()->add($value1, $value2)); } /** @@ -96,11 +96,30 @@ public function it_rounds_a_value($value, $mode, $expected) $this->assertEquals($expected, $this->getCalculator()->round($value, $mode)); } + /** + * @dataProvider compareExamples + * @test + */ + public function it_compares_values($value1, $value2, $expected) + { + $this->assertEquals($expected, $this->getCalculator()->compare($value1, $value2)); + } + public function additionExamples() { return [ [1, 1, '2'], [10, 5, '15'], + ['1.5', '2', '3.5'], + ['1.81', '2.34', '4.15'], + ['1.12', '2.13', '3.25'], + ['1.12', '2.134', '3.254'], + ['1', '2.5', '3.5'], + ['9.999', '0.001', '10'], + ['-5.891', '5.108', '-0.783'], + ['-1.1', '1.1', '0'], + ['-1.1', '-1.1', '-2.2'], + ['1.1', '-1.1', '0'], ]; } @@ -109,6 +128,14 @@ public function subtractionExamples() return [ [1, 1, '0'], [10, 5, '5'], + ['10.1', '0.1', '10'], + ['99.01', '99.02', '-0.01'], + ['10', '-0.01', '10.01'], + ['-10', '-0.01', '-9.99'], + ['-10.875', '-0.125', '-10.75'], + ['-10.875', '-0.025', '-10.85'], + ['-10.875', '0.125', '-11'], + ['-9', '-4', '-5'], ]; } @@ -175,4 +202,16 @@ public function shareExamples() [10, 2, 4, '5'], ]; } + + public function compareExamples() + { + return [ + ['1', '2', '-1'], + ['2', '1', '1'], + ['1', '1', '0'], + ['1.5', '2', '-1'], + ['2', '1.5', '1'], + ['2', '1.5', '1'], + ]; + } } diff --git a/tests/PreciseMoneyTest.php b/tests/PreciseMoneyTest.php new file mode 100644 index 00000000..6276867c --- /dev/null +++ b/tests/PreciseMoneyTest.php @@ -0,0 +1,292 @@ +assertInstanceOf(PreciseMoney::class, $money); + $this->assertEquals('20', $money->getAmount()); + $this->assertEquals('XYZ', $money->getCurrency()->getCode()); + } + + /** + * @dataProvider equalityExamples + * @test + */ + public function it_equals_to_another_money($amount, $currency, $equality) + { + $money = new PreciseMoney(self::AMOUNT, new Currency(self::CURRENCY)); + + $this->assertEquals($equality, $money->equals(new PreciseMoney($amount, $currency))); + } + + /** + * @dataProvider comparisonExamples + * @test + */ + public function it_compares_two_amounts($other, $result) + { + $money = new PreciseMoney(self::AMOUNT, new Currency(self::CURRENCY)); + $other = new PreciseMoney($other, new Currency(self::CURRENCY)); + + $this->assertEquals($result, $money->compare($other)); + $this->assertEquals(1 === $result, $money->greaterThan($other)); + $this->assertEquals(0 <= $result, $money->greaterThanOrEqual($other)); + $this->assertEquals(-1 === $result, $money->lessThan($other)); + $this->assertEquals(0 >= $result, $money->lessThanOrEqual($other)); + } + + /** + * @dataProvider invalidOperandExamples + * @expectedException \InvalidArgumentException + * @test + */ + public function it_throws_an_exception_when_operand_is_invalid_during_multiplication($operand) + { + $money = new PreciseMoney(1, new Currency(self::CURRENCY)); + + $money->multiply($operand); + } + + /** + * @dataProvider roundExamples + */ + public function it_divides_the_amount($divisor, $roundingMode, $result) + { + $money = new PreciseMoney(1, new Currency(self::CURRENCY)); + + $money = $money->divide(1 / $divisor, $roundingMode); + + $this->assertInstanceOf(PreciseMoney::class, $money); + $this->assertEquals((string) $result, $money->getAmount()); + } + + /** + * @dataProvider invalidOperandExamples + * @expectedException \InvalidArgumentException + * @test + */ + public function it_throws_an_exception_when_operand_is_invalid_during_division($operand) + { + $money = new PreciseMoney(1, new Currency(self::CURRENCY)); + + $money->divide($operand); + } + + /** + * @dataProvider allocationExamples + * @test + */ + public function it_allocates_amount($amount, $ratios, $results) + { + $money = new PreciseMoney($amount, new Currency(self::CURRENCY)); + + $allocated = $money->allocate($ratios); + + foreach ($allocated as $key => $money) { + $compareTo = new PreciseMoney($results[$key], $money->getCurrency()); + + $this->assertTrue($money->equals($compareTo)); + } + } + + /** + * @dataProvider allocationTargetExamples + * @test + */ + public function it_allocates_amount_to_n_targets($amount, $target, $results) + { + $money = new PreciseMoney($amount, new Currency(self::CURRENCY)); + + $allocated = $money->allocateTo($target); + + foreach ($allocated as $key => $money) { + $compareTo = new PreciseMoney($results[$key], $money->getCurrency()); + + $this->assertTrue($money->equals($compareTo)); + } + } + + /** + * @dataProvider comparatorExamples + * @test + */ + public function it_has_comparators($amount, $isZero, $isPositive, $isNegative) + { + $money = new PreciseMoney($amount, new Currency(self::CURRENCY)); + + $this->assertEquals($isZero, $money->isZero()); + $this->assertEquals($isPositive, $money->isPositive()); + $this->assertEquals($isNegative, $money->isNegative()); + } + + /** + * @dataProvider absoluteExamples + * @test + */ + public function it_calculates_the_absolute_amount($amount, $result) + { + $money = new PreciseMoney($amount, new Currency(self::CURRENCY)); + + $money = $money->absolute(); + + $this->assertEquals($result, $money->getAmount()); + } + + public function test_it_converts_to_json() + { + $this->assertEquals( + '{"amount":"350","currency":"EUR"}', + json_encode(PreciseMoney::EUR(350)) + ); + } + + public function test_it_supports_max_int() + { + $one = new PreciseMoney(1, new Currency('EUR')); + + $this->assertInstanceOf(PreciseMoney::class, new PreciseMoney(PHP_INT_MAX, new Currency('EUR'))); + $this->assertInstanceOf(PreciseMoney::class, (new PreciseMoney(PHP_INT_MAX, new Currency('EUR')))->add($one)); + $this->assertInstanceOf(PreciseMoney::class, (new PreciseMoney(PHP_INT_MAX, new Currency('EUR')))->subtract($one)); + } + + public function test_it_supports_adding_decimals() + { + $one = new PreciseMoney('1', new Currency('EUR')); + $onePointFive = new PreciseMoney('1.5', new Currency('EUR')); + + $result = $one->add($onePointFive); + + $this->assertInstanceOf(PreciseMoney::class, $result); + $this->assertEquals('2.5', $result->getAmount()); + } + + public function test_it_supports_subtracting_decimals() + { + $one = new PreciseMoney('1', new Currency('EUR')); + $onePointFive = new PreciseMoney('1.5', new Currency('EUR')); + + $result = $one->subtract($onePointFive); + + $this->assertInstanceOf(PreciseMoney::class, $result); + $this->assertEquals('-0.5', $result->getAmount()); + } + + public function test_it_supports_multiplying_decimals() + { + $one = new PreciseMoney('1.5', new Currency('EUR')); + + $result = $one->multiply('1.5'); + + $this->assertInstanceOf(PreciseMoney::class, $result); + $this->assertEquals('2.25', $result->getAmount()); + } + + public function test_it_supports_dividing_decimals() + { + $one = new PreciseMoney('1.5', new Currency('EUR')); + + $result = $one->divide('1.5'); + + $this->assertInstanceOf(PreciseMoney::class, $result); + $this->assertEquals('1', $result->getAmount()); + } + + public function test_it_compares_decimals() + { + $one = new PreciseMoney('1.5', new Currency('EUR')); + $other = new PreciseMoney('-1.5', new Currency('EUR')); + + $this->assertTrue($one->greaterThan($other)); + } + + public function equalityExamples() + { + return [ + [self::AMOUNT, new Currency(self::CURRENCY), true], + [self::AMOUNT + 1, new Currency(self::CURRENCY), false], + [self::AMOUNT, new Currency(self::OTHER_CURRENCY), false], + [self::AMOUNT + 1, new Currency(self::OTHER_CURRENCY), false], + ]; + } + + public function comparisonExamples() + { + return [ + [self::AMOUNT, 0], + [self::AMOUNT - 1, 1], + [self::AMOUNT + 1, -1], + ]; + } + + public function invalidOperandExamples() + { + return [ + [[]], + [false], + ['operand'], + [null], + [new \stdClass()], + ]; + } + + public function allocationExamples() + { + return [ + [100, [1, 1, 1], [34, 33, 33]], + [101, [1, 1, 1], [34, 34, 33]], + [5, [3, 7], [2, 3]], + [5, [7, 3], [4, 1]], + [5, [7, 3, 0], [4, 1, 0]], + [-5, [7, 3], [-3, -2]], + ]; + } + + public function allocationTargetExamples() + { + return [ + [15, 2, [8, 7]], + [10, 2, [5, 5]], + [15, 3, [5, 5, 5]], + [10, 3, [4, 3, 3]], + ]; + } + + public function comparatorExamples() + { + return [ + [1, false, true, false], + [0, true, false, false], + [-1, false, false, true], + ['1', false, true, false], + ['0', true, false, false], + ['-1', false, false, true], + ]; + } + + public function absoluteExamples() + { + return [ + [1, 1], + [0, 0], + [-1, 1], + ['1', 1], + ['0', 0], + ['-1', 1], + ]; + } +}