Skip to content

Multi-Currency

Flow ID: CF-33 | Module(s): eshop, Currency domain | Complexity: Medium Last Updated: 2026-04-26

Business Context

Currencies stored with ISO 4217 code, symbol, and exchange rate. Base currency (from registry DEFAULT_CURRENCY) has rate=1.0. Other currencies multiply prices at display time only — all database storage is in base currency.

Key business behaviors:

  • Default currency cannot be deactivated
  • Cart stores currency_code at creation (default EUR)
  • Frontend: Vuex getSelectedCurrency getter triggers reactive price recalculation
  • Feeds: ?curr= URL parameter for XML currency conversion

API Reference

REST Endpoints

MethodPathAuthDescription
GET/rest/currency/currencyGuestList currencies (filter by code, active, symbol)
GET/rest/currency/currency/itemGuestGet single currency by filter (e.g. filter[code]=EUR)
GET/rest/currency/currency/{id}GuestGet single currency
POST/rest/currency/currencyBackendCreate currency
POST/rest/currency/currency/{id}BackendUpdate currency
DELETE/rest/currency/currency/{id}BackendDelete currency

Available filters: id (exact), code (exact), active (exact), symbol (partial) Available sorts: id, code, rate, active

Routes are defined in application/config/rest_routes.php (lines 292-297, 1296-1302) with both bare and language-prefixed variants ((\w{2})/rest/currency/currency/...). The item route is registered before the (:num) show route to avoid wildcard conflicts.

Legacy Storefront (AJAX)

URL PatternControllerMethod
POST /setCustomerSessionCurrencyIdWebrun.phpsetCustomerSessionCurrencyId()

Code Flow

Step 1: Storefront Initialization

File: ecommercen/core/Adv_front_controller.php — constructor (line 96-116)

On every storefront page load the front controller:

  1. Loads the default currency from registry: getDefaultCurrency() resolves ESHOP.DEFAULT_CURRENCY to a currency entity.
  2. Fetches all active currencies and indexes them by ID: getResultObjectAsIndexedArray($this->currencies_model->getActiveCurrencies(), 'id').
  3. Checks for a ?curr= query parameter via currencyFromQueryString() — if present and the code matches an active currency, stores its ID in session.
  4. During initializeCustomerSessionData(), if no currencyId exists in session, defaults to getDefaultCurrencyId() (the registry value).
  5. Sets $this->currentCurrency from the active currencies array using the session currency ID.

Step 2: Currency Data Passed to Vue

File: ecommercen/core/Adv_front_controller.phprenderVueJson() (line 756-758)

The current currency object is injected into the Vue initial state as selectedCurrency:

php
if (!isset($this->render['jsonState']['selectedCurrency'])) {
    $this->render['jsonState']['selectedCurrency'] = $this->activeCurrencies[$this->cSData['currencyId']];
}

The activeCurrencies array is also passed to the Blade/PHP render context (line 608) for server-side templates.

Step 3: Vue Reactivity

File: assets/vue/data-components/InitialData.vue — mounts selectedCurrency into Vuex via setSelectedCurrency action.

Store files:

  • State: assets/vue/store/state.jsselectedCurrency: [] (initialized empty, hydrated from server)
  • Getter: assets/vue/store/getters.jsgetSelectedCurrency: state => state.selectedCurrency
  • Utility getter: getPriceUsingCurrency: (state) => (price) => (price * state.selectedCurrency.rate).toFixed(2)
  • Mutation: assets/vue/store/mutations.jssetSelectedCurrency(state, selectedCurrency)
  • Action: assets/vue/store/actions.jssetSelectedCurrency({ commit }, selectedCurrency)

Components read getSelectedCurrency and apply the rate at display time:

javascript
// AdvProductCard.vue
priceWithSign (priceString, priceSign) {
  return (parseFloat(priceString) * this.getSelectedCurrency.rate).toFixed(2) + priceSign
}
getSign () {
  return this.getSelectedCurrency.symbol
}

Step 4: Currency Switching (AJAX)

File: application/views/main/components/footer/footer_js.php (line 477-488)

