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 22 commits into
base: 7.4
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f038af2
test: add test for `isActive method`
Crovitche-1623 Aug 15, 2025
1276849
feat: add required metadata in the generated data
Crovitche-1623 Aug 15, 2025
62312ed
feat: add `isActive` method
Crovitche-1623 Aug 15, 2025
449afd2
refactor: cs
Crovitche-1623 Aug 15, 2025
c7719cc
docs: updated CHANGELOG.md
Crovitche-1623 Aug 15, 2025
8d96d1c
docs: fix grammar and consistency in currency notes
Crovitche-1623 Aug 15, 2025
ab6e0f1
fix: incorrect metadata for `from` and `to` properties
Crovitche-1623 Aug 15, 2025
6ee933d
feat: refactor according to comments + multiple parameters to cover a…
Crovitche-1623 Aug 15, 2025
0f4e203
feat: changes related to the review + cs
Crovitche-1623 Aug 18, 2025
88a1d63
test: fix testForCountry expected result
Crovitche-1623 Aug 18, 2025
ada1273
refactor: cs
Crovitche-1623 Aug 18, 2025
ed4d258
bug: fix condition in isValidInCountry
Crovitche-1623 Aug 18, 2025
6ad12f4
test: added few tests for CurrenciesTest.php
Crovitche-1623 Aug 18, 2025
5c86572
test: added few tests for CurrenciesTest.php
Crovitche-1623 Aug 18, 2025
95632e9
docs: updated `CHANGELOG.md`
Crovitche-1623 Aug 18, 2025
b2b7eeb
test: fix incorrect return type for `forCountry` and covered all use …
Crovitche-1623 Aug 18, 2025
492bad8
docs: change `Decode` to `Decodes`
Crovitche-1623 Aug 19, 2025
6a62952
refactor: replace `array_key_exists` check with null coalescing assig…
Crovitche-1623 Aug 19, 2025
44292b1
refactor: iterate CurrencyMap directly instead of using temporary arrays
Crovitche-1623 Aug 19, 2025
6d674db
refactor: inline `readEntry()`` call in foreach loop
Crovitche-1623 Aug 19, 2025
d863b2a
docs(changelog): split currency metadata and methods into separate en…
Crovitche-1623 Aug 19, 2025
3a65b1f
refactor: remove temporary $countries variable in favor of direct for…
Crovitche-1623 Aug 19, 2025
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
2 changes: 2 additions & 0 deletions src/Symfony/Component/Intl/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ CHANGELOG
---

* Allow Kosovo as a component region, controlled by the `SYMFONY_INTL_WITH_USER_ASSIGNED` env var
* Generate legal and validity metadata for currencies
* Add `isValidInAnyCountry`, `isValidInCountry`, `forCountry` methods in `Symfony\Component\Intl\Currencies`

7.1
---
Expand Down
148 changes: 148 additions & 0 deletions src/Symfony/Component/Intl/Currencies.php
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,154 @@ 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 $active Indicates whether the currency should always be active for the given $date. Using null do not filter anything
* @param \DateTimeInterface $date The date string on which the check will be performed
*
* @return list<non-empty-string> a list of unique currencies
*
* @throws MissingResourceException if the given $country does not exist
*/
public static function forCountry(
string $country,
?bool $legalTender = true,
?bool $active = true,
\DateTimeInterface $date = new \DateTimeImmutable('today', new \DateTimeZone('Etc/UTC')),
): array {
$currencies = [];

foreach (self::readEntry(['Map', $country], 'meta') as $currency => $currencyMetadata) {
if (null !== $legalTender && $legalTender !== self::isLegalTender($currencyMetadata)) {
continue;
}

if (null === $active) {
$currencies[] = $currency;

continue;
}

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

$currencies[] = $currency;
}

return $currencies;
}

/**
* @param non-empty-string $country e.g. 'FR'
* @param non-empty-string $currency e.g. 'USD'
* @param ?bool $active Indicates whether the currency should always be active for the given $date. Using null do not filter anything
* @param \DateTimeInterface $date The date that will be checked when $active is set to true
*/
public static function isValidInCountry(
string $country,
string $currency,
?bool $legalTender = true,
?bool $active = true,
\DateTimeInterface $date = new \DateTimeImmutable('today', new \DateTimeZone('Etc/UTC')),
): bool {
try {
$currencyMetadata = self::readEntry(['Map', $country, $currency], 'meta');
} catch (MissingResourceException) {
return false;
}

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

if (null === $active) {
return true;
}

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

/**
* @param array{tender?: bool} $currencyMetadata When the `tender` property does not exist, it means it is a legal tender
*/
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 \DateTimeInterface $date The date on which the check will be performed
*/
private static function isActive(
string $country,
string $currency,
array $currencyMetadata,
\DateTimeInterface $date = new \DateTimeImmutable('today', new \DateTimeZone('Etc/UTC')),
): 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 in $country because they are no validity dates available.");
}

/**
* @var \DateTimeImmutable $from The date format is always valid as it is generated using the CurrencyDataGenerator
*/
$from = \DateTimeImmutable::createFromFormat('Y-m-d', $currencyMetadata['from']);

if (\array_key_exists('to', $currencyMetadata)) {
/**
* @var \DateTimeImmutable $to The date format is always valid as it is generated using the CurrencyDataGenerator
*/
$to = \DateTimeImmutable::createFromFormat('Y-m-d', $currencyMetadata['to']);
} else {
$to = null;
}

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 $active Indicates whether the currency should always be active for the given $date. Using null do not filter anything
* @param \DateTimeInterface $date the date on which the check will be performed if $active is set to true
*/
public static function isValidInAnyCountry(
string $currency,
?bool $legalTender = true,
?bool $active = true,
\DateTimeInterface $date = new \DateTimeImmutable('today', new \DateTimeZone('Etc/UTC')),
): bool {
foreach (self::readEntry(['Map'], 'meta') as $countryCode => $country) {
foreach ($country as $currencyCode => $currencyMetadata) {
if ($currencyCode !== $currency) {
continue;
}

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

if (null === $active) {
return true;
}

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

return true;
}
}

return false;
}

protected static function getPath(): string
{
return Intl::getDataDirectory().'/'.Intl::CURRENCY_DIR;
Expand Down
117 changes: 117 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 = [];

/**
* Decodes 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,74 @@ 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 = [];

foreach ($supplementalDataBundle['CurrencyMap'] as $regionId => $region) {
foreach ($region 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;
}

// Do not exclude countries with no currencies or excluded currencies (e.g. Antartica)
$regionsData[$regionId] ??= [];
}

return $regionsData;
}

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