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..0fd0da77add4 --- /dev/null +++ b/Civi/Core/Format.php @@ -0,0 +1,161 @@ +get('defaultCurrency'); + } + if (!isset($locale)) { + $locale = CRM_Core_I18n::getLocale(); + } + $money = Money::of($amount, $currency, NULL, RoundingMode::HALF_UP); + $formatter = $this->getMoneyFormatter($currency, $locale); + return $money->formatWith($formatter); + } + + /** + * 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 { + $formatter = $this->getMoneyFormatter(NULL, $locale, NumberFormatter::DECIMAL, $attributes); + return $formatter->format($amount); + } + + /** + * Get a number formatted with rounding expectations derived from the currency. + * + * @param string|float|int $amount + * @param string $currency + * @param $locale + * + * @return string + * + * @noinspection PhpDocMissingThrowsInspection + * @noinspection PhpUnhandledExceptionInspection + */ + public function moneyNumber($amount, string $currency, $locale): string { + $formatter = $this->getMoneyFormatter($currency, $locale, NumberFormatter::DECIMAL); + $money = Money::of($amount, $currency, NULL, RoundingMode::HALF_UP); + return $money->formatWith($formatter); + } + + + /** + * Get a money value with formatting but not rounding. + * + * @param string|float|int $amount + * @param string|null $currency + * @param string|null $locale + * + * @return string + * + * @noinspection PhpDocMissingThrowsInspection + * @noinspection PhpUnhandledExceptionInspection + */ + public function moneyLong($amount, ?string $currency, ?string $locale): string { + $formatter = $this->getMoneyFormatter($currency, $locale, NumberFormatter::CURRENCY, [ + NumberFormatter::MAX_FRACTION_DIGITS => 9, + ]); + $money = Money::of($amount, $currency, new AutoContext()); + return $money->formatWith($formatter); + } + + /** + * 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 the money formatter for when we are using configured thousand separators. + * + * Our intent is to phase out these settings in favour of deriving them from the locale. + * + * @param string|null $currency + * @param string|null $locale + * @param int $style + * @param array $attributes + * + * @return \NumberFormatter + * + * @noinspection PhpDocMissingThrowsInspection + * @noinspection PhpUnhandledExceptionInspection + */ + public function getMoneyFormatter(?string $currency = NULL, ?string $locale = NULL, int $style = NumberFormatter::CURRENCY, array $attributes = []): NumberFormatter { + if (!$currency) { + $currency = Civi::settings()->get('defaultCurrency'); + } + $formatter = new NumberFormatter($locale, $style); + + if (!isset($attributes[NumberFormatter::MIN_FRACTION_DIGITS])) { + $attributes[NumberFormatter::MIN_FRACTION_DIGITS] = Currency::of($currency)->getDefaultFractionDigits(); + } + if (!isset($attributes[NumberFormatter::MAX_FRACTION_DIGITS])) { + $attributes[NumberFormatter::MAX_FRACTION_DIGITS] = Currency::of($currency)->getDefaultFractionDigits(); + } + + foreach ($attributes as $attribute => $value) { + $formatter->setAttribute($attribute, $value); + } + if ($locale === CRM_Core_I18n::getLocale() && $this->isUseSeparatorSettings()) { + $formatter->setSymbol(NumberFormatter::MONETARY_GROUPING_SEPARATOR_SYMBOL, CRM_Core_Config::singleton()->monetaryThousandSeparator); + $formatter->setSymbol(NumberFormatter::MONETARY_SEPARATOR_SYMBOL, CRM_Core_Config::singleton()->monetaryDecimalPoint); + } + return $formatter; + } + +} diff --git a/tests/phpunit/Civi/Core/FormatTest.php b/tests/phpunit/Civi/Core/FormatTest.php new file mode 100644 index 000000000000..aea3eb3c3e8b --- /dev/null +++ b/tests/phpunit/Civi/Core/FormatTest.php @@ -0,0 +1,231 @@ + '1234.56', + 'locale' => 'en_US', + 'currency' => 'USD', + 'money' => '$1,234.56', + 'money_number' => '1,234.56', + 'number' => '1,234.56', + 'money_long' => '$1,234.56', + ], + ]; + $cases['en_US_USD_long'] = [ + [ + 'amount' => '1234.56700', + 'locale' => 'en_US', + 'currency' => 'USD', + 'money' => '$1,234.57', + 'money_number' => '1,234.57', + 'number' => '1,234.567', + 'money_long' => '$1,234.567', + ], + ]; + $cases['en_US_USD_pad'] = [ + [ + 'amount' => '1234.50', + 'locale' => 'en_US', + 'currency' => 'USD', + 'money' => '$1,234.50', + 'money_number' => '1,234.50', + 'number' => '1,234.5', + 'money_long' => '$1,234.50', + ], + ]; + $cases['en_US_EUR'] = [ + [ + 'amount' => '1234.56', + 'locale' => 'en_US', + 'currency' => 'EUR', + 'money' => '€1,234.56', + 'money_number' => '1,234.56', + 'number' => '1,234.56', + 'money_long' => '€1,234.56', + ], + ]; + $cases['en_US_EUR_long'] = [ + [ + 'amount' => '1234.56700', + 'locale' => 'en_US', + 'currency' => 'EUR', + 'money' => '€1,234.57', + 'money_number' => '1,234.57', + 'number' => '1,234.567', + 'money_long' => '€1,234.567', + ], + ]; + $cases['en_US_EUR_pad'] = [ + [ + 'amount' => '1234.5', + 'locale' => 'en_US', + 'currency' => 'EUR', + 'money' => '€1,234.50', + 'money_number' => '1,234.50', + 'number' => '1,234.5', + 'money_long' => '€1,234.50', + ], + ]; + $cases['fr_FR_EUR'] = [ + [ + 'amount' => '1234.56', + 'locale' => 'fr_FR', + 'currency' => 'EUR', + 'money' => '1 234,56 €', + 'money_number' => '1 234,56', + 'number' => '1 234,56', + 'money_long' => '1 234,56 €', + ], + ]; + $cases['fr_FR_EUR_long'] = [ + [ + 'amount' => '1234.56700', + 'locale' => 'fr_FR', + 'currency' => 'EUR', + 'money' => '1 234,57 €', + 'money_number' => '1 234,57', + 'number' => '1 234,567', + 'money_long' => '1 234,567 €', + ], + ]; + $cases['fr_FR_EUR_pad'] = [ + [ + 'amount' => '1234.50', + 'locale' => 'fr_FR', + 'currency' => 'EUR', + 'money' => '1 234,50 €', + 'money_number' => '1 234,50', + 'number' => '1 234,5', + 'money_long' => '1 234,50 €', + ], + ]; + $cases['ar_AE_KWD'] = [ + [ + 'amount' => '1234.56', + 'locale' => 'ar_AE', + 'currency' => 'KWD', + 'money' => '١٬٢٣٤٫٥٦٠ د.ك.‏', + 'money_number' => '١٬٢٣٤٫٥٦٠', + 'number' => '١٬٢٣٤٫٥٦', + 'money_long' => '١٬٢٣٤٫٥٦٠ د.ك.‏', + ], + ]; + $cases['ar_AE_KWD_long'] = [ + [ + 'amount' => '1234.56710', + 'locale' => 'ar_AE', + 'currency' => 'KWD', + 'money' => '١٬٢٣٤٫٥٦٧ د.ك.‏', + 'money_number' => '١٬٢٣٤٫٥٦٧', + 'number' => '١٬٢٣٤٫٥٦٧١', + 'money_long' => '١٬٢٣٤٫٥٦٧١ د.ك.‏', + ], + ]; + $cases['ar_AE_KWD_pad'] = [ + [ + 'amount' => '1234.56', + 'locale' => 'ar_AE', + 'currency' => 'KWD', + 'money' => '١٬٢٣٤٫٥٦٠ د.ك.‏', + 'money_number' => '١٬٢٣٤٫٥٦٠', + 'number' => '١٬٢٣٤٫٥٦', + 'money_long' => '١٬٢٣٤٫٥٦٠ د.ك.‏', + ], + ]; + $cases['en_US_KWD'] = [ + [ + 'amount' => '1234.56', + 'locale' => 'fr_FR', + 'currency' => 'KWD', + 'money' => '1 234,560 KWD', + 'money_number' => '1 234,560', + 'number' => '1 234,56', + 'money_long' => '1 234,560 KWD', + ], + ]; + $cases['en_US_KWD_long'] = [ + [ + 'amount' => '1234.5678000', + 'locale' => 'fr_FR', + 'currency' => 'KWD', + 'money' => '1 234,568 KWD', + 'money_number' => '1 234,568', + 'number' => '1 234,5678', + 'money_long' => '1 234,5678 KWD', + ], + ]; + $cases['en_US_KWD_pad'] = [ + [ + 'amount' => '1234.5', + 'locale' => 'fr_FR', + 'currency' => 'KWD', + 'money' => '1 234,500 KWD', + 'money_number' => '1 234,500', + 'number' => '1 234,5', + 'money_long' => '1 234,500 KWD', + ], + ]; + return $cases; + } + + /** + * @dataProvider localeMoneyTestCases + * + * @param array $testData + */ + public function testMoneyAndNumbers(array $testData): void { + $this->assertEquals($testData['money'], Civi::format()->money($testData['amount'], $testData['currency'], $testData['locale'])); + $this->assertEquals($testData['money_number'], Civi::format()->moneyNumber($testData['amount'], $testData['currency'], $testData['locale'])); + $this->assertEquals($testData['number'], Civi::format()->number($testData['amount'], $testData['locale'])); + $this->assertEquals($testData['money_long'], Civi::format()->moneyLong($testData['amount'], $testData['currency'], $testData['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'], + ]; + } + +}