Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Precise money #335

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
2 changes: 1 addition & 1 deletion spec/Calculator/CalculatorBehavior.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
327 changes: 327 additions & 0 deletions spec/PreciseMoneySpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
<?php

namespace spec\Money;

use Money\Calculator;
use Money\Currency;
use Money\PreciseMoney;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;

final class PreciseMoneySpec extends ObjectBehavior
{
const AMOUNT = 10;
const OTHER_AMOUNT = 5;
const CURRENCY = 'EUR';
const OTHER_CURRENCY = 'USD';

function let(Calculator $calculator)
{
// Override the calculator for testing
$reflection = new \ReflectionProperty(PreciseMoney::class, 'calculator');
$reflection->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;
},
];
}
}
6 changes: 3 additions & 3 deletions src/Calculator/BcMathCalculator.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,15 @@ public static function supported()
*/
public function compare($a, $b)
{
return bccomp($a, $b);
return bccomp($a, $b, $this->scale);
}

/**
* {@inheritdoc}
*/
public function add($amount, $addend)
{
return bcadd($amount, $addend, 0);
return (string) Number::fromString(bcadd($amount, $addend, $this->scale));
}

/**
Expand All @@ -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));
}

/**
Expand Down
Loading