Lines
99.03%
103 / 104
Functions and Methods
93.33%
14 / 15
Classes and Traits
0.00%
0 / 1
| Name | Lines | Functions and Methods | CRAP | Classes and Traits | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| Money | 99.03% | 103 / 104 | 93.33% | 14 / 15 | 73 | 0.00% | 0 / 1 | |||
| __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 | |||||
| 1 | <?php | |
| 2 | ||
| 3 | declare(strict_types=1); | |
| 4 | ||
| 5 | namespace Tomaj\BankMailsParser\ValueObject; | |
| 6 | ||
| 7 | use InvalidArgumentException; | |
| 8 | ||
| 9 | /** | |
| 10 | * Immutable amount stored in minor units plus an ISO currency. | |
| 11 | * | |
| 12 | * 12.34 EUR is stored as amount 1234. The public API does not use floats. | |
| 13 | * Space, NBSP, thin space (U+2009), narrow NBSP (U+202F) and tab are thousands separators. | |
| 14 | * Extra fraction digits are rejected unless they are all zeros. Ambiguous separators | |
| 15 | * and values that do not fit in an int are rejected. | |
| 16 | */ | |
| 17 | final 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 | } |