Currency Conversion
The world.money.conversion package provides a pluggable framework for currency conversion through the world.money.conversion.ExchangeRateProvider trait.
Overview
Currency conversion requires two components:
- ExchangeRateProvider - Supplies exchange rates between currency pairs
- Money.convertTo - Performs type-safe conversion using rates from the provider
Core Components
ExchangeRateProvider
The world.money.conversion.ExchangeRateProvider trait defines the service provider interface for exchange rates:
trait ExchangeRateProvider:
def get(query: ConversionQuery): Either[ConversionError, ConversionRate]
Implement this trait to integrate any exchange rate data source—APIs, databases, configuration files, or any other source. The method returns Either[ConversionError, ConversionRate] to handle missing rates or provider failures gracefully.
ConversionQuery
world.money.conversion.ConversionQuery represents a request for an exchange rate:
final case class ConversionQuery(base: Currency, term: Currency)
The query specifies conversion from the base currency to the term (or counter) currency.
ConversionRate
world.money.conversion.ConversionRate represents an exchange rate between two currencies:
import world.money.conversion.ConversionRate
import world.money.currency.Currencies
val rate = ConversionRate(
base = Currencies.USD,
term = Currencies.GBP,
rate = BigDecimal("0.79")
)
rate.base // Currencies.USD
rate.term // Currencies.GBP
rate.rate // BigDecimal(0.79)
The rate represents how much one unit of the base currency is worth in the term currency.
Factory methods:
ConversionRate(base, term, rate)- without metadataConversionRate(base, term, rate, context)- with optional contextConversionRate.withContext(base, term, rate, context)- alternative syntax with context
ConversionContext
world.money.conversion.ConversionContext encapsulates metadata about the exchange rate:
import java.time.Instant
import world.money.conversion.ConversionContext
val context = ConversionContext(
provider = "ECB",
rateTimestamp = Some(Instant.now())
)
Factory methods:
ConversionContext(provider)- without timestampConversionContext(provider, rateTimestamp)- with optional timestampConversionContext.at(provider, rateTimestamp)- alternative syntax with timestamp
Performing Conversions
With a given ExchangeRateProvider in scope, use the convertTo extension method for type-safe currency conversion:
import world.money.*
import world.money.currency.Currencies
// Assuming 'given ExchangeRateProvider' is in scope
val kesAmount = Money[Currencies.KES.type](10000)
// Convert KES -> GBP
val gbpResult = kesAmount.convertTo[Currencies.GBP.type]
gbpResult match
case Right(amount) => println(s"${kesAmount.value.unwrap} KES = ${amount.value.unwrap} GBP")
case Left(err) => println(s"Conversion failed: ${err.getMessage}")
// Result is strongly typed: Money[Currencies.GBP.type]
val result: Either[ConversionError, Money[Currencies.GBP.type]] = gbpResult
Chaining Conversions
Convert through multiple currencies using for-comprehensions:
import world.money.*
import world.money.currency.Currencies
val kesAmount = Money[Currencies.KES.type](10000)
// Chain conversions: KES -> GBP -> OMR
val omrResult = for
gbp <- kesAmount.convertTo[Currencies.GBP.type]
omr <- gbp.convertTo[Currencies.OMR.type]
yield omr
omrResult match
case Right(omr) => println(s"Result: ${omr.value.unwrap} OMR")
case Left(err) => println(s"Conversion failed: ${err.getMessage}")
Error Handling
Conversion operations return Either[ConversionError, Money[T]]. Handle errors appropriately:
import world.money.errors.ConversionError
val result = amount.convertTo[Currencies.OMR.type]
result match
case Right(converted) =>
// Use the converted amount
println(s"Converted: ${converted.value.unwrap}")
case Left(err) =>
err match
case ConversionError.RateNotFound(query) =>
println(s"No rate found for ${query.base.code.value} -> ${query.term.code.value}")
case ConversionError.ProviderError(msg, _) =>
println(s"Provider error: $msg")
case other =>
println(s"Conversion error: ${other.getMessage}")
Implementing a Provider
Simple In-Memory Provider
Example implementation using a fixed rate table with KES as the pivot currency:
import world.money.conversion.*
import world.money.currency.{Currency, Currencies}
import world.money.errors.ConversionError
val rates: Map[Currency, BigDecimal] = Map(
Currencies.GBP -> BigDecimal("0.0061"), // 1 KES = 0.0061 GBP
Currencies.OMR -> BigDecimal("0.0030") // 1 KES = 0.0030 OMR
)
given ExchangeRateProvider with
def get(query: ConversionQuery): Either[ConversionError, ConversionRate] =
val ConversionQuery(base, term) = query
// Same currency
if base == term then
Right(ConversionRate(base, term, BigDecimal(1)))
// Direct: KES -> XXX
else if base == Currencies.KES && rates.contains(term) then
Right(ConversionRate(base, term, rates(term)))
// Inverse: XXX -> KES
else if rates.contains(base) && term == Currencies.KES then
Right(ConversionRate(base, term, BigDecimal(1) / rates(base)))
// Cross-rate: XXX -> YYY via KES
else if rates.contains(base) && rates.contains(term) then
val toKES = BigDecimal(1) / rates(base)
val fromKES = rates(term)
Right(ConversionRate(base, term, toKES * fromKES))
else
Left(ConversionError.RateNotFound(query))
Bidirectional Provider
A provider that automatically calculates inverse rates:
import world.money.conversion.*
import world.money.currency.Currency
import world.money.errors.ConversionError
class BidirectionalProvider(baseRates: Map[(Currency, Currency), BigDecimal])
extends ExchangeRateProvider:
def get(query: ConversionQuery): Either[ConversionError, ConversionRate] =
if query.base == query.term then
Right(ConversionRate(query.base, query.term, BigDecimal(1)))
else
baseRates.get((query.base, query.term)) match
case Some(r) =>
Right(ConversionRate(query.base, query.term, r))
case None =>
// Try inverse rate
baseRates.get((query.term, query.base)) match
case Some(inverseRate) =>
Right(ConversionRate(query.base, query.term, BigDecimal(1) / inverseRate))
case None =>
Left(ConversionError.RateNotFound(query))
Production Provider Example
Typical integration with an external API:
import java.time.Instant
import world.money.conversion.*
import world.money.currency.Currency
import world.money.errors.ConversionError
class ExternalApiProvider(apiKey: String, apiUrl: String) extends ExchangeRateProvider:
def get(query: ConversionQuery): Either[ConversionError, ConversionRate] =
try
val response = fetchRate(query.base, query.term)
response match
case Some((rate, timestamp)) =>
val context = ConversionContext.at("ExternalAPI", timestamp)
Right(ConversionRate.withContext(query.base, query.term, rate, context))
case None =>
Left(ConversionError.RateNotFound(query))
catch
case ex: Exception =>
Left(ConversionError.ProviderError(s"API request failed: ${ex.getMessage}"))
private def fetchRate(base: Currency, term: Currency): Option[(BigDecimal, Instant)] =
// HTTP request to external API
???
Caching Provider
Wrap a provider with caching to reduce API calls and improve performance:
import scala.collection.mutable
import java.time.{Instant, Duration}
import world.money.conversion.*
import world.money.errors.ConversionError
class CachingProvider(
underlying: ExchangeRateProvider,
cacheDuration: Duration = Duration.ofMinutes(15)
) extends ExchangeRateProvider:
private case class CacheEntry(rate: ConversionRate, fetchedAt: Instant)
private val cache = mutable.Map.empty[ConversionQuery, CacheEntry]
def get(query: ConversionQuery): Either[ConversionError, ConversionRate] =
val now = Instant.now()
cache.get(query) match
case Some(entry) if Duration.between(entry.fetchedAt, now).compareTo(cacheDuration) < 0 =>
// Cache hit
Right(entry.rate)
case _ =>
// Cache miss or expired
underlying.get(query) match
case Right(rate) =>
cache.update(query, CacheEntry(rate, now))
Right(rate)
case Left(err) =>
Left(err)
def clearCache(): Unit = cache.clear()
Usage:
val apiProvider = ExternalApiProvider(apiKey, apiUrl)
given ExchangeRateProvider = CachingProvider(apiProvider)
// Subsequent conversions use cached rates
Rate Inversion
world.money.conversion.ConversionRate supports inversion for calculating reciprocal rates:
import world.money.conversion.*
import world.money.currency.Currencies
import world.money.currency.CurrencyMathContext.given
val kesToGbp = ConversionRate(
base = Currencies.KES,
term = Currencies.GBP,
rate = BigDecimal("0.0061")
)
// Invert to get GBP -> KES
val gbpToKes = kesToGbp.inverse
gbpToKes match
case Right(inverted) =>
assert(inverted.base == Currencies.GBP)
assert(inverted.term == Currencies.KES)
assert(inverted.rate == BigDecimal(1) / BigDecimal("0.0061"))
case Left(err) =>
// Inversion fails for zero rates
println(s"Inversion error: ${err.getMessage}")
Best Practices
1. Handle All Error Cases
Always pattern match on conversion results to handle failures gracefully:
import world.money.errors.ConversionError
amount.convertTo[Currencies.GBP.type] match
case Right(converted) =>
// Process successful conversion
processPayment(converted)
case Left(ConversionError.RateNotFound(query)) =>
log.warn(s"Rate not available: ${query.base.code.value} -> ${query.term.code.value}")
case Left(ConversionError.ProviderError(msg, cause)) =>
log.error(s"Provider failed: $msg", cause.orNull)
2. Include Metadata
Use world.money.conversion.ConversionContext to track rate sources and timestamps for audit trails:
import java.time.Instant
import world.money.conversion.*
val context = ConversionContext.at("ECB", Instant.now())
val rate = ConversionRate.withContext(
base = Currencies.EUR,
term = Currencies.GBP,
rate = BigDecimal("0.86"),
context = context
)
3. Precision
Use BigDecimal string constructors for exchange rates to maintain precision:
import world.money.conversion.ConversionRate
import world.money.currency.Currencies
// Good: Precise decimal from string
val precise = ConversionRate(
Currencies.USD,
Currencies.EUR,
BigDecimal("0.85123456")
)
// Avoid: Double may lose precision
// val imprecise = ConversionRate(
// Currencies.USD,
// Currencies.EUR,
// BigDecimal(0.85123456) // Double literal
// )
4. Implement Fallbacks
Chain multiple providers for redundancy:
import world.money.conversion.*
import world.money.errors.ConversionError
class FallbackProvider(primary: ExchangeRateProvider, fallback: ExchangeRateProvider)
extends ExchangeRateProvider:
def get(query: ConversionQuery): Either[ConversionError, ConversionRate] =
primary.get(query) match
case success @ Right(_) => success
case Left(_) => fallback.get(query)
5. Support Inverse Rates
Calculate inverse rates when direct rates are not available:
import world.money.conversion.*
import world.money.currency.CurrencyMathContext.given
def getRate(base: Currency, term: Currency, rates: Map[(Currency, Currency), BigDecimal]): Option[ConversionRate] =
rates.get((base, term)) match
case Some(rate) =>
Some(ConversionRate(base, term, rate))
case None =>
// Try inverse
rates.get((term, base)).map { inverseRate =>
ConversionRate(base, term, BigDecimal(1) / inverseRate)
}
6. Validate Rates
Ensure rates are positive and non-zero before creating world.money.conversion.ConversionRate instances:
import world.money.conversion.*
import world.money.currency.Currency
import world.money.errors.ConversionError
def createRate(base: Currency, term: Currency, rate: BigDecimal): Either[ConversionError, ConversionRate] =
if rate <= 0 then
Left(ConversionError.ProviderError(s"Invalid rate: $rate must be positive"))
else if base == term && rate != 1 then
Left(ConversionError.ProviderError(s"Same-currency rate must be 1.0, got $rate"))
else
Right(ConversionRate(base, term, rate))
7. Cache Appropriately
Balance freshness requirements with API rate limits. Use time-based cache expiry for dynamic rates:
import java.time.Duration
// For volatile forex rates
given ExchangeRateProvider = CachingProvider(forexApi, Duration.ofMinutes(5))
// For stable rates (e.g., pegged currencies)
given ExchangeRateProvider = CachingProvider(stableRatesApi, Duration.ofHours(24))
Testing Strategies
Mock Provider for Testing
import world.money.conversion.*
import world.money.currency.{Currency, Currencies}
import world.money.errors.ConversionError
class MockProvider(rates: Map[(Currency, Currency), BigDecimal]) extends ExchangeRateProvider:
def get(query: ConversionQuery): Either[ConversionError, ConversionRate] =
rates.get((query.base, query.term)) match
case Some(rate) =>
Right(ConversionRate(query.base, query.term, rate))
case None =>
Left(ConversionError.RateNotFound(query))
// In tests
given ExchangeRateProvider = MockProvider(Map(
(Currencies.KES, Currencies.GBP) -> BigDecimal("0.0061"),
(Currencies.GBP, Currencies.OMR) -> BigDecimal("0.495")
))
Test Rate Provider with Fixed Rate
import world.money.conversion.*
import world.money.currency.Currency
import world.money.errors.ConversionError
class TestRateProvider(fixedRate: BigDecimal = BigDecimal("1.0")) extends ExchangeRateProvider:
def get(query: ConversionQuery): Either[ConversionError, ConversionRate] =
Right(ConversionRate(query.base, query.term, fixedRate))
// Usage in tests
given ExchangeRateProvider = TestRateProvider(BigDecimal("0.85"))
API Reference
See world.money.conversion.ExchangeRateProvider, world.money.conversion.ConversionRate, world.money.conversion.ConversionQuery, and world.money.conversion.ConversionContext for the complete API.