diff --git a/.gitignore b/.gitignore
index fd35865456..94ab79d5e8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,5 @@
node_modules
bower_components
npm-debug.log
+/jobs/Backend/Task/.vs
+/.vs
diff --git a/jobs/Backend/Task/Dockerfile b/jobs/Backend/Task/Dockerfile
new file mode 100644
index 0000000000..38ae487373
--- /dev/null
+++ b/jobs/Backend/Task/Dockerfile
@@ -0,0 +1,20 @@
+FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
+WORKDIR /src
+
+COPY ["ExchangeRates.Api/ExchangeRates.Api.csproj", "ExchangeRates.Api/"]
+COPY ["ExchangeRates.Application/ExchangeRates.Application.csproj", "ExchangeRates.Application/"]
+COPY ["ExchangeRates.Domain/ExchangeRates.Domain.csproj", "ExchangeRates.Domain/"]
+COPY ["ExchangeRates.Infrastructure/ExchangeRates.Infrastructure.csproj", "ExchangeRates.Infrastructure/"]
+
+RUN dotnet restore "ExchangeRates.Api/ExchangeRates.Api.csproj"
+
+COPY . .
+WORKDIR "/src/ExchangeRates.Api"
+RUN dotnet publish "ExchangeRates.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false
+
+FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
+WORKDIR /app
+COPY --from=build /app/publish .
+
+EXPOSE 80
+ENTRYPOINT ["dotnet", "ExchangeRates.Api.dll"]
diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs
deleted file mode 100644
index 6f82a97fbe..0000000000
--- a/jobs/Backend/Task/ExchangeRateProvider.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-
-namespace ExchangeRateUpdater
-{
- public class ExchangeRateProvider
- {
- ///
- /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined
- /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK",
- /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide
- /// some of the currencies, ignore them.
- ///
- public IEnumerable GetExchangeRates(IEnumerable currencies)
- {
- return Enumerable.Empty();
- }
- }
-}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj
deleted file mode 100644
index 2fc654a12b..0000000000
--- a/jobs/Backend/Task/ExchangeRateUpdater.csproj
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
- Exe
- net6.0
-
-
-
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln
deleted file mode 100644
index 89be84daff..0000000000
--- a/jobs/Backend/Task/ExchangeRateUpdater.sln
+++ /dev/null
@@ -1,22 +0,0 @@
-
-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}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- 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
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
-EndGlobal
diff --git a/jobs/Backend/Task/ExchangeRates.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/ExchangeRates.Api/Controllers/ExchangeRatesController.cs
new file mode 100644
index 0000000000..b2d6154d09
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Api/Controllers/ExchangeRatesController.cs
@@ -0,0 +1,30 @@
+using ExchangeRates.Api.DTOs;
+using ExchangeRates.Application.Providers;
+using ExchangeRates.Domain.Entities;
+using Microsoft.AspNetCore.Mvc;
+
+[ApiController]
+[Route("api/[controller]")]
+public class ExchangeRatesController : ControllerBase
+{
+ private readonly IExchangeRatesProvider _exchangeRatesProvider;
+
+ public ExchangeRatesController(IExchangeRatesProvider exchangeRatesProvider, ILogger logger)
+ {
+ _exchangeRatesProvider = exchangeRatesProvider;
+ }
+
+ [HttpGet]
+ [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public async Task>> Get([FromQuery] GetExchangeRatesRequest request, CancellationToken cancellationToken)
+ {
+ if (!ModelState.IsValid)
+ return BadRequest(ModelState);
+
+ var currencies = request.Currencies ?? Array.Empty();
+ var rates = await _exchangeRatesProvider.GetExchangeRatesAsync(currencies, cancellationToken);
+ return Ok(rates);
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRates.Api/Dtos/GetExchangeRatesRequest.cs b/jobs/Backend/Task/ExchangeRates.Api/Dtos/GetExchangeRatesRequest.cs
new file mode 100644
index 0000000000..db00dd4f8b
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Api/Dtos/GetExchangeRatesRequest.cs
@@ -0,0 +1,10 @@
+using ExchangeRates.Api.Dtos;
+
+namespace ExchangeRates.Api.DTOs
+{
+ public class GetExchangeRatesRequest
+ {
+ [ValidCurrencyCodes]
+ public IEnumerable? Currencies { get; set; }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRates.Api/Dtos/ValidCurrencyCodesAttribute.cs b/jobs/Backend/Task/ExchangeRates.Api/Dtos/ValidCurrencyCodesAttribute.cs
new file mode 100644
index 0000000000..fad9f795a4
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Api/Dtos/ValidCurrencyCodesAttribute.cs
@@ -0,0 +1,22 @@
+using System.ComponentModel.DataAnnotations;
+using System.Text.RegularExpressions;
+
+namespace ExchangeRates.Api.Dtos
+{
+ [AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)]
+ public class ValidCurrencyCodesAttribute : ValidationAttribute
+ {
+ protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
+ {
+ if (value is IEnumerable arr)
+ {
+ foreach (var c in arr)
+ {
+ if (string.IsNullOrWhiteSpace(c) || !Regex.IsMatch(c, "^[A-Za-z]{3}$"))
+ return new ValidationResult("All currency codes must be exactly 3 alphabetic characters.");
+ }
+ }
+ return ValidationResult.Success;
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRates.Api/ExchangeRates.Api.csproj b/jobs/Backend/Task/ExchangeRates.Api/ExchangeRates.Api.csproj
new file mode 100644
index 0000000000..d56f28bd77
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Api/ExchangeRates.Api.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRates.Api/Extensions/ApplicationBuilderExtensions.cs b/jobs/Backend/Task/ExchangeRates.Api/Extensions/ApplicationBuilderExtensions.cs
new file mode 100644
index 0000000000..05cdd068ba
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Api/Extensions/ApplicationBuilderExtensions.cs
@@ -0,0 +1,27 @@
+namespace ExchangeRates.Api.Extensions
+{
+ public static class ApplicationBuilderExtensions
+ {
+ public static WebApplication ConfigureApp(this WebApplication app, IHostEnvironment env)
+ {
+ app.UseExceptionHandler("/error");
+ app.UseCors("AllowAll");
+ app.UseRouting();
+
+ app.UseSwagger();
+ app.UseSwaggerUI(c =>
+ {
+ c.SwaggerEndpoint("/swagger/v1/swagger.json", "Exchange Rates API v1");
+ c.RoutePrefix = "swagger";
+ });
+
+ app.MapControllers();
+ app.Map("/error", (HttpContext httpContext) =>
+ {
+ return Results.Problem("An unexpected error occurred. Please try again later.");
+ });
+
+ return app;
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRates.Api/Extensions/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRates.Api/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000000..0412d4f423
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Api/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,103 @@
+using ExchangeRates.Application.Options;
+using ExchangeRates.Application.Providers;
+using ExchangeRates.Infrastructure.Clients.CNB;
+using Microsoft.OpenApi.Models;
+
+namespace ExchangeRates.Api.Extensions
+{
+ public static class ServiceCollectionExtensions
+ {
+ public static IServiceCollection AddExchangeRatesServices(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddControllers();
+
+ services
+ .AddSwaggerDocumentation()
+ .AddCorsPolicy()
+ .AddCacheSupport()
+ .AddAppSettings(configuration)
+ .AddCNBClient(configuration)
+ .AddApplicationServices();
+
+ return services;
+ }
+
+ public static IServiceCollection AddAppSettings(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.Configure(configuration.GetSection("ExchangeRates"));
+ services.Configure(configuration.GetSection("CNBApi:HttpClient"));
+ return services;
+ }
+
+ public static IServiceCollection AddSwaggerDocumentation(this IServiceCollection services)
+ {
+ services.AddSwaggerGen(options =>
+ {
+ options.SwaggerDoc("v1", new OpenApiInfo
+ {
+ Version = "v1",
+ Title = "Exchange Rates API",
+ Description = "API that provides daily exchange rates from the Czech National Bank."
+ });
+ });
+ return services;
+ }
+
+ public static IServiceCollection AddCorsPolicy(this IServiceCollection services)
+ {
+ services.AddCors(options =>
+ {
+ options.AddPolicy("AllowAll", policy =>
+ {
+ policy.AllowAnyOrigin()
+ .AllowAnyMethod()
+ .AllowAnyHeader();
+ });
+ });
+ return services;
+ }
+
+ public static IServiceCollection AddCacheSupport(this IServiceCollection services)
+ {
+ services.AddDistributedMemoryCache();
+ return services;
+ }
+
+ public static IServiceCollection AddCNBClient(this IServiceCollection services, IConfiguration configuration)
+ {
+ var options = configuration
+ .GetSection("CNBApi:HttpClient")
+ .Get()
+ ?? new CnbHttpClientOptions();
+
+ if (options == null)
+ throw new InvalidOperationException("CNBApi configuration section is missing.");
+
+ if (string.IsNullOrWhiteSpace(options.BaseUrl))
+ throw new InvalidOperationException("CNBApi:HttpClient:BaseUrl configuration value is missing.");
+
+ services.AddHttpClient((serviceProvider, client) =>
+ {
+ client.BaseAddress = new Uri(options.BaseUrl);
+ client.DefaultRequestHeaders.Add("Accept", "application/json");
+ })
+ .AddPolicyHandler((serviceProvider, request) =>
+ {
+ return CnbHttpClientPolicies.TimeoutPolicy(options);
+ })
+ .AddPolicyHandler((serviceProvider, request) =>
+ {
+ var logger = serviceProvider.GetRequiredService>();
+ return CnbHttpClientPolicies.RetryPolicy(options, logger);
+ });
+
+ return services;
+ }
+
+ public static IServiceCollection AddApplicationServices(this IServiceCollection services)
+ {
+ services.AddScoped();
+ return services;
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRates.Api/Program.cs b/jobs/Backend/Task/ExchangeRates.Api/Program.cs
new file mode 100644
index 0000000000..a8ffed89a3
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Api/Program.cs
@@ -0,0 +1,24 @@
+using ExchangeRates.Api.Extensions;
+
+namespace ExchangeRates.Api
+{
+ public class Program
+ {
+ public static void Main(string[] args)
+ {
+ var builder = WebApplication.CreateBuilder(args);
+
+ builder.Logging.ClearProviders();
+ builder.Logging.AddConfiguration(builder.Configuration.GetSection("Logging"));
+ builder.Logging.AddConsole();
+
+ builder.Services.AddExchangeRatesServices(builder.Configuration);
+
+ var app = builder.Build();
+
+ app.ConfigureApp(builder.Environment);
+
+ app.Run();
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRates.Api/Properties/launchSettings.json b/jobs/Backend/Task/ExchangeRates.Api/Properties/launchSettings.json
new file mode 100644
index 0000000000..b0e3c212b5
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Api/Properties/launchSettings.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:17427",
+ "sslPort": 44320
+ }
+ },
+ "profiles": {
+ "Development": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "https://localhost:7253;http://localhost:5282",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRates.Api/appsettings.Development.json b/jobs/Backend/Task/ExchangeRates.Api/appsettings.Development.json
new file mode 100644
index 0000000000..33e1b2c9f3
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Api/appsettings.Development.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Warning",
+ "Microsoft": "Warning",
+ "ExchangeRates": "Debug"
+ }
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRates.Api/appsettings.json b/jobs/Backend/Task/ExchangeRates.Api/appsettings.json
new file mode 100644
index 0000000000..f23a520012
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Api/appsettings.json
@@ -0,0 +1,23 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Error",
+ "Microsoft": "Error",
+ "System": "Error",
+ "ExchangeRates": "Information"
+ }
+ },
+ "AllowedHosts": "*",
+ "CNBApi": {
+ "HttpClient": {
+ "BaseUrl": "https://api.cnb.cz/",
+ "TimeoutSeconds": 10,
+ "RetryCount": 2,
+ "RetryBaseDelaySeconds": 2,
+ "DailyRefreshTimeCZ": "14:30:00"
+ }
+ },
+ "ExchangeRates": {
+ "DefaultCurrencies": [ "USD", "EUR", "CZK", "JPY", "KES", "RUB", "THB", "TRY", "XYZ" ]
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRates.Application/ExchangeRates.Application.csproj b/jobs/Backend/Task/ExchangeRates.Application/ExchangeRates.Application.csproj
new file mode 100644
index 0000000000..a4f795c02b
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Application/ExchangeRates.Application.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRates.Application/Options/ExchangeRatesOptions.cs b/jobs/Backend/Task/ExchangeRates.Application/Options/ExchangeRatesOptions.cs
new file mode 100644
index 0000000000..4543d48be6
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Application/Options/ExchangeRatesOptions.cs
@@ -0,0 +1,7 @@
+namespace ExchangeRates.Application.Options
+{
+ public class ExchangeRatesOptions
+ {
+ public string[] DefaultCurrencies { get; set; } = Array.Empty();
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRates.Application/Providers/ExchangeRatesProvider.cs b/jobs/Backend/Task/ExchangeRates.Application/Providers/ExchangeRatesProvider.cs
new file mode 100644
index 0000000000..cbc9a1b539
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Application/Providers/ExchangeRatesProvider.cs
@@ -0,0 +1,113 @@
+using ExchangeRates.Application.Options;
+using ExchangeRates.Domain.Entities;
+using ExchangeRates.Infrastructure.Cache;
+using ExchangeRates.Infrastructure.Clients.CNB;
+using ExchangesRates.Infrastructure.External.CNB.Dtos;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using System.Text.Json;
+
+namespace ExchangeRates.Application.Providers
+{
+ public interface IExchangeRatesProvider
+ {
+ Task> GetExchangeRatesAsync(IEnumerable currencyCodes, CancellationToken cancellationToken = default);
+ }
+
+ public class ExchangeRatesProvider : IExchangeRatesProvider
+ {
+ private readonly ICnbHttpClient _cnbHttpClient;
+ private readonly IDistributedCache _cache;
+ private readonly TimeOnly _refreshTimeCZ;
+ private readonly string[] _defaultCurrencies;
+ private readonly ILogger _logger;
+
+ public ExchangeRatesProvider(
+ ICnbHttpClient cnbHttpClient,
+ IDistributedCache cache,
+ IOptions cnbSettings,
+ IOptions exchangeRatesSettings,
+ ILogger logger)
+ {
+ _cnbHttpClient = cnbHttpClient;
+ _cache = cache;
+ _logger = logger;
+
+ _refreshTimeCZ = cnbSettings.Value.DailyRefreshTimeCZ;
+ _defaultCurrencies = exchangeRatesSettings.Value.DefaultCurrencies;
+ }
+
+ public async Task> GetExchangeRatesAsync(IEnumerable? currencyCodes, CancellationToken cancellationToken = default)
+ {
+ var currencies = (currencyCodes != null && currencyCodes.Any())
+ ? currencyCodes.Select(c => new Currency(c.Trim().ToUpperInvariant()))
+ : _defaultCurrencies.Select(c => new Currency(c.Trim().ToUpperInvariant()));
+
+ return await GetExchangeRatesAsync(currencies, cancellationToken);
+ }
+
+ private async Task> GetExchangeRatesAsync(IEnumerable currencies, CancellationToken cancellationToken = default)
+ {
+ var cacheKey = CacheKeys.ExchangeRatesDaily();
+ CnbExRatesResponse? response;
+
+ _logger.LogInformation("Fetching exchange rates for currencies: {Currencies}", string.Join(", ", currencies.Select(c => c.Code)));
+
+ try
+ {
+ var cachedData = await _cache.GetStringAsync(cacheKey, cancellationToken);
+
+ if (!string.IsNullOrEmpty(cachedData))
+ {
+ _logger.LogInformation("Cache hit for key '{CacheKey}'. Deserializing exchange rates.", cacheKey);
+ response = JsonSerializer.Deserialize(cachedData)!;
+ }
+ else
+ {
+ response = await _cnbHttpClient.GetDailyExchangeRatesAsync(cancellationToken: cancellationToken);
+
+ if (response?.Rates == null || !response.Rates.Any())
+ {
+ _logger.LogError("CNB API returned no exchange rate data.");
+ return Enumerable.Empty();
+ }
+
+ var serialized = JsonSerializer.Serialize(response);
+ var apiDataLastUpdated = response.Rates.Min(c => c.ValidFor);
+ var expiration = CacheExpirationHelper.GetCacheExpirationToNextCzTime(_refreshTimeCZ, apiDataLastUpdated);
+
+ _logger.LogInformation("Caching exchange rates under key '{CacheKey}' for {Expiration} seconds.", cacheKey, expiration.TotalSeconds);
+ await _cache.SetStringAsync(
+ cacheKey,
+ serialized,
+ new DistributedCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = expiration
+ },
+ cancellationToken);
+ }
+
+ var czk = new Currency("CZK");
+ var currencySet = currencies.Select(c => c.Code).ToHashSet();
+
+ var result = response.Rates
+ .Where(r => currencySet.Contains(r.CurrencyCode!))
+ .Select(r =>
+ {
+ var target = currencies.First(c => c.Code == r.CurrencyCode);
+ return new ExchangeRate(czk, target, r.Rate / r.Amount);
+ })
+ .ToList();
+
+ _logger.LogInformation("Returning {Count} exchange rates.", result.Count);
+ return result;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error occurred while fetching exchange rates.");
+ throw;
+ }
+ }
+ }
+}
diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/ExchangeRates.Domain/Entities/Currency.cs
similarity index 89%
rename from jobs/Backend/Task/Currency.cs
rename to jobs/Backend/Task/ExchangeRates.Domain/Entities/Currency.cs
index f375776f25..9f592c9a40 100644
--- a/jobs/Backend/Task/Currency.cs
+++ b/jobs/Backend/Task/ExchangeRates.Domain/Entities/Currency.cs
@@ -1,4 +1,4 @@
-namespace ExchangeRateUpdater
+namespace ExchangeRates.Domain.Entities
{
public class Currency
{
diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRates.Domain/Entities/ExchangeRate.cs
similarity index 92%
rename from jobs/Backend/Task/ExchangeRate.cs
rename to jobs/Backend/Task/ExchangeRates.Domain/Entities/ExchangeRate.cs
index 58c5bb10e0..d74767a950 100644
--- a/jobs/Backend/Task/ExchangeRate.cs
+++ b/jobs/Backend/Task/ExchangeRates.Domain/Entities/ExchangeRate.cs
@@ -1,4 +1,4 @@
-namespace ExchangeRateUpdater
+namespace ExchangeRates.Domain.Entities
{
public class ExchangeRate
{
diff --git a/jobs/Backend/Task/ExchangeRates.Domain/ExchangeRates.Domain.csproj b/jobs/Backend/Task/ExchangeRates.Domain/ExchangeRates.Domain.csproj
new file mode 100644
index 0000000000..fa71b7ae6a
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Domain/ExchangeRates.Domain.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRates.Infrastructure/Cache/CacheExpirationsHelper.cs b/jobs/Backend/Task/ExchangeRates.Infrastructure/Cache/CacheExpirationsHelper.cs
new file mode 100644
index 0000000000..5b501f160b
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Infrastructure/Cache/CacheExpirationsHelper.cs
@@ -0,0 +1,42 @@
+namespace ExchangeRates.Infrastructure.Cache
+{
+ public static class CacheExpirationHelper
+ {
+ public static TimeSpan GetCacheExpirationToNextCzTime(
+ TimeOnly dailyRefreshTime,
+ DateTime dataLastUpdated,
+ DateTime? utcNow = null)
+ {
+ var nowUtc = utcNow ?? DateTime.UtcNow;
+ var czTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time");
+ var nowCz = TimeZoneInfo.ConvertTimeFromUtc(nowUtc, czTimeZone);
+
+ var lastUpdateCz = TimeZoneInfo.ConvertTimeFromUtc(dataLastUpdated.Date, czTimeZone);
+ var candidateRefreshCz = lastUpdateCz.Date
+ .AddDays(1)
+ .AddHours(dailyRefreshTime.Hour)
+ .AddMinutes(dailyRefreshTime.Minute)
+ .AddSeconds(dailyRefreshTime.Second);
+
+ // Move to next business day if next refresh is weekend
+ var nextRefreshCz = GetNextBusinessDay(candidateRefreshCz);
+
+ // If the current time has already passed the scheduled refresh, set the cache to expire in 5 minutes
+ if (nowCz >= nextRefreshCz)
+ {
+ return TimeSpan.FromMinutes(5);
+ }
+
+ return nextRefreshCz - nowCz;
+ }
+
+ private static DateTime GetNextBusinessDay(DateTime date)
+ {
+ while (date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday)
+ {
+ date = date.AddDays(1);
+ }
+ return date;
+ }
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRates.Infrastructure/Cache/CacheKeys.cs b/jobs/Backend/Task/ExchangeRates.Infrastructure/Cache/CacheKeys.cs
new file mode 100644
index 0000000000..a1648cda27
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Infrastructure/Cache/CacheKeys.cs
@@ -0,0 +1,7 @@
+namespace ExchangeRates.Infrastructure.Cache
+{
+ public static class CacheKeys
+ {
+ public static string ExchangeRatesDaily() => "ExchangeRatesDaily";
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/CnbHttpClient.cs b/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/CnbHttpClient.cs
new file mode 100644
index 0000000000..986d25e0b7
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/CnbHttpClient.cs
@@ -0,0 +1,84 @@
+using ExchangesRates.Infrastructure.External.CNB.Dtos;
+using Microsoft.Extensions.Logging;
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+
+namespace ExchangeRates.Infrastructure.Clients.CNB
+{
+ public interface ICnbHttpClient
+ {
+ Task GetDailyExchangeRatesAsync(string? date = null, string lang = "EN", CancellationToken cancellationToken = default);
+ }
+
+ public class CnbHttpClient : ICnbHttpClient
+ {
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+
+ private static readonly JsonSerializerOptions _jsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ public CnbHttpClient(HttpClient httpClient, ILogger logger)
+ {
+ _httpClient = httpClient;
+ _logger = logger;
+ }
+
+ ///
+ /// Gets the daily exchange rates from the Czech National Bank API.
+ ///
+ public async Task GetDailyExchangeRatesAsync(string? date = null, string lang = "EN", CancellationToken cancellationToken = default)
+ {
+ var query = $"?lang={lang}";
+ if (!string.IsNullOrEmpty(date))
+ query += $"&date={date}";
+
+ var endpoint = $"/cnbapi/exrates/daily{query}";
+
+ _logger.LogInformation("Requesting CNB daily exchange rates from {Endpoint}", endpoint);
+
+ var stopwatch = System.Diagnostics.Stopwatch.StartNew();
+
+ try
+ {
+ using var response = await _httpClient.GetAsync(endpoint, cancellationToken);
+
+ if (response.StatusCode == HttpStatusCode.NotFound)
+ {
+ _logger.LogWarning("CNB API returned 404: Data not found for date {Date}.", date ?? "latest");
+ return null;
+ }
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var body = await response.Content.ReadAsStringAsync(cancellationToken);
+ var message = $"CNB API returned error {(int)response.StatusCode}: {response.ReasonPhrase}. Body: {body}";
+ _logger.LogError(message);
+ throw new HttpRequestException(message);
+ }
+
+ var result = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken);
+
+ if (result == null || result.Rates == null || !result.Rates.Any())
+ {
+ _logger.LogWarning("CNB API returned an empty response for date {Date}.", date ?? "latest");
+ return null;
+ }
+
+ stopwatch.Stop();
+ _logger.LogInformation("Successfully retrieved {Count} exchange rates from CNB in {ElapsedMilliseconds}ms.",
+ result.Rates.Count, stopwatch.ElapsedMilliseconds);
+
+ return result;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Unexpected error while fetching CNB exchange rates.");
+ throw;
+ }
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/CnbHttpClientOptions.cs b/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/CnbHttpClientOptions.cs
new file mode 100644
index 0000000000..a9ccee28a2
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/CnbHttpClientOptions.cs
@@ -0,0 +1,16 @@
+namespace ExchangeRates.Infrastructure.Clients.CNB
+{
+ public class CnbHttpClientOptions
+ {
+ public string? BaseUrl { get; set; }
+ public TimeOnly DailyRefreshTimeCZ { get; set; }
+
+ public int TimeoutSeconds { get; set; } = 10;
+
+ public int RetryCount { get; set; } = 3;
+ public int RetryBaseDelaySeconds { get; set; } = 2;
+
+ public int CircuitBreakerFailures { get; set; } = 2;
+ public int CircuitBreakerDurationSeconds { get; set; } = 30;
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/CnbHttpClientPolicies.cs b/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/CnbHttpClientPolicies.cs
new file mode 100644
index 0000000000..ea395b9dc0
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/CnbHttpClientPolicies.cs
@@ -0,0 +1,34 @@
+using Microsoft.Extensions.Logging;
+using Polly;
+using Polly.Extensions.Http;
+
+namespace ExchangeRates.Infrastructure.Clients.CNB
+{
+ public static class CnbHttpClientPolicies
+ {
+ public static IAsyncPolicy RetryPolicy(CnbHttpClientOptions options, ILogger logger)
+ {
+ return HttpPolicyExtensions
+ .HandleTransientHttpError()
+ .WaitAndRetryAsync(
+ options.RetryCount,
+ retryAttempt => TimeSpan.FromSeconds(Math.Pow(options.RetryBaseDelaySeconds, retryAttempt)),
+ onRetry: (outcome, timespan, retryAttempt, context) =>
+ {
+ logger.LogWarning(
+ "Retry {RetryAttempt} after {Delay}s due to {Reason}",
+ retryAttempt,
+ timespan.TotalSeconds,
+ outcome.Exception?.Message ?? outcome.Result?.StatusCode.ToString()
+ );
+ });
+ }
+
+ public static IAsyncPolicy TimeoutPolicy(CnbHttpClientOptions options)
+ {
+ return Policy.TimeoutAsync(
+ TimeSpan.FromSeconds(options.TimeoutSeconds)
+ );
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/Dtos/CnbExRate.cs b/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/Dtos/CnbExRate.cs
new file mode 100644
index 0000000000..bf254f43a5
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/Dtos/CnbExRate.cs
@@ -0,0 +1,13 @@
+namespace ExchangesRates.Infrastructure.External.CNB.Dtos
+{
+ public class CnbExRate
+ {
+ public long Amount { get; set; }
+ public string? Country { get; set; }
+ public string? Currency { get; set; }
+ public string? CurrencyCode { get; set; }
+ public int Order { get; set; }
+ public decimal Rate { get; set; }
+ public DateTime ValidFor { get; set; }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/Dtos/CnbExRatesResponse.cs b/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/Dtos/CnbExRatesResponse.cs
new file mode 100644
index 0000000000..cdd089e79f
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/Dtos/CnbExRatesResponse.cs
@@ -0,0 +1,7 @@
+namespace ExchangesRates.Infrastructure.External.CNB.Dtos
+{
+ public class CnbExRatesResponse
+ {
+ public List Rates { get; set; } = new();
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRates.Infrastructure/ExchangeRates.Infrastructure.csproj b/jobs/Backend/Task/ExchangeRates.Infrastructure/ExchangeRates.Infrastructure.csproj
new file mode 100644
index 0000000000..943533e284
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.Infrastructure/ExchangeRates.Infrastructure.csproj
@@ -0,0 +1,14 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRates.UnitTests/Application/Providers/ExchangeRatesProviderTests.cs b/jobs/Backend/Task/ExchangeRates.UnitTests/Application/Providers/ExchangeRatesProviderTests.cs
new file mode 100644
index 0000000000..935453a1b1
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.UnitTests/Application/Providers/ExchangeRatesProviderTests.cs
@@ -0,0 +1,189 @@
+using ExchangeRates.Application.Options;
+using ExchangeRates.Application.Providers;
+using ExchangeRates.Infrastructure.Clients.CNB;
+using ExchangesRates.Infrastructure.External.CNB.Dtos;
+using FluentAssertions;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using System.Text.Json;
+
+namespace ExchangeRates.UnitTests.Application.Providers
+{
+ public class ExchangeRatesProviderTests
+ {
+ private readonly Mock _cnbMock;
+ private readonly Mock _cacheMock;
+ private readonly IOptions _cnbOptions;
+ private readonly IOptions _exchangeOptions;
+ private readonly ILogger _logger;
+
+ public ExchangeRatesProviderTests()
+ {
+ _cnbMock = new Mock();
+ _cacheMock = new Mock();
+
+ _cnbOptions = Options.Create(new CnbHttpClientOptions
+ {
+ DailyRefreshTimeCZ = new TimeOnly(14, 30)
+ });
+
+ _exchangeOptions = Options.Create(new ExchangeRatesOptions
+ {
+ DefaultCurrencies = new[] { "USD", "EUR", "CZK" }
+ });
+
+ _logger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_ReturnsRates_FromCNBClient()
+ {
+ // Arrange
+ var cnbResponse = new CnbExRatesResponse
+ {
+ Rates = new List
+ {
+ new CnbExRate { CurrencyCode = "USD", Amount = 1, Rate = 22 },
+ new CnbExRate { CurrencyCode = "EUR", Amount = 1, Rate = 24 }
+ }
+ };
+
+ _cnbMock.Setup(c => c.GetDailyExchangeRatesAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(cnbResponse);
+
+ var provider = new ExchangeRatesProvider(_cnbMock.Object, _cacheMock.Object, _cnbOptions, _exchangeOptions, _logger);
+
+ // Act
+ var result = await provider.GetExchangeRatesAsync(new[] { "USD", "EUR" }, CancellationToken.None);
+
+ // Assert
+ result.Should().HaveCount(2);
+ result.First(r => r.TargetCurrency.Code == "USD").Value.Should().Be(22);
+ result.First(r => r.TargetCurrency.Code == "EUR").Value.Should().Be(24);
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_UsesCache_WhenDataExists()
+ {
+ // Arrange
+ var cachedResponse = new CnbExRatesResponse
+ {
+ Rates = new List { new CnbExRate { CurrencyCode = "USD", Amount = 1, Rate = 21 } }
+ };
+
+ var serialized = JsonSerializer.Serialize(cachedResponse);
+ var bytes = System.Text.Encoding.UTF8.GetBytes(serialized);
+
+ _cacheMock
+ .Setup(c => c.GetAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(bytes);
+
+ var provider = new ExchangeRatesProvider(_cnbMock.Object, _cacheMock.Object, _cnbOptions, _exchangeOptions, _logger);
+
+ // Act
+ var result = await provider.GetExchangeRatesAsync(new[] { "USD" }, CancellationToken.None);
+
+ // Assert
+ result.Should().HaveCount(1);
+ result.First().Value.Should().Be(21);
+
+ _cacheMock.Verify(c => c.GetAsync(It.IsAny(), It.IsAny()), Times.Once);
+ _cnbMock.Verify(c => c.GetDailyExchangeRatesAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_ReturnsEmpty_WhenCurrencyNotFound()
+ {
+ // Arrange
+ var cnbResponse = new CnbExRatesResponse
+ {
+ Rates = new List { new CnbExRate { CurrencyCode = "USD", Amount = 1, Rate = 22 } }
+ };
+
+ _cnbMock.Setup(c => c.GetDailyExchangeRatesAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(cnbResponse);
+
+ var provider = new ExchangeRatesProvider(_cnbMock.Object, _cacheMock.Object, _cnbOptions, _exchangeOptions, _logger);
+
+ // Act
+ var result = await provider.GetExchangeRatesAsync(new[] { "EUR" }, CancellationToken.None);
+
+ // Assert
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_UsesDefaultCurrencies_WhenInputIsNull()
+ {
+ // Arrange
+ var cnbResponse = new CnbExRatesResponse
+ {
+ Rates = new List
+ {
+ new CnbExRate { CurrencyCode = "USD", Amount = 1, Rate = 22 },
+ new CnbExRate { CurrencyCode = "EUR", Amount = 1, Rate = 24 },
+ new CnbExRate { CurrencyCode = "CZK", Amount = 1, Rate = 1 }
+ }
+ };
+
+ _cnbMock.Setup(c => c.GetDailyExchangeRatesAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(cnbResponse);
+
+ var provider = new ExchangeRatesProvider(_cnbMock.Object, _cacheMock.Object, _cnbOptions, _exchangeOptions, _logger);
+
+ // Act
+ var result = await provider.GetExchangeRatesAsync((IEnumerable?)null, CancellationToken.None);
+
+ // Assert
+ result.Should().HaveCount(3);
+ result.Select(r => r.TargetCurrency.Code).Should().Contain(new[] { "USD", "EUR", "CZK" });
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_Throws_WhenCancellationRequested()
+ {
+ // Arrange
+ var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ _cnbMock
+ .Setup(c => c.GetDailyExchangeRatesAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns((date, lang, token) =>
+ {
+ token.ThrowIfCancellationRequested();
+ return Task.FromResult(new CnbExRatesResponse
+ {
+ Rates = new List { new CnbExRate { CurrencyCode = "USD", Amount = 1, Rate = 22 } }
+ });
+ });
+
+ var provider = new ExchangeRatesProvider(_cnbMock.Object, _cacheMock.Object, _cnbOptions, _exchangeOptions, _logger);
+
+ // Act
+ Func act = async () => await provider.GetExchangeRatesAsync(new[] { "USD" }, cts.Token);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_Throws_WhenCNBClientFails()
+ {
+ // Arrange
+ _cnbMock.Setup(c => c.GetDailyExchangeRatesAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new Exception("CNB API error"));
+
+ var provider = new ExchangeRatesProvider(_cnbMock.Object, _cacheMock.Object, _cnbOptions, _exchangeOptions, _logger);
+
+ // Act
+ Func act = async () => await provider.GetExchangeRatesAsync(new[] { "USD" }, CancellationToken.None);
+
+ // Assert
+ await act.Should().ThrowAsync().WithMessage("CNB API error");
+ }
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRates.UnitTests/ExchangeRates.UnitTests.csproj b/jobs/Backend/Task/ExchangeRates.UnitTests/ExchangeRates.UnitTests.csproj
new file mode 100644
index 0000000000..75c7159a27
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.UnitTests/ExchangeRates.UnitTests.csproj
@@ -0,0 +1,33 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRates.UnitTests/Infrastructure/Cache/CacheExpirationHelperTests.cs b/jobs/Backend/Task/ExchangeRates.UnitTests/Infrastructure/Cache/CacheExpirationHelperTests.cs
new file mode 100644
index 0000000000..5039a642a2
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.UnitTests/Infrastructure/Cache/CacheExpirationHelperTests.cs
@@ -0,0 +1,124 @@
+using ExchangeRates.Infrastructure.Cache;
+using FluentAssertions;
+
+namespace ExchangeRates.UnitTests.Infrastructure.Cache
+{
+ public class CacheExpirationHelperTests
+ {
+ [Fact]
+ public void GetCacheExpirationToNextCzTime_BeforeRefresh_ReturnsSameDayTimeSpan()
+ {
+ // Arrange: Current time is before daily refresh on a weekday
+ var czTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time");
+ var nowCz = new DateTime(2025, 10, 31, 12, 0, 0); // Friday 12:00 CZ
+ var nowUtc = TimeZoneInfo.ConvertTimeToUtc(nowCz, czTimeZone);
+
+ var dailyRefreshTime = new TimeOnly(14, 0); // 14:00 CZ
+ var lastUpdateDate = new DateTime(2025, 10, 30);
+
+ // Act
+ var timespan = CacheExpirationHelper.GetCacheExpirationToNextCzTime(
+ dailyRefreshTime,
+ lastUpdateDate,
+ nowUtc
+ );
+
+ // Assert: 2 hours until next refresh
+ timespan.Should().Be(TimeSpan.FromHours(2));
+ }
+
+ [Fact]
+ public void GetCacheExpirationToNextCzTime_AfterRefresh_ReturnsNextDayDayTimeSpan()
+ {
+ // Arrange: Current time is before daily refresh on a weekday
+ var czTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time");
+ var nowCz = new DateTime(2025, 10, 30, 15, 0, 0); // Thursday 15:00 CZ
+ var nowUtc = TimeZoneInfo.ConvertTimeToUtc(nowCz, czTimeZone);
+
+ var dailyRefreshTime = new TimeOnly(14, 0); // 14:00 CZ
+ var lastUpdateDate = new DateTime(2025, 10, 30);
+
+ // Act
+ var timespan = CacheExpirationHelper.GetCacheExpirationToNextCzTime(
+ dailyRefreshTime,
+ lastUpdateDate,
+ nowUtc
+ );
+
+ // Assert: 2 hours until next refresh
+ timespan.Should().Be(TimeSpan.FromHours(23));
+ }
+
+ [Fact]
+ public void GetCacheExpirationToNextCzTime_DelayedRefresh_ReturnsExtraMinutesTimeSpan()
+ {
+ // Arrange: Current time is after daily refresh on a weekday
+ var czTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time");
+ var nowCz = new DateTime(2025, 10, 31, 14, 15, 0); // Friday 14:15 CZ
+ var nowUtc = TimeZoneInfo.ConvertTimeToUtc(nowCz, czTimeZone);
+
+ var dailyRefreshTime = new TimeOnly(14, 0); // 14:00 CZ
+ var lastUpdateDate = new DateTime(2025, 10, 30);
+
+ // Act
+ var timespan = CacheExpirationHelper.GetCacheExpirationToNextCzTime(
+ dailyRefreshTime,
+ lastUpdateDate,
+ nowUtc
+ );
+
+ // Assert: 5 minutes until next refresh
+ timespan.Should().Be(TimeSpan.FromMinutes(5));
+ }
+
+ [Fact]
+ public void GetCacheExpirationToNextCzTime_AfterRefresh_OnWeekday_ReturnsTimeUntilNextBusinessDay()
+ {
+ // Arrange: Current time is after daily refresh on Friday
+ var czTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time");
+ var nowCz = new DateTime(2025, 10, 31, 15, 0, 0); // Friday 15:00 CZ
+ var nowUtc = TimeZoneInfo.ConvertTimeToUtc(nowCz, czTimeZone);
+
+ var dailyRefreshTime = new TimeOnly(14, 0); // 14:00 CZ
+ var lastUpdateDate = new DateTime(2025, 10, 31); // Last update same day
+
+ // Act
+ var timespan = CacheExpirationHelper.GetCacheExpirationToNextCzTime(
+ dailyRefreshTime,
+ lastUpdateDate,
+ nowUtc
+ );
+
+ // Assert: Next refresh is Monday 3 Nov 14:00
+ var nextRefreshCz = new DateTime(2025, 11, 3, 14, 0, 0);
+ var expected = nextRefreshCz - nowCz;
+
+ timespan.Should().Be(expected);
+ }
+
+ [Fact]
+ public void GetCacheExpirationToNextCzTime_OnSaturday_ReturnsTimeUntilNextBusinessDay()
+ {
+ // Arrange: Current time is on Saturday
+ var czTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time");
+ var nowCz = new DateTime(2025, 11, 1, 10, 0, 0); // Saturday 10:00 CZ
+ var nowUtc = TimeZoneInfo.ConvertTimeToUtc(nowCz, czTimeZone);
+
+ var dailyRefreshTime = new TimeOnly(14, 0); // 14:00 CZ
+ var lastUpdateDate = new DateTime(2025, 10, 31); // Last update was Friday
+
+ // Act
+ var timespan = CacheExpirationHelper.GetCacheExpirationToNextCzTime(
+ dailyRefreshTime,
+ lastUpdateDate,
+ nowUtc
+ );
+
+ // Assert: Next refresh is Monday 3 Nov 14:00
+ var nextRefreshCz = new DateTime(2025, 11, 3, 14, 0, 0);
+ var expected = nextRefreshCz - nowCz;
+
+ timespan.Should().Be(expected);
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRates.sln b/jobs/Backend/Task/ExchangeRates.sln
new file mode 100644
index 0000000000..f5d99252f5
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRates.sln
@@ -0,0 +1,56 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.14.36127.28
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRates.Api", "ExchangeRates.Api\ExchangeRates.Api.csproj", "{5864D3CB-5E68-4DD7-A8CC-26FF3C1EE553}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRates.Application", "ExchangeRates.Application\ExchangeRates.Application.csproj", "{073FC653-7578-8555-169F-F4582C5018A9}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRates.Domain", "ExchangeRates.Domain\ExchangeRates.Domain.csproj", "{C4A559F9-6783-3BBE-EF14-233C00F0346B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRates.Infrastructure", "ExchangeRates.Infrastructure\ExchangeRates.Infrastructure.csproj", "{F3BD4E7F-732C-2DB0-6F4D-B1F55BCAEBB8}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRates.UnitTests", "ExchangeRates.UnitTests\ExchangeRates.UnitTests.csproj", "{92369D9A-AA0C-48BF-B7A3-31E267ABFA12}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {5864D3CB-5E68-4DD7-A8CC-26FF3C1EE553}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5864D3CB-5E68-4DD7-A8CC-26FF3C1EE553}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5864D3CB-5E68-4DD7-A8CC-26FF3C1EE553}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5864D3CB-5E68-4DD7-A8CC-26FF3C1EE553}.Release|Any CPU.Build.0 = Release|Any CPU
+ {073FC653-7578-8555-169F-F4582C5018A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {073FC653-7578-8555-169F-F4582C5018A9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {073FC653-7578-8555-169F-F4582C5018A9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {073FC653-7578-8555-169F-F4582C5018A9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C4A559F9-6783-3BBE-EF14-233C00F0346B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C4A559F9-6783-3BBE-EF14-233C00F0346B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C4A559F9-6783-3BBE-EF14-233C00F0346B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C4A559F9-6783-3BBE-EF14-233C00F0346B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F3BD4E7F-732C-2DB0-6F4D-B1F55BCAEBB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F3BD4E7F-732C-2DB0-6F4D-B1F55BCAEBB8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F3BD4E7F-732C-2DB0-6F4D-B1F55BCAEBB8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F3BD4E7F-732C-2DB0-6F4D-B1F55BCAEBB8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {92369D9A-AA0C-48BF-B7A3-31E267ABFA12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {92369D9A-AA0C-48BF-B7A3-31E267ABFA12}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {92369D9A-AA0C-48BF-B7A3-31E267ABFA12}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {92369D9A-AA0C-48BF-B7A3-31E267ABFA12}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {92369D9A-AA0C-48BF-B7A3-31E267ABFA12} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {99130711-4A8F-4B56-997C-E2F6019554CC}
+ EndGlobalSection
+EndGlobal
diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs
deleted file mode 100644
index 379a69b1f8..0000000000
--- a/jobs/Backend/Task/Program.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-
-namespace ExchangeRateUpdater
-{
- public static class Program
- {
- private static IEnumerable currencies = new[]
- {
- new Currency("USD"),
- new Currency("EUR"),
- new Currency("CZK"),
- new Currency("JPY"),
- new Currency("KES"),
- new Currency("RUB"),
- new Currency("THB"),
- new Currency("TRY"),
- new Currency("XYZ")
- };
-
- public static void Main(string[] args)
- {
- try
- {
- var provider = new ExchangeRateProvider();
- var rates = provider.GetExchangeRates(currencies);
-
- Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:");
- foreach (var rate in rates)
- {
- Console.WriteLine(rate.ToString());
- }
- }
- catch (Exception e)
- {
- Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'.");
- }
-
- Console.ReadLine();
- }
- }
-}