Skip to content

Commit 04b3149

Browse files
Crovitche-1623nicolas-grekas
authored andcommitted
[Intl] Add methods to filter currencies more precisely
1 parent d10e3dd commit 04b3149

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)