diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs
deleted file mode 100644
index f375776f25..0000000000
--- a/jobs/Backend/Task/Currency.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-namespace ExchangeRateUpdater
-{
- public class Currency
- {
- public Currency(string code)
- {
- Code = code;
- }
-
- ///
- /// Three-letter ISO 4217 code of the currency.
- ///
- public string Code { get; }
-
- public override string ToString()
- {
- return Code;
- }
- }
-}
diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs
deleted file mode 100644
index 58c5bb10e0..0000000000
--- a/jobs/Backend/Task/ExchangeRate.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-namespace ExchangeRateUpdater
-{
- public class ExchangeRate
- {
- public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value)
- {
- SourceCurrency = sourceCurrency;
- TargetCurrency = targetCurrency;
- Value = value;
- }
-
- public Currency SourceCurrency { get; }
-
- public Currency TargetCurrency { get; }
-
- public decimal Value { get; }
-
- public override string ToString()
- {
- return $"{SourceCurrency}/{TargetCurrency}={Value}";
- }
- }
-}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core.Tests/CzechNationalBankExchangeRateProviderTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core.Tests/CzechNationalBankExchangeRateProviderTests.cs
new file mode 100644
index 0000000000..a44d8cd3ce
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Core.Tests/CzechNationalBankExchangeRateProviderTests.cs
@@ -0,0 +1,56 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using ExchangeRateUpdater.Core.ApiVendors;
+using ExchangeRateUpdater.Core.Models;
+using ExchangeRateUpdater.Core.Providers;
+using FluentAssertions;
+using Moq;
+using Xunit;
+
+namespace ExchangeRateUpdater.Core.Tests;
+
+public class CzechNationalBankExchangeRateProviderTests
+{
+ [Fact]
+ public async Task GetExchangeRates_EmptyInput_ReturnsEmpty_AndDoesNotCallVendor()
+ {
+ // Arrange
+ var vendorMock = new Mock(MockBehavior.Strict);
+ var sut = new CzechNationalBankExchangeRateProvider(vendorMock.Object);
+
+ // Act
+ var result = await sut.GetExchangeRates([]);
+
+ // Assert
+ result.Should().BeEmpty();
+ vendorMock.Verify(v => v.GetExchangeRates(It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task GetExchangeRates_FiltersToRequestedCurrencies_AndCallsVendorOnce()
+ {
+ // Arrange
+ var vendorMock = new Mock();
+ vendorMock
+ .Setup(v => v.GetExchangeRates("CZK"))
+ .ReturnsAsync([
+ new(new Currency("CZK"), new Currency("USD"), 0.043m),
+ new(new Currency("CZK"), new Currency("EUR"), 0.039m),
+ new(new Currency("CZK"), new Currency("GBP"), 0.034m)
+ ]);
+
+ var sut = new CzechNationalBankExchangeRateProvider(vendorMock.Object);
+
+ var requested = new[] { new Currency("USD"), new Currency("EUR") };
+
+ // Act
+ var result = await sut.GetExchangeRates(requested);
+
+ // Assert
+ result.Should().HaveCount(2);
+ result.Select(r => r.TargetCurrency.ToString()).Should().BeEquivalentTo(new[] { "USD", "EUR" });
+ result.All(r => r.SourceCurrency.ToString() == "CZK").Should().BeTrue();
+ vendorMock.Verify(v => v.GetExchangeRates("CZK"), Times.Once);
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core.Tests/ExchangeRateUpdater.Core.Tests.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Core.Tests/ExchangeRateUpdater.Core.Tests.csproj
new file mode 100644
index 0000000000..3ae51aebb3
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Core.Tests/ExchangeRateUpdater.Core.Tests.csproj
@@ -0,0 +1,21 @@
+
+
+ net9.0
+ false
+ true
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core.Tests/ServiceConfigurationTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core.Tests/ServiceConfigurationTests.cs
new file mode 100644
index 0000000000..db62829313
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Core.Tests/ServiceConfigurationTests.cs
@@ -0,0 +1,42 @@
+using System.Collections.Generic;
+using System.Linq;
+using ExchangeRateUpdater.Core.Configuration;
+using ExchangeRateUpdater.Core.Configuration.Options;
+using ExchangeRateUpdater.Core.Providers;
+using ExchangeRateUpdater.Core.Models;
+using FluentAssertions;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Xunit;
+
+namespace ExchangeRateUpdater.Core.Tests;
+
+public class ServiceConfigurationTests
+{
+ [Fact]
+ public void AddCore_BindsCurrencies_AndRegistersProvider()
+ {
+ // Arrange
+ var inMemory = new[]
+ {
+ new KeyValuePair("Currencies:0", "usd"),
+ new KeyValuePair("Currencies:1", " EUR ")
+ };
+ var configuration = new ConfigurationBuilder().AddInMemoryCollection(inMemory).Build();
+ var services = new ServiceCollection();
+
+ services.AddSingleton(Moq.Mock.Of());
+
+ // Act
+ services.AddCore(configuration);
+ var sp = services.BuildServiceProvider();
+
+ // Assert
+ sp.GetService().Should().NotBeNull();
+
+ var opts = sp.GetRequiredService>().Value;
+ opts.Currencies.Should().HaveCount(2);
+ opts.Currencies.Select(c => c.ToString()).Should().BeEquivalentTo(new[] { "usd", " EUR " }.Select(s => new Currency(s).ToString()));
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/ApiVendors/IExchangeRateVendor.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/ApiVendors/IExchangeRateVendor.cs
new file mode 100644
index 0000000000..cc95a1bf83
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/ApiVendors/IExchangeRateVendor.cs
@@ -0,0 +1,8 @@
+using ExchangeRateUpdater.Core.Models;
+
+namespace ExchangeRateUpdater.Core.ApiVendors;
+
+public interface IExchangeRateVendor
+{
+ Task> GetExchangeRates(string baseCurrencyCode);
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/AppExceptions/VendorFailureException.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/AppExceptions/VendorFailureException.cs
new file mode 100644
index 0000000000..bf9f10bbcc
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/AppExceptions/VendorFailureException.cs
@@ -0,0 +1,3 @@
+namespace ExchangeRateUpdater.Core.AppExceptions;
+
+public class VendorFailureException(string message) : Exception(message);
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/Options/CurrencyOptions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/Options/CurrencyOptions.cs
new file mode 100644
index 0000000000..e4da131e1f
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/Options/CurrencyOptions.cs
@@ -0,0 +1,8 @@
+using ExchangeRateUpdater.Core.Models;
+
+namespace ExchangeRateUpdater.Core.Configuration.Options;
+
+public class CurrencyOptions
+{
+ public Currency[] Currencies { get; set; } = [];
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/ServiceConfiguration.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/ServiceConfiguration.cs
new file mode 100644
index 0000000000..f4c429882b
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/ServiceConfiguration.cs
@@ -0,0 +1,24 @@
+using ExchangeRateUpdater.Core.Configuration.Options;
+using ExchangeRateUpdater.Core.Models;
+using ExchangeRateUpdater.Core.Providers;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ExchangeRateUpdater.Core.Configuration;
+
+public static class ServiceConfiguration
+{
+ public static IServiceCollection AddCore(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddMemoryCache();
+ services.AddTransient();
+ services.AddOptions().Configure(opts =>
+ {
+ var currencies = configuration.GetSection("Currencies").Get() ?? [];
+ opts.Currencies = currencies.Select(s => new Currency(s)).ToArray();
+ })
+ .ValidateOnStart();
+
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/ExchangeRateUpdater.Core.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Core/ExchangeRateUpdater.Core.csproj
new file mode 100644
index 0000000000..9f3759cb9b
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/ExchangeRateUpdater.Core.csproj
@@ -0,0 +1,16 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/Currency.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/Currency.cs
new file mode 100644
index 0000000000..9c4671c1bf
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/Currency.cs
@@ -0,0 +1,16 @@
+namespace ExchangeRateUpdater.Core.Models;
+
+public class Currency(
+ string code
+ )
+{
+ ///
+ /// Three-letter ISO 4217 code of the currency.
+ ///
+ private string Code { get; } = code;
+
+ public override string ToString()
+ {
+ return Code;
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/ExchangeRate.cs
new file mode 100644
index 0000000000..27da573ede
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/ExchangeRate.cs
@@ -0,0 +1,19 @@
+namespace ExchangeRateUpdater.Core.Models;
+
+ public class ExchangeRate(
+ Currency sourceCurrency,
+ Currency targetCurrency,
+ decimal value
+ )
+ {
+ public Currency SourceCurrency { get; } = sourceCurrency;
+
+ public Currency TargetCurrency { get; } = targetCurrency;
+
+ public decimal Value { get; } = value;
+
+ public override string ToString()
+ {
+ return $"{SourceCurrency}/{TargetCurrency}={Value}";
+ }
+ }
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/CzechNationalBankExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/CzechNationalBankExchangeRateProvider.cs
new file mode 100644
index 0000000000..30ab43a42c
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/CzechNationalBankExchangeRateProvider.cs
@@ -0,0 +1,31 @@
+using ExchangeRateUpdater.Core.ApiVendors;
+using ExchangeRateUpdater.Core.Models;
+using Microsoft.Extensions.Caching.Memory;
+
+namespace ExchangeRateUpdater.Core.Providers;
+
+public class CzechNationalBankExchangeRateProvider(
+ IExchangeRateVendor exchangeRateVendor,
+ IMemoryCache? cache = null
+ ) : IExchangeRateProvider
+{
+ private const string BaseCurrencyCode = "CZK";
+ private const string CacheKey = $"ExchangeRates:{BaseCurrencyCode}";
+ private readonly IMemoryCache _cache = cache ?? new MemoryCache(new MemoryCacheOptions());
+
+ public async Task> GetExchangeRates(IEnumerable currencies)
+ {
+ currencies = currencies.ToArray();
+ if (!currencies.Any()) return [];
+
+ var allRates = await _cache.GetOrCreateAsync(CacheKey, async entry =>
+ {
+ entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1);
+ return await exchangeRateVendor.GetExchangeRates(BaseCurrencyCode);
+ }) ?? [];
+
+ return allRates
+ .Where(rate => currencies.Any(currency => currency.ToString() == rate.TargetCurrency.ToString()))
+ .ToList();
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/ExchangeRateProvider.cs
similarity index 64%
rename from jobs/Backend/Task/ExchangeRateProvider.cs
rename to jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/ExchangeRateProvider.cs
index 6f82a97fbe..58233f0974 100644
--- a/jobs/Backend/Task/ExchangeRateProvider.cs
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/ExchangeRateProvider.cs
@@ -1,9 +1,9 @@
using System.Collections.Generic;
using System.Linq;
+using ExchangeRateUpdater.Core.Models;
-namespace ExchangeRateUpdater
-{
- public class ExchangeRateProvider
+namespace ExchangeRateUpdater.Core.Providers;
+ public class ExchangeRateProvider : IExchangeRateProvider
{
///
/// Should return exchange rates among the specified currencies that are defined by the source. But only those defined
@@ -11,9 +11,9 @@ public class ExchangeRateProvider
/// 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)
+ public Task> GetExchangeRates(IEnumerable currencies)
{
- return Enumerable.Empty();
+ return Task.FromResult(new List());
}
}
-}
+
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/IExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/IExchangeRateProvider.cs
new file mode 100644
index 0000000000..ae5e45c2da
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/IExchangeRateProvider.cs
@@ -0,0 +1,8 @@
+using ExchangeRateUpdater.Core.Models;
+
+namespace ExchangeRateUpdater.Core.Providers;
+
+public interface IExchangeRateProvider
+{
+ Task> GetExchangeRates(IEnumerable currencies);
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/CurrencyApiExchangeRateVendorTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/CurrencyApiExchangeRateVendorTests.cs
new file mode 100644
index 0000000000..cc5ea51f36
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/CurrencyApiExchangeRateVendorTests.cs
@@ -0,0 +1,87 @@
+using System;
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Threading.Tasks;
+using ExchangeRateUpdater.Core.AppExceptions;
+using ExchangeRateUpdater.Core.Models;
+using ExchangeRateUpdater.Infrastructure.Dtos;
+using ExchangeRateUpdater.Infrastructure.ExchangeRateVendors;
+using FluentAssertions;
+using Microsoft.Extensions.Logging.Abstractions;
+using Newtonsoft.Json;
+using RichardSzalay.MockHttp;
+using Xunit;
+
+namespace ExchangeRateUpdater.Infrastructure.Tests;
+
+public class CurrencyApiExchangeRateVendorTests
+{
+ private static CurrencyApiExchangeRateVendor CreateSut(MockHttpMessageHandler mock, Uri baseUri, string apiKey)
+ {
+ var client = new HttpClient(mock)
+ {
+ BaseAddress = baseUri
+ };
+ client.DefaultRequestHeaders.Add("apiKey", apiKey);
+ var logger = NullLogger.Instance;
+ return new CurrencyApiExchangeRateVendor(client, logger);
+ }
+
+ [Fact]
+ public async Task GetExchangeRates_Success_ParsesResponse()
+ {
+ // Arrange
+ var baseUri = new Uri("https://api.currencyapi.com/v3/");
+
+ var jsonResponse = File.ReadAllText("Mocks/ExchangeRates.json");
+ var mock = new MockHttpMessageHandler();
+ mock.When(HttpMethod.Get, baseUri + "latest?base_currency=CZK")
+ .WithHeaders("apiKey", "TEST_KEY")
+ .Respond("application/json", jsonResponse);
+
+ var sut = CreateSut(mock, baseUri, "TEST_KEY");
+
+ // Act
+ var result = await sut.GetExchangeRates("CZK");
+
+ // Assert
+ result.Should().HaveCount(2);
+ result.Should().Contain(r => r.SourceCurrency.ToString() == "CZK" && r.TargetCurrency.ToString() == "USD" && r.Value == 0.043m);
+ result.Should().Contain(r => r.SourceCurrency.ToString() == "CZK" && r.TargetCurrency.ToString() == "EUR" && r.Value == 0.039m);
+ }
+
+ [Fact]
+ public async Task GetExchangeRates_NonSuccess_ReturnsEmpty()
+ {
+ // Arrange
+ var baseUri = new Uri("https://api.currencyapi.com/v3/");
+ var mock = new MockHttpMessageHandler();
+ mock.When(HttpMethod.Get, baseUri + "latest?base_currency=CZK")
+ .Respond(HttpStatusCode.InternalServerError);
+ var sut = CreateSut(mock, baseUri, "KEY");
+
+ // Act
+ var result = await sut.GetExchangeRates("CZK");
+
+ // Assert
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task GetExchangeRates_HandlerThrows_ReturnsEmpty()
+ {
+ // Arrange
+ var baseUri = new Uri("https://api.currencyapi.com/v3/");
+ var mock = new MockHttpMessageHandler();
+ mock.When(HttpMethod.Get, baseUri + "latest?base_currency=CZK")
+ .Throw(new HttpRequestException("something happened"));
+ var sut = CreateSut(mock, baseUri, "KEY");
+
+ // Act
+ Action act = () => sut.GetExchangeRates("CZK").GetAwaiter().GetResult();
+
+ // Assert
+ act.Should().Throw().WithMessage("Could not retrieve exchange rates");
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/ExchangeRateUpdater.Infrastructure.Tests.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/ExchangeRateUpdater.Infrastructure.Tests.csproj
new file mode 100644
index 0000000000..495262c6a8
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/ExchangeRateUpdater.Infrastructure.Tests.csproj
@@ -0,0 +1,29 @@
+
+
+ net9.0
+ false
+ true
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/Mocks/ExchangeRates.json b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/Mocks/ExchangeRates.json
new file mode 100644
index 0000000000..4395301f55
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/Mocks/ExchangeRates.json
@@ -0,0 +1,6 @@
+{
+ "data": {
+ "USD": { "code": "USD", "value": 0.043 },
+ "EUR": { "code": "EUR", "value": 0.039 }
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/ServiceConfigurationTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/ServiceConfigurationTests.cs
new file mode 100644
index 0000000000..aba64043d5
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/ServiceConfigurationTests.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Collections.Generic;
+using ExchangeRateUpdater.Core.ApiVendors;
+using ExchangeRateUpdater.Infrastructure.Configuration;
+using FluentAssertions;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace ExchangeRateUpdater.Infrastructure.Tests;
+
+public class ServiceConfigurationTests
+{
+ [Fact]
+ public void AddInfrastructure_Throws_WhenBaseUrlMissing()
+ {
+ var settings = new[]
+ {
+ new KeyValuePair("CurrencyApi:ApiKey", "MY_KEY")
+ };
+ var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
+ var services = new ServiceCollection();
+
+ Action act = () => services.AddInfrastructure(configuration);
+ act.Should().Throw()
+ .WithMessage("*CurrencyApi:BaseUrl*");
+ }
+
+ [Fact]
+ public void AddInfrastructure_Throws_WhenApiKeyMissing()
+ {
+ var settings = new[]
+ {
+ new KeyValuePair("CurrencyApi:BaseUrl", "https://api.currencyapi.com/v3/")
+ };
+ var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
+ var services = new ServiceCollection();
+
+ Action act = () => services.AddInfrastructure(configuration);
+ act.Should().Throw()
+ .WithMessage("*CurrencyApi:ApiKey*");
+ }
+
+ [Fact]
+ public void AddInfrastructure_Resolves_TypedVendor()
+ {
+ var settings = new[]
+ {
+ new KeyValuePair("CurrencyApi:BaseUrl", "https://api.currencyapi.com/v3/"),
+ new KeyValuePair("CurrencyApi:ApiKey", "MY_KEY")
+ };
+ var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
+ var services = new ServiceCollection();
+
+ services.AddInfrastructure(configuration);
+ var sp = services.BuildServiceProvider();
+
+ sp.GetService().Should().NotBeNull();
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Configuration/ServiceConfiguration.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Configuration/ServiceConfiguration.cs
new file mode 100644
index 0000000000..278cc49cd9
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Configuration/ServiceConfiguration.cs
@@ -0,0 +1,23 @@
+using ExchangeRateUpdater.Core.ApiVendors;
+using ExchangeRateUpdater.Infrastructure.ExchangeRateVendors;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ExchangeRateUpdater.Infrastructure.Configuration;
+
+public static class ServiceConfiguration
+{
+ public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
+ {
+ string baseAddress = configuration["CurrencyApi:BaseUrl"] ?? throw new ArgumentException("CurrencyApi:BaseUrl is not set in configuration");
+ string apiKey = configuration["CurrencyApi:ApiKey"] ?? throw new ArgumentException("CurrencyApi:ApiKey is not set in configuration");
+
+ services.AddHttpClient(httpClient =>
+ {
+ httpClient.BaseAddress = new Uri(baseAddress);
+ httpClient.DefaultRequestHeaders.Add("apiKey", !string.IsNullOrWhiteSpace(apiKey) ? apiKey : throw new ArgumentException("CurrencyApi:ApiKey is not set in configuration"));
+ });
+
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Dtos/CurrencyApiErrorResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Dtos/CurrencyApiErrorResponse.cs
new file mode 100644
index 0000000000..9a15806dde
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Dtos/CurrencyApiErrorResponse.cs
@@ -0,0 +1,7 @@
+namespace ExchangeRateUpdater.Infrastructure.Dtos;
+
+public class CurrencyApiErrorResponse
+{
+ public string Message { get; set; }
+ public string Errors { get; set; }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Dtos/CurrencyApiResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Dtos/CurrencyApiResponse.cs
new file mode 100644
index 0000000000..9712421308
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Dtos/CurrencyApiResponse.cs
@@ -0,0 +1,18 @@
+using System.Text.Json.Serialization;
+
+namespace ExchangeRateUpdater.Infrastructure.Dtos;
+
+public record CurrencyApiResponse()
+{
+ [JsonPropertyName("data")]
+ public Dictionary Data { get; set; }
+};
+
+public record CurrencyApiRate
+{
+ [JsonPropertyName("code")]
+ public string Code { get; init; } = string.Empty;
+
+ [JsonPropertyName("value")]
+ public decimal Value { get; init; }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj
new file mode 100644
index 0000000000..a624d10dd3
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateVendors/CurrencyApiExchangeRateVendor.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateVendors/CurrencyApiExchangeRateVendor.cs
new file mode 100644
index 0000000000..5328509883
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateVendors/CurrencyApiExchangeRateVendor.cs
@@ -0,0 +1,44 @@
+using System.Text.Json;
+using ExchangeRateUpdater.Core.ApiVendors;
+using ExchangeRateUpdater.Core.AppExceptions;
+using ExchangeRateUpdater.Core.Models;
+using ExchangeRateUpdater.Infrastructure.Dtos;
+using Microsoft.Extensions.Logging;
+
+namespace ExchangeRateUpdater.Infrastructure.ExchangeRateVendors;
+
+public class CurrencyApiExchangeRateVendor(
+ HttpClient httpClient,
+ ILogger logger
+ ) : IExchangeRateVendor
+{
+ public async Task> GetExchangeRates(string baseCurrencyCode)
+ {
+ try
+ {
+ logger.LogInformation("Retrieving exchange rates for base currency '{BaseCurrencyCode}'" , baseCurrencyCode);
+
+ string requestUri = $"latest?base_currency={baseCurrencyCode}";
+ var result = await httpClient.GetAsync(requestUri);
+
+ if (!result.IsSuccessStatusCode)
+ {
+ var reasonPhrase = await result.Content.ReadAsStringAsync();
+ logger.LogError("Could not retrieve exchange rates: '{StatusCode}', Reason: '{ReasonPhrase}'.", result.StatusCode, reasonPhrase);
+ return [];
+ }
+
+ logger.LogInformation("Successfully retrieved exchange rates.");
+ var jsonString = await result.Content.ReadAsStringAsync();
+
+ var exchangeRates = JsonSerializer.Deserialize(jsonString);
+
+ return exchangeRates == null ? [] : exchangeRates.Data.Select(rate => new ExchangeRate(new Currency(baseCurrencyCode), new Currency(rate.Key), rate.Value.Value)).ToList();
+ }
+ catch (Exception e)
+ {
+ logger.LogError(e, "Could not retrieve exchange rates: '{EMessage}'.", e.Message);
+ throw new VendorFailureException("Could not retrieve exchange rates");
+ }
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj
deleted file mode 100644
index 2fc654a12b..0000000000
--- a/jobs/Backend/Task/ExchangeRateUpdater.csproj
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
- Exe
- net6.0
-
-
-
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln
index 89be84daff..3869c99b2e 100644
--- a/jobs/Backend/Task/ExchangeRateUpdater.sln
+++ b/jobs/Backend/Task/ExchangeRateUpdater.sln
@@ -3,7 +3,17 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 14
VisualStudioVersion = 14.0.25123.0
MinimumVisualStudioVersion = 10.0.40219.1
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Core", "ExchangeRateUpdater.Core\ExchangeRateUpdater.Core.csproj", "{F089DF4F-40EF-465F-96AA-BA2DD075FBC3}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Infrastructure", "ExchangeRateUpdater.Infrastructure\ExchangeRateUpdater.Infrastructure.csproj", "{F8F9AAE8-763A-41C4-A6CA-3B1F86ED30E6}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater\ExchangeRateUpdater.csproj", "{5C09772B-C470-4D42-8EE8-E016B574454F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Core.Tests", "ExchangeRateUpdater.Core.Tests\ExchangeRateUpdater.Core.Tests.csproj", "{41760885-F21C-4928-A490-C8464A9A815F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Infrastructure.Tests", "ExchangeRateUpdater.Infrastructure.Tests\ExchangeRateUpdater.Infrastructure.Tests.csproj", "{35F75B67-93C1-4B05-8803-611A1EDAD49A}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{EE6A45A1-223B-4647-8578-61ECFBEB2634}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -11,12 +21,32 @@ 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
+ {F089DF4F-40EF-465F-96AA-BA2DD075FBC3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F089DF4F-40EF-465F-96AA-BA2DD075FBC3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F089DF4F-40EF-465F-96AA-BA2DD075FBC3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F089DF4F-40EF-465F-96AA-BA2DD075FBC3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F8F9AAE8-763A-41C4-A6CA-3B1F86ED30E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F8F9AAE8-763A-41C4-A6CA-3B1F86ED30E6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F8F9AAE8-763A-41C4-A6CA-3B1F86ED30E6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F8F9AAE8-763A-41C4-A6CA-3B1F86ED30E6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5C09772B-C470-4D42-8EE8-E016B574454F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5C09772B-C470-4D42-8EE8-E016B574454F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5C09772B-C470-4D42-8EE8-E016B574454F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5C09772B-C470-4D42-8EE8-E016B574454F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {41760885-F21C-4928-A490-C8464A9A815F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {41760885-F21C-4928-A490-C8464A9A815F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {41760885-F21C-4928-A490-C8464A9A815F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {41760885-F21C-4928-A490-C8464A9A815F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {35F75B67-93C1-4B05-8803-611A1EDAD49A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {35F75B67-93C1-4B05-8803-611A1EDAD49A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {35F75B67-93C1-4B05-8803-611A1EDAD49A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {35F75B67-93C1-4B05-8803-611A1EDAD49A}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {41760885-F21C-4928-A490-C8464A9A815F} = {EE6A45A1-223B-4647-8578-61ECFBEB2634}
+ {35F75B67-93C1-4B05-8803-611A1EDAD49A} = {EE6A45A1-223B-4647-8578-61ECFBEB2634}
+ EndGlobalSection
EndGlobal
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj
new file mode 100644
index 0000000000..c9307aa07f
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj
@@ -0,0 +1,25 @@
+
+
+
+ Exe
+ net9.0
+ ac453ce2-0607-40b2-9164-c6f948d9facd
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
+
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater/Program.cs
new file mode 100644
index 0000000000..c5fb4aa4a2
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Program.cs
@@ -0,0 +1,27 @@
+using ExchangeRateUpdater.Core.Configuration;
+using ExchangeRateUpdater.Infrastructure.Configuration;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+
+var host = Host.CreateDefaultBuilder(args)
+ .ConfigureAppConfiguration((context, config) =>
+ {
+ if (context.HostingEnvironment.IsDevelopment())
+ {
+ config.AddEnvironmentVariables()
+ .AddJsonFile("appsettings.json")
+ .AddUserSecrets();
+ }
+ })
+ .ConfigureServices((context, services) =>
+ {
+ services.AddLogging();
+ services.AddCore(context.Configuration);
+ services.AddInfrastructure(context.Configuration);
+ services.AddHostedService();
+
+ })
+ .Build();
+
+await host.RunAsync();
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Properties/launchSettings.json b/jobs/Backend/Task/ExchangeRateUpdater/Properties/launchSettings.json
new file mode 100644
index 0000000000..de0e3b85a9
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "ExchangeRateUpdater": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "environmentVariables": {
+ "DOTNET_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Startup/ExchangeRateStartupService.cs b/jobs/Backend/Task/ExchangeRateUpdater/Startup/ExchangeRateStartupService.cs
new file mode 100644
index 0000000000..b4bb47c561
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Startup/ExchangeRateStartupService.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using ExchangeRateUpdater.Core.Configuration.Options;
+using ExchangeRateUpdater.Core.Models;
+using ExchangeRateUpdater.Core.Providers;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+
+namespace ExchangeRateUpdater.Startup;
+
+public class ExchangeRateStartupService(
+ IExchangeRateProvider provider,
+ IOptions options
+ ) : BackgroundService
+{
+ private static readonly IEnumerable DefaultCurrencies = 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")
+ };
+
+ private readonly IEnumerable _currencies = !options.Value.Currencies.Any() ? DefaultCurrencies : options.Value.Currencies;
+
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ try
+ {
+ var rates = await 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();
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.json
new file mode 100644
index 0000000000..f46d1a4b1d
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.json
@@ -0,0 +1,18 @@
+{
+ "Currencies": [
+ "USD",
+ "EUR",
+ "CZK",
+ "JPY",
+ "KES",
+ "RUB",
+ "THB",
+ "TRY",
+ "XYZ",
+ "NGN"
+ ],
+ "CurrencyApi": {
+ "BaseUrl": "https://api.currencyapi.com/v3/",
+ "ApiKey": ""
+ }
+}
diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs
deleted file mode 100644
index 379a69b1f8..0000000000
--- a/jobs/Backend/Task/Program.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-
-namespace ExchangeRateUpdater
-{
- public static class Program
- {
- private static IEnumerable currencies = new[]
- {
- new Currency("USD"),
- new Currency("EUR"),
- new Currency("CZK"),
- new Currency("JPY"),
- new Currency("KES"),
- new Currency("RUB"),
- new Currency("THB"),
- new Currency("TRY"),
- new Currency("XYZ")
- };
-
- public static void Main(string[] args)
- {
- try
- {
- var provider = new ExchangeRateProvider();
- var rates = provider.GetExchangeRates(currencies);
-
- Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:");
- foreach (var rate in rates)
- {
- Console.WriteLine(rate.ToString());
- }
- }
- catch (Exception e)
- {
- Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'.");
- }
-
- Console.ReadLine();
- }
- }
-}
diff --git a/jobs/Backend/Task/Readme.md b/jobs/Backend/Task/Readme.md
new file mode 100644
index 0000000000..2af5182040
--- /dev/null
+++ b/jobs/Backend/Task/Readme.md
@@ -0,0 +1,24 @@
+# Quick Setup
+
+Go to the [Currency API](https://app.currencyapi.com/api-keys) and get an API key. You will need to register for an account and get free calls.
+
+- Enable User Secrets on the app project (from the project folder):
+ - `cd ExchangeRateUpdater`
+ - `dotnet user-secrets init`
+ - `dotnet user-secrets set "CurrencyApi:ApiKey" ""`
+
+- Secrets/config shape (add via User Secrets):
+ ```json
+ {
+ "CurrencyApi": {
+ "ApiKey": "",
+ "BaseUrl": "https://api.currencyapi.com/v3/"
+ },
+ "Currencies": ["USD", "GBP", "CZK"]
+ }
+ ```
+
+- You can configure currencies: change `Currencies` for which you want to get the exchange rates against the base currency CZK
+
+
+The App is a Hosted Service that runs once and outputs the exchange rates to the console.