new appraoch

This commit is contained in:
Tim Bendt
2025-11-26 13:22:58 -05:00
parent de3d100844
commit c520b7df89
6760 changed files with 1009780 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
<?php
namespace Money;
/**
* Money calculations abstracted away from the Money value object.
*
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
interface Calculator
{
/**
* Returns whether the calculator is supported in
* the current server environment.
*
* @return bool
*/
public static function supported();
/**
* Compare a to b.
*
* @param string $a
* @param string $b
*
* @return int
*/
public function compare($a, $b);
/**
* Add added to amount.
*
* @param string $amount
* @param string $addend
*
* @return string
*/
public function add($amount, $addend);
/**
* Subtract subtrahend from amount.
*
* @param string $amount
* @param string $subtrahend
*
* @return string
*/
public function subtract($amount, $subtrahend);
/**
* Multiply amount with multiplier.
*
* @param string $amount
* @param int|float|string $multiplier
*
* @return string
*/
public function multiply($amount, $multiplier);
/**
* Divide amount with divisor.
*
* @param string $amount
* @param int|float|string $divisor
*
* @return string
*/
public function divide($amount, $divisor);
/**
* Round number to following integer.
*
* @param string $number
*
* @return string
*/
public function ceil($number);
/**
* Round number to preceding integer.
*
* @param string $number
*
* @return string
*/
public function floor($number);
/**
* Returns the absolute value of the number.
*
* @param string $number
*
* @return string
*/
public function absolute($number);
/**
* Round number, use rounding mode for tie-breaker.
*
* @param int|float|string $number
* @param int $roundingMode
*
* @return string
*/
public function round($number, $roundingMode);
/**
* Share amount among ratio / total portions.
*
* @param string $amount
* @param int|float|string $ratio
* @param int|float|string $total
*
* @return string
*/
public function share($amount, $ratio, $total);
/**
* Get the modulus of an amount.
*
* @param string $amount
* @param int|float|string $divisor
*
* @return string
*/
public function mod($amount, $divisor);
}

View File

@@ -0,0 +1,241 @@
<?php
namespace Money\Calculator;
use Money\Calculator;
use Money\Money;
use Money\Number;
/**
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
final class BcMathCalculator implements Calculator
{
/**
* @var string
*/
private $scale;
/**
* @param int $scale
*/
public function __construct($scale = 14)
{
$this->scale = $scale;
}
/**
* {@inheritdoc}
*/
public static function supported()
{
return extension_loaded('bcmath');
}
/**
* {@inheritdoc}
*/
public function compare($a, $b)
{
return bccomp($a, $b, $this->scale);
}
/**
* {@inheritdoc}
*/
public function add($amount, $addend)
{
return (string) Number::fromString(bcadd($amount, $addend, $this->scale));
}
/**
* {@inheritdoc}
*
* @param $amount
* @param $subtrahend
*
* @return string
*/
public function subtract($amount, $subtrahend)
{
return (string) Number::fromString(bcsub($amount, $subtrahend, $this->scale));
}
/**
* {@inheritdoc}
*/
public function multiply($amount, $multiplier)
{
$multiplier = Number::fromNumber($multiplier);
return bcmul($amount, (string) $multiplier, $this->scale);
}
/**
* {@inheritdoc}
*/
public function divide($amount, $divisor)
{
$divisor = Number::fromNumber($divisor);
return bcdiv($amount, (string) $divisor, $this->scale);
}
/**
* {@inheritdoc}
*/
public function ceil($number)
{
$number = Number::fromNumber($number);
if ($number->isInteger()) {
return (string) $number;
}
if ($number->isNegative()) {
return bcadd((string) $number, '0', 0);
}
return bcadd((string) $number, '1', 0);
}
/**
* {@inheritdoc}
*/
public function floor($number)
{
$number = Number::fromNumber($number);
if ($number->isInteger()) {
return (string) $number;
}
if ($number->isNegative()) {
return bcadd((string) $number, '-1', 0);
}
return bcadd($number, '0', 0);
}
/**
* {@inheritdoc}
*/
public function absolute($number)
{
return ltrim($number, '-');
}
/**
* {@inheritdoc}
*/
public function round($number, $roundingMode)
{
$number = Number::fromNumber($number);
if ($number->isInteger()) {
return (string) $number;
}
if ($number->isHalf() === false) {
return $this->roundDigit($number);
}
if (Money::ROUND_HALF_UP === $roundingMode) {
return bcadd(
(string) $number,
$number->getIntegerRoundingMultiplier(),
0
);
}
if (Money::ROUND_HALF_DOWN === $roundingMode) {
return bcadd((string) $number, '0', 0);
}
if (Money::ROUND_HALF_EVEN === $roundingMode) {
if ($number->isCurrentEven()) {
return bcadd((string) $number, '0', 0);
}
return bcadd(
(string) $number,
$number->getIntegerRoundingMultiplier(),
0
);
}
if (Money::ROUND_HALF_ODD === $roundingMode) {
if ($number->isCurrentEven()) {
return bcadd(
(string) $number,
$number->getIntegerRoundingMultiplier(),
0
);
}
return bcadd((string) $number, '0', 0);
}
if (Money::ROUND_HALF_POSITIVE_INFINITY === $roundingMode) {
if ($number->isNegative()) {
return bcadd((string) $number, '0', 0);
}
return bcadd(
(string) $number,
$number->getIntegerRoundingMultiplier(),
0
);
}
if (Money::ROUND_HALF_NEGATIVE_INFINITY === $roundingMode) {
if ($number->isNegative()) {
return bcadd(
(string) $number,
$number->getIntegerRoundingMultiplier(),
0
);
}
return bcadd(
(string) $number,
'0',
0
);
}
throw new \InvalidArgumentException('Unknown rounding mode');
}
/**
* @return string
*/
private function roundDigit(Number $number)
{
if ($number->isCloserToNext()) {
return bcadd(
(string) $number,
$number->getIntegerRoundingMultiplier(),
0
);
}
return bcadd((string) $number, '0', 0);
}
/**
* {@inheritdoc}
*/
public function share($amount, $ratio, $total)
{
return $this->floor(bcdiv(bcmul($amount, $ratio, $this->scale), $total, $this->scale));
}
/**
* {@inheritdoc}
*/
public function mod($amount, $divisor)
{
return bcmod($amount, $divisor);
}
}

View File

