From 347630acfe70459c5204703d7d1605491513fc47 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 13 Sep 2026 12:47:32 +0200 Subject: [PATCH 1/3] Let store owners configure payment plugins from the Store panel The Store payment grid rendered method names as plain text, so a store manager could activate a payment method but never reach its settings. The shipped payment plugins only had Areas/Admin controllers, and their ConfigurationUrl is an absolute /Admin/... path, so linking to it would have sent the manager into the admin area instead. Each payment plugin now ships an Areas/Store configuration screen that reads and writes its settings for the manager's own store, and the grid links to that screen - built from the Store-area controller the plugin actually ships, so a plugin without one still renders plain text. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Bf2UUL1avH6UD5Mxxzu7UK --- .../Controllers/PaymentBrainTreeController.cs | 107 ++++++++++++++++++ .../Views/PaymentBrainTree/Configure.cshtml | 106 +++++++++++++++++ .../Areas/Store/Views/_ViewImports.cshtml | 8 ++ .../PaymentCashOnDeliveryController.cs | 94 +++++++++++++++ .../PaymentCashOnDelivery/Configure.cshtml | 97 ++++++++++++++++ .../Areas/Store/Views/_ViewImports.cshtml | 8 ++ .../Controllers/StripeCheckoutController.cs | 102 +++++++++++++++++ .../Views/StripeCheckout/Configure.cshtml | 90 +++++++++++++++ .../Areas/Store/Views/_ViewImports.cshtml | 8 ++ .../Controllers/PaymentControllerTests.cs | 2 + .../Extensions/StoreAreaConfigurationTests.cs | 76 +++++++++++++ .../Areas/Store/Views/Payment/Index.cshtml | 7 +- .../Controllers/PaymentController.cs | 5 + .../Extensions/StoreAreaConfiguration.cs | 43 ++++++- 14 files changed, 747 insertions(+), 6 deletions(-) create mode 100644 src/Plugins/Payments.BrainTree/Areas/Store/Controllers/PaymentBrainTreeController.cs create mode 100644 src/Plugins/Payments.BrainTree/Areas/Store/Views/PaymentBrainTree/Configure.cshtml create mode 100644 src/Plugins/Payments.BrainTree/Areas/Store/Views/_ViewImports.cshtml create mode 100644 src/Plugins/Payments.CashOnDelivery/Areas/Store/Controllers/PaymentCashOnDeliveryController.cs create mode 100644 src/Plugins/Payments.CashOnDelivery/Areas/Store/Views/PaymentCashOnDelivery/Configure.cshtml create mode 100644 src/Plugins/Payments.CashOnDelivery/Areas/Store/Views/_ViewImports.cshtml create mode 100644 src/Plugins/Payments.StripeCheckout/Areas/Store/Controllers/StripeCheckoutController.cs create mode 100644 src/Plugins/Payments.StripeCheckout/Areas/Store/Views/StripeCheckout/Configure.cshtml create mode 100644 src/Plugins/Payments.StripeCheckout/Areas/Store/Views/_ViewImports.cshtml create mode 100644 src/Tests/Grand.Web.Store.Tests/Extensions/StoreAreaConfigurationTests.cs 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/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..116a36fd48 --- /dev/null +++ b/src/Tests/Grand.Web.Store.Tests/Extensions/StoreAreaConfigurationTests.cs @@ -0,0 +1,76 @@ +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] + 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); } /// From e48ba4f9942f07496e0cb4ad4f371a0508bc98e3 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 13 Sep 2026 12:47:38 +0200 Subject: [PATCH 2/3] Make BrainTree and Stripe settings truly per store BrainTree's admin screen wrote its settings globally and read them from the injected instance, which is resolved for whichever store hosts the admin panel - the store scope selector was ignored, so per-store credentials were impossible. It now loads and saves for the selected scope, like the other payment plugins, and offers the scope selector. Stripe assigned the api key to the process-wide StripeConfiguration before every call. With one key for all stores that was harmless; with a key per store two concurrent checkouts would overwrite each other's credentials, so the key now travels with a per-call StripeClient. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Bf2UUL1avH6UD5Mxxzu7UK --- .../Controllers/PaymentBrainTreeController.cs | 59 +++++++++++-------- .../Views/PaymentBrainTree/Configure.cshtml | 1 + .../Services/StripeCheckoutService.cs | 7 ++- 3 files changed, 41 insertions(+), 26 deletions(-) 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.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; From 25042b2de2c4fd7b728f9fc1d8a1b9b6f887ef64 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 13 Sep 2026 14:37:19 +0200 Subject: [PATCH 3/3] Validate the antiforgery token on the test controller's Configure POST The Store-area controller the StoreAreaConfiguration tests discover is a stand-in for a shipped plugin configuration screen, but its POST overload lacked the antiforgery attribute every real one carries, which CodeQL flagged as a missing CSRF token validation. Mirror the shipped controllers so the stand-in is faithful. --- .../Extensions/StoreAreaConfigurationTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Tests/Grand.Web.Store.Tests/Extensions/StoreAreaConfigurationTests.cs b/src/Tests/Grand.Web.Store.Tests/Extensions/StoreAreaConfigurationTests.cs index 116a36fd48..45dbe407a4 100644 --- a/src/Tests/Grand.Web.Store.Tests/Extensions/StoreAreaConfigurationTests.cs +++ b/src/Tests/Grand.Web.Store.Tests/Extensions/StoreAreaConfigurationTests.cs @@ -69,6 +69,7 @@ public IActionResult Configure() } [HttpPost] + [AutoValidateAntiforgeryToken] public IActionResult Configure(string model) { return new EmptyResult();