Skip to content

Commit 545c390

Browse files
feature symfony#61431 [Intl] Add methods to filter currencies more precisely (Crovitche-1623)
This PR was squashed before being merged into the 7.4 branch. Discussion ---------- [Intl] Add methods to filter currencies more precisely | Q | A | ------------- | --- | Branch? | 7.4 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | Close symfony#61365 | License | MIT ## Description In the ICU dataset, there are values that are no longer relevant / no longer used today (e.g. the BEF currency). This PR add a method that check the metadata in the ICU metadata to check if the currency is active in at least 1 country. ## Limitations 1. There are currencies that do not have legal tender and therefore no available date ranges. That can cause problem when we check if the currency is active (within the date range). 2. If we have a case in the future where a currency is no longer valid for a given date range but becomes valid again in the future, this will not work with the current date check (parameter `$active`). Commits ------- 04b3149 [Intl] Add methods to filter currencies more precisely
2 parents d10e3dd + 04b3149 commit 545c390

File tree

4 files changed

+230
-1
lines changed

4 files changed

+230
-1
lines changed

src/Symfony/Component/Intl/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ CHANGELOG
55
---
66

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

911
7.1
1012
---

src/Symfony/Component/Intl/Currencies.php

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,140 @@ public static function forNumericCode(int $numericCode): array
139139
return self::readEntry(['NumericToAlpha3', (string) $numericCode], 'meta');
140140
}
141141

