diff --git a/src/Parser/ExponentialMoneyParser.php b/src/Parser/ExponentialMoneyParser.php new file mode 100644 index 00000000..05b5d525 --- /dev/null +++ b/src/Parser/ExponentialMoneyParser.php @@ -0,0 +1,119 @@ + + */ +final class ExponentialMoneyParser implements MoneyParser +{ + const EXPO_DECIMAL_PATTERN = '/^(?P-)?(?P0|[1-9]\d*)?\.?(?P\d+)?[eE][-+]\d+$/'; + + const DECIMAL_PATTERN = '/^(?P-)?(?P0|[1-9]\d*)?\.?(?P\d+)?$/'; + + /** + * @var Currencies + */ + private $currencies; + + /** + * @param Currencies $currencies + */ + public function __construct(Currencies $currencies) + { + $this->currencies = $currencies; + } + + /** + * {@inheritdoc} + */ + public function parse($money, $forceCurrency = null) + { + if (!is_string($money)) { + throw new ParserException('Formatted raw money should be string, e.g. 1.00'); + } + + if (null === $forceCurrency) { + throw new ParserException( + 'ExponentialMoneyParser cannot parse currency symbols. Use forceCurrency argument' + ); + } + + /* + * This conversion is only required whilst currency can be either a string or a + * Currency object. + */ + $currency = $forceCurrency; + if (!$currency instanceof Currency) { + @trigger_error('Passing a currency as string is deprecated since 3.1 and will be removed in 4.0. Please pass a '.Currency::class.' instance instead.', E_USER_DEPRECATED); + $currency = new Currency($currency); + } + + $expo = trim($money); + if ($expo === '') { + return new Money(0, $currency); + } + + $subunit = $this->currencies->subunitFor($currency); + + if (!preg_match(self::EXPO_DECIMAL_PATTERN, $expo, $matches) || !isset($matches['digits'])) { + throw new ParserException(sprintf( + 'Cannot parse "%s" to Money.', + $expo + )); + } + + $number = number_format($expo, $subunit, '.', ''); + if (!preg_match(self::DECIMAL_PATTERN, $number, $matches) || !isset($matches['digits'])) { + throw new ParserException(sprintf( + 'Cannot parse "%s" to Money.', + $expo + )); + } + + $negative = isset($matches['sign']) && $matches['sign'] === '-'; + + $decimal = $matches['digits']; + + if ($negative) { + $decimal = '-'.$decimal; + } + + if (isset($matches['fraction'])) { + $fractionDigits = strlen($matches['fraction']); + $decimal .= $matches['fraction']; + $decimal = Number::roundMoneyValue($decimal, $subunit, $fractionDigits); + + if ($fractionDigits > $subunit) { + $decimal = substr($decimal, 0, $subunit - $fractionDigits); + } elseif ($fractionDigits < $subunit) { + $decimal .= str_pad('', $subunit - $fractionDigits, '0'); + } + } else { + $decimal .= str_pad('', $subunit, '0'); + } + + if ($negative) { + $decimal = '-'.ltrim(substr($decimal, 1), '0'); + } else { + $decimal = ltrim($decimal, '0'); + } + + if ($decimal === '' || $decimal === '-') { + $decimal = '0'; + } + + return new Money($decimal, $currency); + } +} diff --git a/tests/Parser/ExponentialMoneyParserTest.php b/tests/Parser/ExponentialMoneyParserTest.php new file mode 100644 index 00000000..9bd6ab28 --- /dev/null +++ b/tests/Parser/ExponentialMoneyParserTest.php @@ -0,0 +1,96 @@ +prophesize(Currencies::class); + + $currencies->subunitFor(Argument::allOf( + Argument::type(Currency::class), + Argument::which('getCode', $currency) + ))->willReturn($subunit); + + $parser = new ExponentialMoneyParser($currencies->reveal()); + + $this->assertEquals($result, $parser->parse($decimal, new Currency($currency))->getAmount()); + } + + /** + * @dataProvider invalidMoneyExamples + * @test + */ + public function it_throws_an_exception_upon_invalid_inputs($input) + { + $this->expectException(ParserException::class); + + $currencies = $this->prophesize(Currencies::class); + + $currencies->subunitFor(Argument::allOf( + Argument::type(Currency::class), + Argument::which('getCode', 'USD') + ))->willReturn(2); + + $parser = new ExponentialMoneyParser($currencies->reveal()); + + $parser->parse($input, new Currency('USD'))->getAmount(); + } + + /** + * @group legacy + * @expectedDeprecation Passing a currency as string is deprecated since 3.1 and will be removed in 4.0. Please pass a Money\Currency instance instead. + * @test + */ + public function it_accepts_only_a_currency_object() + { + $currencies = $this->prophesize(Currencies::class); + + $currencies->subunitFor(Argument::allOf( + Argument::type(Currency::class), + Argument::which('getCode', 'USD') + ))->willReturn(2); + + $parser = new ExponentialMoneyParser($currencies->reveal()); + + $parser->parse('2.8865798640254e+15', 'USD')->getAmount(); + } + + public function formattedMoneyExamples() + { + return [ + ['2.8865798640254e+15', 'USD', 2, 288657986402540000], + ['2.8865798640254e-15', 'USD', 2, 0], + ['0.8865798640254e+15', 'USD', 2, 88657986402540000], + ['2.8865798640254e+15', 'JPY', 0, 2886579864025400], + ['2.8865798640254e-15', 'JPY', 0, 0], + ['0.8865798640254e+15', 'JPY', 0, 886579864025400], + ['-2.8865798640254e+15', 'USD', 2, -288657986402540000], + ['-2.8865798640254e-15', 'USD', 2, 0], + ['-0.8865798640254e+15', 'USD', 2, -88657986402540000], + ]; + } + + public static function invalidMoneyExamples() + { + return [ + ['INVALID'], + ['2.00'], + ['2'], + ['0.02'], + ['.'], + ]; + } +}