diff --git a/CRM/Core/Smarty/plugins/modifier.crmDate.php b/CRM/Core/Smarty/plugins/modifier.crmDate.php index 2a175bcfc013..4f26bafd81e4 100644 --- a/CRM/Core/Smarty/plugins/modifier.crmDate.php +++ b/CRM/Core/Smarty/plugins/modifier.crmDate.php @@ -38,30 +38,15 @@ * human readable date format | invalid date message */ function smarty_modifier_crmDate($dateString, ?string $dateFormat = NULL, bool $onlyTime = FALSE): string { - if ($dateString) { - $configuredFormats = [ - 'Datetime', - 'Full', - 'Partial', - 'Time', - 'Year', - 'FinancialBatch', - 'shortdate', - ]; - if (in_array($dateFormat, $configuredFormats, TRUE)) { - $dateFormat = Civi::settings()->get('dateformat' . $dateFormat); - } - // this check needs to be type sensitive - // CRM-3689, CRM-2441 - if ($dateFormat === 0) { - $dateFormat = NULL; - } - if ($onlyTime) { - $config = CRM_Core_Config::singleton(); - $dateFormat = $config->dateformatTime; - } - - return CRM_Utils_Date::customFormat($dateString, $dateFormat); + // this check needs to be type sensitive + // CRM-3689, CRM-2441 + if ($dateFormat === 0) { + $dateFormat = NULL; + } + if ($onlyTime) { + $config = CRM_Core_Config::singleton(); + $dateFormat = $config->dateformatTime; } - return ''; + + return Civi::format()->date($dateString, $dateFormat); } diff --git a/CRM/Utils/Money.php b/CRM/Utils/Money.php index 2d5974867841..7aa4784d5632 100644 --- a/CRM/Utils/Money.php +++ b/CRM/Utils/Money.php @@ -206,17 +206,7 @@ public static function formatUSLocaleNumericRounded($amount, int $numberOfPlaces } return self::formatNumericByFormat($amount, '%!.' . $numberOfPlaces . 'i'); } - $money = Money::of($amount, CRM_Core_Config::singleton()->defaultCurrency, new CustomContext($numberOfPlaces), RoundingMode::HALF_UP); - // @todo - we specify en_US here because we don't want this function to do - // currency replacement at the moment because - // formatLocaleNumericRoundedByPrecision is doing it and if it - // is done there then it is swapped back in there.. This is a short term - // fix to allow us to resolve formatLocaleNumericRoundedByPrecision - // and to make the function comments correct - but, we need to reconsider this - // in master as it is probably better to use locale than our currency separator fields. - $formatter = new \NumberFormatter('en_US', NumberFormatter::DECIMAL); - $formatter->setAttribute(\NumberFormatter::MIN_FRACTION_DIGITS, $numberOfPlaces); - return $money->formatWith($formatter); + return Civi::format()->machineNumber($amount, $numberOfPlaces); } /** @@ -335,7 +325,7 @@ public static function missingIntlNotice() { * * @return int */ - protected static function getDecimalPlacesForAmount(string $amount): int { + public static function getDecimalPlacesForAmount(string $amount): int { $decimalPlaces = strlen(substr($amount, strpos($amount, '.') + 1)); return $decimalPlaces; } diff --git a/Civi.php b/Civi.php index a018bb54d9d2..f087d19c8d6b 100644 --- a/Civi.php +++ b/Civi.php @@ -1,5 +1,7 @@ setPublic(TRUE); + $container->setDefinition('format', new Definition( + '\Civi\Core\Format', + [] + ))->setPublic(TRUE); + $container->setDefinition('bundle.bootstrap3', new Definition('CRM_Core_Resources_Bundle', ['bootstrap3'])) ->setFactory('CRM_Core_Resources_Common::createBootstrap3Bundle')->setPublic(TRUE); diff --git a/Civi/Core/Format.php b/Civi/Core/Format.php new file mode 100644 index 000000000000..91d9c11e56f8 --- /dev/null +++ b/Civi/Core/Format.php @@ -0,0 +1,212 @@ +get('defaultCurrency'); + } + if (!isset($locale) || $locale === CRM_Core_I18n::getLocale()) { + // Legacy mode can't copy with locale or precision so we can only go 'the new way' + // if the new params are passed in. + if (!isset($precision) && $this->isUseSeparatorSettings()) { + // @todo - this legacy method is losing it's charm! However, passing to it, for now, + // means only the new functionality goes through the new, better but less tested, method. + return CRM_Utils_Money::format($amount, $currency); + } + $locale = CRM_Core_I18n::getLocale(); + } + + if ($precision === NULL) { + $precision = CRM_Utils_Money::getCurrencyPrecision($currency); + } + $money = Money::of($amount, $currency, new CustomContext($precision), RoundingMode::HALF_UP); + return $money->formatTo($locale); + } + + /** + * Get a formatted number. + * + * @param string|int|float|Money $amount + * Amount in a machine money format. + * @param string|null $locale + * @param array $attributes + * Additional values supported by NumberFormatter + * https://www.php.net/manual/en/class.numberformatter.php + * By default this will set it to round to 8 places and not + * add any padding. + * + * @return string + */ + public function number($amount, ?string $locale = NULL, array $attributes = [ + NumberFormatter::MIN_FRACTION_DIGITS => 0, + NumberFormatter::MAX_FRACTION_DIGITS => 8, + ]): string { + if ($locale && $locale !== CRM_Core_I18n::getLocale()) { + $formatter = new NumberFormatter($locale, NumberFormatter::DECIMAL); + } + else { + $locale = CRM_Core_I18n::getLocale(); + $formatter = new NumberFormatter($locale, NumberFormatter::DECIMAL); + if ($this->isUseSeparatorSettings()) { + $formatter->setAttribute('DECIMAL_SEPARATOR_SYMBOL', Civi::settings()->get('decimalSeparator')); + $formatter->setAttribute('GROUPING_SEPARATOR_SYMBOL', Civi::settings()->get('thousandSeparator')); + } + } + + foreach ($attributes as $attribute => $value) { + $formatter->setAttribute($attribute, $value); + } + return $formatter->format($amount); + } + + public function moneyNumber($amount, $currency) { + $formatter = new \NumberFormatter('en_US', \NumberFormatter::CURRENCY); + $formatter->setSymbol(\NumberFormatter::CURRENCY_SYMBOL, 'US$'); + $formatter->setSymbol(\NumberFormatter::MONETARY_GROUPING_SEPARATOR_SYMBOL, '·'); + $formatter->setAttribute(\NumberFormatter::MIN_FRACTION_DIGITS, 2); + + $money = Money::of($amount, $currency); + echo $money->formatWith($formatter); // US$5·000.00 + } + + /** + * Should we use the configured thousand & decimal separators. + * + * The goal is to phase this into being FALSE - but for now + * we are looking at how to manage an 'opt in' + */ + protected function isUseSeparatorSettings(): bool { + return !CRM_Utils_Constant::value('IGNORE_SEPARATOR_CONFIG'); + } + + /** + * Get a formatted date. + * + * @param ?string|\DateTime $date + * @param string|null $dateFormat + * @param string|null $timezone + * + * @return string + * + * @noinspection PhpDocMissingThrowsInspection + * @noinspection PhpUnhandledExceptionInspection + */ + public function date($date = 'now', ?string $dateFormat = NULL, ?string $timezone = NULL): string { + if ($timezone && $timezone !== CRM_Core_Config::singleton()->userSystem->getTimeZoneString()) { + if (!is_a($date, 'DateTime')) { + $date = new DateTime($date); + } + $date->setTimezone(new DateTimeZone($timezone)); + } + if (is_a($date, 'DateTime')) { + $date = $date->format('Y-m-d H:i:s'); + } + + $configuredFormats = [ + 'Datetime', + 'Full', + 'Partial', + 'Time', + 'Year', + 'FinancialBatch', + 'shortdate', + ]; + if (in_array($dateFormat, $configuredFormats, TRUE)) { + $dateFormat = Civi::settings()->get('dateformat' . $dateFormat); + } + return CRM_Utils_Date::customFormat($date, $dateFormat); + } + + /** + * Get the date in a machine readable format. + * + * This will be 2012-08-09 13:56:23 + * + * @param string $date + * + * @return string + */ + public function machineDate(string $date = 'now'): string { + return date(strtotime($date), 'Y-m-d H:i:s'); + } + + /** + * Get Machine version of a number. + * + * This format is ready to use in code / the database. + * + * It looks like 12000.456 - note the lack of thousand separators + * and the decimal point. + * + * The rounding defaults to 9 - like our database. + * + * + * @param string $amount + * @param int|null $precision + * + * @return string + */ + public function machineNumber(string $amount, int $precision = 9): string { + $formatter = new NumberFormatter('en_US', NumberFormatter::DECIMAL); + $formatter->setAttribute(NumberFormatter::MAX_FRACTION_DIGITS, $precision); + return $formatter->format($amount); + } + + /** + * Get Machine version of a number with rounding according to the currency. + * + * This format is ready to use in code / the database. + * + * It looks like 12000.45 - note the lack of thousand separators + * and the decimal point and rounding to 2 decimal places per the currency. + * + * + * @param string $amount + * @param ?string $currency + * + * @return string + */ + public function machineMoney(string $amount, ?string $currency = NULL): string { + if (!$currency) { + $currency = Civi::settings()->get('defaultCurrency'); + } + return $this->machineNumber($amount, CRM_Utils_Money::getCurrencyPrecision($currency)); + } + +} diff --git a/tests/phpunit/CRM/Utils/MoneyTest.php b/tests/phpunit/CRM/Utils/MoneyTest.php index f4cb5718ee6f..cfbb222da8f1 100644 --- a/tests/phpunit/CRM/Utils/MoneyTest.php +++ b/tests/phpunit/CRM/Utils/MoneyTest.php @@ -108,4 +108,11 @@ public function testInvalidCurrency() { CRM_Utils_Money::format(4.00, 'NOT_A_CURRENCY'); } + /** + * Test the format object. + */ + public function testFormatter(): void { + $money = \Civi::format()->money(''); + } + } diff --git a/tests/phpunit/Civi/Core/FormatTest.php b/tests/phpunit/Civi/Core/FormatTest.php new file mode 100644 index 000000000000..79cc7ae2a027 --- /dev/null +++ b/tests/phpunit/Civi/Core/FormatTest.php @@ -0,0 +1,125 @@ +assertEquals($expectedAmount, Civi::format()->machineMoney($inputAmount, $precision)); + } + + /** + * Money Locale Format Cases + */ + public function localeMoneyTestCases(): array { + $cases = []; + $cases[] = [ + [ + 'amount' => '1234.56', + 'locale' => 'en_US', + 'currency' => 'USD', + 'money' => '$1,234.56', + 'money_number' => '1,234.56', + 'machine_money' => '1234.56', + 'number' => '1234.56', + ], + ]; + $cases[] = [ + [ + 'amount' => '1234.56700', + 'locale' => 'en_US', + 'currency' => 'USD', + 'money' => '$1,234.57', + 'money_number' => '1,234.57', + 'number' => '1234.567', + 'machine_money' => '1234.567', + ], + ]; + $cases[] = [ + [ + 'amount' => '1234.56', + 'locale' => 'en_US', + 'currency' => 'EUR', + 'money' => '€1,234.56', + 'money_number' => '1,234.56', + 'machine_money' => '1234.56', + 'number' => '1234.56', + ], + ]; + $cases[] = ['1234.50', 'en_US', '1,234.50', 'USD', '$1,234.50']; + $cases[] = ['1234.56789', 'en_US', '1,234.56789', 'EUR', '€1,234.57']; + $cases[] = ['1234.56', 'fr_FR', '1 234,56', 'USD', '1 234,56 $US']; + $cases[] = ['1234.5678900', 'fr_FR', '1 234,56789', 'EUR', '1 234,57 €']; + return $cases; + } + + /** + * @dataProvider localeMoneyTestCases + */ + public function testMoney($inputAmount, $locale, $expectedAmount, $currency, $expectedMoney): void { + $this->assertEquals($expectedMoney, Civi::format()->money($inputAmount, $currency, $locale)); + } + + /** + * @dataProvider localeMoneyTestCases + */ + public function testNumber($inputAmount, $locale, $expectedAmount): void { + $this->assertEquals($expectedAmount, Civi::format()->number($inputAmount, $locale)); + } + + /** + * @dataProvider getDateData + */ + public function testDate($format, $expected): void { + $this->assertEquals($expected, Civi::format()->date('2020-02-01 03:04:05', $format)); + } + + /** + * Data provider for date tests. + * + * @return array + */ + public function getDateData(): array { + return [ + ['shortdate', '02/01/2020'], + ['%B %Y', 'February 2020'], + [NULL, 'February 1st, 2020 3:04 AM'], + ]; + } + +}