@@ -0,0 +1,322 @@
<?php
namespace Money\Calculator;
use Money\Calculator;
use Money\Money;
use Money\Number;
/**
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
final class GmpCalculator implements Calculator
{
/**
* @var string
*/
private $scale;
/**
* @param int $scale
*/
public function __construct($scale = 14)
{
$this->scale = $scale;
}
/**
* {@inheritdoc}
*/
public static function supported()
{
return extension_loaded('gmp');
}
/**
* {@inheritdoc}
*/
public function compare($a, $b)
{
$aNum = Number::fromNumber($a);
$bNum = Number::fromNumber($b);
if ($aNum->isDecimal() || $bNum->isDecimal()) {
$integersCompared = gmp_cmp($aNum->getIntegerPart(), $bNum->getIntegerPart());
if ($integersCompared !== 0) {
return $integersCompared;
}
$aNumFractional = $aNum->getFractionalPart() === '' ? '0' : $aNum->getFractionalPart();
$bNumFractional = $bNum->getFractionalPart() === '' ? '0' : $bNum->getFractionalPart();
return gmp_cmp($aNumFractional, $bNumFractional);
}
return gmp_cmp($a, $b);
}
/**
* {@inheritdoc}
*/
public function add($amount, $addend)
{
return gmp_strval(gmp_add($amount, $addend));
}
/**
* {@inheritdoc}
*/
public function subtract($amount, $subtrahend)
{
return gmp_strval(gmp_sub($amount, $subtrahend));
}
/**
* {@inheritdoc}
*/
public function multiply($amount, $multiplier)
{
$multiplier = Number::fromNumber($multiplier);
if ($multiplier->isDecimal()) {
$decimalPlaces = strlen($multiplier->getFractionalPart());
$multiplierBase = $multiplier->getIntegerPart();
if ($multiplierBase) {
$multiplierBase .= $multiplier->getFractionalPart();
} else {
$multiplierBase = ltrim($multiplier->getFractionalPart(), '0');
}
$resultBase = gmp_strval(gmp_mul(gmp_init($amount), gmp_init($multiplierBase)));
if ('0' === $resultBase) {
return '0';
}
$result = substr($resultBase, $decimalPlaces * -1);
$resultLength = strlen($result);
if ($decimalPlaces > $resultLength) {
return '0.'.str_pad('', $decimalPlaces - $resultLength, '0').$result;
}
return substr($resultBase, 0, $decimalPlaces * -1).'.'.$result;
}
return gmp_strval(gmp_mul(gmp_init($amount), gmp_init((string) $multiplier)));
}
/**
* {@inheritdoc}
*/
public function divide($amount, $divisor)
{
$divisor = Number::fromNumber($divisor);
if ($divisor->isDecimal()) {
$decimalPlaces = strlen($divisor->getFractionalPart());
if ($divisor->getIntegerPart()) {
$divisor = new Number($divisor->getIntegerPart().$divisor->getFractionalPart());
} else {
$divisor = new Number(ltrim($divisor->getFractionalPart(), '0'));
}
$amount = gmp_strval(gmp_mul(gmp_init($amount), gmp_init('1'.str_pad('', $decimalPlaces, '0'))));
}
list($integer, $remainder) = gmp_div_qr(gmp_init($amount), gmp_init((string) $divisor));
if (gmp_cmp($remainder, '0') === 0) {
return gmp_strval($integer);
}
$divisionOfRemainder = gmp_strval(
gmp_div_q(
gmp_mul($remainder, gmp_init('1'.str_pad('', $this->scale, '0'))),
gmp_init((string) $divisor),
GMP_ROUND_MINUSINF
)
);
if ($divisionOfRemainder[0] === '-') {
$divisionOfRemainder = substr($divisionOfRemainder, 1);
}
return gmp_strval($integer).'.'.str_pad($divisionOfRemainder, $this->scale, '0', STR_PAD_LEFT);
}
/**
* {@inheritdoc}
*/
public function ceil($number)
{
$number = Number::fromNumber($number);
if ($number->isInteger()) {
return (string) $number;
}
if ($number->isNegative()) {
return $this->add($number->getIntegerPart(), '0');
}
return $this->add($number->getIntegerPart(), '1');
}
/**
* {@inheritdoc}
*/
public function floor($number)
{
$number = Number::fromNumber($number);
if ($number->isInteger()) {
return (string) $number;
}
if ($number->isNegative()) {
return $this->add($number->getIntegerPart(), '-1');
}
return $this->add($number->getIntegerPart(), '0');
}
/**
* {@inheritdoc}
*/
public function absolute($number)
{
return ltrim($number, '-');
}
/**
* {@inheritdoc}
*/
public function round($number, $roundingMode)
{
$number = Number::fromNumber($number);
if ($number->isInteger()) {
return (string) $number;
}
if ($number->isHalf() === false) {
return $this->roundDigit($number);
}
if (Money::ROUND_HALF_UP === $roundingMode) {
return $this->add(
$number->getIntegerPart(),
$number->getIntegerRoundingMultiplier()
);
}
if (Money::ROUND_HALF_DOWN === $roundingMode) {
return $this->add($number->getIntegerPart(), '0');
}
if (Money::ROUND_HALF_EVEN === $roundingMode) {
if ($number->isCurrentEven()) {
return $this->add($number->getIntegerPart(), '0');
}
return $this->add(
$number->getIntegerPart(),
$number->getIntegerRoundingMultiplier()
);
}
if (Money::ROUND_HALF_ODD === $roundingMode) {
if ($number->isCurrentEven()) {
return $this->add(
$number->getIntegerPart(),
$number->getIntegerRoundingMultiplier()
);
}
return $this->add($number->getIntegerPart(), '0');
}
if (Money::ROUND_HALF_POSITIVE_INFINITY === $roundingMode) {
if ($number->isNegative()) {
return $this->add(
$number->getIntegerPart(),
'0'
);
}
return $this->add(
$number->getIntegerPart(),
$number->getIntegerRoundingMultiplier()
);
}
if (Money::ROUND_HALF_NEGATIVE_INFINITY === $roundingMode) {
if ($number->isNegative()) {
return $this->add(
$number->getIntegerPart(),
$number->getIntegerRoundingMultiplier()
);
}
return $this->add(
$number->getIntegerPart(),
'0'
);
}
throw new \InvalidArgumentException('Unknown rounding mode');
}
/**
* @param $number
*
* @return string
*/
private function roundDigit(Number $number)
{
if ($number->isCloserToNext()) {
return $this->add(
$number->getIntegerPart(),
$number->getIntegerRoundingMultiplier()
);
}
return $this->add($number->getIntegerPart(), '0');
}
/**
* {@inheritdoc}
*/
public function share($amount, $ratio, $total)
{
return $this->floor($this->divide($this->multiply($amount, $ratio), $total));
}
/**
* {@inheritdoc}
*/
public function mod($amount, $divisor)
{
// gmp_mod() only calculates non-negative integers, so we use absolutes
$remainder = gmp_mod($this->absolute($amount), $this->absolute($divisor));
// If the amount was negative, we negate the result of the modulus operation
$amount = Number::fromNumber($amount);
if ($amount->isNegative()) {
$remainder = gmp_neg($remainder);
}
return gmp_strval($remainder);
}
/**
* @test
*/
public function it_divides_bug538()
{
$this->assertSame('-4.54545454545455', $this->getCalculator()->divide('-500', 110));
}
}

View File

