diff --git a/src/Plugins/Payments.BrainTree/Areas/Admin/Controllers/PaymentBrainTreeController.cs b/src/Plugins/Payments.BrainTree/Areas/Admin/Controllers/PaymentBrainTreeController.cs index 5fa8069971..1dbb67f8cd 100644 --- a/src/Plugins/Payments.BrainTree/Areas/Admin/Controllers/PaymentBrainTreeController.cs +++ b/src/Plugins/Payments.BrainTree/Areas/Admin/Controllers/PaymentBrainTreeController.cs @@ -3,6 +3,7 @@ using Grand.Domain.Permissions; using Grand.Web.Common.Controllers; using Grand.Web.Common.Filters; +using Grand.Web.Common.Helpers; using Grand.Web.Common.Security.Authorization; using Microsoft.AspNetCore.Mvc; using Payments.BrainTree.Models; @@ -18,11 +19,11 @@ public class PaymentBrainTreeController : BasePaymentController public PaymentBrainTreeController(ISettingService settingService, ITranslationService translationService, - BrainTreePaymentSettings brainTreePaymentSettings) + IAdminStoreService adminStoreService) { _settingService = settingService; _translationService = translationService; - _brainTreePaymentSettings = brainTreePaymentSettings; + _adminStoreService = adminStoreService; } #endregion @@ -31,23 +32,28 @@ public PaymentBrainTreeController(ISettingService settingService, private readonly ISettingService _settingService; private readonly ITranslationService _translationService; - private readonly BrainTreePaymentSettings _brainTreePaymentSettings; + private readonly IAdminStoreService _adminStoreService; #endregion #region Methods - public IActionResult Configure() + public async Task Configure() { + //load settings for a chosen store scope - the injected settings instance would always be the + //one of the store hosting the admin panel, which ignores the scope the admin selected + var storeScope = await _adminStoreService.GetActiveStore(); + var brainTreePaymentSettings = await _settingService.LoadSetting(storeScope); + var model = new ConfigurationModel { - Use3DS = _brainTreePaymentSettings.Use3DS, - UseSandBox = _brainTreePaymentSettings.UseSandBox, - PublicKey = _brainTreePaymentSettings.PublicKey, - PrivateKey = _brainTreePaymentSettings.PrivateKey, - MerchantId = _brainTreePaymentSettings.MerchantId, - AdditionalFee = _brainTreePaymentSettings.AdditionalFee, - AdditionalFeePercentage = _brainTreePaymentSettings.AdditionalFeePercentage, - DisplayOrder = _brainTreePaymentSettings.DisplayOrder + Use3DS = brainTreePaymentSettings.Use3DS, + UseSandBox = brainTreePaymentSettings.UseSandBox, + PublicKey = brainTreePaymentSettings.PublicKey, + PrivateKey = brainTreePaymentSettings.PrivateKey, + MerchantId = brainTreePaymentSettings.MerchantId, + AdditionalFee = brainTreePaymentSettings.AdditionalFee, + AdditionalFeePercentage = brainTreePaymentSettings.AdditionalFeePercentage, + DisplayOrder = brainTreePaymentSettings.DisplayOrder }; return View(model); @@ -58,23 +64,30 @@ public IActionResult Configure() public async Task Configure(ConfigurationModel model) { if (!ModelState.IsValid) - return Configure(); + return await Configure(); + + //load settings for a chosen store scope + var storeScope = await _adminStoreService.GetActiveStore(); + var brainTreePaymentSettings = await _settingService.LoadSetting(storeScope); //save settings - _brainTreePaymentSettings.Use3DS = model.Use3DS; - _brainTreePaymentSettings.UseSandBox = model.UseSandBox; - _brainTreePaymentSettings.PublicKey = model.PublicKey; - _brainTreePaymentSettings.PrivateKey = model.PrivateKey; - _brainTreePaymentSettings.MerchantId = model.MerchantId; - _brainTreePaymentSettings.AdditionalFee = model.AdditionalFee; - _brainTreePaymentSettings.AdditionalFeePercentage = model.AdditionalFeePercentage; - _brainTreePaymentSettings.DisplayOrder = model.DisplayOrder; + brainTreePaymentSettings.Use3DS = model.Use3DS; + brainTreePaymentSettings.UseSandBox = model.UseSandBox; + brainTreePaymentSettings.PublicKey = model.PublicKey; + brainTreePaymentSettings.PrivateKey = model.PrivateKey; + brainTreePaymentSettings.MerchantId = model.MerchantId; + brainTreePaymentSettings.AdditionalFee = model.AdditionalFee; + brainTreePaymentSettings.AdditionalFeePercentage = model.AdditionalFeePercentage; + brainTreePaymentSettings.DisplayOrder = model.DisplayOrder; + + await _settingService.SaveSetting(brainTreePaymentSettings, storeScope); - await _settingService.SaveSetting(_brainTreePaymentSettings); + //now clear settings cache + await _settingService.ClearCache(); Success(_translationService.GetResource("Admin.Plugins.Saved")); - return Configure(); + return await Configure(); } #endregion diff --git a/src/Plugins/Payments.BrainTree/Areas/Admin/Views/PaymentBrainTree/Configure.cshtml b/src/Plugins/Payments.BrainTree/Areas/Admin/Views/PaymentBrainTree/Configure.cshtml index e760990fc8..0f10ac2ec9 100644 --- a/src/Plugins/Payments.BrainTree/Areas/Admin/Views/PaymentBrainTree/Configure.cshtml +++ b/src/Plugins/Payments.BrainTree/Areas/Admin/Views/PaymentBrainTree/Configure.cshtml @@ -2,6 +2,7 @@ Layout = "_ConfigurePlugin"; } @model Payments.BrainTree.Models.ConfigurationModel +@await Component.InvokeAsync("StoreScope")
diff --git a/src/Plugins/Payments.BrainTree/Areas/Store/Controllers/PaymentBrainTreeController.cs b/src/Plugins/Payments.BrainTree/Areas/Store/Controllers/PaymentBrainTreeController.cs new file mode 100644 index 0000000000..e9d818e9c6 --- /dev/null +++ b/src/Plugins/Payments.BrainTree/Areas/Store/Controllers/PaymentBrainTreeController.cs @@ -0,0 +1,107 @@ +using Grand.Business.Core.Interfaces.Common.Configuration; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Permissions; +using Grand.Infrastructure; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Payments.BrainTree.Models; + +namespace Payments.BrainTree.Areas.Store.Controllers; + +/// +/// Store-manager configuration of the BrainTree payment method. +/// +/// The settings - the gateway credentials included - are read and written for the store the +/// manager is bound to (), so each store transacts on its own +/// BrainTree account. The provider builds a BraintreeGateway per call from the settings +/// instance the container resolved for the current store, so no further change is needed for +/// the payment itself to use them. Note that the first save creates a store row covering the +/// whole settings class, after which this store stops inheriting later global changes to it. +/// +/// +[Area("Store")] +[AuthorizeStore] +[AuthorizeMenu] +[PermissionAuthorize(PermissionSystemName.PaymentMethods)] +public class PaymentBrainTreeController : BasePaymentController +{ + #region Ctor + + public PaymentBrainTreeController(ISettingService settingService, + ITranslationService translationService, + IContextAccessor contextAccessor) + { + _settingService = settingService; + _translationService = translationService; + _contextAccessor = contextAccessor; + } + + #endregion + + #region Fields + + private readonly ISettingService _settingService; + private readonly ITranslationService _translationService; + private readonly IContextAccessor _contextAccessor; + + /// + /// The store the current store manager is bound to. AuthorizeStore already rejects a + /// customer without one, so this is never empty here. + /// + private string CurrentStoreId => _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + + #endregion + + #region Methods + + public async Task Configure() + { + var brainTreePaymentSettings = await _settingService.LoadSetting(CurrentStoreId); + + var model = new ConfigurationModel { + Use3DS = brainTreePaymentSettings.Use3DS, + UseSandBox = brainTreePaymentSettings.UseSandBox, + PublicKey = brainTreePaymentSettings.PublicKey, + PrivateKey = brainTreePaymentSettings.PrivateKey, + MerchantId = brainTreePaymentSettings.MerchantId, + AdditionalFee = brainTreePaymentSettings.AdditionalFee, + AdditionalFeePercentage = brainTreePaymentSettings.AdditionalFeePercentage, + DisplayOrder = brainTreePaymentSettings.DisplayOrder + }; + + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + public async Task Configure(ConfigurationModel model) + { + if (!ModelState.IsValid) + return await Configure(); + + var brainTreePaymentSettings = await _settingService.LoadSetting(CurrentStoreId); + + //save settings + brainTreePaymentSettings.Use3DS = model.Use3DS; + brainTreePaymentSettings.UseSandBox = model.UseSandBox; + brainTreePaymentSettings.PublicKey = model.PublicKey; + brainTreePaymentSettings.PrivateKey = model.PrivateKey; + brainTreePaymentSettings.MerchantId = model.MerchantId; + brainTreePaymentSettings.AdditionalFee = model.AdditionalFee; + brainTreePaymentSettings.AdditionalFeePercentage = model.AdditionalFeePercentage; + brainTreePaymentSettings.DisplayOrder = model.DisplayOrder; + + await _settingService.SaveSetting(brainTreePaymentSettings, CurrentStoreId); + + //now clear settings cache + await _settingService.ClearCache(); + + Success(_translationService.GetResource("Admin.Plugins.Saved")); + + return await Configure(); + } + + #endregion +} diff --git a/src/Plugins/Payments.BrainTree/Areas/Store/Views/PaymentBrainTree/Configure.cshtml b/src/Plugins/Payments.BrainTree/Areas/Store/Views/PaymentBrainTree/Configure.cshtml new file mode 100644 index 0000000000..de225fd1af --- /dev/null +++ b/src/Plugins/Payments.BrainTree/Areas/Store/Views/PaymentBrainTree/Configure.cshtml @@ -0,0 +1,106 @@ +@model Payments.BrainTree.Models.ConfigurationModel +@{ + Layout = "~/Areas/Store/Views/Shared/_StoreLayout.cshtml"; + ViewBag.Title = Loc["Admin.Configuration.Payment.Methods"]; +} + + +
+
+
+
+
+ + @Loc["Payments.BrainTree.FriendlyName"] +
+
+
+ +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
diff --git a/src/Plugins/Payments.BrainTree/Areas/Store/Views/_ViewImports.cshtml b/src/Plugins/Payments.BrainTree/Areas/Store/Views/_ViewImports.cshtml new file mode 100644 index 0000000000..0d1778c763 --- /dev/null +++ b/src/Plugins/Payments.BrainTree/Areas/Store/Views/_ViewImports.cshtml @@ -0,0 +1,8 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Grand.Web.Common + +@using Microsoft.AspNetCore.Mvc.ViewFeatures +@using Grand.Infrastructure.Extensions +@using Grand.Web.Common.Localization + +@inject LocService Loc diff --git a/src/Plugins/Payments.CashOnDelivery/Areas/Store/Controllers/PaymentCashOnDeliveryController.cs b/src/Plugins/Payments.CashOnDelivery/Areas/Store/Controllers/PaymentCashOnDeliveryController.cs new file mode 100644 index 0000000000..2f637361b6 --- /dev/null +++ b/src/Plugins/Payments.CashOnDelivery/Areas/Store/Controllers/PaymentCashOnDeliveryController.cs @@ -0,0 +1,94 @@ +using Grand.Business.Core.Interfaces.Common.Configuration; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Permissions; +using Grand.Infrastructure; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Payments.CashOnDelivery.Models; + +namespace Payments.CashOnDelivery.Areas.Store.Controllers; + +/// +/// Store-manager configuration of the cash on delivery payment method. +/// +/// The settings are read and written for the store the manager is bound to +/// (), never globally - the storefront already resolves every +/// ISettings class for the current store, so a per-store row takes effect on checkout +/// of that store only. Note that the very first save creates a store row covering the whole +/// settings class, after which this store stops inheriting later global changes to it. +/// +/// +[Area("Store")] +[AuthorizeStore] +[AuthorizeMenu] +[PermissionAuthorize(PermissionSystemName.PaymentMethods)] +public class PaymentCashOnDeliveryController : BasePaymentController +{ + private readonly IContextAccessor _contextAccessor; + private readonly ISettingService _settingService; + private readonly ITranslationService _translationService; + + public PaymentCashOnDeliveryController( + ISettingService settingService, + ITranslationService translationService, + IContextAccessor contextAccessor) + { + _settingService = settingService; + _translationService = translationService; + _contextAccessor = contextAccessor; + } + + /// + /// The store the current store manager is bound to. AuthorizeStore already rejects a + /// customer without one, so this is never empty here. + /// + private string CurrentStoreId => _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + + public async Task Configure() + { + var cashOnDeliveryPaymentSettings = + await _settingService.LoadSetting(CurrentStoreId); + + var model = new ConfigurationModel { + DescriptionText = cashOnDeliveryPaymentSettings.DescriptionText, + AdditionalFee = cashOnDeliveryPaymentSettings.AdditionalFee, + AdditionalFeePercentage = cashOnDeliveryPaymentSettings.AdditionalFeePercentage, + ShippableProductRequired = cashOnDeliveryPaymentSettings.ShippableProductRequired, + DisplayOrder = cashOnDeliveryPaymentSettings.DisplayOrder, + SkipPaymentInfo = cashOnDeliveryPaymentSettings.SkipPaymentInfo, + //the store manager can only configure his own store, so no store selector is offered + ActiveStore = CurrentStoreId + }; + + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + public async Task Configure(ConfigurationModel model) + { + if (!ModelState.IsValid) + return await Configure(); + + var cashOnDeliveryPaymentSettings = + await _settingService.LoadSetting(CurrentStoreId); + + cashOnDeliveryPaymentSettings.DescriptionText = model.DescriptionText; + cashOnDeliveryPaymentSettings.AdditionalFee = model.AdditionalFee; + cashOnDeliveryPaymentSettings.AdditionalFeePercentage = model.AdditionalFeePercentage; + cashOnDeliveryPaymentSettings.ShippableProductRequired = model.ShippableProductRequired; + cashOnDeliveryPaymentSettings.DisplayOrder = model.DisplayOrder; + cashOnDeliveryPaymentSettings.SkipPaymentInfo = model.SkipPaymentInfo; + + await _settingService.SaveSetting(cashOnDeliveryPaymentSettings, CurrentStoreId); + + //now clear settings cache + await _settingService.ClearCache(); + + Success(_translationService.GetResource("Admin.Plugins.Saved")); + + return await Configure(); + } +} diff --git a/src/Plugins/Payments.CashOnDelivery/Areas/Store/Views/PaymentCashOnDelivery/Configure.cshtml b/src/Plugins/Payments.CashOnDelivery/Areas/Store/Views/PaymentCashOnDelivery/Configure.cshtml new file mode 100644 index 0000000000..bf85e3e7ae --- /dev/null +++ b/src/Plugins/Payments.CashOnDelivery/Areas/Store/Views/PaymentCashOnDelivery/Configure.cshtml @@ -0,0 +1,97 @@ +@model Payments.CashOnDelivery.Models.ConfigurationModel +@{ + Layout = "~/Areas/Store/Views/Shared/_StoreLayout.cshtml"; + ViewBag.Title = Loc["Admin.Configuration.Payment.Methods"]; +} + + +
+
+
+
+
+ + @Loc["Payments.CashOnDelivery.FriendlyName"] +
+
+
+
+
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
diff --git a/src/Plugins/Payments.CashOnDelivery/Areas/Store/Views/_ViewImports.cshtml b/src/Plugins/Payments.CashOnDelivery/Areas/Store/Views/_ViewImports.cshtml new file mode 100644 index 0000000000..0d1778c763 --- /dev/null +++ b/src/Plugins/Payments.CashOnDelivery/Areas/Store/Views/_ViewImports.cshtml @@ -0,0 +1,8 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Grand.Web.Common + +@using Microsoft.AspNetCore.Mvc.ViewFeatures +@using Grand.Infrastructure.Extensions +@using Grand.Web.Common.Localization + +@inject LocService Loc diff --git a/src/Plugins/Payments.StripeCheckout/Areas/Store/Controllers/StripeCheckoutController.cs b/src/Plugins/Payments.StripeCheckout/Areas/Store/Controllers/StripeCheckoutController.cs new file mode 100644 index 0000000000..5844e9f12f --- /dev/null +++ b/src/Plugins/Payments.StripeCheckout/Areas/Store/Controllers/StripeCheckoutController.cs @@ -0,0 +1,102 @@ +using Grand.Business.Core.Interfaces.Common.Configuration; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Domain.Permissions; +using Grand.Infrastructure; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Payments.StripeCheckout.Models; + +namespace Payments.StripeCheckout.Areas.Store.Controllers; + +/// +/// Store-manager configuration of the Stripe Checkout payment method. +/// +/// The settings - the API key and the webhook secret included - are read and written for the +/// store the manager is bound to (), so each store transacts on +/// its own Stripe account. Every Stripe call is made through a client built from the settings +/// instance the container resolved for the current store. Note that the first save creates a +/// store row covering the whole settings class, after which this store stops inheriting later +/// global changes to it. +/// +/// +[Area("Store")] +[AuthorizeStore] +[AuthorizeMenu] +[PermissionAuthorize(PermissionSystemName.PaymentMethods)] +public class StripeCheckoutController : BasePaymentController +{ + private readonly IContextAccessor _contextAccessor; + private readonly ISettingService _settingService; + private readonly IStoreService _storeService; + private readonly ITranslationService _translationService; + + public StripeCheckoutController( + ISettingService settingService, + ITranslationService translationService, + IStoreService storeService, + IContextAccessor contextAccessor) + { + _settingService = settingService; + _translationService = translationService; + _storeService = storeService; + _contextAccessor = contextAccessor; + } + + /// + /// The store the current store manager is bound to. AuthorizeStore already rejects a + /// customer without one, so this is never empty here. + /// + private string CurrentStoreId => _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + + public async Task Configure() + { + var stripeCheckoutPaymentSettings = + await _settingService.LoadSetting(CurrentStoreId); + + var model = new ConfigurationModel { + ApiKey = stripeCheckoutPaymentSettings.ApiKey, + WebhookEndpointSecret = stripeCheckoutPaymentSettings.WebhookEndpointSecret, + Description = stripeCheckoutPaymentSettings.Description, + Line = stripeCheckoutPaymentSettings.Line, + DisplayOrder = stripeCheckoutPaymentSettings.DisplayOrder, + //the store manager can only configure his own store, so no store selector is offered + StoreScope = CurrentStoreId + }; + + //the webhook has to be registered with Stripe against the manager's own storefront, not + //against whichever host happens to serve this panel + var store = await _storeService.GetStoreById(CurrentStoreId); + ViewBag.WebhookUrl = $"{store?.Url?.TrimEnd('/')}{Url.RouteUrl(StripeCheckoutDefaults.WebHook)}"; + + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + public async Task Configure(ConfigurationModel model) + { + if (!ModelState.IsValid) + return await Configure(); + + var stripeCheckoutPaymentSettings = + await _settingService.LoadSetting(CurrentStoreId); + + stripeCheckoutPaymentSettings.ApiKey = model.ApiKey; + stripeCheckoutPaymentSettings.WebhookEndpointSecret = model.WebhookEndpointSecret; + stripeCheckoutPaymentSettings.Description = model.Description; + stripeCheckoutPaymentSettings.Line = model.Line; + stripeCheckoutPaymentSettings.DisplayOrder = model.DisplayOrder; + + await _settingService.SaveSetting(stripeCheckoutPaymentSettings, CurrentStoreId); + + //now clear settings cache + await _settingService.ClearCache(); + + Success(_translationService.GetResource("Admin.Plugins.Saved")); + + return await Configure(); + } +} diff --git a/src/Plugins/Payments.StripeCheckout/Areas/Store/Views/StripeCheckout/Configure.cshtml b/src/Plugins/Payments.StripeCheckout/Areas/Store/Views/StripeCheckout/Configure.cshtml new file mode 100644 index 0000000000..090c967af8 --- /dev/null +++ b/src/Plugins/Payments.StripeCheckout/Areas/Store/Views/StripeCheckout/Configure.cshtml @@ -0,0 +1,90 @@ +@model Payments.StripeCheckout.Models.ConfigurationModel +@{ + Layout = "~/Areas/Store/Views/Shared/_StoreLayout.cshtml"; + ViewBag.Title = Loc["Admin.Configuration.Payment.Methods"]; +} + + +
+
+
+
+
+ + @Loc["Plugins.Payments.StripeCheckout.FriendlyName"] +
+
+
+
+

