diff --git a/jobs/Backend/.gitignore b/jobs/Backend/.gitignore
new file mode 100644
index 0000000000..7a34e8f84f
--- /dev/null
+++ b/jobs/Backend/.gitignore
@@ -0,0 +1,11 @@
+/.vs
+/.vscode
+/Task/.vs
+/jobs/Backend/Task/.vscode
+/jobs/Backend/Task/.vs
+/jobs/Backend/Task/bin
+/jobs/Backend/Task/obj
+/jobs/Backend/Task/Properties
+/jobs/Backend/Task/appsettings.json
+/jobs/Backend/Task/appsettings.Development.json
+/jobs/Backend/Task/appsettings.Production.json
\ No newline at end of file
diff --git a/jobs/Backend/Task/.dockerignore b/jobs/Backend/Task/.dockerignore
new file mode 100644
index 0000000000..ab5bec978a
--- /dev/null
+++ b/jobs/Backend/Task/.dockerignore
@@ -0,0 +1,27 @@
+**/.dockerignore
+**/.env
+**/.env.*
+**/.git
+**/.gitignore
+**/.project
+**/.settings
+**/.toolstarget
+**/.vs
+**/.vscode
+**/*.*proj.user
+**/*.dbmdl
+**/*.jfm
+**/azds.yaml
+**/bin
+**/charts
+**/docker-compose*
+**/Dockerfile*
+**/node_modules
+**/npm-debug.log
+**/obj
+**/secrets.dev.yaml
+**/values.dev.yaml
+**/TestResults
+**/coverage
+LICENSE
+README.md
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/Dockerfile.api b/jobs/Backend/Task/Dockerfile.api
new file mode 100644
index 0000000000..19c6c9dc04
--- /dev/null
+++ b/jobs/Backend/Task/Dockerfile.api
@@ -0,0 +1,39 @@
+FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
+WORKDIR /app
+EXPOSE 8080
+
+FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
+
+WORKDIR /src
+
+COPY ["ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj", "ExchangeRateUpdater.Api/"]
+COPY ["ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj", "ExchangeRateUpdater.Console/"]
+COPY ["ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj", "ExchangeRateUpdater.Domain/"]
+COPY ["ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj", "ExchangeRateUpdater.Infrastructure/"]
+
+RUN dotnet restore "ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj"
+
+COPY . .
+
+WORKDIR "/src/ExchangeRateUpdater.Api"
+RUN dotnet build "ExchangeRateUpdater.Api.csproj" -c Release -o /app/build
+
+WORKDIR "/src/ExchangeRateUpdater.Console"
+RUN dotnet build "ExchangeRateUpdater.Console.csproj" -c Release -o /app/build
+
+FROM build AS publish
+
+WORKDIR "/src/ExchangeRateUpdater.Api"
+RUN dotnet publish "ExchangeRateUpdater.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false
+
+WORKDIR "/src/ExchangeRateUpdater.Console"
+RUN dotnet publish "ExchangeRateUpdater.Console.csproj" -c Release -o /app/publish /p:UseAppHost=false
+
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+
+ENV ASPNETCORE_ENVIRONMENT=Production
+ENV ASPNETCORE_URLS=http://+:8080
+
+ENTRYPOINT ["dotnet", "ExchangeRateUpdater.Api.dll"]
\ No newline at end of file
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.Api/Binders/CommaSeparatedQueryBinder.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Binders/CommaSeparatedQueryBinder.cs
new file mode 100644
index 0000000000..81dce257d2
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Binders/CommaSeparatedQueryBinder.cs
@@ -0,0 +1,24 @@
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace ExchangeRateUpdater.Api.Binders;
+
+public class CommaSeparatedQueryBinder : IModelBinder
+{
+ public Task BindModelAsync(ModelBindingContext bindingContext)
+ {
+ var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName).ToString();
+
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ bindingContext.Result = ModelBindingResult.Success(new List());
+ return Task.CompletedTask;
+ }
+
+ var values = value.Split(',', StringSplitOptions.RemoveEmptyEntries)
+ .Select(v => v.Trim().ToUpperInvariant())
+ .ToList();
+
+ bindingContext.Result = ModelBindingResult.Success(values);
+ return Task.CompletedTask;
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs
new file mode 100644
index 0000000000..5fba3df990
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs
@@ -0,0 +1,65 @@
+using Microsoft.AspNetCore.Mvc;
+using ExchangeRateUpdater.Api.Extensions;
+using ExchangeRateUpdater.Api.Models;
+using ExchangeRateUpdater.Domain.Models;
+using ExchangeRateUpdater.Domain.Extensions;
+using ExchangeRateUpdater.Api.Binders;
+
+namespace ExchangeRateUpdater.Api.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+[Produces("application/json")]
+public class ExchangeRatesController : ControllerBase
+{
+ private readonly IExchangeRateService _exchangeRateService;
+
+ public ExchangeRatesController(IExchangeRateService exchangeRateService)
+ {
+ _exchangeRateService = exchangeRateService ?? throw new ArgumentNullException(nameof(exchangeRateService));
+ }
+
+ ///
+ /// Get exchange rates for specified currencies on specified date or closest business day if date is not provided.
+ ///
+ /// Comma-separated list of currency codes provided as a list of strings like [USD,EUR,JPY] or multiple currency parameters
+ /// Optional date in YYYY-MM-DD format. Defaults to today if not present or if a future date is provided.
+ /// Exchange rates for the specified currencies
+ /// Returns the exchange rates
+ /// If the request is invalid
+ /// If no exchange rates found for the specified currencies
+ [HttpGet]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
+ public async Task>> GetExchangeRates(
+ [ModelBinder(BinderType = typeof(CommaSeparatedQueryBinder))] List currencies,
+ [FromQuery] DateOnly? date = null)
+ {
+ var currencyObjects = ParseCurrencies(currencies);
+
+ var exchangeRates = await _exchangeRateService.GetExchangeRates(currencyObjects, date.AsMaybe());
+ if (!exchangeRates.Any())
+ {
+ var currencyList = string.Join(", ", currencyObjects.Select(c => c.Code));
+ return NotFound(ApiResponseBuilder.NotFound("No results found",
+ $"No exchange rates found for the specified currencies: {currencyList}"));
+ }
+
+ return Ok(ApiResponseBuilder.Success(
+ exchangeRates.ToExchangeRateResponse(date.AsMaybe()),
+ "Exchange rates retrieved successfully"));
+ }
+
+ private static IEnumerable ParseCurrencies(List currencies)
+ {
+ var currencyCodes = currencies.Select(code => code.Trim().ToUpperInvariant()).ToHashSet();
+
+ if (!currencyCodes.Any())
+ {
+ throw new ArgumentException("At least one currency code must be provided");
+ }
+
+ return currencyCodes.Select(code => new Currency(code));
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj
new file mode 100644
index 0000000000..57f79ea316
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net9.0
+ enable
+ enable
+ true
+ $(NoWarn);1591
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/ExchangeRateExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/ExchangeRateExtensions.cs
new file mode 100644
index 0000000000..552b25a227
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/ExchangeRateExtensions.cs
@@ -0,0 +1,26 @@
+using ExchangeRateUpdater.Api.Models;
+using ExchangeRateUpdater.Domain.Common;
+using ExchangeRateUpdater.Domain.Models;
+
+namespace ExchangeRateUpdater.Api.Extensions;
+
+public static class ExchangeRateExtensions
+{
+ public static ExchangeRateResponseDto ToExchangeRateResponse(
+ this IEnumerable exchangeRates,
+ Maybe requestedDate)
+ {
+ var rateList = exchangeRates.ToList();
+
+ return new ExchangeRateResponseDto(
+ Rates: rateList.Select(rate => new ExchangeRateDto(
+ SourceCurrency: rate.SourceCurrency.Code,
+ TargetCurrency: rate.TargetCurrency.Code,
+ Value: rate.Value,
+ Date: rate.Date.ToDateTime(TimeOnly.MinValue)
+ )).ToList(),
+ RequestedDate: requestedDate.TryGetValue(out var date) ? date.ToDateTime(TimeOnly.MinValue) : DateHelper.Today.ToDateTime(TimeOnly.MinValue),
+ TotalCount: rateList.Count
+ );
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Installers/ExchangeRateApiInstaller.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Installers/ExchangeRateApiInstaller.cs
new file mode 100644
index 0000000000..46671d14aa
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Installers/ExchangeRateApiInstaller.cs
@@ -0,0 +1,34 @@
+using ExchangeRateUpdater.Infrastructure.Installers;
+using ExchangeRateUpdater.Api.Middleware;
+
+namespace ExchangeRateUpdater.Api.Extensions;
+
+public static class ExchangeRateApiInstaller
+{
+ public static IServiceCollection AddApiServices(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddControllers();
+ services.AddOpenApiServices();
+ services.AddExchangeRateInfrastructure(configuration, useApiCache: true);
+ services.AddOpenTelemetry(configuration, "ExchangeRateUpdaterApi");
+
+ return services;
+ }
+
+ public static IServiceCollection AddOpenApiServices(this IServiceCollection services)
+ {
+ services.AddOpenApi();
+ services.AddSwaggerGen(options =>
+ {
+ var xmlFilename = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
+ options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename), includeControllerXmlComments: true);
+ });
+
+ return services;
+ }
+
+ public static IApplicationBuilder UseGlobalExceptionHandling(this IApplicationBuilder builder)
+ {
+ return builder.UseMiddleware();
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Middleware/GlobalExceptionHandlingMiddleware.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Middleware/GlobalExceptionHandlingMiddleware.cs
new file mode 100644
index 0000000000..734505f2cb
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Middleware/GlobalExceptionHandlingMiddleware.cs
@@ -0,0 +1,75 @@
+using System.Net;
+using System.Text.Json;
+using ExchangeRateUpdater.Domain.Common;
+using ExchangeRateUpdater.Domain.Models;
+
+namespace ExchangeRateUpdater.Api.Middleware;
+
+public class GlobalExceptionHandlingMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly ILogger _logger;
+ private readonly IHostEnvironment _environment;
+
+ public GlobalExceptionHandlingMiddleware(
+ RequestDelegate next,
+ ILogger logger,
+ IHostEnvironment environment)
+ {
+ _next = next;
+ _logger = logger;
+ _environment = environment;
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ try
+ {
+ await _next(context);
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(context, ex);
+ }
+ }
+
+ private async Task HandleExceptionAsync(HttpContext context, Exception exception)
+ {
+ _logger.LogError(exception, "An unhandled exception occurred.");
+
+ var response = context.Response;
+ response.ContentType = "application/json";
+
+ var apiResponse = new ApiResponse
+ {
+ Success = false,
+ Message = "An error occurred while processing your request",
+ Errors = new List()
+ };
+
+ switch (exception)
+ {
+ case ExchangeRateProviderException:
+ response.StatusCode = (int)HttpStatusCode.BadGateway;
+ apiResponse.Message = "Exchange rate provider error";
+ apiResponse.Errors.Add(exception.Message);
+ break;
+
+ case ArgumentException:
+ response.StatusCode = (int)HttpStatusCode.BadRequest;
+ apiResponse.Message = "Invalid input";
+ apiResponse.Errors.Add(exception.Message);
+ break;
+
+ default:
+ response.StatusCode = (int)HttpStatusCode.InternalServerError;
+ apiResponse.Errors.Add(_environment.IsDevelopment()
+ ? exception.ToString()
+ : "An unexpected error occurred. Please try again later.");
+ break;
+ }
+
+ var result = JsonSerializer.Serialize(apiResponse);
+ await response.WriteAsync(result);
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ExchangeRateDto.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ExchangeRateDto.cs
new file mode 100644
index 0000000000..3ebfdf2fd8
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ExchangeRateDto.cs
@@ -0,0 +1,13 @@
+namespace ExchangeRateUpdater.Api.Models;
+
+public record ExchangeRateDto(
+ string SourceCurrency,
+ string TargetCurrency,
+ decimal Value,
+ DateTime Date);
+
+public record ExchangeRateResponseDto(
+ List Rates,
+ DateTime RequestedDate,
+ int TotalCount);
+
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs
new file mode 100644
index 0000000000..db5f1f5464
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs
@@ -0,0 +1,31 @@
+using ExchangeRateUpdater.Api.Extensions;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Configuration
+ .SetBasePath(Directory.GetCurrentDirectory())
+ .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
+ .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true)
+ .AddEnvironmentVariables();
+
+builder.Services.AddApiServices(builder.Configuration);
+
+var app = builder.Build();
+
+app.UseSwagger();
+app.UseSwaggerUI(options =>
+{
+ options.SwaggerEndpoint("/openapi/v1.json", "Exchange Rate API V1");
+ options.RoutePrefix = "swagger";
+});
+
+app.UseGlobalExceptionHandling();
+app.UseHttpsRedirection();
+app.MapControllers();
+app.MapOpenApi();
+
+app.Run();
+
+public partial class Program { }
+
+
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Properties/launchSettings.json b/jobs/Backend/Task/ExchangeRateUpdater.Api/Properties/launchSettings.json
new file mode 100644
index 0000000000..cf67092c4d
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Properties/launchSettings.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5216",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json
new file mode 100644
index 0000000000..4c55a1bd50
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json
@@ -0,0 +1,14 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "CacheSettings": {
+ "DefaultCacheExpiry": "00:05:00",
+ "SizeLimit": 10,
+ "CompactionPercentage": 0.5,
+ "ExpirationScanFrequency": "00:01:00"
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json
new file mode 100644
index 0000000000..156e441f14
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json
@@ -0,0 +1,26 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft": "Warning",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+ "ExchangeRate": {
+ "MaxRetryAttempts": 3,
+ "RetryDelay": "00:00:02",
+ "RequestTimeout": "00:00:30",
+ "EnableCaching": true
+ },
+ "CzechNationalBank": {
+ "BaseUrl": "https://api.cnb.cz/cnbapi/exrates/daily",
+ "DateFormat": "yyyy-MM-dd",
+ "Language": "EN"
+ },
+ "CacheSettings": {
+ "DefaultCacheExpiry": "24:00:00",
+ "SizeLimit": 1000,
+ "CompactionPercentage": 0.25,
+ "ExpirationScanFrequency": "00:20:00"
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj
new file mode 100644
index 0000000000..67394a6d13
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj
@@ -0,0 +1,24 @@
+
+
+
+ Exe
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs
new file mode 100644
index 0000000000..d43d39c1d6
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs
@@ -0,0 +1,162 @@
+using ExchangeRateUpdater.Domain.Models;
+using ExchangeRateUpdater.Domain.Extensions;
+using ExchangeRateUpdater.Infrastructure.Installers;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using System.CommandLine;
+using System.Globalization;
+
+namespace ExchangeRateUpdater.Console;
+
+public static class Program
+{
+ private static readonly IEnumerable DefaultCurrenciesList =
+ [
+ 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 async Task Main(string[] args)
+ {
+ // Define command line options
+ var dateOption = new Option(
+ "--date",
+ description: "The date to fetch exchange rates for (format: yyyy-MM-dd). Defaults to today.",
+ parseArgument: result =>
+ {
+ if (result.Tokens.Count == 0)
+ return null;
+
+ var dateString = result.Tokens.Single().Value;
+ if (DateOnly.TryParseExact(dateString, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
+ {
+ return date;
+ }
+
+ result.ErrorMessage = $"Invalid date format. Please use yyyy-MM-dd format (e.g., 2023-12-01).";
+ return null;
+ });
+
+ var currenciesOption = new Option(
+ "--currencies",
+ description: "Comma-separated list of currency codes to fetch (e.g., USD,EUR,JPY). Defaults to predefined list.",
+ parseArgument: result =>
+ {
+ if (result.Tokens.Count == 0)
+ return Array.Empty();
+
+ var currenciesString = result.Tokens.Single().Value;
+ return currenciesString.Split(',', StringSplitOptions.RemoveEmptyEntries)
+ .Select(c => c.Trim().ToUpperInvariant())
+ .ToArray();
+ });
+
+ // Create root command
+ var rootCommand = new RootCommand("Fetches exchange rates from the Czech National Bank.")
+ {
+ dateOption,
+ currenciesOption
+ };
+
+ rootCommand.SetHandler(async (DateOnly? date, string[] currencyCodes) =>
+ {
+ await RunExchangeRateUpdaterAsync(date, currencyCodes);
+ }, dateOption, currenciesOption);
+
+ // Parse and execute
+ await rootCommand.InvokeAsync(args);
+ }
+
+ private static async Task RunExchangeRateUpdaterAsync(DateOnly? date, string[] currencyCodes)
+ {
+ try
+ {
+ var currenciesToFetch = GetCurrenciesToFetch(currencyCodes);
+
+ var configuration = new ConfigurationBuilder()
+ .SetBasePath(Directory.GetCurrentDirectory())
+ .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
+ .AddEnvironmentVariables()
+ .Build();
+
+ // Configure services
+ var services = new ServiceCollection();
+ services.AddExchangeRateInfrastructure(configuration, useApiCache: false);
+ services.AddOpenTelemetry(configuration, "ExchangeRateUpdaterConsole");
+
+ // Build service provider
+ var serviceProvider = services.BuildServiceProvider();
+ var exchangeRateService = serviceProvider.GetRequiredService();
+
+ System.Console.WriteLine("=== Exchange Rate Updater ===");
+ System.Console.WriteLine();
+
+ var rates = await exchangeRateService.GetExchangeRates(currenciesToFetch, date.AsMaybe());
+
+ var rateList = rates.ToList();
+ if (rateList.Any())
+ {
+ System.Console.WriteLine($"Successfully retrieved {rateList.Count} exchange rates:");
+ System.Console.WriteLine();
+
+ foreach (var rate in rateList)
+ {
+ System.Console.WriteLine($" {rate}");
+ }
+ }
+ else
+ {
+ System.Console.WriteLine("No exchange rates were found for the requested currencies.");
+ }
+
+ System.Console.WriteLine();
+ System.Console.WriteLine("Press any key to exit...");
+ System.Console.ReadKey();
+ }
+ catch (Exception ex)
+ {
+ System.Console.WriteLine($"Error: {ex.Message}");
+ if (ex.InnerException != null)
+ {
+ System.Console.WriteLine($"Inner Exception: {ex.InnerException.Message}");
+ }
+ System.Console.WriteLine();
+ System.Console.WriteLine("Press any key to exit...");
+ System.Console.ReadKey();
+ }
+ }
+
+ private static IEnumerable GetCurrenciesToFetch(string[] currencyCodes)
+ {
+ if (currencyCodes != null && currencyCodes.Length > 0)
+ {
+ var currencies = new List();
+ foreach (var code in currencyCodes)
+ {
+ try
+ {
+ currencies.Add(new Currency(code));
+ }
+ catch (ArgumentException ex)
+ {
+ System.Console.WriteLine($"Warning: Invalid currency code '{code}' - {ex.Message}");
+ }
+ }
+
+ if (currencies.Any())
+ {
+ return currencies;
+ }
+ }
+
+ System.Console.WriteLine("Warning: No valid currencies provided, using default set.");
+ return DefaultCurrenciesList;
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Console/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater.Console/appsettings.json
new file mode 100644
index 0000000000..d3d26cb538
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Console/appsettings.json
@@ -0,0 +1,21 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft": "Warning",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+ "ExchangeRate": {
+ "MaxRetryAttempts": 3,
+ "RetryDelay": "00:00:02",
+ "RequestTimeout": "00:00:30",
+ "EnableCaching": false
+ },
+ "CzechNationalBank": {
+ "BaseUrl": "https://api.cnb.cz/cnbapi/exrates/daily",
+ "DateFormat": "yyyy-MM-dd",
+ "Language": "EN"
+ }
+}
+
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/DateHelper.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/DateHelper.cs
new file mode 100644
index 0000000000..5c8bd58557
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/DateHelper.cs
@@ -0,0 +1,6 @@
+namespace ExchangeRateUpdater.Domain.Common;
+
+public static class DateHelper
+{
+ public static DateOnly Today => DateOnly.FromDateTime(DateTime.Today);
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/ExchangeRateProviderException.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/ExchangeRateProviderException.cs
new file mode 100644
index 0000000000..32db494a98
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/ExchangeRateProviderException.cs
@@ -0,0 +1,7 @@
+namespace ExchangeRateUpdater.Domain.Common;
+
+public class ExchangeRateProviderException : Exception
+{
+ public ExchangeRateProviderException(string message) : base(message) { }
+ public ExchangeRateProviderException(string message, Exception innerException) : base(message, innerException) { }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/Maybe.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/Maybe.cs
new file mode 100644
index 0000000000..c8c8b6f531
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/Maybe.cs
@@ -0,0 +1,66 @@
+namespace ExchangeRateUpdater.Domain.Common;
+
+public readonly struct Maybe
+{
+ private readonly T? _value;
+ private readonly bool _hasValue;
+
+ private Maybe(T value)
+ {
+ _value = value;
+ _hasValue = true;
+ }
+
+ public static Maybe Nothing
+ {
+ get => default;
+ }
+
+ public static implicit operator Maybe(T? value) => value != null ? new Maybe(value) : Nothing;
+
+ public bool HasValue => _hasValue;
+
+ ///
+ /// Gets the value if it exists, otherwise throws an exception
+ ///
+ public T Value => _hasValue ? _value! : throw new InvalidOperationException("Maybe has no value");
+
+
+ public T GetValueOrDefault(T defaultValue = default!)
+ {
+ return _hasValue ? _value! : defaultValue;
+ }
+
+ public bool TryGetValue(out T value)
+ {
+ value = _hasValue ? _value! : default!;
+ return _hasValue;
+ }
+
+ public override bool Equals(object? obj)
+ {
+ if (obj is Maybe other)
+ {
+ if (!_hasValue && !other._hasValue)
+ return true;
+ if (_hasValue && other._hasValue)
+ return EqualityComparer.Default.Equals(_value, other._value);
+ }
+ return false;
+ }
+
+ public static bool operator ==(Maybe left, Maybe right)
+ {
+ return left.Equals(right);
+ }
+
+ public static bool operator !=(Maybe left, Maybe right)
+ {
+ return !left.Equals(right);
+ }
+
+ public override int GetHashCode()
+ {
+ return _hasValue ? EqualityComparer.Default.GetHashCode(_value!) : 0;
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj
new file mode 100644
index 0000000000..9acc731bc0
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj
@@ -0,0 +1,16 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/AsReadonlyExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/AsReadonlyExtensions.cs
new file mode 100644
index 0000000000..70ef6bafb7
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/AsReadonlyExtensions.cs
@@ -0,0 +1,25 @@
+using System.Diagnostics;
+
+namespace ExchangeRateUpdater.Domain.Extensions;
+
+[DebuggerStepThrough]
+public static class AsReadonlyExtensions
+{
+ public static IReadOnlyList AsReadOnlyList(this IEnumerable self)
+ {
+ return self switch
+ {
+ IReadOnlyList list => list,
+ _ => self.ToList()
+ };
+ }
+
+ public static IReadOnlyCollection AsReadOnlyCollection(this IEnumerable self)
+ {
+ return self switch
+ {
+ IReadOnlyCollection collection => collection,
+ _ => self.ToList()
+ };
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/MaybeExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/MaybeExtensions.cs
new file mode 100644
index 0000000000..3a20677629
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/MaybeExtensions.cs
@@ -0,0 +1,10 @@
+using ExchangeRateUpdater.Domain.Common;
+
+namespace ExchangeRateUpdater.Domain.Extensions;
+
+public static class MaybeExtensions
+{
+ public static Maybe AsMaybe(this T? self) => self;
+ public static Maybe AsMaybe(this T? self) where T : struct => self ?? Maybe.Nothing;
+ public static Maybe AsMaybe(this object self) where T : class => self as T;
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/TaskExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/TaskExtensions.cs
new file mode 100644
index 0000000000..65746ff21b
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/TaskExtensions.cs
@@ -0,0 +1,6 @@
+namespace ExchangeRateUpdater.Domain.Extensions;
+
+public static class TaskExtensions
+{
+ public static Task AsTask(this T value) => Task.FromResult(value);
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateCache.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateCache.cs
new file mode 100644
index 0000000000..4782020ab9
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateCache.cs
@@ -0,0 +1,10 @@
+using ExchangeRateUpdater.Domain.Common;
+using ExchangeRateUpdater.Domain.Models;
+
+namespace ExchangeRateUpdater.Domain.Interfaces;
+
+public interface IExchangeRateCache
+{
+ Task>> GetCachedRates(IEnumerable currencies, DateOnly date);
+ Task CacheRates(IReadOnlyCollection rates);
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateProvider.cs
new file mode 100644
index 0000000000..31397101fb
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateProvider.cs
@@ -0,0 +1,17 @@
+using ExchangeRateUpdater.Domain.Common;
+using ExchangeRateUpdater.Domain.Models;
+
+namespace ExchangeRateUpdater.Domain.Interfaces;
+
+public interface IExchangeRateProvider
+{
+ ///
+ /// Gets exchange rates for the specified currencies for a specific date
+ ///
+ /// The date to get exchange rates for (uses today if None)
+ /// Maybe containing collection of exchange rates
+ Task>> GetExchangeRatesForDate(Maybe date);
+
+ string ProviderName { get; }
+ string BaseCurrency { get; }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateService.cs
new file mode 100644
index 0000000000..d960ce35be
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateService.cs
@@ -0,0 +1,7 @@
+using ExchangeRateUpdater.Domain.Common;
+using ExchangeRateUpdater.Domain.Models;
+
+public interface IExchangeRateService
+{
+ Task> GetExchangeRates(IEnumerable currencies, Maybe date);
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ApiResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ApiResponse.cs
new file mode 100644
index 0000000000..e3e7151309
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ApiResponse.cs
@@ -0,0 +1,14 @@
+namespace ExchangeRateUpdater.Domain.Models;
+
+public class ApiResponse
+{
+ public bool Success { get; set; }
+ public string Message { get; set; } = string.Empty;
+ public List Errors { get; set; } = new List();
+ public DateTime Timestamp { get; set; } = DateTime.UtcNow;
+}
+
+public class ApiResponse : ApiResponse
+{
+ public T? Data { get; set; }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ApiResponseBuilder.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ApiResponseBuilder.cs
new file mode 100644
index 0000000000..d789c3b030
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ApiResponseBuilder.cs
@@ -0,0 +1,72 @@
+namespace ExchangeRateUpdater.Domain.Models;
+
+public class ApiResponseBuilder
+{
+ private bool _success;
+ private string _message = string.Empty;
+ private List _errors = new();
+ private DateTime _timestamp = DateTime.UtcNow;
+
+ public ApiResponseBuilder WithSuccess(bool success = true)
+ {
+ _success = success;
+ return this;
+ }
+
+ public ApiResponseBuilder WithMessage(string message)
+ {
+ _message = message;
+ return this;
+ }
+
+ public ApiResponseBuilder WithErrors(params string[] errors)
+ {
+ _errors.AddRange(errors);
+ return this;
+ }
+
+ public ApiResponseBuilder WithTimestamp(DateTime timestamp)
+ {
+ _timestamp = timestamp;
+ return this;
+ }
+
+ public ApiResponse Build()
+ {
+ return new ApiResponse
+ {
+ Success = _success,
+ Message = _message,
+ Errors = _errors,
+ Timestamp = _timestamp
+ };
+ }
+
+ public ApiResponse Build(T data)
+ {
+ return new ApiResponse
+ {
+ Success = _success,
+ Message = _message,
+ Data = data,
+ Errors = _errors,
+ Timestamp = _timestamp
+ };
+ }
+
+ // Convenience static methods for common scenarios
+ public static ApiResponse Success(string message = "Operation completed successfully")
+ => new ApiResponseBuilder().WithSuccess().WithMessage(message).Build();
+
+ public static ApiResponse Success(T data, string message = "Operation completed successfully")
+ => new ApiResponseBuilder().WithSuccess().WithMessage(message).Build(data);
+
+ public static ApiResponse BadRequest(string message, params string[] errors)
+ => new ApiResponseBuilder().WithSuccess(false).WithMessage(message).WithErrors(errors).Build();
+
+ public static ApiResponse NotFound(string message, params string[] errors)
+ => new ApiResponseBuilder().WithSuccess(false).WithMessage(message).WithErrors(errors).Build();
+
+ public static ApiResponse InternalError(string message = "An unexpected error occurred")
+ => new ApiResponseBuilder().WithSuccess(false).WithMessage(message).Build();
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CacheSettings.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CacheSettings.cs
new file mode 100644
index 0000000000..4f58eadd8b
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CacheSettings.cs
@@ -0,0 +1,9 @@
+namespace ExchangeRateUpdater.Domain.Models;
+
+public class CacheSettings
+{
+ public TimeSpan DefaultCacheExpiry { get; set; } = TimeSpan.FromHours(12);
+ public int SizeLimit { get; set; } = 1000;
+ public double CompactionPercentage { get; set; } = 0.25;
+ public TimeSpan ExpirationScanFrequency { get; set; } = TimeSpan.FromMinutes(5);
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CnbApiResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CnbApiResponse.cs
new file mode 100644
index 0000000000..b8be067b35
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CnbApiResponse.cs
@@ -0,0 +1,36 @@
+using System.Text.Json.Serialization;
+
+namespace ExchangeRateUpdater.Domain.Models;
+
+///
+/// Root response object from Czech National Bank API
+///
+public class CnbApiResponse
+{
+ [JsonPropertyName("rates")]
+ public List Rates { get; set; } = new();
+}
+
+public class CnbExchangeRateDto
+{
+ [JsonPropertyName("validFor")]
+ public string ValidFor { get; set; } = string.Empty;
+
+ [JsonPropertyName("order")]
+ public int Order { get; set; }
+
+ [JsonPropertyName("country")]
+ public string Country { get; set; } = string.Empty;
+
+ [JsonPropertyName("currency")]
+ public string Currency { get; set; } = string.Empty;
+
+ [JsonPropertyName("amount")]
+ public int Amount { get; set; }
+
+ [JsonPropertyName("currencyCode")]
+ public string CurrencyCode { get; set; } = string.Empty;
+
+ [JsonPropertyName("rate")]
+ public decimal Rate { get; set; }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/Currency.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/Currency.cs
new file mode 100644
index 0000000000..e1ab5cecf9
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/Currency.cs
@@ -0,0 +1,25 @@
+namespace ExchangeRateUpdater.Domain.Models;
+
+///
+/// Currency with a three-letter ISO 4217 code.
+///
+public record Currency(string Code)
+{
+ public string Code { get; } = ValidateAndNormalizeCode(Code);
+
+ private static string ValidateAndNormalizeCode(string code)
+ {
+ if (string.IsNullOrWhiteSpace(code))
+ throw new ArgumentException("Currency code cannot be null or empty", nameof(code));
+
+ if (code.Length != 3)
+ throw new ArgumentException("Currency code must be exactly 3 characters", nameof(code));
+
+ return code.ToUpperInvariant();
+ }
+
+ public override string ToString()
+ {
+ return Code;
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CzechNationalBankOptions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CzechNationalBankOptions.cs
new file mode 100644
index 0000000000..ebf59a3ffa
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CzechNationalBankOptions.cs
@@ -0,0 +1,15 @@
+namespace ExchangeRateUpdater.Domain.Models;
+
+public enum CnbLanguage
+{
+ EN,
+ CZ
+}
+
+public class CzechNationalBankOptions
+{
+ public const string SectionName = "CzechNationalBank";
+ public string BaseUrl { get; set; } = "https://api.cnb.cz/cnbapi/exrates/daily";
+ public string DateFormat { get; set; } = "yyyy-MM-dd";
+ public CnbLanguage Language { get; set; } = CnbLanguage.EN;
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRate.cs
new file mode 100644
index 0000000000..86483d64fc
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRate.cs
@@ -0,0 +1,9 @@
+namespace ExchangeRateUpdater.Domain.Models;
+
+public record ExchangeRate(Currency SourceCurrency, Currency TargetCurrency, decimal Value, DateOnly Date)
+{
+ public override string ToString()
+ {
+ return $"{SourceCurrency}/{TargetCurrency}={Value} (Date: {Date:yyyy-MM-dd})";
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRateOptions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRateOptions.cs
new file mode 100644
index 0000000000..cc25927c47
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRateOptions.cs
@@ -0,0 +1,10 @@
+namespace ExchangeRateUpdater.Domain.Models;
+
+public class ExchangeRateOptions
+{
+ public const string SectionName = "ExchangeRate";
+ public int MaxRetryAttempts { get; set; } = 3;
+ public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(2);
+ public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30);
+ public bool EnableCaching { get; set; } = true;
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/ApiExchangeRateCache.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/ApiExchangeRateCache.cs
new file mode 100644
index 0000000000..7ec0eb10ae
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/ApiExchangeRateCache.cs
@@ -0,0 +1,150 @@
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
+using ExchangeRateUpdater.Domain.Common;
+using ExchangeRateUpdater.Domain.Interfaces;
+using ExchangeRateUpdater.Domain.Models;
+using ExchangeRateUpdater.Infrastructure.Telemetry;
+using PublicHoliday;
+using ExchangeRateUpdater.Domain.Extensions;
+using Microsoft.Extensions.Options;
+
+namespace ExchangeRateUpdater.Infrastructure.Caching;
+
+// Memory cache implementation for exchange rates that handles business day logic and provides efficient currency filtering.
+public class ApiExchangeRateCache : IExchangeRateCache
+{
+ private readonly IMemoryCache _memoryCache;
+ private readonly ILogger _logger;
+ private readonly CzechRepublicPublicHoliday _czechRepublicPublicHoliday = new();
+ private readonly CacheSettings _cacheSettings;
+
+ public ApiExchangeRateCache(IMemoryCache memoryCache, ILogger logger, IOptions cacheSettings)
+ {
+ _memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _cacheSettings = cacheSettings?.Value ?? throw new ArgumentNullException(nameof(cacheSettings));
+ }
+
+ public Task>> GetCachedRates(IEnumerable currencies, DateOnly date)
+ {
+ using var activity = ExchangeRateTelemetry.ActivitySource.StartActivity("GetCachedRates");
+ activity?.SetTag("currency.count", currencies.Count());
+ activity?.SetTag("date", date.ToString());
+
+ if (currencies == null)
+ throw new ArgumentNullException(nameof(currencies));
+
+ var currencyList = currencies.ToList();
+ if (!currencyList.Any())
+ return Maybe>.Nothing.AsTask();
+
+ try
+ {
+ // First, try to get cached rates for the exact requested date
+ var exactDateKey = GetCacheKey(date);
+ if (TryGetCachedRates(exactDateKey, currencyList, out var cachedRatesT))
+ return cachedRatesT.AsTask();
+
+ // If exact date not found, check if it is a business day; if not, find the previous business day
+ var businessDate = GetBusinessDayForCacheCheck(date);
+ if (businessDate != date){
+ var businessDateKey = GetCacheKey(businessDate);
+ if (TryGetCachedRates(businessDateKey, currencyList, out var cachedRates))
+ return cachedRates.AsTask();
+ }
+
+ _logger.LogInformation($"Cache MISS - No rates found for date {date:yyyy-MM-dd} or previous business day {businessDate:yyyy-MM-dd}");
+ ExchangeRateTelemetry.CacheMisses.Add(1, new KeyValuePair("currency.count", currencyList.Count));
+ return Maybe>.Nothing.AsTask();
+ }
+ finally
+ {
+ ExchangeRateTelemetry.CacheOperationDuration.Record(activity?.Duration.TotalSeconds ?? 0);
+ }
+ }
+
+ public Task CacheRates(IReadOnlyCollection rates)
+ {
+ using var activity = ExchangeRateTelemetry.ActivitySource.StartActivity("CacheRates");
+ activity?.SetTag("rates.count", rates.Count);
+
+ if (rates == null)
+ throw new ArgumentNullException(nameof(rates));
+
+ if (!rates.Any())
+ return Task.CompletedTask;
+
+ try
+ {
+ var providerDate = rates.First().Date;
+
+ var cacheOptions = new MemoryCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = _cacheSettings.DefaultCacheExpiry,
+ SlidingExpiration = _cacheSettings.DefaultCacheExpiry / 2,
+ Size = 1
+ };
+
+ var cacheKey = GetCacheKey(providerDate);
+ _memoryCache.Set(cacheKey, rates, cacheOptions);
+
+ _logger.LogInformation($"Cache SET - Key: {cacheKey}, Rates: {rates.Count}, Provider date: {providerDate:yyyy-MM-dd}");
+ ExchangeRateTelemetry.CacheOperations.Add(1, new KeyValuePair("rates.count", rates.Count));
+
+ return Task.CompletedTask;
+ }
+ finally
+ {
+ ExchangeRateTelemetry.CacheOperationDuration.Record(activity?.Duration.TotalSeconds ?? 0);
+ }
+ }
+
+ private bool TryGetCachedRates(string cacheKey, List currencyList, out Maybe> cachedRatesValue)
+ {
+ if (_memoryCache.TryGetValue(cacheKey, out List? cachedRates) && cachedRates != null)
+ {
+ _logger.LogInformation($"Cache HIT - date key: {cacheKey}");
+ ExchangeRateTelemetry.CacheHits.Add(1, new KeyValuePair("currency.count", currencyList.Count));
+
+ var requestedCurrencyCodes = currencyList.Select(c => c.Code).ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var filteredRates = cachedRates.Where(rate => requestedCurrencyCodes.Contains(rate.SourceCurrency.Code)).ToList();
+
+ if (filteredRates.Any())
+ {
+ cachedRatesValue = filteredRates.AsReadOnlyList().AsMaybe();
+ return true;
+ }
+ }
+
+ cachedRatesValue = Maybe>.Nothing;
+ return false;
+ }
+
+ ///
+ /// CNB API returns the closest business date in case we request rates for a holiday or weekend or simply before 2:30 PM when the rates are published.
+ /// This is to match that behaviour when reading the cache.
+ ///
+ ///
+ ///
+ private DateOnly GetBusinessDayForCacheCheck(DateOnly date)
+ {
+ var checkDate = date;
+
+ if (checkDate == DateHelper.Today)
+ {
+ checkDate = checkDate.AddDays(-1);
+ }
+
+ while (_czechRepublicPublicHoliday.IsPublicHoliday(checkDate.ToDateTime(TimeOnly.MinValue)) || checkDate.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
+ {
+ checkDate = checkDate.AddDays(-1);
+ }
+
+ return checkDate;
+ }
+
+ private static string GetCacheKey(DateOnly businessDate)
+ {
+ return $"ExchangeRates_{businessDate:yyyy-MM-dd}";
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/NoOpExchangeRateCache.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/NoOpExchangeRateCache.cs
new file mode 100644
index 0000000000..56c3913369
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/NoOpExchangeRateCache.cs
@@ -0,0 +1,24 @@
+using ExchangeRateUpdater.Domain.Common;
+using ExchangeRateUpdater.Domain.Extensions;
+using ExchangeRateUpdater.Domain.Interfaces;
+using ExchangeRateUpdater.Domain.Models;
+
+namespace ExchangeRateUpdater.Infrastructure.Caching;
+
+public class NoOpExchangeRateCache : IExchangeRateCache
+{
+ public Task>> GetCachedRates(IEnumerable currencies, DateOnly date)
+ {
+ return Maybe>.Nothing.AsTask();
+ }
+
+ public Task CacheRates(IReadOnlyCollection rates)
+ {
+ return Task.CompletedTask;
+ }
+
+ public Task ClearCache()
+ {
+ return Task.CompletedTask;
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj
new file mode 100644
index 0000000000..471c31ac3c
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Extensions/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000000..c00b884e65
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,46 @@
+using ExchangeRateUpdater.Domain.Models;
+using ExchangeRateUpdater.Domain.Interfaces;
+using ExchangeRateUpdater.Infrastructure.Providers;
+using ExchangeRateUpdater.Infrastructure.Services;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Polly;
+using Polly.Extensions.Http;
+
+namespace ExchangeRateUpdater.Infrastructure.Extensions;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddExchangeRateInfrastructureDependencies(this IServiceCollection services, IConfiguration configuration)
+ {
+ var exchangeRateOptions = configuration.GetSection(ExchangeRateOptions.SectionName).Get() ?? new ExchangeRateOptions();
+
+ services.AddHttpClient((serviceProvider, client) =>
+ {
+ var options = serviceProvider.GetRequiredService>().Value;
+ client.BaseAddress = new Uri(options.BaseUrl);
+ client.Timeout = TimeSpan.FromSeconds(30);
+ })
+ .AddPolicyHandler(GetRetryPolicy(exchangeRateOptions))
+ .AddPolicyHandler(Policy.TimeoutAsync(exchangeRateOptions.RequestTimeout));
+
+ services.AddScoped();
+ services.AddScoped();
+
+ return services;
+ }
+
+ private static IAsyncPolicy GetRetryPolicy(ExchangeRateOptions options)
+ {
+ return HttpPolicyExtensions
+ .HandleTransientHttpError()
+ .WaitAndRetryAsync(
+ options.MaxRetryAttempts,
+ retryAttempt => retryAttempt * options.RetryDelay,
+ onRetry: (outcome, timespan, retryCount, context) =>
+ {
+ Console.WriteLine($"Retry {retryCount} after {timespan.TotalMilliseconds}ms due to: {outcome.Exception?.Message ?? outcome.Result?.StatusCode.ToString()}");
+ });
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/CacheInstaller.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/CacheInstaller.cs
new file mode 100644
index 0000000000..c95ae9d97b
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/CacheInstaller.cs
@@ -0,0 +1,35 @@
+using ExchangeRateUpdater.Domain.Interfaces;
+using ExchangeRateUpdater.Domain.Models;
+using ExchangeRateUpdater.Infrastructure.Caching;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ExchangeRateUpdater.Infrastructure.Installers;
+
+public static class CacheInstaller
+{
+ public static IServiceCollection AddCaching(this IServiceCollection services, IConfiguration configuration, bool useApiCache = true)
+ {
+ if (useApiCache)
+ {
+ services.Configure(configuration.GetSection("CacheSettings"));
+ var cacheSettings = configuration.GetSection("CacheSettings").Get() ?? new CacheSettings();
+
+ services.AddMemoryCache(options =>
+ {
+ options.SizeLimit = cacheSettings.SizeLimit;
+ options.CompactionPercentage = cacheSettings.CompactionPercentage;
+ options.ExpirationScanFrequency = cacheSettings.ExpirationScanFrequency;
+ });
+
+ services.AddSingleton();
+ }
+ else
+ {
+ services.AddSingleton();
+ }
+
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/ExchangeRateInstaller.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/ExchangeRateInstaller.cs
new file mode 100644
index 0000000000..54dc4657bd
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/ExchangeRateInstaller.cs
@@ -0,0 +1,53 @@
+using ExchangeRateUpdater.Domain.Models;
+using ExchangeRateUpdater.Domain.Interfaces;
+using ExchangeRateUpdater.Infrastructure.Providers;
+using ExchangeRateUpdater.Infrastructure.Services;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Polly;
+using Polly.Extensions.Http;
+
+namespace ExchangeRateUpdater.Infrastructure.Installers;
+
+public static class ExchangeRateInstaller
+{
+ public static IServiceCollection AddExchangeRateServices(this IServiceCollection services, IConfiguration configuration)
+ {
+ // Configure options
+ services.Configure(configuration.GetSection(ExchangeRateOptions.SectionName));
+ services.Configure(configuration.GetSection(CzechNationalBankOptions.SectionName));
+
+ var exchangeRateOptions = configuration.GetSection(ExchangeRateOptions.SectionName).Get()
+ ?? new ExchangeRateOptions();
+
+ // Configure CNB Provider with resilience policies
+ services.AddHttpClient((serviceProvider, client) =>
+ {
+ var options = serviceProvider.GetRequiredService>().Value;
+ client.BaseAddress = new Uri(options.BaseUrl);
+ client.Timeout = TimeSpan.FromSeconds(30);
+ })
+ .AddPolicyHandler(GetRetryPolicy(exchangeRateOptions))
+ .AddPolicyHandler(Policy.TimeoutAsync(exchangeRateOptions.RequestTimeout));
+
+ // Register services
+ services.AddScoped();
+ services.AddScoped();
+
+ return services;
+ }
+
+ private static IAsyncPolicy GetRetryPolicy(ExchangeRateOptions options)
+ {
+ return HttpPolicyExtensions
+ .HandleTransientHttpError()
+ .WaitAndRetryAsync(
+ options.MaxRetryAttempts,
+ retryAttempt => retryAttempt * options.RetryDelay,
+ onRetry: (outcome, timespan, retryCount, context) =>
+ {
+ Console.WriteLine($"Retry {retryCount} after {timespan.TotalMilliseconds}ms due to: {outcome.Exception?.Message ?? outcome.Result?.StatusCode.ToString()}");
+ });
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/OpenTelemetryInstaller.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/OpenTelemetryInstaller.cs
new file mode 100644
index 0000000000..9407af1a9f
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/OpenTelemetryInstaller.cs
@@ -0,0 +1,66 @@
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+using ExchangeRateUpdater.Infrastructure.Telemetry;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Configuration;
+
+namespace ExchangeRateUpdater.Infrastructure.Installers;
+
+public static class OpenTelemetryInstaller
+{
+ public static IServiceCollection AddOpenTelemetry(this IServiceCollection services, IConfiguration configuration, string serviceName)
+ {
+ services.AddOpenTelemetry()
+ .WithTracing(builder =>
+ {
+ builder
+ .AddAspNetCoreInstrumentation(options =>
+ {
+ options.RecordException = true;
+ options.EnrichWithHttpRequest = (activity, httpRequest) =>
+ {
+ activity.SetTag("http.request.body.size", httpRequest.ContentLength);
+ };
+ options.EnrichWithHttpResponse = (activity, httpResponse) =>
+ {
+ activity.SetTag("http.response.body.size", httpResponse.ContentLength);
+ };
+ })
+ .AddHttpClientInstrumentation(options =>
+ {
+ options.RecordException = true;
+ options.EnrichWithHttpRequestMessage = (activity, request) =>
+ {
+ activity.SetTag("http.client.request.url", request.RequestUri?.ToString());
+ };
+ })
+ .AddSource(ExchangeRateTelemetry.ActivitySource.Name)
+ .SetResourceBuilder(ResourceBuilder.CreateDefault()
+ .AddService(serviceName, "1.0.0")
+ .AddAttributes(new Dictionary
+ {
+ ["deployment.environment"] = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"
+ }))
+ .AddConsoleExporter();
+ // .AddOtlpExporter(options =>
+ // {
+ // options.Endpoint = new Uri(configuration["OpenTelemetry:OtlpEndpoint"] ?? "http://localhost:4317");
+ // });
+ })
+ .WithMetrics(builder =>
+ {
+ builder
+ .AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddMeter(ExchangeRateTelemetry.Meter.Name)
+ .AddConsoleExporter();
+ // .AddOtlpExporter(options =>
+ // {
+ // options.Endpoint = new Uri(configuration["OpenTelemetry:OtlpEndpoint"] ?? "http://localhost:4317");
+ // });
+ });
+
+ return services;
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000000..cc6ab0e798
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/ServiceCollectionExtensions.cs
@@ -0,0 +1,19 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ExchangeRateUpdater.Infrastructure.Installers;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddExchangeRateInfrastructure(
+ this IServiceCollection services,
+ IConfiguration configuration,
+ bool useApiCache = true)
+ {
+ services
+ .AddExchangeRateServices(configuration)
+ .AddCaching(configuration, useApiCache);
+
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Providers/CzechNationalBankProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Providers/CzechNationalBankProvider.cs
new file mode 100644
index 0000000000..3d3c5e9c9d
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Providers/CzechNationalBankProvider.cs
@@ -0,0 +1,128 @@
+using System.Text.Json;
+using ExchangeRateUpdater.Domain.Common;
+using ExchangeRateUpdater.Domain.Models;
+using ExchangeRateUpdater.Domain.Extensions;
+using ExchangeRateUpdater.Domain.Interfaces;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace ExchangeRateUpdater.Infrastructure.Providers;
+
+///
+/// Exchange rate provider for Czech National Bank
+///
+public class CzechNationalBankProvider : IExchangeRateProvider
+{
+ private readonly HttpClient _httpClient;
+ private readonly CzechNationalBankOptions _options;
+ private readonly ILogger _logger;
+
+ public string ProviderName => "Czech National Bank";
+ public string BaseCurrency => "CZK";
+
+ public CzechNationalBankProvider(
+ HttpClient httpClient,
+ IOptions options,
+ ILogger logger)
+ {
+ _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ _options = options?.Value ?? throw new ArgumentNullException(nameof(options));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task>> GetExchangeRatesForDate(Maybe date)
+ {
+ var targetDate = date.GetValueOrDefault(DateHelper.Today);
+ _logger.LogInformation($"Fetching exchange rates from {ProviderName} currencies for date {targetDate}");
+
+ try
+ {
+ var url = BuildApiUrl(targetDate);
+ _logger.LogInformation($"Requesting data from: {url}");
+
+ var response = await _httpClient.GetAsync(url);
+ response.EnsureSuccessStatusCode();
+
+ var jsonContent = await response.Content.ReadAsStringAsync();
+ var rates = ParseCnbJsonFormat(jsonContent);
+
+ if (!rates.Any())
+ {
+ _logger.LogWarning("No rates found in API response");
+ return Maybe>.Nothing;
+ }
+
+ _logger.LogInformation($"Successfully retrieved {rates.Count} exchange rates from {ProviderName}");
+
+ return rates.AsReadOnlyCollection().AsMaybe();
+ }
+ catch (HttpRequestException ex)
+ {
+ _logger.LogError(ex, $"HTTP error occurred while fetching exchange rates from {ProviderName}");
+ throw new ExchangeRateProviderException($"Failed to fetch exchange rates from {ProviderName}", ex);
+ }
+ catch (FormatException ex)
+ {
+ _logger.LogError(ex, $"Parsing error occurred while processing response from {ProviderName}");
+ throw new ExchangeRateProviderException($"Failed to parse response from {ProviderName}", ex);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, $"Unexpected error occurred while fetching exchange rates from {ProviderName}");
+ throw new ExchangeRateProviderException($"Unexpected error occurred while fetching exchange rates from {ProviderName}", ex);
+ }
+ }
+
+ private string BuildApiUrl(DateOnly date)
+ {
+ var dateString = date.ToString(_options.DateFormat);
+ return $"{_options.BaseUrl}?date={dateString}&lang={_options.Language}";
+ }
+
+ private List ParseCnbJsonFormat(string jsonContent)
+ {
+ var rates = new List();
+
+ try
+ {
+ var options = new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ var apiResponse = JsonSerializer.Deserialize(jsonContent, options);
+
+ if (apiResponse?.Rates == null)
+ {
+ _logger.LogWarning("No rates found in JSON response");
+ return rates;
+ }
+
+ foreach (var rateDto in apiResponse.Rates)
+ {
+ // CNB rates are given as: amount units = rate CZK
+ // We want: 1 unit = (rate / amount) CZK
+ var ratePerUnit = rateDto.Rate / rateDto.Amount;
+ var sourceCurrency = new Currency(rateDto.CurrencyCode);
+ var targetCurrency = new Currency(BaseCurrency);
+
+ DateOnly exchangeDate = DateOnly.TryParse(rateDto.ValidFor, out var parsedDate) ? parsedDate : DateHelper.Today;
+
+ var exchangeRate = new ExchangeRate(sourceCurrency, targetCurrency, ratePerUnit, exchangeDate);
+ rates.Add(exchangeRate);
+ }
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogError(ex, "Error parsing CNB JSON format");
+ throw new ExchangeRateProviderException("Failed to parse CNB JSON response format", ex);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error processing CNB JSON response");
+ throw new ExchangeRateProviderException("Failed to process CNB JSON response", ex);
+ }
+
+ return rates;
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Services/ExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Services/ExchangeRateService.cs
new file mode 100644
index 0000000000..ca18c0fa34
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Services/ExchangeRateService.cs
@@ -0,0 +1,121 @@
+using ExchangeRateUpdater.Domain.Common;
+using ExchangeRateUpdater.Domain.Models;
+using ExchangeRateUpdater.Domain.Interfaces;
+using ExchangeRateUpdater.Infrastructure.Telemetry;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace ExchangeRateUpdater.Infrastructure.Services;
+
+public class ExchangeRateService : IExchangeRateService
+{
+ private readonly IExchangeRateProvider _provider;
+ private readonly IExchangeRateCache _cache;
+ private readonly ILogger _logger;
+ private readonly ExchangeRateOptions _exchangeOptions;
+
+ public ExchangeRateService(
+ IExchangeRateProvider provider,
+ IExchangeRateCache cache,
+ ILogger logger,
+ IOptions options)
+ {
+ _provider = provider ?? throw new ArgumentNullException(nameof(provider));
+ _cache = cache ?? throw new ArgumentNullException(nameof(cache));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _exchangeOptions = options?.Value ?? throw new ArgumentNullException(nameof(options));
+ }
+
+ public async Task> GetExchangeRates(
+ IEnumerable currencies,
+ Maybe date
+ )
+ {
+ using var activity = ExchangeRateTelemetry.ActivitySource.StartActivity("GetExchangeRates");
+ activity?.SetTag("currency.count", currencies.Count());
+ activity?.SetTag("date", date.ToString());
+
+ if (currencies == null)
+ throw new ArgumentNullException(nameof(currencies));
+
+ var currencyList = currencies.ToList();
+ if (!currencyList.Any())
+ return Enumerable.Empty();
+
+ var targetDate = DateHelper.Today;
+ if (date.TryGetValue(out var providedValue))
+ targetDate = providedValue > DateHelper.Today ? DateHelper.Today : providedValue;
+
+ _logger.LogInformation($"Getting exchange rates for {currencyList.Count} currencies ({string.Join(", ", currencyList.Select(c => c.Code))}) for date {targetDate:yyyy-MM-dd}");
+ var cachedRates = Maybe>.Nothing;
+
+ try
+ {
+ if (_exchangeOptions.EnableCaching)
+ {
+ cachedRates = await _cache.GetCachedRates(currencyList, targetDate);
+ if (cachedRates.HasValue)
+ {
+ _logger.LogInformation($"Returning {cachedRates.Value.Count()} cached exchange rates");
+ ExchangeRateTelemetry.CacheHits.Add(1, new KeyValuePair("currency.count", currencyList.Count));
+ return cachedRates.Value;
+ }
+ else
+ {
+ ExchangeRateTelemetry.CacheMisses.Add(1, new KeyValuePair("currency.count", currencyList.Count));
+ }
+ }
+
+ // Fetch from provider
+ _logger.LogInformation($"Fetching fresh exchange rates from {_provider.ProviderName}");
+ var maybeRates = await _provider.GetExchangeRatesForDate(date);
+
+ if (maybeRates.TryGetValue(out var rateList))
+ {
+ if (rateList.Any())
+ {
+ if (_exchangeOptions.EnableCaching)
+ {
+ await _cache.CacheRates(rateList);
+ }
+
+ _logger.LogInformation($"Successfully retrieved {rateList.Count()} exchange rates");
+ ExchangeRateTelemetry.ExchangeRateRequests.Add(1, new KeyValuePair("currency.count", currencyList.Count));
+ return rateList.Where(rate => currencyList.Contains(rate.SourceCurrency));
+ }
+ else
+ {
+ _logger.LogWarning("No exchange rates found for the requested currencies");
+ return Enumerable.Empty();
+ }
+ }
+ else
+ {
+ _logger.LogWarning("Failed to retrieve exchange rates from provider");
+ return Enumerable.Empty();
+ }
+ }
+ catch (ExchangeRateProviderException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Unexpected error occurred while getting exchange rates");
+ throw new ExchangeRateServiceException("An unexpected error occurred while getting exchange rates", ex);
+ }
+ finally
+ {
+ ExchangeRateTelemetry.ExchangeRateDuration.Record(
+ activity?.Duration.TotalSeconds ?? 0,
+ new KeyValuePair("source", cachedRates.HasValue ? "cache" : "provider")
+ );
+ }
+ }
+}
+
+public class ExchangeRateServiceException : Exception
+{
+ public ExchangeRateServiceException(string message) : base(message) { }
+ public ExchangeRateServiceException(string message, Exception innerException) : base(message, innerException) { }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Telemetry/ExchangeRateTelemetry.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Telemetry/ExchangeRateTelemetry.cs
new file mode 100644
index 0000000000..d73b3141d5
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Telemetry/ExchangeRateTelemetry.cs
@@ -0,0 +1,29 @@
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+
+namespace ExchangeRateUpdater.Infrastructure.Telemetry;
+
+public static class ExchangeRateTelemetry
+{
+ public static readonly ActivitySource ActivitySource = new("ExchangeRateUpdater");
+ public static readonly Meter Meter = new("ExchangeRateUpdater.Metrics");
+
+ // Business metrics
+ public static readonly Counter ExchangeRateRequests =
+ Meter.CreateCounter("exchange_rate_requests_total", "Total number of exchange rate requests");
+
+ public static readonly Counter CacheHits =
+ Meter.CreateCounter("cache_hits_total", "Total number of cache hits");
+
+ public static readonly Counter CacheMisses =
+ Meter.CreateCounter("cache_misses_total", "Total number of cache misses");
+
+ public static readonly Counter CacheOperations =
+ Meter.CreateCounter("cache_operations_total", "Total number of cache operations");
+
+ public static readonly Histogram ExchangeRateDuration =
+ Meter.CreateHistogram("exchange_rate_duration_seconds", "Duration of exchange rate operations");
+
+ public static readonly Histogram CacheOperationDuration =
+ Meter.CreateHistogram("cache_operation_duration_seconds", "Duration of cache operations");
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ApiExchangeRateCacheTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ApiExchangeRateCacheTests.cs
new file mode 100644
index 0000000000..f487cecd6c
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ApiExchangeRateCacheTests.cs
@@ -0,0 +1,141 @@
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
+using ExchangeRateUpdater.Infrastructure.Caching;
+using ExchangeRateUpdater.Domain.Models;
+using ExchangeRateUpdater.Domain.Common;
+using ExchangeRateUpdater.Domain.Extensions;
+using FluentAssertions;
+using NSubstitute;
+using Microsoft.Extensions.Options;
+
+namespace ExchangeRateUpdater.Tests.Api;
+
+public class ApiExchangeRateCacheTests
+{
+ private readonly IMemoryCache _memoryCache;
+ private readonly ILogger _logger;
+ private readonly IOptions _cacheSettings;
+ private readonly ApiExchangeRateCache _sut;
+ private readonly DateOnly _testDate = new(2025, 9, 26);
+
+ public ApiExchangeRateCacheTests()
+ {
+ _memoryCache = Substitute.For();
+ _logger = Substitute.For>();
+ _cacheSettings = Substitute.For>();
+ _cacheSettings.Value.Returns(new CacheSettings
+ {
+ DefaultCacheExpiry = TimeSpan.FromHours(1)
+ });
+
+ _sut = new ApiExchangeRateCache(_memoryCache, _logger, _cacheSettings);
+ }
+
+ [Fact]
+ public async Task GetCachedRates_WithNullCurrencies_ShouldThrowArgumentNullException()
+ {
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() =>
+ _sut.GetCachedRates(null!, DateHelper.Today));
+
+ exception.ParamName.Should().Be("currencies");
+ }
+
+ [Fact]
+ public async Task GetCachedRates_WithEmptyCurrencies_ShouldReturnNothing()
+ {
+ // Arrange
+ var emptyCurrencies = Array.Empty();
+
+ // Act
+ var result = await _sut.GetCachedRates(emptyCurrencies, DateHelper.Today);
+
+ // Assert
+ result.Should().Be(Maybe>.Nothing);
+ }
+
+ [Fact]
+ public async Task GetCachedRates_WithCacheHit_ShouldReturnFilteredRates()
+ {
+ // Arrange
+ var currencies = new[] { new Currency("USD"), new Currency("EUR") };
+ var allCachedRates = new List
+ {
+ new(new Currency("USD"), new Currency("CZK"), 25.0m, _testDate),
+ new(new Currency("EUR"), new Currency("CZK"), 27.0m, _testDate),
+ new(new Currency("GBP"), new Currency("CZK"), 30.0m, _testDate)
+ };
+
+ var expectedCacheKey = $"ExchangeRates_{_testDate:yyyy-MM-dd}";
+ _memoryCache.TryGetValue(expectedCacheKey, out object? _)
+ .Returns(x =>
+ {
+ x[1] = allCachedRates;
+ return true;
+ });
+
+ // Act
+ var result = await _sut.GetCachedRates(currencies, _testDate);
+
+ // Assert
+ result.Should().NotBe(Maybe>.Nothing);
+ result.HasValue.Should().BeTrue();
+ result.Value.Should().HaveCount(2);
+ result.Value.Should().Contain(r => r.SourceCurrency.Code == "USD");
+ result.Value.Should().Contain(r => r.SourceCurrency.Code == "EUR");
+ result.Value.Should().NotContain(r => r.SourceCurrency.Code == "GBP");
+ }
+
+ [Fact]
+ public async Task GetCachedRates_WithCacheMiss_ShouldReturnNothing()
+ {
+ // Arrange
+ var currencies = new[] { new Currency("USD") };
+
+ var expectedCacheKey = $"ExchangeRates_{_testDate:yyyy-MM-dd}";
+ _memoryCache.TryGetValue(expectedCacheKey, out object? _)
+ .Returns(false);
+
+ // Act
+ var result = await _sut.GetCachedRates(currencies, _testDate);
+
+ // Assert
+ result.Should().Be(Maybe>.Nothing);
+ }
+
+ [Fact]
+ public async Task GetCachedRates_WithCacheHitButNoMatchingCurrencies_ShouldReturnNothing()
+ {
+ // Arrange
+ var currencies = new[] { new Currency("USD") };
+ var allCachedRates = new List
+ {
+ new(new Currency("EUR"), new Currency("CZK"), 27.0m, _testDate),
+ new(new Currency("GBP"), new Currency("CZK"), 30.0m, _testDate)
+ };
+
+ var expectedCacheKey = $"ExchangeRates_{_testDate:yyyy-MM-dd}";
+ _memoryCache.TryGetValue(expectedCacheKey, out object? _)
+ .Returns(x =>
+ {
+ x[1] = allCachedRates;
+ return true;
+ });
+
+ // Act
+ var result = await _sut.GetCachedRates(currencies, _testDate);
+
+ // Assert
+ result.Should().Be(Maybe>.Nothing);
+ }
+
+ [Fact]
+ public async Task CacheRates_WithNullRates_ShouldThrowArgumentNullException()
+ {
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() =>
+ _sut.CacheRates(null!));
+
+ exception.ParamName.Should().Be("rates");
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs
new file mode 100644
index 0000000000..7192c8d14d
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs
@@ -0,0 +1,86 @@
+using Microsoft.AspNetCore.Mvc;
+using ExchangeRateUpdater.Api.Controllers;
+using ExchangeRateUpdater.Api.Models;
+using ExchangeRateUpdater.Domain.Common;
+using ExchangeRateUpdater.Domain.Models;
+using ExchangeRateUpdater.Domain.Interfaces;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using FluentAssertions;
+
+namespace ExchangeRateUpdater.Tests.Api;
+
+public class ExchangeRatesControllerTests
+{
+ private readonly IExchangeRateService _exchangeRateService;
+ private readonly ExchangeRatesController _sut;
+
+ public ExchangeRatesControllerTests()
+ {
+ _exchangeRateService = Substitute.For();
+ _sut = new ExchangeRatesController(_exchangeRateService);
+ }
+
+ [Fact]
+ public async Task GetExchangeRates_ValidRequest_ReturnsOkResult()
+ {
+ // Arrange
+ var expectedRates = new[]
+ {
+ new ExchangeRate(new Currency("CZK"), new Currency("USD"), 21.5m, DateHelper.Today),
+ new ExchangeRate(new Currency("CZK"), new Currency("EUR"), 25.5m, DateHelper.Today)
+ };
+
+ _exchangeRateService
+ .GetExchangeRates(
+ Arg.Is>(c => c.Any(x => x.Code == "USD" || x.Code == "EUR")),
+ Arg.Any>())
+ .Returns(expectedRates);
+
+ // Act
+ var result = await _sut.GetExchangeRates(["USD", "EUR"]);
+
+ // Assert
+ result.Result.Should().BeOfType()
+ .Which.Value.Should().BeOfType>()
+ .Which.Should().Match>(r =>
+ r.Success &&
+ r.Data != null &&
+ r.Data.Rates.Count() == 2);
+ }
+
+ [Fact]
+ public async Task GetExchangeRates_ServiceThrowsException_PropagatesException()
+ {
+ // Arrange
+ _exchangeRateService
+ .GetExchangeRates(Arg.Any>(), Arg.Any>())
+ .Returns(Task.FromException>(
+ new ExchangeRateProviderException("Provider error")));
+
+ // Act & Assert
+ var action = () => _sut.GetExchangeRates(["USD", "EUR"]);
+
+ await action.Should().ThrowAsync()
+ .WithMessage("Provider error");
+ }
+
+ [Fact]
+ public async Task GetExchangeRates_NoRatesFound_ReturnsNotFound()
+ {
+ // Arrange
+ _exchangeRateService
+ .GetExchangeRates(Arg.Any>(), Arg.Any>())
+ .Returns(Array.Empty());
+
+ // Act
+ var result = await _sut.GetExchangeRates(["USD", "EUR"]);
+
+ // Assert
+ var response = result.Result.Should().BeOfType().Subject;
+ var apiResponse = response.Value.Should().BeOfType().Subject;
+
+ apiResponse.Success.Should().BeFalse();
+ apiResponse.Errors.Should().ContainMatch("*No exchange rates found*");
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/Middleware/GlobalExceptionHandlingMiddlewareTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/Middleware/GlobalExceptionHandlingMiddlewareTests.cs
new file mode 100644
index 0000000000..dd0cdf433e
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/Middleware/GlobalExceptionHandlingMiddlewareTests.cs
@@ -0,0 +1,155 @@
+using System.Net;
+using System.Text.Json;
+using ExchangeRateUpdater.Api.Middleware;
+using ExchangeRateUpdater.Domain.Common;
+using ExchangeRateUpdater.Domain.Models;
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+
+namespace ExchangeRateUpdater.Tests.Api.Middleware;
+
+public class GlobalExceptionHandlingMiddlewareTests
+{
+ private readonly ILogger _logger;
+ private readonly IHostEnvironment _environment;
+ private readonly DefaultHttpContext _httpContext;
+ private readonly RequestDelegate _nextMock;
+
+ public GlobalExceptionHandlingMiddlewareTests()
+ {
+ _logger = Substitute.For>();
+ _environment = Substitute.For();
+ _httpContext = new DefaultHttpContext();
+ _httpContext.Response.Body = new MemoryStream();
+ _nextMock = Substitute.For();
+ }
+
+ [Fact]
+ public async Task InvokeAsync_NoException_CallsNextDelegate()
+ {
+ // Arrange
+ _nextMock.Invoke(Arg.Any()).Returns(Task.CompletedTask);
+ var middleware = new GlobalExceptionHandlingMiddleware(_nextMock, _logger, _environment);
+
+ // Act
+ await middleware.InvokeAsync(_httpContext);
+
+ // Assert
+ await _nextMock.Received(1).Invoke(Arg.Is(ctx => ctx == _httpContext));
+ }
+
+ [Fact]
+ public async Task InvokeAsync_ExchangeRateProviderException_ReturnsBadGateway()
+ {
+ // Arrange
+ const string errorMessage = "Provider error";
+ _nextMock
+ .Invoke(Arg.Any())
+ .Returns(x => throw new ExchangeRateProviderException(errorMessage));
+
+ var middleware = new GlobalExceptionHandlingMiddleware(_nextMock, _logger, _environment);
+
+ // Act
+ await middleware.InvokeAsync(_httpContext);
+
+ // Assert
+ var response = await GetResponseAs();
+ _httpContext.Response.StatusCode.Should().Be((int)HttpStatusCode.BadGateway);
+ response.Should().NotBeNull();
+ response.Message.Should().Be("Exchange rate provider error");
+ response.Errors.Should().Contain(errorMessage);
+ response.Success.Should().BeFalse();
+
+ _logger.Received(1).Log(
+ Arg.Is(l => l == LogLevel.Error),
+ Arg.Any(),
+ Arg.Any