Currency Exchange
Converts supplier prices to market currency when creating offers and during checkout. Rates are fetched from the configured exchange rate provider (default: Frankfurter/ECB, can be switched to Fixer.io) and automatically refreshed when stale.
When Conversion Happens
Section titled “When Conversion Happens”The OfferObserver automatically converts land_base_price when:
- Offer has a
supplier_tour_rate_id(linked to a supplier rate) - Service rate price currency differs from market’s
default_currency_code - An exchange rate exists for the currency pair
The source currency is resolved from supplier_service_rate_prices (the actual records that compose the land price), with a fallback to the supplier’s currency.
No conversion occurs if currencies match. Throws an error if currencies differ but no exchange rate exists.
Syncing Exchange Rates
Section titled “Syncing Exchange Rates”Rates sync daily via scheduler (1 API call fetches all currencies at once). Manual sync:
# Sync today's rates (base: EUR)php artisan currency:sync-rates
# Sync specific datephp artisan currency:sync-rates --date=2026-01-15Source: backend/app/Console/Commands/SyncExchangeRatesCommand.php
Staleness Check (Auto-Refresh)
Section titled “Staleness Check (Auto-Refresh)”In addition to daily scheduled syncs, the service automatically refreshes rates when they are older than the configured threshold (default: 1 hour). This ensures price-sensitive flows use fresh rates without waiting for the next scheduled sync.
Where it runs:
- Checkout start (
CheckoutSessionService::start()) — callsensureFreshRates()before any price calculations - Manual offer creation (
CreateOfferwizard) — usesgetRateOrFetch()which checks freshness before returning a rate
Where it does NOT run:
- Auto-offer generation (daily rates are sufficient)
- Admin-triggered flight booking (no actionable outcome from fresher rates)
How it works:
- Checks the most recent
updated_atacross all EUR-basedcurrency_exchange_ratesrows - If older than
exchange.staleness_threshold, triggers a full sync from the API - Uses
Cache::lock('exchange-rate-sync', 30)to prevent concurrent API calls - On failure or lock contention: logs a warning and continues with cached data (graceful degradation)
- Once-per-request guard avoids repeated DB queries in loops (e.g., hotel calculator’s per-night iteration)
Booking Rate Snapshot
Section titled “Booking Rate Snapshot”At payment confirmation, the system captures a snapshot of exchange rates relevant to the booking’s priced services. This enables future FX reconciliation — comparing the rate at booking time vs. rate at supplier payment time to measure margin impact.
When captured: In PaymentService::updateBookingPaymentStatus(), when the first payment succeeds and the booking transitions to PendingFlightBooking or PendingLandConfirmation.
What it stores: Only rates for currencies actually used in the booking’s service pricing — not all rates, not supplier defaults. Currencies are derived from SupplierServiceRatePrice.currency_id on tour base services, package services, and booked upsells.
No extra API call: Reads from DB since rates are already fresh from the checkout start staleness check.
Column: bookings.exchange_rates_snapshot (JSON, nullable, cast to array)
JSON structure:
{ "captured_at": "2026-03-31T10:30:00+00:00", "base_currency": "EUR", "source": "fixer", "rates": { "MAD": 10.85, "USD": 1.08 }}Source: CurrencyExchangeService::captureRatesSnapshot(), PaymentService::updateBookingPaymentStatus()
Key Models
Section titled “Key Models”| Model | Purpose |
|---|---|
CurrencyExchangeRate | Stores rates with inverse calculation support |
Currency | Reference currencies (EUR, USD, GBP, etc.) |
SupplierServiceRatePrice | Stores service price + currency per room type |
Observers
Section titled “Observers”SupplierObserver: When a supplier’s currency_id changes, cascades the update to all related SupplierTourRateRoomPrice records.
OfferObserver: Converts land_base_price from service rate price currency to market currency during offer creation.
Configuration
Section titled “Configuration”Exchange rates use worksome/exchange package configured in config/exchange.php.
| Env Variable | Description | Default |
|---|---|---|
EXCHANGE_DRIVER | Exchange rate provider (fixer, frankfurter, cache) | frankfurter |
FIXER_ACCESS_KEY | Fixer.io API key (required when driver is fixer) | — |
EXCHANGE_RATE_STALENESS_SECONDS | Max age (seconds) before rates are auto-refreshed | 3600 |
Provider notes:
- Fixer.io (free tier): EUR base only, 100 API calls/month, ~155 currencies. Enough for daily syncs.
- Frankfurter (free, no key): EUR base, ~30 ECB currencies. Fallback option.
- To switch providers, change
EXCHANGE_DRIVERin.env— no code changes needed.
Required: Active currencies must exist in currencies table before syncing.