Skip to content

[Intl] Add methods to filter currencies more precisely #61431

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: 7.4
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Symfony/Component/Intl/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ CHANGELOG
---

* Allow Kosovo as a component region, controlled by the `SYMFONY_INTL_WITH_USER_ASSIGNED` env var
* Add `isActive()` to check if a given currency is available in at least 1 region

7.1
---
Expand Down
150 changes: 150 additions & 0 deletions src/Symfony/Component/Intl/Currencies.php
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,156 @@ public static function forNumericCode(int $numericCode): array
return self::readEntry(['NumericToAlpha3', (string) $numericCode], 'meta');
}

/**
* @param non-empty-string $country e.g. 'FR'
* @param ?bool $legalTender if the currency must be a legal tender. Using null do not filter anything
* @param bool $activeOnly exclude currencies that are not active on the given $date in countries
* @param string $date The date string on which the check will be performed
*
* @throws MissingResourceException if the given $country does not exist
*
* @return array<non-empty-string, array{from?: non-empty-string, to?: non-empty-string, tender?: bool}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

im not sure this is the proper return type compared to getCurrencyCodes and forNumeric, unexpected at least i think

*/
public static function forCountry(
string $country,
?bool $legalTender = true,
bool $activeOnly = true,
string $date = 'today',
): array {
$currenciesMetadata = self::readEntry(['Map', $country], 'meta');

$currencies = [];

foreach ($currenciesMetadata as $currency => $currencyMetadata) {
if (null !== $legalTender && $legalTender !== self::isLegalTender($currencyMetadata)) {
continue;
}

if (!$activeOnly) {
$currencies[$currency] = $currencyMetadata;

continue;
}

if (!self::isActive($country, $currency, $currencyMetadata, $date)) {
continue;
}

$currencies[$currency] = $currencyMetadata;
}

return $currencies;
}

/**
* @param non-empty-string $country e.g. 'FR'
* @param non-empty-string $currency e.g. 'USD'
* @param string $date The date that will be checked when $activeOnly is set to true
*/
public static function existsInCountry(
string $country,
string $currency,
bool $activeOnly = true,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nullable boolean $active, to account for both active + inactive?

Copy link
Author

@Crovitche-1623 Crovitche-1623 Aug 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, just use false. I named the parameter activeOnly because I thought I would make no sense to retrieve the inactive ones.
WDYT ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, I'm also fine with the nullable parameter.

string $date = 'today',
Copy link
Contributor

@ro0NL ro0NL Aug 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?\DateTimeImmutable $date = null?

providing new \DateTimeImmutable('today') is little effort, while providing more flexibility

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok 👍

): bool {
try {
$currencyMetadata = self::readEntry(['Map', $country, $currency], 'meta');
} catch (MissingResourceException) {
return false;
}

if (!$activeOnly) {
return true;
}

return self::isActive($country, $currency, $currencyMetadata, $date);
}

/**
* @param array{tender?: bool} $currencyMetadata
*/
private static function isLegalTender(array $currencyMetadata): bool
{
return !\array_key_exists('tender', $currencyMetadata) || false !== $currencyMetadata['tender'];
}

/**
* @param non-empty-string $country e.g. 'FR'
* @param non-empty-string $currency e.g. 'USD'
* @param array{from?: non-empty-string, to?: non-empty-string} $currencyMetadata
* @param string $date The date on which the check will be performed
*/
private static function isActive(string $country, string $currency, array $currencyMetadata, string $date = 'today'): bool
{
if (!\array_key_exists('from', $currencyMetadata)) {
// Note: currencies that are not legal tender don't have often validity dates.
throw new \RuntimeException("Cannot check whether the currency $currency is active or not because they are no validity dates available.");
}

$from = \DateTimeImmutable::createFromFormat('Y-m-d', $currencyMetadata['from']);

if (!$from) {
throw new \RuntimeException("Unable to parse the `from` date for currency $currency in country $country.");
}

if (\array_key_exists('to', $currencyMetadata)) {
$to = \DateTimeImmutable::createFromFormat('Y-m-d', $currencyMetadata['to']);

if (!$to) {
throw new \RuntimeException("Unable to parse the `to` date for currency $currency in country $country.");
}
} else {
$to = null;
}

try {
$date = new \DateTimeImmutable($date);
} catch (\DateMalformedStringException $exception) {
throw new \InvalidArgumentException("Invalid date provided: $date.", previous: $exception);
}

return $from <= $date && (null === $to || $to >= $date);
}

/**
* @param non-empty-string $currency e.g. 'USD'
* @param ?bool $legalTender if the currency must be a legal tender. Using null do not filter anything
* @param bool $activeOnly exclude currencies that are not active on the given $date in countries
* @param string $date the date on which the check will be performed if $activeOnly is set to true
*/
public static function existsInAtLeastOneCountry(
Copy link
Contributor

@ro0NL ro0NL Aug 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this feels a bit weird naming compared to exists(), which should also checks existence in at least 1 country

in that sense im still fine with public API existsActive (or adding nullable boolean $active parameter to current exists() method)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, renaming the method to exists is also fine for me 👍

string $currency,
?bool $legalTender = true,
bool $activeOnly = true,
string $date = 'today',
): bool {
$countries = self::readEntry(['Map'], 'meta');

foreach ($countries as $countryCode => $country) {
foreach ($country as $currencyCode => $currencyMetadata) {
if ($currencyCode !== $currency) {
continue;
}

if (null !== $legalTender && $legalTender !== self::isLegalTender($currencyMetadata)) {
continue;
}

if (!$activeOnly) {
return true;
}

if (!self::isActive($countryCode, $currencyCode, $currencyMetadata, $date)) {
continue;
}

return true;
}
}

return false;
}