142+
/**
143+
* @param string $country e.g. 'FR'
144+
* @param ?bool $legalTender If the currency must be a legal tender; null to not filter anything
145+
* @param ?bool $active Indicates whether the currency should always be active for the given $date; null to not filter anything
146+
* @param \DateTimeInterface $date The date on which the check will be performed
147+
*
148+
* @return list<string> a list of unique currencies
149+
*
150+
* @throws MissingResourceException if the given $country does not exist
151+
*/
152+
public static function forCountry(string $country, ?bool $legalTender = true, ?bool $active = true, \DateTimeInterface $date = new \DateTimeImmutable('today', new \DateTimeZone('Etc/UTC'))): array
153+
{
154+
$currencies = [];
155+
156+
foreach (self::readEntry(['Map', $country], 'meta') as $currency => $currencyMetadata) {
157+
if (null !== $legalTender && $legalTender !== self::isLegalTender($currencyMetadata)) {
158+
continue;
159+
}
160+
161+
if (null === $active) {
162+
$currencies[] = $currency;
163+
164+
continue;
165+
}
166+
167+
if (self::isDateActive($country, $currency, $currencyMetadata, $date) !== $active) {
168+
continue;
169+
}
170+
171+
$currencies[] = $currency;
172+
}
173+
174+
return $currencies;
175+
}
176+
177+
/**
178+
* @param string $country e.g. 'FR'
179+
* @param string $currency e.g. 'USD'
180+
* @param ?bool $legalTender If the currency must be a legal tender; null to not filter anything
181+
* @param ?bool $active Indicates whether the currency should always be active for the given $date; null to not filter anything
182+
* @param \DateTimeInterface $date The date that will be checked when $active is set to true
183+
*/
184+
public static function isValidInCountry(string $country, string $currency, ?bool $legalTender = true, ?bool $active = true, \DateTimeInterface $date = new \DateTimeImmutable('today', new \DateTimeZone('Etc/UTC'))): bool
185+
{
186+
if (!self::exists($currency)) {
187+
throw new \InvalidArgumentException("The currency $currency does not exist.");
188+
}
189+
190+
try {
191+
$currencyMetadata = self::readEntry(['Map', $country, $currency], 'meta');
192+
} catch (MissingResourceException) {
193+
return false;
194+
}
195+
196+
if (null !== $legalTender && $legalTender !== self::isLegalTender($currencyMetadata)) {
197+
return false;
198+
}
199+
200+
if (null === $active) {
201+
return true;
202+
}
203+
204+
return self::isDateActive($country, $currency, $currencyMetadata, $date) === $active;
205+
}
206+
207+
/**
208+
* @param array{tender?: bool} $currencyMetadata When the `tender` property does not exist, it means it is a legal tender
209+
*/
210+
private static function isLegalTender(array $currencyMetadata): bool
211+
{
212+
return !\array_key_exists('tender', $currencyMetadata) || false !== $currencyMetadata['tender'];
213+
}
214+
215+
/**
216+
* @param string $country e.g. 'FR'
217+
* @param string $currency e.g. 'USD'
218+
* @param array{from?: string, to?: string} $currencyMetadata
219+
* @param \DateTimeInterface $date The date on which the check will be performed
220+
*/
221+
private static function isDateActive(string $country, string $currency, array $currencyMetadata, \DateTimeInterface $date): bool
222+
{
223+
if (!\array_key_exists('from', $currencyMetadata)) {
224+
// Note: currencies that are not legal tender don't have often validity dates.
225+
throw new \RuntimeException("Cannot check whether the currency $currency is active or not in $country because they are no validity dates available.");
226+
}
227+
228+
$from = \DateTimeImmutable::createFromFormat('Y-m-d', $currencyMetadata['from']);
229+
230+
if (\array_key_exists('to', $currencyMetadata)) {
231+
$to = \DateTimeImmutable::createFromFormat('Y-m-d', $currencyMetadata['to']);
232+
} else {
233+
$to = null;
234+
}
235+
236+
return $from <= $date && (null === $to || $to >= $date);
237+
}
238+
239+
/**
240+
* @param string $currency e.g. 'USD'
241+
* @param ?bool $legalTender If the currency must be a legal tender; null to not filter anything
242+
* @param ?bool $active Indicates whether the currency should always be active for the given $date; null to not filter anything
243+
* @param \DateTimeInterface $date the date on which the check will be performed if $active is set to true
244+
*/
245+
public static function isValidInAnyCountry(string $currency, ?bool $legalTender = true, ?bool $active = true, \DateTimeInterface $date = new \DateTimeImmutable('today', new \DateTimeZone('Etc/UTC'))): bool
246+
{
247+
if (!self::exists($currency)) {
248+
throw new \InvalidArgumentException("The currency $currency does not exist.");
249+
}
250+
251+
foreach (self::readEntry(['Map'], 'meta') as $countryCode => $country) {
252+
foreach ($country as $currencyCode => $currencyMetadata) {
253+
if ($currencyCode !== $currency) {
254+
continue;
255+
}
256+
257+
if (null !== $legalTender && $legalTender !== self::isLegalTender($currencyMetadata)) {
258+
continue;
259+
}
260+
261+
if (null === $active) {
262+
return true;
263+
}
264+
265+
if (self::isDateActive($countryCode, $currencyCode, $currencyMetadata, $date) !== $active) {
266+
continue;
267+
}
268+
269+
return true;
270+
}
271+
}
272+
273+
return false;
274+
}
275+
142276
protected static function getPath(): string
143277
{
144278
return Intl::getDataDirectory().'/'.Intl::CURRENCY_DIR;

src/Symfony/Component/Intl/Intl.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ public static function getIcuDataVersion(): string
106106
*/
107107
public static function getIcuStubVersion(): string
108108
{
109-
return '76.1';
109+
return '77.1';
110110
}
111111

112112
/**

src/Symfony/Component/Intl/Tests/CurrenciesTest.php

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,4 +805,97 @@ private static function getNumericToAlpha3Mapping()
805805

806806
return $numericToAlpha3;
807807
}
808+
809+
public function testBefCurrencyNoLongerExistIn2025()
810+
{
811+
$this->assertFalse(Currencies::isValidInAnyCountry('BEF', date: new \DateTimeImmutable('2025-01-01', new \DateTimeZone('Etc/UTC'))));
812+
}
813+
814+
public function testUsdCurrencyExistsInAtLeastOneCountryIn2025()
815+
{
816+
$this->assertTrue(Currencies::isValidInAnyCountry('USD', date: new \DateTimeImmutable('2025-01-01', new \DateTimeZone('Etc/UTC'))));
817+
}
818+
819+
public function testCheCurrencyIsNotRecognizedLegallyAnywhere()
820+
{
821+
$this->assertTrue(Currencies::isValidInAnyCountry('CHE', null, active: null));
822+
}
823+
824+
public function testEsbCurrencyIsNotLegalTenderSomewhere()
825+
{
826+
$this->assertFalse(Currencies::isValidInAnyCountry('ESB', active: null));
827+
}
828+
829+
public function testCurrenciesOfSwitzerlandIn2025()
830+
{
831+
$this->assertSame(['CHF'], Currencies::forCountry('CH', date: new \DateTimeImmutable('2025-01-01', new \DateTimeZone('Etc/UTC'))));
832+
}
833+
834+
public function testBefCurrencyExistedLegallyInTheHistory()
835+
{
836+
$this->assertContains('BEF', Currencies::forCountry('BE', active: null));
837+
}
838+
839+
public function testBefCurrencyWasValidIn2001InBelgium()
840+
{
841+
$this->assertTrue(Currencies::isValidInCountry('BE', 'BEF', date: new \DateTimeImmutable('2001-01-01', new \DateTimeZone('Etc/UTC'))));
842+
}
843+
844+
public function testEurCurrencyIsValidIn2025InFrance()
845+
{
846+
$this->assertTrue(Currencies::isValidInCountry('FR', 'EUR', date: new \DateTimeImmutable('2025-01-01', new \DateTimeZone('Etc/UTC'))));
847+
}
848+
849+
public function testCheCurrencyIsValidInSwitzerland()
850+
{
851+
$this->assertTrue(Currencies::isValidInCountry('CH', 'CHE', false, null));
852+
}
853+
854+
public function testInactiveCurrenciesOfChinaIn2025()
855+
{
856+
$this->assertSame(['CNX'], Currencies::forCountry('CN', null, false, new \DateTimeImmutable('2025-01-01', new \DateTimeZone('Etc/UTC'))));
857+
}
858+
859+
public function testUsdCurrencyDoesNotExistInFranceIn2025()
860+
{
861+
$this->assertFalse(Currencies::isValidInCountry('FR', 'USD', active: null, date: new \DateTimeImmutable('2025-01-01', new \DateTimeZone('Etc/UTC'))));
862+
}
863+
864+
public function testChfCurrencyNotConsideredLegalTender()
865+
{
866+
$this->assertFalse(Currencies::isValidInCountry('CH', 'CHF', false, null));
867+
}
868+
869+
public function testCheCurrencyDoesNotHaveValidityDatesInSwitzerland()
870+
{
871+
$this->expectException(\RuntimeException::class);
872+
$this->expectExceptionMessage('Cannot check whether the currency CHE is active or not in CH because they are no validity dates available.');
873+
874+
Currencies::isValidInCountry('CH', 'CHE', false, false);
875+
}
876+
877+
/**
878+
* Special case because the official dataset contains XXX to indicate that Antartica has no currency, but it is
879+
* excluded from the generated data on purpose.
880+
*/
881+
public function testAntarticaHasNoCurrenciesIn2025()
882+
{
883+
$this->assertSame([], Currencies::forCountry('AQ', null, true, new \DateTimeImmutable('2025-01-01', new \DateTimeZone('Etc/UTC'))));
884+
}
885+
886+
public function testIsValidInCountryWithUnknownCurrencyThrowsException()
887+
{
888+
$this->expectException(\InvalidArgumentException::class);
889+
$this->expectExceptionMessage('The currency UNKNOWN-CURRENCY-FROM-ISO-4217 does not exist.');
890+
891+
Currencies::isValidInCountry('CH', 'UNKNOWN-CURRENCY-FROM-ISO-4217');
892+
}
893+
894+
public function testIsValidInAnyCountryWithUnknownCurrencyThrowsException()
895+
{
896+
$this->expectException(\InvalidArgumentException::class);
897+
$this->expectExceptionMessage('The currency UNKNOWN-CURRENCY-FROM-ISO-4217 does not exist.');
898+
899+
Currencies::isValidInAnyCountry('UNKNOWN-CURRENCY-FROM-ISO-4217');
900+
}
808901
}

0 commit comments

Comments
 (0)