@@ -0,0 +1,197 @@
<?php
namespace Money\Calculator;
use Money\Calculator;
use Money\Money;
use Money\Number;
/**
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
final class PhpCalculator implements Calculator
{
/**
* {@inheritdoc}
*/
public static function supported()
{
return true;
}
/**
* {@inheritdoc}
*/
public function compare($a, $b)
{
return ($a < $b) ? -1 : (($a > $b) ? 1 : 0);
}
/**
* {@inheritdoc}
*/
public function add($amount, $addend)
{
$result = $amount + $addend;
$this->assertInteger($result);
return (string) $result;
}
/**
* {@inheritdoc}
*/
public function subtract($amount, $subtrahend)
{
$result = $amount - $subtrahend;
$this->assertInteger($result);
return (string) $result;
}
/**
* {@inheritdoc}
*/
public function multiply($amount, $multiplier)
{
$result = $amount * $multiplier;
$this->assertIntegerBounds($result);
return (string) Number::fromNumber($result);
}
/**
* {@inheritdoc}
*/
public function divide($amount, $divisor)
{
$result = $amount / $divisor;
$this->assertIntegerBounds($result);
return (string) Number::fromNumber($result);
}
/**
* {@inheritdoc}
*/
public function ceil($number)
{
return $this->castInteger(ceil($number));
}
/**
* {@inheritdoc}
*/
public function floor($number)
{
return $this->castInteger(floor($number));
}
/**
* {@inheritdoc}
*/
public function absolute($number)
{
$result = ltrim($number, '-');
$this->assertIntegerBounds($result);
return (string) $result;
}
/**
* {@inheritdoc}
*/
public function round($number, $roundingMode)
{
if (Money::ROUND_HALF_POSITIVE_INFINITY === $roundingMode) {
$number = Number::fromNumber($number);
if ($number->isHalf()) {
return $this->castInteger(ceil((string) $number));
}
return $this->castInteger(round((string) $number, 0, Money::ROUND_HALF_UP));
}
if (Money::ROUND_HALF_NEGATIVE_INFINITY === $roundingMode) {
$number = Number::fromNumber($number);
if ($number->isHalf()) {
return $this->castInteger(floor((string) $number));
}
return $this->castInteger(round((string) $number, 0, Money::ROUND_HALF_DOWN));
}
return $this->castInteger(round($number, 0, $roundingMode));
}
/**
* {@inheritdoc}
*/
public function share($amount, $ratio, $total)
{
return $this->castInteger(floor($amount * $ratio / $total));
}
/**
* {@inheritdoc}
*/
public function mod($amount, $divisor)
{
$result = $amount % $divisor;
$this->assertIntegerBounds($result);
return (string) $result;
}
/**
* Asserts that an integer value didn't become something else
* (after some arithmetic operation).
*
* @param int $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('You overflowed the maximum allowed integer (PHP_INT_MAX)');
} elseif ($amount < ~PHP_INT_MAX) {
throw new \UnderflowException('You underflowed the minimum allowed integer (PHP_INT_MAX)');
}
}
/**
* Casts an amount to integer ensuring that an overflow/underflow did not occur.
*
* @param int $amount
*
* @return string
*/
private function castInteger($amount)
{
$this->assertIntegerBounds($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');
}
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Money;
/**
* Provides a way to convert Money to Money in another Currency using an exchange rate.
*
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
final class Converter
{
/**
* @var Currencies
*/
private $currencies;
/**
* @var Exchange
*/
private $exchange;
public function __construct(Currencies $currencies, Exchange $exchange)
{
$this->currencies = $currencies;
$this->exchange = $exchange;
}
/**
* @param int $roundingMode
*
* @return Money
*/
public function convert(Money $money, Currency $counterCurrency, $roundingMode = Money::ROUND_HALF_UP)
{
$baseCurrency = $money->getCurrency();
$ratio = $this->exchange->quote($baseCurrency, $counterCurrency)->getConversionRatio();
$baseCurrencySubunit = $this->currencies->subunitFor($baseCurrency);
$counterCurrencySubunit = $this->currencies->subunitFor($counterCurrency);
$subunitDifference = $baseCurrencySubunit - $counterCurrencySubunit;
$ratio = (string) Number::fromFloat($ratio)->base10($subunitDifference);
$counterValue = $money->multiply($ratio, $roundingMode);
return new Money($counterValue->getAmount(), $counterCurrency);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Money;
use Money\Exception\UnknownCurrencyException;
/**
* Implement this to provide a list of currencies.
*
* @author Mathias Verraes
*/
interface Currencies extends \IteratorAggregate
{
/**
* Checks whether a currency is available in the current context.
*
* @return bool
*/
public function contains(Currency $currency);
/**
* Returns the subunit for a currency.
*
* @return int
*
* @throws UnknownCurrencyException If currency is not available in the current context
*/
public function subunitFor(Currency $currency);
}

View File

@@ -0,0 +1,77 @@
<?php
namespace Money\Currencies;
use Money\Currencies;
use Money\Currency;
use Money\Exception\UnknownCurrencyException;
/**
* Aggregates several currency repositories.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
final class AggregateCurrencies implements Currencies
{
/**
* @var Currencies[]
*/
private $currencies;
/**
* @param Currencies[] $currencies
*/
public function __construct(array $currencies)
{
foreach ($currencies as $c) {
if (false === $c instanceof Currencies) {
throw new \InvalidArgumentException('All currency repositories must implement '.Currencies::class);
}
}
$this->currencies = $currencies;
}
/**
* {@inheritdoc}
*/
public function contains(Currency $currency)
{
foreach ($this->currencies as $currencies) {
if ($currencies->contains($currency)) {
return true;
}
}
return false;
}
/**
* {@inheritdoc}
*/
public function subunitFor(Currency $currency)
{
foreach ($this->currencies as $currencies) {
if ($currencies->contains($currency)) {
return $currencies->subunitFor($currency);
}
}
throw new UnknownCurrencyException('Cannot find currency '.$currency->getCode());
}
/**
* {@inheritdoc}
*/
#[\ReturnTypeWillChange]
public function getIterator()
{
$iterator = new \AppendIterator();
foreach ($this->currencies as $currencies) {
$iterator->append($currencies->getIterator());
}
return $iterator;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Money\Currencies;
use Money\Currencies;
use Money\Currency;
use Money\Exception\UnknownCurrencyException;
/**
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
final class BitcoinCurrencies implements Currencies
{
const CODE = 'XBT';
const SYMBOL = "\xC9\x83";
/**
* {@inheritdoc}
*/
public function contains(Currency $currency)
{
return self::CODE === $currency->getCode();
}
/**
* {@inheritdoc}
*/
public function subunitFor(Currency $currency)
{
if ($currency->getCode() !== self::CODE) {
throw new UnknownCurrencyException($currency->getCode().' is not bitcoin and is not supported by this currency repository');
}
return 8;
}
/**
* {@inheritdoc}
*/
#[\ReturnTypeWillChange]
public function getIterator()
{
return new \ArrayIterator([new Currency(self::CODE)]);
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace Money\Currencies;
use Cache\Taggable\TaggableItemInterface;
use Money\Currencies;
use Money\Currency;
use Psr\Cache\CacheItemPoolInterface;
/**
* Cache the result of currency checking.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
final class CachedCurrencies implements Currencies
{
/**
* @var Currencies
*/
private $currencies;
/**
* @var CacheItemPoolInterface
*/
private $pool;
public function __construct(Currencies $currencies, CacheItemPoolInterface $pool)
{
$this->currencies = $currencies;
$this->pool = $pool;
}
/**
* {@inheritdoc}
*/
public function contains(Currency $currency)
{
$item = $this->pool->getItem('currency|availability|'.$currency->getCode());
if (false === $item->isHit()) {
$item->set($this->currencies->contains($currency));
if ($item instanceof TaggableItemInterface) {
$item->addTag('currency.availability');
}
$this->pool->save($item);
}
return $item->get();
}
/**
* {@inheritdoc}
*/
public function subunitFor(Currency $currency)
{
$item = $this->pool->getItem('currency|subunit|'.$currency->getCode());
if (false === $item->isHit()) {
$item->set($this->currencies->subunitFor($currency));
if ($item instanceof TaggableItemInterface) {
$item->addTag('currency.subunit');
}
$this->pool->save($item);
}
return $item->get();
}
/**
* {@inheritdoc}
*/
#[\ReturnTypeWillChange]
public function getIterator()
{
return new \CallbackFilterIterator(
$this->currencies->getIterator(),
function (Currency $currency) {
$item = $this->pool->getItem('currency|availability|'.$currency->getCode());
$item->set(true);
if ($item instanceof TaggableItemInterface) {
$item->addTag('currency.availability');
}
$this->pool->save($item);
return true;
}
);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Money\Currencies;
use Money\Currencies;
use Money\Currency;
use Money\Exception\UnknownCurrencyException;
/**
* A list of custom currencies.
*
* @author George Mponos <gmponos@gmail.com>
*/
final class CurrencyList implements Currencies
{
/**
* Map of currencies indexed by code.
*
* @var array
*/
private $currencies;
public function __construct(array $currencies)
{
foreach ($currencies as $currencyCode => $subunit) {
if (empty($currencyCode) || !is_string($currencyCode)) {
throw new \InvalidArgumentException(sprintf('Currency code must be a string and not empty. "%s" given', $currencyCode));
}
if (!is_int($subunit) || $subunit < 0) {
throw new \InvalidArgumentException(sprintf('Currency %s does not have a valid minor unit. Must be a positive integer.', $currencyCode));
}
}
$this->currencies = $currencies;
}
/**
* {@inheritdoc}
*/
public function contains(Currency $currency)
{
return isset($this->currencies[$currency->getCode()]);
}
/**
* {@inheritdoc}
*/
public function subunitFor(Currency $currency)
{
if (!$this->contains($currency)) {
throw new UnknownCurrencyException('Cannot find currency '.$currency->getCode());
}
return $this->currencies[$currency->getCode()];
}
/**
* {@inheritdoc}
*/
#[\ReturnTypeWillChange]
public function getIterator()
{
return new \ArrayIterator(
array_map(
function ($code) {
return new Currency($code);
},
array_keys($this->currencies)
)
);
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace Money\Currencies;
use Money\Currencies;
use Money\Currency;
use Money\Exception\UnknownCurrencyException;
/**
* List of supported ISO 4217 currency codes and names.
*
* @author Mathias Verraes
*/
final class ISOCurrencies implements Currencies
{
/**
* Map of known currencies indexed by code.
*
* @var array
*/
private static $currencies;
/**
* {@inheritdoc}
*/
public function contains(Currency $currency)
{
return isset($this->getCurrencies()[$currency->getCode()]);
}
/**
* {@inheritdoc}
*/
public function subunitFor(Currency $currency)
{
if (!$this->contains($currency)) {
throw new UnknownCurrencyException('Cannot find ISO currency '.$currency->getCode());
}
return $this->getCurrencies()[$currency->getCode()]['minorUnit'];
}
/**
* Returns the numeric code for a currency.
*
* @return int
*
* @throws UnknownCurrencyException If currency is not available in the current context
*/
public function numericCodeFor(Currency $currency)
{
if (!$this->contains($currency)) {
throw new UnknownCurrencyException('Cannot find ISO currency '.$currency->getCode());
}
return $this->getCurrencies()[$currency->getCode()]['numericCode'];
}
/**
* @return \Traversable
*/
#[\ReturnTypeWillChange]
public function getIterator()
{
return new \ArrayIterator(
array_map(
function ($code) {
return new Currency($code);
},
array_keys($this->getCurrencies())
)
);
}
/**
* Returns a map of known currencies indexed by code.
*
* @return array
*/
private function getCurrencies()
{
if (null === self::$currencies) {
self::$currencies = $this->loadCurrencies();
}
return self::$currencies;
}
/**
* @return array
*/
private function loadCurrencies()
{
$file = __DIR__.'/../../resources/currency.php';
if (file_exists($file)) {
return require $file;
}
throw new \RuntimeException('Failed to load currency ISO codes.');
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace Money;
/**
* Currency Value Object.
*
* Holds Currency specific data.
*
* @author Mathias Verraes
*
* @psalm-immutable
*/
final class Currency implements \JsonSerializable
{
/**
* Currency code.
*
* @var string
*/
private $code;
/**
* @param string $code
*/
public function __construct($code)
{
if (!is_string($code)) {
throw new \InvalidArgumentException('Currency code should be string');
}
if ($code === '') {
throw new \InvalidArgumentException('Currency code should not be empty string');
}
$this->code = $code;
}
/**
* Returns the currency code.
*
* @return string
*/
public function getCode()
{
return $this->code;
}
/**
* Checks whether this currency is the same as an other.
*
* @return bool
*/
public function equals(Currency $other)
{
return $this->code === $other->code;
}
/**
* Checks whether this currency is available in the passed context.
*
* @return bool
*/
public function isAvailableWithin(Currencies $currencies)
{
return $currencies->contains($this);
}
/**
* @return string
*/
public function __toString()
{
return $this->code;
}
/**
* {@inheritdoc}
*
* @return string
*/
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
return $this->code;
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace Money;
/**
* Currency Pair holding a base, a counter currency and a conversion ratio.
*
* @author Mathias Verraes
*
* @see http://en.wikipedia.org/wiki/Currency_pair
*/
final class CurrencyPair implements \JsonSerializable
{
/**
* Currency to convert from.
*
* @var Currency
*/
private $baseCurrency;
/**
* Currency to convert to.
*
* @var Currency
*/
private $counterCurrency;
/**
* @var float
*/
private $conversionRatio;
/**
* @param float $conversionRatio
*
* @throws \InvalidArgumentException If conversion ratio is not numeric
*/
public function __construct(Currency $baseCurrency, Currency $counterCurrency, $conversionRatio)
{
if (!is_numeric($conversionRatio)) {
throw new \InvalidArgumentException('Conversion ratio must be numeric');
}
$this->counterCurrency = $counterCurrency;
$this->baseCurrency = $baseCurrency;
$this->conversionRatio = (float) $conversionRatio;
}
/**
* Creates a new Currency Pair based on "EUR/USD 1.2500" form representation.
*
* @param string $iso String representation of the form "EUR/USD 1.2500"
*
* @return CurrencyPair
*
* @throws \InvalidArgumentException Format of $iso is invalid
*/
public static function createFromIso($iso)
{
$currency = '([A-Z]{2,3})';
$ratio = "([0-9]*\.?[0-9]+)"; // @see http://www.regular-expressions.info/floatingpoint.html
$pattern = '#'.$currency.'/'.$currency.' '.$ratio.'#';
$matches = [];
if (!preg_match($pattern, $iso, $matches)) {
throw new \InvalidArgumentException(sprintf('Cannot create currency pair from ISO string "%s", format of string is invalid', $iso));
}
return new self(new Currency($matches[1]), new Currency($matches[2]), $matches[3]);
}
/**
* Returns the counter currency.
*
* @return Currency
*/
public function getCounterCurrency()
{
return $this->counterCurrency;
}
/**
* Returns the base currency.
*
* @return Currency
*/
public function getBaseCurrency()
{
return $this->baseCurrency;
}
/**
* Returns the conversion ratio.
*
* @return float
*/
public function getConversionRatio()
{
return $this->conversionRatio;
}
/**
* Checks if an other CurrencyPair has the same parameters as this.
*
* @return bool
*/
public function equals(CurrencyPair $other)
{
return
$this->baseCurrency->equals($other->baseCurrency)
&& $this->counterCurrency->equals($other->counterCurrency)
&& $this->conversionRatio === $other->conversionRatio
;
}
/**
* {@inheritdoc}
*
* @return array
*/
public function jsonSerialize()
{
return [
'baseCurrency' => $this->baseCurrency,
'counterCurrency' => $this->counterCurrency,
'ratio' => $this->conversionRatio,
];
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Money;
/**
* Common interface for all exceptions thrown by this library.
*
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
interface Exception
{
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Money\Exception;
use Money\Exception;
/**
* Thrown when a Money object cannot be formatted into a string.
*
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
final class FormatterException extends \RuntimeException implements Exception
{
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Money\Exception;
use Money\Exception;
/**
* Thrown when a string cannot be parsed to a Money object.
*
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
final class ParserException extends \RuntimeException implements Exception
{
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Money\Exception;
use Money\Exception;
/**
* Thrown when trying to get ISO currency that does not exists.
*
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
final class UnknownCurrencyException extends \DomainException implements Exception
{
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Money\Exception;
use Money\Currency;
use Money\Exception;
/**
* Thrown when there is no currency pair (rate) available for the given currencies.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
final class UnresolvableCurrencyPairException extends \InvalidArgumentException implements Exception
{
/**
* Creates an exception from Currency objects.
*
* @return UnresolvableCurrencyPairException
*/
public static function createFromCurrencies(Currency $baseCurrency, Currency $counterCurrency)
{
$message = sprintf(
'Cannot resolve a currency pair for currencies: %s/%s',
$baseCurrency->getCode(),
$counterCurrency->getCode()
);
return new self($message);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Money;
use Money\Exception\UnresolvableCurrencyPairException;
/**
* Provides a way to get exchange rate from a third-party source and return a currency pair.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
interface Exchange
{
/**
* Returns a currency pair for the passed currencies with the rate coming from a third-party source.
*
* @return CurrencyPair
*
* @throws UnresolvableCurrencyPairException When there is no currency pair (rate) available for the given currencies
*/
public function quote(Currency $baseCurrency, Currency $counterCurrency);
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Money\Exchange;
use Exchanger\Contract\ExchangeRateProvider;
use Exchanger\CurrencyPair as ExchangerCurrencyPair;
use Exchanger\Exception\Exception as ExchangerException;
use Exchanger\ExchangeRateQuery;
use Money\Currency;
use Money\CurrencyPair;
use Money\Exception\UnresolvableCurrencyPairException;
use Money\Exchange;
/**
* Provides a way to get exchange rate from a third-party source and return a currency pair.
*
* @author Maksim (Ellrion) Platonov <ellrion11@gmail.com>
*/
final class ExchangerExchange implements Exchange
{
/**
* @var ExchangeRateProvider
*/
private $exchanger;
public function __construct(ExchangeRateProvider $exchanger)
{
$this->exchanger = $exchanger;
}
/**
* {@inheritdoc}
*/
public function quote(Currency $baseCurrency, Currency $counterCurrency)
{
try {
$query = new ExchangeRateQuery(
new ExchangerCurrencyPair($baseCurrency->getCode(), $counterCurrency->getCode())
);
$rate = $this->exchanger->getExchangeRate($query);
} catch (ExchangerException $e) {
throw UnresolvableCurrencyPairException::createFromCurrencies($baseCurrency, $counterCurrency);
}
return new CurrencyPair($baseCurrency, $counterCurrency, $rate->getValue());
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Money\Exchange;
use Money\Currency;
use Money\CurrencyPair;
use Money\Exception\UnresolvableCurrencyPairException;
use Money\Exchange;
/**
* Provides a way to get exchange rate from a static list (array).
*
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
final class FixedExchange implements Exchange
{
/**
* @var array
*/
private $list;
public function __construct(array $list)
{
$this->list = $list;
}
/**
* {@inheritdoc}
*/
public function quote(Currency $baseCurrency, Currency $counterCurrency)
{
if (isset($this->list[$baseCurrency->getCode()][$counterCurrency->getCode()])) {
return new CurrencyPair(
$baseCurrency,
$counterCurrency,
$this->list[$baseCurrency->getCode()][$counterCurrency->getCode()]
);
}
throw UnresolvableCurrencyPairException::createFromCurrencies($baseCurrency, $counterCurrency);
}
}

View File

@@ -0,0 +1,195 @@
<?php
namespace Money\Exchange;
use Money\Calculator;
use Money\Calculator\BcMathCalculator;
use Money\Calculator\GmpCalculator;
use Money\Calculator\PhpCalculator;
use Money\Currencies;
use Money\Currency;
use Money\CurrencyPair;
use Money\Exception\UnresolvableCurrencyPairException;
use Money\Exchange;
/**
* Provides a way to get an exchange rate through a minimal set of intermediate conversions.
*
* @author Michael Cordingley <Michael.Cordingley@gmail.com>
*/
final class IndirectExchange implements Exchange
{
/**
* @var Calculator
*/
private static $calculator;
/**
* @var array
*/
private static $calculators = [
BcMathCalculator::class,
GmpCalculator::class,
PhpCalculator::class,
];
/**
* @var Currencies
*/
private $currencies;
/**
* @var Exchange
*/
private $exchange;
public function __construct(Exchange $exchange, Currencies $currencies)
{
$this->exchange = $exchange;
$this->currencies = $currencies;
}
/**
* @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);
}
/**
* {@inheritdoc}
*/
public function quote(Currency $baseCurrency, Currency $counterCurrency)
{
try {
return $this->exchange->quote($baseCurrency, $counterCurrency);
} catch (UnresolvableCurrencyPairException $exception) {
$rate = array_reduce($this->getConversions($baseCurrency, $counterCurrency), function ($carry, CurrencyPair $pair) {
return static::getCalculator()->multiply($carry, $pair->getConversionRatio());
}, '1.0');
return new CurrencyPair($baseCurrency, $counterCurrency, $rate);
}
}
/**
* @return CurrencyPair[]
*
* @throws UnresolvableCurrencyPairException
*/
private function getConversions(Currency $baseCurrency, Currency $counterCurrency)
{
$startNode = $this->initializeNode($baseCurrency);
$startNode->discovered = true;
$nodes = [$baseCurrency->getCode() => $startNode];
$frontier = new \SplQueue();
$frontier->enqueue($startNode);
while ($frontier->count()) {
/** @var \stdClass $currentNode */
$currentNode = $frontier->dequeue();
/** @var Currency $currentCurrency */
$currentCurrency = $currentNode->currency;
if ($currentCurrency->equals($counterCurrency)) {
return $this->reconstructConversionChain($nodes, $currentNode);
}
/** @var Currency $candidateCurrency */
foreach ($this->currencies as $candidateCurrency) {
if (!isset($nodes[$candidateCurrency->getCode()])) {
$nodes[$candidateCurrency->getCode()] = $this->initializeNode($candidateCurrency);
}
/** @var \stdClass $node */
$node = $nodes[$candidateCurrency->getCode()];
if (!$node->discovered) {
try {
// Check if the candidate is a neighbor. This will throw an exception if it isn't.
$this->exchange->quote($currentCurrency, $candidateCurrency);
$node->discovered = true;
$node->parent = $currentNode;
$frontier->enqueue($node);
} catch (UnresolvableCurrencyPairException $exception) {
// Not a neighbor. Move on.
}
}
}
}
throw UnresolvableCurrencyPairException::createFromCurrencies($baseCurrency, $counterCurrency);
}
/**
* @return \stdClass
*/
private function initializeNode(Currency $currency)
{
$node = new \stdClass();
$node->currency = $currency;
$node->discovered = false;
$node->parent = null;
return $node;
}
/**
* @return CurrencyPair[]
*/
private function reconstructConversionChain(array $currencies, \stdClass $goalNode)
{
$current = $goalNode;
$conversions = [];
while ($current->parent) {
$previous = $currencies[$current->parent->currency->getCode()];
$conversions[] = $this->exchange->quote($previous->currency, $current->currency);
$current = $previous;
}
return array_reverse($conversions);
}
/**
* @return Calculator
*/
private function getCalculator()
{
if (null === self::$calculator) {
self::$calculator = self::initializeCalculator();
}
return self::$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');
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Money\Exchange;
use Money\Currency;
use Money\CurrencyPair;
use Money\Exception\UnresolvableCurrencyPairException;
use Money\Exchange;
/**
* Tries the reverse of the currency pair if one is not available.
*
* Note: adding nested ReversedCurrenciesExchange could cause a huge performance hit.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
final class ReversedCurrenciesExchange implements Exchange
{
/**
* @var Exchange
*/
private $exchange;
public function __construct(Exchange $exchange)
{
$this->exchange = $exchange;
}
/**
* {@inheritdoc}
*/
public function quote(Currency $baseCurrency, Currency $counterCurrency)
{
try {
return $this->exchange->quote($baseCurrency, $counterCurrency);
} catch (UnresolvableCurrencyPairException $exception) {
try {
$currencyPair = $this->exchange->quote($counterCurrency, $baseCurrency);
return new CurrencyPair($baseCurrency, $counterCurrency, 1 / $currencyPair->getConversionRatio());
} catch (UnresolvableCurrencyPairException $inversedException) {
throw $exception;
}
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Money\Exchange;
use Exchanger\Exception\Exception as ExchangerException;
use Money\Currency;
use Money\CurrencyPair;
use Money\Exception\UnresolvableCurrencyPairException;
use Money\Exchange;
use Swap\Swap;
/**
* Provides a way to get exchange rate from a third-party source and return a currency pair.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
final class SwapExchange implements Exchange
{
/**
* @var Swap
*/
private $swap;
public function __construct(Swap $swap)
{
$this->swap = $swap;
}
/**
* {@inheritdoc}
*/
public function quote(Currency $baseCurrency, Currency $counterCurrency)
{
try {
$rate = $this->swap->latest($baseCurrency->getCode().'/'.$counterCurrency->getCode());
} catch (ExchangerException $e) {
throw UnresolvableCurrencyPairException::createFromCurrencies($baseCurrency, $counterCurrency);
}
return new CurrencyPair($baseCurrency, $counterCurrency, $rate->getValue());
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Money\Formatter;
use Money\Exception\FormatterException;
use Money\Money;
use Money\MoneyFormatter;
/**
* Formats a Money object using other Money formatters.
*
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
final class AggregateMoneyFormatter implements MoneyFormatter
{
/**
* @var MoneyFormatter[]
*/
private $formatters = [];
/**
* @param MoneyFormatter[] $formatters
*/
public function __construct(array $formatters)
{
if (empty($formatters)) {
throw new \InvalidArgumentException(sprintf('Initialize an empty %s is not possible', self::class));
}
foreach ($formatters as $currencyCode => $formatter) {
if (false === $formatter instanceof MoneyFormatter) {
throw new \InvalidArgumentException('All formatters must implement '.MoneyFormatter::class);
}
$this->formatters[$currencyCode] = $formatter;
}
}
/**
* {@inheritdoc}
*/
public function format(Money $money)
{
$currencyCode = $money->getCurrency()->getCode();
if (isset($this->formatters[$currencyCode])) {
return $this->formatters[$currencyCode]->format($money);
}
if (isset($this->formatters['*'])) {
return $this->formatters['*']->format($money);
}
throw new FormatterException('No formatter found for currency '.$currencyCode);
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace Money\Formatter;
use Money\Currencies;
use Money\Currencies\BitcoinCurrencies;
use Money\Exception\FormatterException;
use Money\Money;
use Money\MoneyFormatter;
use Money\Number;
/**
* Formats Money to Bitcoin currency.
*
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
final class BitcoinMoneyFormatter implements MoneyFormatter
{
/**
* @var int
*/
private $fractionDigits;
/**
* @var Currencies
*/
private $currencies;
/**
* @param int $fractionDigits
*/
public function __construct($fractionDigits, Currencies $currencies)
{
$this->fractionDigits = $fractionDigits;
$this->currencies = $currencies;
}
/**
* {@inheritdoc}
*/
public function format(Money $money)
{
if (BitcoinCurrencies::CODE !== $money->getCurrency()->getCode()) {
throw new FormatterException('Bitcoin Formatter can only format Bitcoin currency');
}
$valueBase = $money->getAmount();
$negative = false;
if ('-' === $valueBase[0]) {
$negative = true;
$valueBase = substr($valueBase, 1);
}
$subunit = $this->currencies->subunitFor($money->getCurrency());
$valueBase = Number::roundMoneyValue($valueBase, $this->fractionDigits, $subunit);
$valueLength = strlen($valueBase);
if ($valueLength > $subunit) {
$formatted = substr($valueBase, 0, $valueLength - $subunit);
if ($subunit) {
$formatted .= '.';
$formatted .= substr($valueBase, $valueLength - $subunit);
}
} else {
$formatted = '0.'.str_pad('', $subunit - $valueLength, '0').$valueBase;
}
if ($this->fractionDigits === 0) {
$formatted = substr($formatted, 0, strpos($formatted, '.'));
} elseif ($this->fractionDigits > $subunit) {
$formatted .= str_pad('', $this->fractionDigits - $subunit, '0');
} elseif ($this->fractionDigits < $subunit) {
$lastDigit = strpos($formatted, '.') + $this->fractionDigits + 1;
$formatted = substr($formatted, 0, $lastDigit);
}
$formatted = BitcoinCurrencies::SYMBOL.$formatted;
if (true === $negative) {
$formatted = '-'.$formatted;
}
return $formatted;
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace Money\Formatter;
use Money\Currencies;
use Money\Money;
use Money\MoneyFormatter;
/**
* Formats a Money object as a decimal string.
*
* @author Teoh Han Hui <teohhanhui@gmail.com>
*/
final class DecimalMoneyFormatter implements MoneyFormatter
{
/**
* @var Currencies
*/
private $currencies;
public function __construct(Currencies $currencies)
{
$this->currencies = $currencies;
}
/**
* {@inheritdoc}
*/
public function format(Money $money)
{
$valueBase = $money->getAmount();
$negative = false;
if ($valueBase[0] === '-') {
$negative = true;
$valueBase = substr($valueBase, 1);
}
$subunit = $this->currencies->subunitFor($money->getCurrency());
$valueLength = strlen($valueBase);
if ($valueLength > $subunit) {
$formatted = substr($valueBase, 0, $valueLength - $subunit);
$decimalDigits = substr($valueBase, $valueLength - $subunit);
if (strlen($decimalDigits) > 0) {
$formatted .= '.'.$decimalDigits;
}
} else {
$formatted = '0.'.str_pad('', $subunit - $valueLength, '0').$valueBase;
}
if ($negative === true) {
$formatted = '-'.$formatted;
}
return $formatted;
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace Money\Formatter;
use Money\Currencies;
use Money\Money;
use Money\MoneyFormatter;
/**
* Formats a Money object using intl extension.
*
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
final class IntlLocalizedDecimalFormatter implements MoneyFormatter
{
/**
* @var \NumberFormatter
*/
private $formatter;
/**
* @var Currencies
*/
private $currencies;
public function __construct(\NumberFormatter $formatter, Currencies $currencies)
{
$this->formatter = $formatter;
$this->currencies = $currencies;
}
/**
* {@inheritdoc}
*/
public function format(Money $money)
{
$valueBase = $money->getAmount();
$negative = false;
if ($valueBase[0] === '-') {
$negative = true;
$valueBase = substr($valueBase, 1);
}
$subunit = $this->currencies->subunitFor($money->getCurrency());
$valueLength = strlen($valueBase);
if ($valueLength > $subunit) {
$formatted = substr($valueBase, 0, $valueLength - $subunit);
$decimalDigits = substr($valueBase, $valueLength - $subunit);
if (strlen($decimalDigits) > 0) {
$formatted .= '.'.$decimalDigits;
}
} else {
$formatted = '0.'.str_pad('', $subunit - $valueLength, '0').$valueBase;
}
if ($negative === true) {
$formatted = '-'.$formatted;
}
return $this->formatter->format($formatted);
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace Money\Formatter;
use Money\Currencies;
use Money\Money;
use Money\MoneyFormatter;
/**
* Formats a Money object using intl extension.
*
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
final class IntlMoneyFormatter implements MoneyFormatter
{
/**
* @var \NumberFormatter
*/
private $formatter;
/**
* @var Currencies
*/
private $currencies;
public function __construct(\NumberFormatter $formatter, Currencies $currencies)
{
$this->formatter = $formatter;
$this->currencies = $currencies;
}
/**
* {@inheritdoc}
*/
public function format(Money $money)
{
$valueBase = $money->getAmount();
$negative = false;
if ($valueBase[0] === '-') {
$negative = true;
$valueBase = substr($valueBase, 1);
}
$subunit = $this->currencies->subunitFor($money->getCurrency());
$valueLength = strlen($valueBase);
if ($valueLength > $subunit) {
$formatted = substr($valueBase, 0, $valueLength - $subunit);
$decimalDigits = substr($valueBase, $valueLength - $subunit);
if (strlen($decimalDigits) > 0) {
$formatted .= '.'.$decimalDigits;
}
} else {
$formatted = '0.'.str_pad('', $subunit - $valueLength, '0').$valueBase;
}
if ($negative === true) {
$formatted = '-'.$formatted;
}
return $this->formatter->formatCurrency($formatted, $money->getCurrency()->getCode());
}
}

View File

@@ -0,0 +1,613 @@
<?php
namespace Money;
use Money\Calculator\BcMathCalculator;
use Money\Calculator\GmpCalculator;
use Money\Calculator\PhpCalculator;
/**
* Money Value Object.
*
* @author Mathias Verraes
*
* @psalm-immutable
*/
final class Money implements \JsonSerializable
{
use MoneyFactory;
const ROUND_HALF_UP = PHP_ROUND_HALF_UP;
const ROUND_HALF_DOWN = PHP_ROUND_HALF_DOWN;
const ROUND_HALF_EVEN = PHP_ROUND_HALF_EVEN;
const ROUND_HALF_ODD = PHP_ROUND_HALF_ODD;
const ROUND_UP = 5;
const ROUND_DOWN = 6;
const ROUND_HALF_POSITIVE_INFINITY = 7;
const ROUND_HALF_NEGATIVE_INFINITY = 8;
/**
* Internal value.
*
* @var string
*/
private $amount;
/**
* @var Currency
*/
private $currency;
/**
* @var Calculator
*/
private static $calculator;
/**
* @var array
*/
private static $calculators = [
BcMathCalculator::class,
GmpCalculator::class,
PhpCalculator::class,
];
/**
* @param int|string $amount Amount, expressed in the smallest units of $currency (eg cents)
*
* @throws \InvalidArgumentException If amount is not integer
*/
public function __construct($amount, Currency $currency)
{
if (filter_var($amount, FILTER_VALIDATE_INT) === false) {
$numberFromString = Number::fromString($amount);
if (!$numberFromString->isInteger()) {
throw new \InvalidArgumentException('Amount must be an integer(ish) value');
}
$amount = $numberFromString->getIntegerPart();
}
$this->amount = (string) $amount;
$this->currency = $currency;
}
/**
* 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.
*
* @return bool
*/
public function isSameCurrency(Money $other)
{
return $this->currency->equals($other->currency);
}
/**
* Asserts that a Money has the same currency as this.
*
* @throws \InvalidArgumentException If $other has a different currency
*/
private function assertSameCurrency(Money $other)
{
if (!$this->isSameCurrency($other)) {
throw new \InvalidArgumentException('Currencies must be identical');
}
}
/**
* Checks whether the value represented by this object equals to the other.
*
* @return bool
*/
public function equals(Money $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.
*
* @return int
*/
public function compare(Money $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.
*
* @return bool
*/
public function greaterThan(Money $other)
{
return $this->compare($other) > 0;
}
/**
* @param \Money\Money $other
*
* @return bool
*/
public function greaterThanOrEqual(Money $other)
{
return $this->compare($other) >= 0;
}
/**
* Checks whether the value represented by this object is less than the other.
*
* @return bool
*/
public function lessThan(Money $other)
{
return $this->compare($other) < 0;
}
/**
* @param \Money\Money $other
*
* @return bool
*/
public function lessThanOrEqual(Money $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 Money[] $addends
*
* @return Money
*/
public function add(Money ...$addends)
{
$amount = $this->amount;
$calculator = $this->getCalculator();
foreach ($addends as $addend) {
$this->assertSameCurrency($addend);
$amount = $calculator->add($amount, $addend->amount);
}
return new self($amount, $this->currency);
}
/**
* Returns a new Money object that represents
* the difference of this and an other Money object.
*
* @param Money[] $subtrahends
*
* @return Money
*
* @psalm-pure
*/
public function subtract(Money ...$subtrahends)
{
$amount = $this->amount;
$calculator = $this->getCalculator();
foreach ($subtrahends as $subtrahend) {
$this->assertSameCurrency($subtrahend);
$amount = $calculator->subtract($amount, $subtrahend->amount);
}
return new self($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);
$divisor = (string) Number::fromNumber($divisor);
if ($this->getCalculator()->compare($divisor, '0') === 0) {
throw new \InvalidArgumentException('Division by zero');
}
$quotient = $this->round($this->getCalculator()->divide($this->amount, $divisor), $roundingMode);
return $this->newInstance($quotient);
}
/**
* Returns a new Money object that represents
* the remainder after dividing the value by
* the given factor.
*
* @return Money
*/
public function mod(Money $divisor)
{
$this->assertSameCurrency($divisor);
return new self($this->getCalculator()->mod($this->amount, $divisor->amount), $this->currency);
}
/**
* Allocate the money according to a list of 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 $key => $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[$key] = $this->newInstance($share);
$remainder = $this->getCalculator()->subtract($remainder, $share);
}
if ($this->getCalculator()->compare($remainder, '0') === 0) {
return $results;
}
$fractions = array_map(function ($ratio) use ($total) {
$share = ($ratio / $total) * $this->amount;
return $share - floor($share);
}, $ratios);
while ($this->getCalculator()->compare($remainder, '0') > 0) {
$index = !empty($fractions) ? array_keys($fractions, max($fractions))[0] : 0;
$results[$index]->amount = $this->getCalculator()->add($results[$index]->amount, '1');
$remainder = $this->getCalculator()->subtract($remainder, '1');
unset($fractions[$index]);
}
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));
}
/**
* @return string
*/
public function ratioOf(Money $money)
{
if ($money->isZero()) {
throw new \InvalidArgumentException('Cannot calculate a ratio of zero');
}
return $this->getCalculator()->divide($this->amount, $money->amount);
}
/**
* @param string $amount
* @param int $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));
}
/**
* @return Money
*/
public function negative()
{
return $this->newInstance(0)->subtract($this);
}
/**
* 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) > 0;
}
/**
* Checks if the value represented by this object is negative.
*
* @return bool
*/
public function isNegative()
{
return $this->getCalculator()->compare($this->amount, 0) < 0;
}
/**
* {@inheritdoc}
*
* @return array
*/
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
return [
'amount' => $this->amount,
'currency' => $this->currency->jsonSerialize(),
];
}
/**
* @param Money $first
* @param Money ...$collection
*
* @return Money
*
* @psalm-pure
*/
public static function min(self $first, self ...$collection)
{
$min = $first;
foreach ($collection as $money) {
if ($money->lessThan($min)) {
$min = $money;
}
}
return $min;
}
/**
* @param Money $first
* @param Money ...$collection
*
* @return Money
*
* @psalm-pure
*/
public static function max(self $first, self ...$collection)
{
$max = $first;
foreach ($collection as $money) {
if ($money->greaterThan($max)) {
$max = $money;
}
}
return $max;
}
/**
* @param Money $first
* @param Money ...$collection
*
* @return Money
*
* @psalm-pure
*/
public static function sum(self $first, self ...$collection)
{
return $first->add(...$collection);
}
/**
* @param Money $first
* @param Money ...$collection
*
* @return Money
*
* @psalm-pure
*/
public static function avg(self $first, self ...$collection)
{
return $first->add(...$collection)->divide(func_num_args());
}
/**
* @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;
}
}

View File

@@ -0,0 +1,208 @@
<?php
namespace Money;
/**
* This is a generated file. Do not edit it manually!
*
* @method static Money AED(string|int $amount)
* @method static Money ALL(string|int $amount)
* @method static Money AMD(string|int $amount)
* @method static Money ANG(string|int $amount)
* @method static Money AOA(string|int $amount)
* @method static Money ARS(string|int $amount)
* @method static Money AUD(string|int $amount)
* @method static Money AWG(string|int $amount)
* @method static Money AZN(string|int $amount)
* @method static Money BAM(string|int $amount)
* @method static Money BBD(string|int $amount)
* @method static Money BDT(string|int $amount)
* @method static Money BGN(string|int $amount)
* @method static Money BHD(string|int $amount)
* @method static Money BIF(string|int $amount)
* @method static Money BMD(string|int $amount)
* @method static Money BND(string|int $amount)
* @method static Money BOB(string|int $amount)
* @method static Money BOV(string|int $amount)
* @method static Money BRL(string|int $amount)
* @method static Money BSD(string|int $amount)
* @method static Money BTN(string|int $amount)
* @method static Money BWP(string|int $amount)
* @method static Money BYN(string|int $amount)
* @method static Money BZD(string|int $amount)
* @method static Money CAD(string|int $amount)
* @method static Money CDF(string|int $amount)
* @method static Money CHE(string|int $amount)
* @method static Money CHF(string|int $amount)
* @method static Money CHW(string|int $amount)
* @method static Money CLF(string|int $amount)
* @method static Money CLP(string|int $amount)
* @method static Money CNY(string|int $amount)
* @method static Money COP(string|int $amount)
* @method static Money COU(string|int $amount)
* @method static Money CRC(string|int $amount)
* @method static Money CUC(string|int $amount)
* @method static Money CUP(string|int $amount)
* @method static Money CVE(string|int $amount)
* @method static Money CZK(string|int $amount)
* @method static Money DJF(string|int $amount)
* @method static Money DKK(string|int $amount)
* @method static Money DOP(string|int $amount)
* @method static Money DZD(string|int $amount)
* @method static Money EGP(string|int $amount)
* @method static Money ERN(string|int $amount)
* @method static Money ETB(string|int $amount)
* @method static Money EUR(string|int $amount)
* @method static Money FJD(string|int $amount)
* @method static Money FKP(string|int $amount)
* @method static Money GBP(string|int $amount)
* @method static Money GEL(string|int $amount)
* @method static Money GHS(string|int $amount)
* @method static Money GIP(string|int $amount)
* @method static Money GMD(string|int $amount)
* @method static Money GNF(string|int $amount)
* @method static Money GTQ(string|int $amount)
* @method static Money GYD(string|int $amount)
* @method static Money HKD(string|int $amount)
* @method static Money HNL(string|int $amount)
* @method static Money HRK(string|int $amount)
* @method static Money HTG(string|int $amount)
* @method static Money HUF(string|int $amount)
* @method static Money IDR(string|int $amount)
* @method static Money ILS(string|int $amount)
* @method static Money INR(string|int $amount)
* @method static Money IQD(string|int $amount)
* @method static Money IRR(string|int $amount)
* @method static Money ISK(string|int $amount)
* @method static Money JMD(string|int $amount)
* @method static Money JOD(string|int $amount)
* @method static Money JPY(string|int $amount)
* @method static Money KES(string|int $amount)
* @method static Money KGS(string|int $amount)
* @method static Money KHR(string|int $amount)
* @method static Money KMF(string|int $amount)
* @method static Money KPW(string|int $amount)
* @method static Money KRW(string|int $amount)
* @method static Money KWD(string|int $amount)
* @method static Money KYD(string|int $amount)
* @method static Money KZT(string|int $amount)
* @method static Money LAK(string|int $amount)
* @method static Money LBP(string|int $amount)
* @method static Money LKR(string|int $amount)
* @method static Money LRD(string|int $amount)
* @method static Money LSL(string|int $amount)
* @method static Money LYD(string|int $amount)
* @method static Money MAD(string|int $amount)
* @method static Money MDL(string|int $amount)
* @method static Money MGA(string|int $amount)
* @method static Money MKD(string|int $amount)
* @method static Money MMK(string|int $amount)
* @method static Money MNT(string|int $amount)
* @method static Money MOP(string|int $amount)
* @method static Money MRU(string|int $amount)
* @method static Money MUR(string|int $amount)
* @method static Money MVR(string|int $amount)
* @method static Money MWK(string|int $amount)
* @method static Money MXN(string|int $amount)
* @method static Money MXV(string|int $amount)
* @method static Money MYR(string|int $amount)
* @method static Money MZN(string|int $amount)
* @method static Money NAD(string|int $amount)
* @method static Money NGN(string|int $amount)
* @method static Money NIO(string|int $amount)
* @method static Money NOK(string|int $amount)
* @method static Money NPR(string|int $amount)
* @method static Money NZD(string|int $amount)
* @method static Money OMR(string|int $amount)
* @method static Money PAB(string|int $amount)
* @method static Money PEN(string|int $amount)
* @method static Money PGK(string|int $amount)
* @method static Money PHP(string|int $amount)
* @method static Money PKR(string|int $amount)
* @method static Money PLN(string|int $amount)
* @method static Money PYG(string|int $amount)
* @method static Money QAR(string|int $amount)
* @method static Money RON(string|int $amount)
* @method static Money RSD(string|int $amount)
* @method static Money RUB(string|int $amount)
* @method static Money RWF(string|int $amount)
* @method static Money SAR(string|int $amount)
* @method static Money SBD(string|int $amount)
* @method static Money SCR(string|int $amount)
* @method static Money SDG(string|int $amount)
* @method static Money SEK(string|int $amount)
* @method static Money SGD(string|int $amount)
* @method static Money SHP(string|int $amount)
* @method static Money SLL(string|int $amount)
* @method static Money SOS(string|int $amount)
* @method static Money SRD(string|int $amount)
* @method static Money SSP(string|int $amount)
* @method static Money STN(string|int $amount)
* @method static Money SVC(string|int $amount)
* @method static Money SYP(string|int $amount)
* @method static Money SZL(string|int $amount)
* @method static Money THB(string|int $amount)
* @method static Money TJS(string|int $amount)
* @method static Money TMT(string|int $amount)
* @method static Money TND(string|int $amount)
* @method static Money TOP(string|int $amount)
* @method static Money TRY(string|int $amount)
* @method static Money TTD(string|int $amount)
* @method static Money TWD(string|int $amount)
* @method static Money TZS(string|int $amount)
* @method static Money UAH(string|int $amount)
* @method static Money UGX(string|int $amount)
* @method static Money USD(string|int $amount)
* @method static Money USN(string|int $amount)
* @method static Money UYI(string|int $amount)
* @method static Money UYU(string|int $amount)
* @method static Money UYW(string|int $amount)
* @method static Money UZS(string|int $amount)
* @method static Money VES(string|int $amount)
* @method static Money VND(string|int $amount)
* @method static Money VUV(string|int $amount)
* @method static Money WST(string|int $amount)
* @method static Money XAF(string|int $amount)
* @method static Money XAG(string|int $amount)
* @method static Money XAU(string|int $amount)
* @method static Money XBA(string|int $amount)
* @method static Money XBB(string|int $amount)
* @method static Money XBC(string|int $amount)
* @method static Money XBD(string|int $amount)
* @method static Money XBT(string|int $amount)
* @method static Money XCD(string|int $amount)
* @method static Money XDR(string|int $amount)
* @method static Money XOF(string|int $amount)
* @method static Money XPD(string|int $amount)
* @method static Money XPF(string|int $amount)
* @method static Money XPT(string|int $amount)
* @method static Money XSU(string|int $amount)
* @method static Money XTS(string|int $amount)
* @method static Money XUA(string|int $amount)
* @method static Money XXX(string|int $amount)
* @method static Money YER(string|int $amount)
* @method static Money ZAR(string|int $amount)
* @method static Money ZMW(string|int $amount)
* @method static Money ZWL(string|int $amount)
*/
trait MoneyFactory
{
/**
* Convenience factory method for a Money object.
*
* <code>
* $fiveDollar = Money::USD(500);
* </code>
*
* @param string $method
* @param array $arguments
*
* @return Money
*
* @throws \InvalidArgumentException If amount is not integer(ish)
*/
public static function __callStatic($method, $arguments)
{
return new Money($arguments[0], new Currency($method));
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Money;
/**
* Formats Money objects.
*
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
interface MoneyFormatter
{
/**
* Formats a Money object as string.
*
* @return string
*
* Exception\FormatterException
*/
public function format(Money $money);
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Money;
/**
* Parses a string into a Money object.
*
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
interface MoneyParser
{
/**
* Parses a string into a Money object (including currency).
*
* @param string $money
* @param Currency|string|null $forceCurrency
*
* @return Money
*
* @throws Exception\ParserException
*/
public function parse($money, $forceCurrency = null);
}

View File

@@ -0,0 +1,331 @@
<?php
namespace Money;
/**
* Represents a numeric value.
*
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
final class Number
{
/**
* @var string
*/
private $integerPart;
/**
* @var string
*/
private $fractionalPart;
/**
* @var array
*/
private static $numbers = [0 => 1, 1 => 1, 2 => 1, 3 => 1, 4 => 1, 5 => 1, 6 => 1, 7 => 1, 8 => 1, 9 => 1];
/**
* @param string $integerPart
* @param string $fractionalPart
*/
public function __construct($integerPart, $fractionalPart = '')
{
if ('' === $integerPart && '' === $fractionalPart) {
throw new \InvalidArgumentException('Empty number is invalid');
}
$this->integerPart = $this->parseIntegerPart((string) $integerPart);
$this->fractionalPart = $this->parseFractionalPart((string) $fractionalPart);
}
/**
* @param $number
*
* @return self
*/
public static function fromString($number)
{
$decimalSeparatorPosition = strpos($number, '.');
if ($decimalSeparatorPosition === false) {
return new self($number, '');
}
return new self(
substr($number, 0, $decimalSeparatorPosition),
rtrim(substr($number, $decimalSeparatorPosition + 1), '0')
);
}
/**
* @param float $number
*
* @return self
*/
public static function fromFloat($number)
{
if (is_float($number) === false) {
throw new \InvalidArgumentException('Floating point value expected');
}
return self::fromString(sprintf('%.14F', $number));
}
/**
* @param float|int|string $number
*
* @return self
*/
public static function fromNumber($number)
{
if (is_float($number)) {
return self::fromString(sprintf('%.14F', $number));
}
if (is_int($number)) {
return new self($number);
}
if (is_string($number)) {
return self::fromString($number);
}
throw new \InvalidArgumentException('Valid numeric value expected');
}
/**
* @return bool
*/
public function isDecimal()
{
return $this->fractionalPart !== '';
}
/**
* @return bool
*/
public function isInteger()
{
return $this->fractionalPart === '';
}
/**
* @return bool
*/
public function isHalf()
{
return $this->fractionalPart === '5';
}
/**
* @return bool
*/
public function isCurrentEven()
{
$lastIntegerPartNumber = $this->integerPart[strlen($this->integerPart) - 1];
return $lastIntegerPartNumber % 2 === 0;
}
/**
* @return bool
*/
public function isCloserToNext()
{
if ($this->fractionalPart === '') {
return false;
}
return $this->fractionalPart[0] >= 5;
}
/**
* @return string
*/
public function __toString()
{
if ($this->fractionalPart === '') {
return $this->integerPart;
}
return $this->integerPart.'.'.$this->fractionalPart;
}
/**
* @return bool
*/
public function isNegative()
{
return $this->integerPart[0] === '-';
}
/**
* @return string
*/
public function getIntegerPart()
{
return $this->integerPart;
}
/**
* @return string
*/
public function getFractionalPart()
{
return $this->fractionalPart;
}
/**
* @return string
*/
public function getIntegerRoundingMultiplier()
{
if ($this->integerPart[0] === '-') {
return '-1';
}
return '1';
}
/**
* @param int $number
*
* @return self
*/
public function base10($number)
{
if (!is_int($number)) {
throw new \InvalidArgumentException('Expecting integer');
}
if ($this->integerPart === '0' && !$this->fractionalPart) {
return $this;
}
$sign = '';
$integerPart = $this->integerPart;
if ($integerPart[0] === '-') {
$sign = '-';
$integerPart = substr($integerPart, 1);
}
if ($number >= 0) {
$integerPart = ltrim($integerPart, '0');
$lengthIntegerPart = strlen($integerPart);
$integers = $lengthIntegerPart - min($number, $lengthIntegerPart);
$zeroPad = $number - min($number, $lengthIntegerPart);
return new self(
$sign.substr($integerPart, 0, $integers),
rtrim(str_pad('', $zeroPad, '0').substr($integerPart, $integers).$this->fractionalPart, '0')
);
}
$number = abs($number);
$lengthFractionalPart = strlen($this->fractionalPart);
$fractions = $lengthFractionalPart - min($number, $lengthFractionalPart);
$zeroPad = $number - min($number, $lengthFractionalPart);
return new self(
$sign.ltrim($integerPart.substr($this->fractionalPart, 0, $lengthFractionalPart - $fractions).str_pad('', $zeroPad, '0'), '0'),
substr($this->fractionalPart, $lengthFractionalPart - $fractions)
);
}
/**
* @param string $number
*
* @return string
*/
private static function parseIntegerPart($number)
{
if ('' === $number || '0' === $number) {
return '0';
}
if ('-' === $number) {
return '-0';
}
$nonZero = false;
for ($position = 0, $characters = strlen($number); $position < $characters; ++$position) {
$digit = $number[$position];
if (!isset(static::$numbers[$digit]) && !(0 === $position && '-' === $digit)) {
throw new \InvalidArgumentException(sprintf('Invalid integer part %1$s. Invalid digit %2$s found', $number, $digit));
}
if (false === $nonZero && '0' === $digit) {
throw new \InvalidArgumentException('Leading zeros are not allowed');
}
$nonZero = true;
}
return $number;
}
/**
* @param string $number
*
* @return string
*/
private static function parseFractionalPart($number)
{
if ('' === $number) {
return $number;
}
for ($position = 0, $characters = strlen($number); $position < $characters; ++$position) {
$digit = $number[$position];
if (!isset(static::$numbers[$digit])) {
throw new \InvalidArgumentException(sprintf('Invalid fractional part %1$s. Invalid digit %2$s found', $number, $digit));
}
}
return $number;
}
/**
* @param string $moneyValue
* @param int $targetDigits
* @param int $havingDigits
*
* @return string
*/
public static function roundMoneyValue($moneyValue, $targetDigits, $havingDigits)
{
$valueLength = strlen($moneyValue);
$shouldRound = $targetDigits < $havingDigits && $valueLength - $havingDigits + $targetDigits > 0;
if ($shouldRound && $moneyValue[$valueLength - $havingDigits + $targetDigits] >= 5) {
$position = $valueLength - $havingDigits + $targetDigits;
$addend = 1;
while ($position > 0) {
$newValue = (string) ((int) $moneyValue[$position - 1] + $addend);
if ($newValue >= 10) {
$moneyValue[$position - 1] = $newValue[1];
$addend = $newValue[0];
--$position;
if ($position === 0) {
$moneyValue = $addend.$moneyValue;
}
} else {
if ($moneyValue[$position - 1] === '-') {
$moneyValue[$position - 1] = $newValue[0];
$moneyValue = '-'.$moneyValue;
} else {
$moneyValue[$position - 1] = $newValue[0];
}
break;
}
}
}
return $moneyValue;
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Money\PHPUnit;
use Money\Currencies\AggregateCurrencies;
use Money\Currencies\BitcoinCurrencies;
use Money\Currencies\ISOCurrencies;
use Money\Formatter\IntlMoneyFormatter;
use Money\Money;
use SebastianBergmann\Comparator\ComparisonFailure;
/**
* The comparator is for comparing Money objects in PHPUnit tests.
*
* Add this to your bootstrap file:
*
* \SebastianBergmann\Comparator\Factory::getInstance()->register(new \Money\PHPUnit\Comparator());
*/
final class Comparator extends \SebastianBergmann\Comparator\Comparator
{
/**
* @var IntlMoneyFormatter
*/
private $formatter;
public function __construct()
{
parent::__construct();
$currencies = new AggregateCurrencies([
new ISOCurrencies(),
new BitcoinCurrencies(),
]);
$numberFormatter = new \NumberFormatter('en_US', \NumberFormatter::CURRENCY);
$this->formatter = new IntlMoneyFormatter($numberFormatter, $currencies);
}
public function accepts($expected, $actual)
{
return $expected instanceof Money && $actual instanceof Money;
}
/**
* @param Money $expected
* @param Money $actual
* @param float $delta
* @param bool $canonicalize
* @param bool $ignoreCase
*/
public function assertEquals(
$expected,
$actual,
$delta = 0.0,
$canonicalize = false,
$ignoreCase = false,
array &$processed = []
) {
if (!$expected->equals($actual)) {
throw new ComparisonFailure($expected, $actual, $this->formatter->format($expected), $this->formatter->format($actual), false, 'Failed asserting that two Money objects are equal.');
}
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Money\Parser;
use Money\Currency;
use Money\Exception;
use Money\MoneyParser;
/**
* Parses a string into a Money object using other parsers.
*
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
final class AggregateMoneyParser implements MoneyParser
{
/**
* @var MoneyParser[]
*/
private $parsers = [];
/**
* @param MoneyParser[] $parsers
*/
public function __construct(array $parsers)
{
if (empty($parsers)) {
throw new \InvalidArgumentException(sprintf('Initialize an empty %s is not possible', self::class));
}
foreach ($parsers as $parser) {
if (false === $parser instanceof MoneyParser) {
throw new \InvalidArgumentException('All parsers must implement '.MoneyParser::class);
}
$this->parsers[] = $parser;
}
}
/**
* {@inheritdoc}
*/
public function parse($money, $forceCurrency = null)
{
if ($forceCurrency !== null && !$forceCurrency 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);
$forceCurrency = new Currency($forceCurrency);
}
foreach ($this->parsers as $parser) {
try {
return $parser->parse($money, $forceCurrency);
} catch (Exception\ParserException $e) {
}
}
throw new Exception\ParserException(sprintf('Unable to parse %s', $money));
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace Money\Parser;
use Money\Currencies\BitcoinCurrencies;
use Money\Currency;
use Money\Exception\ParserException;
use Money\Money;
use Money\MoneyParser;
/**
* Parses Bitcoin currency to Money.
*
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
final class BitcoinMoneyParser implements MoneyParser
{
/**
* @var int
*/
private $fractionDigits;
/**
* @param int $fractionDigits
*/
public function __construct($fractionDigits)
{
$this->fractionDigits = $fractionDigits;
}
/**
* {@inheritdoc}
*/
public function parse($money, $forceCurrency = null)
{
if (is_string($money) === false) {
throw new ParserException('Formatted raw money should be string, e.g. $1.00');
}
if (strpos($money, BitcoinCurrencies::SYMBOL) === false) {
throw new ParserException('Value cannot be parsed as Bitcoin');
}
if ($forceCurrency === null) {
$forceCurrency = new Currency(BitcoinCurrencies::CODE);
}
/*
* 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);
}
$decimal = str_replace(BitcoinCurrencies::SYMBOL, '', $money);
$decimalSeparator = strpos($decimal, '.');
if (false !== $decimalSeparator) {
$decimal = rtrim($decimal, '0');
$lengthDecimal = strlen($decimal);
$decimal = str_replace('.', '', $decimal);
$decimal .= str_pad('', ($lengthDecimal - $decimalSeparator - $this->fractionDigits - 1) * -1, '0');
} else {
$decimal .= str_pad('', $this->fractionDigits, '0');
}
if (substr($decimal, 0, 1) === '-') {
$decimal = '-'.ltrim(substr($decimal, 1), '0');
} else {
$decimal = ltrim($decimal, '0');
}
if ('' === $decimal) {
$decimal = '0';
}
return new Money($decimal, $currency);
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace Money\Parser;
use Money\Currencies;
use Money\Currency;
use Money\Exception\ParserException;
use Money\Money;
use Money\MoneyParser;
use Money\Number;
/**
* Parses a decimal string into a Money object.
*
* @author Teoh Han Hui <teohhanhui@gmail.com>
*/
final class DecimalMoneyParser implements MoneyParser
{
const DECIMAL_PATTERN = '/^(?P<sign>-)?(?P<digits>0|[1-9]\d*)?\.?(?P<fraction>\d+)?$/';
/**
* @var Currencies
*/
private $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('DecimalMoneyParser 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);
}
$decimal = trim($money);
if ($decimal === '') {
return new Money(0, $currency);
}
$subunit = $this->currencies->subunitFor($currency);
if (!preg_match(self::DECIMAL_PATTERN, $decimal, $matches) || !isset($matches['digits'])) {
throw new ParserException(sprintf('Cannot parse "%s" to Money.', $decimal));
}
$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);
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace Money\Parser;
use Money\Currencies;
use Money\Currency;
use Money\Exception\ParserException;
use Money\Money;
use Money\MoneyParser;
use Money\Number;
/**
* Parses a string into a Money object using intl extension.
*
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
final class IntlLocalizedDecimalParser implements MoneyParser
{
/**
* @var \NumberFormatter
*/
private $formatter;
/**
* @var Currencies
*/
private $currencies;
public function __construct(\NumberFormatter $formatter, Currencies $currencies)
{
$this->formatter = $formatter;
$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('IntlLocalizedDecimalParser cannot parse currency symbols. Use forceCurrency argument');
}
$decimal = $this->formatter->parse($money);
if (false === $decimal) {
throw new ParserException('Cannot parse '.$money.' to Money. '.$this->formatter->getErrorMessage());
}
/*
* This conversion is only required whilst currency can be either a string or a
* Currency object.
*/
if (!$forceCurrency 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);
$forceCurrency = new Currency($forceCurrency);
}
$decimal = (string) $decimal;
$subunit = $this->currencies->subunitFor($forceCurrency);
$decimalPosition = strpos($decimal, '.');
if (false !== $decimalPosition) {
$decimalLength = strlen($decimal);
$fractionDigits = $decimalLength - $decimalPosition - 1;
$decimal = str_replace('.', '', $decimal);
$decimal = Number::roundMoneyValue($decimal, $subunit, $fractionDigits);
if ($fractionDigits > $subunit) {
$decimal = substr($decimal, 0, $decimalPosition + $subunit);
} elseif ($fractionDigits < $subunit) {
$decimal .= str_pad('', $subunit - $fractionDigits, '0');
}
} else {
$decimal .= str_pad('', $subunit, '0');
}
if ('-' === $decimal[0]) {
$decimal = '-'.ltrim(substr($decimal, 1), '0');
} else {
$decimal = ltrim($decimal, '0');
}
if ('' === $decimal) {
$decimal = '0';
}
return new Money($decimal, $forceCurrency);
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace Money\Parser;
use Money\Currencies;
use Money\Currency;
use Money\Exception\ParserException;
use Money\Money;
use Money\MoneyParser;
use Money\Number;
/**
* Parses a string into a Money object using intl extension.
*
* @author Frederik Bosch <f.bosch@genkgo.nl>
*/
final class IntlMoneyParser implements MoneyParser
{
/**
* @var \NumberFormatter
*/
private $formatter;
/**
* @var Currencies
*/
private $currencies;
public function __construct(\NumberFormatter $formatter, Currencies $currencies)
{
$this->formatter = $formatter;
$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');
}
$currency = null;
$decimal = $this->formatter->parseCurrency($money, $currency);
if (false === $decimal) {
throw new ParserException('Cannot parse '.$money.' to Money. '.$this->formatter->getErrorMessage());
}
if (null !== $forceCurrency) {
$currency = $forceCurrency;
} else {
$currency = new Currency($currency);
}
/*
* This conversion is only required whilst currency can be either a string or a
* Currency object.
*/
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);
}
$decimal = (string) $decimal;
$subunit = $this->currencies->subunitFor($currency);
$decimalPosition = strpos($decimal, '.');
if (false !== $decimalPosition) {
$decimalLength = strlen($decimal);
$fractionDigits = $decimalLength - $decimalPosition - 1;
$decimal = str_replace('.', '', $decimal);
$decimal = Number::roundMoneyValue($decimal, $subunit, $fractionDigits);
if ($fractionDigits > $subunit) {
$decimal = substr($decimal, 0, $decimalPosition + $subunit);
} elseif ($fractionDigits < $subunit) {
$decimal .= str_pad('', $subunit - $fractionDigits, '0');
}
} else {
$decimal .= str_pad('', $subunit, '0');
}
if ('-' === $decimal[0]) {
$decimal = '-'.ltrim(substr($decimal, 1), '0');
} else {
$decimal = ltrim($decimal, '0');
}
if ('' === $decimal) {
$decimal = '0';
}
return new Money($decimal, $currency);
}
}