Technical docs

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.go

Injected Into

  • PaymentV3Server - Tax preview with multi-currency support
  • TaxServer - Tax calculation API
  • FXPopulationService - Automatic FX population for transactions
  • ForensicsServer - Document analysis
  • InvoiceExtractionServer - Invoice processing

Dependencies

  • gorm.io/gorm - Database ORM
  • github.com/redis/go-redis/v9 - Redis client for caching
  • ExchangeRate-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.000548

Rate 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.0

ConvertAmountForDate

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.53

GetRatesForCurrencies

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 rate

SyncCurrencyIfNeeded

Syncs USD rates from API if not already synced today.

err := currencyService.SyncCurrencyIfNeeded("NGN")
// Fetches fresh rates from ExchangeRate-API if stale

Usage 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