Currency Service
Exchange rate management, currency conversion, and multi-currency support.
Overview
The CurrencyService handles all exchange rate operations in the system. It fetches rates from external APIs, stores them in PostgreSQL, caches them in Redis, and provides conversion methods for both current and historical rates.
Location
backend/internal/services/currency_service.goInjected Into
PaymentV3Server- Tax preview with multi-currency supportTaxServer- Tax calculation APIFXPopulationService- Automatic FX population for transactionsForensicsServer- Document analysisInvoiceExtractionServer- Invoice processing
Dependencies
gorm.io/gorm- Database ORMgithub.com/redis/go-redis/v9- Redis client for cachingExchangeRate-API- External rate provider (v6)
Architecture
The service uses USD as the canonical base currency. All rates are stored as USD→X pairs, and cross-rates are calculated dynamically.
Design Decision: Using USD as the single base currency reduces storage by N times (where N is the number of supported currencies) and ensures consistency across all conversions.
Core Concepts
Base Currency
USD is the canonical base currency. All exchange rates are stored as USD→TARGET pairs.
const BaseCurrency = "USD"Cross-Rate Calculation
For any conversion A→B where neither is USD, the rate is calculated as:
// Formula: A→B = (USD→B) / (USD→A)
// Example: NGN→EUR when USD→NGN=1550 and USD→EUR=0.85
// Rate = 0.85 / 1550 = 0.000548Rate Sources
- exchangerate-api - Automatic daily sync from ExchangeRate-API
- manual - User-defined overrides for specific currency pairs
Manual rates take precedence over API rates for the same date.
Caching Strategy
- Redis cache with 24-hour TTL
- Cache key format:
exchange_rate:FROM:TO - Cache invalidated on manual rate updates
API Reference
Constructor
// Without API key (limited functionality)
service := NewCurrencyService(db, redisClient)
// With API key (recommended)
service := NewCurrencyServiceWithAPIKey(db, redisClient, apiKey)GetLatestRate
Returns the current exchange rate between two currencies.
rate, err := currencyService.GetLatestRate("USD", "NGN")
// rate = 1550.0 (1 USD = 1550 NGN)GetRateForDate
Returns the exchange rate for a specific historical date. Falls back to the closest earlier date if exact date not found.
date := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
rate, err := currencyService.GetRateForDate("EUR", "NGN", date)ConvertAmount
Converts an amount using the latest exchange rate.
amountNGN, err := currencyService.ConvertAmount(100.0, "USD", "NGN")
// amountNGN = 155000.0ConvertAmountForDate
Converts an amount using the exchange rate for a specific date. Critical for tax compliance where transaction-date rates are required.
transactionDate := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC)
amountNGN, err := currencyService.ConvertAmountForDate(
100.0, // amount
"USD", // from currency
"NGN", // to currency
transactionDate,
)ConvertToBaseCurrency
Converts to tenant's base currency and returns both the converted amount and the rate used.
convertedAmount, rate, err := currencyService.ConvertToBaseCurrency(
1000.0, // original amount
"EUR", // original currency
"NGN", // tenant base currency
)
// convertedAmount = 1823529.41, rate = 1823.53GetRatesForCurrencies
Batch retrieval of USD→X rates for multiple currencies. Optimized for imports.
currencies := []string{"NGN", "EUR", "GBP", "PLN"}
rates := currencyService.GetRatesForCurrencies(currencies)
// rates = map[string]float64{
// "NGN": 1550.0,
// "EUR": 0.85,
// "GBP": 0.73,
// "PLN": 4.02,
// }SetExchangeRate
Creates a manual rate override for a currency pair.
rate, err := currencyService.SetExchangeRate("USD", "NGN", 1600.0)
// Creates manual override that takes precedence over API rateSyncCurrencyIfNeeded
Syncs USD rates from API if not already synced today.
err := currencyService.SyncCurrencyIfNeeded("NGN")
// Fetches fresh rates from ExchangeRate-API if staleUsage Examples
Transaction Import with Mixed Currencies
func processImport(rows []ImportRow) error {
// Get all unique currencies in the import
currencies := extractUniqueCurrencies(rows)
// Batch fetch rates (single DB query)
rates := currencyService.GetRatesForCurrencies(currencies)
for _, row := range rows {
if row.Currency != tenantBaseCurrency {
// Convert to base currency using cached rate
rate := rates[row.Currency]
row.BaseAmount = row.Amount * rate
}
}
return nil
}Tax Calculation with Historical Rate
func calculateTaxForTransaction(tx *Transaction) (*TaxResult, error) {
// For Nigerian tax, amounts must be in NGN
if tx.Currency != "NGN" {
// Use transaction date rate (CBN requirement)
amountNGN, err := currencyService.ConvertAmountForDate(
tx.Amount,
tx.Currency,
"NGN",
tx.OccurredAt,
)
if err != nil {
return nil, err
}
taxContext.AmountNGN = amountNGN
}
return taxEngine.Calculate(taxContext)
}Dashboard Stats with Currency Conversion
func getDashboardStats(workspaceID uuid.UUID, reportCurrency string) (*Stats, error) {
// Get raw stats in original currencies
rawStats := fetchRawStats(workspaceID)
// Convert each currency bucket to report currency
for currency, amount := range rawStats.RevenueByCurrency {
converted, _ := currencyService.ConvertAmount(
amount,
currency,
reportCurrency,
)
stats.TotalRevenue += converted
}
return stats, nil
}Rules and Constraints
Immutability Rule
Exchange rates used for posted transactions are IMMUTABLE. Once a transaction is posted to the ledger, its FX rate cannot be changed. Historical recalculations are forbidden.
Tax Compliance Rule
For Nigerian tax calculations, always use the official CBN rate at the transaction date. Use GetRateForDate() or ConvertAmountForDate(), never the latest rate.
Fallback Behavior
GetExchangeRateWithFallback() returns 1.0 if rate not found. Use this only for non-critical display purposes. For financial calculations, always use methods that return errors.
Precision Rules
- Store rates with full precision (float64)
- Round only at presentation layer
- Aggregate first, convert second for reports
Caching Invalidation
- Manual rate changes invalidate Redis cache immediately
- API sync updates both DB and cache atomically
- Cache TTL is 24 hours for calculated cross-rates