diff --git a/.php_cs.dist b/.php_cs.dist index 03393fb3..befefa1a 100644 --- a/.php_cs.dist +++ b/.php_cs.dist @@ -5,6 +5,7 @@ $finder = PhpCsFixer\Finder::create() ->exclude('resources') ->in(__DIR__) ->notPath('src/MoneyFactory.php') + ->notPath('src/MoneyRangeFactory.php') ; return PhpCsFixer\Config::create() diff --git a/composer.json b/composer.json index e2f0ddb0..9b739359 100644 --- a/composer.json +++ b/composer.json @@ -73,7 +73,8 @@ ], "update-currencies": [ "cp vendor/moneyphp/iso-currencies/resources/current.php resources/currency.php", - "php resources/generate-money-factory.php" + "php resources/generate-money-factory.php", + "php resources/generate-money-range-factory.php" ] }, "extra": { diff --git a/resources/currency.php b/resources/currency.php index 8c41c246..a8dd22e6 100644 --- a/resources/currency.php +++ b/resources/currency.php @@ -842,7 +842,7 @@ 'PHP' => array ( 'alphabeticCode' => 'PHP', - 'currency' => 'Philippine Piso', + 'currency' => 'Philippine Peso', 'minorUnit' => 2, 'numericCode' => 608, ), @@ -1122,7 +1122,7 @@ 'UYI' => array ( 'alphabeticCode' => 'UYI', - 'currency' => 'Uruguay Peso en Unidades Indexadas (URUIURUI)', + 'currency' => 'Uruguay Peso en Unidades Indexadas (UI)', 'minorUnit' => 0, 'numericCode' => 940, ), diff --git a/resources/generate-money-range-factory.php b/resources/generate-money-range-factory.php new file mode 100644 index 00000000..84d0f028 --- /dev/null +++ b/resources/generate-money-range-factory.php @@ -0,0 +1,64 @@ + + * \$fiveToTenDollars = MoneyRange::USD(500, 1000); + * + * + * @param string \$method + * @param array \$arguments + * + * @return MoneyRange + * + * @throws \InvalidArgumentException If either amount is not integer(ish) + */ + public static function __callStatic(\$method, \$arguments) + { + return new MoneyRange( + new Money(\$arguments[0], new Currency(\$method)), + new Money(\$arguments[1], new Currency(\$method)) + ); + } +} + +PHP; + +$methodBuffer = ''; + +$currencies = new Currencies\AggregateCurrencies([ + new Currencies\ISOCurrencies(), + new Currencies\BitcoinCurrencies(), +]); + +$currencies = iterator_to_array($currencies); + +usort($currencies, function (\Money\Currency $a, \Money\Currency $b) { + return strcmp($a->getCode(), $b->getCode()); +}); + +/** @var \Money\Currency[] $currencies */ +foreach ($currencies as $currency) { + $methodBuffer .= sprintf(" * @method static MoneyRange %s(string \$amount)\n", $currency->getCode()); +} + +$buffer = str_replace('PHPDOC', rtrim($methodBuffer), $buffer); + +file_put_contents(__DIR__.'/../src/MoneyRangeFactory.php', $buffer); diff --git a/src/MoneyRange.php b/src/MoneyRange.php new file mode 100644 index 00000000..cf71f24b --- /dev/null +++ b/src/MoneyRange.php @@ -0,0 +1,283 @@ + + */ +final class MoneyRange implements \JsonSerializable +{ + use MoneyRangeFactory; + + /** + * @var Money + */ + private $start; + + /** + * @var Money + */ + private $end; + + /** + * @param Money $start Start value + * @param Money $end End value + * + * @throws \InvalidArgumentException If the start and end currencies don't match, or the start value is greater than the end value + */ + public function __construct(Money $start, Money $end) + { + if (!$start->isSameCurrency($end)) { + throw new \InvalidArgumentException('Currencies must be identical'); + } + + if ($start->greaterThan($end)) { + throw new \InvalidArgumentException('End value must be equal to or larger than start value'); + } + + $this->start = $start; + $this->end = $end; + } + + /** + * Get the start value of this range. + * + * @return Money + */ + public function getStart() + { + return $this->start; + } + + /** + * Get the start value of this range. + * + * @return Money + */ + public function getEnd() + { + return $this->end; + } + + /** + * Checks whether the value represented by this object equals to the other. + * + * @param MoneyRange $other + * + * @return bool + */ + public function equals(MoneyRange $other) + { + return $this->isSameCurrency($other) + && $this->start->equals($other->start) + && $this->end->equals($other->end) + ; + } + + /** + * Get the mid point value of this range. + * + * @param int $roundingMode + * + * @return Money + */ + public function midPoint($roundingMode = Money::ROUND_HALF_UP) + { + return $this->start->add( + $this->end->subtract($this->start)->divide(2, $roundingMode) + ); + } + + /** + * Returns a new MoneyRange instance based on the current one using the new start value. + * + * @param Money $start New start value + * + * @return MoneyRange + */ + public function setStart(Money $start) + { + return new MoneyRange($start, $this->end); + } + + /** + * Returns a new MoneyRange instance based on the current one using the new end value. + * + * @param Money $end New end value + * + * @return MoneyRange + */ + public function setEnd(Money $end) + { + return new MoneyRange($this->start, $end); + } + + /** + * Checks whether a Money or MoneyRange has the same Currency as this. + * + * @param Money|MoneyRange $other + * + * @return bool + */ + public function isSameCurrency($other) + { + if (!$other instanceof Money && !$other instanceof MoneyRange) { + throw new \InvalidArgumentException( + \sprintf( + 'Argument passed must be %s or %s.', + Money::class, + MoneyRange::class + ) + ); + } + + return $this->start->getCurrency()->equals($other->getCurrency()); + } + + /** + * Asserts that a Money or MoneyRange has the same currency as this. + * + * @param Money|MoneyRange $other + * + * @throws \InvalidArgumentException If $other has a different currency + */ + private function assertSameCurrency($other) + { + if (!$other instanceof Money && !$other instanceof MoneyRange) { + throw new \InvalidArgumentException( + \sprintf( + 'Argument passed must be %s or %s.', + Money::class, + MoneyRange::class + ) + ); + } + + if (!$this->isSameCurrency($other)) { + throw new \InvalidArgumentException('Currencies must be identical'); + } + } + + /** + * Checks whether the range represented by this object contains a value. + * + * @param Money $value + * + * @return bool + */ + public function contains(Money $value) + { + return $this->isSameCurrency($value) + && $this->start->lessThanOrEqual($value) + && $this->end->greaterThanOrEqual($value); + } + + /** + * Checks whether the range represented by this object is greater than the value. + * + * @param Money $value + * + * @return bool + */ + public function greaterThan(Money $value) + { + return $this->start->greaterThan($value); + } + + /** + * Checks whether the range represented by this object is less than the value. + * + * @param Money $value + * + * @return bool + */ + public function lessThan(Money $value) + { + return $this->end->lessThan($value); + } + + /** + * Returns the currency of this object. + * + * @return Currency + */ + public function getCurrency() + { + return $this->start->getCurrency(); + } + + /** + * Returns a new MoneyRange object that represents + * the multiplied value by the given factor. + * + * @param float|int|string $multiplier + * @param int $roundingMode + * + * @return MoneyRange + */ + public function multiply($multiplier, $roundingMode = Money::ROUND_HALF_UP) + { + $start = $this->start->multiply($multiplier, $roundingMode); + $end = $this->end->multiply($multiplier, $roundingMode); + + return new MoneyRange($start, $end); + } + + /** + * Returns a new MoneyRange object that represents + * the divided value by the given factor. + * + * @param float|int|string $divisor + * @param int $roundingMode + * + * @return MoneyRange + */ + public function divide($divisor, $roundingMode = Money::ROUND_HALF_UP) + { + $start = $this->start->divide($divisor, $roundingMode); + $end = $this->end->divide($divisor, $roundingMode); + + return new MoneyRange($start, $end); + } + + /** + * @return MoneyRange + */ + public function absolute() + { + $start = $this->start->absolute(); + $end = $this->end->absolute(); + + if ($start->greaterThan($end)) { + return new MoneyRange($end, $start); + } + + return new MoneyRange($start, $end); + } + + /** + * Checks if the value represented by this object contains zero. + * + * @return bool + */ + public function containsZero() + { + return $this->contains(new Money(0, $this->start->getCurrency())); + } + + /** + * {@inheritdoc} + * + * @return array + */ + public function jsonSerialize() + { + return [ + 'start' => $this->start->getAmount(), + 'end' => $this->end->getAmount(), + 'currency' => $this->start->getCurrency(), + ]; + } +} diff --git a/src/MoneyRangeFactory.php b/src/MoneyRangeFactory.php new file mode 100644 index 00000000..753b8c60 --- /dev/null +++ b/src/MoneyRangeFactory.php @@ -0,0 +1,210 @@ + + * $fiveToTenDollars = MoneyRange::USD(500, 1000); + * + * + * @param string $method + * @param array $arguments + * + * @return MoneyRange + * + * @throws \InvalidArgumentException If either amount is not integer(ish) + */ + public static function __callStatic($method, $arguments) + { + return new MoneyRange( + new Money($arguments[0], new Currency($method)), + new Money($arguments[1], new Currency($method)) + ); + } +} diff --git a/tests/MoneyRangeFactoryTest.php b/tests/MoneyRangeFactoryTest.php new file mode 100644 index 00000000..ce5ec8d1 --- /dev/null +++ b/tests/MoneyRangeFactoryTest.php @@ -0,0 +1,46 @@ +getCode(); + $range = MoneyRange::{$code}(10, 20); + + $this->assertInstanceOf(MoneyRange::class, $range); + $this->assertEquals( + new MoneyRange(new Money(10, $currency), new Money(20, $currency)), + $range + ); + } + + public function currencyExamples() + { + $currencies = new AggregateCurrencies([ + new ISOCurrencies(), + new BitcoinCurrencies(), + ]); + + $examples = []; + + foreach ($currencies as $currency) { + $examples[] = [$currency]; + } + + return $examples; + } +} diff --git a/tests/MoneyRangeTest.php b/tests/MoneyRangeTest.php new file mode 100644 index 00000000..03d7e264 --- /dev/null +++ b/tests/MoneyRangeTest.php @@ -0,0 +1,204 @@ +assertEquals( + $equality, + $range->equals( + new MoneyRange( + new Money($startAmount, $currency), + new Money($endAmount, $currency) + ) + ) + ); + } + + /** + * @dataProvider comparisonExamples + * @test + */ + public function it_compares_to_values($amount, $result) + { + $currency = new Currency(self::CURRENCY); + + $range = new MoneyRange( + new Money(self::START_AMOUNT, $currency), + new Money(self::END_AMOUNT, $currency) + ); + + $value = new Money($amount, $currency); + + $this->assertEquals(1 === $result, $range->greaterThan($value)); + $this->assertEquals(-1 === $result, $range->lessThan($value)); + } + + /** + * @dataProvider invalidOperandExamples + * @test + */ + public function it_throws_an_exception_when_operand_is_invalid_during_multiplication($operand) + { + $this->expectException(\InvalidArgumentException::class); + + $currency = new Currency(self::CURRENCY); + + $range = new MoneyRange( + new Money(1, $currency), + new Money(2, $currency) + ); + + $range->multiply($operand); + } + + /** + * @dataProvider invalidOperandExamples + * @test + */ + public function it_throws_an_exception_when_operand_is_invalid_during_division($operand) + { + $this->expectException(\InvalidArgumentException::class); + + $currency = new Currency(self::CURRENCY); + + $range = new MoneyRange( + new Money(1, $currency), + new Money(2, $currency) + ); + + $range->divide($operand); + } + + /** + * @dataProvider absoluteExamples + * @test + */ + public function it_calculates_the_absolute_value($startAmount, $endAmount, $result) + { + $currency = new Currency(self::CURRENCY); + + $range = new MoneyRange( + new Money($startAmount, $currency), + new Money($endAmount, $currency) + ); + + $range = $range->absolute(); + + $this->assertEquals($result, $range); + } + + /** + * @dataProvider midPointExamples + * @test + */ + public function it_calculates_the_mid_point($startAmount, $endAmount, $result) + { + $currency = new Currency(self::CURRENCY); + + $range = new MoneyRange( + new Money($startAmount, $currency), + new Money($endAmount, $currency) + ); + + $midPoint = $range->midPoint(); + + $this->assertEquals($result, $midPoint->getAmount()); + } + + /** + * @test + */ + public function it_converts_to_json() + { + $this->assertEquals( + '{"start":"100","end":"350","currency":"EUR"}', + json_encode(MoneyRange::EUR(100,350)) + ); + } + + public function equalityExamples() + { + return [ + [self::START_AMOUNT, self::END_AMOUNT, new Currency(self::CURRENCY), true], + [self::START_AMOUNT + 1, self::END_AMOUNT, new Currency(self::CURRENCY), false], + [self::START_AMOUNT, self::END_AMOUNT + 1, new Currency(self::CURRENCY), false], + [self::START_AMOUNT, self::END_AMOUNT, new Currency(self::OTHER_CURRENCY), false], + [self::START_AMOUNT + 1, self::END_AMOUNT, new Currency(self::OTHER_CURRENCY), false], + [self::START_AMOUNT, self::END_AMOUNT + 1, new Currency(self::OTHER_CURRENCY), false], + [(string) self::START_AMOUNT, self::END_AMOUNT, new Currency(self::CURRENCY), true], + [((string) self::START_AMOUNT).'.000', self::END_AMOUNT, new Currency(self::CURRENCY), true], + ]; + } + + public function comparisonExamples() + { + return [ + [self::START_AMOUNT, 0], + [self::START_AMOUNT - 1, 1], + [self::END_AMOUNT + 1, -1], + ]; + } + + public function invalidOperandExamples() + { + return [ + [[]], + [false], + ['operand'], + [null], + [new \stdClass()], + ]; + } + + public function absoluteExamples() + { + $currency = new Currency(self::CURRENCY); + + return [ + [1, 2, new MoneyRange(new Money(1, $currency), new Money(2, $currency))], + [0, 0, new MoneyRange(new Money(0, $currency), new Money(0, $currency))], + [-1, 1, new MoneyRange(new Money(1, $currency), new Money(1, $currency))], + [-2, -1, new MoneyRange(new Money(1, $currency), new Money(2, $currency))], + ]; + } + + public function midPointExamples() + { + return [ + [10, 20, 15], + [10, 10, 10], + [0, 0, 0], + [-20, -10, -15], + [-10, 10, 0], + ]; + } +}