Commit | Line | Data |
---|---|---|
386fe6c2 EM |
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 | * | |
32e1703c | 26 | * @param string|int|float|BigDecimal $amount |
386fe6c2 EM |
27 | * @param string|null $currency |
28 | * Currency, defaults to site currency if not provided. | |
29 | * @param string|null $locale | |
30 | * | |
31 | * @return string | |
32 | * | |
5efd7d57 EM |
33 | * @throws \CRM_Core_Exception |
34 | * | |
386fe6c2 EM |
35 | * @noinspection PhpDocMissingThrowsInspection |
36 | * @noinspection PhpUnhandledExceptionInspection | |
37 | */ | |
c1c02f43 | 38 | public function money($amount, ?string $currency = NULL, ?string $locale = NULL): string { |
32e1703c | 39 | if (($amount = $this->checkAndConvertAmount($amount)) === '') { |
c1c02f43 CW |
40 | return ''; |
41 | } | |
386fe6c2 EM |
42 | if (!$currency) { |
43 | $currency = Civi::settings()->get('defaultCurrency'); | |
44 | } | |
45 | if (!isset($locale)) { | |
57eda25d | 46 | $locale = Civi::settings()->get('format_locale') ?? CRM_Core_I18n::getLocale(); |
386fe6c2 EM |
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 | |
5efd7d57 | 66 | * @throws \CRM_Core_Exception |
386fe6c2 EM |
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 { | |
32e1703c | 72 | if (($amount = $this->checkAndConvertAmount($amount)) === '') { |
73 | return ''; | |
74 | } | |
386fe6c2 EM |
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 | |
3fd42bb5 | 84 | * @param string|null $locale |
386fe6c2 EM |
85 | * |
86 | * @return string | |
87 | * | |
88 | * @noinspection PhpDocMissingThrowsInspection | |
89 | * @noinspection PhpUnhandledExceptionInspection | |
90 | */ | |
91 | public function moneyNumber($amount, string $currency, $locale): string { | |
32e1703c | 92 | if (($amount = $this->checkAndConvertAmount($amount)) === '') { |
93 | return ''; | |
94 | } | |
386fe6c2 EM |
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 { | |
32e1703c | 113 | if (($amount = $this->checkAndConvertAmount($amount)) === '') { |
114 | return ''; | |
115 | } | |
386fe6c2 EM |
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 { | |
32e1703c | 136 | if (($amount = $this->checkAndConvertAmount($amount)) === '') { |
137 | return ''; | |
138 | } | |
386fe6c2 EM |
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 { | |
57eda25d | 153 | return !Civi::settings()->get('format_locale') && !CRM_Utils_Constant::value('IGNORE_SEPARATOR_CONFIG'); |
386fe6c2 EM |
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 | ||
32e1703c | 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. | |
5efd7d57 | 220 | if ($amount === TRUE || !is_numeric((string) $amount)) { |
32e1703c | 221 | throw new \CRM_Core_Exception('Invalid value for type money'); |
222 | } | |
223 | return (string) $amount; | |
224 | } | |
225 | ||
386fe6c2 | 226 | } |