├── LICENSE.md ├── README.md ├── composer.json ├── config └── exchange.php ├── ecs.php ├── rector.php └── src ├── Actions └── ValidateCurrencyCodes.php ├── Commands ├── Concerns │ └── HasUsefulConsoleMethods.php ├── InstallCommand.php └── ViewLatestRatesCommand.php ├── Contracts ├── Actions │ └── ValidatesCurrencyCodes.php ├── CurrencyCodeProvider.php └── ExchangeRateProvider.php ├── Exceptions ├── InvalidConfigurationException.php └── InvalidCurrencyCodeException.php ├── Exchange.php ├── ExchangeRateProviders ├── CachedProvider.php ├── CurrencyGEOProvider.php ├── ExchangeRateHostProvider.php ├── FixerProvider.php ├── FrankfurterProvider.php └── NullProvider.php ├── ExchangeServiceProvider.php ├── Facades └── Exchange.php ├── Support ├── ExchangeRateManager.php ├── FlatCurrencyCodeProvider.php └── Rates.php └── Testing └── FakeExchangeRateProvider.php /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) worksome 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Exchange 2 | 3 | Check exchange rates for any currency in Laravel 4 | 5 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/worksome/exchange.svg?style=flat-square)](https://packagist.org/packages/worksome/exchange) 6 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/worksome/exchange/tests.yml?branch=main&style=flat-square&label=Tests)](https://github.com/worksome/exchange/actions?query=workflow%3ATests+branch%3Amain) 7 | [![GitHub Static Analysis Action Status](https://img.shields.io/github/actions/workflow/status/worksome/exchange/static.yml?branch=main&style=flat-square&label=Static%20Analysis)](https://github.com/worksome/exchange/actions?query=workflow%3A"Static%20Analysis"+branch%3Amain) 8 | [![Total Downloads](https://img.shields.io/packagist/dt/worksome/exchange.svg?style=flat-square)](https://packagist.org/packages/worksome/exchange) 9 | 10 | If your app supports multi-currency, you'll no doubt need to check exchange rates. There are many third party services 11 | to accomplish this, but why bother reinventing the wheel when we've done all the hard work for you? 12 | 13 | Exchange provides an abstraction layer for exchange rate APIs, with a full suite of tools for caching, testing and local 14 | development. 15 | 16 | ## Installation 17 | 18 | You can install the package via composer. 19 | 20 | ```bash 21 | composer require worksome/exchange 22 | ``` 23 | 24 | To install the exchange config file, you can use our `install` artisan command! 25 | 26 | ```bash 27 | php artisan exchange:install 28 | ``` 29 | 30 | Exchange is now installed! 31 | 32 | ## Usage 33 | 34 | Exchange ships with a number of useful drivers for retrieving exchange rates. The default is `exchange_rate`, 35 | which is a free service, but you're welcome to change that to suit you app's requirements. 36 | 37 | The driver can be set using the `EXCHANGE_DRIVER` environment variable. Supported values are: `null`, `fixer`, `exchange_rate` and `cache`. 38 | Let's take a look at each of the options available. 39 | 40 | ### Null 41 | 42 | You can start using Exchange locally with the `null` driver. This will simply return `1.0` for every exchange rate, which is generally fine for local development. 43 | 44 | ```php 45 | use Worksome\Exchange\Facades\Exchange; 46 | 47 | $exchangeRates = Exchange::rates('USD', ['GBP', 'EUR']); 48 | ``` 49 | 50 | In the example above, we are retrieving exchange rates for GBP and EUR based on USD. The `rates` method will return a `Worksome\Exchange\Support\Rates` object, 51 | which includes the base currency, retrieved rates and the time of retrieval. Retrieved rates are an `array` with currency codes as keys and exchange rates as values. 52 | 53 | ```php 54 | $rates = $exchangeRates->getRates(); // ['GBP' => 1.0, 'EUR' => 1.0] 55 | ``` 56 | 57 | ### Fixer 58 | 59 | Of course, the `null` driver isn't very useful when you want actual exchange rates. For this, you should use the `fixer` driver. 60 | 61 | In your `exchange.php` config file, set `default` to `fixer`, or set `EXCHANGE_DRIVER` to `fixer` in your `.env` file. 62 | Next, you'll need an access key from [https://fixer.io/dashboard](https://fixer.io/dashboard). Set `FIXER_ACCESS_KEY` to your provided 63 | access key from Fixer. 64 | 65 | That's it! Fixer is now configured as the default driver and running `Exchange::rates()` again will make a request to 66 | Fixer for up-to-date exchange rates. 67 | 68 | ### ExchangeRate.host 69 | 70 | [exchangerate.host](https://exchangerate.host) is an alternative to Fixer with an identical API spec. 71 | 72 | In your `exchange.php` config file, set `default` to `exchange_rate`, or set `EXCHANGE_DRIVER` to `exchange_rate` in your `.env` file. 73 | Set `EXCHANGE_RATE_ACCESS_KEY` to your provided access key from exchangerate.host. 74 | 75 | With that task completed, you're ready to start using [exchangerate.host](https://exchangerate.host) for retrieving up-to-date 76 | exchange rates. 77 | 78 | ### Currency.GetGeoApi.com 79 | 80 | [Currency.GetGeoApi.com](https://currency.getgeoapi.com) is an alternative option you can use with a free quota. 81 | 82 | In your `exchange.php` config file, set `default` to `currency_geo`, or set `EXCHANGE_DRIVER` to `currency_geo` in your `.env` file. 83 | Set `CURRENCY_GEO_ACCESS_KEY` to your provided access key from currency.getgeoapi.com. 84 | 85 | With that task completed, you're ready to start using [Currency.GetGeoApi.com](https://currency.getgeoapi.com) for retrieving up-to-date 86 | exchange rates. 87 | 88 | ### Frankfurter.app 89 | 90 | [frankfurter.app](https://frankfurter.app) is an open-source API for current and historical foreign exchange rates published by the European Central Bank, which can be used without an API key. 91 | 92 | In your `exchange.php` config file, set `default` to `frankfurter`, or set `EXCHANGE_DRIVER` to `frankfurter` in your `.env` file. 93 | 94 | With that task completed, you're ready to start using [frankfurter.app](https://frankfurter.app) for retrieving up-to-date 95 | exchange rates. 96 | 97 | ### Cache 98 | 99 | It's unlikely that you want to make a request to a third party service every time you call `Exchange::rates()`. To remedy 100 | this, we provide a cache decorator that can be used to store retrieved exchange rates for a specified period (24 hours by default). 101 | 102 | In your `exchange.php` config file, set `default` to `cache`, or set `EXCHANGE_DRIVER` to `cache` in your `.env` file. 103 | You'll also want to pick a strategy under `services.cache.strategy`. By default, this will be `fixer`, but you are free to change that. 104 | The strategy is the service that will be used to perform the exchange rate lookup when nothing is found in the cache. 105 | 106 | There is also the option to override the `ttl` (how many seconds rates are cached for), `key` for your cached rates, and the `store`. 107 | 108 | ## Artisan 109 | 110 | We provide an Artisan command for you to check Exchange is working correctly in your project. 111 | 112 | ```bash 113 | php artisan exchange:rates USD GBP EUR 114 | ``` 115 | 116 | In the example above, exchange rates will be retrieved and displayed in the console from a base of USD to GBP and EUR respectively. You can add as many currencies as you'd like to the command. 117 | 118 | CleanShot 2022-02-23 at 13 10 55@2x 119 | 120 | ## Testing 121 | 122 | To help you write tests using Exchange, we provide a fake implementation via the `Exchange::fake()` method. 123 | 124 | ```php 125 | it('retrieves exchange rates', function () { 126 | Exchange::fake(['GBP' => 1.25, 'USD' => 1.105]); 127 | 128 | $this->get(route('my-app-route')) 129 | ->assertOk(); 130 | 131 | Exchange::assertRetrievedRates(); 132 | }); 133 | ``` 134 | 135 | The `assertRetrievedRates` method will cause your test to fail if no exchange rates were ever retrieved. 136 | 137 | Internally, Exchange prides itself on a thorough test suite written in Pest, strict static analysis, and a very high level of code coverage. You may run these tests yourself by cloning the project and running our test script: 138 | 139 | ```bash 140 | composer test 141 | ``` 142 | 143 | ## Changelog 144 | 145 | Please see [GitHub Releases](https://github.com/worksome/exchange/releases) for more information on what has changed recently. 146 | 147 | ## Credits 148 | 149 | - [Luke Downing](https://github.com/lukeraymonddowning) 150 | - [All Contributors](../../contributors) 151 | 152 | ## License 153 | 154 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 155 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "worksome/exchange", 3 | "description": "Check Exchange Rates for any currency in Laravel.", 4 | "keywords": [ 5 | "worksome", 6 | "laravel", 7 | "exchange" 8 | ], 9 | "homepage": "https://github.com/worksome/exchange", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Luke Downing", 14 | "email": "lukeraymonddowning@gmail.com", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.2", 20 | "illuminate/contracts": "^11.0 || ^12.0", 21 | "nunomaduro/termwind": "^2.0", 22 | "spatie/laravel-package-tools": "^1.19" 23 | }, 24 | "require-dev": { 25 | "guzzlehttp/guzzle": "^7.5", 26 | "larastan/larastan": "^3.1", 27 | "nunomaduro/collision": "^7.10 || ^8.1.1", 28 | "orchestra/testbench": "^9.12 || ^10.1", 29 | "pestphp/pest": "^3.7", 30 | "pestphp/pest-plugin-laravel": "^3.1", 31 | "worksome/coding-style": "^3.2" 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "Worksome\\Exchange\\": "src", 36 | "Worksome\\Exchange\\Database\\Factories\\": "database/factories" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "Worksome\\Exchange\\Tests\\": "tests" 42 | } 43 | }, 44 | "scripts": { 45 | "lint": "vendor/bin/ecs --fix", 46 | "test:unit": "vendor/bin/pest", 47 | "test:coverage": "vendor/bin/pest --coverage", 48 | "test:types": "vendor/bin/phpstan analyse", 49 | "test:style": "vendor/bin/ecs", 50 | "test": [ 51 | "@test:style", 52 | "@test:types", 53 | "@test:unit" 54 | ] 55 | }, 56 | "config": { 57 | "sort-packages": true, 58 | "allow-plugins": { 59 | "pestphp/pest-plugin": true, 60 | "dealerdirect/phpcodesniffer-composer-installer": true, 61 | "worksome/coding-style": true 62 | } 63 | }, 64 | "extra": { 65 | "laravel": { 66 | "providers": [ 67 | "Worksome\\Exchange\\ExchangeServiceProvider" 68 | ], 69 | "aliases": { 70 | "Exchange": "Worksome\\Exchange\\Facades\\Exchange" 71 | } 72 | } 73 | }, 74 | "minimum-stability": "dev", 75 | "prefer-stable": true 76 | } 77 | -------------------------------------------------------------------------------- /config/exchange.php: -------------------------------------------------------------------------------- 1 | env('EXCHANGE_DRIVER', 'frankfurter'), 15 | 16 | 'services' => [ 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Fixer.io 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Fixer is a paid service for converting currency codes. To use Fixer, you'll 24 | | need an API Access Key from the Fixer dashboard. Set that here, and then 25 | | change the 'default' to 'fixer' or set EXCHANGE_DRIVER to 'fixer'. 26 | | 27 | */ 28 | 29 | 'fixer' => [ 30 | 'access_key' => env('FIXER_ACCESS_KEY'), 31 | ], 32 | 33 | /* 34 | |-------------------------------------------------------------------------- 35 | | ExchangeRate.host 36 | |-------------------------------------------------------------------------- 37 | | 38 | | ExchangeRate is a paid service for converting currency codes. To use ExchangeRate, you'll 39 | | need an API Access Key from the ExchangeRate dashboard. Set that here, and then change 40 | | the 'default' to 'exchange_rate' or set EXCHANGE_DRIVER to 'exchange_rate'. 41 | | 42 | */ 43 | 44 | 'exchange_rate' => [ 45 | 'access_key' => env('EXCHANGE_RATE_ACCESS_KEY'), 46 | ], 47 | 48 | /* 49 | |-------------------------------------------------------------------------- 50 | | CurrencyGeo.com 51 | |-------------------------------------------------------------------------- 52 | | 53 | | CurrencyGeo is a paid service for converting currency codes. To use CurrencyGeo, you'll 54 | | need an API Access Key from the CurrencyGeo dashboard. Set that here, and then change 55 | | the 'default' to 'currency_geo' or set EXCHANGE_DRIVER to 'currency_geo'. 56 | | 57 | */ 58 | 59 | 'currency_geo' => [ 60 | 'access_key' => env('CURRENCY_GEO_ACCESS_KEY'), 61 | ], 62 | 63 | /* 64 | |-------------------------------------------------------------------------- 65 | | Cache 66 | |-------------------------------------------------------------------------- 67 | | 68 | | The cache driver is a decorator that will store rates retrieved from the 69 | | given strategy in your application cache for the specified timeout. By 70 | | default, we set the timeout to 24 hours, but you're free to alter it 71 | | to suit the needs of your app. 72 | | 73 | */ 74 | 75 | 'cache' => [ 76 | 'strategy' => env('EXCHANGE_RATES_CACHE_STRATEGY', 'frankfurter'), 77 | 'ttl' => env('EXCHANGE_RATES_CACHE_TTL', 60 * 60 * 24), // 24 hours 78 | 'key' => env('EXCHANGE_RATES_CACHE_KEY', 'cached_exchange_rates'), 79 | 'store' => env('EXCHANGE_RATES_CACHE_STORE'), 80 | ], 81 | ], 82 | 83 | 'features' => [ 84 | 85 | /** 86 | * Laravel's about command provides useful information regarding the state of 87 | * your Laravel application. If `about_command` is set to true, we will 88 | * show useful information about exchange in about command output. 89 | */ 90 | 'about_command' => true, 91 | 92 | ], 93 | ]; 94 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | paths([ 10 | __DIR__ . '/src', 11 | __DIR__ . '/tests', 12 | __DIR__ . '/config', 13 | ]); 14 | 15 | WorksomeEcsConfig::setup($ecsConfig); 16 | }; 17 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | paths([ 12 | __DIR__ . '/src', 13 | __DIR__ . '/tests', 14 | ]); 15 | 16 | // Define extra rule sets to be applied 17 | $rectorConfig->sets([ 18 | // SetList::DEAD_CODE, 19 | ]); 20 | 21 | // Register extra a single rules 22 | // $rectorConfig->rule(ClassOnObjectRector::class); 23 | }; 24 | -------------------------------------------------------------------------------- /src/Actions/ValidateCurrencyCodes.php: -------------------------------------------------------------------------------- 1 | currencyCodeProvider->all(); 20 | 21 | foreach ($currencyCodes as $currencyCode) { 22 | throw_unless( 23 | in_array($currencyCode, $supportedCodes), 24 | new InvalidCurrencyCodeException($currencyCode), 25 | ); 26 | } 27 | 28 | return $currencyCodes; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Commands/Concerns/HasUsefulConsoleMethods.php: -------------------------------------------------------------------------------- 1 | $message 20 | "); 21 | 22 | return $this; 23 | } 24 | 25 | private function warning(string $message): self 26 | { 27 | render(" 28 |
$message
29 | "); 30 | 31 | return $this; 32 | } 33 | 34 | private function failure(string $message): self 35 | { 36 | render(" 37 |
$message
38 | "); 39 | 40 | return $this; 41 | } 42 | 43 | private function information(string $message): self 44 | { 45 | render(" 46 |
$message
47 | "); 48 | 49 | return $this; 50 | } 51 | 52 | private function askUserToStarRepository(): void 53 | { 54 | render(' 55 | 58 | '); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Commands/InstallCommand.php: -------------------------------------------------------------------------------- 1 | call('vendor:publish', ['--tag' => 'exchange-config']); 21 | 22 | $this->information('Alright, Exchange is installed! Try it out with `php artisan exchange:rates`.'); 23 | 24 | return self::SUCCESS; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Commands/ViewLatestRatesCommand.php: -------------------------------------------------------------------------------- 1 | data($currencyCodeProvider); 30 | 31 | try { 32 | // @phpstan-ignore-next-line 33 | $this->renderRates($exchange->rates($data['base_currency'], $data['currencies'])); 34 | } catch (InvalidCurrencyCodeException $exception) { 35 | $this->newLine(); 36 | $this->failure($exception->getMessage()); 37 | $this->newLine(); 38 | 39 | return self::FAILURE; 40 | } 41 | 42 | $this->askUserToStarRepository(); 43 | 44 | return self::SUCCESS; 45 | } 46 | 47 | /** 48 | * @return array 49 | */ 50 | private function data(CurrencyCodeProvider $currencyCodeProvider): array 51 | { 52 | /** @var array $givenCurrencies */ 53 | $givenCurrencies = $this->argument('currencies'); 54 | 55 | return [ 56 | 'base_currency' => $this->argument('base-currency') ?? $this->ask( 57 | 'Which base currency do you want to use?' 58 | ), 59 | 'currencies' => count($givenCurrencies) > 0 ? $givenCurrencies : $this->choice( 60 | 'Which currencies do you want to fetch exchange rates for?', 61 | $currencyCodeProvider->all(), 62 | multiple: true, 63 | ), 64 | ]; 65 | } 66 | 67 | private function renderRates(Rates $rates): void 68 | { 69 | render(BladeCompiler::render(<<<'HTML' 70 |
71 |
72 | Exchange rates based on 1 {{ $baseCurrency }} 73 |
74 |
75 |
76 | Currency 77 | Exchange Rate 78 |
79 | @foreach ($rates as $currency => $amount) 80 |
81 | {{ $currency }} 82 | 83 | {{ $amount }} 84 |
85 | @endforeach 86 |
87 |
88 | HTML, ['baseCurrency' => $rates->getBaseCurrency(), 'rates' => $rates->getRates()])); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Contracts/Actions/ValidatesCurrencyCodes.php: -------------------------------------------------------------------------------- 1 | $currencyCodes 13 | * 14 | * @return non-empty-array 15 | * 16 | * @throws InvalidCurrencyCodeException 17 | */ 18 | public function __invoke(array $currencyCodes): array; 19 | } 20 | -------------------------------------------------------------------------------- /src/Contracts/CurrencyCodeProvider.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function all(): array; 13 | } 14 | -------------------------------------------------------------------------------- /src/Contracts/ExchangeRateProvider.php: -------------------------------------------------------------------------------- 1 | $currencies 13 | */ 14 | public function getRates(string $baseCurrency, array $currencies): Rates; 15 | } 16 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidConfigurationException.php: -------------------------------------------------------------------------------- 1 | $rates 23 | */ 24 | public function fake(array $rates = []): void 25 | { 26 | $this->exchangeRateProvider = (new FakeExchangeRateProvider())->defineRates($rates); 27 | } 28 | 29 | /** 30 | * @param non-empty-array $currencies 31 | * 32 | * @throws InvalidCurrencyCodeException 33 | */ 34 | public function rates(string $baseCurrency, array $currencies): Rates 35 | { 36 | ($this->validateCurrencyCodes)([$baseCurrency, ...$currencies]); 37 | 38 | return $this->exchangeRateProvider->getRates($baseCurrency, $currencies); 39 | } 40 | 41 | public function __call(string $name, array $arguments): mixed 42 | { 43 | return $this->exchangeRateProvider->$name(...$arguments); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/ExchangeRateProviders/CachedProvider.php: -------------------------------------------------------------------------------- 1 | cache->remember( 27 | "{$this->key}:{$baseCurrency}:{$currenciesForKey}", 28 | $this->ttl, 29 | fn () => $this->strategy->getRates($baseCurrency, $currencies), 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ExchangeRateProviders/CurrencyGEOProvider.php: -------------------------------------------------------------------------------- 1 | 1], 33 | now()->startOfDay(), 34 | ); 35 | } 36 | 37 | $data = $this->makeRequest($baseCurrency, $currencies); 38 | 39 | return new Rates( 40 | $baseCurrency, 41 | // @phpstan-ignore-next-line 42 | collect($data->get('rates'))->map(fn (mixed $value) => floatval($value['rate']))->all(), 43 | now()->startOfDay(), 44 | ); 45 | } 46 | 47 | /** 48 | * @param array $currencies 49 | * 50 | * @return Collection 51 | * 52 | * @throws RequestException 53 | */ 54 | private function makeRequest(string $baseCurrency, array $currencies): Collection 55 | { 56 | return $this->client() 57 | ->get('/currency/convert', [ 58 | 'api_key' => $this->accessKey, 59 | 'from' => $baseCurrency, 60 | 'to' => implode(',', $currencies), 61 | ]) 62 | ->throw() 63 | ->collect(); 64 | } 65 | 66 | private function client(): PendingRequest 67 | { 68 | return $this->client 69 | ->baseUrl($this->baseUrl) 70 | ->asJson() 71 | ->acceptJson(); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/ExchangeRateProviders/ExchangeRateHostProvider.php: -------------------------------------------------------------------------------- 1 | fixerProvider = new FixerProvider($this->client, $this->accessKey, 'https://api.exchangerate.host'); 18 | } 19 | 20 | public function getRates(string $baseCurrency, array $currencies): Rates 21 | { 22 | return $this->fixerProvider->getRates($baseCurrency, $currencies); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/ExchangeRateProviders/FixerProvider.php: -------------------------------------------------------------------------------- 1 | makeRequest($baseCurrency, $currencies); 30 | 31 | return new Rates( 32 | $baseCurrency, 33 | // @phpstan-ignore-next-line 34 | collect($data->get('rates'))->map(fn (mixed $value) => floatval($value))->all(), 35 | CarbonImmutable::createFromTimestamp(intval($data->get('timestamp'))) 36 | ); 37 | } 38 | 39 | /** 40 | * @param array $currencies 41 | * 42 | * @return Collection 43 | * 44 | * @throws RequestException 45 | */ 46 | private function makeRequest(string $baseCurrency, array $currencies): Collection 47 | { 48 | return $this->client() 49 | ->get('/latest', [ 50 | 'access_key' => $this->accessKey, 51 | 'base' => $baseCurrency, 52 | 'format' => 1, 53 | 'symbols' => implode(',', $currencies), 54 | ]) 55 | ->throw() 56 | ->collect(); 57 | } 58 | 59 | private function client(): PendingRequest 60 | { 61 | return $this->client 62 | ->baseUrl($this->baseUrl) 63 | ->asJson() 64 | ->acceptJson(); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/ExchangeRateProviders/FrankfurterProvider.php: -------------------------------------------------------------------------------- 1 | makeRequest($baseCurrency, $currencies); 30 | 31 | return new Rates( 32 | $baseCurrency, 33 | // @phpstan-ignore-next-line 34 | collect($data->get('rates'))->map(fn (mixed $value) => (float) $value)->all(), 35 | $this->getRetreivedAt($data), 36 | ); 37 | } 38 | 39 | /** 40 | * @param array $currencies 41 | * 42 | * @return Collection 43 | * 44 | * @throws RequestException 45 | */ 46 | private function makeRequest(string $baseCurrency, array $currencies): Collection 47 | { 48 | return $this->client() 49 | ->get('/latest', [ 50 | 'from' => $baseCurrency, 51 | 'to' => implode(',', $currencies), 52 | ]) 53 | ->throw() 54 | ->collect(); 55 | } 56 | 57 | private function client(): PendingRequest 58 | { 59 | return $this->client 60 | ->baseUrl($this->baseUrl) 61 | ->asJson() 62 | ->acceptJson(); 63 | } 64 | 65 | /** 66 | * @param Collection $data 67 | * 68 | * @return CarbonImmutable 69 | */ 70 | private function getRetreivedAt(Collection $data): CarbonImmutable 71 | { 72 | $date = $data->get('date'); 73 | 74 | if (! is_string($date)) { 75 | throw new InvalidArgumentException('The returned date could not be parsed.'); 76 | } 77 | 78 | $carbonInstance = CarbonImmutable::createFromFormat('Y-m-d', $date); 79 | 80 | if ($carbonInstance === false) { 81 | throw new InvalidArgumentException('The returned date could not be parsed.'); 82 | } 83 | 84 | return $carbonInstance->timezone('Europe/Amsterdam')->setTime(16, 0, 0); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/ExchangeRateProviders/NullProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton('exchange', Exchange::class); 25 | 26 | $this->app->bind( 27 | ExchangeRateProvider::class, 28 | fn (Application $app) => (new ExchangeRateManager($app))->driver() 29 | ); 30 | 31 | $this->app->bind(CurrencyCodeProvider::class, FlatCurrencyCodeProvider::class); 32 | $this->app->bind(ValidatesCurrencyCodes::class, ValidateCurrencyCodes::class); 33 | 34 | $this->extendAboutCommand(); 35 | } 36 | 37 | public function configurePackage(Package $package): void 38 | { 39 | $package 40 | ->name('exchange') 41 | ->hasConfigFile() 42 | ->hasCommands( 43 | InstallCommand::class, 44 | ViewLatestRatesCommand::class 45 | ); 46 | } 47 | 48 | private function extendAboutCommand(): void 49 | { 50 | if (! class_exists(AboutCommand::class)) { 51 | return; 52 | } 53 | 54 | if (! config('exchange.features.about_command', true)) { 55 | return; 56 | } 57 | 58 | AboutCommand::add('Exchange', [ 59 | 'Driver' => fn () => config('exchange.default'), 60 | ]); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Facades/Exchange.php: -------------------------------------------------------------------------------- 1 | $rates 20 | */ 21 | public static function fake(array $rates = []): void 22 | { 23 | /** 24 | * @var \Worksome\Exchange\Exchange $fake 25 | * 26 | * @phpstan-ignore-next-line 27 | */ 28 | $fake = self::$app->instance(\Worksome\Exchange\Exchange::class, self::getFacadeRoot()); 29 | 30 | $fake->fake($rates); 31 | } 32 | 33 | protected static function getFacadeAccessor(): string 34 | { 35 | return 'exchange'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Support/ExchangeRateManager.php: -------------------------------------------------------------------------------- 1 | config->get('exchange.default') ?? 'null'); 23 | } 24 | 25 | public function createNullDriver(): NullProvider 26 | { 27 | return new NullProvider(); 28 | } 29 | 30 | public function createFixerDriver(): FixerProvider 31 | { 32 | $apiKey = $this->config->get('exchange.services.fixer.access_key'); 33 | 34 | throw_unless(is_string($apiKey), new InvalidConfigurationException( 35 | 'You haven\'t set up an API key for Fixer!' 36 | )); 37 | 38 | return new FixerProvider( 39 | $this->container->make(Factory::class), 40 | $apiKey, 41 | ); 42 | } 43 | 44 | public function createExchangeRateDriver(): ExchangeRateHostProvider 45 | { 46 | $apiKey = $this->config->get('exchange.services.exchange_rate.access_key'); 47 | 48 | throw_unless(is_string($apiKey), new InvalidConfigurationException( 49 | 'You haven\'t set up an API key for ExchangeRate!' 50 | )); 51 | 52 | return new ExchangeRateHostProvider( 53 | $this->container->make(Factory::class), 54 | $apiKey, 55 | ); 56 | } 57 | 58 | public function createCurrencyGeoDriver(): CurrencyGEOProvider 59 | { 60 | $apiKey = $this->config->get('exchange.services.currency_geo.access_key'); 61 | 62 | throw_unless(is_string($apiKey), new InvalidConfigurationException( 63 | 'You haven\'t set up an API key for CurrencyGEO!' 64 | )); 65 | 66 | return new CurrencyGEOProvider( 67 | $this->container->make(Factory::class), 68 | $apiKey, 69 | ); 70 | } 71 | 72 | public function createFrankfurterDriver(): FrankfurterProvider 73 | { 74 | return new FrankfurterProvider( 75 | $this->container->make(Factory::class), 76 | ); 77 | } 78 | 79 | public function createCacheDriver(): CachedProvider 80 | { 81 | /** @var CacheFactory $factory */ 82 | $factory = $this->container->make(CacheFactory::class); 83 | 84 | return new CachedProvider( 85 | // @phpstan-ignore-next-line 86 | $factory->store($this->config->get('exchange.services.cache.store')), 87 | // @phpstan-ignore-next-line 88 | $this->driver($this->config->get('exchange.services.cache.strategy')), 89 | strval($this->config->get('exchange.services.cache.key', 'cached_exchange_rates')), 90 | intval($this->config->get('exchange.services.cache.ttl', 60 * 60 * 24)), 91 | ); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Support/FlatCurrencyCodeProvider.php: -------------------------------------------------------------------------------- 1 | $rates 13 | */ 14 | public function __construct( 15 | private string $baseCurrency, 16 | private array $rates, 17 | private CarbonInterface $retrievedAt, 18 | ) { 19 | } 20 | 21 | public function getBaseCurrency(): string 22 | { 23 | return $this->baseCurrency; 24 | } 25 | 26 | /** 27 | * @return non-empty-array 28 | */ 29 | public function getRates(): array 30 | { 31 | return $this->rates; 32 | } 33 | 34 | public function getRetrievedAt(): CarbonInterface 35 | { 36 | return $this->retrievedAt; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Testing/FakeExchangeRateProvider.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | private array $predefinedRates = []; 19 | 20 | public function getRates(string $baseCurrency, array $currencies): Rates 21 | { 22 | $this->ratesRetrieved++; 23 | 24 | return new Rates($baseCurrency, $this->getRatesArray($currencies), now()); 25 | } 26 | 27 | /** 28 | * @param array $rates 29 | */ 30 | public function defineRates(array $rates): self 31 | { 32 | $this->predefinedRates = array_merge($this->predefinedRates, $rates); 33 | 34 | return $this; 35 | } 36 | 37 | public function assertRetrievedRates(int $times = 1): self 38 | { 39 | Assert::assertSame( 40 | $this->ratesRetrieved, 41 | $times, 42 | "Expected to have retrieved rates {$times} times but they were retrieved {$this->ratesRetrieved} times." 43 | ); 44 | 45 | return $this; 46 | } 47 | 48 | /** 49 | * @param non-empty-array $currencies 50 | * 51 | * @return non-empty-array 52 | */ 53 | private function getRatesArray(array $currencies): array 54 | { 55 | return array_merge(array_fill_keys($currencies, 1.0), $this->predefinedRates); 56 | } 57 | } 58 | --------------------------------------------------------------------------------