Skip to content
Merged
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
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
134 changes: 134 additions & 0 deletions src/Symfony/Component/Intl/Currencies.php
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,140 @@ public static function forNumericCode(int $numericCode): array
return self::readEntry(['NumericToAlpha3', (string) $numericCode], 'meta');
}

/**
* @param string $country e.g. 'FR'
* @param ?bool $legalTender If the currency must be a legal tender; null to not filter anything
* @param ?bool $active Indicates whether the currency should always be active for the given $date; null to not filter anything
* @param \DateTimeInterface $date The date on which the check will be performed
*
* @return list<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 (self::isDateActive($country, $currency, $currencyMetadata, $date) !== $active) {
continue;
}

$currencies[] = $currency;
}

return $currencies;
}

/**
* @param string $country e.g. 'FR'
* @param string $currency e.g. 'USD'
* @param ?bool $legalTender If the currency must be a legal tender; null to not filter anything
* @param ?bool $active Indicates whether the currency should always be active for the given $date; null to 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
{
if (!self::exists($currency)) {
throw new \InvalidArgumentException("The currency $currency does not exist.");
}

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 self::isDateActive($country, $currency, $currencyMetadata, $date) === $active;
}

/**
* @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 string $country e.g. 'FR'
* @param string $currency e.g. 'USD'
* @param array{from?: string, to?: string} $currencyMetadata
* @param \DateTimeInterface $date The date on which the check will be performed
*/
private static function isDateActive(string $country, string $currency, array $currencyMetadata, \DateTimeInterface $date): 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.");
}

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

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

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

/**
* @param string $currency e.g. 'USD'
* @param ?bool $legalTender If the currency must be a legal tender; null to not filter anything
* @param ?bool $active Indicates whether the currency should always be active for the given $date; null to 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
{
if (!self::exists($currency)) {
throw new \InvalidArgumentException("The currency $currency does not exist.");
}

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 (self::isDateActive($countryCode, $currencyCode, $currencyMetadata, $date) !== $active) {
continue;
}

return true;
}
}

return false;
}

protected static function getPath(): string
{
return Intl::getDataDirectory().'/'.Intl::CURRENCY_DIR;
Expand Down
2 changes: 1 addition & 1 deletion src/Symfony/Component/Intl/Intl.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ public static function getIcuDataVersion(): string
*/
public static function getIcuStubVersion(): string
{
return '76.1';
return '77.1';
}

/**
Expand Down
93 changes: 93 additions & 0 deletions src/Symfony/Component/Intl/Tests/CurrenciesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -805,4 +805,97 @@ private static function getNumericToAlpha3Mapping()

return $numericToAlpha3;
}

public function testBefCurrencyNoLongerExistIn2025()
{
$this->assertFalse(Currencies::isValidInAnyCountry('BEF', date: new \DateTimeImmutable('2025-01-01', new \DateTimeZone('Etc/UTC'))));
}

public function testUsdCurrencyExistsInAtLeastOneCountryIn2025()
{
$this->assertTrue(Currencies::isValidInAnyCountry('USD', date: new \DateTimeImmutable('2025-01-01', new \DateTimeZone('Etc/UTC'))));
}

public function testCheCurrencyIsNotRecognizedLegallyAnywhere()
{
$this->assertTrue(Currencies::isValidInAnyCountry('CHE', null, active: null));
}

public function testEsbCurrencyIsNotLegalTenderSomewhere()
{
$this->assertFalse(Currencies::isValidInAnyCountry('ESB', active: null));
}

public function testCurrenciesOfSwitzerlandIn2025()
{
$this->assertSame(['CHF'], Currencies::forCountry('CH', date: new \DateTimeImmutable('2025-01-01', new \DateTimeZone('Etc/UTC'))));
}

public function testBefCurrencyExistedLegallyInTheHistory()
{
$this->assertContains('BEF', Currencies::forCountry('BE', active: null));
}

public function testBefCurrencyWasValidIn2001InBelgium()
{
$this->assertTrue(Currencies::isValidInCountry('BE', 'BEF', date: new \DateTimeImmutable('2001-01-01', new \DateTimeZone('Etc/UTC'))));
}

public function testEurCurrencyIsValidIn2025InFrance()
{
$this->assertTrue(Currencies::isValidInCountry('FR', 'EUR', date: new \DateTimeImmutable('2025-01-01', new \DateTimeZone('Etc/UTC'))));
}

public function testCheCurrencyIsValidInSwitzerland()
{
$this->assertTrue(Currencies::isValidInCountry('CH', 'CHE', false, null));
}

public function testInactiveCurrenciesOfChinaIn2025()
{
$this->assertSame(['CNX'], Currencies::forCountry('CN', null, false, new \DateTimeImmutable('2025-01-01', new \DateTimeZone('Etc/UTC'))));
}

public function testUsdCurrencyDoesNotExistInFranceIn2025()
{
$this->assertFalse(Currencies::isValidInCountry('FR', 'USD', active: null, date: new \DateTimeImmutable('2025-01-01', new \DateTimeZone('Etc/UTC'))));
}

public function testChfCurrencyNotConsideredLegalTender()
{
$this->assertFalse(Currencies::isValidInCountry('CH', 'CHF', false, null));
}

public function testCheCurrencyDoesNotHaveValidityDatesInSwitzerland()
{
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Cannot check whether the currency CHE is active or not in CH because they are no validity dates available.');

Currencies::isValidInCountry('CH', 'CHE', false, false);
}

/**
* Special case because the official dataset contains XXX to indicate that Antartica has no currency, but it is
* excluded from the generated data on purpose.
*/
public function testAntarticaHasNoCurrenciesIn2025()
{
$this->assertSame([], Currencies::forCountry('AQ', null, true, new \DateTimeImmutable('2025-01-01', new \DateTimeZone('Etc/UTC'))));
}

public function testIsValidInCountryWithUnknownCurrencyThrowsException()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('The currency UNKNOWN-CURRENCY-FROM-ISO-4217 does not exist.');

Currencies::isValidInCountry('CH', 'UNKNOWN-CURRENCY-FROM-ISO-4217');
}

public function testIsValidInAnyCountryWithUnknownCurrencyThrowsException()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('The currency UNKNOWN-CURRENCY-FROM-ISO-4217 does not exist.');

Currencies::isValidInAnyCountry('UNKNOWN-CURRENCY-FROM-ISO-4217');
}
}
Loading