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

Feature/money refactor #55

Merged
merged 9 commits into from
Sep 2, 2014
85 changes: 73 additions & 12 deletions src/Money.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
namespace Money;

use InvalidArgumentException;
use OverflowException;
use UnderflowException;
use UnexpectedValueException;

/**
* Money Value Object
Expand Down Expand Up @@ -46,8 +49,9 @@ class Money
public function __construct($amount, Currency $currency)
{
if (!is_int($amount)) {
throw new InvalidArgumentException("The first parameter of Money must be an integer. It's the amount, expressed in the smallest units of currency (eg cents)");
throw new InvalidArgumentException('Amount must be an integer');
}

$this->amount = $amount;
$this->currency = $currency;
}
Expand Down Expand Up @@ -101,7 +105,7 @@ public function isSameCurrency(Money $other)
private function assertSameCurrency(Money $other)
{
if (!$this->isSameCurrency($other)) {
throw new InvalidArgumentException('Different currencies');
throw new InvalidArgumentException('Currencies must be identical');
}
}

Expand All @@ -114,9 +118,7 @@ private function assertSameCurrency(Money $other)
*/
public function equals(Money $other)
{
return
$this->isSameCurrency($other)
&& $this->amount == $other->amount;
return $this->isSameCurrency($other) && $this->amount == $other->amount;
}

/**
Expand All @@ -131,6 +133,7 @@ public function equals(Money $other)
public function compare(Money $other)
{
$this->assertSameCurrency($other);

if ($this->amount < $other->amount) {
return -1;
} elseif ($this->amount == $other->amount) {
Expand Down Expand Up @@ -196,6 +199,18 @@ public function getCurrency()
return $this->currency;
}

/**
* Asserts that integer remains integer after arithmetic operations
*
* @param numeric $amount
*/
public function assertInteger($amount)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why public?

{
if (!is_int($amount)) {
throw new UnexpectedValueException('The result of arithmetic operation is not an integer');
}
}

