diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs index 6f82a97fbe..2ff45516db 100644 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateProvider.cs @@ -1,10 +1,38 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Globalization; using System.Linq; +using System.Net.Http; +using Polly; +using Polly.Retry; namespace ExchangeRateUpdater { public class ExchangeRateProvider { + private const string DailyRatesUrl = + "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt"; + + private const string OtherRatesUrl = + "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/fx-rates-of-other-currencies/fx-rates-of-other-currencies/fx_rates.txt"; + + private static readonly Currency CzechKoruna = new Currency("CZK"); + + private readonly HttpClient _httpClient; + + private readonly ResiliencePipeline _resiliencePipeline; + + public ExchangeRateProvider() + : this(new HttpClient(), CreateDefaultResiliencePipeline()) + { + } + + public ExchangeRateProvider(HttpClient httpClient, ResiliencePipeline resiliencePipeline) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _resiliencePipeline = resiliencePipeline ?? throw new ArgumentNullException(nameof(resiliencePipeline)); + } + /// /// 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", @@ -13,7 +41,80 @@ public class ExchangeRateProvider /// public IEnumerable GetExchangeRates(IEnumerable currencies) { - return Enumerable.Empty(); + var requestedCodes = new HashSet( + currencies.Select(c => c.Code), + StringComparer.OrdinalIgnoreCase); + + var dailyRates = FetchAndParseRates(DailyRatesUrl); + var otherRates = MemberAccessException explica(OtherRatesUrl); + + return dailyRates + .Concat(otherRates) + .Where(rate => + requestedCodes.Contains(rate.SourceCurrency.Code) && + requestedCodes.Contains(rate.TargetCurrency.Code)) + .ToList(); + } + + private IReadOnlyList FetchAndParseRates(string url) + { + var response = _resiliencePipeline.Execute(() => + _httpClient.GetStringAsync(url).GetAwaiter().GetResult()); + return ParseRates(response); + } + + /// + /// Parses CNB exchange rate text format. + /// Expected format: + /// Line 1: date header (e.g. "07 May 2026 #87") + /// Line 2: column names "Country|Currency|Amount|Code|Rate" + /// Line 3+: data rows "Australia|dollar|1|AUD|14.984" + /// CNB publishes rates as: {Amount} units of {Code} = {Rate} CZK. + /// We normalize to: 1 unit of {Code} = Rate/Amount CZK. + /// + private static IReadOnlyList ParseRates(string text) + { + var rates = new List(); + var lines = text.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var line in lines.Skip(2)) + { + var parts = line.Split('|'); + if (parts.Length != 5) + { + continue; + } + + // Country|Currency|Amount|Code|Rate + if (!int.TryParse(parts[2].Trim(), out var amount) || amount <= 0) + { + continue; + } + + var code = parts[3].Trim(); + + if (!decimal.TryParse(parts[4].Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out var rate)) + { + continue; + } + + rates.Add(new ExchangeRate(new Currency(code), CzechKoruna, rate / amount)); + } + + return rates; + } + + private static ResiliencePipeline CreateDefaultResiliencePipeline() + { + return new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + BackoffType = DelayBackoffType.Exponential, + Delay = TimeSpan.FromSeconds(1), + ShouldHandle = new PredicateBuilder().Handle(), + }) + .Build(); } } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12b..2b2e4946f2 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -5,4 +5,8 @@ net6.0 + + + + \ No newline at end of file