diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs
deleted file mode 100644
index f375776f25..0000000000
--- a/jobs/Backend/Task/Currency.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-namespace ExchangeRateUpdater
-{
- public class Currency
- {
- public Currency(string code)
- {
- Code = code;
- }
-
- ///
- /// Three-letter ISO 4217 code of the currency.
- ///
- public string Code { get; }
-
- public override string ToString()
- {
- return Code;
- }
- }
-}
diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs
deleted file mode 100644
index 58c5bb10e0..0000000000
--- a/jobs/Backend/Task/ExchangeRate.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-namespace ExchangeRateUpdater
-{
- public class ExchangeRate
- {
- public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value)
- {
- SourceCurrency = sourceCurrency;
- TargetCurrency = targetCurrency;
- Value = value;
- }
-
- public Currency SourceCurrency { get; }
-
- public Currency TargetCurrency { get; }
-
- public decimal Value { get; }
-
- public override string ToString()
- {
- return $"{SourceCurrency}/{TargetCurrency}={Value}";
- }
- }
-}
diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs
deleted file mode 100644
index 6f82a97fbe..0000000000
--- a/jobs/Backend/Task/ExchangeRateProvider.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-
-namespace ExchangeRateUpdater
-{
- public class ExchangeRateProvider
- {
- ///
- /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined
- /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK",
- /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide
- /// some of the currencies, ignore them.
- ///
- public IEnumerable GetExchangeRates(IEnumerable currencies)
- {
- return Enumerable.Empty();
- }
- }
-}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj
deleted file mode 100644
index 2fc654a12b..0000000000
--- a/jobs/Backend/Task/ExchangeRateUpdater.csproj
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
- Exe
- net6.0
-
-
-
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln
index 89be84daff..e83f998f88 100644
--- a/jobs/Backend/Task/ExchangeRateUpdater.sln
+++ b/jobs/Backend/Task/ExchangeRateUpdater.sln
@@ -3,7 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 14
VisualStudioVersion = 14.0.25123.0
MinimumVisualStudioVersion = 10.0.40219.1
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Api", "src\ExchangeRateUpdater.Api\ExchangeRateUpdater.Api.csproj", "{24FD0841-D796-4953-A583-773A6FD11A01}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Api.Tests", "tests\ExchangeRateUpdater.Api.Tests\ExchangeRateUpdater.Api.Tests.csproj", "{128355E8-210A-4942-92F9-7AC7EEE07118}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -11,10 +13,14 @@ Global
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {24FD0841-D796-4953-A583-773A6FD11A01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {24FD0841-D796-4953-A583-773A6FD11A01}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {24FD0841-D796-4953-A583-773A6FD11A01}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {24FD0841-D796-4953-A583-773A6FD11A01}.Release|Any CPU.Build.0 = Release|Any CPU
+ {128355E8-210A-4942-92F9-7AC7EEE07118}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {128355E8-210A-4942-92F9-7AC7EEE07118}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {128355E8-210A-4942-92F9-7AC7EEE07118}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {128355E8-210A-4942-92F9-7AC7EEE07118}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs
deleted file mode 100644
index 379a69b1f8..0000000000
--- a/jobs/Backend/Task/Program.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-
-namespace ExchangeRateUpdater
-{
- public static class Program
- {
- private static IEnumerable currencies = new[]
- {
- new Currency("USD"),
- new Currency("EUR"),
- new Currency("CZK"),
- new Currency("JPY"),
- new Currency("KES"),
- new Currency("RUB"),
- new Currency("THB"),
- new Currency("TRY"),
- new Currency("XYZ")
- };
-
- public static void Main(string[] args)
- {
- try
- {
- var provider = new ExchangeRateProvider();
- var rates = provider.GetExchangeRates(currencies);
-
- Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:");
- foreach (var rate in rates)
- {
- Console.WriteLine(rate.ToString());
- }
- }
- catch (Exception e)
- {
- Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'.");
- }
-
- Console.ReadLine();
- }
- }
-}
diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md
new file mode 100644
index 0000000000..71c73edee7
--- /dev/null
+++ b/jobs/Backend/Task/README.md
@@ -0,0 +1,94 @@
+# Exchange Rate API
+
+A REST API service that provides daily currency exchange rates from the Czech National Bank. Built with clean architecture principles, the service fetches, caches, and serves exchange rate data with support for filtering by currency and date.
+
+**Purpose:** Simplify currency conversion by providing a reliable, fast, and well-documented API for accessing real-time and historical exchange rates against the Czech Koruna (CZK).
+
+## Tech Stack
+
+- **.NET 10.0** - Latest cross-platform framework
+- **ASP.NET Core** - Web API
+- **DataAnnotation** - Request validation
+- **Microsoft.Extensions.Http.Resilience** - HTTP resilience (retry, circuit breaker, timeout)
+- **Scalar** - Interactive API documentation
+- **xUnit v3** - Unit testing
+- **NSubstitute** - Mocking
+
+## Quick Start
+
+### Prerequisites
+
+- [.NET 10.0 SDK](https://dotnet.microsoft.com/download)
+
+### Run the API
+
+```bash
+cd jobs/Backend/Task
+dotnet run --project src/ExchangeRateUpdater.Api
+```
+
+Access the API at:
+- **API Docs**: http://localhost:5213/scalar/v1
+- **Swagger**: http://localhost:5213/openapi/v1.json
+
+### Run Tests
+
+```bash
+dotnet test
+```
+
+All tests should pass.
+
+### Response Format
+
+**Success (200 OK):**
+```json
+{
+ "data": [
+ {
+ "sourceCurrency": "USD",
+ "targetCurrency": "CZK",
+ "rate": 23.45
+ }
+ ]
+}
+```
+
+**Error (400/404/502):**
+```json
+{
+ "errorCode": "ExchangeRate.CurrencyNotFound",
+ "errorMessage": "Exchange rate for currency 'XYZ' was not found in the source data."
+}
+```
+
+## Architecture
+
+Clean architecture with 4 layers:
+
+```
+Domain/ # Entities, value objects (Currency, ExchangeRate)
+Application/ # Business logic, caching decorator
+Infrastructure/ # External APIs, HTTP clients, CNB provider
+Presentation/ # Controllers, validation, API responses
+```
+
+## Adding More Providers
+
+To add a new exchange rate provider (e.g., ECB, Bank of England):
+
+1. **Create provider folder:** `Infrastructure/Providers/Ecb/`
+2. **Implement HTTP client:** Create `EcbApiClient` with provider-specific logic
+3. **Create adapter:** Implement `IExternalExchangeRateClient` to map to common format
+4. **Register in DI:** Add to `Infrastructure/Extensions/ServiceCollectionExtensions.cs`
+5. **Configure:** Add provider settings to `appsettings.json`
+
+**Run with hot reload:**
+```bash
+dotnet watch --project src/ExchangeRateUpdater.Api
+```
+
+**Test with coverage:**
+```bash
+dotnet test --collect:"XPlat Code Coverage"
+```
diff --git a/jobs/Backend/Task/nuget.config b/jobs/Backend/Task/nuget.config
new file mode 100644
index 0000000000..4d736c19ec
--- /dev/null
+++ b/jobs/Backend/Task/nuget.config
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Extensions/ServiceCollectionExtensions.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000000..71f076b010
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,30 @@
+using ExchangeRateUpdater.Api.Application.Interfaces;
+using ExchangeRateUpdater.Api.Application.Services;
+using ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Options;
+
+namespace ExchangeRateUpdater.Api.Application.Extensions;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddApplication(this IServiceCollection services)
+ {
+ services.AddScoped();
+
+ services.AddScoped(sp =>
+ {
+ var inner = sp.GetRequiredService();
+ var cache = sp.GetRequiredService();
+ var logger = sp.GetRequiredService>();
+ var options = sp.GetRequiredService>().Value;
+
+ return new ExchangeRateProviderDecorator(
+ inner, cache, logger,
+ TimeSpan.FromMinutes(options.CacheDurationMinutes),
+ TimeSpan.FromMinutes(options.HistoricalCacheDurationMinutes));
+ });
+
+ return services;
+ }
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Interfaces/IExchangeRateProvider.cs
new file mode 100644
index 0000000000..95a47593f2
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Interfaces/IExchangeRateProvider.cs
@@ -0,0 +1,11 @@
+using ExchangeRateUpdater.Api.Domain;
+using ExchangeRateUpdater.Api.Domain.Common;
+
+namespace ExchangeRateUpdater.Api.Application.Interfaces;
+
+public interface IExchangeRateProvider
+{
+ Task>> GetDailyRatesAsync(
+ DateOnly? date = null,
+ CancellationToken cancellationToken = default);
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Services/ExchangeRateProvider.cs
new file mode 100644
index 0000000000..a44eb5a073
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Services/ExchangeRateProvider.cs
@@ -0,0 +1,50 @@
+using ExchangeRateUpdater.Api.Application.Interfaces;
+using ExchangeRateUpdater.Api.Domain;
+using ExchangeRateUpdater.Api.Domain.Common;
+using ExchangeRateUpdater.Api.Infrastructure.Interfaces;
+using ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb.Models;
+
+namespace ExchangeRateUpdater.Api.Application.Services;
+
+public sealed class ExchangeRateProvider(
+ IExternalExchangeRateClient externalClient,
+ ILogger logger) : IExchangeRateProvider
+{
+ private const string TargetCurrencyCode = "CZK";
+
+ public async Task>> GetDailyRatesAsync(
+ DateOnly? date = null,
+ CancellationToken cancellationToken = default)
+ {
+ var externalResult = await externalClient.GetDailyRatesAsync(date, cancellationToken);
+
+ if (externalResult.IsFailure)
+ return Result>.Failure(externalResult.Error);
+
+ var domainRates = new List();
+
+ foreach (var external in externalResult.Value.Rates)
+ {
+ var result = MapToDomainModel(external);
+ if (result.IsFailure)
+ return Result>.Failure(result.Error);
+
+ domainRates.Add(result.Value);
+ }
+
+ logger.LogInformation(
+ "Mapped {Count} exchange rates for date {Date}",
+ domainRates.Count,
+ date?.ToString("yyyy-MM-dd") ?? "latest");
+
+ return Result>.Success(domainRates.ToArray());
+ }
+
+ private static Result MapToDomainModel(CnbExRateDaily external) =>
+ ExchangeRate.Create(
+ external.CurrencyCode,
+ TargetCurrencyCode,
+ external.Rate,
+ external.Amount,
+ DateOnly.Parse(external.ValidFor));
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Services/ExchangeRateProviderDecorator.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Services/ExchangeRateProviderDecorator.cs
new file mode 100644
index 0000000000..56fa0797ad
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Services/ExchangeRateProviderDecorator.cs
@@ -0,0 +1,77 @@
+using ExchangeRateUpdater.Api.Application.Interfaces;
+using ExchangeRateUpdater.Api.Domain;
+using ExchangeRateUpdater.Api.Domain.Common;
+using Microsoft.Extensions.Caching.Memory;
+
+namespace ExchangeRateUpdater.Api.Application.Services;
+
+public sealed class ExchangeRateProviderDecorator(
+ IExchangeRateProvider inner,
+ IMemoryCache cache,
+ ILogger logger,
+ TimeSpan cacheDuration,
+ TimeSpan historicalCacheDuration) : IExchangeRateProvider
+{
+ private const string CacheKeyPrefix = "exchange-rates";
+ private static readonly string LatestCacheKey = BuildCacheKey(null);
+
+ private DateOnly _lastCachedUtcDate = DateOnly.FromDateTime(DateTime.UtcNow);
+ private readonly object _dateLock = new();
+
+ public async Task>> GetDailyRatesAsync(
+ DateOnly? date = null,
+ CancellationToken cancellationToken = default)
+ {
+ EvictLatestOnDayRollover();
+
+ var cacheKey = BuildCacheKey(date);
+
+ if (cache.TryGetValue(cacheKey, out Result>? cached) && cached is not null)
+ {
+ logger.LogDebug("Cache hit for exchange rates with key {CacheKey}", cacheKey);
+ return cached;
+ }
+
+ logger.LogDebug("Cache miss for exchange rates with key {CacheKey}", cacheKey);
+
+ var result = await inner.GetDailyRatesAsync(date, cancellationToken);
+
+ if (result.IsSuccess)
+ {
+ var ttl = IsHistoricalDate(date) ? historicalCacheDuration : cacheDuration;
+ var cacheOptions = new MemoryCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = ttl
+ };
+
+ cache.Set(cacheKey, result, cacheOptions);
+ logger.LogDebug("Cached exchange rates with key {CacheKey} for {Duration}", cacheKey, ttl);
+ }
+
+ return result;
+ }
+
+ private static bool IsHistoricalDate(DateOnly? date) =>
+ date.HasValue && date.Value < DateOnly.FromDateTime(DateTime.UtcNow);
+
+ private void EvictLatestOnDayRollover()
+ {
+ var today = DateOnly.FromDateTime(DateTime.UtcNow);
+
+ lock (_dateLock)
+ {
+ if (today <= _lastCachedUtcDate)
+ return;
+
+ _lastCachedUtcDate = today;
+ }
+
+ cache.Remove(LatestCacheKey);
+
+ logger.LogInformation(
+ "New UTC day {Today} detected — evicted latest exchange rates cache", today);
+ }
+
+ private static string BuildCacheKey(DateOnly? date) =>
+ $"{CacheKeyPrefix}-{date?.ToString("yyyy-MM-dd") ?? "latest"}";
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/CurrencyErrors.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/CurrencyErrors.cs
new file mode 100644
index 0000000000..134f910a93
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/CurrencyErrors.cs
@@ -0,0 +1,16 @@
+namespace ExchangeRateUpdater.Api.Domain.Common;
+
+public static class CurrencyErrors
+{
+ public static Error NullOrWhitespace() =>
+ new(ErrorCodes.ValidationCurrencyCodeNullOrWhitespace,
+ "Currency code is missing.");
+
+ public static Error InvalidLength(string code) =>
+ new(ErrorCodes.ValidationCurrencyCodeInvalidLength,
+ $"Currency code must be exactly 3 characters. Got '{code}' with length {code.Length}.");
+
+ public static Error InvalidCharacters(string code) =>
+ new(ErrorCodes.ValidationCurrencyCodeInvalidCharacters,
+ $"Currency code must contain only letters. Got '{code}'.");
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/Error.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/Error.cs
new file mode 100644
index 0000000000..524bdf8fa0
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/Error.cs
@@ -0,0 +1,9 @@
+namespace ExchangeRateUpdater.Api.Domain.Common;
+
+///
+/// Represents a domain error with areadable error code and message.
+///
+public sealed record Error(string Code, string Message, ErrorType Type = ErrorType.Failure)
+{
+ public static readonly Error None = new(string.Empty, string.Empty);
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/ErrorCodes.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/ErrorCodes.cs
new file mode 100644
index 0000000000..f44b7f3a8a
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/ErrorCodes.cs
@@ -0,0 +1,19 @@
+namespace ExchangeRateUpdater.Api.Domain.Common;
+
+public static class ErrorCodes
+{
+ // Validation Errors
+ public const string ValidationFailed = "Validation.Failed";
+ public const string ValidationInvalidCurrencyCode = "Validation.InvalidCurrencyCode";
+ public const string ValidationInvalidDate = "Validation.InvalidDate";
+ public const string ValidationInvalidRate = "Validation.InvalidRate";
+ public const string ValidationInvalidAmount = "Validation.InvalidAmount";
+ public const string ValidationCurrencyCodeNullOrWhitespace = "Validation.CurrencyCodeNullOrWhitespace";
+ public const string ValidationCurrencyCodeInvalidLength = "Validation.CurrencyCodeInvalidLength";
+ public const string ValidationCurrencyCodeInvalidCharacters = "Validation.CurrencyCodeInvalidCharacters";
+
+ // Exchange Rate Errors
+ public const string ExchangeRateCurrencyNotFound = "ExchangeRate.CurrencyNotFound";
+ public const string ExchangeRateSourceUnavailable = "ExchangeRate.SourceUnavailable";
+ public const string ExchangeRateNoRatesAvailable = "ExchangeRate.NoRatesAvailable";
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/ErrorType.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/ErrorType.cs
new file mode 100644
index 0000000000..d4149b5b08
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/ErrorType.cs
@@ -0,0 +1,12 @@
+namespace ExchangeRateUpdater.Api.Domain.Common;
+
+///
+/// Classifies the kind of error for proper HTTP status code mapping.
+///
+public enum ErrorType
+{
+ Failure,
+ Validation,
+ NotFound,
+ Unavailable
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/ExchangeRateErrors.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/ExchangeRateErrors.cs
new file mode 100644
index 0000000000..cd4d4d6a85
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/ExchangeRateErrors.cs
@@ -0,0 +1,39 @@
+namespace ExchangeRateUpdater.Api.Domain.Common;
+
+public static class ExchangeRateErrors
+{
+ public static Error CurrencyNotFound(string currencyCode) =>
+ new(ErrorCodes.ExchangeRateCurrencyNotFound,
+ $"Exchange rate for currency '{currencyCode}' was not found in the source data.",
+ ErrorType.NotFound);
+
+ public static readonly Error SourceUnavailable =
+ new(ErrorCodes.ExchangeRateSourceUnavailable,
+ "The exchange rate data source is currently unavailable. Please try again later.",
+ ErrorType.Unavailable);
+
+ public static Error InvalidCurrencyCode(string code) =>
+ new(ErrorCodes.ValidationInvalidCurrencyCode,
+ $"'{code}' is not a valid ISO 4217 currency code. Currency codes must be exactly 3 letters.",
+ ErrorType.Validation);
+
+ public static Error InvalidDate(string date) =>
+ new(ErrorCodes.ValidationInvalidDate,
+ $"'{date}' is not a valid date. Expected format: yyyy-MM-dd.",
+ ErrorType.Validation);
+
+ public static Error InvalidRate(decimal rate) =>
+ new(ErrorCodes.ValidationInvalidRate,
+ $"Exchange rate '{rate}' is invalid. Rate must be non-negative.",
+ ErrorType.Validation);
+
+ public static Error InvalidAmount(int amount) =>
+ new(ErrorCodes.ValidationInvalidAmount,
+ $"Amount '{amount}' is invalid. Amount must be a positive integer.",
+ ErrorType.Validation);
+
+ public static readonly Error NoRatesAvailable =
+ new(ErrorCodes.ExchangeRateNoRatesAvailable,
+ "No exchange rates are available for the specified criteria.",
+ ErrorType.NotFound);
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/Result.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/Result.cs
new file mode 100644
index 0000000000..d38125b1d0
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/Result.cs
@@ -0,0 +1,35 @@
+namespace ExchangeRateUpdater.Api.Domain.Common;
+
+public sealed class Result
+{
+ private readonly TValue? _value;
+ private readonly Error? _error;
+
+ private Result(TValue value)
+ {
+ _value = value;
+ IsSuccess = true;
+ }
+
+ private Result(Error error)
+ {
+ _error = error;
+ IsSuccess = false;
+ }
+
+ public bool IsSuccess { get; }
+
+ public bool IsFailure => !IsSuccess;
+
+ public TValue Value => IsSuccess
+ ? _value!
+ : throw new InvalidOperationException("Cannot access Value on a failed result. Check IsSuccess first.");
+
+ public Error Error => IsFailure
+ ? _error!
+ : throw new InvalidOperationException("Cannot access Error on a successful result.");
+
+ public static Result Success(TValue value) => new(value);
+
+ public static Result Failure(Error error) => new(error);
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Currency.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Currency.cs
new file mode 100644
index 0000000000..006f8d8598
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Currency.cs
@@ -0,0 +1,38 @@
+namespace ExchangeRateUpdater.Api.Domain;
+
+using Common;
+
+///
+/// Represents a currency identified by its ISO 4217 three-letter code.
+///
+public sealed record Currency
+{
+ private Currency(string code)
+ {
+ Code = code.ToUpperInvariant();
+ }
+
+ ///
+ /// Three-letter ISO 4217 currency code.
+ ///
+ public string Code { get; }
+
+ ///
+ /// Creates a new Currency instance with validation.
+ ///
+ public static Result Create(string code)
+ {
+ if (string.IsNullOrWhiteSpace(code))
+ return Result.Failure(CurrencyErrors.NullOrWhitespace());
+
+ if (code.Length != 3)
+ return Result.Failure(CurrencyErrors.InvalidLength(code));
+
+ if (!code.All(char.IsLetter))
+ return Result.Failure(CurrencyErrors.InvalidCharacters(code));
+
+ return Result.Success(new Currency(code));
+ }
+
+ public override string ToString() => Code;
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/ExchangeRate.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/ExchangeRate.cs
new file mode 100644
index 0000000000..a33c201ba9
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/ExchangeRate.cs
@@ -0,0 +1,68 @@
+using ExchangeRateUpdater.Api.Domain.Common;
+
+namespace ExchangeRateUpdater.Api.Domain;
+
+///
+/// Represents an exchange rate between two currencies.
+/// The rate expresses how many units of the target currency equal one unit of the source currency.
+///
+public sealed record ExchangeRate
+{
+ private ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal rate, int amount, DateOnly validFor)
+ {
+ SourceCurrency = sourceCurrency;
+ TargetCurrency = targetCurrency;
+ Rate = rate;
+ Amount = amount;
+ ValidFor = validFor;
+ }
+
+ ///
+ /// The currency being converted from.
+ ///
+ public Currency SourceCurrency { get; }
+
+ ///
+ /// The currency being converted to (always CZK for CNB rates).
+ ///
+ public Currency TargetCurrency { get; }
+
+ ///
+ /// The exchange rate value: 1 unit of SourceCurrency equals this many units of TargetCurrency.
+ ///
+ public decimal Rate { get; }
+
+ ///
+ /// The number of units of the source currency the rate applies to.
+ ///
+ public int Amount { get; }
+
+ ///
+ /// The date for which this exchange rate is valid.
+ ///
+ public DateOnly ValidFor { get; }
+
+ ///
+ /// Creates a new exchange rate with validation.
+ ///
+ public static Result Create(string sourceCurrencyCode, string targetCurrencyCode, decimal rate, int amount, DateOnly validFor)
+ {
+ var sourceResult = Currency.Create(sourceCurrencyCode);
+ if (!sourceResult.IsSuccess)
+ return Result.Failure(sourceResult.Error);
+
+ var targetResult = Currency.Create(targetCurrencyCode);
+ if (!targetResult.IsSuccess)
+ return Result.Failure(targetResult.Error);
+
+ if (rate < 0)
+ return Result.Failure(ExchangeRateErrors.InvalidRate(rate));
+
+ if (amount <= 0)
+ return Result.Failure(ExchangeRateErrors.InvalidAmount(amount));
+
+ return Result.Success(new ExchangeRate(sourceResult.Value, targetResult.Value, rate, amount, validFor));
+ }
+
+ public override string ToString() => FormattableString.Invariant($"{SourceCurrency}/{TargetCurrency}={Rate}");
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj
new file mode 100644
index 0000000000..261c5b3572
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+ $(NoWarn);1591
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Extensions/ServiceCollectionExtensions.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000000..8372326a69
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,37 @@
+using System.Net.Mime;
+using ExchangeRateUpdater.Api.Infrastructure.Interfaces;
+using ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb;
+using Microsoft.Extensions.Options;
+
+namespace ExchangeRateUpdater.Api.Infrastructure.Extensions;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddInfrastructure(
+ this IServiceCollection services,
+ IConfiguration configuration)
+ {
+ services.Configure(
+ configuration.GetSection(CnbApiOptions.SectionName));
+
+ services.AddMemoryCache();
+ services.AddCnbExchangeRateProvider();
+
+ return services;
+ }
+
+ private static IServiceCollection AddCnbExchangeRateProvider(this IServiceCollection services)
+ {
+ services
+ .AddHttpClient((sp, client) =>
+ {
+ var options = sp.GetRequiredService>().Value;
+ client.BaseAddress = new Uri(options.BaseUrl);
+ client.DefaultRequestHeaders.Accept.Add(
+ new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
+ })
+ .AddStandardResilienceHandler();
+
+ return services;
+ }
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Interfaces/IExternalExchangeRateClient.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Interfaces/IExternalExchangeRateClient.cs
new file mode 100644
index 0000000000..411cbfa195
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Interfaces/IExternalExchangeRateClient.cs
@@ -0,0 +1,11 @@
+using ExchangeRateUpdater.Api.Domain.Common;
+using ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb.Models;
+
+namespace ExchangeRateUpdater.Api.Infrastructure.Interfaces;
+
+public interface IExternalExchangeRateClient
+{
+ Task> GetDailyRatesAsync(
+ DateOnly? date = null,
+ CancellationToken cancellationToken = default);
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Models/ExternalExchangeRate.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Models/ExternalExchangeRate.cs
new file mode 100644
index 0000000000..79c5f46153
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Models/ExternalExchangeRate.cs
@@ -0,0 +1,14 @@
+namespace ExchangeRateUpdater.Api.Infrastructure.Models;
+
+///
+/// Represents a raw exchange rate record from an external data source.
+///
+/// ISO 4217 currency code of the source currency.
+/// ISO 4217 currency code of the target currency.
+/// Exchange rate value.
+/// The amount of source currency units the rate applies to (for normalization).
+public sealed record ExternalExchangeRate(
+ string SourceCurrencyCode,
+ string TargetCurrencyCode,
+ decimal Rate,
+ int Amount = 1);
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/CnbApiClient.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/CnbApiClient.cs
new file mode 100644
index 0000000000..76e9c957c0
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/CnbApiClient.cs
@@ -0,0 +1,62 @@
+using System.Text.Json;
+using ExchangeRateUpdater.Api.Domain.Common;
+using ExchangeRateUpdater.Api.Infrastructure.Interfaces;
+using ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb.Models;
+
+namespace ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb;
+
+public sealed class CnbApiClient(HttpClient httpClient, ILogger logger) : IExternalExchangeRateClient
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+
+ public async Task> GetDailyRatesAsync(
+ DateOnly? date = null,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var url = BuildUrl(date);
+ logger.LogInformation("Fetching daily exchange rates from CNB API: {Url}", url);
+
+ var response = await httpClient.GetFromJsonAsync(
+ url, JsonOptions, cancellationToken);
+
+ if (response is null or { Rates.Length: 0 })
+ {
+ logger.LogWarning("CNB API returned empty rates for date {Date}", date);
+ return Result.Failure(ExchangeRateErrors.NoRatesAvailable);
+ }
+
+ logger.LogInformation("Successfully fetched {Count} raw rates from CNB API", response.Rates.Length);
+ return Result.Success(response);
+ }
+ catch (HttpRequestException ex)
+ {
+ logger.LogError(ex, "HTTP error while fetching exchange rates from CNB API");
+ return Result.Failure(ExchangeRateErrors.SourceUnavailable);
+ }
+ catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
+ {
+ logger.LogError(ex, "Timeout while fetching exchange rates from CNB API");
+ return Result.Failure(ExchangeRateErrors.SourceUnavailable);
+ }
+ catch (JsonException ex)
+ {
+ logger.LogError(ex, "Failed to deserialize CNB API response");
+ return Result.Failure(ExchangeRateErrors.SourceUnavailable);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Something went wrong during CNB API call");
+ return Result.Failure(ExchangeRateErrors.SourceUnavailable);
+ }
+ }
+
+ private static string BuildUrl(DateOnly? date)
+ {
+ var url = "/cnbapi/exrates/daily?lang=EN";
+ if (date.HasValue)
+ url += $"&date={date.Value:yyyy-MM-dd}";
+ return url;
+ }
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/CnbApiOptions.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/CnbApiOptions.cs
new file mode 100644
index 0000000000..79d4394b36
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/CnbApiOptions.cs
@@ -0,0 +1,17 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb;
+
+public sealed class CnbApiOptions
+{
+ public const string SectionName = "CnbApi";
+
+ [Required]
+ public required string BaseUrl { get; set; }
+
+ public string Language { get; set; } = "EN";
+
+ public int CacheDurationMinutes { get; set; } = 60;
+
+ public int HistoricalCacheDurationMinutes { get; set; } = 1440;
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/Interfaces/ICnbApiClient.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/Interfaces/ICnbApiClient.cs
new file mode 100644
index 0000000000..f8711d9661
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/Interfaces/ICnbApiClient.cs
@@ -0,0 +1,11 @@
+using ExchangeRateUpdater.Api.Domain.Common;
+using ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb.Models;
+
+namespace ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb.Interfaces;
+
+public interface ICnbApiClient
+{
+ Task> GetDailyRatesAsync(
+ DateOnly? date = null,
+ CancellationToken cancellationToken = default);
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/Models/CnbExRateDaily.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/Models/CnbExRateDaily.cs
new file mode 100644
index 0000000000..b65ab06e94
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/Models/CnbExRateDaily.cs
@@ -0,0 +1,10 @@
+namespace ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb.Models;
+
+public sealed record CnbExRateDaily(
+ string ValidFor,
+ int Order,
+ string Country,
+ string Currency,
+ int Amount,
+ string CurrencyCode,
+ decimal Rate);
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/Models/CnbExRateDailyResponse.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/Models/CnbExRateDailyResponse.cs
new file mode 100644
index 0000000000..4cd07d4127
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/Models/CnbExRateDailyResponse.cs
@@ -0,0 +1,3 @@
+namespace ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb.Models;
+
+public sealed record CnbExRateDailyResponse(CnbExRateDaily[] Rates);
\ No newline at end of file
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Requests/GetExchangeRateByCodeRequest.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Requests/GetExchangeRateByCodeRequest.cs
new file mode 100644
index 0000000000..854ae2a7f4
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Requests/GetExchangeRateByCodeRequest.cs
@@ -0,0 +1,24 @@
+using System.ComponentModel.DataAnnotations;
+using ExchangeRateUpdater.Api.Presentation.Validators;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ExchangeRateUpdater.Api.Presentation.Contracts.Requests;
+
+public sealed class GetExchangeRateByCodeRequest
+{
+ ///
+ /// ISO 4217 three-letter currency code (e.g. USD, EUR, GBP).
+ ///
+ [FromRoute(Name = "currencyCode")]
+ [Required(ErrorMessage = "Currency code is required.")]
+ [RegularExpression(@"^[a-zA-Z]{3}$",
+ ErrorMessage = "Currency code must be exactly 3 letters.")]
+ public string CurrencyCode { get; init; } = string.Empty;
+
+ ///
+ /// Optional date in yyyy-MM-dd format. Defaults to the latest available rates.
+ ///
+ [FromQuery]
+ [DateFormat]
+ public string? Date { get; init; }
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Requests/GetExchangeRatesRequest.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Requests/GetExchangeRatesRequest.cs
new file mode 100644
index 0000000000..0a84ea3ea4
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Requests/GetExchangeRatesRequest.cs
@@ -0,0 +1,21 @@
+using System.ComponentModel.DataAnnotations;
+using ExchangeRateUpdater.Api.Presentation.Validators;
+
+namespace ExchangeRateUpdater.Api.Presentation.Contracts.Requests;
+
+public sealed class GetExchangeRatesRequest
+{
+ ///
+ /// Optional comma-separated list of ISO 4217 three-letter currency codes to filter by.
+ /// Example: USD,EUR,GBP
+ ///
+ [RegularExpression(@"^[a-zA-Z]{3}(,[a-zA-Z]{3})*$",
+ ErrorMessage = "Currencies must be a comma-separated list of 3-letter ISO 4217 codes (e.g. USD,EUR,GBP).")]
+ public string? Currencies { get; init; }
+
+ ///
+ /// Optional date in yyyy-MM-dd format. Defaults to the latest available rates.
+ ///
+ [DateFormat]
+ public string? Date { get; init; }
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Responses/ApiResponse.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Responses/ApiResponse.cs
new file mode 100644
index 0000000000..7189ced3dc
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Responses/ApiResponse.cs
@@ -0,0 +1,28 @@
+using System.Text.Json.Serialization;
+
+namespace ExchangeRateUpdater.Api.Presentation.Contracts.Responses;
+
+///
+/// Standard API response wrapper that contains either data (success) or error information (failure).
+///
+public sealed class ApiResponse
+{
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public T? Data { get; init; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? ErrorCode { get; init; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? ErrorMessage { get; init; }
+
+ [JsonIgnore]
+ public bool IsSuccess => ErrorCode is null;
+
+ private ApiResponse() { }
+
+ public static ApiResponse Success(T data) => new() { Data = data };
+
+ public static ApiResponse Failure(string errorCode, string errorMessage) =>
+ new() { ErrorCode = errorCode, ErrorMessage = errorMessage };
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Responses/ExchangeRateResponse.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Responses/ExchangeRateResponse.cs
new file mode 100644
index 0000000000..20e978d19b
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Responses/ExchangeRateResponse.cs
@@ -0,0 +1,8 @@
+namespace ExchangeRateUpdater.Api.Presentation.Contracts.Responses;
+
+public sealed record ExchangeRateResponse(
+ string SourceCurrency,
+ string TargetCurrency,
+ decimal Rate,
+ int Amount,
+ string ValidFor);
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Controllers/ApiControllerBase.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Controllers/ApiControllerBase.cs
new file mode 100644
index 0000000000..60c046f19b
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Controllers/ApiControllerBase.cs
@@ -0,0 +1,25 @@
+using ExchangeRateUpdater.Api.Domain.Common;
+using ExchangeRateUpdater.Api.Presentation.Contracts.Responses;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ExchangeRateUpdater.Api.Presentation.Controllers;
+
+///
+/// Base controller providing shared error-to-action-result mapping using the ApiResponse pattern.
+///
+[ApiController]
+public abstract class ApiControllerBase : ControllerBase
+{
+ protected IActionResult ToErrorResponse(Error error)
+ {
+ var response = ApiResponse.Failure(error.Code, error.Message);
+
+ return error.Type switch
+ {
+ ErrorType.Validation => BadRequest(response),
+ ErrorType.NotFound => NotFound(response),
+ ErrorType.Unavailable => StatusCode(StatusCodes.Status502BadGateway, response),
+ _ => StatusCode(StatusCodes.Status500InternalServerError, response)
+ };
+ }
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Controllers/ExchangeRatesController.cs
new file mode 100644
index 0000000000..169fcc6b01
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Controllers/ExchangeRatesController.cs
@@ -0,0 +1,113 @@
+using System.Net.Mime;
+using Asp.Versioning;
+using ExchangeRateUpdater.Api.Domain.Common;
+using ExchangeRateUpdater.Api.Application.Interfaces;
+using ExchangeRateUpdater.Api.Presentation.Contracts.Requests;
+using ExchangeRateUpdater.Api.Presentation.Contracts.Responses;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ExchangeRateUpdater.Api.Presentation.Controllers;
+
+///
+/// API controller for retrieving currency exchange rates from external data providers.
+///
+[ApiVersion("1.0")]
+[Route("api/v{version:apiVersion}/exchange-rates")]
+[Produces(MediaTypeNames.Application.Json)]
+public class ExchangeRatesController(IExchangeRateProvider provider) : ApiControllerBase
+{
+ ///
+ /// Get exchange rates
+ ///
+ ///
+ /// Returns daily exchange rates from the configured data source.
+ /// Optionally filter by comma-separated currency codes and/or a specific date.
+ ///
+ [HttpGet(Name = "GetExchangeRates")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status502BadGateway)]
+ public async Task GetExchangeRates(
+ [FromQuery] GetExchangeRatesRequest request,
+ CancellationToken cancellationToken)
+ {
+ var dateResult = ParseDate(request.Date);
+ var currencyCodes = ParseCurrencyCodes(request.Currencies);
+
+ var result = await provider.GetDailyRatesAsync(dateResult, cancellationToken);
+
+ if (result.IsFailure)
+ return ToErrorResponse>(result.Error);
+
+ var rates = result.Value;
+ var filtered = currencyCodes.Length > 0
+ ? rates.Where(r => currencyCodes.Contains(r.SourceCurrency.Code, StringComparer.OrdinalIgnoreCase))
+ : rates;
+
+ var response = filtered.Select(r => new ExchangeRateResponse(
+ r.SourceCurrency.Code,
+ r.TargetCurrency.Code,
+ r.Rate,
+ r.Amount,
+ r.ValidFor.ToString("yyyy-MM-dd"))).ToArray();
+
+ if (currencyCodes.Length > 0 && response.Length == 0)
+ {
+ var requestedCurrencies = string.Join(", ", currencyCodes);
+
+ return ToErrorResponse>(
+ ExchangeRateErrors.CurrencyNotFound(requestedCurrencies));
+ }
+
+ return Ok(ApiResponse>.Success(response));
+ }
+
+ ///
+ /// Get exchange rate by currency code
+ ///
+ ///
+ /// Returns the daily exchange rate for a specific ISO 4217 currency codefrom the configured data source.
+ ///
+ [HttpGet("{currencyCode}", Name = "GetExchangeRateByCode")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status502BadGateway)]
+ public async Task GetExchangeRate(
+ GetExchangeRateByCodeRequest request,
+ CancellationToken cancellationToken)
+ {
+ var dateResult = ParseDate(request.Date);
+
+ var result = await provider.GetDailyRatesAsync(dateResult, cancellationToken);
+
+ if (result.IsFailure)
+ return ToErrorResponse(result.Error);
+
+ var rate = result.Value.FirstOrDefault(r =>
+ r.SourceCurrency.Code.Equals(request.CurrencyCode, StringComparison.OrdinalIgnoreCase));
+
+ if (rate is null)
+ return ToErrorResponse(ExchangeRateErrors.CurrencyNotFound(request.CurrencyCode));
+
+ var response = new ExchangeRateResponse(
+ rate.SourceCurrency.Code,
+ rate.TargetCurrency.Code,
+ rate.Rate,
+ rate.Amount,
+ rate.ValidFor.ToString("yyyy-MM-dd"));
+
+ return Ok(ApiResponse.Success(response));
+ }
+
+ private static string[] ParseCurrencyCodes(string? currencies) =>
+ string.IsNullOrWhiteSpace(currencies)
+ ? []
+ : currencies.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ private static DateOnly? ParseDate(string? date) =>
+ string.IsNullOrWhiteSpace(date)
+ ? null
+ : DateOnly.ParseExact(date, "yyyy-MM-dd");
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Extensions/OpenApiExtensions.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Extensions/OpenApiExtensions.cs
new file mode 100644
index 0000000000..f3e39659ae
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Extensions/OpenApiExtensions.cs
@@ -0,0 +1,32 @@
+using ExchangeRateUpdater.Api.Presentation.Options;
+using Microsoft.Extensions.Options;
+
+namespace ExchangeRateUpdater.Api.Presentation.Extensions;
+
+public static class OpenApiExtensions
+{
+ public static IServiceCollection AddOpenApiDocumentation(
+ this IServiceCollection services,
+ IConfiguration configuration)
+ {
+ services.Configure(
+ configuration.GetSection(OpenApiOptions.SectionName));
+
+ services.AddOpenApi(options =>
+ {
+ options.AddDocumentTransformer((document, context, _) =>
+ {
+ var openApiOptions = context.ApplicationServices
+ .GetRequiredService>().Value;
+
+ document.Info.Title = openApiOptions.Title;
+ document.Info.Version = openApiOptions.Version;
+ document.Info.Description = openApiOptions.Description;
+
+ return Task.CompletedTask;
+ });
+ });
+
+ return services;
+ }
+}
diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Extensions/ServiceCollectionExtensions.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000000..c2c0803a64
--- /dev/null
+++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,47 @@
+using Asp.Versioning;
+using ExchangeRateUpdater.Api.Domain.Common;
+using ExchangeRateUpdater.Api.Presentation.Contracts.Responses;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ExchangeRateUpdater.Api.Presentation.Extensions;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddPresentation(this IServiceCollection services)
+ {
+ services
+ .AddApiVersioning(options =>
+ {
+ options.DefaultApiVersion = new ApiVersion(1, 0);
+ options.AssumeDefaultVersionWhenUnspecified = true;
+ options.ReportApiVersions = true;
+ options.ApiVersionReader = new UrlSegmentApiVersionReader();
+ })
+ .AddApiExplorer(options =>
+ {
+ options.GroupNameFormat = "'v'VVV";
+ options.SubstituteApiVersionInUrl = true;
+ });
+
+ services
+ .AddControllers()
+ .ConfigureApiBehaviorOptions(options =>
+ {
+ options.InvalidModelStateResponseFactory = context =>
+ {
+ var errors = context.ModelState
+ .Where(e => e.Value?.Errors.Count > 0)
+ .SelectMany(e => e.Value!.Errors.Select(err => err.ErrorMessage))
+ .ToArray();
+
+ var response = ApiResponse