/**
* Returns a new Money object that represents
* the sum of this and an other Money object
Expand All @@ -208,7 +223,11 @@ public function add(Money $addend)
{
$this->assertSameCurrency($addend);

return $this->newInstance($this->amount + $addend->amount);
$amount = $this->amount + $addend->amount;

$this->assertInteger($amount);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can't possibly fail, the sum of two integers is an integer.


return $this->newInstance($amount);
}

/**
Expand All @@ -223,7 +242,11 @@ public function subtract(Money $subtrahend)
{
$this->assertSameCurrency($subtrahend);

return $this->newInstance($this->amount - $subtrahend->amount);
$amount = $this->amount - $subtrahend->amount;

$this->assertInteger($amount);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can't possibly fail, the subtraction of two integers is an integer.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not in the case of an overflow. Php overflows to float.

On Tuesday, 2 September 2014, Mathias Verraes [email protected]
wrote:

In src/Money.php:

@@ -223,7 +242,11 @@ public function subtract(Money $subtrahend)
{
$this->assertSameCurrency($subtrahend);

  •    return $this->newInstance($this->amount - $subtrahend->amount);
    
  •    $amount = $this->amount - $subtrahend->amount;
    
  •    $this->assertInteger($amount);
    

This can't possibly fail, the subtraction of two integers is an integer.


Reply to this email directly or view it on GitHub
https://github.com/mathiasverraes/money/pull/55/files#r16972124.


return $this->newInstance($amount);
}

/**
Expand All @@ -238,6 +261,38 @@ private function assertOperand($operand)
}
}

/**
* Asserts that an integer value didn't become something else
* (after some arithmetic operation)
*
* @param numeric $amount
*
* @throws OverflowException If integer overflow occured
* @throws UnderflowException If integer underflow occured
*/
private function assertIntegerBounds($amount)
{
if ($amount > PHP_INT_MAX) {
throw new OverflowException;
} elseif ($amount < ~PHP_INT_MAX) {
throw new UnderflowException;
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really see the point. If you have 92233720368547758.07 EUR/USD/... then you probably didn't make that using this library :-) Oh well, happy to merge it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haha, yeah I see what you mean. The context where it has been useful for me
in the past, is having a fully general way to handle loss of precision in
ecommerce. This includes handling cases where things added to a cart for
example cause overflow.

On Tuesday, 2 September 2014, Mathias Verraes [email protected]
wrote:

In src/Money.php:

  • \* Asserts that an integer value didn't become something else
    
  • \* (after some arithmetic operation)
    
  • *
    
  • \* @param numeric $amount
    
  • *
    
  • \* @throws OverflowException If integer overflow occured
    
  • \* @throws UnderflowException If integer underflow occured
    
  • */
    
  • private function assertIntegerBounds($amount)
  • {
  •    if ($amount > PHP_INT_MAX) {
    
  •        throw new OverflowException;
    
  •    } elseif ($amount < ~PHP_INT_MAX) {
    
  •        throw new UnderflowException;
    
  •    }
    
  • }

I don't really see the point. If you have 92233720368547758.07 EUR/USD/...
then you probably didn't make that using this library :-) Oh well, happy to
merge it.


Reply to this email directly or view it on GitHub
https://github.com/mathiasverraes/money/pull/55/files#r16972250.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The max/avarage amount is dependent of the currency. So in case of high inflation, it easily reaches the maximum integer value. Not speaking of 32bit integer.


/**
* Casts an amount to integer ensuring that an overflow/underflow did not occur
*
* @param numeric $amount
*
* @return integer
*/
private function castInteger($amount)
{
$this->assertIntegerBounds($amount);

return intval($amount);
}

/**
* Returns a new Money object that represents
* the multiplied value by the given factor
Expand All @@ -255,7 +310,9 @@ public function multiply($multiplier, $rounding_mode = self::ROUND_HALF_UP)
$rounding_mode = new RoundingMode($rounding_mode);
}

$product = (int) round($this->amount * $multiplier, 0, $rounding_mode->getRoundingMode());
$product = round($this->amount * $multiplier, 0, $rounding_mode->getRoundingMode());

$product = $this->castInteger($product);

return $this->newInstance($product);
}
Expand All @@ -277,7 +334,9 @@ public function divide($divisor, $rounding_mode = self::ROUND_HALF_UP)
$rounding_mode = new RoundingMode($rounding_mode);
}

$quotient = (int) round($this->amount / $divisor, 0, $rounding_mode->getRoundingMode());
$quotient = round($this->amount / $divisor, 0, $rounding_mode->getRoundingMode());

$quotient = $this->castInteger($quotient);

return $this->newInstance($quotient);
}
Expand All @@ -287,7 +346,7 @@ public function divide($divisor, $rounding_mode = self::ROUND_HALF_UP)
*
* @param array $ratios
*
* @return Money
* @return Money[]
*/
public function allocate(array $ratios)
{
Expand All @@ -296,10 +355,11 @@ public function allocate(array $ratios)
$total = array_sum($ratios);

foreach ($ratios as $ratio) {
$share = (int) floor($this->amount * $ratio / $total);
$share = $this->castInteger($this->amount * $ratio / $total);
$results[] = $this->newInstance($share);
$remainder -= $share;
}

for ($i = 0; $remainder > 0; $i++) {
$results[$i]->amount++;
$remainder--;
Expand Down Expand Up @@ -351,8 +411,9 @@ public static function stringToUnits($string)
{
//@todo extend the regular expression with grouping characters and eventually currencies
if (!preg_match("/(-)?(\d+)([.,])?(\d)?(\d)?/", $string, $matches)) {
throw new InvalidArgumentException("The value could not be parsed as money");
throw new InvalidArgumentException('The value could not be parsed as money');
}

$units = $matches[1] == "-" ? "-" : "";
$units .= $matches[2];
$units .= isset($matches[4]) ? $matches[4] : "0";
Expand Down
84 changes: 83 additions & 1 deletion tests/MoneyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,18 @@

namespace Money;

/**
* @coversDefaultClass Money\Money
* @uses Money\Currency
* @uses Money\Money
* @uses Money\RoundingMode
* @uses Money\CurrencyPair
*/
class MoneyTest extends \PHPUnit_Framework_TestCase
{
/**
* @covers ::__callStatic
*/
public function testFactoryMethods()
{
$this->assertEquals(
Expand All @@ -25,6 +35,10 @@ public function testFactoryMethods()
);
}

/**
* @covers ::getAmount
* @covers ::getCurrency
*/
public function testGetters()
{
$m = new Money(100, $euro = new Currency('EUR'));
Expand All @@ -33,6 +47,7 @@ public function testGetters()
}

/**
* @covers ::__construct
* @expectedException InvalidArgumentException
*/
public function testDecimalsThrowException()
Expand All @@ -41,13 +56,17 @@ public function testDecimalsThrowException()
}

/**
* @covers ::__construct
* @expectedException InvalidArgumentException
*/
public function testStringThrowsException()
{
$money = new Money('100', new Currency('EUR'));
}

/**
* @covers ::equals
*/
public function testEquality()
{
$m1 = new Money(100, new Currency('EUR'));
Expand Down Expand Up @@ -84,6 +103,19 @@ public function testDifferentCurrenciesCannotBeAdded()
$m1->add($m2);
}

/**
* @expectedException UnexpectedValueException
*/
public function testResultNotAnInteger()
{
$m1 = new Money(PHP_INT_MAX, new Currency('EUR'));
$m2 = new Money(1, new Currency('EUR'));
$m1->add($m2);
}

/**
* @covers ::subtract
*/
public function testSubtraction()
{
$m1 = new Money(100, new Currency('EUR'));
Expand Down Expand Up @@ -123,6 +155,36 @@ public function testMultiplication()
$this->assertNotSame($m, $m->multiply(2));
}

/**
* @expectedException InvalidArgumentException
*/
public function testInvalidMultiplicationOperand()
{
$m = new Money(1, new Currency('EUR'));
$m->multiply('operand');
}

/**
* @expectedException OverflowException
*/
public function testMultiplicationOverflow()
{
$m = new Money(PHP_INT_MAX, new Currency('EUR'));
$m->multiply(2);
}

/**
* @expectedException UnderflowException
*/
public function testMultiplicationUnderflow()
{
$m = new Money(~PHP_INT_MAX, new Currency('EUR'));
$m->multiply(2);
}

/**
* @covers ::divide
*/
public function testDivision()
{
$m = new Money(10, new Currency('EUR'));
Expand Down Expand Up @@ -166,6 +228,9 @@ public function testDifferentCurrenciesCannotBeCompared()
Money::EUR(1)->compare(Money::USD(1));
}

/**
* @covers ::allocate
*/
public function testAllocation()
{
$m = new Money(100, new Currency('EUR'));
Expand All @@ -181,9 +246,11 @@ public function testAllocation()
$this->assertEquals(new Money(33, new Currency('EUR')), $part3);
}

/**
* @covers ::allocate
*/
public function testAllocationOrderIsImportant()
{

$m = new Money(5, new Currency('EUR'));
list($part1, $part2) = $m->allocate(array(3, 7));
$this->assertEquals(new Money(2, new Currency('EUR')), $part1);
Expand All @@ -195,6 +262,11 @@ public function testAllocationOrderIsImportant()
$this->assertEquals(new Money(1, new Currency('EUR')), $part2);
}

/**
* @covers ::isZero
* @covers ::isNegative
* @covers ::isPositive
*/
public function testComparators()
{
$this->assertTrue(Money::EUR(0)->isZero());
Expand Down Expand Up @@ -227,10 +299,20 @@ public static function provideStrings()
}

/**
* @covers ::stringToUnits
* @dataProvider provideStrings
*/
public function testStringToUnits($string, $units)
{
$this->assertEquals($units, Money::stringToUnits($string));
}

/**
* @covers ::stringToUnits
* @expectedException InvalidArgumentException
*/
public function testCannotConvertStringToUnits()
{
Money::stringToUnits('THIS_IS_NOT_CONVERTABLE_TO_UNIT');
}
}