When a customer clicks a .setCurrencyButton element, jQuery posts the currencyId to the URL produced by site_url('setCustomerSessionCurrencyId'):

javascript
$(document).on('click', '.setCurrencyButton', function (event) {
    var currencyId = event.target.getAttribute("data-id")
    $.ajax({
        type: "POST",
        url: site_url('setCustomerSessionCurrencyId'),
        dataType: "json",
        data: {'currencyId': currencyId}
    }).done(function (data) {
        if (data.result == 'success') {
            location.reload()
        } else {
            alert('<?= t(\'generic.error\'); ?>')
        }
    }).fail(function () {
        alert('<?= t(\'generic.error\'); ?>')
    })
})

File: application/controllers/Webrun.phpsetCustomerSessionCurrencyId() (line 272-293)

The endpoint validates:

  1. Request is AJAX (is_ajax_request())
  2. Posted currencyId is a numeric digit string (ctype_digit)
  3. Currency exists and is active (currencies_model->getRecord(['id' => ..., 'active' => true]))

On success, sets currencyId in CI session via set_userdata() and returns {"result": "success"}. The page reloads and the front controller picks up the new session currency.

Step 5: Currency in Feed Generation

File: ecommercen/feeds/core/AdvXml.php — constructor (line 27-29)

Feed XML controllers accept ?curr=CODE to generate prices in a specific currency:

php
$currencyCode = $this->input->get('curr');
$this->activeCurrencies = getResultObjectAsIndexedArray($this->currencies_model->getActiveCurrencies(), 'code');
$this->currency = $this->activeCurrencies[$currencyCode] ?? getDefaultCurrency();

Product URLs in multi-currency feeds include ?curr=CODE via siteUrlWithCurrency() so customers land on the storefront with that currency pre-selected.

Step 6: Currency at Order Placement

File: src/Domains/Checkout/PlaceOrderService.php (resolveOrderCurrency())

When an order is placed via the modern REST checkout, PlaceOrderService resolves the cart's currency from the database via the private resolveOrderCurrency(?string $cartCurrencyCode) method:

  1. Calls CurrencyRepository::matchOne(new Filter('code', $cartCurrencyCode)) to look up the currency matching the cart's stored currency_code.
  2. If the cart code is absent or unmatched, falls back to matchOne(new Filter('active', 1)) — the first active currency.
  3. Throws RuntimeException if no active currency is found at all (misconfigured shop).

The resolved CurrencyEntity populates all three currency columns on the order row: order_currency (ISO code), currency_id (DB primary key), and currency_rate (exchange rate snapshot). This ensures the order record accurately reflects the rate in effect at placement time, not a hardcoded fallback.

Frontend Components

javascript
displayPrice = basePrice * selectedCurrency.rate
displaySymbol = selectedCurrency.symbol

Components that apply selectedCurrency.rate at render time: AdvCartProductRow.vue, CheckoutPage.vue, AdvProductCard.vue, AdvCartProductList.vue, AdvCartProductBundleDisplay.vue, CheckoutGiftPackaging.vue, AfterAddToCartModal.vue, GiftCardPage.vue, AdvOrderTransportersExternal.vue, AdvOrderTransportersMultiple.vue

The wishlist page (assets/main/js/components/wishlist-actions.js) reads selectedCurrency from window.advAppContext.vueData.initialData.selectedCurrency for GA4 tracking events.


Data Model

currencies

ColumnTypeDescription
idint (PK, AI)Currency identifier
codevarcharISO 4217 code (e.g., EUR, USD, GBP)
numvarcharISO 4217 numeric code (e.g., 978 for EUR)
symbolvarcharDisplay symbol (e.g., $)
ratedecimalExchange rate relative to base currency (base = 1.0)
activetinyintWhether currency is available on storefront

shop_order (currency columns)

ColumnTypeDescription
order_currencyvarcharISO 4217 code at time of order (e.g., EUR)
currency_idintFK to currencies.id at order time
currency_ratedecimalExchange rate snapshot at order time

Domain Layer

