Skip to content

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.

The OfferObserver automatically converts land_base_price when:

  1. Offer has a supplier_tour_rate_id (linked to a supplier rate)
  2. Service rate price currency differs from market’s default_currency_code
  3. 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.

Rates sync daily via scheduler (1 API call fetches all currencies at once). Manual sync:

Terminal window
# Sync today's rates (base: EUR)
php artisan currency:sync-rates
# Sync specific date
php artisan currency:sync-rates --date=2026-01-15

Source: backend/app/Console/Commands/SyncExchangeRatesCommand.php

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()) — calls ensureFreshRates() before any price calculations
  • Manual offer creation (CreateOffer wizard) — uses getRateOrFetch() 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:

  1. Checks the most recent updated_at across all EUR-based currency_exchange_rates rows
  2. If older than exchange.staleness_threshold, triggers a full sync from the API
  3. Uses Cache::lock('exchange-rate-sync', 30) to prevent concurrent API calls
  4. On failure or lock contention: logs a warning and continues with cached data (graceful degradation)
  5. Once-per-request guard avoids repeated DB queries in loops (e.g., hotel calculator’s per-night iteration)

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()

ModelPurpose
CurrencyExchangeRateStores rates with inverse calculation support
CurrencyReference currencies (EUR, USD, GBP, etc.)
SupplierServiceRatePriceStores service price + currency per room type

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.

Exchange rates use worksome/exchange package configured in config/exchange.php.

Env VariableDescriptionDefault
EXCHANGE_DRIVERExchange rate provider (fixer, frankfurter, cache)frankfurter
FIXER_ACCESS_KEYFixer.io API key (required when driver is fixer)
EXCHANGE_RATE_STALENESS_SECONDSMax age (seconds) before rates are auto-refreshed3600

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_DRIVER in .env — no code changes needed.

Required: Active currencies must exist in currencies table before syncing.

  • Suppliers - Supplier currency assignment
  • Offers - Offer pricing calculation
  • Source: backend/app/Services/CurrencyExchangeService.php