+ For plugin configuration please check:
+
+ 1. Generate API keys from the Stripe Dashboard: Click here.
+ 2. Register webhook endpoints with Stripe Click here.
+ 3. Your Webhook URL: @ViewBag.WebhookUrl
+ 4. Fill in your credentials below.
+ 5. Save the configuration.
+
+ Note: Ensure that you have configured your primary store currency that is supported by selected payment service provider.
+

+
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
diff --git a/src/Plugins/Payments.StripeCheckout/Areas/Store/Views/_ViewImports.cshtml b/src/Plugins/Payments.StripeCheckout/Areas/Store/Views/_ViewImports.cshtml new file mode 100644 index 0000000000..0d1778c763 --- /dev/null +++ b/src/Plugins/Payments.StripeCheckout/Areas/Store/Views/_ViewImports.cshtml @@ -0,0 +1,8 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Grand.Web.Common + +@using Microsoft.AspNetCore.Mvc.ViewFeatures +@using Grand.Infrastructure.Extensions +@using Grand.Web.Common.Localization + +@inject LocService Loc diff --git a/src/Plugins/Payments.StripeCheckout/Services/StripeCheckoutService.cs b/src/Plugins/Payments.StripeCheckout/Services/StripeCheckoutService.cs index 0f1b8c1bb2..dd91fded09 100644 --- a/src/Plugins/Payments.StripeCheckout/Services/StripeCheckoutService.cs +++ b/src/Plugins/Payments.StripeCheckout/Services/StripeCheckoutService.cs @@ -93,8 +93,6 @@ private async Task CreatePaymentTransaction(PaymentIntent paymentIntent) private async Task CreateUrlSession(Order order) { - StripeConfiguration.ApiKey = _stripeCheckoutPaymentSettings.ApiKey; - var storeLocation = _contextAccessor.StoreContext.CurrentHost.Url.TrimEnd('/'); var options = new SessionCreateOptions { @@ -119,7 +117,10 @@ private async Task CreateUrlSession(Order order) SuccessUrl = $"{storeLocation}/orderdetails/{order.Id}", CancelUrl = $"{storeLocation}/Plugins/PaymentStripeCheckout/CancelOrder/{order.Id}" }; - var service = new SessionService(); + //the api key is store-specific, so it travels with the client - the static + //StripeConfiguration.ApiKey is process-wide and two stores checking out at the same time + //would overwrite each other's credentials + var service = new SessionService(new StripeClient(_stripeCheckoutPaymentSettings.ApiKey)); var session = await service.CreateAsync(options); return session; diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/PaymentControllerTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/PaymentControllerTests.cs index 57215f31e2..981523139b 100644 --- a/src/Tests/Grand.Web.Store.Tests/Controllers/PaymentControllerTests.cs +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/PaymentControllerTests.cs @@ -101,6 +101,8 @@ public async Task Methods_ReturnPaymentMethodsForCurrentStore() var model = ((IEnumerable)data.Data).First(); Assert.AreEqual("Payments.TestMethod", model.SystemName); Assert.IsTrue(model.IsActive); + //the provider ships no Store-area screen, so no configuration link is offered + Assert.IsNull(model.ConfigurationUrl); _paymentServiceMock.Verify(p => p.LoadAllPaymentMethods(null, StoreId, ""), Times.Once); _settingServiceMock.Verify(s => s.LoadSetting(StoreId), Times.Once); } diff --git a/src/Tests/Grand.Web.Store.Tests/Extensions/StoreAreaConfigurationTests.cs b/src/Tests/Grand.Web.Store.Tests/Extensions/StoreAreaConfigurationTests.cs new file mode 100644 index 0000000000..45dbe407a4 --- /dev/null +++ b/src/Tests/Grand.Web.Store.Tests/Extensions/StoreAreaConfigurationTests.cs @@ -0,0 +1,77 @@ +using Grand.Web.Common.Controllers; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Store.Tests.Extensions; + +[TestClass] +public class StoreAreaConfigurationTests +{ + /// + /// A provider coming from this test assembly - which ships + /// below - stands in for a plugin that has a Store-area configuration screen. + /// + private class ProviderWithStoreArea; + + [TestMethod] + public void Exists_ProviderFromAssemblyWithStoreAreaController_ReturnTrue() + { + Assert.IsTrue(StoreAreaConfiguration.Exists(new ProviderWithStoreArea())); + } + + [TestMethod] + public void Exists_ProviderWithoutStoreAreaController_ReturnFalse() + { + //a mocked interface lives in the dynamic proxy assembly, which ships no controllers at all + Assert.IsFalse(StoreAreaConfiguration.Exists(Mock.Of())); + } + + [TestMethod] + public void Exists_NullProvider_ReturnFalse() + { + Assert.IsFalse(StoreAreaConfiguration.Exists(null)); + } + + [TestMethod] + public void GetConfigurationUrl_ProviderFromAssemblyWithStoreAreaController_ReturnStoreAreaUrl() + { + Assert.AreEqual("/Store/FakeStore/Configure", + StoreAreaConfiguration.GetConfigurationUrl(new ProviderWithStoreArea())); + } + + [TestMethod] + public void GetConfigurationUrl_ProviderWithoutStoreAreaController_ReturnNull() + { + Assert.IsNull(StoreAreaConfiguration.GetConfigurationUrl(Mock.Of())); + } + + [TestMethod] + public void GetConfigurationUrl_NullProvider_ReturnNull() + { + Assert.IsNull(StoreAreaConfiguration.GetConfigurationUrl(null)); + } +} + +/// +/// Stands in for the configuration screen a multi-store capable plugin ships. It is the only +/// Store-area controller in this test assembly, so the url built from it is deterministic. +/// The two Configure overloads mirror a real screen: a shipped controller always has both a GET +/// and a POST, and looking the action up by name alone would throw on that ambiguity. +/// +[Area("Store")] +public class FakeStoreController : BaseController +{ + public IActionResult Configure() + { + return new EmptyResult(); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + public IActionResult Configure(string model) + { + return new EmptyResult(); + } +} diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Payment/Index.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Payment/Index.cshtml index c7e50fb359..bb0d7288ca 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Payment/Index.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Payment/Index.cshtml @@ -51,6 +51,7 @@ fields: { FriendlyName: { editable: false, type: "string" }, SystemName: { editable: false, type: "string" }, + ConfigurationUrl: { editable: false, type: "string" }, SupportCapture: { editable: false, type: "boolean" }, SupportRefund: { editable: false, type: "boolean" }, SupportPartiallyRefund: { editable: false, type: "boolean" }, @@ -86,10 +87,12 @@ scrollable: false, columns: [{ field: "FriendlyName", - title: "@Loc["Admin.Configuration.Payment.Methods.Fields.FriendlyName"]" + title: "@Loc["Admin.Configuration.Payment.Methods.Fields.FriendlyName"]", + template: '# if (ConfigurationUrl) { # #:FriendlyName# # } else { # #:FriendlyName# # } #' }, { field: "SystemName", - title: "@Loc["Admin.Configuration.Payment.Methods.Fields.SystemName"]" + title: "@Loc["Admin.Configuration.Payment.Methods.Fields.SystemName"]", + template: '# if (ConfigurationUrl) { # #:SystemName# # } else { # #:SystemName# # } #' }, { field: "SupportCapture", title: "@Loc["Admin.Configuration.Payment.Methods.Fields.SupportCapture"]", diff --git a/src/Web/Grand.Web.Store/Controllers/PaymentController.cs b/src/Web/Grand.Web.Store/Controllers/PaymentController.cs index 7705963591..d4a36132fa 100644 --- a/src/Web/Grand.Web.Store/Controllers/PaymentController.cs +++ b/src/Web/Grand.Web.Store/Controllers/PaymentController.cs @@ -13,6 +13,7 @@ using Grand.Web.AdminShared.Models.Shipping; using Grand.Web.Common.DataSource; using Grand.Web.Common.Security.Authorization; +using Grand.Web.Store.Extensions; using Microsoft.AspNetCore.Mvc; namespace Grand.Web.Store.Controllers; @@ -47,6 +48,10 @@ public async Task Methods() { var tmp = await paymentMethod.ToModel(); tmp.IsActive = paymentMethod.IsPaymentMethodActive(paymentSettings); + //the provider's own url points at the Admin area, so it is replaced by the Store-area + //screen the plugin ships - null when it ships none, and then no link is rendered at all + tmp.ConfigurationUrl = StoreAreaConfiguration.GetConfigurationUrl(paymentMethod); + paymentMethodsModel.Add(tmp); } diff --git a/src/Web/Grand.Web.Store/Extensions/StoreAreaConfiguration.cs b/src/Web/Grand.Web.Store/Extensions/StoreAreaConfiguration.cs index 01e564e938..59dc51ae4f 100644 --- a/src/Web/Grand.Web.Store/Extensions/StoreAreaConfiguration.cs +++ b/src/Web/Grand.Web.Store/Extensions/StoreAreaConfiguration.cs @@ -18,6 +18,7 @@ public static class StoreAreaConfiguration { //plugin assemblies never change within a process lifetime, so the reflection cost is paid once private static readonly ConcurrentDictionary Cache = new(); + private static readonly ConcurrentDictionary UrlCache = new(); /// /// Whether the provider's plugin exposes at least one controller in the Store area. @@ -27,12 +28,46 @@ public static bool Exists(object provider) return provider != null && Cache.GetOrAdd(provider.GetType().Assembly, HasStoreAreaController); } + /// + /// The url of the provider's configuration screen within the Store area, or null when its + /// plugin ships none. + /// + /// A provider's own ConfigurationUrl cannot be reused here: some plugins declare it + /// relative ("../ShippingByWeight/Configure", which resolves per area) but others declare it + /// absolute ("/Admin/PaymentBrainTree/Configure"), which would send a store manager into the + /// Admin area. The url is therefore built from the Store-area controller the plugin actually + /// ships, which by convention exposes a Configure action. + /// + /// + public static string GetConfigurationUrl(object provider) + { + if (provider == null) return null; + var controller = UrlCache.GetOrAdd(provider.GetType().Assembly, FindStoreAreaConfigurationController); + return controller == null ? null : $"/{Constants.AreaStore}/{controller}/Configure"; + } + private static bool HasStoreAreaController(Assembly assembly) { - return GetLoadableTypes(assembly).Any(type => - typeof(ControllerBase).IsAssignableFrom(type) && - string.Equals(type.GetCustomAttribute()?.RouteValue, Constants.AreaStore, - StringComparison.OrdinalIgnoreCase)); + return GetLoadableTypes(assembly).Any(IsStoreAreaController); + } + + private static string FindStoreAreaConfigurationController(Assembly assembly) + { + //a configuration screen has a GET and a POST Configure, so the overloads are enumerated + //rather than asked for by name - GetMethod would throw on the ambiguity + var controller = GetLoadableTypes(assembly).FirstOrDefault(type => + IsStoreAreaController(type) && + type.GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Any(method => method.Name == "Configure")); + + return controller == null ? null : controller.Name.Replace("Controller", string.Empty); + } + + private static bool IsStoreAreaController(Type type) + { + return typeof(ControllerBase).IsAssignableFrom(type) && + string.Equals(type.GetCustomAttribute()?.RouteValue, Constants.AreaStore, + StringComparison.OrdinalIgnoreCase); } ///