-
-
Notifications
You must be signed in to change notification settings - Fork 9.7k
[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
base: 7.4
Are you sure you want to change the base?
Changes from all commits
f038af2
1276849
62312ed
449afd2
c7719cc
8d96d1c
ab6e0f1
6ee933d
0f4e203
88a1d63
ada1273
ed4d258
6ad12f4
5c86572
95632e9
b2b7eeb
492bad8
6a62952
44292b1
6d674db
d863b2a
3a65b1f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -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 | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
* @param \DateTimeInterface $date The date string on which the check will be performed | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
* | ||||||||||||||||||
* @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 { | ||||||||||||||||||
Comment on lines
+152
to
+157
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
$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); | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
/** | ||||||||||||||||||
* @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."); | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
/** | ||||||||||||||||||
* @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 | ||||||||||||||||||
*/ | ||||||||||||||||||
Comment on lines
+242
to
+244
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we prefer not polluting the code with such SA concerns:
Suggested change
|
||||||||||||||||||
$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; | ||||||||||||||||||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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 | ||||||
Comment on lines
+57
to
+58
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd remove the non-empty-string declarations, they're not really useful IMHO
Suggested change
|
||||||
*/ | ||||||
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', | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. let's simplify this and remove the part about $millisecondsRemainder |
||||||
$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'); | ||||||
|
@@ -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), | ||||||
]; | ||||||
|
||||||
|
@@ -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']); | ||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.