Creating Custom Exchange Rate Providers¶
Exchange rate providers fetch currency conversion rates for Merchello's multi-currency support. Merchello ships with a built-in Frankfurter provider (free ECB rates), but you can create your own for premium services like Open Exchange Rates, Fixer.io, or XE.
Quick Overview¶
To create an exchange rate provider, you need to:
- Create a class that implements
IExchangeRateProvider - Implement
Metadata,GetRatesAsync(), andGetRateAsync()
There is no base class for exchange rate providers -- you implement the interface directly.
How Exchange Rates Work in Merchello¶
A few important points about multi-currency:
- Basket amounts are stored in the store currency and never change when the display currency changes.
- The rate is stored as presentment-to-store (for example a rate of
1.25means 1 GBP = 1.25 USD). Display usesamount * rate; checkout/payment usesamount / rate. Your provider only needs to produce honest directional quotes -- don't invert them. - Display amounts are calculated on-the-fly:
amount * rate. - Rates are cached by the exchange rate service (TTL + fallback) so customers are insulated if your provider is briefly unreachable. Return a failed
ExchangeRateResultrather than throwing so the cache can fall back cleanly. - At invoice creation the rate is locked for audit (
PricingExchangeRate,PricingExchangeRateSource,PricingExchangeRateTimestampUtc). Never recompute or overwrite these values later.
Full Example¶
using Merchello.Core.ExchangeRates.Models;
using Merchello.Core.ExchangeRates.Providers;
using Merchello.Core.ExchangeRates.Providers.Interfaces;
using Merchello.Core.Shared.Providers;
using Microsoft.Extensions.Logging;
public class AcmeExchangeRateProvider(
IHttpClientFactory httpClientFactory,
ILogger<AcmeExchangeRateProvider> logger) : IExchangeRateProvider
{
private ExchangeRateProviderConfiguration? _configuration;
private readonly HttpClient _httpClient = httpClientFactory.CreateClient();
// 1. Metadata
public ExchangeRateProviderMetadata Metadata => new(
Alias: "acme-rates", // Unique, immutable
DisplayName: "Acme Exchange Rates",
Icon: "icon-globe",
Description: "Premium exchange rates from Acme Financial",
SupportsHistoricalRates: false,
SupportedCurrencies: [] // Empty = all currencies
);
// 2. Configuration fields (if you need API keys)
public ValueTask<IEnumerable<ProviderConfigurationField>> GetConfigurationFieldsAsync(
CancellationToken cancellationToken = default)
{
return ValueTask.FromResult<IEnumerable<ProviderConfigurationField>>(
[
new ProviderConfigurationField
{
Key = "apiKey",
Label = "API Key",
FieldType = ConfigurationFieldType.Password,
IsRequired = true,
IsSensitive = true,
Placeholder = "your-api-key"
}
]);
}
// 3. Store configuration
public ValueTask ConfigureAsync(
ExchangeRateProviderConfiguration? configuration,
CancellationToken cancellationToken = default)
{
_configuration = configuration;
return ValueTask.CompletedTask;
}
// 4. Fetch ALL rates for a base currency
public async Task<ExchangeRateResult> GetRatesAsync(
string baseCurrency,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(baseCurrency))
return new ExchangeRateResult(false, "", new(), DateTime.UtcNow, "Base currency is required.");
try
{
var apiKey = _configuration?.GetValue("apiKey") ?? "";
var response = await _httpClient.GetAsync(
$"https://api.acme.com/rates?base={baseCurrency}&apikey={apiKey}",
cancellationToken);
if (!response.IsSuccessStatusCode)
return new ExchangeRateResult(false, baseCurrency, new(), DateTime.UtcNow,
$"API returned {response.StatusCode}");
var data = await response.Content.ReadFromJsonAsync<AcmeRatesResponse>(
cancellationToken: cancellationToken);
// Return a dictionary of currency code -> rate
// Rate is "1 base = X target" (e.g., 1 USD = 0.79 GBP)
return new ExchangeRateResult(
Success: true,
BaseCurrency: baseCurrency.ToUpperInvariant(),
Rates: data!.Rates, // Dictionary<string, decimal>
TimestampUtc: DateTime.UtcNow,
ErrorMessage: null
);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
logger.LogWarning(ex, "Failed to fetch rates for {Base}", baseCurrency);
return new ExchangeRateResult(false, baseCurrency, new(), DateTime.UtcNow, ex.Message);
}
}
// 5. Fetch a SINGLE rate between two currencies
public async Task<decimal?> GetRateAsync(
string fromCurrency,
string toCurrency,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(fromCurrency) || string.IsNullOrWhiteSpace(toCurrency))
return null;
// Same currency = 1:1
if (string.Equals(fromCurrency, toCurrency, StringComparison.OrdinalIgnoreCase))
return 1m;
try
{
var apiKey = _configuration?.GetValue("apiKey") ?? "";
var response = await _httpClient.GetAsync(
$"https://api.acme.com/rate?from={fromCurrency}&to={toCurrency}&apikey={apiKey}",
cancellationToken);
if (!response.IsSuccessStatusCode)
return null;
var data = await response.Content.ReadFromJsonAsync<AcmeSingleRateResponse>(
cancellationToken: cancellationToken);
return data?.Rate;
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
logger.LogWarning(ex, "Failed to fetch rate {From}->{To}", fromCurrency, toCurrency);
return null;
}
}
}
Key Points¶
ExchangeRateResult¶
The ExchangeRateResult is a record with these fields:
public record ExchangeRateResult(
bool Success,
string BaseCurrency,
Dictionary<string, decimal> Rates, // Currency code -> rate
DateTime TimestampUtc,
string? ErrorMessage
);
Rate Direction¶
Rates should be expressed as "1 unit of base currency = X units of target currency":
- Base: USD, Target: GBP, Rate: 0.79 means $1 = 0.79 GBP
- Base: GBP, Target: USD, Rate: 1.27 means 1 GBP = $1.27
Error Handling¶
Return a failed ExchangeRateResult rather than throwing exceptions. The exchange rate service handles retries and fallbacks.
Provider Manager¶
Exchange rate providers are managed by IExchangeRateProviderManager, which handles:
- Listing all discovered providers
- Setting the active provider
- Saving provider settings
Only one exchange rate provider can be active at a time.
No Configuration Needed?¶
If your provider doesn't need API keys (like the built-in Frankfurter provider), just return empty configuration:
public ValueTask<IEnumerable<ProviderConfigurationField>> GetConfigurationFieldsAsync(
CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IEnumerable<ProviderConfigurationField>>([]);
public ValueTask ConfigureAsync(
ExchangeRateProviderConfiguration? configuration,
CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
Dependency Injection¶
Warning: Use constructor injection only.
ExtensionManageractivates exchange-rate providers viaActivatorUtilities.CreateInstance; setter injection and post-construction configuration hooks are not supported. Store configuration in a private field insideConfigureAsync, not via a later setter. See Extension Manager.
Update Frequency¶
The exchange rate service decides when to refresh rates from your provider (it caches results and honours its own TTL plus a background refresh job). Your provider should return the freshest rates it can on every call and expose a meaningful TimestampUtc -- do not add your own in-process caching layer on top.
Built-in Provider for Reference¶
| Provider | Location | Notes |
|---|---|---|
| Frankfurter | FrankfurterExchangeRateProvider.cs | Free ECB rates, no API key needed |
Metadata: ExchangeRateProviderMetadata.cs. Interface: IExchangeRateProvider.cs.