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