Completed
Push — master ( 1883bf...af1526 )
by Andreu
02:53 queued 01:24
created

Money::extractNumericAmount()   A

Complexity

Conditions 4
Paths 2

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 4

Importance

Changes 0
Metric Value
dl 0
loc 12
ccs 6
cts 6
cp 1
rs 9.2
c 0
b 0
f 0
cc 4
eloc 7
nc 2
nop 2
crap 4
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Adsmurai\Currency;
6
7
use Adsmurai\Currency\Contracts\Money as MoneyContract;
8
use Adsmurai\Currency\Contracts\MoneyFormat as MoneyFormatInterface;
9
use Adsmurai\Currency\Contracts\Currency as CurrencyContract;
10
use InvalidArgumentException;
11
use Litipk\BigNumbers\Decimal;
12
use Litipk\BigNumbers\Errors\InfiniteInputError;
13
use Litipk\BigNumbers\Errors\NaNInputError;
14
15
final class Money implements MoneyContract
16
{
17
    const DECIMAL_NUMBER_REGEXP = '(?P<amount> 0*(([1-9][0-9]*|[0-9])(\.[0-9]+)?))';
18
    const SIMPLE_CURRENCY_PATTERN = '/^'.self::DECIMAL_NUMBER_REGEXP.'$/x';
19
    const INNER_FRACTIONAL_DIGITS = 8;
20
21
    /** @var Decimal */
22
    private $amount;
23
24
    /** @var CurrencyContract */
25
    private $currency;
26
27
    /**
28
     * @param Decimal      $amount
29
     * @param CurrencyContract $currency
30
     */
31 56
    private function __construct(Decimal $amount, CurrencyContract $currency)
32
    {
33 56
        if ($amount->isNegative()) {
34 12
            throw new InvalidArgumentException('Currency amounts must be positive');
35
        }
36
37 44
        $this->amount = $amount;
38 44
        $this->currency = $currency;
39 44
    }
40
41 11
    public static function fromFloat(float $amount, CurrencyContract $currency): Money
42
    {
43
        try {
44 11
            return new self(
45 11
                Decimal::fromFloat($amount, self::INNER_FRACTIONAL_DIGITS),
46 8
                $currency
47
            );
48 7
        } catch (InfiniteInputError $e) {
49 2
            throw new InvalidArgumentException('Currency amounts must be finite', 0, $e);
50 5
        } catch (NaNInputError $e) {
51 1
            throw new InvalidArgumentException('Currency amounts must be numbers', 0, $e);
52
        }
53
    }
54
55 8
    public static function fromFractionalUnits(int $amount, CurrencyContract $currency): Money
56
    {
57 8
        $decimalAmount = Decimal::fromInteger($amount)
58 8
            ->div(
59 8
                Decimal::fromInteger(10 ** $currency->getNumFractionalDigits()),
60 8
                self::INNER_FRACTIONAL_DIGITS
61
            );
62
63 8
        return new self($decimalAmount, $currency);
64
    }
65
66 64
    public static function fromString(string $amount, CurrencyContract $currency): Money
67
    {
68 64
        return new self(
69 64
            self::extractNumericAmount($amount, $currency),
70 32
            $currency
71
        );
72
    }
73
74 64
    private static function extractNumericAmount(string $amount, CurrencyContract $currency): Decimal
75
    {
76
        if (
77 64
            1 === \preg_match(self::SIMPLE_CURRENCY_PATTERN, $amount, $matches) ||
78 56
            1 === \preg_match(self::getAmountPlusIsoCodePattern($currency), $amount, $matches) ||
79 64
            1 === \preg_match(self::getAmountPlusSymbolPattern($currency), $amount, $matches)
80
        ) {
81 32
            return Decimal::fromString($matches['amount'], self::INNER_FRACTIONAL_DIGITS);
82
        }
83
84 32
        throw new InvalidArgumentException('Invalid currency value');
85
    }
86
87
    /**
88
     * @param CurrencyContract $currency
89
     *
90
     * @return string
91
     */
92 56
    private static function getAmountPlusIsoCodePattern(CurrencyContract $currency): string
93
    {
94 56
        $amountPlusIsoCodePattern = '/^'.self::DECIMAL_NUMBER_REGEXP.'\s*'.$currency->getISOCode().'$/x';
95
96 56
        return $amountPlusIsoCodePattern;
97
    }
98
99 48
    private static function getAmountPlusSymbolPattern(CurrencyContract $currency): string
100
    {
101 48
        $escapedSymbol = \preg_quote($currency->getSymbol());
102
103 48
        return (CurrencyContract::BEFORE_PLACEMENT === $currency->getSymbolPlacement())
104 24
            ? '/^'.$escapedSymbol.'\s*'.self::DECIMAL_NUMBER_REGEXP.'$/x'
105 48
            : '/^'.self::DECIMAL_NUMBER_REGEXP.'\s*'.$escapedSymbol.'$/x';
106
    }
107
108 8
    public static function fromDecimal(Decimal $amount, CurrencyContract $currency): Money
109
    {
110 8
        return new self(
111 8
            Decimal::fromDecimal($amount, self::INNER_FRACTIONAL_DIGITS),
112 8
            $currency
113
        );
114
    }
115
116 9
    public function getCurrency(): CurrencyContract
117
    {
118 9
        return $this->currency;
119
    }
120
121
    /**
122
     * {@inheritdoc}
123
     */
124 13
    public function getAmountAsDecimal(): Decimal
125
    {
126 13
        return $this->amount;
127
    }
128
129
    /**
130
     * {@inheritdoc}
131
     */
132 24
    public function getAmountAsFractionalUnits(): int
133
    {
134 24
        return $this->amount
135 24
            ->mul(
136 24
                Decimal::fromInteger(10 ** $this->currency->getNumFractionalDigits()),
137 24
                self::INNER_FRACTIONAL_DIGITS
138
            )
139 24
            ->asInteger();
140
    }
141
142
    /**
143
     * {@inheritdoc}
144
     */
145 126
    public function format(MoneyFormatInterface $currencyFormat = null): string
146
    {
147 126
        if (is_null($currencyFormat)) {
148 24
            $currencyFormat = MoneyFormat::default();
149
        }
150
151 126
        $nDecimals = $currencyFormat->getPrecision();
152 126
        if (is_null($nDecimals)) {
153 110
            $nDecimals = $this->currency->getNumFractionalDigits() + $currencyFormat->getExtraPrecision();
154
        }
155
156 126
        $amount = Decimal::fromDecimal($this->amount, $nDecimals);
157
158 126
        $number = ('' === $currencyFormat->getThousandsSeparator())
159 110
            ? \str_replace('.', $currencyFormat->getDecimalsSeparator(), $amount->__toString())  // This is safer!
160 16
            : \number_format(
161 16
                $amount->asFloat(),
162 16
                $nDecimals,
163 16
                $currencyFormat->getDecimalsSeparator(),
164 126
                $currencyFormat->getThousandsSeparator()
165
            );
166
167 126
        return $this->decorate($number, $currencyFormat);
168
    }
169
170
    /**
171
     * @param string                  $number
172
     * @param MoneyFormatInterface $currencyFormat
173
     *
174
     * @return string
175
     */
176 126
    private function decorate(string $number, MoneyFormatInterface $currencyFormat): string
177
    {
178 126
        $separator = (MoneyFormat::DECORATION_WITH_SPACE === $currencyFormat->getDecorationSpace())
179 3
            ? ' '
180 126
            : '';
181
182 126
        switch ($currencyFormat->getDecorationType()) {
183 126
            case MoneyFormat::DECORATION_NO_DECORATION:
184 24
                return $number;
185 102
            case MoneyFormat::DECORATION_ISO_CODE:
186 26
                return $number.$separator.$this->currency->getISOCode();
187
            default:
188 76
                return (CurrencyContract::BEFORE_PLACEMENT === $this->currency->getSymbolPlacement())
189 18
                    ? $this->currency->getSymbol().$separator.$number
190 76
                    : $number.$separator.$this->currency->getSymbol();
191
        }
192
    }
193
194
    /**
195
     * {@inheritdoc}
196
     */
197 12
    public function equals(MoneyContract $currency): bool
198
    {
199 12
        return $currency === $this || (
200 12
                $this->amount->equals($currency->getAmountAsDecimal()) &&
201 12
                $this->currency->equals($currency->getCurrency())
202
            );
203
    }
204
}
205