Skip to content

Commit f906f56

Browse files
authored
Merge branch 'master' into implement-national-bank-of-georgia-service
2 parents 327b874 + 4def80d commit f906f56

File tree

8 files changed

+365
-0
lines changed

8 files changed

+365
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Here is the complete list of the currently implemented services:
4848
| [coinlayer](https://coinlayer.com) | * Crypto (Limited standard currencies) | * Crypto (Limited standard currencies) | Yes |
4949
| [European Central Bank](https://www.ecb.europa.eu/home/html/index.en.html) | EUR | * | Yes |
5050
| [National Bank of Georgia](https://nbg.gov.ge) | * | GEL | Yes |
51+
| [National Bank of the Republic of Belarus](https://www.nbrb.by) | * | BYN (from 01-07-2016),<br>BYR (01-01-2000 - 30-06-2016),<br>BYB (25-05-1992 - 31-12-1999) | Yes |
5152
| [National Bank of Romania](http://www.bnr.ro) | RON, AED, AUD, BGN, BRL, CAD, CHF, CNY, CZK, DKK, EGP, EUR, GBP, HRK, HUF, INR, JPY, KRW, MDL, MXN, NOK, NZD, PLN, RSD, RUB, SEK, TRY, UAH, USD, XAU, XDR, ZAR | RON, AED, AUD, BGN, BRL, CAD, CHF, CNY, CZK, DKK, EGP, EUR, GBP, HRK, HUF, INR, JPY, KRW, MDL, MXN, NOK, NZD, PLN, RSD, RUB, SEK, TRY, UAH, USD, XAU, XDR, ZAR | Yes |
5253
| [National Bank of Ukranie](https://bank.gov.ua) | * | UAH | Yes |
5354
| [Central Bank of the Republic of Turkey](http://www.tcmb.gov.tr) | * | TRY | Yes |

composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
}
2424
},
2525
"require": {
26+
"ext-json": "*",
27+
"ext-libxml": "*",
2628
"ext-simplexml": "*",
2729
"php": "^7.1.3 || ^8.0",
2830
"php-http/httplug": "^1.0 || ^2.0",
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of Exchanger.
7+
*
8+
* (c) Florian Voutzinos <[email protected]>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Exchanger\Service;
15+
16+
use Exchanger\Contract\ExchangeRate;
17+
use Exchanger\Contract\ExchangeRateQuery;
18+
use Exchanger\Contract\HistoricalExchangeRateQuery;
19+
use Exchanger\Exception\UnsupportedCurrencyPairException;
20+
use Exchanger\Exception\UnsupportedDateException;
21+
use Exchanger\Exception\UnsupportedExchangeQueryException;
22+
use Exchanger\StringUtil;
23+
24+
/**
25+
* National Bank of the Republic of Belarus (NBRB) Service.
26+
*
27+
* @author Sergey Danilchenko <[email protected]>
28+
*/
29+
class NationalBankOfRepublicBelarus extends HttpService
30+
{
31+
use SupportsHistoricalQueries;
32+
33+
protected const URL = 'https://www.nbrb.by/api/exrates/rates';
34+
35+
/**
36+
* {@inheritdoc}
37+
*
38+
* @throws UnsupportedCurrencyPairException
39+
* @throws UnsupportedDateException
40+
* @throws UnsupportedExchangeQueryException
41+
*/
42+
protected function getLatestExchangeRate(ExchangeRateQuery $exchangeQuery): ExchangeRate
43+
{
44+
return $this->doCreateRate($exchangeQuery);
45+
}
46+
47+
/**
48+
* {@inheritdoc}
49+
*
50+
* @throws UnsupportedCurrencyPairException
51+
* @throws UnsupportedDateException
52+
* @throws UnsupportedExchangeQueryException
53+
*/
54+
protected function getHistoricalExchangeRate(HistoricalExchangeRateQuery $exchangeQuery): ExchangeRate
55+
{
56+
return $this->doCreateRate($exchangeQuery, $exchangeQuery->getDate());
57+
}
58+
59+
/**
60+
* {@inheritdoc}
61+
*
62+
* @param bool $ignoreSupportPeriod
63+
*/
64+
public function supportQuery(ExchangeRateQuery $exchangeQuery, bool $ignoreSupportPeriod = false): bool
65+
{
66+
$currencyPair = $exchangeQuery->getCurrencyPair();
67+
$baseCurrency = $currencyPair->getBaseCurrency();
68+
$quoteCurrency = $currencyPair->getQuoteCurrency();
69+
$date = $exchangeQuery instanceof HistoricalExchangeRateQuery && !$ignoreSupportPeriod
70+
? $exchangeQuery->getDate() : null;
71+
72+
return is_int(self::detectPeriodicity($baseCurrency, $date))
73+
&& self::supportQuoteCurrency($quoteCurrency, $date);
74+
}
75+
76+
/**
77+
* Tells if the service supports base currency for the given date and detect its periodicity if it does.
78+
*
79+
* @param string $baseCurrency
80+
* @param \DateTimeInterface|null $date
81+
*
82+
* @return int|false
83+
*/
84+
private static function detectPeriodicity(string $baseCurrency, \DateTimeInterface $date = null)
85+
{
86+
return array_reduce(
87+
88+
array_reverse(array_intersect_key(
89+
$codes = self::getSupportedCodes(),
90+
array_flip(array_keys(array_column($codes, 'Cur_Abbreviation'), $baseCurrency))
91+
)),
92+
93+
static function ($periodicity, $entry) use ($date) {
94+
if ($date) {
95+
$dateStart = new \DateTimeImmutable($entry['Cur_DateStart']);
96+
$dateEnd = new \DateTimeImmutable($entry['Cur_DateEnd']);
97+
if ($date < $dateStart || $date > $dateEnd) {
98+
return $periodicity;
99+
}
100+
}
101+
102+
return in_array($periodicity, [false, 1], true) ? $entry['Cur_Periodicity'] : $periodicity;
103+
},
104+
105+
false
106+
107+
);
108+
}
109+
110+
/**
111+
* Tells if the service supports quote currency for the given date.
112+
*
113+
* @param string $quoteCurrency
114+
* @param \DateTimeInterface|null $date
115+
*
116+
* @return bool
117+
*/
118+
private static function supportQuoteCurrency(string $quoteCurrency, \DateTimeInterface $date = null): bool
119+
{
120+
if ($date) {
121+
$date = $date->format('Y-m-d');
122+
}
123+
124+
return $date
125+
? $quoteCurrency === 'BYN' && $date >= '2016-07-01'
126+
|| $quoteCurrency === 'BYR' && $date >= '2000-01-01' && $date < '2016-07-01'
127+
|| $quoteCurrency === 'BYB' && $date >= '1992-05-25' && $date < '2000-01-01'
128+
: in_array($quoteCurrency, ['BYN', 'BYR', 'BYB']);
129+
}
130+
131+
/**
132+
* Array of base currency codes supported by the service.
133+
*
134+
* @url https://www.nbrb.by/api/exrates/currencies
135+
*
136+
* @return list<array{
137+
* Cur_Abbreviation: string,
138+
* Cur_Periodicity: int,
139+
* Cur_DateStart: string,
140+
* Cur_DateEnd: string
141+
* }>
142+
*/
143+
private static function getSupportedCodes(): array
144+
{
145+
static $codes;
146+
147+
return $codes = $codes ?? StringUtil::jsonToArray(file_get_contents(__DIR__.'/resources/nbrb-codes.json'));
148+
}
149+
150+
/**
151+
* @inheritDoc
152+
*/
153+
public function getName(): string
154+
{
155+
return 'national_bank_of_republic_belarus';
156+
}
157+
158+
/**
159+
* Creates the rate.
160+
*
161+
* @param ExchangeRateQuery $exchangeQuery
162+
* @param \DateTimeInterface|null $requestedDate
163+
*
164+
* @return ExchangeRate
165+
*
166+
* @throws UnsupportedCurrencyPairException
167+
* @throws UnsupportedDateException
168+
* @throws UnsupportedExchangeQueryException
169+
*/
170+
private function doCreateRate(ExchangeRateQuery $exchangeQuery, \DateTimeInterface $requestedDate = null): ExchangeRate
171+
{
172+
$currencyPair = $exchangeQuery->getCurrencyPair();
173+
$baseCurrency = $currencyPair->getBaseCurrency();
174+
175+
if (!$this->supportQuery($exchangeQuery, true)) {
176+
throw new UnsupportedCurrencyPairException($currencyPair, $this);
177+
}
178+
179+
if ($requestedDate && $requestedDate->format('Y-m-d') < '1995-03-29') {
180+
throw new UnsupportedDateException($requestedDate, $this);
181+
}
182+
183+
$content = $this->request($this->buildUrl($baseCurrency, $requestedDate));
184+
$result = StringUtil::jsonToArray($content);
185+
$entryId = array_search($baseCurrency, array_column($result, 'Cur_Abbreviation'));
186+
187+
if ($entryId === false) {
188+
throw new UnsupportedExchangeQueryException($exchangeQuery, $this);
189+
}
190+
191+
/**
192+
* @var array{
193+
* Cur_ID: int,
194+
* Date: string,
195+
* Cur_Abbreviation: string,
196+
* Cur_Scale: int,
197+
* Cur_Name: string,
198+
* Cur_OfficialRate: float
199+
* } $entry
200+
*/
201+
$entry = $result[$entryId];
202+
203+
if (!isset($entry['Cur_OfficialRate'])) {
204+
throw new \RuntimeException('Service has returned malformed response');
205+
}
206+
207+
$date = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s', $entry['Date'] ?? null);
208+
$requestedDate = $requestedDate ?? new \DateTimeImmutable;
209+
if (!$date || $date->format('Y-m-d') !== $requestedDate->format('Y-m-d')) {
210+
throw new UnsupportedDateException($requestedDate, $this);
211+
}
212+
213+
$rate = $entry['Cur_OfficialRate'];
214+
$scale = $entry['Cur_Scale'] ?? 1;
215+
216+
return $this->createRate($currencyPair, $rate / $scale, $date);
217+
}
218+
219+
/**
220+
* Builds the url.
221+
*
222+
* @param string $baseCurrency
223+
* @param \DateTimeInterface|null $requestedDate
224+
*
225+
* @return string
226+
*/
227+
private function buildUrl(string $baseCurrency, \DateTimeInterface $requestedDate = null): string
228+
{
229+
$data = isset($requestedDate) ? ['ondate' => $requestedDate->format('Y-m-d')] : [];
230+
$data += ['periodicity' => (int) self::detectPeriodicity($baseCurrency, $requestedDate)];
231+
232+
return self::URL.'?'.http_build_query($data);
233+
}
234+
}

src/Service/Registry.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public static function getServices(): array
4040
'fixer_apilayer' => FixerApiLayer::class,
4141
'forge' => Forge::class,
4242
'national_bank_of_georgia' => NationalBankOfGeorgia::class,
43+
'national_bank_of_republic_belarus' => NationalBankOfRepublicBelarus::class,
4344
'national_bank_of_romania' => NationalBankOfRomania::class,
4445
'national_bank_of_ukraine' => NationalBankOfUkraine::class,
4546
'open_exchange_rates' => OpenExchangeRates::class,

src/Service/resources/nbrb-codes.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[{
2+
"Cur_ID": 292,
3+
"Date": "2020-03-07T00:00:00",
4+
"Cur_Abbreviation": "EUR",
5+
"Cur_Scale": 1,
6+
"Cur_Name": "Евро",
7+
"Cur_OfficialRate": 2.5178
8+
}]
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[{
2+
"Cur_ID": 451,
3+
"Date": "%s",
4+
"Cur_Abbreviation": "EUR",
5+
"Cur_Scale": 1,
6+
"Cur_Name": "Евро",
7+
"Cur_OfficialRate": 2.5423
8+
}]
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of Exchanger.
7+
*
8+
* (c) Florian Voutzinos <[email protected]>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Exchanger\Tests\Service;
15+
16+
use Exchanger\Exception\UnsupportedCurrencyPairException;
17+
use Exchanger\Exception\UnsupportedDateException;
18+
use Exchanger\ExchangeRateQuery;
19+
use Exchanger\HistoricalExchangeRateQuery;
20+
use Exchanger\CurrencyPair;
21+
use Exchanger\Service\NationalBankOfRepublicBelarus;
22+
23+
class NationalBankOfRepublicBelarusTest extends ServiceTestCase
24+
{
25+
/**
26+
* @test
27+
*/
28+
public function it_does_not_support_all_queries()
29+
{
30+
$service = new NationalBankOfRepublicBelarus($this->createMock('Http\Client\HttpClient'));
31+
32+
$this->assertFalse($service->supportQuery(new ExchangeRateQuery(CurrencyPair::createFromString('BYN/EUR'))));
33+
$this->assertFalse($service->supportQuery(new ExchangeRateQuery(CurrencyPair::createFromString('EUR/GBP'))));
34+
}
35+
36+
/**
37+
* @test
38+
*/
39+
public function it_throws_an_exception_when_the_pair_is_not_supported()
40+
{
41+
$this->expectException(UnsupportedCurrencyPairException::class);
42+
43+
$service = new NationalBankOfRepublicBelarus($this->createMock('Http\Client\HttpClient'));
44+
$service->getExchangeRate(new ExchangeRateQuery(CurrencyPair::createFromString('XXX/BYN')));
45+
}
46+
47+
/**
48+
* @test
49+
*/
50+
public function it_fetches_a_rate()
51+
{
52+
$url = 'https://www.nbrb.by/api/exrates/rates?periodicity=0';
53+
$content = file_get_contents(__DIR__.'/../../Fixtures/Service/NationalBankOfRepublicBelarus/nbrb_today.json');
54+
$today = new \DateTimeImmutable('today');
55+
$content = sprintf($content, $today->format('Y-m-d\TH:i:s'));
56+
57+
$currencyPair = CurrencyPair::createFromString('EUR/BYN');
58+
$service = new NationalBankOfRepublicBelarus($this->getHttpAdapterMock($url, $content));
59+
$rate = $service->getExchangeRate(new ExchangeRateQuery($currencyPair));
60+
61+
$this->assertSame(2.5423, $rate->getValue());
62+
$this->assertEquals($today, $rate->getDate());
63+
$this->assertEquals('national_bank_of_republic_belarus', $rate->getProviderName());
64+
$this->assertSame($currencyPair, $rate->getCurrencyPair());
65+
}
66+
67+
/**
68+
* @test
69+
*/
70+
public function it_fetches_a_historical_rate()
71+
{
72+
$url = 'https://www.nbrb.by/api/exrates/rates?ondate=2020-03-07&periodicity=0';
73+
$content = file_get_contents(__DIR__.'/../../Fixtures/Service/NationalBankOfRepublicBelarus/nbrb_historical.json');
74+
$currencyPair = CurrencyPair::createFromString('EUR/BYN');
75+
$requestedDate = new \DateTimeImmutable('2020-03-07');
76+
77+
$service = new NationalBankOfRepublicBelarus($this->getHttpAdapterMock($url, $content));
78+
$rate = $service->getExchangeRate(new HistoricalExchangeRateQuery($currencyPair, $requestedDate));
79+
80+
$this->assertEquals(2.5178, $rate->getValue());
81+
$this->assertEquals(new \DateTimeImmutable('2020-03-07'), $rate->getDate());
82+
$this->assertEquals('national_bank_of_republic_belarus', $rate->getProviderName());
83+
$this->assertSame($currencyPair, $rate->getCurrencyPair());
84+
}
85+
86+
/**
87+
* @test
88+
*/
89+
public function it_throws_an_exception_when_historical_date_is_not_supported()
90+
{
91+
$this->expectException(UnsupportedDateException::class);
92+
$this->expectExceptionMessage("The date \"1995-01-01\" is not supported by the service \"Exchanger\Service\NationalBankOfRepublicBelarus\".");
93+
94+
$currencyPair = CurrencyPair::createFromString('EUR/BYN');
95+
$requestedDate = new \DateTimeImmutable('1995-01-01');
96+
97+
$service = new NationalBankOfRepublicBelarus($this->createMock('Http\Client\HttpClient'));
98+
$service->getExchangeRate(new HistoricalExchangeRateQuery($currencyPair, $requestedDate));
99+
}
100+
101+
/**
102+
* @test
103+
*/
104+
public function it_has_a_name()
105+
{
106+
$service = new NationalBankOfRepublicBelarus($this->createMock('Http\Client\HttpClient'));
107+
108+
$this->assertSame('national_bank_of_republic_belarus', $service->getName());
109+
}
110+
}

0 commit comments

Comments
 (0)