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..f3dbb2369f 100644 --- a/src/Business/Grand.Business.Common/Services/Directory/CurrencyService.cs +++ b/src/Business/Grand.Business.Common/Services/Directory/CurrencyService.cs @@ -2,6 +2,7 @@ using Grand.Business.Core.Interfaces.Common.Security; using Grand.Data; using Grand.Domain.Directory; +using Grand.Infrastructure; using Grand.Infrastructure.Caching; using Grand.Infrastructure.Caching.Constants; using Grand.Infrastructure.Extensions; @@ -29,13 +30,15 @@ public CurrencyService(ICacheBase cacheBase, IRepository currencyRepository, IAclService aclService, CurrencySettings currencySettings, - IMediator mediator) + IMediator mediator, + IContextAccessor contextAccessor) { _cacheBase = cacheBase; _currencyRepository = currencyRepository; _aclService = aclService; _currencySettings = currencySettings; _mediator = mediator; + _contextAccessor = contextAccessor; } #endregion @@ -47,6 +50,7 @@ public CurrencyService(ICacheBase cacheBase, private readonly ICacheBase _cacheBase; private readonly IMediator _mediator; private readonly CurrencySettings _currencySettings; + private readonly IContextAccessor _contextAccessor; private Currency _primaryCurrency; private Currency _primaryExchangeRateCurrency; @@ -66,11 +70,18 @@ public virtual Task GetCurrencyById(string currencyId) } /// - /// Gets primary store currency + /// Gets primary store currency - the currency prices are stored in. A store may override it via + /// Store.PrimaryCurrencyId; otherwise the global setting applies. /// /// Currency public async Task GetPrimaryStoreCurrency() { + if (_primaryCurrency != null) return _primaryCurrency; + + var storeCurrencyId = _contextAccessor?.StoreContext?.CurrentStore?.PrimaryCurrencyId; + if (!string.IsNullOrEmpty(storeCurrencyId)) + _primaryCurrency = await GetCurrencyById(storeCurrencyId); + return _primaryCurrency ??= await GetCurrencyById(_currencySettings.PrimaryStoreCurrencyId); } 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/Stores/Store.cs b/src/Core/Grand.Domain/Stores/Store.cs index 95b18b0031..52c001c01a 100644 --- a/src/Core/Grand.Domain/Stores/Store.cs +++ b/src/Core/Grand.Domain/Stores/Store.cs @@ -48,6 +48,12 @@ public class Store : BaseEntity, ITranslationEntity /// public string DefaultCurrencyId { get; set; } + /// + /// Gets or sets the identifier of the primary currency for this store - the currency prices are + /// stored in. Empty means the store follows the global CurrencySettings.PrimaryStoreCurrencyId. + /// + public string PrimaryCurrencyId { get; set; } + /// /// Gets or sets the display order /// 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..dee63762c1 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Services/Prices/PriceFormatterTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Services/Prices/PriceFormatterTests.cs @@ -86,7 +86,8 @@ public void TestInitialize() _currencyRepo, _aclService, _currencySettings, - null); + null, + new Mock().Object); _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..31b89f5c8e 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Services/Prices/PriceServiceTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Services/Prices/PriceServiceTests.cs @@ -105,7 +105,7 @@ public void TestInitialize() _currencyService = new CurrencyService( cacheManager, _currencyRepository, _aclService, - _currencySettings, _eventPublisher); + _currencySettings, _eventPublisher, new Mock().Object); 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/Directory/CurrencyServiceTests.cs b/src/Tests/Grand.Business.Common.Tests/Services/Directory/CurrencyServiceTests.cs index 1681901bb0..a22f71cc6a 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 Mock _contextAccessor; 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); @@ -87,6 +91,7 @@ public void TestInitialize() _cacheManager = new Mock(); _aclService = new Mock(); + _contextAccessor = new Mock(); _serviceProvider = new Mock().Object; _currencySettings = new CurrencySettings { @@ -97,7 +102,7 @@ public void TestInitialize() _currencyService = new CurrencyService( _cacheManager.Object, _currencyRepository, _aclService.Object, - _currencySettings, _eventPublisher); + _currencySettings, _eventPublisher, _contextAccessor.Object); //tempDiscountServiceMock.Setup(x => x.GetAllDiscounts(DiscountType.AssignedToCategories, "", "", false)).ReturnsAsync(new List()); } @@ -167,6 +172,79 @@ public async Task GetPrimaryStoreCurrency_ReturnExpectedValue() Assert.AreEqual(result.Id, currencyUSD.Id); } + [TestMethod] + public async Task GetPrimaryStoreCurrency_StoreDefinesPrimaryCurrency_ReturnsStoreCurrency() + { + _currencySettings.PrimaryStoreCurrencyId = currencyUSD.Id; + ResolveCurrencyByIdFromRepository(); + SetCurrentStore(new Store { Id = "store-1", PrimaryCurrencyId = currencyRUR.Id }); + + var result = await _currencyService.GetPrimaryStoreCurrency(); + + Assert.AreEqual(currencyRUR.Id, result.Id); + } + + [TestMethod] + public async Task GetPrimaryStoreCurrency_StoreDefinesNoPrimaryCurrency_FallsBackToGlobalSetting() + { + _currencySettings.PrimaryStoreCurrencyId = currencyUSD.Id; + ResolveCurrencyByIdFromRepository(); + SetCurrentStore(new Store { Id = "store-1", PrimaryCurrencyId = "" }); + + var result = await _currencyService.GetPrimaryStoreCurrency(); + + Assert.AreEqual(currencyUSD.Id, result.Id); + } + + [TestMethod] + public async Task GetPrimaryStoreCurrency_NoStoreContext_FallsBackToGlobalSetting() + { + _currencySettings.PrimaryStoreCurrencyId = currencyUSD.Id; + ResolveCurrencyByIdFromRepository(); + _contextAccessor.Setup(x => x.StoreContext).Returns((IStoreContext)null); + + var result = await _currencyService.GetPrimaryStoreCurrency(); + + Assert.AreEqual(currencyUSD.Id, result.Id); + } + + [TestMethod] + public async Task GetPrimaryStoreCurrency_StorePrimaryCurrencyNoLongerExists_FallsBackToGlobalSetting() + { + _currencySettings.PrimaryStoreCurrencyId = currencyUSD.Id; + ResolveCurrencyByIdFromRepository(); + SetCurrentStore(new Store { Id = "store-1", PrimaryCurrencyId = "deleted-currency" }); + + var result = await _currencyService.GetPrimaryStoreCurrency(); + + Assert.AreEqual(currencyUSD.Id, result.Id); + } + + [TestMethod] + public async Task GetPrimaryExchangeRateCurrency_StoreDefinesPrimaryCurrency_StaysGlobal() + { + _currencySettings.PrimaryExchangeRateCurrencyId = currencyEUR.Id; + ResolveCurrencyByIdFromRepository(); + SetCurrentStore(new Store { Id = "store-1", PrimaryCurrencyId = currencyRUR.Id }); + + 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()); + } + + private void SetCurrentStore(Store store) + { + var storeContext = new Mock(); + storeContext.Setup(x => x.CurrentStore).Returns(store); + _contextAccessor.Setup(x => x.StoreContext).Returns(storeContext.Object); + } + [TestMethod] public async Task InsertCurrency_ValidArgument() { diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/CurrencyViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/CurrencyViewModelServiceTests.cs index 9e4caa617c..db132710e6 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/CurrencyViewModelServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/CurrencyViewModelServiceTests.cs @@ -1,8 +1,11 @@ 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; using Grand.Domain.Directory; +using Grand.Domain.Stores; using Grand.Infrastructure.Caching; +using Grand.Web.AdminShared.Models.Directory; using Grand.Web.AdminShared.Services; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -14,6 +17,7 @@ namespace Grand.Web.Admin.Tests.Services; public class CurrencyViewModelServiceTests { private Mock _currencyServiceMock; + private Mock _storeServiceMock; private CurrencySettings _currencySettings; private Mock _settingServiceMock; private Mock _translationServiceMock; @@ -24,6 +28,8 @@ public class CurrencyViewModelServiceTests public void Setup() { _currencyServiceMock = new Mock(); + _storeServiceMock = new Mock(); + _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List()); _currencySettings = new CurrencySettings(); _settingServiceMock = new Mock(); _translationServiceMock = new Mock(); @@ -31,6 +37,7 @@ public void Setup() _service = new CurrencyViewModelService( _currencyServiceMock.Object, + _storeServiceMock.Object, _currencySettings, _settingServiceMock.Object, _translationServiceMock.Object, @@ -90,6 +97,68 @@ public async Task ValidateCurrencyUnpublish_OtherCurrenciesExist_CanProceed() Assert.IsTrue(canProceed); } + [TestMethod] + public async Task ValidateCurrencyStoreMapping_PublishedAndNotLimited_CanProceed() + { + var (canProceed, message) = await _service.ValidateCurrencyStoreMapping(new Currency { Id = "currency-1" }, + new CurrencyModel { Published = true, Stores = [] }); + + Assert.IsTrue(canProceed); + Assert.AreEqual(string.Empty, message); + _storeServiceMock.Verify(s => s.GetAllStores(), Times.Never); + } + + [TestMethod] + public async Task ValidateCurrencyStoreMapping_StoreLeftWithoutCurrency_CannotProceed() + { + _currencyServiceMock.Setup(c => c.GetAllCurrencies(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { + new() { Id = "currency-1" }, + new() { Id = "currency-2", LimitedToStores = true, Stores = ["store-1"] } + }); + _storeServiceMock.Setup(s => s.GetAllStores()) + .ReturnsAsync(new List { new() { Id = "store-1" }, new() { Id = "store-2", Name = "Second" } }); + _translationServiceMock.Setup(t => t.GetResource("Admin.Configuration.Currencies.CantLimitStores")) + .Returns("Store '{0}' has no currency"); + + 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' has no currency", message); + } + + [TestMethod] + public async Task ValidateCurrencyStoreMapping_AnotherGlobalCurrencyExists_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" } }); + + var (canProceed, _) = await _service.ValidateCurrencyStoreMapping(new Currency { Id = "currency-1" }, + new CurrencyModel { Published = true, Stores = ["store-1"] }); + + Assert.IsTrue(canProceed); + } + + [TestMethod] + public async Task ValidateCurrencyStoreMapping_UnpublishingLastCurrencyOfStore_CannotProceed() + { + _currencyServiceMock.Setup(c => c.GetAllCurrencies(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { new() { Id = "currency-1" } }); + _storeServiceMock.Setup(s => s.GetAllStores()) + .ReturnsAsync(new List { new() { Id = "store-1", Name = "First" } }); + _translationServiceMock.Setup(t => t.GetResource("Admin.Configuration.Currencies.CantLimitStores")) + .Returns("Store '{0}' has no currency"); + + var (canProceed, message) = await _service.ValidateCurrencyStoreMapping(new Currency { Id = "currency-1" }, + new CurrencyModel { Published = false, Stores = [] }); + + Assert.IsFalse(canProceed); + Assert.AreEqual("Store 'First' has no currency", message); + } + [TestMethod] public async Task ValidateCurrencyDelete_PrimaryStoreCurrency_CannotDelete() { @@ -103,6 +172,73 @@ public async Task ValidateCurrencyDelete_PrimaryStoreCurrency_CannotDelete() 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", PrimaryCurrencyId = "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 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", PrimaryCurrencyId = "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", PrimaryCurrencyId = "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", PrimaryCurrencyId = "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 new file mode 100644 index 0000000000..f21aa718c5 --- /dev/null +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/CurrencyControllerTests.cs @@ -0,0 +1,250 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Domain.Customers; +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; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using DomainStore = Grand.Domain.Stores.Store; + +namespace Grand.Web.Store.Tests.Controllers; + +[TestClass] +public class CurrencyControllerTests +{ + private const string StoreId = "storeId"; + + private CurrencyController _controller; + private Mock _currencyServiceMock; + private Mock _productServiceMock; + private Mock _storeServiceMock; + private Mock _translationServiceMock; + private CurrencySettings _currencySettings; + + [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(); + + var workContextMock = new Mock(); + workContextMock.Setup(w => w.CurrentCustomer).Returns(new Customer { StaffStoreId = StoreId }); + var contextAccessorMock = new Mock(); + contextAccessorMock.Setup(c => c.WorkContext).Returns(workContextMock.Object); + + _controller = new CurrencyController( + _currencyServiceMock.Object, + _currencySettings, + _translationServiceMock.Object, + _storeServiceMock.Object, + _productServiceMock.Object, + contextAccessorMock.Object); + + var httpContext = new DefaultHttpContext(); + _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + _controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); + } + + private static Currency StoreCurrency(string id) + { + return new Currency { Id = id, Published = true, LimitedToStores = true, Stores = [StoreId] }; + } + + [TestMethod] + public async Task UnassignStore_LastAvailableCurrency_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 }); + _currencyServiceMock.Setup(c => c.GetAllCurrencies(It.IsAny(), StoreId)) + .ReturnsAsync(new List { currency }); + + var result = await _controller.UnassignStore(currency.Id) as JsonResult; + + Assert.IsNotNull(result); + Assert.AreEqual("Admin.Configuration.Currencies.CantUnassignLast", + result.Value.GetType().GetProperty("message")!.GetValue(result.Value)); + Assert.IsTrue(currency.Stores.Contains(StoreId)); + _currencyServiceMock.Verify(c => c.UpdateCurrency(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task UnassignStore_AnotherCurrencyRemains_IsUnassigned() + { + 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 }); + _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.IsTrue((bool)result.Value.GetType().GetProperty("success")!.GetValue(result.Value)!); + 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, PrimaryCurrencyId = 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"); + var store = new DomainStore { Id = StoreId }; + ArrangeSetPrimaryCurrency(currency, store, 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)!); + Assert.IsNull(store.PrimaryCurrencyId); + _storeServiceMock.Verify(s => s.UpdateStore(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SetPrimaryCurrency_SharedProductsExistAndConfirmed_IsAccepted() + { + var currency = StoreCurrency("currency-1"); + var store = new DomainStore { Id = StoreId }; + ArrangeSetPrimaryCurrency(currency, store, sharedProducts: 3); + + var result = await _controller.SetPrimaryCurrency(currency.Id, true) as JsonResult; + + Assert.IsNotNull(result); + Assert.IsTrue(Success(result)); + Assert.AreEqual(currency.Id, store.PrimaryCurrencyId); + _storeServiceMock.Verify(s => s.UpdateStore(store), Times.Once); + } + + [TestMethod] + public async Task SetPrimaryCurrency_NoSharedProducts_IsAcceptedWithoutConfirmation() + { + var currency = StoreCurrency("currency-1"); + var store = new DomainStore { Id = StoreId }; + ArrangeSetPrimaryCurrency(currency, store, sharedProducts: 0); + + var result = await _controller.SetPrimaryCurrency(currency.Id, false) as JsonResult; + + Assert.IsNotNull(result); + Assert.IsTrue(Success(result)); + Assert.AreEqual(currency.Id, store.PrimaryCurrencyId); + _storeServiceMock.Verify(s => s.UpdateStore(store), Times.Once); + } + + [TestMethod] + public async Task SetPrimaryCurrency_CurrencyNotAssignedToStore_IsRejected() + { + var currency = new Currency + { Id = "currency-1", Published = true, LimitedToStores = true, Stores = ["another-store"] }; + var store = new DomainStore { Id = StoreId }; + ArrangeSetPrimaryCurrency(currency, store, sharedProducts: 0); + + var result = await _controller.SetPrimaryCurrency(currency.Id, true) as JsonResult; + + Assert.IsNotNull(result); + Assert.AreEqual("Admin.Configuration.Currencies.NotAssignedToStore", Message(result)); + Assert.IsNull(store.PrimaryCurrencyId); + _storeServiceMock.Verify(s => s.UpdateStore(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SetPrimaryCurrency_CurrencyNotPublished_IsRejected() + { + var currency = StoreCurrency("currency-1"); + currency.Published = false; + var store = new DomainStore { Id = StoreId }; + ArrangeSetPrimaryCurrency(currency, store, sharedProducts: 0); + + var result = await _controller.SetPrimaryCurrency(currency.Id, true) as JsonResult; + + Assert.IsNotNull(result); + Assert.AreEqual("Admin.Configuration.Currencies.NotPublished", Message(result)); + Assert.IsNull(store.PrimaryCurrencyId); + _storeServiceMock.Verify(s => s.UpdateStore(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ListData_StoreOverridesPrimaryCurrency_MarksStoreCurrencyAsPrimary() + { + var storeCurrency = StoreCurrency("currency-1"); + var globalCurrency = new Currency { Id = "currency-2", Published = true }; + _currencySettings.PrimaryStoreCurrencyId = globalCurrency.Id; + _storeServiceMock.Setup(s => s.GetStoreById(StoreId)) + .ReturnsAsync(new DomainStore { Id = StoreId, PrimaryCurrencyId = storeCurrency.Id }); + _currencyServiceMock.Setup(c => c.GetAllCurrencies(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { storeCurrency, globalCurrency }); + + 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 == globalCurrency.Id).IsPrimaryStoreCurrency); + } + + [TestMethod] + public async Task ListData_StoreHasNoPrimaryCurrency_FallsBackToGlobalSetting() + { + var storeCurrency = StoreCurrency("currency-1"); + var globalCurrency = new Currency { Id = "currency-2", Published = true }; + _currencySettings.PrimaryStoreCurrencyId = globalCurrency.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, globalCurrency }); + + 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 == globalCurrency.Id).IsPrimaryStoreCurrency); + Assert.IsFalse(items.Single(x => x.Id == storeCurrency.Id).IsPrimaryStoreCurrency); + } + + private void ArrangeSetPrimaryCurrency(Currency currency, DomainStore store, int sharedProducts) + { + _currencyServiceMock.Setup(c => c.GetCurrencyById(currency.Id)).ReturnsAsync(currency); + _storeServiceMock.Setup(s => s.GetStoreById(StoreId)).ReturnsAsync(store); + _productServiceMock.Setup(p => p.CountSharedProducts(StoreId)).ReturnsAsync(sharedProducts); + } + + 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 f6630714b7..ac62df16c4 100644 --- a/src/Web/Grand.Web.Admin/Controllers/CurrencyController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/CurrencyController.cs @@ -225,6 +225,15 @@ public async Task Edit(CurrencyModel model, bool continueEditing) return RedirectToAction("Edit", new { id = currency.Id }); } + //ensure no store is left without an available currency + var (canMap, mappingMessage) = + await _currencyViewModelService.ValidateCurrencyStoreMapping(currency, model); + if (!canMap) + { + Error(mappingMessage); + return RedirectToAction("Edit", new { id = currency.Id }); + } + currency = await _currencyViewModelService.UpdateCurrencyModel(currency, model); Success(_translationService.GetResource("Admin.Configuration.Currencies.Updated")); if (continueEditing) diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/ICurrencyViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/ICurrencyViewModelService.cs index 4cabc67061..3fadf012ab 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/ICurrencyViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/ICurrencyViewModelService.cs @@ -11,5 +11,6 @@ public interface ICurrencyViewModelService Task MarkAsPrimaryExchangeRateCurrency(string id); Task MarkAsPrimaryStoreCurrency(string id); Task<(bool canProceed, string message)> ValidateCurrencyUnpublish(string currencyId, bool published); + Task<(bool canProceed, string message)> ValidateCurrencyStoreMapping(Currency currency, CurrencyModel model); Task<(bool canDelete, string message)> ValidateCurrencyDelete(Currency currency); } \ No newline at end of file diff --git a/src/Web/Grand.Web.AdminShared/Services/CurrencyViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/CurrencyViewModelService.cs index d33f6092d8..d5a36b0e41 100644 --- a/src/Web/Grand.Web.AdminShared/Services/CurrencyViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/CurrencyViewModelService.cs @@ -1,6 +1,7 @@ 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; using Grand.Domain.Directory; using Grand.Infrastructure.Caching; using Grand.Web.AdminShared.Extensions.Mapping; @@ -14,6 +15,7 @@ public class CurrencyViewModelService : ICurrencyViewModelService #region Fields private readonly ICurrencyService _currencyService; + private readonly IStoreService _storeService; private readonly CurrencySettings _currencySettings; private readonly ISettingService _settingService; private readonly ITranslationService _translationService; @@ -24,12 +26,14 @@ public class CurrencyViewModelService : ICurrencyViewModelService #region Constructors public CurrencyViewModelService(ICurrencyService currencyService, + IStoreService storeService, CurrencySettings currencySettings, ISettingService settingService, ITranslationService translationService, ICacheBase cacheBase) { _currencyService = currencyService; + _storeService = storeService; _currencySettings = currencySettings; _settingService = settingService; _translationService = translationService; @@ -77,6 +81,12 @@ public virtual async Task MarkAsPrimaryStoreCurrency(string id) await _cacheBase.Clear(); } + private async Task IsPrimaryCurrencyOfAnyStore(string currencyId) + { + var stores = await _storeService.GetAllStores(); + return stores.Any(s => s.PrimaryCurrencyId == currencyId); + } + public virtual async Task<(bool canProceed, string message)> ValidateCurrencyUnpublish(string currencyId, bool published) { @@ -87,12 +97,52 @@ 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 IsPrimaryCurrencyOfAnyStore(currencyId)) + return (false, _translationService.GetResource("Admin.Configuration.Currencies.CantUnpublishPrimary")); + + return (true, string.Empty); + } + + public virtual async Task<(bool canProceed, string message)> ValidateCurrencyStoreMapping(Currency currency, + CurrencyModel model) + { + var limitedToStores = model.Stores is { Length: > 0 }; + + //a published currency available to every store can never leave a store without one + if (model.Published && !limitedToStores) + return (true, string.Empty); + + var otherCurrencies = (await _currencyService.GetAllCurrencies()) + .Where(c => c.Id != currency.Id).ToList(); + + foreach (var store in await _storeService.GetAllStores()) + { + //a store's prices are stored in its primary currency - it must stay available there + if (store.PrimaryCurrencyId == 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; + + if (model.Published && (!limitedToStores || model.Stores.Contains(store.Id))) + continue; + + return (false, string.Format( + _translationService.GetResource("Admin.Configuration.Currencies.CantLimitStores"), store.Name)); + } + return (true, string.Empty); } public virtual async Task<(bool canDelete, string message)> ValidateCurrencyDelete(Currency currency) { - if (currency.Id == _currencySettings.PrimaryStoreCurrencyId) + if (currency.Id == _currencySettings.PrimaryStoreCurrencyId || + await IsPrimaryCurrencyOfAnyStore(currency.Id)) return (false, _translationService.GetResource("Admin.Configuration.Currencies.CantDeletePrimary")); if (currency.Id == _currencySettings.PrimaryExchangeRateCurrencyId) diff --git a/src/Web/Grand.Web.Common/WorkContextSetter.cs b/src/Web/Grand.Web.Common/WorkContextSetter.cs index 805ace465f..7c32b24a1f 100644 --- a/src/Web/Grand.Web.Common/WorkContextSetter.cs +++ b/src/Web/Grand.Web.Common/WorkContextSetter.cs @@ -397,6 +397,12 @@ protected async Task WorkingCurrency(Customer customer, Language langu var allStoreCurrencies = await _currencyService.GetAllCurrencies(storeId: store.Id); + //no published currency is mapped to the store - fall back to the primary store currency + //rather than failing every request for that store + if (!allStoreCurrencies.Any()) + return await _currencyService.GetPrimaryStoreCurrency() ?? + throw new Exception("No currency could be loaded"); + if (allStoreCurrencies.Count == 1) return allStoreCurrencies.FirstOrDefault(); 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 3004601f1c..359a6bec04 100644 --- a/src/Web/Grand.Web.Store/Controllers/CurrencyController.cs +++ b/src/Web/Grand.Web.Store/Controllers/CurrencyController.cs @@ -1,3 +1,4 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Stores; @@ -8,6 +9,7 @@ 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; @@ -17,10 +19,21 @@ public class CurrencyController( CurrencySettings currencySettings, ITranslationService translationService, IStoreService storeService, + IProductService productService, IContextAccessor contextAccessor) : BaseStoreController { private string CurrentStoreId => contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + /// + /// The currency prices are stored in for this store - its own override, or the global setting + /// + private string EffectivePrimaryCurrencyId(DomainStore store) + { + return !string.IsNullOrEmpty(store?.PrimaryCurrencyId) + ? store.PrimaryCurrencyId + : currencySettings.PrimaryStoreCurrencyId; + } + public IActionResult Index() { return RedirectToAction("List"); @@ -37,9 +50,9 @@ public IActionResult List() public async Task ListData() { var storeId = CurrentStoreId; - var primaryStoreCurrencyId = currencySettings.PrimaryStoreCurrencyId; var store = await storeService.GetStoreById(storeId); + var primaryStoreCurrencyId = EffectivePrimaryCurrencyId(store); var defaultCurrencyId = store?.DefaultCurrencyId; var currencies = await currencyService.GetAllCurrencies(showHidden: false); @@ -99,21 +112,69 @@ 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 == EffectivePrimaryCurrencyId(store)) + 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") }); + //the store must keep at least one available currency, otherwise its work context cannot be built + var storeCurrencies = await currencyService.GetAllCurrencies(storeId: storeId); + if (!storeCurrencies.Any(c => c.Id != currency.Id)) + return Json(new { success = false, message = translationService.GetResource("Admin.Configuration.Currencies.CantUnassignLast") }); + if (currency.Stores.Remove(storeId)) await currencyService.UpdateCurrency(currency); 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) + }); + } + + store.PrimaryCurrencyId = currency.Id; + await storeService.UpdateStore(store); + + return Json(new { success = true }); + } + [HttpPost] [PermissionAuthorizeAction(PermissionActionName.Edit)] public async Task SetDefaultCurrency(string id) diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index 09de03d11a..8cb8a0d58a 100644 Binary files a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml and b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml differ diff --git a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_240.xml b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_240.xml index 9f51c3550a..6d0aa66f23 100644 --- a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_240.xml +++ b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_240.xml @@ -81,9 +81,15 @@ Global currencies cannot be modified by store owners. + + The store '{0}' would be left without an available currency. + The default store currency can't be unassigned. + + At least one currency must remain available for your store. + This currency is not assigned to your store. @@ -249,4 +255,19 @@ 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. +