Merge pull request #22892 from braders/ensure-tabheader-keys-are-assigned
[civicrm-core.git] / Civi / Core / Format.php
CommitLineData
386fe6c2
EM
1<?php
2
3namespace Civi\Core;
4
5use Brick\Money\Currency;
6use Brick\Money\Money;
7use Brick\Math\RoundingMode;
8use Civi;
9use CRM_Core_Config;
10use CRM_Core_I18n;
11use CRM_Utils_Constant;
12use NumberFormatter;
13use Brick\Money\Context\AutoContext;
14
15/**
16 * Class Paths
17 * @package Civi\Core
18 *
19 * This class provides standardised formatting
20 */
21class 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}