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/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs
deleted file mode 100644
index 6f82a97fbe..0000000000
--- 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 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
deleted file mode 100644
index 89be84daff..0000000000
--- 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.slnx b/jobs/Backend/Task/ExchangeRateUpdater.slnx
new file mode 100644
index 0000000000..5a4e224552
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.slnx
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ 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 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/docs/readme.md b/jobs/Backend/Task/docs/readme.md
new file mode 100644
index 0000000000..2967902d90
--- /dev/null
+++ b/jobs/Backend/Task/docs/readme.md
@@ -0,0 +1,179 @@
+# Exchange Rate Updater – Notes & Considerations
+
+> **TL;DR**
+> A clean, immutable, and test-focused solution that fetches and parses daily CNB exchange rates, emphasizing correctness, validation, and separation of concerns
+
+
+
+
+### _How to Run The Application_
+
+```bash
+# navigate to src/Application folder
+cd src/Application
+
+# restore dependencies
+dotnet restore
+
+# build the solution
+dotnet build
+
+# run unit tests
+dotnet test
+
+# run the application
+dotnet run --project ExchangeRateUpdater.Application.csproj
+```
+
+
+
+
+
+## 1. Problem Statement
+
+_See Challenge Instructions here: [Mews Backend .NET Challenge Instructions](https://github.com/MewsSystems/developers/blob/master/jobs/Backend/DotNet.md)_
+
+
+The goal of this challenge is to fetch daily foreign exchange rates published by the Czech National Bank (CNB), parse the returned data, and expose the rates in a clean, domain-driven model.
+
+The CNB endpoint returns exchange rates as a plain-text, pipe-separated file containing:
+- A header with the publication date and working day number
+- A column definition row
+- One row per currency exchange rate
+
+The solution is expected to:
+- Retrieve the data over HTTP
+- Parse and validate the response format
+- Convert the raw data into strongly typed domain objects
+- Handle invalid or unexpected input gracefully
+
+
+
+## 2. Assumptions
+The following assumptions were made to keep the solution focused and explicit:
+
+- The CNB response format is stable and follows the structure found in the [sample_data.txt](sample_data.txt)
+- All exchange rate values are positive
+- The application aims to display the convertion of 1 unit of given currency. Example: 1 EUR -> CZK
+- The target currency is implicitly CZK, as implied by the [CNB source](https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt)
+- The full response fits comfortably in memory
+- This is a read-only, batch-style operation (no persistence required)
+
+
+
+## 3. Approach
+The solution is structured around a clear separation of concerns:
+
+_It uses IoC and DI to allow new implementations to be added without breaking existing ones_
+
+1. **HTTP Client Layer**
+ - Responsible for retrieving raw data from the CNB endpoint
+ - Configured with timeouts, retries, and resilience policies
+
+2. **Parsing Layer**
+ - Converts the pipe-separated text response into structured models
+ - Validates headers, column definitions, and data rows and fails early in case the contract ever changes
+ - Produces deterministic parsing results or meaningful exceptions
+
+3. **Domain Layer**
+ - Models currencies and exchange rates as immutable value objects
+ - Enforces basic domain invariants (e.g. positive exchange rates)
+
+4. **Application Layer**
+ - Orchestrates the flow between client, parser, and domain
+ - Exposes a clean API for consuming exchange rate data
+
+The design favors clarity and correctness over premature optimization.
+
+
+
+## 4. Design Decisions
+
+### Immutability
+- Domain entities and DTOs are implemented as immutable types
+- Value-based equality is used where identity is defined purely by data
+- This reduces side effects and simplifies reasoning and testing
+
+### Separation of Concerns
+- Parsing logic is isolated from HTTP and orchestration logic
+- Domain models are independent of infrastructure concerns
+- This makes individual components easier to test and evolve
+
+### Explicit Validation
+- The parser validates headers, column definitions, and row structure
+- Failures are detected early and reported with clear exception messages
+
+### Resilience
+- HTTP calls are protected using retry and circuit breaker policies
+- Transient failures are handled without leaking infrastructure concerns into the domain
+
+
+
+## 5. Immutability & Modeling
+The following modeling strategy was applied:
+
+- **Records**
+ - Domain value objects such as `Currency` and `ExchangeRate`
+ - DTOs and parsing result models
+ - Configuration objects loaded from application settings
+
+- **Classes**
+ - Services, clients, and parsers
+ - Components that encapsulate behavior or orchestration
+
+This distinction reflects the difference between *data* and *behavior* in the system.
+
+
+
+## 6. Edge Cases & Error Handling
+The solution explicitly handles:
+
+- Empty or whitespace-only responses
+- Missing or malformed headers
+- Unexpected column definitions
+- Invalid numeric values
+- Incomplete or malformed data rows
+
+When errors occur, the system:
+- Fails fast during parsing
+- Throws domain-specific exceptions with descriptive messages
+- Avoids silently ignoring invalid data
+
+
+
+## 7. Testing Strategy
+The testing approach focuses on correctness and determinism:
+
+- Unit tests for domain entities and invariants
+- Comprehensive parser tests covering:
+ - Valid inputs
+ - Invalid formats
+ - Edge cases and error scenarios
+- Tests are isolated, fast, and do not rely on external systems
+
+Unit tests for higher-level application behavior and Integration tests were intentionally omitted due to scope.
+
+
+
+## 8. Limitations
+Known limitations include:
+
+- No caching of results between runs
+- No dynamically parsing program args as currencies input
+- No persistence layer
+- No localization or formatting concerns addressed
+- No retry backoff customization exposed via configuration
+
+These were accepted trade-offs given the scope of the challenge.
+
+
+
+## 9. Possible Improvements
+With additional time, the following enhancements could be made:
+
+- Add integration tests with a mocked HTTP server
+- Implement better Domain Exceptions for proper retries / logging
+- Implement greceful failures for malformed Exchange Rates
+- Consider adding caching
+- Support additional rate providers via a common abstraction
+- Expose richer observability (metrics, structured logs)
\ No newline at end of file
diff --git a/jobs/Backend/Task/docs/sample_data.txt b/jobs/Backend/Task/docs/sample_data.txt
new file mode 100644
index 0000000000..d6f580d4cb
--- /dev/null
+++ b/jobs/Backend/Task/docs/sample_data.txt
@@ -0,0 +1,33 @@
+23 Dec 2025 #248
+Country|Currency|Amount|Code|Rate
+Australia|dollar|1|AUD|13.818
+Brazil|real|1|BRL|3.694
+Bulgaria|lev|1|BGN|12.434
+Canada|dollar|1|CAD|15.064
+China|renminbi|1|CNY|2.936
+Denmark|krone|1|DKK|3.256
+EMU|euro|1|EUR|24.320
+Hongkong|dollar|1|HKD|2.652
+Hungary|forint|100|HUF|6.218
+Iceland|krona|100|ISK|16.432
+IMF|SDR|1|XDR|28.206
+India|rupee|100|INR|23.043
+Indonesia|rupiah|1000|IDR|1.230
+Israel|new shekel|1|ILS|6.452
+Japan|yen|100|JPY|13.226
+Malaysia|ringgit|1|MYR|5.077
+Mexico|peso|1|MXN|1.150
+New Zealand|dollar|1|NZD|12.049
+Norway|krone|1|NOK|2.051
+Philippines|peso|100|PHP|35.072
+Poland|zloty|1|PLN|5.747
+Romania|leu|1|RON|4.778
+Singapore|dollar|1|SGD|16.051
+South Africa|rand|1|ZAR|1.237
+South Korea|won|100|KRW|1.393
+Sweden|krona|1|SEK|2.247
+Switzerland|franc|1|CHF|26.193
+Thailand|baht|100|THB|66.364
+Turkey|lira|100|TRY|48.183
+United Kingdom|pound|1|GBP|27.861
+USA|dollar|1|USD|20.631
\ No newline at end of file
diff --git a/jobs/Backend/Task/src/Application/ExchangeRateApp.cs b/jobs/Backend/Task/src/Application/ExchangeRateApp.cs
new file mode 100644
index 0000000000..66f3c32820
--- /dev/null
+++ b/jobs/Backend/Task/src/Application/ExchangeRateApp.cs
@@ -0,0 +1,38 @@
+using ExchangeRateUpdater.Domain.Entities;
+using ExchangeRateUpdater.Domain.Interfaces;
+using Microsoft.Extensions.Logging;
+
+namespace ExchangeRateUpdater.Application;
+
+public class ExchangeRateApp(IExchangeRateProvider provider, ILogger logger)
+{
+ public async Task RunAsync(IEnumerable currencies)
+ {
+ try
+ {
+ var rates = await provider.GetExchangeRatesAsync(currencies);
+
+ Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:");
+
+ foreach (var rate in rates)
+ {
+ Console.WriteLine(rate);
+ }
+
+ logger.LogInformation(
+ "Application completed successfully. Retrieved {Count} exchange rates",
+ rates.Count()
+ );
+ }
+ catch (HttpRequestException ex)
+ {
+ logger.LogError(ex, "Network error while retrieving exchange rates");
+ Console.WriteLine("Unable to retrieve exchange rates due to network error. Please check your connection.");
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Unexpected error while retrieving exchange rates");
+ Console.WriteLine("Unable to retrieve exchange rates at this time. Please try again later.");
+ }
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/src/Application/ExchangeRateUpdater.Application.csproj b/jobs/Backend/Task/src/Application/ExchangeRateUpdater.Application.csproj
new file mode 100644
index 0000000000..b0b8f22fef
--- /dev/null
+++ b/jobs/Backend/Task/src/Application/ExchangeRateUpdater.Application.csproj
@@ -0,0 +1,27 @@
+
+
+
+ Exe
+ net10.0
+ enable
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/src/Application/Program.cs b/jobs/Backend/Task/src/Application/Program.cs
new file mode 100644
index 0000000000..ebce75ae35
--- /dev/null
+++ b/jobs/Backend/Task/src/Application/Program.cs
@@ -0,0 +1,81 @@
+using ExchangeRateUpdater.Domain.Entities;
+using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Serilog;
+
+namespace ExchangeRateUpdater.Application;
+
+public static class Program
+{
+ private static IEnumerable _currencies =
+ [
+ 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)
+ {
+ ConfigureLogging();
+
+ try
+ {
+ Log.Information("Starting Exchange Rate Updater Application");
+
+ var host = CreateHostBuilder(args).Build();
+
+ using var scope = host.Services.CreateScope();
+ var app = scope.ServiceProvider.GetRequiredService();
+ await app.RunAsync(_currencies);
+
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ Log.Fatal(ex, "Application terminated unexpectedly");
+ Console.WriteLine($"Could not retrieve exchange rates: '{ex.Message}'.");
+ return 1;
+ }
+ finally
+ {
+ Log.CloseAndFlush();
+ }
+ }
+
+ private static void ConfigureLogging()
+ {
+ Log.Logger = new LoggerConfiguration()
+ .MinimumLevel.Information()
+ .MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning)
+ .MinimumLevel.Override("System", Serilog.Events.LogEventLevel.Warning)
+ .Enrich.FromLogContext()
+ .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
+ .CreateLogger();
+ }
+
+ private static IHostBuilder CreateHostBuilder(string[] args) =>
+ Host.CreateDefaultBuilder(args)
+ .UseSerilog()
+ .UseDefaultServiceProvider(options =>
+ {
+ options.ValidateScopes = true;
+ options.ValidateOnBuild = true;
+ }
+ )
+ .ConfigureServices((context, services) =>
+ {
+ services
+ .UseCzechNationalBankProvider(context);
+
+ services.AddTransient();
+ }
+ );
+
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/src/Application/appsettings.json b/jobs/Backend/Task/src/Application/appsettings.json
new file mode 100644
index 0000000000..fe1c0141a0
--- /dev/null
+++ b/jobs/Backend/Task/src/Application/appsettings.json
@@ -0,0 +1,17 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft": "Warning",
+ "System": "Warning"
+ }
+ },
+ "CnbProvider": {
+ "BaseUrl": "https://www.cnb.cz",
+ "DailyRatesPath": "/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt",
+ "TimeoutSeconds": 10,
+ "DurationOfCircuitBreakSeconds": 30,
+ "RetryCount": 3,
+ "UserAgent": "Chrome/120.0.0.0 Safari/537.36"
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/src/Domain/Entities/Currency.cs b/jobs/Backend/Task/src/Domain/Entities/Currency.cs
new file mode 100644
index 0000000000..fc0e244608
--- /dev/null
+++ b/jobs/Backend/Task/src/Domain/Entities/Currency.cs
@@ -0,0 +1,26 @@
+namespace ExchangeRateUpdater.Domain.Entities;
+
+///
+/// Represents a currency using its ISO 4217 code.
+///
+public record Currency
+{
+ public string Code { get; }
+
+ public Currency(string code)
+ {
+ if (string.IsNullOrWhiteSpace(code))
+ {
+ throw new ArgumentException("Currency code cannot be empty", nameof(code));
+ }
+
+ if (code.Length != 3)
+ {
+ throw new ArgumentException("Currency code must be 3 characters", nameof(code));
+ }
+
+ Code = code.ToUpperInvariant();
+ }
+
+ public override string ToString() => Code;
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/src/Domain/Entities/ExchangeRate.cs b/jobs/Backend/Task/src/Domain/Entities/ExchangeRate.cs
new file mode 100644
index 0000000000..933f1870fc
--- /dev/null
+++ b/jobs/Backend/Task/src/Domain/Entities/ExchangeRate.cs
@@ -0,0 +1,46 @@
+namespace ExchangeRateUpdater.Domain.Entities;
+
+///
+/// Represents an exchange rate between two currencies.
+///
+///
+/// The value expresses how much of the target currency equals one unit of the source currency.
+///
+public record ExchangeRate
+{
+ public Currency SourceCurrency { get; }
+
+ public Currency TargetCurrency { get; }
+
+ public decimal Value { get; }
+
+ ///
+ /// Creates a new exchange rate.
+ ///
+ /// The base currency.
+ /// The currency being quoted.
+ /// The exchange rate value. Must be greater than zero.
+ ///
+ /// Thrown when or is null.
+ ///
+ ///
+ /// Thrown when is less than or equal to zero.
+ ///
+ public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value)
+ {
+ ArgumentNullException.ThrowIfNull(sourceCurrency, nameof(sourceCurrency));
+ ArgumentNullException.ThrowIfNull(targetCurrency, nameof(targetCurrency));
+ if (value <= 0)
+ {
+ throw new ArgumentException("Exchange rate value must be positive", nameof(value));
+ }
+
+ SourceCurrency = sourceCurrency;
+ TargetCurrency = targetCurrency;
+ Value = value;
+ }
+
+ public override string ToString() =>
+ $"{SourceCurrency}/{TargetCurrency} = {Value}";
+
+}
diff --git a/jobs/Backend/Task/src/Domain/ExchangeRateUpdater.Domain.csproj b/jobs/Backend/Task/src/Domain/ExchangeRateUpdater.Domain.csproj
new file mode 100644
index 0000000000..d1c2dc5118
--- /dev/null
+++ b/jobs/Backend/Task/src/Domain/ExchangeRateUpdater.Domain.csproj
@@ -0,0 +1,8 @@
+
+
+
+ net10.0
+ enable
+
+
+
diff --git a/jobs/Backend/Task/src/Domain/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/src/Domain/Interfaces/IExchangeRateProvider.cs
new file mode 100644
index 0000000000..f668048fba
--- /dev/null
+++ b/jobs/Backend/Task/src/Domain/Interfaces/IExchangeRateProvider.cs
@@ -0,0 +1,22 @@
+using ExchangeRateUpdater.Domain.Entities;
+
+namespace ExchangeRateUpdater.Domain.Interfaces;
+
+///
+/// Provides exchange rates from an external source.
+///
+public interface IExchangeRateProvider
+{
+ ///
+ /// Retrieves the latest available exchange rates.
+ ///
+ ///
+ /// A collection of exchange rates indexed by currency code.
+ ///
+ ///
+ /// Thrown when exchange rates cannot be retrieved.
+ ///
+ Task> GetExchangeRatesAsync(
+ IEnumerable currencies,
+ CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/CzechNationalBankHttpClient.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/CzechNationalBankHttpClient.cs
new file mode 100644
index 0000000000..f2f2ececa2
--- /dev/null
+++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/CzechNationalBankHttpClient.cs
@@ -0,0 +1,65 @@
+using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Configuration;
+using Microsoft.Extensions.Logging;
+
+namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Clients;
+
+///
+/// HTTP client for fetching Czech National Bank exchange rate data.
+///
+sealed internal class CzechNationalBankHttpClient : ICzechNationalBankClient
+{
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+ private readonly ProviderOptions _options;
+
+ public CzechNationalBankHttpClient(
+ HttpClient httpClient,
+ ProviderOptions options,
+ ILogger logger)
+ {
+ _httpClient = httpClient;
+ _options = options;
+ _logger = logger;
+
+ _httpClient.BaseAddress = new Uri(_options.BaseUrl);
+ _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(_options.UserAgent);
+ }
+
+ public async Task GetDailyRatesAsync(CancellationToken cancellationToken = default)
+ {
+ _logger.LogInformation(
+ "Fetching daily exchange rates from CNB: {Url}",
+ new Uri(_httpClient.BaseAddress!, _options.DailyRatesPath)
+ );
+
+ try
+ {
+ var response = await _httpClient.GetAsync(_options.DailyRatesPath, cancellationToken);
+
+ response.EnsureSuccessStatusCode();
+
+ var content = await response.Content.ReadAsStringAsync(cancellationToken);
+
+ _logger.LogInformation(
+ "Successfully retrieved exchange rates data ({Size} bytes)",
+ content.Length
+ );
+
+ _logger.LogDebug("Response content preview: {Preview}",
+ content.Length > 200 ? content.Substring(0, 200) + "..." : content
+ );
+
+ return content;
+ }
+ catch (HttpRequestException ex)
+ {
+ _logger.LogError(ex, "HTTP request failed while fetching exchange rates from CNB");
+ throw;
+ }
+ catch (TaskCanceledException ex)
+ {
+ _logger.LogError(ex, "Request timed out while fetching exchange rates from CNB");
+ throw;
+ }
+ }
+}
diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/ICzechNationalBankClient.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/ICzechNationalBankClient.cs
new file mode 100644
index 0000000000..78bc15c381
--- /dev/null
+++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/ICzechNationalBankClient.cs
@@ -0,0 +1,6 @@
+namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Clients;
+
+public interface ICzechNationalBankClient
+{
+ Task GetDailyRatesAsync(CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/PollyPolicies.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/PollyPolicies.cs
new file mode 100644
index 0000000000..11a92c87ba
--- /dev/null
+++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/PollyPolicies.cs
@@ -0,0 +1,46 @@
+using Microsoft.Extensions.Logging;
+using Polly;
+using Polly.Extensions.Http;
+
+namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Configuration;
+
+internal sealed class PollyPolicies(
+ ProviderOptions options,
+ ILogger logger)
+{
+ public IAsyncPolicy RetryPolicy =>
+ HttpPolicyExtensions
+ .HandleTransientHttpError()
+ .WaitAndRetryAsync(
+ retryCount: options.RetryCount,
+ sleepDurationProvider: retryAttempt =>
+ TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
+ onRetry: (outcome, timespan, retryCount, _) =>
+ {
+ logger.LogWarning(
+ "Retry {RetryCount} after {Delay}s due to {Reason}",
+ retryCount,
+ timespan.TotalSeconds,
+ outcome.Exception?.Message
+ ?? outcome.Result.StatusCode.ToString());
+ });
+
+ public IAsyncPolicy CircuitBreakerPolicy =>
+ HttpPolicyExtensions
+ .HandleTransientHttpError()
+ .CircuitBreakerAsync(
+ handledEventsAllowedBeforeBreaking: options.RetryCount,
+ durationOfBreak: TimeSpan.FromSeconds(options.DurationOfCircuitBreakSeconds),
+ onBreak: (_, duration) =>
+ {
+ logger.LogError("Circuit breaker opened for {Duration}s", duration.TotalSeconds);
+ },
+ onReset: () =>
+ {
+ logger.LogInformation("Circuit breaker reset");
+ });
+
+ public IAsyncPolicy TimeoutPolicy =>
+ Policy.TimeoutAsync(
+ TimeSpan.FromSeconds(options.TimeoutSeconds));
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/ProviderOptions.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/ProviderOptions.cs
new file mode 100644
index 0000000000..4694caf3a4
--- /dev/null
+++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/ProviderOptions.cs
@@ -0,0 +1,48 @@
+namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Configuration;
+
+///
+/// Configuration options for the Czech National Bank exchange rate provider.
+///
+internal record ProviderOptions
+{
+ public static string ConfigurationSectionName => "CnbProvider";
+ public required string BaseUrl { get; set; } = string.Empty;
+ public required string DailyRatesPath { get; set; } = string.Empty;
+ public required int TimeoutSeconds { get; set; } = 10;
+ public required int DurationOfCircuitBreakSeconds { get; set; } = 30;
+ public required int RetryCount { get; set; } = 3;
+ public required string UserAgent { get; set; } = string.Empty;
+
+ public void Validate()
+ {
+ if (string.IsNullOrWhiteSpace(BaseUrl))
+ {
+ throw new InvalidOperationException("BaseUrl is required");
+ }
+
+ if (!Uri.TryCreate(BaseUrl, UriKind.Absolute, out _))
+ {
+ throw new InvalidOperationException("BaseUrl must be a valid URL");
+ }
+
+ if (string.IsNullOrWhiteSpace(DailyRatesPath))
+ {
+ throw new InvalidOperationException($"{nameof(DailyRatesPath)} is required");
+ }
+
+ if (TimeoutSeconds <= 0)
+ {
+ throw new InvalidOperationException($"{nameof(TimeoutSeconds)} must be positive");
+ }
+
+ if (DurationOfCircuitBreakSeconds <= 0)
+ {
+ throw new InvalidOperationException($"{nameof(DurationOfCircuitBreakSeconds)} must be positive");
+ }
+
+ if (RetryCount < 0)
+ {
+ throw new InvalidOperationException($"{nameof(RetryCount)} cannot be negative");
+ }
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Exceptions/CzechNationalBankParsingException.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Exceptions/CzechNationalBankParsingException.cs
new file mode 100644
index 0000000000..67c65c721b
--- /dev/null
+++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Exceptions/CzechNationalBankParsingException.cs
@@ -0,0 +1,7 @@
+namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Exceptions;
+
+///
+/// Represents errors that occur while parsing CNB exchange rate responses.
+///
+public class CzechNationalBankParsingException(string message) : Exception(message)
+{ }
\ No newline at end of file
diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs
new file mode 100644
index 0000000000..586e4f9183
--- /dev/null
+++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs
@@ -0,0 +1,110 @@
+using ExchangeRateUpdater.Domain.Entities;
+using ExchangeRateUpdater.Domain.Interfaces;
+using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Clients;
+using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Exceptions;
+using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Parsers;
+using Microsoft.Extensions.Logging;
+
+namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank;
+
+///
+/// Exchange rate provider backed by the Czech National Bank daily rates feed.
+///
+///
+/// This provider fetches and parses daily exchange rates published by the CNB.
+///
+internal class ExchangeRateProvider : IExchangeRateProvider
+{
+ private static readonly Currency _targetCurrency = new("CZK");
+
+ private readonly ICzechNationalBankClient _client;
+ private readonly IDailyExchangeRatesResponseParser _parser;
+ private readonly ILogger _logger;
+
+ public ExchangeRateProvider(
+ ICzechNationalBankClient client,
+ IDailyExchangeRatesResponseParser parser,
+ ILogger logger)
+ {
+ _client = client;
+ _parser = parser;
+ _logger = logger;
+ }
+
+ public async Task> GetExchangeRatesAsync(
+ IEnumerable currencies,
+ CancellationToken cancellationToken = default)
+ {
+ var currencyList = currencies.ToList();
+ _logger.LogInformation(
+ "Fetching exchange rates for {Count} currencies: {Currencies}",
+ currencyList.Count,
+ string.Join(", ", currencyList.Select(c => c.Code))
+ );
+
+ try
+ {
+ var rawData = await _client.GetDailyRatesAsync(cancellationToken);
+ var data = _parser.Parse(rawData);
+
+ var requestedCodes = new HashSet(
+ currencyList.Select(c => c.Code),
+ StringComparer.OrdinalIgnoreCase
+ );
+
+ var exchangeRates = data
+ .ExchangeRates
+ .Where(model => requestedCodes.Contains(model.Code))
+ .Select(DomainExchangeRate)
+ .ToList();
+
+ _logger.LogInformation(
+ "Successfully retrieved {Count} exchange rates out of {Requested} requested",
+ exchangeRates.Count,
+ currencyList.Count
+ );
+
+ LogNotFoundCurrencies(currencyList, exchangeRates);
+
+ return exchangeRates;
+ }
+ catch (CzechNationalBankParsingException ex)
+ {
+ _logger.LogError(ex, "Failed to parse exchange rates from Czech National Bank response");
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to retrieve exchange rates");
+ throw;
+ }
+ }
+
+ private void LogNotFoundCurrencies(List currencyList, List exchangeRates)
+ {
+ var foundCodes = new HashSet(
+ exchangeRates.Select(rate => rate.SourceCurrency.Code),
+ StringComparer.OrdinalIgnoreCase
+ );
+
+ var missingCurrencies = currencyList
+ .Where(rate => !foundCodes.Contains(rate.Code))
+ .Select(rate => rate.Code)
+ .ToList();
+
+ if (missingCurrencies.Any())
+ {
+ _logger.LogWarning(
+ "Could not find exchange rates for currencies: {Currencies}",
+ string.Join(", ", missingCurrencies)
+ );
+ }
+ }
+
+ private static ExchangeRate DomainExchangeRate(Models.ExchangeRate model) =>
+ new(
+ sourceCurrency: new Currency(model.Code),
+ targetCurrency: _targetCurrency,
+ value: model.Rate / model.Amount
+ );
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateUpdater.Providers.CzechNationalBank.csproj b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateUpdater.Providers.CzechNationalBank.csproj
new file mode 100644
index 0000000000..7b76e29bb1
--- /dev/null
+++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateUpdater.Providers.CzechNationalBank.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+ <_Parameter1>ExchangeRateUpdater.UnitTests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Models/DailyExchangeRatesResponse.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Models/DailyExchangeRatesResponse.cs
new file mode 100644
index 0000000000..68a8130e58
--- /dev/null
+++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Models/DailyExchangeRatesResponse.cs
@@ -0,0 +1,13 @@
+namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Models;
+
+///
+/// Represents the complete daily exchange rate data from CNB.
+///
+/// The date of the exchange rates.
+/// Sequential number of the publication. Represents the number of working day according to Czech bank holidays
+/// Collection of exchange rate records.
+public record DailyExchangeRatesResponse(
+ DateOnly Date,
+ int Sequence,
+ IReadOnlyList ExchangeRates
+);
\ No newline at end of file
diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Models/ExchangeRate.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Models/ExchangeRate.cs
new file mode 100644
index 0000000000..8a0ceaae34
--- /dev/null
+++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Models/ExchangeRate.cs
@@ -0,0 +1,17 @@
+namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Models;
+
+///
+/// Represents a single exchange rate record from the CNB daily file.
+///
+/// Country name.
+/// Currency name.
+/// The amount of foreign currency (typically 1, 100, or 1000).
+/// Three-letter ISO 4217 currency code.
+/// Exchange rate to CZK for the specified amount.
+public record ExchangeRate(
+ string Country,
+ string CurrencyName,
+ int Amount,
+ string Code,
+ decimal Rate
+);
\ No newline at end of file
diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/IDailyExchangeRatesResponseParser.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/IDailyExchangeRatesResponseParser.cs
new file mode 100644
index 0000000000..720143e27e
--- /dev/null
+++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/IDailyExchangeRatesResponseParser.cs
@@ -0,0 +1,20 @@
+using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Exceptions;
+using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Models;
+
+namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Parsers;
+
+///
+/// Parses a raw daily exchange rates response into a structured model.
+///
+public interface IDailyExchangeRatesResponseParser
+{
+ ///
+ /// Parses the raw response content.
+ ///
+ /// Raw response body returned by the provider.
+ /// The parsed daily exchange rates.
+ ///
+ /// Thrown when the response format is invalid or unsupported.
+ ///
+ DailyExchangeRatesResponse Parse(string rawData);
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedDailyExchangeResponseParser.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedDailyExchangeResponseParser.cs
new file mode 100644
index 0000000000..8ffb13414c
--- /dev/null
+++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedDailyExchangeResponseParser.cs
@@ -0,0 +1,133 @@
+using System.Globalization;
+using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Exceptions;
+using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Models;
+
+namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Parsers;
+
+///
+/// Parses exchange rate data returned by the Czech National Bank daily rates endpoint.
+///
+///
+/// Expected format:
+///
+/// 23 Dec 2025 #248
+/// Country|Currency|Amount|Code|Rate
+/// Australia|dollar|1|AUD|13.818
+///
+///
+internal sealed class PipeSeparatedDailyExchangeResponseParser : IDailyExchangeRatesResponseParser
+{
+ private static readonly char[] _newLineCharacters = ['\r', '\n'];
+ private const int _expectedColumnCount = 5;
+ private const int _minimumLineCount = 3;
+ private const int _expectedHeaderPartsCount = 2;
+ private const string _expectedHeaderColumns = "Country|Currency|Amount|Code|Rate";
+ public DailyExchangeRatesResponse Parse(string rawData)
+ {
+ if (string.IsNullOrWhiteSpace(rawData))
+ {
+ throw new CzechNationalBankParsingException("Raw data is empty, null or white space.");
+ }
+
+ var contents = GetContents(rawData);
+
+ var header = contents[0];
+ var headerParts = GetHeaderParts(header);
+ var exchangeDate = GetExchangeDate(headerParts);
+ var exchangeSequence = GetExchangeSequence(headerParts);
+
+ ValidateColumnNames(contents[1]);
+
+ return new DailyExchangeRatesResponse
+ (
+ Date: exchangeDate,
+ Sequence: exchangeSequence,
+ ExchangeRates: contents[2..]
+ .Select(ParseRecord)
+ .ToArray()
+ );
+ }
+
+ private static string[] GetContents(string rawData)
+ {
+ var contents = rawData.Split(_newLineCharacters, StringSplitOptions.RemoveEmptyEntries);
+ if (contents.Length < _minimumLineCount)
+ {
+ throw new CzechNationalBankParsingException($"Response does not contain enough lines. Lines found: {contents.Length}.");
+ }
+
+ return contents;
+ }
+
+ private static string[] GetHeaderParts(string header)
+ {
+ var headerParts = header.Split('#', StringSplitOptions.TrimEntries);
+
+ if (headerParts.Length != _expectedHeaderPartsCount)
+ {
+ throw new CzechNationalBankParsingException($"Header is not in expected format. Header value: '{header}'.");
+ }
+
+ return headerParts;
+ }
+
+ private static DateOnly GetExchangeDate(string[] headerParts)
+ {
+ var value = headerParts[0];
+ if (!DateOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
+ {
+ throw new CzechNationalBankParsingException($"Header date is not in expected format. Value: '{value}'.");
+ }
+
+ return date;
+ }
+
+ private static int GetExchangeSequence(string[] headerParts)
+ {
+ var value = headerParts[1];
+ if (!int.TryParse(value, out var sequence))
+ {
+ throw new CzechNationalBankParsingException($"Header sequence is not in expected format. Value: '{value}'.");
+ }
+
+ return sequence;
+ }
+
+ private static void ValidateColumnNames(string columnNames)
+ {
+ if (!string.Equals(columnNames.Trim(), _expectedHeaderColumns, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new CzechNationalBankParsingException($"Column names are not in expected format. Value: '{columnNames}'.");
+ }
+ }
+
+ private static ExchangeRate ParseRecord(string line)
+ {
+ var parts = line.Split('|', StringSplitOptions.TrimEntries);
+
+ if (parts.Length != _expectedColumnCount)
+ {
+ throw new CzechNationalBankParsingException($"Invalid record format: '{line}'. Expected {_expectedColumnCount} pipe-separated columns.");
+ }
+
+ var amountPart = parts[2];
+ if (!int.TryParse(amountPart, out var amount))
+ {
+ throw new CzechNationalBankParsingException($"Invalid amount: '{amountPart}'");
+ }
+
+ var ratePart = parts[4];
+ if (!decimal.TryParse(ratePart, NumberStyles.Number, CultureInfo.InvariantCulture, out var rate))
+ {
+ throw new CzechNationalBankParsingException($"Invalid rate: '{ratePart}'");
+ }
+
+ return new ExchangeRate(
+ Country: parts[0],
+ CurrencyName: parts[1],
+ Amount: amount,
+ Code: parts[3],
+ Rate: rate
+ );
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Registration.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Registration.cs
new file mode 100644
index 0000000000..de48174f84
--- /dev/null
+++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Registration.cs
@@ -0,0 +1,43 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Configuration;
+using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Clients;
+using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Parsers;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+
+namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank;
+
+
+public static class Registration
+{
+ public static IServiceCollection UseCzechNationalBankProvider(
+ this IServiceCollection services,
+ HostBuilderContext context)
+ {
+ services
+ .AddOptions()
+ .Bind(context.Configuration.GetSection(ProviderOptions.ConfigurationSectionName))
+ .Validate(opts =>
+ {
+ opts.Validate();
+ return true;
+ }
+ );
+
+ services.AddSingleton(sp => sp.GetRequiredService>().Value);
+
+ services
+ .AddSingleton()
+ .AddScoped();
+
+ services
+ .AddSingleton()
+ .AddHttpClient()
+ .AddPolicyHandler((sp, _) => sp.GetRequiredService().RetryPolicy)
+ .AddPolicyHandler((sp, _) => sp.GetRequiredService().CircuitBreakerPolicy)
+ .AddPolicyHandler((sp, _) => sp.GetRequiredService().TimeoutPolicy);
+
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/tests/UnitTests/Domain/Entities/CurrencyTests.cs b/jobs/Backend/Task/tests/UnitTests/Domain/Entities/CurrencyTests.cs
new file mode 100644
index 0000000000..15e4151a62
--- /dev/null
+++ b/jobs/Backend/Task/tests/UnitTests/Domain/Entities/CurrencyTests.cs
@@ -0,0 +1,51 @@
+using ExchangeRateUpdater.Domain.Entities;
+
+namespace ExchangeRateUpdater.UnitTests;
+
+public class CurrencyTests
+{
+ [Fact]
+ public void Constructor_ShouldThrow_WhenCodeIsEmpty()
+ {
+ Assert.Throws(() => new Currency(string.Empty));
+ }
+
+ [Fact]
+ public void Constructor_ShouldThrow_WhenCodeIsWhitespace()
+ {
+ Assert.Throws(() => new Currency(" "));
+ }
+
+ [Fact]
+ public void Constructor_ShouldThrow_WhenCodeIsNotThreeCharacters()
+ {
+ Assert.Throws(() => new Currency("US"));
+ Assert.Throws(() => new Currency("USDA"));
+ }
+
+ [Fact]
+ public void Constructor_ShouldSetCode_ToUpperInvariant()
+ {
+ var currency = new Currency("usd");
+ Assert.Equal("USD", currency.Code);
+ }
+
+ [Fact]
+ public void TwoCurrencies_WithSameCode_ShouldBeEqual()
+ {
+ var currency1 = new Currency("USD");
+ var currency2 = new Currency("USD");
+
+ Assert.Equal(currency1, currency2);
+ Assert.True(currency1 == currency2);
+ }
+
+ [Fact]
+ public void Currency_ShouldBeCaseInsensitive()
+ {
+ var currency1 = new Currency("USD");
+ var currency2 = new Currency("usd");
+
+ Assert.Equal(currency1, currency2);
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/tests/UnitTests/Domain/Entities/ExchangeRateTests.cs b/jobs/Backend/Task/tests/UnitTests/Domain/Entities/ExchangeRateTests.cs
new file mode 100644
index 0000000000..f6fea0807e
--- /dev/null
+++ b/jobs/Backend/Task/tests/UnitTests/Domain/Entities/ExchangeRateTests.cs
@@ -0,0 +1,48 @@
+
+using ExchangeRateUpdater.Domain.Entities;
+
+namespace ExchangeRateUpdater.UnitTests;
+
+public class ExchangeRateTests
+{
+ [Fact]
+ public void Constructor_ShouldThrow_WhenSourceCurrencyIsNull()
+ {
+ var targetCurrency = new Currency("USD");
+ var value = 1;
+
+ Assert.Throws(() => new ExchangeRate(null, targetCurrency, value));
+ }
+
+ [Fact]
+ public void Constructor_ShouldThrow_WhenTargetCurrencyIsNull()
+ {
+ var sourceCurrency = new Currency("USD");
+ var value = 1;
+
+ Assert.Throws(() => new ExchangeRate(sourceCurrency, null, value));
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void Constructor_ShouldThrow_WhenValueIsNotPositive(int value)
+ {
+ var sourceCurrency = new Currency("USD");
+ var targetCurrency = new Currency("EUR");
+
+ Assert.Throws(() => new ExchangeRate(sourceCurrency, targetCurrency, value));
+ }
+
+ [Fact]
+ public void ToString_ShouldReturnCorrectFormat()
+ {
+ var sourceCurrency = new Currency("USD");
+ var targetCurrency = new Currency("EUR");
+ var exchangeRate = new ExchangeRate(sourceCurrency, targetCurrency, 1.2m);
+
+ var result = exchangeRate.ToString();
+
+ Assert.Equal("USD/EUR = 1.2", result);
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/tests/UnitTests/ExchangeRateUpdater.UnitTests.csproj b/jobs/Backend/Task/tests/UnitTests/ExchangeRateUpdater.UnitTests.csproj
new file mode 100644
index 0000000000..18b01414aa
--- /dev/null
+++ b/jobs/Backend/Task/tests/UnitTests/ExchangeRateUpdater.UnitTests.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net10.0
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/jobs/Backend/Task/tests/UnitTests/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParserTests.cs b/jobs/Backend/Task/tests/UnitTests/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParserTests.cs
new file mode 100644
index 0000000000..7e8da2f55b
--- /dev/null
+++ b/jobs/Backend/Task/tests/UnitTests/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParserTests.cs
@@ -0,0 +1,204 @@
+using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Exceptions;
+using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Parsers;
+
+namespace ExchangeRateUpdater.UnitTests;
+
+public class PipeSeparatedResponseParserTests
+{
+ private readonly PipeSeparatedDailyExchangeResponseParser _sut = new();
+ private readonly DateOnly _testDate = new(2025, 12, 23);
+ private const int _testSequence = 248;
+ private const string _validHeader = "23 Dec 2025 #248";
+ private const string _validColumnNames = "Country|Currency|Amount|Code|Rate";
+
+ private static string RawData(params string[] parts)
+ => string.Join(Environment.NewLine, parts);
+
+ [Fact]
+ public void Parse_ShouldThrowCnbParsingException_WhenRawDataIsNull()
+ {
+ AssertThrowsWithMessage(() => _sut.Parse(null), "Raw data is empty, null or white space.");
+ }
+
+ [Theory]
+ [InlineData("")] // Empty string
+ [InlineData(" ")] // Whitespace only
+ [InlineData("\t")] // Tab character
+ [InlineData("\n")] // Newline character
+ [InlineData("\r\n")] // Carriage return and newline
+ public void Parse_ShouldThrowCnbParsingException_WhenRawDataIsEmptyOrWhitespace(string input)
+ {
+ AssertThrowsWithMessage(() => _sut.Parse(input), "Raw data is empty, null or white space.");
+ }
+
+ [Theory]
+ [InlineData("invalidheader\n anything \n anything")] // No hash symbolon header
+ [InlineData("Multiple##hash##symbols\n anything \n anything")] // More than one hash symbol
+ [InlineData("##StartsWithHash\n anything \n anything")] // Hash at the beginning
+ public void Parse_ShouldThrowCnbParsingException_WhenHeaderIsInvalid(string input)
+ {
+ AssertThrowsWithMessage(() => _sut.Parse(input), "Header is not in expected format.");
+ }
+
+
+ public static IEnumerable