ComponentPath
Servicesrc/Domains/Currency/Currency/Service.php
WriteServicesrc/Domains/Currency/Currency/WriteService.php
Validatorsrc/Domains/Currency/Currency/Validator.php
WriteDatasrc/Domains/Currency/Currency/WriteData.php
ListRequestsrc/Domains/Currency/Currency/ListRequest.php
Entitysrc/Domains/Currency/Currency/Repository/Entity.php
Repositorysrc/Domains/Currency/Currency/Repository/Repository.php
RepositoryConfiguratorsrc/Domains/Currency/Currency/Repository/RepositoryConfigurator.php
WriteRepositorysrc/Domains/Currency/Currency/Repository/WriteRepository.php
REST Controllersrc/Rest/Currency/Controllers/Currency.php
Resourcesrc/Rest/Currency/Resources/Currency/Resource.php
Collectionsrc/Rest/Currency/Resources/Currency/Collection.php
Domain DIsrc/Domains/Currency/container.php
REST DIsrc/Rest/Currency/container.php

Legacy Model

ModelPathKey Methods
Adv_currencies_modelecommercen/eshop/models/Adv_currencies_model.phpgetActiveCurrencies(), getRecords(), getRecord(), add(), edit()

Helper Functions

All defined in ecommercen/helpers/shopmodule_helper.php:

FunctionPurpose
getDefaultCurrency()Returns the currency object for ESHOP.DEFAULT_CURRENCY (cached via PSCache)
getDefaultCurrencyId()Returns the registry value ESHOP.DEFAULT_CURRENCY
getSelectedCurrencyData()Returns currency object for the session's currencyId (falls back to default)
getCurrencyData($id)Returns currency object by ID (cached)
getActiveCurrencies()Returns all active currencies from the model
getActiveCurrenciesForDropdown()Returns [id => "CODE (symbol)"] array for HTML selects
applyCurrencyRate($amount, $rate)formatNumber($amount * $rate)
applyCurrency($amount, $currency)formatNumber($amount * $currency->rate) . $currency->symbol

Configuration

Registry Keys

GroupKeyPurposeSet In
ESHOPDEFAULT_CURRENCYID of the base currency (rate=1.0). Set during initial seed.Adv_settings.php > Other Settings

The default currency is selected from the active currencies dropdown in the admin settings panel (ecommercen/settings/controllers/Adv_settings.php, line 1832/1916).

Admin Menu

Currencies admin is registered in application/config/admin_menu.php under the SETTINGS group:

php
[
    'route' => 'currencies_admin',
    'icon' => t('admin.menu.currencies.icon'),
    'label' => t('admin.menu.currencies.label'),
    'roles' => [AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN],
    'group' => 'SETTINGS'
]

REST Policies

Defined in application/config/rest_policies.php:

php
Currency::class => [
    'defaults' => ['auth' => 'backend'],
    'methods'  => [
        'index' => ['auth' => 'guest'],
        'show'  => ['auth' => 'guest'],
        'item'  => ['auth' => 'guest'],
    ],
],

Read endpoints (index, show, item) are public (guest). Write endpoints (store, update, destroy) require backend JWT authentication.

Admin Route

application/config/routes.php:666-669

php
$route['currencies_admin'] = 'eshop/currencies_admin/index';
$route['currencies_admin/(.+)'] = 'eshop/currencies_admin/$1';
$route['(\w{2})/currencies_admin'] = 'eshop/currencies_admin/index';
$route['(\w{2})/currencies_admin/(.+)'] = 'eshop/currencies_admin/$2';

Client Extension Points

Admin Controller Override

The admin CRUD controller uses the standard thin-wrapper pattern:

File: application/modules/eshop/controllers/Currencies_admin.php

php
class Currencies_admin extends Adv_currencies_admin
{
    //
}

Client repos can override this file at application/modules/eshop/controllers/Currencies_admin.php to add custom validation, fields, or behavior (e.g., restricting which currencies can be created).

DI Container Overrides (Client Repos)

Override Domain layer services via custom/Domains/container.php:

php
// Override the RepositoryConfigurator to add custom relations
$services->set(\Custom\Domains\Currency\Currency\Repository\RepositoryConfigurator::class);
$services->alias(
    \Advisable\Domains\Currency\Currency\Repository\RepositoryConfigurator::class,
    \Custom\Domains\Currency\Currency\Repository\RepositoryConfigurator::class
);

