| 1 | <?php |
| 2 | |
| 3 | namespace Civi\Core; |
| 4 | |
| 5 | use Brick\Money\Currency; |
| 6 | use Brick\Money\Money; |
| 7 | use Brick\Math\RoundingMode; |
| 8 | use Civi; |
| 9 | use CRM_Core_Config; |
| 10 | use CRM_Core_I18n; |
| 11 | use CRM_Utils_Constant; |
| 12 | use NumberFormatter; |
| 13 | use Brick\Money\Context\AutoContext; |
| 14 | |
| 15 | /** |
| 16 | * Class Paths |
| 17 | * @package Civi\Core |
| 18 | * |
| 19 | * This class provides standardised formatting |
| 20 | */ |
| 21 | class Format { |
| 22 | |
| 23 | /** |
| 24 | * Get formatted money |
| 25 | * |
| 26 | * @param string|int|float|BigDecimal $amount |
| 27 | * @param string|null $currency |
| 28 | * Currency, defaults to site currency if not provided. |
| 29 | * @param string|null $locale |
| 30 | * |
| 31 | * @return string |
| 32 | * |
| 33 | * @throws \CRM_Core_Exception |
| 34 | * |
| 35 | * @noinspection PhpDocMissingThrowsInspection |
| 36 | * @noinspection PhpUnhandledExceptionInspection |
| 37 | */ |
| 38 | public function money($amount, ?string $currency = NULL, ?string $locale = NULL): string { |
| 39 | if (($amount = $this->checkAndConvertAmount($amount)) === '') { |
| 40 | return ''; |
| 41 | } |
| 42 | if (!$currency) { |
| 43 | $currency = Civi::settings()->get('defaultCurrency'); |
| 44 | } |
| 45 | if (!isset($locale)) { |
| 46 | $locale = Civi::settings()->get('format_locale') ?? CRM_Core_I18n::getLocale(); |
| 47 | } |
| 48 | $money = Money::of($amount, $currency, NULL, RoundingMode::HALF_UP); |
| 49 | $formatter = $this->getMoneyFormatter($currency, $locale); |
| 50 | return $money->formatWith($formatter); |
| 51 | } |
| 52 | |
| 53 | /** |
| 54 | * Get a formatted number. |
| 55 | * |
| 56 | * @param string|int|float|Money $amount |
| 57 | * Amount in a machine money format. |
| 58 | * @param string|null $locale |
| 59 | * @param array $attributes |
| 60 | * Additional values supported by NumberFormatter |
| 61 | * https://www.php.net/manual/en/class.numberformatter.php |
| 62 | * By default this will set it to round to 8 places and not |
| 63 | * add any padding. |
| 64 | * |
| 65 | * @return string |
| 66 | * @throws \CRM_Core_Exception |
| 67 | */ |
| 68 | public function number($amount, ?string $locale = NULL, array $attributes = [ |
| 69 | NumberFormatter::MIN_FRACTION_DIGITS => 0, |
| 70 | NumberFormatter::MAX_FRACTION_DIGITS => 8, |
| 71 | ]): string { |
| 72 | if (($amount = $this->checkAndConvertAmount($amount)) === '') { |
| 73 | return ''; |
| 74 | } |
| 75 | $formatter = $this->getMoneyFormatter(NULL, $locale, NumberFormatter::DECIMAL, $attributes); |
| 76 | return $formatter->format($amount); |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * Get a number formatted with rounding expectations derived from the currency. |
| 81 | * |
| 82 | * @param string|float|int $amount |
| 83 | * @param string $currency |
| 84 | * @param string|null $locale |
| 85 | * |
| 86 | * @return string |
| 87 | * |
| 88 | * @noinspection PhpDocMissingThrowsInspection |
| 89 | * @noinspection PhpUnhandledExceptionInspection |
| 90 | */ |
| 91 | public function moneyNumber($amount, string $currency, $locale): string { |
| 92 | if (($amount = $this->checkAndConvertAmount($amount)) === '') { |
| 93 | return ''; |
| 94 | } |
| 95 | $formatter = $this->getMoneyFormatter($currency, $locale, NumberFormatter::DECIMAL); |
| 96 | $money = Money::of($amount, $currency, NULL, RoundingMode::HALF_UP); |
| 97 | return $money->formatWith($formatter); |
| 98 | } |
| 99 | |
| 100 | /** |
| 101 | * Get a money value with formatting but not rounding. |
| 102 | * |
| 103 | * @param string|float|int $amount |
| 104 | * @param string|null $currency |
| 105 | * @param string|null $locale |
| 106 | * |
| 107 | * @return string |
| 108 | * |
| 109 | * @noinspection PhpDocMissingThrowsInspection |
| 110 | * @noinspection PhpUnhandledExceptionInspection |
| 111 | */ |
| 112 | public function moneyLong($amount, ?string $currency, ?string $locale): string { |
| 113 | if (($amount = $this->checkAndConvertAmount($amount)) === '') { |
| 114 | return ''; |
| 115 | } |
| 116 | $formatter = $this->getMoneyFormatter($currency, $locale, NumberFormatter::CURRENCY, [ |
| 117 | NumberFormatter::MAX_FRACTION_DIGITS => 9, |
| 118 | ]); |
| 119 | $money = Money::of($amount, $currency, new AutoContext()); |
| 120 | return $money->formatWith($formatter); |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * Get a number with minimum decimal places based on the currency but no rounding. |
| 125 | * |
| 126 | * @param string|float|int $amount |
| 127 | * @param string|null $currency |
| 128 | * @param string|null $locale |
| 129 | * |
| 130 | * @return string |
| 131 | * |
| 132 | * @noinspection PhpDocMissingThrowsInspection |
| 133 | * @noinspection PhpUnhandledExceptionInspection |
| 134 | */ |
| 135 | public function moneyNumberLong($amount, ?string $currency, ?string $locale): string { |
| 136 | if (($amount = $this->checkAndConvertAmount($amount)) === '') { |
| 137 | return ''; |
| 138 | } |
| 139 | $formatter = $this->getMoneyFormatter($currency, $locale, NumberFormatter::DECIMAL, [ |
| 140 | NumberFormatter::MAX_FRACTION_DIGITS => 9, |
| 141 | ]); |
| 142 | $money = Money::of($amount, $currency, new AutoContext()); |
| 143 | return $money->formatWith($formatter); |
| 144 | } |
| 145 | |
| 146 | /** |
| 147 | * Should we use the configured thousand & decimal separators. |
| 148 | * |
| 149 | * The goal is to phase this into being FALSE - but for now |
| 150 | * we are looking at how to manage an 'opt in' |
| 151 | */ |
| 152 | protected function isUseSeparatorSettings(): bool { |
| 153 | return !Civi::settings()->get('format_locale') && !CRM_Utils_Constant::value('IGNORE_SEPARATOR_CONFIG'); |
| 154 | } |
| 155 | |
| 156 | /** |
| 157 | * Get the money formatter for when we are using configured thousand separators. |
| 158 | * |
| 159 | * Our intent is to phase out these settings in favour of deriving them from the locale. |
| 160 | * |
| 161 | * @param string|null $currency |
| 162 | * @param string|null $locale |
| 163 | * @param int $style |
| 164 | * See https://www.php.net/manual/en/class.numberformatter.php#intl.numberformatter-constants |
| 165 | * @param array $attributes |
| 166 | * See https://www.php.net/manual/en/class.numberformatter.php#intl.numberformatter-constants.unumberformatattribute |
| 167 | * |
| 168 | * @return \NumberFormatter |
| 169 | * |
| 170 | * @noinspection PhpDocMissingThrowsInspection |
| 171 | * @noinspection PhpUnhandledExceptionInspection |
| 172 | */ |
| 173 | public function getMoneyFormatter(?string $currency = NULL, ?string $locale = NULL, int $style = NumberFormatter::CURRENCY, array $attributes = []): NumberFormatter { |
| 174 | if (!$currency) { |
| 175 | $currency = Civi::settings()->get('defaultCurrency'); |
| 176 | } |
| 177 | $cacheKey = __CLASS__ . $currency . '_' . $locale . '_' . $style . (!empty($attributes) ? md5(json_encode($attributes)) : ''); |
| 178 | if (!isset(\Civi::$statics[$cacheKey])) { |
| 179 | $formatter = new NumberFormatter($locale, $style); |
| 180 | |
| 181 | if (!isset($attributes[NumberFormatter::MIN_FRACTION_DIGITS])) { |
| 182 | $attributes[NumberFormatter::MIN_FRACTION_DIGITS] = Currency::of($currency) |
| 183 | ->getDefaultFractionDigits(); |
| 184 | } |
| 185 | if (!isset($attributes[NumberFormatter::MAX_FRACTION_DIGITS])) { |
| 186 | $attributes[NumberFormatter::MAX_FRACTION_DIGITS] = Currency::of($currency) |
| 187 | ->getDefaultFractionDigits(); |
| 188 | } |
| 189 | |
| 190 | foreach ($attributes as $attribute => $value) { |
| 191 | $formatter->setAttribute($attribute, $value); |
| 192 | } |
| 193 | if ($locale === CRM_Core_I18n::getLocale() && $this->isUseSeparatorSettings()) { |
| 194 | $formatter->setSymbol(NumberFormatter::MONETARY_GROUPING_SEPARATOR_SYMBOL, CRM_Core_Config::singleton()->monetaryThousandSeparator); |
| 195 | $formatter->setSymbol(NumberFormatter::MONETARY_SEPARATOR_SYMBOL, CRM_Core_Config::singleton()->monetaryDecimalPoint); |
| 196 | } |
| 197 | \Civi::$statics[$cacheKey] = $formatter; |
| 198 | } |
| 199 | return \Civi::$statics[$cacheKey]; |
| 200 | } |
| 201 | |
| 202 | /** |
| 203 | * Since the input can be various data types and values, we need to handle |
| 204 | * them before passing on to the Brick libraries which would throw exceptions |
| 205 | * for ones that we are ok just converting to the empty string. |
| 206 | * |
| 207 | * @param string|int|float|BigDecimal $amount |
| 208 | * @return string |
| 209 | * Either the empty string if an empty-ish value, or the original amount as a string. |
| 210 | * @throws \CRM_Core_Exception |
| 211 | */ |
| 212 | private function checkAndConvertAmount($amount): string { |
| 213 | // Empty value => empty string |
| 214 | // FALSE should be an error but some smarty variables are filled with FALSE to avoid ENOTICES. |
| 215 | if (is_null($amount) || $amount === '' || $amount === FALSE) { |
| 216 | return ''; |
| 217 | } |
| 218 | // Verify the amount is a number or numeric string/object. |
| 219 | // We cast to string because it can be a BigDecimal object. |
| 220 | if ($amount === TRUE || !is_numeric((string) $amount)) { |
| 221 | throw new \CRM_Core_Exception('Invalid value for type money'); |
| 222 | } |
| 223 | return (string) $amount; |
| 224 | } |
| 225 | |
| 226 | } |