16 final class Money implements \JsonSerializable
20 const ROUND_HALF_UP = PHP_ROUND_HALF_UP;
22 const ROUND_HALF_DOWN = PHP_ROUND_HALF_DOWN;
24 const ROUND_HALF_EVEN = PHP_ROUND_HALF_EVEN;
26 const ROUND_HALF_ODD = PHP_ROUND_HALF_ODD;
32 const ROUND_HALF_POSITIVE_INFINITY = 7;
34 const ROUND_HALF_NEGATIVE_INFINITY = 8;
51 private static $calculator;
56 private static $calculators = [
57 BcMathCalculator::class,
68 public function __construct($amount,
Currency $currency)
70 if (filter_var($amount, FILTER_VALIDATE_INT) ===
false) {
72 if (!$numberFromString->isInteger()) {
73 throw new \InvalidArgumentException(
'Amount must be an integer(ish) value');
76 $amount = $numberFromString->getIntegerPart();
79 $this->amount = (string) $amount;
80 $this->currency = $currency;
92 private function newInstance($amount)
94 return new self($amount, $this->currency);
104 public function isSameCurrency(
Money $other)
106 return $this->currency->
equals($other->currency);
116 private function assertSameCurrency(
Money $other)
118 if (!$this->isSameCurrency($other)) {
119 throw new \InvalidArgumentException(
'Currencies must be identical');
130 public function equals(
Money $other)
132 return $this->isSameCurrency($other) && $this->amount === $other->amount;
144 public function compare(
Money $other)
146 $this->assertSameCurrency($other);
148 return $this->getCalculator()->compare($this->amount, $other->amount);
158 public function greaterThan(
Money $other)
160 return $this->compare($other) > 0;
168 public function greaterThanOrEqual(
Money $other)
170 return $this->compare($other) >= 0;
180 public function lessThan(
Money $other)
182 return $this->compare($other) < 0;
190 public function lessThanOrEqual(
Money $other)
192 return $this->compare($other) <= 0;
200 public function getAmount()
202 return $this->amount;
210 public function getCurrency()
212 return $this->currency;
223 public function add(
Money ...$addends)
225 $amount = $this->amount;
226 $calculator = $this->getCalculator();
228 foreach ($addends as $addend) {
229 $this->assertSameCurrency($addend);
231 $amount = $calculator->add($amount, $addend->amount);
234 return new self($amount, $this->currency);
247 public function subtract(
Money ...$subtrahends)
249 $amount = $this->amount;
250 $calculator = $this->getCalculator();
252 foreach ($subtrahends as $subtrahend) {
253 $this->assertSameCurrency($subtrahend);
255 $amount = $calculator->subtract($amount, $subtrahend->amount);
258 return new self($amount, $this->currency);
268 private function assertOperand($operand)
270 if (!is_numeric($operand)) {
271 throw new \InvalidArgumentException(sprintf(
272 'Operand should be a numeric value, "%s" given.',
273 is_object($operand) ? get_class($operand) : gettype($operand)
285 private function assertRoundingMode($roundingMode)
289 self::ROUND_HALF_DOWN, self::ROUND_HALF_EVEN, self::ROUND_HALF_ODD,
290 self::ROUND_HALF_UP, self::ROUND_UP, self::ROUND_DOWN,
291 self::ROUND_HALF_POSITIVE_INFINITY, self::ROUND_HALF_NEGATIVE_INFINITY,
294 throw new \InvalidArgumentException(
295 'Rounding mode should be Money::ROUND_HALF_DOWN | '.
296 'Money::ROUND_HALF_EVEN | Money::ROUND_HALF_ODD | '.
297 'Money::ROUND_HALF_UP | Money::ROUND_UP | Money::ROUND_DOWN'.
298 'Money::ROUND_HALF_POSITIVE_INFINITY | Money::ROUND_HALF_NEGATIVE_INFINITY'
312 public function multiply($multiplier, $roundingMode = self::ROUND_HALF_UP)
314 $this->assertOperand($multiplier);
315 $this->assertRoundingMode($roundingMode);
317 $product = $this->round($this->getCalculator()->multiply($this->amount, $multiplier), $roundingMode);
319 return $this->newInstance($product);
331 public function divide($divisor, $roundingMode = self::ROUND_HALF_UP)
333 $this->assertOperand($divisor);
334 $this->assertRoundingMode($roundingMode);
338 if ($this->getCalculator()->compare($divisor,
'0') === 0) {
339 throw new \InvalidArgumentException(
'Division by zero');
342 $quotient = $this->round($this->getCalculator()->divide($this->amount, $divisor), $roundingMode);
344 return $this->newInstance($quotient);
356 public function mod(
Money $divisor)
358 $this->assertSameCurrency($divisor);
360 return new self($this->getCalculator()->mod($this->amount, $divisor->amount), $this->currency);
370 public function allocate(array $ratios)
372 if (count($ratios) === 0) {
373 throw new \InvalidArgumentException(
'Cannot allocate to none, ratios cannot be an empty array');
376 $remainder = $this->amount;
378 $total = array_sum($ratios);
381 throw new \InvalidArgumentException(
'Cannot allocate to none, sum of ratios must be greater than zero');
384 foreach ($ratios as $key => $ratio) {
386 throw new \InvalidArgumentException(
'Cannot allocate to none, ratio must be zero or positive');
388 $share = $this->getCalculator()->share($this->amount, $ratio, $total);
389 $results[$key] = $this->newInstance($share);
390 $remainder = $this->getCalculator()->subtract($remainder, $share);
393 if ($this->getCalculator()->compare($remainder,
'0') === 0) {
397 $fractions = array_map(
function ($ratio) use ($total) {
398 $share = ($ratio / $total) * $this->amount;
400 return $share - floor($share);
403 while ($this->getCalculator()->compare($remainder,
'0') > 0) {
404 $index = !empty($fractions) ? array_keys($fractions, max($fractions))[0] : 0;
405 $results[$index]->amount = $this->getCalculator()->add($results[$index]->amount,
'1');
406 $remainder = $this->getCalculator()->subtract($remainder,
'1');
407 unset($fractions[$index]);
422 public function allocateTo($n)
425 throw new \InvalidArgumentException(
'Number of targets must be an integer');
429 throw new \InvalidArgumentException(
'Cannot allocate to none, target must be greater than zero');
432 return $this->allocate(array_fill(0, $n, 1));
440 public function ratioOf(
Money $money)
442 if ($money->isZero()) {
443 throw new \InvalidArgumentException(
'Cannot calculate a ratio of zero');
446 return $this->getCalculator()->divide($this->amount, $money->amount);
455 private function round($amount, $rounding_mode)
457 $this->assertRoundingMode($rounding_mode);
459 if ($rounding_mode === self::ROUND_UP) {
460 return $this->getCalculator()->ceil($amount);
463 if ($rounding_mode === self::ROUND_DOWN) {
464 return $this->getCalculator()->floor($amount);
467 return $this->getCalculator()->round($amount, $rounding_mode);
473 public function absolute()
475 return $this->newInstance($this->getCalculator()->absolute($this->amount));
481 public function negative()
483 return $this->newInstance(0)->subtract($this);
491 public function isZero()
493 return $this->getCalculator()->compare($this->amount, 0) === 0;
501 public function isPositive()
503 return $this->getCalculator()->compare($this->amount, 0) > 0;
511 public function isNegative()
513 return $this->getCalculator()->compare($this->amount, 0) < 0;
521 public function jsonSerialize()
524 'amount' => $this->amount,
525 'currency' => $this->currency->jsonSerialize(),
537 public static function min(
self $first,
self ...$collection)
541 foreach ($collection as $money) {
542 if ($money->lessThan($min)) {
558 public static function max(
self $first,
self ...$collection)
562 foreach ($collection as $money) {
563 if ($money->greaterThan($max)) {
579 public static function sum(
self $first,
self ...$collection)
581 return $first->add(...$collection);
592 public static function avg(
self $first,
self ...$collection)
594 return $first->add(...$collection)->divide(func_num_args());
600 public static function registerCalculator($calculator)
602 if (is_a($calculator, Calculator::class,
true) ===
false) {
603 throw new \InvalidArgumentException(
'Calculator must implement '.Calculator::class);
606 array_unshift(self::$calculators, $calculator);
614 private static function initializeCalculator()
616 $calculators = self::$calculators;
618 foreach ($calculators as $calculator) {
620 if ($calculator::supported()) {
621 return new $calculator();
625 throw new \RuntimeException(
'Cannot find calculator for money calculations');
631 private function getCalculator()
633 if (
null === self::$calculator) {
634 self::$calculator = self::initializeCalculator();
637 return self::$calculator;