FX / Multi-currency

Rate sources, rate locking, settlement vs display currency, historical rates for reconciliation

Phase 3
Spec
1.

Core Concepts

FX Service is the platform's centralised service for exchange rates. It is the single source of truth for all conversions. No other component performs currency conversion independently. All limits, balances, and settlements operate in settlement currency, while players see amounts in their local display currency.

Settlement currency

The currency in which the platform settles with the PSP and holds internal balances. All financial positions, limits, and reconciliation run in settlement currency. Typically USD or EUR, configured per operator at onboarding.

Display currency

The currency the player sees in the UI -- their local currency (e.g. BRL, INR, NGN). Set per player based on GEO or player preference. Display amounts are always converted from settlement currency at the current rate at render time.

Rate lock (rate fixing)

The moment a player initiates a payment, the FX rate is locked for that transaction. This guarantees the player pays exactly what was shown on the checkout screen, regardless of how long PSP processing takes. The locked rate is stored on the transaction record.

Rate refresh cycle

Exchange rates are fetched from an external provider (e.g. Open Exchange Rates, Fixer.io, or ECB) and cached internally. Rates are refreshed every 15 minutes for display and locked at transaction initiation. Stale rates older than 30 minutes must not be used for new transactions.

2.

Rate Sources

The platform must have at least two sources: a primary commercial provider and a fallback (ECB or cached last-known). If the primary provider is unavailable and there is no fallback, new transactions in currencies other than settlement currency must be blocked, not processed at a stale rate.
ProviderTierCoverageCostUse case
Open Exchange Rates
Recommended
Commercial
170+ currencies$12–$97/mo depending on planProduction -- reliable, wide coverage, SLA
Fixer.io
Commercial
170+ currencies$14–$99/moProduction -- EUR base, solid European coverage
ECB (European Central Bank)
Free / Official
32 currencies (EUR base)FreeFallback / audit reference only -- too slow for live rates
CoinGecko / Binance
Crypto
BTC, ETH, USDT, etc.Free tier availableRequired alongside fiat provider for crypto deposit support
3.

Rate Lock Flow

Rate locking protects the player from rate fluctuations during transaction processing and protects the platform from deliberate use of delays to capture a more favourable rate. The locked rate is an immutable contract between player and platform for that transaction.
1

Player opens checkout

Frontend calls GET /api/fx/rate?from=USD&to=BRL. Service returns current rate from cache. Player sees amount in display currency (e.g. "500 BRL").

2

Player submits payment

At the moment of submission, FX Service locks the rate for this transaction. The locked rate is valid for the TTL window (e.g. 10 minutes). If PSP processing takes longer, the locked rate still applies.

3

Rate stored on transaction

Transaction record gets fx_rate, fx_rate_locked_at, display_currency, display_amount fields populated. These are immutable once set -- no process may change the locked rate after this point.

4

Orchestrator calls PSP

Payment is submitted to PSP in settlement currency (USD/EUR). PSP never sees the display currency -- conversion is internal to the platform. The amount in settlement currency was calculated from the locked rate.

5

Wallet Engine credits settlement amount

On deposit success, Wallet credits the settlement_amount (not display_amount). Player balance is always stored in settlement currency. The frontend converts to display currency for rendering.

4.

Data Model

Table fx_rates -- current and historical rates

ColumnTypeDescription
id
uuid PK
Record ID
base_currency
char(3)
ISO 4217 base currency (platform settlement currency, e.g. USD)
quote_currency
char(3)
ISO 4217 quote currency (player display currency, e.g. BRL)
rate
numeric(18,8)
Exchange rate: 1 base = rate * quote
source
text
Rate provider: "openexchangerates" | "fixer" | "ecb" | "manual"
fetched_at
timestamptz
Timestamp when rate was fetched from provider
valid_until
timestamptz
Rate expires at this time. System rejects rates beyond this point.
is_active
boolean
Only one active rate per currency pair. Superseded rates kept for history.

FX fields on the transactions table

These fields are added to the existing transactions table as part of Phase 3. None of them may be NULL for transactions in currencies other than settlement currency. For transactions in settlement currency -- display_currency = settlement_currency, display_amount = settlement_amount, fx_rate = 1.0.
ColumnTypeDescription
settlement_currency
char(3)
Currency used for PSP and Wallet (platform base currency)
settlement_amount
numeric(18,2)
Amount in settlement currency -- what actually moves
display_currency
char(3)
Player's display currency at time of transaction
display_amount
numeric(18,2)
Amount shown to player (in display currency)
fx_rate
numeric(18,8)
Locked rate at transaction time: 1 settlement = fx_rate * display
fx_rate_locked_at
timestamptz
When the rate was locked -- audit trail
fx_rate_source
text
Rate provider used for this transaction
5.

Configuration Cases

Single-currency operator (simplest case)

Operator's settlement currency = player's display currency. FX conversion is a no-op (rate = 1.0). All amounts pass through unchanged. This is the default for EU operators running in EUR.

Settlement: EUR. Player GEO: Germany. Display: EUR. Rate: 1.0. No conversion needed.

Multi-currency operator (standard case)

