diff --git a/.env.example b/.env.example
index c0660ea..4f121b3 100644
--- a/.env.example
+++ b/.env.example
@@ -63,3 +63,13 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
+
+CURRENCY_PROVIDER=nbu
+CURRENCY_NBU_API_URL=https://bank.gov.ua/NBUStatService/v1/statdirectory/exchange
+CURRENCY_FALLBACK_PROVIDER=open_er_api
+CURRENCY_OPEN_ER_API_URL=https://open.er-api.com/v6/latest/UAH
+
+CURRENCY_CACHE_TTL_MINUTES=10
+CURRENCY_TIMEOUT_SECONDS=5
+
+DUMMYJSON_BASE_URL=https://dummyjson.com
diff --git a/README.md b/README.md
index 2606a8e..6b1465c 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,13 @@
# Smartphone Catalog API
-A Laravel 12 backend application that serves a smartphone product catalog with real-time currency conversion (USD to UAH) using the National Bank of Ukraine (NBU) exchange rates.
+A Laravel 12 backend application that serves a smartphone product catalog with real-time currency conversion (USD, UAH, EUR) using exchange-rate providers (NBU as primary, Open ER API as fallback) with caching.
## Features
- Browse smartphone catalog sourced from [DummyJSON](https://dummyjson.com/)
-- View prices in USD (original) or UAH (converted via NBU rates)
-- Retrieve the current USD/UAH exchange rate from the NBU API
-- Modular domain-driven architecture for the Currency module
+- View prices in USD (original) or UAH/EUR (converted via exchange-rate providers)
+- Retrieve the current USD/UAH and EUR/UAH exchange rates
+- Modular domain-driven architecture for the Currency module with pluggable providers
## Tech Stack
@@ -24,7 +24,7 @@ Returns a list of smartphones with prices in the requested currency.
| Parameter | Type | Allowed Values | Description |
|------------|--------|----------------|--------------------------------------|
-| `currency` | string | `usd`, `uah` | Currency for product prices |
+| `currency` | string | `usd`, `uah`, `eur` | Currency for product prices |
**Response (200):**
@@ -44,9 +44,9 @@ Returns a list of smartphones with prices in the requested currency.
Unsupported currencies return **404**.
-### `GET /api/exchangeRate/USD`
+### `GET /api/exchangeRate/{currency}`
-Returns the current USD to UAH exchange rate from the NBU.
+Returns the current exchange rate of the given currency to UAH (currently supports `USD` and `EUR`).
**Response (200):**
@@ -66,29 +66,39 @@ Returns the current USD to UAH exchange rate from the NBU.
app/
├── Http/
│ ├── Controllers/
-│ │ └── CatalogController.php # Smartphone catalog endpoint
+│ │ └── CatalogController.php # Smartphone catalog endpoint
│ └── Requests/
-│ └── CatalogRequest.php # Validates currency parameter
+│ └── CatalogRequest.php # Validates currency parameter
+├── Jobs/
+│ └── TrackCatalogCurrencyUsage.php # Tracks catalog usage in background
├── Models/
-│ └── User.php
+│ └── CatalogCurrencyUsage.php # Catalog currency usage entries
└── Modules/
└── Currency/
├── Application/
│ ├── Facades/
- │ │ └── CurrencyExchangeFacade.php # Entry point for currency operations
+ │ │ └── CurrencyExchangeFacade.php # Entry point for currency operations
│ └── Http/
│ ├── Controllers/
│ │ └── ExchangeRateController.php
│ └── Requests/
│ └── ExchangeRateRequest.php
├── Domain/
- │ ├── ConvertedPrice.php # DTO for converted price data
- │ ├── CurrencyExchangeService.php # Core exchange logic
- │ └── CurrencyRate.php # DTO for rate data
+ │ ├── Contracts/
+ │ │ └── ExchangeRateProviderInterface.php # Abstraction for rate providers
+ │ ├── Enums/
+ │ │ └── CurrencyCode.php # Supported currency codes
+ │ ├── ConvertedPrice.php # DTO for converted price data
+ │ ├── CurrencyExchangeService.php # Core exchange logic
+ │ └── CurrencyRate.php # DTO for rate data
├── Infrastructure/
- │ └── NbuApiCurrencyRepository.php # NBU API integration
+ │ └── Providers/
+ │ ├── NbuExchangeRateProvider.php # NBU API integration (primary)
+ │ ├── OpenErApiExchangeRateProvider.php # Open ER API integration (fallback)
+ │ ├── FailoverExchangeRateProvider.php # Primary + fallback composition
+ │ └── CachedExchangeRateProvider.php # Caching decorator
└── Providers/
- └── CurrencyServiceProvider.php # DI bindings
+ └── CurrencyServiceProvider.php # DI bindings
```
## Getting Started
@@ -145,13 +155,15 @@ Tests use `Http::fake()` to mock external API calls (DummyJSON and NBU), so no n
### Test Coverage
-- **Catalog endpoint** — USD/UAH responses, price conversion, currency validation, HTTP method restrictions
-- **Exchange rate endpoint** — JSON structure, rate values, HTTP method restrictions
-- Both endpoints verified to not require authentication
+- **Catalog endpoint** — USD/UAH/EUR responses, price conversion, currency validation, HTTP method restrictions
+- **Exchange rate endpoint** — USD/EUR JSON structure, rate values, HTTP method restrictions
+- **Currency providers** — conversion logic, failover and caching behavior
+- **Catalog usage tracking** — job execution and database records
## External APIs
| API | Purpose | URL |
|-----|---------|-----|
| DummyJSON | Smartphone product data | `https://dummyjson.com/products/category/smartphones` |
-| NBU | USD/UAH exchange rate | `https://bank.gov.ua/NBUStatService/v1/statdirectory/exchange` |
+| NBU | Exchange rates (primary) | `https://bank.gov.ua/NBUStatService/v1/statdirectory/exchange` |
+| Open ER API | Exchange rates (fallback) | `https://open.er-api.com/v6/latest/UAH` |
diff --git a/app/Actions/GetCatalogAction.php b/app/Actions/GetCatalogAction.php
new file mode 100644
index 0000000..81188cc
--- /dev/null
+++ b/app/Actions/GetCatalogAction.php
@@ -0,0 +1,43 @@
+value, now()->toIso8601String());
+
+ $products = $this->catalogClient->getSmartphones(self::DEFAULT_LIMIT);
+
+ return collect($products)
+ ->map(function (array $product) use ($currencyCode) {
+ $convertedPrice = $this->currencyExchange->convertFromUsdTo(
+ $currencyCode,
+ (float) $product['price'],
+ );
+
+ return [
+ 'id' => $product['id'],
+ 'title' => $product['title'],
+ 'price' => $convertedPrice->convertedPrice,
+ 'rating' => $product['rating'],
+ 'thumbnail' => $product['thumbnail'],
+ ];
+ })
+ ->values()
+ ->all();
+ }
+}
diff --git a/app/Http/Controllers/CatalogController.php b/app/Http/Controllers/CatalogController.php
index 57fb8a2..1cc72fe 100644
--- a/app/Http/Controllers/CatalogController.php
+++ b/app/Http/Controllers/CatalogController.php
@@ -2,41 +2,25 @@
namespace App\Http\Controllers;
+use App\Actions\GetCatalogAction;
use App\Http\Requests\CatalogRequest;
use Illuminate\Http\JsonResponse;
-use Illuminate\Support\Facades\Http;
-use Modules\Currency\Application\Facades\CurrencyExchangeFacade;
+use Modules\Currency\Domain\Enums\CurrencyCode;
class CatalogController extends Controller
{
public function __construct(
- private readonly CurrencyExchangeFacade $currencyExchange,
+ private readonly GetCatalogAction $getCatalogAction,
) {}
public function __invoke(CatalogRequest $request): JsonResponse
{
- $currency = $request->route('currency');
- $response = Http::get('https://dummyjson.com/products/category/smartphones', [
- 'limit' => 5,
- ]);
-
- $products = collect($response->json('products'))->map(function (array $product) use ($currency) {
- $price = $product['price'];
+ $currencyCode = CurrencyCode::from(strtoupper($request->route('currency')));
- if (strtoupper($currency) === 'UAH') {
- $converted = $this->currencyExchange->convertFromUsdToUah((int) $price);
- $price = $converted->convertedPrice;
- }
+ $products = $this->getCatalogAction->handle($currencyCode);
- return [
- 'id' => $product['id'],
- 'title' => $product['title'],
- 'price' => $price,
- 'rating' => $product['rating'],
- 'thumbnail' => $product['thumbnail'],
- ];
- });
-
- return response()->json(['data' => $products]);
+ return response()->json([
+ 'data' => $products,
+ ]);
}
}
diff --git a/app/Http/Requests/CatalogRequest.php b/app/Http/Requests/CatalogRequest.php
index f3ee2d8..fa80e9f 100644
--- a/app/Http/Requests/CatalogRequest.php
+++ b/app/Http/Requests/CatalogRequest.php
@@ -5,6 +5,7 @@
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
+use Modules\Currency\Domain\Enums\CurrencyCode;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CatalogRequest extends FormRequest
@@ -12,7 +13,7 @@ class CatalogRequest extends FormRequest
public function rules(): array
{
return [
- 'currency' => ['required', 'string', Rule::in(['uah', 'usd'])],
+ 'currency' => ['required', 'string', Rule::in(CurrencyCode::catalogSupported())],
];
}
diff --git a/app/Jobs/TrackCatalogCurrencyUsage.php b/app/Jobs/TrackCatalogCurrencyUsage.php
new file mode 100644
index 0000000..26db7ac
--- /dev/null
+++ b/app/Jobs/TrackCatalogCurrencyUsage.php
@@ -0,0 +1,29 @@
+ $this->currency,
+ 'requested_at' => CarbonImmutable::parse($this->requestedAt),
+ ]);
+ }
+}
diff --git a/app/Models/CatalogCurrencyUsage.php b/app/Models/CatalogCurrencyUsage.php
new file mode 100644
index 0000000..7c713bd
--- /dev/null
+++ b/app/Models/CatalogCurrencyUsage.php
@@ -0,0 +1,22 @@
+ 'datetime',
+ ];
+}
diff --git a/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php b/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php
index 2e62b92..17b6931 100644
--- a/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php
+++ b/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php
@@ -5,33 +5,21 @@
use Modules\Currency\Domain\ConvertedPrice;
use Modules\Currency\Domain\CurrencyExchangeService;
use Modules\Currency\Domain\CurrencyRate;
+use Modules\Currency\Domain\Enums\CurrencyCode;
class CurrencyExchangeFacade
{
- private const string USD_CODE = 'USD';
- private const string UAH_CODE = 'UAH';
-
public function __construct(
private readonly CurrencyExchangeService $service,
) {}
- public function getUsdRate(): CurrencyRate
+ public function getRate(CurrencyCode $currency): CurrencyRate
{
- return $this->service->findUsdRate();
+ return $this->service->getRate($currency);
}
- public function convertFromUsdToUah(int $price): ConvertedPrice
+ public function convertFromUsdTo(CurrencyCode $currency, float $price): ConvertedPrice
{
- $usdRate = $this->service->findUsdRate();
-
- $convertedPrice = $price * $usdRate->rate;
-
- return new ConvertedPrice(
- originalPrice: $price,
- convertedPrice: round($convertedPrice, 2),
- fromCurrency: self::USD_CODE,
- toCurrency: self::UAH_CODE,
- rate: $usdRate->rate,
- );
+ return $this->service->convertUsdTo($currency, $price);
}
}
diff --git a/app/Modules/Currency/Application/Http/Controllers/ExchangeRateController.php b/app/Modules/Currency/Application/Http/Controllers/ExchangeRateController.php
index 3865b97..765c6f2 100644
--- a/app/Modules/Currency/Application/Http/Controllers/ExchangeRateController.php
+++ b/app/Modules/Currency/Application/Http/Controllers/ExchangeRateController.php
@@ -6,6 +6,7 @@
use Illuminate\Http\JsonResponse;
use Modules\Currency\Application\Facades\CurrencyExchangeFacade;
use Modules\Currency\Application\Http\Requests\ExchangeRateRequest;
+use Modules\Currency\Domain\Enums\CurrencyCode;
class ExchangeRateController extends Controller
{
@@ -15,7 +16,9 @@ public function __construct(
public function __invoke(ExchangeRateRequest $request): JsonResponse
{
- $rate = $this->currencyExchange->getUsdRate();
+ $currencyCode = CurrencyCode::from(strtoupper($request->route('currency')));
+
+ $rate = $this->currencyExchange->getRate($currencyCode);
return response()->json([
'data' => [
diff --git a/app/Modules/Currency/Application/Http/Requests/ExchangeRateRequest.php b/app/Modules/Currency/Application/Http/Requests/ExchangeRateRequest.php
index b9fbeaa..998cdf4 100644
--- a/app/Modules/Currency/Application/Http/Requests/ExchangeRateRequest.php
+++ b/app/Modules/Currency/Application/Http/Requests/ExchangeRateRequest.php
@@ -3,11 +3,22 @@
namespace Modules\Currency\Application\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+use Modules\Currency\Domain\Enums\CurrencyCode;
class ExchangeRateRequest extends FormRequest
{
public function rules(): array
{
- return [];
+ return [
+ 'currency' => ['required', 'string', Rule::in(CurrencyCode::exchangeRateSupported())],
+ ];
+ }
+
+ public function validationData(): array
+ {
+ return array_merge(parent::validationData(), [
+ 'currency' => strtolower($this->route('currency')),
+ ]);
}
}
diff --git a/app/Modules/Currency/Domain/Contracts/ExchangeRateProviderInterface.php b/app/Modules/Currency/Domain/Contracts/ExchangeRateProviderInterface.php
new file mode 100644
index 0000000..5c813d3
--- /dev/null
+++ b/app/Modules/Currency/Domain/Contracts/ExchangeRateProviderInterface.php
@@ -0,0 +1,15 @@
+repository->getAll();
+ return $this->provider->getAll();
+ }
+
+ public function getRate(CurrencyCode $currency): CurrencyRate
+ {
+ return $this->provider->getRate($currency->value);
}
- public function findUsdRate(): CurrencyRate
+ public function convertUsdTo(CurrencyCode $target, float $usdPrice): ConvertedPrice
{
- foreach ($this->repository->getAll() as $currency) {
- if ($currency->currencyCode === self::USD_CODE) {
- return $currency;
- }
+ if ($target === CurrencyCode::USD) {
+ return new ConvertedPrice(
+ originalPrice: $usdPrice,
+ convertedPrice: round($usdPrice, 2),
+ fromCurrency: CurrencyCode::USD->value,
+ toCurrency: CurrencyCode::USD->value,
+ rate: 1.0,
+ );
}
- throw new \RuntimeException('USD rate not found in exchange rates');
+ $usdToUahRate = $this->getRate(CurrencyCode::USD)->rate;
+ $uahPrice = $usdPrice * $usdToUahRate;
+
+ $targetToUahRate = $target === CurrencyCode::UAH
+ ? 1.0
+ : $this->getRate($target)->rate;
+
+ $convertedPrice = $uahPrice / $targetToUahRate;
+ $effectiveRate = $usdToUahRate / $targetToUahRate;
+
+ return new ConvertedPrice(
+ originalPrice: $usdPrice,
+ convertedPrice: round($convertedPrice, 2),
+ fromCurrency: CurrencyCode::USD->value,
+ toCurrency: $target->value,
+ rate: $effectiveRate,
+ );
}
}
diff --git a/app/Modules/Currency/Domain/Enums/CurrencyCode.php b/app/Modules/Currency/Domain/Enums/CurrencyCode.php
new file mode 100644
index 0000000..fdc0944
--- /dev/null
+++ b/app/Modules/Currency/Domain/Enums/CurrencyCode.php
@@ -0,0 +1,27 @@
+value),
+ strtolower(self::UAH->value),
+ strtolower(self::EUR->value),
+ ];
+ }
+
+ public static function exchangeRateSupported(): array
+ {
+ return [
+ strtolower(self::USD->value),
+ strtolower(self::EUR->value),
+ ];
+ }
+}
diff --git a/app/Modules/Currency/Domain/Exceptions/ExchangeRateProviderException.php b/app/Modules/Currency/Domain/Exceptions/ExchangeRateProviderException.php
new file mode 100644
index 0000000..b57a2bc
--- /dev/null
+++ b/app/Modules/Currency/Domain/Exceptions/ExchangeRateProviderException.php
@@ -0,0 +1,9 @@
+ '']);
-
- return array_map(
- fn (array $item) => new CurrencyRate(
- name: $item['txt'],
- rate: (float) $item['rate'],
- currencyCode: $item['cc'],
- exchangeDate: $item['exchangedate'],
- ),
- $response->json(),
- );
- }
-}
diff --git a/app/Modules/Currency/Infrastructure/Providers/CachedExchangeRateProvider.php b/app/Modules/Currency/Infrastructure/Providers/CachedExchangeRateProvider.php
new file mode 100644
index 0000000..06e4dc1
--- /dev/null
+++ b/app/Modules/Currency/Infrastructure/Providers/CachedExchangeRateProvider.php
@@ -0,0 +1,45 @@
+cacheKey();
+ $ttlMinutes = (int) config('currency.cache_ttl_minutes', 10);
+
+ return Cache::remember(
+ $cacheKey,
+ now()->addMinutes($ttlMinutes),
+ fn () => $this->inner->getAll(),
+ );
+ }
+
+ public function getRate(string $currencyCode): CurrencyRate
+ {
+ foreach ($this->getAll() as $currencyRate) {
+ if ($currencyRate->currencyCode === $currencyCode) {
+ return $currencyRate;
+ }
+ }
+
+ throw new \RuntimeException(sprintf('Exchange rate for %s not found in cached rates', $currencyCode));
+ }
+
+ private function cacheKey(): string
+ {
+ return sprintf('currency:rates:%s', now()->format('Y-m-d'));
+ }
+}
diff --git a/app/Modules/Currency/Infrastructure/Providers/FailoverExchangeRateProvider.php b/app/Modules/Currency/Infrastructure/Providers/FailoverExchangeRateProvider.php
new file mode 100644
index 0000000..933dec8
--- /dev/null
+++ b/app/Modules/Currency/Infrastructure/Providers/FailoverExchangeRateProvider.php
@@ -0,0 +1,36 @@
+primary->getAll();
+ } catch (ExchangeRateProviderException) {
+ return $this->fallback->getAll();
+ }
+ }
+
+ public function getRate(string $currencyCode): CurrencyRate
+ {
+ try {
+ return $this->primary->getRate($currencyCode);
+ } catch (ExchangeRateProviderException) {
+ return $this->fallback->getRate($currencyCode);
+ }
+ }
+}
diff --git a/app/Modules/Currency/Infrastructure/Providers/NbuExchangeRateProvider.php b/app/Modules/Currency/Infrastructure/Providers/NbuExchangeRateProvider.php
new file mode 100644
index 0000000..0af9f9e
--- /dev/null
+++ b/app/Modules/Currency/Infrastructure/Providers/NbuExchangeRateProvider.php
@@ -0,0 +1,70 @@
+get(config('currency.api_urls.nbu'), ['json' => ''])
+ ->throw();
+ } catch (\Throwable $exception) {
+ throw new ExchangeRateProviderException(
+ 'Failed to fetch exchange rates from NBU provider.',
+ previous: $exception,
+ );
+ }
+
+ $data = $response->json();
+
+ if (! is_array($data)) {
+ throw new ExchangeRateProviderException(
+ 'Invalid response from NBU exchange API.'
+ );
+ }
+
+ return array_map(
+ fn (array $item): CurrencyRate => $this->mapRate($item),
+ $data
+ );
+ }
+
+ public function getRate(string $currencyCode): CurrencyRate
+ {
+ foreach ($this->getAll() as $currencyRate) {
+ if ($currencyRate->currencyCode === $currencyCode) {
+ return $currencyRate;
+ }
+ }
+
+ throw new ExchangeRateProviderException(
+ sprintf('Exchange rate for %s not found in NBU rates.', $currencyCode)
+ );
+ }
+
+ private function mapRate(array $item): CurrencyRate
+ {
+ if (! isset($item['txt'], $item['rate'], $item['cc'], $item['exchangedate'])) {
+ throw new ExchangeRateProviderException(
+ 'Incomplete currency item received from NBU exchange API.'
+ );
+ }
+
+ return new CurrencyRate(
+ name: $item['txt'],
+ rate: (float) $item['rate'],
+ currencyCode: $item['cc'],
+ exchangeDate: $item['exchangedate'],
+ );
+ }
+}
diff --git a/app/Modules/Currency/Infrastructure/Providers/OpenErApiExchangeRateProvider.php b/app/Modules/Currency/Infrastructure/Providers/OpenErApiExchangeRateProvider.php
new file mode 100644
index 0000000..3ae2a73
--- /dev/null
+++ b/app/Modules/Currency/Infrastructure/Providers/OpenErApiExchangeRateProvider.php
@@ -0,0 +1,88 @@
+get(config('currency.api_urls.open_er_api'))
+ ->throw();
+ } catch (\Throwable $exception) {
+ throw new ExchangeRateProviderException(
+ 'Failed to fetch exchange rates from Open ER API provider.',
+ previous: $exception,
+ );
+ }
+
+ $data = $response->json();
+
+ if (! is_array($data)) {
+ throw new ExchangeRateProviderException(
+ 'Invalid response from Open ER API.'
+ );
+ }
+
+ if (($data['result'] ?? null) !== 'success') {
+ throw new ExchangeRateProviderException(
+ 'Open ER API returned unsuccessful result.'
+ );
+ }
+
+ $rates = $data['rates'] ?? null;
+
+ if (! is_array($rates)) {
+ throw new ExchangeRateProviderException(
+ 'Invalid rates structure from Open ER API.'
+ );
+ }
+
+ $date = $data['time_last_update_utc'] ?? null;
+
+ $exchangeDate = $date
+ ? date('d.m.Y', strtotime($date))
+ : now()->format('d.m.Y');
+
+ $currencies = [];
+
+ foreach ($rates as $code => $rate) {
+ if (! is_string($code) || ! is_numeric($rate) || (float) $rate <= 0) {
+ continue;
+ }
+
+ // API base is UAH, so rates are returned as "target currency per 1 UAH".
+ // We invert them to normalize all providers to "1 unit of currency in UAH".
+ $currencies[] = new CurrencyRate(
+ name: $code,
+ rate: 1 / (float) $rate,
+ currencyCode: $code,
+ exchangeDate: $exchangeDate,
+ );
+ }
+
+ return $currencies;
+ }
+
+ public function getRate(string $currencyCode): CurrencyRate
+ {
+ foreach ($this->getAll() as $currencyRate) {
+ if ($currencyRate->currencyCode === $currencyCode) {
+ return $currencyRate;
+ }
+ }
+
+ throw new ExchangeRateProviderException(
+ sprintf('Exchange rate for %s not found in Open ER API rates.', $currencyCode)
+ );
+ }
+}
diff --git a/app/Modules/Currency/Providers/CurrencyServiceProvider.php b/app/Modules/Currency/Providers/CurrencyServiceProvider.php
index cd7ad9f..5232cef 100644
--- a/app/Modules/Currency/Providers/CurrencyServiceProvider.php
+++ b/app/Modules/Currency/Providers/CurrencyServiceProvider.php
@@ -2,25 +2,75 @@
namespace Modules\Currency\Providers;
+use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
-use Modules\Currency\Domain\CurrencyExchangeService;
use Modules\Currency\Application\Facades\CurrencyExchangeFacade;
-use Modules\Currency\Infrastructure\NbuApiCurrencyRepository;
+use Modules\Currency\Domain\Contracts\ExchangeRateProviderInterface;
+use Modules\Currency\Domain\CurrencyExchangeService;
+use Modules\Currency\Infrastructure\Providers\CachedExchangeRateProvider;
+use Modules\Currency\Infrastructure\Providers\FailoverExchangeRateProvider;
class CurrencyServiceProvider extends ServiceProvider
{
public function register(): void
{
- $this->app->singleton(CurrencyExchangeService::class, function ($app) {
+ $this->app->singleton(ExchangeRateProviderInterface::class, function (Application $app) {
+ $primaryProvider = $this->resolveProvider(
+ $app,
+ config('currency.default')
+ );
+
+ $fallbackProvider = $this->resolveProvider(
+ $app,
+ config('currency.fallback')
+ );
+
+ $failoverProvider = new FailoverExchangeRateProvider(
+ $primaryProvider,
+ $fallbackProvider,
+ );
+
+ return new CachedExchangeRateProvider($failoverProvider);
+ });
+
+ $this->app->singleton(CurrencyExchangeService::class, function (Application $app) {
return new CurrencyExchangeService(
- $app->make(NbuApiCurrencyRepository::class),
+ $app->make(ExchangeRateProviderInterface::class),
);
});
- $this->app->singleton(CurrencyExchangeFacade::class, function ($app) {
+ $this->app->singleton(CurrencyExchangeFacade::class, function (Application $app) {
return new CurrencyExchangeFacade(
$app->make(CurrencyExchangeService::class),
);
});
}
+
+ private function resolveProvider(
+ Application $app,
+ ?string $providerName,
+ ): ExchangeRateProviderInterface {
+ $providers = config('currency.providers', []);
+ $providerClass = $providers[$providerName] ?? null;
+
+ if (! is_string($providerClass) || $providerClass === '') {
+ throw new \RuntimeException(
+ sprintf('Currency provider [%s] is not configured.', $providerName)
+ );
+ }
+
+ $provider = $app->make($providerClass);
+
+ if (! $provider instanceof ExchangeRateProviderInterface) {
+ throw new \RuntimeException(
+ sprintf(
+ 'Currency provider [%s] must implement %s.',
+ $providerName,
+ ExchangeRateProviderInterface::class,
+ )
+ );
+ }
+
+ return $provider;
+ }
}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index ee65e59..23d75fa 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -3,6 +3,7 @@
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
+use Modules\Currency\Providers\CurrencyServiceProvider;
class AppServiceProvider extends ServiceProvider
{
@@ -11,7 +12,7 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
- $this->app->register(\Modules\Currency\Providers\CurrencyServiceProvider::class);
+ $this->app->register(CurrencyServiceProvider::class);
}
/**
diff --git a/app/Services/Catalog/SmartphoneCatalogClient.php b/app/Services/Catalog/SmartphoneCatalogClient.php
new file mode 100644
index 0000000..ba8f5bd
--- /dev/null
+++ b/app/Services/Catalog/SmartphoneCatalogClient.php
@@ -0,0 +1,27 @@
+timeout((int) config('services.dummyjson.timeout_seconds', 5))
+ ->get('/products/category/smartphones', [
+ 'limit' => $limit,
+ ])
+ ->throw();
+
+ $products = $response->json('products');
+
+ if (! is_array($products)) {
+ throw new RuntimeException('Invalid smartphones catalog response.');
+ }
+
+ return $products;
+ }
+}
diff --git a/config/currency.php b/config/currency.php
new file mode 100644
index 0000000..7462b07
--- /dev/null
+++ b/config/currency.php
@@ -0,0 +1,26 @@
+ env('CURRENCY_PROVIDER', 'nbu'),
+ 'fallback' => env('CURRENCY_FALLBACK_PROVIDER', 'open_er_api'),
+
+ 'providers' => [
+ 'nbu' => \Modules\Currency\Infrastructure\Providers\NbuExchangeRateProvider::class,
+ 'open_er_api' => \Modules\Currency\Infrastructure\Providers\OpenErApiExchangeRateProvider::class,
+ ],
+
+ 'api_urls' => [
+ 'nbu' => env(
+ 'CURRENCY_NBU_API_URL',
+ 'https://bank.gov.ua/NBUStatService/v1/statdirectory/exchange'
+ ),
+
+ 'open_er_api' => env(
+ 'CURRENCY_OPEN_ER_API_URL',
+ 'https://open.er-api.com/v6/latest/UAH'
+ ),
+ ],
+
+ 'cache_ttl_minutes' => env('CURRENCY_CACHE_TTL_MINUTES', 10),
+ 'timeout_seconds' => env('CURRENCY_TIMEOUT_SECONDS', 5),
+];
diff --git a/config/services.php b/config/services.php
index 6a90eb8..2284cbc 100644
--- a/config/services.php
+++ b/config/services.php
@@ -35,4 +35,8 @@
],
],
+ 'dummyjson' => [
+ 'base_url' => env('DUMMYJSON_BASE_URL', 'https://dummyjson.com'),
+ 'timeout_seconds' => env('DUMMYJSON_TIMEOUT_SECONDS', 5),
+ ],
];
diff --git a/database/migrations/2026_03_11_000000_create_catalog_currency_usages_table.php b/database/migrations/2026_03_11_000000_create_catalog_currency_usages_table.php
new file mode 100644
index 0000000..199ccc7
--- /dev/null
+++ b/database/migrations/2026_03_11_000000_create_catalog_currency_usages_table.php
@@ -0,0 +1,24 @@
+id();
+ $table->string('currency');
+ $table->timestamp('requested_at');
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('catalog_currency_usages');
+ }
+};
+
diff --git a/phpunit.xml b/phpunit.xml
index bbfcf81..d703241 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -23,7 +23,8 @@
-
+
+
diff --git a/routes/api.php b/routes/api.php
index 3dee5ce..e3a54b7 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -5,4 +5,4 @@
use Modules\Currency\Application\Http\Controllers\ExchangeRateController;
Route::get('/catalog/{currency}', CatalogController::class);
-Route::get('/exchangeRate/USD', ExchangeRateController::class);
+Route::get('/exchangeRate/{currency}', ExchangeRateController::class);
diff --git a/tests/Feature/CatalogEurTest.php b/tests/Feature/CatalogEurTest.php
new file mode 100644
index 0000000..56a5ee1
--- /dev/null
+++ b/tests/Feature/CatalogEurTest.php
@@ -0,0 +1,54 @@
+ Http::response([
+ 'products' => [
+ [
+ 'id' => 1,
+ 'title' => 'iPhone 5s',
+ 'description' => 'A classic smartphone.',
+ 'category' => 'smartphones',
+ 'price' => 200.0,
+ 'rating' => 2.83,
+ 'brand' => 'Apple',
+ 'thumbnail' => 'https://cdn.dummyjson.com/product-images/smartphones/iphone-5s/thumbnail.webp',
+ ],
+ ],
+ 'total' => 1,
+ 'skip' => 0,
+ 'limit' => 5,
+ ], 200),
+
+ 'https://bank.gov.ua/*' => Http::response([
+ ['r030' => 840, 'txt' => 'Долар США', 'rate' => 40.0, 'cc' => 'USD', 'exchangedate' => '02.03.2026'],
+ ['r030' => 978, 'txt' => 'Євро', 'rate' => 50.0, 'cc' => 'EUR', 'exchangedate' => '02.03.2026'],
+ ], 200),
+ ]);
+
+ $response = $this->getJson('/api/catalog/eur');
+
+ $response->assertOk()
+ ->assertJsonCount(1, 'data')
+ ->assertJsonPath('data.0.id', 1)
+ ->assertJsonPath('data.0.title', 'iPhone 5s');
+
+ $this->assertEquals(160.0, $response->json('data.0.price'));
+
+ Queue::assertPushed(TrackCatalogCurrencyUsage::class, function (TrackCatalogCurrencyUsage $job) {
+ return $job->currency === 'EUR';
+ });
+ }
+}
diff --git a/tests/Feature/CatalogTest.php b/tests/Feature/CatalogTest.php
index 80ba085..3f380cf 100644
--- a/tests/Feature/CatalogTest.php
+++ b/tests/Feature/CatalogTest.php
@@ -3,10 +3,18 @@
namespace Tests\Feature;
use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class CatalogTest extends TestCase
{
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ Queue::fake();
+ }
+
private function fakeApis(float $usdRate = 43.0996): void
{
Http::fake([
@@ -77,8 +85,8 @@ public function test_catalog_uah_returns_converted_price(): void
$response = $this->getJson('/api/catalog/uah');
$data = $response->json('data');
- $this->assertEquals(199 * 40.0, $data[0]['price']);
- $this->assertEquals(299 * 40.0, $data[1]['price']);
+ $this->assertEquals(round(199.99 * 40.0, 2), $data[0]['price']);
+ $this->assertEquals(round(299.99 * 40.0, 2), $data[1]['price']);
}
public function test_catalog_endpoint_does_not_require_authentication(): void
@@ -91,13 +99,6 @@ public function test_catalog_endpoint_does_not_require_authentication(): void
->assertJsonStructure(['data']);
}
- public function test_catalog_eur_returns_not_found(): void
- {
- $response = $this->getJson('/api/catalog/EUR');
-
- $response->assertStatus(404);
- }
-
public function test_catalog_rejects_unsupported_currency(): void
{
$response = $this->getJson('/api/catalog/GBP');
diff --git a/tests/Feature/CatalogUsageTest.php b/tests/Feature/CatalogUsageTest.php
new file mode 100644
index 0000000..3e03608
--- /dev/null
+++ b/tests/Feature/CatalogUsageTest.php
@@ -0,0 +1,41 @@
+ Http::response([
+ 'products' => [
+ [
+ 'id' => 1,
+ 'title' => 'iPhone 13',
+ 'price' => 1000,
+ 'rating' => 4.5,
+ 'thumbnail' => 'https://example.com/iphone.jpg',
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $response = $this->getJson('/api/catalog/usd');
+
+ $response->assertOk();
+
+ Queue::assertPushed(TrackCatalogCurrencyUsage::class, function (TrackCatalogCurrencyUsage $job) {
+ return $job->currency === 'USD';
+ });
+ }
+}
diff --git a/tests/Feature/ExchangeRateEurTest.php b/tests/Feature/ExchangeRateEurTest.php
new file mode 100644
index 0000000..52f545d
--- /dev/null
+++ b/tests/Feature/ExchangeRateEurTest.php
@@ -0,0 +1,27 @@
+ Http::response([
+ ['r030' => 840, 'txt' => 'Долар США', 'rate' => 40.0, 'cc' => 'USD', 'exchangedate' => '02.03.2026'],
+ ['r030' => 978, 'txt' => 'Євро', 'rate' => 50.0, 'cc' => 'EUR', 'exchangedate' => '02.03.2026'],
+ ], 200),
+ ]);
+
+ $response = $this->getJson('/api/exchangeRate/EUR');
+
+ $response->assertOk()
+ ->assertJsonPath('data.currencyCode', 'EUR')
+ ->assertJsonPath('data.exchangeDate', '02.03.2026');
+
+ $this->assertEquals(50.0, $response->json('data.rate'));
+ }
+}
diff --git a/tests/Feature/ExchangeRateTest.php b/tests/Feature/ExchangeRateTest.php
index 12f238e..86ebd67 100644
--- a/tests/Feature/ExchangeRateTest.php
+++ b/tests/Feature/ExchangeRateTest.php
@@ -11,8 +11,8 @@ private function fakeNbuApi(float $usdRate = 43.0996): void
{
Http::fake([
'bank.gov.ua/*' => Http::response([
- ['r030' => 840, 'txt' => 'Долар США', 'rate' => $usdRate, 'cc' => 'USD', 'exchangedate' => '02.03.2026'],
- ['r030' => 978, 'txt' => 'Євро', 'rate' => 50.8661, 'cc' => 'EUR', 'exchangedate' => '02.03.2026'],
+ ['r030' => 840, 'txt' => 'USD', 'rate' => $usdRate, 'cc' => 'USD', 'exchangedate' => '02.03.2026'],
+ ['r030' => 978, 'txt' => 'EUR', 'rate' => 50.8661, 'cc' => 'EUR', 'exchangedate' => '02.03.2026'],
]),
]);
}
diff --git a/tests/Feature/TrackCatalogCurrencyUsageJobTest.php b/tests/Feature/TrackCatalogCurrencyUsageJobTest.php
new file mode 100644
index 0000000..bdf3e18
--- /dev/null
+++ b/tests/Feature/TrackCatalogCurrencyUsageJobTest.php
@@ -0,0 +1,26 @@
+handle();
+
+ $this->assertDatabaseHas('catalog_currency_usages', [
+ 'currency' => 'USD',
+ ]);
+ }
+}
diff --git a/tests/Unit/CachedExchangeRateProviderTest.php b/tests/Unit/CachedExchangeRateProviderTest.php
new file mode 100644
index 0000000..de081ae
--- /dev/null
+++ b/tests/Unit/CachedExchangeRateProviderTest.php
@@ -0,0 +1,133 @@
+calls++;
+
+ return $this->calls === 1 ? [$this->first] : [$this->second];
+ }
+
+ public function getRate(string $currencyCode): CurrencyRate
+ {
+ return $this->getAll()[0];
+ }
+ };
+
+ $provider = new CachedExchangeRateProvider($inner);
+
+ $firstCall = $provider->getAll();
+ $secondCall = $provider->getAll();
+
+ $this->assertCount(1, $firstCall);
+ $this->assertCount(1, $secondCall);
+ $this->assertSame(40.0, $firstCall[0]->rate);
+ $this->assertSame(40.0, $secondCall[0]->rate);
+ $this->assertSame(1, $inner->calls);
+ }
+
+ public function test_new_day_uses_new_cache_key(): void
+ {
+ Cache::flush();
+
+ $rateDay1 = new CurrencyRate('USD', 40.0, 'USD', '01.01.2026');
+ $rateDay2 = new CurrencyRate('USD', 41.0, 'USD', '02.01.2026');
+
+ $inner = new class($rateDay1, $rateDay2) implements ExchangeRateProviderInterface {
+ public int $calls = 0;
+
+ public function __construct(
+ private CurrencyRate $first,
+ private CurrencyRate $second,
+ ) {}
+
+ public function getAll(): array
+ {
+ $this->calls++;
+
+ return $this->calls === 1 ? [$this->first] : [$this->second];
+ }
+
+ public function getRate(string $currencyCode): CurrencyRate
+ {
+ return $this->getAll()[0];
+ }
+ };
+
+ $provider = new CachedExchangeRateProvider($inner);
+
+ $this->travelTo('2026-01-01 10:00:00');
+ $day1 = $provider->getAll();
+ $this->assertSame(40.0, $day1[0]->rate);
+
+ $this->travelTo('2026-01-02 10:00:00');
+ $day2 = $provider->getAll();
+ $this->assertSame(41.0, $day2[0]->rate);
+
+ $this->assertSame(2, $inner->calls);
+
+ $this->travelBack();
+ }
+
+ public function test_get_rate_uses_cached_rates(): void
+ {
+ Cache::flush();
+
+ $rate1 = new CurrencyRate('USD', 40.0, 'USD', '01.01.2026');
+ $rate2 = new CurrencyRate('USD', 41.0, 'USD', '01.01.2026');
+
+ $inner = new class($rate1, $rate2) implements ExchangeRateProviderInterface {
+ public int $calls = 0;
+
+ public function __construct(
+ private CurrencyRate $first,
+ private CurrencyRate $second,
+ ) {}
+
+ public function getAll(): array
+ {
+ $this->calls++;
+
+ return $this->calls === 1 ? [$this->first] : [$this->second];
+ }
+
+ public function getRate(string $currencyCode): CurrencyRate
+ {
+ return $this->getAll()[0];
+ }
+ };
+
+ $provider = new CachedExchangeRateProvider($inner);
+
+ $first = $provider->getRate('USD');
+ $second = $provider->getRate('USD');
+
+ $this->assertSame(40.0, $first->rate);
+ $this->assertSame(40.0, $second->rate);
+ $this->assertSame(1, $inner->calls);
+ }
+}
diff --git a/tests/Unit/CurrencyExchangeServiceTest.php b/tests/Unit/CurrencyExchangeServiceTest.php
new file mode 100644
index 0000000..7c44020
--- /dev/null
+++ b/tests/Unit/CurrencyExchangeServiceTest.php
@@ -0,0 +1,95 @@
+rates;
+ }
+
+ public function getRate(string $currencyCode): CurrencyRate
+ {
+ foreach ($this->rates as $rate) {
+ if ($rate->currencyCode === $currencyCode) {
+ return $rate;
+ }
+ }
+
+ throw new ExchangeRateProviderException('Rate not found.');
+ }
+ };
+
+ return new CurrencyExchangeService($provider);
+ }
+
+ public function test_convert_usd_to_usd(): void
+ {
+ $service = $this->createServiceWithRates([]);
+
+ $converted = $service->convertUsdTo(CurrencyCode::USD, 100.0);
+
+ $this->assertSame(100.0, $converted->originalPrice);
+ $this->assertSame(100.0, $converted->convertedPrice);
+ $this->assertSame('USD', $converted->fromCurrency);
+ $this->assertSame('USD', $converted->toCurrency);
+ $this->assertSame(1.0, $converted->rate);
+ }
+
+ public function test_convert_usd_to_uah(): void
+ {
+ $usdRate = new CurrencyRate('USD', 40.0, 'USD', '01.01.2026');
+ $eurRate = new CurrencyRate('EUR', 50.0, 'EUR', '01.01.2026');
+
+ $service = $this->createServiceWithRates([$usdRate, $eurRate]);
+
+ $converted = $service->convertUsdTo(CurrencyCode::UAH, 100.0);
+
+ $this->assertSame(100.0, $converted->originalPrice);
+ $this->assertEquals(4000.0, $converted->convertedPrice);
+ $this->assertSame('USD', $converted->fromCurrency);
+ $this->assertSame('UAH', $converted->toCurrency);
+ $this->assertEquals(40.0, $converted->rate);
+ }
+
+ public function test_convert_usd_to_eur(): void
+ {
+ $usdRate = new CurrencyRate('USD', 40.0, 'USD', '01.01.2026');
+ $eurRate = new CurrencyRate('EUR', 50.0, 'EUR', '01.01.2026');
+
+ $service = $this->createServiceWithRates([$usdRate, $eurRate]);
+
+ $converted = $service->convertUsdTo(CurrencyCode::EUR, 100.0);
+
+ $this->assertSame(100.0, $converted->originalPrice);
+ $this->assertEquals(80.0, $converted->convertedPrice);
+ $this->assertSame('USD', $converted->fromCurrency);
+ $this->assertSame('EUR', $converted->toCurrency);
+ $this->assertEquals(0.8, $converted->rate);
+ }
+
+ public function test_convert_usd_to_throws_when_rate_missing(): void
+ {
+ $usdRate = new CurrencyRate('USD', 40.0, 'USD', '01.01.2026');
+
+ $service = $this->createServiceWithRates([$usdRate]);
+
+ $this->expectException(ExchangeRateProviderException::class);
+ $this->expectExceptionMessage('Rate not found.');
+
+ $service->convertUsdTo(CurrencyCode::EUR, 100.0);
+ }
+}
diff --git a/tests/Unit/FailoverExchangeRateProviderTest.php b/tests/Unit/FailoverExchangeRateProviderTest.php
new file mode 100644
index 0000000..29a512d
--- /dev/null
+++ b/tests/Unit/FailoverExchangeRateProviderTest.php
@@ -0,0 +1,130 @@
+rate];
+ }
+
+ public function getRate(string $currencyCode): CurrencyRate
+ {
+ return $this->rate;
+ }
+ };
+
+ $fallback = new class($this) implements ExchangeRateProviderInterface {
+ public function __construct(private TestCase $testCase) {}
+
+ public function getAll(): array
+ {
+ $this->testCase->fail('Fallback should not be called.');
+ }
+
+ public function getRate(string $currencyCode): CurrencyRate
+ {
+ $this->testCase->fail('Fallback should not be called.');
+ }
+ };
+
+ $provider = new FailoverExchangeRateProvider($primary, $fallback);
+
+ $all = $provider->getAll();
+ $this->assertCount(1, $all);
+ $this->assertSame($rate, $all[0]);
+
+ $single = $provider->getRate('USD');
+ $this->assertSame($rate, $single);
+ }
+
+ public function test_primary_provider_exception_uses_fallback(): void
+ {
+ $fallbackRate = new CurrencyRate('USD', 41.0, 'USD', '01.01.2026');
+
+ $primary = new class implements ExchangeRateProviderInterface {
+ public function getAll(): array
+ {
+ throw new ExchangeRateProviderException('Primary failed.');
+ }
+
+ public function getRate(string $currencyCode): CurrencyRate
+ {
+ throw new ExchangeRateProviderException('Primary failed.');
+ }
+ };
+
+ $fallback = new class($fallbackRate) implements ExchangeRateProviderInterface {
+ public function __construct(private CurrencyRate $rate) {}
+
+ public function getAll(): array
+ {
+ return [$this->rate];
+ }
+
+ public function getRate(string $currencyCode): CurrencyRate
+ {
+ return $this->rate;
+ }
+ };
+
+ $provider = new FailoverExchangeRateProvider($primary, $fallback);
+
+ $all = $provider->getAll();
+ $this->assertCount(1, $all);
+ $this->assertSame($fallbackRate, $all[0]);
+
+ $single = $provider->getRate('USD');
+ $this->assertSame($fallbackRate, $single);
+ }
+
+ public function test_unexpected_exception_is_not_swallowed(): void
+ {
+ $primary = new class implements ExchangeRateProviderInterface {
+ public function getAll(): array
+ {
+ throw new \RuntimeException('Unexpected failure.');
+ }
+
+ public function getRate(string $currencyCode): CurrencyRate
+ {
+ throw new \RuntimeException('Unexpected failure.');
+ }
+ };
+
+ $fallback = new class($this) implements ExchangeRateProviderInterface {
+ public function __construct(private TestCase $testCase) {}
+
+ public function getAll(): array
+ {
+ $this->testCase->fail('Fallback should not be called for unexpected exceptions.');
+ }
+
+ public function getRate(string $currencyCode): CurrencyRate
+ {
+ $this->testCase->fail('Fallback should not be called for unexpected exceptions.');
+ }
+ };
+
+ $provider = new FailoverExchangeRateProvider($primary, $fallback);
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('Unexpected failure.');
+
+ $provider->getAll();
+ }
+}