From 529b53273713978c71a1bd64f1b3b1b1cb66a7ca Mon Sep 17 00:00:00 2001 From: Frederik Bosch Date: Tue, 29 Nov 2016 20:01:03 +0100 Subject: [PATCH 01/12] Copy functionality of Money to PreciseMoney --- src/PreciseMoney.php | 507 +++++++++++++++++++++++++++++++++++++ tests/PreciseMoneyTest.php | 256 +++++++++++++++++++ 2 files changed, 763 insertions(+) create mode 100644 src/PreciseMoney.php create mode 100644 tests/PreciseMoneyTest.php diff --git a/src/PreciseMoney.php b/src/PreciseMoney.php new file mode 100644 index 000000000..8837fe770 --- /dev/null +++ b/src/PreciseMoney.php @@ -0,0 +1,507 @@ +isInteger()) { + throw new \InvalidArgumentException('Amount must be an integer(ish) value'); + } + + $this->amount = (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 int|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 \Money\Money $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 Money $subtrahend + * + * @return Money + */ + 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) + )); + } + } + + /** + * Asserts that rounding mode is a valid integer value. + * + * @param int $roundingMode + * + * @throws \InvalidArgumentException If $roundingMode is not valid + */ + private function assertRoundingMode($roundingMode) + { + if (!in_array( + $roundingMode, [ + self::ROUND_HALF_DOWN, self::ROUND_HALF_EVEN, self::ROUND_HALF_ODD, + self::ROUND_HALF_UP, self::ROUND_UP, self::ROUND_DOWN, + self::ROUND_HALF_POSITIVE_INFINITY, self::ROUND_HALF_NEGATIVE_INFINITY, + ], true + )) { + throw new \InvalidArgumentException( + 'Rounding mode should be Money::ROUND_HALF_DOWN | '. + 'Money::ROUND_HALF_EVEN | Money::ROUND_HALF_ODD | '. + 'Money::ROUND_HALF_UP | Money::ROUND_UP | Money::ROUND_DOWN'. + 'Money::ROUND_HALF_POSITIVE_INFINITY | Money::ROUND_HALF_NEGATIVE_INFINITY' + ); + } + } + + /** + * Returns a new Money object that represents + * the multiplied value by the given factor. + * + * @param float|int|string $multiplier + * @param int $roundingMode + * + * @return Money + */ + public function multiply($multiplier, $roundingMode = self::ROUND_HALF_UP) + { + $this->assertOperand($multiplier); + $this->assertRoundingMode($roundingMode); + + $product = $this->round($this->getCalculator()->multiply($this->amount, $multiplier), $roundingMode); + + return $this->newInstance($product); + } + + /** + * Returns a new Money object that represents + * the divided value by the given factor. + * + * @param float|int|string $divisor + * @param int $roundingMode + * + * @return Money + */ + public function divide($divisor, $roundingMode = self::ROUND_HALF_UP) + { + $this->assertOperand($divisor); + $this->assertRoundingMode($roundingMode); + + if ($this->getCalculator()->compare((string) $divisor, '0') === 0) { + throw new \InvalidArgumentException('Division by zero'); + } + + $quotient = $this->round($this->getCalculator()->divide($this->amount, $divisor), $roundingMode); + + 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)); + } + + /** + * @param int|float $amount + * @param $rounding_mode + * + * @return string + */ + private function round($amount, $rounding_mode) + { + $this->assertRoundingMode($rounding_mode); + + if ($rounding_mode === self::ROUND_UP) { + return $this->getCalculator()->ceil($amount); + } + + if ($rounding_mode === self::ROUND_DOWN) { + return $this->getCalculator()->floor($amount); + } + + return $this->getCalculator()->round($amount, $rounding_mode); + } + + /** + * @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/PreciseMoneyTest.php b/tests/PreciseMoneyTest.php new file mode 100644 index 000000000..2655fb666 --- /dev/null +++ b/tests/PreciseMoneyTest.php @@ -0,0 +1,256 @@ +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 roundExamples + * @test + */ + public function it_multiplies_the_amount($multiplier, $roundingMode, $result) + { + $money = new PreciseMoney(1, new Currency(self::CURRENCY)); + + $money = $money->multiply($multiplier, $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_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 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], + ]; + } +} From 6e124893afeabc7aed40acb73dcc5731afd95cd2 Mon Sep 17 00:00:00 2001 From: Frederik Bosch Date: Tue, 29 Nov 2016 20:05:16 +0100 Subject: [PATCH 02/12] Add spec tests for PreciseMoney --- spec/PreciseMoneySpec.php | 339 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 spec/PreciseMoneySpec.php diff --git a/spec/PreciseMoneySpec.php b/spec/PreciseMoneySpec.php new file mode 100644 index 000000000..a8f5b635b --- /dev/null +++ b/spec/PreciseMoneySpec.php @@ -0,0 +1,339 @@ +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_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'); + } + + public function it_throws_an_exception_when_rounding_mode_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(1.0, 'INVALID_ROUNDING_MODE'); + } + + 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'); + } + + public function it_throws_an_exception_when_rounding_mode_is_invalid_during_division(Calculator $calculator) + { + $calculator->compare('1.0', '0')->shouldNotBeCalled(); + $calculator->divide(Argument::type('string'), Argument::type('numeric'))->shouldNotBeCalled(); + $calculator->round(Argument::type('string'), Argument::type('integer'))->shouldNotBeCalled(); + + $this->shouldThrow(\InvalidArgumentException::class)->duringDivide(1.0, 'INVALID_ROUNDING_MODE'); + } + + 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; + }, + ]; + } +} From f73f3cae678644ad9430189b761640b8e691d097 Mon Sep 17 00:00:00 2001 From: Frederik Bosch Date: Tue, 29 Nov 2016 20:18:55 +0100 Subject: [PATCH 03/12] construct decimals --- spec/PreciseMoneySpec.php | 5 +++++ src/PreciseMoney.php | 12 ++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/spec/PreciseMoneySpec.php b/spec/PreciseMoneySpec.php index a8f5b635b..620be9011 100644 --- a/spec/PreciseMoneySpec.php +++ b/spec/PreciseMoneySpec.php @@ -60,6 +60,11 @@ 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)); diff --git a/src/PreciseMoney.php b/src/PreciseMoney.php index 8837fe770..55ad1bffe 100644 --- a/src/PreciseMoney.php +++ b/src/PreciseMoney.php @@ -49,18 +49,18 @@ final class PreciseMoney implements \JsonSerializable ]; /** - * @param int|string $amount Amount, expressed in the smallest units of $currency (eg cents) + * @param string $amount Amount, expressed in the smallest units of $currency (eg cents) * @param Currency $currency * * @throws \InvalidArgumentException If amount is not integer */ public function __construct($amount, Currency $currency) { - if (filter_var($amount, FILTER_VALIDATE_INT) === false && !Number::fromString($amount)->isInteger()) { - throw new \InvalidArgumentException('Amount must be an integer(ish) value'); + if (!is_int($amount) && !is_string($amount)) { + throw new \InvalidArgumentException('Amount must be a string'); } - $this->amount = (string) $amount; + $this->amount = (string) Number::fromString((string) $amount); $this->currency = $currency; } @@ -86,7 +86,7 @@ public static function __callStatic($method, $arguments) /** * Returns a new Money instance based on the current one using the Currency. * - * @param int|string $amount + * @param string $amount * * @return Money * @@ -164,7 +164,7 @@ public function greaterThan(PreciseMoney $other) } /** - * @param \Money\Money $other + * @param PreciseMoney $other * * @return bool */ From 633da38fa226859bfd46c29c3468f65ab6309c8b Mon Sep 17 00:00:00 2001 From: Frederik Bosch Date: Tue, 29 Nov 2016 21:09:03 +0100 Subject: [PATCH 04/12] add numbers precisely --- src/Calculator/BcMathCalculator.php | 2 +- src/Calculator/GmpCalculator.php | 31 ++++++++++++++++++++++++- src/Calculator/PhpCalculator.php | 16 ------------- src/Number.php | 4 ++-- tests/Calculator/CalculatorTestCase.php | 11 ++++++++- tests/PreciseMoneyTest.php | 11 +++++++++ 6 files changed, 54 insertions(+), 21 deletions(-) diff --git a/src/Calculator/BcMathCalculator.php b/src/Calculator/BcMathCalculator.php index 074464a6f..5f961e81b 100644 --- a/src/Calculator/BcMathCalculator.php +++ b/src/Calculator/BcMathCalculator.php @@ -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)); } /** diff --git a/src/Calculator/GmpCalculator.php b/src/Calculator/GmpCalculator.php index e5d72600f..f9a903926 100644 --- a/src/Calculator/GmpCalculator.php +++ b/src/Calculator/GmpCalculator.php @@ -45,7 +45,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 $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 = 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') + )); } /** diff --git a/src/Calculator/PhpCalculator.php b/src/Calculator/PhpCalculator.php index b9e35c967..992be2ca4 100644 --- a/src/Calculator/PhpCalculator.php +++ b/src/Calculator/PhpCalculator.php @@ -34,8 +34,6 @@ public function add($amount, $addend) { $result = $amount + $addend; - $this->assertInteger($result); - return (string) $result; } @@ -46,8 +44,6 @@ public function subtract($amount, $subtrahend) { $result = $amount - $subtrahend; - $this->assertInteger($result); - return (string) $result; } @@ -170,16 +166,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 8ed961588..6ab121cc4 100644 --- a/src/Number.php +++ b/src/Number.php @@ -33,8 +33,8 @@ public function __construct($integerPart, $fractionalPart = '') 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/tests/Calculator/CalculatorTestCase.php b/tests/Calculator/CalculatorTestCase.php index ba6c440c1..ea53ae549 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)); } /** @@ -101,6 +101,15 @@ 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', '0'], ]; } diff --git a/tests/PreciseMoneyTest.php b/tests/PreciseMoneyTest.php index 2655fb666..edd1ea8cb 100644 --- a/tests/PreciseMoneyTest.php +++ b/tests/PreciseMoneyTest.php @@ -178,6 +178,17 @@ public function test_it_supports_max_int() $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 equalityExamples() { return [ From faa126a88fb8fc2fbcd8181100c796dcc908fb66 Mon Sep 17 00:00:00 2001 From: Frederik Bosch Date: Tue, 29 Nov 2016 22:09:55 +0100 Subject: [PATCH 05/12] divide and multiply precise money --- src/Calculator/BcMathCalculator.php | 2 +- src/Calculator/GmpCalculator.php | 63 ++++++++++++++++++++++++- src/Calculator/PhpCalculator.php | 51 +++++++++++++++++++- src/Number.php | 2 +- src/PreciseMoney.php | 62 +++--------------------- tests/Calculator/CalculatorTestCase.php | 30 ++++++++++++ tests/PreciseMoneyTest.php | 53 +++++++++++++++------ 7 files changed, 187 insertions(+), 76 deletions(-) diff --git a/src/Calculator/BcMathCalculator.php b/src/Calculator/BcMathCalculator.php index 5f961e81b..9e618787c 100644 --- a/src/Calculator/BcMathCalculator.php +++ b/src/Calculator/BcMathCalculator.php @@ -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 f9a903926..30b464f9e 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()); } /** @@ -82,7 +94,54 @@ 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 992be2ca4..29f05277d 100644 --- a/src/Calculator/PhpCalculator.php +++ b/src/Calculator/PhpCalculator.php @@ -42,9 +42,56 @@ public function add($amount, $addend) */ public function subtract($amount, $subtrahend) { - $result = $amount - $subtrahend; + $amount = Number::fromString($amount); + $subtrahend = Number::fromString($subtrahend); - return (string) $result; + if ($amount->isInteger() && $subtrahend->isInteger()) { + $result = (string) $amount - (string) $subtrahend; + + 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'); } /** diff --git a/src/Number.php b/src/Number.php index 6ab121cc4..9c34d4a36 100644 --- a/src/Number.php +++ b/src/Number.php @@ -26,7 +26,7 @@ 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) { diff --git a/src/PreciseMoney.php b/src/PreciseMoney.php index 55ad1bffe..77af33d20 100644 --- a/src/PreciseMoney.php +++ b/src/PreciseMoney.php @@ -234,9 +234,9 @@ public function add(PreciseMoney $addend) * Returns a new Money object that represents * the difference of this and an other Money object. * - * @param Money $subtrahend + * @param PreciseMoney $subtrahend * - * @return Money + * @return PreciseMoney */ public function subtract(PreciseMoney $subtrahend) { @@ -262,46 +262,19 @@ private function assertOperand($operand) } } - /** - * Asserts that rounding mode is a valid integer value. - * - * @param int $roundingMode - * - * @throws \InvalidArgumentException If $roundingMode is not valid - */ - private function assertRoundingMode($roundingMode) - { - if (!in_array( - $roundingMode, [ - self::ROUND_HALF_DOWN, self::ROUND_HALF_EVEN, self::ROUND_HALF_ODD, - self::ROUND_HALF_UP, self::ROUND_UP, self::ROUND_DOWN, - self::ROUND_HALF_POSITIVE_INFINITY, self::ROUND_HALF_NEGATIVE_INFINITY, - ], true - )) { - throw new \InvalidArgumentException( - 'Rounding mode should be Money::ROUND_HALF_DOWN | '. - 'Money::ROUND_HALF_EVEN | Money::ROUND_HALF_ODD | '. - 'Money::ROUND_HALF_UP | Money::ROUND_UP | Money::ROUND_DOWN'. - 'Money::ROUND_HALF_POSITIVE_INFINITY | Money::ROUND_HALF_NEGATIVE_INFINITY' - ); - } - } - /** * Returns a new Money object that represents * the multiplied value by the given factor. * * @param float|int|string $multiplier - * @param int $roundingMode * * @return Money */ - public function multiply($multiplier, $roundingMode = self::ROUND_HALF_UP) + public function multiply($multiplier) { $this->assertOperand($multiplier); - $this->assertRoundingMode($roundingMode); - $product = $this->round($this->getCalculator()->multiply($this->amount, $multiplier), $roundingMode); + $product = $this->getCalculator()->multiply($this->amount, $multiplier); return $this->newInstance($product); } @@ -311,20 +284,18 @@ public function multiply($multiplier, $roundingMode = self::ROUND_HALF_UP) * the divided value by the given factor. * * @param float|int|string $divisor - * @param int $roundingMode * * @return Money */ - public function divide($divisor, $roundingMode = self::ROUND_HALF_UP) + public function divide($divisor) { $this->assertOperand($divisor); - $this->assertRoundingMode($roundingMode); if ($this->getCalculator()->compare((string) $divisor, '0') === 0) { throw new \InvalidArgumentException('Division by zero'); } - $quotient = $this->round($this->getCalculator()->divide($this->amount, $divisor), $roundingMode); + $quotient = $this->getCalculator()->divide($this->amount, $divisor); return $this->newInstance($quotient); } @@ -390,27 +361,6 @@ public function allocateTo($n) return $this->allocate(array_fill(0, $n, 1)); } - /** - * @param int|float $amount - * @param $rounding_mode - * - * @return string - */ - private function round($amount, $rounding_mode) - { - $this->assertRoundingMode($rounding_mode); - - if ($rounding_mode === self::ROUND_UP) { - return $this->getCalculator()->ceil($amount); - } - - if ($rounding_mode === self::ROUND_DOWN) { - return $this->getCalculator()->floor($amount); - } - - return $this->getCalculator()->round($amount, $rounding_mode); - } - /** * @return Money */ diff --git a/tests/Calculator/CalculatorTestCase.php b/tests/Calculator/CalculatorTestCase.php index ea53ae549..8d9ed037d 100644 --- a/tests/Calculator/CalculatorTestCase.php +++ b/tests/Calculator/CalculatorTestCase.php @@ -96,6 +96,15 @@ 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 [ @@ -109,6 +118,7 @@ public function additionExamples() ['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'], ]; } @@ -118,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'], ]; } @@ -184,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 index edd1ea8cb..6276867c4 100644 --- a/tests/PreciseMoneyTest.php +++ b/tests/PreciseMoneyTest.php @@ -50,20 +50,6 @@ public function it_compares_two_amounts($other, $result) $this->assertEquals(0 >= $result, $money->lessThanOrEqual($other)); } - /** - * @dataProvider roundExamples - * @test - */ - public function it_multiplies_the_amount($multiplier, $roundingMode, $result) - { - $money = new PreciseMoney(1, new Currency(self::CURRENCY)); - - $money = $money->multiply($multiplier, $roundingMode); - - $this->assertInstanceOf(PreciseMoney::class, $money); - $this->assertEquals((string) $result, $money->getAmount()); - } - /** * @dataProvider invalidOperandExamples * @expectedException \InvalidArgumentException @@ -189,6 +175,45 @@ public function test_it_supports_adding_decimals() $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 [ From 0d9be3a9f1aa94cc67c02fcb80879c86af78849c Mon Sep 17 00:00:00 2001 From: Frederik Bosch Date: Tue, 29 Nov 2016 22:15:55 +0100 Subject: [PATCH 06/12] style and bug fixes --- spec/PreciseMoneySpec.php | 17 -------------- src/Calculator/GmpCalculator.php | 8 +++---- src/Calculator/PhpCalculator.php | 39 ++++++++++++++++++++++++++++---- src/PreciseMoney.php | 4 ++-- 4 files changed, 40 insertions(+), 28 deletions(-) diff --git a/spec/PreciseMoneySpec.php b/spec/PreciseMoneySpec.php index 620be9011..5d107b19e 100644 --- a/spec/PreciseMoneySpec.php +++ b/spec/PreciseMoneySpec.php @@ -167,14 +167,6 @@ public function it_throws_an_exception_when_operand_is_invalid_during_multiplica $this->shouldThrow(\InvalidArgumentException::class)->duringMultiply('INVALID_OPERAND'); } - public function it_throws_an_exception_when_rounding_mode_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(1.0, 'INVALID_ROUNDING_MODE'); - } - function it_divides_the_amount(Calculator $calculator) { $this->beConstructedWith(4, new Currency(self::CURRENCY)); @@ -198,15 +190,6 @@ public function it_throws_an_exception_when_operand_is_invalid_during_division(C $this->shouldThrow(\InvalidArgumentException::class)->duringDivide('INVALID_OPERAND'); } - public function it_throws_an_exception_when_rounding_mode_is_invalid_during_division(Calculator $calculator) - { - $calculator->compare('1.0', '0')->shouldNotBeCalled(); - $calculator->divide(Argument::type('string'), Argument::type('numeric'))->shouldNotBeCalled(); - $calculator->round(Argument::type('string'), Argument::type('integer'))->shouldNotBeCalled(); - - $this->shouldThrow(\InvalidArgumentException::class)->duringDivide(1.0, 'INVALID_ROUNDING_MODE'); - } - function it_throws_an_exception_when_divisor_is_zero(Calculator $calculator) { $calculator->compare(0, '0')->willThrow(\InvalidArgumentException::class); diff --git a/src/Calculator/GmpCalculator.php b/src/Calculator/GmpCalculator.php index 30b464f9e..1d15365ba 100644 --- a/src/Calculator/GmpCalculator.php +++ b/src/Calculator/GmpCalculator.php @@ -41,7 +41,7 @@ public function compare($a, $b) $b = Number::fromString($b); if ($a->isInteger() && $b->isInteger()) { - return gmp_cmp((string)$a, (string)$b); + return gmp_cmp((string) $a, (string) $b); } $compareIntegers = gmp_cmp($a->getIntegerPart(), $b->getIntegerPart()); @@ -115,9 +115,9 @@ public function subtract($amount, $subtrahend) $leadingZeros = str_pad('', max(strlen($basedAmount), strlen($basedSubtrahend)), '0'); if ($basedResult[0] === '-') { - $basedResult = '-'.$leadingZeros . substr($basedResult, 1); + $basedResult = '-'.$leadingZeros.substr($basedResult, 1); } else { - $basedResult = $leadingZeros . $basedResult; + $basedResult = $leadingZeros.$basedResult; } $integerPart = $this->trimLeadingZeros(substr($basedResult, 0, $largestDigits * -1)); @@ -138,7 +138,7 @@ public function subtract($amount, $subtrahend) private function trimLeadingZeros($value) { if ($value[0] === '-') { - return '-' . ltrim(substr($value, 1), '0'); + return '-'.ltrim(substr($value, 1), '0'); } return ltrim($value, '0'); diff --git a/src/Calculator/PhpCalculator.php b/src/Calculator/PhpCalculator.php index 29f05277d..33dee0160 100644 --- a/src/Calculator/PhpCalculator.php +++ b/src/Calculator/PhpCalculator.php @@ -32,9 +32,38 @@ public function compare($a, $b) */ public function add($amount, $addend) { - $result = $amount + $addend; + $amount = Number::fromString($amount); + $addend = Number::fromString($addend); - return (string) $result; + if ($amount->isInteger() && $addend->isInteger()) { + $result = (string) $amount + (string) $addend; + + 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') + )); } /** @@ -65,9 +94,9 @@ public function subtract($amount, $subtrahend) $leadingZeros = str_pad('', max(strlen($basedAmount), strlen($basedSubtrahend)), '0'); if ($basedResult[0] === '-') { - $basedResult = '-'.$leadingZeros . substr($basedResult, 1); + $basedResult = '-'.$leadingZeros.substr($basedResult, 1); } else { - $basedResult = $leadingZeros . $basedResult; + $basedResult = $leadingZeros.$basedResult; } $integerPart = $this->trimLeadingZeros(substr($basedResult, 0, $largestDigits * -1)); @@ -88,7 +117,7 @@ public function subtract($amount, $subtrahend) private function trimLeadingZeros($value) { if ($value[0] === '-') { - return '-' . ltrim(substr($value, 1), '0'); + return '-'.ltrim(substr($value, 1), '0'); } return ltrim($value, '0'); diff --git a/src/PreciseMoney.php b/src/PreciseMoney.php index 77af33d20..277ba9ed9 100644 --- a/src/PreciseMoney.php +++ b/src/PreciseMoney.php @@ -49,8 +49,8 @@ final class PreciseMoney implements \JsonSerializable ]; /** - * @param string $amount Amount, expressed in the smallest units of $currency (eg cents) - * @param Currency $currency + * @param string $amount Amount, expressed in the smallest units of $currency (eg cents) + * @param Currency $currency * * @throws \InvalidArgumentException If amount is not integer */ From 47cfa2fd27d0874faeb732aaa26c5bbcc1f327cb Mon Sep 17 00:00:00 2001 From: Frederik Bosch Date: Tue, 29 Nov 2016 22:16:41 +0100 Subject: [PATCH 07/12] style and bug fixes --- src/Calculator/GmpCalculator.php | 1 + src/Calculator/PhpCalculator.php | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Calculator/GmpCalculator.php b/src/Calculator/GmpCalculator.php index 1d15365ba..b7ff18ffc 100644 --- a/src/Calculator/GmpCalculator.php +++ b/src/Calculator/GmpCalculator.php @@ -133,6 +133,7 @@ public function subtract($amount, $subtrahend) /** * @param $value + * * @return string */ private function trimLeadingZeros($value) diff --git a/src/Calculator/PhpCalculator.php b/src/Calculator/PhpCalculator.php index 33dee0160..2b265d962 100644 --- a/src/Calculator/PhpCalculator.php +++ b/src/Calculator/PhpCalculator.php @@ -112,6 +112,7 @@ public function subtract($amount, $subtrahend) /** * @param $value + * * @return string */ private function trimLeadingZeros($value) From 7e2b0cd07354a40b37638a392e05f8cd7f109a91 Mon Sep 17 00:00:00 2001 From: Frederik Bosch Date: Tue, 29 Nov 2016 22:18:51 +0100 Subject: [PATCH 08/12] remove php calculator from precise money --- src/PreciseMoney.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/PreciseMoney.php b/src/PreciseMoney.php index 277ba9ed9..b22938cb7 100644 --- a/src/PreciseMoney.php +++ b/src/PreciseMoney.php @@ -4,7 +4,6 @@ use Money\Calculator\BcMathCalculator; use Money\Calculator\GmpCalculator; -use Money\Calculator\PhpCalculator; /** * PreciseMoney Value Object. @@ -45,7 +44,6 @@ final class PreciseMoney implements \JsonSerializable private static $calculators = [ BcMathCalculator::class, GmpCalculator::class, - PhpCalculator::class, ]; /** From cf3620e73a95fed4adf5f1d6d5fcd7999c12a8d5 Mon Sep 17 00:00:00 2001 From: Frederik Bosch Date: Tue, 29 Nov 2016 22:25:35 +0100 Subject: [PATCH 09/12] fixes php 5 and hhvm --- src/Calculator/GmpCalculator.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Calculator/GmpCalculator.php b/src/Calculator/GmpCalculator.php index b7ff18ffc..36dc144f7 100644 --- a/src/Calculator/GmpCalculator.php +++ b/src/Calculator/GmpCalculator.php @@ -66,11 +66,11 @@ public function add($amount, $addend) $integer = gmp_add($amount->getIntegerPart(), $addend->getIntegerPart()); if ($amount->isInteger()) { - return $integer.'.'.$addend->getFractionalPart(); + return (string) $integer.'.'.$addend->getFractionalPart(); } if ($addend->isInteger()) { - return $integer.'.'.$amount->getFractionalPart(); + return (string) $integer.'.'.$amount->getFractionalPart(); } $largestDigits = max(strlen($amount->getFractionalPart()), strlen($addend->getFractionalPart())); From 179ab793fbd8415856104eb687b6e15e535d70a5 Mon Sep 17 00:00:00 2001 From: Frederik Bosch Date: Tue, 29 Nov 2016 22:33:40 +0100 Subject: [PATCH 10/12] never divide by zero in tests --- spec/Calculator/CalculatorBehavior.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/Calculator/CalculatorBehavior.php b/spec/Calculator/CalculatorBehavior.php index 109d47c67..ec4c46e70 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() From 139c34617bc761e9854c5e4561e68d69840fbd6b Mon Sep 17 00:00:00 2001 From: Frederik Bosch Date: Tue, 29 Nov 2016 22:41:46 +0100 Subject: [PATCH 11/12] fix php 5.5 and hhvm --- src/Calculator/GmpCalculator.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Calculator/GmpCalculator.php b/src/Calculator/GmpCalculator.php index 36dc144f7..33df23b0a 100644 --- a/src/Calculator/GmpCalculator.php +++ b/src/Calculator/GmpCalculator.php @@ -66,11 +66,11 @@ public function add($amount, $addend) $integer = gmp_add($amount->getIntegerPart(), $addend->getIntegerPart()); if ($amount->isInteger()) { - return (string) $integer.'.'.$addend->getFractionalPart(); + return gmp_strval($integer).'.'.$addend->getFractionalPart(); } if ($addend->isInteger()) { - return (string) $integer.'.'.$amount->getFractionalPart(); + return gmp_strval($integer).'.'.$amount->getFractionalPart(); } $largestDigits = max(strlen($amount->getFractionalPart()), strlen($addend->getFractionalPart())); From 212101d66ddcf3475ca3aa4594c16951f26533d2 Mon Sep 17 00:00:00 2001 From: Lajos Fazakas Date: Thu, 14 Sep 2017 17:18:27 +0300 Subject: [PATCH 12/12] Fix comparison operation The scale (precision) parameter was missing from the comparison operation, leading to wrong results. --- src/Calculator/BcMathCalculator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Calculator/BcMathCalculator.php b/src/Calculator/BcMathCalculator.php index 9e618787c..bd56b4c07 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); } /**