diff --git a/.gitignore b/.gitignore
index fd3586545..8742acdd6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,23 +1,58 @@
-!.gitkeep
-!.gitignore
-!*.dll
-[Oo]bj
-[Bb]in
-*.user
-*.suo
-*.[Cc]ache
-*.bak
-*.ncb
-*.DS_Store
-*.userprefs
-*.iml
-*.ncrunch*
-.*crunch*.local.xml
-.idea
-[Tt]humbs.db
-*.tgz
-*.sublime-*
-
-node_modules
-bower_components
-npm-debug.log
+## A streamlined .gitignore for modern .NET projects
+## including temporary files, build results, and
+## files generated by popular .NET tools. If you are
+## developing with Visual Studio, the VS .gitignore
+## https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
+## has more thorough IDE-specific entries.
+##
+## Get latest from https://github.com/github/gitignore/blob/main/Dotnet.gitignore
+
+# Rider
+.idea/
+*.DotSettings.user
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+
+# Others
+~$*
+*~
+CodeCoverage/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
\ No newline at end of file
diff --git a/jobs/Backend/Task/.dockerignore b/jobs/Backend/Task/.dockerignore
new file mode 100644
index 000000000..cd967fc3a
--- /dev/null
+++ b/jobs/Backend/Task/.dockerignore
@@ -0,0 +1,25 @@
+**/.dockerignore
+**/.env
+**/.git
+**/.gitignore
+**/.project
+**/.settings
+**/.toolstarget
+**/.vs
+**/.vscode
+**/.idea
+**/*.*proj.user
+**/*.dbmdl
+**/*.jfm
+**/azds.yaml
+**/bin
+**/charts
+**/docker-compose*
+**/Dockerfile*
+**/node_modules
+**/npm-debug.log
+**/obj
+**/secrets.dev.yaml
+**/values.dev.yaml
+LICENSE
+README.md
\ No newline at end of file
diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs
deleted file mode 100644
index f375776f2..000000000
--- 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 b/jobs/Backend/Task/Dockerfile
new file mode 100644
index 000000000..baf5b6344
--- /dev/null
+++ b/jobs/Backend/Task/Dockerfile
@@ -0,0 +1,47 @@
+FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
+USER $APP_UID
+WORKDIR /app
+EXPOSE 8080
+
+FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
+ARG BUILD_CONFIGURATION=Release
+
+WORKDIR /src
+
+COPY ./ExchangeRateUpdater.sln .
+COPY ./Exchange.Api/ ./Exchange.Api/
+COPY ./Exchange.Application/ ./Exchange.Application/
+COPY ./Exchange.ConsoleApp/ ./Exchange.ConsoleApp/
+COPY ./Exchange.Domain/ ./Exchange.Domain/
+COPY ./Exchange.Infrastructure/ ./Exchange.Infrastructure/
+
+COPY ./Exchange.Application.UnitTests ./Exchange.Application.UnitTests
+COPY ./Exchange.Domain.UnitTests ./Exchange.Domain.UnitTests
+COPY ./Exchange.Infrastructure.UnitTests ./Exchange.Infrastructure.UnitTests
+
+RUN dotnet restore ExchangeRateUpdater.sln
+
+COPY . .
+
+# Build API
+WORKDIR /src/Exchange.Api
+RUN dotnet build -c $BUILD_CONFIGURATION -o /app/build
+
+# Build ConsoleApp
+WORKDIR /src/Exchange.ConsoleApp
+RUN dotnet build -c $BUILD_CONFIGURATION -o /app/build
+
+FROM build AS publish
+ARG BUILD_CONFIGURATION=Release
+
+WORKDIR /src/Exchange.Api
+RUN dotnet publish -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
+
+WORKDIR /src/Exchange.ConsoleApp
+RUN dotnet publish -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
+
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+
+CMD [ "dotnet", "Exchange.ConsoleApp.dll" ]
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Api/Dtos/ExchangeRateDto.cs b/jobs/Backend/Task/Exchange.Api/Dtos/ExchangeRateDto.cs
new file mode 100644
index 000000000..9f5c961dc
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Api/Dtos/ExchangeRateDto.cs
@@ -0,0 +1,3 @@
+namespace Exchange.Api.Dtos;
+
+public record ExchangeRateDto(string SourceCurrency, string TargetCurrency, decimal Value);
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Api/Exchange.Api.csproj b/jobs/Backend/Task/Exchange.Api/Exchange.Api.csproj
new file mode 100644
index 000000000..fe009853f
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Api/Exchange.Api.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/Exchange.Api/Middlewares/ExceptionHandlingMiddleware.cs b/jobs/Backend/Task/Exchange.Api/Middlewares/ExceptionHandlingMiddleware.cs
new file mode 100644
index 000000000..87163ca5c
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Api/Middlewares/ExceptionHandlingMiddleware.cs
@@ -0,0 +1,39 @@
+using Exchange.Domain.Abstractions.Exceptions;
+
+namespace Exchange.Api.Middlewares;
+
+public class ExceptionHandlingMiddleware(RequestDelegate next, ILogger logger)
+{
+ public async Task InvokeAsync(HttpContext context)
+ {
+ try
+ {
+ await next(context);
+ }
+ catch (BadRequestException ex)
+ {
+ logger.LogError(ex, "A bad request occurred");
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ context.Response.ContentType = "application/json";
+ var response = new
+ {
+ error = ex.Message
+ };
+ await context.Response.WriteAsJsonAsync(response);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "An unhandled exception occurred during request processing");
+
+ context.Response.StatusCode = StatusCodes.Status500InternalServerError;
+ context.Response.ContentType = "application/json";
+
+ var response = new
+ {
+ error = "An internal server error occurred"
+ };
+
+ await context.Response.WriteAsJsonAsync(response);
+ }
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Api/Middlewares/RequestTimingMiddleware.cs b/jobs/Backend/Task/Exchange.Api/Middlewares/RequestTimingMiddleware.cs
new file mode 100644
index 000000000..d9e5561ff
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Api/Middlewares/RequestTimingMiddleware.cs
@@ -0,0 +1,28 @@
+using System.Diagnostics;
+
+namespace Exchange.Api.Middlewares;
+
+public class RequestTimingMiddleware(RequestDelegate next, ILogger logger)
+{
+ public async Task Invoke(HttpContext context)
+ {
+ var stopwatch = Stopwatch.StartNew();
+
+ await next(context);
+
+ stopwatch.Stop();
+
+ var elapsedMs = stopwatch.ElapsedMilliseconds;
+ var method = context.Request.Method;
+ var path = context.Request.Path;
+ var statusCode = context.Response.StatusCode;
+
+ logger.LogInformation(
+ "Request {Method} {Path} responded {StatusCode} in {Elapsed} ms",
+ method,
+ path,
+ statusCode,
+ elapsedMs
+ );
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Api/Program.cs b/jobs/Backend/Task/Exchange.Api/Program.cs
new file mode 100644
index 000000000..678df9d8f
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Api/Program.cs
@@ -0,0 +1,51 @@
+using Exchange.Api.Dtos;
+using Exchange.Api.Middlewares;
+using Exchange.Application.Extensions;
+using Exchange.Application.Services;
+using Exchange.Domain.ValueObjects;
+using Exchange.Infrastructure.Extensions.ServiceCollectionExtensions;
+using Microsoft.AspNetCore.Mvc;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Add services to the container.
+// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen();
+
+builder.Services.AddDateTimeProvider();
+builder.Services.AddCnbApiClient(builder.Configuration);
+builder.Services.AddInMemoryCache(builder.Configuration);
+builder.Services.AddExchangeRateProvider();
+
+var app = builder.Build();
+
+app.UseMiddleware();
+app.UseMiddleware();
+
+app.UseSwagger();
+app.UseSwaggerUI();
+
+app.MapGet("/exchange-rates", async (
+ [FromServices] IExchangeRateProvider exchangeRateProvider,
+ [FromQuery] string[] currencyCodes,
+ CancellationToken cancellationToken
+ ) =>
+ {
+ var requestedCurrencies = currencyCodes.Select(Currency.FromCode).ToList();
+
+ var exchangeRates = await exchangeRateProvider.GetExchangeRatesAsync(requestedCurrencies, cancellationToken);
+
+ return exchangeRates.Select(er =>
+ new ExchangeRateDto(
+ er.SourceCurrency.Code,
+ er.TargetCurrency.Code,
+ er.Value)
+ ).ToList();
+ })
+ .WithName("GetExchangeRates")
+ .Produces>()
+ .Produces(StatusCodes.Status400BadRequest)
+ .WithOpenApi();
+
+await app.RunAsync();
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Api/Properties/launchSettings.json b/jobs/Backend/Task/Exchange.Api/Properties/launchSettings.json
new file mode 100644
index 000000000..70903dc0c
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Api/Properties/launchSettings.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "http://localhost:5032",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "https://localhost:7167;http://localhost:5032",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/jobs/Backend/Task/Exchange.Api/appsettings.Development.json b/jobs/Backend/Task/Exchange.Api/appsettings.Development.json
new file mode 100644
index 000000000..0c208ae91
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Api/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/jobs/Backend/Task/Exchange.Api/appsettings.json b/jobs/Backend/Task/Exchange.Api/appsettings.json
new file mode 100644
index 000000000..d0f8e78c7
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Api/appsettings.json
@@ -0,0 +1,17 @@
+{
+ "CnbApi": {
+ "BaseAddress": "https://api.cnb.cz/",
+ "TimeoutInSeconds": 30
+ },
+ "Cache": {
+ "DefaultAbsoluteExpiration": "10"
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "System.Net.Http.HttpClient": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/jobs/Backend/Task/Exchange.Application.UnitTests/Exchange.Application.UnitTests.csproj b/jobs/Backend/Task/Exchange.Application.UnitTests/Exchange.Application.UnitTests.csproj
new file mode 100644
index 000000000..446192e1d
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Application.UnitTests/Exchange.Application.UnitTests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/Exchange.Application.UnitTests/Services/ExchangeRateProviderTests.cs b/jobs/Backend/Task/Exchange.Application.UnitTests/Services/ExchangeRateProviderTests.cs
new file mode 100644
index 000000000..ef6d13847
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Application.UnitTests/Services/ExchangeRateProviderTests.cs
@@ -0,0 +1,91 @@
+using Exchange.Application.Abstractions.ApiClients;
+using Exchange.Application.Services;
+using Exchange.Domain.Entities;
+using Exchange.Domain.ValueObjects;
+using FluentAssertions;
+using Moq;
+
+namespace Exchange.Application.UnitTests.Services;
+
+public class ExchangeRateProviderTests
+{
+ private readonly Mock _cnbApiClientMock = new();
+ private readonly ExchangeRateProvider _sut;
+
+ public ExchangeRateProviderTests()
+ {
+ _sut = new ExchangeRateProvider(_cnbApiClientMock.Object);
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_WhenCurrenciesExistInSource_ThenReturnsFilteredRates()
+ {
+ // Arrange
+ IEnumerable requestedCurrencies = [Currency.BRL, Currency.EUR];
+ IEnumerable cnbExchangeRates =
+ [
+ new("2025-01-01", 1, Currency.BRL.Country, Currency.BRL.Name, 1, Currency.BRL.Code, 3.881),
+ new("2025-01-01", 1, Currency.EUR.Country, Currency.EUR.Name, 1, Currency.EUR.Code, 24.295)
+ ];
+ _cnbApiClientMock
+ .Setup(x => x.GetExchangeRatesAsync(It.IsAny()))
+ .ReturnsAsync(cnbExchangeRates);
+
+ // Act
+ var result = await _sut.GetExchangeRatesAsync(requestedCurrencies);
+
+ // Assert
+ var expectedResult = new List()
+ {
+ new(Currency.BRL, Currency.CZK, 3.881m),
+ new(Currency.EUR, Currency.CZK, 24.295m)
+ };
+ result.Should().BeEquivalentTo(expectedResult);
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_WhenNoCurrenciesMatch_ThenReturnsEmpty()
+ {
+ // Arrange
+ IEnumerable requestedCurrencies = [Currency.USD, Currency.GBP];
+ IEnumerable cnbExchangeRates =
+ [
+ new("2025-01-01", 1, Currency.BRL.Country, Currency.BRL.Name, 1, Currency.BRL.Code, 3.881),
+ new("2025-01-01", 1, Currency.EUR.Country, Currency.EUR.Name, 1, Currency.EUR.Code, 24.295)
+ ];
+ _cnbApiClientMock
+ .Setup(x => x.GetExchangeRatesAsync(It.IsAny()))
+ .ReturnsAsync(cnbExchangeRates);
+
+ // Act
+ var result = await _sut.GetExchangeRatesAsync(requestedCurrencies);
+
+ // Assert
+ var expectedResult = Enumerable.Empty();
+ result.Should().BeEquivalentTo(expectedResult);
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_WhenAmountBiggerThanOne_ThenCalculateCalculateExchangeRateForOneCzk()
+ {
+ // Arrange
+ IEnumerable requestedCurrencies = [Currency.IDR];
+ IEnumerable cnbExchangeRates =
+ [
+ new("2025-01-01", 1, Currency.IDR.Country, Currency.IDR.Name, 1000, Currency.IDR.Code, 2)
+ ];
+ _cnbApiClientMock
+ .Setup(x => x.GetExchangeRatesAsync(It.IsAny()))
+ .ReturnsAsync(cnbExchangeRates);
+
+ // Act
+ var result = await _sut.GetExchangeRatesAsync(requestedCurrencies);
+
+ // Assert
+ var expectedResult = new List()
+ {
+ new(Currency.IDR, Currency.CZK, 500m)
+ };
+ result.Should().BeEquivalentTo(expectedResult);
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Application/Abstractions/ApiClients/CnbExchangeRate.cs b/jobs/Backend/Task/Exchange.Application/Abstractions/ApiClients/CnbExchangeRate.cs
new file mode 100644
index 000000000..545beaf29
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Application/Abstractions/ApiClients/CnbExchangeRate.cs
@@ -0,0 +1,11 @@
+namespace Exchange.Application.Abstractions.ApiClients;
+
+public record CnbExchangeRate(
+ string ValidFor,
+ int Order,
+ string Country,
+ string Currency,
+ int Amount,
+ string CurrencyCode,
+ double Rate
+);
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Application/Abstractions/ApiClients/ICnbApiClient.cs b/jobs/Backend/Task/Exchange.Application/Abstractions/ApiClients/ICnbApiClient.cs
new file mode 100644
index 000000000..feba2ea46
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Application/Abstractions/ApiClients/ICnbApiClient.cs
@@ -0,0 +1,6 @@
+namespace Exchange.Application.Abstractions.ApiClients;
+
+public interface ICnbApiClient
+{
+ Task> GetExchangeRatesAsync(CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Application/Abstractions/Caching/ICachingService.cs b/jobs/Backend/Task/Exchange.Application/Abstractions/Caching/ICachingService.cs
new file mode 100644
index 000000000..23d59f0c8
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Application/Abstractions/Caching/ICachingService.cs
@@ -0,0 +1,7 @@
+namespace Exchange.Application.Abstractions.Caching;
+
+public interface ICacheService
+{
+ Task GetAsync(string key);
+ Task SetAsync(string key, T value, TimeSpan? absoluteExpiration = null);
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Application/Exchange.Application.csproj b/jobs/Backend/Task/Exchange.Application/Exchange.Application.csproj
new file mode 100644
index 000000000..7cacb698e
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Application/Exchange.Application.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/Exchange.Application/Extensions/ExchangeRateProviderServiceCollectionExtension.cs b/jobs/Backend/Task/Exchange.Application/Extensions/ExchangeRateProviderServiceCollectionExtension.cs
new file mode 100644
index 000000000..6333fa72e
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Application/Extensions/ExchangeRateProviderServiceCollectionExtension.cs
@@ -0,0 +1,15 @@
+using Exchange.Application.Services;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Exchange.Application.Extensions;
+
+public static class ExchangeRateProviderServiceCollectionExtension
+{
+ public static IServiceCollection AddExchangeRateProvider(this IServiceCollection services)
+ {
+ services
+ .AddScoped();
+
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Application/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/Exchange.Application/Services/ExchangeRateProvider.cs
new file mode 100644
index 000000000..23e925e68
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Application/Services/ExchangeRateProvider.cs
@@ -0,0 +1,51 @@
+using Exchange.Application.Abstractions.ApiClients;
+using Exchange.Domain.Entities;
+using Exchange.Domain.ValueObjects;
+
+namespace Exchange.Application.Services;
+
+public interface IExchangeRateProvider
+{
+ Task> GetExchangeRatesAsync(
+ IEnumerable currencies,
+ CancellationToken cancellationToken = default
+ );
+}
+
+public class ExchangeRateProvider(ICnbApiClient cnbApiClient) : IExchangeRateProvider
+{
+ ///
+ /// 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 async Task> GetExchangeRatesAsync(
+ IEnumerable currencies,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var requestedCurrencyCodes = new HashSet(
+ currencies.Select(c => c.Code),
+ StringComparer.OrdinalIgnoreCase
+ );
+
+ var allCnbExchangeRates = await cnbApiClient.GetExchangeRatesAsync(cancellationToken);
+
+ var filteredExchangeRates = allCnbExchangeRates
+ .Where(r => requestedCurrencyCodes.Contains(r.CurrencyCode))
+ .Select(er => new ExchangeRate(
+ Currency.FromCode(er.CurrencyCode),
+ Currency.CZK,
+ CalculateValue(er)
+ ))
+ .ToList();
+
+ return filteredExchangeRates;
+
+ decimal CalculateValue(CnbExchangeRate exchangeRate)
+ {
+ return (decimal)(exchangeRate.Amount == 1 ? exchangeRate.Rate : exchangeRate.Amount / exchangeRate.Rate);
+ }
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.ConsoleApp/App.cs b/jobs/Backend/Task/Exchange.ConsoleApp/App.cs
new file mode 100644
index 000000000..a7d7de0d6
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.ConsoleApp/App.cs
@@ -0,0 +1,81 @@
+using Exchange.Application.Services;
+using Exchange.Domain.Abstractions.Exceptions;
+using Exchange.Domain.ValueObjects;
+
+namespace Exchange.ConsoleApp;
+
+public class App(IExchangeRateProvider exchangeRateProvider)
+{
+ private static readonly IEnumerable InitialCurrencies =
+ [
+ Currency.USD,
+ Currency.BRL,
+ Currency.EUR,
+ Currency.CZK,
+ Currency.JPY,
+ Currency.KES,
+ Currency.RUB,
+ Currency.THB,
+ Currency.TRY,
+ Currency.IDR
+ ];
+
+ public async Task RunAsync()
+ {
+ var userInput = string.Empty;
+
+ do
+ {
+ await DisplayExchangeRates(
+ GetCurrencies(userInput)
+ );
+
+ Console.WriteLine("Enter currency codes to retrieve exchange rates for:");
+ Console.WriteLine("Ex: USD,BRL,EUR");
+ Console.WriteLine("Empty value to exit.");
+
+ userInput = Console.ReadLine();
+ } while (!string.IsNullOrWhiteSpace(userInput));
+
+
+ Console.ReadLine();
+ }
+
+ private async Task DisplayExchangeRates(IEnumerable currencies)
+ {
+ try
+ {
+ var exchangeRates = await exchangeRateProvider.GetExchangeRatesAsync(currencies);
+
+ Console.WriteLine($"Successfully retrieved {exchangeRates.Count()} exchange rates:");
+
+ foreach (var exchangeRate in exchangeRates)
+ {
+ Console.WriteLine(exchangeRate.ToString());
+ }
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'.");
+ }
+ }
+
+ private static IEnumerable GetCurrencies(string currencyCodes = "")
+ {
+ if (string.IsNullOrWhiteSpace(currencyCodes))
+ return InitialCurrencies;
+
+ var codes = currencyCodes.Trim().Split(',');
+
+ try
+ {
+ return codes.Select(c => Currency.FromCode(c.Trim()));
+ }
+ catch (BadRequestException ex)
+ {
+ Console.WriteLine(ex.Message);
+ }
+
+ return InitialCurrencies;
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.ConsoleApp/Exchange.ConsoleApp.csproj b/jobs/Backend/Task/Exchange.ConsoleApp/Exchange.ConsoleApp.csproj
new file mode 100644
index 000000000..53353d680
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.ConsoleApp/Exchange.ConsoleApp.csproj
@@ -0,0 +1,27 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
diff --git a/jobs/Backend/Task/Exchange.ConsoleApp/Program.cs b/jobs/Backend/Task/Exchange.ConsoleApp/Program.cs
new file mode 100644
index 000000000..29ddef294
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.ConsoleApp/Program.cs
@@ -0,0 +1,37 @@
+using Exchange.Application.Extensions;
+using Exchange.ConsoleApp;
+using Exchange.Infrastructure.Extensions.ServiceCollectionExtensions;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+var host = Host.CreateDefaultBuilder(args)
+ .ConfigureLogging(builder =>
+ {
+ builder.ClearProviders();
+ builder.AddConsole();
+ })
+ .ConfigureAppConfiguration(builder =>
+ {
+ builder.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
+ })
+ .ConfigureServices((context, services) =>
+ {
+ services.AddDateTimeProvider();
+ services.AddCnbApiClient(context.Configuration);
+ services.AddInMemoryCache(context.Configuration);
+ services.AddExchangeRateProvider();
+ services.AddTransient();
+ })
+ .UseDefaultServiceProvider((context, options) =>
+ {
+ options.ValidateScopes = true;
+ options.ValidateOnBuild = true;
+ })
+ .Build();
+
+using var scope = host.Services.CreateScope();
+await scope.ServiceProvider
+ .GetRequiredService()
+ .RunAsync();
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.ConsoleApp/appsettings.json b/jobs/Backend/Task/Exchange.ConsoleApp/appsettings.json
new file mode 100644
index 000000000..5e0a39333
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.ConsoleApp/appsettings.json
@@ -0,0 +1,15 @@
+{
+ "CnbApi": {
+ "BaseAddress": "https://api.cnb.cz/",
+ "TimeoutInSeconds": 30
+ },
+ "Cache": {
+ "DefaultAbsoluteExpiration": "10"
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "System.Net.Http.HttpClient": "Warning"
+ }
+ }
+}
diff --git a/jobs/Backend/Task/Exchange.Domain.UnitTests/Exchange.Domain.UnitTests.csproj b/jobs/Backend/Task/Exchange.Domain.UnitTests/Exchange.Domain.UnitTests.csproj
new file mode 100644
index 000000000..b21effccd
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Domain.UnitTests/Exchange.Domain.UnitTests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/Exchange.Domain.UnitTests/ValueObjects/CurrencyTests.cs b/jobs/Backend/Task/Exchange.Domain.UnitTests/ValueObjects/CurrencyTests.cs
new file mode 100644
index 000000000..96314c8a6
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Domain.UnitTests/ValueObjects/CurrencyTests.cs
@@ -0,0 +1,47 @@
+using Exchange.Domain.Exceptions;
+using Exchange.Domain.ValueObjects;
+using FluentAssertions;
+
+namespace Exchange.Domain.UnitTests.ValueObjects;
+
+public class CurrencyTests
+{
+ [Fact]
+ public void FromCode_WhenCodeIsValid_ReturnsCurrency()
+ {
+ // Arrange
+ const string code = "EUR";
+
+ // Act
+ var currency = Currency.FromCode(code);
+
+ // Assert
+ currency.Should().Be(Currency.EUR);
+ }
+
+ [Fact]
+ public void FromCode_WhenCodeIsInvalid_ThrowsException()
+ {
+ // Arrange
+ const string code = "INVALID";
+
+ // Act
+ var act = () => Currency.FromCode(code);
+
+ // Assert
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void FromCode_WhenCodeIsEmpty_ThenThrowsException()
+ {
+ // Arrange
+ const string code = "";
+
+ // Act
+ var act = () => Currency.FromCode(code);
+
+ // Assert
+ act.Should().Throw();
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Domain/Abstractions/Exceptions/BadRequestException.cs b/jobs/Backend/Task/Exchange.Domain/Abstractions/Exceptions/BadRequestException.cs
new file mode 100644
index 000000000..802fce5f7
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Domain/Abstractions/Exceptions/BadRequestException.cs
@@ -0,0 +1,3 @@
+namespace Exchange.Domain.Abstractions.Exceptions;
+
+public abstract class BadRequestException(string? message) : Exception(message);
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Domain/Entities/ExchangeRate.cs b/jobs/Backend/Task/Exchange.Domain/Entities/ExchangeRate.cs
new file mode 100644
index 000000000..796cd475d
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Domain/Entities/ExchangeRate.cs
@@ -0,0 +1,24 @@
+using Exchange.Domain.ValueObjects;
+
+namespace Exchange.Domain.Entities;
+
+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.Code}/{TargetCurrency.Code}={Value:000.000}";
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Domain/Exceptions/InvalidCurrencyCodeException.cs b/jobs/Backend/Task/Exchange.Domain/Exceptions/InvalidCurrencyCodeException.cs
new file mode 100644
index 000000000..9dbbf9fd4
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Domain/Exceptions/InvalidCurrencyCodeException.cs
@@ -0,0 +1,5 @@
+using Exchange.Domain.Abstractions.Exceptions;
+
+namespace Exchange.Domain.Exceptions;
+
+public class InvalidCurrencyCodeException(string code) : BadRequestException($"Invalid currency code [{code}].");
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Domain/Exceptions/NotSupportedCurrencyCodeException.cs b/jobs/Backend/Task/Exchange.Domain/Exceptions/NotSupportedCurrencyCodeException.cs
new file mode 100644
index 000000000..74cd31769
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Domain/Exceptions/NotSupportedCurrencyCodeException.cs
@@ -0,0 +1,6 @@
+using Exchange.Domain.Abstractions.Exceptions;
+
+namespace Exchange.Domain.Exceptions;
+
+public class NotSupportedCurrencyCodeException(string code)
+ : BadRequestException($"Not supported currency code [{code}].");
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Domain/Exchange.Domain.csproj b/jobs/Backend/Task/Exchange.Domain/Exchange.Domain.csproj
new file mode 100644
index 000000000..3a6353295
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Domain/Exchange.Domain.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/jobs/Backend/Task/Exchange.Domain/ValueObjects/Currency.cs b/jobs/Backend/Task/Exchange.Domain/ValueObjects/Currency.cs
new file mode 100644
index 000000000..f4719e838
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Domain/ValueObjects/Currency.cs
@@ -0,0 +1,107 @@
+using Exchange.Domain.Exceptions;
+
+namespace Exchange.Domain.ValueObjects;
+
+public record Currency
+{
+ ///
+ /// Three-letter ISO 4217 code of the currency.
+ ///
+ public string Code { get; init; }
+
+ public string Country { get; init; }
+
+ public string Name { get; init; }
+
+ private Currency(string Code, string Country, string Name)
+ {
+ this.Code = Code;
+ this.Country = Country;
+ this.Name = Name;
+ }
+
+ public static readonly Currency AUD = new("AUD", "Australia", "dollar");
+ public static readonly Currency BRL = new("BRL", "Brazil", "real");
+ public static readonly Currency BGN = new("BGN", "Bulgaria", "lev");
+ public static readonly Currency CAD = new("CAD", "Canada", "dollar");
+ public static readonly Currency CNY = new("CNY", "China", "renminbi");
+ public static readonly Currency CZK = new("CZK", "Czech Republic", "koruna");
+ public static readonly Currency HRK = new("HRK", "Croatia", "kuna");
+ public static readonly Currency DKK = new("DKK", "Denmark", "krone");
+ public static readonly Currency EUR = new("EUR", "EMU", "euro");
+ public static readonly Currency HKD = new("HKD", "Hongkong", "dollar");
+ public static readonly Currency HUF = new("HUF", "Hungary", "forint");
+ public static readonly Currency ISK = new("ISK", "Iceland", "krona");
+ public static readonly Currency XDR = new("XDR", "IMF", "SDR");
+ public static readonly Currency INR = new("INR", "India", "rupee");
+ public static readonly Currency IDR = new("IDR", "Indonesia", "rupiah");
+ public static readonly Currency ILS = new("ILS", "Israel", "new shekel");
+ public static readonly Currency JPY = new("JPY", "Japan", "yen");
+ public static readonly Currency MYR = new("MYR", "Malaysia", "ringgit");
+ public static readonly Currency MXN = new("MXN", "Mexico", "peso");
+ public static readonly Currency NZD = new("NZD", "New Zealand", "dollar");
+ public static readonly Currency NOK = new("NOK", "Norway", "krone");
+ public static readonly Currency PHP = new("PHP", "Philippines", "peso");
+ public static readonly Currency PLN = new("PLN", "Poland", "zloty");
+ public static readonly Currency RON = new("RON", "Romania", "leu");
+ public static readonly Currency RUB = new("RUB", "Russia", "rouble");
+ public static readonly Currency SGD = new("SGD", "Singapore", "dollar");
+ public static readonly Currency ZAR = new("ZAR", "South Africa", "rand");
+ public static readonly Currency KES = new("KES", "Kenya", "shilling");
+ public static readonly Currency KRW = new("KRW", "South Korea", "won");
+ public static readonly Currency SEK = new("SEK", "Sweden", "krona");
+ public static readonly Currency CHF = new("CHF", "Switzerland", "franc");
+ public static readonly Currency THB = new("THB", "Thailand", "baht");
+ public static readonly Currency TRY = new("TRY", "Turkey", "lira");
+ public static readonly Currency GBP = new("GBP", "United Kingdom", "pound");
+ public static readonly Currency USD = new("USD", "USA", "dollar");
+
+ private static readonly Dictionary Currencies = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["AUD"] = AUD,
+ ["BRL"] = BRL,
+ ["BGN"] = BGN,
+ ["CAD"] = CAD,
+ ["CNY"] = CNY,
+ ["HRK"] = HRK,
+ ["DKK"] = DKK,
+ ["EUR"] = EUR,
+ ["HKD"] = HKD,
+ ["HUF"] = HUF,
+ ["ISK"] = ISK,
+ ["XDR"] = XDR,
+ ["INR"] = INR,
+ ["IDR"] = IDR,
+ ["ILS"] = ILS,
+ ["JPY"] = JPY,
+ ["MYR"] = MYR,
+ ["MXN"] = MXN,
+ ["NZD"] = NZD,
+ ["NOK"] = NOK,
+ ["PHP"] = PHP,
+ ["PLN"] = PLN,
+ ["RON"] = RON,
+ ["RUB"] = RUB,
+ ["SGD"] = SGD,
+ ["ZAR"] = ZAR,
+ ["KRW"] = KRW,
+ ["SEK"] = SEK,
+ ["CHF"] = CHF,
+ ["THB"] = THB,
+ ["TRY"] = TRY,
+ ["GBP"] = GBP,
+ ["USD"] = USD,
+ ["CZK"] = CZK
+ };
+
+ public static Currency FromCode(string code)
+ {
+ if (string.IsNullOrWhiteSpace(code))
+ throw new InvalidCurrencyCodeException(code);
+
+ if (Currencies.TryGetValue(code, out var currency))
+ return currency;
+
+ throw new NotSupportedCurrencyCodeException(code);
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Infrastructure.UnitTests/ApiClients/CnbApiClientCacheDecoratorTests.cs b/jobs/Backend/Task/Exchange.Infrastructure.UnitTests/ApiClients/CnbApiClientCacheDecoratorTests.cs
new file mode 100644
index 000000000..ccb7366f3
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Infrastructure.UnitTests/ApiClients/CnbApiClientCacheDecoratorTests.cs
@@ -0,0 +1,156 @@
+using Exchange.Application.Abstractions.ApiClients;
+using Exchange.Application.Abstractions.Caching;
+using Exchange.Infrastructure.ApiClients;
+using Exchange.Infrastructure.DateTimeProviders;
+using FluentAssertions;
+using Microsoft.Extensions.Logging;
+using Moq;
+
+namespace Exchange.Infrastructure.UnitTests.ApiClients;
+
+public class CnbApiClientCacheDecoratorTests
+{
+ private readonly Mock _mockCnbApiClient = new();
+ private readonly Mock _mockCacheService = new();
+ private readonly Mock _mockDateTimeProvider = new();
+ private readonly Mock _mockUpdateCalculator = new();
+ private readonly Mock> _mockLogger = new();
+ private readonly CnbApiClientCacheDecorator _sut;
+
+ private readonly List _sampleExchangeRates =
+ [
+ new("2023-01-01", 1, "USA", "Dollar", 1, "USD", 22.5)
+ ];
+
+ public CnbApiClientCacheDecoratorTests()
+ {
+ _sut = new CnbApiClientCacheDecorator(
+ _mockCnbApiClient.Object,
+ _mockCacheService.Object,
+ _mockUpdateCalculator.Object,
+ _mockDateTimeProvider.Object,
+ _mockLogger.Object
+ );
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_WhenExchangeRatesCached_ReturnsFromCache()
+ {
+ // Arrange
+ var currentDate = new DateTime(2023, 1, 1, 13, 0, 0);
+ _mockDateTimeProvider.Setup(x => x.Now).Returns(currentDate);
+ _mockCacheService
+ .Setup(x => x.GetAsync>(It.IsAny()))
+ .ReturnsAsync(_sampleExchangeRates);
+
+ // Act
+ var result = await _sut.GetExchangeRatesAsync();
+
+ // Assert
+ result.Should().BeEquivalentTo(_sampleExchangeRates);
+ _mockCnbApiClient.Verify(x => x.GetExchangeRatesAsync(It.IsAny()), Times.Never);
+ _mockCacheService.Verify(
+ x => x.SetAsync(It.IsAny(), It.IsAny>(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_WhenExchangeRatesNotCached_ReturnsFromApi()
+ {
+ // Arrange
+ IEnumerable? cachedData = null;
+ _mockCacheService
+ .Setup(x => x.GetAsync>(It.IsAny()))
+ .ReturnsAsync(cachedData);
+
+ _mockCnbApiClient
+ .Setup(x => x.GetExchangeRatesAsync(It.IsAny()))
+ .ReturnsAsync(_sampleExchangeRates);
+
+ // Act
+ var result = await _sut.GetExchangeRatesAsync();
+
+ // Assert
+ result.Should().BeEquivalentTo(_sampleExchangeRates);
+ _mockCnbApiClient.Verify(x => x.GetExchangeRatesAsync(It.IsAny()), Times.Once);
+ _mockCacheService.Verify(x => x.SetAsync(
+ It.IsAny(), It.IsAny>(), It.IsAny()
+ ), Times.Once
+ );
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_WhenPastUpdateTimeAndApiResponseNotUpdated_UseShortAbsoluteExpiration()
+ {
+ // Arrange
+ IEnumerable? cachedData = null;
+ _mockCacheService
+ .Setup(x => x.GetAsync>(It.IsAny()))
+ .ReturnsAsync(cachedData);
+
+ _mockCnbApiClient
+ .Setup(x => x.GetExchangeRatesAsync(It.IsAny()))
+ .ReturnsAsync(_sampleExchangeRates);
+
+ var expectedUpdateDate = new DateTime(2023, 1, 1, 14, 30, 0);
+ var currentDateTime = new DateTime(2023, 1, 1, 15, 0, 0);
+ var shortAbsoluteExpiration = TimeSpan.FromMinutes(10);
+
+ _mockUpdateCalculator
+ .Setup(x => x.GetNextExpectedUpdateDate(It.IsAny()))
+ .Returns(expectedUpdateDate);
+
+ _mockDateTimeProvider.Setup(x => x.Now).Returns(currentDateTime);
+
+ // Act
+ var result = await _sut.GetExchangeRatesAsync();
+
+ // Assert
+ result.Should().BeEquivalentTo(_sampleExchangeRates);
+ _mockCnbApiClient.Verify(x => x.GetExchangeRatesAsync(It.IsAny()), Times.Once);
+ _mockCacheService.Verify(x => x.SetAsync(
+ It.IsAny(), It.IsAny>(), shortAbsoluteExpiration),
+ Times.Once
+ );
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_WhenPastUpdateTimeAndApiResponseUpdated_UseCalculatedExpiration()
+ {
+ // Arrange
+ var updatedExchangeRates = new List
+ {
+ new("2023-01-02", 1, "USA", "Dollar", 1, "USD", 23.0)
+ };
+
+ IEnumerable? cachedData = null;
+ _mockCacheService
+ .Setup(x => x.GetAsync>(It.IsAny()))
+ .ReturnsAsync(cachedData);
+
+ _mockCnbApiClient
+ .Setup(x => x.GetExchangeRatesAsync(It.IsAny()))
+ .ReturnsAsync(updatedExchangeRates);
+
+ var currentDateTime = new DateTime(2023, 1, 2, 15, 0, 0);
+ var nextUpdateDateTime = new DateTime(2023, 1, 3, 14, 30, 0);
+ var expectedCacheExpiration = nextUpdateDateTime - currentDateTime;
+
+ _mockDateTimeProvider.Setup(x => x.Now).Returns(currentDateTime);
+ _mockUpdateCalculator
+ .Setup(x => x.GetNextExpectedUpdateDate(new DateOnly(2023, 1, 2)))
+ .Returns(nextUpdateDateTime);
+
+ // Act
+ var result = await _sut.GetExchangeRatesAsync();
+
+ // Assert
+ result.Should().BeEquivalentTo(updatedExchangeRates);
+ _mockCnbApiClient.Verify(x => x.GetExchangeRatesAsync(It.IsAny()), Times.Once);
+ _mockCacheService.Verify(x => x.SetAsync(
+ It.IsAny(), It.IsAny>(), expectedCacheExpiration),
+ Times.Once
+ );
+ _mockUpdateCalculator.Verify(x => x.GetNextExpectedUpdateDate(new DateOnly(2023, 1, 2)), Times.Once);
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Infrastructure.UnitTests/ApiClients/CnbApiClientDataUpdateCalculatorTests.cs b/jobs/Backend/Task/Exchange.Infrastructure.UnitTests/ApiClients/CnbApiClientDataUpdateCalculatorTests.cs
new file mode 100644
index 000000000..b84dde448
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Infrastructure.UnitTests/ApiClients/CnbApiClientDataUpdateCalculatorTests.cs
@@ -0,0 +1,140 @@
+using Exchange.Infrastructure.ApiClients;
+using Exchange.Infrastructure.DateTimeProviders;
+using FluentAssertions;
+using Microsoft.Extensions.Logging;
+using Moq;
+
+namespace Exchange.Infrastructure.UnitTests.ApiClients;
+
+public class CnbApiClientDataUpdateCalculatorTests
+{
+ private readonly Mock _dateTimeProviderMock;
+ private readonly Mock> _loggerMock = new();
+ private readonly CnbApiClientDataUpdateCalculator _sut;
+
+ public CnbApiClientDataUpdateCalculatorTests()
+ {
+ _dateTimeProviderMock = new Mock();
+ _sut = new CnbApiClientDataUpdateCalculator(_dateTimeProviderMock.Object, _loggerMock.Object);
+ }
+
+ [Fact]
+ public void GetNextExpectedUpdateDate_WhenCurrentTimeIsBeforeUpdateTimeOnWorkingDay_ShouldReturnSameDay()
+ {
+ // Arrange
+ var currentDateWednesday = new DateOnly(2025, 10, 15);
+ var timeBeforeDataUpdateTime = new TimeOnly(10, 0);
+ var currentDateTime = currentDateWednesday.ToDateTime(timeBeforeDataUpdateTime);
+
+ _dateTimeProviderMock.Setup(p => p.Now).Returns(currentDateTime);
+
+ // Act
+ var result = _sut.GetNextExpectedUpdateDate(currentDateWednesday);
+
+ // Assert
+ result.Should().Be(currentDateWednesday.ToDateTime(new TimeOnly(14, 30)));
+ }
+
+ [Fact]
+ public void GetNextExpectedUpdateDate_WhenCurrentTimeIsAfterUpdateTimeOnWorkingDay_ShouldReturnNextWorkingDay()
+ {
+ // Arrange
+ var currentDateWednesday = new DateOnly(2025, 10, 15);
+ var timeAfterDataUpdateTime = new TimeOnly(15, 0);
+ var currentDateTime = currentDateWednesday.ToDateTime(timeAfterDataUpdateTime);
+
+ _dateTimeProviderMock.Setup(p => p.Now).Returns(currentDateTime);
+
+ // Act
+ var result = _sut.GetNextExpectedUpdateDate(currentDateWednesday);
+
+ // Assert
+ var expectedDate = new DateOnly(2025, 10, 16);
+ result.Should().Be(expectedDate.ToDateTime(new TimeOnly(14, 30)));
+ }
+
+ [Fact]
+ public void GetNextExpectedUpdateDate_WhenCurrentDateIsWeekend_ShouldReturnNextWorkingDay()
+ {
+ // Arrange
+ var currentDateSaturday = new DateOnly(2025, 10, 18);
+ var currentDateTime = currentDateSaturday.ToDateTime(new TimeOnly(10, 0));
+
+ _dateTimeProviderMock.Setup(p => p.Now).Returns(currentDateTime);
+
+ // Act
+ var result = _sut.GetNextExpectedUpdateDate(currentDateSaturday);
+
+ // Assert
+ var expectedDate = new DateOnly(2025, 10, 20);
+ result.Should().Be(expectedDate.ToDateTime(new TimeOnly(14, 30)));
+ }
+
+ [Fact]
+ public void GetNextExpectedUpdateDate_WhenCurrentDateIsHoliday_ShouldReturnNextWorkingDay()
+ {
+ // Arrange
+ var holidayDate = new DateOnly(2025, 12, 25);
+ var currentDateTime = holidayDate.ToDateTime(new TimeOnly(10, 0));
+
+ _dateTimeProviderMock.Setup(p => p.Now).Returns(currentDateTime);
+
+ // Act
+ var result = _sut.GetNextExpectedUpdateDate(holidayDate);
+
+ // Assert
+ var expectedDate = new DateOnly(2025, 12, 26);
+ result.Should().Be(expectedDate.ToDateTime(new TimeOnly(14, 30)));
+ }
+
+ [Fact]
+ public void GetNextExpectedUpdateDate_WhenNextDayIsWeekend_ShouldSkipWeekendDays()
+ {
+ // Arrange
+ var currentDateFriday = new DateOnly(2025, 10, 17);
+ var timeAfterDataUpdateTime = currentDateFriday.ToDateTime(new TimeOnly(15, 0));
+
+ _dateTimeProviderMock.Setup(p => p.Now).Returns(timeAfterDataUpdateTime);
+
+ // Act
+ var result = _sut.GetNextExpectedUpdateDate(currentDateFriday);
+
+ // Assert
+ var expectedDate = new DateOnly(2025, 10, 20);
+ result.Should().Be(expectedDate.ToDateTime(new TimeOnly(14, 30)));
+ }
+
+ [Fact]
+ public void GetNextExpectedUpdateDate_WhenNextDayIsHoliday_ShouldSkipHoliday()
+ {
+ // Arrange
+ var dayBeforeHoliday = new DateOnly(2025, 11, 30);
+ var currentDateTime = dayBeforeHoliday.ToDateTime(new TimeOnly(15, 0));
+
+ _dateTimeProviderMock.Setup(p => p.Now).Returns(currentDateTime);
+
+ // Act
+ var result = _sut.GetNextExpectedUpdateDate(dayBeforeHoliday);
+
+ // Assert
+ var expectedDate = new DateOnly(2025, 12, 1);
+ result.Should().Be(expectedDate.ToDateTime(new TimeOnly(14, 30)));
+ }
+
+ [Fact]
+ public void GetNextExpectedUpdateDate_WhenMultipleNonWorkingDaysAhead_ShouldFindFirstWorkingDay()
+ {
+ // Arrange
+ var dayBeforeWeekendAndHolidays = new DateOnly(2025, 12, 5);
+ var currentDateTime = dayBeforeWeekendAndHolidays.ToDateTime(new TimeOnly(15, 0));
+
+ _dateTimeProviderMock.Setup(p => p.Now).Returns(currentDateTime);
+
+ // Act
+ var result = _sut.GetNextExpectedUpdateDate(dayBeforeWeekendAndHolidays);
+
+ // Assert
+ var expectedDate = new DateOnly(2025, 12, 9);
+ result.Should().Be(expectedDate.ToDateTime(new TimeOnly(14, 30)));
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Infrastructure.UnitTests/Exchange.Infrastructure.UnitTests.csproj b/jobs/Backend/Task/Exchange.Infrastructure.UnitTests/Exchange.Infrastructure.UnitTests.csproj
new file mode 100644
index 000000000..2ea4c78d6
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Infrastructure.UnitTests/Exchange.Infrastructure.UnitTests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiClient.cs b/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiClient.cs
new file mode 100644
index 000000000..510dcfba2
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiClient.cs
@@ -0,0 +1,20 @@
+using System.Net.Http.Json;
+using Exchange.Application.Abstractions.ApiClients;
+
+
+namespace Exchange.Infrastructure.ApiClients;
+
+public class CnbApiClient(HttpClient httpClient) : ICnbApiClient
+{
+ private const string ExchangeRatesUrl = "cnbapi/exrates/daily?lang=EN";
+
+ public async Task> GetExchangeRatesAsync(CancellationToken cancellationToken = default)
+ {
+ var response = await httpClient.GetAsync(ExchangeRatesUrl, cancellationToken);
+ var exchangeRates =
+ await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken);
+ return exchangeRates?.Rates ?? [];
+ }
+}
+
+file record ExchangeRatesResponse(List Rates);
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiClientCacheDecorator.cs b/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiClientCacheDecorator.cs
new file mode 100644
index 000000000..3f35facca
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiClientCacheDecorator.cs
@@ -0,0 +1,70 @@
+using Exchange.Application.Abstractions.ApiClients;
+using Exchange.Application.Abstractions.Caching;
+using Exchange.Infrastructure.DateTimeProviders;
+using Microsoft.Extensions.Logging;
+
+namespace Exchange.Infrastructure.ApiClients;
+
+public class CnbApiClientCacheDecorator(
+ ICnbApiClient cnbApiClient,
+ ICacheService cacheService,
+ ICnbApiClientDataUpdateCalculator cnbApiClientDataUpdateCalculator,
+ IDateTimeProvider dateTimeProvider,
+ ILogger logger
+) : ICnbApiClient
+{
+ private readonly TimeSpan _shortAbsoluteExpiration = TimeSpan.FromMinutes(10);
+
+ private const string CacheKey = nameof(CnbApiClient);
+
+ public async Task> GetExchangeRatesAsync(CancellationToken cancellationToken = default)
+ {
+ var cached = await cacheService.GetAsync>(CacheKey);
+
+ if (cached is not null)
+ {
+ logger.LogInformation("Data retrieved from cache.");
+ return cached;
+ }
+
+ var cnbExchangeRates = await cnbApiClient.GetExchangeRatesAsync(cancellationToken);
+
+ await CacheExchangeRatesAsync(cnbExchangeRates);
+
+ return cnbExchangeRates;
+ }
+
+ private async Task CacheExchangeRatesAsync(IEnumerable cnbExchangeRates)
+ {
+ logger.LogInformation("Caching exchange rates.");
+ var cacheExpiration = CalculateCacheExpiration(cnbExchangeRates);
+ logger.LogInformation(
+ "Cache expiration: {Days} days, {Hours} hours, {Minutes} minutes, {Seconds} seconds",
+ cacheExpiration.Days,
+ cacheExpiration.Hours,
+ cacheExpiration.Minutes,
+ cacheExpiration.Seconds
+ );
+ await cacheService.SetAsync(CacheKey, cnbExchangeRates, cacheExpiration);
+ }
+
+ private TimeSpan CalculateCacheExpiration(IEnumerable exchangeRates)
+ {
+ var lastUpdateDate = GetLastUpdateDate(exchangeRates);
+ var expectedUpdateTime = cnbApiClientDataUpdateCalculator.GetNextExpectedUpdateDate(lastUpdateDate);
+ logger.LogInformation("Expected update time: {ExpectedUpdateTime}", expectedUpdateTime);
+ var currentTime = dateTimeProvider.Now;
+
+ return expectedUpdateTime > currentTime
+ ? expectedUpdateTime - currentTime
+ : _shortAbsoluteExpiration;
+ }
+
+ private DateOnly GetLastUpdateDate(IEnumerable exchangeRates)
+ {
+ var firstRate = exchangeRates.First();
+ var lastUpdateDate = DateOnly.Parse(firstRate.ValidFor);
+ logger.LogInformation("Last update date: {LastUpdateDate}", lastUpdateDate);
+ return lastUpdateDate;
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiClientDataUpdateCalculator.cs b/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiClientDataUpdateCalculator.cs
new file mode 100644
index 000000000..de450befd
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiClientDataUpdateCalculator.cs
@@ -0,0 +1,62 @@
+using Exchange.Infrastructure.DateTimeProviders;
+using Exchange.Infrastructure.Extensions;
+using Microsoft.Extensions.Logging;
+
+namespace Exchange.Infrastructure.ApiClients;
+
+public interface ICnbApiClientDataUpdateCalculator
+{
+ DateTime GetNextExpectedUpdateDate(DateOnly lastUpdate);
+}
+
+public class CnbApiClientDataUpdateCalculator(
+ IDateTimeProvider dateTimeProvider,
+ ILogger logger
+) : ICnbApiClientDataUpdateCalculator
+{
+ private static readonly TimeOnly DataUpdateTime = new(14, 30);
+
+ private readonly DateOnly[] _bankHolidays =
+ [
+ new(2025, 11, 1),
+ new(2025, 12, 6),
+ new(2025, 12, 8),
+ new(2025, 12, 25)
+ ];
+
+
+ public DateTime GetNextExpectedUpdateDate(DateOnly lastUpdate)
+ {
+ var targetDate = lastUpdate;
+
+ if (CanUpdateOnSameDay(targetDate, dateTimeProvider.Now))
+ {
+ logger.LogInformation("Data update is expected on the same day.");
+ return targetDate.ToDateTime(DataUpdateTime);
+ }
+
+ logger.LogInformation("Data update is expected on the next working day.");
+ return GetNextWorkingDate(targetDate).ToDateTime(DataUpdateTime);
+ }
+
+ private bool CanUpdateOnSameDay(DateOnly date, DateTime currentDateTime) =>
+ IsWorkingDay(date) &&
+ date == currentDateTime.ToDateOnly() &&
+ currentDateTime.TimeOfDay < DataUpdateTime.ToTimeSpan();
+
+ private bool IsWorkingDay(DateOnly date) =>
+ !date.IsWeekend() && !IsHoliday(date);
+
+ private bool IsHoliday(DateOnly date) => _bankHolidays.Contains(date);
+
+ private DateOnly GetNextWorkingDate(DateOnly startDate)
+ {
+ var nextDate = startDate.AddDays(1);
+ while (!IsWorkingDay(nextDate))
+ {
+ nextDate = nextDate.AddDays(1);
+ }
+
+ return nextDate;
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiOptions.cs b/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiOptions.cs
new file mode 100644
index 000000000..cb85fe322
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiOptions.cs
@@ -0,0 +1,10 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Exchange.Infrastructure.ApiClients;
+
+public class CnbApiOptions
+{
+ public const string SectionName = "CnbApi";
+ [Required] [Url] public required string BaseAddress { get; set; }
+ [Range(1, 100)] public required int TimeoutInSeconds { get; set; } = 60;
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Infrastructure/Caching/CacheOptions.cs b/jobs/Backend/Task/Exchange.Infrastructure/Caching/CacheOptions.cs
new file mode 100644
index 000000000..b4389c299
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Infrastructure/Caching/CacheOptions.cs
@@ -0,0 +1,7 @@
+namespace Exchange.Infrastructure.Caching;
+
+public class CacheOptions
+{
+ public const string SectionName = "Cache";
+ public TimeSpan DefaultAbsoluteExpiration { get; set; } = TimeSpan.FromMinutes(5);
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Infrastructure/Caching/InMemoryCacheService.cs b/jobs/Backend/Task/Exchange.Infrastructure/Caching/InMemoryCacheService.cs
new file mode 100644
index 000000000..bbf0dada9
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Infrastructure/Caching/InMemoryCacheService.cs
@@ -0,0 +1,23 @@
+using Exchange.Application.Abstractions.Caching;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Options;
+
+namespace Exchange.Infrastructure.Caching;
+
+public class InMemoryCacheService(IMemoryCache memoryCache, IOptions options) : ICacheService
+{
+ public Task GetAsync(string key)
+ {
+ memoryCache.TryGetValue(key, out T? value);
+ return Task.FromResult(value);
+ }
+
+ public Task SetAsync(string key, T value, TimeSpan? absoluteExpiration = null)
+ {
+ var cacheEntryOptions = new MemoryCacheEntryOptions();
+ cacheEntryOptions.SetAbsoluteExpiration(absoluteExpiration ?? options.Value.DefaultAbsoluteExpiration);
+
+ memoryCache.Set(key, value, cacheEntryOptions);
+ return Task.CompletedTask;
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Infrastructure/DateTimeProviders/DateTimeProvider.cs b/jobs/Backend/Task/Exchange.Infrastructure/DateTimeProviders/DateTimeProvider.cs
new file mode 100644
index 000000000..abbf3b89c
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Infrastructure/DateTimeProviders/DateTimeProvider.cs
@@ -0,0 +1,11 @@
+namespace Exchange.Infrastructure.DateTimeProviders;
+
+public interface IDateTimeProvider
+{
+ DateTime Now { get; }
+}
+
+public class DateTimeProvider : IDateTimeProvider
+{
+ public DateTime Now => DateTime.Now;
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Infrastructure/Exchange.Infrastructure.csproj b/jobs/Backend/Task/Exchange.Infrastructure/Exchange.Infrastructure.csproj
new file mode 100644
index 000000000..4992311e2
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Infrastructure/Exchange.Infrastructure.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/Exchange.Infrastructure/Extensions/DateOnlyExtension.cs b/jobs/Backend/Task/Exchange.Infrastructure/Extensions/DateOnlyExtension.cs
new file mode 100644
index 000000000..81c7d2ac7
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Infrastructure/Extensions/DateOnlyExtension.cs
@@ -0,0 +1,6 @@
+namespace Exchange.Infrastructure.Extensions;
+
+public static class DateOnlyExtension
+{
+ public static bool IsWeekend(this DateOnly date) => date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Infrastructure/Extensions/DateTimeExtension.cs b/jobs/Backend/Task/Exchange.Infrastructure/Extensions/DateTimeExtension.cs
new file mode 100644
index 000000000..e08592fe4
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Infrastructure/Extensions/DateTimeExtension.cs
@@ -0,0 +1,6 @@
+namespace Exchange.Infrastructure.Extensions;
+
+public static class DateTimeExtension
+{
+ public static DateOnly ToDateOnly(this DateTime dateTime) => DateOnly.FromDateTime(dateTime);
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Infrastructure/Extensions/ServiceCollectionExtensions/CachingServiceCollectionExtension.cs b/jobs/Backend/Task/Exchange.Infrastructure/Extensions/ServiceCollectionExtensions/CachingServiceCollectionExtension.cs
new file mode 100644
index 000000000..49bdb882f
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Infrastructure/Extensions/ServiceCollectionExtensions/CachingServiceCollectionExtension.cs
@@ -0,0 +1,24 @@
+using Exchange.Application.Abstractions.Caching;
+using Exchange.Infrastructure.Caching;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Exchange.Infrastructure.Extensions.ServiceCollectionExtensions;
+
+public static class CachingServiceCollectionExtension
+{
+ public static IServiceCollection AddInMemoryCache(
+ this IServiceCollection services,
+ IConfiguration configuration
+ )
+ {
+ services
+ .AddOptions()
+ .Bind(configuration.GetSection(CacheOptions.SectionName));
+
+ services
+ .AddMemoryCache()
+ .AddSingleton();
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Infrastructure/Extensions/ServiceCollectionExtensions/CnbApiClientServiceCollectionExtension.cs b/jobs/Backend/Task/Exchange.Infrastructure/Extensions/ServiceCollectionExtensions/CnbApiClientServiceCollectionExtension.cs
new file mode 100644
index 000000000..804b17470
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Infrastructure/Extensions/ServiceCollectionExtensions/CnbApiClientServiceCollectionExtension.cs
@@ -0,0 +1,58 @@
+using Exchange.Application.Abstractions.ApiClients;
+using Exchange.Infrastructure.ApiClients;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Polly;
+using Polly.Extensions.Http;
+
+namespace Exchange.Infrastructure.Extensions.ServiceCollectionExtensions;
+
+public static class CnbApiClientServiceCollectionExtension
+{
+ public static IServiceCollection AddCnbApiClient(this IServiceCollection services, IConfiguration configuration)
+ {
+ services
+ .AddOptions()
+ .Bind(configuration.GetSection(CnbApiOptions.SectionName))
+ .ValidateDataAnnotations()
+ .ValidateOnStart();
+
+ services.AddSingleton();
+
+ services.AddHttpClient(ConfigureCnbApiClient())
+ .AddPolicyHandler(GetRetryPolicy())
+ .AddPolicyHandler(GetCircuitBreakerPolicy());
+
+ services.Decorate();
+
+ return services;
+ }
+
+ private static Action ConfigureCnbApiClient()
+ {
+ return (provider, client) =>
+ {
+ var cnbApiOptions = provider.GetRequiredService>().Value;
+ client.BaseAddress = new Uri(cnbApiOptions.BaseAddress);
+ client.Timeout = TimeSpan.FromSeconds(cnbApiOptions.TimeoutInSeconds);
+ client.DefaultRequestHeaders.Add("Accept", "application/json");
+ };
+ }
+
+ private static IAsyncPolicy GetRetryPolicy() =>
+ HttpPolicyExtensions
+ .HandleTransientHttpError()
+ .WaitAndRetryAsync(
+ retryCount: 3,
+ retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))
+ );
+
+ private static IAsyncPolicy GetCircuitBreakerPolicy() =>
+ HttpPolicyExtensions
+ .HandleTransientHttpError()
+ .CircuitBreakerAsync(
+ handledEventsAllowedBeforeBreaking: 3,
+ durationOfBreak: TimeSpan.FromSeconds(30)
+ );
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Exchange.Infrastructure/Extensions/ServiceCollectionExtensions/DateTimeServiceCollectionExtension.cs b/jobs/Backend/Task/Exchange.Infrastructure/Extensions/ServiceCollectionExtensions/DateTimeServiceCollectionExtension.cs
new file mode 100644
index 000000000..8fd99269e
--- /dev/null
+++ b/jobs/Backend/Task/Exchange.Infrastructure/Extensions/ServiceCollectionExtensions/DateTimeServiceCollectionExtension.cs
@@ -0,0 +1,14 @@
+using Exchange.Infrastructure.DateTimeProviders;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Exchange.Infrastructure.Extensions.ServiceCollectionExtensions;
+
+public static class DateTimeServiceCollectionExtension
+{
+ public static IServiceCollection AddDateTimeProvider(this IServiceCollection services)
+ {
+ services.AddSingleton();
+
+ return services;
+ }
+}
\ 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 58c5bb10e..000000000
--- 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 6f82a97fb..000000000
--- 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 2fc654a12..000000000
--- 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 89be84daf..a32a9a018 100644
--- a/jobs/Backend/Task/ExchangeRateUpdater.sln
+++ b/jobs/Backend/Task/ExchangeRateUpdater.sln
@@ -3,7 +3,35 @@ 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}") = "Exchange.Infrastructure", "Exchange.Infrastructure\Exchange.Infrastructure.csproj", "{D2F5C81D-272F-4302-9F27-609F1D404B64}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Exchange.Domain", "Exchange.Domain\Exchange.Domain.csproj", "{889137B9-9077-486F-AD80-5CBC96502F89}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Exchange.Api", "Exchange.Api\Exchange.Api.csproj", "{8F1423E3-C75E-4CF2-905F-72E41FCA93CD}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Exchange.ConsoleApp", "Exchange.ConsoleApp\Exchange.ConsoleApp.csproj", "{9DD81895-8C6B-43CA-9278-7AD6218733B8}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Exchange.Application", "Exchange.Application\Exchange.Application.csproj", "{68101D08-9A34-441C-A65C-2093F9ED3F54}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{471CD861-7E09-4B8C-A0B1-F137751B1B10}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Exchange.Domain.UnitTests", "Exchange.Domain.UnitTests\Exchange.Domain.UnitTests.csproj", "{0CB71C33-F564-4B8A-A5B4-0B6380FE2B1C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Exchange.Infrastructure.UnitTests", "Exchange.Infrastructure.UnitTests\Exchange.Infrastructure.UnitTests.csproj", "{4EE17604-72BE-4EE3-A4CC-98B934B21182}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Exchange.Application.UnitTests", "Exchange.Application.UnitTests\Exchange.Application.UnitTests.csproj", "{842D2482-7B8D-4A0D-81BB-69C1AF554F2C}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docker", "Docker", "{E752DE29-3CAD-48C0-9FEE-71E3DE446DC2}"
+ ProjectSection(SolutionItems) = preProject
+ .dockerignore = .dockerignore
+ Dockerfile = Dockerfile
+ docker-compose.yaml = docker-compose.yaml
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docs", "Docs", "{AB6927D7-EEBF-4709-B84E-4F2617E9B284}"
+ ProjectSection(SolutionItems) = preProject
+ README.md = README.md
+ EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -11,12 +39,45 @@ 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
+ {D2F5C81D-272F-4302-9F27-609F1D404B64}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D2F5C81D-272F-4302-9F27-609F1D404B64}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D2F5C81D-272F-4302-9F27-609F1D404B64}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D2F5C81D-272F-4302-9F27-609F1D404B64}.Release|Any CPU.Build.0 = Release|Any CPU
+ {889137B9-9077-486F-AD80-5CBC96502F89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {889137B9-9077-486F-AD80-5CBC96502F89}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {889137B9-9077-486F-AD80-5CBC96502F89}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {889137B9-9077-486F-AD80-5CBC96502F89}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8F1423E3-C75E-4CF2-905F-72E41FCA93CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8F1423E3-C75E-4CF2-905F-72E41FCA93CD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8F1423E3-C75E-4CF2-905F-72E41FCA93CD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8F1423E3-C75E-4CF2-905F-72E41FCA93CD}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9DD81895-8C6B-43CA-9278-7AD6218733B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9DD81895-8C6B-43CA-9278-7AD6218733B8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9DD81895-8C6B-43CA-9278-7AD6218733B8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9DD81895-8C6B-43CA-9278-7AD6218733B8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {68101D08-9A34-441C-A65C-2093F9ED3F54}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {68101D08-9A34-441C-A65C-2093F9ED3F54}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {68101D08-9A34-441C-A65C-2093F9ED3F54}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {68101D08-9A34-441C-A65C-2093F9ED3F54}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0CB71C33-F564-4B8A-A5B4-0B6380FE2B1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0CB71C33-F564-4B8A-A5B4-0B6380FE2B1C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0CB71C33-F564-4B8A-A5B4-0B6380FE2B1C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0CB71C33-F564-4B8A-A5B4-0B6380FE2B1C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4EE17604-72BE-4EE3-A4CC-98B934B21182}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4EE17604-72BE-4EE3-A4CC-98B934B21182}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4EE17604-72BE-4EE3-A4CC-98B934B21182}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4EE17604-72BE-4EE3-A4CC-98B934B21182}.Release|Any CPU.Build.0 = Release|Any CPU
+ {842D2482-7B8D-4A0D-81BB-69C1AF554F2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {842D2482-7B8D-4A0D-81BB-69C1AF554F2C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {842D2482-7B8D-4A0D-81BB-69C1AF554F2C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {842D2482-7B8D-4A0D-81BB-69C1AF554F2C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {0CB71C33-F564-4B8A-A5B4-0B6380FE2B1C} = {471CD861-7E09-4B8C-A0B1-F137751B1B10}
+ {4EE17604-72BE-4EE3-A4CC-98B934B21182} = {471CD861-7E09-4B8C-A0B1-F137751B1B10}
+ {842D2482-7B8D-4A0D-81BB-69C1AF554F2C} = {471CD861-7E09-4B8C-A0B1-F137751B1B10}
+ EndGlobalSection
EndGlobal
diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs
deleted file mode 100644
index 379a69b1f..000000000
--- 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 000000000..2946b41ee
--- /dev/null
+++ b/jobs/Backend/Task/README.md
@@ -0,0 +1,160 @@
+# Code Challenge – CNB Exchange Rate Provider
+
+This pull request implements a solution for retrieving exchange rate data from the Czech National Bank (CNB) web services.
+
+---
+
+## ✅ Requirements Implemented
+
+- **ExchangeRateProvider**:
+ - Fetches daily exchange rates from the CNB JSON API.
+ - Filters results based on requested currency codes.
+
+---
+
+## 🔍 Data Source Analysis
+
+The CNB provides daily exchange rates in three formats:
+
+- **JSON**: `https://api.cnb.cz/cnbapi/exrates/daily?date=YYYY-MM-DD&lang=EN`
+- **Text**: Plain text format.
+- **XML**: Standard XML with metadata.
+
+> **Chosen Format**: The JSON API was selected for this implementation due to its modern structure and ease of parsing via `.NET`’s `System.Text.Json`.
+
+> **Authentication**: None of the CNB endpoints require authentication.
+---
+
+## 🕒 Exchange Rate Update Schedule
+
+- **Update Time**: On working days after **14:30 local time** (no guaranteed exact time).
+- **No Updates**: On weekends and **Czech public holidays**.
+- **Update Detection**:
+ - JSON: `validFor` field
+ - Text: First line (e.g., `19.09.2025 #183`)
+ - XML: Root attribute `date` (e.g., ``)
+
+> Note: The system determines the next working day programmatically, considering weekends and Czech national holidays.
+
+---
+
+## 🧱 Architectural Approach
+
+### Clean Architecture
+
+The project follows Clean Architecture principles, separating responsibilities across the following layers:
+
+- **Presentation**:
+ - `Api`: RESTful interface for consumers
+ - `ConsoleApp`: Lightweight CLI tool for debugging or testing
+- **Application / Domain / Infrastructure**:
+ - Encapsulates core logic, abstractions, and external dependencies
+
+### Benefits
+
+- **Testability**: Interfaces are used throughout, enabling mocking.
+- **Maintainability**: Clear separation of concerns.
+- **Extensibility**: Components can be replaced or extended with minimal impact.
+
+---
+
+## 🌐 CNB Integration
+
+- **Typed HTTP Client** is used for structured, reusable access.
+- **Deserialization**: Handled by `System.Text.Json.JsonSerializer`.
+- **Resilience**: Powered by **Polly** with retry policies for transient failures.
+
+---
+
+## 🧠 Caching Strategy
+
+To optimize performance and respect the CNB’s update cadence:
+
+- **Initial Request**: Triggers data fetch and in-memory cache population.
+- **Cache Expiry**:
+ - Set to 14:30 on the next working day.
+- **Post-14:30 Logic**:
+ - After 14:30, the system checks for updates every 10 minutes until the `validFor` value changes.
+
+### Key Benefits
+
+- Reduces unnecessary API calls
+- Ensures timely refreshes once new data becomes available
+- Enables consistent and predictable refresh cycles
+
+> **Note**: In-memory caching is used for simplicity. This can be swapped with a distributed cache like Redis for scalability.
+
+---
+
+## 🧰 Middleware
+
+### ExceptionHandlingMiddleware
+
+Centralized error handling that:
+
+- Catches unhandled exceptions
+- Returns standardized error responses
+- Prevents internal details from leaking
+- Logs exceptions for diagnostics
+
+### RequestTimingMiddleware
+
+- Logs the duration of each request for observability
+
+---
+
+## 📝 Logging
+
+Key application events are logged, including:
+
+- Source of data (cache vs CNB API)
+- Current `validFor` value
+- Next expected refresh time
+- Errors or retry attempts (via Polly)
+
+---
+
+## 🔄 Dependency Injection
+
+The project uses **.NET’s built-in Dependency Injection** system.
+
+### Composition Root Strategy
+
+- Each layer registers its dependencies via extension methods.
+- Shared configuration is centralized and reused across both the API and Console apps.
+
+### Examples of Registered Services
+
+```csharp
+services.AddTransient();
+services.AddHttpClient();
+services.AddMemoryCache();
+```
+
+### Application Entry Points
+
+- **API**: Configured via Program.cs using standard WebApplicationBuilder.
+- **ConsoleApp**: Uses HostBuilder to replicate the same DI setup, ensuring full parity between environments.
+
+
+## 🧪 Unit Testing
+
+The project includes unit tests organized by layer:
+- Follows the same structure as the production code for easier traceability.
+- Each test project targets:
+ - Domain logic
+ - Application services
+ - Infrastructure adapters
+- Uses appropriate mocking (e.g., Moq) to isolate behaviors.
+ - Test Frameworks:
+ - xUnit
+ - Moq (for mocking dependencies)
+ - FluentAssertion
+
+---
+## ✅ Summary
+
+This implementation:
+- Meets all functional requirements.
+- Follows modern .NET and Clean Architecture best practices.
+- Is resilient, testable, and ready for extension (e.g., distributed caching, fallback APIs, scheduled jobs).
diff --git a/jobs/Backend/Task/docker-compose.yaml b/jobs/Backend/Task/docker-compose.yaml
new file mode 100644
index 000000000..ded9a350e
--- /dev/null
+++ b/jobs/Backend/Task/docker-compose.yaml
@@ -0,0 +1,10 @@
+services:
+ api:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ image: exchange-api
+ container_name: exchange-api
+ ports:
+ - "8080:8080"
+ command: [ "dotnet", "Exchange.Api.dll" ]
\ No newline at end of file