From 450fdc986e3b035120cbf8cb56caa73d51c886c4 Mon Sep 17 00:00:00 2001 From: Alex Halenko Date: Mon, 3 Nov 2025 04:03:39 +0000 Subject: [PATCH] Implement backend .NET task functionality --- jobs/Backend/Task/ExchangeRateHttpClient.cs | 56 ++++++++++++++++++++ jobs/Backend/Task/ExchangeRateProvider.cs | 45 ++++++++++++---- jobs/Backend/Task/ExchangeRateResponse.cs | 49 +++++++++++++++++ jobs/Backend/Task/ExchangeRateUpdater.csproj | 9 ++++ jobs/Backend/Task/MonthlyRateHelper.cs | 23 ++++++++ jobs/Backend/Task/MonthlyRateHelperTests.cs | 35 ++++++++++++ jobs/Backend/Task/Program.cs | 15 +++--- 7 files changed, 216 insertions(+), 16 deletions(-) create mode 100644 jobs/Backend/Task/ExchangeRateHttpClient.cs create mode 100644 jobs/Backend/Task/ExchangeRateResponse.cs create mode 100644 jobs/Backend/Task/MonthlyRateHelper.cs create mode 100644 jobs/Backend/Task/MonthlyRateHelperTests.cs diff --git a/jobs/Backend/Task/ExchangeRateHttpClient.cs b/jobs/Backend/Task/ExchangeRateHttpClient.cs new file mode 100644 index 0000000000..0d4e8f50f1 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateHttpClient.cs @@ -0,0 +1,56 @@ +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Web; + +namespace ExchangeRateUpdater +{ + public class ExchangeRateHttpClient + { + private const string CommonExchangeRateUrl = "https://api.cnb.cz/cnbapi/exrates/daily"; + private const string OtherExchangeRateUrl = "https://api.cnb.cz/cnbapi/fxrates/daily-month"; + + private readonly HttpClient _httpClient; + + public ExchangeRateHttpClient(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task GetCommonRatesAsync(CancellationToken cancellationToken = default) + { + var query = GenerateQueryString(CommonExchangeRateUrl); + + using var response = await _httpClient.GetAsync(query, cancellationToken); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + } + + public async Task GetOtherRatesAsync(CancellationToken cancellationToken = default) + { + var query = GenerateQueryString(OtherExchangeRateUrl, MonthlyRateHelper.GetDeclarationMonth()); + + using var response = await _httpClient.GetAsync(query, cancellationToken); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + } + + private static string GenerateQueryString(string baseUrl, string yearMonth = null) + { + var builder = new UriBuilder(baseUrl); + var query = HttpUtility.ParseQueryString(builder.Query); + + query["lang"] = "EN"; + + if (!string.IsNullOrEmpty(yearMonth)) + query["yearMonth"] = yearMonth; + + builder.Query = query.ToString() ?? string.Empty; + return builder.ToString(); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs index 6f82a97fbe..54950521b5 100644 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateProvider.cs @@ -1,19 +1,44 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; 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) + private readonly ExchangeRateHttpClient _client; + private readonly Currency _baseCurrency = new Currency("CZK"); + + public ExchangeRateProvider(ExchangeRateHttpClient client) + { + _client = client; + } + + public async Task> GetExchangeRatesAsync(IEnumerable currencies) { - return Enumerable.Empty(); + var commonRatesResponse = await _client.GetCommonRatesAsync(); + var otherRatesResponse = await _client.GetOtherRatesAsync(); + + var result = new List(); + + foreach (var currency in currencies) + { + var dto = commonRatesResponse.Rates.FirstOrDefault(r => + string.Equals(r.CurrencyCode, currency.Code, StringComparison.OrdinalIgnoreCase)) ?? + otherRatesResponse.Rates.FirstOrDefault(r => + string.Equals(r.CurrencyCode, currency.Code, StringComparison.OrdinalIgnoreCase)); + + if (dto == null) continue; + + result.Add(new ExchangeRate( + sourceCurrency: currency, + targetCurrency: _baseCurrency, + value: dto.Rate / dto.Amount + )); + } + + return result; } } -} +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateResponse.cs b/jobs/Backend/Task/ExchangeRateResponse.cs new file mode 100644 index 0000000000..8c8aefad0c --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateResponse.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater +{ + public class ExchangeRateResponse + { + [JsonPropertyName("rates")] + public List Rates { get; set; } = new(); + } + + public class ExchangeRateDto + { + /// + /// The date on which this rate was declared by the CNB. For commonly traded currencies, rates are declared + /// every working day after 2:30 p.m. and remain valid for that day and any immediately following non-working + /// days (weekends or public holidays). For other currencies, rates are declared on the last working day of the + /// month and remain valid for the entire following month. This property indicates the declaration date, but + /// the rate itself stays valid until it is superseded by the next declared rate according to these rules. + /// + [JsonPropertyName("validFor")] + public DateTime DeclarationDate { get; set; } + + [JsonPropertyName("order")] + public int Order { get; set; } + + [JsonPropertyName("country")] + public string Country { get; set; } = string.Empty; + + [JsonPropertyName("currency")] + public string Currency { get; set; } = string.Empty; + + /// + /// The quantity of the currency that the rate applies to; used to calculate the per-unit rate as Rate / Amount. + /// + [JsonPropertyName("amount")] + public int Amount { get; set; } + + [JsonPropertyName("currencyCode")] + public string CurrencyCode { get; set; } = string.Empty; + + /// + /// The exchange rate corresponding to the specified Amount; divide by Amount to obtain the per-unit rate. + /// + [JsonPropertyName("rate")] + public decimal Rate { get; set; } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12b..6432cc584e 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -5,4 +5,13 @@ net6.0 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + \ No newline at end of file diff --git a/jobs/Backend/Task/MonthlyRateHelper.cs b/jobs/Backend/Task/MonthlyRateHelper.cs new file mode 100644 index 0000000000..927d49a981 --- /dev/null +++ b/jobs/Backend/Task/MonthlyRateHelper.cs @@ -0,0 +1,23 @@ +using System; + +namespace ExchangeRateUpdater +{ + public static class MonthlyRateHelper + { + /// + /// Returns the month (as "yyyy-MM") in which the active "other currency" CNB exchange rates were declared. + /// According to the CNB, "other currency" exchange rates are declared on the last working day of the month, + /// applying to the entire following month. This method converts the provided UTC time (or current time if + /// none is supplied) to Central European Time (CET) and then returns the previous month, which corresponds + /// to the declaration month used by the CNB. + /// + public static string GetDeclarationMonth(DateTime? nowUtc = null) + { + var cet = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"); + var nowCet = TimeZoneInfo.ConvertTimeFromUtc(nowUtc ?? DateTime.UtcNow, cet); + + var prevMonth = nowCet.AddMonths(-1); + return $"{prevMonth:yyyy-MM}"; + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/MonthlyRateHelperTests.cs b/jobs/Backend/Task/MonthlyRateHelperTests.cs new file mode 100644 index 0000000000..c8c904c5e0 --- /dev/null +++ b/jobs/Backend/Task/MonthlyRateHelperTests.cs @@ -0,0 +1,35 @@ +using System; +using Xunit; + +namespace ExchangeRateUpdater +{ + public class MonthlyRateHelperTests + { + [Theory] + // Regular middle-of-month + [InlineData("2025-11-15T00:00:00Z", "2025-10")] + // Start of the month + [InlineData("2025-11-01T00:00:00Z", "2025-10")] + // Start of November (when converted to CEST) + [InlineData("2025-10-31T23:00:00Z", "2025-10")] + // End of November (when converted to CEST) + [InlineData("2025-11-30T22:59:59Z", "2025-10")] + // Start of December (when converted to CEST) + [InlineData("2025-11-30T23:00:00Z", "2025-11")] + // January → previous year + [InlineData("2025-01-10T12:00:00Z", "2024-12")] + // Leap year: March after February 29 + [InlineData("2024-03-01T00:00:00Z", "2024-02")] + public void GetDeclarationMonth_ReturnsExpectedMonth(string utcString, string expected) + { + // Arrange + var utcDate = DateTime.Parse(utcString, null, System.Globalization.DateTimeStyles.AdjustToUniversal); + + // Act + var result = MonthlyRateHelper.GetDeclarationMonth(utcDate); + + // Assert + Assert.Equal(expected, result); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f8..024fcbdf11 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,12 +1,14 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; namespace ExchangeRateUpdater { public static class Program { - private static IEnumerable currencies = new[] + private static readonly IEnumerable Currencies = new[] { new Currency("USD"), new Currency("EUR"), @@ -19,17 +21,18 @@ public static class Program new Currency("XYZ") }; - public static void Main(string[] args) + public static async Task Main(string[] args) { try { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); + var provider = new ExchangeRateProvider(new ExchangeRateHttpClient(new HttpClient())); + var rates = (await provider.GetExchangeRatesAsync(Currencies)).ToList(); + + Console.WriteLine($"Successfully retrieved {rates.Count} exchange rates:"); - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); foreach (var rate in rates) { - Console.WriteLine(rate.ToString()); + Console.WriteLine(rate); } } catch (Exception e)