Skip to content

Let each store owner set their own primary currency - #839

Open
KrzysztofPajak wants to merge 1 commit into
developfrom
feature/store-primary-currency-setting
Open

Let each store owner set their own primary currency#839
KrzysztofPajak wants to merge 1 commit into
developfrom
feature/store-primary-currency-setting

Conversation

@KrzysztofPajak

Copy link
Copy Markdown
Member

Type: feature

Replaces the closed #837, which put the override on the Store entity and had CurrencyService read it from the ambient store context. This version keeps the service free of request context.

Issue

Product prices are stored in the primary store currency, and that currency was a single global field on CurrencySettings. In a multi-store installation where each store owner runs their own catalog, every store was forced to hold its prices in the same base currency. Store.DefaultCurrencyId only selects which currency a visitor sees, not the one prices are stored in, so there was no way to express "this store's prices are in PLN".

Solution

The primary currency moves out of CurrencySettings into its own PrimaryCurrencySettings, which is store-scopable.

This split is the whole design. SettingService.LoadSetting falls back to the global value per object, not per field, and the three remaining fields of CurrencySettingsPrimaryExchangeRateCurrencyId, ActiveExchangeRateProviderSystemName, AutoUpdateEnabled — are genuinely system-wide. Had the primary currency stayed there, a store-scoped override would have frozen those three for that store too, and a later change to the global exchange rate currency would never have reached it: a silent rate failure.

With the field on its own class, the existing settings mechanism does the scoping. AddSettings already resolves every ISettings for the current store with a fallback to the global value, so CurrencyService reads its injected PrimaryCurrencySettings and needs no store context of its own — the ambient read stays in the composition root, where it already happens for 40-odd other settings classes. Every price conversion routes through GetPrimaryStoreCurrency(), so pricing, checkout and the storefront needed no changes.

Call sites that resolved the currency by hand — GetCurrencyById(currencySettings.PrimaryStoreCurrencyId) in the product, order, checkout-attribute and settings screens plus the Shipping.ByWeight plugin — now call ICurrencyService.GetPrimaryStoreCurrency(). Same lookup, one dependency less each.

Store owners set the currency from Store manager → Configuration → Currencies. Prices are not recalculated; the stored amounts are reinterpreted in the new currency. Because a product shared with another store would silently change meaning, IProductService.CountSharedProducts counts products visible both here and elsewhere, and the owner has to confirm when there are any.

Deleting, unpublishing or unmapping a currency now inspects the currency resolved for every store rather than only the global one — previously a store's primary currency could be deleted or unmapped from another panel.

Breaking changes

Three public contracts changed. All are source-breaking for anyone compiling against them; none changes behaviour for existing installations.

  • CurrencySettings.PrimaryStoreCurrencyId is removed. It now lives on PrimaryCurrencySettings.CurrencyId.
  • CurrencyService takes PrimaryCurrencySettings as a constructor parameter, inserted before IMediator.
  • IProductService gains Task<int> CountSharedProducts(string storeId). Any external implementation of the interface must add it.

MigrationPrimaryCurrencySetting (2.4) copies the stored value across for existing installations, reading the old element straight out of the setting metadata since the property no longer exists on the class. It is idempotent and covers store-scoped CurrencySettings rows as well as the global one. New localization resources ship in Upgrade/en_240.xml, picked up by the existing 2.4 resource migration.

Testing

  1. Upgrade an existing 2.3 installation and confirm prices still render — the migration must have written a primarycurrencysettings document holding the currency that was in currencysettings.
  2. Run with at least two stores and a store manager account whose StaffStoreId is one of them.
  3. Sign in to Store manager and open Configuration → Currencies. The star badge marks the currency inherited from the global setting.
  4. Assign a second currency to the store (Assign store), then press Set as primary currency on it.
    • If any product in the store is also available in another store (Limited to stores unchecked, or mapped to more than one store), a confirmation dialog reports how many. Cancel it — nothing changes.
    • Confirm it — the star badge moves to the new currency.
  5. Open the storefront for that store: the price number is unchanged but is now interpreted in the new currency, and conversion to other currencies follows its rate.
  6. Open the storefront for the other store and confirm its prices are untouched.
  7. In the main admin panel, open Configuration → Currencies and change the primary exchange rate currency. Confirm the store from step 4 picks it up — this is what the settings split exists to guarantee.
  8. Back in Store manager, try Unassign store on the new primary currency — it is rejected.
  9. In the main admin panel, try to delete or unpublish that currency — both are rejected because a store uses it as primary. Editing its store mapping to exclude that store is rejected too.

Automated

Full solution builds. All 22 test projects pass (~3260 tests, 0 failures), re-run after rebasing onto develop. New coverage: SettingService.LoadSetting store fallback (4 tests — the mechanism this feature now rides on, previously untested), the 2.4 migration including idempotency (5), CountSharedProducts (4), the store-owner controller (7), and the extended currency validations (4).

🤖 Generated with Claude Code

Product prices are stored in the primary store currency, which was a
single global setting. A store owner running their own catalog had no
way to hold prices in their own currency.

The primary currency moves out of CurrencySettings into its own
PrimaryCurrencySettings. Settings fall back to the global value per
object rather than per field, and the three remaining fields of
CurrencySettings - the exchange rate currency, the rate provider and
the auto update flag - are system-wide. Leaving the primary currency
there would mean a store-scoped override also froze those three for
that store, so a later change to the global exchange rate currency
would never reach it.

Splitting it lets the existing settings mechanism do the scoping:
AddSettings already resolves every ISettings for the current store with
a fallback to the global value, so CurrencyService reads its injected
PrimaryCurrencySettings and needs no store context of its own.

Callers that resolved the currency by hand from the setting now ask
ICurrencyService.GetPrimaryStoreCurrency() instead, which is the same
lookup and leaves them with one dependency less.

Prices are not recalculated when the currency changes - the stored
amounts are simply reinterpreted. Because a product shared with another
store would silently change meaning, the store owner has to confirm the
change when CountSharedProducts finds any.

Deleting, unpublishing or unmapping a currency now checks the currency
resolved for every store, not just the global one, so a store cannot be
left with a currency it no longer has access to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrGY1NnCzdkn7wUxbyHzsX
Copilot AI lite review requested due to automatic review settings September 13, 2026 09:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.


Assert.IsNotNull(result);
Assert.IsFalse(Success(result));
Assert.IsTrue((bool)result.Value.GetType().GetProperty("requiresConfirmation")!.GetValue(result.Value)!);
var result = await _controller.ListData() as JsonResult;

Assert.IsNotNull(result);
var items = ((DataSourceResult)result.Value!).Data.Cast<StoreCurrencyModel>().ToList();
Comment on lines +68 to +71
catch (Exception ex)
{
logService.LogError(ex, "UpgradeProcess - MigrationPrimaryCurrencySetting (2.4)");
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants