diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/CopilotIndices/17.14.1431.25910/CodeChunks.db b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/CopilotIndices/17.14.1431.25910/CodeChunks.db
new file mode 100644
index 000000000..f8c0ead15
Binary files /dev/null and b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/CopilotIndices/17.14.1431.25910/CodeChunks.db differ
diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/CopilotIndices/17.14.1431.25910/SemanticSymbols.db b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/CopilotIndices/17.14.1431.25910/SemanticSymbols.db
new file mode 100644
index 000000000..18d6da4ba
Binary files /dev/null and b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/CopilotIndices/17.14.1431.25910/SemanticSymbols.db differ
diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/DesignTimeBuild/.dtbcache.v2 b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/DesignTimeBuild/.dtbcache.v2
new file mode 100644
index 000000000..30310e717
Binary files /dev/null and b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/DesignTimeBuild/.dtbcache.v2 differ
diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/36282d28-6132-48c3-8512-f85835b2f394.vsidx b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/36282d28-6132-48c3-8512-f85835b2f394.vsidx
new file mode 100644
index 000000000..adc83e519
Binary files /dev/null and b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/36282d28-6132-48c3-8512-f85835b2f394.vsidx differ
diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/888382d4-02f3-4671-b900-17d449760895.vsidx b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/888382d4-02f3-4671-b900-17d449760895.vsidx
new file mode 100644
index 000000000..1165d2dfa
Binary files /dev/null and b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/888382d4-02f3-4671-b900-17d449760895.vsidx differ
diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/9ea21c94-818e-481e-bff6-638adfc1731e.vsidx b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/9ea21c94-818e-481e-bff6-638adfc1731e.vsidx
new file mode 100644
index 000000000..65fed7bab
Binary files /dev/null and b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/9ea21c94-818e-481e-bff6-638adfc1731e.vsidx differ
diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/f35f0a0d-cd21-458e-9332-f515123e0f4e.vsidx b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/f35f0a0d-cd21-458e-9332-f515123e0f4e.vsidx
new file mode 100644
index 000000000..c9e217144
Binary files /dev/null and b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/f35f0a0d-cd21-458e-9332-f515123e0f4e.vsidx differ
diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/fb18cc91-a2c1-46b8-87e7-3b55c50aab66.vsidx b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/fb18cc91-a2c1-46b8-87e7-3b55c50aab66.vsidx
new file mode 100644
index 000000000..70aef67a0
Binary files /dev/null and b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/fb18cc91-a2c1-46b8-87e7-3b55c50aab66.vsidx differ
diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/v17/.futdcache.v2 b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/v17/.futdcache.v2
new file mode 100644
index 000000000..68aedd34e
Binary files /dev/null and b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/v17/.futdcache.v2 differ
diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/v17/DocumentLayout.backup.json b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/v17/DocumentLayout.backup.json
new file mode 100644
index 000000000..5b0ada326
--- /dev/null
+++ b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/v17/DocumentLayout.backup.json
@@ -0,0 +1,55 @@
+{
+ "Version": 1,
+ "WorkspaceRootPath": "C:\\1_task\\ExchangeRateUpdater\\",
+ "Documents": [
+ {
+ "AbsoluteMoniker": "D:0:0:{F718280A-7459-4E33-B42A-E19CB8ACE541}|ExchangeRateUpdater\\ExchangeRateUpdater.csproj|c:\\1_task\\exchangerateupdater\\exchangerateupdater\\exchangerateprovider.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
+ "RelativeMoniker": "D:0:0:{F718280A-7459-4E33-B42A-E19CB8ACE541}|ExchangeRateUpdater\\ExchangeRateUpdater.csproj|solutionrelative:exchangerateupdater\\exchangerateprovider.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
+ },
+ {
+ "AbsoluteMoniker": "D:0:0:{4A3E62F0-B85E-4261-BBFD-354692E70DD6}|Demo\\Demo.csproj|c:\\1_task\\exchangerateupdater\\demo\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
+ "RelativeMoniker": "D:0:0:{4A3E62F0-B85E-4261-BBFD-354692E70DD6}|Demo\\Demo.csproj|solutionrelative:demo\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
+ }
+ ],
+ "DocumentGroupContainers": [
+ {
+ "Orientation": 0,
+ "VerticalTabListWidth": 256,
+ "DocumentGroups": [
+ {
+ "DockedWidth": 200,
+ "SelectedChildIndex": 0,
+ "Children": [
+ {
+ "$type": "Document",
+ "DocumentIndex": 0,
+ "Title": "ExchangeRateProvider.cs",
+ "DocumentMoniker": "C:\\1_task\\ExchangeRateUpdater\\ExchangeRateUpdater\\ExchangeRateProvider.cs",
+ "RelativeDocumentMoniker": "ExchangeRateUpdater\\ExchangeRateProvider.cs",
+ "ToolTip": "C:\\1_task\\ExchangeRateUpdater\\ExchangeRateUpdater\\ExchangeRateProvider.cs",
+ "RelativeToolTip": "ExchangeRateUpdater\\ExchangeRateProvider.cs",
+ "ViewState": "AgIAALcAAAAAAAAAAAAIwNUAAAAAAAAAAAAAAA==",
+ "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
+ "WhenOpened": "2025-12-29T16:03:14.636Z",
+ "IsPinned": true,
+ "EditorCaption": ""
+ },
+ {
+ "$type": "Document",
+ "DocumentIndex": 1,
+ "Title": "Program.cs",
+ "DocumentMoniker": "C:\\1_task\\ExchangeRateUpdater\\Demo\\Program.cs",
+ "RelativeDocumentMoniker": "Demo\\Program.cs",
+ "ToolTip": "C:\\1_task\\ExchangeRateUpdater\\Demo\\Program.cs",
+ "RelativeToolTip": "Demo\\Program.cs",
+ "ViewState": "AgIAAAAAAAAAAAAAAAAAADsAAAAAAAAAAAAAAA==",
+ "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
+ "WhenOpened": "2025-12-29T16:05:37.307Z",
+ "EditorCaption": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/v17/DocumentLayout.json b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/v17/DocumentLayout.json
new file mode 100644
index 000000000..5b0ada326
--- /dev/null
+++ b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/v17/DocumentLayout.json
@@ -0,0 +1,55 @@
+{
+ "Version": 1,
+ "WorkspaceRootPath": "C:\\1_task\\ExchangeRateUpdater\\",
+ "Documents": [
+ {
+ "AbsoluteMoniker": "D:0:0:{F718280A-7459-4E33-B42A-E19CB8ACE541}|ExchangeRateUpdater\\ExchangeRateUpdater.csproj|c:\\1_task\\exchangerateupdater\\exchangerateupdater\\exchangerateprovider.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
+ "RelativeMoniker": "D:0:0:{F718280A-7459-4E33-B42A-E19CB8ACE541}|ExchangeRateUpdater\\ExchangeRateUpdater.csproj|solutionrelative:exchangerateupdater\\exchangerateprovider.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
+ },
+ {
+ "AbsoluteMoniker": "D:0:0:{4A3E62F0-B85E-4261-BBFD-354692E70DD6}|Demo\\Demo.csproj|c:\\1_task\\exchangerateupdater\\demo\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
+ "RelativeMoniker": "D:0:0:{4A3E62F0-B85E-4261-BBFD-354692E70DD6}|Demo\\Demo.csproj|solutionrelative:demo\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
+ }
+ ],
+ "DocumentGroupContainers": [
+ {
+ "Orientation": 0,
+ "VerticalTabListWidth": 256,
+ "DocumentGroups": [
+ {
+ "DockedWidth": 200,
+ "SelectedChildIndex": 0,
+ "Children": [
+ {
+ "$type": "Document",
+ "DocumentIndex": 0,
+ "Title": "ExchangeRateProvider.cs",
+ "DocumentMoniker": "C:\\1_task\\ExchangeRateUpdater\\ExchangeRateUpdater\\ExchangeRateProvider.cs",
+ "RelativeDocumentMoniker": "ExchangeRateUpdater\\ExchangeRateProvider.cs",
+ "ToolTip": "C:\\1_task\\ExchangeRateUpdater\\ExchangeRateUpdater\\ExchangeRateProvider.cs",
+ "RelativeToolTip": "ExchangeRateUpdater\\ExchangeRateProvider.cs",
+ "ViewState": "AgIAALcAAAAAAAAAAAAIwNUAAAAAAAAAAAAAAA==",
+ "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
+ "WhenOpened": "2025-12-29T16:03:14.636Z",
+ "IsPinned": true,
+ "EditorCaption": ""
+ },
+ {
+ "$type": "Document",
+ "DocumentIndex": 1,
+ "Title": "Program.cs",
+ "DocumentMoniker": "C:\\1_task\\ExchangeRateUpdater\\Demo\\Program.cs",
+ "RelativeDocumentMoniker": "Demo\\Program.cs",
+ "ToolTip": "C:\\1_task\\ExchangeRateUpdater\\Demo\\Program.cs",
+ "RelativeToolTip": "Demo\\Program.cs",
+ "ViewState": "AgIAAAAAAAAAAAAAAAAAADsAAAAAAAAAAAAAAA==",
+ "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
+ "WhenOpened": "2025-12-29T16:05:37.307Z",
+ "EditorCaption": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/ExchangeRateUpdater/.vs/ProjectEvaluation/exchangerateupdater.metadata.v9.bin b/ExchangeRateUpdater/.vs/ProjectEvaluation/exchangerateupdater.metadata.v9.bin
new file mode 100644
index 000000000..bfb62d45c
Binary files /dev/null and b/ExchangeRateUpdater/.vs/ProjectEvaluation/exchangerateupdater.metadata.v9.bin differ
diff --git a/ExchangeRateUpdater/.vs/ProjectEvaluation/exchangerateupdater.projects.v9.bin b/ExchangeRateUpdater/.vs/ProjectEvaluation/exchangerateupdater.projects.v9.bin
new file mode 100644
index 000000000..539da212e
Binary files /dev/null and b/ExchangeRateUpdater/.vs/ProjectEvaluation/exchangerateupdater.projects.v9.bin differ
diff --git a/ExchangeRateUpdater/.vs/ProjectEvaluation/exchangerateupdater.strings.v9.bin b/ExchangeRateUpdater/.vs/ProjectEvaluation/exchangerateupdater.strings.v9.bin
new file mode 100644
index 000000000..3e3dd7b92
Binary files /dev/null and b/ExchangeRateUpdater/.vs/ProjectEvaluation/exchangerateupdater.strings.v9.bin differ
diff --git a/ExchangeRateUpdater/Demo/Demo.csproj b/ExchangeRateUpdater/Demo/Demo.csproj
new file mode 100644
index 000000000..c15173963
--- /dev/null
+++ b/ExchangeRateUpdater/Demo/Demo.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ExchangeRateUpdater/Demo/Program.cs b/ExchangeRateUpdater/Demo/Program.cs
new file mode 100644
index 000000000..1f471b299
--- /dev/null
+++ b/ExchangeRateUpdater/Demo/Program.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Linq;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using ExchangeRateUpdater;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+internal static class Program
+{
+ private static async Task Main(string[] args)
+ {
+ using var cts = new CancellationTokenSource();
+ Console.CancelKeyPress += (_, e) =>
+ {
+ e.Cancel = true;
+ cts.Cancel();
+ };
+
+ var services = new ServiceCollection();
+
+ services.AddLogging(b =>
+ {
+ b.AddSimpleConsole(o =>
+ {
+ o.SingleLine = true;
+ o.TimestampFormat = "HH:mm:ss ";
+ });
+ b.SetMinimumLevel(LogLevel.Information);
+ });
+
+ services.AddHttpClient(c =>
+ {
+ c.Timeout = TimeSpan.FromSeconds(10);
+ });
+
+ using var provider = services.BuildServiceProvider();
+
+ var rateProvider = provider.GetRequiredService();
+
+ var currencies = new[]
+ {
+ new Currency("CZK"),
+ new Currency("EUR"),
+ new Currency("USD"),
+ new Currency("GBP"),
+ };
+
+ try
+ {
+ var rates = await rateProvider
+ .GetCzkBasedRatesAsync(currencies, cts.Token)
+ .ConfigureAwait(false);
+
+ foreach (var r in rates.OrderBy(r => r.TargetCurrency.Code))
+ {
+ Console.WriteLine(r);
+ }
+
+ return 0;
+ }
+ catch (OperationCanceledException)
+ {
+ Console.WriteLine("Cancelled.");
+ return 1;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Error: {ex.Message}");
+ return 2;
+ }
+ }
+}
diff --git a/ExchangeRateUpdater/ExchangeRateUpdater.sln b/ExchangeRateUpdater/ExchangeRateUpdater.sln
new file mode 100644
index 000000000..26263d5ef
--- /dev/null
+++ b/ExchangeRateUpdater/ExchangeRateUpdater.sln
@@ -0,0 +1,31 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.14.36705.20 d17.14
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater\ExchangeRateUpdater.csproj", "{F718280A-7459-4E33-B42A-E19CB8ACE541}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Demo", "Demo\Demo.csproj", "{4A3E62F0-B85E-4261-BBFD-354692E70DD6}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {F718280A-7459-4E33-B42A-E19CB8ACE541}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F718280A-7459-4E33-B42A-E19CB8ACE541}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F718280A-7459-4E33-B42A-E19CB8ACE541}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F718280A-7459-4E33-B42A-E19CB8ACE541}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4A3E62F0-B85E-4261-BBFD-354692E70DD6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4A3E62F0-B85E-4261-BBFD-354692E70DD6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4A3E62F0-B85E-4261-BBFD-354692E70DD6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4A3E62F0-B85E-4261-BBFD-354692E70DD6}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {7BB53F43-2197-4A44-BBD1-967E5B8C1B6F}
+ EndGlobalSection
+EndGlobal
diff --git a/ExchangeRateUpdater/ExchangeRateUpdater/Currency.cs b/ExchangeRateUpdater/ExchangeRateUpdater/Currency.cs
new file mode 100644
index 000000000..e3723cbda
--- /dev/null
+++ b/ExchangeRateUpdater/ExchangeRateUpdater/Currency.cs
@@ -0,0 +1,20 @@
+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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ExchangeRateUpdater/ExchangeRateUpdater/ExchangeRate.cs b/ExchangeRateUpdater/ExchangeRateUpdater/ExchangeRate.cs
new file mode 100644
index 000000000..e93737eef
--- /dev/null
+++ b/ExchangeRateUpdater/ExchangeRateUpdater/ExchangeRate.cs
@@ -0,0 +1,23 @@
+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}";
+ }
+ }
+}
\ No newline at end of file
diff --git a/ExchangeRateUpdater/ExchangeRateUpdater/ExchangeRateProvider.cs b/ExchangeRateUpdater/ExchangeRateUpdater/ExchangeRateProvider.cs
new file mode 100644
index 000000000..42b9c9b2d
--- /dev/null
+++ b/ExchangeRateUpdater/ExchangeRateUpdater/ExchangeRateProvider.cs
@@ -0,0 +1,213 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace ExchangeRateUpdater
+{
+ public sealed class ExchangeRateProvider
+ {
+ private const string CnbDailyUrl =
+ "https://www.cnb.cz/en/financial_markets/foreign_exchange_market/exchange_rate_fixing/daily.txt";
+
+ private static readonly StringComparer CodeComparer = StringComparer.OrdinalIgnoreCase;
+
+ private readonly HttpClient _http;
+ private readonly ILogger _log;
+
+ // Reuse also prevents pointless allocations.
+ private readonly ConcurrentDictionary _currencyCache = new(CodeComparer);
+
+ public ExchangeRateProvider(HttpClient http, ILogger log)
+ {
+ _http = http ?? throw new ArgumentNullException(nameof(http));
+ _log = log ?? throw new ArgumentNullException(nameof(log));
+
+ // If someone forgot to configure it, keep us from hanging forever.
+ if (_http.Timeout == Timeout.InfiniteTimeSpan)
+ _http.Timeout = TimeSpan.FromSeconds(10);
+ }
+
+ ///
+ /// Returns CZK->X exchange rates for requested currencies.
+ ///
+ public async Task> GetCzkBasedRatesAsync(
+ IEnumerable currencies,
+ CancellationToken ct = default)
+ {
+ if (currencies is null) throw new ArgumentNullException(nameof(currencies));
+
+ var requested = new HashSet(
+ currencies.Select(c => c?.Code).Where(s => !string.IsNullOrWhiteSpace(s))!,
+ CodeComparer);
+
+ if (requested.Count == 0)
+ return Array.Empty();
+
+ // CNB daily.txt is CZK-based. If caller doesn't care about CZK, we can't help here.
+ if (!requested.Contains("CZK"))
+ return Array.Empty();
+
+ string body;
+ try
+ {
+ body = await DownloadWithRetryAsync(CnbDailyUrl, ct).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (!ct.IsCancellationRequested)
+ {
+ _log.LogError(ex, "Failed to download CNB daily rates.");
+ throw;
+ }
+
+ var all = ParseCnbDailyTxt(body);
+
+ // Return only CZK->requested (excluding CZK->CZK)
+ var result = all
+ .Where(r => requested.Contains(r.TargetCurrency.Code) &&
+ !CodeComparer.Equals(r.TargetCurrency.Code, "CZK"))
+ .ToList();
+
+ _log.LogDebug("CNB rates: parsed={Parsed}, returned={Returned}", all.Count, result.Count);
+ return result;
+ }
+
+ private async Task DownloadWithRetryAsync(string url, CancellationToken ct)
+ {
+ const int maxAttempts = 3;
+
+ for (var attempt = 1; attempt <= maxAttempts; attempt++)
+ {
+ try
+ {
+ using var req = new HttpRequestMessage(HttpMethod.Get, url);
+
+ using var resp = await _http.SendAsync(
+ req,
+ HttpCompletionOption.ResponseHeadersRead,
+ ct)
+ .ConfigureAwait(false);
+
+ if (IsTransient(resp.StatusCode) && attempt < maxAttempts)
+ {
+ var delay = Backoff(attempt);
+ _log.LogWarning("CNB request transient ({StatusCode}). Attempt {Attempt}/{Max}. Retrying in {Delay}ms.",
+ (int)resp.StatusCode, attempt, maxAttempts, (int)delay.TotalMilliseconds);
+
+ await Task.Delay(delay, ct).ConfigureAwait(false);
+ continue;
+ }
+
+ if (!resp.IsSuccessStatusCode)
+ {
+ throw new HttpRequestException(
+ $"CNB request failed: {(int)resp.StatusCode} {resp.ReasonPhrase}");
+ }
+
+
+ return await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ // Caller canceled: don’t retry, just bubble.
+ throw;
+ }
+ catch (TaskCanceledException ex) when (attempt < maxAttempts)
+ {
+ // Timeout typically ends up here; treat as transient.
+ var delay = Backoff(attempt);
+ _log.LogWarning(ex, "CNB request timed out. Attempt {Attempt}/{Max}. Retrying in {Delay}ms.",
+ attempt, maxAttempts, (int)delay.TotalMilliseconds);
+
+ await Task.Delay(delay, ct).ConfigureAwait(false);
+ }
+ catch (HttpRequestException ex) when (attempt < maxAttempts)
+ {
+ var delay = Backoff(attempt);
+ _log.LogWarning(ex, "CNB request failed (network). Attempt {Attempt}/{Max}. Retrying in {Delay}ms.",
+ attempt, maxAttempts, (int)delay.TotalMilliseconds);
+
+ await Task.Delay(delay, ct).ConfigureAwait(false);
+ }
+ }
+
+ throw new InvalidOperationException("Failed to download CNB exchange rates after multiple attempts.");
+ }
+
+ private static bool IsTransient(HttpStatusCode code)
+ {
+ var n = (int)code;
+ return code == HttpStatusCode.TooManyRequests || (n >= 500 && n <= 599);
+ }
+
+ private static TimeSpan Backoff(int attempt)
+ => TimeSpan.FromMilliseconds(250 * Math.Pow(2, attempt - 1));
+
+
+ private List ParseCnbDailyTxt(string text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ throw new FormatException("Unexpected CNB response: empty body.");
+
+ var lines = text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries);
+
+ // CNB daily.txt typically has 2 header lines, then data rows.
+ if (lines.Length < 3)
+ throw new FormatException($"Unexpected CNB response format. Lines={lines.Length}.");
+
+ var czk = GetCurrency("CZK");
+ var result = new List(capacity: Math.Max(0, lines.Length - 2));
+
+ for (int i = 2; i < lines.Length; i++)
+ {
+ // Expected: Country|Currency|Amount|Code|Rate
+ var parts = lines[i].Split('|');
+ if (parts.Length != 5)
+ {
+ _log.LogDebug("Skipping CNB line {Line}: unexpected field count ({Count}).", i + 1, parts.Length);
+ continue;
+ }
+
+ if (!int.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var amount) || amount <= 0)
+ {
+ _log.LogDebug("Skipping CNB line {Line}: bad amount '{Amount}'.", i + 1, parts[2]);
+ continue;
+ }
+
+ var code = (parts[3] ?? "").Trim();
+ if (code.Length != 3)
+ {
+ _log.LogDebug("Skipping CNB line {Line}: bad code '{Code}'.", i + 1, code);
+ continue;
+ }
+
+ if (!decimal.TryParse(parts[4], NumberStyles.Number, CultureInfo.InvariantCulture, out var rate) || rate <= 0m)
+ {
+ _log.LogDebug("Skipping CNB line {Line}: bad rate '{Rate}'.", i + 1, parts[4]);
+ continue;
+ }
+
+ var target = GetCurrency(code);
+
+ // CNB provides CZK per (amount) units => normalize to CZK per 1 unit
+ result.Add(new ExchangeRate(
+ sourceCurrency: czk,
+ targetCurrency: target,
+ value: rate / amount));
+ }
+
+ if (result.Count == 0)
+ _log.LogWarning("CNB response parsed successfully but produced 0 rates. Header: '{Header}'", lines[0]);
+
+ return result;
+ }
+
+ private Currency GetCurrency(string code)
+ => _currencyCache.GetOrAdd(code.Trim(), c => new Currency(c.Trim().ToUpperInvariant()));
+ }
+}
diff --git a/ExchangeRateUpdater/ExchangeRateUpdater/ExchangeRateUpdater.csproj b/ExchangeRateUpdater/ExchangeRateUpdater/ExchangeRateUpdater.csproj
new file mode 100644
index 000000000..ca87ef7ce
--- /dev/null
+++ b/ExchangeRateUpdater/ExchangeRateUpdater/ExchangeRateUpdater.csproj
@@ -0,0 +1,13 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+