Skip to content

Commit 85f51cd

Browse files
Merge branch '7.4' into 8.0
* 7.4: (31 commits) [Validator] Update Romanian translations fix tests [JsonStreamer] Fix decoding iterable lists [String][Inflector] Fix edge cases [Serializer][Validator] Add JSON schema for validating and autocompleting YAML config files [DependencyInjection] Allow adding resource tags using any config formats Fix merge Add missing Sweego Mailer Bridge webhook events [Security] Fix attribute-based chained user providers [Intl] Fix Intl::getIcuStubVersion() [Intl] Add methods to filter currencies more precisely [Notifier] Add tests for option classes Sync intl scripts [Intl] Add metadata about currencies' validtity dates Bump Symfony version to 7.3.4 Update VERSION for 7.3.3 Update CHANGELOG for 7.3.3 Bump Symfony version to 6.4.26 Update VERSION for 6.4.25 Update CONTRIBUTORS for 6.4.25 ...
2 parents 7236757 + ebf6d79 commit 85f51cd

File tree

7 files changed

+2558
-2
lines changed

7 files changed

+2558
-2
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ CHANGELOG
1010
---
1111

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

1416
7.1
1517
---

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;

Data/Generator/CurrencyDataGenerator.php

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ protected function generateDataForMeta(BundleEntryReaderInterface $reader, strin
102102
$data = [
103103
'Currencies' => $this->currencyCodes,
104104
'Meta' => $this->generateCurrencyMeta($supplementalDataBundle),
105+
'Map' => $this->generateCurrencyMap($supplementalDataBundle),
105106
'Alpha3ToNumeric' => $this->generateAlpha3ToNumericMapping($numericCodesBundle, $this->currencyCodes),
106107
];
107108

@@ -125,6 +126,70 @@ private function generateCurrencyMeta(ArrayAccessibleResourceBundle $supplementa
125126
return iterator_to_array($supplementalDataBundle['CurrencyMeta']);
126127
}
127128

129+
/**
130+
* @return array<string, array>
131+
*/
132+
private function generateCurrencyMap(mixed $supplementalDataBundle): array
133+
{
134+
/**
135+
* @var list<string, list<string, array{from?: string, to?: string, tender?: false}>> $regionsData
136+
*/
137+
$regionsData = [];
138+
139+
foreach ($supplementalDataBundle['CurrencyMap'] as $regionId => $region) {
140+
foreach ($region as $metadata) {
141+
/**
142+
* Note 1: The "to" property (if present) is always greater than "from".
143+
* Note 2: The "to" property may be missing if the currency is still in use.
144+
* Note 3: The "tender" property indicates whether the country legally recognizes the currency within
145+
* its borders. This property is explicitly set to `false` only if that is not the case;
146+
* otherwise, it is `true` by default.
147+
* Note 4: The "from" and "to" dates are not stored as strings; they are stored as a pair of integers.
148+
* Note 5: The "to" property may be missing if "tender" is set to `false`.
149+
*
150+
* @var array{
151+
* from?: array{0: int, 1: int},
152+
* to?: array{0: int, 2: int},
153+
* tender?: bool,
154+
* id: string
155+
* } $metadata
156+
*/
157+
$metadata = iterator_to_array($metadata);
158+
159+
$id = $metadata['id'];
160+
161+
unset($metadata['id']);
162+
163+
if (\array_key_exists($id, self::DENYLIST)) {
164+
continue;
165+
}
166+
167+
if (\array_key_exists('from', $metadata)) {
168+
$metadata['from'] = self::icuPairToDate($metadata['from']);
169+
}
170+
171+
if (\array_key_exists('to', $metadata)) {
172+
$metadata['to'] = self::icuPairToDate($metadata['to']);
173+
}
174+
175+
if (\array_key_exists('tender', $metadata)) {
176+
$metadata['tender'] = filter_var($metadata['tender'], \FILTER_VALIDATE_BOOLEAN, \FILTER_NULL_ON_FAILURE);
177+
178+
if (null === $metadata['tender']) {
179+
throw new \RuntimeException('Unexpected boolean value for tender attribute.');
180+
}
181+
}
182+
183+
$regionsData[$regionId][$id] = $metadata;
184+
}
185+
186+
// Do not exclude countries with no currencies or excluded currencies (e.g. Antartica)
187+
$regionsData[$regionId] ??= [];
188+
}
189+
190+
return $regionsData;
191+
}
192+
128193
private function generateAlpha3ToNumericMapping(ArrayAccessibleResourceBundle $numericCodesBundle, array $currencyCodes): array
129194
{
130195
$alpha3ToNumericMapping = iterator_to_array($numericCodesBundle['codeMap']);
@@ -152,4 +217,41 @@ private function generateNumericToAlpha3Mapping(array $alpha3ToNumericMapping):
152217

153218
return $numericToAlpha3Mapping;
154219
}
220+
221+
/**
222+
* Decodes ICU "date pair" into a DateTimeImmutable (UTC).
223+
*
224+
* ICU stores UDate = milliseconds since 1970-01-01T00:00:00Z in a signed 64-bit.
225+
*
226+
* @param array{0: int, 1: int} $pair
227+
*/
228+
private static function icuPairToDate(array $pair): string
229+
{
230+
[$highBits32, $lowBits32] = $pair;
231+
232+
// Recompose a 64-bit unsigned integer from two 32-bit chunks.
233+
$unsigned64 = ((($highBits32 & 0xFFFFFFFF) << 32) | ($lowBits32 & 0xFFFFFFFF));
234+
235+
// Convert to signed 64-bit (two's complement) if sign bit is set.
236+
if ($unsigned64 >= (1 << 63)) {
237+
$unsigned64 -= (1 << 64);
238+
}
239+
240+
// Split into seconds and milliseconds.
241+
$seconds = intdiv($unsigned64, 1000);
242+
$millisecondsRemainder = $unsigned64 - $seconds * 1000;
243+
244+
// Normalize negative millisecond remainders (e.g., for pre-1970 values)
245+
if (0 > $millisecondsRemainder) {
246+
--$seconds;
247+
}
248+
249+
$datetime = \DateTimeImmutable::createFromFormat('U', $seconds, new \DateTimeZone('Etc/UTC'));
250+
251+
if (false === $datetime) {
252+
throw new \RuntimeException('Unable to parse ICU milliseconds pair.');
253+
}
254+
255+
return $datetime->format('Y-m-d');
256+
}
155257
}

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
/**

Resources/bin/compile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@ docker run \
88
-v /tmp/symfony/icu:/tmp \
99
-v $(pwd):/symfony \
1010
-w /symfony \
11-
jakzal/php-intl:8.3-74.1 \
11+
jakzal/php-intl:8.4-77.1 \
1212
php src/Symfony/Component/Intl/Resources/bin/update-data.php

0 commit comments

Comments
 (0)