Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions jobs/Backend/Task/ExchangeRateHttpClient.cs
Original file line number Diff line number Diff line change
@@ -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<ExchangeRateResponse> GetCommonRatesAsync(CancellationToken cancellationToken = default)
{
var query = GenerateQueryString(CommonExchangeRateUrl);

using var response = await _httpClient.GetAsync(query, cancellationToken);
response.EnsureSuccessStatusCode();

return await response.Content.ReadFromJsonAsync<ExchangeRateResponse>(cancellationToken: cancellationToken);
}

public async Task<ExchangeRateResponse> 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<ExchangeRateResponse>(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();
}
}
}
45 changes: 35 additions & 10 deletions jobs/Backend/Task/ExchangeRateProvider.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public IEnumerable<ExchangeRate> GetExchangeRates(IEnumerable<Currency> currencies)
private readonly ExchangeRateHttpClient _client;
private readonly Currency _baseCurrency = new Currency("CZK");

public ExchangeRateProvider(ExchangeRateHttpClient client)
{
_client = client;
}

public async Task<IEnumerable<ExchangeRate>> GetExchangeRatesAsync(IEnumerable<Currency> currencies)
{
return Enumerable.Empty<ExchangeRate>();
var commonRatesResponse = await _client.GetCommonRatesAsync();
var otherRatesResponse = await _client.GetOtherRatesAsync();

var result = new List<ExchangeRate>();

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;
}
}
}
}
49 changes: 49 additions & 0 deletions jobs/Backend/Task/ExchangeRateResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;

namespace ExchangeRateUpdater
{
public class ExchangeRateResponse
{
[JsonPropertyName("rates")]
public List<ExchangeRateDto> Rates { get; set; } = new();
}

public class ExchangeRateDto
{
/// <summary>
/// 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.
/// </summary>
[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;

/// <summary>
/// The quantity of the currency that the rate applies to; used to calculate the per-unit rate as Rate / Amount.
/// </summary>
[JsonPropertyName("amount")]
public int Amount { get; set; }

[JsonPropertyName("currencyCode")]
public string CurrencyCode { get; set; } = string.Empty;

/// <summary>
/// The exchange rate corresponding to the specified Amount; divide by Amount to obtain the per-unit rate.
/// </summary>
[JsonPropertyName("rate")]
public decimal Rate { get; set; }
}
}
9 changes: 9 additions & 0 deletions jobs/Backend/Task/ExchangeRateUpdater.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,13 @@
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

</Project>
23 changes: 23 additions & 0 deletions jobs/Backend/Task/MonthlyRateHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System;

namespace ExchangeRateUpdater
{
public static class MonthlyRateHelper
{
/// <summary>
/// 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.
/// </summary>
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}";
}
}
}
35 changes: 35 additions & 0 deletions jobs/Backend/Task/MonthlyRateHelperTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
15 changes: 9 additions & 6 deletions jobs/Backend/Task/Program.cs
Original file line number Diff line number Diff line change
@@ -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<Currency> currencies = new[]
private static readonly IEnumerable<Currency> Currencies = new[]
{
new Currency("USD"),
new Currency("EUR"),
Expand All @@ -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)
Expand Down