From 100ec50d165c89a83a0989a8e197ef2dcce04669 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 13 Sep 2026 11:44:10 +0200 Subject: [PATCH] Let each store owner set their own primary currency 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 Claude-Session: https://claude.ai/code/session_01GrGY1NnCzdkn7wUxbyHzsX --- .../Services/Products/ProductService.cs | 14 ++ .../Services/Directory/CurrencyService.cs | 9 +- .../Catalog/Products/IProductService.cs | 8 + .../Directory/CurrencySettings.cs | 1 - .../Directory/PrimaryCurrencySettings.cs | 18 ++ .../Services/InstallDataSettings.cs | 5 +- .../2.4/MigrationPrimaryCurrencySetting.cs | 86 ++++++++++ .../Controllers/ShippingByWeightController.cs | 4 +- .../Controllers/ShippingByWeightController.cs | 12 +- .../Services/Prices/PriceFormatterTests.cs | 1 + .../Services/Prices/PriceServiceTests.cs | 5 +- .../Services/Products/ProductServiceTests.cs | 55 ++++++ .../Configuration/SettingServiceTests.cs | 62 +++++++ .../Directory/CurrencyServiceTests.cs | 39 ++++- .../MigrationPrimaryCurrencySettingTests.cs | 115 +++++++++++++ .../Services/CurrencyViewModelServiceTests.cs | 89 +++++++++- .../Controllers/CurrencyControllerTests.cs | 159 +++++++++++++++++- .../Controllers/CurrencyController.cs | 4 +- .../Controllers/SettingController.cs | 3 +- .../Controllers/SystemController.cs | 2 +- .../BaseCheckoutAttributeController.cs | 6 +- .../CheckoutAttributeViewModelService.cs | 4 +- .../Services/CurrencyViewModelService.cs | 39 ++++- .../Services/OrderViewModelService.cs | 4 +- .../Services/ProductViewModelService.cs | 10 +- .../Areas/Store/Views/Currency/List.cshtml | 42 +++++ .../Controllers/CurrencyController.cs | 69 +++++++- .../Controllers/SettingController.cs | 8 +- .../App_Data/Resources/DefaultLanguage.xml | Bin 1544402 -> 1546534 bytes .../App_Data/Resources/Upgrade/en_240.xml | 15 ++ 30 files changed, 833 insertions(+), 55 deletions(-) create mode 100644 src/Core/Grand.Domain/Directory/PrimaryCurrencySettings.cs create mode 100644 src/Modules/Grand.Module.Migration/Migrations/2.4/MigrationPrimaryCurrencySetting.cs create mode 100644 src/Tests/Grand.Modules.Tests/Services/Migrations/MigrationPrimaryCurrencySettingTests.cs diff --git a/src/Business/Grand.Business.Catalog/Services/Products/ProductService.cs b/src/Business/Grand.Business.Catalog/Services/Products/ProductService.cs index 71b5a49915..b8266abd30 100644 --- a/src/Business/Grand.Business.Catalog/Services/Products/ProductService.cs +++ b/src/Business/Grand.Business.Catalog/Services/Products/ProductService.cs @@ -156,6 +156,20 @@ where c.AppliedDiscounts.Any(x => x == discountId) return await _productRepository.PagedAsync(query, pageIndex, pageSize); } + /// + /// Counts products visible in the given store that are also visible in at least one other store + /// + /// Store identifier + /// Number of shared products + public virtual async Task CountSharedProducts(string storeId) + { + var query = from p in _productRepository.Table + where !p.LimitedToStores || (p.Stores.Contains(storeId) && p.Stores.Count > 1) + select p; + + return await _productRepository.CountAsync(query); + } + /// /// Inserts a product diff --git a/src/Business/Grand.Business.Common/Services/Directory/CurrencyService.cs b/src/Business/Grand.Business.Common/Services/Directory/CurrencyService.cs index 5f37c1c087..6991ac56c8 100644 --- a/src/Business/Grand.Business.Common/Services/Directory/CurrencyService.cs +++ b/src/Business/Grand.Business.Common/Services/Directory/CurrencyService.cs @@ -24,17 +24,20 @@ public class CurrencyService : ICurrencyService /// Currency repository /// ACL service /// Currency settings + /// Primary currency settings, store-scoped /// Mediator public CurrencyService(ICacheBase cacheBase, IRepository currencyRepository, IAclService aclService, CurrencySettings currencySettings, + PrimaryCurrencySettings primaryCurrencySettings, IMediator mediator) { _cacheBase = cacheBase; _currencyRepository = currencyRepository; _aclService = aclService; _currencySettings = currencySettings; + _primaryCurrencySettings = primaryCurrencySettings; _mediator = mediator; } @@ -47,6 +50,7 @@ public CurrencyService(ICacheBase cacheBase, private readonly ICacheBase _cacheBase; private readonly IMediator _mediator; private readonly CurrencySettings _currencySettings; + private readonly PrimaryCurrencySettings _primaryCurrencySettings; private Currency _primaryCurrency; private Currency _primaryExchangeRateCurrency; @@ -66,12 +70,13 @@ public virtual Task GetCurrencyById(string currencyId) } /// - /// Gets primary store currency + /// Gets primary store currency - the currency prices are stored in. PrimaryCurrencySettings is resolved for + /// the current store, so a store overriding it gets its own currency and every other store the global one. /// /// Currency public async Task GetPrimaryStoreCurrency() { - return _primaryCurrency ??= await GetCurrencyById(_currencySettings.PrimaryStoreCurrencyId); + return _primaryCurrency ??= await GetCurrencyById(_primaryCurrencySettings.CurrencyId); } /// diff --git a/src/Business/Grand.Business.Core/Interfaces/Catalog/Products/IProductService.cs b/src/Business/Grand.Business.Core/Interfaces/Catalog/Products/IProductService.cs index d76f1c8f14..0526c2905c 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Catalog/Products/IProductService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Catalog/Products/IProductService.cs @@ -57,6 +57,14 @@ public interface IProductService /// Products Task> GetProductsByDiscount(string discountId, int pageIndex = 0, int pageSize = int.MaxValue); + /// + /// Counts products visible in the given store that are also visible in at least one other store, + /// either because they are not limited to stores at all or because they are mapped to more stores. + /// + /// Store identifier + /// Number of shared products + Task CountSharedProducts(string storeId); + /// /// Inserts a product /// diff --git a/src/Core/Grand.Domain/Directory/CurrencySettings.cs b/src/Core/Grand.Domain/Directory/CurrencySettings.cs index 1fb7a205b4..535b21aefd 100644 --- a/src/Core/Grand.Domain/Directory/CurrencySettings.cs +++ b/src/Core/Grand.Domain/Directory/CurrencySettings.cs @@ -4,7 +4,6 @@ namespace Grand.Domain.Directory; public class CurrencySettings : ISettings { - public string PrimaryStoreCurrencyId { get; set; } public string PrimaryExchangeRateCurrencyId { get; set; } public string ActiveExchangeRateProviderSystemName { get; set; } public bool AutoUpdateEnabled { get; set; } diff --git a/src/Core/Grand.Domain/Directory/PrimaryCurrencySettings.cs b/src/Core/Grand.Domain/Directory/PrimaryCurrencySettings.cs new file mode 100644 index 0000000000..5b1cb73fdd --- /dev/null +++ b/src/Core/Grand.Domain/Directory/PrimaryCurrencySettings.cs @@ -0,0 +1,18 @@ +using Grand.Domain.Configuration; + +namespace Grand.Domain.Directory; + +/// +/// The currency a store's prices are stored in. +/// Kept apart from so a single store can override it: settings fall back to the +/// global value per object rather than per field, so a store-scoped override placed on that class would also +/// freeze its system-wide fields - the exchange rate currency, the rate provider and the auto update flag - for +/// the same store. +/// +public class PrimaryCurrencySettings : ISettings +{ + /// + /// Gets or sets the identifier of the currency prices are stored in + /// + public string CurrencyId { get; set; } +} diff --git a/src/Modules/Grand.Module.Installer/Services/InstallDataSettings.cs b/src/Modules/Grand.Module.Installer/Services/InstallDataSettings.cs index 615e8da8ec..cae43c4dc6 100644 --- a/src/Modules/Grand.Module.Installer/Services/InstallDataSettings.cs +++ b/src/Modules/Grand.Module.Installer/Services/InstallDataSettings.cs @@ -344,12 +344,15 @@ await _settingRepository.SaveSetting(new LoyaltyPointsSettings { }); await _settingRepository.SaveSetting(new CurrencySettings { - PrimaryStoreCurrencyId = _currencyRepository.Table.Single(c => c.CurrencyCode == "USD").Id, PrimaryExchangeRateCurrencyId = _currencyRepository.Table.Single(c => c.CurrencyCode == "USD").Id, ActiveExchangeRateProviderSystemName = "CurrencyExchange.MoneyConverter", AutoUpdateEnabled = false }); + await _settingRepository.SaveSetting(new PrimaryCurrencySettings { + CurrencyId = _currencyRepository.Table.Single(c => c.CurrencyCode == "USD").Id + }); + await _settingRepository.SaveSetting(new MeasureSettings { BaseDimensionId = _measureDimensionRepository.Table.Single(m => m.SystemKeyword == "centimetres").Id, BaseWeightId = _measureWeightRepository.Table.Single(m => m.SystemKeyword == "lb").Id diff --git a/src/Modules/Grand.Module.Migration/Migrations/2.4/MigrationPrimaryCurrencySetting.cs b/src/Modules/Grand.Module.Migration/Migrations/2.4/MigrationPrimaryCurrencySetting.cs new file mode 100644 index 0000000000..a64f20cd19 --- /dev/null +++ b/src/Modules/Grand.Module.Migration/Migrations/2.4/MigrationPrimaryCurrencySetting.cs @@ -0,0 +1,86 @@ +using Grand.Data; +using Grand.Domain.Configuration; +using Grand.Domain.Directory; +using Grand.Infrastructure.Migrations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace Grand.Module.Migration.Migrations._2._4; + +/// +/// Moves the primary store currency out of into its own +/// , so a single store can override it. +/// Settings fall back to the global value per object rather than per field, so leaving the primary currency on +/// CurrencySettings would mean a store-scoped override also froze that class's system-wide fields - the +/// exchange rate currency, the rate provider and the auto update flag - for the same store. +/// The old PrimaryStoreCurrencyId element is read straight out of the stored metadata, because the +/// property no longer exists on the class. It is left in the document: the global IgnoreExtraElementsConvention +/// skips it on read and it disappears the next time the settings are saved. +/// +public class MigrationPrimaryCurrencySetting : IMigration +{ + public int Priority => 1; + public DbVersion Version => new(2, 4); + public Guid Identity => new("2F91B4C7-6E38-4A05-9D1B-C38E7A426D5F"); + public string Name => "Move the primary store currency into PrimaryCurrencySettings 2.4"; + + /// + /// Upgrade process + /// + /// + /// + public bool UpgradeProcess(IServiceProvider serviceProvider) + { + var repository = serviceProvider.GetRequiredService>(); + var logService = serviceProvider.GetRequiredService>(); + + try + { + var sourceName = nameof(CurrencySettings).ToLowerInvariant(); + var targetName = nameof(PrimaryCurrencySettings).ToLowerInvariant(); + + var currencySettings = repository.Table.Where(x => x.Name == sourceName).ToList(); + var primaryCurrencySettings = repository.Table.Where(x => x.Name == targetName).ToList(); + + foreach (var setting in currencySettings) + { + var currencyId = ReadPrimaryStoreCurrencyId(setting.Metadata); + if (string.IsNullOrEmpty(currencyId)) + { + logService.LogWarning( + "No primary store currency found in the currency settings of store {StoreId} - set it in the admin area, prices cannot be converted without it", + string.IsNullOrEmpty(setting.StoreId) ? "(all stores)" : setting.StoreId); + continue; + } + + //never overwrite a value that is already there - the migration must be safe to re-run + if (primaryCurrencySettings.Any(x => x.StoreId == setting.StoreId)) + continue; + + repository.Insert(new Setting { + Name = targetName, + StoreId = setting.StoreId, + Metadata = JsonSerializer.Serialize(new PrimaryCurrencySettings { CurrencyId = currencyId }) + }); + } + } + catch (Exception ex) + { + logService.LogError(ex, "UpgradeProcess - MigrationPrimaryCurrencySetting (2.4)"); + } + + return true; + } + + private static string ReadPrimaryStoreCurrencyId(string metadata) + { + if (string.IsNullOrEmpty(metadata)) + return null; + + using var document = JsonDocument.Parse(metadata); + return document.RootElement.TryGetProperty("PrimaryStoreCurrencyId", out var currencyId) + ? currencyId.GetString() + : null; + } +} diff --git a/src/Plugins/Shipping.ByWeight/Areas/Admin/Controllers/ShippingByWeightController.cs b/src/Plugins/Shipping.ByWeight/Areas/Admin/Controllers/ShippingByWeightController.cs index 35d5c4cbfe..7a920c5e81 100644 --- a/src/Plugins/Shipping.ByWeight/Areas/Admin/Controllers/ShippingByWeightController.cs +++ b/src/Plugins/Shipping.ByWeight/Areas/Admin/Controllers/ShippingByWeightController.cs @@ -176,7 +176,7 @@ public async Task AddPopup() { var model = new ShippingByWeightModel { PrimaryStoreCurrencyCode = - (await _currencyService.GetCurrencyById(_currencySettings.PrimaryStoreCurrencyId)).CurrencyCode, + (await _currencyService.GetPrimaryStoreCurrency()).CurrencyCode, BaseWeightIn = (await _measureService.GetMeasureWeightById(_measureSettings.BaseWeightId)).Name, To = 1000000 }; @@ -255,7 +255,7 @@ public async Task EditPopup(string id) RatePerWeightUnit = sbw.RatePerWeightUnit, LowerWeightLimit = sbw.LowerWeightLimit, PrimaryStoreCurrencyCode = - (await _currencyService.GetCurrencyById(_currencySettings.PrimaryStoreCurrencyId)).CurrencyCode, + (await _currencyService.GetPrimaryStoreCurrency()).CurrencyCode, BaseWeightIn = (await _measureService.GetMeasureWeightById(_measureSettings.BaseWeightId)).Name }; diff --git a/src/Plugins/Shipping.ByWeight/Areas/Store/Controllers/ShippingByWeightController.cs b/src/Plugins/Shipping.ByWeight/Areas/Store/Controllers/ShippingByWeightController.cs index c48f82d2dd..3caab70995 100644 --- a/src/Plugins/Shipping.ByWeight/Areas/Store/Controllers/ShippingByWeightController.cs +++ b/src/Plugins/Shipping.ByWeight/Areas/Store/Controllers/ShippingByWeightController.cs @@ -1,4 +1,4 @@ -using Grand.Business.Core.Interfaces.Catalog.Directory; +using Grand.Business.Core.Interfaces.Catalog.Directory; using Grand.Business.Core.Interfaces.Checkout.Shipping; using Grand.Business.Core.Interfaces.Common.Configuration; using Grand.Business.Core.Interfaces.Common.Directory; @@ -193,9 +193,8 @@ public async Task AddPopup() var model = new ShippingByWeightModel { //the owner cannot create records for another store StoreId = CurrentStoreId, - //CurrencySettings is resolved per current store, so this already honours a store override - PrimaryStoreCurrencyCode = - (await _currencyService.GetCurrencyById(_currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode, + //PrimaryCurrencySettings is resolved per current store, so this already honours a store override + PrimaryStoreCurrencyCode = (await _currencyService.GetPrimaryStoreCurrency())?.CurrencyCode, BaseWeightIn = (await _measureService.GetMeasureWeightById(_measureSettings.BaseWeightId)).Name, To = 1000000 }; @@ -258,9 +257,8 @@ public async Task EditPopup(string id) PercentageRateOfSubtotal = sbw.PercentageRateOfSubtotal, RatePerWeightUnit = sbw.RatePerWeightUnit, LowerWeightLimit = sbw.LowerWeightLimit, - //CurrencySettings is resolved per current store, so this already honours a store override - PrimaryStoreCurrencyCode = - (await _currencyService.GetCurrencyById(_currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode, + //PrimaryCurrencySettings is resolved per current store, so this already honours a store override + PrimaryStoreCurrencyCode = (await _currencyService.GetPrimaryStoreCurrency())?.CurrencyCode, BaseWeightIn = (await _measureService.GetMeasureWeightById(_measureSettings.BaseWeightId)).Name }; diff --git a/src/Tests/Grand.Business.Catalog.Tests/Services/Prices/PriceFormatterTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Services/Prices/PriceFormatterTests.cs index 3e4199f545..8cdfeef578 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Services/Prices/PriceFormatterTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Services/Prices/PriceFormatterTests.cs @@ -86,6 +86,7 @@ public void TestInitialize() _currencyRepo, _aclService, _currencySettings, + new PrimaryCurrencySettings(), null); _taxSettings = new TaxSettings(); diff --git a/src/Tests/Grand.Business.Catalog.Tests/Services/Prices/PriceServiceTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Services/Prices/PriceServiceTests.cs index 96953aa040..aaf8079068 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Services/Prices/PriceServiceTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Services/Prices/PriceServiceTests.cs @@ -66,8 +66,7 @@ public void TestInitialize() _eventPublisher = eventPublisher.Object; _currencySettings = new CurrencySettings { - PrimaryExchangeRateCurrencyId = "1", - PrimaryStoreCurrencyId = "1" + PrimaryExchangeRateCurrencyId = "1" }; _currency = new Currency { @@ -105,7 +104,7 @@ public void TestInitialize() _currencyService = new CurrencyService( cacheManager, _currencyRepository, _aclService, - _currencySettings, _eventPublisher); + _currencySettings, new PrimaryCurrencySettings { CurrencyId = "1" }, _eventPublisher); tempDiscountApplicationService = new Mock(); diff --git a/src/Tests/Grand.Business.Catalog.Tests/Services/Products/ProductServiceTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Services/Products/ProductServiceTests.cs index c134491da4..cdce7c5df2 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Services/Products/ProductServiceTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Services/Products/ProductServiceTests.cs @@ -862,4 +862,59 @@ public async Task InsertDiscountTest() Assert.IsNotNull(result); Assert.HasCount(1, result.AppliedDiscounts); } + + [TestMethod] + public async Task CountSharedProducts_ProductAvailableInEveryStore_IsCountedAsShared() + { + //Arrange + await _productRepository.InsertAsync(new Product { LimitedToStores = false }); + + //Act + var result = await _productService.CountSharedProducts("store-1"); + + //Assert + Assert.AreEqual(1, result); + } + + [TestMethod] + public async Task CountSharedProducts_ProductLimitedToThisStoreOnly_IsNotCountedAsShared() + { + //Arrange + await _productRepository.InsertAsync(new Product + { LimitedToStores = true, Stores = new List { "store-1" } }); + + //Act + var result = await _productService.CountSharedProducts("store-1"); + + //Assert + Assert.AreEqual(0, result); + } + + [TestMethod] + public async Task CountSharedProducts_ProductLimitedToThisStoreAndAnother_IsCountedAsShared() + { + //Arrange + await _productRepository.InsertAsync(new Product + { LimitedToStores = true, Stores = new List { "store-1", "store-2" } }); + + //Act + var result = await _productService.CountSharedProducts("store-1"); + + //Assert + Assert.AreEqual(1, result); + } + + [TestMethod] + public async Task CountSharedProducts_ProductLimitedToAnotherStoreOnly_IsNotCountedAsShared() + { + //Arrange + await _productRepository.InsertAsync(new Product + { LimitedToStores = true, Stores = new List { "store-2", "store-3" } }); + + //Act + var result = await _productService.CountSharedProducts("store-1"); + + //Assert + Assert.AreEqual(0, result); + } } \ No newline at end of file diff --git a/src/Tests/Grand.Business.Common.Tests/Services/Configuration/SettingServiceTests.cs b/src/Tests/Grand.Business.Common.Tests/Services/Configuration/SettingServiceTests.cs index 18499df3cc..f19671131a 100644 --- a/src/Tests/Grand.Business.Common.Tests/Services/Configuration/SettingServiceTests.cs +++ b/src/Tests/Grand.Business.Common.Tests/Services/Configuration/SettingServiceTests.cs @@ -1,9 +1,11 @@ using Grand.Business.Common.Services.Configuration; using Grand.Data; using Grand.Domain.Configuration; +using Grand.Domain.Directory; using Grand.Infrastructure.Caching; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; +using System.Text.Json; namespace Grand.Business.Common.Tests.Services.Configuration; @@ -63,4 +65,64 @@ public void DeleteSetting_NullArgument_TrhowException() { Assert.ThrowsExactlyAsync(async () => await _service.DeleteSetting(null)); } + + [TestMethod] + public void LoadSetting_StoreHasItsOwnValue_ReturnsIt() + { + GivenStoredSettings( + Stored(string.Empty, "global-currency"), + Stored("store-1", "store-currency")); + + var result = _service.LoadSetting(typeof(PrimaryCurrencySettings), "store-1"); + + Assert.AreEqual("store-currency", ((PrimaryCurrencySettings)result).CurrencyId); + } + + [TestMethod] + public void LoadSetting_StoreHasNoValue_FallsBackToTheGlobalOne() + { + GivenStoredSettings(Stored(string.Empty, "global-currency")); + + var result = _service.LoadSetting(typeof(PrimaryCurrencySettings), "store-1"); + + Assert.AreEqual("global-currency", ((PrimaryCurrencySettings)result).CurrencyId); + } + + [TestMethod] + public void LoadSetting_AnotherStoreHasAValue_DoesNotLeakItAcrossStores() + { + GivenStoredSettings( + Stored(string.Empty, "global-currency"), + Stored("store-1", "store-currency")); + + var result = _service.LoadSetting(typeof(PrimaryCurrencySettings), "store-2"); + + Assert.AreEqual("global-currency", ((PrimaryCurrencySettings)result).CurrencyId); + } + + [TestMethod] + public void LoadSetting_NothingStored_ReturnsDefaultInstance() + { + GivenStoredSettings(); + + var result = _service.LoadSetting(typeof(PrimaryCurrencySettings), "store-1"); + + Assert.IsNull(((PrimaryCurrencySettings)result).CurrencyId); + } + + private static Setting Stored(string storeId, string currencyId) + { + return new Setting { + Name = nameof(PrimaryCurrencySettings).ToLowerInvariant(), + StoreId = storeId, + Metadata = JsonSerializer.Serialize(new PrimaryCurrencySettings { CurrencyId = currencyId }) + }; + } + + private void GivenStoredSettings(params Setting[] settings) + { + _repositoryMock.Setup(x => x.Table).Returns(settings.AsQueryable()); + _cacheMock.Setup(c => c.Get(It.IsAny(), It.IsAny>())) + .Returns((string _, Func acquire) => acquire()); + } } \ No newline at end of file diff --git a/src/Tests/Grand.Business.Common.Tests/Services/Directory/CurrencyServiceTests.cs b/src/Tests/Grand.Business.Common.Tests/Services/Directory/CurrencyServiceTests.cs index 1681901bb0..feebe6898c 100644 --- a/src/Tests/Grand.Business.Common.Tests/Services/Directory/CurrencyServiceTests.cs +++ b/src/Tests/Grand.Business.Common.Tests/Services/Directory/CurrencyServiceTests.cs @@ -4,6 +4,8 @@ using Grand.Data; using Grand.Data.Mongo; using Grand.Domain.Directory; +using Grand.Domain.Stores; +using Grand.Infrastructure; using Grand.Infrastructure.Caching; using Grand.Infrastructure.Events; using Grand.SharedKernel; @@ -19,6 +21,7 @@ public class CurrencyServiceTests { private Mock _aclService; private Mock _cacheManager; + private PrimaryCurrencySettings _primaryCurrencySettings; private IRepository _currencyRepository; private ICurrencyService _currencyService; private CurrencySettings _currencySettings; @@ -74,6 +77,7 @@ public void TestInitialize() IMongoCollection.Insert(currencyRUR); tempCurrencyRepository.Setup(x => x.Table).Returns(IMongoCollection.Table); + tempCurrencyRepository.Setup(x => x.GetByIdAsync(It.IsAny())).ReturnsAsync((Currency)null); tempCurrencyRepository.Setup(x => x.GetByIdAsync(currencyUSD.Id)).ReturnsAsync(currencyUSD); tempCurrencyRepository.Setup(x => x.GetByIdAsync(currencyEUR.Id)).ReturnsAsync(currencyEUR); tempCurrencyRepository.Setup(x => x.GetByIdAsync(currencyRUR.Id)).ReturnsAsync(currencyRUR); @@ -90,14 +94,14 @@ public void TestInitialize() _serviceProvider = new Mock().Object; _currencySettings = new CurrencySettings { - PrimaryStoreCurrencyId = currencyUSD.Id, PrimaryExchangeRateCurrencyId = currencyEUR.Id }; + _primaryCurrencySettings = new PrimaryCurrencySettings { CurrencyId = currencyUSD.Id }; _currencyService = new CurrencyService( _cacheManager.Object, _currencyRepository, _aclService.Object, - _currencySettings, _eventPublisher); + _currencySettings, _primaryCurrencySettings, _eventPublisher); //tempDiscountServiceMock.Setup(x => x.GetAllDiscounts(DiscountType.AssignedToCategories, "", "", false)).ReturnsAsync(new List()); } @@ -160,13 +164,42 @@ public void ConvertToPrimaryExchangeRateCurrency_CannotLoadPrimaryExchange_Throw [TestMethod] public async Task GetPrimaryStoreCurrency_ReturnExpectedValue() { - _currencySettings.PrimaryStoreCurrencyId = currencyUSD.Id; + _primaryCurrencySettings.CurrencyId = currencyUSD.Id; _cacheManager.Setup(c => c.GetAsync(It.IsAny(), It.IsAny>>())) .Returns(Task.FromResult(currencyUSD)); var result = await _currencyService.GetPrimaryStoreCurrency(); Assert.AreEqual(result.Id, currencyUSD.Id); } + [TestMethod] + public async Task GetPrimaryStoreCurrency_ReadsTheCurrencyFromPrimaryCurrencySettings() + { + _primaryCurrencySettings.CurrencyId = currencyRUR.Id; + ResolveCurrencyByIdFromRepository(); + + var result = await _currencyService.GetPrimaryStoreCurrency(); + + Assert.AreEqual(currencyRUR.Id, result.Id); + } + + [TestMethod] + public async Task GetPrimaryExchangeRateCurrency_IsIndependentOfThePrimaryStoreCurrency() + { + _primaryCurrencySettings.CurrencyId = currencyRUR.Id; + _currencySettings.PrimaryExchangeRateCurrencyId = currencyEUR.Id; + ResolveCurrencyByIdFromRepository(); + + var result = await _currencyService.GetPrimaryExchangeRateCurrency(); + + Assert.AreEqual(currencyEUR.Id, result.Id); + } + + private void ResolveCurrencyByIdFromRepository() + { + _cacheManager.Setup(c => c.GetAsync(It.IsAny(), It.IsAny>>())) + .Returns((string _, Func> acquire) => acquire()); + } + [TestMethod] public async Task InsertCurrency_ValidArgument() { diff --git a/src/Tests/Grand.Modules.Tests/Services/Migrations/MigrationPrimaryCurrencySettingTests.cs b/src/Tests/Grand.Modules.Tests/Services/Migrations/MigrationPrimaryCurrencySettingTests.cs new file mode 100644 index 0000000000..0509454a66 --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Services/Migrations/MigrationPrimaryCurrencySettingTests.cs @@ -0,0 +1,115 @@ +using Grand.Data; +using Grand.Data.Tests.MongoDb; +using Grand.Domain.Configuration; +using Grand.Domain.Directory; +using Grand.Module.Migration.Migrations._2._4; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Text.Json; + +namespace Grand.Modules.Tests.Services.Migrations; + +[TestClass] +public class MigrationPrimaryCurrencySettingTests +{ + private const string CurrencySettingsName = "currencysettings"; + private const string PrimaryCurrencySettingsName = "primarycurrencysettings"; + + private MigrationPrimaryCurrencySetting _migration; + private IRepository _repository; + + [TestInitialize] + public void Setup() + { + _repository = new MongoDBRepositoryTest(); + _migration = new MigrationPrimaryCurrencySetting(); + + var services = new ServiceCollection(); + services.AddSingleton(_repository); + services.AddSingleton(Mock.Of>()); + _serviceProvider = services.BuildServiceProvider(); + } + + private IServiceProvider _serviceProvider; + + private void GivenCurrencySettings(string storeId, string primaryStoreCurrencyId) + { + _repository.Insert(new Setting { + Name = CurrencySettingsName, + StoreId = storeId, + Metadata = JsonSerializer.Serialize(new { + PrimaryStoreCurrencyId = primaryStoreCurrencyId, + PrimaryExchangeRateCurrencyId = "exchange-currency", + ActiveExchangeRateProviderSystemName = "CurrencyExchange.MoneyConverter", + AutoUpdateEnabled = false + }) + }); + } + + private string PrimaryCurrencyIdOf(string storeId) + { + var setting = _repository.Table + .FirstOrDefault(x => x.Name == PrimaryCurrencySettingsName && x.StoreId == storeId); + + return setting == null + ? null + : JsonSerializer.Deserialize(setting.Metadata)!.CurrencyId; + } + + [TestMethod] + public void UpgradeProcess_CopiesTheGlobalPrimaryCurrencyIntoItsOwnSetting() + { + GivenCurrencySettings(string.Empty, "currency-1"); + + var result = _migration.UpgradeProcess(_serviceProvider); + + Assert.IsTrue(result); + Assert.AreEqual("currency-1", PrimaryCurrencyIdOf(string.Empty)); + } + + [TestMethod] + public void UpgradeProcess_CopiesStoreScopedCurrencySettingsSeparately() + { + GivenCurrencySettings(string.Empty, "currency-1"); + GivenCurrencySettings("store-1", "currency-2"); + + _migration.UpgradeProcess(_serviceProvider); + + Assert.AreEqual("currency-1", PrimaryCurrencyIdOf(string.Empty)); + Assert.AreEqual("currency-2", PrimaryCurrencyIdOf("store-1")); + } + + [TestMethod] + public void UpgradeProcess_RunTwice_DoesNotDuplicateOrOverwrite() + { + GivenCurrencySettings(string.Empty, "currency-1"); + _migration.UpgradeProcess(_serviceProvider); + + _migration.UpgradeProcess(_serviceProvider); + + Assert.AreEqual(1, + _repository.Table.Count(x => x.Name == PrimaryCurrencySettingsName && x.StoreId == string.Empty)); + } + + [TestMethod] + public void UpgradeProcess_NoPrimaryCurrencyStored_WritesNothing() + { + GivenCurrencySettings(string.Empty, null); + + var result = _migration.UpgradeProcess(_serviceProvider); + + Assert.IsTrue(result); + Assert.IsNull(PrimaryCurrencyIdOf(string.Empty)); + } + + [TestMethod] + public void UpgradeProcess_NoCurrencySettingsAtAll_WritesNothing() + { + var result = _migration.UpgradeProcess(_serviceProvider); + + Assert.IsTrue(result); + Assert.IsEmpty(_repository.Table.Where(x => x.Name == PrimaryCurrencySettingsName).ToList()); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/CurrencyViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/CurrencyViewModelServiceTests.cs index e0504913a3..9a763def05 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/CurrencyViewModelServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/CurrencyViewModelServiceTests.cs @@ -29,8 +29,11 @@ public void Setup() { _currencyServiceMock = new Mock(); _storeServiceMock = new Mock(); + _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List()); _currencySettings = new CurrencySettings(); _settingServiceMock = new Mock(); + _settingServiceMock.Setup(s => s.LoadSetting(It.IsAny())) + .ReturnsAsync(new PrimaryCurrencySettings()); _translationServiceMock = new Mock(); _cacheBaseMock = new Mock(); @@ -43,6 +46,15 @@ public void Setup() _cacheBaseMock.Object); } + /// + /// What LoadSetting resolves for a given store - its own override, or the global value it falls back to + /// + private void SetPrimaryCurrencyOfStore(string storeId, string currencyId) + { + _settingServiceMock.Setup(s => s.LoadSetting(storeId)) + .ReturnsAsync(new PrimaryCurrencySettings { CurrencyId = currencyId }); + } + [TestMethod] public async Task MarkAsPrimaryExchangeRateCurrency_SavesSettingAndClearsCache() { @@ -54,12 +66,13 @@ public async Task MarkAsPrimaryExchangeRateCurrency_SavesSettingAndClearsCache() } [TestMethod] - public async Task MarkAsPrimaryStoreCurrency_SavesSettingAndClearsCache() + public async Task MarkAsPrimaryStoreCurrency_SavesTheGlobalPrimaryCurrencyAndClearsCache() { await _service.MarkAsPrimaryStoreCurrency("currency-1"); - Assert.AreEqual("currency-1", _currencySettings.PrimaryStoreCurrencyId); - _settingServiceMock.Verify(s => s.SaveSetting(_currencySettings, It.IsAny()), Times.Once); + _settingServiceMock.Verify( + s => s.SaveSetting(It.Is(p => p.CurrencyId == "currency-1"), string.Empty), + Times.Once); _cacheBaseMock.Verify(c => c.Clear(true), Times.Once); } @@ -161,7 +174,22 @@ public async Task ValidateCurrencyStoreMapping_UnpublishingLastCurrencyOfStore_C [TestMethod] public async Task ValidateCurrencyDelete_PrimaryStoreCurrency_CannotDelete() { - _currencySettings.PrimaryStoreCurrencyId = "currency-1"; + SetPrimaryCurrencyOfStore(string.Empty, "currency-1"); + _translationServiceMock.Setup(t => t.GetResource("Admin.Configuration.Currencies.CantDeletePrimary")) + .Returns("Cannot delete primary"); + + var (canDelete, message) = await _service.ValidateCurrencyDelete(new Currency { Id = "currency-1" }); + + Assert.IsFalse(canDelete); + Assert.AreEqual("Cannot delete primary", message); + } + + [TestMethod] + public async Task ValidateCurrencyDelete_PrimaryCurrencyOfAnotherStore_CannotDelete() + { + _storeServiceMock.Setup(s => s.GetAllStores()) + .ReturnsAsync(new List { new() { Id = "store-1" } }); + SetPrimaryCurrencyOfStore("store-1", "currency-1"); _translationServiceMock.Setup(t => t.GetResource("Admin.Configuration.Currencies.CantDeletePrimary")) .Returns("Cannot delete primary"); @@ -171,6 +199,59 @@ public async Task ValidateCurrencyDelete_PrimaryStoreCurrency_CannotDelete() Assert.AreEqual("Cannot delete primary", message); } + [TestMethod] + public async Task ValidateCurrencyUnpublish_PrimaryCurrencyOfAnotherStore_CannotProceed() + { + _currencyServiceMock.Setup(c => c.GetAllCurrencies(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { new() { Id = "currency-1" }, new() { Id = "currency-2" } }); + _storeServiceMock.Setup(s => s.GetAllStores()) + .ReturnsAsync(new List { new() { Id = "store-1" } }); + SetPrimaryCurrencyOfStore("store-1", "currency-1"); + _translationServiceMock.Setup(t => t.GetResource("Admin.Configuration.Currencies.CantUnpublishPrimary")) + .Returns("Cannot unpublish primary"); + + var (canProceed, message) = await _service.ValidateCurrencyUnpublish("currency-1", false); + + Assert.IsFalse(canProceed); + Assert.AreEqual("Cannot unpublish primary", message); + } + + [TestMethod] + public async Task ValidateCurrencyStoreMapping_TakesPrimaryCurrencyAwayFromItsStore_CannotProceed() + { + _currencyServiceMock.Setup(c => c.GetAllCurrencies(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { new() { Id = "currency-1" }, new() { Id = "currency-2" } }); + _storeServiceMock.Setup(s => s.GetAllStores()) + .ReturnsAsync(new List { + new() { Id = "store-1" }, + new() { Id = "store-2", Name = "Second" } + }); + SetPrimaryCurrencyOfStore("store-2", "currency-1"); + _translationServiceMock.Setup(t => t.GetResource("Admin.Configuration.Currencies.CantLimitPrimaryStores")) + .Returns("Store '{0}' uses this currency as primary"); + + var (canProceed, message) = await _service.ValidateCurrencyStoreMapping(new Currency { Id = "currency-1" }, + new CurrencyModel { Published = true, Stores = ["store-1"] }); + + Assert.IsFalse(canProceed); + Assert.AreEqual("Store 'Second' uses this currency as primary", message); + } + + [TestMethod] + public async Task ValidateCurrencyStoreMapping_KeepsPrimaryCurrencyOfItsStore_CanProceed() + { + _currencyServiceMock.Setup(c => c.GetAllCurrencies(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { new() { Id = "currency-1" }, new() { Id = "currency-2" } }); + _storeServiceMock.Setup(s => s.GetAllStores()) + .ReturnsAsync(new List { new() { Id = "store-1" }, new() { Id = "store-2" } }); + SetPrimaryCurrencyOfStore("store-2", "currency-1"); + + var (canProceed, _) = await _service.ValidateCurrencyStoreMapping(new Currency { Id = "currency-1" }, + new CurrencyModel { Published = true, Stores = ["store-1", "store-2"] }); + + Assert.IsTrue(canProceed); + } + [TestMethod] public async Task ValidateCurrencyDelete_PrimaryExchangeRateCurrency_CannotDelete() { diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/CurrencyControllerTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/CurrencyControllerTests.cs index 6850d38479..a7cb489ac9 100644 --- a/src/Tests/Grand.Web.Store.Tests/Controllers/CurrencyControllerTests.cs +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/CurrencyControllerTests.cs @@ -1,3 +1,5 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Configuration; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Stores; @@ -5,7 +7,9 @@ using Grand.Domain.Directory; using Grand.Domain.Stores; using Grand.Infrastructure; +using Grand.Web.Common.DataSource; using Grand.Web.Store.Controllers; +using Grand.Web.Store.Models; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ViewFeatures; @@ -22,18 +26,21 @@ public class CurrencyControllerTests private CurrencyController _controller; private Mock _currencyServiceMock; + private Mock _productServiceMock; private Mock _storeServiceMock; private Mock _translationServiceMock; - private CurrencySettings _currencySettings; + private Mock _settingServiceMock; [TestInitialize] public void Setup() { _currencyServiceMock = new Mock(); + _productServiceMock = new Mock(); _storeServiceMock = new Mock(); _translationServiceMock = new Mock(); _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns(x => x); - _currencySettings = new CurrencySettings(); + _settingServiceMock = new Mock(); + SetPrimaryCurrencyOfStore(null); var workContextMock = new Mock(); workContextMock.Setup(w => w.CurrentCustomer).Returns(new Customer { StaffStoreId = StoreId }); @@ -42,9 +49,10 @@ public void Setup() _controller = new CurrencyController( _currencyServiceMock.Object, - _currencySettings, + _settingServiceMock.Object, _translationServiceMock.Object, _storeServiceMock.Object, + _productServiceMock.Object, contextAccessorMock.Object); var httpContext = new DefaultHttpContext(); @@ -52,6 +60,15 @@ public void Setup() _controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); } + /// + /// What LoadSetting resolves for the staff store - its own override, or the global value it falls back to + /// + private void SetPrimaryCurrencyOfStore(string currencyId) + { + _settingServiceMock.Setup(s => s.LoadSetting(StoreId)) + .ReturnsAsync(new PrimaryCurrencySettings { CurrencyId = currencyId }); + } + private static Currency StoreCurrency(string id) { return new Currency { Id = id, Published = true, LimitedToStores = true, Stores = [StoreId] }; @@ -91,4 +108,140 @@ public async Task UnassignStore_AnotherCurrencyRemains_IsUnassigned() Assert.IsFalse(currency.Stores.Contains(StoreId)); _currencyServiceMock.Verify(c => c.UpdateCurrency(currency), Times.Once); } + + [TestMethod] + public async Task UnassignStore_PrimaryCurrencyOfThisStore_IsRejected() + { + var currency = StoreCurrency("currency-1"); + _currencyServiceMock.Setup(c => c.GetCurrencyById(currency.Id)).ReturnsAsync(currency); + _storeServiceMock.Setup(s => s.GetStoreById(StoreId)).ReturnsAsync(new DomainStore { Id = StoreId }); + SetPrimaryCurrencyOfStore(currency.Id); + _currencyServiceMock.Setup(c => c.GetAllCurrencies(It.IsAny(), StoreId)) + .ReturnsAsync(new List { currency, StoreCurrency("currency-2") }); + + var result = await _controller.UnassignStore(currency.Id) as JsonResult; + + Assert.IsNotNull(result); + Assert.AreEqual("Admin.Configuration.Currencies.CantUnassignPrimary", Message(result)); + Assert.IsTrue(currency.Stores.Contains(StoreId)); + _currencyServiceMock.Verify(c => c.UpdateCurrency(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SetPrimaryCurrency_SharedProductsExist_RequiresConfirmation() + { + var currency = StoreCurrency("currency-1"); + ArrangeSetPrimaryCurrency(currency, sharedProducts: 3); + + var result = await _controller.SetPrimaryCurrency(currency.Id, false) as JsonResult; + + Assert.IsNotNull(result); + Assert.IsFalse(Success(result)); + Assert.IsTrue((bool)result.Value.GetType().GetProperty("requiresConfirmation")!.GetValue(result.Value)!); + VerifyNothingSaved(); + } + + [TestMethod] + public async Task SetPrimaryCurrency_SharedProductsExistAndConfirmed_IsSavedForThisStoreOnly() + { + var currency = StoreCurrency("currency-1"); + ArrangeSetPrimaryCurrency(currency, sharedProducts: 3); + + var result = await _controller.SetPrimaryCurrency(currency.Id, true) as JsonResult; + + Assert.IsNotNull(result); + Assert.IsTrue(Success(result)); + VerifySavedForStore(currency.Id); + } + + [TestMethod] + public async Task SetPrimaryCurrency_NoSharedProducts_IsSavedWithoutConfirmation() + { + var currency = StoreCurrency("currency-1"); + ArrangeSetPrimaryCurrency(currency, sharedProducts: 0); + + var result = await _controller.SetPrimaryCurrency(currency.Id, false) as JsonResult; + + Assert.IsNotNull(result); + Assert.IsTrue(Success(result)); + VerifySavedForStore(currency.Id); + } + + [TestMethod] + public async Task SetPrimaryCurrency_CurrencyNotAssignedToStore_IsRejected() + { + var currency = new Currency + { Id = "currency-1", Published = true, LimitedToStores = true, Stores = ["another-store"] }; + ArrangeSetPrimaryCurrency(currency, sharedProducts: 0); + + var result = await _controller.SetPrimaryCurrency(currency.Id, true) as JsonResult; + + Assert.IsNotNull(result); + Assert.AreEqual("Admin.Configuration.Currencies.NotAssignedToStore", Message(result)); + VerifyNothingSaved(); + } + + [TestMethod] + public async Task SetPrimaryCurrency_CurrencyNotPublished_IsRejected() + { + var currency = StoreCurrency("currency-1"); + currency.Published = false; + ArrangeSetPrimaryCurrency(currency, sharedProducts: 0); + + var result = await _controller.SetPrimaryCurrency(currency.Id, true) as JsonResult; + + Assert.IsNotNull(result); + Assert.AreEqual("Admin.Configuration.Currencies.NotPublished", Message(result)); + VerifyNothingSaved(); + } + + [TestMethod] + public async Task ListData_MarksTheCurrencyResolvedForThisStoreAsPrimary() + { + var storeCurrency = StoreCurrency("currency-1"); + var otherCurrency = new Currency { Id = "currency-2", Published = true }; + SetPrimaryCurrencyOfStore(storeCurrency.Id); + _storeServiceMock.Setup(s => s.GetStoreById(StoreId)).ReturnsAsync(new DomainStore { Id = StoreId }); + _currencyServiceMock.Setup(c => c.GetAllCurrencies(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { storeCurrency, otherCurrency }); + + var result = await _controller.ListData() as JsonResult; + + Assert.IsNotNull(result); + var items = ((DataSourceResult)result.Value!).Data.Cast().ToList(); + Assert.IsTrue(items.Single(x => x.Id == storeCurrency.Id).IsPrimaryStoreCurrency); + Assert.IsFalse(items.Single(x => x.Id == otherCurrency.Id).IsPrimaryStoreCurrency); + } + + private void ArrangeSetPrimaryCurrency(Currency currency, int sharedProducts) + { + _currencyServiceMock.Setup(c => c.GetCurrencyById(currency.Id)).ReturnsAsync(currency); + _storeServiceMock.Setup(s => s.GetStoreById(StoreId)).ReturnsAsync(new DomainStore { Id = StoreId }); + _productServiceMock.Setup(p => p.CountSharedProducts(StoreId)).ReturnsAsync(sharedProducts); + } + + private void VerifySavedForStore(string currencyId) + { + _settingServiceMock.Verify( + s => s.SaveSetting(It.Is(p => p.CurrencyId == currencyId), StoreId), + Times.Once); + _settingServiceMock.Verify( + s => s.SaveSetting(It.IsAny(), string.Empty), Times.Never); + } + + private void VerifyNothingSaved() + { + _settingServiceMock.Verify( + s => s.SaveSetting(It.IsAny(), It.IsAny()), Times.Never); + } + + private static bool Success(JsonResult result) + { + return (bool)result.Value!.GetType().GetProperty("success")!.GetValue(result.Value)!; + } + + private static string Message(JsonResult result) + { + return (string)result.Value!.GetType().GetProperty("message")?.GetValue(result.Value); + } } diff --git a/src/Web/Grand.Web.Admin/Controllers/CurrencyController.cs b/src/Web/Grand.Web.Admin/Controllers/CurrencyController.cs index ac62df16c4..f351c8712c 100644 --- a/src/Web/Grand.Web.Admin/Controllers/CurrencyController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/CurrencyController.cs @@ -110,10 +110,12 @@ public async Task List(ExchangeRateModel model) public async Task ListGrid(DataSourceRequest command) { var currenciesModel = (await _currencyService.GetAllCurrencies(true)).Select(x => x.ToModel()).ToList(); + //the global primary currency - a store overriding it manages that from its own panel + var primaryStoreCurrencyId = (await _settingService.LoadSetting()).CurrencyId; foreach (var currency in currenciesModel) currency.IsPrimaryExchangeRateCurrency = currency.Id == _currencySettings.PrimaryExchangeRateCurrencyId; foreach (var currency in currenciesModel) - currency.IsPrimaryStoreCurrency = currency.Id == _currencySettings.PrimaryStoreCurrencyId; + currency.IsPrimaryStoreCurrency = currency.Id == primaryStoreCurrencyId; var gridModel = new DataSourceResult { Data = currenciesModel, diff --git a/src/Web/Grand.Web.Admin/Controllers/SettingController.cs b/src/Web/Grand.Web.Admin/Controllers/SettingController.cs index 1fcaf63df1..0006735f3c 100644 --- a/src/Web/Grand.Web.Admin/Controllers/SettingController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/SettingController.cs @@ -230,8 +230,7 @@ public async Task Sales([FromServices] IOrderStatusService orderS ActiveStore = storeScope }; - var currencySettings = await settingService.LoadSetting(); - var currency = await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId); + var currency = await currencyService.GetPrimaryStoreCurrency(); //loyal model.LoyaltyPointsSettings.PrimaryStoreCurrencyCode = currency?.CurrencyCode; diff --git a/src/Web/Grand.Web.Admin/Controllers/SystemController.cs b/src/Web/Grand.Web.Admin/Controllers/SystemController.cs index 5701c879c2..ac02ca88fa 100644 --- a/src/Web/Grand.Web.Admin/Controllers/SystemController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/SystemController.cs @@ -155,7 +155,7 @@ public async Task SystemInfo() } //primary store currency - var pscCurrency = await _currencyService.GetCurrencyById(_currencySettings.PrimaryStoreCurrencyId); + var pscCurrency = await _currencyService.GetPrimaryStoreCurrency(); if (pscCurrency != null) model.SystemWarnings.Add(new SystemInfoModel.SystemWarningModel { Level = SystemInfoModel.SystemWarningModel.SystemWarningLevel.Pass, diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseCheckoutAttributeController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseCheckoutAttributeController.cs index a31ff7e46c..eab75ba644 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseCheckoutAttributeController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseCheckoutAttributeController.cs @@ -1,4 +1,4 @@ -using Grand.Business.Core.Extensions; +using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Catalog.Directory; using Grand.Business.Core.Interfaces.Checkout.CheckoutAttributes; using Grand.Business.Core.Interfaces.Common.Directory; @@ -178,7 +178,7 @@ public virtual async Task ValueCreatePopup(CheckoutAttributeValue await checkoutAttributeViewModelService.InsertCheckoutAttributeValueModel(checkoutAttribute, model); return Content(""); } - model.PrimaryStoreCurrencyCode = (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId)).CurrencyCode; + model.PrimaryStoreCurrencyCode = (await currencyService.GetPrimaryStoreCurrency()).CurrencyCode; model.BaseWeightIn = (await measureService.GetMeasureWeightById(measureSettings.BaseWeightId)).Name; return View(model); } @@ -219,7 +219,7 @@ public virtual async Task ValueEditPopup(CheckoutAttributeValueMo await checkoutAttributeViewModelService.UpdateCheckoutAttributeValueModel(checkoutAttribute, cav, model); return Content(""); } - model.PrimaryStoreCurrencyCode = (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId)).CurrencyCode; + model.PrimaryStoreCurrencyCode = (await currencyService.GetPrimaryStoreCurrency()).CurrencyCode; model.BaseWeightIn = (await measureService.GetMeasureWeightById(measureSettings.BaseWeightId)).Name; return View(model); } diff --git a/src/Web/Grand.Web.AdminShared/Services/CheckoutAttributeViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/CheckoutAttributeViewModelService.cs index da4b224bb4..af636fb17f 100644 --- a/src/Web/Grand.Web.AdminShared/Services/CheckoutAttributeViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/CheckoutAttributeViewModelService.cs @@ -73,7 +73,7 @@ public virtual async Task PrepareCheckoutAttributeV var checkoutAttribute = await checkoutAttributeService.GetCheckoutAttributeById(checkoutAttributeId); var model = new CheckoutAttributeValueModel { CheckoutAttributeId = checkoutAttributeId, - PrimaryStoreCurrencyCode = (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId)) + PrimaryStoreCurrencyCode = (await currencyService.GetPrimaryStoreCurrency()) .CurrencyCode, BaseWeightIn = (await measureService.GetMeasureWeightById(measureSettings.BaseWeightId)).Name, //color squares @@ -90,7 +90,7 @@ public virtual async Task PrepareCheckoutAttributeV var model = checkoutAttributeValue.ToModel(); model.DisplayColorSquaresRgb = checkoutAttribute.AttributeControlTypeId == AttributeControlType.ColorSquares; model.PrimaryStoreCurrencyCode = - (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId)).CurrencyCode; + (await currencyService.GetPrimaryStoreCurrency()).CurrencyCode; model.BaseWeightIn = (await measureService.GetMeasureWeightById(measureSettings.BaseWeightId)).Name; return model; diff --git a/src/Web/Grand.Web.AdminShared/Services/CurrencyViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/CurrencyViewModelService.cs index 9e9c5f2bc7..c25b766d7f 100644 --- a/src/Web/Grand.Web.AdminShared/Services/CurrencyViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/CurrencyViewModelService.cs @@ -76,11 +76,32 @@ public virtual async Task MarkAsPrimaryExchangeRateCurrency(string id) public virtual async Task MarkAsPrimaryStoreCurrency(string id) { - _currencySettings.PrimaryStoreCurrencyId = id; - await _settingService.SaveSetting(_currencySettings); + var primaryCurrencySettings = await _settingService.LoadSetting(); + primaryCurrencySettings.CurrencyId = id; + await _settingService.SaveSetting(primaryCurrencySettings); await _cacheBase.Clear(); } + /// + /// The currency each store's prices are stored in - its own override where it has one, the global value + /// otherwise. The empty store id covers the global value itself, which applies when no store exists yet. + /// + private async Task> EffectivePrimaryCurrencyIds() + { + var storeIds = (await _storeService.GetAllStores()).Select(s => s.Id).Append(string.Empty); + + var currencyIds = new HashSet(); + foreach (var storeId in storeIds) + currencyIds.Add((await _settingService.LoadSetting(storeId)).CurrencyId); + + return currencyIds; + } + + private async Task EffectivePrimaryCurrencyId(string storeId) + { + return (await _settingService.LoadSetting(storeId)).CurrencyId; + } + public virtual async Task<(bool canProceed, string message)> ValidateCurrencyUnpublish(string currencyId, bool published) { @@ -91,6 +112,10 @@ public virtual async Task MarkAsPrimaryStoreCurrency(string id) if (allCurrencies.Count == 1 && allCurrencies[0].Id == currencyId) return (false, "At least one published currency is required."); + //a store cannot be left with an unpublished currency its prices are stored in + if ((await EffectivePrimaryCurrencyIds()).Contains(currencyId)) + return (false, _translationService.GetResource("Admin.Configuration.Currencies.CantUnpublishPrimary")); + return (true, string.Empty); } @@ -108,6 +133,14 @@ public virtual async Task MarkAsPrimaryStoreCurrency(string id) foreach (var store in await _storeService.GetAllStores()) { + //a store's prices are stored in its primary currency - it must stay available there + if (await EffectivePrimaryCurrencyId(store.Id) == currency.Id && + (!model.Published || (limitedToStores && !model.Stores.Contains(store.Id)))) + return (false, string.Format( + _translationService.GetResource("Admin.Configuration.Currencies.CantLimitPrimaryStores"), + store.Name)); + + if (otherCurrencies.Any(c => !c.LimitedToStores || c.Stores.Contains(store.Id))) continue; @@ -123,7 +156,7 @@ public virtual async Task MarkAsPrimaryStoreCurrency(string id) public virtual async Task<(bool canDelete, string message)> ValidateCurrencyDelete(Currency currency) { - if (currency.Id == _currencySettings.PrimaryStoreCurrencyId) + if ((await EffectivePrimaryCurrencyIds()).Contains(currency.Id)) return (false, _translationService.GetResource("Admin.Configuration.Currencies.CantDeletePrimary")); if (currency.Id == _currencySettings.PrimaryExchangeRateCurrencyId) diff --git a/src/Web/Grand.Web.AdminShared/Services/OrderViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/OrderViewModelService.cs index 667009634b..002b006e44 100644 --- a/src/Web/Grand.Web.AdminShared/Services/OrderViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/OrderViewModelService.cs @@ -303,7 +303,7 @@ public virtual async Task PrepareOrderListModel( orderTagId: model.OrderTag); - var primaryStoreCurrency = await _currencyService.GetCurrencyById(_currencySettings.PrimaryStoreCurrencyId); + var primaryStoreCurrency = await _currencyService.GetPrimaryStoreCurrency(); if (primaryStoreCurrency == null) throw new Exception("Cannot load primary store currency"); @@ -423,7 +423,7 @@ public virtual async Task PrepareOrderDetailsModel(OrderModel model, Order order // gate) to format each OrderItemModel's unit price/discount/subtotal/commission, which // Vendor's own OrderDetails.Products partial does render. var primaryStoreCurrency = await _currencyService.GetCurrencyByCode(order.PrimaryCurrencyCode) ?? - await _currencyService.GetCurrencyById(_currencySettings.PrimaryStoreCurrencyId); + await _currencyService.GetPrimaryStoreCurrency(); if (primaryStoreCurrency == null) throw new Exception("Cannot load primary store currency"); diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs index 9c85e49c52..f564e3cfad 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs @@ -137,7 +137,7 @@ public virtual async Task PrepareAddProductAttributeCombinationModel(ProductAttr IsDefault = picture.IsDefault }); model.PrimaryStoreCurrencyCode = - (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode; + (await currencyService.GetPrimaryStoreCurrency())?.CurrencyCode; } public virtual async Task PrepareTierPriceModel(ProductModel.TierPriceModel model) @@ -257,7 +257,7 @@ public virtual async Task PrepareProductModel(ProductModel model, Product produc { ArgumentNullException.ThrowIfNull(model); - model.PrimaryStoreCurrencyCode = (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode; + model.PrimaryStoreCurrencyCode = (await currencyService.GetPrimaryStoreCurrency())?.CurrencyCode; model.BaseWeightIn = (await measureService.GetMeasureWeightById(measureSettings.BaseWeightId))?.Name; model.BaseDimensionIn = (await measureService.GetMeasureDimensionById(measureSettings.BaseDimensionId))?.Name; @@ -1796,7 +1796,7 @@ public virtual async Task UpdateProductAttributeConditionModel(Product product, //image squares DisplayImageSquaresPicture = productAttributeMapping.AttributeControlTypeId == AttributeControlType.ImageSquares, - PrimaryStoreCurrencyCode = (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId)) + PrimaryStoreCurrencyCode = (await currencyService.GetPrimaryStoreCurrency()) ?.CurrencyCode, //default qantity for associated product Quantity = 1 @@ -1852,7 +1852,7 @@ await pictureService.GetPictureUrl( : "", Cost = x.Cost, PrimaryStoreCurrencyCode = - (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode, + (await currencyService.GetPrimaryStoreCurrency())?.CurrencyCode, Quantity = x.Quantity, IsPreSelected = x.IsPreSelected, DisplayOrder = x.DisplayOrder, @@ -1884,7 +1884,7 @@ await pictureService.GetPictureUrl( PriceAdjustment = pav.PriceAdjustment, WeightAdjustment = pav.WeightAdjustment, Cost = pav.Cost, - PrimaryStoreCurrencyCode = (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId)) + PrimaryStoreCurrencyCode = (await currencyService.GetPrimaryStoreCurrency()) ?.CurrencyCode, Quantity = pav.Quantity, IsPreSelected = pav.IsPreSelected, diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Currency/List.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Currency/List.cshtml index 0d5be7c31d..7b39214f0d 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Currency/List.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Currency/List.cshtml @@ -74,6 +74,22 @@ attributes: { style: "text-align:center" }, template: '# if(LimitedToStores) {# #} else {# #} #' }, + { + field: "IsPrimaryStoreCurrency", + title: "@Loc["Admin.Configuration.Currencies.SetPrimaryCurrency"]", + width: 180, + headerAttributes: { style: "text-align:center" }, + attributes: { style: "text-align:center" }, + template: function (dataItem) { + if (dataItem.IsPrimaryStoreCurrency) { + return ' @Loc["Admin.Configuration.Currencies.Fields.IsPrimaryStoreCurrency"]'; + } + if (dataItem.IsAssignedToCurrentStore && dataItem.Published) { + return ' @Loc["Admin.Configuration.Currencies.SetPrimaryCurrency"]'; + } + return '-'; + } + }, { field: "IsDefaultStoreCurrency", title: "@Loc["Admin.Configuration.Currencies.SetDefaultCurrency"]", @@ -155,6 +171,32 @@ }); } + function setPrimaryCurrency(id, confirmed) { + var postData = { id: id, confirmed: confirmed }; + addAntiForgeryToken(postData); + $.ajax({ + cache: false, + type: "POST", + url: "@Html.Raw(Url.Action("SetPrimaryCurrency", "Currency", new { area = Constants.AreaStore }))", + data: postData, + success: function (data) { + if (data.success) { + var grid = $("#currencies-grid").data('kendoGrid'); + grid.dataSource.read(); + } else if (data.requiresConfirmation) { + if (confirm(data.message)) { + setPrimaryCurrency(id, true); + } + } else { + alert(data.message); + } + }, + error: function () { + alert('Failed to set primary currency'); + } + }); + } + function setDefaultCurrency(id) { var postData = { id: id }; addAntiForgeryToken(postData); diff --git a/src/Web/Grand.Web.Store/Controllers/CurrencyController.cs b/src/Web/Grand.Web.Store/Controllers/CurrencyController.cs index 494e23b131..37480e1847 100644 --- a/src/Web/Grand.Web.Store/Controllers/CurrencyController.cs +++ b/src/Web/Grand.Web.Store/Controllers/CurrencyController.cs @@ -1,3 +1,5 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Configuration; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Stores; @@ -8,19 +10,31 @@ using Grand.Web.Common.Security.Authorization; using Grand.Web.Store.Models; using Microsoft.AspNetCore.Mvc; +using DomainStore = Grand.Domain.Stores.Store; namespace Grand.Web.Store.Controllers; [PermissionAuthorize(PermissionSystemName.Currencies)] public class CurrencyController( ICurrencyService currencyService, - CurrencySettings currencySettings, + ISettingService settingService, ITranslationService translationService, IStoreService storeService, + IProductService productService, IContextAccessor contextAccessor) : BaseStoreController { private string CurrentStoreId => contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + /// + /// The currency this store's prices are stored in - its own override where it has one, the global value + /// otherwise. Loaded for the staff store explicitly rather than taken from the injected settings, which are + /// resolved for the store the request itself was routed to. + /// + private async Task EffectivePrimaryCurrencyId() + { + return (await settingService.LoadSetting(CurrentStoreId)).CurrencyId; + } + public IActionResult Index() { return RedirectToAction("List"); @@ -37,9 +51,9 @@ public IActionResult List() public async Task ListData() { var storeId = CurrentStoreId; - var primaryStoreCurrencyId = currencySettings.PrimaryStoreCurrencyId; var store = await storeService.GetStoreById(storeId); + var primaryStoreCurrencyId = await EffectivePrimaryCurrencyId(); var defaultCurrencyId = store?.DefaultCurrencyId; var currencies = await currencyService.GetAllCurrencies(showHidden: false); @@ -99,12 +113,14 @@ public async Task UnassignStore(string id) if (!currency.LimitedToStores) return Json(new { success = false, message = translationService.GetResource("Admin.Configuration.Currencies.CannotModifyGlobal") }); - if (currency.Id == currencySettings.PrimaryStoreCurrencyId) - return Json(new { success = false, message = translationService.GetResource("Admin.Configuration.Currencies.CantDeletePrimary") }); - var storeId = CurrentStoreId; var store = await storeService.GetStoreById(storeId); + + //the currency prices are stored in cannot leave the store + if (currency.Id == await EffectivePrimaryCurrencyId()) + return Json(new { success = false, message = translationService.GetResource("Admin.Configuration.Currencies.CantUnassignPrimary") }); + if (store?.DefaultCurrencyId == currency.Id) return Json(new { success = false, message = translationService.GetResource("Admin.Configuration.Currencies.CantUnassignDefault") }); @@ -119,6 +135,49 @@ public async Task UnassignStore(string id) return Json(new { success = true }); } + [HttpPost] + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task SetPrimaryCurrency(string id, bool confirmed) + { + var currency = await currencyService.GetCurrencyById(id); + if (currency == null) + return Json(new { success = false, message = translationService.GetResource("Admin.Configuration.Currencies.NotFound") }); + + if (!currency.Published) + return Json(new { success = false, message = translationService.GetResource("Admin.Configuration.Currencies.NotPublished") }); + + var storeId = CurrentStoreId; + + if (currency.LimitedToStores && !currency.Stores.Contains(storeId)) + return Json(new { success = false, message = translationService.GetResource("Admin.Configuration.Currencies.NotAssignedToStore") }); + + var store = await storeService.GetStoreById(storeId); + if (store == null) + return Json(new { success = false, message = translationService.GetResource("Admin.Configuration.Stores.NotFound") }); + + //product prices are stored in the primary currency and are not recalculated - a product shared with + //another store would silently change its meaning, so the store owner has to confirm it + if (!confirmed) + { + var sharedProducts = await productService.CountSharedProducts(storeId); + if (sharedProducts > 0) + return Json(new { + success = false, + requiresConfirmation = true, + message = string.Format( + translationService.GetResource("Admin.Configuration.Currencies.PrimaryCurrency.SharedProducts"), + sharedProducts) + }); + } + + var primaryCurrencySettings = await settingService.LoadSetting(storeId); + primaryCurrencySettings.CurrencyId = currency.Id; + await settingService.SaveSetting(primaryCurrencySettings, storeId); + await settingService.ClearCache(); + + return Json(new { success = true }); + } + [HttpPost] [PermissionAuthorizeAction(PermissionActionName.Edit)] public async Task SetDefaultCurrency(string id) diff --git a/src/Web/Grand.Web.Store/Controllers/SettingController.cs b/src/Web/Grand.Web.Store/Controllers/SettingController.cs index 59cf921fcd..e8b829cd66 100644 --- a/src/Web/Grand.Web.Store/Controllers/SettingController.cs +++ b/src/Web/Grand.Web.Store/Controllers/SettingController.cs @@ -1,4 +1,4 @@ -using Grand.Business.Core.Extensions; +using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Checkout.Orders; using Grand.Business.Core.Interfaces.Common.Configuration; using Grand.Business.Core.Interfaces.Common.Directory; @@ -268,8 +268,7 @@ public async Task Sales([FromServices] IOrderStatusService orderS ActiveStore = storeScope }; - var currencySettings = await settingService.LoadSetting(); - var currency = await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId); + var currency = await currencyService.GetPrimaryStoreCurrency(); model.LoyaltyPointsSettings.PrimaryStoreCurrencyCode = currency?.CurrencyCode; var status = await orderStatusService.GetAll(); @@ -323,8 +322,7 @@ public async Task Sales(SalesSettingsModel model, foreach (var error in modelState.Errors) Error(error.ErrorMessage); - var currencySettings = await settingService.LoadSetting(); - var currency = await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId); + var currency = await currencyService.GetPrimaryStoreCurrency(); model.LoyaltyPointsSettings.PrimaryStoreCurrencyCode = currency?.CurrencyCode; model.OrderSettings.PrimaryStoreCurrencyCode = currency?.CurrencyCode; diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index 38ceab54f4cdb014dbdcfdb7a043671950e65b82..8cb8a0d58a773b7ab8e834659d07c5089c6a5979 100644 GIT binary patch delta 670 zcmZ{i%WD%+6vl5eJ;`*^NhZ^I6yswGt#qNK?pzdY42ci$fs__Rs!1%#l(x4`CZevo z2nt=6!U0#-MWH(>N;V48wTt3E-~$Aaf?fCr_}wXzm5bjU&OPUR=bJP0d3%0mZ+^Yg z*GY7=PXAlFTj1$?G|cx4G@EXVmRJCRh}aA%KTwH-5aISAxjZ^c7VB5&W%pWv{%Aa1 zAf0~|art$NZt(LG**vqJ@OXd2_s+CLB&29U@eve5v4XQED)DngNWM^{59X?9i>g>c zx+NO?Ws9z|6~fP*%ak+&)JmuXC|Tg#xS8j&XIotEw@!@pQF7M3lnL5|yZII+HB-oDS1RZV02f)Z8^ruKd1tU$~L#Oy%K u0mPg@%mu{UK+FTgyg Store portal + + Set as primary currency + + + {0} product(s) available in your store are also available in other stores. Product prices are stored in the primary currency and are NOT recalculated, so changing it reinterprets those prices in the new currency. Continue? + + + The currency your store prices are stored in can not be unassigned. + + + This currency can not be unpublished because at least one store uses it as its primary currency. + + + The store '{0}' uses this currency as its primary currency. +