diff --git a/.github/workflows/min-stability.yml b/.github/workflows/min-stability.yml new file mode 100644 index 0000000..75925f8 --- /dev/null +++ b/.github/workflows/min-stability.yml @@ -0,0 +1,30 @@ +name: Minimum Stability + +on: + push: + branches: + - "**" + pull_request: + branches: [ "master" ] + +permissions: + contents: read + +jobs: + standard: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + + - name: Composer Install + run: composer update --prefer-dist --no-progress --prefer-lowest + + - name: PHPUnit + run: ./vendor/bin/phpunit --testsuite Full diff --git a/.github/workflows/phpcodesniffer.yml b/.github/workflows/phpcodesniffer.yml new file mode 100644 index 0000000..53351c3 --- /dev/null +++ b/.github/workflows/phpcodesniffer.yml @@ -0,0 +1,23 @@ +name: PHP_CodeSniffer + +on: + push: + branches: + - "**" + +permissions: + contents: read + +jobs: + standard: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Composer Install + run: composer install --prefer-dist --no-progress + + - name: PHP_CodeSniffer + run: ./vendor/bin/phpcs diff --git a/.github/workflows/php.yml b/.github/workflows/phpcoverage.yml similarity index 67% rename from .github/workflows/php.yml rename to .github/workflows/phpcoverage.yml index 3fd5a8e..47d34bf 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/phpcoverage.yml @@ -1,11 +1,9 @@ -name: PHP Composer +name: PHPCoverage on: push: branches: - "**" - pull_request: - branches: [ "master" ] schedule: - cron: "0 1 * * *" @@ -23,14 +21,8 @@ jobs: - name: Composer Install run: composer install --prefer-dist --no-progress - - name: PHPUnit + - name: PHPUnitCoverage run: XDEBUG_MODE=coverage ./vendor/bin/phpunit --log-junit junit_report.xml --coverage-clover clover.xml --coverage-text --colors=never - - name: PHPStan - run: ./vendor/bin/phpstan - - - name: PHP_CodeSniffer - run: ./vendor/bin/phpcs - - - name: CodeCov + - name: CodeCov uses: codecov/codecov-action@v3 diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml new file mode 100644 index 0000000..c2adca4 --- /dev/null +++ b/.github/workflows/phpstan.yml @@ -0,0 +1,23 @@ +name: PHPStan + +on: + push: + branches: + - "**" + +permissions: + contents: read + +jobs: + standard: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Composer Install + run: composer install --prefer-dist --no-progress + + - name: PHPStan + run: ./vendor/bin/phpstan --memory-limit=256M diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 0000000..8d80c1a --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,27 @@ +name: PHPUnit + +on: + push: + branches: + - "**" + pull_request: + branches: [ "master" ] + schedule: + - cron: "0 1 * * *" + +permissions: + contents: read + +jobs: + standard: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Composer Install + run: composer install --prefer-dist --no-progress + + - name: PHPUnit + run: XDEBUG_MODE=coverage ./vendor/bin/phpunit diff --git a/.idea/currency-rates.iml b/.idea/currency-rates.iml index 497f7b2..70bde57 100644 --- a/.idea/currency-rates.iml +++ b/.idea/currency-rates.iml @@ -40,6 +40,21 @@ + + + + + + + + + + + + + + + diff --git a/.idea/php-docker-settings.xml b/.idea/php-docker-settings.xml index aa7f0f3..1303964 100644 --- a/.idea/php-docker-settings.xml +++ b/.idea/php-docker-settings.xml @@ -3,6 +3,21 @@ + + + + + + + diff --git a/.idea/php.xml b/.idea/php.xml index b2d1ec1..da491c7 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -52,6 +52,19 @@ + + + + + + + + + + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b2b3bf3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.1.0] - 2023-12-02 + +### Added +- Exchanger - Bank of England - [\#1](../../../issues/1) diff --git a/composer.json b/composer.json index 16dd6d5..20cb625 100644 --- a/composer.json +++ b/composer.json @@ -36,18 +36,26 @@ "require": { "php": "^8.1", "mibo/currencies": "^1.0", - "ext-xmlreader": "*" + "ext-xmlreader": "*", + "nesbot/carbon": "^2.72" }, "require-dev": { - "phpunit/phpunit": "^10.0", + "phpunit/phpunit": "^10.1", "phpunit/php-invoker": "^4.0", "phpstan/phpstan": "^1.5", "squizlabs/php_codesniffer": "^3.6", "phpstan/phpstan-strict-rules": "^1.5", - "jetbrains/phpstorm-attributes": "^1.0" + "jetbrains/phpstorm-attributes": "^1.0", + "slevomat/coding-standard": "^8.13", + "phpstan/extension-installer": "^1.3", + "php-parallel-lint/php-parallel-lint": "^1.3" }, "config": { "platform-check": true, - "lock": false + "lock": false, + "allow-plugins": { + "phpstan/extension-installer": true, + "dealerdirect/phpcodesniffer-composer-installer": true + } } } diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 8d2b10e..dfa2ed3 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -7,129 +7,79 @@ ./src + ./tests - - 0 - - - 0 - - - 0 - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - - - 0 - - - 0 - - - 0 - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - 0 - - - 0 - - - 0 - - - 0 + + ./src/Contracts/* - - 0 + + ./src/Exceptions/* - - 0 + + ./src/Exceptions/* - - 0 - - + - - + + + + - - 0 - - - 0 - - - 0 - - - 0 + + ./tests/* - - 0 + + ./tests/* - - 0 + + ./tests/* - - 0 + + ./tests/* - - 0 - - - - - - - + - + - - 0 + + ./src/Exchangers/* - - 0 - - - 0 - - - - - diff --git a/src/Contracts/ExchangerInterface.php b/src/Contracts/ExchangerInterface.php index 751cb9c..d723cb5 100644 --- a/src/Contracts/ExchangerInterface.php +++ b/src/Contracts/ExchangerInterface.php @@ -60,7 +60,7 @@ public function getExchangeRates(): array; /** * Lists all available currencies for the Exchanger. * - * @return string[] Available currencies. + * @return array Available currencies. */ public function getAvailableCurrencies(): array; } diff --git a/src/Exchangers/BoE.php b/src/Exchangers/BoE.php new file mode 100644 index 0000000..e48c77c --- /dev/null +++ b/src/Exchangers/BoE.php @@ -0,0 +1,230 @@ + + * + * @since 1.1.0 + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise. + */ +class BoE implements ExchangerInterface +{ + use ExchangerHelper; + + private const URL = 'https://www.bankofengland.co.uk/boeapps/database/_iadb-fromshowcolumns.asp?csv.y=yes'; + private const SERIES = [ + 'XUDLADS' => 'AUD', + 'XUDLBK25' => 'CZK', + 'XUDLBK33' => 'HUF', + 'XUDLBK47' => 'PLN', + 'XUDLBK78' => 'ILS', + 'XUDLBK83' => 'MYR', + 'XUDLBK87' => 'THB', + 'XUDLBK89' => 'CNY', + 'XUDLBK93' => 'KRW', + 'XUDLBK95' => 'TRY', + 'XUDLBK97' => 'INR', + 'XUDLCDS' => 'CAD', + 'XUDLDKS' => 'DKK', + 'XUDLERS' => 'EUR', + 'XUDLHDS' => 'HKD', + 'XUDLJYS' => 'JPY', + 'XUDLNDS' => 'NZD', + 'XUDLNKS' => 'NOK', + 'XUDLSFS' => 'CHF', + 'XUDLSGS' => 'SGD', + 'XUDLSKS' => 'SEK', + 'XUDLSRS' => 'SAR', + 'XUDLTWS' => 'TWD', + 'XUDLUSS' => 'USD', + 'XUDLZRS' => 'ZAR', + ]; + + /** + * @inheritDoc + */ + final public function getDefaultCurrencyCode(): string + { + return 'GBP'; + } + + /** + * @return non-empty-string + */ + final protected function composeUlr(): string + { + /** @phpstan-var \Carbon\Carbon $currentTime */ + $currentTime = Carbon::now()->isWeekend() ? Carbon::now()->previous(CarbonInterface::FRIDAY) : Carbon::now(); + + /** @phpstan-var \Carbon\Carbon $timeFrom */ + $timeFrom = $currentTime->copy()->subDays(3); + $queries = [ + 'csv.x' => 'yes', + 'CSVF' => 'CN', + 'DAT' => 'RNG', + 'FD' => $timeFrom->day, + 'Filter' => 'N', + 'FM' => $timeFrom->format('M'), + 'FNY' => '', + 'FromSeries' => 1, + 'FY' => $timeFrom->year, + 'TD' => $currentTime->day, + 'TM' => $currentTime->format('M'), + 'ToSeries' => 50, + 'Travel' => 'NIxIRxSUx', + 'TY' => $currentTime->year, + ]; + $currencies = [ + 'C=EC3', + 'C=DS7', + 'C=5LA', + 'C=5OW', + 'C=IN7', + 'C=IN8', + 'C=INA', + 'C=INB', + 'C=INC', + 'C=IND', + 'C=INE', + 'C=ECL', + 'C=ECH', + 'C=C8J', + 'C=ECN', + 'C=C8N', + 'C=ECO', + 'C=EC6', + 'C=ECU', + 'C=ECQ', + 'C=ECC', + 'C=ECZ', + 'C=ECD', + 'C=C8P', + 'C=ECE', + ]; + + return self::URL . '&' . http_build_query($queries) . '&' . implode('&', $currencies); + } + + /** + * @inheritDoc + */ + public function getExchangeRates(): array + { + $content = file_get_contents($this->composeUlr()); + + if ($content === false) { + throw new ExchangeRateNotAvailableException(); + } + + return $this->csvContentIntoArray($content); + } + + /** + * @inheritDoc + */ + public function getAvailableCurrencies(): array + { + return array_merge(array_values(self::SERIES), [$this->getDefaultCurrencyCode()]); + } + + /** + * @inheritDoc + * + * @param array $rates + */ + protected function getFor(array $rates, string $currency, string $fromCurrency): float + { + $rate = $rates[$currency]["rate"]; + + if ($fromCurrency === $this->getDefaultCurrencyCode()) { + return $rate; + } + + return 1 / $rates[$fromCurrency]["rate"] * $rate; + } + + /** + * @param string $data + * + * @return array, rate: float}> + */ + private function csvContentIntoArray(string $data): array + { + $rows = explode("\n", $data); + + unset($rows[0]); + + /** @phpstan-var array{0: string, 1: key-of, 2: numeric-string} $rows */ + $rows = array_map('str_getcsv', $rows); + // @phpstan-ignore-next-line + $rows = array_filter($rows, static fn (array $row): bool => count($row) === 3); + $rows = array_map( + // @phpstan-ignore-next-line + static fn (array $row): array => [ + 'currency' => self::SERIES[$row[1]], + 'date' => Carbon::createFromFormat('d M Y', $row[0]), + 'value' => (float) $row[2], + ], + $rows + ); + + /** @phpstan-var \Carbon\Carbon|null $latestDate */ + $latestDate = null; + + /** @var array{date: \Carbon\Carbon, value: float, currency: string} $row */ + foreach ($rows as $row) { + $latestDate ??= $row['date']; + + if ($latestDate->isAfter($row['date']) || $latestDate->isSameDay($row['date'])) { + break; + } + } + + /** @phpstan-var array $rows */ + array_filter($rows, static fn (array $row): bool => $row['date']->isSameDay($latestDate)); + + $result = []; + + foreach ($rows as $row) { + $result[$row['currency']] = [ + 'amount' => 1, + 'rate' => $row['value'], + ]; + } + + return $result; + } +} diff --git a/src/Exchangers/CNB.php b/src/Exchangers/CNB.php index d5d9755..3734b91 100644 --- a/src/Exchangers/CNB.php +++ b/src/Exchangers/CNB.php @@ -7,6 +7,7 @@ use MiBo\Currency\Rates\Contracts\ExchangerInterface; use MiBo\Currency\Rates\Exceptions\ExchangeRateNotAvailableException; use MiBo\Currency\Rates\Traits\ExchangerHelper; +use function count; /** * Class CNB @@ -70,25 +71,10 @@ final public function getDefaultCurrencyCode(): string return "CZK"; } - /** - * @inheritDoc - * - * @param array $rates - */ - protected function getFor(array $rates, string $currency, string $fromCurrency): float - { - $rate = ($rates[$currency]["amount"] ?? 1) / $rates[$currency]["rate"]; - - if ($fromCurrency === $this->getDefaultCurrencyCode()) { - return $rate; - } - - return $rate / (($rates[$fromCurrency]["amount"] ?? 1) * $rates[$fromCurrency]["rate"]); - } - /** * @inheritDoc */ + // @phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh public function getExchangeRates(): array { $content = file_get_contents(static::URL); @@ -124,4 +110,20 @@ public function getExchangeRates(): array return $rates; } + + /** + * @inheritDoc + * + * @param array $rates + */ + protected function getFor(array $rates, string $currency, string $fromCurrency): float + { + $rate = ($rates[$currency]["amount"] ?? 1) / $rates[$currency]["rate"]; + + if ($fromCurrency === $this->getDefaultCurrencyCode()) { + return $rate; + } + + return $rate / (($rates[$fromCurrency]["amount"] ?? 1) * $rates[$fromCurrency]["rate"]); + } } diff --git a/src/Exchangers/ECB.php b/src/Exchangers/ECB.php index 3ae10dc..1be411a 100644 --- a/src/Exchangers/ECB.php +++ b/src/Exchangers/ECB.php @@ -13,14 +13,14 @@ * Class ECB * * The European Central Bank (ECB) is the prime component of the Eurosystem and the European System of - * Central Banks (ESCB) as well as one of seven institutions of the European Union.[2] It is one of the + * Central Banks (ESCB) as well as one of seven institutions of the European Union. It is one of the * world's most important central banks. * * The ECB Governing Council makes monetary policy for the Eurozone and the European Union, administers * the foreign exchange reserves of EU member states, engages in foreign exchange operations, and defines * the intermediate monetary objectives and key interest rate of the EU. The ECB Executive Board enforces * the policies and decisions of the Governing Council, and may direct the national central banks when - * doing so.[3] The ECB has the exclusive right to authorise the issuance of euro banknotes. Member states can + * doing so. The ECB has the exclusive right to authorise the issuance of euro banknotes. Member states can * issue euro coins, but the volume must be approved by the ECB beforehand. The bank also operates the TARGET2 * payments system. * @@ -29,13 +29,13 @@ * the official status of an EU institution. When the ECB was created, it covered a Eurozone of eleven * members. Since then, Greece joined in January 2001, Slovenia in January 2007, Cyprus and Malta in January * 2008, Slovakia in January 2009, Estonia in January 2011, Latvia in January 2014, Lithuania in January 2015 - * and Croatia in January 2023.[4] The current President of the ECB is Christine Lagarde. Seated in Frankfurt, + * and Croatia in January 2023. The current President of the ECB is Christine Lagarde. Seated in Frankfurt, * Germany, the bank formerly occupied the Eurotower prior to the construction of its new seat. * * The ECB is directly governed by European Union law. Its capital stock, worth €11 billion, is owned by * all 27 central banks of the EU member states as shareholders.[5] The initial capital allocation key was * determined in 1998 on the basis of the states' population and GDP, but the capital key has been readjusted - * since.[5] Shares in the ECB are not transferable and cannot be used as collateral. + * since. Shares in the ECB are not transferable and cannot be used as collateral. * * @link https://www.ecb.europa.eu/ * @@ -56,30 +56,15 @@ class ECB implements ExchangerInterface /** * @inheritDoc */ - public function getDefaultCurrencyCode(): string + final public function getDefaultCurrencyCode(): string { return "EUR"; } - /** - * @inheritDoc - * - * @param array $rates - */ - protected function getFor(array $rates, string $currency, string $fromCurrency): float - { - $rate = $rates[$currency]["rate"]; - - if ($fromCurrency === $this->getDefaultCurrencyCode()) { - return $rate; - } - - return (1 / $rates[$fromCurrency]["rate"] ) * $rate; - } - /** * @inheritDoc */ + // @phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh public function getExchangeRates(): array { $rates = []; @@ -106,14 +91,30 @@ public function getExchangeRates(): array break; } + /** @phpstan-var object{value: string}|null $currency */ $currency = $DOMNode->attributes->item(0); - $rate = $DOMNode->attributes->item(1); - /** @var object{value: string}|null $currency */ - /** @var object{value: string}|null $rate */ + /** @phpstan-var object{value: string}|null $rate */ + $rate = $DOMNode->attributes->item(1); $rates[$currency?->value] = ["rate" => (float) $rate?->value]; } return $rates; } + + /** + * @inheritDoc + * + * @param array $rates + */ + protected function getFor(array $rates, string $currency, string $fromCurrency): float + { + $rate = $rates[$currency]["rate"]; + + if ($fromCurrency === $this->getDefaultCurrencyCode()) { + return $rate; + } + + return 1 / $rates[$fromCurrency]["rate"] * $rate; + } } diff --git a/src/Traits/ExchangerHelper.php b/src/Traits/ExchangerHelper.php index d2eebd7..c714163 100644 --- a/src/Traits/ExchangerHelper.php +++ b/src/Traits/ExchangerHelper.php @@ -42,6 +42,7 @@ abstract protected function getFor(array $rates, string $currency, string $fromC /** * @inheritDoc */ + // @phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh public function getRateFor( CurrencyInterface|string $currency, CurrencyInterface|string|null $fromCurrency = null @@ -49,9 +50,9 @@ public function getRateFor( { $rates = $this->getExchangeRates(); $currency = $currency instanceof CurrencyInterface ? $currency->getAlphabeticalCode() : $currency; - $fromCurrency = $fromCurrency instanceof CurrencyInterface ? - $fromCurrency->getAlphabeticalCode() : - $fromCurrency; + $fromCurrency = $fromCurrency instanceof CurrencyInterface + ? $fromCurrency->getAlphabeticalCode() + : $fromCurrency; if ($currency === $fromCurrency || ($currency === $this->getDefaultCurrencyCode() && $fromCurrency === null)) { return 1; diff --git a/tests/Core/BoETest.php b/tests/Core/BoETest.php new file mode 100644 index 0000000..2ec557d --- /dev/null +++ b/tests/Core/BoETest.php @@ -0,0 +1,59 @@ + + * + * @since 1.1.0 + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise. + * + * @coversDefaultClass \MiBo\Currency\Rates\Exchangers\BoE + */ +final class BoETest extends ExchangerTestCase +{ + /** + * @medium + * + * @covers ::getExchangeRates + * @covers ::getAvailableCurrencies + * @covers ::getRateFor + * @covers ::getFor + * @covers ::getDefaultCurrencyCode + * @covers ::composeUlr + * @covers ::csvContentIntoArray + * + * @return void + */ + public function test(): void + { + $class = new BoE(); + + $this->assertAvailableCountries( + [ + "CZK", + "EUR", + "AUD", + "USD", + "CAD", + "SEK", + "GBP", + "PLN", + "JPY", + ], + $class->getAvailableCurrencies() + ); + + $this->assertRate(1.16, $class->getRateFor("EUR")); + $this->assertRate(28.22, $class->getRateFor('CZK')); + } +} diff --git a/tests/Core/CNBTest.php b/tests/Core/CNBTest.php index b5435c2..a9d6a1e 100644 --- a/tests/Core/CNBTest.php +++ b/tests/Core/CNBTest.php @@ -19,7 +19,7 @@ * * @coversDefaultClass \MiBo\Currency\Rates\Exchangers\CNB */ -class CNBTest extends ExchangerTestCase +final class CNBTest extends ExchangerTestCase { /** * @medium diff --git a/tests/Core/ECBTest.php b/tests/Core/ECBTest.php index 9c37d45..33c2bdb 100644 --- a/tests/Core/ECBTest.php +++ b/tests/Core/ECBTest.php @@ -4,7 +4,6 @@ namespace MiBo\Currency\Rates\Tests; -use MiBo\Currency\Rates\Exchangers\CNB; use MiBo\Currency\Rates\Exchangers\ECB; /** @@ -20,7 +19,7 @@ * * @coversDefaultClass \MiBo\Currency\Rates\Exchangers\ECB */ -class ECBTest extends ExchangerTestCase +final class ECBTest extends ExchangerTestCase { /** * @medium diff --git a/tests/Core/ExchangerTestCase.php b/tests/Core/ExchangerTestCase.php index 12d5cbd..be066a3 100644 --- a/tests/Core/ExchangerTestCase.php +++ b/tests/Core/ExchangerTestCase.php @@ -17,34 +17,27 @@ * * @no-named-arguments Parameter names are not covered by the backward compatibility promise. */ -class ExchangerTestCase extends TestCase +abstract class ExchangerTestCase extends TestCase { /** - * @param string[] $expected - * @param string[] $actual + * @param array $expected + * @param array $actual * * @return void */ public static function assertAvailableCountries(array $expected, array $actual): void { foreach ($expected as $country) { - static::assertContains($country, $actual); + self::assertContains($country, $actual); } } - /** - * @param float $expected - * @param float $actual - * @param float $delta - * - * @return void - */ public static function assertRate(float $expected, float $actual, float $delta = 10): void { $min = $expected - ($expected / 100 * $delta); $max = $expected + ($expected / 100 * $delta); - static::assertTrue( + self::assertTrue( $actual >= $min && $actual <= $max, "Expected rate is not in range of $min - $max (actual $actual)" );