protected static function getPath(): string
{
return Intl::getDataDirectory().'/'.Intl::CURRENCY_DIR;
Expand Down
126 changes: 126 additions & 0 deletions src/Symfony/Component/Intl/Data/Generator/CurrencyDataGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,54 @@ class CurrencyDataGenerator extends AbstractDataGenerator
*/
private array $currencyCodes = [];

/**
* Decode ICU "date pair" into a DateTimeImmutable (UTC).
*
* ICU stores UDate = milliseconds since 1970-01-01T00:00:00Z in a signed 64-bit.
*
* @param array{0: int, 1: int} $pair
*
* @return non-empty-string
*/
private static function icuPairToDate(array $pair): string
{
[$highBits32, $lowBits32] = $pair;

// Recompose a 64-bit unsigned integer from two 32-bit chunks.
$unsigned64 = ((($highBits32 & 0xFFFFFFFF) << 32) | ($lowBits32 & 0xFFFFFFFF));

// Convert to signed 64-bit (two's complement) if sign bit is set.
if ($unsigned64 >= (1 << 63)) {
$unsigned64 -= (1 << 64);
}

// Split into seconds and milliseconds.
$seconds = intdiv($unsigned64, 1000);
$millisecondsRemainder = $unsigned64 - $seconds * 1000;

// Normalize negative millisecond remainders (e.g., for pre-1970 values)
if (0 > $millisecondsRemainder) {
$millisecondsRemainder += 1000;

--$seconds;
}

// Build "U.u" string (seconds.microseconds). Pad ms to 3 digits, then add 000 to get microseconds.
$epochWithMicros = \sprintf('%d.%03d000', $seconds, $millisecondsRemainder);

$datetime = \DateTimeImmutable::createFromFormat(
'U.u',
$epochWithMicros,
new \DateTimeZone('UTC'),
);

if (false === $datetime) {
throw new \RuntimeException('Unable to parse ICU milliseconds pair.');
}

return $datetime->format('Y-m-d');
}

protected function scanLocales(LocaleScanner $scanner, string $sourceDir): array
{
return $scanner->scanLocales($sourceDir.'/curr');
Expand Down Expand Up @@ -102,6 +150,7 @@ protected function generateDataForMeta(BundleEntryReaderInterface $reader, strin
$data = [
'Currencies' => $this->currencyCodes,
'Meta' => $this->generateCurrencyMeta($supplementalDataBundle),
'Map' => $this->generateCurrencyMap($supplementalDataBundle),
'Alpha3ToNumeric' => $this->generateAlpha3ToNumericMapping($numericCodesBundle, $this->currencyCodes),
];

Expand All @@ -125,6 +174,83 @@ private function generateCurrencyMeta(ArrayAccessibleResourceBundle $supplementa
return iterator_to_array($supplementalDataBundle['CurrencyMeta']);
}

/**
* @return array<non-empty-string, array>
*/
private function generateCurrencyMap(mixed $supplementalDataBundle): array
{
/**
* @var list<non-empty-string, list<non-empty-string, array{
* from?: non-empty-string,
* to?: non-empty-string,
* tender?: false
* }>> $regionsData
*/
$regionsData = [];

/**
* The key is the country iso3166 identifier (e.g. "CH").
*
* @var array<non-empty-string, \ResourceBundle> $regions
*/
$regions = iterator_to_array($supplementalDataBundle['CurrencyMap']);

foreach ($regions as $regionId => $region) {
/**
* @var list<\ResourceBundle> $currencies
*/
$currencies = iterator_to_array($region);

foreach ($currencies as $metadata) {
/**
* Note 1: The "to" property (if present) is always greater than "from".
* Note 2: The "to" property may be missing if the currency is still in use.
* Note 3: The "tender" property indicates whether the country legally recognizes the currency within
* its borders. This property is explicitly set to `false` only if that is not the case;
* otherwise, it is `true` by default.
* Note 4: The "from" and "to" dates are not stored as strings; they are stored as a pair of integers.
* Note 5: The "to" property may be missing if "tender" is set to `false`.
*
* @var array{
* from?: array{0: int, 1: int},
* to?: array{0: int, 2: int},
* tender?: bool,
* id: non-empty-string
* } $metadata
*/
$metadata = iterator_to_array($metadata);

$id = $metadata['id'];

unset($metadata['id']);

if (\array_key_exists($id, self::DENYLIST)) {
continue;
}

if (\array_key_exists('from', $metadata)) {
$metadata['from'] = self::icuPairToDate($metadata['from']);
}

if (\array_key_exists('to', $metadata)) {
$metadata['to'] = self::icuPairToDate($metadata['to']);
}

if (\array_key_exists('tender', $metadata)) {
$metadata['tender'] = filter_var($metadata['tender'], \FILTER_VALIDATE_BOOLEAN, \FILTER_NULL_ON_FAILURE);

if (null === $metadata['tender']) {
throw new \RuntimeException('Unexpected boolean value for tender attribute.');
}
}

$regionsData[$regionId][$id] = $metadata;
}
}

return $regionsData;
}

private function generateAlpha3ToNumericMapping(ArrayAccessibleResourceBundle $numericCodesBundle, array $currencyCodes): array
{
$alpha3ToNumericMapping = iterator_to_array($numericCodesBundle['codeMap']);
Expand Down
Loading
Loading