// Override WriteService to add custom validation logic
$services->set(\Custom\Domains\Currency\Currency\WriteService::class);
$services->alias(
    \Advisable\Domains\Currency\Currency\WriteService::class,
    \Custom\Domains\Currency\Currency\WriteService::class
);

Model Override

ModelOverride In
Adv_currencies_modelapplication/modules/eshop/models/Adv_currencies_model.php

Storefront Currency Switcher

The jQuery handler in footer_js.php targets .setCurrencyButton elements. Client themes can customize the switcher UI by placing currency buttons with data-id attributes in their template. The activeCurrencies array is available in the PHP render context.


Business Rules

Default Currency Protection

The default currency (identified by ESHOP.DEFAULT_CURRENCY registry value) cannot be deactivated. In Adv_currencies_admin::edit() (line 81-83), if the edited currency is the default, the active flag is forced to true:

php
if ($this->defaultCurrency->id == $id) {
    $currency['active'] = true;
}

Rate Application Formula

All prices in the database are stored in the base currency. Conversion is display-only:

displayPrice = basePrice * selectedCurrency.rate
  • PHP helper: applyCurrencyRate(float $amount, float $rate) returns formatNumber($amount * $rate)
  • PHP helper with symbol: applyCurrency(float $amount, object $currency) returns formatted price + symbol
  • Vue getter: getPriceUsingCurrency(price) returns (price * state.selectedCurrency.rate).toFixed(2)

The base currency always has rate = 1.0, so multiplication is a no-op for the default.

Active Toggle

Only currencies with active = 1 are:

  • Available on the storefront currency switcher (getActiveCurrencies())
  • Selectable via the session switch endpoint (validated in Webrun::setCustomerSessionCurrencyId())
  • Available in feed generation (AdvXml::__construct())

Deactivating a currency does not affect existing orders that were placed with that currency — the rate is snapshotted at order time.

Session Persistence

  • Currency selection is stored in CI session as currencyId (the integer primary key from the currencies table).
  • If a customer's session has no currencyId, the front controller defaults to getDefaultCurrencyId().
  • The ?curr=CODE query parameter overrides the session currency (used by feed URLs and direct links).

Validation

The Validator class (src/Domains/Currency/Currency/Validator.php) defines validateForCreate() and validateForUpdate() methods, but currently contains no specific rules — validation is open. Required fields for creation are enforced by WriteData: code, num, symbol, rate.


Known Issues & Security Gaps

  1. Order currency snapshot — RESOLVED (fix #169, 2026-04-24). PlaceOrderService::resolveOrderCurrency() now looks up the cart's currency_code against the currencies table at order-placement time and snapshots currency_id, currency_rate, and order_currency on the order row. Falls back to the first active currency when the code can't be matched; throws RuntimeException if no active currency is configured. Citation: src/Domains/Checkout/PlaceOrderService.php (see resolveOrderCurrency() and the order-data block).

  2. Currency WriteService Validator contains no specific validation rules — all writes are unchecked at the domain validation layer. Field presence is enforced only by WriteData constructor typing. Any constraints beyond required-field presence (e.g., valid ISO 4217 code format, positive rate, unique code) are absent. Citation: src/Domains/Currency/Currency/Validator.php.


Tests

FileTypeCoverage
tests/Unit/Domains/Currency/Currency/ServiceTest.phpUnitService layer
tests/Integration/Domains/Currency/Currency/ServiceTest.phpIntegrationService layer (DB)
tests/Integration/Domains/Currency/Currency/RepositoryTest.phpIntegrationRepository layer (DB)
tests/Unit/Rest/Currency/Resources/Currency/ResourceTest.phpUnitREST Resource transformer
tests/Unit/Rest/Currency/Resources/Currency/CollectionTest.phpUnitREST Collection transformer

No dedicated tests cover the currency-switching AJAX flow (Webrun::setCustomerSessionCurrencyId()), the front-controller initialization path, or feed currency resolution.