Lines 99.03% 103 / 104
Methods 93.33% 14 / 15
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 fromDecimalString 100.00% 3 / 3 100.00% 1 / 1 2
 toDecimalString 100.00% 10 / 10 100.00% 1 / 1 6
 format 100.00% 1 / 1 100.00% 1 / 1 1
 isPositive 100.00% 1 / 1 100.00% 1 / 1 1
 isNegative 100.00% 1 / 1 100.00% 1 / 1 1
 isZero 100.00% 1 / 1 100.00% 1 / 1 1
 equals 100.00% 1 / 1 100.00% 1 / 1 2
 abs 100.00% 3 / 3 100.00% 1 / 1 3
 toMinorUnits 100.00% 22 / 22 100.00% 1 / 1 14
 normalizeSeparators 100.00% 28 / 28 100.00% 1 / 1 17
 stripGroups 90.00% 9 / 10 0.00% 0 / 1 7.05
 combine 100.00% 11 / 11 100.00% 1 / 1 10
 assertSpaceGroups 100.00% 7 / 7 100.00% 1 / 1 5
 scale 100.00% 4 / 4 100.00% 1 / 1 2
17final readonly class Money
18{
19    public function __construct(
20        public int $amount,
21        public Currency $currency,
22    ) {}
23
24    /**
25     * @param string $amount Decimal amount such as "12.34", "12,34" or "-1 234,56"
26     * @param Currency|string $currency Currency or ISO code ("EUR", "978", "SEK")
27     * @param string|null $decimalSeparator Bank-specific decimal mark. "." or ",".
28     *        When omitted, a single separator followed by exactly 3 digits is rejected as ambiguous.
29     */
30    public static function fromDecimalString(string $amount, Currency|string $currency, ?string $decimalSeparator = null): self
31    {
32        if (is_string($currency)) {
33            $currency = Currency::fromCode($currency);
34        }
35
36        return new self(self::toMinorUnits($amount, $currency->minorUnits(), $decimalSeparator), $currency);
37    }
38
39    public function toDecimalString(): string
40    {
41        $scale = self::scale($this->currency->minorUnits());
42        $negative = $this->amount < 0;
43        if ($this->amount === PHP_INT_MIN) {
44            throw new InvalidArgumentException('Amount is too small to format.');
45        }
46        $absolute = $negative ? -$this->amount : $this->amount;
47        if ($scale === 1) {
48            return ($negative ? '-' : '') . $absolute;
49        }
50
51        $major = intdiv($absolute, $scale);
52        $minor = $absolute % $scale;
53
54        return ($negative ? '-' : '') . $major . '.' . str_pad((string) $minor, $this->currency->minorUnits(), '0', STR_PAD_LEFT);
55    }
56
57    public function format(): string
58    {
59        return $this->toDecimalString() . ' ' . $this->currency->value;
60    }
61
62    public function isPositive(): bool
63    {
64        return $this->amount > 0;
65    }
66
67    public function isNegative(): bool
68    {
69        return $this->amount < 0;
70    }
71
72    public function isZero(): bool
73    {
74        return $this->amount === 0;
75    }
76
77    public function equals(self $other): bool
78    {
79        return $this->amount === $other->amount && $this->currency->equals($other->currency);
80    }
81
82    public function abs(): self
83    {
84        if ($this->amount === PHP_INT_MIN) {
85            throw new InvalidArgumentException('Amount is too small to negate.');
86        }
87
88        return new self($this->amount < 0 ? -$this->amount : $this->amount, $this->currency);
89    }
90
91    private static function toMinorUnits(string $amount, int $minorUnits, ?string $decimalSeparator): int
92    {
93        if ($decimalSeparator !== null && $decimalSeparator !== '.' && $decimalSeparator !== ',') {
94            throw new InvalidArgumentException('Decimal separator must be "." or ",".');
95        }
96
97        $spaced = trim(str_replace(["\u{00A0}", "\u{2009}", "\u{202F}", "\t"], ' ', trim($amount)));
98        self::assertSpaceGroups($spaced, $amount);
99        $normalized = str_replace(' ', '', $spaced);
100        if ($normalized === '' || $normalized === '-' || $normalized === '+') {
101            throw new InvalidArgumentException('Amount must be a decimal number.');
102        }
103
104        $negative = str_starts_with($normalized, '-');
105        if ($negative || str_starts_with($normalized, '+')) {
106            $normalized = substr($normalized, 1);
107        }
108
109        if ($normalized === '' || preg_match('/^[0-9.,]+$/', $normalized) !== 1) {
110            throw new InvalidArgumentException(sprintf('Invalid decimal amount "%s".', $amount));
111        }
112
113        $normalized = self::normalizeSeparators($normalized, $decimalSeparator, $amount);
114        if (preg_match('/^(?<major>\d+)(?:[.,](?<fraction>\d+))?$/', $normalized, $matches) !== 1) {
115            throw new InvalidArgumentException(sprintf('Invalid decimal amount "%s".', $amount));
116        }
117
118        $fraction = $matches['fraction'] ?? '';
119        if (strlen($fraction) > $minorUnits) {
120            $extra = substr($fraction, $minorUnits);
121            if (preg_match('/^0+$/', $extra) !== 1) {
122                throw new InvalidArgumentException(sprintf('Amount "%s" has more fraction digits than the currency allows.', $amount));
123            }
124            $fraction = substr($fraction, 0, $minorUnits);
125        }
126
127        return self::combine($matches['major'], $fraction, $minorUnits, $negative, $amount);
128    }
129
130    private static function normalizeSeparators(string $body, ?string $decimalSeparator, string $original): string
131    {
132        $hasDot = str_contains($body, '.');
133        $hasComma = str_contains($body, ',');
134        if (!$hasDot && !$hasComma) {
135            return $body;
136        }
137
138        if ($hasDot && $hasComma) {
139            $lastDot = strrpos($body, '.');
140            $lastComma = strrpos($body, ',');
141            $decimal = ($lastDot !== false && $lastComma !== false && $lastDot > $lastComma) ? '.' : ',';
142            if ($decimalSeparator !== null && $decimal !== $decimalSeparator) {
143                throw new InvalidArgumentException(sprintf('Invalid decimal amount "%s".', $original));
144            }
145            $group = $decimal === '.' ? ',' : '.';
146            $pos = (int) strrpos($body, $decimal);
147            $major = self::stripGroups(substr($body, 0, $pos), $group, $original);
148            $fraction = substr($body, $pos + 1);
149            if (preg_match('/^\d+$/', $fraction) !== 1) {
150                throw new InvalidArgumentException(sprintf('Invalid decimal amount "%s".', $original));
151            }
152
153            return $major . $decimal . $fraction;
154        }
155
156        $separator = $hasDot ? '.' : ',';
157        if (substr_count($body, $separator) > 1) {
158            return self::stripGroups($body, $separator, $original);
159        }
160
161        if ($decimalSeparator === null) {
162            $fractionLength = strlen(substr($body, (int) strrpos($body, $separator) + 1));
163            if ($fractionLength === 3) {
164                throw new InvalidArgumentException(sprintf('Ambiguous decimal amount "%s".', $original));
165            }
166
167            return $body;
168        }
169
170        if ($decimalSeparator === $separator) {
171            return $body;
172        }
173
174        return self::stripGroups($body, $separator, $original);
175    }
176
177    private static function stripGroups(string $body, string $group, string $original): string
178    {
179        if ($group !== '.' && $group !== ',') {
180            throw new InvalidArgumentException(sprintf('Invalid decimal amount "%s".', $original));
181        }
182
183        $parts = explode($group, $body);
184        $first = array_shift($parts);
185        if ($first === '' || preg_match('/^\d{1,3}$/', $first) !== 1) {
186            throw new InvalidArgumentException(sprintf('Invalid decimal amount "%s".', $original));
187        }
188
189        foreach ($parts as $part) {
190            if (preg_match('/^\d{3}$/', $part) !== 1) {
191                throw new InvalidArgumentException(sprintf('Invalid decimal amount "%s".', $original));
192            }
193        }
194
195        return $first . implode('', $parts);
196    }
197
198    private static function combine(string $major, string $fraction, int $minorUnits, bool $negative, string $original): int
199    {
200        $digits = $minorUnits === 0 ? $major : $major . str_pad($fraction, $minorUnits, '0');
201        $digits = ltrim($digits, '0');
202        if ($digits === '') {
203            return 0;
204        }
205
206        $limit = ltrim((string) ($negative ? PHP_INT_MIN : PHP_INT_MAX), '-');
207        if (strlen($digits) > strlen($limit) || (strlen($digits) === strlen($limit) && strcmp($digits, $limit) > 0)) {
208            throw new InvalidArgumentException(sprintf('Amount "%s" does not fit into an integer number of minor units.', $original));
209        }
210
211        if ($negative && $digits === ltrim((string) PHP_INT_MIN, '-')) {
212            throw new InvalidArgumentException(sprintf('Amount "%s" does not fit into an integer number of minor units.', $original));
213        }
214
215        $units = (int) $digits;
216
217        return $negative ? -$units : $units;
218    }
219
220    private static function assertSpaceGroups(string $spaced, string $original): void
221    {
222        $body = $spaced;
223        if (str_starts_with($body, '-') || str_starts_with($body, '+')) {
224            $body = substr($body, 1);
225        }
226        if (!str_contains($body, ' ')) {
227            return;
228        }
229
230        if (preg_match('/^\d{1,3}(?: \d{3})+(?:[.,]\d+)?$/', $body) !== 1) {
231            throw new InvalidArgumentException(sprintf('Invalid decimal amount "%s".', $original));
232        }
233    }
234
235    private static function scale(int $minorUnits): int
236    {
237        $scale = 1;
238        for ($i = 0; $i < $minorUnits; ++$i) {
239            $scale *= 10;
240        }
241
242        return $scale;
243    }
244}