diff --git a/.gitignore b/.gitignore
index fd3586545..cad8b3e8a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,5 @@
node_modules
bower_components
npm-debug.log
+
+.fake
\ 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/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
deleted file mode 100644
index 89be84daf..000000000
--- 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/ExchangeRateUpdater/src/Common/TextParsingUtils.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Common/TextParsingUtils.cs
new file mode 100644
index 000000000..69828911d
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Common/TextParsingUtils.cs
@@ -0,0 +1,60 @@
+namespace ExchangeRateUpdater.Common
+{
+ ///
+ /// Provides static utility methods for parsing text-based data.
+ ///
+ public static class TextParsingUtils
+ {
+ ///
+ /// Analyzes a sample of data lines to automatically detect the most likely column delimiter.
+ ///
+ /// The collection of all lines from the source file.
+ /// The detected delimiter character, or ',' as a default fallback.
+ public static char DetectDelimiter(IEnumerable lines)
+ {
+ var potentialDelimiters = new[] { '|', ',', ';', '\t' };
+ var sampleLines = lines.Where(l => !string.IsNullOrWhiteSpace(l)).Take(10).ToList();
+
+ if (sampleLines.Count < 2)
+ {
+ return ',';
+ }
+
+ var delimiterInfo = potentialDelimiters
+ .Select(d => new
+ {
+ Delimiter = d,
+ MostCommonGroup = sampleLines.GroupBy(l => l.Count(c => c == d))
+ .Where(g => g.Key > 0)
+ .OrderByDescending(g => g.Count())
+ .ThenByDescending(g => g.Key)
+ .Select(g => new { DelimiterCount = g.Key, LineCount = g.Count() })
+ .FirstOrDefault()
+ })
+ .Where(info => info.MostCommonGroup != null && info.MostCommonGroup.LineCount > 1)
+ .OrderByDescending(info => info.MostCommonGroup?.LineCount)
+ .ThenByDescending(info => info.MostCommonGroup?.DelimiterCount)
+ .FirstOrDefault();
+
+ return delimiterInfo?.Delimiter ?? ',';
+ }
+
+ ///
+ /// Finds the zero-based index of the header row in the data file.
+ ///
+ /// The collection of all lines from the source file.
+ /// The delimiter character to look for in the header.
+ /// The index of the header row, or -1 if not found.
+ public static int FindHeaderRowIndex(IReadOnlyList lines, char delimiter)
+ {
+ for (int i = 0; i < lines.Count; i++)
+ {
+ if (lines[i].Contains(delimiter))
+ {
+ return i;
+ }
+ }
+ return -1;
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Interfaces/ICnbApiClient.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Interfaces/ICnbApiClient.cs
new file mode 100644
index 000000000..ca05f68a6
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Interfaces/ICnbApiClient.cs
@@ -0,0 +1,14 @@
+namespace ExchangeRateUpdater.Core.Interfaces
+{
+ ///
+ /// Interface for a client that interacts with the Czech National Bank API to fetch exchange rates.
+ ///
+ public interface ICnbApiClient
+ {
+ ///
+ /// Asynchronously retrieves the latest exchange rates from the Czech National Bank API.
+ ///
+ /// A task that represents the asynchronous operation, containing the latest exchange rates.
+ Task GetLatestExchangeRatesAsync();
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Interfaces/ICnbExchangeRateRepository.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Interfaces/ICnbExchangeRateRepository.cs
new file mode 100644
index 000000000..0a4dff5d8
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Interfaces/ICnbExchangeRateRepository.cs
@@ -0,0 +1,22 @@
+using ExchangeRateUpdater.Core.Models;
+namespace ExchangeRateUpdater.Core.Interfaces
+{
+ ///
+ /// Interface for a repository that retrieves exchange rates from the Czech National Bank (CNB) API.
+ ///
+ public interface ICnbExchangeRateRepository
+ {
+ ///
+ /// Asynchronously retrieves the latest exchange rates.
+ ///
+ /// A collection of objects representing the latest exchange rates.
+ Task> GetExchangeRatesAsync();
+
+ ///
+ /// Asynchronously retrieves specific exchange rates for the given currencies.
+ ///
+ ///
+ ///
+ Task> GetSpecificExchangeRatesAsync(IEnumerable currencies);
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Interfaces/IExchangeRateProvider.cs
new file mode 100644
index 000000000..1746e5882
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Interfaces/IExchangeRateProvider.cs
@@ -0,0 +1,17 @@
+using ExchangeRateUpdater.Core.Models;
+
+namespace ExchangeRateUpdater.Core.Interfaces
+{
+ ///
+ /// Provides functionality to retrieve exchange rates for specified currencies.
+ ///
+ public interface IExchangeRateProvider
+ {
+ ///
+ /// Gets exchange rates for the specified currencies.
+ ///
+ /// Collection of currencies to get exchange rates for.
+ /// Collection of exchange rates for the specified currencies.
+ Task> GetExchangeRatesAsync(IEnumerable currencies);
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Models/Currency.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Models/Currency.cs
new file mode 100644
index 000000000..0ffca080d
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Models/Currency.cs
@@ -0,0 +1,77 @@
+namespace ExchangeRateUpdater.Core.Models
+{
+ ///
+ /// Represents a currency.
+ ///
+ public class Currency
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The three-letter ISO 4217 code of the currency.
+ ///
+ public Currency(string code)
+ {
+ if (string.IsNullOrWhiteSpace(code))
+ {
+ throw new ArgumentException("Currency code cannot be null or empty.", nameof(code));
+ }
+ Code = code.ToUpperInvariant();
+ }
+
+ ///
+ /// Three-letter ISO 4217 code of the currency.
+ ///
+ public string Code { get; }
+
+ ///
+ /// Overrides the ToString method to return the currency code.
+ ///
+ /// The currency code.
+ public override string ToString()
+ {
+ return Code;
+ }
+
+ ///
+ /// Determines whether the specified object is equal to the current currency.
+ ///
+ /// The object to compare with the current currency.
+ /// true if the specified object is equal to the current currency; otherwise, false.
+ public override bool Equals(object? obj)
+ {
+ return obj is Currency currency && Code == currency.Code;
+ }
+
+ ///
+ /// Generates a hash code for the current currency.
+ ///
+ /// A hash code for the current currency.
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(Code);
+ }
+
+ ///
+ /// Overloaded equality operator to compare two Currency objects.
+ ///
+ /// The first Currency object to compare.
+ /// The second Currency object to compare.
+ /// true if both Currency objects are equal; otherwise, false.
+ public static bool operator ==(Currency? left, Currency? right)
+ {
+ return EqualityComparer.Default.Equals(left, right);
+ }
+
+ ///
+ /// Overloaded inequality operator to compare two Currency objects.
+ ///
+ /// The first Currency object to compare.
+ /// The second Currency object to compare.
+ /// true if both Currency objects are not equal; otherwise, false.
+ public static bool operator !=(Currency? left, Currency? right)
+ {
+ return !(left == right);
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Models/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Models/ExchangeRate.cs
new file mode 100644
index 000000000..4877387b6
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Models/ExchangeRate.cs
@@ -0,0 +1,99 @@
+namespace ExchangeRateUpdater.Core.Models
+{
+ ///
+ /// Represents an exchange rate between two currencies.
+ ///
+ public class ExchangeRate
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The source currency (the currency to be converted from).
+ /// The target currency (the currency to be converted to).
+ /// The exchange rate value, which must be greater than zero.
+ /// Thrown when source or target currency is null.
+ /// Thrown when the value is less than or equal to zero.
+ public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value)
+ {
+ if (string.IsNullOrWhiteSpace(sourceCurrency.Code) || string.IsNullOrWhiteSpace(targetCurrency.Code))
+ {
+ throw new ArgumentException("Source and target currencies cannot be null or empty.");
+ }
+ if (value <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(value), "Exchange rate value must be greater than zero.");
+ }
+ SourceCurrency = sourceCurrency;
+ TargetCurrency = targetCurrency;
+ Value = value;
+ }
+
+ ///
+ /// The source currency of the exchange rate.
+ ///
+ public Currency SourceCurrency { get; }
+
+ ///
+ /// The target currency of the exchange rate.
+ ///
+ public Currency TargetCurrency { get; }
+
+ ///
+ /// The value of the exchange rate, representing how much one unit of the source currency is worth in the target currency.
+ ///
+ public decimal Value { get; }
+
+ ///
+ /// Overrides the ToString method to return a string representation of the exchange rate.
+ ///
+ /// A string in the format "SourceCurrency/TargetCurrency=Value".
+ public override string ToString()
+ {
+ return $"{SourceCurrency}/{TargetCurrency}={Value}";
+ }
+
+ ///
+ /// Determines whether the specified object is equal to the current exchange rate.
+ ///
+ ///
+ /// true if the specified object is equal to the current exchange rate; otherwise, false.
+ public override bool Equals(object? obj)
+ {
+ return obj is ExchangeRate rate &&
+ EqualityComparer.Default.Equals(SourceCurrency, rate.SourceCurrency) &&
+ EqualityComparer.Default.Equals(TargetCurrency, rate.TargetCurrency) &&
+ Value == rate.Value;
+ }
+
+ ///
+ /// Generates a hash code for the current exchange rate.
+ ///
+ /// A hash code for the current exchange rate.
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(SourceCurrency, TargetCurrency, Value);
+ }
+
+ ///
+ /// Overloaded equality operator to compare two ExchangeRate objects.
+ ///
+ ///
+ ///
+ /// true if both ExchangeRate objects are equal; otherwise, false.
+ public static bool operator ==(ExchangeRate? left, ExchangeRate? right)
+ {
+ return EqualityComparer.Default.Equals(left, right);
+ }
+
+ ///
+ /// Overloaded inequality operator to compare two ExchangeRate objects.
+ ///
+ ///
+ ///
+ /// true if both ExchangeRate objects are not equal; otherwise, false.
+ public static bool operator !=(ExchangeRate? left, ExchangeRate? right)
+ {
+ return !(left == right);
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/Data/CnbExchangeRateRepository.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Data/CnbExchangeRateRepository.cs
new file mode 100644
index 000000000..20ce0267f
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Data/CnbExchangeRateRepository.cs
@@ -0,0 +1,123 @@
+using ExchangeRateUpdater.Core.Interfaces;
+using ExchangeRateUpdater.Core.Models;
+using ExchangeRateUpdater.Common;
+using Microsoft.Extensions.Logging;
+
+namespace ExchangeRateUpdater.Data;
+
+public class CnbExchangeRateRepository : ICnbExchangeRateRepository
+{
+ private readonly ICnbApiClient _apiClient;
+ private readonly ILogger _logger;
+ private readonly Currency _baseCurrency = new Currency("CZK");
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ ///
+ ///
+ public CnbExchangeRateRepository(ICnbApiClient apiClient, ILogger logger)
+ {
+ _apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// Asynchronously retrieves the latest exchange rates from the Czech National Bank (CNB) API.
+ ///
+ /// A collection of objects representing the latest exchange rates.
+ ///
+ public async Task> GetExchangeRatesAsync()
+ {
+ try
+ {
+ _logger.LogInformation("Retrieving exchange rates from CNB API.");
+ var response = await _apiClient.GetLatestExchangeRatesAsync();
+ var rates = ParseExchangeRates(response);
+ _logger.LogInformation($"Successfully retrieved {rates.Count()} exchange rates.");
+ return rates;
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "Could not retrieve exchange rates from CNB API.");
+ throw new HttpRequestException("Could not retrieve exchange rates from CNB API.", e);
+ }
+ }
+
+ ///
+ /// Asynchronously retrieves specific exchange rates for the given currencies.
+ ///
+ ///
+ /// A collection of objects for the specified currencies.
+ ///
+ public async Task> GetSpecificExchangeRatesAsync(IEnumerable currencies)
+ {
+ try
+ {
+ _logger.LogInformation("Retrieving specific exchange rates from CNB API for currencies: {Currencies}", string.Join(", ", currencies));
+ var allRates = await GetExchangeRatesAsync();
+ var specificRates = allRates.Where(rate => currencies.Contains(rate.SourceCurrency));
+ _logger.LogInformation("Successfully retrieved specific exchange rates.");
+ return specificRates;
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "Could not retrieve specific exchange rates from CNB API.");
+ throw new HttpRequestException("Could not retrieve specific exchange rates from CNB API.", e);
+ }
+ }
+
+ private IEnumerable ParseExchangeRates(string responseText)
+ {
+ if (string.IsNullOrWhiteSpace(responseText))
+ {
+ throw new ArgumentException("Exchange rate response text cannot be null or empty.", nameof(responseText));
+ }
+
+ string[] lines = responseText.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries);
+ char delimiter = TextParsingUtils.DetectDelimiter(lines);
+ int headerRowIndex = TextParsingUtils.FindHeaderRowIndex(lines, delimiter);
+
+ if (headerRowIndex == -1)
+ {
+ _logger.LogError("Could not find header row in exchange rate response.");
+ yield break;
+ }
+
+ foreach (var line in lines.Skip(headerRowIndex + 1))
+ {
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ continue;
+ }
+
+ var parts = line.Split(delimiter);
+ if (parts.Length < 5)
+ {
+ _logger.LogWarning("Skipping invalid exchange rate line format: {Line}", line);
+ continue;
+ }
+
+ if (!int.TryParse(parts[2], out var amount))
+ {
+ _logger.LogWarning("Skipping line due to invalid amount format: {Line}", line);
+ continue;
+ }
+
+ if (!decimal.TryParse(parts[4], System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var rateValue))
+ {
+ _logger.LogWarning("Skipping line due to invalid rate value format: {Line}", line);
+ continue;
+ }
+
+ var normalizedRate = rateValue / amount;
+
+ var currency = new Currency(parts[3]);
+ yield return new ExchangeRate(
+ sourceCurrency: currency,
+ targetCurrency: _baseCurrency,
+ value: normalizedRate);
+ }
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater/src/ExchangeRateUpdater.csproj
new file mode 100644
index 000000000..eb243badf
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/src/ExchangeRateUpdater.csproj
@@ -0,0 +1,35 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ ExchangeRateUpdater
+ true
+ 1701;1702;1591
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater/src/ExchangeRateUpdater.sln
new file mode 100644
index 000000000..d8c45e36d
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/src/ExchangeRateUpdater.sln
@@ -0,0 +1,48 @@
+
+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
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Tests", "../tests/ExchangeRateUpdater.Tests.csproj", "{736A8F22-C35E-4C99-845D-33232BB1E5EB}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ 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}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x64.Build.0 = Debug|Any CPU
+ {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x86.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
+ {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x64.ActiveCfg = Release|Any CPU
+ {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x64.Build.0 = Release|Any CPU
+ {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x86.ActiveCfg = Release|Any CPU
+ {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x86.Build.0 = Release|Any CPU
+ {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Debug|x64.Build.0 = Debug|Any CPU
+ {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Debug|x86.Build.0 = Debug|Any CPU
+ {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Release|x64.ActiveCfg = Release|Any CPU
+ {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Release|x64.Build.0 = Release|Any CPU
+ {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Release|x86.ActiveCfg = Release|Any CPU
+ {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/Infrastructure/Http/CnbApiClient.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Infrastructure/Http/CnbApiClient.cs
new file mode 100644
index 000000000..83fde5d69
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Infrastructure/Http/CnbApiClient.cs
@@ -0,0 +1,119 @@
+using ExchangeRateUpdater.Core.Interfaces;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Polly;
+using Polly.CircuitBreaker;
+using Polly.Timeout;
+
+namespace ExchangeRateUpdater.Infrastructure.Http
+{
+ ///
+ /// Implementation of that retrieves exchange rates from the Czech National Bank (CNB) API.
+ /// Uses Polly for resilience with retry, circuit breaker, timeout, and fallback policies.
+ /// The client is configured via CnbApiOptions, which can be set in appsettings.json.
+ ///
+ public class CnbApiClient : ICnbApiClient
+ {
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+ private readonly CnbApiOptions _options;
+ private readonly IAsyncPolicy _policy;
+
+ public CnbApiClient(HttpClient httpClient, ILogger logger, IOptions options)
+ {
+ _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _options = options.Value ?? throw new ArgumentNullException(nameof(options));
+
+ _httpClient.BaseAddress = new Uri(_options.BaseUrl);
+ _httpClient.Timeout = TimeSpan.FromSeconds(_options.RequestTimeoutSeconds);
+
+ _policy = CreatePolicies();
+ }
+ ///
+ /// Retrieves latest exchange rates from the Czech National Bank (CNB) API.
+ ///
+ /// The latest exchange rates as a string.
+ ///
+ public async Task GetLatestExchangeRatesAsync()
+ {
+ try
+ {
+ return await _policy.ExecuteAsync(async () =>
+ {
+ _logger.LogDebug("Attempting to retrieve exchange rates from CNB API.");
+ using var response = await _httpClient.GetAsync(_options.ExchangeRatesEndpoint);
+ response.EnsureSuccessStatusCode();
+
+ var content = await response.Content.ReadAsStringAsync();
+ _logger.LogDebug("Successfully retrieved exchange rates from CNB API.");
+ return content;
+ });
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "Failed to retrieve exchange rates from CNB API.");
+ throw new HttpRequestException("Failed to retrieve exchange rates from CNB API.", e);
+ }
+ }
+
+ private IAsyncPolicy CreatePolicies()
+ {
+ var retryPolicy = Policy
+ .Handle()
+ .Or()
+ .Or()
+ .WaitAndRetryAsync(_options.MaxRetries, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)),
+ onRetry: (exception, delay, retryCount, _) =>
+ {
+ var ex = exception.Exception ?? new Exception(exception.ToString() ?? "Unknown error");
+ _logger.LogWarning(ex, "Retry {RetryCount} for request due to: {ExceptionMessage}. Waiting {Delay} before next attempt.",
+ retryCount, ex.Message, delay);
+ });
+
+ var circuitBreakerPolicy = Policy
+ .Handle()
+ .Or()
+ .AdvancedCircuitBreakerAsync(
+ failureThreshold: _options.CircuitBreakerFailureThreshold,
+ samplingDuration: TimeSpan.FromSeconds(_options.CircuitBreakerSamplingDurationSeconds),
+ minimumThroughput: _options.CircuitBreakerMinimumThroughput,
+ durationOfBreak: TimeSpan.FromSeconds(_options.CircuitBreakerDurationOfBreakSeconds),
+ onBreak: (exception, breakDelay) =>
+ {
+ var ex = exception.Exception ?? new Exception(exception.ToString() ?? "Unknown error");
+ _logger.LogWarning(
+ ex,
+ "Circuit breaker opened for {BreakDelay} seconds",
+ breakDelay.TotalSeconds);
+ },
+ onReset: () =>
+ {
+ _logger.LogInformation("Circuit breaker reset, flow is now normal");
+ },
+ onHalfOpen: () =>
+ {
+ _logger.LogInformation("Circuit breaker half-open, testing if service has recovered");
+ });
+
+ var timeoutPolicy = Policy.TimeoutAsync(
+ TimeSpan.FromSeconds(_options.RequestTimeoutSeconds),
+ TimeoutStrategy.Optimistic);
+
+ var fallbackPolicy = Policy
+ .Handle()
+ .Or()
+ .Or()
+ .FallbackAsync(
+ fallbackValue: string.Empty,
+ onFallbackAsync: async (outcome) =>
+ {
+ var ex = outcome.Exception ?? new Exception(outcome.ToString() ?? "Unknown error");
+ _logger.LogWarning(ex, "Fallback triggered due to error");
+ await Task.CompletedTask;
+ });
+
+ return Policy.WrapAsync(fallbackPolicy, Policy.WrapAsync(retryPolicy, circuitBreakerPolicy, timeoutPolicy));
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/Infrastructure/Http/CnbApiOptions.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Infrastructure/Http/CnbApiOptions.cs
new file mode 100644
index 000000000..ca9e79d98
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Infrastructure/Http/CnbApiOptions.cs
@@ -0,0 +1,49 @@
+namespace ExchangeRateUpdater.Infrastructure.Http
+{
+ ///
+ /// Provides configuration options for the CnbApiClient.
+ /// These options are typically configured in appsettings.json and bound at runtime.
+ ///
+ public class CnbApiOptions
+ {
+ ///
+ /// Gets or sets the base URL for the Czech National Bank (CNB) API.
+ ///
+ public string BaseUrl { get; set; } = "https://www.cnb.cz/";
+
+ ///
+ /// Gets or sets the endpoint for retrieving daily exchange rates.
+ ///
+ public string ExchangeRatesEndpoint { get; set; } = "en/financial_markets/foreign_exchange_market/exchange_rate_fixing/daily.txt";
+
+ ///
+ /// Gets or sets the timeout in seconds for a single HTTP request to the CNB API.
+ ///
+ public int RequestTimeoutSeconds { get; set; } = 30;
+
+ ///
+ /// Gets or sets the maximum number of retries for a failed request.
+ ///
+ public int MaxRetries { get; set; } = 3;
+
+ ///
+ /// Gets or sets the failure threshold for the circuit breaker (e.g., 0.5 means 50% of requests failed).
+ ///
+ public double CircuitBreakerFailureThreshold { get; set; } = 0.5;
+
+ ///
+ /// Gets or sets the duration in seconds over which failures are measured for the circuit breaker.
+ ///
+ public int CircuitBreakerSamplingDurationSeconds { get; set; } = 60;
+
+ ///
+ /// Gets or sets the minimum number of requests in the sampling duration before the circuit breaker can open.
+ ///
+ public int CircuitBreakerMinimumThroughput { get; set; } = 5;
+
+ ///
+ /// Gets or sets the duration in seconds for which the circuit breaker will remain open before transitioning to half-open.
+ ///
+ public int CircuitBreakerDurationOfBreakSeconds { get; set; } = 30;
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Program.cs
new file mode 100644
index 000000000..b44015c6b
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Program.cs
@@ -0,0 +1,106 @@
+using ExchangeRateUpdater.Core.Interfaces;
+using ExchangeRateUpdater.Core.Models;
+using ExchangeRateUpdater.Data;
+using ExchangeRateUpdater.Infrastructure.Http;
+using ExchangeRateUpdater.Services;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace ExchangeRateUpdater
+{
+ public class Program
+ {
+ 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")
+ };
+
+ public static async Task Main(string[] args)
+ {
+ try
+ {
+ using var host = CreateHostBuilder(args).Build();
+
+ var exchangeRateProvider = host.Services.GetRequiredService();
+ var logger = host.Services.GetRequiredService>();
+
+ var currencies = args.Length > 0
+ ? args.Select(c => new Currency(c.Trim().ToUpper()))
+ : DefaultCurrencies;
+
+ logger.LogInformation("Starting exchange rate retrieval...");
+
+ var rates = await exchangeRateProvider.GetExchangeRatesAsync(currencies);
+ var ratesList = rates.ToList();
+
+ if (ratesList.Count == 0)
+ {
+ logger.LogWarning("No exchange rates found for the specified currencies: {Currencies}", string.Join(", ", currencies));
+ return 0;
+ }
+
+ logger.LogInformation("Successfully retrieved {Count} exchange rates:", ratesList.Count);
+ foreach (var rate in ratesList)
+ {
+ Console.WriteLine(rate.ToString());
+ }
+
+ logger.LogInformation("Exchange rate retrieval completed successfully.");
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"An error occurred: {ex.Message}");
+ return 1;
+ }
+ }
+
+ private static IHostBuilder CreateHostBuilder(string[] args) =>
+ Host.CreateDefaultBuilder(args)
+ .ConfigureAppConfiguration((hostingContext, config) =>
+ {
+ var env = hostingContext.HostingEnvironment;
+ config.Sources.Clear();
+
+ var appLocation = Path.GetDirectoryName(typeof(Program).Assembly.Location)!;
+
+ config.SetBasePath(appLocation)
+ .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
+ .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true)
+ .AddEnvironmentVariables()
+ .AddCommandLine(args);
+ })
+ .ConfigureServices((hostContext, services) =>
+ {
+
+ services.Configure(
+ hostContext.Configuration.GetSection("CnbApi"));
+
+
+ services.AddHttpClient((serviceProvider, client) =>
+ {
+ var options = serviceProvider.GetRequiredService>().Value;
+ client.BaseAddress = new Uri(options.BaseUrl);
+ client.Timeout = TimeSpan.FromSeconds(options.RequestTimeoutSeconds);
+ });
+
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddLogging(configure =>
+ configure.AddConsole().AddDebug().SetMinimumLevel(LogLevel.Information));
+ });
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/Services/ExchangerateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Services/ExchangerateProvider.cs
new file mode 100644
index 000000000..a63cec392
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Services/ExchangerateProvider.cs
@@ -0,0 +1,70 @@
+using ExchangeRateUpdater.Core.Interfaces;
+using ExchangeRateUpdater.Core.Models;
+using Microsoft.Extensions.Logging;
+
+namespace ExchangeRateUpdater.Services
+{
+ ///
+ /// Provides a mechanism for retrieving exchange rates for a given set of currencies.
+ /// This is the default implementation of and relies on an to fetch the data.
+ ///
+ public class ExchangeRateProvider : IExchangeRateProvider
+ {
+ private readonly ICnbExchangeRateRepository _cnbExchangeRateRepository;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The repository for accessing CNB exchange rate data.
+ /// The logger for logging information and errors.
+ /// Thrown if or is null.
+ public ExchangeRateProvider(
+ ICnbExchangeRateRepository exchangeRateRepository,
+ ILogger logger)
+ {
+ _cnbExchangeRateRepository = exchangeRateRepository ?? throw new ArgumentNullException(nameof(exchangeRateRepository));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+
+ ///
+ /// Asynchronously retrieves exchange rates for a specified list of currencies.
+ ///
+ /// An enumerable collection of objects for which to retrieve exchange rates.
+ ///
+ /// A task that represents the asynchronous operation. The task result contains an enumerable collection of
+ /// for the requested currencies. Returns an empty collection if the input is empty.
+ ///
+ /// Thrown if the collection is null.
+ /// Rethrows exceptions from the underlying repository on failure.
+ public async Task> GetExchangeRatesAsync(IEnumerable currencies)
+ {
+ if (currencies == null)
+ throw new ArgumentNullException(nameof(currencies));
+
+ var currencyList = currencies.ToList();
+ if (currencyList.Count == 0)
+ {
+ _logger.LogWarning("No currencies provided for exchange rate lookup");
+ return Enumerable.Empty();
+ }
+
+ try
+ {
+ _logger.LogInformation("Retrieving exchange rates for {Count} currencies", currencyList.Count);
+
+ var rates = await _cnbExchangeRateRepository.GetSpecificExchangeRatesAsync(currencyList);
+ var ratesList = rates.ToList();
+
+ _logger.LogInformation("Successfully retrieved {Count} exchange rates", ratesList.Count);
+ return ratesList;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to retrieve exchange rates");
+ throw;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater/src/appsettings.json
new file mode 100644
index 000000000..5e41ee28c
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/src/appsettings.json
@@ -0,0 +1,19 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft": "Warning",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+ "CnbApi": {
+ "BaseUrl": "https://www.cnb.cz/",
+ "ExchangeRatesEndpoint": "en/financial_markets/foreign_exchange_market/exchange_rate_fixing/daily.txt",
+ "RequestTimeoutSeconds": 30,
+ "MaxRetries": 3,
+ "CircuitBreakerFailureThreshold": 0.5,
+ "CircuitBreakerSamplingDurationSeconds": 60,
+ "CircuitBreakerMinimumThroughput": 5,
+ "CircuitBreakerDurationOfBreakSeconds": 30
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task/ExchangeRateUpdater/tests/ExchangeRateUpdater.Tests.csproj
new file mode 100644
index 000000000..fc34557eb
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/tests/ExchangeRateUpdater.Tests.csproj
@@ -0,0 +1,34 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/tests/Integration/CnbApiClientIntegrationTests.cs b/jobs/Backend/Task/ExchangeRateUpdater/tests/Integration/CnbApiClientIntegrationTests.cs
new file mode 100644
index 000000000..72ec84fd8
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/tests/Integration/CnbApiClientIntegrationTests.cs
@@ -0,0 +1,28 @@
+using Xunit;
+using Moq;
+using Microsoft.Extensions.Logging;
+using ExchangeRateUpdater.Infrastructure.Http;
+using Microsoft.Extensions.Options;
+
+namespace ExchangeRateUpdater.Tests.Integration
+{
+ public class CnbApiClientIntegrationTests
+ {
+ [Fact]
+ [Trait("Category", "Integration")] // An attribute to mark this as a slow integration test
+ public async Task GetLatestExchangeRatesAsync_WhenCalled_ConnectsToCnbAndFetchesData()
+ {
+ var options = Options.Create(new CnbApiOptions());
+
+ var httpClient = new HttpClient();
+ var mockLogger = new Mock>();
+
+ var apiClient = new CnbApiClient(httpClient, mockLogger.Object, options);
+
+ var result = await apiClient.GetLatestExchangeRatesAsync();
+
+ Assert.NotNull(result);
+ Assert.False(string.IsNullOrWhiteSpace(result));
+ }
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/tests/Unit/CnbExchangeRateRepositoryTest.cs b/jobs/Backend/Task/ExchangeRateUpdater/tests/Unit/CnbExchangeRateRepositoryTest.cs
new file mode 100644
index 000000000..1dc08a376
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/tests/Unit/CnbExchangeRateRepositoryTest.cs
@@ -0,0 +1,62 @@
+using Xunit;
+using Moq;
+using Microsoft.Extensions.Logging;
+using ExchangeRateUpdater.Data;
+using ExchangeRateUpdater.Core.Interfaces;
+
+namespace ExchangeRateUpdater.Tests.Unit
+{
+ public class CnbExchangeRateRepositoryTest
+ {
+ private readonly Mock _mockApiClient;
+ private readonly Mock> _mockLogger;
+ private readonly CnbExchangeRateRepository _repository;
+
+ public CnbExchangeRateRepositoryTest()
+ {
+ _mockApiClient = new Mock();
+ _mockLogger = new Mock>();
+ _repository = new CnbExchangeRateRepository(_mockApiClient.Object, _mockLogger.Object);
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_WithValidData_ReturnsCorrectlyParsedRates()
+ {
+ var fakeApiResponse = @"21 Jun 2024 #118
+Country|Currency|Amount|Code|Rate
+Australia|dollar|1|AUD|15.426
+Brazil|real|1|BRL|4.274
+Canada|dollar|1|CAD|17.009";
+
+ _mockApiClient
+ .Setup(client => client.GetLatestExchangeRatesAsync())
+ .ReturnsAsync(fakeApiResponse);
+
+ var result = await _repository.GetExchangeRatesAsync();
+ var rates = result.ToList();
+
+ Assert.NotNull(result);
+ Assert.Equal(3, rates.Count);
+
+ var audRate = rates.FirstOrDefault(r => r.SourceCurrency.Code == "AUD");
+ Assert.NotNull(audRate);
+ Assert.Equal("AUD", audRate.SourceCurrency.Code);
+ Assert.Equal("CZK", audRate.TargetCurrency.Code);
+ Assert.Equal(15.426m, audRate.Value);
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_WithEmptyString_ThrowsHttpRequestException()
+ {
+ var fakeApiResponse = string.Empty;
+
+ _mockApiClient
+ .Setup(client => client.GetLatestExchangeRatesAsync())
+ .ReturnsAsync(fakeApiResponse);
+
+ var exception = await Assert.ThrowsAsync(() => _repository.GetExchangeRatesAsync());
+
+ Assert.IsType(exception.InnerException);
+ }
+ }
+}
\ No newline at end of file
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();
- }
- }
-}