Operator settles in USD. Players span multiple GEOs with local currencies (BRL, INR, MXN, etc.). FX Service converts each player deposit amount from display currency to USD at the locked rate. Limits, wallets, and reconciliation work in USD.

Player in Brazil deposits "500 BRL". Locked rate: 1 USD = 5.02 BRL. Settlement amount: 99.60 USD. Wallet is credited 99.60 USD.

PSP-currency mismatch

Platform settlement is USD, but the PSP for a given GEO only accepts EUR. Orchestrator detects the mismatch and calls FX Service to convert settlement_amount from USD to EUR before the PSP API call. A second fx_rate (settlement_to_psp) is stored on the transaction.

Settlement: 99.60 USD. PSP requires EUR. Rate: 1 USD = 0.921 EUR. PSP receives: 91.73 EUR. Both rates stored for full audit trail.

Two conversion steps introduce two rate lock moments -- both must be stored on the transaction for reconciliation and dispute resolution.

Crypto deposit (display in crypto, settle in fiat)

Player deposits USDT or BTC. Display amount is in crypto units. FX Service fetches the crypto/USD rate from a crypto provider (CoinGecko/Binance), locks it, and converts to settlement currency. Crypto rate volatility means the lock TTL should be shorter (2–3 minutes vs 10 for fiat).

Player deposits 100 USDT. Rate: 1 USDT = 0.9996 USD. Settlement: 99.96 USD. Lock TTL: 3 minutes.

For volatile assets (BTC, ETH), display the locked rate on the checkout screen with a visible countdown. If TTL expires before submission, refresh the rate and re-display.

Withdrawal in display currency

Player requests withdrawal in their local currency. Wallet holds settlement currency -- FX Service converts at the current (not locked) rate to determine the display amount shown to the player. The actual PSP transfer is in settlement or PSP currency. The rate is locked at the moment the withdrawal is approved, not when the player requests it.

Player balance: 200 USD. Requests withdrawal to BRL. Displayed: "~1,006 BRL" (preview, not locked). On approval: rate locked at 1 USD = 5.03 BRL → 1,006 BRL sent.

6.

Affected Modules

FX Service arrives in Phase 3 but affects modules from multiple phases. When implementing each of them, the key invariant is that all amounts arrive in settlement currency and none of these modules should independently apply FX conversion.

Limits & Rules

All limits (min/max deposit, daily caps, velocity) are defined and evaluated in settlement currency. FX Service converts the player's display amount to settlement before the limits engine checks it.

KYC / AML

AML thresholds (e.g. report transactions above €10,000) are defined in a reference currency. FX Service provides conversion to determine whether a transaction crosses reporting thresholds.

Reconciliation

PSP settles in settlement currency, so reconciliation compares settlement_amount values. The fx_rate stored on each transaction is the source of truth for any historical conversion -- never re-apply current rates to past transactions.

Payment Orchestrator

Orchestrator routes in settlement currency. When a PSP only supports specific fiat currencies (not USD), Orchestrator signals FX Service to convert to the PSP's required currency before submission.

Bonus Engine

Bonus match amounts and wagering requirements are denominated in settlement currency. A player depositing in BRL gets a bonus calculated on the settlement_amount, not the display_amount.

Admin Panel analytics

All revenue and volume metrics in Admin Panel are shown in settlement currency with operator-configured display currency overlay. Cross-operator aggregates are always in settlement currency.

7.

Admin Panel Configuration

Settlement currency is immutable after the first transaction. Add a hard guard in Admin Panel and backend -- if any transactions exist for the operator, the settlement_currency field must be read-only. Changing it requires a separate financial migration involving the CFO.

Per-operator settlement currency

Set at operator onboarding and stored in operator_config. Cannot be changed after first transaction -- changing settlement currency requires full financial migration. Add a guard in Admin Panel: field is read-only once transactions exist.

Supported display currencies per operator

Each operator configures which display currencies are available for their players (GEO-based or explicit list). Unsupported currencies fall back to settlement currency display. Config stored as JSON array in operator_config.supported_currencies.

FX spread / markup

Platform may add a configurable spread (e.g. 1.5%) on top of the mid-market rate. Spread is configured per operator and applied when generating locked rates. The gross rate shown to the player already includes the spread -- it is never shown separately. Revenue from spread is tracked in the reconciliation fee ledger.

Rate refresh interval

Default: 15 minutes for fiat, 2 minutes for crypto. Configurable per operator in Admin Panel. Shorter intervals = more API calls to rate provider = higher cost. Platform default should be conservative (15 min) with option to reduce for high-volume operators.

Rate lock TTL

Default: 10 minutes for fiat, 3 minutes for crypto. If a transaction is not submitted to PSP within TTL, the locked rate expires and the checkout must refresh. Stored in operator_config.fx_lock_ttl_seconds. Never set above 30 minutes for fiat or 5 minutes for crypto.

Manual rate override

Admin Panel allows entering a manual rate for a currency pair (source = "manual"). Used when the rate provider is down or for testing. Manual rates expire after 1 hour and require a reason field. All manual rate events are audit-logged with admin user ID.