Calculator
1 year ago
Currencies
1 year ago
Exception
1 year ago
Exchange
1 year ago
Formatter
1 year ago
PHPUnit
1 year ago
Parser
1 year ago
Calculator.php
4 years ago
Converter.php
1 year ago
Currencies.php
1 year ago
Currency.php
1 year ago
CurrencyPair.php
1 year ago
Exception.php
4 years ago
Exchange.php
1 year ago
Money.php
1 year ago
MoneyFactory.php
4 years ago
MoneyFormatter.php
1 year ago
MoneyParser.php
4 years ago
Number.php
1 year ago
Money.php
614 lines
| 1 | <?php |
| 2 | |
| 3 | namespace Money; |
| 4 | |
| 5 | use Money\Calculator\BcMathCalculator; |
| 6 | use Money\Calculator\GmpCalculator; |
| 7 | use Money\Calculator\PhpCalculator; |
| 8 | |
| 9 | /** |
| 10 | * Money Value Object. |
| 11 | * |
| 12 | * @author Mathias Verraes |
| 13 | * |
| 14 | * @psalm-immutable |
| 15 | */ |
| 16 | final class Money implements \JsonSerializable |
| 17 | { |
| 18 | use MoneyFactory; |
| 19 | |
| 20 | const ROUND_HALF_UP = PHP_ROUND_HALF_UP; |
| 21 | |
| 22 | const ROUND_HALF_DOWN = PHP_ROUND_HALF_DOWN; |
| 23 | |
| 24 | const ROUND_HALF_EVEN = PHP_ROUND_HALF_EVEN; |
| 25 | |
| 26 | const ROUND_HALF_ODD = PHP_ROUND_HALF_ODD; |
| 27 | |
| 28 | const ROUND_UP = 5; |
| 29 | |
| 30 | const ROUND_DOWN = 6; |
| 31 | |
| 32 | const ROUND_HALF_POSITIVE_INFINITY = 7; |
| 33 | |
| 34 | const ROUND_HALF_NEGATIVE_INFINITY = 8; |
| 35 | |
| 36 | /** |
| 37 | * Internal value. |
| 38 | * |
| 39 | * @var string |
| 40 | */ |
| 41 | private $amount; |
| 42 | |
| 43 | /** |
| 44 | * @var Currency |
| 45 | */ |
| 46 | private $currency; |
| 47 | |
| 48 | /** |
| 49 | * @var Calculator |
| 50 | */ |
| 51 | private static $calculator; |
| 52 | |
| 53 | /** |
| 54 | * @var array |
| 55 | */ |
| 56 | private static $calculators = [ |
| 57 | BcMathCalculator::class, |
| 58 | GmpCalculator::class, |
| 59 | PhpCalculator::class, |
| 60 | ]; |
| 61 | |
| 62 | /** |
| 63 | * @param int|string $amount Amount, expressed in the smallest units of $currency (eg cents) |
| 64 | * |
| 65 | * @throws \InvalidArgumentException If amount is not integer |
| 66 | */ |
| 67 | public function __construct($amount, Currency $currency) |
| 68 | { |
| 69 | if (filter_var($amount, FILTER_VALIDATE_INT) === false) { |
| 70 | $numberFromString = Number::fromString($amount); |
| 71 | if (!$numberFromString->isInteger()) { |
| 72 | throw new \InvalidArgumentException('Amount must be an integer(ish) value'); |
| 73 | } |
| 74 | |
| 75 | $amount = $numberFromString->getIntegerPart(); |
| 76 | } |
| 77 | |
| 78 | $this->amount = (string) $amount; |
| 79 | $this->currency = $currency; |
| 80 | } |
| 81 | |
| 82 | /** |
| 83 | * Returns a new Money instance based on the current one using the Currency. |
| 84 | * |
| 85 | * @param int|string $amount |
| 86 | * |
| 87 | * @return Money |
| 88 | * |
| 89 | * @throws \InvalidArgumentException If amount is not integer |
| 90 | */ |
| 91 | private function newInstance($amount) |
| 92 | { |
| 93 | return new self($amount, $this->currency); |
| 94 | } |
| 95 | |
| 96 | /** |
| 97 | * Checks whether a Money has the same Currency as this. |
| 98 | * |
| 99 | * @return bool |
| 100 | */ |
| 101 | public function isSameCurrency(Money $other) |
| 102 | { |
| 103 | return $this->currency->equals($other->currency); |
| 104 | } |
| 105 | |
| 106 | /** |
| 107 | * Asserts that a Money has the same currency as this. |
| 108 | * |
| 109 | * @throws \InvalidArgumentException If $other has a different currency |
| 110 | */ |
| 111 | private function assertSameCurrency(Money $other) |
| 112 | { |
| 113 | if (!$this->isSameCurrency($other)) { |
| 114 | throw new \InvalidArgumentException('Currencies must be identical'); |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | /** |
| 119 | * Checks whether the value represented by this object equals to the other. |
| 120 | * |
| 121 | * @return bool |
| 122 | */ |
| 123 | public function equals(Money $other) |
| 124 | { |
| 125 | return $this->isSameCurrency($other) && $this->amount === $other->amount; |
| 126 | } |
| 127 | |
| 128 | /** |
| 129 | * Returns an integer less than, equal to, or greater than zero |
| 130 | * if the value of this object is considered to be respectively |
| 131 | * less than, equal to, or greater than the other. |
| 132 | * |
| 133 | * @return int |
| 134 | */ |
| 135 | public function compare(Money $other) |
| 136 | { |
| 137 | $this->assertSameCurrency($other); |
| 138 | |
| 139 | return $this->getCalculator()->compare($this->amount, $other->amount); |
| 140 | } |
| 141 | |
| 142 | /** |
| 143 | * Checks whether the value represented by this object is greater than the other. |
| 144 | * |
| 145 | * @return bool |
| 146 | */ |
| 147 | public function greaterThan(Money $other) |
| 148 | { |
| 149 | return $this->compare($other) > 0; |
| 150 | } |
| 151 | |
| 152 | /** |
| 153 | * @param \Money\Money $other |
| 154 | * |
| 155 | * @return bool |
| 156 | */ |
| 157 | public function greaterThanOrEqual(Money $other) |
| 158 | { |
| 159 | return $this->compare($other) >= 0; |
| 160 | } |
| 161 | |
| 162 | /** |
| 163 | * Checks whether the value represented by this object is less than the other. |
| 164 | * |
| 165 | * @return bool |
| 166 | */ |
| 167 | public function lessThan(Money $other) |
| 168 | { |
| 169 | return $this->compare($other) < 0; |
| 170 | } |
| 171 | |
| 172 | /** |
| 173 | * @param \Money\Money $other |
| 174 | * |
| 175 | * @return bool |
| 176 | */ |
| 177 | public function lessThanOrEqual(Money $other) |
| 178 | { |
| 179 | return $this->compare($other) <= 0; |
| 180 | } |
| 181 | |
| 182 | /** |
| 183 | * Returns the value represented by this object. |
| 184 | * |
| 185 | * @return string |
| 186 | */ |
| 187 | public function getAmount() |
| 188 | { |
| 189 | return $this->amount; |
| 190 | } |
| 191 | |
| 192 | /** |
| 193 | * Returns the currency of this object. |
| 194 | * |
| 195 | * @return Currency |
| 196 | */ |
| 197 | public function getCurrency() |
| 198 | { |
| 199 | return $this->currency; |
| 200 | } |
| 201 | |
| 202 | /** |
| 203 | * Returns a new Money object that represents |
| 204 | * the sum of this and an other Money object. |
| 205 | * |
| 206 | * @param Money[] $addends |
| 207 | * |
| 208 | * @return Money |
| 209 | */ |
| 210 | public function add(Money ...$addends) |
| 211 | { |
| 212 | $amount = $this->amount; |
| 213 | $calculator = $this->getCalculator(); |
| 214 | |
| 215 | foreach ($addends as $addend) { |
| 216 | $this->assertSameCurrency($addend); |
| 217 | |
| 218 | $amount = $calculator->add($amount, $addend->amount); |
| 219 | } |
| 220 | |
| 221 | return new self($amount, $this->currency); |
| 222 | } |
| 223 | |
| 224 | /** |
| 225 | * Returns a new Money object that represents |
| 226 | * the difference of this and an other Money object. |
| 227 | * |
| 228 | * @param Money[] $subtrahends |
| 229 | * |
| 230 | * @return Money |
| 231 | * |
| 232 | * @psalm-pure |
| 233 | */ |
| 234 | public function subtract(Money ...$subtrahends) |
| 235 | { |
| 236 | $amount = $this->amount; |
| 237 | $calculator = $this->getCalculator(); |
| 238 | |
| 239 | foreach ($subtrahends as $subtrahend) { |
| 240 | $this->assertSameCurrency($subtrahend); |
| 241 | |
| 242 | $amount = $calculator->subtract($amount, $subtrahend->amount); |
| 243 | } |
| 244 | |
| 245 | return new self($amount, $this->currency); |
| 246 | } |
| 247 | |
| 248 | /** |
| 249 | * Asserts that the operand is integer or float. |
| 250 | * |
| 251 | * @param float|int|string $operand |
| 252 | * |
| 253 | * @throws \InvalidArgumentException If $operand is neither integer nor float |
| 254 | */ |
| 255 | private function assertOperand($operand) |
| 256 | { |
| 257 | if (!is_numeric($operand)) { |
| 258 | throw new \InvalidArgumentException(sprintf('Operand should be a numeric value, "%s" given.', is_object($operand) ? get_class($operand) : gettype($operand))); |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | /** |
| 263 | * Asserts that rounding mode is a valid integer value. |
| 264 | * |
| 265 | * @param int $roundingMode |
| 266 | * |
| 267 | * @throws \InvalidArgumentException If $roundingMode is not valid |
| 268 | */ |
| 269 | private function assertRoundingMode($roundingMode) |
| 270 | { |
| 271 | if (!in_array( |
| 272 | $roundingMode, [ |
| 273 | self::ROUND_HALF_DOWN, self::ROUND_HALF_EVEN, self::ROUND_HALF_ODD, |
| 274 | self::ROUND_HALF_UP, self::ROUND_UP, self::ROUND_DOWN, |
| 275 | self::ROUND_HALF_POSITIVE_INFINITY, self::ROUND_HALF_NEGATIVE_INFINITY, |
| 276 | ], true |
| 277 | )) { |
| 278 | 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'); |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | /** |
| 283 | * Returns a new Money object that represents |
| 284 | * the multiplied value by the given factor. |
| 285 | * |
| 286 | * @param float|int|string $multiplier |
| 287 | * @param int $roundingMode |
| 288 | * |
| 289 | * @return Money |
| 290 | */ |
| 291 | public function multiply($multiplier, $roundingMode = self::ROUND_HALF_UP) |
| 292 | { |
| 293 | $this->assertOperand($multiplier); |
| 294 | $this->assertRoundingMode($roundingMode); |
| 295 | |
| 296 | $product = $this->round($this->getCalculator()->multiply($this->amount, $multiplier), $roundingMode); |
| 297 | |
| 298 | return $this->newInstance($product); |
| 299 | } |
| 300 | |
| 301 | /** |
| 302 | * Returns a new Money object that represents |
| 303 | * the divided value by the given factor. |
| 304 | * |
| 305 | * @param float|int|string $divisor |
| 306 | * @param int $roundingMode |
| 307 | * |
| 308 | * @return Money |
| 309 | */ |
| 310 | public function divide($divisor, $roundingMode = self::ROUND_HALF_UP) |
| 311 | { |
| 312 | $this->assertOperand($divisor); |
| 313 | $this->assertRoundingMode($roundingMode); |
| 314 | |
| 315 | $divisor = (string) Number::fromNumber($divisor); |
| 316 | |
| 317 | if ($this->getCalculator()->compare($divisor, '0') === 0) { |
| 318 | throw new \InvalidArgumentException('Division by zero'); |
| 319 | } |
| 320 | |
| 321 | $quotient = $this->round($this->getCalculator()->divide($this->amount, $divisor), $roundingMode); |
| 322 | |
| 323 | return $this->newInstance($quotient); |
| 324 | } |
| 325 | |
| 326 | /** |
| 327 | * Returns a new Money object that represents |
| 328 | * the remainder after dividing the value by |
| 329 | * the given factor. |
| 330 | * |
| 331 | * @return Money |
| 332 | */ |
| 333 | public function mod(Money $divisor) |
| 334 | { |
| 335 | $this->assertSameCurrency($divisor); |
| 336 | |
| 337 | return new self($this->getCalculator()->mod($this->amount, $divisor->amount), $this->currency); |
| 338 | } |
| 339 | |
| 340 | /** |
| 341 | * Allocate the money according to a list of ratios. |
| 342 | * |
| 343 | * @return Money[] |
| 344 | */ |
| 345 | public function allocate(array $ratios) |
| 346 | { |
| 347 | if (count($ratios) === 0) { |
| 348 | throw new \InvalidArgumentException('Cannot allocate to none, ratios cannot be an empty array'); |
| 349 | } |
| 350 | |
| 351 | $remainder = $this->amount; |
| 352 | $results = []; |
| 353 | $total = array_sum($ratios); |
| 354 | |
| 355 | if ($total <= 0) { |
| 356 | throw new \InvalidArgumentException('Cannot allocate to none, sum of ratios must be greater than zero'); |
| 357 | } |
| 358 | |
| 359 | foreach ($ratios as $key => $ratio) { |
| 360 | if ($ratio < 0) { |
| 361 | throw new \InvalidArgumentException('Cannot allocate to none, ratio must be zero or positive'); |
| 362 | } |
| 363 | $share = $this->getCalculator()->share($this->amount, $ratio, $total); |
| 364 | $results[$key] = $this->newInstance($share); |
| 365 | $remainder = $this->getCalculator()->subtract($remainder, $share); |
| 366 | } |
| 367 | |
| 368 | if ($this->getCalculator()->compare($remainder, '0') === 0) { |
| 369 | return $results; |
| 370 | } |
| 371 | |
| 372 | $fractions = array_map(function ($ratio) use ($total) { |
| 373 | $share = ($ratio / $total) * $this->amount; |
| 374 | |
| 375 | return $share - floor($share); |
| 376 | }, $ratios); |
| 377 | |
| 378 | while ($this->getCalculator()->compare($remainder, '0') > 0) { |
| 379 | $index = !empty($fractions) ? array_keys($fractions, max($fractions))[0] : 0; |
| 380 | $results[$index]->amount = $this->getCalculator()->add($results[$index]->amount, '1'); |
| 381 | $remainder = $this->getCalculator()->subtract($remainder, '1'); |
| 382 | unset($fractions[$index]); |
| 383 | } |
| 384 | |
| 385 | return $results; |
| 386 | } |
| 387 | |
| 388 | /** |
| 389 | * Allocate the money among N targets. |
| 390 | * |
| 391 | * @param int $n |
| 392 | * |
| 393 | * @return Money[] |
| 394 | * |
| 395 | * @throws \InvalidArgumentException If number of targets is not an integer |
| 396 | */ |
| 397 | public function allocateTo($n) |
| 398 | { |
| 399 | if (!is_int($n)) { |
| 400 | throw new \InvalidArgumentException('Number of targets must be an integer'); |
| 401 | } |
| 402 | |
| 403 | if ($n <= 0) { |
| 404 | throw new \InvalidArgumentException('Cannot allocate to none, target must be greater than zero'); |
| 405 | } |
| 406 | |
| 407 | return $this->allocate(array_fill(0, $n, 1)); |
| 408 | } |
| 409 | |
| 410 | /** |
| 411 | * @return string |
| 412 | */ |
| 413 | public function ratioOf(Money $money) |
| 414 | { |
| 415 | if ($money->isZero()) { |
| 416 | throw new \InvalidArgumentException('Cannot calculate a ratio of zero'); |
| 417 | } |
| 418 | |
| 419 | return $this->getCalculator()->divide($this->amount, $money->amount); |
| 420 | } |
| 421 | |
| 422 | /** |
| 423 | * @param string $amount |
| 424 | * @param int $rounding_mode |
| 425 | * |
| 426 | * @return string |
| 427 | */ |
| 428 | private function round($amount, $rounding_mode) |
| 429 | { |
| 430 | $this->assertRoundingMode($rounding_mode); |
| 431 | |
| 432 | if ($rounding_mode === self::ROUND_UP) { |
| 433 | return $this->getCalculator()->ceil($amount); |
| 434 | } |
| 435 | |
| 436 | if ($rounding_mode === self::ROUND_DOWN) { |
| 437 | return $this->getCalculator()->floor($amount); |
| 438 | } |
| 439 | |
| 440 | return $this->getCalculator()->round($amount, $rounding_mode); |
| 441 | } |
| 442 | |
| 443 | /** |
| 444 | * @return Money |
| 445 | */ |
| 446 | public function absolute() |
| 447 | { |
| 448 | return $this->newInstance($this->getCalculator()->absolute($this->amount)); |
| 449 | } |
| 450 | |
| 451 | /** |
| 452 | * @return Money |
| 453 | */ |
| 454 | public function negative() |
| 455 | { |
| 456 | return $this->newInstance(0)->subtract($this); |
| 457 | } |
| 458 | |
| 459 | /** |
| 460 | * Checks if the value represented by this object is zero. |
| 461 | * |
| 462 | * @return bool |
| 463 | */ |
| 464 | public function isZero() |
| 465 | { |
| 466 | return $this->getCalculator()->compare($this->amount, 0) === 0; |
| 467 | } |
| 468 | |
| 469 | /** |
| 470 | * Checks if the value represented by this object is positive. |
| 471 | * |
| 472 | * @return bool |
| 473 | */ |
| 474 | public function isPositive() |
| 475 | { |
| 476 | return $this->getCalculator()->compare($this->amount, 0) > 0; |
| 477 | } |
| 478 | |
| 479 | /** |
| 480 | * Checks if the value represented by this object is negative. |
| 481 | * |
| 482 | * @return bool |
| 483 | */ |
| 484 | public function isNegative() |
| 485 | { |
| 486 | return $this->getCalculator()->compare($this->amount, 0) < 0; |
| 487 | } |
| 488 | |
| 489 | /** |
| 490 | * {@inheritdoc} |
| 491 | * |
| 492 | * @return array |
| 493 | */ |
| 494 | #[\ReturnTypeWillChange] |
| 495 | public function jsonSerialize() |
| 496 | { |
| 497 | return [ |
| 498 | 'amount' => $this->amount, |
| 499 | 'currency' => $this->currency->jsonSerialize(), |
| 500 | ]; |
| 501 | } |
| 502 | |
| 503 | /** |
| 504 | * @param Money $first |
| 505 | * @param Money ...$collection |
| 506 | * |
| 507 | * @return Money |
| 508 | * |
| 509 | * @psalm-pure |
| 510 | */ |
| 511 | public static function min(self $first, self ...$collection) |
| 512 | { |
| 513 | $min = $first; |
| 514 | |
| 515 | foreach ($collection as $money) { |
| 516 | if ($money->lessThan($min)) { |
| 517 | $min = $money; |
| 518 | } |
| 519 | } |
| 520 | |
| 521 | return $min; |
| 522 | } |
| 523 | |
| 524 | /** |
| 525 | * @param Money $first |
| 526 | * @param Money ...$collection |
| 527 | * |
| 528 | * @return Money |
| 529 | * |
| 530 | * @psalm-pure |
| 531 | */ |
| 532 | public static function max(self $first, self ...$collection) |
| 533 | { |
| 534 | $max = $first; |
| 535 | |
| 536 | foreach ($collection as $money) { |
| 537 | if ($money->greaterThan($max)) { |
| 538 | $max = $money; |
| 539 | } |
| 540 | } |
| 541 | |
| 542 | return $max; |
| 543 | } |
| 544 | |
| 545 | /** |
| 546 | * @param Money $first |
| 547 | * @param Money ...$collection |
| 548 | * |
| 549 | * @return Money |
| 550 | * |
| 551 | * @psalm-pure |
| 552 | */ |
| 553 | public static function sum(self $first, self ...$collection) |
| 554 | { |
| 555 | return $first->add(...$collection); |
| 556 | } |
| 557 | |
| 558 | /** |
| 559 | * @param Money $first |
| 560 | * @param Money ...$collection |
| 561 | * |
| 562 | * @return Money |
| 563 | * |
| 564 | * @psalm-pure |
| 565 | */ |
| 566 | public static function avg(self $first, self ...$collection) |
| 567 | { |
| 568 | return $first->add(...$collection)->divide(func_num_args()); |
| 569 | } |
| 570 | |
| 571 | /** |
| 572 | * @param string $calculator |
| 573 | */ |
| 574 | public static function registerCalculator($calculator) |
| 575 | { |
| 576 | if (is_a($calculator, Calculator::class, true) === false) { |
| 577 | throw new \InvalidArgumentException('Calculator must implement '.Calculator::class); |
| 578 | } |
| 579 | |
| 580 | array_unshift(self::$calculators, $calculator); |
| 581 | } |
| 582 | |
| 583 | /** |
| 584 | * @return Calculator |
| 585 | * |
| 586 | * @throws \RuntimeException If cannot find calculator for money calculations |
| 587 | */ |
| 588 | private static function initializeCalculator() |
| 589 | { |
| 590 | $calculators = self::$calculators; |
| 591 | |
| 592 | foreach ($calculators as $calculator) { |
| 593 | /** @var Calculator $calculator */ |
| 594 | if ($calculator::supported()) { |
| 595 | return new $calculator(); |
| 596 | } |
| 597 | } |
| 598 | |
| 599 | throw new \RuntimeException('Cannot find calculator for money calculations'); |
| 600 | } |
| 601 | |
| 602 | /** |
| 603 | * @return Calculator |
| 604 | */ |
| 605 | private function getCalculator() |
| 606 | { |
| 607 | if (null === self::$calculator) { |
| 608 | self::$calculator = self::initializeCalculator(); |
| 609 | } |
| 610 | |
| 611 | return self::$calculator; |
| 612 | } |
| 613 | } |
| 614 |